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 uncommitted state never run in the main worktree, and run in a linked worktree
4//!      only while it is clean.
5//! Why:
6//!  `git stash` that changes state,
7//!  a deleting `git clean`,
8//!  a `git reset` that is not `--soft`,
9//!  `git restore`,
10//!  `git checkout` and `git switch` forms that overwrite files,
11//!  a forced `rm`,
12//!  `mv`,
13//!  or `checkout-index`,
14//!  `read-tree --reset` and an `--abort` can throw away work, and the loss leaves no record.
15//!  This file decides from the arguments alone whether the command is guarded and when,
16//!  and from the worktree kind and dirtiness the caller measured whether to reject it.
17//!  It starts no process.
18//!
19//! In TS you'd write (pseudocode):
20//! ```ts
21//! // decideLinkedWorktree(['clean', '-fd']) => { kind: 'needs-effective-target', command: 'clean' }
22//! ```
23
24/// What:
25///  `use` brings names from sibling files into this file;
26///  `super::` means "the parent
27///       module",
28///  where every sibling file of this crate is declared.
29/// Why:
30///   Whether a clean deletes and whether a reset rewrites files come from Git's own
31///       option tables of those commands.
32///
33/// In TS you'd write (pseudocode):
34/// ```ts
35/// import { cleanChangesWorktree, parseCleanRegion } from './command_clean.ts';
36/// ```
37use super::command_clean::{CleanRegion, clean_changes_worktree, parse_clean_region};
38use super::command_discard::{
39    Discard, abort_discard, checkout_discard, force_discard, read_tree_discard, restore_discard,
40    restores_index_only, switch_discard,
41};
42use super::command_options::OptionError;
43use super::command_reset::{ResetMode, ResetRegion, parse_reset_region};
44use super::command_stash::{parse_stash_region, stash_changes_state};
45use super::effective_target::EffectiveTarget;
46use super::global_arguments::command_tokens;
47use super::rule_worktree_dirty::DirtyScope;
48pub use super::rule_linked_worktree_messages::{
49    dirty_worktree_message, main_worktree_message, outside_worktree_message,
50};
51/// What:
52///  `OsString` is owned operating-system text of raw bytes.
53///  Sibling the reader might
54///       expect:
55///  `String`,
56///  which must be valid UTF-8.
57/// Why:
58///   Arguments are compared as bytes and never decoded.
59///
60/// In TS you'd write (pseudocode):
61/// ```ts
62/// // string, but byte-preserving.
63/// ```
64use std::ffi::OsString;
65
66/// What:
67///  The finding code of a linked-worktree rejection.
68///  `&str` is borrowed text baked
69///       into the program.
70/// Why:
71///   Callers identify the finding by this stable code.
72///
73/// In TS you'd write (pseudocode):
74/// ```ts
75/// const LINKED_WORKTREE_REQUIRED_CODE = 'linked-worktree-required';
76/// ```
77pub const LINKED_WORKTREE_REQUIRED_CODE: &str = "linked-worktree-required";
78
79/// What:
80///  The forms the policy guards.
81///  An `enum` is a closed set of named alternatives.
82///       `#[derive(...)]` asks the compiler to generate copying,
83///  debug printing and `==`.
84/// Why:
85///   Each form has its own rejection text, because the same subcommand serves different intents:
86///  `restore --staged` unstages while `restore <path>` reverts a file.
87///
88/// In TS you'd write (pseudocode):
89/// ```ts
90/// type GuardedCommand = 'stash' | 'clean' | 'reset-hard' | 'reset-mixed' | 'restore-staged' | 'restore-worktree'
91///   | 'checkout-paths' | 'checkout-move' | 'switch-force' | 'switch-move' | 'rm' | 'mv' | 'checkout-index'
92///   | 'read-tree' | 'abort-merge' | 'abort-rebase' | 'abort-pick' | 'abort-am';
93/// ```
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95pub enum GuardedCommand {
96    /// Every `git stash` form that changes state.
97    Stash,
98    /// A `git clean` that is not a dry run.
99    Clean,
100    /// A `git reset` with `--hard`,
101    ///  `--merge` or `--keep`.
102    ResetHard,
103    /// A `git reset` in the default or `--mixed` mode.
104    ResetMixed,
105    /// A `git restore` that names the index and not the worktree.
106    RestoreStaged,
107    /// A `git restore` that writes worktree files.
108    RestoreWorktree,
109    /// A `git checkout` with paths, `--force`, `--patch`, `--ours` or `--theirs`.
110    CheckoutPaths,
111    /// A `git checkout` that moves `HEAD`.
112    CheckoutMove,
113    /// A `git switch` with `--force` or `--discard-changes`.
114    SwitchForce,
115    /// A `git switch` that moves `HEAD`.
116    SwitchMove,
117    /// A `git rm` with `--force`.
118    Remove,
119    /// A `git mv` with `--force`.
120    Move,
121    /// A `git checkout-index` with `--force`.
122    CheckoutIndex,
123    /// A `git read-tree` with `--reset`.
124    ReadTree,
125    /// `git merge --abort`.
126    AbortMerge,
127    /// `git rebase --abort`.
128    AbortRebase,
129    /// `git cherry-pick --abort` or `git revert --abort`.
130    AbortPick,
131    /// `git am --abort`.
132    AbortAm,
133}
134
135/// What:
136///  The outcome of looking at the argument list only.
137/// Why:
138///   Most commands are not guarded and need no repository query at all.
139///
140/// In TS you'd write (pseudocode):
141/// ```ts
142/// type LinkedWorktreeDecision = { kind: 'pass' } | { kind: 'needs-effective-target'; command: GuardedCommand }
143///   | { kind: 'when-dirty'; command: GuardedCommand };
144/// ```
145#[derive(Clone, Copy, Debug, Eq, PartialEq)]
146pub enum LinkedWorktreeDecision {
147    /// Not a guarded command,
148    ///  a harmless form,
149    ///  or a region Git refuses.
150    Pass,
151    /// The form overwrites or deletes by itself:
152    ///  the caller classifies the worktree the command targets,
153    ///  then calls `resolve_linked_worktree`;
154    ///  a linked worktree is still judged by whether it is dirty.
155    NeedsEffectiveTarget(GuardedCommand),
156    /// The form moves `HEAD` and loses nothing unless local changes exist:
157    ///  it is rejected in any worktree, the main one included, only while the worktree is dirty.
158    WhenDirty(GuardedCommand),
159}
160
161/// What:
162///  The files that count as uncommitted state for a guarded command.
163/// Why:
164///   A `clean` deletes untracked files, so they count for it; every other form loses tracked changes only.
165///
166/// In TS you'd write (pseudocode):
167/// ```ts
168/// function dirtyScope(command: GuardedCommand): DirtyScope;
169/// ```
170pub fn dirty_scope(command: GuardedCommand) -> DirtyScope {
171    if command == GuardedCommand::Clean {
172        return DirtyScope::TrackedAndUntracked;
173    }
174    return DirtyScope::Tracked;
175}
176
177/// What:
178///  Turn one classifier reading into a decision.
179/// Why:
180///   Every command shares this step: an overwrite needs the worktree kind, a `HEAD` move needs the dirtiness,
181///  and a harmless region needs nothing.
182///
183/// In TS you'd write (pseudocode):
184/// ```ts
185/// function decided(command: GuardedCommand, reading: Discard): LinkedWorktreeDecision;
186/// ```
187fn decided(command: GuardedCommand, reading: Discard) -> LinkedWorktreeDecision {
188    match reading {
189        Discard::Harmless => return LinkedWorktreeDecision::Pass,
190        Discard::MovesHead => return LinkedWorktreeDecision::WhenDirty(command),
191        Discard::Overwrites => return LinkedWorktreeDecision::NeedsEffectiveTarget(command),
192    }
193}
194
195/// What:
196///  Whether a guarded subcommand word and its region can discard, and in which way.
197///  `&[u8]` borrows the subcommand word;
198///  `Result<A, B>` is "either success `A` or failure `B`".
199/// Why:
200///   Each subcommand is read with its own Git grammar, so `rm -- -f` is a path and `rm -rf` is a force.
201///  A region Git refuses is the refusal, and Git then discards nothing.
202///
203/// In TS you'd write (pseudocode):
204/// ```ts
205/// function guardedDecision(word: string, region: string[]): LinkedWorktreeDecision; // throws OptionError
206/// ```
207fn guarded_decision(
208    word: &[u8],
209    region: &[OsString],
210) -> Result<LinkedWorktreeDecision, OptionError> {
211    if word == b"stash" {
212        // A region the stash parser refuses stays guarded, as every stash form was before the read-only forms.
213        let reads_only: bool = parse_stash_region(region, &[])
214            .is_ok_and(|facts| { return !stash_changes_state(facts.subcommand); });
215        if reads_only {
216            return Ok(LinkedWorktreeDecision::Pass);
217        }
218        return Ok(LinkedWorktreeDecision::NeedsEffectiveTarget(GuardedCommand::Stash));
219    }
220    if word == b"clean" {
221        // `&[]` is an empty flag list: wrapper controls were removed before this point.
222        let facts: CleanRegion = parse_clean_region(region, &[])?;
223        if clean_changes_worktree(&facts) {
224            return Ok(LinkedWorktreeDecision::NeedsEffectiveTarget(GuardedCommand::Clean));
225        }
226        return Ok(LinkedWorktreeDecision::Pass);
227    }
228    if word == b"reset" {
229        let facts: ResetRegion = parse_reset_region(region, &[])?;
230        // Every mode but `--soft` replaces the index, and with it the staged entries.
231        return Ok(match facts.mode {
232            Some(ResetMode::Soft) => LinkedWorktreeDecision::Pass,
233            Some(ResetMode::Hard | ResetMode::Merge | ResetMode::Keep) => {
234                LinkedWorktreeDecision::NeedsEffectiveTarget(GuardedCommand::ResetHard)
235            }
236            Some(ResetMode::Mixed) | None => {
237                LinkedWorktreeDecision::NeedsEffectiveTarget(GuardedCommand::ResetMixed)
238            }
239        });
240    }
241    if word == b"checkout" {
242        return Ok(match checkout_discard(region)? {
243            Discard::Overwrites => decided(GuardedCommand::CheckoutPaths, Discard::Overwrites),
244            reading => decided(GuardedCommand::CheckoutMove, reading),
245        });
246    }
247    if word == b"switch" {
248        return Ok(match switch_discard(region)? {
249            Discard::Overwrites => decided(GuardedCommand::SwitchForce, Discard::Overwrites),
250            reading => decided(GuardedCommand::SwitchMove, reading),
251        });
252    }
253    if word == b"restore" {
254        let command: GuardedCommand = if restores_index_only(region) {
255            GuardedCommand::RestoreStaged
256        } else {
257            GuardedCommand::RestoreWorktree
258        };
259        return Ok(decided(command, restore_discard(region)));
260    }
261    if word == b"rm" {
262        return Ok(decided(GuardedCommand::Remove, force_discard(region)));
263    }
264    if word == b"mv" {
265        return Ok(decided(GuardedCommand::Move, force_discard(region)));
266    }
267    if word == b"checkout-index" {
268        return Ok(decided(GuardedCommand::CheckoutIndex, force_discard(region)));
269    }
270    if word == b"read-tree" {
271        return Ok(decided(GuardedCommand::ReadTree, read_tree_discard(region)));
272    }
273    for (aborting, command) in [
274        (&b"merge"[..], GuardedCommand::AbortMerge),
275        (b"rebase", GuardedCommand::AbortRebase),
276        (b"cherry-pick", GuardedCommand::AbortPick),
277        (b"revert", GuardedCommand::AbortPick),
278        (b"am", GuardedCommand::AbortAm),
279    ] {
280        if word == aborting {
281            return Ok(decided(command, abort_discard(region)));
282        }
283    }
284    return Ok(LinkedWorktreeDecision::Pass);
285}
286
287/// What:
288///  Decide from the argument list.
289///  `&[OsString]` borrows the arguments,
290///  already free
291///       of wrapper controls.
292/// Why:
293///   A command that is not guarded,
294///  or that Git itself refuses,
295///  needs no measurement.
296///
297/// In TS you'd write (pseudocode):
298/// ```ts
299/// function decideLinkedWorktree(args: string[]): LinkedWorktreeDecision;
300/// ```
301pub fn decide_linked_worktree(arguments: &[OsString]) -> LinkedWorktreeDecision {
302    // `let Some((a, b)) = ... else { ... };` unpacks the command word and the tokens after
303    // it, or returns when Git runs no subcommand.
304    let Some((word, region)) = command_tokens(arguments) else {
305        return LinkedWorktreeDecision::Pass;
306    };
307    // `.as_encoded_bytes()` lends the raw bytes of the subcommand word.
308    return guarded_decision(word.as_encoded_bytes(), region).unwrap_or(LinkedWorktreeDecision::Pass);
309}
310
311/// What:
312///  Decide from the measured worktree kind:
313///  the rejection text,
314///  or nothing to let the command through for now.
315/// Why:
316///   The main worktree and a place without a worktree reject at once;
317///  a linked worktree and an allowlisted tool cache pass this step,
318///  and a linked worktree is then judged by its dirtiness.
319///
320/// In TS you'd write (pseudocode):
321/// ```ts
322/// function resolveLinkedWorktree(command: GuardedCommand, target: EffectiveTarget): string | undefined;
323/// ```
324pub fn resolve_linked_worktree(command: GuardedCommand, target: EffectiveTarget) -> Option<String> {
325    match target {
326        // `Some(x)` is the "present" case of `Option`; `None` is the "absent" case.
327        EffectiveTarget::OutsideWorktree => return Some(outside_worktree_message(command)),
328        EffectiveTarget::MainWorktree => return Some(main_worktree_message(command)),
329        EffectiveTarget::LinkedWorktree | EffectiveTarget::Allowlisted => return None,
330    }
331}
332
333/// Guarded forms,
334///  harmless forms and every rejection text.
335#[cfg(test)]
336#[path = "rule_linked_worktree_tests.rs"]
337mod tests;