Skip to main content

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;