Skip to main content

git_policy_cli/
command_discard.rs

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