Skip to main content

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