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;