Skip to main content

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