Skip to main content

git_remote_object_store/protocol/
push.rs

1//! `push` handler with per-ref locking via conditional writes.
2//!
3//! Sequential-batch semantics: every `push <refspec>` line in a batch
4//! is processed in order under its own per-ref lock, and one outcome
5//! line is emitted per push (`ok <ref>\n` or `error <ref> "msg"\n`).
6//! `gix::Repository` is `!Sync` so the handler holds the repo handle
7//! on a single task — pushes never run in parallel within one client.
8//!
9//! Stdout discipline: this module returns [`PushOutcome`] values and
10//! never writes to the protocol stream itself. The REPL renders each
11//! outcome and the trailing blank-line terminator (see
12//! `.claude/rules/protocol-stdout.md`).
13
14use std::collections::HashSet;
15use std::env;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::sync::atomic::{AtomicU64, Ordering};
19
20use bytes::Bytes;
21use time::{Duration, OffsetDateTime};
22use tokio::sync::watch;
23use tokio::task::JoinHandle;
24use tracing::{debug, info, warn};
25
26use crate::git::{self, GitError, RefName, RefNameError, Sha, ShaError, is_valid_ref_name};
27use crate::keys;
28use crate::object_store::{ObjectMeta, ObjectStore, ObjectStoreError, ProgressSink, PutOpts};
29use crate::packchain::gc::{tombstoned_bundle_keys, write_baseline_tombstone_best_effort};
30use crate::packchain::schema::Sha40;
31use crate::url::{BackendKind, StorageEngine};
32
33/// Default per-ref lock TTL (seconds) when [`ENV_LOCK_TTL_SECONDS`] is
34/// unset or unparseable. Single source of truth for the lock TTL —
35/// `manage::doctor`'s stale-lock predicate, the CLI's
36/// `--lock-ttl-seconds` default (resolved through [`lock_ttl_from_env`]
37/// when the flag is unset), and integration-test wire-format pinning
38/// all consume this constant (the management surface re-exports it
39/// from [`crate::manage::DEFAULT_LOCK_TTL_SECONDS`]) so the views of
40/// "stale" cannot drift silently.
41pub const DEFAULT_LOCK_TTL_SECONDS: u64 = 60;
42
43/// Push configuration that is constant across an entire batch.
44///
45/// Bundles the `zip`, `engine`, and `ttl` parameters so that [`push_one`]
46/// and [`perform_push_under_lock`] stay within the argument-count budget.
47struct PushConfig {
48    /// Whether to upload `repo.zip` alongside each bundle.
49    zip: bool,
50    /// Storage engine to lock into the `FORMAT` key on the first push.
51    engine: StorageEngine,
52    /// Per-ref lock TTL.
53    ttl: Duration,
54    /// Backend kind of the destination. Used by the zip-artifact path to
55    /// decide whether to attach the `codepipeline-artifact-revision-summary`
56    /// user-metadata header — that header is meaningful only on S3
57    /// (AWS `CodePipeline` consumes it) and the hyphenated key is invalid on
58    /// Azure, where it would otherwise cause the entire zip upload to fail
59    /// silently under the issue #127 best-effort swallow contract.
60    /// See issue #161.
61    kind: BackendKind,
62}
63
64/// Environment override for the lock TTL, in seconds.
65pub(crate) const ENV_LOCK_TTL_SECONDS: &str = "GIT_REMOTE_OBJECT_STORE_LOCK_TTL_SECONDS";
66
67/// Stable substring embedded in the rejection message returned when the
68/// remote ref is not an ancestor of the pushed local ref. Treated as a
69/// user-facing contract: shellspec suites assert on this token to
70/// distinguish the ancestor-mismatch failure mode from unrelated push
71/// failures (network, permission, malformed URL, ...). Reword the
72/// surrounding sentence freely; do not change this token without
73/// updating every call site, including the spec files under
74/// `spec/integration/*/force_push_spec.sh` and
75/// `spec/live/*/force_push_spec.sh`.
76pub(crate) const NOT_ANCESTOR_TOKEN: &str = "not ancestor";
77
78/// Build the canonical wire-format rejection message returned when a
79/// push is refused because the remote ref is not an ancestor of
80/// `local_spec`. Centralising the template here keeps the bundle and
81/// packchain engines in lockstep — they previously inlined byte-identical
82/// `format!` calls, and silent drift between them would have produced
83/// engine-dependent wire output the spec suites could not match against
84/// [`NOT_ANCESTOR_TOKEN`].
85pub(crate) fn not_ancestor_wire_message(local_spec: &str) -> String {
86    format!(r#""remote ref is {NOT_ANCESTOR_TOKEN} of {local_spec}."?"#)
87}
88
89/// Canonical wire-format message returned when a delete is refused
90/// because a `PROTECTED#` marker is present under the ref. Shared by
91/// the bundle engine ([`delete_remote_ref_under_lock`]) and the
92/// packchain engine ([`crate::packchain`]'s `delete_remote_ref_packchain`)
93/// so both surface identical bytes to git/clients. A single source of
94/// truth here avoids the duplicate-literal drift that a `const _` byte
95/// equality guard previously had to defend against.
96pub(crate) const DELETE_PROTECTION_MESSAGE: &str = r#""ref is protected. Run git-remote-object-store unprotect <url> <branch> to remove protection before deleting."?"#;
97
98/// Errors surfaced by the push path. These abort the helper — per-ref
99/// failures (multi-bundle, ancestor mismatch, lock contention, ...) are
100/// returned as [`PushOutcome::Error`] without aborting the batch.
101#[derive(Debug, thiserror::Error)]
102pub enum PushError {
103    /// `push <refspec>` line could not be parsed.
104    #[error("invalid push command {line:?}: expected `[+]<src>:<dst>`")]
105    Parse {
106        /// The offending line payload (after the `push ` prefix).
107        line: String,
108    },
109
110    /// Local rev-spec failed permissive ref-name validation.
111    #[error("invalid local ref-spec: {0:?}")]
112    InvalidLocalSpec(String),
113
114    /// Remote ref name is malformed.
115    #[error("invalid remote ref: {0}")]
116    RemoteRef(#[from] RefNameError),
117
118    /// SHA hex extracted from a stored bundle key was malformed.
119    #[error("invalid SHA in bundle key: {0}")]
120    Sha(#[from] ShaError),
121
122    /// Object-store transport / auth failure.
123    #[error("object-store error during push: {0}")]
124    Store(#[from] ObjectStoreError),
125
126    /// Local git operation failed (rev-parse, bundle, archive).
127    #[error("git error during push: {0}")]
128    Git(#[from] GitError),
129
130    /// Local I/O failure (tempdir, file read).
131    #[error("local I/O error during push: {0}")]
132    Io(#[from] std::io::Error),
133
134    /// Packchain-engine-specific failure surfaced by
135    /// [`crate::packchain::push::push_batch`]. Wrapped here so the
136    /// per-ref `error <ref>` arm in [`push_batch`] can render a
137    /// uniform wire line regardless of which engine produced the
138    /// error.
139    #[error("packchain engine error during push: {0}")]
140    Packchain(#[from] crate::packchain::PackchainError),
141}
142
143/// Result of a single push within a batch. Rendered to stdout by the REPL
144/// as either `ok <ref>\n` or `error <ref> <msg>\n`.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum PushOutcome {
147    /// Push succeeded. `remote_ref` echoes back to git so it can mark
148    /// the local ref as updated.
149    Ok {
150        /// The remote ref that was pushed (unparsed wire form).
151        remote_ref: String,
152    },
153    /// Push was rejected. `message` is the free-form reason rendered
154    /// after the ref name on the wire.
155    Error {
156        /// The remote ref the rejection applies to.
157        remote_ref: String,
158        /// Human-readable rejection reason.
159        message: String,
160    },
161}
162
163impl PushOutcome {
164    /// Format `self` as the single line emitted on stdout (terminator
165    /// included).
166    #[must_use]
167    pub(crate) fn to_protocol_line(&self) -> String {
168        match self {
169            Self::Ok { remote_ref } => format!("ok {remote_ref}\n"),
170            Self::Error {
171                remote_ref,
172                message,
173            } => format!("error {remote_ref} {message}\n"),
174        }
175    }
176}
177
178/// Parsed `push` command line.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub(crate) struct PushSpec {
181    /// `+` was present — the user requested a force push.
182    pub(crate) force: bool,
183    /// User-supplied local rev-spec. Empty means "delete the remote ref".
184    pub(crate) local_spec: String,
185    /// Strict, fully-qualified remote ref.
186    pub(crate) remote_ref: RefName,
187}
188
189/// Parse the payload of a `push <refspec>` line (the bytes after the
190/// `push ` prefix have already been stripped by the REPL).
191pub(crate) fn parse_push_args(args: &str) -> Result<PushSpec, PushError> {
192    let parse_err = || PushError::Parse {
193        line: args.to_owned(),
194    };
195    if args.is_empty() || args.contains(' ') {
196        return Err(parse_err());
197    }
198    let (local, remote) = args.split_once(':').ok_or_else(parse_err)?;
199    if remote.is_empty() {
200        return Err(parse_err());
201    }
202    let (force, local) = match local.strip_prefix('+') {
203        Some(rest) => (true, rest),
204        None => (false, local),
205    };
206    if !local.is_empty() && !is_valid_ref_name(local) {
207        return Err(PushError::InvalidLocalSpec(local.to_owned()));
208    }
209    let remote_ref = RefName::new(remote)?;
210    Ok(PushSpec {
211        force,
212        local_spec: local.to_owned(),
213        remote_ref,
214    })
215}
216
217/// Build the `<prefix>/<ref>/` listing prefix used by lock and bundle
218/// listings. Empty / absent prefix collapses to a bare `<ref>/`.
219///
220/// Thin typed wrapper over [`keys::ref_listing_prefix`] that takes the
221/// validated `RefName` newtype so helper-protocol call sites can't pass
222/// an unvalidated string. The shared helper is the single allocation
223/// point — `manage::doctor` and `manage::branch` reach it directly with
224/// already-validated `&str` ref paths.
225pub(crate) fn ref_listing_prefix(prefix: Option<&str>, remote_ref: &RefName) -> String {
226    keys::ref_listing_prefix(prefix, remote_ref.as_str())
227}
228
229/// Build the lock key: `<prefix>/<ref>/LOCK#.lock`.
230pub(crate) fn lock_key(prefix: Option<&str>, remote_ref: &RefName) -> String {
231    format!("{}LOCK#.lock", ref_listing_prefix(prefix, remote_ref))
232}
233
234/// Build the zip-archive key: `<prefix>/<ref>/repo.zip`.
235fn archive_key(prefix: Option<&str>, remote_ref: &RefName) -> String {
236    format!("{}repo.zip", ref_listing_prefix(prefix, remote_ref))
237}
238
239/// Build the HEAD key: `<prefix>/HEAD` (no slash when prefix is absent).
240pub(crate) fn head_key(prefix: Option<&str>) -> String {
241    keys::join(prefix, "HEAD")
242}
243
244/// Bundle-candidate filter: positive predicate — the final path segment
245/// must be `<sha>.bundle` (40 lower-hex chars + `.bundle`). All other
246/// per-ref siblings (`repo.zip`, `LOCK#.lock`, `PROTECTED#`, any
247/// hypothetical future artefact) are rejected by construction.
248///
249/// Earlier revisions filtered by full-key substring (`.zip`, `/LOCKS/`,
250/// `.lock`), which false-positively dropped real bundle keys whose ref
251/// name happened to contain those substrings — e.g.
252/// `refs/heads/v1.zip-rc1/<sha>.bundle` or
253/// `refs/heads/LOCKS-feature/x/<sha>.bundle`. A positive check on the
254/// final segment cannot misfire on ref-name content.
255fn is_bundle_candidate(key: &str) -> bool {
256    parse_remote_sha_from_key(key).is_some()
257}
258
259/// Returns every bundle object currently stored under `remote_ref`,
260/// filtered by [`is_bundle_candidate`] and excluding bundles named by
261/// any `<prefix>/gc/baseline-tomb-*.json` (issue #157). The store's
262/// listing prefix is `<prefix>/<ref>/` so sibling-ref keys don't leak
263/// in.
264///
265/// Tombstoned bundles are filtered here because the bundle engine
266/// uses the bundle-key listing as its source of truth for "ref →
267/// current SHA". A force-push that deferred the prior bundle leaves
268/// two `<sha>.bundle` keys under the ref; without the tombstone
269/// filter the next push's under-lock multi-bundle guard would refuse
270/// it. The tombstoned bundle stays readable at its original key for
271/// in-flight fetchers — only the listing path hides it.
272///
273/// Issue #165: `cached_hidden` lets the caller supply a tombstone set
274/// computed earlier in the same push (e.g. by [`prepare_push`]) and
275/// reused under the per-ref lock by [`perform_push_under_lock`]. The
276/// cached set is sound because all tombstone writers for a given ref
277/// (push's `defer_prior_bundle_via_tombstone`, compact's
278/// `tombstone_prior_baseline_bundle`, delete-branch's orphan path)
279/// run under the same per-ref lock — so no new tombstone for this
280/// ref can land between the pre-lock and under-lock calls within one
281/// `push_one` invocation. `None` keeps the original "fetch on demand"
282/// shape used by call sites that don't have a cache to share.
283async fn bundles_for_ref(
284    store: &dyn ObjectStore,
285    prefix: Option<&str>,
286    remote_ref: &RefName,
287    cached_hidden: Option<&HashSet<String>>,
288) -> Result<Vec<ObjectMeta>, ObjectStoreError> {
289    let listing = ref_listing_prefix(prefix, remote_ref);
290    let metas = store.list(&listing).await?;
291    let bundles: Vec<ObjectMeta> = metas
292        .into_iter()
293        .filter(|m| is_bundle_candidate(&m.key))
294        .collect();
295    // Short-circuit the tombstone lookup when there is nothing that
296    // could be filtered. The common case (no force-push in the last
297    // grace window) pays no extra listing cost.
298    if bundles.is_empty() {
299        return Ok(bundles);
300    }
301    // Reuse the caller-supplied set when present; otherwise fetch
302    // on demand. The owned binding outlives the borrow so a single
303    // `&HashSet` covers both paths without an `Option`/`expect` dance.
304    let fetched;
305    let hidden: &HashSet<String> = if let Some(h) = cached_hidden {
306        h
307    } else {
308        fetched = tombstoned_bundle_keys(store, prefix).await?;
309        &fetched
310    };
311    Ok(bundles
312        .into_iter()
313        .filter(|m| !hidden.contains(&m.key))
314        .collect())
315}
316
317/// Returns `true` iff a `<prefix>/<ref>/PROTECTED#` marker exists.
318///
319/// Uses an exact-key `head` rather than a prefix `list`. `ObjectStore::list`
320/// is a byte-prefix match, so a `list("…/PROTECTED#")` would also return any
321/// future `PROTECTED#`-prefixed sibling key (e.g. `PROTECTED#audit`). The
322/// equality check here matches the semantics of the canonical
323/// `is_protected_marker_segment` snapshot-side helper and avoids
324/// spuriously blocking pushes or deletes on unprotected refs.
325pub(crate) async fn is_protected(
326    store: &dyn ObjectStore,
327    prefix: Option<&str>,
328    remote_ref: &RefName,
329) -> Result<bool, ObjectStoreError> {
330    let key = protected_marker_key(prefix, remote_ref);
331    match store.head(&key).await {
332        Ok(_) => Ok(true),
333        Err(ObjectStoreError::NotFound(_)) => Ok(false),
334        Err(e) => Err(e),
335    }
336}
337
338/// Build the `<prefix>/<ref>/PROTECTED#` key. Pulled out so the
339/// pre-delete protection probe ([`is_protected`]) and the post-sweep
340/// integrity verification ([`verify_no_orphan_protected_after_delete`])
341/// share one source of truth for the marker key shape.
342pub(crate) fn protected_marker_key(prefix: Option<&str>, remote_ref: &RefName) -> String {
343    format!(
344        "{}{}",
345        ref_listing_prefix(prefix, remote_ref),
346        keys::PROTECTED_MARKER_SEGMENT,
347    )
348}
349
350/// Issue #151 defence-in-depth: after a delete-path sweep completes,
351/// confirm no `PROTECTED#` marker exists for `remote_ref`. The primary
352/// defence is the per-ref lock (#158, #159): every delete path acquires
353/// `<prefix>/<ref>/LOCK#.lock` before its under-lock listing and the
354/// sweep, and both `protect` and `unprotect` acquire the same key —
355/// so a marker landing between the under-lock listing and the sweep is
356/// mechanically impossible.
357///
358/// This helper is the belt to the lock's suspenders: if the marker is
359/// observed here, the contract was violated (a lock bypass, bucket-level
360/// inconsistency, or a misbehaving sibling tool), and we surface it as
361/// a structured `tracing::error!` so operators can investigate. The
362/// delete is NOT rolled back — the operator-visible "ref is gone"
363/// outcome stands; the orphan marker, if real, will block any future
364/// recreation of the same ref until `unprotect` removes it.
365///
366/// Uses one `head` (not a `list`) — cheap, exact-key, identical shape
367/// to [`is_protected`]. The caller passes the same `prefix` / `remote_ref`
368/// it used to compute the listing prefix so the marker key matches the
369/// sweep's namespace byte-for-byte.
370pub(crate) async fn verify_no_orphan_protected_after_delete(
371    store: &dyn ObjectStore,
372    prefix: Option<&str>,
373    remote_ref: &RefName,
374) {
375    let key = protected_marker_key(prefix, remote_ref);
376    match store.head(&key).await {
377        Ok(_) => {
378            tracing::error!(
379                key = %key,
380                remote_ref = %remote_ref.as_str(),
381                "delete path observed a PROTECTED# marker after sweep; the per-ref lock contract (#158, #159) should make this impossible — investigate for lock bypass or bucket-level inconsistency",
382            );
383        }
384        Err(ObjectStoreError::NotFound(_)) => {}
385        Err(e) => {
386            // The probe itself failed; this is not the integrity
387            // violation we are guarding against. Log at `debug!` so
388            // operators have the trail if a future incident needs it,
389            // but do not promote a transient `head` failure to an
390            // error — the delete already succeeded under the lock.
391            tracing::debug!(
392                key = %key,
393                error = %e,
394                "post-sweep PROTECTED# probe failed; cannot verify orphan-marker invariant for this delete",
395            );
396        }
397    }
398}
399
400/// Extract the SHA from a `<…>/<sha>.bundle` key. Returns `None` if the
401/// trailing segment does not match `[0-9a-f]{40}\.bundle`.
402///
403/// Charset/length validation goes through [`keys::is_valid_bundle_stem`]
404/// so the doctor's malformed-key detector and this parser agree on
405/// what counts as a well-formed stem. The defensive parse-error arm in
406/// [`prepare_push`] still calls this — although push's pre-lock listing
407/// filters malformed stems via [`is_bundle_candidate`] before reaching
408/// the arm, the guard remains in place so a future caller that bypasses
409/// the filter cannot silently treat a malformed key as a bundle.
410fn parse_remote_sha_from_key(key: &str) -> Option<Sha> {
411    let stem = parse_remote_sha_stem_from_key(key)?;
412    Sha::from_hex(stem).ok()
413}
414
415/// Extract the validated 40-lowercase-hex stem from a `<…>/<sha>.bundle`
416/// key without the `&str → Sha → String` round-trip
417/// [`parse_remote_sha_from_key`] incurs. Callers that need the schema
418/// [`Sha40`] (e.g. the tombstone writers) save one hex-encode + one
419/// re-validation by going through the borrowed stem directly.
420fn parse_remote_sha_stem_from_key(key: &str) -> Option<&str> {
421    let last = key.rsplit('/').next()?;
422    let stem = last.strip_suffix(".bundle")?;
423    if !keys::is_valid_bundle_stem(stem) {
424        return None;
425    }
426    Some(stem)
427}
428
429/// Read the lock TTL from `GIT_REMOTE_OBJECT_STORE_LOCK_TTL_SECONDS`,
430/// falling back to [`DEFAULT_LOCK_TTL_SECONDS`] if the env var is unset,
431/// unparseable, or zero. A zero TTL would make `acquire_lock` treat
432/// every held lock as instantly stale and defeat per-ref locking, so
433/// we mirror [`crate::packchain::gc::grace_hours_from_env`] and clamp
434/// it to the default.
435pub(crate) fn lock_ttl_from_env() -> Duration {
436    // Test-only: the `cfg`-gated block compiles out of release
437    // binaries (the `test-util` feature is dev-only and `cfg(test)`
438    // is set only when the crate itself is under test), so production
439    // callers pay nothing. In test builds, the read lock serialises
440    // against env-mutating tests that hold an `EnvGuard` for this
441    // key; `env_var_read_lock` returns `None` when the current thread
442    // itself holds the writer, so the env-mutating test does not
443    // deadlock on its own write lock.
444    #[cfg(any(test, feature = "test-util"))]
445    let _read = crate::test_util::env_var_read_lock(ENV_LOCK_TTL_SECONDS);
446    let secs = env::var(ENV_LOCK_TTL_SECONDS)
447        .ok()
448        .and_then(|s| s.parse::<u64>().ok())
449        .filter(|s| *s > 0)
450        .unwrap_or(DEFAULT_LOCK_TTL_SECONDS);
451    saturating_duration_seconds(secs)
452}
453
454/// Convert a `u64` seconds count to [`Duration`], saturating at
455/// [`i64::MAX`] (~292-billion-year sentinel TTL ceiling). Centralises
456/// the `i64::try_from(secs).unwrap_or(i64::MAX)` idiom that previously
457/// lived inline in both [`lock_ttl_from_env`] and
458/// [`crate::manage::compact::Compact::run_into`] (#221).
459#[must_use]
460pub(crate) fn saturating_duration_seconds(secs: u64) -> Duration {
461    Duration::seconds(i64::try_from(secs).unwrap_or(i64::MAX))
462}
463
464/// Same as [`lock_ttl_from_env`] but returns the value as `u64` seconds
465/// for callers that want the raw count rather than a `Duration`. The
466/// `expect` surfaces a future regression that loosens `lock_ttl_from_env`'s
467/// non-negative invariant instead of silently masking it.
468pub(crate) fn lock_ttl_from_env_seconds() -> u64 {
469    u64::try_from(lock_ttl_from_env().whole_seconds())
470        .expect("lock_ttl_from_env returns a non-negative seconds count")
471}
472
473/// Resolve a caller-supplied `Option<u64>` lock TTL to a concrete
474/// seconds value, applying the same zero-clamp as [`lock_ttl_from_env`].
475///
476/// Without this helper, `Compact::run_into` would accept a raw
477/// `Option<u64>` and feed `Some(0)` straight into the engine,
478/// bypassing the #112 clamp that protects against `acquire_lock`
479/// treating every held lock as instantly stale. A zero TTL defeats
480/// per-ref locking and corrupts concurrent pushes (issue #208).
481/// `Doctor::resolved_lock_ttl_seconds` deliberately does NOT route
482/// through this helper — doctor only compares lock ages and never
483/// acquires a lock, so an operator-explicit `--lock-ttl-seconds 0`
484/// is honoured as a "treat every lock as stale" request.
485///
486/// Resolution rules:
487///
488/// * `None` — defer to [`lock_ttl_from_env_seconds`] (env override or default).
489/// * `Some(0)` — defer to [`lock_ttl_from_env_seconds`] as well, so
490///   that an accidental zero from a CLI flag or default-constructed
491///   opts still picks up the operator's env override. This matches
492///   the shape of the existing env-side clamp.
493/// * `Some(n)` for `n > 0` — return `n` unchanged.
494///
495/// No upper bound is enforced: the existing env path accepts any
496/// `u64` and downstream `time::Duration::seconds` saturates at
497/// `i64::MAX` (~292 billion years), which is fine for a TTL ceiling.
498pub(crate) fn resolve_lock_ttl_seconds(opt: Option<u64>) -> u64 {
499    opt.filter(|&n| n > 0)
500        .unwrap_or_else(lock_ttl_from_env_seconds)
501}
502
503/// Handle to an acquired per-ref lock with a live heartbeat task.
504///
505/// Returned by [`acquire_lock`]. The guard owns a background tokio task
506/// that periodically re-PUTs the lock key (overwrite, not conditional)
507/// to refresh `last_modified` and keep stale-lock recovery from
508/// stealing a still-live lock from under a long-running critical
509/// section (issue #118).
510///
511/// ## Release
512///
513/// Call [`release_lock`] (or [`Self::release`]) to relinquish: this
514/// signals cooperative shutdown to the heartbeat, awaits its exit so
515/// any in-flight heartbeat PUT completes (or errors) on the server
516/// BEFORE the DELETE is issued, then deletes the lock key. Without
517/// the await-then-delete ordering, an in-flight heartbeat PUT could
518/// settle on the server after the DELETE and resurrect the lock key
519/// as an orphan (issue #150).
520///
521/// ## Drop
522///
523/// If a guard is dropped without an explicit release (panic, early
524/// return without the post-result match, etc.) the heartbeat is told
525/// to stop via the same shutdown signal and the join handle is
526/// aborted (we cannot `.await` in `Drop`). The lock key remains on
527/// the bucket and will be picked up by the next acquire attempt's
528/// stale-recovery path after `ttl` elapses (bounded by
529/// `ttl + heartbeat_interval` since the last heartbeat). Callers
530/// should still prefer explicit release so the lock is freed
531/// immediately.
532#[must_use = "lock guards must be released; dropping leaks the lock until TTL"]
533pub(crate) struct LockGuard {
534    /// Bucket key of the lock. `pub(crate)` so call sites can format
535    /// it into error messages (the old API surfaced `&str`).
536    lock_key: String,
537    /// Store handle, kept around so [`Self::release`] can delete the
538    /// key without callers re-passing it. Also given to the heartbeat
539    /// task via clone.
540    store: Arc<dyn ObjectStore>,
541    /// Cooperative shutdown signal. The heartbeat task watches this
542    /// receiver via [`tokio::select!`]: a `send(true)` wakes the loop
543    /// at any await point inside the `select!` (between ticks) AND is
544    /// checked synchronously before issuing each `put_bytes`, so no
545    /// PUT can be dispatched after [`Self::release`] has begun.
546    shutdown: watch::Sender<bool>,
547    /// Heartbeat task handle. `Some` while the guard owns a live
548    /// heartbeat; taken by release (awaited) or drop (aborted as a
549    /// fallback since `Drop` cannot await).
550    heartbeat: Option<JoinHandle<()>>,
551}
552
553/// Upper bound on how long [`LockGuard::release`] waits for the
554/// heartbeat task to exit cooperatively before falling back to abort.
555///
556/// The heartbeat task wakes from its `select!` immediately on
557/// shutdown and only issues a PUT if it had already passed the
558/// shutdown re-check, so the worst-case wait is one in-flight
559/// `put_bytes` RTT. Five seconds is well above any healthy RTT but
560/// well below any reasonable user-visible push timeout — if the
561/// network is wedged hard enough to outlast it, the fallback abort
562/// keeps `release` from hanging and the orphan key just regresses to
563/// the pre-fix behaviour (stale-lock recovery after TTL).
564const HEARTBEAT_JOIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
565
566impl LockGuard {
567    /// Release the lock: cooperatively stop the heartbeat (awaiting
568    /// the task's exit so any in-flight PUT settles first), then
569    /// delete the lock key. `NotFound` is mapped to `Ok(())` (the
570    /// heartbeat may have raced a stale-recovery delete, or an
571    /// operator may have cleared the lock manually); every other
572    /// delete failure is propagated.
573    pub(crate) async fn release(mut self) -> Result<(), ObjectStoreError> {
574        // ORDER IS LOAD-BEARING: stop the heartbeat AND wait for its
575        // join handle BEFORE deleting the lock key. The previous
576        // implementation called `handle.abort()` and then DELETE
577        // synchronously, but `abort()` only cancels the future at its
578        // next await point — a `put_bytes` already in flight on the
579        // server completes regardless of cancellation and can settle
580        // AFTER our DELETE, resurrecting the lock key as an orphan
581        // (issue #150). Cooperative shutdown via `watch` + join-await
582        // makes the worst case "one in-flight PUT completes before
583        // DELETE" instead of "one in-flight PUT completes after
584        // DELETE".
585        self.stop_heartbeat().await;
586        delete_idempotent(self.store.as_ref(), &self.lock_key).await
587    }
588
589    /// Signal cooperative shutdown to the heartbeat task and await
590    /// its exit (bounded by [`HEARTBEAT_JOIN_TIMEOUT`]). On timeout
591    /// the handle is `abort()`-ed, restoring the pre-#150 behaviour
592    /// for the pathological case of a `put_bytes` hung past the
593    /// timeout.
594    async fn stop_heartbeat(&mut self) {
595        // `send` only fails when there are no receivers — i.e. the
596        // heartbeat task has already exited. Either way, there's no
597        // PUT we can still prevent, so we ignore the result.
598        let _ = self.shutdown.send(true);
599        let Some(mut handle) = self.heartbeat.take() else {
600            return;
601        };
602        // Borrow the handle into `timeout` so we can still call
603        // `abort()` on it after the wait fails.
604        if tokio::time::timeout(HEARTBEAT_JOIN_TIMEOUT, &mut handle)
605            .await
606            .is_err()
607        {
608            warn!(
609                key = %self.lock_key,
610                timeout_secs = HEARTBEAT_JOIN_TIMEOUT.as_secs(),
611                "lock heartbeat did not exit within join timeout; \
612                 falling back to abort (in-flight PUT may still race \
613                 the upcoming DELETE)",
614            );
615            handle.abort();
616        }
617    }
618}
619
620impl Drop for LockGuard {
621    fn drop(&mut self) {
622        // `Drop` cannot `.await`, so we cannot mirror the release
623        // path's "wait for in-flight PUT to settle" guarantee here.
624        // The best we can do is (a) signal shutdown so the heartbeat
625        // task exits at its next await point, and (b) abort the
626        // JoinHandle so a future PUT issued while we were dropping
627        // doesn't keep racing forever. The lock key may briefly
628        // outlive the holder; the stale-lock recovery path
629        // (`acquire_lock`) reclaims it after TTL.
630        let _ = self.shutdown.send(true);
631        if let Some(handle) = self.heartbeat.take() {
632            handle.abort();
633        }
634    }
635}
636
637/// Heartbeat interval: `ttl/3`, floored at one second so a pathological
638/// sub-second TTL doesn't busy-loop. We use `ttl/3` rather than `ttl/2`
639/// so a single missed heartbeat (transient network blip with
640/// `MissedTickBehavior::Delay`) still leaves margin before `age > ttl`:
641/// two consecutive misses are needed to push the lock past the
642/// staleness threshold (#118 follow-up).
643pub(crate) fn heartbeat_interval(ttl: Duration) -> std::time::Duration {
644    let secs = ttl.whole_seconds().max(3) / 3;
645    // `try_from` rather than `as`: we just clamped to ≥ 1, but
646    // `clippy::cast_sign_loss` insists on the explicit fallible cast.
647    let secs_u64 =
648        u64::try_from(secs).expect("ttl.whole_seconds().max(3) / 3 is always >= 1 (non-negative)");
649    std::time::Duration::from_secs(secs_u64)
650}
651
652/// Try to acquire the per-ref lock. Returns `Ok(Some(guard))` when the
653/// lock was taken (heartbeat is already running) and `Ok(None)` on
654/// contention (caller should surface a "lock held" error). On a stale
655/// lock (older than `ttl`, with no heartbeat from a live holder), the
656/// lock is deleted and the conditional `put_if_absent` is retried
657/// once.
658///
659/// The race window between `head` and the retry `put_if_absent` is
660/// inherent to non-conditional deletes — another client could acquire
661/// the lock between our delete and retry. We accept that race; the
662/// retry `put_if_absent` will return `Ok(false)` and the user will
663/// retry.
664///
665/// Heartbeat semantics (issue #118): a live holder refreshes the lock
666/// every `ttl/3` so the staleness check correctly excludes locks held
667/// by an in-flight critical section. A long-running [`compact`] no
668/// longer races a concurrent writer that wakes up after the original
669/// TTL elapses.
670///
671/// [`compact`]: crate::packchain::compact::compact
672pub(crate) async fn acquire_lock(
673    store: Arc<dyn ObjectStore>,
674    lock_key: &str,
675    ttl: Duration,
676    now: OffsetDateTime,
677) -> Result<Option<LockGuard>, ObjectStoreError> {
678    if store.put_if_absent(lock_key, Bytes::new()).await? {
679        return Ok(Some(spawn_lock_guard(store, lock_key.to_owned(), ttl)));
680    }
681    let meta = match store.head(lock_key).await {
682        Ok(m) => m,
683        // Lock vanished between put_if_absent and head — another client
684        // released it. Treat as contention; user retries.
685        Err(ObjectStoreError::NotFound(_)) => return Ok(None),
686        Err(e) => return Err(e),
687    };
688    let age = now - meta.last_modified;
689    if age <= ttl {
690        return Ok(None);
691    }
692    debug!(key = %lock_key, age_secs = age.whole_seconds(), "deleting stale lock");
693    delete_idempotent(store.as_ref(), lock_key).await?;
694    if store.put_if_absent(lock_key, Bytes::new()).await? {
695        Ok(Some(spawn_lock_guard(store, lock_key.to_owned(), ttl)))
696    } else {
697        Ok(None)
698    }
699}
700
701/// Build a [`LockGuard`] and spawn its heartbeat task. The heartbeat
702/// re-PUTs the lock key every `heartbeat_interval(ttl)` (overwrite, not
703/// conditional) so the stale-lock recovery branch in [`acquire_lock`]
704/// correctly excludes still-held locks. Heartbeat failures are logged
705/// at `warn` and retried on the next tick — a transient blip should
706/// not surface as a critical-section-aborting error.
707///
708/// The task watches a [`watch::Receiver<bool>`]: `release` flips it to
709/// `true` and the loop exits at the next `select!` await point.
710/// Critically, the loop also re-checks the flag synchronously after
711/// waking from the tick and BEFORE issuing `put_bytes`, so once
712/// `release` has signalled shutdown no further PUT can be dispatched
713/// — closing the issue #150 race where an `abort()`-cancelled task
714/// had an in-flight `put_bytes` that settled on the server after
715/// `release`'s DELETE.
716fn spawn_lock_guard(store: Arc<dyn ObjectStore>, lock_key: String, ttl: Duration) -> LockGuard {
717    let interval = heartbeat_interval(ttl);
718    let task_store = Arc::clone(&store);
719    let task_key = lock_key.clone();
720    let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
721    let handle = tokio::spawn(async move {
722        let mut tick = tokio::time::interval(interval);
723        // Skip the immediate first tick — the acquire just wrote the
724        // key, so a refresh in the same millisecond would be wasted
725        // bandwidth.
726        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
727        tick.tick().await; // immediate
728        loop {
729            tokio::select! {
730                // `biased` so shutdown is checked first on every
731                // wake: a tick and a shutdown that fire on the same
732                // poll must resolve to shutdown, otherwise the tick
733                // arm could win and issue one final PUT after release
734                // has begun.
735                biased;
736                _ = shutdown_rx.changed() => break,
737                _ = tick.tick() => {}
738            }
739            // Re-check the shutdown flag synchronously between the
740            // tick and the `put_bytes` await. Without this, a
741            // `release` that signals shutdown AFTER `tick.tick()`
742            // resolved but BEFORE we issued `put_bytes` would still
743            // let the PUT through and race the DELETE — exactly the
744            // issue #150 failure mode the `select!` alone does not
745            // close.
746            if *shutdown_rx.borrow() {
747                break;
748            }
749            match task_store
750                .put_bytes(&task_key, Bytes::new(), PutOpts::default())
751                .await
752            {
753                Ok(()) => debug!(key = %task_key, "lock heartbeat refreshed"),
754                Err(e) => warn!(
755                    key = %task_key,
756                    error = %e,
757                    "lock heartbeat refresh failed; will retry",
758                ),
759            }
760        }
761    });
762    LockGuard {
763        lock_key,
764        store,
765        shutdown: shutdown_tx,
766        heartbeat: Some(handle),
767    }
768}
769
770/// Release a previously acquired per-ref lock. `NotFound` is mapped to
771/// `Ok(())` (another client or the TTL may have already cleaned it up);
772/// every other delete failure is propagated so the caller can surface it.
773///
774/// Heartbeat semantics: the guard's heartbeat task is aborted before
775/// the delete so a heartbeat refresh in flight cannot re-create the
776/// key after we've removed it.
777pub(crate) async fn release_lock(guard: LockGuard) -> Result<(), ObjectStoreError> {
778    guard.release().await
779}
780
781/// Idempotent delete: treats `NotFound` as success (another client may
782/// have raced ahead) but propagates every other error.
783pub(crate) async fn delete_idempotent(
784    store: &dyn ObjectStore,
785    key: &str,
786) -> Result<(), ObjectStoreError> {
787    match store.delete(key).await {
788        Ok(()) | Err(ObjectStoreError::NotFound(_)) => Ok(()),
789        Err(e) => Err(e),
790    }
791}
792
793/// Best-effort delete of the prior bundle once the new bundle is durable.
794///
795/// Issue #121: `perform_push_under_lock` writes the new bundle first
796/// (the durable commit) and then deletes the previous bundle. A
797/// non-`NotFound` error on the delete must NOT fail the push — the new
798/// bundle is already on the bucket, so reporting failure to the user
799/// is a lie about the remote state. The two-bundle state that results
800/// from the orphan trips the under-lock "multiple bundles" guard on
801/// the next push, which directs the operator to `doctor`; the warn
802/// log gives them the orphan key directly.
803///
804/// Mirrors `force_push_baseline_cleanup` in `packchain::push`
805/// (issue #113).
806///
807/// Issue #157: this is now the fallback path used only when the
808/// preferred deferred-via-tombstone path
809/// ([`defer_prior_bundle_via_tombstone`]) cannot be taken — namely a
810/// prior key whose stem does not parse as a [`Sha40`]. The on-bucket
811/// layout filter ([`is_bundle_candidate`]) already rejects malformed
812/// stems before they reach the push path, so this branch is reachable
813/// only via a future schema loosening or manual bucket tampering.
814async fn delete_prior_bundle_best_effort(
815    store: &dyn ObjectStore,
816    remote_ref: &RefName,
817    prior_key: &str,
818) {
819    if let Err(e) = delete_idempotent(store, prior_key).await {
820        warn!(
821            ref_path = %remote_ref.as_str(),
822            key = %prior_key,
823            error = %e,
824            "prior-bundle cleanup failed (new bundle already committed); \
825             orphan key left for manual cleanup",
826        );
827    }
828}
829
830/// Defer the prior-bundle delete to `gc sweep` by writing a baseline
831/// tombstone naming the old SHA.
832///
833/// Issue #157: synchronously deleting the prior bundle from the push
834/// path created a race against in-flight fetches. A client that ran
835/// `list` and saw `refs/heads/main -> old_sha` then issued
836/// `fetch old_sha refs/heads/main` against the EXACT
837/// `<prefix>/<ref>/<old_sha>.bundle` path — [`fetch_one`] does not
838/// re-list or consult HEAD. If a concurrent force-push synchronously
839/// deleted that key between the `list` advertisement and the fetcher's
840/// GET, fetch failed with `NotFound`.
841///
842/// The fix mirrors the packchain force-push tombstone path (issue
843/// #134, `force_push_baseline_cleanup` in `packchain::push`): write a
844/// `<prefix>/gc/baseline-tomb-*.json` record naming the prior SHA, and
845/// let `gc sweep` reclaim the bundle after the configured grace
846/// window. Until grace expires the prior bundle remains readable, so
847/// any fetcher that advertised it from a stale list can still
848/// complete.
849///
850/// The tombstone format and key namespace are shared with the
851/// packchain engine because `gc sweep` is engine-agnostic at the
852/// reclamation layer: [`sweep_one_baseline_tombstone`] derives the
853/// bundle key from `(ref_name, sha)` and runs the same live-state
854/// recheck regardless of which engine wrote the tombstone.
855///
856/// **Fallback semantics on tombstone write failure**: `gc sweep` only
857/// reclaims keys it has a tombstone for, so a tombstone PUT failure
858/// would otherwise orphan the prior bundle indefinitely. To preserve
859/// the issue #121 "two-bundle state surfaces via the doctor message"
860/// recovery path, this helper falls back to the synchronous
861/// best-effort delete from [`delete_prior_bundle_best_effort`] on
862/// either:
863///
864/// - a prior key whose stem does not parse as a [`Sha40`] (no way to
865///   name it in the tombstone body), or
866/// - a [`write_baseline_tombstone_best_effort`] PUT failure (the
867///   helper already logged a warn naming the orphan).
868async fn defer_prior_bundle_via_tombstone(
869    store: &dyn ObjectStore,
870    prefix: Option<&str>,
871    remote_ref: &RefName,
872    prior_key: &str,
873    local_sha: Sha,
874) {
875    let Some(prior_stem) = parse_remote_sha_stem_from_key(prior_key) else {
876        warn!(
877            ref_path = %remote_ref.as_str(),
878            key = %prior_key,
879            "prior bundle key does not parse as a valid SHA; falling back to synchronous delete",
880        );
881        delete_prior_bundle_best_effort(store, remote_ref, prior_key).await;
882        return;
883    };
884    // `parse_remote_sha_stem_from_key` already validated the stem
885    // against `is_valid_bundle_stem` (40 lowercase hex), so
886    // `Sha40::try_new(prior_stem)` is infallible in practice; surface
887    // a failure as the synchronous-delete fallback rather than
888    // panicking — a future tightening of `Sha40` validation must not
889    // crash the push. `local_sha` still routes through `to_string`
890    // because `Sha` (gix `ObjectId`) only exposes a binary form.
891    let (Ok(prior_sha40), Ok(local_sha40)) = (
892        Sha40::try_new(prior_stem),
893        Sha40::try_new(local_sha.to_string()),
894    ) else {
895        warn!(
896            ref_path = %remote_ref.as_str(),
897            key = %prior_key,
898            "prior or local sha failed Sha40 validation; falling back to synchronous delete",
899        );
900        delete_prior_bundle_best_effort(store, remote_ref, prior_key).await;
901        return;
902    };
903    let wrote = write_baseline_tombstone_best_effort(
904        store,
905        prefix,
906        remote_ref,
907        &prior_sha40,
908        &local_sha40,
909        "bundle-engine-force-push",
910    )
911    .await;
912    if wrote {
913        debug!(
914            ref_path = %remote_ref.as_str(),
915            key = %prior_key,
916            "prior bundle deferred to gc sweep via baseline tombstone",
917        );
918    } else {
919        // `write_baseline_tombstone_best_effort` already logged a warn
920        // naming the orphan key. Fall back to the synchronous delete
921        // so the next push's multi-bundle guard does not flag this
922        // case to the operator unnecessarily.
923        delete_prior_bundle_best_effort(store, remote_ref, prior_key).await;
924    }
925}
926
927/// Best-effort upload of the optional zip artifact once the new bundle
928/// is durable.
929///
930/// Issue #127: `perform_push_under_lock` writes the new bundle, `HEAD`,
931/// and `FORMAT` (the git-protocol contract for a successful push) and
932/// then uploads an optional `repo.zip` CodePipeline-side convenience
933/// artifact. A non-`NotFound` error on the zip upload must NOT fail
934/// the push — the bundle is already durable, so reporting failure is
935/// a lie about the remote state. The next push re-puts the zip at
936/// the same key (idempotent), so an operator who notices the warn log
937/// or wants the artifact re-uploaded simply re-pushes.
938///
939/// Mirrors [`delete_prior_bundle_best_effort`] (issue #121) and
940/// `force_push_baseline_cleanup` in `packchain::push` (issue #113):
941/// log at warn with `ref_path`, `key`, and `error`, and return
942/// without propagating.
943async fn upload_zip_artifact_best_effort(
944    store: &dyn ObjectStore,
945    remote_ref: &RefName,
946    zip_dest: &str,
947    archive_path: &Path,
948    opts: PutOpts,
949) {
950    if let Err(e) = store.put_path(zip_dest, archive_path, opts).await {
951        warn!(
952            ref_path = %remote_ref.as_str(),
953            key = %zip_dest,
954            error = %e,
955            "zip artifact upload failed (bundle already committed); \
956             retry the push to re-upload the zip at the same key",
957        );
958    }
959}
960
961/// Drive a batch of `push` commands sequentially.
962///
963/// Each command is parsed, executed under its own per-ref lock, and
964/// produces one [`PushOutcome`]. Catastrophic errors (transport,
965/// malformed protocol input) abort the batch and bubble out as
966/// [`PushError`]; per-ref failures are encoded as
967/// [`PushOutcome::Error`] and the batch continues.
968pub(crate) async fn push_batch(
969    ctx: &super::BatchCtx,
970    kind: BackendKind,
971    zip: bool,
972    engine: StorageEngine,
973    cmds: Vec<String>,
974) -> Result<Vec<PushOutcome>, PushError> {
975    if cmds.is_empty() {
976        return Ok(Vec::new());
977    }
978    debug!(count = cmds.len(), "processing push batch");
979
980    let config = PushConfig {
981        zip,
982        engine,
983        ttl: lock_ttl_from_env(),
984        kind,
985    };
986    let mut outcomes = Vec::with_capacity(cmds.len());
987
988    for cmd in cmds {
989        // `parse_push_args` failures are catastrophic: a malformed `push`
990        // line means we cannot trust subsequent commands. Abort the batch.
991        let spec = parse_push_args(&cmd)?;
992        // Capture the ref name before `push_one` consumes the spec so we
993        // can still render an `error <ref> ...` line if the call fails.
994        let remote_ref_str = spec.remote_ref.as_str().to_owned();
995        let outcome = match push_one(
996            Arc::clone(&ctx.store),
997            ctx.prefix.as_deref(),
998            ctx.repo_dir.as_path(),
999            &config,
1000            OffsetDateTime::now_utc(),
1001            spec,
1002        )
1003        .await
1004        {
1005            Ok(o) => o,
1006            // Per-push operational failures (transport, local git, local I/O,
1007            // malformed remote bundle SHA) become `error <ref>` lines so the
1008            // batch can continue. Without this, a single 5xx blip in the
1009            // middle of a multi-ref push would silently drop the outcome
1010            // lines for already-completed pushes and leave git's local
1011            // ref-tracking inconsistent with the remote.
1012            Err(e)
1013                if matches!(
1014                    e,
1015                    PushError::Store(_) | PushError::Git(_) | PushError::Io(_) | PushError::Sha(_)
1016                ) =>
1017            {
1018                let chain = full_error_chain(&e);
1019                warn!(ref = %remote_ref_str, error = %chain, "push ref failed");
1020                PushOutcome::Error {
1021                    remote_ref: remote_ref_str,
1022                    message: format!(r#""{chain}"?"#),
1023                }
1024            }
1025            Err(e) => return Err(e),
1026        };
1027        outcomes.push(outcome);
1028    }
1029    Ok(outcomes)
1030}
1031
1032/// Render a [`PushError`] as a colon-separated chain so the
1033/// `error <ref>` wire line and the stderr log carry every level of
1034/// causal context.
1035///
1036/// `PushError`'s `thiserror` formats inline the immediate source via
1037/// `{0}` / `{source}`, and some of those sources (notably
1038/// [`ObjectStoreError::Network`], whose own Display embeds its boxed
1039/// inner error) themselves inline a further level — so a naive
1040/// chain-walk would produce `"object-store error during push: network
1041/// error: dns failure: dns failure"`. [`super::append_source_chain`]
1042/// dedups any level whose text is already at the tail of `msg`, so
1043/// the rendered chain is uniform across `Store`, `Git`, `Io`, and `Sha`
1044/// variants without per-variant special-casing.
1045fn full_error_chain(err: &PushError) -> String {
1046    let mut msg = err.to_string();
1047    super::append_source_chain(&mut msg, err);
1048    msg
1049}
1050
1051/// Recoverable per-push errors discovered while talking to the local
1052/// repo. Mapped by the caller into [`PushOutcome::Error`] strings.
1053enum GitProbeError {
1054    /// `local_spec` did not resolve in the local repo.
1055    LocalRefNotFound,
1056    /// Pre-existing remote bundle is not an ancestor of `local_sha`.
1057    NotAncestor,
1058}
1059
1060/// Local git work that must run synchronously because `gix::Repository`
1061/// is `!Sync` and cannot cross `.await` points without making the
1062/// surrounding future `!Send`.
1063struct LocalGit {
1064    /// Resolved commit OID for the user's `local_spec`.
1065    local_sha: Sha,
1066    /// Working directory passed to `bundle::create`.
1067    cwd: PathBuf,
1068    /// On the zip path: archive on disk + metadata for the upload.
1069    /// `None` on the regular push path. The `TempDir` keeps the file
1070    /// alive until the async caller reads its bytes.
1071    zip_artifacts: Option<ZipArtifacts>,
1072    /// Whether the pre-lock-listed remote bundle's SHA was an ancestor
1073    /// of `local_sha`. `true` when there was no pre-existing bundle.
1074    /// Stashed so [`perform_push_under_lock`] can decide, under the
1075    /// per-ref lock, whether a force-push against a now-`PROTECTED#`
1076    /// ref is a safe fast-forward (issue #129).
1077    pre_existing_was_ancestor: bool,
1078}
1079
1080struct ZipArtifacts {
1081    archive_path: PathBuf,
1082    short_sha: String,
1083    commit_msg: String,
1084    /// Owned tempdir that backs `archive_path`; dropped after upload.
1085    _tempdir: tempfile::TempDir,
1086}
1087
1088/// All state computed before the per-ref lock is acquired.
1089///
1090/// Passed to [`perform_push_under_lock`] so the argument count stays within
1091/// the clippy budget and the two phases of [`push_one`] have a clean
1092/// boundary: pre-lock work (protect check, bundle listing, local git,
1093/// bundle creation) vs. under-lock work (re-list, upload, HEAD/FORMAT
1094/// init, old-bundle deletion).
1095struct PushReadyState {
1096    remote_ref: RefName,
1097    local_sha: Sha,
1098    /// Key of the pre-existing bundle (if any). Checked inside the lock
1099    /// to detect concurrent pushes (stale-remote guard).
1100    pre_existing: Option<String>,
1101    bundle_path: PathBuf,
1102    zip_artifacts: Option<ZipArtifacts>,
1103    engine: StorageEngine,
1104    /// User's `--force` intent. Carried into the lock window so the
1105    /// `PROTECTED#` check can run *under* the lock and close the TOCTOU
1106    /// window between the pre-lock check and the lock acquisition
1107    /// (issue #129).
1108    force: bool,
1109    /// Whether the pre-lock-listed bundle's SHA was an ancestor of
1110    /// `local_sha` (always `true` when there was no pre-existing
1111    /// bundle). Used together with `force` and the under-lock
1112    /// protection re-check to decide whether to reject a force-push
1113    /// that became protected mid-flight.
1114    pre_existing_was_ancestor: bool,
1115    /// Original local refspec, kept so the under-lock `NotAncestor` error
1116    /// message renders the same text the pre-lock arm would have used.
1117    local_spec: String,
1118    /// Issue #165: tombstone set captured during [`prepare_push`] so the
1119    /// under-lock [`bundles_for_ref`] call inside
1120    /// [`perform_push_under_lock`] does not re-list `<prefix>/gc/` and
1121    /// re-fetch every baseline tombstone. Sound because all tombstone
1122    /// writers for this ref serialize through the same per-ref lock —
1123    /// no new tombstone for this ref can land in the pre-lock /
1124    /// under-lock window.
1125    hidden_bundles: HashSet<String>,
1126    /// Keeps the temp directory (and `bundle_path`) alive until the bundle
1127    /// is uploaded inside `perform_push_under_lock`.
1128    _temp_dir: tempfile::TempDir,
1129}
1130
1131/// Outcome of [`prepare_push`]: one of three paths into [`push_one`]'s
1132/// lock window.
1133///
1134/// - [`PrepareOutcome::Ready`] — pre-lock work completed; caller must
1135///   acquire the per-ref lock and call [`perform_push_under_lock`].
1136/// - [`PrepareOutcome::Delete`] — delete refspec (`:<ref>`); caller must
1137///   acquire the per-ref lock and call [`delete_remote_ref_under_lock`].
1138///   Issue #133: the listing and sweep MUST run under the lock so a
1139///   concurrent push that lands a new bundle cannot turn the delete
1140///   into a silent false success.
1141/// - [`PrepareOutcome::Done`] — outcome already decided pre-lock
1142///   (multi-bundle corruption, ancestry failure, …); caller returns it
1143///   directly without taking the lock.
1144enum PrepareOutcome {
1145    // `PushReadyState` is the largest variant (paths, temp dir guard,
1146    // hidden-bundle hash set added in #165); boxing keeps the enum's
1147    // discriminant compact regardless of variant.
1148    Ready(Box<PushReadyState>),
1149    Delete { remote_ref: RefName },
1150    Done(PushOutcome),
1151}
1152
1153/// Open the repo, resolve `local_sha`, compute ancestry of the
1154/// pre-existing remote bundle, and (for the zip variant) build the
1155/// archive synchronously. The `Repository` handle is dropped before
1156/// this returns so the caller's `Future` can stay `Send`. Archive
1157/// bytes are NOT read here — the async caller does that with
1158/// `tokio::fs::read` to avoid blocking the runtime on file I/O.
1159///
1160/// Ancestry is always computed and returned via
1161/// [`LocalGit::pre_existing_was_ancestor`]; this function only emits
1162/// [`GitProbeError::NotAncestor`] for the non-force case. Force pushes
1163/// defer the ancestry-vs-protection decision to
1164/// [`perform_push_under_lock`] so a concurrent `protect` cannot race
1165/// the pre-lock check (issue #129).
1166fn local_git_work(
1167    repo_dir: &Path,
1168    local_spec: &str,
1169    pre_existing_sha: Option<Sha>,
1170    force_push: bool,
1171    zip: bool,
1172) -> Result<Result<LocalGit, GitProbeError>, GitError> {
1173    let repo = gix::open(repo_dir)?;
1174    let cwd = repo.workdir().unwrap_or_else(|| repo.git_dir()).to_owned();
1175
1176    let Ok(local_sha) = git::branch::resolve(&repo, local_spec) else {
1177        return Ok(Err(GitProbeError::LocalRefNotFound));
1178    };
1179
1180    let pre_existing_was_ancestor = match pre_existing_sha {
1181        None => true,
1182        Some(remote_sha) => git::is_ancestor(&repo, remote_sha, local_sha)?,
1183    };
1184    if !force_push && !pre_existing_was_ancestor {
1185        return Ok(Err(GitProbeError::NotAncestor));
1186    }
1187
1188    let zip_artifacts = if zip {
1189        let tempdir = tempfile::Builder::new()
1190            .prefix("git_remote_object_store_archive_")
1191            .tempdir()?;
1192        let archive_path = git::archive(&repo, tempdir.path(), local_spec)?;
1193        let commit_msg = git::last_commit_message(&repo).unwrap_or_default();
1194        let sha_hex = local_sha.to_string();
1195        let short_sha = sha_hex[..8].to_owned();
1196        Some(ZipArtifacts {
1197            archive_path,
1198            short_sha,
1199            commit_msg,
1200            _tempdir: tempdir,
1201        })
1202    } else {
1203        None
1204    };
1205
1206    drop(repo);
1207    Ok(Ok(LocalGit {
1208        local_sha,
1209        cwd,
1210        zip_artifacts,
1211        pre_existing_was_ancestor,
1212    }))
1213}
1214
1215/// Perform all pre-lock work for a push: resolve the local ref, check
1216/// ancestry, list the remote bundles, and create the local bundle file.
1217/// Returns [`PrepareOutcome::Done`] for cases that are already resolved
1218/// (delete-refspec, protection check, multiple-bundle error, ancestry
1219/// failure) or [`PrepareOutcome::Ready`] when the caller should proceed
1220/// to acquire the per-ref lock and call [`perform_push_under_lock`].
1221async fn prepare_push(
1222    store: &dyn ObjectStore,
1223    prefix: Option<&str>,
1224    repo_dir: &Path,
1225    config: &PushConfig,
1226    spec: PushSpec,
1227) -> Result<PrepareOutcome, PushError> {
1228    let PushSpec {
1229        force,
1230        local_spec,
1231        remote_ref,
1232    } = spec;
1233    let remote_ref_str = remote_ref.as_str().to_owned();
1234
1235    if local_spec.is_empty() {
1236        // Issue #133: do NOT list / sweep here. The bundle engine's
1237        // delete must run INSIDE the per-ref lock so a concurrent push
1238        // landing a new bundle between our listing and our deletion
1239        // cannot produce a silent false success. Defer to
1240        // [`delete_remote_ref_under_lock`] in [`push_one`].
1241        return Ok(PrepareOutcome::Delete { remote_ref });
1242    }
1243
1244    // Issue #129: do NOT call `is_protected` here. A pre-lock check
1245    // races against a concurrent `protect`: the check could pass and a
1246    // `PROTECTED#` marker could land before we acquire the per-ref
1247    // lock, letting a force-push overwrite a now-protected ref. The
1248    // check is performed *under* the lock in
1249    // [`perform_push_under_lock`] instead. Pre-lock we just respect the
1250    // user's `--force` intent; the `local_git_work` probe still returns
1251    // ancestry info via [`LocalGit::pre_existing_was_ancestor`] so the
1252    // under-lock arm can render the same NotAncestor message a
1253    // protection-demoted non-force push would have produced.
1254    let force_push = force;
1255    debug!(local = %local_spec, remote = %remote_ref, force_push, "push");
1256
1257    // Issue #165: compute the tombstone set once here and reuse it under
1258    // the per-ref lock in [`perform_push_under_lock`]. The lock ensures
1259    // no concurrent writer can publish a tombstone for this ref between
1260    // the two `bundles_for_ref` calls. The cached set is unused on the
1261    // multi-bundle / delete / parse-error early-return paths — a wasted
1262    // listing — but each path already terminates the push, so the
1263    // ~one-call overhead is bounded.
1264    let hidden_bundles = tombstoned_bundle_keys(store, prefix).await?;
1265    let pre_bundles = bundles_for_ref(store, prefix, &remote_ref, Some(&hidden_bundles)).await?;
1266    if pre_bundles.len() > 1 {
1267        return Ok(PrepareOutcome::Done(PushOutcome::Error {
1268            remote_ref: remote_ref_str,
1269            message:
1270                r#""multiple bundles exist on server. Run git-remote-object-store doctor to fix."?"#
1271                    .to_owned(),
1272        }));
1273    }
1274    let pre_existing = pre_bundles.into_iter().next().map(|m| m.key);
1275
1276    let pre_existing_sha = match pre_existing.as_deref() {
1277        None => None,
1278        Some(key) => {
1279            let Some(s) = parse_remote_sha_from_key(key) else {
1280                return Ok(PrepareOutcome::Done(PushOutcome::Error {
1281                    remote_ref: remote_ref_str,
1282                    message: format!(
1283                        r#""unable to parse remote bundle key {key:?}; run git-remote-object-store doctor to fix."?"#,
1284                    ),
1285                }));
1286            };
1287            Some(s)
1288        }
1289    };
1290
1291    // Sync gix work (rev-parse / ancestor / archive) runs in a
1292    // dedicated scope so the !Sync `Repository` is dropped before any
1293    // .await — keeps `push_batch`'s future `Send`.
1294    let probe = local_git_work(
1295        repo_dir,
1296        &local_spec,
1297        pre_existing_sha,
1298        force_push,
1299        config.zip,
1300    )?;
1301    let local = match probe {
1302        Ok(local) => local,
1303        Err(GitProbeError::LocalRefNotFound) => {
1304            return Ok(PrepareOutcome::Done(PushOutcome::Error {
1305                remote_ref: remote_ref_str,
1306                message: format!(r#""{local_spec} not found"?"#),
1307            }));
1308        }
1309        Err(GitProbeError::NotAncestor) => {
1310            return Ok(PrepareOutcome::Done(PushOutcome::Error {
1311                remote_ref: remote_ref_str,
1312                message: not_ancestor_wire_message(&local_spec),
1313            }));
1314        }
1315    };
1316
1317    let temp_dir = tempfile::Builder::new()
1318        .prefix("git_remote_object_store_push_")
1319        .tempdir()?;
1320    let bundle_path =
1321        git::bundle_at(&local.cwd, temp_dir.path(), local.local_sha, &local_spec).await?;
1322
1323    Ok(PrepareOutcome::Ready(Box::new(PushReadyState {
1324        remote_ref,
1325        local_sha: local.local_sha,
1326        pre_existing,
1327        bundle_path,
1328        zip_artifacts: local.zip_artifacts,
1329        engine: config.engine,
1330        force,
1331        pre_existing_was_ancestor: local.pre_existing_was_ancestor,
1332        local_spec,
1333        hidden_bundles,
1334        _temp_dir: temp_dir,
1335    })))
1336}
1337
1338/// Execute one push or delete: prepare, lock, do work, release.
1339///
1340/// Both the upload path ([`perform_push_under_lock`]) and the delete
1341/// path ([`delete_remote_ref_under_lock`]) run inside the SAME per-ref
1342/// lock window with identical acquire/release accounting:
1343///
1344/// - `acquire_lock` returning `None` → emit the standard
1345///   "failed to acquire ref lock" wire error without doing work.
1346/// - `release_lock` failing AFTER successful work → downgrade the
1347///   outcome to an `Error` so the operator is alerted to a potentially
1348///   dangling lock.
1349/// - A genuine work error takes priority over a release failure — the
1350///   release error is logged but never masks the original failure.
1351///
1352/// Issue #133: putting the delete path under this same window closes
1353/// the race where a concurrent push could land a new bundle between a
1354/// pre-lock listing and a pre-lock sweep, producing a silent false
1355/// success.
1356async fn push_one(
1357    store: Arc<dyn ObjectStore>,
1358    prefix: Option<&str>,
1359    repo_dir: &Path,
1360    config: &PushConfig,
1361    now: OffsetDateTime,
1362    spec: PushSpec,
1363) -> Result<PushOutcome, PushError> {
1364    let (remote_ref_str, work): (String, UnderLockWork) =
1365        match prepare_push(store.as_ref(), prefix, repo_dir, config, spec).await? {
1366            PrepareOutcome::Done(o) => return Ok(o),
1367            PrepareOutcome::Ready(state) => (
1368                state.remote_ref.as_str().to_owned(),
1369                // `state` is already `Box<PushReadyState>` (`PrepareOutcome::Ready`
1370                // is boxed to keep the enum's largest variant compact); reuse the
1371                // existing allocation instead of re-boxing.
1372                UnderLockWork::Push(state),
1373            ),
1374            PrepareOutcome::Delete { remote_ref } => (
1375                remote_ref.as_str().to_owned(),
1376                UnderLockWork::Delete { remote_ref },
1377            ),
1378        };
1379
1380    let lock = match &work {
1381        UnderLockWork::Push(state) => lock_key(prefix, &state.remote_ref),
1382        UnderLockWork::Delete { remote_ref, .. } => lock_key(prefix, remote_ref),
1383    };
1384
1385    let Some(guard) = acquire_lock(Arc::clone(&store), &lock, config.ttl, now).await? else {
1386        return Ok(PushOutcome::Error {
1387            remote_ref: remote_ref_str,
1388            message: format!(
1389                // `push_one` covers BOTH the Push and Delete arms under the
1390                // same per-ref lock, so the contention message names both
1391                // — mirroring the packchain delete path's wording. A
1392                // delete-arm caller previously saw a misleading
1393                // "may be pushing" hint here.
1394                r#""failed to acquire ref lock at {lock}. Another client may be pushing or deleting. If this persists beyond {}s, run git-remote-object-store doctor to inspect and optionally clear stale locks."?"#,
1395                config.ttl.whole_seconds(),
1396            ),
1397        });
1398    };
1399
1400    let result = match work {
1401        UnderLockWork::Push(state) => {
1402            perform_push_under_lock(store.as_ref(), prefix, config.kind, *state).await
1403        }
1404        UnderLockWork::Delete { remote_ref } => {
1405            delete_remote_ref_under_lock(store.as_ref(), prefix, &remote_ref, &lock).await
1406        }
1407    };
1408    let release_result = release_lock(guard).await;
1409
1410    match (&result, release_result) {
1411        (Ok(PushOutcome::Ok { .. }), Err(e)) => {
1412            warn!(key = %lock, error = %e, "failed to release lock");
1413            Ok(PushOutcome::Error {
1414                remote_ref: remote_ref_str,
1415                message: format!(
1416                    r#""failed to release lock. You may need to manually remove the lock {lock} from the server or use git-remote-object-store doctor to fix."?"#,
1417                ),
1418            })
1419        }
1420        (_, Err(e)) => {
1421            warn!(key = %lock, error = %e, "lock release failed (work already errored)");
1422            result
1423        }
1424        _ => result,
1425    }
1426}
1427
1428/// The work to perform inside the per-ref lock acquired by [`push_one`].
1429///
1430/// `PushReadyState` is significantly larger than the `Delete` variant
1431/// (paths, the temp dir guard, the captured refspec); boxing it keeps
1432/// the [`UnderLockWork`] discriminant compact regardless of variant.
1433enum UnderLockWork {
1434    Push(Box<PushReadyState>),
1435    Delete { remote_ref: RefName },
1436}
1437
1438/// Re-list under the lock, upload the bundle, init HEAD, write the `FORMAT`
1439/// key, defer the previous bundle's delete via a baseline tombstone (issue
1440/// #157), optionally upload `repo.zip`. Split out so the lock release in
1441/// the caller is unconditional.
1442async fn perform_push_under_lock(
1443    store: &dyn ObjectStore,
1444    prefix: Option<&str>,
1445    kind: BackendKind,
1446    state: PushReadyState,
1447) -> Result<PushOutcome, PushError> {
1448    let PushReadyState {
1449        remote_ref,
1450        local_sha,
1451        pre_existing,
1452        bundle_path,
1453        zip_artifacts,
1454        engine,
1455        force,
1456        pre_existing_was_ancestor,
1457        local_spec,
1458        hidden_bundles,
1459        _temp_dir,
1460    } = state;
1461    let remote_ref_str = remote_ref.as_str().to_owned();
1462
1463    // Issue #129: under-lock force-push protection check. A pre-lock
1464    // check would race against a concurrent `protect`; running here —
1465    // after `acquire_lock`, before any writes — closes that TOCTOU
1466    // window. The historical "protected ref + force" semantic is
1467    // "demote to non-force": if the local SHA would have been a
1468    // fast-forward against the pre-lock-listed remote (already pinned
1469    // by the stale-remote guard below), let the push through; if not,
1470    // emit the same NotAncestor wire error a pre-lock non-force probe
1471    // would have produced. The `is_protected` helper uses `head`, not
1472    // `list`, so this adds one cheap key probe and does not duplicate
1473    // any existing under-lock listing.
1474    if force && !pre_existing_was_ancestor && is_protected(store, prefix, &remote_ref).await? {
1475        return Ok(PushOutcome::Error {
1476            remote_ref: remote_ref_str,
1477            message: not_ancestor_wire_message(&local_spec),
1478        });
1479    }
1480
1481    // Issue #165: reuse the tombstone set captured in `prepare_push`.
1482    // The per-ref lock prevents any new tombstone for this ref from
1483    // landing between then and now.
1484    let current = bundles_for_ref(store, prefix, &remote_ref, Some(&hidden_bundles)).await?;
1485    if current.len() > 1 {
1486        return Ok(PushOutcome::Error {
1487            remote_ref: remote_ref_str,
1488            message: r#""multiple bundles exist for the same ref on server. Run git-remote-object-store doctor to fix."?"#.to_owned(),
1489        });
1490    }
1491    let current_key = current.into_iter().next().map(|m| m.key);
1492    // Compare the pre-lock snapshot to the under-lock reality. All five
1493    // (pre / under) cases must agree:
1494    //   None / None           — happy path, no bundle existed before or now.
1495    //   Some(K) / Some(K)     — happy path, same bundle (e.g. force-push that
1496    //                           re-uploads against the same SHA).
1497    //   Some(A) / Some(B)     — concurrent push replaced our snapshot's
1498    //                           bundle. Reject: we'd silently overwrite it.
1499    //   None / Some           — concurrent push created a bundle after our
1500    //                           pre-lock list. Reject.
1501    //   Some / None           — concurrent delete (`git push :ref`) removed
1502    //                           our snapshot's bundle. Reject.
1503    // Without this guard a concurrent writer between the pre-lock list and
1504    // the lock acquisition would be silently overwritten.
1505    if pre_existing.as_deref() != current_key.as_deref() {
1506        return Ok(PushOutcome::Error {
1507            remote_ref: remote_ref_str,
1508            message: r#""stale remote. Please fetch and retry."?"#.to_owned(),
1509        });
1510    }
1511
1512    let bundle_dest = keys::bundle_key(prefix, &remote_ref, local_sha);
1513    // Stat once for "X / total" formatting in progress logs. The
1514    // multipart upload re-stats `bundle_path` to size the part plan;
1515    // a brief race here would surface as the log showing a stale
1516    // total — never as wrong bytes — so we tolerate it. On stat
1517    // failure (vanishingly rare on a tempdir we just wrote) fall
1518    // back to "unknown" rather than aborting the push.
1519    let bundle_total = tokio::fs::metadata(&bundle_path)
1520        .await
1521        .map(|m| m.len())
1522        .ok();
1523    let bundle_opts = PutOpts {
1524        progress: Some(bundle_progress_sink(&bundle_dest, bundle_total)),
1525        ..PutOpts::default()
1526    };
1527    store
1528        .put_path(&bundle_dest, &bundle_path, bundle_opts)
1529        .await?;
1530
1531    // HEAD bootstrap: write only if absent. Single round-trip via
1532    // put_if_absent — we don't care about the boolean (existing HEAD is
1533    // intentionally preserved).
1534    let head = head_key(prefix);
1535    store
1536        .put_if_absent(
1537            &head,
1538            Bytes::copy_from_slice(remote_ref.as_str().as_bytes()),
1539        )
1540        .await?;
1541
1542    // Lock in the storage engine on the first push. `put_if_absent` makes
1543    // concurrent first-push races safe — both write the same `bundle` value,
1544    // so the one that loses is a no-op. The boolean result is intentionally
1545    // ignored: an existing FORMAT key was already validated at connect time by
1546    // `backend::build`.
1547    let format_key = keys::join(prefix, "FORMAT");
1548    store
1549        .put_if_absent(&format_key, Bytes::from_static(engine.as_str().as_bytes()))
1550        .await?;
1551
1552    if let Some(prev) = current_key
1553        && prev != bundle_dest
1554    {
1555        // Issue #157: defer the prior-bundle delete to `gc sweep` via a
1556        // baseline tombstone instead of deleting it synchronously. A
1557        // concurrent fetcher that already advertised the prior SHA via
1558        // `list` would otherwise race the GET against this DELETE; the
1559        // tombstone gives `fetch_one` the grace window it needs.
1560        defer_prior_bundle_via_tombstone(store, prefix, &remote_ref, &prev, local_sha).await;
1561    }
1562
1563    if let Some(artifacts) = zip_artifacts {
1564        let zip_dest = archive_key(prefix, &remote_ref);
1565        let zip_total = tokio::fs::metadata(&artifacts.archive_path)
1566            .await
1567            .map(|m| m.len())
1568            .ok();
1569        // Issue #161: the `codepipeline-artifact-revision-summary` user
1570        // metadata is only meaningful on S3 (AWS CodePipeline consumes it
1571        // as `x-amz-meta-codepipeline-artifact-revision-summary`). Azure
1572        // metadata names must be valid C# identifiers (no hyphens), so
1573        // attaching the header on the Azure path causes the entire blob
1574        // upload to fail with `InvalidMetadata` — and the issue #127
1575        // best-effort swallow then hides the failure, leaving every
1576        // `?zip=1` push silently missing `repo.zip`. Omit the header
1577        // outside S3 so the zip artifact lands successfully.
1578        let user_metadata = match kind {
1579            BackendKind::S3 => vec![(
1580                "codepipeline-artifact-revision-summary".to_owned(),
1581                sanitize_metadata_value(&artifacts.commit_msg),
1582            )],
1583            BackendKind::Azure => Vec::new(),
1584        };
1585        let opts = PutOpts {
1586            content_disposition: Some(format!(
1587                "attachment; filename=repo-{}.zip",
1588                artifacts.short_sha
1589            )),
1590            user_metadata,
1591            progress: Some(bundle_progress_sink(&zip_dest, zip_total)),
1592        };
1593        upload_zip_artifact_best_effort(
1594            store,
1595            &remote_ref,
1596            &zip_dest,
1597            &artifacts.archive_path,
1598            opts,
1599        )
1600        .await;
1601    }
1602
1603    Ok(PushOutcome::Ok {
1604        remote_ref: remote_ref_str,
1605    })
1606}
1607
1608/// Build a [`ProgressSink`] that emits one structured `tracing::info!`
1609/// line per chunk transferred during a bundle / zip-archive upload.
1610///
1611/// Issue #55. Git's helper protocol has no upload-progress channel
1612/// (the helper-protocol stdout is reserved for protocol traffic per
1613/// `.claude/rules/protocol-stdout.md`), so the bundle path's only way
1614/// to inform the user during a multi-GiB upload is `tracing::info!`,
1615/// which routes to stderr via the tracing-subscriber initialised in
1616/// `main()`. The LFS path keeps its own sink wiring (one
1617/// progress-event JSON line per chunk on stdout, governed by the LFS
1618/// custom-transfer protocol).
1619///
1620/// `total` is the bundle size at the moment we stat'd it. A short
1621/// race against a writer that re-stats during multipart planning
1622/// would only mis-format the log line — it cannot drive wrong-byte
1623/// behaviour. `None` renders "unknown" so the call site can
1624/// gracefully degrade when stat'ing the source fails.
1625///
1626/// The granularity of events is whatever the backend's `put_path`
1627/// provides: one event per completed multipart part / staged block
1628/// for bodies above [`crate::object_store::multipart::MULTIPART_PUT_THRESHOLD`],
1629/// one event total for bodies below it. With the default 16 MiB
1630/// part size and 8-way concurrency, a 1 GiB bundle emits ~64 lines
1631/// — ample motion to spot a stall, far short of log spam.
1632pub(crate) fn bundle_progress_sink(key: &str, total: Option<u64>) -> ProgressSink {
1633    // The closure needs an owned `String` because `ProgressSink` is
1634    // `'static`; cloning here keeps the call sites' borrow intact so
1635    // they can pass `&bundle_dest` to `put_path` without juggling
1636    // ownership.
1637    let key = key.to_owned();
1638    let bytes_so_far = Arc::new(AtomicU64::new(0));
1639    ProgressSink::new(move |bytes_amount| {
1640        // `Ordering::Relaxed` is enough: we're not synchronising with
1641        // any other state, just maintaining a monotonic counter for
1642        // log lines. The worst a re-ordered store could do is print
1643        // an out-of-order count, which `tracing` already disclaims.
1644        let so_far = bytes_so_far
1645            .fetch_add(bytes_amount, Ordering::Relaxed)
1646            .saturating_add(bytes_amount);
1647        // Render `total` as a Display value so the field is omitted
1648        // when stat'ing the source failed at the call site. Tracing's
1649        // `Option<u64>` rendering would print "None" — uglier than a
1650        // bare absence of the field.
1651        if let Some(t) = total {
1652            info!(
1653                key = %key,
1654                bytes_so_far = so_far,
1655                total = t,
1656                bytes_chunk = bytes_amount,
1657                "uploading"
1658            );
1659        } else {
1660            info!(
1661                key = %key,
1662                bytes_so_far = so_far,
1663                bytes_chunk = bytes_amount,
1664                "uploading"
1665            );
1666        }
1667    })
1668}
1669
1670/// Replace ASCII control characters in `s` with spaces so the result
1671/// is safe to use as an HTTP header value.
1672///
1673/// `commit_msg` flows from `git::last_commit_message` (whose summary
1674/// is "everything before the first blank line" per gix) into the
1675/// `codepipeline-artifact-revision-summary` user-metadata header on
1676/// the zip-archive upload. A maliciously-crafted commit could embed
1677/// `\r\n` in the summary, which would be a CRLF injection on
1678/// transport — splitting one logical header into two and letting an
1679/// attacker forge arbitrary user-metadata headers on the uploaded
1680/// archive. Both backends' SDKs reject CRLF at the transport layer
1681/// today, but that defense is version-dependent and the resulting
1682/// error is a cryptic "invalid header" 400; sanitising here surfaces
1683/// a clean, predictable value at the call site instead.
1684fn sanitize_metadata_value(s: &str) -> String {
1685    s.chars()
1686        .map(|c| if c.is_control() { ' ' } else { c })
1687        .collect()
1688}
1689
1690/// Handle a delete refspec (`:<remote_ref>`) UNDER the per-ref lock
1691/// acquired by [`push_one`]: list `<prefix>/<ref>/`, classify the
1692/// remaining entries by shape, sweep every artifact that belongs to the
1693/// ref, and emit `ok` or the appropriate error.
1694///
1695/// Issue #133: this must run inside the lock window. A pre-lock
1696/// listing-then-sweep races a concurrent push that lands a new bundle
1697/// between the listing and the deletion, producing a silent false
1698/// success — the delete reports `ok` to git while the ref survives
1699/// with a different bundle on the server.
1700///
1701/// The lock key (`<prefix>/<ref>/LOCK#.lock`) is filtered from the
1702/// listing — `release_lock` removes it last, after this function
1703/// returns. The sweep must not touch it (deleting our own lock
1704/// mid-critical-section would let concurrent clients acquire it).
1705///
1706/// Issue #128: the `PROTECTED#` marker check is the FIRST guard, run
1707/// against the fresh under-lock listing BEFORE any sweep dispatch, so a
1708/// listing that pairs a marker with a bundle (and/or `repo.zip`) cannot
1709/// sweep the marker and report `ok`.
1710///
1711/// Issue #242: deletability is decided by the entry SHAPE, never by the
1712/// connection-time `zip` flag. The `repo.zip` upload is best-effort and
1713/// its failure is swallowed (see [`upload_zip_artifact_best_effort`]),
1714/// so a `?zip=1` ref can legitimately have only its `<sha>.bundle` with
1715/// no sibling archive — and a non-zip URL can be asked to delete a ref
1716/// that still carries a leftover `repo.zip` from an earlier zip-mode
1717/// push. Asserting `entries.len() == (1 or 2 by zip flag)` mis-routed
1718/// both cases to the corruption branch. Instead we count the
1719/// `<sha>.bundle` objects and treat every non-bundle sibling (the
1720/// `repo.zip` archive and any future per-ref artifact) as a deletable
1721/// companion of the ref's single bundle.
1722///
1723/// Four behaviours fall out:
1724///
1725/// 1. **Protected ref** — the listing (lock filtered out) includes the
1726///    [`keys::PROTECTED_MARKER_SEGMENT`] marker. Emit a
1727///    protection-specific refusal naming the `unprotect` workflow.
1728/// 2. **Exactly one bundle** — with or without a `repo.zip` sibling,
1729///    sweep every present artifact (bundle + siblings) and report `ok`.
1730/// 3. **No bundle present** — emit the `"not found"?` wire error. This
1731///    covers a ref whose only on-server state was a stale `LOCK#.lock`
1732///    (recovered and now held by us, so filtered out) and a ref that
1733///    carries only an orphaned `repo.zip` with no bundle.
1734/// 4. **Two or more distinct `<sha>.bundle` keys** — genuine
1735///    multi-bundle corruption. Fall through to the doctor message.
1736async fn delete_remote_ref_under_lock(
1737    store: &dyn ObjectStore,
1738    prefix: Option<&str>,
1739    remote_ref: &RefName,
1740    lock_key: &str,
1741) -> Result<PushOutcome, PushError> {
1742    let listing = ref_listing_prefix(prefix, remote_ref);
1743    let all_entries = store.list(&listing).await?;
1744    let remote_ref_str = remote_ref.as_str().to_owned();
1745    // Issue #128: the canonical protection guard, run FIRST against the
1746    // fresh under-lock listing. `entries_have_protected_marker` matches
1747    // only the literal `PROTECTED#` last segment — never the
1748    // `LOCK#.lock` lock key — so scanning the unfiltered `all_entries`
1749    // here is safe.
1750    if keys::entries_have_protected_marker(&all_entries) {
1751        return Ok(PushOutcome::Error {
1752            remote_ref: remote_ref_str,
1753            message: DELETE_PROTECTION_MESSAGE.to_owned(),
1754        });
1755    }
1756    // Issue #133: filter out the lock key we hold so the sweep cannot
1757    // delete it. Everything that survives this filter is a deletable
1758    // artifact of the ref (bundle objects plus any `repo.zip` / future
1759    // sibling) — the `PROTECTED#` marker already returned above.
1760    let deletable: Vec<&ObjectMeta> = all_entries.iter().filter(|e| e.key != lock_key).collect();
1761    // Issue #242: route on the number of distinct `<sha>.bundle` keys,
1762    // not the connection-time `zip` flag. A ref is a clean single-bundle
1763    // ref when exactly one bundle is present, regardless of whether a
1764    // `repo.zip` sibling rode along.
1765    let bundle_count = deletable
1766        .iter()
1767        .filter(|e| is_bundle_candidate(&e.key))
1768        .count();
1769    match bundle_count {
1770        0 => Ok(PushOutcome::Error {
1771            remote_ref: remote_ref_str,
1772            message: r#""not found"?"#.to_owned(),
1773        }),
1774        1 => {
1775            for entry in &deletable {
1776                delete_idempotent(store, &entry.key).await?;
1777            }
1778            // Issue #151 defence-in-depth: confirm no `PROTECTED#` marker
1779            // sneaked in. The lock window is still open (the caller
1780            // releases it after we return), so a `protect`/`unprotect`
1781            // racing this delete would be blocked on the lock per #159.
1782            // Finding a marker here would indicate a contract violation;
1783            // the helper logs at `error!` and the delete still reports
1784            // `ok` — see the helper doc for the rationale.
1785            verify_no_orphan_protected_after_delete(store, prefix, remote_ref).await;
1786            Ok(PushOutcome::Ok {
1787                remote_ref: remote_ref_str,
1788            })
1789        }
1790        _ => Ok(PushOutcome::Error {
1791            remote_ref: remote_ref_str,
1792            message:
1793                r#""multiple bundles exist on server. Run git-remote-object-store doctor to fix."?"#
1794                    .to_owned(),
1795        }),
1796    }
1797}
1798
1799#[cfg(test)]
1800mod tests {
1801    use super::*;
1802    use crate::object_store::mock::MockStore;
1803    use crate::packchain::gc::baseline_tombstone_listing_prefix;
1804
1805    const SHA: &str = "0123456789abcdef0123456789abcdef01234567";
1806    /// A second 40-char hex SHA distinct from `SHA`. Used by tests that
1807    /// need to seed pre-existing state under a SHA different from
1808    /// `local_sha` so a regression cannot pass through coincidental key
1809    /// alignment between `pre_existing` and `bundle_dest`.
1810    const OTHER_SHA: &str = "ffffffffffffffffffffffffffffffffffffffff";
1811
1812    /// Compile-time guard: replaces the runtime `assert_ne!(other_sha, SHA, …)`
1813    /// that the constant lift removed. A typo making the two consts equal
1814    /// fails the build rather than silently letting a stale-remote test
1815    /// pass for the wrong reason.
1816    const _: () = {
1817        let a = SHA.as_bytes();
1818        let b = OTHER_SHA.as_bytes();
1819        let mut i = 0;
1820        let mut differs = a.len() != b.len();
1821        while i < a.len() && i < b.len() {
1822            if a[i] != b[i] {
1823                differs = true;
1824            }
1825            i += 1;
1826        }
1827        assert!(differs, "OTHER_SHA must differ from SHA");
1828    };
1829
1830    fn rn(s: &str) -> RefName {
1831        RefName::new(s).expect("RefName")
1832    }
1833
1834    // --- parse_push_args ----------------------------------------------
1835
1836    #[test]
1837    fn parse_push_args_accepts_canonical_form() {
1838        let spec = parse_push_args("refs/heads/main:refs/heads/main").expect("parse");
1839        assert!(!spec.force);
1840        assert_eq!(spec.local_spec, "refs/heads/main");
1841        assert_eq!(spec.remote_ref.as_str(), "refs/heads/main");
1842    }
1843
1844    #[test]
1845    fn parse_push_args_accepts_force_flag() {
1846        let spec = parse_push_args("+refs/heads/main:refs/heads/main").expect("parse");
1847        assert!(spec.force);
1848        assert_eq!(spec.local_spec, "refs/heads/main");
1849    }
1850
1851    #[test]
1852    fn parse_push_args_accepts_delete_form() {
1853        let spec = parse_push_args(":refs/heads/main").expect("parse");
1854        assert!(!spec.force);
1855        assert!(spec.local_spec.is_empty());
1856        assert_eq!(spec.remote_ref.as_str(), "refs/heads/main");
1857    }
1858
1859    #[test]
1860    fn parse_push_args_accepts_force_delete_form() {
1861        let spec = parse_push_args("+:refs/heads/main").expect("parse");
1862        assert!(spec.force);
1863        assert!(spec.local_spec.is_empty());
1864        assert_eq!(spec.remote_ref.as_str(), "refs/heads/main");
1865    }
1866
1867    #[test]
1868    fn parse_push_args_accepts_short_local() {
1869        let spec = parse_push_args("HEAD:refs/heads/main").expect("parse");
1870        assert_eq!(spec.local_spec, "HEAD");
1871    }
1872
1873    #[test]
1874    fn parse_push_args_rejects_missing_colon() {
1875        assert!(matches!(
1876            parse_push_args("refs/heads/main"),
1877            Err(PushError::Parse { .. })
1878        ));
1879    }
1880
1881    #[test]
1882    fn parse_push_args_rejects_empty_remote() {
1883        assert!(matches!(
1884            parse_push_args("refs/heads/main:"),
1885            Err(PushError::Parse { .. })
1886        ));
1887    }
1888
1889    #[test]
1890    fn parse_push_args_rejects_invalid_remote_ref() {
1891        assert!(matches!(
1892            parse_push_args("refs/heads/main:refs/heads/.bad"),
1893            Err(PushError::RemoteRef(_))
1894        ));
1895    }
1896
1897    #[test]
1898    fn parse_push_args_rejects_invalid_local_spec() {
1899        assert!(matches!(
1900            parse_push_args("refs/heads/.bad:refs/heads/main"),
1901            Err(PushError::InvalidLocalSpec(_))
1902        ));
1903    }
1904
1905    #[test]
1906    fn parse_push_args_rejects_embedded_whitespace() {
1907        assert!(matches!(
1908            parse_push_args("refs/heads/main:refs/heads/main extra"),
1909            Err(PushError::Parse { .. })
1910        ));
1911    }
1912
1913    #[test]
1914    fn parse_push_args_rejects_empty_input() {
1915        assert!(matches!(parse_push_args(""), Err(PushError::Parse { .. })));
1916    }
1917
1918    // --- key formatting -----------------------------------------------
1919
1920    #[test]
1921    fn key_formatters_with_prefix() {
1922        let r = rn("refs/heads/main");
1923        let sha = Sha::from_hex(SHA).unwrap();
1924        assert_eq!(
1925            keys::bundle_key(Some("repo"), &r, sha),
1926            format!("repo/refs/heads/main/{SHA}.bundle"),
1927        );
1928        assert_eq!(
1929            lock_key(Some("repo"), &r),
1930            "repo/refs/heads/main/LOCK#.lock"
1931        );
1932        assert_eq!(
1933            archive_key(Some("repo"), &r),
1934            "repo/refs/heads/main/repo.zip"
1935        );
1936        assert_eq!(head_key(Some("repo")), "repo/HEAD");
1937    }
1938
1939    #[test]
1940    fn key_formatters_with_no_prefix() {
1941        let r = rn("refs/heads/main");
1942        let sha = Sha::from_hex(SHA).unwrap();
1943        assert_eq!(
1944            keys::bundle_key(None, &r, sha),
1945            format!("refs/heads/main/{SHA}.bundle"),
1946        );
1947        assert_eq!(lock_key(None, &r), "refs/heads/main/LOCK#.lock");
1948        assert_eq!(archive_key(None, &r), "refs/heads/main/repo.zip");
1949        assert_eq!(head_key(None), "HEAD");
1950        // Empty-string prefix is treated identically to None.
1951        assert_eq!(head_key(Some("")), "HEAD");
1952        assert_eq!(lock_key(Some(""), &r), "refs/heads/main/LOCK#.lock");
1953    }
1954
1955    // --- bundle filter ------------------------------------------------
1956
1957    #[test]
1958    fn is_bundle_candidate_keeps_real_bundles() {
1959        assert!(is_bundle_candidate(&format!(
1960            "repo/refs/heads/main/{SHA}.bundle"
1961        )));
1962        assert!(is_bundle_candidate(&format!(
1963            "refs/heads/main/{SHA}.bundle"
1964        )));
1965    }
1966
1967    #[test]
1968    fn is_bundle_candidate_rejects_protected_zip_lock() {
1969        assert!(!is_bundle_candidate("repo/refs/heads/main/PROTECTED#"));
1970        assert!(!is_bundle_candidate("repo/refs/heads/main/repo.zip"));
1971        assert!(!is_bundle_candidate("repo/refs/heads/main/LOCK#.lock"));
1972        assert!(!is_bundle_candidate("repo/refs/heads/main/file.lock"));
1973        assert!(!is_bundle_candidate("repo/refs/heads/main/LOCKS/x"));
1974    }
1975
1976    /// Regression: refs whose names embed `.zip` as a substring must
1977    /// not be filtered out. Previously the predicate rejected any key
1978    /// containing `.zip` anywhere in the byte sequence.
1979    #[test]
1980    fn is_bundle_candidate_keeps_refs_containing_zip_substring() {
1981        assert!(is_bundle_candidate(&format!(
1982            "repo/refs/heads/v1.zip-rc1/{SHA}.bundle"
1983        )));
1984        assert!(is_bundle_candidate(&format!(
1985            "refs/heads/myrelease.zip-v1/{SHA}.bundle"
1986        )));
1987    }
1988
1989    /// Regression: refs whose names embed `LOCKS` as a substring must
1990    /// not be filtered out. Previously the predicate rejected any key
1991    /// containing `/LOCKS/` anywhere in the byte sequence.
1992    #[test]
1993    fn is_bundle_candidate_keeps_refs_containing_locks_substring() {
1994        assert!(is_bundle_candidate(&format!(
1995            "repo/refs/heads/LOCKS-feature/x/{SHA}.bundle"
1996        )));
1997        assert!(is_bundle_candidate(&format!(
1998            "refs/heads/LOCKS/sub/{SHA}.bundle"
1999        )));
2000    }
2001
2002    /// Regression: a ref-name segment ending in `.lock` is permitted by
2003    /// `gix_validate`; bundle keys under such refs must still match.
2004    /// The unwanted `.lock` sibling is `<ref>/LOCK#.lock`, where the
2005    /// final segment is `LOCK#.lock`, not `<sha>.bundle`.
2006    #[test]
2007    fn is_bundle_candidate_keeps_refs_containing_lock_substring() {
2008        assert!(is_bundle_candidate(&format!(
2009            "refs/heads/feature.lock-rc/{SHA}.bundle"
2010        )));
2011    }
2012
2013    // --- parse_remote_sha_from_key ------------------------------------
2014
2015    #[test]
2016    fn parse_remote_sha_from_key_extracts_lower_hex_40() {
2017        let sha = parse_remote_sha_from_key(&format!("repo/refs/heads/main/{SHA}.bundle"))
2018            .expect("parse");
2019        assert_eq!(sha.to_string(), SHA);
2020    }
2021
2022    #[test]
2023    fn parse_remote_sha_from_key_rejects_uppercase() {
2024        let upper = SHA.to_uppercase();
2025        assert!(parse_remote_sha_from_key(&format!("refs/heads/main/{upper}.bundle")).is_none());
2026    }
2027
2028    #[test]
2029    fn parse_remote_sha_from_key_rejects_wrong_length() {
2030        let short = &SHA[..39];
2031        assert!(parse_remote_sha_from_key(&format!("refs/heads/main/{short}.bundle")).is_none());
2032    }
2033
2034    #[test]
2035    fn parse_remote_sha_from_key_rejects_missing_extension() {
2036        assert!(parse_remote_sha_from_key(&format!("refs/heads/main/{SHA}")).is_none());
2037    }
2038
2039    // --- bundles_for_ref / is_protected ------------------------------
2040
2041    #[tokio::test]
2042    async fn bundles_for_ref_filters_protected_zip_lock() {
2043        let store = MockStore::new();
2044        let r = rn("refs/heads/main");
2045        store.insert(
2046            format!("repo/refs/heads/main/{SHA}.bundle"),
2047            Bytes::from_static(b"b"),
2048        );
2049        store.insert("repo/refs/heads/main/PROTECTED#", Bytes::from_static(b""));
2050        store.insert("repo/refs/heads/main/repo.zip", Bytes::from_static(b""));
2051        store.insert("repo/refs/heads/main/LOCK#.lock", Bytes::from_static(b""));
2052        let bundles = bundles_for_ref(&store, Some("repo"), &r, None)
2053            .await
2054            .unwrap();
2055        assert_eq!(bundles.len(), 1);
2056        assert!(bundles[0].key.ends_with(".bundle"));
2057    }
2058
2059    /// Regression for #109: a ref whose name contains `.zip` must
2060    /// not have its bundle silently filtered out.
2061    #[tokio::test]
2062    async fn bundles_for_ref_keeps_bundle_when_ref_name_contains_zip() {
2063        let store = MockStore::new();
2064        let r = rn("refs/heads/v1.zip-rc1");
2065        let bundle_key = format!("repo/refs/heads/v1.zip-rc1/{SHA}.bundle");
2066        store.insert(bundle_key.clone(), Bytes::from_static(b"b"));
2067        let bundles = bundles_for_ref(&store, Some("repo"), &r, None)
2068            .await
2069            .unwrap();
2070        assert_eq!(bundles.len(), 1);
2071        assert_eq!(bundles[0].key, bundle_key);
2072    }
2073
2074    /// Regression for #109: a ref whose name contains `LOCKS` must
2075    /// not have its bundle silently filtered out.
2076    #[tokio::test]
2077    async fn bundles_for_ref_keeps_bundle_when_ref_name_contains_locks() {
2078        let store = MockStore::new();
2079        let r = rn("refs/heads/LOCKS-feature/x");
2080        let bundle_key = format!("repo/refs/heads/LOCKS-feature/x/{SHA}.bundle");
2081        store.insert(bundle_key.clone(), Bytes::from_static(b"b"));
2082        let bundles = bundles_for_ref(&store, Some("repo"), &r, None)
2083            .await
2084            .unwrap();
2085        assert_eq!(bundles.len(), 1);
2086        assert_eq!(bundles[0].key, bundle_key);
2087    }
2088
2089    /// Issue #165: a caller-supplied `cached_hidden` set must satisfy
2090    /// the tombstone lookup — `bundles_for_ref` must NOT re-list
2091    /// `<prefix>/gc/` or fetch any tombstone body. Counts every `list`
2092    /// + `get_bytes` call so a regression that drops the cache and
2093    /// re-walks the tombstone set fails this test.
2094    #[tokio::test]
2095    async fn bundles_for_ref_skips_tombstone_lookup_when_cache_provided() {
2096        use std::sync::atomic::{AtomicUsize, Ordering};
2097
2098        struct CountingStore {
2099            inner: MockStore,
2100            gc_lists: AtomicUsize,
2101            tombstone_gets: AtomicUsize,
2102        }
2103
2104        // `put_path` is intentionally omitted from the forward list to
2105        // preserve the original behavior (trait default forwarding via
2106        // `Self::put_bytes`); this decorator never has `put_path` called
2107        // on it in practice.
2108        crate::delegate_to_inner_impl! {
2109            impl ObjectStore for CountingStore {
2110                forward: get_to_file, get_bytes_range,
2111                         put_bytes, put_if_absent,
2112                         head, copy, delete;
2113
2114                async fn list(&self, prefix: &str) -> Result<Vec<ObjectMeta>, ObjectStoreError> {
2115                    if prefix == "repo/gc/" {
2116                        self.gc_lists.fetch_add(1, Ordering::SeqCst);
2117                    }
2118                    self.inner.list(prefix).await
2119                }
2120
2121                async fn get_bytes(&self, key: &str) -> Result<Bytes, ObjectStoreError> {
2122                    if key.starts_with(&baseline_tombstone_listing_prefix(Some("repo"))) {
2123                        self.tombstone_gets.fetch_add(1, Ordering::SeqCst);
2124                    }
2125                    self.inner.get_bytes(key).await
2126                }
2127            }
2128        }
2129
2130        let inner = MockStore::new();
2131        let r = rn("refs/heads/main");
2132        // Seed: a bundle for the ref plus a baseline tombstone naming a
2133        // *different* SHA. The tombstone exists so a non-cached call
2134        // would have to fetch & parse it to compute the hide set.
2135        inner.insert(
2136            format!("repo/refs/heads/main/{SHA}.bundle"),
2137            Bytes::from_static(b"b"),
2138        );
2139        // Plausible tombstone body — `tombstoned_bundle_keys` parses it
2140        // as `BaselineTombstone` JSON. Body shape mirrors the one
2141        // written by `write_baseline_tombstone_unconditional`; `v`
2142        // must be `TOMBSTONE_SCHEMA_VERSION` or the parser rejects it
2143        // and the tombstone is silently skipped (defeating the test).
2144        let tomb_body = format!(
2145            r#"{{"v":1,"ref_name":"refs/heads/main","sha":"{OTHER_SHA}","marked_at":"2024-01-01T00:00:00Z"}}"#
2146        );
2147        inner.insert(
2148            format!(
2149                "{}test.json",
2150                baseline_tombstone_listing_prefix(Some("repo"))
2151            ),
2152            Bytes::from(tomb_body),
2153        );
2154
2155        let store = CountingStore {
2156            inner,
2157            gc_lists: AtomicUsize::new(0),
2158            tombstone_gets: AtomicUsize::new(0),
2159        };
2160
2161        // Baseline: a `None` cache pays for one `gc/` list + one
2162        // tombstone GET. Asserting these counts pins the un-optimised
2163        // shape so the contrast with the cached path is meaningful.
2164        let bundles_uncached = bundles_for_ref(&store, Some("repo"), &r, None)
2165            .await
2166            .unwrap();
2167        assert_eq!(bundles_uncached.len(), 1);
2168        assert_eq!(store.gc_lists.load(Ordering::SeqCst), 1);
2169        assert_eq!(store.tombstone_gets.load(Ordering::SeqCst), 1);
2170
2171        // Cached path: hand the same hide set we already paid for.
2172        // `bundles_for_ref` must consult it and skip the `gc/` walk
2173        // entirely. A regression that drops the cache and re-lists
2174        // would bump these counters past 1.
2175        let hidden: HashSet<String> = [format!("repo/refs/heads/main/{OTHER_SHA}.bundle")]
2176            .into_iter()
2177            .collect();
2178        let bundles_cached = bundles_for_ref(&store, Some("repo"), &r, Some(&hidden))
2179            .await
2180            .unwrap();
2181        assert_eq!(bundles_cached.len(), 1);
2182        assert_eq!(
2183            store.gc_lists.load(Ordering::SeqCst),
2184            1,
2185            "cached call must not re-list gc/",
2186        );
2187        assert_eq!(
2188            store.tombstone_gets.load(Ordering::SeqCst),
2189            1,
2190            "cached call must not refetch any tombstone body",
2191        );
2192    }
2193
2194    #[tokio::test]
2195    async fn is_protected_detects_marker() {
2196        let store = MockStore::new();
2197        let r = rn("refs/heads/main");
2198        assert!(!is_protected(&store, Some("repo"), &r).await.unwrap());
2199        store.insert("repo/refs/heads/main/PROTECTED#", Bytes::from_static(b""));
2200        assert!(is_protected(&store, Some("repo"), &r).await.unwrap());
2201    }
2202
2203    /// Regression for #119: only the exact `PROTECTED#` key counts as a
2204    /// protection marker. A sibling key that merely starts with
2205    /// `PROTECTED#` (e.g. `PROTECTED#audit`) must not flip the result.
2206    #[tokio::test]
2207    async fn is_protected_ignores_protected_prefixed_sibling() {
2208        let store = MockStore::new();
2209        let r = rn("refs/heads/main");
2210        store.insert(
2211            "repo/refs/heads/main/PROTECTED#audit",
2212            Bytes::from_static(b""),
2213        );
2214        assert!(!is_protected(&store, Some("repo"), &r).await.unwrap());
2215    }
2216
2217    /// Regression for #119: `is_protected` must use `head`, not `list`.
2218    /// Arm `AccessDeniedOnAnyList`; if the implementation calls `list`
2219    /// at all, the fault fires and the call returns an error. We assert
2220    /// success and that the fault is still pending (i.e. unfired).
2221    #[tokio::test]
2222    async fn is_protected_uses_head_not_list() {
2223        use crate::object_store::mock::Fault;
2224        let store = MockStore::new();
2225        let r = rn("refs/heads/main");
2226        store.arm(Fault::AccessDeniedOnAnyList);
2227        let got = is_protected(&store, Some("repo"), &r).await.unwrap();
2228        assert!(!got);
2229        assert_eq!(store.pending_faults(), 1, "is_protected must not call list");
2230        // And the positive case too — still no list.
2231        store.insert("repo/refs/heads/main/PROTECTED#", Bytes::from_static(b""));
2232        let got = is_protected(&store, Some("repo"), &r).await.unwrap();
2233        assert!(got);
2234        assert_eq!(store.pending_faults(), 1, "is_protected must not call list");
2235    }
2236
2237    // --- acquire_lock / release_lock ----------------------------------
2238    //
2239    // Issue #118: `acquire_lock` returns a `LockGuard` instead of a
2240    // plain bool because the lock now carries a background heartbeat
2241    // task. Tests construct an `Arc<dyn ObjectStore>` so the heartbeat
2242    // task can clone the store; `MockStore`'s internal state is
2243    // already `Arc<Mutex<...>>`-shared, so the `Arc` clone is shape
2244    // bookkeeping, not extra state.
2245
2246    #[tokio::test]
2247    async fn acquire_lock_succeeds_when_absent() {
2248        let store = Arc::new(MockStore::new());
2249        let now = OffsetDateTime::now_utc();
2250        let guard = acquire_lock(
2251            Arc::clone(&store) as Arc<dyn ObjectStore>,
2252            "k",
2253            Duration::seconds(60),
2254            now,
2255        )
2256        .await
2257        .unwrap();
2258        assert!(guard.is_some(), "expected a fresh guard");
2259        assert!(store.contains("k"));
2260        // Drop the guard so the heartbeat task exits before the
2261        // runtime tears down (also covered explicitly by
2262        // `lock_guard_drop_aborts_heartbeat`).
2263        drop(guard);
2264    }
2265
2266    #[tokio::test]
2267    async fn acquire_lock_returns_none_when_recently_held() {
2268        let store = Arc::new(MockStore::new());
2269        let now = OffsetDateTime::now_utc();
2270        store.insert_with("k", Bytes::new(), now, PutOpts::default());
2271        let guard = acquire_lock(
2272            Arc::clone(&store) as Arc<dyn ObjectStore>,
2273            "k",
2274            Duration::seconds(60),
2275            now,
2276        )
2277        .await
2278        .unwrap();
2279        assert!(guard.is_none(), "expected contention");
2280    }
2281
2282    #[tokio::test]
2283    async fn acquire_lock_recovers_stale_lock() {
2284        let store = Arc::new(MockStore::new());
2285        let now = OffsetDateTime::now_utc();
2286        let stale = now - Duration::seconds(120);
2287        store.insert_with("k", Bytes::new(), stale, PutOpts::default());
2288        let guard = acquire_lock(
2289            Arc::clone(&store) as Arc<dyn ObjectStore>,
2290            "k",
2291            Duration::seconds(60),
2292            now,
2293        )
2294        .await
2295        .unwrap();
2296        assert!(guard.is_some(), "stale lock must be recoverable");
2297        // Lock still exists (we re-created it with put_if_absent).
2298        assert!(store.contains("k"));
2299        drop(guard);
2300    }
2301
2302    #[tokio::test]
2303    async fn acquire_lock_treats_disappeared_lock_as_contention() {
2304        // First put_if_absent says "exists", but head returns NotFound
2305        // (race: another client released between the calls). We must
2306        // surface contention, not error.
2307        use crate::object_store::mock::Fault;
2308        let store = MockStore::new();
2309        store.insert("k", Bytes::new());
2310        store.arm(Fault::NotFoundOnHead { key: "k".into() });
2311        let arc = Arc::new(store);
2312        let now = OffsetDateTime::now_utc();
2313        let guard = acquire_lock(
2314            Arc::clone(&arc) as Arc<dyn ObjectStore>,
2315            "k",
2316            Duration::seconds(60),
2317            now,
2318        )
2319        .await
2320        .unwrap();
2321        assert!(guard.is_none(), "expected contention on disappeared lock");
2322        // Confirm head() was actually called — a regression that skipped
2323        // the staleness branch and returned None directly would also
2324        // satisfy the assertion above. The fault firing proves head ran.
2325        assert_eq!(arc.pending_faults(), 0);
2326    }
2327
2328    #[tokio::test]
2329    async fn release_lock_deletes_existing_key() {
2330        let store = Arc::new(MockStore::new());
2331        let now = OffsetDateTime::now_utc();
2332        let guard = acquire_lock(
2333            Arc::clone(&store) as Arc<dyn ObjectStore>,
2334            "k",
2335            Duration::seconds(60),
2336            now,
2337        )
2338        .await
2339        .unwrap()
2340        .expect("acquire_lock must succeed on an empty store");
2341        release_lock(guard).await.unwrap();
2342        assert!(!store.contains("k"));
2343    }
2344
2345    #[tokio::test]
2346    async fn release_lock_swallows_not_found_when_lock_already_gone() {
2347        // Acquire a lock, then delete the key out-of-band before
2348        // release_lock runs. The release must map NotFound → Ok(()).
2349        let store = Arc::new(MockStore::new());
2350        let now = OffsetDateTime::now_utc();
2351        let guard = acquire_lock(
2352            Arc::clone(&store) as Arc<dyn ObjectStore>,
2353            "k",
2354            Duration::seconds(60),
2355            now,
2356        )
2357        .await
2358        .unwrap()
2359        .expect("acquire_lock must succeed");
2360        // Cancel the heartbeat first so it cannot race the manual
2361        // delete and re-create the key.
2362        guard.heartbeat.as_ref().unwrap().abort();
2363        // Give the abort a chance to take effect, then remove the key.
2364        tokio::task::yield_now().await;
2365        let _ = store.delete("k").await;
2366        release_lock(guard).await.unwrap();
2367    }
2368
2369    #[tokio::test]
2370    async fn release_lock_propagates_non_not_found_errors() {
2371        use crate::object_store::mock::Fault;
2372        let store = Arc::new(MockStore::new());
2373        let now = OffsetDateTime::now_utc();
2374        let guard = acquire_lock(
2375            Arc::clone(&store) as Arc<dyn ObjectStore>,
2376            "k",
2377            Duration::seconds(60),
2378            now,
2379        )
2380        .await
2381        .unwrap()
2382        .expect("acquire_lock must succeed");
2383        store.arm(Fault::NetworkOnDelete { key: "k".into() });
2384        let err = release_lock(guard).await.unwrap_err();
2385        assert!(
2386            matches!(err, ObjectStoreError::Network(_)),
2387            "expected Network error, got {err:?}",
2388        );
2389        // The fault fired exactly once.
2390        assert_eq!(store.pending_faults(), 0);
2391        // Key remains because the delete was faulted, not executed.
2392        assert!(store.contains("k"));
2393    }
2394
2395    /// Issue #118: a long-running critical section must not lose its
2396    /// lock. The heartbeat refreshes `last_modified` faster than the
2397    /// TTL expires, so a concurrent acquire after the original TTL
2398    /// elapses still sees a live lock and returns `None` (contention).
2399    #[tokio::test(start_paused = true)]
2400    async fn heartbeat_keeps_lock_alive_past_ttl() {
2401        let store = Arc::new(MockStore::new());
2402        let now = OffsetDateTime::now_utc();
2403        // TTL is 4 s → heartbeat fires every 2 s (see
2404        // `heartbeat_interval`). Run the test for ~10 s of virtual
2405        // time so a regression that disabled the heartbeat would have
2406        // multiple TTLs to expire under.
2407        let ttl = Duration::seconds(4);
2408        let guard = acquire_lock(Arc::clone(&store) as Arc<dyn ObjectStore>, "k", ttl, now)
2409            .await
2410            .unwrap()
2411            .expect("acquire must succeed");
2412
2413        // Advance the clock past several TTLs, letting the heartbeat
2414        // task fire each time.
2415        for _ in 0..5 {
2416            tokio::time::advance(std::time::Duration::from_secs(3)).await;
2417            // Yield so the spawned heartbeat task can take its turn
2418            // on the runtime and PUT the lock key.
2419            tokio::task::yield_now().await;
2420        }
2421
2422        // A concurrent acquire would see `last_modified` recent
2423        // (heartbeat just refreshed it), so it should report
2424        // contention — not steal the lock as stale. We use a `now`
2425        // far in the future (matches the wall-clock view a second
2426        // process would have) but rely on the heartbeat having
2427        // overwritten `last_modified` to the runtime "now".
2428        let future = OffsetDateTime::now_utc();
2429        let other = acquire_lock(Arc::clone(&store) as Arc<dyn ObjectStore>, "k", ttl, future)
2430            .await
2431            .unwrap();
2432        assert!(
2433            other.is_none(),
2434            "live lock must not be stealable while the holder's heartbeat runs",
2435        );
2436
2437        release_lock(guard).await.unwrap();
2438        assert!(!store.contains("k"));
2439    }
2440
2441    /// Releasing the guard must stop the heartbeat so no further PUTs
2442    /// hit the lock key after release. We assert by deleting the key
2443    /// post-release and confirming it stays gone.
2444    #[tokio::test(start_paused = true)]
2445    async fn release_lock_stops_heartbeat() {
2446        let store = Arc::new(MockStore::new());
2447        let now = OffsetDateTime::now_utc();
2448        let ttl = Duration::seconds(4);
2449        let guard = acquire_lock(Arc::clone(&store) as Arc<dyn ObjectStore>, "k", ttl, now)
2450            .await
2451            .unwrap()
2452            .expect("acquire must succeed");
2453        release_lock(guard).await.unwrap();
2454        assert!(!store.contains("k"));
2455
2456        // Advance well past multiple heartbeat intervals. A
2457        // regression that forgot to abort the task would re-create
2458        // the key via put_bytes.
2459        for _ in 0..5 {
2460            tokio::time::advance(std::time::Duration::from_secs(3)).await;
2461            tokio::task::yield_now().await;
2462        }
2463        assert!(
2464            !store.contains("k"),
2465            "heartbeat must not re-create the key after release",
2466        );
2467    }
2468
2469    /// Dropping the guard without calling `release_lock` aborts the
2470    /// heartbeat (so the lock becomes stealable after TTL) and leaves
2471    /// the lock key in place (a future caller's stale-recovery path
2472    /// reclaims it).
2473    #[tokio::test(start_paused = true)]
2474    async fn lock_guard_drop_aborts_heartbeat() {
2475        let store = Arc::new(MockStore::new());
2476        let now = OffsetDateTime::now_utc();
2477        let ttl = Duration::seconds(4);
2478        let guard = acquire_lock(Arc::clone(&store) as Arc<dyn ObjectStore>, "k", ttl, now)
2479            .await
2480            .unwrap()
2481            .expect("acquire must succeed");
2482        drop(guard);
2483
2484        // Capture the lock's last_modified right after drop —
2485        // heartbeats after this point would advance it. `head` is the
2486        // trait-level path to last_modified and avoids a test-only
2487        // accessor on MockStore.
2488        let after_drop = store.head("k").await.expect("lock present").last_modified;
2489
2490        // Advance time past multiple heartbeat intervals.
2491        for _ in 0..5 {
2492            tokio::time::advance(std::time::Duration::from_secs(3)).await;
2493            tokio::task::yield_now().await;
2494        }
2495
2496        let after_advance = store
2497            .head("k")
2498            .await
2499            .expect("lock still present")
2500            .last_modified;
2501        assert_eq!(
2502            after_drop, after_advance,
2503            "heartbeat must not refresh last_modified after drop",
2504        );
2505
2506        // And the lock is now stealable via the stale path: an acquire
2507        // with a `now` past TTL deletes the orphaned lock and reclaims.
2508        let future = now + Duration::seconds(120);
2509        let recovered = acquire_lock(Arc::clone(&store) as Arc<dyn ObjectStore>, "k", ttl, future)
2510            .await
2511            .unwrap();
2512        assert!(recovered.is_some(), "orphaned lock must be reclaimable");
2513        drop(recovered);
2514    }
2515
2516    /// Issue #150 regression: a heartbeat `put_bytes` already in
2517    /// flight when `release` is called must complete BEFORE the
2518    /// release issues its DELETE. The pre-fix code did
2519    /// `handle.abort(); delete`, which only cancelled the future at
2520    /// its next await point — an in-flight network PUT continued on
2521    /// the server and could settle AFTER the DELETE, resurrecting the
2522    /// lock key as an orphan.
2523    ///
2524    /// The test wraps the mock store in a barrier-gated `put_bytes`
2525    /// (the first PUT records its start, then waits on a notify) so
2526    /// the heartbeat tick lands in a state where the PUT has been
2527    /// issued but not yet completed when `release` runs. The
2528    /// post-condition is the sequence of recorded operations: the
2529    /// in-flight PUT's completion must precede the DELETE's start.
2530    /// Expected sequence is derived from the invariant in the
2531    /// `LockGuard::release` doc comment (the spec), not from the
2532    /// code's current output (lesson #5).
2533    // The test body inlines a small `ObjectStore` decorator
2534    // (`GatedPutStore`) so the trait wiring inflates the line count
2535    // past clippy's default budget. Splitting the decorator into a
2536    // sibling helper would obscure the test's single behaviour
2537    // contract, so we accept the lint locally.
2538    #[allow(clippy::too_many_lines)]
2539    #[tokio::test(start_paused = true)]
2540    async fn release_awaits_in_flight_heartbeat_put_before_delete() {
2541        use std::sync::Mutex;
2542        use tokio::sync::Notify;
2543
2544        /// Op-log entry recording the relative order of `put_bytes`
2545        /// and `delete` events. The release contract requires
2546        /// `PutEnd` to precede `DeleteStart`.
2547        #[derive(Debug, PartialEq, Eq, Clone, Copy)]
2548        enum Op {
2549            PutStart,
2550            PutEnd,
2551            DeleteStart,
2552            DeleteEnd,
2553        }
2554
2555        /// Decorator that gates the FIRST `put_bytes` on a notify
2556        /// barrier. Subsequent `put_bytes` calls pass through (they
2557        /// should not happen — once we hold the gate, release should
2558        /// stop the heartbeat before another tick fires).
2559        struct GatedPutStore {
2560            inner: Arc<MockStore>,
2561            put_gate: Arc<Notify>,
2562            log: Arc<Mutex<Vec<Op>>>,
2563            gated_key: String,
2564            gate_consumed: std::sync::atomic::AtomicBool,
2565        }
2566
2567        #[async_trait::async_trait]
2568        impl ObjectStore for GatedPutStore {
2569            async fn list(&self, prefix: &str) -> Result<Vec<ObjectMeta>, ObjectStoreError> {
2570                self.inner.list(prefix).await
2571            }
2572            async fn get_to_file(
2573                &self,
2574                key: &str,
2575                dest: &std::path::Path,
2576                opts: crate::object_store::GetOpts,
2577            ) -> Result<(), ObjectStoreError> {
2578                self.inner.get_to_file(key, dest, opts).await
2579            }
2580            async fn get_bytes(&self, key: &str) -> Result<Bytes, ObjectStoreError> {
2581                self.inner.get_bytes(key).await
2582            }
2583            async fn get_bytes_range(
2584                &self,
2585                key: &str,
2586                range: std::ops::Range<u64>,
2587            ) -> Result<Bytes, ObjectStoreError> {
2588                self.inner.get_bytes_range(key, range).await
2589            }
2590            async fn put_bytes(
2591                &self,
2592                key: &str,
2593                body: Bytes,
2594                opts: PutOpts,
2595            ) -> Result<(), ObjectStoreError> {
2596                let is_gated = key == self.gated_key
2597                    && !self
2598                        .gate_consumed
2599                        .swap(true, std::sync::atomic::Ordering::SeqCst);
2600                if is_gated {
2601                    self.log.lock().unwrap().push(Op::PutStart);
2602                    self.put_gate.notified().await;
2603                }
2604                let result = self.inner.put_bytes(key, body, opts).await;
2605                if is_gated {
2606                    self.log.lock().unwrap().push(Op::PutEnd);
2607                }
2608                result
2609            }
2610            async fn put_path(
2611                &self,
2612                key: &str,
2613                src: &std::path::Path,
2614                opts: PutOpts,
2615            ) -> Result<(), ObjectStoreError> {
2616                self.inner.put_path(key, src, opts).await
2617            }
2618            async fn put_if_absent(
2619                &self,
2620                key: &str,
2621                body: Bytes,
2622            ) -> Result<bool, ObjectStoreError> {
2623                self.inner.put_if_absent(key, body).await
2624            }
2625            async fn head(&self, key: &str) -> Result<ObjectMeta, ObjectStoreError> {
2626                self.inner.head(key).await
2627            }
2628            async fn copy(&self, src: &str, dst: &str) -> Result<(), ObjectStoreError> {
2629                self.inner.copy(src, dst).await
2630            }
2631            async fn delete(&self, key: &str) -> Result<(), ObjectStoreError> {
2632                self.log.lock().unwrap().push(Op::DeleteStart);
2633                let result = self.inner.delete(key).await;
2634                self.log.lock().unwrap().push(Op::DeleteEnd);
2635                result
2636            }
2637        }
2638
2639        let inner = Arc::new(MockStore::new());
2640        let put_gate = Arc::new(Notify::new());
2641        let log = Arc::new(Mutex::new(Vec::<Op>::new()));
2642        let store = Arc::new(GatedPutStore {
2643            inner: Arc::clone(&inner),
2644            put_gate: Arc::clone(&put_gate),
2645            log: Arc::clone(&log),
2646            gated_key: "k".to_owned(),
2647            gate_consumed: std::sync::atomic::AtomicBool::new(false),
2648        });
2649
2650        let now = OffsetDateTime::now_utc();
2651        let ttl = Duration::seconds(4);
2652        let guard = acquire_lock(Arc::clone(&store) as Arc<dyn ObjectStore>, "k", ttl, now)
2653            .await
2654            .unwrap()
2655            .expect("acquire must succeed");
2656
2657        // Yield so the heartbeat task is polled and consumes its
2658        // immediate (zero-duration) first `tick.tick()`. Without this
2659        // yield, the next `advance` would fire the immediate tick
2660        // first and then the periodic tick, but the task still
2661        // wouldn't be polled until the test yields again — we want
2662        // to be inside the loop body before advancing time.
2663        for _ in 0..4 {
2664            tokio::task::yield_now().await;
2665        }
2666
2667        // Drive one heartbeat tick. The heartbeat task issues
2668        // `put_bytes`, which the gate intercepts and parks before the
2669        // inner write completes. The interval is `ttl/3 = 1s` so any
2670        // advance >= 1s is sufficient.
2671        tokio::time::advance(std::time::Duration::from_secs(2)).await;
2672        // Yield repeatedly so the spawned heartbeat task is polled
2673        // and reaches the gate.
2674        for _ in 0..16 {
2675            tokio::task::yield_now().await;
2676            if !log.lock().unwrap().is_empty() {
2677                break;
2678            }
2679        }
2680        assert_eq!(
2681            log.lock().unwrap().as_slice(),
2682            &[Op::PutStart],
2683            "heartbeat PUT must be in flight before release fires",
2684        );
2685
2686        // Begin release on a separate task so we can observe the
2687        // ordering: release blocks on `stop_heartbeat`'s join-await,
2688        // which can only complete after the gated PUT finishes.
2689        let release_store = Arc::clone(&store);
2690        let release_handle = tokio::spawn(async move {
2691            let _ = release_store; // borrow check: keep store alive
2692            release_lock(guard).await
2693        });
2694
2695        // Let the release task get as far as it can — up to the
2696        // join-await on the heartbeat task.
2697        for _ in 0..8 {
2698            tokio::task::yield_now().await;
2699        }
2700        // No DELETE may have fired yet: the heartbeat task is still
2701        // inside the gated `put_bytes`.
2702        assert_eq!(
2703            log.lock().unwrap().as_slice(),
2704            &[Op::PutStart],
2705            "release must NOT issue DELETE while heartbeat PUT is in flight",
2706        );
2707
2708        // Open the gate: the in-flight PUT completes, the heartbeat
2709        // task exits the loop on its next shutdown re-check, and
2710        // release proceeds to DELETE.
2711        put_gate.notify_one();
2712        let result = release_handle.await.expect("release task panicked");
2713        result.expect("release_lock");
2714
2715        let final_log = log.lock().unwrap().clone();
2716        assert_eq!(
2717            final_log,
2718            vec![Op::PutStart, Op::PutEnd, Op::DeleteStart, Op::DeleteEnd],
2719            "operation order must be: heartbeat PUT completes, THEN release DELETE",
2720        );
2721        assert!(
2722            !inner.contains("k"),
2723            "lock key must be deleted after release"
2724        );
2725    }
2726
2727    // --- delete_remote_ref_under_lock ---------------------------------
2728
2729    #[tokio::test]
2730    async fn delete_remote_ref_removes_single_bundle() {
2731        let store = MockStore::new();
2732        let r = rn("refs/heads/main");
2733        store.insert(
2734            format!("repo/refs/heads/main/{SHA}.bundle"),
2735            Bytes::from_static(b"b"),
2736        );
2737        let outcome = delete_remote_ref_under_lock(
2738            &store,
2739            Some("repo"),
2740            &r,
2741            "repo/refs/heads/main/LOCK#.lock",
2742        )
2743        .await
2744        .unwrap();
2745        assert_eq!(
2746            outcome,
2747            PushOutcome::Ok {
2748                remote_ref: "refs/heads/main".into()
2749            }
2750        );
2751        // Prefix-empty oracle (lesson 15): no key survives under the ref
2752        // prefix after a successful delete. Stronger than `!contains(bundle)`
2753        // because a regression that wrote a tombstone or other residue
2754        // (e.g. a re-introduction of c5468b4's bundle-engine tombstone)
2755        // would also trip it.
2756        let remaining: Vec<_> = store
2757            .keys()
2758            .into_iter()
2759            .filter(|k| k.starts_with("repo/refs/heads/main/"))
2760            .collect();
2761        assert!(
2762            remaining.is_empty(),
2763            "ref prefix must be empty after delete: {remaining:?}",
2764        );
2765        // Bundle-engine delete must not write a tombstone — tombstoning
2766        // is a packchain-only deferral (#143, #203).
2767        let gc_keys: Vec<_> = store
2768            .keys()
2769            .into_iter()
2770            .filter(|k| k.starts_with("repo/gc/"))
2771            .collect();
2772        assert!(
2773            gc_keys.is_empty(),
2774            "bundle-engine delete must not write a tombstone: {gc_keys:?}",
2775        );
2776    }
2777
2778    #[tokio::test]
2779    async fn delete_remote_ref_returns_not_found_when_empty() {
2780        let store = MockStore::new();
2781        let r = rn("refs/heads/main");
2782        let outcome = delete_remote_ref_under_lock(
2783            &store,
2784            Some("repo"),
2785            &r,
2786            "repo/refs/heads/main/LOCK#.lock",
2787        )
2788        .await
2789        .unwrap();
2790        match outcome {
2791            PushOutcome::Error { message, .. } => {
2792                assert_eq!(message, r#""not found"?"#);
2793            }
2794            PushOutcome::Ok { .. } => panic!("expected Error outcome"),
2795        }
2796    }
2797
2798    #[tokio::test]
2799    async fn delete_remote_ref_rejects_protected_marker() {
2800        // PROTECTED# is unfiltered for the delete-path count, but we
2801        // detect the marker before the generic multi-bundle error and
2802        // emit a protection-specific refusal that names `unprotect`.
2803        let store = MockStore::new();
2804        let r = rn("refs/heads/main");
2805        let bundle = format!("repo/refs/heads/main/{SHA}.bundle");
2806        let protected = "repo/refs/heads/main/PROTECTED#";
2807        store.insert(&bundle, Bytes::from_static(b"b"));
2808        store.insert(protected, Bytes::from_static(b""));
2809        let outcome = delete_remote_ref_under_lock(
2810            &store,
2811            Some("repo"),
2812            &r,
2813            "repo/refs/heads/main/LOCK#.lock",
2814        )
2815        .await
2816        .unwrap();
2817        match outcome {
2818            PushOutcome::Error { message, .. } => {
2819                assert_eq!(
2820                    message,
2821                    r#""ref is protected. Run git-remote-object-store unprotect <url> <branch> to remove protection before deleting."?"#,
2822                );
2823            }
2824            PushOutcome::Ok { .. } => panic!("expected Error outcome"),
2825        }
2826        // Both keys must remain — a regression that deleted on the way
2827        // to the error branch would still satisfy the message check.
2828        assert!(store.contains(&bundle));
2829        assert!(store.contains(protected));
2830    }
2831
2832    #[tokio::test]
2833    async fn delete_remote_ref_reports_corruption_without_protected_marker() {
2834        // Two bundles, no PROTECTED# marker → genuine corruption case
2835        // still falls through to the doctor message.
2836        let store = MockStore::new();
2837        let r = rn("refs/heads/main");
2838        let bundle_a = format!("repo/refs/heads/main/{SHA}.bundle");
2839        let bundle_b = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
2840        store.insert(&bundle_a, Bytes::from_static(b"a"));
2841        store.insert(&bundle_b, Bytes::from_static(b"b"));
2842        let outcome = delete_remote_ref_under_lock(
2843            &store,
2844            Some("repo"),
2845            &r,
2846            "repo/refs/heads/main/LOCK#.lock",
2847        )
2848        .await
2849        .unwrap();
2850        match outcome {
2851            PushOutcome::Error { message, .. } => {
2852                assert_eq!(
2853                    message,
2854                    r#""multiple bundles exist on server. Run git-remote-object-store doctor to fix."?"#,
2855                );
2856            }
2857            PushOutcome::Ok { .. } => panic!("expected Error outcome"),
2858        }
2859        assert!(store.contains(&bundle_a));
2860        assert!(store.contains(&bundle_b));
2861    }
2862
2863    /// Issue #128 / #242: the canonical PROTECTED# guard must reject
2864    /// before any sweep, even when a bundle rides alongside the marker.
2865    /// The guard runs first against the unfiltered under-lock listing, so
2866    /// a `[bundle, PROTECTED#]` listing surfaces the protection refusal
2867    /// rather than sweeping the marker — independent of the connection's
2868    /// `zip` flag.
2869    #[tokio::test]
2870    async fn delete_remote_ref_rejects_protected_marker_alongside_bundle() {
2871        let store = MockStore::new();
2872        let r = rn("refs/heads/main");
2873        let bundle = format!("repo/refs/heads/main/{SHA}.bundle");
2874        let protected = "repo/refs/heads/main/PROTECTED#";
2875        store.insert(&bundle, Bytes::from_static(b"b"));
2876        store.insert(protected, Bytes::from_static(b""));
2877        let outcome = delete_remote_ref_under_lock(
2878            &store,
2879            Some("repo"),
2880            &r,
2881            "repo/refs/heads/main/LOCK#.lock",
2882        )
2883        .await
2884        .unwrap();
2885        match outcome {
2886            PushOutcome::Error { message, .. } => {
2887                assert_eq!(
2888                    message,
2889                    r#""ref is protected. Run git-remote-object-store unprotect <url> <branch> to remove protection before deleting."?"#,
2890                );
2891            }
2892            PushOutcome::Ok { .. } => panic!("expected protection-refusal Error"),
2893        }
2894        assert!(store.contains(&bundle), "bundle must survive");
2895        assert!(store.contains(protected), "marker must survive");
2896    }
2897
2898    /// Issue #128: the protection guard must also fire when the lone
2899    /// remaining key under the ref prefix is the PROTECTED# marker
2900    /// itself. Pre-#128, `entries == [PROTECTED#]` matched `expected = 1`
2901    /// in non-zip mode and the marker was swept — the next push to the
2902    /// ref would then succeed against an unprotected branch even though
2903    /// the operator never ran `unprotect`. Pin the guard: the marker
2904    /// survives and the wire error is the protection-specific message.
2905    #[tokio::test]
2906    async fn delete_remote_ref_rejects_protected_marker_when_only_marker_present() {
2907        let store = MockStore::new();
2908        let r = rn("refs/heads/main");
2909        let protected = "repo/refs/heads/main/PROTECTED#";
2910        store.insert(protected, Bytes::from_static(b""));
2911        let outcome = delete_remote_ref_under_lock(
2912            &store,
2913            Some("repo"),
2914            &r,
2915            "repo/refs/heads/main/LOCK#.lock",
2916        )
2917        .await
2918        .unwrap();
2919        match outcome {
2920            PushOutcome::Error { message, .. } => {
2921                assert_eq!(
2922                    message,
2923                    r#""ref is protected. Run git-remote-object-store unprotect <url> <branch> to remove protection before deleting."?"#,
2924                );
2925            }
2926            PushOutcome::Ok { .. } => panic!("expected protection-refusal Error"),
2927        }
2928        assert!(store.contains(protected), "marker must survive");
2929    }
2930
2931    /// Issue #128: simulate the TOCTOU sequence the bug describes.
2932    /// Client A starts a delete; between `acquire_lock` and the
2933    /// under-lock listing, a marker appears at PROTECTED#. After #159
2934    /// `protect` itself acquires the same lock and cannot race here,
2935    /// but the under-lock listing remains the canonical guard against
2936    /// any other source of a same-key marker (a lock-bypass bug, a
2937    /// non-cooperating client). The fresh under-lock listing reflects
2938    /// the marker, so the canonical guard rejects the delete. Both the
2939    /// bundle and the marker survive.
2940    #[tokio::test]
2941    async fn delete_remote_ref_rejects_protect_landed_between_acquire_and_list() {
2942        let store = MockStore::new();
2943        let r = rn("refs/heads/main");
2944        let bundle = format!("repo/refs/heads/main/{SHA}.bundle");
2945        let lock_key = "repo/refs/heads/main/LOCK#.lock";
2946        let protected = "repo/refs/heads/main/PROTECTED#";
2947        // Client A acquired the lock; bundle was already present.
2948        store.insert(&bundle, Bytes::from_static(b"b"));
2949        store.insert(lock_key, Bytes::from_static(b"held-lock-payload"));
2950        // A PROTECTED# marker appears AFTER our lock-acquire but BEFORE
2951        // our under-lock listing — exactly the window the pre-#128
2952        // ordering left open. Post-#159 `protect` cannot reach this
2953        // window itself, but the under-lock listing must still catch
2954        // markers from any other source (lock-bypass bug, non-cooperating
2955        // client).
2956        store.insert(protected, Bytes::from_static(b""));
2957
2958        let outcome = delete_remote_ref_under_lock(&store, Some("repo"), &r, lock_key)
2959            .await
2960            .unwrap();
2961
2962        match outcome {
2963            PushOutcome::Error { message, .. } => {
2964                assert_eq!(
2965                    message,
2966                    r#""ref is protected. Run git-remote-object-store unprotect <url> <branch> to remove protection before deleting."?"#,
2967                );
2968            }
2969            PushOutcome::Ok { .. } => panic!("expected protection-refusal Error"),
2970        }
2971        assert!(store.contains(&bundle), "bundle must survive");
2972        assert!(store.contains(protected), "marker must survive");
2973        assert!(store.contains(lock_key), "held lock must survive");
2974    }
2975
2976    /// Issue #242: a single bundle with a `repo.zip` sibling sweeps both
2977    /// and reports `ok`. Deletability is decided by entry shape, so this
2978    /// holds regardless of the connection's `zip` flag.
2979    #[tokio::test]
2980    async fn delete_remote_ref_sweeps_bundle_and_zip_sibling() {
2981        let store = MockStore::new();
2982        let r = rn("refs/heads/main");
2983        let bundle = format!("repo/refs/heads/main/{SHA}.bundle");
2984        let zip = "repo/refs/heads/main/repo.zip";
2985        store.insert(&bundle, Bytes::from_static(b"b"));
2986        store.insert(zip, Bytes::from_static(b""));
2987        let outcome = delete_remote_ref_under_lock(
2988            &store,
2989            Some("repo"),
2990            &r,
2991            "repo/refs/heads/main/LOCK#.lock",
2992        )
2993        .await
2994        .unwrap();
2995        assert_eq!(
2996            outcome,
2997            PushOutcome::Ok {
2998                remote_ref: "refs/heads/main".into()
2999            }
3000        );
3001        assert!(!store.contains(&bundle));
3002        assert!(!store.contains(zip));
3003    }
3004
3005    /// Issue #242 regression: a ref whose only on-server state is a
3006    /// single `<sha>.bundle` (no `repo.zip` sibling) must delete cleanly
3007    /// even though the push connection was opened with `?zip=1`. The
3008    /// `repo.zip` upload is best-effort, so a zip-mode ref can legitimately
3009    /// carry only its bundle. Pre-#242 the count-vs-`zip`-flag assertion
3010    /// (`entries.len() == 2`) mis-routed this `len == 1` listing to the
3011    /// "multiple bundles exist" corruption branch. The fix routes on the
3012    /// bundle count, so the lone bundle is swept and `ok` is reported.
3013    #[tokio::test]
3014    async fn delete_remote_ref_zip_mode_with_only_bundle_sweeps_and_oks() {
3015        let store = MockStore::new();
3016        let r = rn("refs/heads/main");
3017        let bundle = format!("repo/refs/heads/main/{SHA}.bundle");
3018        store.insert(&bundle, Bytes::from_static(b"b"));
3019        // The connection's `zip` flag is no longer an input to the delete
3020        // path — this listing is exactly what a `?zip=1` ref looks like
3021        // after a swallowed `repo.zip` upload failure.
3022        let outcome = delete_remote_ref_under_lock(
3023            &store,
3024            Some("repo"),
3025            &r,
3026            "repo/refs/heads/main/LOCK#.lock",
3027        )
3028        .await
3029        .unwrap();
3030        assert_eq!(
3031            outcome,
3032            PushOutcome::Ok {
3033                remote_ref: "refs/heads/main".into()
3034            }
3035        );
3036        assert!(!store.contains(&bundle), "bundle must be swept");
3037    }
3038
3039    /// Issue #242: a ref carrying only an orphaned `repo.zip` and no
3040    /// `<sha>.bundle` has zero bundles, so the delete reports `not found`
3041    /// rather than `ok`. The orphan archive is left untouched — sweeping a
3042    /// sibling with no owning bundle would be deleting state the delete
3043    /// path cannot attribute to this ref's lifecycle.
3044    #[tokio::test]
3045    async fn delete_remote_ref_with_only_orphan_zip_reports_not_found() {
3046        let store = MockStore::new();
3047        let r = rn("refs/heads/main");
3048        let zip = "repo/refs/heads/main/repo.zip";
3049        store.insert(zip, Bytes::from_static(b""));
3050        let outcome = delete_remote_ref_under_lock(
3051            &store,
3052            Some("repo"),
3053            &r,
3054            "repo/refs/heads/main/LOCK#.lock",
3055        )
3056        .await
3057        .unwrap();
3058        match outcome {
3059            PushOutcome::Error { message, .. } => assert_eq!(message, r#""not found"?"#),
3060            PushOutcome::Ok { .. } => panic!("expected not-found Error outcome"),
3061        }
3062        assert!(
3063            store.contains(zip),
3064            "orphan zip must survive a no-bundle delete",
3065        );
3066    }
3067
3068    /// Issue #242: two distinct `<sha>.bundle` keys still trip the
3069    /// multi-bundle corruption guard even when a `repo.zip` sibling is
3070    /// also present — the sibling does not mask the corruption, and
3071    /// neither bundle is swept. (That the sibling is not itself counted
3072    /// as a bundle is pinned at the 0-/1-bundle boundary by
3073    /// `delete_remote_ref_with_only_orphan_zip_reports_not_found`, where
3074    /// miscounting would flip the outcome; at this 2-bundle boundary it
3075    /// could not.)
3076    #[tokio::test]
3077    async fn delete_remote_ref_two_bundles_with_zip_sibling_reports_corruption() {
3078        let store = MockStore::new();
3079        let r = rn("refs/heads/main");
3080        let bundle_a = format!("repo/refs/heads/main/{SHA}.bundle");
3081        let bundle_b = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
3082        let zip = "repo/refs/heads/main/repo.zip";
3083        store.insert(&bundle_a, Bytes::from_static(b"a"));
3084        store.insert(&bundle_b, Bytes::from_static(b"b"));
3085        store.insert(zip, Bytes::from_static(b""));
3086        let outcome = delete_remote_ref_under_lock(
3087            &store,
3088            Some("repo"),
3089            &r,
3090            "repo/refs/heads/main/LOCK#.lock",
3091        )
3092        .await
3093        .unwrap();
3094        match outcome {
3095            PushOutcome::Error { message, .. } => assert_eq!(
3096                message,
3097                r#""multiple bundles exist on server. Run git-remote-object-store doctor to fix."?"#,
3098            ),
3099            PushOutcome::Ok { .. } => panic!("expected corruption Error outcome"),
3100        }
3101        assert!(store.contains(&bundle_a), "bundle a must survive");
3102        assert!(store.contains(&bundle_b), "bundle b must survive");
3103        assert!(store.contains(zip), "zip sibling must survive");
3104    }
3105
3106    // --- PushOutcome rendering ----------------------------------------
3107
3108    #[test]
3109    fn push_outcome_renders_ok_line() {
3110        let line = PushOutcome::Ok {
3111            remote_ref: "refs/heads/main".into(),
3112        }
3113        .to_protocol_line();
3114        assert_eq!(line, "ok refs/heads/main\n");
3115    }
3116
3117    #[test]
3118    fn push_outcome_renders_error_line() {
3119        let line = PushOutcome::Error {
3120            remote_ref: "refs/heads/main".into(),
3121            message: r#""bad"?"#.into(),
3122        }
3123        .to_protocol_line();
3124        assert_eq!(line, "error refs/heads/main \"bad\"?\n");
3125    }
3126
3127    /// Both duplicate-bundle paths (pre-lock at ~line 482 and under-lock
3128    /// at ~line 600) must produce wire output ending in `"?\n`. The `?`
3129    /// suffix is the project-wide Rust convention for `error <ref> "..."`
3130    /// messages — git treats `"..."?` as recoverable and `"..."` as
3131    /// fatal. Both branches normalize to the recoverable form.
3132    #[test]
3133    fn duplicate_bundle_errors_use_consistent_wire_format() {
3134        let pre_lock_line = PushOutcome::Error {
3135            remote_ref: "refs/heads/main".into(),
3136            message:
3137                r#""multiple bundles exist on server. Run git-remote-object-store doctor to fix."?"#
3138                    .to_owned(),
3139        }
3140        .to_protocol_line();
3141        let under_lock_line = PushOutcome::Error {
3142            remote_ref: "refs/heads/main".into(),
3143            message: r#""multiple bundles exist for the same ref on server. Run git-remote-object-store doctor to fix."?"#.to_owned(),
3144        }
3145        .to_protocol_line();
3146
3147        assert_eq!(
3148            pre_lock_line,
3149            "error refs/heads/main \"multiple bundles exist on server. \
3150             Run git-remote-object-store doctor to fix.\"?\n",
3151        );
3152        assert_eq!(
3153            under_lock_line,
3154            "error refs/heads/main \"multiple bundles exist for the same ref on server. \
3155             Run git-remote-object-store doctor to fix.\"?\n",
3156        );
3157        assert!(pre_lock_line.ends_with("\"?\n"));
3158        assert!(under_lock_line.ends_with("\"?\n"));
3159    }
3160
3161    // --- lock_ttl_from_env --------------------------------------------
3162
3163    #[test]
3164    fn lock_ttl_env_override_falls_back_for_unset_invalid_or_zero() {
3165        // Group all env-var cases in one test fn so they share a single
3166        // `EnvGuard` and its per-key lock — the var is process-global,
3167        // and the guard serialises against `manage::doctor`'s
3168        // env-touching test that pokes the same key. Drop restores the
3169        // prior value on every exit path, including assertion panics.
3170        let env = crate::test_util::EnvGuard::take(ENV_LOCK_TTL_SECONDS);
3171        let default_ttl = Duration::seconds(i64::try_from(DEFAULT_LOCK_TTL_SECONDS).unwrap());
3172        // Unset returns default.
3173        env.clear();
3174        assert_eq!(lock_ttl_from_env(), default_ttl);
3175        // `None` and `Some(0)` (issue #208) defer to env-or-default.
3176        assert_eq!(
3177            resolve_lock_ttl_seconds(None),
3178            DEFAULT_LOCK_TTL_SECONDS,
3179            "None must defer to env-or-default",
3180        );
3181        assert_eq!(
3182            resolve_lock_ttl_seconds(Some(0)),
3183            DEFAULT_LOCK_TTL_SECONDS,
3184            "Some(0) must not defeat per-ref locking (issue #208)",
3185        );
3186        // Non-numeric falls back.
3187        env.set_to("not-a-number");
3188        assert_eq!(lock_ttl_from_env(), default_ttl);
3189        // Zero falls back (would defeat per-ref locking).
3190        env.set_to("0");
3191        assert_eq!(lock_ttl_from_env(), default_ttl);
3192        // Positive integer wins.
3193        env.set_to("120");
3194        assert_eq!(lock_ttl_from_env(), Duration::seconds(120));
3195        // With env set, `None` and `Some(0)` honour the env override —
3196        // an operator's env var must still take effect when a CLI
3197        // consumer accidentally passes the wrong default.
3198        assert_eq!(
3199            resolve_lock_ttl_seconds(None),
3200            120,
3201            "None must honour env override",
3202        );
3203        assert_eq!(
3204            resolve_lock_ttl_seconds(Some(0)),
3205            120,
3206            "Some(0) must honour env override",
3207        );
3208    }
3209
3210    // --- saturating_duration_seconds (issue #221) ---------------------
3211
3212    #[test]
3213    fn saturating_duration_seconds_caps_at_i64_max() {
3214        // `u64::MAX` exceeds `i64::MAX` — the helper must saturate at
3215        // `i64::MAX` rather than panic on the `try_from`. This is the
3216        // ~292-billion-year sentinel ceiling shared by all TTL paths.
3217        assert_eq!(
3218            saturating_duration_seconds(u64::MAX),
3219            Duration::seconds(i64::MAX),
3220        );
3221    }
3222
3223    #[test]
3224    fn saturating_duration_seconds_passes_normal_value() {
3225        assert_eq!(saturating_duration_seconds(60), Duration::seconds(60));
3226    }
3227
3228    // --- resolve_lock_ttl_seconds (issue #208) ------------------------
3229    //
3230    // `Compact::run_into` used to accept `Some(0)` and feed it straight
3231    // into the engine, bypassing the `lock_ttl_from_env` zero-clamp
3232    // from #112 and defeating per-ref locking. The shared resolver
3233    // collapses both `None` and `Some(0)` onto the env-or-default path
3234    // so the lock-acquiring call site cannot re-introduce the footgun.
3235    // `Doctor::resolved_lock_ttl_seconds` deliberately does NOT route
3236    // through this resolver — doctor only compares lock ages and never
3237    // acquires a lock, so an operator-explicit `Some(0)` is a valid
3238    // "treat every lock as stale" request and is honoured.
3239
3240    #[test]
3241    fn resolve_lock_ttl_some_positive_returns_unchanged() {
3242        // Positive values bypass the env entirely and so are safe to
3243        // assert in parallel with the env-touching test below. Cover
3244        // the smallest valid value, a normal value, and the u64 ceiling
3245        // — the ceiling documents the deliberate decision to not impose
3246        // an upper bound: downstream `time::Duration::seconds` saturates
3247        // safely at `i64::MAX`.
3248        assert_eq!(resolve_lock_ttl_seconds(Some(1)), 1);
3249        assert_eq!(resolve_lock_ttl_seconds(Some(120)), 120);
3250        assert_eq!(resolve_lock_ttl_seconds(Some(u64::MAX)), u64::MAX);
3251    }
3252
3253    // --- FORMAT key write via perform_push_under_lock --------------------
3254
3255    /// Helper: run `perform_push_under_lock` against a temporary bundle file
3256    /// so we can assert on the resulting store state.
3257    async fn push_under_lock_with_bundle(
3258        store: &MockStore,
3259        prefix: Option<&str>,
3260        engine: StorageEngine,
3261    ) -> PushOutcome {
3262        let r = rn("refs/heads/main");
3263        let temp_dir = tempfile::Builder::new()
3264            .prefix("test_push_")
3265            .tempdir()
3266            .unwrap();
3267        let bundle_path = temp_dir.path().join("bundle");
3268        std::fs::write(&bundle_path, b"fake bundle").unwrap();
3269
3270        let state = PushReadyState {
3271            remote_ref: r,
3272            local_sha: Sha::from_hex(SHA).unwrap(),
3273            pre_existing: None,
3274            bundle_path,
3275            zip_artifacts: None,
3276            engine,
3277            force: false,
3278            pre_existing_was_ancestor: true,
3279            local_spec: "refs/heads/main".to_owned(),
3280            hidden_bundles: HashSet::new(),
3281            _temp_dir: temp_dir,
3282        };
3283
3284        perform_push_under_lock(store, prefix, BackendKind::S3, state)
3285            .await
3286            .unwrap()
3287    }
3288
3289    #[tokio::test]
3290    async fn perform_push_under_lock_writes_format_key_on_first_push() {
3291        let store = MockStore::new();
3292        let outcome =
3293            push_under_lock_with_bundle(&store, Some("repo"), StorageEngine::Bundle).await;
3294        assert!(
3295            matches!(outcome, PushOutcome::Ok { .. }),
3296            "expected Ok outcome"
3297        );
3298        assert!(
3299            store.contains("repo/FORMAT"),
3300            "FORMAT key must be written on the first push",
3301        );
3302        let content = store.get_bytes("repo/FORMAT").await.unwrap();
3303        assert_eq!(content.as_ref(), b"bundle");
3304    }
3305
3306    #[tokio::test]
3307    async fn perform_push_under_lock_writes_format_key_without_prefix() {
3308        let store = MockStore::new();
3309        let outcome = push_under_lock_with_bundle(&store, None, StorageEngine::Bundle).await;
3310        assert!(
3311            matches!(outcome, PushOutcome::Ok { .. }),
3312            "expected Ok outcome"
3313        );
3314        assert!(
3315            store.contains("FORMAT"),
3316            "FORMAT key must be written at root when no prefix",
3317        );
3318        let content = store.get_bytes("FORMAT").await.unwrap();
3319        assert_eq!(content.as_ref(), b"bundle");
3320    }
3321
3322    #[tokio::test]
3323    async fn perform_push_under_lock_format_key_is_idempotent() {
3324        // If FORMAT already exists (second push), put_if_absent is a no-op.
3325        // Pre-insert with a trailing newline so the original bytes differ from
3326        // what the push would write — a plain `put` would overwrite to
3327        // b"bundle", while put_if_absent must preserve b"bundle\n".
3328        let store = MockStore::new();
3329        store.insert("repo/FORMAT", Bytes::from_static(b"bundle\n"));
3330        let outcome =
3331            push_under_lock_with_bundle(&store, Some("repo"), StorageEngine::Bundle).await;
3332        assert!(
3333            matches!(outcome, PushOutcome::Ok { .. }),
3334            "expected Ok outcome"
3335        );
3336        // Original content preserved — put_if_absent did not overwrite.
3337        let content = store.get_bytes("repo/FORMAT").await.unwrap();
3338        assert_eq!(content.as_ref(), b"bundle\n");
3339    }
3340
3341    // --- stale-remote guard -------------------------------------------
3342
3343    /// Build a `PushReadyState` with a specific `pre_existing` key and a
3344    /// matching `bundle_path` on disk. The store is left in whatever state
3345    /// the caller configured (e.g. with a pre-seeded bundle key); the
3346    /// caller already knows which prefix it seeded under, so this helper
3347    /// does not need to take it.
3348    fn push_state_with_pre_existing(pre_existing: Option<String>) -> PushReadyState {
3349        let r = rn("refs/heads/main");
3350        let temp_dir = tempfile::Builder::new()
3351            .prefix("test_push_")
3352            .tempdir()
3353            .unwrap();
3354        let bundle_path = temp_dir.path().join("bundle");
3355        std::fs::write(&bundle_path, b"fake bundle").unwrap();
3356        PushReadyState {
3357            remote_ref: r,
3358            local_sha: Sha::from_hex(SHA).unwrap(),
3359            pre_existing,
3360            bundle_path,
3361            zip_artifacts: None,
3362            engine: StorageEngine::Bundle,
3363            force: false,
3364            pre_existing_was_ancestor: true,
3365            local_spec: "refs/heads/main".to_owned(),
3366            hidden_bundles: HashSet::new(),
3367            _temp_dir: temp_dir,
3368        }
3369    }
3370
3371    /// `pre_existing=None`, `current_key=Some(...)`: a concurrent push
3372    /// created a bundle after our pre-lock list but before we acquired
3373    /// the lock.
3374    ///
3375    /// The seeded `existing_key` deliberately uses a SHA distinct from
3376    /// the local push's `local_sha` (= `SHA`). If they matched, a
3377    /// regression that compared `current_key` against `bundle_dest`
3378    /// (the key derived from `local_sha`) instead of against
3379    /// `pre_existing` could pass for the wrong reason — the keys
3380    /// happen to align. With distinct SHAs the only way the test
3381    /// passes is the correct comparison: `pre_existing(None) !=
3382    /// current_key(Some)`.
3383    #[tokio::test]
3384    async fn perform_push_under_lock_rejects_none_to_some_stale_remote() {
3385        let store = MockStore::new();
3386        let existing_key = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
3387        store.insert(&existing_key, Bytes::from_static(b"old bundle"));
3388        let state = push_state_with_pre_existing(None);
3389        let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
3390            .await
3391            .unwrap();
3392        assert!(
3393            matches!(
3394                &outcome,
3395                PushOutcome::Error { message, .. }
3396                    if message == r#""stale remote. Please fetch and retry."?"#
3397            ),
3398            "expected stale-remote error, got {outcome:?}",
3399        );
3400    }
3401
3402    /// `pre_existing=Some(key)`, `current_key=None`: the bundle was
3403    /// deleted between our pre-lock list and the lock acquisition (e.g.
3404    /// a concurrent `git push :<ref>` delete).
3405    #[tokio::test]
3406    async fn perform_push_under_lock_rejects_some_to_none_stale_remote() {
3407        let store = MockStore::new();
3408        let old_key = format!("repo/refs/heads/main/{SHA}.bundle");
3409        let state = push_state_with_pre_existing(Some(old_key.clone()));
3410        // Store is empty — the previously-seen bundle is gone.
3411        let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
3412            .await
3413            .unwrap();
3414        assert!(
3415            matches!(
3416                &outcome,
3417                PushOutcome::Error { message, .. }
3418                    if message == r#""stale remote. Please fetch and retry."?"#
3419            ),
3420            "expected stale-remote error, got {outcome:?}",
3421        );
3422    }
3423
3424    /// `pre_existing=Some(key_a)`, `current_key=Some(key_b)` where
3425    /// `key_a != key_b`: a concurrent push replaced our bundle.
3426    #[tokio::test]
3427    async fn perform_push_under_lock_rejects_replaced_bundle_stale_remote() {
3428        let store = MockStore::new();
3429        let old_sha = SHA;
3430        let new_sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
3431        let old_key = format!("repo/refs/heads/main/{old_sha}.bundle");
3432        let new_key = format!("repo/refs/heads/main/{new_sha}.bundle");
3433        // Under-lock the store shows the *new* key.
3434        store.insert(&new_key, Bytes::from_static(b"new bundle"));
3435        let state = push_state_with_pre_existing(Some(old_key));
3436        let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
3437            .await
3438            .unwrap();
3439        assert!(
3440            matches!(
3441                &outcome,
3442                PushOutcome::Error { message, .. }
3443                    if message == r#""stale remote. Please fetch and retry."?"#
3444            ),
3445            "expected stale-remote error, got {outcome:?}",
3446        );
3447    }
3448
3449    /// Under-lock re-listing sees two bundles for the same ref (two clients
3450    /// raced and both uploaded before either acquired the lock). Must return
3451    /// the under-lock duplicate-bundle error before reaching the stale-remote
3452    /// guard or the upload.
3453    #[tokio::test]
3454    async fn perform_push_under_lock_rejects_two_bundles_seen_under_lock() {
3455        let store = MockStore::new();
3456        let sha_a = "1111111111111111111111111111111111111111";
3457        let sha_b = "2222222222222222222222222222222222222222";
3458        store.insert(
3459            format!("repo/refs/heads/main/{sha_a}.bundle"),
3460            Bytes::from_static(b"bundle_a"),
3461        );
3462        store.insert(
3463            format!("repo/refs/heads/main/{sha_b}.bundle"),
3464            Bytes::from_static(b"bundle_b"),
3465        );
3466        // Pre-lock snapshot saw zero bundles; under-lock sees two.
3467        let state = push_state_with_pre_existing(None);
3468        let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
3469            .await
3470            .unwrap();
3471        assert!(
3472            matches!(
3473                &outcome,
3474                PushOutcome::Error { message, .. }
3475                    if message == r#""multiple bundles exist for the same ref on server. Run git-remote-object-store doctor to fix."?"#
3476            ),
3477            "expected under-lock multi-bundle error, got {outcome:?}",
3478        );
3479        // Neither bundle was overwritten or deleted.
3480        assert!(store.contains(&format!("repo/refs/heads/main/{sha_a}.bundle")));
3481        assert!(store.contains(&format!("repo/refs/heads/main/{sha_b}.bundle")));
3482    }
3483
3484    /// Stale-remote happy path: `pre_existing == current_key`. The four
3485    /// drift scenarios above all assert that mismatch produces an error.
3486    /// This case asserts that the *match* path goes through to a normal
3487    /// `Ok(remote_ref)` push outcome — without it, a regression that
3488    /// flipped the comparison to always-mismatch would pass every drift
3489    /// test and silently break every real push.
3490    ///
3491    /// `pre_existing` is seeded under SHA distinct from `local_sha`
3492    /// (mirroring the `rejects_none_to_some` hardening): so a buggy
3493    /// regression that compared `current_key` against `bundle_dest`
3494    /// (the key derived from `local_sha`) instead of against
3495    /// `pre_existing` cannot pass for the wrong reason — the keys
3496    /// are deliberately different. The push must:
3497    ///   1. produce `Ok(remote_ref)`
3498    ///   2. upload the new bundle at `bundle_dest` (SHA = `local_sha`)
3499    ///   3. write a baseline tombstone naming `OTHER_SHA` (issue #157
3500    ///      defers the prior-bundle delete to `gc sweep`) and leave
3501    ///      the prior bundle in place until grace expires
3502    ///   4. write `HEAD` and `FORMAT`
3503    #[tokio::test]
3504    async fn perform_push_under_lock_passes_through_when_pre_existing_matches_current() {
3505        let store = MockStore::new();
3506        let pre_key = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
3507        // Seed under `OTHER_SHA` so under-lock list returns this key.
3508        // The local push's `local_sha` is `SHA`, so `bundle_dest` is a
3509        // DIFFERENT key. The stale-remote check passes (pre_existing ==
3510        // current_key, both = pre_key); the function then uploads the
3511        // new bundle at `bundle_dest` and writes a baseline tombstone
3512        // naming the old bundle's SHA.
3513        store.insert(&pre_key, Bytes::from_static(b"old bundle"));
3514        let state = push_state_with_pre_existing(Some(pre_key.clone()));
3515        let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
3516            .await
3517            .unwrap();
3518        assert!(
3519            matches!(&outcome, PushOutcome::Ok { remote_ref } if remote_ref == "refs/heads/main"),
3520            "expected Ok(refs/heads/main), got {outcome:?}",
3521        );
3522        // The new bundle must land at the bundle_dest derived from
3523        // local_sha, not at pre_key.
3524        let bundle_dest = format!("repo/refs/heads/main/{SHA}.bundle");
3525        let new_bytes = store
3526            .get_bytes(&bundle_dest)
3527            .await
3528            .expect("new bundle must be uploaded at bundle_dest");
3529        assert_eq!(
3530            new_bytes.as_ref(),
3531            b"fake bundle",
3532            "new bundle must contain the local payload",
3533        );
3534        // Issue #157: the old bundle must remain readable — fetchers
3535        // that advertised it via an earlier `list` need the grace
3536        // window. `gc sweep` will reclaim it later.
3537        assert!(
3538            store.contains(&pre_key),
3539            "old bundle at pre_key must survive the push (deferred via tombstone)",
3540        );
3541        // A single baseline tombstone naming the prior SHA must exist.
3542        let tomb_listing = baseline_tombstone_listing_prefix(Some("repo"));
3543        let metas = store.list("repo/gc/").await.unwrap();
3544        let tombstones: Vec<_> = metas
3545            .iter()
3546            .filter(|m| m.key.starts_with(&tomb_listing))
3547            .collect();
3548        assert_eq!(
3549            tombstones.len(),
3550            1,
3551            "exactly one baseline tombstone must be written; got keys: {:?}",
3552            tombstones.iter().map(|m| &m.key).collect::<Vec<_>>(),
3553        );
3554        let body = store.get_bytes(&tombstones[0].key).await.unwrap();
3555        let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
3556        assert_eq!(
3557            parsed["ref_name"].as_str(),
3558            Some("refs/heads/main"),
3559            "tombstone must name the pushed ref",
3560        );
3561        assert_eq!(
3562            parsed["sha"].as_str(),
3563            Some(OTHER_SHA),
3564            "tombstone must name the prior SHA so `gc sweep` reclaims OTHER_SHA.bundle",
3565        );
3566        assert!(
3567            store.contains("repo/FORMAT"),
3568            "FORMAT key must be written by the push",
3569        );
3570        assert!(
3571            store.contains("repo/HEAD"),
3572            "HEAD key must be written by the push",
3573        );
3574    }
3575
3576    /// Issue #121 + #157 regression: the prior-bundle cleanup must
3577    /// never fail the push. The new bundle is already durable;
3578    /// reporting failure misrepresents the remote state. Match the
3579    /// `compact` / `force_push_baseline_cleanup` best-effort contract:
3580    /// log at warn and report success.
3581    ///
3582    /// Under issue #157 the preferred cleanup path is a baseline
3583    /// tombstone (deferred reclamation). When the tombstone PUT
3584    /// itself fails, the fallback synchronous delete runs — so this
3585    /// test arms a fault on BOTH the tombstone PUT and the prior
3586    /// bundle's delete to exercise the worst-case orphan path. The
3587    /// two-bundle state remains on the bucket so the next push's
3588    /// under-lock multi-bundle guard surfaces it to the operator.
3589    #[tokio::test]
3590    async fn perform_push_under_lock_succeeds_when_prior_cleanup_fails() {
3591        use crate::object_store::mock::Fault;
3592        let store = MockStore::new();
3593        let pre_key = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
3594        store.insert(&pre_key, Bytes::from_static(b"old bundle"));
3595        // Fail the tombstone PUT (any key under the baseline-tomb
3596        // namespace) AND the fallback synchronous delete of the
3597        // prior bundle. The push must still report Ok.
3598        store.arm(Fault::NetworkOnPutBytesPrefix {
3599            prefix: baseline_tombstone_listing_prefix(Some("repo")),
3600        });
3601        store.arm(Fault::NetworkOnDelete {
3602            key: pre_key.clone(),
3603        });
3604
3605        let state = push_state_with_pre_existing(Some(pre_key.clone()));
3606        let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
3607            .await
3608            .expect("push must succeed even when prior-bundle cleanup fails");
3609        assert!(
3610            matches!(&outcome, PushOutcome::Ok { remote_ref } if remote_ref == "refs/heads/main"),
3611            "expected Ok(refs/heads/main), got {outcome:?}",
3612        );
3613
3614        let bundle_dest = format!("repo/refs/heads/main/{SHA}.bundle");
3615        assert!(
3616            store.contains(&bundle_dest),
3617            "new bundle must be uploaded at bundle_dest",
3618        );
3619        // Both faults fired: tombstone PUT first, fallback delete
3620        // second. None should remain pending.
3621        assert_eq!(store.pending_faults(), 0);
3622        // Orphan remains on the bucket — operator sees the warn log and
3623        // the next push's multi-bundle guard will direct them to doctor.
3624        assert!(
3625            store.contains(&pre_key),
3626            "cleanup faults must leave the prior bundle in place",
3627        );
3628        // No tombstone was written (the PUT failed); the operator path
3629        // is the multi-bundle guard, not deferred reclamation.
3630        let metas = store.list("repo/gc/").await.unwrap();
3631        let tomb_listing = baseline_tombstone_listing_prefix(Some("repo"));
3632        assert!(
3633            !metas.iter().any(|m| m.key.starts_with(&tomb_listing)),
3634            "no baseline tombstone must remain after a failed PUT",
3635        );
3636    }
3637
3638    /// Issue #157 fallback path: when the baseline tombstone PUT fails
3639    /// but the synchronous delete succeeds, the prior bundle is
3640    /// reclaimed immediately. This is the recovery shape that
3641    /// preserves the issue #121 "two-bundle state surfaces via doctor"
3642    /// invariant — without the fallback delete, a tombstone PUT
3643    /// failure would orphan the prior bundle indefinitely (gc sweep
3644    /// has no tombstone to act on).
3645    #[tokio::test]
3646    async fn perform_push_under_lock_falls_back_to_sync_delete_on_tombstone_put_failure() {
3647        use crate::object_store::mock::Fault;
3648        let store = MockStore::new();
3649        let pre_key = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
3650        store.insert(&pre_key, Bytes::from_static(b"old bundle"));
3651        // Fail only the tombstone PUT — the prior-bundle delete is
3652        // left armable-free so the fallback path completes.
3653        store.arm(Fault::NetworkOnPutBytesPrefix {
3654            prefix: baseline_tombstone_listing_prefix(Some("repo")),
3655        });
3656
3657        let state = push_state_with_pre_existing(Some(pre_key.clone()));
3658        let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
3659            .await
3660            .expect("push must succeed when tombstone PUT fails but fallback delete succeeds");
3661        assert!(
3662            matches!(&outcome, PushOutcome::Ok { remote_ref } if remote_ref == "refs/heads/main"),
3663            "expected Ok(refs/heads/main), got {outcome:?}",
3664        );
3665
3666        // The fault on the tombstone PUT fired; no tombstone remains.
3667        assert_eq!(store.pending_faults(), 0);
3668        let metas = store.list("repo/gc/").await.unwrap();
3669        let tomb_listing = baseline_tombstone_listing_prefix(Some("repo"));
3670        assert!(
3671            !metas.iter().any(|m| m.key.starts_with(&tomb_listing)),
3672            "tombstone PUT failed, so no baseline-tomb key may exist",
3673        );
3674        // The fallback synchronous delete succeeded — prior bundle gone.
3675        assert!(
3676            !store.contains(&pre_key),
3677            "fallback synchronous delete must reclaim the prior bundle",
3678        );
3679        // The new bundle is durable.
3680        let bundle_dest = format!("repo/refs/heads/main/{SHA}.bundle");
3681        assert!(
3682            store.contains(&bundle_dest),
3683            "new bundle must be uploaded at bundle_dest",
3684        );
3685    }
3686
3687    /// Issue #127 regression: a non-`NotFound` error on the optional
3688    /// zip artifact upload after the new bundle has been put must NOT
3689    /// fail the push. The bundle, `HEAD`, and `FORMAT` are already
3690    /// durable; reporting failure misrepresents the remote state. The
3691    /// zip is a CodePipeline-side convenience surface — bundle
3692    /// availability is what determines whether `git clone`/`fetch`
3693    /// work. Match the `delete_prior_bundle_best_effort` contract: log
3694    /// at warn and report success.
3695    #[tokio::test]
3696    async fn perform_push_under_lock_succeeds_when_zip_upload_fails() {
3697        use crate::object_store::mock::Fault;
3698        let store = MockStore::new();
3699
3700        // Build a PushReadyState with zip_artifacts present so the
3701        // zip-upload block runs. The archive file is written to disk
3702        // so a future refactor that re-orders the fault check vs. the
3703        // file read still has a real file to act on.
3704        let r = rn("refs/heads/main");
3705        let temp_dir = tempfile::Builder::new()
3706            .prefix("test_push_")
3707            .tempdir()
3708            .unwrap();
3709        let bundle_path = temp_dir.path().join("bundle");
3710        std::fs::write(&bundle_path, b"fake bundle").unwrap();
3711
3712        let archive_tempdir = tempfile::Builder::new()
3713            .prefix("test_zip_")
3714            .tempdir()
3715            .unwrap();
3716        let archive_path = archive_tempdir.path().join("repo.zip");
3717        std::fs::write(&archive_path, b"fake zip body").unwrap();
3718
3719        let state = PushReadyState {
3720            remote_ref: r,
3721            local_sha: Sha::from_hex(SHA).unwrap(),
3722            pre_existing: None,
3723            bundle_path,
3724            zip_artifacts: Some(ZipArtifacts {
3725                archive_path,
3726                short_sha: "deadbeef".to_owned(),
3727                commit_msg: "test commit".to_owned(),
3728                _tempdir: archive_tempdir,
3729            }),
3730            engine: StorageEngine::Bundle,
3731            force: false,
3732            pre_existing_was_ancestor: true,
3733            local_spec: "refs/heads/main".to_owned(),
3734            hidden_bundles: HashSet::new(),
3735            _temp_dir: temp_dir,
3736        };
3737
3738        let zip_dest = "repo/refs/heads/main/repo.zip".to_owned();
3739        store.arm(Fault::NetworkOnPutPath {
3740            key: zip_dest.clone(),
3741        });
3742
3743        let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
3744            .await
3745            .expect("push must succeed even when zip upload fails");
3746        assert!(
3747            matches!(&outcome, PushOutcome::Ok { remote_ref } if remote_ref == "refs/heads/main"),
3748            "expected Ok(refs/heads/main), got {outcome:?}",
3749        );
3750
3751        // The bundle reached the bucket — that is the git-protocol
3752        // contract for a successful push.
3753        let bundle_dest = format!("repo/refs/heads/main/{SHA}.bundle");
3754        assert!(
3755            store.contains(&bundle_dest),
3756            "new bundle must be uploaded at bundle_dest",
3757        );
3758        // The zip fault fired exactly once — proves put_path was
3759        // attempted and failed.
3760        assert_eq!(store.pending_faults(), 0);
3761        // The zip key is absent — proves the failure was not silently
3762        // swallowed by a retry that masked the regression.
3763        assert!(
3764            !store.contains(&zip_dest),
3765            "zip key must be absent when the upload fault fires",
3766        );
3767    }
3768
3769    /// Issue #161: on S3 the zip-artifact upload must carry the
3770    /// `codepipeline-artifact-revision-summary` user-metadata header so
3771    /// AWS `CodePipeline` can consume the commit summary. On Azure the
3772    /// same hyphenated key is rejected by the service (metadata names
3773    /// must be valid C# identifiers), and the issue #127 swallow path
3774    /// then hides the upload failure — silently dropping every zip
3775    /// artifact. The fix only emits the metadata on S3; this pair of
3776    /// tests pins both halves of the contract against `MockStore`,
3777    /// which records `user_metadata` verbatim and lets us inspect it
3778    /// without standing up a live backend.
3779    #[tokio::test]
3780    async fn perform_push_under_lock_emits_codepipeline_metadata_on_s3() {
3781        let (store, zip_dest) = run_zip_push(BackendKind::S3).await;
3782        let meta = store.metadata(&zip_dest).expect("zip stored");
3783        let summary = meta
3784            .user_metadata
3785            .iter()
3786            .find(|(k, _)| k == "codepipeline-artifact-revision-summary")
3787            .expect("S3 push must attach the CodePipeline revision-summary metadata");
3788        assert_eq!(summary.1, "test commit");
3789    }
3790
3791    #[tokio::test]
3792    async fn perform_push_under_lock_omits_codepipeline_metadata_on_azure() {
3793        let (store, zip_dest) = run_zip_push(BackendKind::Azure).await;
3794        let meta = store.metadata(&zip_dest).expect("zip stored");
3795        assert!(
3796            meta.user_metadata.is_empty(),
3797            "Azure push must not attach hyphenated CodePipeline metadata; \
3798             got {entries:?}",
3799            entries = meta.user_metadata,
3800        );
3801    }
3802
3803    /// Drive a single `?zip=1` push through `perform_push_under_lock` for
3804    /// the given backend kind, returning the store and the zip key so
3805    /// each test can assert on `user_metadata` independently. Centralised
3806    /// here so an accidental drift between the S3 and Azure variants
3807    /// (different `commit_msg`, different prefix, different ref) cannot
3808    /// hide a regression in the metadata wiring.
3809    async fn run_zip_push(kind: BackendKind) -> (MockStore, String) {
3810        let store = MockStore::new();
3811        let r = rn("refs/heads/main");
3812        let temp_dir = tempfile::Builder::new()
3813            .prefix("test_push_")
3814            .tempdir()
3815            .unwrap();
3816        let bundle_path = temp_dir.path().join("bundle");
3817        std::fs::write(&bundle_path, b"fake bundle").unwrap();
3818
3819        let archive_tempdir = tempfile::Builder::new()
3820            .prefix("test_zip_")
3821            .tempdir()
3822            .unwrap();
3823        let archive_path = archive_tempdir.path().join("repo.zip");
3824        std::fs::write(&archive_path, b"fake zip body").unwrap();
3825
3826        let state = PushReadyState {
3827            remote_ref: r,
3828            local_sha: Sha::from_hex(SHA).unwrap(),
3829            pre_existing: None,
3830            bundle_path,
3831            zip_artifacts: Some(ZipArtifacts {
3832                archive_path,
3833                short_sha: "deadbeef".to_owned(),
3834                commit_msg: "test commit".to_owned(),
3835                _tempdir: archive_tempdir,
3836            }),
3837            engine: StorageEngine::Bundle,
3838            force: false,
3839            pre_existing_was_ancestor: true,
3840            local_spec: "refs/heads/main".to_owned(),
3841            hidden_bundles: HashSet::new(),
3842            _temp_dir: temp_dir,
3843        };
3844
3845        let outcome = perform_push_under_lock(&store, Some("repo"), kind, state)
3846            .await
3847            .expect("push must succeed");
3848        assert!(matches!(outcome, PushOutcome::Ok { .. }));
3849        let zip_dest = "repo/refs/heads/main/repo.zip".to_owned();
3850        assert!(
3851            store.contains(&zip_dest),
3852            "zip artifact must land on bucket"
3853        );
3854        (store, zip_dest)
3855    }
3856
3857    // --- delete_remote_ref_under_lock ---------------------------------
3858
3859    /// Issue #133: under the lock, a ref whose only remaining object is
3860    /// the lock key itself reports `"not found"?` rather than the
3861    /// pre-#133 quirk of treating the lock as a bundle. In production
3862    /// the lock here is the one [`acquire_lock`] holds across the call;
3863    /// `release_lock` deletes it after this function returns, so the
3864    /// caller never sees a dangling lock either way.
3865    ///
3866    /// This pins the new contract: the filter on the lock key must
3867    /// short-circuit to `"not found"?` when the lock is the only
3868    /// listed entry, NOT delete it as a bundle.
3869    #[tokio::test]
3870    async fn delete_remote_ref_under_lock_reports_not_found_when_only_lock_present() {
3871        let store = MockStore::new();
3872        let lock_key = "repo/refs/heads/main/LOCK#.lock";
3873        // Simulate the lock we hold across the call (the production
3874        // caller has already acquired it via `acquire_lock`).
3875        store.insert(lock_key, Bytes::from_static(b"held-lock-payload"));
3876        let r = rn("refs/heads/main");
3877
3878        let outcome = delete_remote_ref_under_lock(&store, Some("repo"), &r, lock_key)
3879            .await
3880            .unwrap();
3881
3882        match outcome {
3883            PushOutcome::Error { message, .. } => {
3884                assert_eq!(message, r#""not found"?"#);
3885            }
3886            PushOutcome::Ok { .. } => panic!("expected Error, got Ok"),
3887        }
3888        // The lock key is NOT swept by `delete_remote_ref_under_lock` —
3889        // `release_lock` is responsible for removing it. Pin that here
3890        // so a regression that swept the held lock would fail.
3891        assert!(
3892            store.contains(lock_key),
3893            "delete_remote_ref_under_lock must NOT delete the held lock key",
3894        );
3895    }
3896
3897    /// Issue #133: when a concurrent push lands a NEW bundle between
3898    /// the lock-acquire and our listing inside the lock, the post-lock
3899    /// listing reflects the new bundle. The delete proceeds normally
3900    /// against that bundle (and the lock is filtered out). This pins
3901    /// the close of the race window the issue describes: the listing
3902    /// is now under the lock, so the deletion target is whatever the
3903    /// concurrent writer left behind, not a stale pre-lock snapshot.
3904    #[tokio::test]
3905    async fn delete_remote_ref_under_lock_sweeps_concurrently_landed_bundle() {
3906        let store = MockStore::new();
3907        let r = rn("refs/heads/main");
3908        let lock_key = "repo/refs/heads/main/LOCK#.lock";
3909        // Concurrent push landed this bundle; we hold the lock now.
3910        let bundle = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
3911        store.insert(&bundle, Bytes::from_static(b"new"));
3912        store.insert(lock_key, Bytes::from_static(b"held-lock-payload"));
3913
3914        let outcome = delete_remote_ref_under_lock(&store, Some("repo"), &r, lock_key)
3915            .await
3916            .unwrap();
3917
3918        assert_eq!(
3919            outcome,
3920            PushOutcome::Ok {
3921                remote_ref: "refs/heads/main".into()
3922            }
3923        );
3924        assert!(
3925            !store.contains(&bundle),
3926            "concurrently-landed bundle must be swept by the under-lock listing",
3927        );
3928        assert!(
3929            store.contains(lock_key),
3930            "held lock must survive the sweep (release_lock removes it)",
3931        );
3932    }
3933
3934    /// Stale lock is deleted but another client re-acquires it before our
3935    /// retry `put_if_absent`. Must return `Ok(None)` — the caller maps
3936    /// this to a "lock held" user error, not a hard failure.
3937    #[tokio::test]
3938    async fn acquire_lock_stale_retry_loses_second_race() {
3939        use crate::object_store::mock::Fault;
3940        let store = MockStore::new();
3941        let now = OffsetDateTime::now_utc();
3942        let stale = now - Duration::seconds(120);
3943        store.insert_with("k", Bytes::new(), stale, PutOpts::default());
3944        // Another client wins the race between our delete and retry.
3945        store.arm(Fault::ContendedPutIfAbsent { key: "k".into() });
3946        let arc = Arc::new(store);
3947        let guard = acquire_lock(
3948            Arc::clone(&arc) as Arc<dyn ObjectStore>,
3949            "k",
3950            Duration::seconds(60),
3951            now,
3952        )
3953        .await
3954        .unwrap();
3955        assert!(guard.is_none(), "expected contention on the retry race");
3956        // Fault fired — confirms the retry put_if_absent was called.
3957        assert_eq!(arc.pending_faults(), 0);
3958        // The stale lock was removed; no key remains.
3959        assert!(!arc.contains("k"));
3960    }
3961
3962    // --- full_error_chain dedup --------------------------------------
3963
3964    /// Tripwire for the dedup-by-suffix fix: a naive chain-walk on
3965    /// `PushError::Store(ObjectStoreError::Network(_))` produces a
3966    /// duplicated tail because both `PushError::Store` and
3967    /// `ObjectStoreError::Network` inline their immediate source via
3968    /// `{0}` in the `Display` derive. The shared
3969    /// `super::append_source_chain` helper skips levels whose text is
3970    /// already at the tail of the message. A regression that
3971    /// re-introduced the always-append walk would render
3972    /// `"…network error: dns failure: dns failure"` (or even longer
3973    /// for deeper chains), failing this byte-exact assertion.
3974    #[test]
3975    fn full_error_chain_deduplicates_inlined_source_text() {
3976        let inner: crate::object_store::BoxError = Box::new(std::io::Error::other("dns failure"));
3977        let err = PushError::Store(ObjectStoreError::Network(inner));
3978        let rendered = full_error_chain(&err);
3979        assert_eq!(
3980            rendered, "object-store error during push: network error: dns failure",
3981            "PushError::Store(Network(_)) must not duplicate the inner source",
3982        );
3983    }
3984
3985    // --- bundle progress sink wiring (issue #55) -----------------------
3986
3987    /// Decorator around `MockStore` that records, for every `put_path`
3988    /// call, whether `opts.progress` was `Some`. Used to pin the
3989    /// "bundle uploads attach a progress sink" contract from issue
3990    /// #55: a regression that drops the sink from `perform_push_under_lock`
3991    /// would silently regress the only thing `git push` users have to
3992    /// watch a multi-GiB transfer.
3993    ///
3994    /// The decorator forwards every other method to the inner
3995    /// `MockStore` unchanged. Wrapping for the assertion-of-interest
3996    /// alone keeps the test tightly scoped — there is no fault
3997    /// injection, no chunking knob, just a Vec of "did `put_path` get
3998    /// a sink?" booleans keyed by the destination key.
3999    #[derive(Default)]
4000    struct RecordingPutPathStore {
4001        inner: MockStore,
4002        put_path_progress_seen: std::sync::Mutex<Vec<(String, bool)>>,
4003    }
4004
4005    impl RecordingPutPathStore {
4006        fn observed(&self) -> Vec<(String, bool)> {
4007            self.put_path_progress_seen
4008                .lock()
4009                .expect("observation lock")
4010                .clone()
4011        }
4012    }
4013
4014    #[async_trait::async_trait]
4015    impl ObjectStore for RecordingPutPathStore {
4016        async fn list(&self, prefix: &str) -> Result<Vec<ObjectMeta>, ObjectStoreError> {
4017            self.inner.list(prefix).await
4018        }
4019        async fn get_to_file(
4020            &self,
4021            key: &str,
4022            dest: &Path,
4023            opts: crate::object_store::GetOpts,
4024        ) -> Result<(), ObjectStoreError> {
4025            self.inner.get_to_file(key, dest, opts).await
4026        }
4027        async fn get_bytes(&self, key: &str) -> Result<Bytes, ObjectStoreError> {
4028            self.inner.get_bytes(key).await
4029        }
4030        async fn get_bytes_range(
4031            &self,
4032            key: &str,
4033            range: std::ops::Range<u64>,
4034        ) -> Result<Bytes, ObjectStoreError> {
4035            self.inner.get_bytes_range(key, range).await
4036        }
4037        async fn put_bytes(
4038            &self,
4039            key: &str,
4040            body: Bytes,
4041            opts: PutOpts,
4042        ) -> Result<(), ObjectStoreError> {
4043            self.inner.put_bytes(key, body, opts).await
4044        }
4045        async fn put_path(
4046            &self,
4047            key: &str,
4048            src: &Path,
4049            opts: PutOpts,
4050        ) -> Result<(), ObjectStoreError> {
4051            self.put_path_progress_seen
4052                .lock()
4053                .expect("observation lock")
4054                .push((key.to_owned(), opts.progress.is_some()));
4055            self.inner.put_path(key, src, opts).await
4056        }
4057        async fn put_if_absent(&self, key: &str, body: Bytes) -> Result<bool, ObjectStoreError> {
4058            self.inner.put_if_absent(key, body).await
4059        }
4060        async fn head(&self, key: &str) -> Result<ObjectMeta, ObjectStoreError> {
4061            self.inner.head(key).await
4062        }
4063        async fn copy(&self, src: &str, dst: &str) -> Result<(), ObjectStoreError> {
4064            self.inner.copy(src, dst).await
4065        }
4066        async fn delete(&self, key: &str) -> Result<(), ObjectStoreError> {
4067            self.inner.delete(key).await
4068        }
4069    }
4070
4071    /// `perform_push_under_lock` must attach a `ProgressSink` to the
4072    /// bundle `put_path` so `git push` users see motion during a slow
4073    /// upload (issue #55). Before the fix, this call passed
4074    /// `PutOpts::default()` and the user saw nothing for hours on a
4075    /// 20 GiB push.
4076    #[tokio::test]
4077    async fn perform_push_under_lock_attaches_progress_sink_to_bundle_put_path() {
4078        let store = RecordingPutPathStore::default();
4079        let r = rn("refs/heads/main");
4080        let temp_dir = tempfile::Builder::new()
4081            .prefix("test_push_progress_")
4082            .tempdir()
4083            .unwrap();
4084        let bundle_path = temp_dir.path().join("bundle");
4085        std::fs::write(&bundle_path, b"fake bundle").unwrap();
4086        let state = PushReadyState {
4087            remote_ref: r,
4088            local_sha: Sha::from_hex(SHA).unwrap(),
4089            pre_existing: None,
4090            bundle_path,
4091            zip_artifacts: None,
4092            engine: StorageEngine::Bundle,
4093            force: false,
4094            pre_existing_was_ancestor: true,
4095            local_spec: "refs/heads/main".to_owned(),
4096            hidden_bundles: HashSet::new(),
4097            _temp_dir: temp_dir,
4098        };
4099        let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
4100            .await
4101            .unwrap();
4102        assert!(
4103            matches!(outcome, PushOutcome::Ok { .. }),
4104            "expected Ok outcome",
4105        );
4106        let observed = store.observed();
4107        // Exactly one `put_path` call (no zip artifacts in this fixture).
4108        // Pinning ≥1 rather than ==1 guards against future test
4109        // refactors that legitimately add another upload — the contract
4110        // we care about is that *every* bundle-class upload carries a
4111        // sink.
4112        assert!(
4113            !observed.is_empty(),
4114            "perform_push_under_lock must call put_path for the bundle",
4115        );
4116        for (key, has_sink) in &observed {
4117            assert!(
4118                has_sink,
4119                "put_path for `{key}` must carry a ProgressSink (issue #55)",
4120            );
4121        }
4122    }
4123
4124    /// `bundle_progress_sink` accepts an arbitrary stream of
4125    /// `report(amount)` calls without panicking and tolerates `None`
4126    /// for `total` (the stat-failed fallback). Pins the sink's
4127    /// public-shape contract: a regression that switched the
4128    /// `Option<u64>` to a non-optional `u64` would force every caller
4129    /// to handle stat errors, breaking the "graceful degradation"
4130    /// note in the helper's doc comment.
4131    #[test]
4132    fn bundle_progress_sink_accepts_reports_without_panicking() {
4133        let with_total = bundle_progress_sink("repo/bundle.bundle", Some(1_024));
4134        with_total.report(256);
4135        with_total.report(256);
4136        with_total.report(512);
4137
4138        let without_total = bundle_progress_sink("repo/bundle.bundle", None);
4139        without_total.report(1);
4140        without_total.report(u64::MAX); // saturates rather than wraps
4141    }
4142
4143    /// Pin the not-ancestor wire token at the Rust level. The shellspec
4144    /// suites (`spec/integration/{s3,az}/force_push_spec.sh`,
4145    /// `spec/live/s3/force_push_spec.sh`) assert on the literal
4146    /// substring `"not ancestor"` — but they only run via `make
4147    /// shellspec-*` targets, not the default `cargo test --workspace`
4148    /// gate. This test catches a Rust dev who renames the constant
4149    /// without updating the spec files.
4150    #[test]
4151    fn not_ancestor_token_value_is_stable() {
4152        assert_eq!(
4153            NOT_ANCESTOR_TOKEN, "not ancestor",
4154            "spec/{{integration,live}}/*/force_push_spec.sh asserts on this exact substring",
4155        );
4156        let formatted = format!(r#""remote ref is {NOT_ANCESTOR_TOKEN} of refs/heads/main."?"#);
4157        assert!(
4158            formatted.contains(NOT_ANCESTOR_TOKEN),
4159            "the not-ancestor PushOutcome::Error message must embed the token literally; got {formatted:?}",
4160        );
4161    }
4162
4163    /// CRLF in a commit-message summary would split the
4164    /// `x-amz-meta-codepipeline-artifact-revision-summary` (or
4165    /// `x-ms-meta-…`) header on the wire, letting a forged commit
4166    /// inject arbitrary user metadata onto the uploaded zip
4167    /// archive. `sanitize_metadata_value` must collapse every ASCII
4168    /// control byte (CR, LF, NUL, HTAB, …) to a space so the
4169    /// resulting header value is single-line and free of injection
4170    /// payloads.
4171    #[test]
4172    fn sanitize_metadata_value_strips_control_chars() {
4173        assert_eq!(
4174            sanitize_metadata_value("hello\r\nX-Injected: yes"),
4175            "hello  X-Injected: yes",
4176        );
4177        assert_eq!(sanitize_metadata_value("nul\0byte"), "nul byte");
4178        assert_eq!(sanitize_metadata_value("plain text"), "plain text");
4179        assert_eq!(
4180            sanitize_metadata_value("café — short summary"),
4181            "café — short summary",
4182            "non-ASCII printable characters must pass through unchanged",
4183        );
4184        assert_eq!(sanitize_metadata_value(""), "");
4185    }
4186
4187    // --- Issue #129: force-push protection check runs under the lock ---
4188
4189    /// Regression for issue #129. If a concurrent `protect` lands a
4190    /// `PROTECTED#` marker between the pre-lock work in `prepare_push`
4191    /// and the lock acquisition in `push_one`, the under-lock arm of
4192    /// `perform_push_under_lock` must observe it and reject a non-FF
4193    /// force-push with the same `NotAncestor` wire token the pre-lock
4194    /// non-force probe would have produced.
4195    ///
4196    /// `pre_existing_was_ancestor = false` represents "this force-push
4197    /// is NOT a fast-forward" — exactly the case the historical
4198    /// protection-demotion logic rejected. The `PROTECTED#` key is
4199    /// seeded after the would-be pre-lock check (we simulate that by
4200    /// constructing the state with `force=true` and seeding the marker
4201    /// alongside the matching pre-existing bundle).
4202    #[tokio::test]
4203    async fn perform_push_under_lock_rejects_force_when_protected_under_lock_and_not_ff() {
4204        let store = MockStore::new();
4205        let pre_key = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
4206        store.insert(&pre_key, Bytes::from_static(b"old bundle"));
4207        // Concurrent `protect` lands the marker before we get the lock.
4208        store.insert("repo/refs/heads/main/PROTECTED#", Bytes::from_static(b""));
4209        let mut state = push_state_with_pre_existing(Some(pre_key.clone()));
4210        state.force = true;
4211        state.pre_existing_was_ancestor = false;
4212        let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
4213            .await
4214            .unwrap();
4215        assert!(
4216            matches!(
4217                &outcome,
4218                PushOutcome::Error { message, .. }
4219                    if message == r#""remote ref is not ancestor of refs/heads/main."?"#
4220            ),
4221            "expected under-lock NotAncestor refusal, got {outcome:?}",
4222        );
4223        // The protection marker must survive — the engine never touches
4224        // protect/unprotect state. A regression that swept it on the
4225        // refusal path would silently downgrade a server's protection.
4226        assert!(store.contains("repo/refs/heads/main/PROTECTED#"));
4227        // The pre-existing bundle must survive intact: a refusal path
4228        // that erroneously progressed past the protection check could
4229        // overwrite or delete it.
4230        let local_sha = SHA;
4231        assert!(store.contains(&pre_key));
4232        assert!(
4233            !store.contains(&format!("repo/refs/heads/main/{local_sha}.bundle")),
4234            "refused push must not upload the new bundle",
4235        );
4236    }
4237
4238    /// Companion to the rejection case: when the user's local tip IS a
4239    /// fast-forward of the pre-existing remote bundle (`pre_existing_was_ancestor
4240    /// = true`), the historical "protected ref + force" semantic is to
4241    /// proceed — protection only blocks non-fast-forward force-pushes.
4242    /// This test pins that branch: a `PROTECTED#` marker under the
4243    /// lock plus a FF push must still succeed.
4244    #[tokio::test]
4245    async fn perform_push_under_lock_allows_force_when_protected_under_lock_but_ff() {
4246        let store = MockStore::new();
4247        let pre_key = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
4248        store.insert(&pre_key, Bytes::from_static(b"old bundle"));
4249        store.insert("repo/refs/heads/main/PROTECTED#", Bytes::from_static(b""));
4250        let mut state = push_state_with_pre_existing(Some(pre_key.clone()));
4251        state.force = true;
4252        state.pre_existing_was_ancestor = true; // FF case.
4253        let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
4254            .await
4255            .unwrap();
4256        assert!(
4257            matches!(&outcome, PushOutcome::Ok { remote_ref } if remote_ref == "refs/heads/main"),
4258            "FF push must pass even when protected, got {outcome:?}",
4259        );
4260        // Protection marker still in place after the push.
4261        assert!(store.contains("repo/refs/heads/main/PROTECTED#"));
4262    }
4263
4264    /// Companion to the rejection case: a legitimate force-push (force,
4265    /// non-FF, no `PROTECTED#` marker) must proceed. This pins the
4266    /// polarity of the AND-clause guarding the protection rejection — a
4267    /// regression that dropped the `is_protected` check from the
4268    /// condition would refuse every non-FF force-push, not just those
4269    /// against protected refs.
4270    #[tokio::test]
4271    async fn perform_push_under_lock_allows_force_when_not_ancestor_and_not_protected() {
4272        let store = MockStore::new();
4273        let pre_key = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
4274        store.insert(&pre_key, Bytes::from_static(b"old bundle"));
4275        // No PROTECTED# marker.
4276        let mut state = push_state_with_pre_existing(Some(pre_key.clone()));
4277        state.force = true;
4278        state.pre_existing_was_ancestor = false; // non-FF force-push.
4279        let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
4280            .await
4281            .unwrap();
4282        assert!(
4283            matches!(&outcome, PushOutcome::Ok { remote_ref } if remote_ref == "refs/heads/main"),
4284            "legitimate non-FF force-push must proceed, got {outcome:?}",
4285        );
4286        // A passing push uploads the new bundle keyed by `local_sha`.
4287        let local_sha = SHA;
4288        assert!(
4289            store.contains(&format!("repo/refs/heads/main/{local_sha}.bundle")),
4290            "new bundle must be uploaded on a successful force-push",
4291        );
4292        // Defensive: the NotAncestor wire token must NOT appear anywhere
4293        // in the outcome — that would mean the guard mis-fired.
4294        if let PushOutcome::Error { message, .. } = &outcome {
4295            assert!(
4296                !message.contains("not ancestor"),
4297                "force-push without protection must not emit NotAncestor: {message}",
4298            );
4299        }
4300    }
4301
4302    /// A non-force push (`force=false`) must never consult `is_protected`
4303    /// under the lock: non-FF non-force pushes were already rejected
4304    /// pre-lock by the ancestry probe, and FF non-force pushes are
4305    /// unaffected by protection. The under-lock check is gated on
4306    /// `force` precisely so this round-trip costs zero extra HEAD calls.
4307    ///
4308    /// Test-design note: `pre_existing_was_ancestor` is forced to `false`
4309    /// here so that ONLY the `force` clause of the under-lock guard
4310    /// (`force && !pre_existing_was_ancestor && is_protected(...)`)
4311    /// keeps the protection check off. A regression that drops the
4312    /// `force &&` clause would flip the guard to true and fail this
4313    /// test; if `pre_existing_was_ancestor` were left as `true`, the
4314    /// `!pre_existing_was_ancestor` clause would short-circuit and
4315    /// hide that regression. The `pre_existing` bundle key is omitted
4316    /// to keep the FF-vs-non-FF semantics consistent with "no prior
4317    /// remote SHA, therefore not an ancestor".
4318    #[tokio::test]
4319    async fn perform_push_under_lock_skips_protection_check_for_non_force() {
4320        let store = MockStore::new();
4321        // Marker present, but a non-force push must not even probe for it.
4322        store.insert("repo/refs/heads/main/PROTECTED#", Bytes::from_static(b""));
4323        let mut state = push_state_with_pre_existing(None);
4324        state.force = false;
4325        state.pre_existing_was_ancestor = false;
4326        let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
4327            .await
4328            .unwrap();
4329        assert!(
4330            matches!(&outcome, PushOutcome::Ok { remote_ref } if remote_ref == "refs/heads/main"),
4331            "non-force push must pass regardless of protection: {outcome:?}",
4332        );
4333    }
4334
4335    // -----------------------------------------------------------------
4336    // Issue #151 — bundle-engine delete must not miss a `PROTECTED#`
4337    // marker written after the under-lock listing. The primary defence
4338    // is the per-ref lock (#159 made `protect`/`unprotect` acquire the
4339    // same key the delete holds). These tests pin the post-sweep
4340    // defensive verification (`verify_no_orphan_protected_after_delete`)
4341    // behaves correctly on the happy path and is silent there.
4342    // -----------------------------------------------------------------
4343
4344    #[tokio::test]
4345    async fn issue_151_clean_delete_passes_post_sweep_verification() {
4346        // Happy path with the lock contract honoured: bundle present,
4347        // no marker, lock held. The sweep deletes the bundle and the
4348        // post-sweep `head(PROTECTED#)` returns NotFound — silently —
4349        // and the delete reports `ok`. A regression that promoted the
4350        // post-sweep probe into a hard error would surface here.
4351        let store = MockStore::new();
4352        let r = rn("refs/heads/main");
4353        let bundle = format!("repo/refs/heads/main/{SHA}.bundle");
4354        let lock_key = "repo/refs/heads/main/LOCK#.lock";
4355        store.insert(&bundle, Bytes::from_static(b"b"));
4356        store.insert(lock_key, Bytes::from_static(b"held-lock-payload"));
4357
4358        let outcome = delete_remote_ref_under_lock(&store, Some("repo"), &r, lock_key)
4359            .await
4360            .unwrap();
4361        assert_eq!(
4362            outcome,
4363            PushOutcome::Ok {
4364                remote_ref: "refs/heads/main".into()
4365            },
4366            "clean delete must report ok after the post-sweep probe",
4367        );
4368        assert!(!store.contains(&bundle), "bundle must be swept");
4369        assert!(
4370            store.contains(lock_key),
4371            "lock survives the sweep (release removes it)",
4372        );
4373    }
4374
4375    /// Helper unit test for [`verify_no_orphan_protected_after_delete`]:
4376    /// the helper itself must not error or panic when the marker is
4377    /// absent, and the call must be cheap (a single `head`). Pinned
4378    /// here so a future refactor of the helper cannot regress its
4379    /// contract — the delete paths rely on this being a no-op on
4380    /// the happy path.
4381    #[tokio::test]
4382    async fn verify_no_orphan_protected_after_delete_is_noop_when_marker_absent() {
4383        use crate::object_store::mock::Fault;
4384        let store = MockStore::new();
4385        let r = rn("refs/heads/main");
4386        // Arm a one-shot transient HEAD fault on the marker key. The
4387        // helper MUST issue a `head()` on that key; the fault is the
4388        // witness. A regression that turned the helper into a literal
4389        // no-op would leave the fault unconsumed and fail the
4390        // pending-faults assertion below. Without this witness, the
4391        // "marker absent → marker absent" assertion is vacuous.
4392        store.arm(Fault::NetworkOnHead {
4393            key: "repo/refs/heads/main/PROTECTED#".to_owned(),
4394        });
4395        verify_no_orphan_protected_after_delete(&store, Some("repo"), &r).await;
4396        assert_eq!(
4397            store.pending_faults(),
4398            0,
4399            "helper must call head() on the marker key — fault unconsumed",
4400        );
4401        // The transient HEAD error goes through the `debug!` branch and
4402        // the helper returns silently; the bucket stays unchanged.
4403        assert!(
4404            !store.contains("repo/refs/heads/main/PROTECTED#"),
4405            "helper must not touch the bucket",
4406        );
4407    }
4408
4409    /// When the helper observes a marker — the lock-contract-violation
4410    /// branch — it logs at `error!` and returns silently. The bucket
4411    /// state must be unchanged (no rollback). This is the
4412    /// belt-and-suspenders branch the issue's race scenario would
4413    /// reach if a future regression bypassed the lock.
4414    #[tokio::test]
4415    async fn verify_no_orphan_protected_after_delete_does_not_mutate_when_marker_present() {
4416        let store = MockStore::new();
4417        let r = rn("refs/heads/main");
4418        let marker = "repo/refs/heads/main/PROTECTED#";
4419        store.insert(marker, Bytes::new());
4420        verify_no_orphan_protected_after_delete(&store, Some("repo"), &r).await;
4421        // The helper logs but does NOT delete the marker: rollback is
4422        // not the helper's job (the delete is already complete; the
4423        // operator-visible "ref is gone" outcome stands; the orphan
4424        // marker is surveillance telemetry).
4425        assert!(
4426            store.contains(marker),
4427            "helper must not delete the orphan marker — surveillance only",
4428        );
4429    }
4430}