Skip to content

Restore the carrier checkout after a failed publish and report real push errors - #21

Merged
hmirin merged 2 commits into
mainfrom
fix/carrier-publish-failure
Oct 28, 2026
Merged

hmirin merged 2 commits into
mainfrom
fix/carrier-publish-failure

Conversation

@hmirin

@hmirin hmirin commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Fixes A1, C1 and C3 from the implementation review. All three were reproduced on main before the fix.

A1 (high): a ref Git was told failed got published with the next ref

publish_checkout_head wrote HEAD and made a local commit before git push, and undid neither when the push failed. local_head() reads the working-tree HEAD first, so the unpublished manifest became the "current" head. The helper handles a whole push batch on one storage instance.

Reproduced: git push private main:refs/heads/a main:refs/heads/b with the first publish rejected by a hook. Git reported a as failed, yet the final manifest held a, b and main.

Fix: publish_checkout_head wraps a new commit_and_push_head. On any error, restore_checkout(base_commit) puts the checkout back to the last published commit:

  • it resets the local branch and index to that commit (for genesis, it deletes the ref and empties the index);
  • it removes the working-tree HEAD file;
  • it deletes untracked files under README.md, FORMAT, manifests/ and packs/, so the failed publication's objects are not uploaded later.

If the restore itself fails, its error is attached to the original one.

C1 (medium): every push failure looked like a CAS conflict

Every failed push became CasConflict and stderr was dropped. Since #14 the helper maps that to fetch first, so a hook rejection or an authentication failure showed [rejected] (fetch first) and the git pull hint.

Fix: after a failed push, the branch is fetched again and the code acts on the observed tip, not on stderr text:

  • tip equals the new commit: success, because the push landed;
  • tip equals the base: an ordinary error, git push carrier branch failed: <stderr>;
  • any other tip: CasConflict;
  • the re-fetch itself fails: the push error is returned.

C3 (low): a garbage remote HEAD hid the conflict

head_at_commit validated the remote HEAD while building the conflict, so a stale CAS against a malformed HEAD returned invalid opaque object id. It is replaced by conflicting_head(commit) -> Option<String>, used only to fill CasConflict.actual. Reads that need a valid HEAD still validate it.

Tests

  • rejected_carrier_publish_restores_the_published_head
  • rejected_carrier_genesis_leaves_an_uninitialized_checkout
  • failed_ref_in_a_helper_push_batch_is_not_published_by_the_next_ref. It locates the failed ref from --porcelain output and checks that the hook's message appears and fetch first does not.
  • stale_compare_and_swap_conflicts_even_when_the_remote_head_is_garbage
  • The existing race tests still yield CasConflict.
  • cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test --all-targets (144 passed).

Limits

  • The "push reported failure but landed" path has no test; I found no deterministic way to produce it.
  • If the tip moves for an unrelated reason between the failed push and the re-fetch, the result is CasConflict. The tip did move, so this is accepted.
  • The push error carries git's full stderr on one line, so it can be long.

…ush errors

A carrier compare-and-swap wrote the new HEAD into its checkout and committed
it locally before pushing, and left both in place when the push failed. Reads
on the same storage then saw the unpublished manifest as the current head, so
in a helper push batch a ref that Git had been told failed was published
together with the next ref. publish_checkout_head now restores the checkout
to the last published commit on any failure, including removing the failed
publication's staged objects.

Every failed push was also reported as a CAS conflict and its stderr
discarded, so an authentication failure or hook rejection told the user to
git pull. After a failed push the branch is fetched again: a tip equal to the
new commit means the push landed, a tip equal to the base means an ordinary
error carrying git's stderr, and any other tip is a CAS conflict.

A stale compare-and-swap against a remote whose HEAD file is malformed now
reports a conflict instead of a parse error.
@hmirin
hmirin merged commit 2e45117 into main Oct 28, 2026
1 check passed
@hmirin
hmirin deleted the fix/carrier-publish-failure branch October 29, 2026 00:10
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.

1 participant