git_policy_cli/post_commit_hook.rs
1//! What:
2//! The single `post-commit` run after a landing: its author environment, the hook lock around it,
3//! and the real Git hook runner with inherited standard streams.
4//! Why:
5//! The incumbent's `commit-landing-post-commit-hook.ts` runs it outside both landing locks,
6//! under the hook lock unless `hooks.concurrentCommits` is `true` or an outer preparation lease is inherited.
7//! Its exit status is ignored, as native Git ignores it, and the user sees the hook's output on the terminal.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! await runPostCommitHook({ gitPath, cwd, globalArgs, realIndexPath, landedOid, plan });
12//! ```
13
14use super::diagnostic_log::debug;
15use super::forwarding::git_command;
16use super::hook_dispatch_plan::HookDispatchPlan;
17use super::owner_lock::{DEFAULT_POLL_DELAY, OwnerLock, SilentWait, acquire_owner_lock};
18use super::transaction_git::{GitContext, GitRequest, run_git};
19use std::ffi::OsString;
20use std::path::Path;
21use std::process::{Command, Stdio};
22
23/// What:
24/// A failure of the post-commit run: the author lookup, the hook lock, or the hook's start.
25/// Why:
26/// The caller reports the message and exits 1, as the incumbent reports a thrown error after a landing.
27///
28/// In TS you'd write (pseudocode):
29/// ```ts
30/// throw new CommitTransactionGitError(message);
31/// ```
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct PostCommitHookError(pub String);
34
35/// What:
36/// Whether `post-commit` ran, and the status its hook returned when it did.
37/// Why:
38/// A disabled event runs nothing, and a non-zero status is recorded but never fails the landed commit.
39///
40/// In TS you'd write (pseudocode):
41/// ```ts
42/// type PostCommitHookOutcome = 'disabled' | { exitCode: number };
43/// ```
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45pub enum PostCommitHookOutcome {
46 /// `hook.post-commit.enabled=false` disabled the event; nothing ran.
47 Disabled,
48 /// The hook runner's status, which the caller ignores.
49 Ran(i32),
50}
51
52/// What:
53/// The inputs of one post-commit run.
54/// Why:
55/// Named fields keep the paths apart, and the plan carries the disabled events and the hook lock.
56///
57/// In TS you'd write (pseudocode):
58/// ```ts
59/// { gitPath, cwd, globalArgs, realIndexPath, landedOid, plan }
60/// ```
61#[derive(Clone, Copy, Debug)]
62pub struct PostCommitHookRequest<'a> {
63 /// Real Git and the caller's global options.
64 pub context: &'a GitContext,
65 /// Owning worktree root, where the hook runs.
66 pub worktree: &'a Path,
67 /// Real index native Git names in `GIT_INDEX_FILE`.
68 pub real_index: &'a Path,
69 /// Landed commit whose author the hook sees.
70 pub landed_oid: &'a str,
71 /// Dispatch plan holding the disabled events and the hook lock.
72 pub plan: &'a HookDispatchPlan,
73}
74
75/// What:
76/// Run `post-commit` once for a landed commit.
77/// Why:
78/// The order matches the incumbent: disabled check, author lookup, hook lock, hook run, lock release.
79///
80/// In TS you'd write (pseudocode):
81/// ```ts
82/// if (plan.disabledEvents.includes('post-commit')) return; await using lock = plan.skipHookLock ? undefined : await acquireOwnerLock(...); await runTransactionGit(...);
83/// ```
84pub fn run_post_commit_hook(
85 request: PostCommitHookRequest,
86) -> Result<PostCommitHookOutcome, PostCommitHookError> {
87 if request.plan.disabled_events.iter().any(|name| {
88 return name.as_str() == "post-commit";
89 }) {
90 debug(
91 "runPostCommitHook",
92 "post-commit disabled by hook.post-commit.enabled=false",
93 );
94 return Ok(PostCommitHookOutcome::Disabled);
95 }
96 let mut environment: Vec<(OsString, OsString)> =
97 read_author_environment(request.context, request.worktree, request.landed_oid)?;
98 environment.push((
99 OsString::from("GIT_INDEX_FILE"),
100 request.real_index.as_os_str().to_os_string(),
101 ));
102 environment.push((OsString::from("GIT_EDITOR"), OsString::from(":")));
103 // Held until the end of this function, so an early return releases it through `Drop`.
104 let lock: Option<OwnerLock> = if request.plan.skip_hook_lock {
105 None
106 } else {
107 Some(acquire_hook_lock(request.plan.hook_lock_path.as_str())?)
108 };
109 let status: i32 = run_hook(request.context, request.worktree, environment.as_slice())?;
110 if let Some(held) = lock {
111 held.release()
112 .map_err(|error| return PostCommitHookError(error.to_string()))?;
113 }
114 return Ok(PostCommitHookOutcome::Ran(status));
115}
116
117/// What:
118/// Take the hook lock for the post-commit run, waiting silently while a live owner holds it.
119/// Why:
120/// The incumbent's `acquireOwnerLock` on `plan.hookLockPath`, shared with the preparation dispatchers.
121///
122/// In TS you'd write (pseudocode):
123/// ```ts
124/// await acquireOwnerLock({ lockDirectory: plan.hookLockPath });
125/// ```
126fn acquire_hook_lock(lock_path: &str) -> Result<OwnerLock, PostCommitHookError> {
127 let directory: &Path = Path::new(lock_path);
128 // The incumbent creates the lock's parents recursively; the native owner lock creates one level only.
129 if let Some(parent) = directory.parent() {
130 std::fs::create_dir_all(parent).map_err(|error| {
131 return PostCommitHookError(format!("creating {} failed: {error}", parent.display()));
132 })?;
133 }
134 return acquire_owner_lock(directory, DEFAULT_POLL_DELAY, &mut SilentWait)
135 .map_err(|error| return PostCommitHookError(error.to_string()));
136}
137
138/// What:
139/// Read the landed commit's author as `GIT_AUTHOR_*` variables, through a Git start without the global options.
140/// Why:
141/// The incumbent's `authorEnvironment` runs `show` with the bare command; a refusal fails the run before the hook starts.
142///
143/// In TS you'd write (pseudocode):
144/// ```ts
145/// await runTransactionGit({ gitPath, cwd, args: ['show', '--no-patch', '--date=raw', '--format=%an%x00%ae%x00%ad', landedOid] });
146/// ```
147fn read_author_environment(
148 context: &GitContext,
149 worktree: &Path,
150 landed_oid: &str,
151) -> Result<Vec<(OsString, OsString)>, PostCommitHookError> {
152 let request: GitRequest = GitRequest {
153 without_prefix: true,
154 ..GitRequest::new(
155 worktree,
156 &[
157 "show",
158 "--no-patch",
159 "--date=raw",
160 "--format=%an%x00%ae%x00%ad",
161 landed_oid,
162 ],
163 )
164 };
165 let output = run_git(context, &request).map_err(|error| {
166 return PostCommitHookError(format!("git show could not start: {error}"));
167 })?;
168 if !output.succeeded() {
169 return Err(PostCommitHookError(output.error_text()));
170 }
171 let text: String = String::from_utf8(output.stdout).map_err(|error| {
172 return PostCommitHookError(format!(
173 "the landed commit's author fields are not UTF-8: {error}"
174 ));
175 })?;
176 return Ok(author_environment(text.as_str())
177 .into_iter()
178 .map(|(name, value)| {
179 return (OsString::from(name), OsString::from(value));
180 })
181 .collect());
182}
183
184/// What:
185/// Start the real Git hook runner for `post-commit` with inherited standard streams, and return its status.
186/// Why:
187/// The user sees the hook's output on the terminal, as the incumbent's `stdio: 'inherit'` gives it;
188/// a missing status (a signal) is 1, which the caller ignores.
189///
190/// In TS you'd write (pseudocode):
191/// ```ts
192/// await runTransactionGit({ gitPath, cwd, args: [...globalArgs, 'hook', 'run', '--ignore-missing', 'post-commit'], environment, stdio: 'inherit', allowFailure: true });
193/// ```
194fn run_hook(
195 context: &GitContext,
196 worktree: &Path,
197 environment: &[(OsString, OsString)],
198) -> Result<i32, PostCommitHookError> {
199 let mut arguments: Vec<OsString> = context.global_prefix.clone();
200 arguments.extend(
201 ["hook", "run", "--ignore-missing", "post-commit"]
202 .into_iter()
203 .map(OsString::from),
204 );
205 let mut command: Command = git_command(
206 context.real_git.as_path(),
207 arguments.as_slice(),
208 context.overlay.as_slice(),
209 );
210 command
211 .current_dir(worktree)
212 .envs(environment.iter().cloned())
213 .stdin(Stdio::inherit())
214 .stdout(Stdio::inherit())
215 .stderr(Stdio::inherit());
216 let finished = command.status().map_err(|error| {
217 return PostCommitHookError(format!(
218 "{} could not start: {error}",
219 context.real_git.display()
220 ));
221 })?;
222 return Ok(finished.code().unwrap_or(1));
223}
224
225/// What:
226/// The environment native `git commit` gives the hook, from the landed commit's `%an`, `%ae` and raw `%ad`.
227/// Why:
228/// Hooks read `GIT_AUTHOR_*`; the incumbent takes the NUL-separated fields and drops one trailing newline.
229///
230/// In TS you'd write (pseudocode):
231/// ```ts
232/// const [name = '', email = '', date = ''] = output.replace(/\n$/, '').split('\0');
233/// ```
234pub fn author_environment(output: &str) -> Vec<(String, String)> {
235 let trimmed: &str = output.strip_suffix('\n').unwrap_or(output);
236 let mut fields = trimmed.split('\0');
237 let name: &str = fields.next().unwrap_or("");
238 let email: &str = fields.next().unwrap_or("");
239 let date: &str = fields.next().unwrap_or("");
240 return vec![
241 (String::from("GIT_AUTHOR_NAME"), String::from(name)),
242 (String::from("GIT_AUTHOR_EMAIL"), String::from(email)),
243 (String::from("GIT_AUTHOR_DATE"), format!("@{date}")),
244 ];
245}
246
247/// Post-commit controls stay out of the release executable.
248#[cfg(test)]
249#[path = "post_commit_hook_tests.rs"]
250mod tests;