git_policy_cli/hook_dispatch.rs
1//! What:
2//! The hook dispatcher: a native entry started by a preparation hook name inside a transaction's
3//! `hooks` directory, which runs the repository's hooks for that event under the hook lock.
4//! Why:
5//! Git starts the entry through `core.hooksPath`; the start name selects the event.
6//! The incumbent's `dispatch.mjs` (`src/hook-dispatch/hook-dispatch-program.ts`) does the same work,
7//! and both take the hook lock at `<common>/cli-git/hook.lock`, so they serialize on one lock.
8//! Any other start name, including `post-commit` or a hook name outside a transaction, behaves as `git`.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! await dispatch(event, process.argv.slice(2));
13//! ```
14
15/// Plan reading, the lock path and the plan's JSON spelling.
16use super::hook_dispatch_plan::{
17 ConfigParameters, CONFIG_PARAMETERS_VARIABLE, HookDispatchPlan, PLAN_FILENAME, PLAN_LIMIT,
18 PREPARATION_HOOK_EVENTS, WORK_TREE_VARIABLE, parse_hook_dispatch_plan,
19};
20/// The hook entry naming rule on Windows.
21use super::hook_dispatch_entries::hook_entry_name;
22/// Lock acquisition and release shared with the other owner locks.
23use super::live_lock::acquire_live_owner_lock;
24use super::owner_lock::OwnerLock;
25/// Bounded no-follow reads of the plan.
26use super::private_storage::read_regular_file;
27/// Preparation lease variable name.
28use super::transaction_lease::PREPARATION_LEASE_VARIABLE;
29/// Registry naming of transaction directories.
30use super::transaction_registry::{TRANSACTION_ROOT_NAME, is_transaction_id};
31/// The wrapper's ordinary path when the start name is not a dispatch.
32use super::entry::run_process;
33/// `OsStr`/`OsString` are operating-system text; `Command` starts Git.
34use std::ffi::{OsStr, OsString};
35/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
36use std::path::{Path, PathBuf};
37/// Child process starting.
38use std::process::Command;
39
40/// What:
41/// One recognized dispatch: the event the entry is named after and its transaction's hooks directory.
42/// Why:
43/// The dispatcher needs both, and the start name is the only proof that Git started this entry.
44///
45/// In TS you'd write (pseudocode):
46/// ```ts
47/// type DispatchTarget = { event: CommitHookEvent; hooksDirectory: string };
48/// ```
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct DispatchTarget {
51 /// Preparation event the entry is named after.
52 pub event: &'static str,
53 /// The transaction's `hooks` directory, holding `plan.json` beside the entry.
54 pub hooks_directory: PathBuf,
55}
56
57/// What:
58/// The event a start name names, if any.
59/// Why:
60/// Only the preparation events have entries; on Windows Git's `.exe` suffix is part of the lookup.
61///
62/// In TS you'd write (pseudocode):
63/// ```ts
64/// PREPARATION_HOOK_EVENTS.find((event) => name === hookEntryName(event));
65/// ```
66fn event_of_name(name: &str) -> Option<&'static str> {
67 for event in PREPARATION_HOOK_EVENTS {
68 if name == hook_entry_name(event) {
69 return Some(event);
70 }
71 }
72 return None;
73}
74
75/// What:
76/// Recognize a start name inside a transaction's `hooks` directory beside a `plan.json`.
77/// Why:
78/// The incumbent's dispatcher runs from `<transaction>/hooks`; recognizing any other name would
79/// turn ordinary Git invocations into dispatches.
80/// The name is read lexically, and the file system is touched only after every name matches.
81///
82/// In TS you'd write (pseudocode):
83/// ```ts
84/// // the dispatcher is the program written as <tx>/hooks/<event> beside <tx>/hooks/plan.json
85/// ```
86pub fn dispatch_target_for_start_name(start_name: &Path) -> Option<DispatchTarget> {
87 let name: &str = start_name.file_name()?.to_str()?;
88 let event: &'static str = event_of_name(name)?;
89 let hooks_directory: &Path = start_name.parent()?;
90 if hooks_directory.file_name()? != "hooks" {
91 return None;
92 }
93 let transaction: &Path = hooks_directory.parent()?;
94 if !is_transaction_id(transaction.file_name()?.to_str()?) {
95 return None;
96 }
97 if transaction.parent()?.file_name()? != TRANSACTION_ROOT_NAME {
98 return None;
99 }
100 if read_regular_file(hooks_directory.join(PLAN_FILENAME).as_path(), PLAN_LIMIT).is_err() {
101 return None;
102 }
103 return Some(DispatchTarget {
104 event,
105 hooks_directory: hooks_directory.to_path_buf(),
106 });
107}
108
109/// What:
110/// The environment the dispatched Git sees: the caller's variables with the plan's three overrides.
111/// Why:
112/// The incumbent's dispatcher restores `GIT_CONFIG_PARAMETERS` (or removes it), exports the worktree root
113/// and the preparation lease; nothing else changes.
114///
115/// In TS you'd write (pseudocode):
116/// ```ts
117/// environment.GIT_WORK_TREE = plan.worktreeRoot; environment.CLI_GIT_PREPARATION_LEASE = plan.lease;
118/// ```
119pub fn dispatch_environment(
120 environment: &[(OsString, OsString)],
121 plan: &HookDispatchPlan,
122) -> Vec<(OsString, OsString)> {
123 // `mut` lets the overrides replace the caller's values.
124 let mut result: Vec<(OsString, OsString)> = Vec::with_capacity(environment.len() + 3);
125 for (name, value) in environment {
126 let overridden: bool = name == CONFIG_PARAMETERS_VARIABLE
127 || name == WORK_TREE_VARIABLE
128 || name == PREPARATION_LEASE_VARIABLE;
129 if !overridden {
130 result.push((name.clone(), value.clone()));
131 }
132 }
133 if let ConfigParameters::Present(value) = &plan.config_parameters {
134 result.push((OsString::from(CONFIG_PARAMETERS_VARIABLE), OsString::from(value)));
135 }
136 result.push((
137 OsString::from(WORK_TREE_VARIABLE),
138 OsString::from(plan.worktree_root.as_str()),
139 ));
140 result.push((
141 OsString::from(PREPARATION_LEASE_VARIABLE),
142 OsString::from(plan.lease.as_str()),
143 ));
144 return result;
145}
146
147/// What:
148/// Print one dispatcher diagnostic and return exit status 1, as an uncaught error does in the incumbent.
149/// Why:
150/// Failures stop the hook with a message; the lock is released before this returns.
151///
152/// In TS you'd write (pseudocode):
153/// ```ts
154/// throw new Error('cli-git hook dispatcher: ' + message);
155/// ```
156fn dispatch_failure(message: &str) -> i32 {
157 eprintln!("cli-git hook dispatcher: {message}");
158 return 1;
159}
160
161/// What:
162/// Run the real Git hook runner for one event under the plan's lock, and return its status.
163/// Why:
164/// The incumbent's `dispatch`:
165/// a disabled event exits 0 untouched, the lock is taken unless the plan skips it,
166/// Git runs `hook run --ignore-missing <event>` with the repository's hooks path,
167/// a signal or a failure to report a status is exit 1, and the lock is released afterwards.
168///
169/// In TS you'd write (pseudocode):
170/// ```ts
171/// async function dispatch(event, args): Promise<void>;
172/// ```
173pub fn run_hook_dispatch(
174 target: &DispatchTarget,
175 arguments: &[OsString],
176 environment: &[(OsString, OsString)],
177) -> i32 {
178 let plan_path: PathBuf = target.hooks_directory.join(PLAN_FILENAME);
179 let Ok(bytes) = read_regular_file(plan_path.as_path(), PLAN_LIMIT) else {
180 return dispatch_failure(
181 format!("cannot read the plan {}", plan_path.display()).as_str(),
182 );
183 };
184 let Some(plan) = String::from_utf8(bytes)
185 .ok()
186 .and_then(|text| { return parse_hook_dispatch_plan(text.as_str()); })
187 else {
188 return dispatch_failure(
189 format!("the plan {} is malformed", plan_path.display()).as_str(),
190 );
191 };
192 if plan.disabled_events.iter().any(|name| { return name == target.event; }) {
193 return 0;
194 }
195 let child_environment: Vec<(OsString, OsString)> = dispatch_environment(environment, &plan);
196 let lock: Option<OwnerLock> = if plan.skip_hook_lock {
197 None
198 } else {
199 match acquire_live_owner_lock(Path::new(plan.hook_lock_path.as_str()), "the hook lock") {
200 Ok(acquired) => Some(acquired),
201 Err(error) => return dispatch_failure(error.to_string().as_str()),
202 }
203 };
204 let status: i32 = run_git_hook(&plan, target.event, arguments, child_environment.as_slice());
205 if let Some(held) = lock
206 && let Err(error) = held.release()
207 {
208 return dispatch_failure(error.to_string().as_str());
209 }
210 return status;
211}
212
213/// What:
214/// Start Git's hook runner for one event with the plan's paths, and wait for it.
215/// Why:
216/// The incumbent's `spawn` with `stdio: 'inherit'`; a missing status (a signal or a failure to start) is 1.
217///
218/// In TS you'd write (pseudocode):
219/// ```ts
220/// spawn(plan.gitPath, ['-c', `hook.${event}.enabled=true`, '-c', `core.hooksPath=${plan.hooksPath}`, 'hook', 'run', '--ignore-missing', event, '--', ...args], { stdio: 'inherit', env });
221/// ```
222fn run_git_hook(
223 plan: &HookDispatchPlan,
224 event: &str,
225 arguments: &[OsString],
226 environment: &[(OsString, OsString)],
227) -> i32 {
228 let enabled: String = format!("hook.{event}.enabled=true");
229 let hooks_path: String = format!("core.hooksPath={}", plan.hooks_path);
230 let status = Command::new(plan.git_path.as_str())
231 .args(["-c", enabled.as_str(), "-c", hooks_path.as_str(), "hook", "run", "--ignore-missing", event, "--"])
232 .args(arguments)
233 .env_clear()
234 .envs(environment.iter().map(|(name, value)| { return (name.clone(), value.clone()); }))
235 .status();
236 let Ok(finished) = status else {
237 return dispatch_failure(
238 format!("cannot start {}", plan.git_path).as_str(),
239 );
240 };
241 return finished.code().unwrap_or(1);
242}
243
244/// What:
245/// Dispatch when the start name names a transaction's entry, otherwise run the wrapper.
246/// Why:
247/// The executable calls this with its own start name first; every other start name, and
248/// an absent one, reaches the ordinary path unchanged.
249///
250/// In TS you'd write (pseudocode):
251/// ```ts
252/// if (startName is a dispatch) await dispatch(event, args); else await runProcess(args, env);
253/// ```
254pub fn run_executable(
255 start_name: Option<&OsStr>,
256 arguments: &[OsString],
257 environment: &[(OsString, OsString)],
258) -> i32 {
259 if let Some(name) = start_name
260 && let Some(target) = dispatch_target_for_start_name(Path::new(name))
261 {
262 return run_hook_dispatch(&target, arguments, environment);
263 }
264 return run_process(arguments, environment);
265}
266
267#[cfg(test)]
268#[path = "hook_dispatch_tests.rs"]
269mod tests;