Skip to main content

git_policy_cli/
rule_branch_worktree.rs

1//! What:
2//!  Pure decision of the branch-worktree-only policy:
3//!  a new branch starts in its own
4//!       linked worktree,
5//!  not in the current one.
6//! Why:
7//!  `git branch <name>`,
8//!  `git checkout -b`,
9//!  `git switch -c` and Git's guess of a remote
10//!      branch all create a branch in the current worktree.
11//!  The policy rejects them and
12//!      points at `git worktree add -b`.
13//!  This file decides from the arguments alone,
14//!  names
15//!      the one repository query the guess needs,
16//!  and reads that query's answer.
17//!  It starts
18//!      no process.
19//!
20//! In TS you'd write (pseudocode):
21//! ```ts
22//! // decideBranchWorktree(['switch', '-c', 'topic']) => { kind: 'creates', command: 'switch' }
23//! ```
24
25/// What:
26///  `use` brings names from sibling files into this file;
27///  `super::` means "the parent
28///       module",
29///  where every sibling file of this crate is declared.
30/// Why:
31///   The creation facts come from Git's own option tables of the three commands.
32///
33/// In TS you'd write (pseudocode):
34/// ```ts
35/// import { parseBranchCreationRegion } from './command_branch_create.ts';
36/// ```
37use super::command_branch_create::{
38    BranchCreationCommand, BranchCreationRegion, branch_creation_command,
39    parse_branch_creation_region,
40};
41use super::command_options::OptionError;
42use super::escape_hatch::BRANCH_WORKTREE_ESCAPE_HATCH;
43use super::global_arguments::command_tokens;
44/// What:
45///  `OsStr`/`OsString` are borrowed/owned operating-system text of raw bytes.
46///  Sibling
47///       the reader might expect:
48///  `&str`/`String`,
49///  which must be valid UTF-8.
50/// Why:
51///   A branch name is passed to Git with its exact bytes.
52///
53/// In TS you'd write (pseudocode):
54/// ```ts
55/// // string, but byte-preserving.
56/// ```
57use std::ffi::{OsStr, OsString};
58
59/// What:
60///  The finding code of a branch-worktree rejection.
61///  `&str` is borrowed text baked
62///       into the program.
63/// Why:
64///   Callers identify the finding by this stable code.
65///
66/// In TS you'd write (pseudocode):
67/// ```ts
68/// const BRANCH_CREATION_CODE = 'branch-creation-requires-worktree';
69/// ```
70pub const BRANCH_CREATION_CODE: &str = "branch-creation-requires-worktree";
71
72/// Ref namespace of local branches.
73const LOCAL_BRANCH_PREFIX: &[u8] = b"refs/heads/";
74
75/// Ref namespace of remote-tracking branches.
76const REMOTE_BRANCH_PREFIX: &[u8] = b"refs/remotes/";
77
78/// What:
79///  The outcome of looking at the argument list only.
80///  An `enum` is a closed set of
81///       named alternatives;
82///  `NeedsRemoteGuess` carries the name Git may guess.
83///       `#[derive(...)]` asks the compiler to generate cloning,
84///  debug printing and `==`.
85/// Why:
86///   Explicit creation is decided here;
87///  the guess depends on which branches exist.
88///
89/// In TS you'd write (pseudocode):
90/// ```ts
91/// type BranchWorktreeDecision = { kind: 'pass' } | { kind: 'creates'; command } | { kind: 'needs-remote-guess'; command; target: string };
92/// ```
93#[derive(Clone, Debug, Eq, PartialEq)]
94pub enum BranchWorktreeDecision {
95    /// Not a guarded command,
96    ///  a form that creates nothing,
97    ///  or a region Git refuses.
98    Pass,
99    /// The command creates a branch explicitly.
100    Creates(BranchCreationCommand),
101    /// `git switch <target>` or `git checkout <target>`:
102    ///  creation depends on the
103    /// repository's branches;
104    ///  the caller asks and then calls `resolve_remote_guess`.
105    NeedsRemoteGuess {
106        /// The command being run.
107        command: BranchCreationCommand,
108        /// The name Git may guess,
109        ///  with its exact bytes.
110        target: OsString,
111    },
112}
113
114/// What:
115///  Decide from the argument list.
116///  `&[OsString]` borrows the arguments,
117///  already free
118///       of wrapper controls.
119/// Why:
120///   Only `branch`,
121///  `checkout` and `switch` can create a branch outside
122///       `git worktree add`.
123///  A region Git itself refuses creates nothing.
124///
125/// In TS you'd write (pseudocode):
126/// ```ts
127/// function decideBranchWorktree(args: string[]): BranchWorktreeDecision;
128/// ```
129pub fn decide_branch_worktree(arguments: &[OsString]) -> BranchWorktreeDecision {
130    // `let Some((a, b)) = ... else { ... };` unpacks the command word and the tokens after
131    // it, or returns when Git runs no subcommand.
132    let Some((word, region)) = command_tokens(arguments) else {
133        return BranchWorktreeDecision::Pass;
134    };
135    // `.as_encoded_bytes()` lends the raw bytes of the subcommand word;
136    // `let Some(x) = ... else { ... };` unwraps the guarded command or returns.
137    let Some(command) = branch_creation_command(word.as_encoded_bytes()) else {
138        return BranchWorktreeDecision::Pass;
139    };
140    // `Result<A, B>` is "either success `A` or failure `B`"; `&[]` is an empty flag list.
141    let parsed: Result<BranchCreationRegion, OptionError> =
142        parse_branch_creation_region(command, region, &[]);
143    // `let Ok(x) = ... else { ... };` unwraps the facts or returns for a refused region.
144    let Ok(facts) = parsed else {
145        return BranchWorktreeDecision::Pass;
146    };
147    if facts.creates_branch {
148        return BranchWorktreeDecision::Creates(command);
149    }
150    // `if let Some(token) = ...` runs only when Git may guess a remote branch.
151    if let Some(token) = facts.implicit_creation_target {
152        return BranchWorktreeDecision::NeedsRemoteGuess {
153            command,
154            // `.clone()` copies the name's bytes into the decision.
155            target: region[token].clone(),
156        };
157    }
158    return BranchWorktreeDecision::Pass;
159}
160
161/// What:
162///  The subcommand word of a guarded command.
163///  `&'static str` is text baked into the
164///       program.
165/// Why:
166///   The rejection names the command the caller typed.
167///
168/// In TS you'd write (pseudocode):
169/// ```ts
170/// const word = subcommand; // the union member already is the string
171/// ```
172fn command_word(command: BranchCreationCommand) -> &'static str {
173    // `match` picks one arm per variant; the compiler refuses a forgotten variant.
174    match command {
175        BranchCreationCommand::Branch => return "branch",
176        BranchCreationCommand::Checkout => return "checkout",
177        BranchCreationCommand::Switch => return "switch",
178    }
179}
180
181/// What:
182///  Build the rejection text.
183///  `Option<&OsStr>` is "a borrowed branch name or
184///       nothing";
185///  `String` is the owned result.
186/// Why:
187///   The text names the command,
188///  the guessed branch when there is one,
189///  the worktree
190///       form to use instead,
191///  and the one-invocation bypass.
192///
193/// In TS you'd write (pseudocode):
194/// ```ts
195/// function branchCreationMessage({ subcommand, target }): string;
196/// ```
197pub fn branch_creation_message(command: BranchCreationCommand, target: Option<&OsStr>) -> String {
198    // `String::new()` is empty owned text; `mut` allows filling it below.
199    let mut target_clause: String = String::new();
200    // `if let Some(name) = target` runs only for a guessed branch.
201    if let Some(name) = target {
202        // `.to_string_lossy()` renders the name as text, replacing undecodable bytes.
203        target_clause = format!(" for {}", name.to_string_lossy());
204    }
205    return format!(
206        "cli-git: git {}{target_clause} branch creation is rejected in the current worktree. \
207         Use `git worktree add -b <branch> <path> [<start-point>]` so new branch work starts \
208         in its own checkout, or pass {BRANCH_WORKTREE_ESCAPE_HATCH} to bypass for this \
209         invocation.",
210        command_word(command)
211    );
212}
213
214/// What:
215///  Build the real-Git argument list of the guess query:
216///  the caller's global
217///       options,
218///  then one `for-each-ref` over the local branch of that name and the
219///       remote branches of that name.
220///  `Vec<OsString>` is the owned result.
221/// Why:
222///   One listing answers both questions the guess depends on,
223///  so the check starts one
224///       Git process.
225///  The query must inspect the repository the command will run in,
226///  so
227///       the global options are forwarded unchanged.
228///
229/// In TS you'd write (pseudocode):
230/// ```ts
231/// const args = [...pre, 'for-each-ref', '--format=%(refname)', `refs/heads/${target}`, `refs/remotes/*/${target}`];
232/// ```
233pub fn remote_guess_query_arguments(global_prefix: &[OsString], target: &OsStr) -> Vec<OsString> {
234    // `.to_vec()` copies the borrowed prefix into an owned, growable list.
235    let mut arguments: Vec<OsString> = global_prefix.to_vec();
236    arguments.push(OsString::from("for-each-ref"));
237    arguments.push(OsString::from("--format=%(refname)"));
238    let mut local: OsString = OsString::from("refs/heads/");
239    // `.push` appends the name's bytes unchanged.
240    local.push(target);
241    arguments.push(local);
242    let mut remote: OsString = OsString::from("refs/remotes/*/");
243    remote.push(target);
244    arguments.push(remote);
245    return arguments;
246}
247
248/// Named predicate for splitting output into lines;
249///  `&u8` borrows one byte.
250fn is_line_feed(byte: &u8) -> bool {
251    return *byte == b'\n';
252}
253
254/// Named predicate for finding the end of a remote's name.
255fn is_slash(byte: &u8) -> bool {
256    return *byte == b'/';
257}
258
259/// What:
260///  Whether a listed ref is `refs/remotes/<remote>/<target>` with a remote name of
261///       one path segment.
262///  `&[u8]` borrows the ref name and the branch name.
263/// Why:
264///   The query's patterns can also list refs that merely start with the name,
265///  so
266///       each line is compared exactly.
267///
268/// In TS you'd write (pseudocode):
269/// ```ts
270/// const isRemote = line.startsWith('refs/remotes/') && line.slice(line.indexOf('/', 13) + 1) === target;
271/// ```
272fn is_remote_branch_of(line: &[u8], target: &[u8]) -> bool {
273    // `.strip_prefix` is `Some(rest)` when the bytes start with the prefix.
274    let Some(rest) = line.strip_prefix(REMOTE_BRANCH_PREFIX) else {
275        return false;
276    };
277    // `.iter().position(f)` is the index of the first byte for which `f` is true.
278    let Some(slash) = rest.iter().position(is_slash) else {
279        return false;
280    };
281    // `&rest[n..]` borrows the bytes after the remote's name and its slash.
282    return &rest[slash + 1..] == target;
283}
284
285/// What:
286///  Read the guess query's listing:
287///  no local branch of that name,
288///  and exactly one
289///       remote branch of that name.
290///  `bool` is true or false.
291/// Why:
292///   That is when `git switch <name>` and `git checkout <name>` create a local branch.
293///       A remote's `HEAD` pointer is not a branch and is never guessed.
294///
295/// In TS you'd write (pseudocode):
296/// ```ts
297/// const creates = !lines.includes(`refs/heads/${target}`) && remoteMatches.length === 1 && target !== 'HEAD';
298/// ```
299pub fn remote_guess_creates_branch(stdout: &[u8], target: &[u8]) -> bool {
300    if target == b"HEAD" {
301        return false;
302    }
303    // `usize` counts the remote branches of that name.
304    let mut remotes: usize = 0;
305    // `.split(f)` cuts the bytes at each line break.
306    for line in stdout.split(is_line_feed) {
307        if line.strip_prefix(LOCAL_BRANCH_PREFIX) == Some(target) {
308            return false;
309        }
310        if is_remote_branch_of(line, target) {
311            remotes += 1;
312        }
313    }
314    return remotes == 1;
315}
316
317/// Decisions,
318///  the message,
319///  and the guess query with real Git 2.56.0 controls.
320#[cfg(test)]
321#[path = "rule_branch_worktree_tests.rs"]
322mod tests;