How Worktrees and Project Grouping Resolve in `lib/worktree.ts`
The lib/worktree.ts module in Pi Web resolves projects through three phases: cached discovery, Git interrogation via git rev-parse, and path‑based project shape derivation to correctly group sessions by repository and worktree.
Pi Web is a terminal session manager that groups sessions by Git project and supports Git worktrees for parallel branch development. The lib/worktree.ts file contains the core logic that determines which project a session belongs to, whether that directory is a worktree, and how to enumerate or create worktrees. This article breaks down the resolution algorithm, caching strategy, and worktree utilities as implemented in the agegr/pi-web source code.
Project Discovery and Caching in resolveProject
The entry point for all project resolution is resolveProject. This async function returns a ProjectInfo object containing projectRoot, branch, isWorktree, and isTopLevel flags.
The One-Minute Cache
Results are cached on globalThis.__piProjectCache for up to 60 seconds to avoid repeated Git subprocess calls. The cache is created lazily on first access:
// Lines 43-46 in lib/worktree.ts
const cache = (globalThis.__piProjectCache ??= new Map<string, CacheEntry>());
const cached = cache.get(cwd);
if (cached && Date.now() - cached.ts < 60_000) {
return cached.info;
}
Handling Deleted Worktrees
When a directory no longer exists on disk—common when a worktree was manually deleted—inferRemovedWorktree attempts to map it back to a deleted entry under <repo>-worktrees. This prevents the UI from displaying phantom projects:
// Lines 77-83 in lib/worktree.ts
const inferred = await inferRemovedWorktree(cwd);
if (inferred) {
return inferred; // Returns cached info with projectRoot rewired to main repo
}
Git Interrogation with Deterministic Locale
All Git commands execute through a git helper that forces LC_ALL=C and applies a short timeout. This ensures error message matching works across different system locales:
// Lines 52-60 in lib/worktree.ts
async function git(cwd: string, args: string[]): Promise<string> {
const result = await $`git ${args}`.cwd(cwd).timeout("5s").env("LC_ALL", "C");
return result.stdout.trim();
}
The Four rev-parse Arguments
resolveProject calls git rev-parse with four arguments to gather repository metadata:
| Argument | Purpose |
|---|---|
--git-common-dir |
Shared .git directory for all linked worktrees |
--git-dir |
Actual .git directory of current worktree (differs for linked worktrees) |
--show-toplevel |
Repository top-level path |
--abbrev-ref HEAD |
Current branch name, or HEAD if detached |
These four values enable the module to distinguish between the main repository, linked worktrees, and subdirectories within either.
Deriving the Project Shape
After normalizing paths with toNativePath and realPathOrSelf from lib/paths.ts, resolveProject computes three key properties:
isTopLevel – True when realCwd equals the toplevel path (compared via samePath).
isWorktreeTopLevel – True when gitDir ≠ commonDir and isTopLevel is true. This indicates the current directory is the root of a linked worktree, not the main repository.
projectRoot – The grouping key for sessions:
- For linked-worktree top levels: parent of the shared
.gitfolder - Otherwise: the
toplevelpath (orcwditself for non-Git directories)
The final object is cached and returned:
// Lines 126-128 in lib/worktree.ts
const info: ProjectInfo = { projectRoot, branch, isWorktree, isTopLevel };
cache.set(cwd, { info, ts: Date.now() });
return info;
Worktree Enumeration and Management
Beyond project resolution, lib/worktree.ts provides utilities for creating, listing, and removing worktrees.
listWorktrees: Parse Porcelain Output
Parses git worktree list --porcelain into structured objects, filtering prunable or missing entries:
// Lines 44-78 in lib/worktree.ts
export async function listWorktrees(cwd: string): Promise<WorktreeInfo[]> {
const lines = await git(cwd, ["worktree", "list", "--porcelain"]);
// Parses worktree path, branch, HEAD, and prunable status
}
findCurrentWorktreePath: Locate Containing Worktree
Given a list of worktrees and a cwd, finds which worktree contains the directory using real path comparison:
// Lines 85-87 in lib/worktree.ts
export function findCurrentWorktreePath(worktrees: WorktreeInfo[], cwd: string): string | undefined {
return worktrees.find(w => samePath(w.path, cwd))? w.path;
}
addWorktree: Create Linked Worktrees
Creates a new worktree under <repo>-worktrees/<sanitized-branch>, registers the path as an allowed file root, and invalidates the project cache:
// Lines 93-104 in lib/worktree.ts
export async function addWorktree(cwd: string, branch: string) {
const sanitized = sanitizeBranchForDir(branch);
const path = join(repoRoot, "..", `${basename(repoRoot)}-worktrees`, sanitized);
await git(cwd, ["worktree", "add", path, branch]);
await addAllowedRoot(path); // From lib/allowed-roots.ts
invalidateProjectCache();
return { path, branch: usedBranch };
}
removeWorktree: Clean Removal
Calls git worktree remove with optional --force and clears the cache:
// Lines 132-140 in lib/worktree.ts
export async function removeWorktree(cwd: string, path: string, force = false) {
const args = ["worktree", "remove", ...(force ? ["--force"] : []), path];
await git(cwd, args);
removeAllowedRoot(path);
invalidateProjectCache();
}
sanitizeBranchForDir: Safe Directory Names
Converts branch names to filesystem-safe strings by replacing illegal characters with hyphens:
// Lines 89-91 in lib/worktree.ts
export function sanitizeBranchForDir(branch: string): string {
return branch.replace(/[\\/:*?"<>|]+/g, "-");
}
Practical Usage Examples
Resolve Project Information
import { resolveProject } from "./lib/worktree";
async function showProjectInfo(cwd: string) {
const info = await resolveProject(cwd);
console.log(`Root: ${info.projectRoot}`);
console.log(`Branch: ${info.branch ?? "detached"}`);
console.log(`Worktree? ${info.isWorktree}`);
console.log(`Top-level? ${info.isTopLevel}`);
}
List and Select Current Worktree
import { listWorktrees, findCurrentWorktreePath } from "./lib/worktree";
async function pickWorktree(cwd: string) {
const worktrees = await listWorktrees(cwd);
const current = findCurrentWorktreePath(worktrees, cwd);
console.log("All worktrees:", worktrees);
console.log("Current worktree path:", current);
}
Create a Feature Branch Worktree
import { addWorktree } from "./lib/worktree";
async function createFeatureWorktree(cwd: string, branch = "feature/foo") {
const { path, branch: used } = await addWorktree(cwd, branch);
console.log(`Created worktree at ${path} for branch ${used}`);
}
UI Grouping Implications
According to the Pi Web source code, session grouping relies entirely on resolveProject output:
- Sessions are grouped by
projectRoot - Worktree sessions at their top-level (
isWorktree: true,isTopLevel: true) appear as separate sidebar rows - Subdirectories inside worktrees retain their own
projectRootequal tocwd, preventing accidental regrouping - Deleted worktrees trigger
inferRemovedWorktreeto rewire the session back to the main repository
The app/api/sessions/route.ts endpoint consumes resolveProject to attach sessions to the correct project, while lib/allowed-roots.ts ensures newly created worktrees are readable by the /api/files endpoint.
Summary
resolveProjectinlib/worktree.tsis the central function for project discovery, using a 60-second cache andgit rev-parseinterrogation- Project shape is derived from four Git paths:
commonDir,gitDir,toplevel, and branch name isWorktreeTopLeveldistinguishes linked worktree roots from main repository directories- Worktree utilities (
listWorktrees,addWorktree,removeWorktree) provide full CRUD operations with automatic cache invalidation - Path normalization via
lib/paths.tsutilities ensures cross-platform correctness
Frequently Asked Questions
How does Pi Web handle sessions in deleted worktrees?
When resolveProject encounters a non-existent directory, inferRemovedWorktree attempts to map it back to a deleted worktree entry under <repo>-worktrees. If found, it rewires the projectRoot to the main repository, preventing phantom projects from appearing in the sidebar.
Why does the git helper force LC_ALL=C?
The helper at lines 52-60 sets LC_ALL=C to ensure Git error messages are in English. This allows reliable string matching on error conditions across different system locales, which is critical for correctly handling non-Git directories and deleted worktrees.
What is the difference between isWorktree and isWorktreeTopLevel?
isWorktree indicates the directory belongs to a linked worktree (true whenever gitDir ≠ commonDir). isWorktreeTopLevel is stricter: it requires both the worktree condition AND isTopLevel to be true, meaning the current directory is specifically the root of that linked worktree.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →