git_policy_cli/rule_linked_worktree.rs
1//! What:
2//! Pure decision of the linked-worktree-only policy:
3//! commands that can discard
4//! worktree state run only in a linked worktree.
5//! Why:
6//! `git stash`,
7//! a deleting `git clean` and a `git reset` that rewrites files can throw
8//! away work in the primary checkout.
9//! The policy confines them to linked worktrees.
10//! This file decides from the arguments alone whether the command is guarded,
11//! and
12//! from the worktree kind the caller measured whether to reject it.
13//! It starts no
14//! process.
15//!
16//! In TS you'd write (pseudocode):
17//! ```ts
18//! // decideLinkedWorktree(['clean', '-fd']) => { kind: 'needs-effective-target', command: 'clean' }
19//! ```
20
21/// What:
22/// `use` brings names from sibling files into this file;
23/// `super::` means "the parent
24/// module",
25/// where every sibling file of this crate is declared.
26/// Why:
27/// Whether a clean deletes and whether a reset rewrites files come from Git's own
28/// option tables of those commands.
29///
30/// In TS you'd write (pseudocode):
31/// ```ts
32/// import { cleanChangesWorktree, parseCleanRegion } from './command_clean.ts';
33/// ```
34use super::command_clean::{CleanRegion, clean_changes_worktree, parse_clean_region};
35use super::command_options::OptionError;
36use super::command_reset::{ResetRegion, parse_reset_region, reset_changes_worktree};
37use super::effective_target::EffectiveTarget;
38use super::global_arguments::command_tokens;
39/// What:
40/// `OsString` is owned operating-system text of raw bytes.
41/// Sibling the reader might
42/// expect:
43/// `String`,
44/// which must be valid UTF-8.
45/// Why:
46/// Arguments are compared as bytes and never decoded.
47///
48/// In TS you'd write (pseudocode):
49/// ```ts
50/// // string, but byte-preserving.
51/// ```
52use std::ffi::OsString;
53
54/// What:
55/// The finding code of a linked-worktree rejection.
56/// `&str` is borrowed text baked
57/// into the program.
58/// Why:
59/// Callers identify the finding by this stable code.
60///
61/// In TS you'd write (pseudocode):
62/// ```ts
63/// const LINKED_WORKTREE_REQUIRED_CODE = 'linked-worktree-required';
64/// ```
65pub const LINKED_WORKTREE_REQUIRED_CODE: &str = "linked-worktree-required";
66
67/// What:
68/// The commands the policy guards.
69/// An `enum` is a closed set of named alternatives.
70/// `#[derive(...)]` asks the compiler to generate copying,
71/// debug printing and `==`.
72/// Why:
73/// Each has its own rejection text.
74///
75/// In TS you'd write (pseudocode):
76/// ```ts
77/// type GuardedCommand = 'stash' | 'clean' | 'reset';
78/// ```
79#[derive(Clone, Copy, Debug, Eq, PartialEq)]
80pub enum GuardedCommand {
81 /// Every `git stash` form.
82 Stash,
83 /// A `git clean` that is not a dry run.
84 Clean,
85 /// A `git reset` with `--hard`,
86 /// `--merge` or `--keep`.
87 Reset,
88}
89
90/// What:
91/// The outcome of looking at the argument list only.
92/// Why:
93/// Most commands are not guarded and need no repository query at all.
94///
95/// In TS you'd write (pseudocode):
96/// ```ts
97/// type LinkedWorktreeDecision = { kind: 'pass' } | { kind: 'needs-effective-target'; command: GuardedCommand };
98/// ```
99#[derive(Clone, Copy, Debug, Eq, PartialEq)]
100pub enum LinkedWorktreeDecision {
101 /// Not a guarded command,
102 /// a harmless form,
103 /// or a region Git refuses.
104 Pass,
105 /// The caller classifies the worktree the command targets,
106 /// then calls
107 /// `resolve_linked_worktree`.
108 NeedsEffectiveTarget(GuardedCommand),
109}
110
111/// What:
112/// Whether a guarded region can change worktree files.
113/// `&[u8]` borrows the
114/// subcommand word;
115/// `Option<T>` is "a value or nothing":
116/// nothing for another command.
117/// `Result<A, B>` is "either success `A` or failure `B`".
118/// Why:
119/// `stash` is guarded in every form,
120/// as the incumbent guards it;
121/// `clean` and `reset`
122/// only in the forms Git lets touch files.
123/// Gotcha:
124/// A trailing `?` returns Git's refusal to our caller,
125/// or unwraps the facts.
126///
127/// In TS you'd write (pseudocode):
128/// ```ts
129/// function guardedCommand(word: string, region: string[]): GuardedCommand | undefined; // throws OptionError
130/// ```
131fn guarded_command(
132 word: &[u8],
133 region: &[OsString],
134) -> Result<Option<GuardedCommand>, OptionError> {
135 if word == b"stash" {
136 // `Ok(Some(x))` is success carrying a present value.
137 return Ok(Some(GuardedCommand::Stash));
138 }
139 if word == b"clean" {
140 // `&[]` is an empty flag list: wrapper controls were removed before this point.
141 let facts: CleanRegion = parse_clean_region(region, &[])?;
142 // `&facts` lends the facts read-only.
143 if clean_changes_worktree(&facts) {
144 return Ok(Some(GuardedCommand::Clean));
145 }
146 }
147 if word == b"reset" {
148 let facts: ResetRegion = parse_reset_region(region, &[])?;
149 if reset_changes_worktree(&facts) {
150 return Ok(Some(GuardedCommand::Reset));
151 }
152 }
153 // `Ok(None)` is success carrying "not guarded".
154 return Ok(None);
155}
156
157/// What:
158/// Decide from the argument list.
159/// `&[OsString]` borrows the arguments,
160/// already free
161/// of wrapper controls.
162/// Why:
163/// A command that is not guarded,
164/// or that Git itself refuses,
165/// needs no measurement.
166///
167/// In TS you'd write (pseudocode):
168/// ```ts
169/// function decideLinkedWorktree(args: string[]): LinkedWorktreeDecision;
170/// ```
171pub fn decide_linked_worktree(arguments: &[OsString]) -> LinkedWorktreeDecision {
172 // `let Some((a, b)) = ... else { ... };` unpacks the command word and the tokens after
173 // it, or returns when Git runs no subcommand.
174 let Some((word, region)) = command_tokens(arguments) else {
175 return LinkedWorktreeDecision::Pass;
176 };
177 // `.as_encoded_bytes()` lends the raw bytes of the subcommand word.
178 let guarded: Result<Option<GuardedCommand>, OptionError> =
179 guarded_command(word.as_encoded_bytes(), region);
180 // `if let Ok(Some(command)) = ...` runs only for an accepted, guarded region.
181 if let Ok(Some(command)) = guarded {
182 return LinkedWorktreeDecision::NeedsEffectiveTarget(command);
183 }
184 return LinkedWorktreeDecision::Pass;
185}
186
187/// What:
188/// The rejection text for a command run where there is no worktree.
189/// `&'static str`
190/// is text baked into the program.
191/// Why:
192/// The text says what the command could destroy and how to run it safely.
193///
194/// In TS you'd write (pseudocode):
195/// ```ts
196/// function outsideWorktreeMessage({ command }): string;
197/// ```
198pub fn outside_worktree_message(command: GuardedCommand) -> &'static str {
199 // `match` picks one arm per variant; the compiler refuses a forgotten variant.
200 match command {
201 GuardedCommand::Stash => {
202 return "cli-git: git stash requires the effective working directory to be inside a linked git worktree. \
203 Refusing to run from outside a worktree because git stash can revert filesystem state outside what the caller expected. \
204 cd to a linked worktree root or pass -C <linked-worktree-root> before stash.";
205 }
206 GuardedCommand::Clean => {
207 return "cli-git: state-changing git clean requires the effective working directory to be inside a linked git worktree. \
208 Refusing to run from outside a worktree because git clean can delete filesystem state outside what the caller expected. \
209 cd to a linked worktree root or pass -C <linked-worktree-root> before clean, or use --dry-run to inspect.";
210 }
211 GuardedCommand::Reset => {
212 return "cli-git: destructive git reset modes require the effective working directory to be inside a linked git worktree. \
213 Refusing to run from outside a worktree because git reset --hard, --merge, and --keep can rewrite tracked files outside what the caller expected. \
214 cd to a linked worktree root or pass -C <linked-worktree-root> before reset.";
215 }
216 }
217}
218
219/// What:
220/// The rejection text for a command run in the main worktree.
221/// Why:
222/// The text says what the command could destroy and where to run it instead.
223///
224/// In TS you'd write (pseudocode):
225/// ```ts
226/// function mainWorktreeMessage({ command }): string;
227/// ```
228pub fn main_worktree_message(command: GuardedCommand) -> &'static str {
229 match command {
230 GuardedCommand::Stash => {
231 return "cli-git: git stash is rejected in the main git worktree. \
232 Refusing to run because git stash can revert primary checkout filesystem state outside what the caller expected. \
233 Use a linked worktree for stash operations.";
234 }
235 GuardedCommand::Clean => {
236 return "cli-git: state-changing git clean is rejected in the main git worktree. \
237 Refusing to run because git clean can delete primary checkout filesystem state outside what the caller expected. \
238 Use a linked worktree for state-changing clean operations, or use --dry-run to inspect.";
239 }
240 GuardedCommand::Reset => {
241 return "cli-git: destructive git reset modes are rejected in the main git worktree. \
242 Refusing to run because git reset --hard, --merge, and --keep can rewrite primary checkout files outside what the caller expected. \
243 Use a linked worktree for destructive reset operations.";
244 }
245 }
246}
247
248/// What:
249/// Decide from the measured worktree kind:
250/// the rejection text,
251/// or nothing to let the
252/// command through.
253/// Why:
254/// A linked worktree and an allowlisted tool cache are the two places where these
255/// commands may run.
256///
257/// In TS you'd write (pseudocode):
258/// ```ts
259/// function resolveLinkedWorktree(command: GuardedCommand, target: EffectiveTarget): string | undefined;
260/// ```
261pub fn resolve_linked_worktree(
262 command: GuardedCommand,
263 target: EffectiveTarget,
264) -> Option<&'static str> {
265 match target {
266 // `Some(x)` is the "present" case of `Option`; `None` is the "absent" case.
267 EffectiveTarget::OutsideWorktree => return Some(outside_worktree_message(command)),
268 EffectiveTarget::MainWorktree => return Some(main_worktree_message(command)),
269 EffectiveTarget::LinkedWorktree | EffectiveTarget::Allowlisted => return None,
270 }
271}
272
273/// Guarded forms,
274/// harmless forms and every rejection text.
275#[cfg(test)]
276#[path = "rule_linked_worktree_tests.rs"]
277mod tests;