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:
project/
.git/ (directory)
src/
README.md
With worktrees, you can have additional checkouts:
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:
gitdir: ../../path/to/project/.git/worktrees/review
Creating a worktree
$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:
- Creates the branch if it does not exist (from HEAD or the specified
$fromcommit) - Creates the metadata directory at
.git/worktrees/<name>/ - Writes the
.gitfile in the worktree directory - Writes
HEAD,commondir, andgitdirmetadata files - Checks out the branch's tree into the worktree directory
- 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.
$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
$wt = $repo->addWorktree('/tmp/hotfix', 'hotfix/urgent', $repo->resolve('v1.0.0'));
Listing worktrees
$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
$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:
- Checks if the worktree is locked (fails unless
$forceis true) - Removes the
.gitfile from the worktree directory - 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:
$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
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).
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
// 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
// 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
.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
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) |