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