@@ -32,19 +32,50 @@ enum MirrorMode {
3232 Promisor ,
3333}
3434
35- /// Decide the mirror mode from the origin's `withheld-paths` response.
35+ /// The on-disk promisor state of an existing mirror, read from
36+ /// `remote.origin.promisor`. Three-valued so a git error is not mistaken for a
37+ /// definitive "not a promisor": `git config --get` collapses "key absent" and
38+ /// "git failed" into the same non-zero exit otherwise, and treating an errored
39+ /// probe as `NotPromisor` would let a transient failure downgrade a still-withheld
40+ /// mirror (issue #48).
41+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
42+ enum PromisorProbe {
43+ /// `remote.origin.promisor` is `"true"`.
44+ Promisor ,
45+ /// The key is absent (git exit 1) or set to a non-`true` value.
46+ NotPromisor ,
47+ /// The probe itself failed (git spawn error or other non-zero exit).
48+ Unknown ,
49+ }
50+
51+ /// Decide the mirror mode from the origin's `withheld-paths` response plus, when
52+ /// the response is unknown, the existing mirror's on-disk promisor state.
3653///
3754/// `Some(non-empty)` → the repo has a private subtree → `Promisor`.
3855/// `Some(empty)` → fully public → `Plain`.
39- /// `None` → the lookup 404'd or failed. Attempt a `Plain` mirror; a
40- /// mode-A repo also 404s the git read endpoint, so the clone
41- /// fails and nothing is mirrored (fail-closed at the git
42- /// layer), while a public repo on a peer that predates the
43- /// `withheld-paths` route still gets mirrored.
44- fn classify_mirror ( withheld : Option < Vec < String > > ) -> MirrorMode {
56+ /// `None` → the lookup 404'd or failed; the answer is *unknown*, which
57+ /// is not the same as "public". For a fresh clone this stays
58+ /// `Plain` (a mode-A repo also 404s the git read endpoint, so
59+ /// the clone fails and nothing is mirrored — fail-closed at the
60+ /// git layer — while a public repo on a peer that predates the
61+ /// `withheld-paths` route still gets mirrored). For an existing
62+ /// mirror it *biases toward preserving* the promisor state: a
63+ /// genuine public transition returns `Some(empty)`, so on
64+ /// `None` we cannot distinguish "still withheld, unreachable"
65+ /// from "newly public, unreachable" and prefer the recoverable
66+ /// choice over destroying the partial-clone config. An
67+ /// indeterminate probe (`Unknown`) preserves for the same
68+ /// reason (defense-in-depth, #48).
69+ fn resolve_mirror_mode (
70+ withheld : Option < Vec < String > > ,
71+ exists : bool ,
72+ promisor : PromisorProbe ,
73+ ) -> MirrorMode {
4574 match withheld {
4675 Some ( globs) if !globs. is_empty ( ) => MirrorMode :: Promisor ,
47- _ => MirrorMode :: Plain ,
76+ Some ( _) => MirrorMode :: Plain ,
77+ None if exists && promisor != PromisorProbe :: NotPromisor => MirrorMode :: Promisor ,
78+ None => MirrorMode :: Plain ,
4879 }
4980}
5081
@@ -212,9 +243,30 @@ async fn process_batch(
212243 let remote_url = format ! ( "{}/{}" , origin_url, item. repo) ;
213244
214245 let withheld = fetch_withheld ( client, & origin_url, owner_short, repo_name) . await ;
215- let mode = classify_mirror ( withheld) ;
246+ let exists = local_path. exists ( ) ;
247+ let lookup_unknown = withheld. is_none ( ) ;
248+ // Only probe the on-disk promisor state when the lookup is unknown and the
249+ // repo already exists — the sole case where it changes the resolved mode.
250+ let promisor = if lookup_unknown && exists {
251+ let local_str = local_path. to_str ( ) . unwrap_or ( "." ) ;
252+ existing_promisor_state ( local_str) . await
253+ } else {
254+ PromisorProbe :: NotPromisor
255+ } ;
256+ let mode = resolve_mirror_mode ( withheld, exists, promisor) ;
257+ // Surface the case where an unknown withheld-paths lookup kept (or, on an
258+ // indeterminate probe, defensively applied) promisor mode instead of
259+ // downgrading to a full clone. Derived from the resolved mode so it cannot
260+ // drift from resolve_mirror_mode's preserve branch.
261+ if lookup_unknown && mode == MirrorMode :: Promisor {
262+ warn ! (
263+ repo = %item. repo,
264+ origin = %origin_url,
265+ "withheld-paths lookup unavailable; using promisor mirror mode to avoid an unsafe full-clone downgrade"
266+ ) ;
267+ }
216268
217- let result = if local_path . exists ( ) {
269+ let result = if exists {
218270 fetch_repo ( & local_path, & remote_url, mode) . await
219271 } else {
220272 clone_repo ( & remote_url, & local_path, mode) . await
@@ -273,7 +325,7 @@ async fn process_batch(
273325
274326/// Query the origin's anonymous `withheld-paths` endpoint. Returns the withheld
275327/// glob list on a 2xx, or `None` on any non-success / network / parse error
276- /// (treated as "unknown" by `classify_mirror `).
328+ /// (treated as "unknown" by `resolve_mirror_mode `).
277329async fn fetch_withheld (
278330 client : & reqwest:: Client ,
279331 origin_url : & str ,
@@ -468,6 +520,38 @@ async fn git_config_get(repo: &str, key: &str) -> Option<String> {
468520 ( !value. is_empty ( ) ) . then_some ( value)
469521}
470522
523+ /// Probe an existing mirror's `remote.origin.promisor` config as a three-valued
524+ /// state. Unlike [`git_config_get`], this distinguishes a definitively-absent key
525+ /// from a probe failure so a transient git error cannot be read as "not a
526+ /// promisor" and trigger a downgrade (issue #48).
527+ ///
528+ /// `git config --get` exits 0 when the key is set, 1 when it is absent (or the
529+ /// directory is not a git repo) — both definitive `NotPromisor` — and other
530+ /// non-zero codes (e.g. 128 for a bad path or unreadable config) on error. A spawn
531+ /// failure or any non-{0,1} exit is `Unknown`.
532+ async fn existing_promisor_state ( repo : & str ) -> PromisorProbe {
533+ let out = match tokio:: process:: Command :: new ( "git" )
534+ . args ( [ "-C" , repo, "config" , "--get" , "remote.origin.promisor" ] )
535+ . output ( )
536+ . await
537+ {
538+ Ok ( out) => out,
539+ Err ( _) => return PromisorProbe :: Unknown ,
540+ } ;
541+ match out. status . code ( ) {
542+ Some ( 0 ) => {
543+ let value = String :: from_utf8_lossy ( & out. stdout ) . trim ( ) . to_string ( ) ;
544+ if value == "true" {
545+ PromisorProbe :: Promisor
546+ } else {
547+ PromisorProbe :: NotPromisor
548+ }
549+ }
550+ Some ( 1 ) => PromisorProbe :: NotPromisor ,
551+ _ => PromisorProbe :: Unknown ,
552+ }
553+ }
554+
471555/// Mirror-clone a repo from a remote URL into a local bare repo.
472556/// `Promisor` mode adds `--filter=blob:limit=10g`, which marks the repo a git
473557/// promisor (so a pack with origin-omitted withheld blobs is accepted) while
@@ -558,25 +642,68 @@ mod tests {
558642 use tempfile:: TempDir ;
559643
560644 #[ test]
561- fn classify_promisor_when_withheld_nonempty ( ) {
562- let mode = classify_mirror ( Some ( vec ! [ "/secret/**" . to_string( ) ] ) ) ;
645+ fn resolve_promisor_when_withheld_nonempty ( ) {
646+ let mode = resolve_mirror_mode (
647+ Some ( vec ! [ "/secret/**" . to_string( ) ] ) ,
648+ true ,
649+ PromisorProbe :: NotPromisor ,
650+ ) ;
563651 assert ! ( matches!( mode, MirrorMode :: Promisor ) ) ;
564652 }
565653
566654 #[ test]
567- fn classify_plain_when_withheld_empty ( ) {
568- let mode = classify_mirror ( Some ( vec ! [ ] ) ) ;
569- assert ! ( matches!( mode, MirrorMode :: Plain ) ) ;
655+ fn resolve_plain_when_withheld_empty ( ) {
656+ // A genuine public transition returns Some(empty) and still downgrades,
657+ // regardless of whether the mirror exists or was a promisor.
658+ for exists in [ true , false ] {
659+ for probe in [
660+ PromisorProbe :: Promisor ,
661+ PromisorProbe :: NotPromisor ,
662+ PromisorProbe :: Unknown ,
663+ ] {
664+ let mode = resolve_mirror_mode ( Some ( vec ! [ ] ) , exists, probe) ;
665+ assert ! ( matches!( mode, MirrorMode :: Plain ) ) ;
666+ }
667+ }
668+ }
669+
670+ #[ test]
671+ fn resolve_preserves_promisor_on_unknown_lookup_for_existing_mirror ( ) {
672+ // Regression for #48: a transient withheld-paths outage (None) must NOT
673+ // downgrade a still-withheld promisor mirror to a full clone.
674+ let mode = resolve_mirror_mode ( None , true , PromisorProbe :: Promisor ) ;
675+ assert ! ( matches!( mode, MirrorMode :: Promisor ) ) ;
570676 }
571677
572678 #[ test]
573- fn classify_plain_when_lookup_failed ( ) {
574- // None == 404 / network error / parse failure: attempt a plain mirror
575- // and let the git read endpoint fail-close a mode-A repo.
576- let mode = classify_mirror ( None ) ;
679+ fn resolve_preserves_promisor_on_indeterminate_probe ( ) {
680+ // Defense-in-depth (#48): if the config probe itself fails (Unknown) in the
681+ // same cycle as a withheld-paths outage, bias toward preserving rather than
682+ // firing the destructive downgrade.
683+ let mode = resolve_mirror_mode ( None , true , PromisorProbe :: Unknown ) ;
684+ assert ! ( matches!( mode, MirrorMode :: Promisor ) ) ;
685+ }
686+
687+ #[ test]
688+ fn resolve_plain_when_unknown_lookup_for_non_promisor_mirror ( ) {
689+ // An existing non-promisor mirror is unaffected by the preserve branch.
690+ let mode = resolve_mirror_mode ( None , true , PromisorProbe :: NotPromisor ) ;
577691 assert ! ( matches!( mode, MirrorMode :: Plain ) ) ;
578692 }
579693
694+ #[ test]
695+ fn resolve_plain_when_unknown_lookup_for_fresh_clone ( ) {
696+ // No local mirror yet: None stays Plain (fail-closed at the git layer).
697+ for probe in [
698+ PromisorProbe :: Promisor ,
699+ PromisorProbe :: NotPromisor ,
700+ PromisorProbe :: Unknown ,
701+ ] {
702+ let mode = resolve_mirror_mode ( None , false , probe) ;
703+ assert ! ( matches!( mode, MirrorMode :: Plain ) ) ;
704+ }
705+ }
706+
580707 fn rb ( oid : & str , cid : & str ) -> ReplicaBlob {
581708 ReplicaBlob {
582709 oid : oid. to_string ( ) ,
@@ -716,6 +843,38 @@ mod tests {
716843 assert_eq ! ( git_config( & dest, "remote.origin.mirror" ) , "true" ) ;
717844 }
718845
846+ #[ tokio:: test]
847+ async fn probe_reports_promisor_for_promisor_mirror ( ) {
848+ let ( td, url) = bare_remote ( & [ ( "public/a.txt" , b"pub\n " ) ] ) ;
849+ let dest = td. path ( ) . join ( "mirror.git" ) ;
850+ clone_repo ( & url, & dest, MirrorMode :: Promisor ) . await . unwrap ( ) ;
851+
852+ let probe = existing_promisor_state ( dest. to_str ( ) . unwrap ( ) ) . await ;
853+ assert_eq ! ( probe, PromisorProbe :: Promisor ) ;
854+ }
855+
856+ #[ tokio:: test]
857+ async fn probe_reports_not_promisor_when_key_absent ( ) {
858+ // A plain mirror never sets remote.origin.promisor, so `git config --get`
859+ // exits 1 (key absent) — the probe must read that as NotPromisor, never
860+ // Unknown (which would wrongly preserve and upgrade a plain mirror).
861+ let ( td, url) = bare_remote ( & [ ( "public/a.txt" , b"pub\n " ) ] ) ;
862+ let dest = td. path ( ) . join ( "mirror.git" ) ;
863+ clone_repo ( & url, & dest, MirrorMode :: Plain ) . await . unwrap ( ) ;
864+
865+ let probe = existing_promisor_state ( dest. to_str ( ) . unwrap ( ) ) . await ;
866+ assert_eq ! ( probe, PromisorProbe :: NotPromisor ) ;
867+ }
868+
869+ #[ tokio:: test]
870+ async fn probe_reports_unknown_on_git_error ( ) {
871+ // A path git cannot resolve as a repo at all (exit 128) is an indeterminate
872+ // probe, not a definitive "not a promisor" — it must map to Unknown so the
873+ // caller preserves rather than downgrades (#48 defense-in-depth).
874+ let probe = existing_promisor_state ( "/nonexistent/gitlawb-probe-xyz" ) . await ;
875+ assert_eq ! ( probe, PromisorProbe :: Unknown ) ;
876+ }
877+
719878 #[ tokio:: test]
720879 async fn promisor_fetch_updates_existing_mirror ( ) {
721880 let ( td, url) = bare_remote ( & [ ( "public/a.txt" , b"pub\n " ) ] ) ;
0 commit comments