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;