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;