pointbreak 0.10.0

Durable terminal code review for changes humans and coding agents collaborate on together
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
//! The git backend seam. Every routable `git_*` operation dispatches through a
//! closed [`GitBackendKind`] enum resolved at one choke point ([`dispatch`]);
//! the concrete work lives behind the object-safe [`GitBackend`] trait. Today
//! the only variant shells out to the `git` binary ([`subprocess`]); a library
//! backend can be added later without touching call sites.
//!
//! Capture-time diff and `write-tree` are deliberately **not** trait methods:
//! they stay direct-subprocess free functions so no dispatch path can ever route
//! them away from `git` itself.

use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;

use crate::error::{Result, ShoreError};
use crate::git::command::{Ancestry, GitInventoryPath, GitReflogEntry, GitWorktree, RefEntry};

#[cfg(feature = "gix")]
pub(crate) mod gix;
pub(crate) mod subprocess;

#[cfg(feature = "gix")]
use gix::GixBackend;
use subprocess::SubprocessBackend;

/// One method per routable git operation, each mirroring the existing typed
/// return so the three-valued/allowed-status exit semantics stay absorbed inside
/// the operation and no exit code crosses the seam. Object-safe by construction
/// (every method takes `&self` and returns an owned value).
pub(crate) trait GitBackend: Send + Sync {
    // Repository discovery.
    fn worktree_root(&self, repo: &Path) -> Result<PathBuf>;
    fn common_dir(&self, repo: &Path) -> Result<PathBuf>;

    // Read: graph / refs.
    fn is_ancestor(
        &self,
        repo: &Path,
        ancestor_oid: &str,
        descendant_oid: &str,
    ) -> Result<Ancestry>;
    fn independent_commits(&self, repo: &Path, oids: &[String]) -> Result<Vec<String>>;
    fn commit_changed_paths(&self, repo: &Path, commit_oid: &str) -> Result<Vec<String>>;
    fn commit_subjects(
        &self,
        repo: &Path,
        commit_oids: &BTreeSet<String>,
    ) -> Result<BTreeMap<String, String>>;
    fn for_each_ref(&self, repo: &Path, patterns: &[&str]) -> Result<Vec<RefEntry>>;
    fn ref_state_lines(&self, repo: &Path) -> Result<String>;
    fn object_exists(&self, repo: &Path, oid: &str) -> Result<bool>;
    fn default_branch_ref(&self, repo: &Path) -> Result<Option<String>>;
    fn rev_list_range(&self, repo: &Path, range: &str) -> Result<Vec<String>>;
    fn rev_list_reachable(&self, repo: &Path, tips: &[String]) -> Result<HashSet<String>>;
    fn rev_list_reflog_reachable(&self, repo: &Path) -> Result<HashSet<String>>;
    fn reflog_entries(&self, repo: &Path, ref_name: &str) -> Result<Vec<GitReflogEntry>>;
    fn worktree_list(&self, repo: &Path) -> Result<Vec<GitWorktree>>;

    // Read: ignore (the exclude stack is opened/reloaded per call, so an
    // ignore-source mutation is always observed by a later probe).
    fn paths_are_ignored(&self, repo: &Path, pathspecs: &[&str]) -> Result<Vec<bool>>;

    // Read: inventory.
    fn untracked_inventory(&self, repo: &Path) -> Result<Vec<GitInventoryPath>>;
    fn tracked_and_untracked_inventory(&self, repo: &Path) -> Result<Vec<GitInventoryPath>>;
    fn path_is_untracked(&self, repo: &Path, relative_path: &str) -> Result<bool>;

    // Read: config. Option returns — a backend/config miss is `None`, never an
    // error, matching the writer-identity fallback semantics.
    fn config_get(&self, repo: &Path, key: &str) -> Option<String>;
    fn config_path_get(&self, repo: &Path, key: &str) -> Option<String>;

    // Identity-grade scalars.
    fn head_ref(&self, repo: &Path) -> Result<Option<String>>;
    fn head_oid(&self, repo: &Path) -> Result<String>;
    fn head_commit_oid_optional(&self, repo: &Path) -> Result<Option<String>>;
    fn rev_parse_commit_oid(&self, repo: &Path, rev: &str) -> Result<String>;
    fn commit_tree_oid(&self, repo: &Path, commit_oid: &str) -> Result<String>;
    fn empty_tree_oid(&self, repo: &Path) -> Result<String>;
}

