Skip to content

subtree tool: merge by default, add the deletion audit, rename it - #13

Draft
Peeja wants to merge 2 commits into
mainfrom
claude/subtree-tool
Draft

Peeja wants to merge 2 commits into
mainfrom
claude/subtree-tool

Conversation

@Peeja

@Peeja Peeja commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

[From Claude:]

Follow-up to #10. Two changes: one because the tool was awkward to call, one because it had a hole.

--apply is gone; merging is what it does

--dry-run now prints a real unified diff (git diff --no-index) rather than the custom before/after format the old default emitted. There is no reason to have our own rendering of a diff — the output is now something you can read, grade, pipe to patch, or hand to another tool.

.github/scripts/finish-subtree-pull.sh <prefix>              merge, write, audit
.github/scripts/finish-subtree-pull.sh --dry-run <prefix>    print the diffs only

Almost every call is an agent finishing a pull, so the common path is the default and the preview is the flag.

The hole: a lost upstream change that raises no conflict

The old tool was driven entirely by conflicts. There is a way to lose an upstream change with no conflict at all:

When upstream deletes a file we had moved out of the prefix, both sides deleted that path. Git raises nothing. git status says nothing. The pull succeeds, and we go on carrying a file upstream removed.

Nothing conflict-driven can see that, because there is no conflict to see. So the tool now also audits the merge itself: it reads upstream's own diff against the previous split point and reports deletions whose file we still have.

-M is load-bearing there — without it every upstream rename reads as a delete, and each one is a false positive.

Why the name changed

That audit has to run when nothing conflicted — which is precisely when the old tool did not run at all. So the mode is detected rather than flagged:

what git left behind mode what it does
MERGE_HEAD exists conflicted pull in progress merge the rename-blind conflicts, then audit
HEAD is a merge commit pull just finished audit only
neither exit 2, says so

Hence finish-subtree-pull.sh, and hence the AGENTS.md heading now being an instruction ("Run this with every git subtree pull") rather than a condition ("When a git subtree pull conflicts…"). Either way, run it.

Exit is non-zero if anything was left for a human or anything was flagged by the audit.

Verification

Synthetic subtree fixtures, both modes. Each case built from a real git subtree add + git subtree pull, not simulated.

case expected result
upstream modifies a file we moved out merged to its new home merged svc/pkg/a.go -> shared/a.go (1 conflict(s); markers in the file, UU in git status); git ls-files -u shows stages 1/2/3 at shared/a.go, git add resolves it
upstream deletes a file we moved out flagged, exit 1 STILL HERE upstream deleted pkg/doomed.go; we moved it to shared/doomed.go and still have it — in both modes, including the completed-pull audit where nothing else would have mentioned it
upstream renames a file we moved out merged, not flagged ✅ mapped back through MERGE_HEAD to svc/pkg/renamed.go, merged … (clean, staged), 0 upstream deletion(s), exit 0 — the -M case
upstream deletes a file we never moved git deletes it, nothing flagged D svc/pkg/keep.go staged by git, 0 flagged, exit 0
--dry-run tree and index untouched git status --porcelain still DU svc/pkg/a.go after the run

