Skip to main content

git_policy_cli/command/
command_discard.rs

1//! What:
2//!  Which regions of `git checkout`,
3//! `git switch`,
4//! `git restore`,
5//! `git rm`,
6//! `git mv`,
7//! `git checkout-index`,
8//!  `git read-tree` and the `--abort` forms of `merge`,
9//! `rebase`,
10//! `cherry-pick`,
11//! `revert` and `am` can overwrite or
12//!  delete uncommitted state.
13//! Why:
14//!  The linked-worktree policy confines such commands:
15//! it rejects them in the main worktree and,
16//! once the worktree
17//!  holds uncommitted state,
18//! in a linked worktree too.
19//! Each region is read as Git 2.56.0 reads it,
20//! so a spelling
21//!  Git accepts as `--force` is not missed and a value that looks like a flag is not mistaken for one.
22//! Gotcha:
23//!  Where Git itself refuses the region the answer is the refusal;
24//! the caller lets Git say so.
25//!
26//! In TS you'd write (pseudocode):
27//! ```ts
28//! // checkoutDiscard(['--', 'a.txt']) === 'overwrites'; checkoutDiscard(['topic']) === 'moves-head'
29//! ```
30
31use crate::command_checkout_table::{
32    CHECKOUT_TABLE, DISCARD_CHANGES, FORCE, PATCH, PATHSPEC_FROM_FILE, STAGE, SWITCH_TABLE,
33};
34use crate::command_options::{Boundary, DEFAULT_MODE, OptionError, ParsedOptions, parse_options};
35use crate::command_options_query::{is_enabled, is_stated, positional_tokens};
36use std::ffi::OsString;
37
38/// What:
39///  What a region can do to uncommitted state.
40///  An `enum` is a closed set of named alternatives.
41/// Why:
42///   A region that overwrites is judged by the worktree kind;
43/// one that only moves `HEAD` loses nothing unless
44///  local changes exist,
45/// so it is judged by them alone.
46///
47/// In TS you'd write (pseudocode):
48/// ```ts
49/// type Discard = 'harmless' | 'moves-head' | 'overwrites';
50/// ```
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52pub enum Discard {
53    /// Nothing is overwritten or deleted.
54    Harmless,
55    /// `HEAD` moves;
56    /// Git refuses to overwrite a local change,
57    /// but a `--force` or a path form would.
58    MovesHead,
59    /// The region overwrites or deletes worktree files or staged entries by itself.
60    Overwrites,
61}
62
63/// What:
64///  Read a `git checkout` region.
65///  `Result<A, B>` is "either success `A` or failure `B`".
66/// Why:
67///   `--force`,
68/// `--patch`,
69/// `--ours`,
70/// `--theirs`,
71/// a path list (`--`,
72/// `--pathspec-from-file`,
73/// or a tree-ish followed
74///  by paths) overwrite files;
75/// a lone name moves `HEAD` or,
76/// when it is a file,
77/// overwrites it,
78/// so it counts as a
79///  `HEAD` move that is rejected once local changes exist.
80///
81/// In TS you'd write (pseudocode):
82/// ```ts
83/// function checkoutDiscard(region: string[]): Discard; // throws OptionError
84/// ```
85pub fn checkout_discard(region: &[OsString]) -> Result<Discard, OptionError> {
86    let parsed: ParsedOptions = parse_options(region, CHECKOUT_TABLE, DEFAULT_MODE, &[])?;
87    let names: usize = positional_tokens(&parsed, region.len()).len();
88    if is_enabled(&parsed, FORCE)
89        || is_enabled(&parsed, PATCH)
90        || is_stated(&parsed, STAGE)
91        || is_stated(&parsed, PATHSPEC_FROM_FILE)
92        || matches!(parsed.boundary, Boundary::DashDash(_))
93        || names > 1
94    {
95        return Ok(Discard::Overwrites);
96    }
97    if names == 1 {
98        return Ok(Discard::MovesHead);
99    }
100    return Ok(Discard::Harmless);
101}
102
103/// What:
104///  Read a `git switch` region.
105/// Why:
106///   `--force` and `--discard-changes` throw local changes away;
107/// any named target moves `HEAD`.
108///
109/// In TS you'd write (pseudocode):
110/// ```ts
111/// function switchDiscard(region: string[]): Discard; // throws OptionError
112/// ```
113pub fn switch_discard(region: &[OsString]) -> Result<Discard, OptionError> {
114    let parsed: ParsedOptions = parse_options(region, SWITCH_TABLE, DEFAULT_MODE, &[])?;
115    if is_enabled(&parsed, FORCE) || is_enabled(&parsed, DISCARD_CHANGES) {
116        return Ok(Discard::Overwrites);
117    }
118    if !positional_tokens(&parsed, region.len()).is_empty() {
119        return Ok(Discard::MovesHead);
120    }
121    return Ok(Discard::Harmless);
122}
123
124/// The tokens a flag scan reads:
125/// everything before the first `--`.
126///  A value after `--` is a path,
127/// never an option.
128fn options_of(region: &[OsString]) -> Vec<&[u8]> {
129    let mut tokens: Vec<&[u8]> = Vec::<&[u8]>::new();
130    for token in region {
131        let bytes: &[u8] = token.as_encoded_bytes();
132        if bytes == b"--" {
133            break;
134        }
135        tokens.push(bytes);
136    }
137    return tokens;
138}
139
140/// Whether a byte separates an option's name from its inline value.
141fn is_equals(byte: &u8) -> bool {
142    return *byte == b'=';
143}
144
145/// What:
146///  Whether a token spells the long option `full` (written with its leading dashes) by any abbreviation of at
147///  least `minimum` characters,
148/// with or without an `=value`.
149/// Why:
150///   Git accepts every unambiguous prefix of a long option;
151/// `minimum` is the shortest prefix that no sibling
152///  option of the command shares.
153///
154/// In TS you'd write (pseudocode):
155/// ```ts
156/// function spellsLong(token: string, full: string, minimum: number): boolean;
157/// ```
158fn spells_long(token: &[u8], full: &str, minimum: usize) -> bool {
159    if !token.starts_with(b"--") {
160        return false;
161    }
162    let name: &[u8] = token.split(is_equals).next().unwrap_or_default();
163    return name.len() >= minimum && full.as_bytes().starts_with(name);
164}
165
166/// What:
167///  Whether a token is a cluster of short flags that contains `letter`.
168/// Why:
169///   `-rf` and `-fr` are `-r -f` in every command read by a flag scan,
170/// none of whose short flags takes a value.
171///
172/// In TS you'd write (pseudocode):
173/// ```ts
174/// function hasShort(token: string, letter: string): boolean;
175/// ```
176fn has_short(token: &[u8], letter: u8) -> bool {
177    return token.len() > 1 && token[0] == b'-' && token[1] != b'-' && token[1..].contains(&letter);
178}
179
180/// What:
181///  Read a `git restore` region.
182/// Why:
183///   Every form writes worktree files,
184/// the index,
185/// or both;
186/// only a request for usage text writes nothing.
187///
188/// In TS you'd write (pseudocode):
189/// ```ts
190/// function restoreDiscard(region: string[]): Discard;
191/// ```
192pub fn restore_discard(region: &[OsString]) -> Discard {
193    for token in options_of(region) {
194        if token == b"-h" || spells_long(token, "--help", 4) {
195            return Discard::Harmless;
196        }
197    }
198    return Discard::Overwrites;
199}
200
201/// What:
202///  Whether a `git restore` region touches the index only.
203/// Why:
204///   `--staged` alone unstages and leaves the files,
205/// so the guidance differs from a restore of file content;
206///  `--worktree` beside it brings the files back in.
207/// `--st` and `--w` are the shortest unambiguous prefixes.
208/// A token that
209///  is the value of `--source`,
210/// `-s` or `--conflict` is that value and never a flag (`restore --source -W --staged` names
211///  a revision `-W` and restores the index only).
212///
213/// In TS you'd write (pseudocode):
214/// ```ts
215/// function restoresIndexOnly(region: string[]): boolean;
216/// ```
217pub fn restores_index_only(region: &[OsString]) -> bool {
218    let tokens: Vec<&[u8]> = options_of(region);
219    let mut staged: bool = false;
220    let mut worktree: bool = false;
221    let mut at: usize = 0;
222    while at < tokens.len() {
223        let token: &[u8] = tokens[at];
224        at += 1;
225        if token.starts_with(b"--") {
226            if spells_long(token, "--staged", 4) {
227                staged = true;
228            }
229            if spells_long(token, "--worktree", 3) {
230                worktree = true;
231            }
232            // `--source <tree>` and `--conflict <style>` take the next token as their value, whatever it looks like.
233            if !token.contains(&b'=') && (spells_long(token, "--source", 4) || spells_long(token, "--conflict", 3)) {
234                at += 1;
235            }
236            continue;
237        }
238        if token.len() > 1 && token[0] == b'-' {
239            // A short cluster is read letter by letter; `s` takes the rest of the token as its value, or the next token
240            // when it ends the cluster, so nothing after it in the same token is a flag.
241            for (position, letter) in token[1..].iter().enumerate() {
242                match *letter {
243                    b'S' => staged = true,
244                    b'W' => worktree = true,
245                    b's' => {
246                        if position + 2 == token.len() {
247                            at += 1;
248                        }
249                        break;
250                    }
251                    _ => {}
252                }
253            }
254        }
255    }
256    return staged && !worktree;
257}
258
259/// What:
260///  Read a `git rm`,
261/// `git mv` or `git checkout-index` region.
262/// Why:
263///   Each refuses to overwrite or delete a modified file unless forced;
264/// `-f` and `--force` are the only way
265///  these commands discard.
266/// `--fo` is the shortest prefix of `--force` in all three.
267///
268/// In TS you'd write (pseudocode):
269/// ```ts
270/// function forceDiscard(region: string[]): Discard;
271/// ```
272pub fn force_discard(region: &[OsString]) -> Discard {
273    for token in options_of(region) {
274        if has_short(token, b'f') || spells_long(token, "--force", 4) {
275            return Discard::Overwrites;
276        }
277    }
278    return Discard::Harmless;
279}
280
281/// What:
282///  Read a `git read-tree` region.
283/// Why:
284///   `--reset` replaces the index and,
285/// with `-u`,
286/// the worktree,
287/// dropping every unmerged and local entry.
288///   `--res` is the shortest prefix that `--recurse-submodules` does not share.
289///
290/// In TS you'd write (pseudocode):
291/// ```ts
292/// function readTreeDiscard(region: string[]): Discard;
293/// ```
294pub fn read_tree_discard(region: &[OsString]) -> Discard {
295    for token in options_of(region) {
296        if spells_long(token, "--reset", 5) {
297            return Discard::Overwrites;
298        }
299    }
300    return Discard::Harmless;
301}
302
303/// What:
304///  Read a region of `merge`,
305/// `rebase`,
306/// `cherry-pick`,
307/// `revert` or `am` for `--abort`.
308/// Why:
309///   Aborting restores the pre-operation index and worktree,
310/// discarding everything done since,
311/// including the
312///  hand edits that resolved a conflict.
313/// `--ab` is the shortest prefix no sibling option shares.
314///
315/// In TS you'd write (pseudocode):
316/// ```ts
317/// function abortDiscard(region: string[]): Discard;
318/// ```
319pub fn abort_discard(region: &[OsString]) -> Discard {
320    for token in options_of(region) {
321        if spells_long(token, "--abort", 4) {
322            return Discard::Overwrites;
323        }
324    }
325    return Discard::Harmless;
326}
327
328/// Each reading with Git's own refusals,
329/// abbreviations,
330/// clusters and values that look like flags.
331#[cfg(test)]
332#[path = "command_discard_tests.rs"]
333mod tests;