Skip to main content

git_policy_cli/
rule_require_root.rs

1//! What:
2//!  Pure decision of the require-root policy:
3//!  commands run from the repository root.
4//! Why:
5//!  Running Git from a subdirectory makes pathspecs mean something else.
6//!  The policy
7//!      rejects that,
8//!  except for commands that need no repository.
9//!  This file decides from
10//!      the argument list alone which commands are exempt,
11//!  and from two paths the caller
12//!      measured whether the directory is the root.
13//!  It reads no file and starts no process.
14//!
15//! In TS you'd write (pseudocode):
16//! ```ts
17//! // decideRequireRoot(args): 'exempt' | 'needs-root'
18//! // resolveRequireRoot({ effectiveDirectory, repositoryRoot }): 'pass' | Violation
19//! ```
20
21/// What:
22///  Bring the `git config` facts and the global-option boundary into this file.
23/// Why:
24///   The exemptions are keyed on the subcommand word and on `git config`'s file scope.
25///
26/// In TS you'd write (pseudocode):
27/// ```ts
28/// import { parseConfigRegion } from './command_config.ts';
29/// ```
30use super::command_config::{ConfigRegion, parse_config_region};
31use super::command_options::OptionError;
32use super::global_arguments::{GlobalLayout, GlobalOutcome, global_layout};
33/// `OsString` is owned operating-system text of raw bytes (sibling `String` must be UTF-8).
34use std::ffi::OsString;
35/// `PathBuf` is an owned file path of raw bytes (sibling `String` must be UTF-8).
36use std::path::PathBuf;
37
38/// What:
39///  The finding code of a require-root rejection.
40///  `&str` is borrowed text baked into
41///       the program.
42/// Why:
43///   Callers and transcripts identify the finding by this stable code.
44///
45/// In TS you'd write (pseudocode):
46/// ```ts
47/// export const NOT_AT_ROOT_CODE = 'not-at-root';
48/// ```
49pub const NOT_AT_ROOT_CODE: &str = "not-at-root";
50
51/// What:
52///  Subcommands that create a repository or need none.
53///  `&[&[u8]]` is a borrowed list
54///       of byte spellings.
55/// Why:
56///   `init` and `clone` run before a root exists;
57///  `version` and `help` read none.
58///
59/// In TS you'd write (pseudocode):
60/// ```ts
61/// const EXEMPT_SUBCOMMANDS = new Set(['init', 'clone', 'version', 'help']);
62/// ```
63const EXEMPT_SUBCOMMANDS: &[&[u8]] = &[b"init", b"clone", b"version", b"help"];
64
65/// What:
66///  Why a command is exempt.
67///  An `enum` is a closed set of named alternatives.
68/// Why:
69///   The caller logs the reason;
70///  the decision itself is the same for all three.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// type RequireRootExemption = 'no-command' | 'subcommand' | 'config-scope';
75/// ```
76#[derive(Clone, Copy, Debug, Eq, PartialEq)]
77pub enum RequireRootExemption {
78    /// Git runs no subcommand:
79    ///  none was written,
80    ///  or Git prints help,
81    ///  a version or its own
82    /// error for the global options.
83    NoCommand,
84    /// The subcommand is `init`,
85    ///  `clone`,
86    ///  `version` or `help`.
87    Subcommand,
88    /// `git config` on the per-user or system file,
89    ///  or listing with `-l`/`--list`.
90    ConfigScope,
91}
92
93/// What:
94///  The outcome of looking at the argument list only.
95/// Why:
96///   Most commands need a measurement the caller makes;
97///  exempt ones never do.
98///
99/// In TS you'd write (pseudocode):
100/// ```ts
101/// type RequireRootDecision = { kind: 'exempt'; reason: RequireRootExemption } | { kind: 'needs-repository-root' };
102/// ```
103#[derive(Clone, Copy, Debug, Eq, PartialEq)]
104pub enum RequireRootDecision {
105    /// Forward unchanged without measuring anything.
106    Exempt(RequireRootExemption),
107    /// The caller must measure the effective directory and the repository root,
108    ///  then call
109    /// `resolve_require_root`.
110    NeedsRepositoryRoot,
111}
112
113/// What:
114///  The two measurements the decision needs.
115///  `Option<PathBuf>` is "a path or nothing".
116/// Why:
117///   Finding them needs the filesystem and Git's `-C` handling,
118///  which stay outside
119///       this file.
120///  `repository_root` is nothing when the directory is in no repository.
121///
122/// In TS you'd write (pseudocode):
123/// ```ts
124/// type RequireRootFacts = { effectiveDirectory: string; repositoryRoot: string | undefined };
125/// ```
126#[derive(Clone, Debug, Eq, PartialEq)]
127pub struct RequireRootFacts {
128    /// The directory the subcommand runs in,
129    ///  after every global `-C <path>`.
130    pub effective_directory: PathBuf,
131    /// The root of the repository that directory is in,
132    ///  if any.
133    pub repository_root: Option<PathBuf>,
134}
135
136/// What:
137///  A require-root rejection with its user-facing text.
138///  `String` is owned text.
139/// Why:
140///   The text names both directories and both ways forward.
141///
142/// In TS you'd write (pseudocode):
143/// ```ts
144/// type RequireRootViolation = { code: 'not-at-root'; message: string };
145/// ```
146#[derive(Clone, Debug, Eq, PartialEq)]
147pub struct RequireRootViolation {
148    /// Complete diagnostic for the person who ran the command.
149    pub message: String,
150}
151
152/// What:
153///  The outcome once the two paths are known.
154/// Why:
155///   Either the command is forwarded unchanged or it is rejected with a message.
156///
157/// In TS you'd write (pseudocode):
158/// ```ts
159/// type RequireRootVerdict = { kind: 'pass' } | { kind: 'not-at-root'; violation: RequireRootViolation };
160/// ```
161#[derive(Clone, Debug, Eq, PartialEq)]
162pub enum RequireRootVerdict {
163    /// Outside any repository,
164    ///  or at the root:
165    ///  forward unchanged.
166    Pass,
167    /// Inside a repository below its root.
168    NotAtRoot(RequireRootViolation),
169}
170
171/// What:
172///  Whether a `git config` region works on a file outside the repository or lists.
173///       `&[OsString]` borrows the tokens after `config`.
174/// Why:
175///   Those forms need no repository root.
176///  A region Git itself refuses earns no
177///       exemption,
178///  so the command is treated like any other.
179/// Gotcha:
180///  Divergence from the incumbent,
181///  which exempted on these spellings anywhere in the
182///         argument list (`git config user.name --global` stores a value in the
183///         repository) and missed `git config list`,
184///  the 2.56.0 spelling of `--list`.
185///
186/// In TS you'd write (pseudocode):
187/// ```ts
188/// const exempt = region.global || region.system || region.lists;
189/// ```
190fn config_is_exempt(region: &[OsString], wrapper_flags: &[&[u8]]) -> bool {
191    let parsed: Result<ConfigRegion, OptionError> = parse_config_region(region, wrapper_flags);
192    // `if let Ok(found) = ...` runs only for a region Git accepts.
193    if let Ok(found) = parsed {
194        return found.global || found.system || found.lists;
195    }
196    return false;
197}
198
199/// What:
200///  Decide from the argument list whether the command is exempt.
201///  `wrapper_flags` are
202///       the caller's wrapper-only spellings,
203///  which Git must not see as unknown options.
204/// Why:
205///   An exempt command is forwarded without touching the filesystem.
206///
207/// In TS you'd write (pseudocode):
208/// ```ts
209/// function decideRequireRoot(args: string[], wrapperFlags: string[]): RequireRootDecision;
210/// ```
211pub fn decide_require_root(arguments: &[OsString], wrapper_flags: &[&[u8]]) -> RequireRootDecision {
212    let layout: GlobalLayout = global_layout(arguments);
213    if layout.outcome != GlobalOutcome::Command {
214        return RequireRootDecision::Exempt(RequireRootExemption::NoCommand);
215    }
216    // `.as_encoded_bytes()` borrows the raw bytes of the subcommand word.
217    let word: &[u8] = arguments[layout.prefix_len].as_encoded_bytes();
218    if EXEMPT_SUBCOMMANDS.contains(&word) {
219        return RequireRootDecision::Exempt(RequireRootExemption::Subcommand);
220    }
221    // `&arguments[n..]` borrows the tokens after the subcommand word.
222    if word == b"config" && config_is_exempt(&arguments[layout.prefix_len + 1..], wrapper_flags) {
223        return RequireRootDecision::Exempt(RequireRootExemption::ConfigScope);
224    }
225    return RequireRootDecision::NeedsRepositoryRoot;
226}
227
228/// What:
229///  Decide from the measured paths whether to forward or reject.
230///  `&RequireRootFacts`
231///       borrows the measurements read-only.
232/// Why:
233///   Outside a repository Git reports its own error if the subcommand needs one.
234/// Gotcha:
235///  Paths compare by components,
236///  so `/repo/` and `/repo` are the same directory;
237///         symbolic links are the caller's concern.
238///
239/// In TS you'd write (pseudocode):
240/// ```ts
241/// function resolveRequireRoot(facts: RequireRootFacts): RequireRootVerdict;
242/// ```
243pub fn resolve_require_root(facts: &RequireRootFacts) -> RequireRootVerdict {
244    // `match &facts.repository_root` borrows the optional path without moving it.
245    let root: &PathBuf = match &facts.repository_root {
246        Some(found) => found,
247        None => return RequireRootVerdict::Pass,
248    };
249    if *root == facts.effective_directory {
250        return RequireRootVerdict::Pass;
251    }
252    // `.display()` shows a path as text, replacing bytes that are not UTF-8.
253    let message: String = format!(
254        "cli-git: not at the root of the git repository. \
255         Repo root is {root} but effective cwd is {directory}. \
256         Tip: cd to {root} or pass -C {root} before the subcommand.",
257        root = root.display(),
258        directory = facts.effective_directory.display(),
259    );
260    return RequireRootVerdict::NotAtRoot(RequireRootViolation { message });
261}
262
263/// Exemptions and verdicts,
264///  including every case of the incumbent's unit tests.
265#[cfg(test)]
266#[path = "rule_require_root_tests.rs"]
267mod tests;