Reverse the extended headers in ReverseFileDiff - #92
Conversation
ReverseFileDiff swaps OrigName/NewName, OrigTime/NewTime and every hunk range, but copied Extended verbatim. A reversed file creation therefore kept "new file mode 100644" while its +++ line became /dev/null, and git apply rejects that: error: git apply: bad git-diff - expected /dev/null on line 2 For an empty new file it is worse: PrintFileDiff short-circuits when Hunks is nil, so the reversed diff is byte-identical to the forward one and re-parsing gives back the original direction. Swap the direction-bearing headers the way parse.go already reads them: new file mode/deleted file mode, old mode/new mode, rename from/rename to, copy from/copy to, and the two hashes in the index line. Gated on a leading "diff --git " line, which is the same gate handleEmpty uses.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0da2c37008
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
| swapHeaderValues(reversed, "old mode ", "new mode ") | ||
| swapHeaderValues(reversed, "rename from ", "rename to ") | ||
| swapHeaderValues(reversed, "copy from ", "copy to ") |
There was a problem hiding this comment.
Reject copy diffs instead of swapping their endpoints
When the input is a Git copy such as copy from old / copy to new, swapping these values produces a copy from new back to old, but the inverse of a copy must remove new while retaining old. After the forward patch has been applied, both files exist, so this reversed patch fails because old already exists rather than undoing the creation of new. Since a copy-only diff does not contain enough file content to synthesize a deletion patch, this case should be rejected or handled through a representation that can actually delete the destination.
Useful? React with 👍 / 👎.
A Git copy records how to create the destination but not enough content to construct its inverse deletion. Returning an error avoids producing a patch that tries to overwrite the still-existing source, while the smaller index parser keeps mode and CRLF preservation explicit. Amp-Thread-ID: https://ampcode.com/threads/T-01a08a17-d51a-71a5-a028-1399ee2700e4 Co-authored-by: Amp <amp@ampcode.com>
|
Thanks for the thorough report and implementation. I pushed a small maintainer follow-up. Copy diffs need different treatment: the inverse of a copy is deleting the destination, not copying it back over the source. Because a copy-only diff does not contain enough content to construct that deletion, I also simplified index-header reversal with |
keegancsmith
left a comment
There was a problem hiding this comment.
The extended-header reversal is appropriately scoped, and the copy case now fails explicitly rather than producing a patch that cannot undo the original operation.
Git-derived fixtures guard the directional metadata and hunk behavior added in PR #92 without requiring Git during tests. Binary expectations retain the dual payload ordering because swapped index hashes select the inverse data, while diff header argument rendering stays orthogonal for compatibility with PR #94. Amp-Thread-ID: https://ampcode.com/threads/T-01a08a2d-c34e-7623-88c1-084780f7dd6d Co-authored-by: Amp <amp@ampcode.com>
Git-derived fixtures guard the directional metadata and hunk behavior added in PR #92 without requiring Git during tests. Binary expectations retain the dual payload ordering because swapped index hashes select the inverse data, while diff header argument rendering stays orthogonal for compatibility with PR #94.
The inconsistency
ReverseFileDiffswaps every direction-bearing field except one:and the hunk ranges are swapped at
:65-69. ButExtendedcarries direction too,and this package already knows it:
handleEmptyreadsdeleted file modevsnew file modeatdiff/parse.go:533,536to decide which side is/dev/null,and
diff/parse.go:584-585readsrename from/rename tofor the same purpose.The doc comment at
diff/reverse.go:9-10says the result "undoes the edit of theoriginal". A diff that still says
new file modedoes not undo a creation.What a caller sees
ParseFileDiff→ReverseFileDiff→PrintFileDiffongit diff's output for afile creation:
For a rename it is
error: git apply: bad git-diff - inconsistent old filename on line 2.For reference,
git diff -Ron the same commit range producesdeleted file mode 100644andindex 587be6b..0000000. After this change theoutput matches and
git apply --checkaccepts it.And an empty new file is a silent no-op.
PrintFileDiffshort-circuits atdiff/print.go:48whenHunks == nil, so nothing is written but the extendedheaders — which still say
new file mode. Round-tripping the "reversed" diff backthrough
ParseFileDiffgivesOrigName == "/dev/null", i.e. byte-identical to theforward diff.
ReverseFileDiffdid nothing at all.Nothing downstream compensates: the only non-test callers are
ReverseMultiFileDiff→ReverseFileDiff(diff/reverse.go:33) andReverseFileDiff→reverseHunk(:20), neither touchesExtended, andPrintFileDiffwritesd.Extendedstraight out atdiff/print.go:32-36.The change
Swap the direction-bearing headers using the same vocabulary
parse.goalreadyreads:
new file mode↔deleted file mode,old mode↔new mode,rename from↔rename to,copy from↔copy to, and the two hashes inindex <a>..<b>[ mode].Gated on a leading
diff --gitline — the same gatehandleEmptyuses atdiff/parse.go:511. That gate is load-bearing: without it the existingdiff/testdata/sample_multi_file.reversedgolden churns, because it encodes adiff --ruNheader withold mode 0777/new mode 0755that is not git-shaped.There is a test row for exactly that.
Every rewrite is an involution, so the existing
ReverseMultiFileDiff(ReverseMultiFileDiff(x)) == xtest atdiff/reverse_test.go:172-176still passes unchanged. The helper copies the slice,so the caller's
FileDiffis not mutated — there is a test row for that too.Deliberate limitation
git diff -Ralso swaps the two arguments of thediff --gitline itself(
diff --git a/old b/new→diff --git b/new a/old). I left that line alone:parseDiffGitArgs(diff/parse.go:455-505) shows it is genuinely ambiguous forfilenames containing spaces or quotes, and round-tripping it would need re-quoting
logic.
git apply --checkaccepts the output without it, which I verified for boththe creation and the rename case. Happy to extend if you want it.
similarity index/dissimilarity indexare left as-is, matchinggit diff -R.Verification
go test ./...green;gofmtandgo vetclean.Extended: fd.Extendedfails the new tests; dropping thediff --gitgate also fails them, on thediff --ruNrow.git diff -Rfrom the same commit range in a scratch repo, withgit apply --checkas acceptance — not a hand-written expectation.go-cmpand stdlib, both already imported, and builds on theGo 1.20 in
go.mod. No testdata files added, so no line-ending or TZ exposure.I did not benchmark this.
ReverseFileDiffis called once per file diff and theadded work is a slice copy plus a linear scan over at most a handful of header
lines;
git log -- diff/reverse.goshows no perf-motivated commits. Say the wordif you want numbers.
Note
Open PRs #87/#88 also touch
diff/reverse_test.go, but only as a mechanical Go1.26 modernize. My tests are appended at the end of the file, so a rebase should be
trivial whichever lands first. No open PR touches
diff/reverse.go.Disclosure
I used an AI assistant to help find and prepare this change. I reviewed and tested
it myself, and the outputs above are from runs I performed.