Skip to content

Worktrees

Git worktrees allow multiple working directories to share a single repository. Each worktree has its own checkout (HEAD, index, working files) but shares the object store, config, and packed-refs with the main repository. Pitmaster can create, list, lock, and remove linked worktrees, and open them as repositories. Pruning, moving, and repairing worktrees are not implemented.

Concepts

A regular git repository has a single working tree:

Code
project/
  .git/          (directory)
  src/
  README.md

With worktrees, you can have additional checkouts:

Code
project/                          (main worktree)
  .git/                           (the actual git directory)
    worktrees/
      review/                     (metadata for linked worktree)
        HEAD
        commondir
        gitdir

/tmp/review/                      (linked worktree)
  .git                            (file, not directory)
  src/
  README.md

The linked worktree's .git is a file (not a directory) containing a path relative to the worktree:

Code
gitdir: ../../path/to/project/.git/worktrees/review

Creating a worktree

PHP
$repo = Pitmaster::open('/path/to/project');

// Create a linked worktree on a new branch
$wt = $repo->addWorktree('/tmp/review', 'feature/review');

echo $wt->path;        // '/tmp/review'
echo $wt->branch;      // 'feature/review'
echo $wt->gitDir;      // '/path/to/project/.git/worktrees/review'
var_dump($wt->isMain);      // bool(false)
var_dump($wt->isDetached);  // bool(false)

addWorktree() does the following:

  1. Creates the branch if it does not exist (from HEAD or the specified $from commit)
  2. Creates the metadata directory at .git/worktrees/<name>/
  3. Writes the .git file in the worktree directory
  4. Writes HEAD, commondir, and gitdir metadata files
  5. Checks out the branch's tree into the worktree directory
  6. Writes the index for the worktree

If the branch already exists, $from is ignored. Unlike git, addWorktree() does not refuse a branch that is already checked out in another worktree, and it does not run the post-checkout hook.

With an explicit metadata name

Use the optional name argument when multiple linked worktrees can share the same checkout basename.

PHP
$wt = $repo->addWorktree(
    '/tmp/sandboxes/app',
    'feature/review',
    name: 'sandbox-app',
);

That metadata name is used under .git/worktrees/<name>/ instead of basename($path).

From a specific commit

PHP
$wt = $repo->addWorktree('/tmp/hotfix', 'hotfix/urgent', $repo->resolve('v1.0.0'));

Listing worktrees

PHP
$worktrees = $repo->worktrees();

foreach ($worktrees as $wt) {
    $type = $wt->isMain ? 'main' : 'linked';
    $branch = $wt->branch ?? 'detached';
    $locked = $wt->isLocked ? ' [locked]' : '';

    echo "{$wt->path} ({$type}) [{$branch}]{$locked}\n";
}

The list always includes the main worktree first, followed by linked worktrees. Call worktrees() on a handle for the main repository: on a linked worktree handle, the main entry currently reports the linked worktree's own path (#14).

Removing a worktree

PHP
$repo->removeWorktree('review');          // by metadata name
$repo->removeWorktree('/tmp/review');     // or by worktree path

// Force remove (even if locked)
$repo->removeWorktree('review', force: true);

Removing a worktree:

  1. Checks if the worktree is locked (fails unless $force is true)
  2. Removes the .git file from the worktree directory
  3. Removes the metadata directory from .git/worktrees/

The working tree files are not automatically deleted. The branch continues to exist.

Opening a linked worktree

You can open a linked worktree just like any repository:

PHP
$repo = Pitmaster::open('/tmp/review');

// Pitmaster detects the .git file and follows the gitdir reference
echo $repo->isLinkedWorktree();  // true
echo $repo->gitDir();            // '/path/to/project/.git/worktrees/review'
echo $repo->commonGitDir();      // '/path/to/project/.git'
echo $repo->workDir();           // '/tmp/review'

Repository operations resolve paths like this:

  • Objects: read from and written to the common git dir (project/.git/objects/)
  • Config: read from the common git dir (project/.git/config)
  • Packed-refs: read from the common git dir
  • HEAD: per-worktree (each worktree tracks its own branch), together with ORIG_HEAD, MERGE_HEAD, and the other operation refs
  • Index: per-worktree (each worktree has its own staging area)
  • Loose refs: branches and tags are shared in the common dir (project/.git/refs/)
  • Hooks: read from the per-worktree git dir, so the shared hooks do not run (#12)

Stash does not handle linked worktrees yet: it reads the identity from the per-worktree directory, which has no config, and writes its reflog there (#14).

Worktree value object

PHP
use Pitmaster\Worktree\Worktree;

$wt->name;          // Metadata name (null for the main worktree)
$wt->path;          // Working directory path
$wt->gitDir;        // Per-worktree git dir
$wt->branch;        // Branch name (null if detached)
$wt->head;          // ObjectId of HEAD commit
$wt->isMain;        // true for the main worktree
$wt->isDetached;    // true if HEAD is detached
$wt->isLocked;      // true if worktree is locked
$wt->lockReason;    // Lock reason, or null when there is none (an empty reason is also null)

WorktreeManager

For low-level worktree management. WorktreeManager::add() only writes the metadata and the .git file: it does not create the branch, check out files, or write an index. Use $repo->addWorktree() for a usable checkout. The branch must already exist (or be a full ref name such as refs/tags/v1.0.0, which detaches HEAD).

PHP
use Pitmaster\Worktree\WorktreeManager;

$manager = new WorktreeManager($repo->commonGitDir(), $mainWorkDir);

// List
$worktrees = $manager->list();

// Add
$wt = $manager->add('/tmp/review', 'feature/review');
$wt = $manager->add('/tmp/review', 'feature/review', 'review-app');

// Remove
$manager->remove('review');
$manager->remove('review', force: true);

// Lock/unlock (metadata name only)
$manager->lock('review', 'Do not delete, code review in progress');
$manager->unlock('review');

Locking worktrees

PHP
// Lock (prevents removal)
$manager->lock('review', 'Code review in progress');

// Attempt to remove locked worktree
try {
    $manager->remove('review');
} catch (\RuntimeException $e) {
    // "Worktree is locked: review (Code review in progress)"
}

// Force remove ignores the lock
$manager->remove('review', force: true);

// Unlock
$manager->unlock('review');

The lock is stored as a file at .git/worktrees/<name>/locked. The file content is the lock reason (may be empty).

Detecting worktrees

PHP
// Check if a path is a linked worktree
if (Pitmaster::isWorktree('/tmp/review')) {
    echo "This is a linked worktree\n";
}

// Resolve the common git dir from any path
$common = Pitmaster::commonGitDir('/tmp/review');
// '/path/to/project/.git'

// Resolve a .git file to the actual git directory
$gitDir = WorktreeManager::resolveGitFile('/tmp/review');
// '/path/to/project/.git/worktrees/review'

File layout

Main worktree metadata for linked worktrees

Code
.git/worktrees/<name>/
  HEAD         Per-worktree HEAD (symbolic ref or detached hash)
  commondir    Relative path to the shared git dir ("../..")
  gitdir       Path back to the worktree's .git file, as passed to addWorktree()
  locked       Present if locked (content = reason)
  index        Per-worktree index/staging area

Linked worktree's .git file

Code
gitdir: <relative path to>/project/.git/worktrees/<name>

Resource sharing

Resource Location Shared?
Objects $commonDir/objects/ Yes
Config $commonDir/config Yes
Packed-refs $commonDir/packed-refs Yes
Hooks $gitDir/hooks/ No: Pitmaster does not run the shared hooks in linked worktrees
HEAD $gitDir/HEAD No (per-worktree)
Index $gitDir/index No (per-worktree)
Loose refs $commonDir/refs/ Yes (HEAD and operation refs such as MERGE_HEAD are per-worktree)