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 .git folder
  • Otherwise: the toplevel path (or cwd itself 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 projectRoot equal to cwd, preventing accidental regrouping
  • Deleted worktrees trigger inferRemovedWorktree to 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

  • resolveProject in lib/worktree.ts is the central function for project discovery, using a 60-second cache and git rev-parse interrogation
  • Project shape is derived from four Git paths: commonDir, gitDir, toplevel, and branch name
  • isWorktreeTopLevel distinguishes 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.ts utilities 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →