Skip to main content

git_policy_cli/
hook_dispatch_plan.rs

1//! What:
2//!  The dispatch plan of one commit transaction:
3//!  which hooks run, how they run, and under which lock.
4//! Why:
5//!  Inside native `git commit` the hook entries see only the transaction's overrides,
6//!  so the plan is computed by the wrapper from the real repository and the caller's environment.
7//!  The incumbent (`src/hook-dispatch/hook-dispatch-plan.ts`) and both dispatchers read the same
8//!  `plan.json`, so its field order and spelling are part of the on-disk format.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const plan: HookDispatchPlan = await computeHookDispatchPlan({ gitPath, cwd, globalArgs, commonDir, ... });
13//! ```
14
15/// The one configuration read, and its lookup answers.
16use super::config_snapshot::{ConfigSnapshot, Lookup};
17/// Config reads and the per-transaction Git context.
18use super::transaction_git::{GitContext, GitRequest, run_git};
19/// Lenient typed readers over the plan's JSON object.
20use super::json_record::{ObjectWriter, parse_object, safe_integer_field, string_array, string_field};
21/// Unguessable tokens for preparation leases.
22use super::random_id::random_uuid;
23/// Lease spelling shared with the transaction lease validator.
24use super::transaction_lease::format_preparation_lease;
25/// `OsString` is owned operating-system text of raw bytes.
26use std::ffi::OsString;
27/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
28use std::path::{Component, Path, PathBuf};
29
30/// Plan schema version written and required on read.
31pub const PLAN_SCHEMA_VERSION: i64 = 1;
32
33/// Name of the plan inside a transaction's `hooks` directory.
34pub const PLAN_FILENAME: &str = "plan.json";
35
36/// Events the shim dispatches during preparation; `post-commit` runs once after landing instead.
37pub const PREPARATION_HOOK_EVENTS: [&str; 3] = ["pre-commit", "prepare-commit-msg", "commit-msg"];
38
39/// Every commit event the transaction silences inside native `git commit`.
40pub const SILENCED_HOOK_EVENTS: [&str; 4] =
41    ["pre-commit", "prepare-commit-msg", "commit-msg", "post-commit"];
42
43/// Environment variable that carries Git's config parameters into hooks.
44pub const CONFIG_PARAMETERS_VARIABLE: &str = "GIT_CONFIG_PARAMETERS";
45
46/// Environment variable naming the worktree root for hooks.
47pub const WORK_TREE_VARIABLE: &str = "GIT_WORK_TREE";
48
49/// Largest plan a dispatcher reads.
50pub const PLAN_LIMIT: u64 = 1024 * 1024;
51
52/// What:
53///  Why planning or encoding a plan failed.
54/// Why:
55///   The caller stops the commit with the message;
56///  a plan is never partially written.
57///
58/// In TS you'd write (pseudocode):
59/// ```ts
60/// throw new TypeError(`git config --get ${key} failed: ${stderr.trim()}`);
61/// ```
62#[derive(Clone, Debug, Eq, PartialEq)]
63pub enum HookPlanError {
64    /// A `git config --get` read failed with a code other than 1; holds Git's stderr text.
65    ConfigRead(String),
66    /// A value the plan records is not UTF-8, so it cannot be written as JSON text.
67    NotUtf8(&'static str),
68    /// The random source for a preparation lease token failed.
69    Random(String),
70}
71
72/// What:
73///  One line for each planning failure.
74/// Why:
75///   The caller prints it as the diagnostic.
76///
77/// In TS you'd write (pseudocode):
78/// ```ts
79/// error.message
80/// ```
81impl std::fmt::Display for HookPlanError {
82    /// Writes one sentence naming what failed.
83    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        match self {
85            Self::ConfigRead(stderr) => {
86                return write!(formatter, "git config failed: {stderr}");
87            }
88            Self::NotUtf8(what) => {
89                return write!(formatter, "the {what} is not UTF-8 and cannot be recorded");
90            }
91            Self::Random(message) => {
92                return write!(formatter, "no random token for the preparation lease: {message}");
93            }
94        }
95    }
96}
97
98/// What:
99///  The value the dispatcher restores as `GIT_CONFIG_PARAMETERS`, or its absence.
100/// Why:
101///   Git reads the variable as text; an absent variable must stay absent in hooks.
102///
103/// In TS you'd write (pseudocode):
104/// ```ts
105/// type ConfigParameters = { kind: 'absent' } | { kind: 'present'; value: string };
106/// ```
107#[derive(Clone, Debug, Eq, PartialEq)]
108pub enum ConfigParameters {
109    /// No config parameters were inherited or given.
110    Absent,
111    /// The combined parameter text.
112    Present(String),
113}
114
115/// What:
116///  The plan written to `<transaction>/hooks/plan.json`.
117/// Why:
118///   Both dispatchers and the lease validator read this one record,
119///  so every field keeps the incumbent's name and meaning.
120///
121/// In TS you'd write (pseudocode):
122/// ```ts
123/// type HookDispatchPlan = { schemaVersion: 1; gitPath: string; disabledEvents: string[]; ... };
124/// ```
125#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct HookDispatchPlan {
127    /// Absolute real Git executable the dispatcher runs `git hook run` with.
128    pub git_path: String,
129    /// Events the user disabled with `hook.<event>.enabled=false`, in the silenced list's order.
130    pub disabled_events: Vec<String>,
131    /// Caller's config parameters including its global `-c` options.
132    pub config_parameters: ConfigParameters,
133    /// Absolute worktree root exported as `GIT_WORK_TREE`.
134    pub worktree_root: String,
135    /// Preparation lease exported to hook descendants.
136    pub lease: String,
137    /// Absolute hook lock directory.
138    pub hook_lock_path: String,
139    /// Whether hooks skip the hook lock.
140    pub skip_hook_lock: bool,
141    /// Whether the configuration may define hooks by name (`hook.<name>.event` or `.command`), which run for their
142    ///  event whatever the hooks directory holds; `true` when the configuration was not inspected.
143    ///  Not recorded in `plan.json`: the dispatchers never read it, and a plan read back says `true`.
144    pub named_hooks_possible: bool,
145}
146
147/// What:
148///  The inputs of one plan computation.
149/// Why:
150///   Named fields keep the many inputs apart; the context carries the caller's global options
151///  as its global prefix, so every config read sees them.
152///
153/// In TS you'd write (pseudocode):
154/// ```ts
155/// computeHookDispatchPlan({ gitPath, cwd, globalArgs, commonDir, worktreeRoot, lease, ... });
156/// ```
157#[derive(Clone, Copy, Debug)]
158pub struct HookPlanInputs<'a> {
159    /// Real Git and the caller's global options.
160    pub context: &'a GitContext,
161    /// Owning worktree directory where the config reads run.
162    pub cwd: &'a Path,
163    /// Caller's global options, whose `-c` values join the config parameters.
164    pub global_arguments: &'a [OsString],
165    /// Absolute common Git directory.
166    pub common_directory: &'a Path,
167    /// Absolute worktree root.
168    pub worktree_root: &'a Path,
169    /// Preparation lease token text, formatted by `new_preparation_lease`.
170    pub lease: &'a str,
171    /// `hooks.concurrentCommits`.
172    pub concurrent_commits: bool,
173    /// Whether this invocation runs under a valid outer preparation lease.
174    pub inherited_lease_valid: bool,
175    /// Caller environment, whose `GIT_CONFIG_PARAMETERS` is inherited.
176    pub environment: &'a [(OsString, OsString)],
177    /// The configuration read once with the caller's global options;
178    ///  `None` makes each key start its own `git config`.
179    pub snapshot: Option<&'a ConfigSnapshot>,
180}
181
182/// What:
183///  Quote one `key=value` entry for Git's `GIT_CONFIG_PARAMETERS`.
184/// Why:
185///   Git's `sq_quote_buf` closes the quote around `'` and `!`;
186///  the parser accepts that form in every supported version.
187///
188/// In TS you'd write (pseudocode):
189/// ```ts
190/// quoteConfigParameter("user.name=O'Brien"); // "'user.name=O'\\''Brien'"
191/// ```
192pub fn quote_config_parameter(value: &str) -> String {
193    // `mut` lets the quoted text grow one character at a time.
194    let mut body: String = String::with_capacity(value.len() + 2);
195    body.push('\'');
196    for character in value.chars() {
197        match character {
198            '\'' => body.push_str("'\\''"),
199            '!' => body.push_str("'\\!'"),
200            other => body.push(other),
201        }
202    }
203    body.push('\'');
204    return body;
205}
206
207/// What:
208///  Collect the value of every global `-c` option, in order.
209/// Why:
210///   The incumbent pairs each `-c` with the token after it, even when that token is itself `-c`.
211///  A non-UTF-8 value cannot be recorded, so it is refused.
212///
213/// In TS you'd write (pseudocode):
214/// ```ts
215/// globalArgs.flatMap((token, i) => (token === '-c') && globalArgs[i + 1] !== undefined ? [globalArgs[i + 1]] : []);
216/// ```
217pub fn global_config_overrides(global_arguments: &[OsString]) -> Result<Vec<String>, HookPlanError> {
218    // `mut` lets the list grow one value at a time.
219    let mut values: Vec<String> = Vec::new();
220    for (index, token) in global_arguments.iter().enumerate() {
221        let Some(next) = global_arguments.get(index + 1) else {
222            continue;
223        };
224        if token != "-c" {
225            continue;
226        }
227        let Some(text) = next.to_str() else {
228            return Err(HookPlanError::NotUtf8("global -c option"));
229        };
230        values.push(String::from(text));
231    }
232    return Ok(values);
233}
234
235/// What:
236///  Read the caller's inherited `GIT_CONFIG_PARAMETERS`, last entry winning.
237/// Why:
238///   An empty value counts as none, as the incumbent's `?? ''` and the joining rule do.
239///
240/// In TS you'd write (pseudocode):
241/// ```ts
242/// environment.GIT_CONFIG_PARAMETERS ?? ''
243/// ```
244pub fn inherited_config_parameters(
245    environment: &[(OsString, OsString)],
246) -> Result<String, HookPlanError> {
247    // `Option` starts absent; each matching entry replaces the earlier one.
248    let mut found: Option<&OsString> = None;
249    for (name, value) in environment {
250        if name == CONFIG_PARAMETERS_VARIABLE {
251            found = Some(value);
252        }
253    }
254    let Some(value) = found else {
255        return Ok(String::new());
256    };
257    let Some(text) = value.to_str() else {
258        return Err(HookPlanError::NotUtf8("inherited GIT_CONFIG_PARAMETERS"));
259    };
260    return Ok(String::from(text));
261}
262
263/// What:
264///  Join the inherited parameters and the quoted global `-c` values.
265/// Why:
266///   Git hands hooks the inherited entries first, then the command-line options;
267///  no entry means the variable is absent.
268///
269/// In TS you'd write (pseudocode):
270/// ```ts
271/// combineConfigParameters({ inherited, overrides });
272/// ```
273pub fn combine_config_parameters(
274    inherited: &str,
275    overrides: &[String],
276) -> Result<ConfigParameters, HookPlanError> {
277    // `mut` lets the entry list grow in order.
278    let mut entries: Vec<String> = Vec::new();
279    if !inherited.is_empty() {
280        entries.push(String::from(inherited));
281    }
282    for value in overrides {
283        entries.push(quote_config_parameter(value.as_str()));
284    }
285    if entries.is_empty() {
286        return Ok(ConfigParameters::Absent);
287    }
288    return Ok(ConfigParameters::Present(entries.join(" ")));
289}
290
291/// What:
292///  Normalize a path lexically, the way Node's `path.join` and `path.resolve` do.
293/// Why:
294///   Recorded paths must match the incumbent's spelling byte for byte,
295///  and `PathBuf::join` keeps `.` and `..`.
296///
297/// In TS you'd write (pseudocode):
298/// ```ts
299/// path.resolve(path);
300/// ```
301pub fn normalized_path(path: &Path) -> PathBuf {
302    // `mut` lets the result grow and shrink as components arrive.
303    let mut result: PathBuf = PathBuf::new();
304    for component in path.components() {
305        match component {
306            Component::Prefix(_) | Component::RootDir => {
307                result.push(component.as_os_str());
308            }
309            Component::CurDir => {}
310            Component::ParentDir => {
311                // A `..` above the root stays at the root, as Node clamps it.
312                let at_root: bool = result.parent().is_none();
313                if !at_root && result.file_name().is_some() {
314                    result.pop();
315                }
316            }
317            Component::Normal(name) => {
318                result.push(name);
319            }
320        }
321    }
322    return result;
323}
324
325/// What:
326///  The hook lock directory under the common Git directory.
327/// Why:
328///   The incumbent's `join(commonDir, 'cli-git', 'hook.lock')`; the same lock serializes both wrappers.
329///
330/// In TS you'd write (pseudocode):
331/// ```ts
332/// join(commonDir, 'cli-git', 'hook.lock')
333/// ```
334pub fn hook_lock_directory(common_directory: &Path) -> PathBuf {
335    return normalized_path(common_directory.join("cli-git").join("hook.lock").as_path());
336}
337
338/// What:
339///  The silenced events the user disabled with `hook.<event>.enabled`,
340///  in the silenced list's order,
341///  from one Git process.
342/// Why:
343///   The incumbent reads each event's switch with its own `git config --get`;
344///  one `--get-regexp` over the same four keys answers all of them,
345///  and `--type=bool` canonicalizes every spelling (`False`, `no`, `0`, a valueless key) per line.
346///  Git prints repeated keys in precedence order with `-c` values last,
347///  so the last value of a key is the one `--get` returns.
348///  Exit code 1 means no event has a switch;
349///  any other failure stops planning, as it does for a single read.
350///
351/// In TS you'd write (pseudocode):
352/// ```ts
353/// const lines = (await git(['config', '--type=bool', '--get-regexp', pattern])).split('\n');
354/// SILENCED_HOOK_EVENTS.filter((event) => lastValue(lines, `hook.${event}.enabled`) === 'false');
355/// ```
356fn read_disabled_events(context: &GitContext, cwd: &Path) -> Result<Vec<String>, HookPlanError> {
357    let pattern: String = format!("^hook\\.({})\\.enabled$", SILENCED_HOOK_EVENTS.join("|"));
358    let arguments: Vec<String> = vec![
359        String::from("config"),
360        String::from("--type=bool"),
361        String::from("--get-regexp"),
362        pattern.clone(),
363    ];
364    let request: GitRequest = GitRequest::new(cwd, arguments.as_slice());
365    let output = match run_git(context, &request) {
366        Ok(finished) => finished,
367        Err(error) => {
368            return Err(HookPlanError::ConfigRead(format!(
369                "git config --get-regexp {pattern} could not start: {error}"
370            )));
371        }
372    };
373    if output.code == Some(1) {
374        return Ok(Vec::new());
375    }
376    if !output.succeeded() {
377        return Err(HookPlanError::ConfigRead(format!(
378            "git config --get-regexp {pattern} failed: {}",
379            String::from_utf8_lossy(output.stderr.as_slice()).trim()
380        )));
381    }
382    let Ok(text) = String::from_utf8(output.stdout) else {
383        return Err(HookPlanError::ConfigRead(format!(
384            "git config --get-regexp {pattern} printed bytes that are not UTF-8"
385        )));
386    };
387    // Each line is `<key> <canonical bool>`; a later line replaces an earlier one for the same key.
388    let mut switches: Vec<(&str, &str)> = Vec::new();
389    for line in text.lines() {
390        if let Some((key, value)) = line.rsplit_once(' ') {
391            switches.retain(|(known, _)| return *known != key);
392            switches.push((key, value));
393        }
394    }
395    let mut disabled: Vec<String> = Vec::new();
396    for event in SILENCED_HOOK_EVENTS {
397        let key: String = format!("hook.{event}.enabled");
398        let enabled: Option<&str> = switches
399            .iter()
400            .find(|(known, _)| return *known == key.as_str())
401            .map(|(_, value)| return *value);
402        if enabled == Some("false") {
403            disabled.push(String::from(event));
404        }
405    }
406    return Ok(disabled);
407}
408
409/// What:
410///  The silenced events the user disabled with `hook.<event>.enabled`, from the configuration snapshot.
411/// Why:
412///   The snapshot answers exactly what the `--get-regexp` read answers when every entry of the four switches is a
413///  boolean it models; one it does not model (a bad boolean, a unit suffix) is `None`,
414///  and the caller starts Git, which reports the same error it always did.
415///
416/// In TS you'd write (pseudocode):
417/// ```ts
418/// SILENCED_HOOK_EVENTS.filter((event) => snapshot.boolValue(`hook.${event}.enabled`) === false);
419/// ```
420fn snapshot_disabled_events(snapshot: &ConfigSnapshot) -> Option<Vec<String>> {
421    let mut disabled: Vec<String> = Vec::new();
422    for event in SILENCED_HOOK_EVENTS {
423        match snapshot.bool_value(format!("hook.{event}.enabled").as_str()) {
424            Lookup::Unset | Lookup::Found(true) => {}
425            Lookup::Found(false) => disabled.push(String::from(event)),
426            Lookup::Unhandled => return None,
427        }
428    }
429    return Some(disabled);
430}
431
432/// What:
433///  Compute the dispatch plan of one transaction from the real repository.
434/// Why:
435///   The incumbent's `computeHookDispatchPlan`, minus its hooks-path resolution, because traditional hooks are not
436///  supported:
437///  the event switches (only the exact value `false` disables an event),
438///  the combined config parameters, the lock path and whether the lock is skipped.
439///
440/// In TS you'd write (pseudocode):
441/// ```ts
442/// async function computeHookDispatchPlan(inputs): Promise<HookDispatchPlan>;
443/// ```
444pub fn compute_hook_dispatch_plan(
445    inputs: &HookPlanInputs<'_>,
446) -> Result<HookDispatchPlan, HookPlanError> {
447    let disabled_events: Vec<String> = match inputs.snapshot.and_then(snapshot_disabled_events) {
448        Some(events) => events,
449        None => read_disabled_events(inputs.context, inputs.cwd)?,
450    };
451    let inherited: String = inherited_config_parameters(inputs.environment)?;
452    let overrides: Vec<String> = global_config_overrides(inputs.global_arguments)?;
453    let config_parameters: ConfigParameters = combine_config_parameters(
454        inherited.as_str(),
455        overrides.as_slice(),
456    )?;
457    let plan: HookDispatchPlan = HookDispatchPlan {
458        git_path: path_text(inputs.context.real_git.as_path(), "real Git path")?,
459        disabled_events,
460        config_parameters,
461        worktree_root: path_text(inputs.worktree_root, "worktree root")?,
462        lease: String::from(inputs.lease),
463        hook_lock_path: path_text(
464            hook_lock_directory(inputs.common_directory).as_path(),
465            "hook lock path",
466        )?,
467        skip_hook_lock: inputs.concurrent_commits || inputs.inherited_lease_valid,
468        named_hooks_possible: inputs.snapshot.is_none_or(ConfigSnapshot::defines_named_hook),
469    };
470    return Ok(plan);
471}
472
473/// What:
474///  A path as UTF-8 text, or the refusal naming what it was.
475/// Why:
476///   The plan's JSON strings hold text; lossy conversion would name another path.
477///
478/// In TS you'd write (pseudocode):
479/// ```ts
480/// // paths are strings in TypeScript
481/// ```
482fn path_text(path: &Path, what: &'static str) -> Result<String, HookPlanError> {
483    let Some(text) = path.to_str() else {
484        return Err(HookPlanError::NotUtf8(what));
485    };
486    return Ok(String::from(text));
487}
488
489/// What:
490///  Spell a plan as `JSON.stringify` writes it, without the trailing newline.
491/// Why:
492///   The incumbent writes `JSON.stringify(plan)` followed by one newline,
493///  and both dispatchers and the lease validator read the same bytes.
494///
495/// In TS you'd write (pseudocode):
496/// ```ts
497/// JSON.stringify(plan)
498/// ```
499pub fn encode_hook_dispatch_plan(plan: &HookDispatchPlan) -> String {
500    // `mut` allows appending one field at a time.
501    let mut writer: ObjectWriter = ObjectWriter::new();
502    writer.integer("schemaVersion", PLAN_SCHEMA_VERSION);
503    writer.string("gitPath", plan.git_path.as_str());
504    writer.raw(
505        "disabledEvents",
506        string_array(plan.disabled_events.as_slice()).as_str(),
507    );
508    writer.raw(
509        "configParameters",
510        encode_config_parameters(&plan.config_parameters).as_str(),
511    );
512    writer.string("worktreeRoot", plan.worktree_root.as_str());
513    writer.string("lease", plan.lease.as_str());
514    writer.string("hookLockPath", plan.hook_lock_path.as_str());
515    writer.boolean("skipHookLock", plan.skip_hook_lock);
516    return writer.finish();
517}
518
519/// What:
520///  Spell the config parameters as the incumbent's tagged object.
521/// Why:
522///   The dispatcher distinguishes an absent variable from an empty one.
523///
524/// In TS you'd write (pseudocode):
525/// ```ts
526/// configParameters.kind === 'absent' ? { kind: 'absent' } : { kind: 'present', value }
527/// ```
528fn encode_config_parameters(parameters: &ConfigParameters) -> String {
529    // `match` picks the spelling by variant.
530    match parameters {
531        ConfigParameters::Absent => {
532            let mut writer: ObjectWriter = ObjectWriter::new();
533            writer.string("kind", "absent");
534            return writer.finish();
535        }
536        ConfigParameters::Present(value) => {
537            let mut writer: ObjectWriter = ObjectWriter::new();
538            writer.string("kind", "present").string("value", value.as_str());
539            return writer.finish();
540        }
541    }
542}
543
544/// What:
545///  Read a plan from its JSON text, refusing any other shape.
546/// Why:
547///   The dispatcher and the lease validator must agree on what a plan is;
548///  an unknown event or schema makes the plan unusable.
549///
550/// In TS you'd write (pseudocode):
551/// ```ts
552/// const plan = JSON.parse(text) as HookDispatchPlan; // checked fields
553/// ```
554pub fn parse_hook_dispatch_plan(text: &str) -> Option<HookDispatchPlan> {
555    let object = parse_object(text)?;
556    if safe_integer_field(&object, "schemaVersion") != Some(PLAN_SCHEMA_VERSION) {
557        return None;
558    }
559    let events_value = object.get("disabledEvents")?.as_array()?;
560    // `mut` lets the event list grow in file order.
561    let mut disabled_events: Vec<String> = Vec::new();
562    for item in events_value {
563        let name: &str = item.as_str()?;
564        if !SILENCED_HOOK_EVENTS.contains(&name) {
565            return None;
566        }
567        disabled_events.push(String::from(name));
568    }
569    let parameters = object.get("configParameters")?.as_object()?;
570    let config_parameters: ConfigParameters = match string_field(parameters, "kind")? {
571        "absent" => ConfigParameters::Absent,
572        "present" => ConfigParameters::Present(String::from(string_field(parameters, "value")?)),
573        _ => return None,
574    };
575    return Some(HookDispatchPlan {
576        git_path: String::from(string_field(&object, "gitPath")?),
577        disabled_events,
578        config_parameters,
579        worktree_root: String::from(string_field(&object, "worktreeRoot")?),
580        lease: String::from(string_field(&object, "lease")?),
581        hook_lock_path: String::from(string_field(&object, "hookLockPath")?),
582        skip_hook_lock: object.get("skipHookLock")?.as_bool()?,
583        named_hooks_possible: true,
584    });
585}
586
587/// What:
588///  A fresh preparation lease for one transaction directory.
589/// Why:
590///   The incumbent's `formatPreparationLease({ directory, token })` with an unguessable token.
591///
592/// In TS you'd write (pseudocode):
593/// ```ts
594/// formatPreparationLease({ directory, token: randomUUID() });
595/// ```
596pub fn new_preparation_lease(transaction_directory: &Path) -> Result<String, HookPlanError> {
597    let directory: String = path_text(transaction_directory, "transaction directory")?;
598    let token: String = random_uuid().map_err(|error| { return HookPlanError::Random(format!("{error:?}")); })?;
599    return Ok(format_preparation_lease(directory.as_str(), token.as_str()));
600}
601
602#[cfg(test)]
603#[path = "hook_dispatch_plan_tests.rs"]
604mod tests;