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;