Skip to main content

grit_lib/
gc.rs

1//! In-process maintenance primitives that embedders such as `jj` use in place
2//! of shelling out to `git gc` / `git remote show` / `gix::refs::transaction`.
3//!
4//! These are the remaining "replaced paths" from the jj spike (PR #9632) that
5//! are not transport-shaped:
6//!
7//! * [`prune_loose_unreachable`] — what `jj util gc` actually needs: delete the
8//!   loose objects that are not reachable from a set of roots (full repack /
9//!   `pack-refs` are explicitly out of scope).
10//! * [`remote_default_branch_local`] — the `git remote show` default-branch
11//!   lookup for a local / `file://` remote, via the remote `HEAD` symref.
12//! * [`update_refs`] — a thin, compare-and-swap, all-or-nothing batch ref
13//!   transaction over the [`crate::refs`] primitives (the in-process equivalent
14//!   of what jj built on `gix::refs::transaction`).
15//!
16//! Scope note: only the local / on-disk path is in scope here. `git://`,
17//! `http(s)`, and `ssh` remotes are out of scope and left as TODOs.
18
19use std::cell::OnceCell;
20use std::collections::{HashSet, VecDeque};
21use std::path::Path;
22use std::time::SystemTime;
23
24use crate::error::{Error, Result};
25use crate::objects::{parse_commit, parse_tag, ObjectId, ObjectKind, OidHashSet, TreeRefIter};
26use crate::odb::Odb;
27use crate::refs::store::{Expected, RawRef, RefUpdate, RefUpdateFlags};
28use crate::refs::{self, Ref};
29
30/// Result of a loose-object prune.
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
32pub struct PruneStats {
33    /// Number of loose objects deleted.
34    pub pruned: usize,
35    /// Number of loose objects kept (reachable, or too recent to prune).
36    pub kept: usize,
37}
38
39/// Delete loose objects in `odb` that are not reachable from `reachable_roots`.
40///
41/// This is the in-process core of `jj util gc`: it walks the full reachability
42/// closure from `reachable_roots` (commits → parents and trees, trees → entries,
43/// annotated tags → their target) and then removes every **loose** object whose
44/// id is not in that closure. Packed objects are never touched — only loose
45/// object files under `objects/??/` are candidates for deletion.
46///
47/// When `keep_newer_than` is `Some(t)`, a loose object is only deleted if its
48/// file modification time is strictly older than `t`. This mirrors Git's
49/// `gc.pruneExpire` grace window: recently written objects (which may be the
50/// in-progress target of a concurrent operation) are kept even when currently
51/// unreachable. A `None` grace window prunes every unreachable loose object
52/// regardless of age.
53///
54/// Like `git prune`, the walk is skipped when no loose object is old enough to
55/// delete; the roots themselves are still required to exist.
56///
57/// Submodule (gitlink) tree entries are skipped during the walk: the commit they
58/// name lives in another object store.
59///
60/// When `odb` belongs to a repository ([`Odb::config_git_dir`]), the commits listed in
61/// its `shallow` file are treated as having no parents, like every Git history walk:
62/// their trees are walked but their (absent) parents are not. Promisor pack members
63/// and their available descendants form another boundary: promised objects may
64/// remain absent and no maintenance walk fetches them from the remote.
65///
66/// The repository hash width is threaded through [`Odb::hash_algo`] (via the
67/// 2-char fan-out directory + suffix length), so SHA-256 repositories work.
68///
69/// # Errors
70///
71/// Returns [`Error::ObjectNotFound`] if a non-promised root is missing, an error if the walk runs
72/// and a reachable object is missing or cannot be read or parsed, or on I/O failure
73/// while enumerating or deleting loose object files.
74pub fn prune_loose_unreachable(
75    odb: &Odb,
76    reachable_roots: &[ObjectId],
77    keep_newer_than: Option<SystemTime>,
78) -> Result<PruneStats> {
79    prune_loose_unreachable_with_dry_run(odb, reachable_roots, keep_newer_than, false)
80}
81
82/// Like [`prune_loose_unreachable`], but skips deletion when `dry_run` is true.
83///
84/// # Errors
85///
86/// Same as [`prune_loose_unreachable`].
87pub fn prune_loose_unreachable_with_dry_run(
88    odb: &Odb,
89    reachable_roots: &[ObjectId],
90    keep_newer_than: Option<SystemTime>,
91    dry_run: bool,
92) -> Result<PruneStats> {
93    let shallow = odb
94        .config_git_dir()
95        .map(crate::shallow::load_shallow_boundaries)
96        .unwrap_or_default();
97    prune_loose_unreachable_with(
98        odb,
99        &LazyReachable::new(odb, reachable_roots, &shallow),
100        keep_newer_than,
101        dry_run,
102    )
103}
104
105/// [`prune_loose_unreachable_with_dry_run`] sharing `reachable` with the other steps of a
106/// maintenance run, so one run walks the object graph at most once.
107pub(crate) fn prune_loose_unreachable_with(
108    odb: &Odb,
109    reachable: &LazyReachable<'_>,
110    keep_newer_than: Option<SystemTime>,
111    dry_run: bool,
112) -> Result<PruneStats> {
113    odb.require_files_primary("gc")?;
114    let mut stats = PruneStats::default();
115
116    // Respect the grace window first: objects whose mtime is not strictly older than
117    // the cutoff are kept whether reachable or not, so they never need the walk.
118    let candidates: Vec<_> = odb
119        .enumerate_local_loose_objects()?
120        .into_iter()
121        .filter(|(_, path)| {
122            let too_new = keep_newer_than.is_some_and(|cutoff| {
123                std::fs::metadata(path)
124                    .and_then(|m| m.modified())
125                    .is_ok_and(|mtime| mtime >= cutoff)
126            });
127            if too_new {
128                stats.kept += 1;
129            }
130            !too_new
131        })
132        .collect();
133    if candidates.is_empty() {
134        reachable.require_roots_exist()?;
135        return Ok(stats);
136    }
137
138    let reachable = reachable.get()?;
139    for (oid, path) in candidates {
140        if reachable.contains(&oid) {
141            stats.kept += 1;
142            continue;
143        }
144
145        if dry_run {
146            stats.pruned += 1;
147            continue;
148        }
149        match std::fs::remove_file(&path) {
150            Ok(()) => stats.pruned += 1,
151            // A concurrent prune may have already removed it; treat as pruned.
152            Err(e) if e.kind() == std::io::ErrorKind::NotFound => stats.pruned += 1,
153            Err(e) => return Err(e.into()),
154        }
155    }
156
157    Ok(stats)
158}
159
160/// Object ids named directly by refs (any kind), used as roots for [`prune_loose_unreachable`].
161///
162/// Includes annotated tag objects, blob/tree tips, and detached-HEAD oids. Does not peel tags to
163/// commits only — the reachability walk expands each root through the object graph.
164///
165/// # Errors
166///
167/// Propagates ref listing or HEAD read failures.
168pub fn collect_referenced_object_roots(git_dir: &Path) -> Result<Vec<ObjectId>> {
169    let mut roots = HashSet::new();
170    for (_, oid) in refs::list_refs(git_dir, "refs/")? {
171        roots.insert(oid);
172    }
173    let head_path = git_dir.join("HEAD");
174    if head_path.is_file() {
175        match refs::read_ref_file(&head_path)? {
176            Ref::Direct(oid) => {
177                roots.insert(oid);
178            }
179            Ref::Symbolic(_) => {}
180        }
181    }
182    Ok(roots.into_iter().collect())
183}
184
185/// Object closure reachable from a fixed set of roots, walked on first use.
186///
187/// One maintenance run (repack skip check, loosening superseded packs, unreachable prune)
188/// shares a single instance, so the graph is walked at most once and not at all when no
189/// step needs it.
190pub(crate) struct LazyReachable<'a> {
191    odb: &'a Odb,
192    roots: &'a [ObjectId],
193    shallow: &'a HashSet<ObjectId>,
194    closure: OnceCell<OidHashSet>,
195    promised: OnceCell<HashSet<ObjectId>>,
196}
197
198impl<'a> LazyReachable<'a> {
199    /// Closure of `roots` in `odb`, not following the parents of the `shallow` boundary
200    /// commits; nothing is read until [`Self::get`].
201    pub(crate) fn new(odb: &'a Odb, roots: &'a [ObjectId], shallow: &'a HashSet<ObjectId>) -> Self {
202        Self {
203            odb,
204            roots,
205            shallow,
206            closure: OnceCell::new(),
207            promised: OnceCell::new(),
208        }
209    }
210
211    /// The reachable set, walking the graph on the first call.
212    ///
213    /// # Errors
214    ///
215    /// Same as the reachability walk: [`Error::ObjectNotFound`] for a missing non-promised
216    /// object, and read or parse errors.
217    pub(crate) fn get(&self) -> Result<&OidHashSet> {
218        if let Some(closure) = self.closure.get() {
219            return Ok(closure);
220        }
221        let closure = reachable_closure(self.odb, self.roots, self.shallow, self.promised()?)?;
222        Ok(self.closure.get_or_init(|| closure))
223    }
224
225    /// Fail with [`Error::ObjectNotFound`] when a non-promised root is missing.
226    pub(crate) fn require_roots_exist(&self) -> Result<()> {
227        if self.closure.get().is_some() {
228            return Ok(());
229        }
230        match require_objects_exist(self.odb, self.roots, &HashSet::new()) {
231            Err(Error::ObjectNotFound(_)) => {
232                require_objects_exist(self.odb, self.roots, self.promised()?)
233            }
234            result => result,
235        }
236    }
237
238    /// Promisor membership and available descendants, computed once without fetching.
239    pub(crate) fn promised(&self) -> Result<&HashSet<ObjectId>> {
240        if let Some(promised) = self.promised.get() {
241            return Ok(promised);
242        }
243        let promised = crate::promisor::maintenance_promise_boundary(self.odb)?;
244        Ok(self.promised.get_or_init(|| promised))
245    }
246}
247
248/// File type bits of a tree entry mode, and the value that marks a directory.
249pub(crate) const S_IFMT: u32 = 0o170000;
250pub(crate) const S_IFDIR: u32 = 0o040000;
251
252/// Compute the full object closure reachable from `roots` (commits → parents and
253/// tree, trees → entries, tags → target). Mirrors the reachability walk used by
254/// the transfer pack builder, but specialized for prune (no exclusion set).
255///
256/// Commits in `shallow` are boundaries: their tree is walked, their parents are not.
257/// Blobs named by trees are checked for existence but not read: they link to nothing.
258///
259/// # Errors
260///
261/// [`Error::ObjectNotFound`] when a reachable object is missing, and read or parse
262/// errors for the objects that are walked.
263fn reachable_closure(
264    odb: &Odb,
265    roots: &[ObjectId],
266    shallow: &HashSet<ObjectId>,
267    promised: &HashSet<ObjectId>,
268) -> Result<OidHashSet> {
269    // Protect available promised loose objects too, even beyond a stopped walk.
270    let mut seen: OidHashSet = promised.iter().copied().collect();
271    let mut queue: VecDeque<ObjectId> = VecDeque::new();
272    let oid_len = odb.hash_algo().len();
273    let mut blobs: Vec<ObjectId> = Vec::new();
274    let alternates = crate::alternate_boundary::AlternateSources::load(odb)?;
275
276    for &root in roots {
277        if seen.insert(root) && !alternates.contains(&root) {
278            queue.push_back(root);
279        }
280    }
281
282    while let Some(oid) = queue.pop_front() {
283        if alternates.contains(&oid) {
284            continue;
285        }
286        let obj = odb
287            .try_read_once(&oid)?
288            .ok_or_else(|| Error::ObjectNotFound(oid.to_hex()))?;
289        match obj.kind {
290            ObjectKind::Commit => {
291                let commit = parse_commit(&obj.data)?;
292                if !shallow.contains(&oid) {
293                    for parent in commit.parents {
294                        if seen.insert(parent) && !alternates.contains(&parent) {
295                            queue.push_back(parent);
296                        }
297                    }
298                }
299                if seen.insert(commit.tree) && !alternates.contains(&commit.tree) {
300                    queue.push_back(commit.tree);
301                }
302            }
303            ObjectKind::Tree => {
304                for entry in TreeRefIter::with_oid_len(&obj.data, oid_len) {
305                    let entry = entry?;
306                    // Skip submodule (gitlink) entries.
307                    if entry.mode == 0o160000 {
308                        continue;
309                    }
310                    if !seen.insert(entry.oid) {
311                        continue;
312                    }
313                    if alternates.contains(&entry.oid) {
314                        continue;
315                    }
316                    // Classify by file type bits, as Git does, so non-canonical directory
317                    // modes from old trees still count as trees.
318                    if entry.mode & S_IFMT == S_IFDIR {
319                        queue.push_back(entry.oid);
320                    } else {
321                        blobs.push(entry.oid);
322                    }
323                }
324            }
325            ObjectKind::Tag => {
326                let tag = parse_tag(&obj.data)?;
327                if seen.insert(tag.object) && !alternates.contains(&tag.object) {
328                    queue.push_back(tag.object);
329                }
330            }
331            ObjectKind::Blob => {}
332        }
333    }
334
335    require_objects_exist(odb, &blobs, promised)?;
336    Ok(seen)
337}
338
339/// Fail with [`Error::ObjectNotFound`] for the first of `oids` that does not exist.
340///
341/// Local pack indexes are loaded once and searched directly: going through
342/// [`Odb::exists`] or [`Odb::read_info`] per object revalidates the pack directory
343/// or probes for a loose file each time, which dominates prune on large trees.
344fn require_objects_exist(odb: &Odb, oids: &[ObjectId], promised: &HashSet<ObjectId>) -> Result<()> {
345    let indexes = odb.local_pack_indexes().unwrap_or_default();
346    for oid in oids {
347        if promised.contains(oid) {
348            continue;
349        }
350        let packed = indexes.iter().any(|idx| idx.find_position(oid).is_some());
351        if !packed && !odb.contains(oid)? {
352            return Err(Error::ObjectNotFound(oid.to_hex()));
353        }
354    }
355    Ok(())
356}
357
358/// Enumerate the loose objects physically present in `odb`'s objects directory,
359/// returning each `(oid, path)`. Only the `??/<rest>` fan-out directories are
360/// scanned; pack files, `info/`, and any non-object entries are ignored.
361///
362/// Entries whose names do not form a valid full-length hex OID for the
363/// repository's hash algorithm are skipped (e.g. tmp files, the wrong hash
364/// width), matching Git's loose-object scan.
365/// Return the short name of a local remote's default branch (its `HEAD` symref
366/// target), e.g. `main` for a `HEAD` pointing at `refs/heads/main`.
367///
368/// This is the `git remote show <remote>` default-branch lookup for a local /
369/// `file://` remote. It reuses [`crate::remote::list_refs_from_git_dir`]'s symref handling
370/// to read the remote `HEAD` and strips the `refs/heads/` prefix from its
371/// target. Returns `None` when the remote has no symbolic `HEAD` (e.g. a
372/// detached or absent `HEAD`).
373///
374/// # Errors
375///
376/// Returns an error if the remote git directory cannot be read.
377//
378// TODO(phase: remote transports): the `git://`, `http(s)`, and `ssh`
379// default-branch lookup (handshake `symref=HEAD:`) is out of scope here.
380pub fn remote_default_branch_local(remote_git_dir: &Path) -> Result<Option<String>> {
381    let remote_odb =
382        Odb::new(&remote_git_dir.join("objects")).with_config_git_dir(remote_git_dir.to_path_buf());
383
384    let entries = crate::remote::list_refs_from_git_dir(
385        remote_git_dir,
386        Some(&remote_odb),
387        &crate::remote::ListRefsOptions {
388            symrefs: true,
389            ..Default::default()
390        },
391    )
392    .map_err(crate::error::Error::from)?;
393
394    for entry in &entries {
395        if entry.name == "HEAD" {
396            return Ok(entry
397                .symref_target
398                .as_ref()
399                .map(|t| t.strip_prefix("refs/heads/").unwrap_or(t).to_owned()));
400        }
401    }
402
403    Ok(None)
404}
405
406/// A single ref change in an [`update_refs`] batch transaction.
407#[derive(Clone, Debug, PartialEq, Eq)]
408pub struct RefTransactionItem {
409    /// Full ref name (e.g. `refs/heads/main`).
410    pub name: String,
411    /// New value to write, or `None` to delete the ref.
412    pub new_oid: Option<ObjectId>,
413    /// Compare-and-swap expectation. When `Some`, the ref's current value must
414    /// equal this for the item to apply (an `expected_old` of an oid that is not
415    /// the current value — including when the ref is absent — fails the batch).
416    /// When `None`, the current value is not checked.
417    ///
418    /// Note: this CAS form expects the ref to currently hold `expected_old`. To
419    /// require that a ref be *created* (must not already exist), leave this
420    /// `None` — callers that need create-only semantics check existence
421    /// themselves; matching jj's transaction model where `None` means "any".
422    pub expected_old: Option<ObjectId>,
423}
424
425/// Apply a batch of ref create/update/delete operations transactionally with
426/// compare-and-swap semantics.
427///
428/// Every item whose `expected_old` is `Some` is checked against the ref's
429/// current value first; if **any** CAS check fails, the entire batch is rejected
430/// and **nothing** is written (all-or-nothing). Only once all CAS checks pass
431/// are the changes applied:
432///
433/// * `new_oid = Some(oid)` writes (creates or updates) the ref to `oid` via
434///   [`crate::refs::write_ref`].
435/// * `new_oid = None` deletes the ref via [`crate::refs::delete_ref`].
436///
437/// This is a thin transactional wrapper over the [`crate::refs`] primitives,
438/// matching the in-process ref-update path jj built on `gix::refs::transaction`.
439///
440/// # Errors
441///
442/// Returns [`crate::refs::store::RefStoreError`] for duplicate ref names, CAS failures,
443/// lock conflicts, and namespace checks. Propagates other ref I/O errors.
444pub fn update_refs(git_dir: &Path, updates: &[RefTransactionItem]) -> Result<()> {
445    if updates.is_empty() {
446        return Ok(());
447    }
448    let store_updates: Vec<RefUpdate> = updates
449        .iter()
450        .map(ref_transaction_item_to_store_update)
451        .collect();
452    refs::commit_ref_store_batch(git_dir, &store_updates)
453}
454
455fn ref_transaction_item_to_store_update(item: &RefTransactionItem) -> RefUpdate {
456    let mut flags = RefUpdateFlags::default();
457    if item.name == "HEAD" {
458        flags.no_deref = true;
459    }
460    RefUpdate {
461        name: item.name.clone(),
462        new_value: item.new_oid.map(RawRef::Direct),
463        expected: match item.expected_old {
464            Some(oid) => Expected::Oid(oid),
465            None => Expected::Any,
466        },
467        reflog: None,
468        flags,
469    }
470}
471
472#[cfg(test)]
473mod update_refs_tests {
474    use super::*;
475    use crate::error::Error;
476    use crate::refs::store::RefStoreError;
477    use crate::repo::init_repository;
478    use tempfile::TempDir;
479
480    fn sample_oid(hex: &str) -> ObjectId {
481        hex.parse().expect("oid")
482    }
483
484    #[test]
485    fn rejects_duplicate_ref_names_in_batch() {
486        let tmp = TempDir::new().expect("tempdir");
487        let repo = init_repository(
488            tmp.path(),
489            false,
490            "main",
491            None,
492            crate::RefStorageFormat::Files,
493        )
494        .expect("init");
495        let oid = sample_oid("67bf698f3ab735e92fb011a99cff3497c44d30c1");
496        let items = vec![
497            RefTransactionItem {
498                name: "refs/heads/dup".to_owned(),
499                new_oid: Some(oid),
500                expected_old: None,
501            },
502            RefTransactionItem {
503                name: "refs/heads/dup".to_owned(),
504                new_oid: Some(oid),
505                expected_old: None,
506            },
507        ];
508        let err = update_refs(&repo.git_dir(), &items).expect_err("duplicate");
509        match &err {
510            Error::RefStore(RefStoreError::DuplicateUpdate { name }) => {
511                assert_eq!(name, "refs/heads/dup");
512            }
513            other => panic!("expected DuplicateUpdate, got {other}"),
514        }
515    }
516
517    #[test]
518    fn update_refs_typed_errors() {
519        let tmp = TempDir::new().expect("tempdir");
520        let repo = init_repository(
521            tmp.path(),
522            false,
523            "main",
524            None,
525            crate::RefStorageFormat::Files,
526        )
527        .expect("init");
528        let git_dir = repo.git_dir();
529        let c1 = refs::test_commit(&git_dir, "c1");
530        let c2 = refs::test_commit(&git_dir, "c2");
531        refs::write_ref(&git_dir, "refs/heads/cas", &c2).expect("seed");
532
533        let err = update_refs(
534            &git_dir,
535            &[RefTransactionItem {
536                name: "refs/heads/cas".to_owned(),
537                new_oid: Some(c1),
538                expected_old: Some(c1),
539            }],
540        )
541        .expect_err("stale cas");
542        match &err {
543            Error::RefStore(RefStoreError::ExpectedMismatch { name, .. }) => {
544                assert_eq!(name, "refs/heads/cas");
545            }
546            other => panic!("expected ExpectedMismatch, got {other}"),
547        }
548    }
549
550    #[test]
551    fn reftable_cas_rechecked_under_stack_lock() {
552        let tmp = TempDir::new().expect("tempdir");
553        let repo = init_repository(
554            tmp.path(),
555            false,
556            "main",
557            None,
558            crate::RefStorageFormat::Reftable,
559        )
560        .expect("init");
561        let git_dir = repo.git_dir();
562        let c1 = refs::test_commit(&git_dir, "c1");
563        let c2 = refs::test_commit(&git_dir, "c2");
564        crate::refs::write_ref(&git_dir, "refs/heads/locked-cas", &c1).expect("seed");
565        crate::refs::write_ref(&git_dir, "refs/heads/locked-cas", &c2).expect("advance");
566        let err = update_refs(
567            &git_dir,
568            &[RefTransactionItem {
569                name: "refs/heads/locked-cas".to_owned(),
570                new_oid: Some(c1),
571                expected_old: Some(c1),
572            }],
573        )
574        .expect_err("stale cas");
575        assert!(
576            matches!(err, Error::RefStore(RefStoreError::ExpectedMismatch { .. })),
577            "{err}"
578        );
579        assert_eq!(
580            crate::refs::resolve_ref(&git_dir, "refs/heads/locked-cas").unwrap(),
581            c2
582        );
583    }
584
585    #[cfg_attr(
586        not(feature = "full-tests"),
587        ignore = "starts system git or is heavy; run with --features full-tests"
588    )]
589    #[test]
590    fn apply_cas_rejects_after_concurrent_packed_delete() {
591        let _pause_serial = refs::test_pause_injection_serial_guard();
592        use std::path::Path;
593        use std::process::Command;
594        use std::sync::Arc;
595        use std::thread;
596
597        let _pause_scope = refs::ref_store_batch_pause_test_scope();
598
599        fn git(dir: &Path, args: &[&str]) {
600            let out = Command::new("git")
601                .current_dir(dir)
602                .args(args)
603                .env("GIT_AUTHOR_NAME", "T")
604                .env("GIT_AUTHOR_EMAIL", "t@example.com")
605                .env("GIT_COMMITTER_NAME", "T")
606                .env("GIT_COMMITTER_EMAIL", "t@example.com")
607                .env("GIT_CONFIG_GLOBAL", "/dev/null")
608                .env("GIT_CONFIG_SYSTEM", "/dev/null")
609                .output()
610                .expect("git");
611            assert!(
612                out.status.success(),
613                "git {:?}: {}",
614                args,
615                String::from_utf8_lossy(&out.stderr)
616            );
617        }
618
619        fn rev_parse(repo: &Path, refname: &str) -> ObjectId {
620            let hex = Command::new("git")
621                .current_dir(repo)
622                .args(["rev-parse", refname])
623                .env("GIT_CONFIG_GLOBAL", "/dev/null")
624                .env("GIT_CONFIG_SYSTEM", "/dev/null")
625                .output()
626                .expect("rev-parse")
627                .stdout;
628            String::from_utf8(hex)
629                .expect("utf8")
630                .trim()
631                .parse()
632                .expect("oid")
633        }
634
635        let tmp = TempDir::new().expect("tempdir");
636        let repo = tmp.path().join("repo");
637        std::fs::create_dir_all(&repo).unwrap();
638        git(&repo, &["init", "-q", "-b", "main", "."]);
639        let git_dir = repo.join(".git");
640
641        std::fs::write(repo.join("f.txt"), "one\n").unwrap();
642        git(&repo, &["add", "f.txt"]);
643        git(&repo, &["commit", "-q", "-m", "c1"]);
644        let c1 = rev_parse(&repo, "HEAD");
645
646        std::fs::write(repo.join("f.txt"), "two\n").unwrap();
647        git(&repo, &["add", "f.txt"]);
648        git(&repo, &["commit", "-q", "-m", "c2"]);
649        let c2 = rev_parse(&repo, "HEAD");
650
651        for name in ["refs/heads/first", "refs/heads/middle", "refs/heads/last"] {
652            git(&repo, &["update-ref", name, &c1.to_hex()]);
653        }
654        git(&repo, &["pack-refs", "--all"]);
655        for name in ["first", "middle", "last"] {
656            let _ = std::fs::remove_file(git_dir.join(format!("refs/heads/{name}")));
657        }
658
659        let gate = refs::new_ref_store_batch_pause_gate();
660        refs::set_test_inject_ref_store_batch_pause_after_apply(
661            0,
662            &git_dir,
663            Some(Arc::clone(&gate)),
664        );
665
666        let updates = vec![
667            RefTransactionItem {
668                name: "refs/heads/first".to_owned(),
669                new_oid: Some(c2),
670                expected_old: Some(c1),
671            },
672            RefTransactionItem {
673                name: "refs/heads/middle".to_owned(),
674                new_oid: Some(c2),
675                expected_old: Some(c1),
676            },
677            RefTransactionItem {
678                name: "refs/heads/last".to_owned(),
679                new_oid: Some(c2),
680                expected_old: Some(c1),
681            },
682        ];
683
684        let git_dir_thread = git_dir.clone();
685        let updates_thread = updates.clone();
686        let worker = thread::spawn(move || update_refs(&git_dir_thread, &updates_thread));
687
688        let mut paused = gate.wait.lock().expect("lock");
689        while !*paused {
690            paused = gate.cv.wait(paused).expect("wait");
691        }
692
693        git(
694            &repo,
695            &["update-ref", "-d", "refs/heads/last", &c1.to_hex()],
696        );
697
698        *paused = false;
699        gate.cv.notify_all();
700        drop(paused);
701
702        let err = worker.join().expect("join").expect_err("stale CAS");
703        match &err {
704            Error::RefStore(RefStoreError::ExpectedMismatch { name, .. }) => {
705                assert_eq!(name, "refs/heads/last");
706            }
707            other => panic!("expected ExpectedMismatch for last ref, got {other}"),
708        }
709        assert!(crate::refs::resolve_ref(&git_dir, "refs/heads/last").is_err());
710        assert_eq!(
711            crate::refs::resolve_ref(&git_dir, "refs/heads/first").expect("first"),
712            c2
713        );
714
715        refs::set_test_inject_ref_store_batch_pause_after_apply(0, &git_dir, None);
716    }
717
718    #[cfg_attr(
719        not(feature = "full-tests"),
720        ignore = "starts system git or is heavy; run with --features full-tests"
721    )]
722    #[test]
723    fn unconditional_create_rejects_after_concurrent_packed_parent() {
724        let _pause_serial = refs::test_pause_injection_serial_guard();
725        use std::path::Path;
726        use std::process::Command;
727        use std::sync::Arc;
728        use std::thread;
729
730        let _pause_scope = refs::ref_store_batch_pause_test_scope();
731
732        fn git(dir: &Path, args: &[&str]) {
733            let out = Command::new("git")
734                .current_dir(dir)
735                .args(args)
736                .env("GIT_AUTHOR_NAME", "T")
737                .env("GIT_AUTHOR_EMAIL", "t@example.com")
738                .env("GIT_COMMITTER_NAME", "T")
739                .env("GIT_COMMITTER_EMAIL", "t@example.com")
740                .env("GIT_CONFIG_GLOBAL", "/dev/null")
741                .env("GIT_CONFIG_SYSTEM", "/dev/null")
742                .output()
743                .expect("git");
744            assert!(
745                out.status.success(),
746                "git {:?}: {}",
747                args,
748                String::from_utf8_lossy(&out.stderr)
749            );
750        }
751
752        fn rev_parse(repo: &Path, refname: &str) -> ObjectId {
753            let hex = Command::new("git")
754                .current_dir(repo)
755                .args(["rev-parse", refname])
756                .env("GIT_CONFIG_GLOBAL", "/dev/null")
757                .env("GIT_CONFIG_SYSTEM", "/dev/null")
758                .output()
759                .expect("rev-parse")
760                .stdout;
761            String::from_utf8(hex)
762                .expect("utf8")
763                .trim()
764                .parse()
765                .expect("oid")
766        }
767
768        let tmp = TempDir::new().expect("tempdir");
769        let repo = tmp.path().join("repo");
770        std::fs::create_dir_all(&repo).unwrap();
771        git(&repo, &["init", "-q", "-b", "main", "."]);
772        let git_dir = repo.join(".git");
773
774        std::fs::write(repo.join("f.txt"), "one\n").unwrap();
775        git(&repo, &["add", "f.txt"]);
776        git(&repo, &["commit", "-q", "-m", "c1"]);
777        let c1 = rev_parse(&repo, "HEAD");
778
779        let gate = refs::new_ref_store_batch_pause_gate();
780        refs::set_test_inject_ref_store_batch_pause_after_apply(
781            0,
782            &git_dir,
783            Some(Arc::clone(&gate)),
784        );
785
786        let updates = vec![
787            RefTransactionItem {
788                name: "refs/heads/one".to_owned(),
789                new_oid: Some(c1),
790                expected_old: None,
791            },
792            RefTransactionItem {
793                name: "refs/heads/two".to_owned(),
794                new_oid: Some(c1),
795                expected_old: None,
796            },
797            RefTransactionItem {
798                name: "refs/heads/conflict/child".to_owned(),
799                new_oid: Some(c1),
800                expected_old: None,
801            },
802        ];
803
804        let git_dir_thread = git_dir.clone();
805        let updates_thread = updates.clone();
806        let worker = thread::spawn(move || update_refs(&git_dir_thread, &updates_thread));
807
808        let mut paused = gate.wait.lock().expect("lock");
809        while !*paused {
810            paused = gate.cv.wait(paused).expect("wait");
811        }
812
813        git(&repo, &["update-ref", "refs/heads/conflict", &c1.to_hex()]);
814        git(&repo, &["pack-refs", "--all"]);
815
816        *paused = false;
817        gate.cv.notify_all();
818        drop(paused);
819
820        let err = worker
821            .join()
822            .expect("join")
823            .expect_err("namespace conflict on child");
824        assert!(
825            err.to_string().contains("namespace conflict")
826                || err.to_string().contains("cannot lock ref"),
827            "{err}"
828        );
829        assert!(crate::refs::resolve_ref(&git_dir, "refs/heads/conflict").is_ok());
830        assert!(crate::refs::resolve_ref(&git_dir, "refs/heads/conflict/child").is_err());
831
832        refs::set_test_inject_ref_store_batch_pause_after_apply(0, &git_dir, None);
833    }
834}
835
836#[cfg(test)]
837mod prune_walk_tests {
838    use super::*;
839    use crate::maintenance::{repack, RepackOptions};
840    use crate::repo::init_repository;
841    use tempfile::TempDir;
842
843    #[test]
844    fn promise_boundary_is_shared_and_missing_local_objects_still_fail() {
845        let tmp = TempDir::new().expect("tempdir");
846        let repo = init_repository(
847            tmp.path(),
848            true,
849            "main",
850            None,
851            crate::RefStorageFormat::Files,
852        )
853        .expect("init");
854        let missing = ObjectId::from_hex("1234567890123456789012345678901234567890").expect("oid");
855        let mut tree = b"100644 promised\0".to_vec();
856        tree.extend_from_slice(missing.as_bytes());
857        let tree = repo.odb().write(ObjectKind::Tree, &tree).expect("tree");
858        crate::promisor::write_promisor_marker(repo.git_dir(), &HashSet::from([missing]))
859            .expect("marker");
860        let roots = [tree];
861        let shallow = HashSet::new();
862        let reachable = LazyReachable::new(repo.odb(), &roots, &shallow);
863        assert!(reachable.get().expect("closure").contains(&missing));
864        crate::promisor::write_promisor_marker(repo.git_dir(), &HashSet::new())
865            .expect("clear marker");
866        assert!(reachable
867            .promised()
868            .expect("cached promises")
869            .contains(&missing));
870        let next = LazyReachable::new(repo.odb(), &roots, &shallow);
871        assert!(matches!(next.get(), Err(Error::ObjectNotFound(_))));
872    }
873
874    #[test]
875    fn prune_checks_packed_blobs_without_per_object_lookups() {
876        let tmp = TempDir::new().expect("tempdir");
877        let repo = init_repository(
878            tmp.path(),
879            false,
880            "main",
881            None,
882            crate::RefStorageFormat::Files,
883        )
884        .expect("init");
885        let odb = repo.odb();
886        let mut tree = Vec::new();
887        for i in 0..50 {
888            let blob = odb
889                .write(ObjectKind::Blob, format!("blob {i}\n").as_bytes())
890                .expect("blob");
891            tree.extend_from_slice(format!("100644 f{i:02}\0").as_bytes());
892            tree.extend_from_slice(blob.as_bytes());
893        }
894        let tree = odb.write(ObjectKind::Tree, &tree).expect("tree");
895        let commit = format!(
896            "tree {tree}\nauthor A <a@example.com> 1 +0000\ncommitter A <a@example.com> 1 +0000\n\nm\n"
897        );
898        let commit = odb
899            .write(ObjectKind::Commit, commit.as_bytes())
900            .expect("commit");
901        std::fs::write(
902            repo.git_dir().join("refs/heads/main"),
903            format!("{}\n", commit.to_hex()),
904        )
905        .expect("ref");
906        repack(&repo, &RepackOptions::default()).expect("repack");
907        assert!(odb
908            .enumerate_local_loose_objects()
909            .expect("loose")
910            .is_empty());
911
912        odb.reset_exists_probe();
913        let stats = prune_loose_unreachable(odb, &[commit], None).expect("prune");
914        assert_eq!(stats, PruneStats::default());
915        assert_eq!(
916            odb.exists_probe_count(),
917            0,
918            "packed blobs must be checked against the pack indexes, not one lookup each"
919        );
920    }
921}