Skip to main content

git_policy_cli/
command_conflict_side.rs

1//! What:
2//!  Which `git checkout` and `git restore` command lines take one side of a conflicted file, and the paths they name.
3//! Why:
4//!  During a merge, a rebase, a cherry-pick or a revert the usual way to settle a conflict is
5//!  `git checkout --ours|--theirs -- <path>`, `git checkout <rev> -- <path>` or the `git restore` forms of the same.
6//!  The linked-worktree policy rejects them as discarding forms; the conflict-side guard lets them through for conflicted
7//!  paths after saving the file. Only a command line whose every token is understood qualifies: a form with `--force`,
8//!  `--patch`, `--pathspec-from-file`, a branch option, an abbreviated `git restore` option, an index-only restore, or no
9//!  `--` before the paths stays rejected, because what it would discard is not known from the arguments alone.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! conflictSideRequest('checkout', ['--theirs', '--', 'a.rs']) // { paths: ['a.rs'] }
14//! conflictSideRequest('checkout', ['-f', '--', 'a.rs'])       // undefined
15//! ```
16
17use super::command_checkout_table::{
18    CHECKOUT_TABLE, DETACH, FORCE, NEW_BRANCH, NEW_BRANCH_FORCE, ORPHAN, PATCH, PATHSPEC_FROM_FILE,
19};
20use super::command_options::{Boundary, DEFAULT_MODE, ParsedOptions, parse_options};
21use super::command_options_query::is_stated;
22use std::ffi::OsString;
23
24/// The paths a qualifying command line names, as typed.
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct SideRequest {
27    /// The pathspec tokens after `--`, in order, none of them resolved or checked yet.
28    pub paths: Vec<OsString>,
29}
30
31/// What:
32///  Read a `git checkout` region for the conflict-side forms.
33/// Why:
34///   The form is `checkout [options] [<tree-ish>] -- <path>...`, where the options are the ones that choose a side or
35///  how a file is written. Any option that creates or moves a branch, forces, patches or reads paths from a file
36///  disqualifies the line.
37///
38/// In TS you'd write (pseudocode):
39/// ```ts
40/// function checkoutSideRequest(region: string[]): SideRequest | undefined;
41/// ```
42fn checkout_request(region: &[OsString]) -> Option<SideRequest> {
43    let parsed: ParsedOptions = parse_options(region, CHECKOUT_TABLE, DEFAULT_MODE, &[]).ok()?;
44    let Boundary::DashDash(at) = parsed.boundary else {
45        return None;
46    };
47    for forbidden in [NEW_BRANCH, NEW_BRANCH_FORCE, ORPHAN, DETACH, PATCH, FORCE, PATHSPEC_FROM_FILE] {
48        if is_stated(&parsed, forbidden) {
49            return None;
50        }
51    }
52    if parsed.leading.len() > 1 {
53        return None;
54    }
55    let paths: Vec<OsString> = region[at + 1..].to_vec();
56    if paths.is_empty() {
57        return None;
58    }
59    return Some(SideRequest { paths });
60}
61
62/// The spellings of `git restore` options that take no value and keep the command a worktree restore of named paths.
63const RESTORE_PLAIN: [&[u8]; 14] = [
64    b"--ours",
65    b"--theirs",
66    b"-m",
67    b"--merge",
68    b"-W",
69    b"--worktree",
70    b"-q",
71    b"--quiet",
72    b"--progress",
73    b"--no-progress",
74    b"--overlay",
75    b"--no-overlay",
76    b"--ignore-unmerged",
77    b"--ignore-skip-worktree-bits",
78];
79
80/// The spellings of `git restore` options that take a value, as `--name=value` or as the next token.
81const RESTORE_VALUED: [&[u8]; 3] = [b"--source", b"-s", b"--conflict"];
82
83/// What:
84///  Read a `git restore` region for the conflict-side forms.
85/// Why:
86///   `git restore` has no option table in this crate, and Git accepts abbreviations, so the reading is exact: a token
87///  that is not one of the listed spellings disqualifies the line. A restore that names the index and not the worktree
88///  (`--staged` alone) writes no file and is not a way to take a side, so it stays rejected as before.
89///
90/// In TS you'd write (pseudocode):
91/// ```ts
92/// function restoreSideRequest(region: string[]): SideRequest | undefined;
93/// ```
94fn restore_request(region: &[OsString]) -> Option<SideRequest> {
95    let mut at: usize = 0;
96    while at < region.len() {
97        let token: &[u8] = region[at].as_encoded_bytes();
98        if token == b"--" {
99            let paths: Vec<OsString> = region[at + 1..].to_vec();
100            if paths.is_empty() {
101                return None;
102            }
103            return Some(SideRequest { paths });
104        }
105        if RESTORE_PLAIN.contains(&token) {
106            at += 1;
107            continue;
108        }
109        // `--staged` beside `--worktree` still writes files; `--staged` alone does not, and is not accepted.
110        if token == b"-S" || token == b"--staged" {
111            let writes_worktree: bool = region.iter().take_while(|each| { return each.as_encoded_bytes() != b"--"; }).any(|each| {
112                return matches!(each.as_encoded_bytes(), b"-W" | b"--worktree");
113            });
114            if !writes_worktree {
115                return None;
116            }
117            at += 1;
118            continue;
119        }
120        let name: &[u8] = token.split(|byte| { return *byte == b'='; }).next().unwrap_or_default();
121        if RESTORE_VALUED.contains(&name) {
122            // `--source=<tree>` carries its value; `--source <tree>` takes the next token.
123            at += if name.len() < token.len() { 1 } else { 2 };
124            continue;
125        }
126        return None;
127    }
128    // No `--`: the paths could be mistaken for option values, so this form is left to the usual rejection.
129    return None;
130}
131
132/// What:
133///  The conflict-side reading of a subcommand and its region, or nothing when the command line does not qualify.
134/// Why:
135///   The caller asks only for `checkout` and `restore`; every other command word is not a way to take a side.
136///
137/// In TS you'd write (pseudocode):
138/// ```ts
139/// function conflictSideRequest(word: string, region: string[]): SideRequest | undefined;
140/// ```
141pub fn conflict_side_request(word: &[u8], region: &[OsString]) -> Option<SideRequest> {
142    if word == b"checkout" {
143        return checkout_request(region);
144    }
145    if word == b"restore" {
146        return restore_request(region);
147    }
148    return None;
149}
150
151/// Each spelling that qualifies and each that does not.
152#[cfg(test)]
153#[path = "command_conflict_side_tests.rs"]
154mod tests;