Skip to main content

git_policy_cli/
service_pruned.rs

1//! What:
2//!  How a commit headed for the service notices that the content it prepared was removed from the repository before it
3//!  landed: the policy read that fails on a missing object, the check of the request's objects after the service could not
4//!  land it, and the numbered phase markers a test uses to remove content at a chosen instant.
5//! Why:
6//!  A commit's blobs, trees and commit object are unreferenced until the branch moves, and `git gc --prune=now` deletes every
7//!  unreferenced object at once. The Git manual for `git gc` (the installed 2.56.0 manual and
8//!  <https://git-scm.com/docs/git-gc>, section NOTES, checked 2026-11-01) says: "when git gc runs concurrently with another
9//!  process, there is a risk of it deleting an object that the other process is using but hasn't created a reference to.
10//!  This may just cause the other process to fail or may corrupt the repository if the other process later adds a
11//!  reference to the deleted object", and for the option itself: "--prune=now prunes loose objects regardless of their age
12//!  and increases the risk of corruption if another process is writing to the repository concurrently". Git's own
13//!  mitigation is the two-week grace of the default `--prune` date, which `--prune=now` removes. This commit path therefore
14//!  says plainly what happened and what to do (`ClientFailure::ContentPruned`), instead of showing a policy event for a
15//!  machine or a failure of the service. It does not prepare the commit again: the bytes the command captured are what is
16//!  gone, and reading the worktree again would commit whatever it holds by then.
17//!
18//! In TS you'd write (pseudocode):
19//! ```ts
20//! if (contentWasRemoved(events) || preparedContentMissing(request)) fail(CONTENT_PRUNED);
21//! ```
22
23use super::candidate_reader::OBJECT_MISSING_PREFIX;
24use super::diagnostic_log::warn;
25use super::diagnostics::EngineFailureCode;
26use super::policy_events::PolicyEvent;
27use super::repo_read_layout::ObjectFormat;
28use super::repo_read_object::{ObjectKind, from_hex, name_length};
29use super::repo_read_odb::ObjectDatabase;
30use super::repo_read_tree::{MODE_GITLINK, is_tree_mode, parse_tree};
31use super::service_protocol::CommitRequest;
32use super::transaction_phase::{Phase, PhaseMarkerError, reach_transaction_phase};
33use std::collections::HashSet;
34use std::path::Path;
35use std::sync::atomic::{AtomicU64, Ordering};
36
37/// Tag used for diagnostics from this module.
38const TAG: &str = "cli-git-service";
39
40/// Counts how often this process reached `ServiceStaged`, which numbers the marker files of a commit prepared again.
41static STAGED_REACHED: AtomicU64 = AtomicU64::new(0);
42
43/// Counts how often this process reached `ServicePrepared`.
44static PREPARED_REACHED: AtomicU64 = AtomicU64::new(0);
45
46/// What:
47///  Whether a policy run stopped because an object it had to read is missing from the repository.
48/// Why:
49///   That is the instant a pruning `git gc` is seen from the policies; preparing the commit again writes the content anew.
50///   Other content failures (a Git that did not start, an object of the wrong kind) are not this and keep their event.
51///
52/// In TS you'd write (pseudocode):
53/// ```ts
54/// events.some((event) => event.type === 'engine-failure' && event.message.startsWith(OBJECT_MISSING_PREFIX))
55/// ```
56pub fn content_was_removed(events: &[PolicyEvent]) -> bool {
57    return events.iter().any(|event| {
58        return matches!(
59            event,
60            PolicyEvent::EngineFailure { code: EngineFailureCode::ContentUnavailable, message, .. } if message.starts_with(OBJECT_MISSING_PREFIX)
61        );
62    });
63}
64
65/// How many tree entries (and trees) the walk of one batch examines before it stops asking and leaves the rest to Git.
66pub const CLOSURE_ENTRY_BUDGET: usize = 5_000_000;
67
68/// How the walk over the trees of a batch ended.
69///
70/// In TS you'd write (pseudocode):
71/// ```ts
72/// type ClosureEnd = 'intact' | 'missing' | 'out-of-budget';
73/// ```
74#[derive(Clone, Copy, Debug, Eq, PartialEq)]
75enum ClosureEnd {
76    /// Every tree and blob below every root is present, or is left to Git as unreadable.
77    Intact,
78    /// An object below a root is gone from the object store.
79    Missing,
80    /// The budget ran out first, so nothing is known about the rest.
81    OutOfBudget,
82}
83
84/// What a walk found and what it cost.
85#[derive(Clone, Copy, Debug, Eq, PartialEq)]
86struct ClosureWalk {
87    /// How it ended.
88    end: ClosureEnd,
89    /// Entries and trees examined, which is what the budget counts.
90    examined: usize,
91}
92
93/// What:
94///  Whether a tree, a tree below it or a blob in any of them is gone from the object store, for every root of a batch.
95/// Why:
96///   An amendment carries a whole tree and no list of blobs, and a prune can remove a blob from under a tree that survives
97///  until the branch moves. One work stack and one set of examined objects serve the whole batch, so a tree that several
98///  requests share (the usual amendment batch changes few files of one large tree) is walked once, and a blob that several
99///  trees list is looked up once: the cost is linear in the batch's distinct objects, with no recursion. A found loss ends
100///  the whole walk, so a shared set never hides one. The budget counts every tree and entry examined, not only distinct
101///  trees; when it runs out the answer is "not known", as for an object that cannot be read here, because holding the
102///  landing for an unbounded walk delays every later commit of the worktree. A submodule entry names a commit of another
103///  repository and is skipped, and an object that is present but cannot be read here (unreadable, beyond the reader's size
104///  limit) is left to Git.
105///
106/// In TS you'd write (pseudocode):
107/// ```ts
108/// walkTrees(roots, { shared: true, budget }).some((entry) => !objectStore.has(entry.id))
109/// ```
110fn walk_tree_closures(database: &ObjectDatabase, roots: &[&str], format: ObjectFormat, budget: usize) -> ClosureWalk {
111    let mut pending: Vec<Vec<u8>> = roots.iter().filter_map(|root| { return from_hex(root.as_bytes(), format); }).collect();
112    let mut visited: HashSet<Vec<u8>> = HashSet::new();
113    let mut examined: usize = 0;
114    while let Some(name) = pending.pop() {
115        if !visited.insert(name.clone()) {
116            continue;
117        }
118        if examined >= budget {
119            return ClosureWalk { end: ClosureEnd::OutOfBudget, examined };
120        }
121        examined += 1;
122        let Some(object) = database.read(name.as_slice()) else {
123            if database.contains(name.as_slice()) {
124                continue;
125            }
126            return ClosureWalk { end: ClosureEnd::Missing, examined };
127        };
128        if object.kind != ObjectKind::Tree {
129            continue;
130        }
131        let Some(entries) = parse_tree(object.bytes.as_slice(), name_length(format)) else { continue };
132        if entries.len() > budget - examined {
133            return ClosureWalk { end: ClosureEnd::OutOfBudget, examined };
134        }
135        examined += entries.len();
136        for entry in entries {
137            if entry.mode == MODE_GITLINK {
138                continue;
139            }
140            if is_tree_mode(entry.mode) {
141                pending.push(entry.id);
142            } else if visited.insert(entry.id.clone()) && !database.contains(entry.id.as_slice()) {
143                return ClosureWalk { end: ClosureEnd::Missing, examined };
144            }
145        }
146    }
147    return ClosureWalk { end: ClosureEnd::Intact, examined };
148}
149
150/// Whether the walk found a loss; an exhausted budget is logged and counts as "not known", which is `false`.
151fn tree_closures_missing(database: &ObjectDatabase, roots: &[&str], format: ObjectFormat) -> bool {
152    let walk: ClosureWalk = walk_tree_closures(database, roots, format, CLOSURE_ENTRY_BUDGET);
153    if walk.end == ClosureEnd::OutOfBudget {
154        warn(TAG, &format!("the trees of the batch hold more than {CLOSURE_ENTRY_BUDGET} entries; their objects are not checked here and are left to Git"));
155    }
156    return walk.end == ClosureEnd::Missing;
157}
158
159/// What:
160///  Whether an object the request depends on (a blob it names, the tree of an amend with everything below it, the commit
161///  Git built) is gone from the object store the client wrote it to.
162/// Why:
163///   When the service cannot land a request it answers with a general failure; whether the cause is removed content is
164///  something only the objects can say, and reading them here starts no Git. A store the in-process reader cannot open
165///  answers `false`, which leaves the general failure as it is.
166///
167/// In TS you'd write (pseudocode):
168/// ```ts
169/// namesOf(request).some((name) => !objectStore.has(name))
170/// ```
171pub fn prepared_content_missing(common_dir: &Path, objects_dir: &Path, object_format: &str, request: &CommitRequest) -> bool {
172    let format: ObjectFormat = if object_format == ObjectFormat::Sha256.name() { ObjectFormat::Sha256 } else { ObjectFormat::Sha1 };
173    let Some(database) = ObjectDatabase::open(common_dir, Some(objects_dir), format) else { return false };
174    let named = request
175        .changes
176        .iter()
177        .filter_map(|change| { return change.blob.as_deref(); })
178        .chain(request.tree.as_deref())
179        .chain(request.commit.as_deref());
180    for name in named {
181        match from_hex(name.as_bytes(), format) {
182            Some(bytes) if !database.contains(bytes.as_slice()) => return true,
183            Some(_) | None => {}
184        }
185    }
186    return request.tree.as_deref().is_some_and(|tree| { return tree_closures_missing(&database, &[tree], format); });
187}
188
189/// What:
190///  Whether any object the requests of a batch depend on is gone from the repository's object store.
191/// Why:
192///   The landing asks this before it imports anything or moves the branch: `fast-import` dies on a missing blob and leaves a
193///  crash report in the Git directory, and Git moves a branch to a commit whose objects are gone without looking. Loose
194///  objects are found with one lookup each; the packs are opened only when an object is not loose. A store the in-process
195///  reader cannot open answers `false`, which leaves the decision to Git, as for the client's own check.
196///
197/// In TS you'd write (pseudocode):
198/// ```ts
199/// requests.flatMap(namesOf).some((name) => !loose(name) && !(await packs()).has(name))
200/// ```
201pub fn batch_content_missing(common_dir: &Path, requests: &[&CommitRequest]) -> bool {
202    let names: Vec<&str> = requests
203        .iter()
204        .flat_map(|request| {
205            return request.changes.iter().filter_map(|change| { return change.blob.as_deref(); }).chain(request.tree.as_deref()).chain(request.commit.as_deref());
206        })
207        .collect();
208    let objects: std::path::PathBuf = common_dir.join("objects");
209    let is_loose = |name: &str| -> bool { return name.len() > 2 && objects.join(&name[..2]).join(&name[2..]).is_file(); };
210    let absent: Vec<&str> = names.into_iter().filter(|name| { return !is_loose(name); }).collect();
211    // An embedded tree is walked whether or not its own object is loose, so a request with one always needs the reader.
212    let trees: Vec<&str> = requests.iter().filter_map(|request| { return request.tree.as_deref(); }).collect();
213    let Some(sample) = absent.first().or(trees.first()) else { return false };
214    let format: ObjectFormat = if sample.len() == ObjectFormat::Sha256.hex_length() { ObjectFormat::Sha256 } else { ObjectFormat::Sha1 };
215    let Some(database) = ObjectDatabase::open(common_dir, None, format) else { return false };
216    if absent.iter().any(|name| { return from_hex(name.as_bytes(), format).is_some_and(|bytes| { return !database.contains(bytes.as_slice()); }); }) {
217        return true;
218    }
219    return tree_closures_missing(&database, &trees, format);
220}
221
222/// What:
223///  Reaches a service-commit phase marker, numbering the files by how often this process reached the phase.
224/// Why:
225///   A commit prepared again reaches each phase again, and a test that removes content at the phase must be able to answer
226///  each time separately (`<phase>-<n>.reached`, `<phase>-<n>.release`).
227///
228/// In TS you'd write (pseudocode):
229/// ```ts
230/// await reachServicePhase('service-staged')
231/// ```
232pub fn reach_service_phase(phase: Phase) -> Result<(), PhaseMarkerError> {
233    let counter: &AtomicU64 = if phase == Phase::ServiceStaged { &STAGED_REACHED } else { &PREPARED_REACHED };
234    let number: u64 = counter.fetch_add(1, Ordering::SeqCst) + 1;
235    return reach_transaction_phase(phase, Some(number));
236}
237
238/// Controls stay out of the release executable.
239#[cfg(test)]
240#[path = "service_pruned_tests.rs"]
241mod tests;