1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
//! What:
//! The plan for one `git worktree add` the cheap-worktree ladder may serve: where the new worktree goes and where in
//! the argument list a rung inserts `--no-checkout`, or nothing when the invocation uses a form the ladder does not
//! know.
//! Why:
//! The ladder runs real Git once with the person's arguments, so it only needs the destination path and a promise that
//! every other word means what the table says. An option the table does not list, a bundled short flag, a global
//! option that changes how Git reads configuration, or a destination whose parent does not exist makes the whole
//! invocation a plain Git run, which is always correct.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const plan = parseAddPlan(['-C', 'repo', 'worktree', 'add', '-b', 'topic', '../topic'], 1, cwd); // undefined: plain Git
//! ```
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
/// What:
/// The facts of a `git worktree add` the ladder serves.
/// `usize` is an unsigned size or index; `PathBuf` is an owned file-system path.
/// Why:
/// The destination decides the subvolume to create and the worktree to settle; the insertion index decides where
/// `--no-checkout` goes in the arguments real Git receives.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type AddPlan = { destination: string; insertAt: number; quiet: boolean };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AddPlan {
/// The destination as Git records it: the real path of its parent joined with its last component.
pub destination: PathBuf,
/// The index in the full argument list where options of `add` may be inserted: directly after the word `add`.
pub insert_at: usize,
/// Whether the person passed `--quiet`, which silences the line Git's checkout prints.
pub quiet: bool,
}
/// The options of `add` that take no value and keep the checkout of a new worktree the ordinary one.
const FLAG_OPTIONS: &[&[u8]] = &[b"--detach", b"-d", b"--quiet", b"-q", b"--lock", b"--checkout"];
/// The options of `add` whose value is the next argument.
const VALUE_OPTIONS: &[&[u8]] = &[b"-b", b"--reason"];
/// The global options that cannot change how the ladder's own Git runs read the repository.
const HARMLESS_GLOBALS: &[&[u8]] = &[b"--no-pager", b"-P", b"--paginate", b"-p", b"--no-advice", b"--no-lazy-fetch"];
/// What:
/// The directory a command starts in: the process directory moved by every `-C <dir>` of the global prefix, or nothing
/// when the prefix holds an option the ladder does not serve.
/// Why:
/// Git changes directory at each `-C` in turn, and a relative destination is relative to the result. A `..`
/// component in a `-C` value is declined, because following it lexically differs from Git's directory changes
/// through a symbolic link.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function startDirectory(prefix: string[], cwd: string): string | undefined;
/// ```
pub fn start_directory(global_prefix: &[OsString], current: &Path) -> Option<PathBuf> {
let mut start: PathBuf = current.to_path_buf();
let mut tokens = global_prefix.iter();
while let Some(token) = tokens.next() {
let bytes: &[u8] = token.as_encoded_bytes();
if bytes == b"-C" {
let target: &OsString = tokens.next()?;
if Path::new(target).components().any(|part| { return part == std::path::Component::ParentDir; }) {
return None;
}
if !target.is_empty() {
start = start.join(target);
}
} else if !HARMLESS_GLOBALS.contains(&bytes) {
return None;
}
}
return Some(start);
}
/// What:
/// The real path of a destination as Git records it: the canonical parent joined with the last component.
/// Why:
/// Git stores `strbuf_add_real_path` of the new worktree after creating its parent directories; the ladder serves
/// only a parent that exists, so no directory has to be guessed. A last component of `.` or `..`, or one that is already
/// a symbolic link, is declined.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function recordedDestination(start: string, operand: string): string | undefined;
/// ```
fn recorded_destination(start: &Path, operand: &OsStr) -> Option<PathBuf> {
use std::os::unix::ffi::OsStrExt;
let mut trimmed: &[u8] = operand.as_bytes();
while let Some(rest) = trimmed.strip_suffix(b"/") {
trimmed = rest;
}
let last: &[u8] = trimmed.rsplit(|byte| { return *byte == b'/'; }).next()?;
if last.is_empty() || last == b"." || last == b".." {
return None;
}
let joined: PathBuf = start.join(OsStr::from_bytes(trimmed));
let parent: PathBuf = std::fs::canonicalize(joined.parent()?).ok()?;
let recorded: PathBuf = parent.join(OsStr::from_bytes(last));
// Git records the resolved path of a destination that is a link, so the directory Git fills and the path the ladder
// would swap differ: decline, and let Git run alone.
if std::fs::symlink_metadata(recorded.as_path()).is_ok_and(|found: std::fs::Metadata| { return found.file_type().is_symlink(); }) {
return None;
}
return Some(recorded);
}
/// What:
/// Read the arguments after `add`: the operands and whether `--quiet` was given, or nothing when any option is outside
/// the table.
/// Why:
/// `--` ends the options. An operand count other than one or two is a usage error that Git words itself.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function addOperands(region: string[]): { operands: string[]; quiet: boolean } | undefined;
/// ```
fn add_operands(region: &[OsString]) -> Option<(Vec<&OsString>, bool)> {
let mut operands: Vec<&OsString> = Vec::new();
let mut quiet: bool = false;
let mut tokens = region.iter();
let mut options_ended: bool = false;
while let Some(token) = tokens.next() {
let bytes: &[u8] = token.as_encoded_bytes();
if options_ended || !bytes.starts_with(b"-") || bytes == b"-" {
operands.push(token);
} else if bytes == b"--" {
options_ended = true;
} else if VALUE_OPTIONS.contains(&bytes) {
tokens.next()?;
} else if bytes == b"-q" || bytes == b"--quiet" {
quiet = true;
} else if !bytes.starts_with(b"--reason=") && !FLAG_OPTIONS.contains(&bytes) {
return None;
}
}
return if (1..=2).contains(&operands.len()) { Some((operands, quiet)) } else { None };
}
/// What:
/// Plan one invocation: `arguments` is the whole argument list, `subcommand_index` the index of `worktree` in it, and
/// `start` the directory the command starts in.
/// Why:
/// The word directly after `worktree` must be `add` itself, because Git reads the subcommand there and never
/// abbreviates it.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function parseAddPlan(args: string[], subcommandIndex: number, start: string): AddPlan | undefined;
/// ```
pub fn parse_add_plan(arguments: &[OsString], subcommand_index: usize, start: &Path) -> Option<AddPlan> {
if arguments.get(subcommand_index)?.as_encoded_bytes() != b"worktree" {
return None;
}
let word_index: usize = subcommand_index + 1;
if arguments.get(word_index)?.as_encoded_bytes() != b"add" {
return None;
}
let (operands, quiet): (Vec<&OsString>, bool) = add_operands(&arguments[word_index + 1..])?;
let destination: PathBuf = recorded_destination(start, operands[0])?;
return Some(AddPlan { destination, insert_at: word_index + 1, quiet });
}
/// Plan controls, with a real Git control, stay out of the release executable.
#[cfg(test)]
#[path = "worktree_add_plan_tests.rs"]
mod tests;