/// The closed set of git backends resolved at the [`dispatch`] choke point. The
/// subprocess backend is always present; the in-process `gix` backend is added
/// by the `gix` cargo feature (in the default set since the default flip), so a
/// `--no-default-features` build keeps a single-variant enum.
pub(crate) enum GitBackendKind {
    Subprocess(SubprocessBackend),
    #[cfg(feature = "gix")]
    Gix(GixBackend),
}

impl GitBackendKind {
    /// Borrow the active backend as a trait object. The delegating `GitBackend`
    /// impl below routes every method through this one match, so adding a
    /// variant is a single new arm here.
    fn as_backend(&self) -> &dyn GitBackend {
        match self {
            GitBackendKind::Subprocess(backend) => {
                #[cfg(test)]
                subprocess::record_backend_tag(subprocess::BackendTag::Subprocess);
                backend
            }
            #[cfg(feature = "gix")]
            GitBackendKind::Gix(backend) => {
                #[cfg(test)]
                subprocess::record_backend_tag(subprocess::BackendTag::Gix);
                backend
            }
        }
    }
}

impl GitBackend for GitBackendKind {
    fn worktree_root(&self, repo: &Path) -> Result<PathBuf> {
        self.as_backend().worktree_root(repo)
    }

    fn common_dir(&self, repo: &Path) -> Result<PathBuf> {
        self.as_backend().common_dir(repo)
    }

    fn is_ancestor(
        &self,
        repo: &Path,
        ancestor_oid: &str,
        descendant_oid: &str,
    ) -> Result<Ancestry> {
        self.as_backend()
            .is_ancestor(repo, ancestor_oid, descendant_oid)
    }

    fn independent_commits(&self, repo: &Path, oids: &[String]) -> Result<Vec<String>> {
        self.as_backend().independent_commits(repo, oids)
    }

    fn commit_changed_paths(&self, repo: &Path, commit_oid: &str) -> Result<Vec<String>> {
        self.as_backend().commit_changed_paths(repo, commit_oid)
    }

    fn commit_subjects(
        &self,
        repo: &Path,
        commit_oids: &BTreeSet<String>,
    ) -> Result<BTreeMap<String, String>> {
        self.as_backend().commit_subjects(repo, commit_oids)
    }

    fn for_each_ref(&self, repo: &Path, patterns: &[&str]) -> Result<Vec<RefEntry>> {
        self.as_backend().for_each_ref(repo, patterns)
    }

    fn ref_state_lines(&self, repo: &Path) -> Result<String> {
        self.as_backend().ref_state_lines(repo)
    }

    fn object_exists(&self, repo: &Path, oid: &str) -> Result<bool> {
        self.as_backend().object_exists(repo, oid)
    }

    fn default_branch_ref(&self, repo: &Path) -> Result<Option<String>> {
        self.as_backend().default_branch_ref(repo)
    }

    fn rev_list_range(&self, repo: &Path, range: &str) -> Result<Vec<String>> {
        self.as_backend().rev_list_range(repo, range)
    }

    fn rev_list_reachable(&self, repo: &Path, tips: &[String]) -> Result<HashSet<String>> {
        self.as_backend().rev_list_reachable(repo, tips)
    }

    fn rev_list_reflog_reachable(&self, repo: &Path) -> Result<HashSet<String>> {
        self.as_backend().rev_list_reflog_reachable(repo)
    }

    fn reflog_entries(&self, repo: &Path, ref_name: &str) -> Result<Vec<GitReflogEntry>> {
        self.as_backend().reflog_entries(repo, ref_name)
    }

    fn worktree_list(&self, repo: &Path) -> Result<Vec<GitWorktree>> {
        self.as_backend().worktree_list(repo)
    }

    fn paths_are_ignored(&self, repo: &Path, pathspecs: &[&str]) -> Result<Vec<bool>> {
        self.as_backend().paths_are_ignored(repo, pathspecs)
    }

