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