git_policy_cli/worktree_copying/worktree_copy_selection.rs
1//! What:
2//! Which source paths the linked-worktree copy selects:
3//! the ignored roots Git reports under the standard exclusion stack,
4//! the ignored special entries (sockets, FIFOs, devices) Git's listing omits,
5//! and the validation every repository path passes before it touches the filesystem.
6//! Why:
7//! Git decides what is ignored; the copy only adds the entries Git's index-oriented listing
8//! cannot report, and it never trusts a path that could leave the worktree.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const roots = await readIgnoredRoots({ sourceRoot, gitPath, excludedRoots });
13//! ```
14
15use crate::transaction_git::{GitContext, GitRequest, run_git};
16use crate::worktree_copy_prune::Prune;
17use crate::worktree_copy_error::{WorktreeCopyError, raw_failure};
18use crate::json_record::quote;
19use std::cmp::Ordering;
20use std::path::{Path, PathBuf};
21
22/// What:
23/// The Git directory marker that `ls-files --directory` appends to ignored directories.
24/// Why:
25/// The copy works on the directory itself, so the marker is removed before validation.
26///
27/// In TS you'd write (pseudocode):
28/// ```ts
29/// const DIRECTORY_SUFFIX = '/';
30/// ```
31const DIRECTORY_SUFFIX: char = '/';
32
33/// What:
34/// The private stage prefix the copy reserves; no selected path may contain a component that starts with it.
35/// Why:
36/// A selected path that is a private stage would copy the copy into itself.
37///
38/// In TS you'd write (pseudocode):
39/// ```ts
40/// export const STAGE_PREFIX = '.cli-git-worktree-copy-';
41/// ```
42pub const STAGE_PREFIX: &str = ".cli-git-worktree-copy-";
43
44/// What:
45/// Whether a repository path equals or descends from another repository path.
46/// Why:
47/// Comparison is by whole components, so `cache2` is not inside `cache`.
48///
49/// In TS you'd write (pseudocode):
50/// ```ts
51/// (candidate === parent) || candidate.startsWith(`${parent}/`)
52/// ```
53pub fn repository_path_within(candidate: &str, parent: &str) -> bool {
54 // `||` keeps the exact-equality case and the component-prefix case in one expression.
55 return candidate == parent || candidate.starts_with(format!("{parent}/").as_str());
56}
57
58/// What:
59/// Validates one slash-separated path that Git reported before it is joined to a filesystem root.
60/// Why:
61/// An empty, absolute, `.` or `..` component could select a path outside the worktree.
62///
63/// In TS you'd write (pseudocode):
64/// ```ts
65/// function assertSafeRepositoryPath(repositoryPath: string): void;
66/// ```
67pub fn assert_safe_repository_path(repository_path: &str) -> Result<(), WorktreeCopyError> {
68 let unsafe_path: bool = repository_path.is_empty()
69 || repository_path.starts_with('/')
70 || Path::new(repository_path).is_absolute()
71 || repository_path
72 .split('/')
73 .any(|component| return component.is_empty() || component == "." || component == "..");
74 if unsafe_path {
75 return Err(WorktreeCopyError(format!(
76 "cli-git: Git returned unsafe ignored path {}.",
77 quote(repository_path)
78 )));
79 }
80 return Ok(());
81}
82
83/// What:
84/// The native path of one validated repository path under a filesystem root.
85/// Why:
86/// Every filesystem access of the copy goes through this join, so the validation cannot be skipped.
87///
88/// In TS you'd write (pseudocode):
89/// ```ts
90/// join(root, ...repositoryPath.split('/'));
91/// ```
92pub fn filesystem_path(root: &Path, repository_path: &str) -> Result<PathBuf, WorktreeCopyError> {
93 assert_safe_repository_path(repository_path)?;
94 let mut path: PathBuf = root.to_path_buf();
95 for component in repository_path.split('/') {
96 path.push(component);
97 }
98 return Ok(path);
99}
100
101/// What:
102/// Sorts strings the way JavaScript's default `toSorted` does: by UTF-16 code units.
103/// Why:
104/// Manifest and journal order must match the incumbent's, which differs from byte order
105/// only for supplementary-plane characters.
106///
107/// In TS you'd write (pseudocode):
108/// ```ts
109/// [...values].toSorted()
110/// ```
111pub fn js_order(left: &str, right: &str) -> Ordering {
112 return left.encode_utf16().cmp(right.encode_utf16());
113}
114
115/// What:
116/// Keeps the outermost of several ignored roots, dropping every root under another one.
117/// Why:
118/// Copying a parent already copies its descendants, so a second copy would duplicate work and entries.
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// function collapseIgnoredRoots(paths: readonly string[]): readonly string[];
123/// ```
124fn collapse_ignored_roots(paths: &[String]) -> Vec<String> {
125 let mut candidates: Vec<String> = paths.to_vec();
126 candidates.sort_by(|left, right| return js_order(left, right));
127 candidates.dedup();
128 let mut roots: Vec<String> = Vec::new();
129 for candidate in candidates {
130 let owned: bool = roots
131 .iter()
132 .any(|root| return repository_path_within(candidate.as_str(), root.as_str()));
133 if !owned {
134 roots.push(candidate);
135 }
136 }
137 return roots;
138}
139
140/// What:
141/// Whether Git's standard exclusion stack ignores one special entry.
142/// Why:
143/// `check-ignore --no-index` answers the same question for a path Git's listing never names;
144/// exit status 1 is "not ignored" and every other failure is fatal.
145///
146/// In TS you'd write (pseudocode):
147/// ```ts
148/// await runMetadataGit({ args: ['-C', sourceRoot, 'check-ignore', '--no-index', '--', repositoryPath] })
149/// ```
150fn is_ignored_special_path(
151 context: &GitContext,
152 source_root: &Path,
153 repository_path: &str,
154) -> Result<bool, WorktreeCopyError> {
155 let request: GitRequest = GitRequest {
156 cwd: source_root.to_path_buf(),
157 arguments: vec![
158 "check-ignore".into(),
159 "--no-index".into(),
160 "--".into(),
161 repository_path.into(),
162 ],
163 without_prefix: true,
164 ..GitRequest::default()
165 };
166 let output = run_git(context, &request).map_err(|error| return raw_failure(&error))?;
167 // `match` on the exit code: 0 is ignored, 1 is not ignored, anything else is a failure.
168 match output.code {
169 Some(0) => return Ok(true),
170 Some(1) => return Ok(false),
171 _ => {
172 return Err(raw_failure(&String::from_utf8_lossy(
173 output.stderr.as_slice(),
174 )));
175 }
176 }
177}
178
179/// What:
180/// Whether a native path lies at or under one of the excluded absolute directories.
181/// Why:
182/// Walks skip registered worktrees and the private stage, so they never copy themselves.
183///
184/// In TS you'd write (pseudocode):
185/// ```ts
186/// isFilesystemPathWithin({ candidate, parent })
187/// ```
188fn is_excluded(candidate: &Path, excluded_roots: &[PathBuf]) -> bool {
189 return excluded_roots
190 .iter()
191 .any(|root| return candidate.starts_with(root.as_path()));
192}
193
194/// What:
195/// One pending directory of the special-entry walk, with its repository path.
196/// Why:
197/// The walk is an explicit stack so depth cannot exhaust the call stack.
198///
199/// In TS you'd write (pseudocode):
200/// ```ts
201/// const pending: string[] = ['.'];
202/// ```
203fn special_entries(
204 context: &GitContext,
205 source_root: &Path,
206 selected_roots: &[String],
207 excluded_roots: &[PathBuf],
208) -> Result<Vec<String>, WorktreeCopyError> {
209 let mut pending: Vec<String> = vec![String::from(".")];
210 let mut special_paths: Vec<String> = Vec::new();
211 while let Some(current) = pending.pop() {
212 let directory_path: PathBuf = if current == "." {
213 source_root.to_path_buf()
214 } else {
215 filesystem_path(source_root, current.as_str())?
216 };
217 let listing = std::fs::read_dir(directory_path.as_path())
218 .map_err(|error| return raw_failure(&error))?;
219 for listed in listing {
220 let entry: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
221 let name: String = entry.file_name().to_string_lossy().into_owned();
222 let repository_path: String = if current == "." {
223 name
224 } else {
225 format!("{current}/{name}")
226 };
227 if repository_path == ".git" {
228 continue;
229 }
230 let source_path: PathBuf = filesystem_path(source_root, repository_path.as_str())?;
231 if is_excluded(source_path.as_path(), excluded_roots) {
232 continue;
233 }
234 let selected_ancestor: bool = selected_roots
235 .iter()
236 .any(|root| return repository_path_within(repository_path.as_str(), root.as_str()));
237 if selected_ancestor {
238 continue;
239 }
240 let file_type: std::fs::FileType = entry
241 .file_type()
242 .map_err(|error| return raw_failure(&error))?;
243 // A directory is walked; a file or symbolic link is already selected by Git's listing;
244 // anything else is a special entry that needs Git's own ignore answer.
245 if file_type.is_dir() {
246 pending.push(repository_path);
247 } else if !file_type.is_file() && !file_type.is_symlink() {
248 special_paths.push(repository_path);
249 }
250 }
251 }
252 let mut ignored: Vec<String> = Vec::new();
253 for repository_path in special_paths {
254 if is_ignored_special_path(context, source_root, repository_path.as_str())? {
255 ignored.push(repository_path);
256 }
257 }
258 return Ok(ignored);
259}
260
261/// What:
262/// Every existing source path ignored by Git's standard exclusion stack,
263/// as collapsed repository paths.
264/// Why:
265/// This is the one selection the copy stages, so the same validation and collapse apply everywhere.
266///
267/// In TS you'd write (pseudocode):
268/// ```ts
269/// async function readIgnoredRoots({ sourceRoot, gitPath, excludedRoots }): Promise<readonly string[]>;
270/// ```
271pub fn read_ignored_roots(
272 context: &GitContext,
273 source_root: &Path,
274 excluded_roots: &[PathBuf],
275) -> Result<Vec<String>, WorktreeCopyError> {
276 let request: GitRequest = GitRequest {
277 cwd: source_root.to_path_buf(),
278 arguments: vec![
279 "ls-files".into(),
280 "--others".into(),
281 "--ignored".into(),
282 "--exclude-standard".into(),
283 "--directory".into(),
284 "-z".into(),
285 ],
286 without_prefix: true,
287 ..GitRequest::default()
288 };
289 let output = run_git(context, &request).map_err(|error| return raw_failure(&error))?;
290 if output.code != Some(0) {
291 return Err(raw_failure(&String::from_utf8_lossy(
292 output.stderr.as_slice(),
293 )));
294 }
295 let mut paths: Vec<String> = Vec::new();
296 for field in output.stdout.split(|byte| return *byte == 0) {
297 if field.is_empty() {
298 continue;
299 }
300 let text: String = String::from_utf8_lossy(field).into_owned();
301 // `strip_suffix` removes the directory marker only when present, as the incumbent does.
302 let path: String = match text.strip_suffix(DIRECTORY_SUFFIX) {
303 Some(stripped) => String::from(stripped),
304 None => text,
305 };
306 assert_safe_repository_path(path.as_str())?;
307 paths.push(path);
308 }
309 let selected: Vec<String> = paths.clone();
310 let special: Vec<String> =
311 special_entries(context, source_root, selected.as_slice(), excluded_roots)?;
312 let mut all: Vec<String> = paths;
313 all.extend(special);
314 return Ok(collapse_ignored_roots(all.as_slice()));
315}
316
317/// What:
318/// Which of a source's ignored paths one copy takes:
319/// every ignored root, every ignored root minus what a main checkout leaves out, or only the named repository paths.
320/// Why:
321/// A linked or bare source hands over all of its ignored state, as the incumbent does;
322/// a main checkout hands over the same minus its caches and every other checkout, so a worktree added from it works at once.
323///
324/// In TS you'd write (pseudocode):
325/// ```ts
326/// type CopySelection = { kind: 'ignored' } | { kind: 'main-checkout' } | { kind: 'paths'; paths: readonly string[] };
327/// ```
328#[derive(Clone, Debug, Eq, PartialEq)]
329pub enum CopySelection {
330 /// Every path Git's standard exclusion stack ignores, plus the ignored special entries.
331 Ignored,
332 /// The same listing, walked under the main checkout's leave-out rule (`worktree_copy_prune.rs`).
333 MainCheckout,
334 /// Only these repository paths, and only the ones Git ignores and the source has.
335 Paths(Vec<String>),
336}
337
338/// What:
339/// The named repository paths that exist in the source and that Git's standard exclusion stack ignores.
340/// Why:
341/// A configured file such as the forbidden-strings rules is copied by name, so the source is never enumerated;
342/// a name Git does not list exactly (absent, tracked, not ignored, or a directory) selects nothing,
343/// which leaves the destination without it instead of inventing it.
344///
345/// In TS you'd write (pseudocode):
346/// ```ts
347/// async function readNamedIgnoredRoots({ sourceRoot, paths, gitPath }): Promise<readonly string[]>;
348/// ```
349pub fn read_named_ignored_roots(
350 context: &GitContext,
351 source_root: &Path,
352 paths: &[String],
353) -> Result<Vec<String>, WorktreeCopyError> {
354 let mut selected: Vec<String> = Vec::new();
355 for path in paths {
356 assert_safe_repository_path(path.as_str())?;
357 let request: GitRequest = GitRequest {
358 cwd: source_root.to_path_buf(),
359 arguments: vec![
360 "ls-files".into(),
361 "--others".into(),
362 "--ignored".into(),
363 "--exclude-standard".into(),
364 "-z".into(),
365 "--".into(),
366 // `top` anchors the name at the worktree root and `literal` turns off globbing and magic.
367 format!(":(top,literal){path}").into(),
368 ],
369 without_prefix: true,
370 ..GitRequest::default()
371 };
372 let output = run_git(context, &request).map_err(|error| return raw_failure(&error))?;
373 if output.code != Some(0) {
374 return Err(raw_failure(&String::from_utf8_lossy(
375 output.stderr.as_slice(),
376 )));
377 }
378 // Git lists a directory's files rather than the directory, so only an exact name is a file Git ignores.
379 let listed: bool = output
380 .stdout
381 .split(|byte| return *byte == 0)
382 .any(|field| return field == path.as_bytes());
383 if listed {
384 selected.push(path.clone());
385 }
386 }
387 return Ok(collapse_ignored_roots(selected.as_slice()));
388}
389
390/// What:
391/// The collapsed repository paths one copy takes from a source under the chosen selection.
392/// Why:
393/// Staging asks one question whichever source it copies from, so the selection mode is decided in one place.
394///
395/// In TS you'd write (pseudocode):
396/// ```ts
397/// const roots = selection.kind === 'ignored' ? await readIgnoredRoots(...) : await readNamedIgnoredRoots(...);
398/// ```
399pub fn select_roots(
400 context: &GitContext,
401 source_root: &Path,
402 excluded_roots: &[PathBuf],
403 selection: &CopySelection,
404) -> Result<Vec<String>, WorktreeCopyError> {
405 // `match` on the selection: the full listing for linked and bare sources, the named lookup otherwise.
406 match selection {
407 CopySelection::Ignored | CopySelection::MainCheckout => {
408 return read_ignored_roots(context, source_root, excluded_roots);
409 }
410 CopySelection::Paths(paths) => {
411 return read_named_ignored_roots(context, source_root, paths.as_slice());
412 }
413 }
414}
415
416impl CopySelection {
417 /// What:
418 /// The leave-out rule the selection's walk applies.
419 /// Why:
420 /// The stage and the final source check must walk with the same rule, so both ask the selection.
421 ///
422 /// In TS you'd write (pseudocode):
423 /// ```ts
424 /// selection.kind === 'main-checkout' ? Prune.mainCheckout() : Prune.never
425 /// ```
426 pub fn prune(&self) -> Prune {
427 return match self {
428 CopySelection::MainCheckout => Prune::main_checkout(),
429 CopySelection::Ignored | CopySelection::Paths(_) => Prune::Never,
430 };
431 }
432}
433
434/// Selection controls stay out of the release executable.
435#[cfg(test)]
436#[path = "worktree_copy_selection_tests.rs"]
437mod tests;