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