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.
19use super::private_storage::write_exclusive_synced;
20/// Journal record shape and its encoder.
21use super::transaction_journal::{AddedPath, PreparedRecord};
22use super::transaction_journal_encode::encode_prepared;
23/// Invocation capture facts the shadow was built from.
24use super::capture_invocation::InvocationCapture;
25/// The hooks-off value Git is given again after the caller's options.
26use super::hooks_off::NO_HOOKS_DIRECTORY;
27/// Shadow removal, so a failed preparation leaves no shadow.
28use super::shadow_git::remove_shadow_repository;
29/// The shadow request and the real-Git command builder.
30use super::shadow_git::shadow_request;
31use super::forwarding::git_command;
32use super::signal_relay::{SignalGuard, is_guard_active_refusal};
33/// Context, one required Git start and failure text.
34use super::transaction_git::{GitContext, GitFailure, GitOutput, run_git_checked};
35/// `OsString` is owned operating-system text.
36use std::ffi::OsString;
37/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
38use std::path::Path;
39
40/// Variables native Git must not inherit, so every object it writes lands in the shadow store.
41pub const NATIVE_UNSET_VARIABLES: [&str; 4] = ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY"];
42
43/// Name of the journal record a preparation writes.
44pub const PREPARED_FILENAME: &str = "prepared.json";
45
46/// What:
47///  Everything one native preparation needs from its transaction.
48/// Why:
49///   The incumbent passes these as separate options, and grouping them keeps the call short.
50///
51/// In TS you'd write (pseudocode):
52/// ```ts
53/// runNativePreparation({ gitPath, cwd, globalArgs, commitArgs, shadowPath, worktreeRoot, hooksDirectory, commitIndexPath });
54/// ```
55#[derive(Clone, Copy, Debug)]
56pub struct PreparationRequest<'request> {
57    /// Invocation capture the shadow was created from.
58    pub capture: &'request InvocationCapture,
59    /// Shadow repository of the transaction.
60    pub shadow: &'request Path,
61    /// Private commit index.
62    pub commit_index: &'request Path,
63    /// Kept global options of the invocation.
64    pub global_args: &'request [String],
65    /// Commit arguments without pathspecs or `--only`.
66    pub commit_args: &'request [String],
67    /// The object directory Git writes its new objects into; nothing leaves them in the shadow's own store.
68    ///  The commit service names the real one, so the commit it lands is already where the branch can point at it.
69    pub object_directory: Option<&'request Path>,
70}
71
72/// What:
73///  Why a preparation stopped.
74/// Why:
75///   A nonzero native exit is the invocation's own exit code, while a start failure is an error.
76///
77/// In TS you'd write (pseudocode):
78/// ```ts
79/// class NativeCommitFailedError extends Error { exitCode: number }
80/// ```
81#[derive(Debug)]
82pub enum PreparationError {
83    /// Native `git commit` exited nonzero; its code is the invocation's code.
84    NativeExit {
85        /// Git's exit code.
86        exit_code: i32,
87    },
88    /// A Git step around the commit failed.
89    Git(GitFailure),
90    /// `prepared.json` could not be written.
91    Write(String),
92}
93
94impl std::fmt::Display for PreparationError {
95    /// Writes the wording of the failure.
96    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        return match self {
98            Self::NativeExit { exit_code } => {
99                write!(formatter, "git commit exited {exit_code} during private preparation; nothing landed.")
100            }
101            Self::Git(error) => write!(formatter, "{error}"),
102            Self::Write(text) => formatter.write_str(text.as_str()),
103        };
104    }
105}
106
107/// What:
108///  The prepared commit read back from the shadow `HEAD`.
109/// Why:
110///   Replay rebuilds every later commit from the exact object bytes,
111///  and the tree and signature decide what landing may do.
112///
113/// In TS you'd write (pseudocode):
114/// ```ts
115/// type PreparedCommit = { oid: string; treeOid: string; signed: boolean; raw: RawCommit };
116/// ```
117#[derive(Clone, Debug, Eq, PartialEq)]
118pub struct PreparedCommit {
119    /// Prepared commit, the shadow `HEAD` value.
120    pub oid: String,
121    /// Prepared commit tree.
122    pub tree_oid: String,
123    /// Whether the header block carries a `gpgsig` or `gpgsig-sha256` header.
124    pub signed: bool,
125    /// Exact commit object bytes.
126    pub raw: Vec<u8>,
127}
128
129/// What:
130///  Builds the arguments of native `git commit` in the shadow.
131/// Why:
132///   The kept global options come first, then the shadow, then a hooks directory that cannot exist, which also beats a
133///  `-c core.hooksPath` of the caller.
134///  The shadow's own config switches every commit event off, so no external program runs for a hook.
135///  No `--work-tree` is passed: the shadow's own config names the work tree.
136///
137/// In TS you'd write (pseudocode):
138/// ```ts
139/// [...globalArgs, `--git-dir=${shadowPath}`, '-c', `core.hooksPath=${NO_HOOKS}`, 'commit', ...commitArgs]
140/// ```
141fn native_commit_arguments(request: &PreparationRequest) -> Vec<String> {
142    let mut arguments: Vec<String> = request.global_args.to_vec();
143    arguments.push(format!("--git-dir={}", request.shadow.display()));
144    arguments.push(String::from("-c"));
145    arguments.push(format!("core.hooksPath={NO_HOOKS_DIRECTORY}"));
146    arguments.push(String::from("commit"));
147    arguments.extend(request.commit_args.iter().cloned());
148    return arguments;
149}
150
151/// What:
152///  Install the signal guard, waiting while another thread of this process holds it.
153/// Why:
154///   One wrapper process runs one Git child at a time, so the wait never happens there; library tests run several
155///  preparations in threads of one process, and the guard is process-wide.
156///
157/// In TS you'd write (pseudocode):
158/// ```ts
159/// const guard = installSignalGuard();
160/// ```
161pub fn install_guard_when_free() -> std::io::Result<SignalGuard> {
162    return retry_while_active(SignalGuard::install, GUARD_WAIT_LIMIT, GUARD_WAIT_DELAY);
163}
164
165/// The most times the guard installation is retried while another thread holds the guard.
166const GUARD_WAIT_LIMIT: u32 = 1_200;
167
168/// The pause between two tries of the guard installation.
169const GUARD_WAIT_DELAY: std::time::Duration = std::time::Duration::from_millis(50);
170
171/// What:
172///  Run `install` again while it fails only because another holder is active, up to `limit` more times.
173/// Why:
174///   Any other failure, and a holder that never lets go, end the wait with the failure itself.
175///
176/// In TS you'd write (pseudocode):
177/// ```ts
178/// for (let attempt = 0; ; attempt += 1) { try { return install(); } catch (e) { if (!isActive(e) || attempt >= limit) throw e; await wait(delay); } }
179/// ```
180pub fn retry_while_active<Value>(
181    mut install: impl FnMut() -> std::io::Result<Value>,
182    limit: u32,
183    delay: std::time::Duration,
184) -> std::io::Result<Value> {
185    let mut attempts: u32 = 0;
186    loop {
187        match install() {
188            Ok(value) => return Ok(value),
189            Err(error) if is_guard_active_refusal(&error) && attempts < limit => {
190                attempts += 1;
191                std::thread::sleep(delay);
192            }
193            Err(error) => return Err(error),
194        }
195    }
196}
197
198/// What:
199///  Runs native `git commit` in the shadow with the terminal attached and returns its exit code.
200/// Why:
201///   The editor output reaches the user unchanged,
202///  so the child inherits standard streams.
203///
204/// In TS you'd write (pseudocode):
205/// ```ts
206/// await runTransactionGit({ gitPath, cwd, args, indexPath, unsetEnvironment, stdio: 'inherit', allowFailure: true });
207/// ```
208pub fn run_native_commit(
209    context: &GitContext,
210    cwd: &Path,
211    request: &PreparationRequest,
212) -> Result<i32, PreparationError> {
213    let arguments: Vec<OsString> = native_commit_arguments(request).iter().map(OsString::from).collect();
214    let mut command = git_command(context.real_git.as_path(), arguments.as_slice(), context.overlay.as_slice());
215    command
216        .current_dir(cwd)
217        .env("GIT_INDEX_FILE", request.commit_index);
218    for name in NATIVE_UNSET_VARIABLES {
219        command.env_remove(name);
220    }
221    if let Some(directory) = request.object_directory {
222        command.env("GIT_OBJECT_DIRECTORY", directory);
223    }
224    return run_guarded_child(&mut command);
225}
226
227/// What:
228///  Run the native commit under the signal guard and return its exit code.
229/// Why:
230///   A signal sent to the wrapper while Git runs reaches Git when a process sent it; Git's ending decides the status.
231///
232/// In TS you'd write (pseudocode):
233/// ```ts
234/// using guard = installSignalGuard(); return exitCode(await guard.runChild(command));
235/// ```
236fn run_guarded_child(command: &mut std::process::Command) -> Result<i32, PreparationError> {
237    let guard: SignalGuard = install_guard_when_free().map_err(|error| {
238        return PreparationError::Git(GitFailure(format!("installing the signal guard failed: {error}")));
239    })?;
240    let outcome = guard.run_child(command).map_err(|error| {
241        return PreparationError::Git(GitFailure(format!("git commit could not start: {error}")));
242    })?;
243    return Ok(super::forwarding::exit_code(outcome));
244}
245
246/// What:
247///  Reads the prepared commit from the shadow `HEAD`:
248///  its object, its tree and whether it is signed.
249/// Why:
250///   The header block ends at the first empty line; a signature header in it marks the commit signed.
251///
252/// In TS you'd write (pseudocode):
253/// ```ts
254/// await readPreparedCommit({ gitPath, shadowPath });
255/// ```
256pub fn read_prepared_commit(context: &GitContext, shadow: &Path) -> Result<PreparedCommit, GitFailure> {
257    let oid_output: GitOutput = run_git_checked(
258        context,
259        &shadow_request(shadow, shadow, &["rev-parse", "--verify", "HEAD^{commit}"]),
260    )?;
261    let oid: String = String::from_utf8_lossy(oid_output.stdout.as_slice()).trim().to_string();
262    let raw: Vec<u8> = run_git_checked(
263        context,
264        &shadow_request(shadow, shadow, &["cat-file", "commit", oid.as_str()]),
265    )?
266    .stdout;
267    let header_end: usize = raw
268        .windows(2)
269        .position(|window| { return window == b"\n\n"; })
270        .unwrap_or(raw.len());
271    let header: String = String::from_utf8_lossy(&raw[..header_end]).into_owned();
272    let tree_oid: String = header
273        .lines()
274        .find_map(|line| { return line.strip_prefix("tree "); })
275        .unwrap_or("")
276        .to_string();
277    let signed: bool = header
278        .lines()
279        .any(|line| { return line.starts_with("gpgsig ") || line.starts_with("gpgsig-sha256 "); });
280    return Ok(PreparedCommit {
281        oid,
282        tree_oid,
283        signed,
284        raw,
285    });
286}
287
288/// What:
289///  Writes `prepared.json` exclusively and makes it durable.
290/// Why:
291///   Recovery reads the prepared record to decide what a dead transaction had prepared.
292///
293/// In TS you'd write (pseudocode):
294/// ```ts
295/// await writeJournalRecord({ directory, filename: PREPARED_FILENAME, record });
296/// ```
297pub fn write_prepared_record(directory: &Path, record: &PreparedRecord) -> Result<(), PreparationError> {
298    return write_exclusive_synced(
299        directory.join(PREPARED_FILENAME).as_path(),
300        encode_prepared(record).as_bytes(),
301    )
302    .map_err(|error| { return PreparationError::Write(format!("writing {PREPARED_FILENAME} failed: {error}")); });
303}
304
305/// What:
306///  The facts `prepared.json` records beyond what the shadow and the index already answer.
307/// Why:
308///   The landing decides what to replay from these lists, and the policy pass that fills them is a later slice.
309///
310/// In TS you'd write (pseudocode):
311/// ```ts
312/// { committedPaths, addedPaths, selectedWorktreePaths }
313/// ```
314#[derive(Clone, Debug, Default, Eq, PartialEq)]
315pub struct PreparedPaths {
316    /// Selected and added paths the commit carries.
317    pub committed_paths: Vec<Vec<u8>>,
318    /// Added paths with the blobs their worktree copies receive after landing.
319    pub added_paths: Vec<AddedPath>,
320    /// Selected corrections and hook changes that the worktree receives after landing.
321    pub selected_worktree_paths: Vec<AddedPath>,
322}
323
324/// What:
325///  Runs native preparation end to end:
326///       native commit, the prepared commit read back, and `prepared.json`.
327/// Why:
328///   A failure removes the shadow before it returns, so no shadow outlives a failed preparation,
329///  and the exit code of a failed native commit is the invocation's exit code.
330///
331/// In TS you'd write (pseudocode):
332/// ```ts
333/// await runNativePreparation(...); const prepared = await readPreparedCommit(...); await writeJournalRecord(...);
334/// ```
335pub fn prepare_native_commit(
336    context: &GitContext,
337    cwd: &Path,
338    request: &PreparationRequest,
339    journal_directory: &Path,
340    intended_tree_oid: &str,
341    paths: &PreparedPaths,
342) -> Result<PreparedCommit, PreparationError> {
343    let exit_code: i32 = match run_native_commit(context, cwd, request) {
344        Ok(code) => code,
345        Err(error) => {
346            remove_failed_shadow(request.shadow);
347            return Err(error);
348        }
349    };
350    if exit_code != 0 {
351        remove_failed_shadow(request.shadow);
352        return Err(PreparationError::NativeExit { exit_code });
353    }
354    let prepared: PreparedCommit = match read_prepared_commit(context, request.shadow) {
355        Ok(commit) => commit,
356        Err(error) => {
357            remove_failed_shadow(request.shadow);
358            return Err(PreparationError::Git(error));
359        }
360    };
361    write_prepared_record(
362        journal_directory,
363        &PreparedRecord {
364            shadow_path: request.shadow.display().to_string(),
365            prepared_oid: prepared.oid.clone(),
366            signed: prepared.signed,
367            intended_tree_oid: intended_tree_oid.to_string(),
368            committed_paths: paths.committed_paths.clone(),
369            added_paths: paths.added_paths.clone(),
370            selected_worktree_paths: paths.selected_worktree_paths.clone(),
371        },
372    )?;
373    return Ok(prepared);
374}
375
376/// What:
377///  Removes the shadow of a failed preparation.
378/// Why:
379///   The original failure is the answer, so a removal failure is ignored here.
380///
381/// In TS you'd write (pseudocode):
382/// ```ts
383/// await removeShadowRepository(shadowPath);
384/// ```
385fn remove_failed_shadow(shadow: &Path) {
386    let _ = remove_shadow_repository(shadow);
387}
388
389/// Native preparation controls stay out of the release executable.
390#[cfg(test)]
391#[path = "commit_preparation_tests.rs"]
392mod tests;