Previously measured on a real sprue pull (#10): 6 skips, 0 false positives.

What it still refuses to guess

Unchanged from #10, and worth restating: it applies only where a rename was recorded and the destination verified to exist in the index. It refuses — reports and skips, never guesses — a same-basename match, a true delete, a destination with uncommitted changes, and a binary file (where git merge-file produces plausible garbage rather than failing).


Generated by Claude Code

Two changes, one because the tool was awkward to use and one because it had
a hole.

`--apply` is gone; merging is what it does. `--dry-run` prints a real unified
diff (`git diff --no-index`) instead of the custom before/after format the old
default emitted, so the output is a diff you can read, grade, pipe to `patch`,
or hand to another tool. Nothing here needs a bespoke rendering of a diff.
Almost every call is an agent finishing a pull; making the common path the
default and the preview the flag matches that.

The hole: the old tool was driven entirely by conflicts, and there is a way to
lose an upstream change with no conflict at all. When upstream DELETES a file
we had moved out of the prefix, both sides deleted that path -- git raises
nothing, `git status` says nothing, the pull succeeds, and we go on carrying a
file upstream removed. Nothing conflict-driven can see it, because there is no
conflict to see.

So the tool now also audits the merge itself: it reads upstream's own diff
against the previous split point and reports deletions whose file we still
have. `-M` is load-bearing there -- without it every upstream rename reads as a
delete and each one is a false positive.

That audit has to run when nothing conflicted, which is precisely when the old
tool did not run at all. So the mode is detected rather than flagged: MERGE_HEAD
means a conflicted pull in progress, a merge commit at HEAD means one that just
finished. Either way, run it; hence the name, `finish-subtree-pull.sh`, and
hence the section heading in AGENTS.md being an instruction rather than a
condition.

Exit is non-zero if anything was left for a human OR anything was flagged by
the audit.

Verified on synthetic subtree fixtures, both modes:

- upstream modifies a file we moved out -> merged to its new home; conflicted
  result gets diff3 markers in the file and stages 1/2/3 at the new path, so
  `git status` shows UU and `git add` resolves it
- upstream deletes a file we moved out -> flagged STILL HERE, exit 1, in both
  the conflicted-pull and completed-pull modes
- upstream RENAMES a file we moved out -> mapped back through MERGE_HEAD and
  merged cleanly, and NOT flagged as a deletion (the -M case)
- upstream deletes a file we never moved -> git deletes it, nothing flagged,
  exit 0
- --dry-run leaves the working tree and index untouched

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGAGAib517Ae1kg8SCdcEt
…uses

finish-subtree-pull.sh handles files we moved OUT of a prefix, which git's
rename detection cannot follow. That is the rare case. The common one is the
conflict this repository creates by existing: every service's imports were
rewritten to github.com/fil-forge/forge/<svc>, so a pull conflicts on every
file where upstream touched that import block -- fourteen of them in the
resync of eight prefixes, each the same non-decision.

resolve-rewrite-conflicts.sh takes upstream's file and re-applies the rewrite,
but only where that is provably safe, and THE CHECK IS THE POINT. It verifies
per file that rewrite(base) is exactly ours -- meaning our side carries nothing
upstream could disagree with -- and refuses with a diff when it is not.

gofmt normalises both sides for .go files, which is load-bearing rather than
tidy: the rewrite makes a path longer, which can move it within its import
group, and gofmt sorts groups. Without it, a file whose only difference IS the
rewrite compares unequal byte for byte and gets refused as a hand merge.

Verified against the real thing rather than a fixture. Replaying the sprue pull
of the resync branch, from origin/main, with up-sprue at 506f5f6:

  - 3 resolved, 8 left, matching the hand resolution exactly
  - all three resolved files are BYTE-IDENTICAL to what was committed after
    resolving them by hand, which is the strongest available evidence that
    those hand resolutions were right
  - go.mod and go.sum refused, with the sibling pins and the libforge
    difference printed as the reason
  - the six modify/delete workflow conflicts reported as not its business

And proved to fail on the defect: appending one unrelated comment line to our
side of ucan_conclude_http_put.go makes it refuse that file and print the added
line, while still taking its sibling in the same run.

Peeja commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

[From Claude:]

Added a second script, resolve-rewrite-conflicts.sh (1a17fdc). It belongs here rather than in #14 because it is the same moment and the same problem shape, and the two are complementary rather than overlapping.

finish-subtree-pull.sh handles files we moved out of a prefix, which git's rename detection cannot follow. That is the rare case. The common one is the conflict this repository creates by existing: every service's imports were rewritten to github.com/fil-forge/forge/<svc>, so a pull conflicts on every file where upstream touched that import block — 14 of them across the eight prefixes of #14, each the same non-decision.

The check is the point, not the fix

Taking upstream's file and re-applying the rewrite is safe only if our side carries nothing upstream could disagree with. So it verifies per file that rewrite(base) is exactly ours, and refuses with a diff when it is not. go.mod and go.sum fail that every time — which is the check working, since those carry real decisions (siblings at v0.0.0 with replace ../<svc>, a unified libforge) that have to be re-applied by hand.

gofmt normalises both sides for .go files, and that is load-bearing rather than tidiness: the rewrite makes a path longer, which can move it within its import group, and gofmt sorts groups. Without normalising, a file whose only difference is the rewrite compares unequal byte for byte and gets refused as a hand merge. That bit me on swarf before I fixed it.

Verified against the real thing, not a fixture

Replaying #14's sprue pull from origin/main with up-sprue at 506f5f6:

resolved / left 3 / 8 — matching the hand resolution exactly
the three resolved files byte-identical to what #14 committed after resolving them by hand
go.mod, go.sum refused, with the sibling pins and the libforge difference printed as the reason
the six modify/delete workflow conflicts reported as not its business

That byte-identical result is the strongest evidence available that #14's 14 mechanical resolutions were right: the tool and the hand agree exactly, having been done independently.

And proved to fail on the defect

Appending one unrelated comment line to our side of sprue/pkg/service/handlers/ucan_conclude_http_put.go:

LEFT  sprue/pkg/service/handlers/ucan_conclude_http_put.go: ours differs from the base by more than the rewrite:
        140a141,142
        >
        > // A local change that is NOT the rewrite. The resolver must refuse this file.
TAKE  sprue/pkg/service/handlers/ucan_conclude_http_put_test.go: ours is base+rewrite only; would take upstream and re-apply it

It refuses that file, names exactly what the extra content is, and still takes its sibling in the same run.

Neither tool sees the third class

Worth stating plainly, since two tools can read like full coverage. A hunk can merge cleanly and still carry a polyrepo import path, because it never touched a line we had rewritten — true of a file upstream added and of an existing file that merely gained an import. Nothing conflicts, so nothing reports it. check-module-paths.sh on #14 is what does, and AGENTS.md now says so under both tools.


Generated by Claude Code

Peeja pushed a commit that referenced this pull request Sep 18, 2026
…r all

The guppy digest re-pin was the right diagnosis -- e2e passed on 9b3a0b0.

And a reversal worth recording rather than quietly making: the resync commit
said the indexing-service poller fix would arrive with a later pull and that
not porting it ahead of upstream was deliberate. Then it went red on #14 with
the identical signature. Waiting on my own open PR to merge is still waiting,
so it is ported -- byte for byte, no import lines touched, no-ops on the next
pull. The upstream PR stays open; that is still where the fix belongs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants