Summary
pilot worktree sync squash-merges the lane branch into the base branch. A squash records no ancestry, so git merge-base <base> spec/<slug> stays pinned at the lane's fork point no matter how many syncs have landed. Every later sync therefore replays the whole branch-vs-fork diff against a base branch that already carries the earlier syncs' content.
Any file an earlier sync already merged, and that the lane has modified since, comes back as both-sides-modified and the sync fails with:
— an empty detail after the colon, so there is nothing to act on.
Follow-up work that only adds new files syncs repeatedly without complaint (git sees identical additions on both sides), which makes the failure look intermittent rather than structural.
Version: 9.14.0. Not addressed in 9.15.0.
Why this is a normal path, not an edge case
The spec workflow's sync gate offers "keep worktree, sync later" as a first-class choice. Taking it, continuing work in the lane, and syncing again is the intended use of that option — and it is exactly what breaks, because the plan/spec file itself was part of the first squash and is usually the first thing edited afterwards.
Reproduction
mkdir demo && cd demo
git init -q -b main . && echo v1 > f.txt && git add f.txt && git commit -qm base
pilot worktree create demo --json
WT=$(ls -d .worktrees/*)
(cd "$WT" && echo v2 > f.txt && git commit -qam "round 1")
pilot worktree sync --json demo # ok
(cd "$WT" && echo v3 > f.txt && git commit -qam "round 2")
pilot worktree sync --json demo # fails
Actual output:
{"success": true, "path": ".../demo/.worktrees/spec-demo-5a07d3c", "branch": "spec/demo", "base_branch": "main", "plan_slug": "demo"}
{"success": true, "error": null, "files_changed": 1, "commit_hash": "74c6a89..."}
{"success": false, "error": "Merge conflict: ", "files_changed": 0, "commit_hash": null}
Confirming the stale base:
merge-base main spec/demo : 5a07d3c (the fork point)
main : 74c6a89 (already contains round 1)
Swap echo v3 > f.txt for echo x > g.txt and the second sync succeeds — the failure is specific to re-touching an already-synced file.
What is not wrong
Worth stating, since it bounds the severity: the failure is a stall, not data loss. Sync aborts its own merge, leaves the primary checkout clean (no MERGE_HEAD/SQUASH_MSG, base ref unmoved), and exits 1, so the documented sync && cleanup --force chain correctly stops. cleanup also refuses independently while the branch holds unmerged commits.
Workaround
From inside the lane worktree, before each subsequent sync:
git merge -s ours main -m "record sync anchor"
This moves the merge base to the base branch's tip without rewriting history or changing a tracked file, so the next sync carries only the delta. It has to be repeated before every later sync, since sync never re-anchors itself.
Same underlying property — a squash records no ancestry — surfacing on a different command. #166/#169 were about cleanup mis-deriving "merged" from git state after a sync; this is sync itself mis-deriving the base for its own next run. The fix accepted there (have sync record what it produced, rather than re-deriving it from git afterwards) carries over directly: a recorded last-synced commit per lane is exactly what the next sync needs to squash only the delta.
Suggested fix
- Re-anchor after a successful sync — record the merge (e.g. an
-s ours merge of the base branch into the lane, or reset the lane branch to the new base tip) so the next sync's merge base is the tip rather than the fork point. Alternatively, detect the already-synced prefix and squash only the delta.
- Populate the failure message with the conflicting paths.
Merge conflict: with an empty detail gives the user nothing to work from, and the underlying git output is already available at that point.
Summary
pilot worktree syncsquash-merges the lane branch into the base branch. A squash records no ancestry, sogit merge-base <base> spec/<slug>stays pinned at the lane's fork point no matter how many syncs have landed. Every later sync therefore replays the whole branch-vs-fork diff against a base branch that already carries the earlier syncs' content.Any file an earlier sync already merged, and that the lane has modified since, comes back as both-sides-modified and the sync fails with:
— an empty detail after the colon, so there is nothing to act on.
Follow-up work that only adds new files syncs repeatedly without complaint (git sees identical additions on both sides), which makes the failure look intermittent rather than structural.
Version: 9.14.0. Not addressed in 9.15.0.
Why this is a normal path, not an edge case
The spec workflow's sync gate offers "keep worktree, sync later" as a first-class choice. Taking it, continuing work in the lane, and syncing again is the intended use of that option — and it is exactly what breaks, because the plan/spec file itself was part of the first squash and is usually the first thing edited afterwards.
Reproduction
Actual output:
Confirming the stale base:
Swap
echo v3 > f.txtforecho x > g.txtand the second sync succeeds — the failure is specific to re-touching an already-synced file.What is not wrong
Worth stating, since it bounds the severity: the failure is a stall, not data loss. Sync aborts its own merge, leaves the primary checkout clean (no
MERGE_HEAD/SQUASH_MSG, base ref unmoved), and exits 1, so the documentedsync && cleanup --forcechain correctly stops.cleanupalso refuses independently while the branch holds unmerged commits.Workaround
From inside the lane worktree, before each subsequent sync:
git merge -s ours main -m "record sync anchor"This moves the merge base to the base branch's tip without rewriting history or changing a tracked file, so the next sync carries only the delta. It has to be repeated before every later sync, since sync never re-anchors itself.
Relation to #166 / #169
Same underlying property — a squash records no ancestry — surfacing on a different command. #166/#169 were about
cleanupmis-deriving "merged" from git state after a sync; this issyncitself mis-deriving the base for its own next run. The fix accepted there (havesyncrecord what it produced, rather than re-deriving it from git afterwards) carries over directly: a recorded last-synced commit per lane is exactly what the next sync needs to squash only the delta.Suggested fix
-s oursmerge of the base branch into the lane, or reset the lane branch to the new base tip) so the next sync's merge base is the tip rather than the fork point. Alternatively, detect the already-synced prefix and squash only the delta.Merge conflict:with an empty detail gives the user nothing to work from, and the underlying git output is already available at that point.