    fn untracked_inventory(&self, repo: &Path) -> Result<Vec<GitInventoryPath>> {
        self.as_backend().untracked_inventory(repo)
    }

    fn tracked_and_untracked_inventory(&self, repo: &Path) -> Result<Vec<GitInventoryPath>> {
        self.as_backend().tracked_and_untracked_inventory(repo)
    }

    fn path_is_untracked(&self, repo: &Path, relative_path: &str) -> Result<bool> {
        self.as_backend().path_is_untracked(repo, relative_path)
    }

    fn config_get(&self, repo: &Path, key: &str) -> Option<String> {
        self.as_backend().config_get(repo, key)
    }

    fn config_path_get(&self, repo: &Path, key: &str) -> Option<String> {
        self.as_backend().config_path_get(repo, key)
    }

    fn head_ref(&self, repo: &Path) -> Result<Option<String>> {
        self.as_backend().head_ref(repo)
    }

    fn head_oid(&self, repo: &Path) -> Result<String> {
        self.as_backend().head_oid(repo)
    }

    fn head_commit_oid_optional(&self, repo: &Path) -> Result<Option<String>> {
        self.as_backend().head_commit_oid_optional(repo)
    }

    fn rev_parse_commit_oid(&self, repo: &Path, rev: &str) -> Result<String> {
        self.as_backend().rev_parse_commit_oid(repo, rev)
    }

    fn commit_tree_oid(&self, repo: &Path, commit_oid: &str) -> Result<String> {
        self.as_backend().commit_tree_oid(repo, commit_oid)
    }

    fn empty_tree_oid(&self, repo: &Path) -> Result<String> {
        self.as_backend().empty_tree_oid(repo)
    }
}

static SUBPROCESS_KIND: GitBackendKind = GitBackendKind::Subprocess(SubprocessBackend);

#[cfg(feature = "gix")]
static GIX_KIND: GitBackendKind = GitBackendKind::Gix(GixBackend);

static SUBPROCESS_BACKEND: SubprocessBackend = SubprocessBackend;

/// The environment variable that overrides the compiled backend default. Absent
/// uses the compiled default; `subprocess`/`gix` force every routable operation
/// onto that backend; any other value (empty, non-UTF-8, unknown, or `gix` on a
/// build without the gix feature) is a hard, actionable error.
const POINTBREAK_GIT_BACKEND: &str = "POINTBREAK_GIT_BACKEND";

/// How the process resolves a routable operation's backend. `Compiled` follows
/// each class's build-time default (the qualified classes route to gix when the
/// `gix` feature is compiled in; the rest stay subprocess); the two `Force*`
/// values are the runtime override for diagnostics and immediate mitigation.
/// Resolved once per process (see [`selector`]).
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum BackendSelector {
    Compiled,
    ForceSubprocess,
    // On a build without the gix backend, `ForceGix` is unreachable through the
    // environment (`parse_selector` rejects `gix`), so production code never
    // constructs it — but the variant must still exist so `dispatch` can reject a
    // test-injected `ForceGix` (the F4 hard-error contract). It is live in any
    // gix build.
    #[cfg_attr(not(feature = "gix"), allow(dead_code))]
    ForceGix,
}

/// Parse `POINTBREAK_GIT_BACKEND` into a [`BackendSelector`]. Absent is the
/// compiled default; `subprocess`/`gix` are the forced values; empty, non-UTF-8,
/// unknown, and `gix` on a feature-off build are all actionable errors — never a
/// silent fallback. An explicit `gix` with no gix backend fails here rather than
/// quietly resolving to subprocess.
fn parse_selector(raw: Option<&OsStr>) -> Result<BackendSelector> {
    let Some(value) = raw else {
        return Ok(BackendSelector::Compiled);
    };
    let Some(text) = value.to_str() else {
        return Err(ShoreError::Message(format!(
            "{POINTBREAK_GIT_BACKEND} is not valid UTF-8; set it to 'subprocess' or 'gix'"
        )));
    };
    match text {
        "subprocess" => Ok(BackendSelector::ForceSubprocess),
        #[cfg(feature = "gix")]
        "gix" => Ok(BackendSelector::ForceGix),
        #[cfg(not(feature = "gix"))]
        "gix" => Err(ShoreError::Message(format!(
            "{POINTBREAK_GIT_BACKEND}=gix but this build was compiled without the gix backend"
        ))),
        other => Err(ShoreError::Message(format!(
            "{POINTBREAK_GIT_BACKEND}={other:?} is not a known git backend \
             (expected 'subprocess' or 'gix')"
        ))),
    }
}

