Repository navigation
Fix squash reset atomicity - #4193
Bennyjitsu wants to merge 21 commits into
Conversation
wt merge, wt step commit, and wt step squash always committed under the ambient git identity with no way to override it, which blocks any workflow that needs per-agent commit authorship enforced by a pre-commit hook (e.g. a hook that requires AI-driven commits to pass --author explicitly). Adds --author <spec> to all three commands, threaded through the shared commit_staged_changes path (commit.rs) and the squash commit (squash.rs). Also fixes a data-safety bug in wt step squash / wt merge --squash: the squash commit ran `git reset --soft <merge-base>` before attempting the final commit, with no rollback if that commit failed (e.g. blocked by a pre-commit hook). A failure there left the branch's original per-author commits collapsed into anonymous staged changes instead of restored. Captures the pre-reset HEAD and resets back to it if the commit fails. wt step relocate --commit passes author: None (unchanged behavior; no author concept in that path).
- Thread --author into wt merge --no-ff's commit-tree merge commit (src/commands/worktree/push.rs). commit-tree has no --author flag, so identity is set via GIT_AUTHOR_NAME/EMAIL env vars instead; committer is left unset and falls through to the ambient identity, matching `git commit --author`. Adds Repository::run_command_with_env for this. - Squash rollback: chain the original commit error into the restore-failure error instead of dropping it, and extend the same rollback to a failed has_staged_changes() check (a post-reset failure, same as the commit failure it already covered). NoNetChanges is left untouched per explicit decision - it's pre-existing upstream behavior, out of scope for this PR. - Fix help text: --author overrides the commit author, not "the ambient identity" - the committer stays ambient. Same wording on all three flags. - CommitOptions.author is now Option<&'a str> instead of Option<String>, matching MergeOptions/handle_squash's existing style. - Add regression tests (tests/integration_tests/merge.rs): --author on step commit/step squash/merge/merge --no-ff, asserting author changes and committer doesn't; and a squash failure at the git commit stage (invalid --author, which fires after the reset - a worktrunk pre-commit hook runs before it) restores the original per-commit history.
…aged_changes bug
- push.rs: split_author_spec's doc comment had swallowed handle_no_ff_merge's
own rustdoc (inserted with no blank line between them, so both attached to
the wrong item). Restored handle_no_ff_merge's doc, gave split_author_spec
its own.
- push.rs: split_author_spec was stricter than git commit --author, which
tolerates trailing whitespace/junk after the closing `>`. On a default
wt merge --no-ff (squash still on), squash could accept an author spec via
git commit --author and rewrite the feature branch, then the no-ff
commit-tree step would reject the same spec with my stricter parser -
failing after history was already rewritten. Now uses rsplit_once('>') to
match git's own leniency, plus empty name/email rejection. Verified against
the reproduction: --author "Bot <bot@example.com> " (trailing space) now
succeeds through squash and no-ff alike.
- merge.rs: MergeOptions.author doc comment was stale (said "git commit
--author" only; it now also feeds commit-tree for --no-ff).
- relocate.rs: dropped a redundant inline comment.
- working_tree.rs: has_staged_changes() collapsed a real git failure (e.g. a
corrupted index) into Ok(true) alongside the intended "diff exists" case,
because it read run_command's Err (any non-zero exit) as "has changes".
Squash's rollback wrapper around this call was therefore unreachable dead
code. Now reads the exit code directly: 0 = false, 1 = true, anything else
propagates as a real error. Added a unit test reproducing the failure via
a corrupted (directory) index file.
Known open item (not fixed): the generated docs/help snapshots for the new
--author flag (docs/src/content/docs/{merge,step}.md, skills mirrors,
help_merge/help_step_commit/help_step_squash snapshots, test_docs_are_in_sync)
still need regenerating. That test suite and the help snapshots are
#![cfg(not(windows))] by this project's own design (clap renders markdown
differently on Windows) - genuinely can't run or regenerate them on this
machine. No WSL/Linux toolchain available here either.
Verified: cargo build --release clean, cargo clippy --all-targets clean,
cargo test --lib (1465/1469, 4 pre-existing unrelated failures) + --bins
(985/985) + --test integration (1982/2010, same 28 pre-existing unrelated
failures as prior rounds) - zero regressions from this round's changes.
git's own author-ident parser closes at the first '>' after '<', discarding
everything after it - including another literal '>'. rsplit_once('>') took
the LAST one instead, so "Bot <bot@example.com> trailing >" parsed as email
"bot@example.com> trailing" rather than "bot@example.com", diverging from
what git commit --author (used by squash/commit) would have accepted for
the same spec. Verified against real git and against the wt binary
end-to-end. Grok's round-4 review independently surfaced the same
first-vs-last-'>' imprecision but judged it unrealistic to hit and left it
optional; skipping its other optional item (accepting an empty email, which
git allows) as genuinely not realistic for this flag's purpose.
Add --author override to merge/commit/squash
Regenerates docs/src/content/docs/{merge,step}.md, their skill mirrors,
and the three wt-merge help snapshots via cargo insta test --accept
--test integration -- test_help and cargo test --test integration
readme_sync, run from a Linux (WSL2 Ubuntu) environment since these
tests are #![cfg(not(windows))] by design.
Regenerates the diff left pending by the --author flag patch (PR #1),
unblocking the upstream PR to max-sixty/worktrunk.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-docs docs: regenerate merge/step help and docs for --author flag
sync: pull 3 commits from upstream max-sixty/worktrunk
worktrunk-bot
left a comment
There was a problem hiding this comment.
The squash-atomicity half is a real fix and the rollback itself is correct — git reset --soft restores HEAD while leaving the index and working tree exactly as the stage step left them, so the branch comes back intact. Two things hold the verdict.
--author's entire behaviour is already reachable without a new flag. Every git child wt spawns inherits the environment — Cmd never clears it, and scrub_git_discovery_env_vars strips only the path vars (GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR, GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY). So GIT_AUTHOR_NAME="Bot" GIT_AUTHOR_EMAIL="bot@example.com" wt merge --no-ff already sets the author on all four paths this PR threads --author through, with the committer left ambient — which is exactly what the flag's help text promises. I verified against git 2.55 that both git commit and git commit-tree honour those two variables and leave the committer alone.
That makes the flag ergonomics rather than capability, and CLAUDE.md is explicit that "a command, option, user-facing concept, or configuration key is a product decision. Its user value must justify the code and documentation surface it adds." Here that surface is a flag on three commands, three doc pages, a hand-rolled ident parser that doesn't actually match the git commit --author it's modelled on (inline), and a new Repository::run_command_with_env. Whether the ergonomics are worth it is a maintainer call, not mine — so this is a COMMENT rather than an approval, not a request to drop the feature.
#4192 already fixes has_staged_changes, with the same exit-code split plus two flags this version omits. Details inline; rebasing onto it removes the conflict and is strictly more correct.
One thing to know before splitting the PR: the two halves are coupled through the tests. test_step_squash_failed_commit_restores_original_history uses an invalid --author as its failure injector, so the atomicity fix would need a different one — a .git/hooks/pre-commit exiting non-zero fires in the same place, since the squash commit doesn't pass --no-verify, and that is also the trigger the PR description names as the original bug.
| // question — propagate it rather than reporting "no changes" or | ||
| // silently reporting "has changes" as `run_command`'s Ok/Err split | ||
| // would (both non-zero exits collapse to Err there). | ||
| let args = ["diff", "--cached", "--quiet", "--exit-code"]; |
There was a problem hiding this comment.
#4192 makes this same exit-code change to this same function, and additionally passes --no-relative and --ignore-submodules=none, with a regression test for the diff.relative case.
Those flags matter here rather than being incidental: git diff --cached is porcelain, so it reads display configuration that changes what it reports. diff.relative is the configuration CLAUDE.md's Data Safety section records as having "once made wt remove delete an unmerged branch", and "Plumbing for Output wt Consumes" is the rule this call sits under — wt consumes the answer as a boolean, so the invocation needs its behaviour pinned.
Rebasing onto #4192 and dropping this hunk gets the fix plus the flags and removes the conflict between the two PRs.
GIT_AUTHOR_NAME/GIT_AUTHOR_EMAIL env vars already propagate through every commit path this flag touched (git commit, commit-tree via env, squash) with no code needed, since none of them scrub the environment. Verified live against a build with this flag against all three commit paths (step commit, step squash, merge --no-ff) before removing it. The flag also had a correctness bug: its author-spec parser didn't match real `git commit --author` semantics (rejected pattern-lookup and `Name <>` forms git accepts), and the same value was silently accepted on the squash/commit path but rejected on the --no-ff commit-tree path. Drops split_author_spec, run_command_with_env, and the four dedicated --author tests. test_step_squash_failed_commit_restores_original_history loses its --author-based failure injector (a real .git/hooks/pre-commit exiting non-zero replaces it, firing at the same point post-reset). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Unix-only (help.rs is #![cfg(not(windows))]); regenerated via WSL2. Full pre-merge gate (4934 tests, clippy, doc sync) green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Remove --author flag
restore_after_failed_reset passed the original commit error as a .context() *argument* when the rollback itself also failed. context() only stores a Display value, so this reduced the original error's own chain (e.g. a CommandError carrying git's stderr) to just its outermost message, dropping everything deeper. The success path had the same shape of bug: the "original commits restored" context string replaced the failure's headline instead of describing the rollback outcome alongside it. Fix keeps the original error as the chain's root in both branches, adding the rollback outcome as context on top of it rather than folding it in as a flattened string. No deterministic CLI trigger exists for a rollback that itself fails (would need both the squash commit and the reset to fail), so it's proven with a direct unit test on restore_after_failed_reset -- confirmed failing against the prior code before confirming it passes against the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fix squash rollback losing the original error on a double failure
commit.rs and squash.rs both kept a `let commit_args = vec![...]` binding that only existed to support the removed --author push()es. Collapsed back to the inline array literal each call site used before, and restored commit.rs's original `&commit_message` (the `.as_str()` was only needed while the Vec required a uniform &str element type). commit.rs is now byte-identical to upstream's version of that function again. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Remove leftover intermediate variable from --author removal
f4cabfd to
dad2d78
Compare
worktrunk-bot
left a comment
There was a problem hiding this comment.
The third post-reset exit still leaves the branch rewound, and it reports success. The new comment calls the !has_staged path "a normal, intentional outcome left as pre-existing behavior", but that path returns Ok(SquashResult::NoNetChanges) with the branch still soft-reset to the merge base. Pre-existing, yes — but the PR now captures pre_reset_sha three lines above it, and the two exits it does cover are the same reset leaking the same history.
Where that costs the user is wt merge. The reset leaves the branch pointing at the merge base, so the cleanup step reads it as "same commit as main" and removes the worktree and deletes the branch. That check is worktrunk's guard against deleting unmerged work, and the squash's own reset is what makes an unmerged branch satisfy it. With a clean worktree no safety backup is taken, and deleting the branch and the worktree takes both reflogs that named those commits with them — afterwards git fsck is the only thing that still finds them.
Not introduced by this diff, so it needn't be fixed here. It also isn't mechanical, which is why I'd rather flag it than push a fix: restoring pre_reset_sha on this path makes the "No changes after squashing N commits" line literally true and keeps the branch intact, but it also changes what wt merge does next — it would go on to merge two commits that net to zero into main instead of treating the branch as already there. Which of those is wanted is a call for you.
Reproduction, against this PR's merged tree
A branch whose commits cancel out. No config needed — the fallback commit-message path reproduces it too.
git init -q -b main . && echo base > base.txt && git add -A && git commit -qm base
wt switch -c feature # then cd into the feature worktree
echo x > tmp.txt && git add -A && git commit -qm "add tmp"
git rm -q tmp.txt && git commit -qm "remove tmp"
wt merge --yes --no-hooks
◎ Squashing 2 commits into a single commit...
◎ Generating squash commit message...
○ No changes after squashing 2 commits
○ Already up to date with main (no new commits, no rebase needed)
◎ Removing feature worktree & branch in background (same commit as main, _)
Afterwards, in the primary worktree:
$ git reflog --all
a4b3a6c refs/heads/main@{0}: commit (initial): base
a4b3a6c HEAD@{0}: commit (initial): base
$ git fsck --lost-found
dangling commit 723f67fbbbf77219d18f65c136dbaff6a0be86ca
wt step squash on its own stops a step earlier: exit 0, No changes after squashing 2 commits, and the branch has silently moved from its tip to the merge base.
The no-net-changes early return in handle_squash left the branch soft-reset to the merge base instead of restored to pre_reset_sha, while reporting success. wt merge's cleanup then reads that state as "same commit as target" and deletes the worktree and branch, discarding the branch's original (unmerged) commits with no safety backup taken. Flagged by worktrunk-bot's review of upstream PR max-sixty#4193: this path predates that PR but sits right next to the atomicity fix it already made, in the same function, so fixing it here rather than filing it separately. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Restore branch tip when squash finds no net changes
worktrunk-bot
left a comment
There was a problem hiding this comment.
The restore leaves faithful state — reset --soft moves only HEAD, so the index keeps whatever wt.stage put there and the branch returns to its real tip — but nothing asserts that.
test_step_squash_no_net_changes_json builds exactly the scenario this commit fixes (three commits that cancel out, then wt step squash) and checks only that the JSON reports no_net_changes. That assertion passed before this commit too, with the branch silently rewound to the merge base, and it will pass again if the restore is ever dropped. The only thing pinning the new behaviour is merge_squash_empty_changes.snap, and a snapshot gets re-accepted with cargo insta test --accept without anyone weighing what moved.
The commit-failure path already has the direct check, in test_step_squash_failed_commit_restores_original_history: rev-parse feature before and after. The same two lines close this one.
// before the `wt step squash` invocation
let pre_squash_head = repo.git_output(&["rev-parse", "feature"]);
// after the existing outcome assertion
assert_eq!(
repo.git_output(&["rev-parse", "feature"]),
pre_squash_head,
"a no-net-change squash must leave the branch tip where it was"
);That test sits outside this PR's diff, so GitHub won't take it as an applyable suggestion — say the word and I'll push it.
|
I think there is actually something to do here, but it looks quite different to this |
|
Agreed the shape here isn't right. For whatever you build instead, here is what I could verify still holds on
The check that fires is worktrunk's guard against deleting unmerged work, and the squash's own reset is what makes an unmerged branch satisfy it. A failed commit leaves the branch rewound. With a What I can't settle is the semantics, which is probably the part that makes it look different: restoring the branch on the no-net-changes path keeps it intact, but then Reproductions, against a debug build of
|
…ranch (#4206) `wt step squash` and `wt merge`'s squash step ran `git reset --soft <merge-base>` and then `git commit`. When the commit failed — a `pre-commit` hook rejecting it, a broken signing setup — the branch was left rewound to the merge base with its commits collapsed into anonymous staged changes, on a path that reported failure. #4193 found it. The squash now rewrites history the way `git rebase` does. It detaches HEAD onto the merge base with `update-ref --no-deref`, which leaves the branch, the index and the working tree untouched; commits there; then moves the branch to the new commit with one compare-and-swap `update-ref` and puts HEAD back on it. The branch keeps its commits until the commit exists, so a rejected or failed commit leaves it exactly as it was, and the reattach restores HEAD. Committing through `git commit` rather than plumbing is what keeps git's own hooks, `commit.cleanup`, signing and identity resolution behaving as they do for any other commit. Those hooks see a detached HEAD, as they do under `git rebase` — verified on git 2.55, where a real `rebase -i` squash runs `prepare-commit-msg` and `post-commit` with no current branch and skips `pre-commit` and `commit-msg` entirely. The `wt step squash` help says so. Two smaller changes ride along: - The no-net-changes check compares the index tree against the merge base's tree instead of asking `git diff --cached`, which is porcelain and reads `submodule.<name>.ignore`. A squash whose only change is a submodule pointer hidden by that setting is now committed rather than reported as empty. That path still leaves the branch at the merge base, so `wt merge` goes on treating a branch whose commits cancel out as already integrated. - `signs_commits` asks git from the current worktree instead of reading the cached config map, which is read from the common dir and so missed worktree-scoped config, and which read a valueless `gpgsign` as false where git reads it as true. `wt merge --no-ff` builds its merge commit with `commit-tree` and asks this helper whether to sign it. `test_step_squash_failed_commit_leaves_branch_intact` covers the path end to end: a `.git/hooks/pre-commit` that exits 1 fails the squash and leaves the branch, HEAD and the history untouched; without the hook the same squash lands. It fails against the old code on the branch-tip assertion, and against a plumbing-based squash on the hook assertion. Two more hooks drive the failure paths the new sequence adds: one moves the branch mid-squash, so the compare-and-swap refuses rather than clobbering the other write, and one seals the worktree's git directory before rejecting the commit, so the reattach fails and has to say how to put HEAD back. The ORIG_HEAD write is best-effort, as `git reset` makes it — the branch has already moved by then, so failing there would report a squash that landed as an error. > _This was written by Claude Code on behalf of max-sixty_ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Squash reset atomicity
wt step squash/wt merge --squash: a commit failure after the softreset (e.g. blocked by a pre-commit hook) left the branch's original commits
collapsed into anonymous staged changes instead of restored. Fixed by
capturing the pre-reset SHA and rolling back to it on failure.
Also fixes
has_staged_changes(), which silently reported "no stagedchanges" on a real git failure instead of propagating it — the bug that made
the rollback's own error-check path unreachable.
Update (this revision)
The PR originally also added an
--authorflag tomerge/step commit/step squash, bundled with this fix since they touched the same functions.Per this review's feedback:
GIT_AUTHOR_NAME/GIT_AUTHOR_EMAILenv varsalready propagate through every commit path the flag touched (
git commit,commit-treevia env, squash), since nothing here scrubs the environment —verified live against all three paths before removing it. The flag also had
a real correctness bug (its author-spec parser didn't match
git commit --author's own semantics, and the same value was accepted on one mergepath but rejected on another), not worth fixing in code that turned out to
be unnecessary. It's gone; this PR is now just the atomicity fix.
Also fixed: the error-wrapping bug this review flagged in
restore_after_failed_reset— the rollback outcome now rides as context ontop of the original error instead of flattening it into a Display string,
so a double failure (commit fails, then the rollback also fails) no longer
loses the original git error from the output. Proven with a direct unit
test (confirmed failing against the prior code, passing against the fix).
Full test suite passes (4,935 tests, 0 failed), full pre-merge gate
(clippy, doc-sync) green.
Update 2 (this revision)
Fixed a third bug this review flagged: the no-net-changes early return in
handle_squash(commits that squash down to zero net diff) left the branchsoft-reset to the merge base while reporting success, instead of restoring
pre_reset_shalike the other two post-reset exits already do.wt merge'scleanup then reads that state as "same commit as target" and deletes the
worktree and the branch, discarding the branch's original (unmerged)
commits with no safety backup taken.
This path predates the PR — it wasn't introduced by the atomicity fix above
— but it's the same function, so fixed here rather than filed separately.
test_merge_squash_empty_changesnow correctly shows the net-zero commitsbeing merged into the target, instead of the branch being silently deleted
as "already merged".