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