/// The process-wide backend selector, resolved once from the environment and
/// cached. Tests inject a thread-local override so a bad-selector case never
/// poisons the shared cache for a concurrent test.
fn selector() -> Result<BackendSelector> {
    #[cfg(test)]
    if let Some(injected) = INJECTED_SELECTOR.with(std::cell::Cell::get) {
        return Ok(injected);
    }

    // Cache the parsed value (or its error text) once per process. `ShoreError`
    // is not `Clone`, so the error is cached as its rendered message and rebuilt.
    static CACHED: OnceLock<std::result::Result<BackendSelector, String>> = OnceLock::new();
    CACHED
        .get_or_init(|| {
            parse_selector(std::env::var_os(POINTBREAK_GIT_BACKEND).as_deref())
                .map_err(|error| error.to_string())
        })
        .clone()
        .map_err(ShoreError::Message)
}

/// Validate `POINTBREAK_GIT_BACKEND` at startup, surfacing an actionable error
/// for an empty/non-UTF-8/unknown/feature-off-`gix` value before any subcommand
/// runs. Re-exported from `git/mod.rs` so the separate binary crate's `run_cli`
/// can call it as its single validation boundary; every CLI path flows through
/// there, so the infallible config helpers simply run post-validation.
#[doc(hidden)]
pub fn validate_backend_selector() -> Result<()> {
    selector().map(|_| ())
}

/// The routable operation classes. Each routable `git_*` helper carries exactly
/// one class, classified at its highest-risk use, and the class chooses the
/// backend via its compiled default. The two non-routable operations — the
/// capture diff pipeline and write-tree — are deliberately absent: they never
/// dispatch, so no class default or runtime selector can route them away from
/// `git` itself.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub(crate) enum BackendClass {
    ReadGraphRefs,
    ReadIgnore,
    ReadInventory,
    ReadConfigDiscovery,
    ReadRepoDiscovery,
    IdentityScalars,
}

/// The backend a class resolves to. Flipping a class promotes its compiled
/// default from `Subprocess` to `Gix`; per-class rollback is the same constant
/// reversed.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum RoutedBackend {
    Subprocess,
    // Constructed once a class default is flipped to gix, or when the runtime
    // selector forces gix. On a build without the gix backend no class default is
    // gix and the forced-gix selector is rejected earlier, so this variant stays
    // unconstructed there and would otherwise read as dead code.
    #[cfg_attr(not(feature = "gix"), allow(dead_code))]
    Gix,
}

// The compiled per-class defaults. A class flips to gix one constant at a time,
// only after byte-equal cross-platform parity plus a measured win, and the flip
// takes effect only in a build that includes the gix backend (a gix-free build
// collapses a gix default back to subprocess in `dispatch`).
//
// Qualified to gix (zero divergence on the macOS and Windows differential
// batteries and no failure in the forced-gix full suite on both platforms): the
// read graph/refs, ignore, inventory, and repo-discovery classes, and the
// identity-grade scalars. The gix backend normalizes the Windows path-form
// spellings that once diverged (`common_dir`/`worktree_list`/`worktree_root`
// verbatim `\\?\` paths, `path_is_untracked`'s backslash comparison key) to git's
// form. `IdentityScalars` additionally qualifies under its SHA-256 OID
// byte-parity and multi-scope writer `config --get` precedence legs.
// `ReadConfigDiscovery` stays on subprocess: git's `config --type=path` renders a
// `~`-expanded signing-key path in forward-slash form but an absolute stored path
// with its backslashes, and gix cannot reproduce that conditional spelling — a
// supported held steady state.
const DEFAULT_READ_GRAPH_REFS: RoutedBackend = RoutedBackend::Gix;
const DEFAULT_READ_IGNORE: RoutedBackend = RoutedBackend::Gix;
const DEFAULT_READ_INVENTORY: RoutedBackend = RoutedBackend::Gix;
const DEFAULT_READ_CONFIG_DISCOVERY: RoutedBackend = RoutedBackend::Subprocess;
const DEFAULT_READ_REPO_DISCOVERY: RoutedBackend = RoutedBackend::Gix;
const DEFAULT_IDENTITY_SCALARS: RoutedBackend = RoutedBackend::Gix;

