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;