Skip to main content

git_sprout/
argv.rs

1// ABOUTME: Parses the `git worktree add` command line and git's own global options.
2// ABOUTME: Anything it does not fully understand becomes a delegation, never an error.
3
4use std::ffi::{OsStr, OsString};
5
6/// Git's global options that take a separate value argument.
7const GLOBAL_WITH_VALUE: &[&str] = &["-C", "-c", "--git-dir", "--work-tree", "--namespace"];
8
9/// Git's global options that stand alone.
10const GLOBAL_STANDALONE: &[&str] = &[
11    "-p",
12    "--paginate",
13    "-P",
14    "--no-pager",
15    "--bare",
16    "--no-replace-objects",
17    "--literal-pathspecs",
18    "--glob-pathspecs",
19    "--noglob-pathspecs",
20    "--icase-pathspecs",
21    "--no-optional-locks",
22];
23
24/// `git worktree add` long options that stand alone.
25const ADD_STANDALONE: &[&str] = &[
26    "--force",
27    "--no-force",
28    "--detach",
29    "--no-detach",
30    "--checkout",
31    "--no-checkout",
32    "--orphan",
33    "--no-orphan",
34    "--lock",
35    "--no-lock",
36    "--no-reason",
37    "--quiet",
38    "--no-quiet",
39    "--track",
40    "--no-track",
41    "--guess-remote",
42    "--no-guess-remote",
43    "--relative-paths",
44    "--no-relative-paths",
45];
46
47/// `git worktree add` long options that take a value.
48const ADD_WITH_VALUE: &[&str] = &["--reason"];
49
50/// A `git worktree add` invocation parsed well enough to consider accelerating.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct AddCommand {
53    /// Git's global options, in order, to place before the `worktree` subcommand.
54    pub globals: Vec<OsString>,
55    /// The arguments for `git worktree add`, with sprout's own flags removed.
56    pub passthrough: Vec<OsString>,
57    /// Position of `--` within `passthrough`, if the user wrote one.
58    pub double_dash: Option<usize>,
59    /// The destination worktree path.
60    pub path: OsString,
61    /// The commit-ish to check out, when the user named one.
62    pub commit_ish: Option<OsString>,
63    pub quiet: bool,
64    /// False when `--no-checkout` was given.
65    pub checkout: bool,
66    pub orphan: bool,
67    /// True when the user asked for the plain `git worktree add` path.
68    pub no_cow: bool,
69}
70
71impl AddCommand {
72    /// The argument list that reproduces this request through `git` itself.
73    pub fn git_args(&self) -> Vec<OsString> {
74        let mut args = self.globals.clone();
75        args.push(OsString::from("worktree"));
76        args.push(OsString::from("add"));
77        args.extend(self.passthrough.iter().cloned());
78        args
79    }
80
81    /// The subcommand arguments for step 2, which creates the worktree without files.
82    /// The globals are left out; whoever runs git puts them in front.
83    pub fn worktree_add_args_no_checkout(&self) -> Vec<OsString> {
84        let mut args = vec![OsString::from("worktree"), OsString::from("add")];
85        let insert_at = self.double_dash.unwrap_or(self.passthrough.len());
86        args.extend(self.passthrough[..insert_at].iter().cloned());
87        args.push(OsString::from("--no-checkout"));
88        args.extend(self.passthrough[insert_at..].iter().cloned());
89        args
90    }
91}
92
93/// What the command line asks the tool to do.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum Invocation {
96    /// Understood; acceleration may be attempted.
97    Add(Box<AddCommand>),
98    /// Not understood. Run `git` with these arguments and exit with its status.
99    Delegate {
100        git_args: Vec<OsString>,
101        reason: &'static str,
102    },
103    /// Report the tool's own version.
104    Version,
105}
106
107fn delegate(args: &[OsString], reason: &'static str) -> Invocation {
108    let mut git_args = vec![OsString::from("worktree")];
109    git_args.extend(args.iter().cloned());
110    Invocation::Delegate { git_args, reason }
111}
112
113/// Splits `--name=value` into its two halves.
114fn split_assignment(token: &str) -> Option<(&str, &str)> {
115    token
116        .strip_prefix("--")
117        .and_then(|rest| rest.split_once('='))
118        .map(|(name, value)| (&token[..name.len() + 2], value))
119}
120
121/// Consumes git's global options from the front of `args`, returning them and the rest.
122fn take_globals(args: &[OsString]) -> Option<(Vec<OsString>, &[OsString])> {
123    let mut globals = Vec::new();
124    let mut rest = args;
125    while let Some(first) = rest.first() {
126        let Some(token) = first.to_str() else { break };
127        if !token.starts_with('-') {
128            break;
129        }
130        if GLOBAL_STANDALONE.contains(&token) {
131            globals.push(first.clone());
132            rest = &rest[1..];
133        } else if GLOBAL_WITH_VALUE.contains(&token) {
134            let value = rest.get(1)?;
135            globals.push(first.clone());
136            globals.push(value.clone());
137            rest = &rest[2..];
138        } else if split_assignment(token).is_some_and(|(name, _)| GLOBAL_WITH_VALUE.contains(&name))
139        {
140            globals.push(first.clone());
141            rest = &rest[1..];
142        } else {
143            return None;
144        }
145    }
146    Some((globals, rest))
147}
148
149/// Parses the argument tail the binary was invoked with.
150pub fn parse(args: &[OsString]) -> Invocation {
151    if args.len() == 1 && (args[0] == "--version" || args[0] == "-V") {
152        return Invocation::Version;
153    }
154
155    let Some((globals, rest)) = take_globals(args) else {
156        return delegate(args, "unrecognised git global option");
157    };
158
159    match rest.first().and_then(|arg| arg.to_str()) {
160        Some("add") => {}
161        _ => return delegate(args, "not a `worktree add` invocation"),
162    }
163
164    match parse_add(globals, &rest[1..]) {
165        Ok(add) => Invocation::Add(Box::new(add)),
166        Err(reason) => delegate(args, reason),
167    }
168}
169
170/// The state built up while walking the `add` arguments.
171struct AddParse {
172    passthrough: Vec<OsString>,
173    double_dash: Option<usize>,
174    positionals: Vec<OsString>,
175    quiet: bool,
176    checkout: bool,
177    orphan: bool,
178    no_cow: bool,
179}
180
181fn parse_add(globals: Vec<OsString>, args: &[OsString]) -> Result<AddCommand, &'static str> {
182    let mut state = AddParse {
183        passthrough: Vec::new(),
184        double_dash: None,
185        positionals: Vec::new(),
186        quiet: false,
187        checkout: true,
188        orphan: false,
189        no_cow: false,
190    };
191
192    let mut index = 0;
193    while index < args.len() {
194        let arg = &args[index];
195        index += 1;
196
197        if state.double_dash.is_some() {
198            state.positionals.push(arg.clone());
199            state.passthrough.push(arg.clone());
200            continue;
201        }
202
203        let Some(token) = arg.to_str() else {
204            state.positionals.push(arg.clone());
205            state.passthrough.push(arg.clone());
206            continue;
207        };
208
209        if token == "--" {
210            state.double_dash = Some(state.passthrough.len());
211            state.passthrough.push(arg.clone());
212            continue;
213        }
214
215        if token == "--no-cow" {
216            state.no_cow = true;
217            continue;
218        }
219
220        if token.starts_with("--") {
221            parse_long(&mut state, token, args, &mut index)?;
222            continue;
223        }
224
225        if token.len() > 1 && token.starts_with('-') {
226            parse_shorts(&mut state, arg, token, args, &mut index)?;
227            continue;
228        }
229
230        state.positionals.push(arg.clone());
231        state.passthrough.push(arg.clone());
232    }
233
234    if state.positionals.is_empty() {
235        return Err("no worktree path given");
236    }
237    if state.positionals.len() > 2 {
238        return Err("more positional arguments than `git worktree add` takes");
239    }
240
241    Ok(AddCommand {
242        globals,
243        passthrough: state.passthrough,
244        double_dash: state.double_dash,
245        path: state.positionals[0].clone(),
246        commit_ish: state.positionals.get(1).cloned(),
247        quiet: state.quiet,
248        checkout: state.checkout,
249        orphan: state.orphan,
250        no_cow: state.no_cow,
251    })
252}
253
254fn parse_long(
255    state: &mut AddParse,
256    token: &str,
257    args: &[OsString],
258    index: &mut usize,
259) -> Result<(), &'static str> {
260    if ADD_STANDALONE.contains(&token) {
261        match token {
262            "--quiet" => state.quiet = true,
263            "--no-quiet" => state.quiet = false,
264            "--no-checkout" => state.checkout = false,
265            "--checkout" => state.checkout = true,
266            "--orphan" => state.orphan = true,
267            "--no-orphan" => state.orphan = false,
268            _ => {}
269        }
270        state.passthrough.push(OsString::from(token));
271        return Ok(());
272    }
273
274    if ADD_WITH_VALUE.contains(&token) {
275        let value = args.get(*index).ok_or("long option is missing its value")?;
276        *index += 1;
277        state.passthrough.push(OsString::from(token));
278        state.passthrough.push(value.clone());
279        return Ok(());
280    }
281
282    if let Some((name, _)) = split_assignment(token) {
283        if ADD_WITH_VALUE.contains(&name) {
284            state.passthrough.push(OsString::from(token));
285            return Ok(());
286        }
287    }
288
289    Err("unrecognised `git worktree add` option")
290}
291
292fn parse_shorts(
293    state: &mut AddParse,
294    arg: &OsStr,
295    token: &str,
296    args: &[OsString],
297    index: &mut usize,
298) -> Result<(), &'static str> {
299    for (offset, flag) in token[1..].char_indices() {
300        match flag {
301            'f' => {}
302            'd' => {}
303            'q' => state.quiet = true,
304            'b' | 'B' => {
305                let sticky = &token[1 + offset + flag.len_utf8()..];
306                if sticky.is_empty() {
307                    let value = args
308                        .get(*index)
309                        .ok_or("short option is missing its value")?;
310                    *index += 1;
311                    state.passthrough.push(arg.to_os_string());
312                    state.passthrough.push(value.clone());
313                } else {
314                    state.passthrough.push(arg.to_os_string());
315                }
316                return Ok(());
317            }
318            _ => return Err("unrecognised `git worktree add` option"),
319        }
320    }
321    state.passthrough.push(arg.to_os_string());
322    Ok(())
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    fn argv(tokens: &[&str]) -> Vec<OsString> {
330        tokens.iter().map(OsString::from).collect()
331    }
332
333    fn strings(tokens: &[OsString]) -> Vec<String> {
334        tokens
335            .iter()
336            .map(|token| token.to_string_lossy().into_owned())
337            .collect()
338    }
339
340    fn add_of(tokens: &[&str]) -> AddCommand {
341        match parse(&argv(tokens)) {
342            Invocation::Add(add) => *add,
343            other => panic!("expected an add invocation, got {other:?}"),
344        }
345    }
346
347    fn delegation_reason(tokens: &[&str]) -> &'static str {
348        match parse(&argv(tokens)) {
349            Invocation::Delegate { reason, .. } => reason,
350            other => panic!("expected a delegation, got {other:?}"),
351        }
352    }
353
354    #[test]
355    fn takes_the_path_and_commit_ish() {
356        let add = add_of(&["add", "../wt", "v1.0"]);
357        assert_eq!(add.path, OsString::from("../wt"));
358        assert_eq!(add.commit_ish, Some(OsString::from("v1.0")));
359        assert!(add.checkout);
360        assert!(!add.quiet);
361    }
362
363    #[test]
364    fn keeps_git_options_in_the_passthrough() {
365        let add = add_of(&["add", "-b", "feature", "../wt"]);
366        assert_eq!(strings(&add.passthrough), ["-b", "feature", "../wt"]);
367        assert_eq!(
368            strings(&add.git_args()),
369            ["worktree", "add", "-b", "feature", "../wt"]
370        );
371    }
372
373    #[test]
374    fn removes_its_own_flag_from_the_passthrough() {
375        let add = add_of(&["add", "--no-cow", "../wt"]);
376        assert!(add.no_cow);
377        assert_eq!(strings(&add.passthrough), ["../wt"]);
378    }
379
380    #[test]
381    fn understands_bundled_and_sticky_short_options() {
382        let add = add_of(&["add", "-fq", "-bfeature", "../wt"]);
383        assert!(add.quiet);
384        assert_eq!(strings(&add.passthrough), ["-fq", "-bfeature", "../wt"]);
385    }
386
387    #[test]
388    fn understands_a_long_option_with_an_attached_value() {
389        let add = add_of(&["add", "--lock", "--reason=busy", "../wt"]);
390        assert_eq!(
391            strings(&add.passthrough),
392            ["--lock", "--reason=busy", "../wt"]
393        );
394    }
395
396    #[test]
397    fn understands_a_long_option_with_a_separate_value() {
398        let add = add_of(&["add", "--lock", "--reason", "busy", "../wt"]);
399        assert_eq!(
400            strings(&add.passthrough),
401            ["--lock", "--reason", "busy", "../wt"]
402        );
403    }
404
405    #[test]
406    fn records_no_checkout_and_orphan() {
407        assert!(!add_of(&["add", "--no-checkout", "../wt"]).checkout);
408        assert!(add_of(&["add", "--orphan", "../wt"]).orphan);
409    }
410
411    #[test]
412    fn a_later_checkout_flag_wins() {
413        assert!(add_of(&["add", "--no-checkout", "--checkout", "../wt"]).checkout);
414        assert!(!add_of(&["add", "--checkout", "--no-checkout", "../wt"]).checkout);
415    }
416
417    #[test]
418    fn inserts_no_checkout_before_a_double_dash() {
419        let add = add_of(&["add", "--", "../wt"]);
420        assert_eq!(
421            strings(&add.worktree_add_args_no_checkout()),
422            ["worktree", "add", "--no-checkout", "--", "../wt"]
423        );
424    }
425
426    #[test]
427    fn appends_no_checkout_when_there_is_no_double_dash() {
428        let add = add_of(&["add", "-b", "feature", "../wt"]);
429        assert_eq!(
430            strings(&add.worktree_add_args_no_checkout()),
431            ["worktree", "add", "-b", "feature", "../wt", "--no-checkout"]
432        );
433    }
434
435    #[test]
436    fn carries_git_global_options_ahead_of_the_subcommand() {
437        let add = add_of(&["-C", "/repo", "-c", "core.bare=false", "add", "../wt"]);
438        assert_eq!(
439            strings(&add.git_args()),
440            [
441                "-C",
442                "/repo",
443                "-c",
444                "core.bare=false",
445                "worktree",
446                "add",
447                "../wt"
448            ]
449        );
450    }
451
452    #[test]
453    fn an_unknown_option_is_a_delegation_not_an_error() {
454        assert_eq!(
455            delegation_reason(&["add", "--tomorrows-flag", "../wt"]),
456            "unrecognised `git worktree add` option"
457        );
458        assert_eq!(
459            delegation_reason(&["add", "-Z", "../wt"]),
460            "unrecognised `git worktree add` option"
461        );
462        assert_eq!(
463            delegation_reason(&["--tomorrows-global", "add", "../wt"]),
464            "unrecognised git global option"
465        );
466    }
467
468    #[test]
469    fn an_abbreviated_option_is_a_delegation() {
470        assert_eq!(
471            delegation_reason(&["add", "--deta", "../wt"]),
472            "unrecognised `git worktree add` option"
473        );
474    }
475
476    #[test]
477    fn a_delegation_reproduces_the_original_argv() {
478        match parse(&argv(&["add", "--tomorrows-flag", "../wt"])) {
479            Invocation::Delegate { git_args, .. } => assert_eq!(
480                strings(&git_args),
481                ["worktree", "add", "--tomorrows-flag", "../wt"]
482            ),
483            other => panic!("expected a delegation, got {other:?}"),
484        }
485    }
486
487    #[test]
488    fn other_subcommands_go_straight_to_git() {
489        assert_eq!(
490            delegation_reason(&["list"]),
491            "not a `worktree add` invocation"
492        );
493    }
494
495    #[test]
496    fn a_missing_or_extra_path_is_a_delegation() {
497        assert_eq!(delegation_reason(&["add"]), "no worktree path given");
498        assert_eq!(
499            delegation_reason(&["add", "a", "b", "c"]),
500            "more positional arguments than `git worktree add` takes"
501        );
502    }
503
504    #[test]
505    fn reports_its_version() {
506        assert_eq!(parse(&argv(&["--version"])), Invocation::Version);
507    }
508}