1use std::ffi::{OsStr, OsString};
5
6const GLOBAL_WITH_VALUE: &[&str] = &["-C", "-c", "--git-dir", "--work-tree", "--namespace"];
8
9const 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
24const 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
47const ADD_WITH_VALUE: &[&str] = &["--reason"];
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct AddCommand {
53 pub globals: Vec<OsString>,
55 pub passthrough: Vec<OsString>,
57 pub double_dash: Option<usize>,
59 pub path: OsString,
61 pub commit_ish: Option<OsString>,
63 pub quiet: bool,
64 pub checkout: bool,
66 pub orphan: bool,
67 pub no_cow: bool,
69}
70
71impl AddCommand {
72 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 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#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum Invocation {
96 Add(Box<AddCommand>),
98 Delegate {
100 git_args: Vec<OsString>,
101 reason: &'static str,
102 },
103 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
113fn 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
121fn 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
149pub 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
170struct 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}