/// The compiled default backend for a class.
fn class_default(class: BackendClass) -> RoutedBackend {
    match class {
        BackendClass::ReadGraphRefs => DEFAULT_READ_GRAPH_REFS,
        BackendClass::ReadIgnore => DEFAULT_READ_IGNORE,
        BackendClass::ReadInventory => DEFAULT_READ_INVENTORY,
        BackendClass::ReadConfigDiscovery => DEFAULT_READ_CONFIG_DISCOVERY,
        BackendClass::ReadRepoDiscovery => DEFAULT_READ_REPO_DISCOVERY,
        BackendClass::IdentityScalars => DEFAULT_IDENTITY_SCALARS,
    }
}

/// Resolve a class to its backend, honoring the runtime selector. An explicit
/// `subprocess`/`gix` override forces every class onto that backend; `Compiled`
/// uses the class's default. A feature-off explicit `gix` is a hard error here,
/// before any collapse into a routed value — only a compiled default may resolve
/// to subprocess on a build without the gix backend.
fn routed_backend(class: BackendClass) -> Result<RoutedBackend> {
    Ok(match selector()? {
        BackendSelector::ForceSubprocess => RoutedBackend::Subprocess,
        #[cfg(feature = "gix")]
        BackendSelector::ForceGix => RoutedBackend::Gix,
        #[cfg(not(feature = "gix"))]
        BackendSelector::ForceGix => {
            return Err(ShoreError::Message(format!(
                "{POINTBREAK_GIT_BACKEND}=gix but this build was compiled without the gix backend"
            )));
        }
        BackendSelector::Compiled => class_default(class),
    })
}

/// Resolve the backend for a routable operation's class. Fallible because it
/// surfaces the selector error: `subprocess`/`Compiled`-on-a-subprocess-default
/// routes to the subprocess backend, a gix default or an explicit `gix` routes to
/// the native gix backend, and a feature-off explicit `gix` is a hard error (only
/// a compiled gix default may fall back to subprocess on a gix-free build).
pub(crate) fn dispatch(class: BackendClass) -> Result<&'static GitBackendKind> {
    match routed_backend(class)? {
        RoutedBackend::Subprocess => Ok(&SUBPROCESS_KIND),
        #[cfg(feature = "gix")]
        RoutedBackend::Gix => Ok(&GIX_KIND),
        #[cfg(not(feature = "gix"))]
        RoutedBackend::Gix => Ok(&SUBPROCESS_KIND),
    }
}

/// Map a harness class name to its [`BackendClass`]. Parity-harness-only — the
/// enforcing gate keys on it — so an unknown name is a test bug and panics. The
/// `all(test, gix-parity)` gate (not `any`) keeps it from compiling unused in the
/// all-features non-test library build.
#[cfg(all(test, feature = "gix-parity"))]
pub(crate) fn backend_class_for_name(name: &str) -> BackendClass {
    match name {
        "read:graph-refs" => BackendClass::ReadGraphRefs,
        "read:ignore" => BackendClass::ReadIgnore,
        "read:inventory" => BackendClass::ReadInventory,
        "read:config-discovery" => BackendClass::ReadConfigDiscovery,
        "read:repo-discovery" => BackendClass::ReadRepoDiscovery,
        "identity-scalars" => BackendClass::IdentityScalars,
        other => panic!("unknown git-backend parity class name: {other:?}"),
    }
}

/// True when a class's compiled default is gix — it has been qualified and
/// flipped. The enforcing parity gate fails only these classes on divergence; a
/// class still on subprocess is reported, never failed. Single-sourced from the
/// per-class defaults so the gate and the routing never disagree.
#[cfg(all(test, feature = "gix-parity"))]
pub(crate) fn is_gix_qualified(name: &str) -> bool {
    class_default(backend_class_for_name(name)) == RoutedBackend::Gix
}

