git_policy_cli/worktree/worktree_add_plan.rs
1//! What:
2//! The plan for one `git worktree add` the cheap-worktree ladder may serve: where the new worktree goes and where in
3//! the argument list a rung inserts `--no-checkout`, or nothing when the invocation uses a form the ladder does not
4//! know.
5//! Why:
6//! The ladder runs real Git once with the person's arguments, so it only needs the destination path and a promise that
7//! every other word means what the table says. An option the table does not list, a bundled short flag, a global
8//! option that changes how Git reads configuration, or a destination whose parent does not exist makes the whole
9//! invocation a plain Git run, which is always correct.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! const plan = parseAddPlan(['-C', 'repo', 'worktree', 'add', '-b', 'topic', '../topic'], 1, cwd); // undefined: plain Git
14//! ```
15
16use std::ffi::{OsStr, OsString};
17use std::path::{Path, PathBuf};
18
19/// What:
20/// The facts of a `git worktree add` the ladder serves.
21/// `usize` is an unsigned size or index; `PathBuf` is an owned file-system path.
22/// Why:
23/// The destination decides the subvolume to create and the worktree to settle; the insertion index decides where
24/// `--no-checkout` goes in the arguments real Git receives.
25///
26/// In TS you'd write (pseudocode):
27/// ```ts
28/// type AddPlan = { destination: string; insertAt: number; quiet: boolean };
29/// ```
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct AddPlan {
32 /// The destination as Git records it: the real path of its parent joined with its last component.
33 pub destination: PathBuf,
34 /// The index in the full argument list where options of `add` may be inserted: directly after the word `add`.
35 pub insert_at: usize,
36 /// Whether the person passed `--quiet`, which silences the line Git's checkout prints.
37 pub quiet: bool,
38}
39
40/// The options of `add` that take no value and keep the checkout of a new worktree the ordinary one.
41const FLAG_OPTIONS: &[&[u8]] = &[b"--detach", b"-d", b"--quiet", b"-q", b"--lock", b"--checkout"];
42
43/// The options of `add` whose value is the next argument.
44const VALUE_OPTIONS: &[&[u8]] = &[b"-b", b"--reason"];
45
46/// The global options that cannot change how the ladder's own Git runs read the repository.
47const HARMLESS_GLOBALS: &[&[u8]] = &[b"--no-pager", b"-P", b"--paginate", b"-p", b"--no-advice", b"--no-lazy-fetch"];
48
49/// What:
50/// The directory a command starts in: the process directory moved by every `-C <dir>` of the global prefix, or nothing
51/// when the prefix holds an option the ladder does not serve.
52/// Why:
53/// Git changes directory at each `-C` in turn, and a relative destination is relative to the result. A `..`
54/// component in a `-C` value is declined, because following it lexically differs from Git's directory changes
55/// through a symbolic link.
56///
57/// In TS you'd write (pseudocode):
58/// ```ts
59/// function startDirectory(prefix: string[], cwd: string): string | undefined;
60/// ```
61pub fn start_directory(global_prefix: &[OsString], current: &Path) -> Option<PathBuf> {
62 let mut start: PathBuf = current.to_path_buf();
63 let mut tokens = global_prefix.iter();
64 while let Some(token) = tokens.next() {
65 let bytes: &[u8] = token.as_encoded_bytes();
66 if bytes == b"-C" {
67 let target: &OsString = tokens.next()?;
68 if Path::new(target).components().any(|part| { return part == std::path::Component::ParentDir; }) {
69 return None;
70 }
71 if !target.is_empty() {
72 start = start.join(target);
73 }
74 } else if !HARMLESS_GLOBALS.contains(&bytes) {
75 return None;
76 }
77 }
78 return Some(start);
79}
80
81/// What:
82/// The real path of a destination as Git records it: the canonical parent joined with the last component.
83/// Why:
84/// Git stores `strbuf_add_real_path` of the new worktree after creating its parent directories; the ladder serves
85/// only a parent that exists, so no directory has to be guessed. A last component of `.` or `..`, or one that is already
86/// a symbolic link, is declined.
87///
88/// In TS you'd write (pseudocode):
89/// ```ts
90/// function recordedDestination(start: string, operand: string): string | undefined;
91/// ```
92fn recorded_destination(start: &Path, operand: &OsStr) -> Option<PathBuf> {
93 use std::os::unix::ffi::OsStrExt;
94 let mut trimmed: &[u8] = operand.as_bytes();
95 while let Some(rest) = trimmed.strip_suffix(b"/") {
96 trimmed = rest;
97 }
98 let last: &[u8] = trimmed.rsplit(|byte| { return *byte == b'/'; }).next()?;
99 if last.is_empty() || last == b"." || last == b".." {
100 return None;
101 }
102 let joined: PathBuf = start.join(OsStr::from_bytes(trimmed));
103 let parent: PathBuf = std::fs::canonicalize(joined.parent()?).ok()?;
104 let recorded: PathBuf = parent.join(OsStr::from_bytes(last));
105 // Git records the resolved path of a destination that is a link, so the directory Git fills and the path the ladder
106 // would swap differ: decline, and let Git run alone.
107 if std::fs::symlink_metadata(recorded.as_path()).is_ok_and(|found: std::fs::Metadata| { return found.file_type().is_symlink(); }) {
108 return None;
109 }
110 return Some(recorded);
111}
112
113/// What:
114/// Read the arguments after `add`: the operands and whether `--quiet` was given, or nothing when any option is outside
115/// the table.
116/// Why:
117/// `--` ends the options. An operand count other than one or two is a usage error that Git words itself.
118///
119/// In TS you'd write (pseudocode):
120/// ```ts
121/// function addOperands(region: string[]): { operands: string[]; quiet: boolean } | undefined;
122/// ```
123fn add_operands(region: &[OsString]) -> Option<(Vec<&OsString>, bool)> {
124 let mut operands: Vec<&OsString> = Vec::new();
125 let mut quiet: bool = false;
126 let mut tokens = region.iter();
127 let mut options_ended: bool = false;
128 while let Some(token) = tokens.next() {
129 let bytes: &[u8] = token.as_encoded_bytes();
130 if options_ended || !bytes.starts_with(b"-") || bytes == b"-" {
131 operands.push(token);
132 } else if bytes == b"--" {
133 options_ended = true;
134 } else if VALUE_OPTIONS.contains(&bytes) {
135 tokens.next()?;
136 } else if bytes == b"-q" || bytes == b"--quiet" {
137 quiet = true;
138 } else if !bytes.starts_with(b"--reason=") && !FLAG_OPTIONS.contains(&bytes) {
139 return None;
140 }
141 }
142 return if (1..=2).contains(&operands.len()) { Some((operands, quiet)) } else { None };
143}
144
145/// What:
146/// Plan one invocation: `arguments` is the whole argument list, `subcommand_index` the index of `worktree` in it, and
147/// `start` the directory the command starts in.
148/// Why:
149/// The word directly after `worktree` must be `add` itself, because Git reads the subcommand there and never
150/// abbreviates it.
151///
152/// In TS you'd write (pseudocode):
153/// ```ts
154/// function parseAddPlan(args: string[], subcommandIndex: number, start: string): AddPlan | undefined;
155/// ```
156pub fn parse_add_plan(arguments: &[OsString], subcommand_index: usize, start: &Path) -> Option<AddPlan> {
157 if arguments.get(subcommand_index)?.as_encoded_bytes() != b"worktree" {
158 return None;
159 }
160 let word_index: usize = subcommand_index + 1;
161 if arguments.get(word_index)?.as_encoded_bytes() != b"add" {
162 return None;
163 }
164 let (operands, quiet): (Vec<&OsString>, bool) = add_operands(&arguments[word_index + 1..])?;
165 let destination: PathBuf = recorded_destination(start, operands[0])?;
166 return Some(AddPlan { destination, insert_at: word_index + 1, quiet });
167}
168
169
170/// How an option of `git worktree add` takes its value, as `parse-options.c` of Git 2.56.0 does (`do_get_value`, line 136).
171#[derive(Clone, Copy, Debug, Eq, PartialEq)]
172enum Takes {
173 /// No value: `--name=x` is Git's error, and so is a value on the negated form.
174 Nothing,
175 /// A value in the rest of the token, after `=`, or in the next token; the negated form takes none.
176 Required,
177}
178
179/// One option of `git worktree add`: its short letter, its long name, and how it takes a value.
180struct AddOption {
181 /// The letter of the short form, when there is one.
182 short: Option<u8>,
183 /// The long name, when there is one; every long option also has a `--no-` form.
184 long: Option<&'static str>,
185 /// How the option takes its value.
186 takes: Takes,
187}
188
189/// The options of `git worktree add` in the order of `add` in `builtin/worktree.c` (Git 2.56.0, lines 821 to 862), the one
190/// table every reading of the command line uses.
191/// `git worktree add -h` lists these, and a control compares the two, so the table cannot drift from Git unnoticed.
192const ADD_OPTIONS: &[AddOption] = &[
193 AddOption { short: Some(b'f'), long: Some("force"), takes: Takes::Nothing },
194 AddOption { short: Some(b'b'), long: None, takes: Takes::Required },
195 AddOption { short: Some(b'B'), long: None, takes: Takes::Required },
196 AddOption { short: None, long: Some("orphan"), takes: Takes::Nothing },
197 AddOption { short: Some(b'd'), long: Some("detach"), takes: Takes::Nothing },
198 AddOption { short: None, long: Some("checkout"), takes: Takes::Nothing },
199 AddOption { short: None, long: Some("lock"), takes: Takes::Nothing },
200 AddOption { short: None, long: Some("reason"), takes: Takes::Required },
201 AddOption { short: Some(b'q'), long: Some("quiet"), takes: Takes::Nothing },
202 AddOption { short: None, long: Some("track"), takes: Takes::Nothing },
203 AddOption { short: None, long: Some("guess-remote"), takes: Takes::Nothing },
204 AddOption { short: None, long: Some("relative-paths"), takes: Takes::Nothing },
205];
206
207/// The global options that take no value and change nothing about where a relative destination lies.
208const COPY_NEUTRAL_GLOBALS: &[&[u8]] = &[
209 b"--no-pager", b"-P", b"--paginate", b"-p", b"--no-replace-objects", b"--no-lazy-fetch", b"--no-optional-locks",
210 b"--no-advice", b"--literal-pathspecs", b"--no-literal-pathspecs", b"--glob-pathspecs", b"--noglob-pathspecs",
211 b"--icase-pathspecs", b"--bare",
212];
213
214/// The global options that take a value in the next token or after `=`, and change nothing about where a relative
215/// destination lies (`git.c` of Git 2.56.0, `handle_options`: `--git-dir`, `--work-tree`, `--namespace`, `--config-env`,
216/// `--attr-source`). A relative destination lies against the working directory whatever they say: Git changes into the work
217/// tree and prefixes the operand when the working directory is inside it, and leaves it alone when it is outside
218/// (`setup.c`, lines 1222 to 1231).
219const COPY_VALUED_GLOBALS: &[&[u8]] = &[b"--git-dir", b"--work-tree", b"--namespace", b"--config-env", b"--attr-source"];
220
221/// The global options that take a value in the next token only.
222const COPY_SEPARATE_VALUE_GLOBALS: &[&[u8]] = &[b"-c", b"--shallow-file"];
223
224/// What:
225/// The directory a command's relative destination is resolved against, after the global options.
226/// Why:
227/// Only `-C` moves it; the others change nothing about it. An option this reading does not know leaves the destination
228/// unknown, and the copy then says it did not run.
229///
230/// In TS you'd write (pseudocode):
231/// ```ts
232/// function copyStartDirectory(globalPrefix: string[], current: string): string | undefined;
233/// ```
234pub fn copy_start_directory(global_prefix: &[OsString], current: &Path) -> Option<PathBuf> {
235 let mut start: PathBuf = current.to_path_buf();
236 let mut tokens = global_prefix.iter();
237 while let Some(token) = tokens.next() {
238 let bytes: &[u8] = token.as_encoded_bytes();
239 if bytes == b"-C" {
240 let target: &OsString = tokens.next()?;
241 if !target.is_empty() {
242 start = start.join(target);
243 }
244 } else if COPY_SEPARATE_VALUE_GLOBALS.contains(&bytes) || COPY_VALUED_GLOBALS.contains(&bytes) {
245 tokens.next()?;
246 } else if COPY_VALUED_GLOBALS.iter().any(|name| { return bytes.strip_prefix(*name).is_some_and(|rest| { return rest.first() == Some(&b'='); }); })
247 || bytes.starts_with(b"--exec-path=")
248 {
249 continue;
250 } else if !COPY_NEUTRAL_GLOBALS.contains(&bytes) {
251 return None;
252 }
253 }
254 return Some(start);
255}
256
257/// The option a long argument names and how, resolved exactly as `parse_long_opt` of Git 2.56.0 does (`parse-options.c`, lines
258/// 520 to 593).
259struct LongOption<'a> {
260 /// The option the text names, by its full name or by a unique prefix.
261 option: &'static AddOption,
262 /// Whether the `--no-` form was written.
263 unset: bool,
264 /// The text after `=`, when there is one.
265 attached: Option<&'a str>,
266}
267
268/// What:
269/// Resolves the text after `--` to one option, or to nothing where Git has none: an exact name wins at once, whatever
270/// prefixes were seen before; otherwise one unique prefix is the option; two prefixes are Git's "ambiguous option" error.
271/// Why:
272/// The ignored-state copy needs the operands of an add, and an abbreviated option decides which tokens are operands.
273/// `--no-` negates and `--no-no-` is accepted by no option here. Git also reads an argument that is a prefix of `no-` as an
274/// abbreviation of every option's negation; here that changes nothing, because no option begins with `n`, and the text
275/// `no-` itself already fits every option.
276///
277/// In TS you'd write (pseudocode):
278/// ```ts
279/// function resolveLongOption(arg: string): { option; unset; attached? } | undefined;
280/// ```
281fn resolve_long(arg: &str) -> Option<LongOption<'_>> {
282 return resolve_in(ADD_OPTIONS, arg);
283}
284
285/// What:
286/// `resolve_long` over a given table of options.
287/// Why:
288/// The real table has no option whose name is a prefix of another's, so the rule that an exact name wins can only be
289/// shown with a table that has one.
290///
291/// In TS you'd write (pseudocode):
292/// ```ts
293/// function resolveIn(options: AddOption[], arg: string): { option; unset; attached? } | undefined;
294/// ```
295fn resolve_in<'a>(table: &'static [AddOption], arg: &'a str) -> Option<LongOption<'a>> {
296 let arg_end: usize = arg.find('=').unwrap_or(arg.len());
297 // `--no-x` is the negation of `x`; `--no-no-x` is accepted by no option whose name has no `no-` (line 535 to 543).
298 let (arg_start, unset, double_negation): (&str, bool, bool) = match arg.strip_prefix("no-") {
299 Some(rest) => match rest.strip_prefix("no-") {
300 Some(inner) => (inner, false, true),
301 None => (rest, true, false),
302 },
303 None => (arg, false, false),
304 };
305 if double_negation {
306 return None;
307 }
308 // Git counts the name's length against the original text, so a text cut inside `no-` has no length and matches nothing.
309 let length: usize = arg_end.checked_sub(arg.len() - arg_start.len())?;
310 let mut abbreviated: Option<(&'static AddOption, bool)> = None;
311 let mut ambiguous: bool = false;
312 for option in table {
313 let Some(name) = option.long else { continue };
314 if let Some(rest) = arg_start.strip_prefix(name) {
315 // An exact name returns at once, even after prefixes were seen.
316 if let Some(value) = rest.strip_prefix('=') {
317 return Some(LongOption { option, unset, attached: Some(value) });
318 }
319 if rest.is_empty() {
320 return Some(LongOption { option, unset, attached: None });
321 }
322 continue;
323 }
324 if name.as_bytes().get(..length) == Some(&arg_start.as_bytes()[..length]) {
325 ambiguous |= abbreviated.is_some();
326 abbreviated = Some((option, unset));
327 }
328 }
329 if ambiguous {
330 return None;
331 }
332 let (matched, matched_unset) = abbreviated?;
333 let attached: Option<&str> = if arg_end < arg.len() { Some(&arg[arg_end + 1..]) } else { None };
334 return Some(LongOption { option: matched, unset: matched_unset, attached });
335}
336
337/// What:
338/// Whether Git accepts the value an option was given, and takes the next token when the option wants it there.
339/// Why:
340/// `do_get_value` (`parse-options.c`, line 136) refuses a value on a negated form and on a flag, and an option that needs a
341/// value and has none; refusing here keeps Git's error the only word on a form Git rejects.
342///
343/// In TS you'd write (pseudocode):
344/// ```ts
345/// function takeValue(option, unset, attached, tokens): boolean;
346/// ```
347fn take_value<'a>(option: &AddOption, unset: bool, attached: Option<&str>, tokens: &mut impl Iterator<Item = &'a OsString>) -> Option<()> {
348 if unset && attached.is_some() {
349 return None;
350 }
351 match option.takes {
352 Takes::Nothing => return if attached.is_some() { None } else { Some(()) },
353 Takes::Required => {
354 if !unset && attached.is_none() {
355 tokens.next()?;
356 }
357 return Some(());
358 }
359 }
360}
361
362/// What:
363/// The operands of a `git worktree add`: the path and the optional commit-ish, with every option skipped the way Git's
364/// `parse_options` skips it.
365/// Why:
366/// The destination of a copy must be known from the command line. Long options are resolved by `resolve_long`, short
367/// options bundle (`-fq`, `-bfeature`, `-b feature`), `--` and `--end-of-options` end the options, and everything Git
368/// rejects makes the reading refuse, so the copy never guesses an operand.
369///
370/// In TS you'd write (pseudocode):
371/// ```ts
372/// function copyAddOperands(region: string[]): string[] | undefined;
373/// ```
374fn copy_add_operands(region: &[OsString]) -> Option<Vec<&OsString>> {
375 let mut operands: Vec<&OsString> = Vec::new();
376 let mut tokens = region.iter();
377 while let Some(token) = tokens.next() {
378 let bytes: &[u8] = token.as_encoded_bytes();
379 if !bytes.starts_with(b"-") || bytes == b"-" {
380 operands.push(token);
381 } else if bytes == b"--" || bytes == b"--end-of-options" {
382 operands.extend(tokens.by_ref());
383 } else if let Some(long) = bytes.strip_prefix(b"--") {
384 let resolved: LongOption<'_> = resolve_long(std::str::from_utf8(long).ok()?)?;
385 take_value(resolved.option, resolved.unset, resolved.attached, &mut tokens)?;
386 } else {
387 // Bundled short options: flags stand alone, and `b` or `B` takes the rest of the token or the next token.
388 let letters: &[u8] = &bytes[1..];
389 for (position, letter) in letters.iter().enumerate() {
390 let option: &AddOption = ADD_OPTIONS.iter().find(|candidate| { return candidate.short == Some(*letter); })?;
391 if option.takes == Takes::Required {
392 if position + 1 == letters.len() {
393 tokens.next()?;
394 }
395 break;
396 }
397 }
398 }
399 }
400 return if (1..=2).contains(&operands.len()) { Some(operands) } else { None };
401}
402
403/// What:
404/// The destination of a path operand in the form Git records it, whether or not its parent directories exist yet.
405/// Why:
406/// Git creates the leading directories of the destination, so `.claude/worktrees/topic` names a destination before
407/// `.claude/worktrees` does; the nearest existing ancestor is resolved to its real path and the rest is appended as written.
408/// A destination that is a link is declined, as in the ladder's reading, and a `..` in the part that does not exist yet is
409/// declined because it cannot be resolved without the directories.
410///
411/// In TS you'd write (pseudocode):
412/// ```ts
413/// function creatingDestination(start: string, operand: string): string | undefined;
414/// ```
415fn creating_destination(start: &Path, operand: &OsStr) -> Option<PathBuf> {
416 use std::os::unix::ffi::OsStrExt;
417 let mut trimmed: &[u8] = operand.as_bytes();
418 while let Some(rest) = trimmed.strip_suffix(b"/") {
419 trimmed = rest;
420 }
421 let last: &[u8] = trimmed.rsplit(|byte| { return *byte == b'/'; }).next()?;
422 if last.is_empty() || last == b"." || last == b".." {
423 return None;
424 }
425 let joined: PathBuf = start.join(OsStr::from_bytes(trimmed));
426 if std::fs::symlink_metadata(joined.as_path()).is_ok_and(|found: std::fs::Metadata| { return found.file_type().is_symlink(); }) {
427 return None;
428 }
429 let existing: &Path = joined.ancestors().find(|ancestor| { return ancestor.exists(); })?;
430 let mut resolved: PathBuf = std::fs::canonicalize(existing).ok()?;
431 for part in joined.strip_prefix(existing).ok()?.components() {
432 match part {
433 std::path::Component::Normal(name) => resolved.push(name),
434 std::path::Component::CurDir => {}
435 _ => return None,
436 }
437 }
438 return Some(resolved);
439}
440
441/// What:
442/// The destination a `git worktree add` names, in the form Git records it, for every form Git's option parser accepts.
443/// Why:
444/// The ignored-state copy goes to the worktree this command created, found from the command line instead of by comparing
445/// the repository's registrations before and after, which another add running at the same time would disturb.
446///
447/// In TS you'd write (pseudocode):
448/// ```ts
449/// function parseCopyDestination(args: string[], subcommandIndex: number, start: string): string | undefined;
450/// ```
451pub fn parse_copy_destination(arguments: &[OsString], subcommand_index: usize, start: &Path) -> Option<PathBuf> {
452 if arguments.get(subcommand_index)?.as_encoded_bytes() != b"worktree" {
453 return None;
454 }
455 let word_index: usize = subcommand_index + 1;
456 if arguments.get(word_index)?.as_encoded_bytes() != b"add" {
457 return None;
458 }
459 let operands: Vec<&OsString> = copy_add_operands(&arguments[word_index + 1..])?;
460 return creating_destination(start, operands[0]);
461}
462
463/// Plan controls, with a real Git control, stay out of the release executable.
464#[cfg(test)]
465#[path = "worktree_add_plan_tests.rs"]
466mod tests;