Appearance
Worktree Migration
Convert a repository from the old root-checkout layout (a normal checkout of main at the repository root, with worktrees nested under .worktrees/) to the container layout described in Worktrees, in place, without re-cloning, preserving every worktree and all uncommitted work.
This topic is written to be executed by an agent with zero prior context. Given only the instruction "migrate the repositories under <root> to the container layout", follow this document top to bottom. Do not improvise a shortcut; the ordering and the guards exist because directories move out from under running processes and path-keyed state, and a wrong order loses work or detaches sessions.
A note on tooling: the shell commands here operate on the filesystem and on Git plumbing (moving directories, writing the one-line .git pointer, pruning empty admin directories). That is distinct from the repo rule against using the shell to read or edit source files. Where a step rewrites file contents (the transcript cwd rewrites in Phase D), use your editor's read/write tooling rather than sed or awk.
What "done" looks like
For each repository under the target root:
<repo>/ # container: no package.json, no node_modules, no source
├── .bare/ # the git directory (bare)
├── .git # file: "gitdir: ./.bare"
├── main/ # checkout of the default branch, ALWAYS present
└── <other-branches>/ # one per pre-existing worktree, named by branchInvariants to verify at the end (see Verification):
git worktree listshows a(bare)entry plus one worktree per branch.- Every worktree directory path equals its branch name.
main/(ormaster/, per the repo's default branch) exists.- No worktree is marked
prunable. - No path-keyed state store references a directory that no longer exists.
Parameters
<root>: the directory containing the repositories, e.g./Volumes/src. Take this as an argument. Never hardcode it; the same runbook runs on other machines with a different root.
Phase A: survey and classify (read-only)
Do this first, for the whole root, before touching anything. Produce a table the operator can see.
Enumerate directories directly under
<root>.Classify each:
- Not a git repo (no
.git): skip and report. Do not touch. - Already migrated (
.gitis a file containinggitdir: ./.bare, and.bare/exists): skip as done. The runbook is idempotent (see Idempotence). - Old layout (
.gitis a directory): in scope. - Earlier container (
.gitis a directory that is itself a bare repository, with worktrees beside it): in scope. There is no root checkout to carry; converting it is the gitdir relocation below plus moving any worktree not yet at its branch's path.git statuson such a container fails with "this operation must be run in a work tree", which is the tell.
- Not a git repo (no
For each in-scope repo, record:
- The default branch (
git -C <repo> symbolic-ref refs/remotes/origin/HEAD, falling back toorigin's HEAD; it may bemainormaster). - The branch the root is currently on (
git -C <repo> rev-parse --abbrev-ref HEAD). If it is not the default branch, the root has drifted; flag it. - Whether the root working tree is dirty (
git -C <repo> status --porcelain), including untracked files. - Every nested worktree, its branch, and its current path (
git -C <repo> worktree list --porcelain). Read the branch from here; never infer it from the directory name. Directory and branch often disagree. Persist this old-path -> branch map: it is the input to Rollback and to Phase D, and once the directories move the original names are unrecoverable.
Write that map to
<repo>/.bare/migration-map.jsonas soon as.bareexists, as{"moves": [{"from": "<old>", "to": "<new>"}]}, including the root's own move. It lives inside the git directory, so it survives every move the migration makes, and it is what lets Phase D run later, or run on a repo somebody else migrated, without anyone remembering what used to be where.- Any
<repo>/.worktrees/directory whose entry is not ingit worktree list(orphaned debris, e.g. from a native tool). Report; never silently delete. - Sibling
*-worktrees/directories next to the repo (a third legacy pattern). Report for deletion if empty.
- The default branch (
Present the classification and stop for the operator before Phase B if anything is surprising (a repo you did not expect to be in scope, an orphan with real content).
Phase B: quiesce processes (per machine, once)
Directories are about to move. A process holding a cwd inside one keeps running after the move (its cwd follows the rename), but every recorded path goes stale, and long-running watchers and timers misbehave. Before moving anything:
- Inventory live processes, servers, and sessions with a cwd under
<root>. On macOS:lsof -a -d cwd -c node -c claudefiltered to<root>. - Stop launchd/service agents that operate on the tree. In this fleet the dashboard app (
cog, in thecoding-harnessrepo) installs two: a server (com.cog.server) and an updater (com.cog.updater) that runsgit pulland redeploys on a 60-second timer. The updater firing mid-migration is a hazard; boot both out first:shlaunchctl bootout gui/$UID/com.cog.updater launchctl bootout gui/$UID/com.cog.server - Kill stray dev servers and agent processes rooted under
<root>, including any running out of a worktree that no longer appears ingit worktree list. - Note tmux sessions holding a cwd under
<root>; they will need re-pointing or restarting.
Nothing under <root> should hold a live cwd when Phase C begins.
Phase C: migrate one repository
Run these steps per in-scope repo. The container path never moves; do not mv <repo> <repo>.old (it yanks the directory from under any process or shell sitting in it, and it forces a slow reinstall). The gitdir is relocated in place, so the object store, all branches, all worktrees, and all stashes survive.
Step 0: relocate the root worktree, with an approval gate
This is always first. Until the root checkout becomes a normal worktree, the container invariant does not hold.
Let BRANCH = the branch the root is currently on.
If the root working tree is dirty, STOP and get approval before moving anything. The agent must not decide on the operator's behalf what happens to changes it did not make.
- Present a zero-context overview. Assume the operator has no memory of the changes. State the repo, the branch, how long since the last commit, the file count, and for each file what the change actually is, summarized from the diff, not a bare
git status. Include untracked files explicitly; an untracked.envor scratch file is invisible in a diff and painful to lose. - Recommend a handling with a one-line reason: commit on the current branch, stash, move to a new branch, or discard.
- Ask, and wait, via the question tool, one question per dirty repo. Do not proceed on that repo without an answer.
A clean root skips the gate and migrates unattended.
Relocate the gitdir and demote the container:
sh
cd <repo>
mv .git .bare
printf 'gitdir: ./.bare\n' > .git
git config core.bare true
git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'If the repository has extensions.worktreeConfig on, core.bare goes in .bare/config.worktree instead. In that mode git applies the shared config's core.bare to every worktree, so the line above turns each one, the default branch's included, into a bare repository that refuses git status. Git's documented home for it is the main working tree's config.worktree:
sh
git config --unset core.bare # if the shared config has one
git config --worktree core.bare true # writes .bare/config.worktreeRegister a worktree for the root's branch without checking it out, then carry the working files (and index) into it:
sh
git worktree add --no-checkout "$BRANCH" "$BRANCH"
# preserve staged changes: copy the old root index into the new worktree. Ask
# Git for the exact admin index path; do NOT guess it from the branch basename,
# because Git suffixes the admin directory when two worktrees share a basename.
cp .bare/index "$(git -C "$BRANCH" rev-parse --absolute-git-dir)/index"
# move every root entry into the new worktree, preserving modified, staged,
# and untracked files and node_modules as-is. Skip the container's own entries:
# .bare .git .worktrees and the new worktree's own top-level directory,
# which is the FIRST path segment of "$BRANCH" (e.g. "feature" for
# "feature/auth"), not the literal "$BRANCH" path.Move the remaining entries (source, node_modules, dotfiles, untracked files) into "$BRANCH"/. A directory that also holds a worktree, such as .claude/ with Claude Code's .claude/worktrees/ inside it, waits until Step 1 has moved that worktree out, then moves into "$BRANCH"/ like the rest: its other contents (local settings, skill links) belong to the checkout. The skip of the first path segment matters for slash-named drifted branches: git worktree add "$BRANCH" created feature/auth/, so skipping feature/ avoids moving the new worktree into its own descendant while still relocating any other root contents. After the move, git -C "$BRANCH" status --porcelain must match what Phase A recorded, byte for byte, apart from the nested worktrees themselves, which an unignored .worktrees/ shows as untracked in the root and which are not work to decide about.
Restore the always-present default branch. If BRANCH is the default branch, you are done with Step 0. If the root had drifted (BRANCH is a feature branch), the files now live at <branch>/, and you must additionally create a fresh default worktree so the container still has one. Use the default branch name recorded in Phase A (main or master). A migrated checkout usually already has a local default branch, because it was cloned normally, so adding the worktree with -b fails there with a "branch already exists" error. Check first, and fall back to creating it from origin when the local branch is absent:
sh
# <default> is the default branch recorded in Phase A (main or master)
if git show-ref --verify --quiet "refs/heads/<default>"; then
git worktree add <default> <default>
else
git worktree add -b <default> <default> origin/<default>
fiDrift is a defect being repaired, not a shape to preserve. Never relocate a feature branch's files into main/; that leaves the wrong branch behind the main/ name. Report the drift to the operator as a finding.
Step 1: relocate the other worktrees
For every registered worktree from the Phase A survey (git worktree list --porcelain), not just those under .worktrees/, move it to the path its branch implies (read the branch from the survey, never from the old directory name), then repair the git links. This includes sibling *-worktrees/ worktrees and any registered worktree living outside .worktrees/; a worktree left at its old path after the gitdir moved has a broken admin link and shows as prunable.
sh
mkdir -p "$(dirname <branch>)" # for slash-nested branches
mv <old-path> <branch> # <old-path> from `git worktree list`
# ...repeat for every registered worktree...
git worktree repair <branch-1> <branch-2> ...git worktree repair must be passed the new paths explicitly. Run bare from the container with no arguments, it fails with a gitdir unreadable error and leaves every moved worktree marked prunable while still pointing at the old .git path. A later git worktree prune would then discard real work. Always pass the paths, then verify nothing is prunable.
Detect target-path collisions before moving (two worktrees whose branches map to the same directory, or a branch dir that already exists). If any collide, stop and report; do not merge.
Clean up the now-empty .worktrees/ directory:
sh
find .worktrees -type d -empty -delete 2>/dev/null; rmdir .worktrees 2>/dev/nullAnything still in it is unregistered: typically a build cache or an empty parent folder left by a worktree deleted long before the migration. Report it and leave it; it is not a failed migration.
Internal metadata keeps the old basename (.bare/worktrees/<old-dir> for a worktree now at <branch>). This is cosmetic and harmless; do not try to rename it.
Step 2: reinstall dependencies per worktree
Every worktree needs its own install now, because under the old layout it may have been resolving into the root's tree. This is where any previously masked breakage surfaces, which is the point. If the shell sets NODE_ENV=production, npm install silently skips devDependencies; unset it for the install.
Phase D: rewrite path-keyed state (per machine, one pass)
Moving directories detaches every store that keys on an absolute path. Run this after the directories have moved and before restarting anything that would write to the stores again.
What is actually at stake (measured, not assumed)
Chat continuity is not at risk. Given a session id, the CLI searches every bucket, so a session resumes from its new path even with nothing migrated. That was verified end to end: a session was started in a checkout, the checkout was migrated to the container layout, and claude --resume <id> run from the new main/ worktree answered from the pre-migration conversation.
What does break is filing. The transcript stays in the bucket named for the directory that no longer exists, and new turns keep appending there. Anything that lists sessions for a directory looks in projects/<slug(cwd)> and finds nothing, so the checkout looks like it has no history, and the dashboard, which attributes a session to a repo by the cwd recorded inside the transcript, drops it. Phase D is what restores that.
Claude Code has its own relocation routine for this, and it is worth knowing exactly what it does, because Phase D does the same thing: when a live session's cwd changes, the CLI moves <id>.jsonl and the <id>/ sidecar directory into projects/<slug(newCwd)>, re-points ~/.claude/tasks/**/*.output symlinks, and appends a {"type":"relocated","relocatedCwd":...} record, which its metadata reader prefers over the first cwd line. It cannot help here for two reasons: it only fires on a cwd change inside a running session, and it takes the early-exit path in print mode. Sessions that were not running when their directory moved never get stamped.
Run the tool
The whole phase is one command, and it takes no decisions from you:
sh
node skills/git/relocate-session-logs.js --root <root> --apply --historyIt discovers every migrated container under <root>, reads the old -> new map from <repo>/.bare/migration-map.json (reconstructing it from Git if that file is missing, covering the nested .worktrees/<name>, Claude Code's .claude/worktrees/<name>, and the sibling <repo>-worktrees/<name> legacy shapes, since the administrative directory name it works from is the same either way), and for each session bucket:
- reads the bucket's real cwd out of the transcript, never from the bucket name, which is lossy, cannot be inverted, and on a machine that has been running a while is already wrong for some buckets;
- moves the bucket to the slug of the new cwd, carrying the
<sessionId>/sidecar directories andmemory/with it; - rewrites the
cwdfields inside the transcripts and appends the CLI's ownrelocatedstamp; - re-points task output symlinks that pointed into the old bucket.
A reconstructed move is a guess: git hands a deleted worktree's administrative name to the next worktree that wants it. So a guessed move is applied only when the history confirms it: lines under the old path recorded a gitBranch, and every one recorded is the worktree's current branch or a former name git's reflog records for it. Anything else, including a bucket mixing the two worktrees' sessions, is reported with the exact --move <old>=<new> to pass if the guess is right. The migration map, which migrate-worktrees.js writes before it moves anything, is exact and needs none of this.
Without --apply it prints the plan and writes nothing. Re-running is a no-op: a bucket already sitting at its destination is left alone.
After an apply it prints CHECK: for every rewritten cwd that names no directory. Each one is either a directory deleted since that session ran or a path the tool got wrong, and only a person can tell which, so read them rather than trusting a clean exit. Matching known bad shapes instead is how a run that wrote 1,691 wrong paths once reported clean.
The awkward cases are handled rather than handed back, because there is nobody to hand them to at 3am in the middle of a fleet migration:
| Case | What the tool does |
|---|---|
| Bucket name disagrees with its own transcript's cwd | Re-files it under the correct slug (real stores drift; two buckets here hold each other's sessions) |
| Two buckets swap names | Moves everything through staging names, so neither clobbers the other |
| Two directories share one slug (the slug is lossy) | Merges them, keeping each transcript and its own cwd |
| One session id present on both sides of a merge | Keeps both: the longer transcript takes the name, the other is preserved as .superseded |
| A worktree deleted before the migration | Left alone: it is in no map, so there is no new path to move its history to |
| A path inside a worktree that moved | Follows that worktree's own move, never an ancestor's (the root's move covers every path) |
Bucket has no cwd in any transcript | Leaves it alone and reports it; there is no evidence to act on |
| Path outside the migration root | Never touched, including its filing |
One case is genuinely ambiguous and is left alone rather than guessed at. If the old root checkout had a subdirectory whose name matches a branch that is now a worktree (a <repo>/live folder, and also a branch live), the two share one bucket and nothing on disk records which sessions ran where. That bucket is treated as already relocated, so it keeps both histories rather than half of it being moved somewhere it may not belong.
The rest of the stores
~/.claude/sessions/<pid>.json(live registry, keyed by pid) and~/.claude/ide/<pid>.lock: these reference processes you killed in Phase B. Delete the stale ones.~/.claude/history.jsonl(projectfield per prompt):--historyrewrites it.- UUID-keyed stores need no change:
~/.claude/tasks/<sessionId>/and~/.claude/session-env/<sessionId>/.
Two things worth knowing before you go looking for them. A transcript's first cwd line can sit far past any head window (one on this machine is at byte 112,481, behind a long queued prompt), so anything that reads a fixed prefix to identify a session gets it wrong; the dashboard's 32 KB read has this bug (chriscalo/coding-harness#496). And under the container layout the CLI files project memory under the slug of <repo>/.bare, since that is where the git directory now lives; it is harmless, but it does mean a ...--bare bucket is expected rather than debris.
Dashboard (cog) stores
- Leave
~/.cache/cog/session-workstreams.jsonuntouched. It maps session UUID to issue number and is deliberately path-free; it is what carries session-to-issue linkage across the move. Do not "fix" it. ~/.cache/cog/ttl-memo.jsonis a rebuildable cache keyed by absolute repo path. Rewrite the keys or just delete the file (costs one cold refetch).
Phase E: restart services
- Reinstall the launchd plists from the new checkout path (they bake in absolute paths for the server script, checkout dir, and
node_modules/.bin). The sanctioned command in this fleet isnpm run service:install; the deploy tooling deliberately refuses to auto-reinstall plists. - Restart the server and confirm health.
Verification
Per repo:
sh
cd <repo>
git worktree list # (bare) + one per branch, none prunable
git worktree list --porcelain | grep -c prunable # must be 0
test -d main || test -d master # default branch worktree presentPer worktree: dependencies resolve inside it and fail loudly outside an installed one (the whole reason for the migration):
sh
cd <repo>/<branch> && node -e "require.resolve('<a-real-dep>')" # resolvesSession continuity (per machine):
- Every transcript is filed where the CLI looks for it: for each bucket, the effective cwd (its last
relocatedstamp, else its firstcwdline) slugs back to the bucket's own name. A second run of the tool reports zero moves, which is the same check from the other side. - No path-keyed store references a directory that does not exist, apart from directories that were already gone before the migration (deleted worktrees keep the path they ran in). The tool's
CHECK:lines list every rewritten path that names no directory; each needs an explanation. - Every live process's actual cwd matches its registry entry.
- The dashboard lists every repo and its sessions, and a resumed session can send a message (this exercises repo discovery and resume end to end).
This was exercised against a copy of a real store (73 buckets, 662 MB, 502 transcripts): file count unchanged, every sidecar directory intact, every moved transcript stamped, and all 502 transcripts filed where the CLI looks. The one bucket left misfiled belonged to a directory outside the migration root, which the tool deliberately does not touch.
Idempotence
Re-running the runbook on an already-migrated repo must be a no-op. The Phase A classifier detects the container shape (.git is a file pointing at .bare, .bare/ exists) and skips. If a run is interrupted mid-repo, the repo is in a detectable partial state (.bare exists but main/ does not, or .worktrees/ still has entries); resume that repo, do not restart the whole root.
Rollback
Because the container path never moved and the gitdir was relocated (not recreated), a single repo is reversible until Phase D. Undo the steps in reverse order, and do every directory move while .bare still exists. The relocated worktrees' admin links point into .bare, so restoring the gitdir first strands them: Git commands inside them fail with gitdir unreadable and their registrations show as prunable.
Rollback's input is the old-path -> branch map recorded in Phase A.
sh
cd <repo>
# 1. Undo Step 1: move each relocated worktree back to its pre-migration path.
# Recreate the parent first. The old path is usually under `.worktrees/`,
# which Step 1 deleted once it was empty, so a bare `mv` fails with "No such
# file or directory" and leaves the worktree stranded: the repair in step 4
# then targets a path that does not exist, and the worktree stays broken and
# prunable. Same reason the forward migration creates parents for
# slash-nested branches.
mkdir -p "$(dirname <old-path>)" # per worktree, from the Phase A map
mv <branch> <old-path>
# Remove the now-empty ancestors the container layout introduced (`feat/` after
# `feat/x` moves back), so the container is left as it was found.
find . -maxdepth 1 -type d -empty -delete
# 2. Undo Step 0: move the root worktree's files back to the container root,
# then remove the now-empty "$BRANCH"/ directory. If the root had drifted,
# Step 0 also created a fresh <default>/ worktree that did not exist before;
# delete that too, after confirming it holds no work committed since the
# migration.
# 3. Restore the gitdir.
rm -f .git # remove the container's pointer file BEFORE restoring the gitdir
mv .bare .git
git config --unset core.bare
# With extensions.worktreeConfig on, Step 0 wrote core.bare to config.worktree
# instead, where the line above does not reach it.
git config --file .git/config.worktree --unset core.bare 2>/dev/null
# 4. Re-point every restored worktree's admin link at the recovered .git.
# Without this they still reference <repo>/.bare and stay broken. As in the
# migration, repair needs the paths passed explicitly.
git worktree repair <old-path-1> <old-path-2> ...
# 5. Only now prune the vestigial registrations Step 0 created for "$BRANCH"/
# and <default>/. Pruning BEFORE the repair in step 4 would discard the
# real worktrees' registrations too.
git worktree prune
git worktree list --porcelain | grep -c prunable # must be 0Step 0 also set remote.origin.fetch, but that is the standard value for a non-bare clone, so it needs no reversal.
In practice the cleaner recovery is to finish the migration rather than revert, since no data was destroyed. Once Phase D has rewritten the ~/.claude buckets, roll those back from their pre-migration copy rather than reversing each edit; take that copy before Phase D begins.
See also
- Worktrees: the target layout and day-to-day worktree commands.
relocate-session-logs.js, next to this topic: the Phase D tool, with its test suite inrelocate-session-logs.test.js.