Skip to main content

git_policy_cli/conflict/
command_conflict_side.rs

1//! What:
2//!  Which `git checkout` and `git restore` command lines take one side of a conflicted file,
3//! and the paths they name.
4//! Why:
5//!  During a merge,
6//! a rebase,
7//! a cherry-pick or a revert the usual way to settle a conflict is
8//!  `git checkout --ours|--theirs -- <path>`,
9//! `git checkout <rev> -- <path>` or the `git restore` forms of the same.
10//!  The linked-worktree policy rejects them as discarding forms;
11//! the conflict-side guard lets them through for conflicted
12//!  paths after saving the file.
13//! Only a command line whose every token is understood qualifies:
14//! a form with `--force`,
15//!  `--patch`,
16//! `--pathspec-from-file`,
17//! a branch option,
18//! an abbreviated `git restore` option,
19//! an index-only restore,
20//! or no
21//!  `--` before the paths stays rejected,
22//! because what it would discard is not known from the arguments alone.
23//!
24//! In TS you'd write (pseudocode):
25//! ```ts
26//! conflictSideRequest('checkout', ['--theirs', '--', 'a.rs']) // { paths: ['a.rs'] }
27//! conflictSideRequest('checkout', ['-f', '--', 'a.rs'])       // undefined
28//! ```
29
30use crate::command_checkout_table::{
31    CHECKOUT_TABLE, DETACH, FORCE, NEW_BRANCH, NEW_BRANCH_FORCE, ORPHAN, PATCH, PATHSPEC_FROM_FILE,
32};
33use crate::command_options::{Boundary, DEFAULT_MODE, ParsedOptions, parse_options};
34use crate::command_options_query::is_stated;
35use crate::diagnostic_log::debug;
36use std::ffi::OsString;
37
38/// Module tag for diagnostics.
39const TAG: &str = "conflictSideRequest";
40
41/// What:
42///  Say why a command line is not read as a conflict-side form,
43/// and give the answer that sends it to the usual rule.
44/// Why:
45///   The usual rejection then stands,
46/// and a person asking why a line they thought was allowed was rejected can read the
47///  reason in the verbose log.
48///
49/// In TS you'd write (pseudocode):
50/// ```ts
51/// function declined(word: string, reason: string, region: string[]): undefined;
52/// ```
53fn declined(word: &str, reason: &str, region: &[OsString]) -> Option<SideRequest> {
54    debug(TAG, &format!("git {word} {region:?} is not read as taking a side of a conflicted file: {reason}"));
55    return None;
56}
57
58/// The paths a qualifying command line names,
59/// as typed.
60#[derive(Clone, Debug, Eq, PartialEq)]
61pub struct SideRequest {
62    /// The pathspec tokens after `--`,
63    /// in order,
64    /// none of them resolved or checked yet.
65    pub paths: Vec<OsString>,
66}
67
68/// What:
69///  Read a `git checkout` region for the conflict-side forms.
70/// Why:
71///   The form is `checkout [options] [<tree-ish>] -- <path>...`,
72/// where the options are the ones that choose a side or
73///  how a file is written.
74/// Any option that creates or moves a branch,
75/// forces,
76/// patches or reads paths from a file
77///  disqualifies the line.
78///
79/// In TS you'd write (pseudocode):
80/// ```ts
81/// function checkoutSideRequest(region: string[]): SideRequest | undefined;
82/// ```
83fn checkout_request(region: &[OsString]) -> Option<SideRequest> {
84    let Ok(parsed): Result<ParsedOptions, _> = parse_options(region, CHECKOUT_TABLE, DEFAULT_MODE, &[]) else {
85        return declined("checkout", "an option is not one Git's table lists", region);
86    };
87    let Boundary::DashDash(at) = parsed.boundary else {
88        return declined("checkout", "there is no `--` before the paths", region);
89    };
90    for forbidden in [NEW_BRANCH, NEW_BRANCH_FORCE, ORPHAN, DETACH, PATCH, FORCE, PATHSPEC_FROM_FILE] {
91        if is_stated(&parsed, forbidden) {
92            return declined("checkout", "it forces, patches, makes a branch, detaches or reads paths from a file", region);
93        }
94    }
95    if parsed.leading.len() > 1 {
96        return declined("checkout", "it names more than one thing before `--`", region);
97    }
98    let paths: Vec<OsString> = region[at + 1..].to_vec();
99    if paths.is_empty() {
100        return declined("checkout", "no path follows `--`", region);
101    }
102    return Some(SideRequest { paths });
103}
104
105/// Whether a byte separates an option's name from its inline value.
106fn is_equals(byte: &u8) -> bool {
107    return *byte == b'=';
108}
109
110/// The spellings of `git restore` options that take no value and keep the command a worktree restore of named paths.
111const RESTORE_PLAIN: [&[u8]; 14] = [
112    b"--ours",
113    b"--theirs",
114    b"-m",
115    b"--merge",
116    b"-W",
117    b"--worktree",
118    b"-q",
119    b"--quiet",
120    b"--progress",
121    b"--no-progress",
122    b"--overlay",
123    b"--no-overlay",
124    b"--ignore-unmerged",
125    b"--ignore-skip-worktree-bits",
126];
127
128/// The spellings of `git restore` options that take a value,
129/// as `--name=value` or as the next token.
130const RESTORE_VALUED: [&[u8]; 3] = [b"--source", b"-s", b"--conflict"];
131
132/// What:
133///  Read a `git restore` region for the conflict-side forms.
134/// Why:
135///   `git restore` has no option table in this crate,
136/// and Git accepts abbreviations,
137/// so the reading is exact:
138/// a token
139///  that is not one of the listed spellings disqualifies the line.
140/// A restore that names the index and not the worktree
141///  (`--staged` alone) writes no file and is not a way to take a side,
142/// so it stays rejected as before.
143///
144/// In TS you'd write (pseudocode):
145/// ```ts
146/// function restoreSideRequest(region: string[]): SideRequest | undefined;
147/// ```
148fn restore_request(region: &[OsString]) -> Option<SideRequest> {
149    // Whether `--staged` and whether `--worktree` were parsed as options; a token that is the value of `--source`,
150    // `-s` or `--conflict` is skipped with its option and is never one of them.
151    let mut staged: bool = false;
152    let mut worktree: bool = false;
153    let mut at: usize = 0;
154    while at < region.len() {
155        let token: &[u8] = region[at].as_encoded_bytes();
156        if token == b"--" {
157            // `--staged` beside `--worktree` still writes files; `--staged` alone does not, and is not accepted.
158            if staged && !worktree {
159                return declined("restore", "it restores the index only, which writes no worktree file", region);
160            }
161            let paths: Vec<OsString> = region[at + 1..].to_vec();
162            if paths.is_empty() {
163                return declined("restore", "no path follows `--`", region);
164            }
165            return Some(SideRequest { paths });
166        }
167        if token == b"-W" || token == b"--worktree" {
168            worktree = true;
169            at += 1;
170            continue;
171        }
172        if RESTORE_PLAIN.contains(&token) {
173            at += 1;
174            continue;
175        }
176        if token == b"-S" || token == b"--staged" {
177            staged = true;
178            at += 1;
179            continue;
180        }
181        let name: &[u8] = token.split(is_equals).next().unwrap_or_default();
182        if RESTORE_VALUED.contains(&name) {
183            // `--source=<tree>` carries its value; `--source <tree>` takes the next token.
184            at += if name.len() < token.len() { 1 } else { 2 };
185            continue;
186        }
187        return declined("restore", format!("`{}` is not a spelling this reading lists", region[at].to_string_lossy()).as_str(), region);
188    }
189    // No `--`: the paths could be mistaken for option values, so this form is left to the usual rejection.
190    return declined("restore", "there is no `--` before the paths", region);
191}
192
193/// What:
194///  The conflict-side reading of a subcommand and its region,
195/// or nothing when the command line does not qualify.
196/// Why:
197///   The caller asks only for `checkout` and `restore`;
198/// every other command word is not a way to take a side.
199///
200/// In TS you'd write (pseudocode):
201/// ```ts
202/// function conflictSideRequest(word: string, region: string[]): SideRequest | undefined;
203/// ```
204pub fn conflict_side_request(word: &[u8], region: &[OsString]) -> Option<SideRequest> {
205    if word == b"checkout" {
206        return checkout_request(region);
207    }
208    if word == b"restore" {
209        return restore_request(region);
210    }
211    return None;
212}
213
214/// Each spelling that qualifies and each that does not.
215#[cfg(test)]
216#[path = "command_conflict_side_tests.rs"]
217mod tests;