Skip to main content

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