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