/// The direct subprocess handle for the two non-routable operations — write-tree
/// and (via the ingest pipeline) capture diff. It never consults [`dispatch`], so
/// no selector or class default can route these identity-bearing operations away
/// from `git` itself; that is what keeps their "subprocess by construction"
/// guarantee structural rather than configured.
pub(crate) fn subprocess_backend() -> &'static SubprocessBackend {
    &SUBPROCESS_BACKEND
}

// A test-only, thread-local backend selector override. Thread-local (like the
// Phase 1 instrumentation) so a test's inject/act/reset is never perturbed by a
// concurrent test on another thread under a shared-process runner.
#[cfg(test)]
thread_local! {
    static INJECTED_SELECTOR: std::cell::Cell<Option<BackendSelector>> =
        const { std::cell::Cell::new(None) };
}

#[cfg(test)]
pub(crate) fn inject_selector(selector: BackendSelector) {
    INJECTED_SELECTOR.with(|cell| cell.set(Some(selector)));
}

#[cfg(test)]
pub(crate) fn reset_selector() {
    INJECTED_SELECTOR.with(|cell| cell.set(None));
}

#[cfg(test)]
mod tests {
    use subprocess::run_git;
    use tempfile::TempDir;

    use super::*;

    fn init_repo() -> TempDir {
        let dir = TempDir::new().expect("create temp git repository directory");
        run_git(dir.path(), ["init"]).unwrap();
        run_git(dir.path(), ["config", "user.name", "Shore Tests"]).unwrap();
        run_git(
            dir.path(),
            ["config", "user.email", "shore-tests@example.com"],
        )
        .unwrap();
        run_git(dir.path(), ["config", "commit.gpgsign", "false"]).unwrap();
        std::fs::write(dir.path().join("file.txt"), "one\n").unwrap();
        run_git(dir.path(), ["add", "--all"]).unwrap();
        run_git(dir.path(), ["commit", "-m", "first"]).unwrap();
        dir
    }

    #[test]
    fn subprocess_backend_resolves_discovery_and_graph() {
        let repo = init_repo();
        let backend = SubprocessBackend;

        let root = backend.worktree_root(repo.path()).unwrap();
        assert_eq!(
            root.canonicalize().unwrap(),
            repo.path().canonicalize().unwrap()
        );
        assert!(backend.common_dir(repo.path()).is_ok());

        let entries = backend.for_each_ref(repo.path(), &["refs/heads/"]).unwrap();
        assert!(
            entries
                .iter()
                .any(|entry| entry.name.starts_with("refs/heads/"))
        );
    }

    #[test]
    fn dispatch_routes_through_the_subprocess_backend() {
        let repo = init_repo();
        // Pin subprocess so the assertion holds even when the whole suite runs
        // under `POINTBREAK_GIT_BACKEND=gix`.
        #[cfg(feature = "gix")]
        inject_selector(BackendSelector::ForceSubprocess);
        // The choke point resolves the same discovery/graph contract as the
        // backend directly, proving call sites can dispatch through the enum.
        assert!(
            dispatch(BackendClass::IdentityScalars)
                .unwrap()
                .worktree_root(repo.path())
                .is_ok()
        );
        assert!(
            dispatch(BackendClass::ReadGraphRefs)
                .unwrap()
                .for_each_ref(repo.path(), &["refs/heads/"])
                .is_ok()
        );
        #[cfg(feature = "gix")]
        reset_selector();
    }

    #[test]
    fn selector_rejects_bad_values_and_feature_off_gix() {
        assert!(parse_selector(Some(OsStr::new("libgit2"))).is_err());
        assert!(parse_selector(Some(OsStr::new(""))).is_err());
        assert_eq!(parse_selector(None).unwrap(), BackendSelector::Compiled);
        assert_eq!(
            parse_selector(Some(OsStr::new("subprocess"))).unwrap(),
            BackendSelector::ForceSubprocess
        );
        #[cfg(not(feature = "gix"))]
        assert!(parse_selector(Some(OsStr::new("gix"))).is_err());
        #[cfg(feature = "gix")]
        assert_eq!(
            parse_selector(Some(OsStr::new("gix"))).unwrap(),
            BackendSelector::ForceGix
        );
    }

