Worktrees
TLDR/BLUF
Give each concurrent session its own worktree. These commands create checkouts, rescue misplaced work, restore the shared primary, and remove finished worktrees while preserving commit recovery.
A shared git checkout can replace another session’s files without warning. These PowerShell 7
scripts target concurrent work and have been tested on Windows; test other platforms before relying
on them.
Concepts defines the terms. Use Install for the gate and backstop, and Pruning for automatic cleanup.
Each agent or human needs a separate worktree to avoid branch changes replacing a peer’s files. Worktrees have separate directories, branches, and indexes, with shared git history and remotes.
The usual branch, pull request, and merge process stays the same.
These scripts use PowerShell 7 and were developed and tested on Windows. Their path handling supports other platforms, but the defaults assume Windows; limits are noted below.
Command reference
Commands normally resolve the primary checkout from the first git worktree list entry. These three
have extra location rules:
prune-merged.ps1refuses unless you run it from the primary, exiting 2 and naming both paths. From a linked worktree the candidate set is empty for the wrong reason.remove.ps1refuses when you are standing inside the worktree you named.bin/ccx-doctor.ps1does not anchor at all. With no-Repoit reports on your current directory, which is how a long green report about the wrong clone happens.
Five of the eight commands validate -Name with \A[A-Za-z0-9._-]+\z, since it becomes a
directory and branch name. restore-primary.ps1, sessions.ps1, and the doctor take no name.
prune-merged.ps1 instead accepts an unvalidated name list. It limits the sweep to those worktrees
and explicitly overrides their recent-activity veto.
That override never bypasses liveness, nested-worktree exclusion, or a worktree lock. See Pruning.
Use \A...\z for full .NET string matching. ^...$ also accepts a trailing newline because $
matches before it.
| Task | Command |
|---|---|
| Create a worktree on its own branch | pwsh -NoProfile -File scripts/worktree/new.ps1 -Name <name> |
| Create it and open an editor there | pwsh -NoProfile -File scripts/worktree/spawn.ps1 -Name <name> |
| Move uncommitted work out of the primary | pwsh -NoProfile -File scripts/worktree/rescue.ps1 -Name <name> |
| Put the primary back on its home branch | pwsh -NoProfile -File scripts/worktree/restore-primary.ps1 |
| Remove one worktree | pwsh -NoProfile -File scripts/worktree/remove.ps1 -Name <name> |
| Remove the finished ones in bulk | pwsh -NoProfile -File scripts/worktree/prune-merged.ps1 (dry run; -Apply acts) |
| Find sessions whose transcript moved | pwsh -NoProfile -File scripts/worktree/sessions.ps1 |
| Prove the guards are actually live | pwsh -NoProfile -File bin/ccx-doctor.ps1 |
Repository-root ccx.config.json holds the command settings and enables announce.
The worktree gate and SessionStart backstop instead use ~/.claude/hooks/ccx-gate.repos.txt. Apart
from the backstop’s prefix setting, config does not control them; see
Limits.
| Key | Effect on this document |
|---|---|
trunk |
The default -Base for a new worktree and the ref merged work is measured against. auto asks the remote what its default branch is. Overridable per session with CCX_TRUNK. |
worktreeLayout |
sibling (default) or nested – see Two layouts. |
setupHook |
The per-checkout bootstrap new.ps1 runs after creating a worktree. |
prefix |
The stem for the state root, the <prefix>.homeBranch git config key, and the <prefix>-home-branch sidecar record. |
Creating a worktree
Create your checkout and branch from the current remote tip.
Run either command from any checkout of the repository:
pwsh -NoProfile -File scripts/worktree/new.ps1 -Name alerts
pwsh -NoProfile -File scripts/worktree/new.ps1 -Name sqltuning -Base feature/sql-tuning
pwsh -NoProfile -File scripts/worktree/new.ps1 -Name quicklook -NoSetup
new.ps1 fetches, creates the worktree, runs setup, and prints
Worktree ready: <path> (branch '<branch>') with next steps.
spawn.ps1 forwards -Name, -Base, and -NoSetup, then opens an editor only after creation
succeeds. It reports an editor missing from PATH.
-Editor overrides CCX_EDITOR, then EDITOR, then default code.
The base is the freshly fetched remote tip, not local main
Local main often lags upstream in a multi-worktree repository. Starting from it creates stale work
and stale merge judgments, including the reaper’s check.
new.ps1 fetches first and defaults to a remote-tracking ref such as origin/main. Two rules
govern that fetch:
- It fetches the remote the trunk lives on, parsed out of the base ref, rather than a hardcoded
origin. A fork-based workflow whose trunk isupstream/mainwould otherwise fetch the wrong remote and report success. - A fetch failure (offline) is a loud warning, not fatal. You can still branch off the refs you have – you just need to know that is what happened.
An explicit local -Base that trails upstream triggers a warning with the branch, lag count, and
suggested remote ref. Remote-tracking refs lack @{upstream}, so the default skips this check.
Read from a ref, not from a working tree
The rule above governs where a branch starts. The same lag defeats reading, and that failure is quieter: a search of a checkout answers for whatever that checkout last had.
Measured in this repository on 2026-09-17, from the primary checkout:
git rev-parse --abbrev-ref HEAD # a feature branch, not main
git rev-list --count HEAD..origin/main # 48
git ls-files .github/workflows/required-workflow-state.yml # 0 lines
git show 'origin/main:.github/workflows/required-workflow-state.yml' # 158 lines
The primary sat 48 commits behind the trunk, on a branch nobody had switched back. A file that
exists on origin/main reads as absent there.
A second clone on the same machine read 257 behind by the same command. Three analysis passes reported a file missing from that project because they had searched its primary tree.
So name the ref. git show <ref>:<path> and git grep <pattern> <ref> answer about the ref rather
than about wherever your shell happens to be standing.
Their positive control fired, which is why nobody caught it. Tips and tricks carries that half.
Concurrent creation races .git/config.lock
Concurrent git worktree add calls can fail with
could not lock config file .git/config: File exists, leaving an orphaned branch. Creation writes
upstream settings into shared .git/config.
new.ps1 serializes creation with Enter-CcxLock -Name 'worktree-add' and a 90-second timeout. The
lock has three required properties:
- Atomic exclusive-create is the mutex. The lock is a file created with create-new semantics; the filesystem, not a read-then-write, decides who won.
- It never steals. On timeout it fails loudly and names the holder. Breaking a lock can admit simultaneous writers. No reliable liveness signal here proves that a lock is abandoned.
- The script refuses to run without it. If
scripts/coord/lock.ps1is missing, or does not defineEnter-CcxLock,new.ps1throws rather than racing quietly. A safety property that degrades to “not applied” when a file is missing is not a safety property.
One dependency environment per worktree
A shared editable or linked dependency install points to its original source directory. Tests in worktree B can import worktree A, passing locally while CI fails.
Build each worktree’s dependency environment inside that worktree through setupHook. new.ps1
runs the language-specific hook and reports its result:
| Contract | Detail |
|---|---|
| Working directory | The new worktree. Relative paths resolve against it. |
CCX_WORKTREE_PATH |
Absolute path of the new worktree |
CCX_WORKTREE_NAME |
The -Name it was created with |
CCX_PRIMARY_ROOT |
Absolute path of the primary checkout |
CCX_BASE_REF |
The ref the branch was created from |
| Arguments | None are passed, so a hook may declare whatever parameters it likes |
| Exit code | A .ps1 hook runs in a child pwsh, so its exit code is a real contract and it cannot leave state behind in the calling session |
examples/worktree-setup.ps1.example includes Python and Node examples. Copy the parts you need to
setupHook ‘s path, shipped as .ccx/worktree-setup.ps1, and follow these rules:
- Build the environment inside the worktree, for the reason above.
- Install from your lockfile, not from your version ranges. A worktree that re-resolves dependencies
gets whatever the registry serves today – a different formatter from CI. With a
--fixmode in a commit hook, that formatter rewrites your source to match a version CI does not have.
Setup reports two distinct failures:
- The hook file is not found in the new worktree or the primary -> a warning that the worktree has
not been set up. The worktree’s versioned copy takes precedence. The primary copy supports
git-ignored hooks that
git worktree addcannot deliver. - The hook exits non-zero -> a throw that states the worktree was created, at which path, on which branch, and is not rolled back. A bare “setup failed” reads as “nothing happened”, and the next session re-runs creation into a path that is now occupied.
Use -NoSetup to create files without an environment. The printed next steps explicitly report that
setup has not run.
Environments multiply, so start the removal habit in week one
One environment per worktree stays right. It is also the line item that grows, and nothing here removes an environment for you.
Measured on the development host on 2026-09-17:
- 110 registered worktrees in one clone and 40 in a second, by
git worktree list. The first clone read 107 earlier the same day, so treat these as a shape rather than a level. - 0.86 GB for a single worktree’s
.venv, byGet-ChildItem <path>/.venv -Recurse -File -Forcepiped intoMeasure-Object -Property Length -Sum. - 63.36 GB across every worktree directory on the machine, by
du -sb. Dependency environments were 19.66 GB of that total, or 31 percent. - 18.75 GB across 322,826 files in the agent scratchpad directories, by
Get-ChildItem -Recurse.
Pruning is the sweep that clears them. prune-merged.ps1 defaults to a dry run, so
reading its decisions costs nothing and is the cheapest habit to start with.
Rescuing work already in the primary
Use rescue.ps1 to move unfinished work out of the shared primary when the gate blocks further
edits.
Name the destination worktree:
pwsh -NoProfile -File scripts/worktree/rescue.ps1 -Name alerts-fix
The script stashes primary changes, creates a worktree, and applies the stash there by object name. Four details preserve the work:
--include-untracked. Without it, untracked files stay behind in the primary. The new worktree then recreates them: two diverging copies, with no indication which one you are editing.- The new branch is cut from the primary’s current commit, not from the trunk, so the stash applies cleanly. This is the one case where the fetched-remote-tip rule above is deliberately not applied – a rescue that conflicts is a rescue that failed.
- The entry is named, never taken off the top. The push carries a per-run token, the script reads
the entry’s object name back, and the restore is
git stash apply <sha>. Building the worktree takes minutes, andstash@{0}by then may be a peer’s. - The stash is the safety net, and the recovery path is printed at the moment of failure. If the
restore fails,
finallyprints the object name and the commands to inspect and apply it. Mid-panic is not when someone opens a document.
If the primary is clean, the script reports nothing to rescue and suggests new.ps1. It creates no
empty worktree.
Known defect: rescue.ps1 pops the stack by position
RETIRED 2026-09-17. Fixed in #119, which landed after #118 wrote the text below. The script now pins the entry it pushed and restores it by object name. Both pops are gone: the one it ran, and the one it printed.
tests/test_the_rescue_names_the_stash_entry_it_pushed.py holds the fixture. Its mutation control
reverts the restore to a bare pop and requires the case to go red, so the claim is falsifiable.
Two constraints shaped it. git stash drop refuses an object name – is not a stash reference –
so a drop must resolve the SHA back to a stash@{n} first. And git stash create avoids the race
but cannot capture untracked files, which is why this command exists.
Kept rather than deleted, per the rule that retired text stays with its reason. A reader who sees only a removal cannot tell whether the defect was fixed or the claim was wrong.
The retired text follows.
Read at 6eb6be0, both of the script’s pops are bare. It pushes with a unique -m tag, runs
new.ps1 as a child process, then pops whatever now sits on top.
Its finally block prints the same shape: find your entry in stash list by its message, then
git stash pop. The stack is shared, so those are two different entries whenever a peer stashed in
between.
The fix is to resolve the pushed entry to a SHA and apply that SHA, which is what the shared-stack row asks of every other caller. It is filed and not yet made.
Until it lands, run rescue.ps1 when no peer session is mid-stash, and read the message on the
entry before you accept the result.
What actually stops the failure
Three mechanisms address primary branch changes. Only the gate prevents them:
| Role | Script | What it does |
|---|---|---|
| Prevention | scripts/hooks/worktree_gate.ps1 |
Refuses the git verbs that would swap or discard the primary’s tree, before the tool call runs. It reads command strings, so a script or a shell redirect is invisible to it, and it fails open |
| Repair | scripts/worktree/worktree-selfheal.ps1 |
Restores the primary when its HEAD has drifted and the tree is clean. On a dirty tree it declines and touches nothing |
| Detection | the home-branch record | Covers a linked worktree that drifted, not the primary – so it does not see the failure above. Warn-only, and wrong by design |
Restoring the primary
restore-primary.ps1 returns the shared primary to its home branch after a checkout or detached
HEAD replaced peers’ files. The gate blocks ordinary tree-swapping git commands there.
A session may repair the primary but must not take it over.
Preview the switch with -WhatIf:
pwsh -NoProfile -File scripts/worktree/restore-primary.ps1
pwsh -NoProfile -File scripts/worktree/restore-primary.ps1 -Branch main
pwsh -NoProfile -File scripts/worktree/restore-primary.ps1 -WhatIf
The script chooses the home branch in this order:
-Branch, for this run onlygit config <prefix>.homeBranch- the local branch matching the configured trunk (
origin/main->main) main, thenmaster
Step 3 supports trunks with other names using the shared config source. It avoids maintaining a second list of branch defaults.
worktree-selfheal.ps1 lacks step 3. It tries the config key, then main, then master.
If a primary drifts from develop and main exists, the unattended backstop can wrongly switch it
to main at session start.
For a non-main trunk, save the home branch once so both scripts agree:
git -C <primary> config <prefix>.homeBranch <your-trunk>
The script refuses a dirty primary and points to rescue.ps1. Switching could move or lose
someone’s uncommitted work, and the script cannot identify its owner.
-Force skips the script’s dirty check only. Its underlying git checkout uses neither --force
nor -m.
Git still refuses if modified tracked files differ across branches; neither path discards changes.
Use rescue.ps1 to move that work first.
The SessionStart backstop, and the half-failed auto-worktree
install-selfheal.ps1 wires worktree-selfheal.ps1 as an unattended SessionStart repair hook.
On Windows, automatic worktree creation can move the primary’s HEAD to a session branch while
leaving an empty stub. See
anthropics/claude-code#76590.
The backstop handles each state as follows:
| Situation | Action |
|---|---|
| A governed primary has drifted off its home branch and its tree is clean | Switch it back, and say so in the session’s context. A silent repair is indistinguishable from nothing having happened. |
| A governed primary has drifted and its tree is dirty | Touch nothing, and say why. Uncommitted work is never at risk from this hook – but the decline is reported, not silent: the checkout stays on the wrong branch until a person commits, stashes or rescues that work, and a silent decline reads exactly like a primary that was fine. |
| A governed primary is on a detached HEAD, or has no resolvable home branch | Nothing, silently. Neither state says which branch it was meant to be on, and switching a shared checkout onto a guess is worse than leaving it. |
This session’s cwd is under <primary>/.claude/worktrees/<name> with no .git there |
Report it as a ghost stub and tell the model to create a real worktree before editing. A real linked worktree has a .git file pointing at its private git directory; a half-failed stub has nothing there at all. That single test separates them. |
| This session’s own linked worktree is on a different branch from its recorded home | Warn only. Never auto-switch a linked worktree under the session standing in it. |
The hook’s only safety check is its dirty-tree refusal. The doctor requires a reasoned refusal on a
dirty, drifted fixture; a repair produces RED.
A clean fixture cannot prove that guard exists.
Every error path exits 0 silently. The installed hook is self-contained, duplicating
scripts/coord/_common.ps1 rather than loading helpers from a working tree.
That keeps branch switches from removing its dependencies; a missing hook would also fail silently.
Both installers use the same fixed allowlist, ~/.claude/hooks/ccx-gate.repos.txt. The backstop
and PreToolUse gate read it.
Earlier installers kept separate files: one rewrote its list, while the other only created a missing copy. Changes did not propagate between them.
Uninstalling the gate could leave the backstop active and able to switch the shared primary.
Deleting the shared file disables both controls immediately, including in running sessions.
The sidecar home-branch record is wrong by design
new.ps1 records home in <git-common-dir>/worktrees/<id>/<prefix>-home-branch, inside that
worktree’s private git directory. The drift detector reads it; branch changes cannot move it or
expose another worktree’s value.
The record preserves the creation branch and never updates after intentional branch changes. The detector must only warn; automatic repair could replace a live session’s files.
During the audit, most live worktrees differed from their records. Creation and first-sighting bootstrap both wrote records, but neither updated them.
The suggested repair would have moved sessions off their actual branches. Apply these rules:
- Prefer the authoritative source.
git worktree list --porcelainneeds no sidecar at all. Use the record only for the question it can answer. - Treat a mismatch as a question, never a verdict. The hook warns and names both branches; the human decides.
- Never print a destructive remediation command from a detector you have not proven correct. The warning tells you to commit or stash first, and to run the switch yourself from a plain terminal.
Take the commit. Those are the hook’s words, and a stash lands on the stack every worktree shares.
When no record exists, the backstop records the worktree’s current branch. That bootstrap can race the harness’s session setup.
If bootstrap runs before the harness switches branches, it records the pre-setup branch. Every later start then repeats a stale mismatch warning.
Measured here on 2026-08-05: creation at 09:21:50, record at 09:21:54, and session-branch switch at 09:23:10. The record became stale after 76 seconds; no hijack occurred.
The record alone cannot distinguish setup from hijacking. Check the worktree reflog for a branch change caused by an agent tool call before interpreting the warning.
<prefix>.homeBranch configures the primary’s home for restore and selfheal. <prefix>-home-branch
records each worktree’s creation branch.
Both derive their names from prefix, preventing separate renames from splitting the convention.
Removing a worktree
Remove a finished worktree while preserving a route back to its commits.
Run from any checkout except the one you are removing:
pwsh -NoProfile -File scripts/worktree/remove.ps1 -Name alerts
pwsh -NoProfile -File scripts/worktree/remove.ps1 -Name alerts -DeleteBranch
pwsh -NoProfile -File scripts/worktree/remove.ps1 -Name alerts -Force # discard tracked changes too
The script prints the tip before deleting the worktree. With -DeleteBranch, it then asks git to
delete the branch using -d.
The script refuses if you stand inside the target worktree. Its message explains that location error before git attempts removal.
Uncommitted tracked changes block removal unless you pass -Force. Untracked dependencies, build
output, and scratch databases do not block it.
Removal deletes untracked files permanently. Every path uses git worktree remove --force,
even without the script’s -Force flag.
Inspect .env, databases, and scratch files before removal. Git never stored them, so neither
reflog nor fsck can recover them.
The automatic reaper blocks on untracked files. Manual
remove.ps1assumes you inspected those files and judged them disposable; keep this stricter rule for unattended cleanup.
Reference the tip before anything is destroyed
Deleting a worktree and its branch can leave commits in no ref or reflog. The interface then offers no evidence that the work existed.
Resolve and print the tip before deleting anything. With -DeleteBranch, save it in a keep-ref
before removing the branch:
List them: git for-each-ref refs/<prefix>/removed/
Recover one: git branch <branch-that-was-deleted> refs/<prefix>/removed/<name>
Drop one: git update-ref -d refs/<prefix>/removed/<name>
The keep-ref uses directory -Name, while deletion uses the worktree’s actual branch. Recovery
must preserve that distinction.
new.ps1 -Name my-task -Branch feature/my-task gives them different names. Use the deleted branch
name printed by remove.ps1 when restoring it.
The keep-ref preserves commit recovery past the next gc.
git branch -d refusing is a signal
git branch -d may refuse work merged into remote trunk while local trunk lags. Using -D for that
reason bypasses git’s protection against losing commits.
remove.ps1 always uses -d. On refusal it preserves the branch, prints git’s reason verbatim and
the tip again, then offers the forcing command.
It deletes the worktree’s actual branch, not its directory -Name. Namespaced branches work with
new.ps1 -Name my-task -Branch feature/my-task, though directory names cannot contain /.
Deleting my-task in that example gives branch not found. The script now relays git’s reason
instead of claiming the branch contains unique commits.
That old diagnosis sent users searching for nonexistent commits while the actual namespaced branch remained after apparent cleanup.
A detached worktree has no branch to delete. The script reports that state without guessing from the directory name.
Never git worktree prune as cleanup
remove.ps1 does not run git worktree prune. Prune can deregister worktrees on disconnected
drives, unmounted volumes, or temporarily missing nested paths.
git worktree remove already deregisters its own target.
Removal can also deregister a worktree before failing to delete its directory. Follow Pruning to recover that leftover folder.
For bulk cleanup, prune-merged.ps1 defaults to a dry run. It requires merged, clean, unoccupied
worktrees; occupancy may veto removal but never authorize it.
Two layouts coexist, and only one has scripted teardown
Both sibling and nested worktrees were live together in the source project:
| Layout | Path | Created by | Torn down by |
|---|---|---|---|
| sibling (default) | <parent-of-primary>/<primary-leaf>-<name> |
new.ps1 / spawn.ps1 / rescue.ps1 |
remove.ps1, prune-merged.ps1 |
| nested | <primary>/.claude/worktrees/<name> |
the harness itself – and these scripts too, under worktreeLayout: nested |
remove.ps1 only, and only for one you named |
worktreeLayout selects where new.ps1, spawn.ps1, and rescue.ps1 create worktrees. Setting
nested gives them the same layout the harness uses.
Test-CcxHarnessWorktreePath excludes .claude/worktrees/ paths from the gate and reaper.
remove.ps1 never calls it; under nested, remove.ps1 -Name x removes that named nested
worktree.
One helper handles two different requirements:
- A gate protecting the primary must not govern a nested worktree. Its path starts with primary, but its git commands change only its own tree. Governing it refused the most ordinary thing a session does.
- A reaper must never remove one. Some nested paths also start with
<primary>-, misleading a sibling-prefix scan. Removing one can destroy a live session’s checkout.
Two related path rules also apply:
-
“Sibling” is not a prefix match.
<primary>-work/xhas the prefix but is no sibling.Test-CcxSiblingWorktreePathrequires the same parent directory, a leaf of exactly<primary-leaf>-<something>, and not a harness worktree.Even then it only looks like ours: removal turns on occupancy, cleanliness and merge state.
-
A nested checkout is git-ignored inside its parent. The parent therefore reads perfectly clean, and a
--forceremoval of the parent deletes both – leaving the nested worktree registered with no directory.
A wrong-cwd run must refuse loudly, never green no-op
A sweep once ran from a linked worktree, found no siblings, and exited 0. The apparent all-clear hid that it had searched from the wrong root.
Resolve primary through the first git worktree list --porcelain entry, as Get-CcxPrimaryRoot
does. Do not derive it from $PSScriptRoot/../...
prune-merged.ps1 now exits nonzero with
REFUSED: this is a linked worktree, not the primary checkout and names both paths.
remove.ps1 likewise refuses when invoked inside its target.
Get-CcxWorktreePath in scripts/coord/_common.ps1 owns the layout formula. Four scripts once
duplicated it and a fifth pattern-matched it, letting rules drift apart.
What a worktree does not isolate
Worktrees separate files, branches, indexes, and setup-hook dependency environments. These six resources still need shared rules:
| Shared thing | Why | What to do |
|---|---|---|
| The git stash stack | One stack lives in the shared git directory, so every worktree sees every session’s entries. A bare pop takes whichever was pushed last: one session restores another’s work into its own tree, and neither notices. |
Set work aside with a WIP commit. Where you must stash, use git stash push -m "<tag>", read the SHA back with git stash list --format='%H %gs', and restore with git stash apply <sha>. Recovery needs that SHA. |
| Coordination state | It lives at <git-common-dir>/<prefix>-coord, which is identical across every worktree of a clone (that is the point – a claim taken in one worktree must be visible in another). |
Its corollary: state outlives the worktree. Remove a worktree and the claims it took are still there. Release on evidence – the directory is gone and deregistered – never on a timer. See Coordination. |
| The git hooks directory | One commit-msg / pre-push set lives in the shared git directory and governs every worktree of that clone at once. |
Install once per clone, not per worktree. It also sees every write route, because it inspects the tree at commit time rather than a tool call. |
.git/config |
Written by git worktree add. |
Already handled by the mutex above. |
| The AI coding assistant’s project memory | It lives outside the repository, in one directory shared by every session on the machine. Last write wins. | Reads are fine. Coordinate writes explicitly, or let exactly one session own them. |
.claude/ mostly does not reach a new worktree |
A project-scoped settings file is a creation-time snapshot at best, lives on one branch, and is commonly git-ignored. Anything tracked under .claude/ is checked out like any other file; what is git-ignored cannot arrive at all. |
Wire cross-session hooks at user scope, with the script installed outside every working tree. See Install. |
Ports, development databases, Redis keyspaces, package caches, and git-ignored .env files also
remain outside these checks. See Limits and requirements.
A dropped stash is recoverable until the next gc, and only from its SHA
Nothing detects a wrong stash pop. The stack records no worktree, so a popped entry leaves no trace of where it came from or where it went.
The row above read “record the SHA it prints” and “git stash drop does not undo” until
2026-09-17. Both were false. Measured on git 2.55.0.windows.5, in a throwaway git init
repository rather than against this machine’s shared stack.
Push prints no SHA, so the published instruction could not be followed. The read-back is a second command:
$ git stash push -u -m "tag-alpha"
Saved working directory and index state On master: tag-alpha
$ git stash list --format='%H %gs'
a39e73a87b7f2678bb59674f794f21fdc1ece1b7 On master: tag-alpha
grep -c -E '[0-9a-f]{40}' over that push output returns 0, against 1 over git rev-parse HEAD as
a control, so the pattern was live rather than empty.
The drop leaves the commit object in place, and git stash store puts the entry back:
$ git stash drop
Dropped refs/stash@{0} (a39e73a87b7f2678bb59674f794f21fdc1ece1b7)
$ git cat-file -t a39e73a87b7f2678bb59674f794f21fdc1ece1b7
commit
$ git stash store -m "tag-alpha" a39e73a87b7f2678bb59674f794f21fdc1ece1b7
$ git stash list
stash@{0}: tag-alpha
git stash apply then restored the tracked edit and the untracked file both.
Recovery needs three things, and the session that lost the work holds none of them.
| What recovery needs | Why the losing session lacks it |
|---|---|
| The SHA | git stash push prints none. The Dropped ... (<sha>) line prints in whoever ran the drop, not in you. |
| The object, unpruned | git reflog expire --expire-unreachable=now --all then git gc --prune=now took it. cat-file exited 128 and stash store refused it as not a stash-like commit. |
| A reason to look | The pop is silent in your tree. You find out when something is missing, which may be after the window has shut. |
git fsck --unreachable finds the commit without a recorded SHA. It names every unreachable object
in the repository, so it identifies the entry only where there is one candidate.
A raw object name cannot be dropped: git stash drop <sha> answers is not a stash reference. So a
drop re-resolves stash@{n} at drop time, and that is the step where it takes a peer’s entry.
Known limits
KORUS needs Claude Code for Desktop and vendored scripts in the governed repository. Limits and requirements details those needs; these platform limits affect worktree commands:
-
PowerShell 7, Windows-first. Most scripts are
#Requires -Version 7.3and were exercised on Windows. Since$env:USERPROFILEis Windows-only, home lookups fall back safely to the .NET accessor. Windows remains the tested platform.worktree-selfheal.ps1and its installer declare-Version 7, not 7.3. That is why 7.0-7.2 is worse than unsupported: the backstop installs there and the gates do not. - Paths fold case on Windows and macOS, not on a case-sensitive filesystem. Use the folded form for
comparison only, never for git, the filesystem, or a human. One silent gate failure on Linux CI: a
lower-cased path went to
git -C, git failed, and the rule fell through to allow. - The harness’s session record format is a vendor contract. The liveness fence behind the reaper reads per-session records the harness writes; that schema can change without notice. The fence then reports itself unavailable and nothing is pruned – the intended failure direction, and an outage.
-
Session listings do not see every session kind. Sessions relocated into a worktree file their transcript under a different key and drop out of the list of the window they were born in.
sessions.ps1is how you find them, and-Rehomeis how you put one back:pwsh -NoProfile -File scripts/worktree/sessions.ps1 # list pwsh -NoProfile -File scripts/worktree/sessions.ps1 -Id <id-prefix> # one session pwsh -NoProfile -File scripts/worktree/sessions.ps1 -Rehome <id> -WhatIf # preview the move pwsh -NoProfile -File scripts/worktree/sessions.ps1 -Rehome <id> # move it backA bare invocation only ever lists.
-Rehomeis the one action that moves anything, and it honours-WhatIf. - Nothing here can prove a session is gone. There is no heartbeat. Every occupancy verdict is the absence of a veto, not a permission.
Run pwsh -NoProfile -File bin/ccx-doctor.ps1 to check which guards are installed and enforcing on
this machine.
The shared-state map separates each worktree from the records the clone shares.