How pi-web Integrates with Git Worktrees for Branch-Based Development

pi-web treats each Git worktree as a separate project while unifying them in the UI through automatic project resolution and session grouping.

pi-web, an open-source development environment maintained in the agegr/pi-web repository, enables seamless branch-based development by leveraging Git worktrees to isolate branches while maintaining a unified interface. The application automatically detects worktree relationships through Git metadata, groups sessions by underlying repository root regardless of which worktree they originated from, and allows dynamic branch switching via a dedicated UI component.

Project Resolution via Git Metadata

At the heart of pi-web’s worktree integration sits resolveProject() in lib/worktree.ts. Whenever a session’s working directory is inspected, this function executes a targeted Git query to discover the common .git directory shared by all worktrees:


git rev-parse --git-common-dir

If the current working directory resides within a linked worktree, resolveProject() marks isWorktree as true, records the current branch name in the branch field, and identifies the main repository root. For directories outside Git repositories, it falls back to the cwd itself. To avoid the overhead of repeated Git spawns, results are cached on globalThis.__piProjectCache for 60 seconds.

Session Grouping by Repository Root

The listAllSessions() function in lib/session-reader.ts loads every session file and invokes resolveProject() once per unique cwd. It attaches the returned ProjectInfo to each SessionInfo object as projectRoot and, when applicable, as worktreeBranch. This metadata allows the sidebar component to group sessions that belong to the same underlying repository, regardless of whether they were created in the main working tree or a linked worktree.

REST API for Worktree Operations

The /api/worktrees endpoint in app/api/worktrees/route.ts exposes three core operations for branch management:

  • GET – Returns the repository’s projectRoot, boolean flags indicating whether the cwd is a Git repo and whether it is the repository top-level, plus a complete list of existing worktrees via listWorktrees().
  • POST – Invokes addWorktree() to create a new directory under <repoRoot>-worktrees/<sanitized-branch> and immediately registers the path with the file-access allow-list.
  • DELETE – Calls removeWorktree() to delete a worktree directory, optionally forcing removal of dirty worktrees with uncommitted changes.

File-Access Gating for Security

Worktree paths are added to the global allow-list through allowFileRoot()—implemented in lib/file-access.ts—both when created via addWorktree() and when enumerated via the GET endpoint. This gating ensures the file explorer can safely read and write files within any worktree without exposing unrelated parts of the host filesystem.

Branch-Aware UI Components

The interface adapts dynamically based on worktree context. The sidebar—rendered by components/SessionSidebar.tsx—displays a worktree switcher only when the current session resides at the repository top-level (project.isTopLevel). This component, along with components/BranchNavigator.tsx, lists each worktree’s path and branch obtained from listWorktrees().

Selecting a different worktree updates the session’s cwd, which automatically triggers resolveProject() to re-resolve the project context for the new branch.

Cache Invalidation on Worktree Changes

Mutating worktree state requires cache synchronization. Adding or removing a worktree invokes invalidateProjectCache(), clearing cached ProjectInfo entries so subsequent resolves reflect the updated repository structure. Additionally, invalidateSessionListCache() clears session-list caches to ensure sidebar grouping remains accurate after worktree modifications.

Code Examples

Resolve the project information for any directory to determine if it lives inside a worktree:

import { resolveProject } from "@/lib/worktree";

const cwd = "/home/alice/myrepo-feature";
const info = await resolveProject(cwd);
// info => { projectRoot: "/home/alice/myrepo", branch: "feature", isWorktree: true, isTopLevel: true }

List all existing worktrees for repository navigation:

import { listWorktrees } from "@/lib/worktree";

const worktrees = await listWorktrees("/home/alice/myrepo");
console.log(worktrees);
/* [
 *   { path: "/home/alice/myrepo-worktrees/feature", branch: "feature", isMain: true },
 *   { path: "/home/alice/myrepo-worktrees/bugfix",  branch: "bugfix",  isMain: false },
 * ] */

Create a new worktree for a feature branch via the API:

await fetch("/api/worktrees", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ cwd: "/home/alice/myrepo", branch: "new-feature" })
});
// Returns: { path: "/home/alice/myrepo-worktrees/new-feature", branch: "new-feature" }

Remove a worktree, optionally forcing deletion of dirty trees:

await fetch("/api/worktrees", {
  method: "DELETE",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ 
    cwd: "/home/alice/myrepo", 
    path: "/home/alice/myrepo-worktrees/old-feature", 
    force: true 
  })
});
// Returns: { success: true }

Summary

  • Automatic detection: resolveProject() in lib/worktree.ts uses git rev-parse to identify worktree relationships and cache results for 60 seconds.
  • Unified grouping: lib/session-reader.ts attaches projectRoot and worktreeBranch to sessions, enabling the sidebar to group sessions by underlying repository.
  • Secure file access: The allowFileRoot() mechanism in lib/file-access.ts gates filesystem access, registering worktree paths only when explicitly created or listed.
  • RESTful management: app/api/worktrees/route.ts provides endpoints to create, list, and delete worktrees under <repoRoot>-worktrees/<branch>.
  • Dynamic UI: Components like BranchNavigator.tsx expose branch switching only at the repository top-level, updating session cwd to trigger re-resolution.

Frequently Asked Questions

How does pi-web detect if a directory is a Git worktree?

pi-web detects worktrees through the resolveProject() function in lib/worktree.ts, which executes git rev-parse --git-common-dir to locate the shared .git directory. If the query reveals the cwd is a linked worktree rather than the main repository, the function sets isWorktree to true and extracts the current branch name.

Where does pi-web create new worktrees when adding a branch?

When calling the POST endpoint at /api/worktrees, pi-web’s addWorktree() function creates a new directory under <repoRoot>-worktrees/<sanitized-branch>. This path is immediately registered with allowFileRoot() in lib/file-access.ts to grant the file explorer access.

How does pi-web prevent unauthorized file access when browsing worktrees?

pi-web maintains a global allow-list of permitted file roots via allowFileRoot(). Worktree paths are added to this list only when explicitly created through addWorktree() or enumerated via the GET /api/worktrees endpoint, ensuring the UI cannot access arbitrary filesystem locations outside the repository structure.

Why does the worktree switcher only appear at the repository top-level?

The BranchNavigator.tsx component checks the isTopLevel property returned by resolveProject() before rendering the switcher. This design prevents nested worktree directories from displaying redundant navigation controls, as the root repository context is required to enumerate all available worktrees through listWorktrees().

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 →