    #[cfg(not(feature = "gix"))]
    #[test]
    fn dispatch_rejects_feature_off_force_gix() {
        // A feature-off build cannot resolve an explicit gix selection: an
        // injected `ForceGix` errors rather than collapsing to subprocess.
        inject_selector(BackendSelector::ForceGix);
        assert!(dispatch(BackendClass::ReadGraphRefs).is_err());
        reset_selector();
    }

    #[cfg(feature = "gix")]
    #[test]
    fn identity_scalars_route_to_gix_by_default() {
        // The identity-grade scalar class routes to gix by its compiled default
        // once qualified (SHA-256 OID parity + multi-scope config precedence).
        inject_selector(BackendSelector::Compiled);
        assert_eq!(
            routed_backend(BackendClass::IdentityScalars).unwrap(),
            RoutedBackend::Gix
        );
        reset_selector();
    }

    #[cfg(feature = "gix")]
    #[test]
    fn compiled_defaults_route_qualified_classes_to_gix() {
        // The qualified classes route to gix by their compiled default; only
        // config-discovery stays on subprocess. Pin the compiled path explicitly
        // (not `reset_selector`) so the assertion is deterministic even under
        // `POINTBREAK_GIT_BACKEND=gix`.
        inject_selector(BackendSelector::Compiled);
        for class in [
            BackendClass::ReadIgnore,
            BackendClass::ReadGraphRefs,
            BackendClass::ReadInventory,
            BackendClass::ReadRepoDiscovery,
            BackendClass::IdentityScalars,
        ] {
            assert_eq!(
                routed_backend(class).unwrap(),
                RoutedBackend::Gix,
                "{class:?} is qualified to gix"
            );
        }
        // Only config-discovery stays on subprocess (git's `config --type=path`
        // spelling is not reproducible in gix).
        assert_eq!(
            routed_backend(BackendClass::ReadConfigDiscovery).unwrap(),
            RoutedBackend::Subprocess,
            "config-discovery stays on subprocess"
        );
        reset_selector();
    }

    #[cfg(not(feature = "gix"))]
    #[test]
    fn default_build_qualified_class_stays_subprocess() {
        // The qualified class's compiled default is gix, but a gix-free build has
        // only the subprocess variant, so dispatch collapses it back to subprocess
        // — behavior is identical to before the flip.
        inject_selector(BackendSelector::Compiled);
        let repo = init_repo();
        subprocess::reset_backend_tag();
        let _ = dispatch(BackendClass::ReadIgnore)
            .unwrap()
            .paths_are_ignored(repo.path(), &["file.txt"]);
        assert_eq!(
            subprocess::last_backend_tag(),
            Some(subprocess::BackendTag::Subprocess)
        );
        reset_selector();
    }

    #[cfg(feature = "gix")]
    #[test]
    fn force_gix_routes_every_class_to_gix() {
        // The runtime override forces every routable class onto gix regardless
        // of its compiled default.
        inject_selector(BackendSelector::ForceGix);
        assert_eq!(
            routed_backend(BackendClass::ReadGraphRefs).unwrap(),
            RoutedBackend::Gix
        );
        assert_eq!(
            routed_backend(BackendClass::IdentityScalars).unwrap(),
            RoutedBackend::Gix
        );
        reset_selector();
    }

    #[cfg(feature = "gix-parity")]
    #[test]
    fn backend_class_for_name_covers_every_harness_class_name() {
        // Every class name the harness emits maps to a distinct BackendClass
        // (no panic, no collision), so the enforcing gate can never misroute.
        use std::collections::HashSet;
        let names = [
            "read:graph-refs",
            "read:ignore",
            "read:inventory",
            "read:config-discovery",
            "read:repo-discovery",
            "identity-scalars",
        ];
        let classes: HashSet<_> = names
            .iter()
            .map(|name| backend_class_for_name(name))
            .collect();
        assert_eq!(
            classes.len(),
            6,
            "each harness class name maps to a distinct BackendClass"
        );
    }
}