git_policy_cli/capture_invocation.rs
1//! What:
2//! The transaction facts recorded before any Git mutation:
3//! the repository layout,
4//! the symbolic `HEAD` target,
5//! the conclusion kind,
6//! the ref storage backend,
7//! and the preparation base.
8//! Why:
9//! Every later read of `HEAD` inside a transaction uses the recorded base or the landed commit,
10//! never live `HEAD`,
11//! because another landing may have moved it since the invocation started.
12//! The layout query and the base read run as separate steps,
13//! so pruning sees the transaction before the base is read
14//! (`src/policy-engine/commit-transaction-capture.ts`,
15//! `captureInvocationLayout` and `captureInvocationBase`).
16//!
17//! In TS you'd write (pseudocode):
18//! ```ts
19//! const layout = await captureInvocationLayout({ gitPath, cwd, amend });
20//! const capture = await captureInvocationBase({ gitPath, cwd, layout });
21//! ```
22
23/// Journal enums shared with the transaction record:
24/// the base, the symbolic `HEAD`, the conclusion and the ref backend.
25use super::transaction_journal::{Base, Conclusion, RefFormat, SymbolicHead};
26/// Failure text of one Git start that the layout reports as unusable.
27use super::transaction_git::{GitContext, GitFailure, GitOutput, GitRequest, run_git, run_git_checked};
28/// The files-backend `HEAD` reader and where one worktree's ref files live.
29use super::ref_files::{RefFilesLayout, read_symbolic_head};
30/// The in-process repository layout, tried before the `rev-parse` report.
31use super::repo_read_adapter::invocation_report;
32use super::git_system_config::with_real_git;
33use super::repo_read_layout::read_layout_in;
34/// Proof that a sequencer marker names nothing, without a Git process.
35use super::sequencer_marker::markers_maybe_present;
36/// The empty tree's name per object format, known without a Git process.
37use super::empty_tree::empty_tree_name;
38/// Trim with JavaScript's whitespace rules, as `String.prototype.trim` does.
39use super::js_text::trim_javascript;
40/// Invocation start time, ISO-8601 with milliseconds.
41use super::iso_time::now_iso;
42/// The one configuration read the invocation's consumers share.
43use super::config_snapshot::{ConfigSnapshot, read_config_snapshot};
44/// `PathBuf` is an owned filesystem path.
45use std::path::{Path, PathBuf};
46
47/// What:
48/// Facts about the repository one invocation selects,
49/// recorded before the transaction publishes anything.
50/// Why:
51/// `InvocationLayout` in the incumbent:
52/// every field except the preparation base.
53///
54/// In TS you'd write (pseudocode):
55/// ```ts
56/// type InvocationLayout = Omit<InvocationCapture, 'base'>;
57/// ```
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct InvocationLayout {
60 /// Symbolic `HEAD` target at invocation.
61 pub symbolic_head: SymbolicHead,
62 /// Ref the landing advances by compare-and-swap:
63 /// the branch,
64 /// or `HEAD` when detached.
65 pub target_ref: String,
66 /// Commit kind deciding moved-target behavior.
67 pub conclusion: Conclusion,
68 /// Canonical worktree root.
69 pub repository_root: PathBuf,
70 /// Absolute Git directory of the owning worktree.
71 pub git_dir: PathBuf,
72 /// Absolute common Git directory.
73 pub common_dir: PathBuf,
74 /// Absolute real index path,
75 /// honoring a caller-set `GIT_INDEX_FILE`.
76 pub real_index_path: PathBuf,
77 /// Absolute real object directory named by the shadow's alternates.
78 pub object_directory: PathBuf,
79 /// Absolute per-worktree transaction registry.
80 pub registry_root: PathBuf,
81 /// Ref storage backend.
82 pub ref_format: RefFormat,
83 /// Object hash algorithm name.
84 pub object_format: String,
85 /// Empty tree of the repository's object format,
86 /// standing in for an unborn base.
87 pub empty_tree_oid: String,
88 /// ISO-8601 invocation start time.
89 pub invoked_at: String,
90 /// The Git configuration read once with the caller's global options, or `None` when Git must answer per key.
91 pub config_snapshot: Option<ConfigSnapshot>,
92}
93
94/// What:
95/// Every invocation fact, including the preparation base.
96/// Why:
97/// `InvocationCapture` in the incumbent is the layout plus the base read after it.
98///
99/// In TS you'd write (pseudocode):
100/// ```ts
101/// type InvocationCapture = InvocationLayout & { base: PreparationBase };
102/// ```
103#[derive(Clone, Debug, Eq, PartialEq)]
104pub struct InvocationCapture {
105 /// Target commit at invocation, or unborn.
106 pub base: Base,
107 /// Facts other than the base.
108 pub layout: InvocationLayout,
109}
110
111/// What:
112/// One required Git report, decoded and trimmed.
113/// Why:
114/// Git output is strict UTF-8 in the incumbent;
115/// a non-UTF-8 report is a failure, not a lossy text.
116///
117/// In TS you'd write (pseudocode):
118/// ```ts
119/// new TextDecoder('utf-8', { fatal: true }).decode(bytes).trim()
120/// ```
121fn decoded_trimmed(output: &GitOutput) -> Result<String, GitFailure> {
122 // `from_utf8` reports invalid bytes instead of replacing them.
123 let text: &str = std::str::from_utf8(output.stdout.as_slice())
124 .map_err(|_| { return GitFailure(String::from("Git returned non-UTF-8 output.")); })?;
125 return Ok(String::from(trim_javascript(text)));
126}
127
128/// What:
129/// A Git request in `cwd` that ignores the invocation's global options,
130/// as the incumbent's transaction Git does.
131/// Why:
132/// The incumbent runs these queries in the effective directory without the global options.
133///
134/// In TS you'd write (pseudocode):
135/// ```ts
136/// runTransactionGit({ gitPath, cwd, args })
137/// ```
138fn transaction_request(cwd: &Path, arguments: &[&str]) -> GitRequest {
139 let mut request: GitRequest = GitRequest::new(cwd, arguments);
140 request.without_prefix = true;
141 return request;
142}
143
144/// What:
145/// Runs a query whose nonzero exit is an answer,
146/// returning the output whatever its status.
147/// Why:
148/// `allowFailure: true` in the incumbent:
149/// a missing ref or a detached `HEAD` is not an error.
150///
151/// In TS you'd write (pseudocode):
152/// ```ts
153/// await runTransactionGit({ gitPath, cwd, args, allowFailure: true })
154/// ```
155fn probe(context: &GitContext, cwd: &Path, arguments: &[&str]) -> Result<GitOutput, GitFailure> {
156 return match run_git(context, &transaction_request(cwd, arguments)) {
157 Ok(output) => Ok(output),
158 Err(error) => Err(GitFailure(format!(
159 "git {} could not start: {error}",
160 arguments.join(" ")
161 ))),
162 };
163}
164
165/// What:
166/// Runs a query whose failure is an error.
167/// Why:
168/// Matches the incumbent's default of throwing on a nonzero exit.
169///
170/// In TS you'd write (pseudocode):
171/// ```ts
172/// await runTransactionGit({ gitPath, cwd, args })
173/// ```
174fn required(context: &GitContext, cwd: &Path, arguments: &[&str]) -> Result<GitOutput, GitFailure> {
175 return run_git_checked(context, &transaction_request(cwd, arguments));
176}
177
178/// What:
179/// Reads one required line of a fixed-order layout report.
180/// Why:
181/// An empty line means Git gave an incomplete layout,
182/// which must stop the transaction before any mutation.
183///
184/// In TS you'd write (pseudocode):
185/// ```ts
186/// requiredLine({ lines, index })
187/// ```
188fn required_line(lines: &[&str], index: usize) -> Result<String, GitFailure> {
189 // `get` returns `None` past the end, which is the same empty answer.
190 let line: &str = lines.get(index).copied().unwrap_or("");
191 if line.is_empty() {
192 return Err(GitFailure(String::from("Git returned incomplete repository layout.")));
193 }
194 return Ok(String::from(line));
195}
196
197/// What:
198/// Resolves one reported path against the invocation directory.
199/// Why:
200/// Git may report a relative path; the transaction stores absolute ones.
201///
202/// In TS you'd write (pseudocode):
203/// ```ts
204/// isAbsolute(reported) ? reported : resolve(cwd, reported)
205/// ```
206fn absolute_reported(cwd: &Path, lines: &[&str], index: usize) -> Result<PathBuf, GitFailure> {
207 let reported: PathBuf = PathBuf::from(required_line(lines, index)?);
208 return Ok(if reported.is_absolute() {
209 reported
210 } else {
211 cwd.join(reported)
212 });
213}
214
215/// What:
216/// Reports whether a pseudoref names an object in the owning worktree.
217/// Why:
218/// `MERGE_HEAD`, `CHERRY_PICK_HEAD` and `REVERT_HEAD` decide the conclusion kind
219/// by resolution, not by file presence, so a reftable repository answers the same way.
220///
221/// In TS you'd write (pseudocode):
222/// ```ts
223/// (await runTransactionGit({ args: ['rev-parse', '--verify', '--quiet', name], allowFailure: true })).exitCode === 0
224/// ```
225fn pseudoref_exists(context: &GitContext, cwd: &Path, name: &str) -> Result<bool, GitFailure> {
226 return Ok(probe(context, cwd, &["rev-parse", "--verify", "--quiet", name])?.code == Some(0));
227}
228
229/// What:
230/// Resolves the commit kind from the amend flag and the owning worktree's conclusion state.
231/// Why:
232/// An amend always wins;
233/// otherwise the first present marker in the incumbent's order decides.
234/// A marker whose every lookup place is empty on the files backend is absent without asking Git;
235/// any entry in any place, of any content, is resolved by `rev-parse --verify`,
236/// so a marker naming a missing object stays a conclusion.
237///
238/// In TS you'd write (pseudocode):
239/// ```ts
240/// await resolveConclusionKind({ gitPath, cwd, amend });
241/// ```
242pub fn resolve_conclusion_kind(
243 context: &GitContext,
244 cwd: &Path,
245 amend: bool,
246 ref_files: &RefFilesLayout<'_>,
247) -> Result<Conclusion, GitFailure> {
248 if amend {
249 return Ok(Conclusion::Amend);
250 }
251 let names: [&str; 3] = ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"];
252 let kinds: [Conclusion; 3] = [Conclusion::Merge, Conclusion::CherryPick, Conclusion::Revert];
253 let maybe_present: Vec<bool> = markers_maybe_present(ref_files, &names);
254 for ((name, kind), maybe) in names.into_iter().zip(kinds).zip(maybe_present) {
255 if maybe && pseudoref_exists(context, cwd, name)? {
256 return Ok(kind);
257 }
258 }
259 return Ok(Conclusion::None);
260}
261
262/// What:
263/// Resolves the symbolic `HEAD` target in the owning worktree.
264/// Why:
265/// The `HEAD` file answers when it is one of the two plain spellings on the files backend,
266/// read afresh on every call so the landing's re-read stays a read at landing time;
267/// anything else asks Git,
268/// where exit 1 from `symbolic-ref --quiet` means detached and any other failure is an error.
269///
270/// In TS you'd write (pseudocode):
271/// ```ts
272/// readHeadFile(refFiles) ?? (await resolveSymbolicHeadWithGit({ gitPath, cwd }));
273/// ```
274pub fn resolve_symbolic_head(
275 context: &GitContext,
276 cwd: &Path,
277 ref_files: &RefFilesLayout<'_>,
278) -> Result<SymbolicHead, GitFailure> {
279 if let Some(read) = read_symbolic_head(ref_files) {
280 return Ok(read);
281 }
282 let output: GitOutput = probe(context, cwd, &["symbolic-ref", "--quiet", "HEAD"])?;
283 if output.code == Some(1) {
284 return Ok(SymbolicHead::Detached);
285 }
286 if output.code != Some(0) {
287 return Err(GitFailure(format!(
288 "git symbolic-ref HEAD failed: {}",
289 String::from_utf8_lossy(output.stderr.as_slice()).trim()
290 )));
291 }
292 return Ok(SymbolicHead::Branch(decoded_trimmed(&output)?));
293}
294
295/// What:
296/// Resolves a ref to its commit,
297/// or to an unborn base when it names nothing.
298/// Why:
299/// The preparation base is the target's commit at invocation,
300/// and an unborn target starts from the empty tree.
301///
302/// In TS you'd write (pseudocode):
303/// ```ts
304/// await resolveRefCommit({ gitPath, cwd, ref });
305/// ```
306pub fn resolve_ref_commit(context: &GitContext, cwd: &Path, reference: &str) -> Result<Base, GitFailure> {
307 let peeled: String = format!("{reference}^{{commit}}");
308 let output: GitOutput = probe(context, cwd, &["rev-parse", "--verify", "--quiet", peeled.as_str()])?;
309 if output.code != Some(0) {
310 return Ok(Base::Unborn);
311 }
312 return Ok(Base::Commit(decoded_trimmed(&output)?));
313}
314
315/// What:
316/// Reads the ref storage backend.
317/// Why:
318/// A Git that cannot report it (before 2.45) is the files backend.
319///
320/// In TS you'd write (pseudocode):
321/// ```ts
322/// await resolveRefFormat({ gitPath, cwd });
323/// ```
324pub fn resolve_ref_format(context: &GitContext, cwd: &Path) -> Result<RefFormat, GitFailure> {
325 return resolve_ref_format_with(context, cwd, None);
326}
327
328/// What:
329/// Reads the ref storage backend, from the configuration snapshot when it proves `files`.
330/// Why:
331/// `rev-parse --show-ref-format` prints `reftable` only for a repository whose configuration names
332/// `extensions.refStorage`, so a snapshot read without global options that has no such key proves `files`
333/// without starting Git; any other snapshot, and a missing one, asks Git.
334///
335/// In TS you'd write (pseudocode):
336/// ```ts
337/// snapshot?.provesFilesRefStorage() ? 'files' : await resolveRefFormat({ gitPath, cwd });
338/// ```
339pub fn resolve_ref_format_with(
340 context: &GitContext,
341 cwd: &Path,
342 snapshot: Option<&ConfigSnapshot>,
343) -> Result<RefFormat, GitFailure> {
344 if snapshot.is_some_and(ConfigSnapshot::proves_files_ref_storage) {
345 return Ok(RefFormat::Files);
346 }
347 let output: GitOutput = probe(context, cwd, &["rev-parse", "--show-ref-format"])?;
348 let reftable: bool = output.code == Some(0) && decoded_trimmed(&output)? == "reftable";
349 return Ok(if reftable { RefFormat::Reftable } else { RefFormat::Files });
350}
351
352/// What:
353/// The empty tree's object name under the repository's object format.
354/// Why:
355/// The name is a constant per object format, so a known format needs no Git process;
356/// a format the table does not know is hashed by Git, which stays the authority.
357///
358/// In TS you'd write (pseudocode):
359/// ```ts
360/// emptyTreeName(objectFormat) ?? (await runTransactionGit({ args: ['hash-object', '-t', 'tree', '--stdin'] })).trim()
361/// ```
362fn resolve_empty_tree(context: &GitContext, cwd: &Path, object_format: &str) -> Result<String, GitFailure> {
363 if let Some(known) = empty_tree_name(object_format) {
364 return Ok(String::from(known));
365 }
366 let hashed: GitOutput = required(context, cwd, &["hash-object", "-t", "tree", "--stdin"])?;
367 return decoded_trimmed(&hashed);
368}
369
370/// What:
371/// Captures every invocation fact except the preparation base,
372/// before any Git mutation.
373/// Why:
374/// One layout request reports the paths,
375/// and the independent probes follow it.
376///
377/// In TS you'd write (pseudocode):
378/// ```ts
379/// await captureInvocationLayout({ gitPath, cwd, amend });
380/// ```
381pub fn capture_invocation_layout(
382 context: &GitContext,
383 cwd: &Path,
384 amend: bool,
385) -> Result<InvocationLayout, GitFailure> {
386 // Taken before any Git runs, as the incumbent does.
387 let invoked_at: String = now_iso();
388 let config_snapshot: Option<ConfigSnapshot> = read_config_snapshot(context, cwd);
389 // A plain layout is read from the file system; Git is asked only when the reader declines it.
390 let text: String = match read_layout_in(cwd, with_real_git(context.overlay.as_slice(), context.real_git.as_path()).as_slice()).as_ref().and_then(invocation_report) {
391 Some(read) => read,
392 None => {
393 let report: GitOutput = required(
394 context,
395 cwd,
396 &[
397 "rev-parse",
398 "--path-format=absolute",
399 "--show-toplevel",
400 "--absolute-git-dir",
401 "--git-common-dir",
402 "--git-path",
403 "index",
404 "--git-path",
405 "objects",
406 "--git-path",
407 "cli-git-transactions",
408 "--show-object-format",
409 ],
410 )?;
411 decoded_trimmed(&report)?
412 }
413 };
414 let lines: Vec<&str> = text.split('\n').collect();
415 let git_dir: PathBuf = absolute_reported(cwd, &lines, 1)?;
416 let common_dir: PathBuf = absolute_reported(cwd, &lines, 2)?;
417 let object_format: String = required_line(&lines, 6)?;
418 // The ref storage backend is read first: the `HEAD` file only answers for the files backend.
419 let ref_format: RefFormat = resolve_ref_format_with(context, cwd, config_snapshot.as_ref())?;
420 let ref_files: RefFilesLayout<'_> = RefFilesLayout {
421 git_dir: git_dir.as_path(),
422 common_dir: common_dir.as_path(),
423 ref_format,
424 object_format: object_format.as_str(),
425 };
426 let symbolic_head: SymbolicHead = resolve_symbolic_head(context, cwd, &ref_files)?;
427 let target_ref: String = match &symbolic_head {
428 SymbolicHead::Branch(reference) => reference.clone(),
429 SymbolicHead::Detached => String::from("HEAD"),
430 };
431 let empty_tree_oid: String = resolve_empty_tree(context, cwd, object_format.as_str())?;
432 let repository_root: PathBuf = std::fs::canonicalize(absolute_reported(cwd, &lines, 0)?)
433 .map_err(|error| { return GitFailure(format!("repository root is unreadable: {error}")); })?;
434 return Ok(InvocationLayout {
435 symbolic_head,
436 target_ref,
437 conclusion: resolve_conclusion_kind(context, cwd, amend, &ref_files)?,
438 repository_root,
439 git_dir,
440 common_dir,
441 real_index_path: absolute_reported(cwd, &lines, 3)?,
442 object_directory: absolute_reported(cwd, &lines, 4)?,
443 registry_root: absolute_reported(cwd, &lines, 5)?,
444 ref_format,
445 object_format,
446 empty_tree_oid,
447 invoked_at,
448 config_snapshot,
449 });
450}
451
452/// What:
453/// Completes the capture with the preparation base:
454/// the target's commit now.
455/// Why:
456/// Read after the layout and after the capture sequence is taken,
457/// so the base is never older than the sequence the transaction records.
458///
459/// In TS you'd write (pseudocode):
460/// ```ts
461/// await captureInvocationBase({ gitPath, cwd, layout });
462/// ```
463pub fn capture_invocation_base(
464 context: &GitContext,
465 cwd: &Path,
466 layout: InvocationLayout,
467) -> Result<InvocationCapture, GitFailure> {
468 let base: Base = resolve_ref_commit(context, cwd, layout.target_ref.as_str())?;
469 return Ok(InvocationCapture { base, layout });
470}
471
472/// What:
473/// The revision the transaction reads as its baseline tree:
474/// the base commit, or the empty tree when unborn.
475/// Why:
476/// Every baseline read of the transaction uses this one revision.
477///
478/// In TS you'd write (pseudocode):
479/// ```ts
480/// baseRevision(capture) // capture.base.kind === 'commit' ? capture.base.oid : capture.emptyTreeOid
481/// ```
482pub fn base_revision(capture: &InvocationCapture) -> String {
483 return match &capture.base {
484 Base::Commit(oid) => oid.clone(),
485 Base::Unborn => capture.layout.empty_tree_oid.clone(),
486 };
487}
488
489/// Invocation capture controls stay out of the release executable.
490#[cfg(test)]
491#[path = "capture_invocation_tests.rs"]
492mod tests;