git_policy_cli/landing_support.rs
1//! What:
2//! Target comparison, the nonce-bearing reflog message and the compare-and-swap a landing uses
3//! to advance its target.
4//! Why:
5//! The reflog subject carries the transaction nonce, so recovery can tell whether the target
6//! advanced to this landing even after a crash (`src/policy-engine/commit-landing-support.ts`).
7//! The swap runs in the owning worktree's context, which also writes the real `HEAD` reflog.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! await compareAndSwapTarget({ gitPath, cwd, capture, transactionId, newOid, expectedOld });
12//! ```
13
14/// Journal target value.
15use super::transaction_journal::Base;
16/// Strict-enough JavaScript whitespace trimming for Git's subject text.
17use super::js_text::trim_javascript;
18/// Running real Git through the transaction runner.
19use super::transaction_git::{GitContext, GitFailure, GitOutput, GitRequest, run_git};
20/// `Path` is a borrowed filesystem path.
21use std::path::Path;
22
23/// What:
24/// Reports whether two target values are equal.
25/// Why:
26/// An unborn target equals only another unborn target.
27///
28/// In TS you'd write (pseudocode):
29/// ```ts
30/// sameTarget({ left, right });
31/// ```
32pub fn same_target(left: &Base, right: &Base) -> bool {
33 return left == right;
34}
35
36/// What:
37/// The reflog subject prefix carrying the transaction nonce.
38/// Why:
39/// Recovery searches the reflog for this prefix to find an advanced target.
40///
41/// In TS you'd write (pseudocode):
42/// ```ts
43/// landingReflogPrefix(nonce); // 'commit (cli-git <id>):'
44/// ```
45pub fn landing_reflog_prefix(nonce: &str) -> String {
46 return format!("commit (cli-git {nonce}):");
47}
48
49/// What:
50/// Builds the nonce-bearing reflog message of one landed commit.
51/// Why:
52/// The message is the prefix followed by the commit's subject, as Git's own commit reflog writes it.
53///
54/// In TS you'd write (pseudocode):
55/// ```ts
56/// await landingReflogMessage({ gitPath, cwd, nonce, oid });
57/// ```
58pub fn landing_reflog_message(
59 context: &GitContext,
60 cwd: &Path,
61 nonce: &str,
62 oid: &str,
63) -> Result<String, GitFailure> {
64 let request = GitRequest::new(cwd, &["show", "--no-patch", "--format=%s", oid]);
65 let output: GitOutput = run_git(context, &request)
66 .map_err(|error| { return GitFailure(format!("git show {oid} could not start: {error}")); })?;
67 if !output.succeeded() {
68 return Err(GitFailure(format!(
69 "git show {oid} failed: {}",
70 String::from_utf8_lossy(&output.stderr).trim_end()
71 )));
72 }
73 let subject: String = String::from_utf8(output.stdout)
74 .map_err(|error| { return GitFailure(format!("git show {oid} printed non-UTF-8 text: {error}")); })?;
75 return Ok(format!("{} {}", landing_reflog_prefix(nonce), trim_javascript(&subject)));
76}
77
78/// What:
79/// One compare-and-swap of a landing's target.
80/// Why:
81/// Grouping the facts keeps the call site readable and names what the swap depends on.
82///
83/// In TS you'd write (pseudocode):
84/// ```ts
85/// { targetRef, symbolicHead, transactionId, newOid, expectedOld, emptyTreeOid }
86/// ```
87pub struct CompareAndSwap<'swap> {
88 /// Ref to move: the branch, or `HEAD` when detached.
89 pub target_ref: &'swap str,
90 /// Whether `HEAD` is detached, so `--no-deref` moves `HEAD` itself.
91 pub detached: bool,
92 /// Transaction ID written into the reflog message.
93 pub nonce: &'swap str,
94 /// Commit to land.
95 pub new_oid: &'swap str,
96 /// Value the target must still hold.
97 pub expected_old: &'swap Base,
98 /// Hash length in hex digits, which sets the width of the all-zero unborn value.
99 pub hash_length: usize,
100}
101
102/// What:
103/// Advances the target from the expected value to the new commit, in the owning worktree.
104/// Why:
105/// A nonzero exit is a lost race, not an error: the caller reads the winning value next.
106///
107/// In TS you'd write (pseudocode):
108/// ```ts
109/// await runTransactionGit({ gitPath, cwd, args: ['update-ref', '-m', message, ...], allowFailure: true });
110/// ```
111pub fn compare_and_swap_target(
112 context: &GitContext,
113 cwd: &Path,
114 swap: &CompareAndSwap<'_>,
115) -> Result<GitOutput, GitFailure> {
116 let message: String = landing_reflog_message(context, cwd, swap.nonce, swap.new_oid)?;
117 let expected: String = match swap.expected_old {
118 Base::Commit(oid) => oid.clone(),
119 Base::Unborn => "0".repeat(swap.hash_length),
120 };
121 let mut arguments: Vec<String> = vec!["update-ref".to_string(), "-m".to_string(), message];
122 if swap.detached {
123 arguments.push("--no-deref".to_string());
124 }
125 arguments.push(swap.target_ref.to_string());
126 arguments.push(swap.new_oid.to_string());
127 arguments.push(expected);
128 let request = GitRequest::new(cwd, &arguments);
129 return run_git(context, &request)
130 .map_err(|error| { return GitFailure(format!("git update-ref could not start: {error}")); });
131}
132
133/// Landing support controls stay out of the production code path.
134#[cfg(test)]
135#[path = "landing_support_tests.rs"]
136mod tests;