Skip to main content

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;