git_policy_cli/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 super::transaction_git::{GitContext, GitRequest, run_git};
16use super::worktree_copy_error::{WorktreeCopyError, raw_failure};
17use crate::json_record::quote;
18use std::cmp::Ordering;
19use std::path::{Path, PathBuf};
20
21/// What:
22/// The Git directory marker that `ls-files --directory` appends to ignored directories.
23/// Why:
24/// The copy works on the directory itself, so the marker is removed before validation.
25///
26/// In TS you'd write (pseudocode):
27/// ```ts
28/// const DIRECTORY_SUFFIX = '/';
29/// ```
30const DIRECTORY_SUFFIX: char = '/';
31
32/// What:
33/// The private stage prefix the copy reserves; no selected path may contain a component that starts with it.
34/// Why:
35/// A selected path that is a private stage would copy the copy into itself.
36///
37/// In TS you'd write (pseudocode):
38/// ```ts
39/// export const STAGE_PREFIX = '.cli-git-worktree-copy-';
40/// ```
41pub const STAGE_PREFIX: &str = ".cli-git-worktree-copy-";
42
43/// What:
44/// Whether a repository path equals or descends from another repository path.
45/// Why:
46/// Comparison is by whole components, so `cache2` is not inside `cache`.
47///
48/// In TS you'd write (pseudocode):
49/// ```ts
50/// (candidate === parent) || candidate.startsWith(`${parent}/`)
51/// ```
52pub fn repository_path_within(candidate: &str, parent: &str) -> bool {
53 // `||` keeps the exact-equality case and the component-prefix case in one expression.
54 return candidate == parent || candidate.starts_with(format!("{parent}/").as_str());
55}
56
57/// What:
58/// Validates one slash-separated path that Git reported before it is joined to a filesystem root.
59/// Why:
60/// An empty, absolute, `.` or `..` component could select a path outside the worktree.
61///
62/// In TS you'd write (pseudocode):
63/// ```ts
64/// function assertSafeRepositoryPath(repositoryPath: string): void;
65/// ```
66pub fn assert_safe_repository_path(repository_path: &str) -> Result<(), WorktreeCopyError> {
67 let unsafe_path: bool = repository_path.is_empty()
68 || repository_path.starts_with('/')
69 || Path::new(repository_path).is_absolute()
70 || repository_path
71 .split('/')
72 .any(|component| return component.is_empty() || component == "." || component == "..");
73 if unsafe_path {
74 return Err(WorktreeCopyError(format!(
75 "cli-git: Git returned unsafe ignored path {}.",
76 quote(repository_path)
77 )));
78 }
79 return Ok(());
80}
81
82/// What:
83/// The native path of one validated repository path under a filesystem root.
84/// Why:
85/// Every filesystem access of the copy goes through this join, so the validation cannot be skipped.
86///
87/// In TS you'd write (pseudocode):
88/// ```ts
89/// join(root, ...repositoryPath.split('/'));
90/// ```
91pub fn filesystem_path(root: &Path, repository_path: &str) -> Result<PathBuf, WorktreeCopyError> {
92 assert_safe_repository_path(repository_path)?;
93 let mut path: PathBuf = root.to_path_buf();
94 for component in repository_path.split('/') {
95 path.push(component);
96 }
97 return Ok(path);
98}
99
100/// What:
101/// Sorts strings the way JavaScript's default `toSorted` does: by UTF-16 code units.
102/// Why:
103/// Manifest and journal order must match the incumbent's, which differs from byte order
104/// only for supplementary-plane characters.
105///
106/// In TS you'd write (pseudocode):
107/// ```ts
108/// [...values].toSorted()
109/// ```
110pub fn js_order(left: &str, right: &str) -> Ordering {
111 return left.encode_utf16().cmp(right.encode_utf16());
112}
113
114/// What:
115/// Keeps the outermost of several ignored roots, dropping every root under another one.
116/// Why:
117/// Copying a parent already copies its descendants, so a second copy would duplicate work and entries.
118///
119/// In TS you'd write (pseudocode):
120/// ```ts
121/// function collapseIgnoredRoots(paths: readonly string[]): readonly string[];
122/// ```
123fn collapse_ignored_roots(paths: &[String]) -> Vec<String> {
124 let mut candidates: Vec<String> = paths.to_vec();
125 candidates.sort_by(|left, right| return js_order(left, right));
126 candidates.dedup();
127 let mut roots: Vec<String> = Vec::new();
128 for candidate in candidates {
129 let owned: bool = roots
130 .iter()
131 .any(|root| return repository_path_within(candidate.as_str(), root.as_str()));
132 if !owned {
133 roots.push(candidate);
134 }
135 }
136 return roots;
137}
138
139/// What:
140/// Whether Git's standard exclusion stack ignores one special entry.
141/// Why:
142/// `check-ignore --no-index` answers the same question for a path Git's listing never names;
143/// exit status 1 is "not ignored" and every other failure is fatal.
144///
145/// In TS you'd write (pseudocode):
146/// ```ts
147/// await runMetadataGit({ args: ['-C', sourceRoot, 'check-ignore', '--quiet', '--no-index', '--', repositoryPath] })
148/// ```
149fn is_ignored_special_path(
150 context: &GitContext,
151 source_root: &Path,
152 repository_path: &str,
153) -> Result<bool, WorktreeCopyError> {
154 let request: GitRequest = GitRequest {
155 cwd: source_root.to_path_buf(),
156 arguments: vec![
157 "check-ignore".into(),
158 "--quiet".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, 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 holds the repository's heavy ignored trees, so it hands over only what the caller names.
323///
324/// In TS you'd write (pseudocode):
325/// ```ts
326/// type CopySelection = { kind: 'ignored' } | { 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 /// Only these repository paths, and only the ones Git ignores and the source has.
333 Paths(Vec<String>),
334}
335
336/// What:
337/// The named repository paths that exist in the source and that Git's standard exclusion stack ignores.
338/// Why:
339/// A configured file such as the forbidden-strings rules is copied by name, so the source is never enumerated;
340/// a name Git does not list exactly (absent, tracked, not ignored, or a directory) selects nothing,
341/// which leaves the destination without it instead of inventing it.
342///
343/// In TS you'd write (pseudocode):
344/// ```ts
345/// async function readNamedIgnoredRoots({ sourceRoot, paths, gitPath }): Promise<readonly string[]>;
346/// ```
347pub fn read_named_ignored_roots(
348 context: &GitContext,
349 source_root: &Path,
350 paths: &[String],
351) -> Result<Vec<String>, WorktreeCopyError> {
352 let mut selected: Vec<String> = Vec::new();
353 for path in paths {
354 assert_safe_repository_path(path.as_str())?;
355 let request: GitRequest = GitRequest {
356 cwd: source_root.to_path_buf(),
357 arguments: vec![
358 "ls-files".into(),
359 "--others".into(),
360 "--ignored".into(),
361 "--exclude-standard".into(),
362 "-z".into(),
363 "--".into(),
364 // `top` anchors the name at the worktree root and `literal` turns off globbing and magic.
365 format!(":(top,literal){path}").into(),
366 ],
367 without_prefix: true,
368 ..GitRequest::default()
369 };
370 let output = run_git(context, &request).map_err(|error| return raw_failure(&error))?;
371 if output.code != Some(0) {
372 return Err(raw_failure(&String::from_utf8_lossy(
373 output.stderr.as_slice(),
374 )));
375 }
376 // Git lists a directory's files rather than the directory, so only an exact name is a file Git ignores.
377 let listed: bool = output
378 .stdout
379 .split(|byte| return *byte == 0)
380 .any(|field| return field == path.as_bytes());
381 if listed {
382 selected.push(path.clone());
383 }
384 }
385 return Ok(collapse_ignored_roots(selected.as_slice()));
386}
387
388/// What:
389/// The collapsed repository paths one copy takes from a source under the chosen selection.
390/// Why:
391/// Staging asks one question whichever source it copies from, so the selection mode is decided in one place.
392///
393/// In TS you'd write (pseudocode):
394/// ```ts
395/// const roots = selection.kind === 'ignored' ? await readIgnoredRoots(...) : await readNamedIgnoredRoots(...);
396/// ```
397pub fn select_roots(
398 context: &GitContext,
399 source_root: &Path,
400 excluded_roots: &[PathBuf],
401 selection: &CopySelection,
402) -> Result<Vec<String>, WorktreeCopyError> {
403 // `match` on the selection: the full listing for linked and bare sources, the named lookup otherwise.
404 match selection {
405 CopySelection::Ignored => return read_ignored_roots(context, source_root, excluded_roots),
406 CopySelection::Paths(paths) => {
407 return read_named_ignored_roots(context, source_root, paths.as_slice());
408 }
409 }
410}
411
412/// Selection controls stay out of the release executable.
413#[cfg(test)]
414#[path = "worktree_copy_selection_tests.rs"]
415mod tests;