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