Skip to main content

git_policy_cli/
commit_preparation.rs

1//! What:
2//!  Native `git commit` in the shadow repository against the private commit index,
3//!       the prepared commit read back from the shadow,
4//!       and `prepared.json`.
5//! Why:
6//!  Git owns the hooks, the editor, the templates, the message cleanup and the signing;
7//!  this module only points Git at the shadow and the private index.
8//!  Every hook is off in the shadow (its config switches the commit events off and the hooks-off option comes
9//!  first), so Git starts no external program for a hook and never starts the wrapper.
10//!  A failing preparation removes the shadow so no shadow outlives the failure
11//!  (`src/policy-engine/commit-preparation-native.ts`, `src/shadow-repository/shadow-repository.ts`).
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! await runNativePreparation({ gitPath, cwd, globalArgs, commitArgs, shadowPath, hookPlan, commitIndexPath });
16//! ```
17
18/// Private file write for the journal record.
19/// Journal record shape and its encoder.
20/// Invocation capture facts the shadow was built from.
21use super::capture_invocation::InvocationCapture;
22/// The hooks-off value Git is given again after the caller's options.
23use super::hooks_off::NO_HOOKS_DIRECTORY;
24/// Shadow removal, so a failed preparation leaves no shadow.
25/// The shadow request and the real-Git command builder.
26use super::shadow_git::shadow_request;
27use super::forwarding::git_command;
28use super::signal_relay::{SignalGuard, is_guard_active_refusal};
29/// Context, one required Git start and failure text.
30use super::transaction_git::{GitContext, GitFailure, GitOutput, run_git_checked};
31/// `OsString` is owned operating-system text.
32use std::ffi::OsString;
33/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
34use std::path::Path;
35
36/// Variables native Git must not inherit, so every object it writes lands in the shadow store.
37pub const NATIVE_UNSET_VARIABLES: [&str; 4] = ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY"];
38
39/// What:
40///  Everything one native preparation needs from its transaction.
41/// Why:
42///   The incumbent passes these as separate options, and grouping them keeps the call short.
43///
44/// In TS you'd write (pseudocode):
45/// ```ts
46/// runNativePreparation({ gitPath, cwd, globalArgs, commitArgs, shadowPath, worktreeRoot, hooksDirectory, commitIndexPath });
47/// ```
48#[derive(Clone, Copy, Debug)]
49pub struct PreparationRequest<'request> {
50    /// Invocation capture the shadow was created from.
51    pub capture: &'request InvocationCapture,
52    /// Shadow repository of the transaction.
53    pub shadow: &'request Path,
54    /// Private commit index.
55    pub commit_index: &'request Path,
56    /// Kept global options of the invocation.
57    pub global_args: &'request [String],
58    /// Commit arguments without pathspecs or `--only`.
59    pub commit_args: &'request [String],
60    /// The object directory Git writes its new objects into; nothing leaves them in the shadow's own store.
61    ///  The commit service names the real one, so the commit it lands is already where the branch can point at it.
62    pub object_directory: Option<&'request Path>,
63}
64
65/// What:
66///  Why a preparation stopped.
67/// Why:
68///   A nonzero native exit is the invocation's own exit code, while a start failure is an error.
69///
70/// In TS you'd write (pseudocode):
71/// ```ts
72/// class NativeCommitFailedError extends Error { exitCode: number }
73/// ```
74#[derive(Debug)]
75pub enum PreparationError {
76    /// A Git step around the commit failed.
77    Git(GitFailure),
78}
79
80impl std::fmt::Display for PreparationError {
81    /// Writes the wording of the failure.
82    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        return match self {
84            Self::Git(error) => write!(formatter, "{error}"),
85        };
86    }
87}
88
89/// What:
90///  The prepared commit read back from the shadow `HEAD`.
91/// Why:
92///   Replay rebuilds every later commit from the exact object bytes,
93///  and the tree and signature decide what landing may do.
94///
95/// In TS you'd write (pseudocode):
96/// ```ts
97/// type PreparedCommit = { oid: string; treeOid: string; signed: boolean; raw: RawCommit };
98/// ```
99#[derive(Clone, Debug, Eq, PartialEq)]
100pub struct PreparedCommit {
101    /// Prepared commit, the shadow `HEAD` value.
102    pub oid: String,
103    /// Prepared commit tree.
104    pub tree_oid: String,
105    /// Whether the header block carries a `gpgsig` or `gpgsig-sha256` header.
106    pub signed: bool,
107    /// Exact commit object bytes.
108    pub raw: Vec<u8>,
109}
110
111/// What:
112///  Builds the arguments of native `git commit` in the shadow.
113/// Why:
114///   The kept global options come first, then the shadow, then a hooks directory that cannot exist, which also beats a
115///  `-c core.hooksPath` of the caller.
116///  The shadow's own config switches every commit event off, so no external program runs for a hook.
117///  No `--work-tree` is passed: the shadow's own config names the work tree.
118///
119/// In TS you'd write (pseudocode):
120/// ```ts
121/// [...globalArgs, `--git-dir=${shadowPath}`, '-c', `core.hooksPath=${NO_HOOKS}`, 'commit', ...commitArgs]
122/// ```
123fn native_commit_arguments(request: &PreparationRequest) -> Vec<String> {
124    let mut arguments: Vec<String> = request.global_args.to_vec();
125    arguments.push(format!("--git-dir={}", request.shadow.display()));
126    arguments.push(String::from("-c"));
127    arguments.push(format!("core.hooksPath={NO_HOOKS_DIRECTORY}"));
128    arguments.push(String::from("commit"));
129    arguments.extend(request.commit_args.iter().cloned());
130    return arguments;
131}
132
133/// What:
134///  Install the signal guard, waiting while another thread of this process holds it.
135/// Why:
136///   One wrapper process runs one Git child at a time, so the wait never happens there; library tests run several
137///  preparations in threads of one process, and the guard is process-wide.
138///
139/// In TS you'd write (pseudocode):
140/// ```ts
141/// const guard = installSignalGuard();
142/// ```
143pub fn install_guard_when_free() -> std::io::Result<SignalGuard> {
144    return retry_while_active(SignalGuard::install, GUARD_WAIT_LIMIT, GUARD_WAIT_DELAY);
145}
146
147/// The most times the guard installation is retried while another thread holds the guard.
148const GUARD_WAIT_LIMIT: u32 = 1_200;
149
150/// The pause between two tries of the guard installation.
151const GUARD_WAIT_DELAY: std::time::Duration = std::time::Duration::from_millis(50);
152
153/// What:
154///  Run `install` again while it fails only because another holder is active, up to `limit` more times.
155/// Why:
156///   Any other failure, and a holder that never lets go, end the wait with the failure itself.
157///
158/// In TS you'd write (pseudocode):
159/// ```ts
160/// for (let attempt = 0; ; attempt += 1) { try { return install(); } catch (e) { if (!isActive(e) || attempt >= limit) throw e; await wait(delay); } }
161/// ```
162pub fn retry_while_active<Value>(
163    mut install: impl FnMut() -> std::io::Result<Value>,
164    limit: u32,
165    delay: std::time::Duration,
166) -> std::io::Result<Value> {
167    let mut attempts: u32 = 0;
168    loop {
169        match install() {
170            Ok(value) => return Ok(value),
171            Err(error) if is_guard_active_refusal(&error) && attempts < limit => {
172                attempts += 1;
173                std::thread::sleep(delay);
174            }
175            Err(error) => return Err(error),
176        }
177    }
178}
179
180/// What:
181///  Runs native `git commit` in the shadow with the terminal attached and returns its exit code.
182/// Why:
183///   The editor output reaches the user unchanged,
184///  so the child inherits standard streams.
185///
186/// In TS you'd write (pseudocode):
187/// ```ts
188/// await runTransactionGit({ gitPath, cwd, args, indexPath, unsetEnvironment, stdio: 'inherit', allowFailure: true });
189/// ```
190pub fn run_native_commit(
191    context: &GitContext,
192    cwd: &Path,
193    request: &PreparationRequest,
194) -> Result<i32, PreparationError> {
195    let arguments: Vec<OsString> = native_commit_arguments(request).iter().map(OsString::from).collect();
196    let mut command = git_command(context.real_git.as_path(), arguments.as_slice(), context.overlay.as_slice());
197    command
198        .current_dir(cwd)
199        .env("GIT_INDEX_FILE", request.commit_index);
200    for name in NATIVE_UNSET_VARIABLES {
201        command.env_remove(name);
202    }
203    if let Some(directory) = request.object_directory {
204        command.env("GIT_OBJECT_DIRECTORY", directory);
205    }
206    return run_guarded_child(&mut command);
207}
208
209/// What:
210///  Run the native commit under the signal guard and return its exit code.
211/// Why:
212///   A signal sent to the wrapper while Git runs reaches Git when a process sent it; Git's ending decides the status.
213///
214/// In TS you'd write (pseudocode):
215/// ```ts
216/// using guard = installSignalGuard(); return exitCode(await guard.runChild(command));
217/// ```
218fn run_guarded_child(command: &mut std::process::Command) -> Result<i32, PreparationError> {
219    let guard: SignalGuard = install_guard_when_free().map_err(|error| {
220        return PreparationError::Git(GitFailure(format!("installing the signal guard failed: {error}")));
221    })?;
222    let outcome = guard.run_child(command).map_err(|error| {
223        return PreparationError::Git(GitFailure(format!("git commit could not start: {error}")));
224    })?;
225    return Ok(super::forwarding::exit_code(outcome));
226}
227
228/// What:
229///  Reads the prepared commit from the shadow `HEAD`:
230///  its object, its tree and whether it is signed.
231/// Why:
232///   The header block ends at the first empty line; a signature header in it marks the commit signed.
233///
234/// In TS you'd write (pseudocode):
235/// ```ts
236/// await readPreparedCommit({ gitPath, shadowPath });
237/// ```
238pub fn read_prepared_commit(context: &GitContext, shadow: &Path) -> Result<PreparedCommit, GitFailure> {
239    let oid_output: GitOutput = run_git_checked(
240        context,
241        &shadow_request(shadow, shadow, &["rev-parse", "--verify", "HEAD^{commit}"]),
242    )?;
243    let oid: String = String::from_utf8_lossy(oid_output.stdout.as_slice()).trim().to_string();
244    let raw: Vec<u8> = run_git_checked(
245        context,
246        &shadow_request(shadow, shadow, &["cat-file", "commit", oid.as_str()]),
247    )?
248    .stdout;
249    let header_end: usize = raw
250        .windows(2)
251        .position(|window| { return window == b"\n\n"; })
252        .unwrap_or(raw.len());
253    let header: String = String::from_utf8_lossy(&raw[..header_end]).into_owned();
254    let tree_oid: String = header
255        .lines()
256        .find_map(|line| { return line.strip_prefix("tree "); })
257        .unwrap_or("")
258        .to_string();
259    let signed: bool = header
260        .lines()
261        .any(|line| { return line.starts_with("gpgsig ") || line.starts_with("gpgsig-sha256 "); });
262    return Ok(PreparedCommit {
263        oid,
264        tree_oid,
265        signed,
266        raw,
267    });
268}
269
270/// Native preparation controls stay out of the release executable.
271#[cfg(test)]
272#[path = "commit_preparation_tests.rs"]
273mod tests;