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::diagnostics::EngineFailureCode;
25use super::policy_events::PolicyEvent;
26use super::repo_read_layout::ObjectFormat;
27use super::repo_read_object::{ObjectKind, from_hex, name_length};
28use super::repo_read_odb::ObjectDatabase;
29use super::repo_read_tree::{MODE_GITLINK, is_tree_mode, parse_tree};
30use super::service_protocol::CommitRequest;
31use super::transaction_phase::{Phase, PhaseMarkerError, reach_transaction_phase};
32use std::collections::HashSet;
33use std::path::Path;
34use std::sync::atomic::{AtomicU64, Ordering};
35
36/// Counts how often this process reached `ServiceStaged`, which numbers the marker files of a commit prepared again.
37static STAGED_REACHED: AtomicU64 = AtomicU64::new(0);
38
39/// Counts how often this process reached `ServicePrepared`.
40static PREPARED_REACHED: AtomicU64 = AtomicU64::new(0);
41
42/// What:
43/// Whether a policy run stopped because an object it had to read is missing from the repository.
44/// Why:
45/// That is the instant a pruning `git gc` is seen from the policies; preparing the commit again writes the content anew.
46/// Other content failures (a Git that did not start, an object of the wrong kind) are not this and keep their event.
47///
48/// In TS you'd write (pseudocode):
49/// ```ts
50/// events.some((event) => event.type === 'engine-failure' && event.message.startsWith(OBJECT_MISSING_PREFIX))
51/// ```
52pub fn content_was_removed(events: &[PolicyEvent]) -> bool {
53 return events.iter().any(|event| {
54 return matches!(
55 event,
56 PolicyEvent::EngineFailure { code: EngineFailureCode::ContentUnavailable, message, .. } if message.starts_with(OBJECT_MISSING_PREFIX)
57 );
58 });
59}
60
61/// What:
62/// Whether a tree, a tree below it or a blob in any of them is gone from the object store.
63/// Why:
64/// An amendment carries a whole tree and no list of blobs, and a prune can remove a blob from under a tree that survives
65/// until the branch moves; the walk visits each tree once with a work stack, so a deep or repetitive tree costs time
66/// linear in its distinct trees and no recursion. A submodule entry names a commit of another repository and is skipped,
67/// and an object that is present but cannot be read here (unreadable, beyond the reader's size limit) is left to Git.
68///
69/// In TS you'd write (pseudocode):
70/// ```ts
71/// walkTrees(root).some((entry) => !objectStore.has(entry.id))
72/// ```
73fn tree_closure_missing(database: &ObjectDatabase, root: &str, format: ObjectFormat) -> bool {
74 let Some(start) = from_hex(root.as_bytes(), format) else { return false };
75 let mut pending: Vec<Vec<u8>> = vec![start];
76 let mut visited: HashSet<Vec<u8>> = HashSet::new();
77 while let Some(name) = pending.pop() {
78 if !visited.insert(name.clone()) {
79 continue;
80 }
81 let Some(object) = database.read(name.as_slice()) else {
82 if database.contains(name.as_slice()) {
83 continue;
84 }
85 return true;
86 };
87 if object.kind != ObjectKind::Tree {
88 continue;
89 }
90 let Some(entries) = parse_tree(object.bytes.as_slice(), name_length(format)) else { continue };
91 for entry in entries {
92 if entry.mode == MODE_GITLINK {
93 continue;
94 }
95 if is_tree_mode(entry.mode) {
96 pending.push(entry.id);
97 } else if !database.contains(entry.id.as_slice()) {
98 return true;
99 }
100 }
101 }
102 return false;
103}
104
105/// What:
106/// Whether an object the request depends on (a blob it names, the tree of an amend with everything below it, the commit
107/// Git built) is gone from the object store the client wrote it to.
108/// Why:
109/// When the service cannot land a request it answers with a general failure; whether the cause is removed content is
110/// something only the objects can say, and reading them here starts no Git. A store the in-process reader cannot open
111/// answers `false`, which leaves the general failure as it is.
112///
113/// In TS you'd write (pseudocode):
114/// ```ts
115/// namesOf(request).some((name) => !objectStore.has(name))
116/// ```
117pub fn prepared_content_missing(common_dir: &Path, objects_dir: &Path, object_format: &str, request: &CommitRequest) -> bool {
118 let format: ObjectFormat = if object_format == ObjectFormat::Sha256.name() { ObjectFormat::Sha256 } else { ObjectFormat::Sha1 };
119 let Some(database) = ObjectDatabase::open(common_dir, Some(objects_dir), format) else { return false };
120 let named = request
121 .changes
122 .iter()
123 .filter_map(|change| { return change.blob.as_deref(); })
124 .chain(request.tree.as_deref())
125 .chain(request.commit.as_deref());
126 for name in named {
127 match from_hex(name.as_bytes(), format) {
128 Some(bytes) if !database.contains(bytes.as_slice()) => return true,
129 Some(_) | None => {}
130 }
131 }
132 return request.tree.as_deref().is_some_and(|tree| { return tree_closure_missing(&database, tree, format); });
133}
134
135/// What:
136/// Whether any object the requests of a batch depend on is gone from the repository's object store.
137/// Why:
138/// The landing asks this before it imports anything or moves the branch: `fast-import` dies on a missing blob and leaves a
139/// crash report in the Git directory, and Git moves a branch to a commit whose objects are gone without looking. Loose
140/// objects are found with one lookup each; the packs are opened only when an object is not loose. A store the in-process
141/// reader cannot open answers `false`, which leaves the decision to Git, as for the client's own check.
142///
143/// In TS you'd write (pseudocode):
144/// ```ts
145/// requests.flatMap(namesOf).some((name) => !loose(name) && !(await packs()).has(name))
146/// ```
147pub fn batch_content_missing(common_dir: &Path, requests: &[&CommitRequest]) -> bool {
148 let names: Vec<&str> = requests
149 .iter()
150 .flat_map(|request| {
151 return request.changes.iter().filter_map(|change| { return change.blob.as_deref(); }).chain(request.tree.as_deref()).chain(request.commit.as_deref());
152 })
153 .collect();
154 let objects: std::path::PathBuf = common_dir.join("objects");
155 let is_loose = |name: &str| -> bool { return name.len() > 2 && objects.join(&name[..2]).join(&name[2..]).is_file(); };
156 let absent: Vec<&str> = names.into_iter().filter(|name| { return !is_loose(name); }).collect();
157 // An embedded tree is walked whether or not its own object is loose, so a request with one always needs the reader.
158 let trees: Vec<&str> = requests.iter().filter_map(|request| { return request.tree.as_deref(); }).collect();
159 let Some(sample) = absent.first().or(trees.first()) else { return false };
160 let format: ObjectFormat = if sample.len() == ObjectFormat::Sha256.hex_length() { ObjectFormat::Sha256 } else { ObjectFormat::Sha1 };
161 let Some(database) = ObjectDatabase::open(common_dir, None, format) else { return false };
162 if absent.iter().any(|name| { return from_hex(name.as_bytes(), format).is_some_and(|bytes| { return !database.contains(bytes.as_slice()); }); }) {
163 return true;
164 }
165 return trees.iter().any(|tree| { return tree_closure_missing(&database, tree, format); });
166}
167
168/// What:
169/// Reaches a service-commit phase marker, numbering the files by how often this process reached the phase.
170/// Why:
171/// A commit prepared again reaches each phase again, and a test that removes content at the phase must be able to answer
172/// each time separately (`<phase>-<n>.reached`, `<phase>-<n>.release`).
173///
174/// In TS you'd write (pseudocode):
175/// ```ts
176/// await reachServicePhase('service-staged')
177/// ```
178pub fn reach_service_phase(phase: Phase) -> Result<(), PhaseMarkerError> {
179 let counter: &AtomicU64 = if phase == Phase::ServiceStaged { &STAGED_REACHED } else { &PREPARED_REACHED };
180 let number: u64 = counter.fetch_add(1, Ordering::SeqCst) + 1;
181 return reach_transaction_phase(phase, Some(number));
182}
183
184/// Controls stay out of the release executable.
185#[cfg(test)]
186#[path = "service_pruned_tests.rs"]
187mod tests;