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
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
//! Is there a git repository beneath this directory? (#7673)
//!
//! Why: seeding a `CLAUDE.md` into a WORKSPACE PARENT injects it into every
//! project beneath it, so the seed guard must know whether a child repository
//! exists. A scan that stops early and answers "none" makes the guard seed
//! exactly the directory it exists to protect (#7673 round 2 review,
//! CRITICAL), so the answer has three outcomes, not two.
//! What: [`scan_for_child_repo`], a bounded breadth-first walk returning
//! [`ChildRepoScan`]. It is `pub` rather than `pub(crate)` because its natural
//! second consumer, `commands::auto_git_init::ensure_git_repo` (#6274), lives
//! in the `tm` bin target, which sees this library only through its public API.
//! Test: `child_repo_scan_tests.rs`.
use std::collections::VecDeque;
use std::fs::FileType;
use std::io;
use std::path::{Path, PathBuf};
/// Bound on the downward scan: directories visited, not depth.
///
/// Why: a depth bound misses a scoped package three levels down; a visit
/// budget reaches any depth while staying cheap on an ordinary tree.
/// Exhausting it is reported as [`ChildRepoScan::Incomplete`], never as
/// absence (#7673).
/// Test: `a_directory_wider_than_the_budget_is_incomplete`.
pub const WORKSPACE_SCAN_BUDGET: usize = 256;
/// Directory names the scan checks shallowly instead of descending.
///
/// Why: vendor, dependency, build and cache trees are the widest directories
/// in a workspace and the least likely to hold a project. Descending them spent
/// the whole budget before a real child repository was dequeued (#7673 round 2
/// review, HIGH). A skip-listed directory can still BE a repository, or hold
/// one as an immediate child (a vendored git submodule), so it is checked
/// shallowly (#7673 round 3 review, CRITICAL).
/// What: a skip-listed directory is not charged to [`WORKSPACE_SCAN_BUDGET`];
/// its own `.git` and each immediate child's `.git` are checked, and nothing
/// below its immediate children is. `.git` is listed because a repository's
/// own object store never holds a sibling project.
/// Test: `a_wide_node_modules_does_not_hide_a_child_repository`,
/// `a_repository_under_a_skip_listed_name_is_found`,
/// `a_repository_that_is_a_skip_listed_directory_is_found`.
pub const SCAN_SKIP_DIRS: &[&str] = &[
"node_modules",
"target",
".venv",
"venv",
"vendor",
".git",
"__pycache__",
"dist",
"build",
".next",
".cache",
];
/// Most immediate children of one skip-listed directory the shallow check
/// probes.
///
/// Why: the shallow check is not charged to [`WORKSPACE_SCAN_BUDGET`], so it
/// needs its own bound, and `node_modules` can hold thousands of packages.
/// 1024 probes cost one `lstat` each, a few milliseconds, and cover a typical
/// hoisted `node_modules`, a Go `vendor/` or a `target/`. A wider directory is
/// [`ChildRepoScan::Incomplete`], never [`ChildRepoScan::Clear`] (#7673).
/// What: counts directory and symlink children only; a plain file cannot hold
/// a `.git` and is not probed.
/// Test: `a_skip_listed_directory_wider_than_its_check_cap_is_incomplete`.
pub const SKIP_DIR_CHECK_CAP: usize = 1024;
/// The outcome of [`scan_for_child_repo`].
///
/// Why: only [`ChildRepoScan::Clear`] proves there is no child repository;
/// a caller must treat [`ChildRepoScan::Incomplete`] as "could not rule one
/// out", never as clear (#7673).
/// Test: `a_directory_wider_than_the_budget_is_incomplete`,
/// `an_unreadable_child_directory_is_incomplete`,
/// `clear_is_constructed_in_one_place`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChildRepoScan {
/// A directory beneath the root contains a `.git` entry.
Found(PathBuf),
/// Every directory beneath the root outside a skip-listed tree was checked,
/// as was every skip-listed directory and its immediate children; none is a
/// repository. Symlinks are checked but never descended.
Clear,
/// The scan stopped before checking everything.
Incomplete(ScanIncomplete),
}
/// Why a [`ChildRepoScan::Incomplete`] scan stopped.
///
/// What: the I/O error is kept as text so the value stays `Clone + Eq` and
/// can ride inside [`crate::core::claude_md_seed::SeedRefusal`].
/// Test: `the_scan_incomplete_refusal_tells_the_operator_how_to_proceed`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScanIncomplete {
/// More than [`WORKSPACE_SCAN_BUDGET`] directories needed checking.
BudgetExhausted,
/// A skip-listed directory held more than [`SKIP_DIR_CHECK_CAP`]
/// directories or symlinks to check.
SkipDirTooWide {
/// The skip-listed directory.
path: PathBuf,
},
/// Reading or stat-ing `path` failed: permission denied, or it vanished
/// mid-walk.
Unreadable {
/// The directory or entry that could not be read.
path: PathBuf,
/// The I/O error, rendered.
error: String,
},
}
impl std::fmt::Display for ScanIncomplete {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BudgetExhausted => write!(
f,
"the scan stopped after checking {WORKSPACE_SCAN_BUDGET} directories"
),
Self::SkipDirTooWide { path } => write!(
f,
"{} holds more than {SKIP_DIR_CHECK_CAP} directories to check",
path.display()
),
Self::Unreadable { path, error } => {
write!(f, "{} could not be read: {error}", path.display())
}
}
}
}
/// Scan `dir`'s subtree breadth-first for a child git repository.
///
/// What: each real directory beneath `dir` is charged to
/// [`WORKSPACE_SCAN_BUDGET`] and checked for a `.git` entry; a repository is
/// returned without descending, anything else is queued. Names in
/// [`SCAN_SKIP_DIRS`] are checked shallowly and uncharged. A symlink is never
/// descended (no cycle, no escape to `/` or `$HOME`), but a symlink whose
/// target holds a `.git` still counts as [`ChildRepoScan::Found`]. Budget
/// exhaustion, an over-wide skip-listed directory and every I/O error return
/// [`ChildRepoScan::Incomplete`]. A `dir` that does not exist at all queues
/// nothing, so it ends as [`ChildRepoScan::Clear`]. `Clear` is built in one
/// place: after the queue is empty.
/// Test: `a_wide_node_modules_does_not_hide_a_child_repository`,
/// `a_root_that_does_not_exist_yet_is_clear`,
/// `a_directory_wider_than_the_budget_is_incomplete`,
/// `an_unreadable_child_directory_is_incomplete`,
/// `a_symlink_cycle_is_not_descended`,
/// `a_symlink_to_an_outside_directory_is_not_traversed`,
/// `a_symlink_to_a_repository_counts_as_found`,
/// `an_unreadable_symlink_target_is_incomplete`,
/// `a_skip_listed_directory_wider_than_its_check_cap_is_incomplete`,
/// `clear_is_constructed_in_one_place`.
pub fn scan_for_child_repo(dir: &Path) -> ChildRepoScan {
scan(dir, None)
}
/// [`scan_for_child_repo`], with one entry of the tree left out.
///
/// Why: #8878 R1 — `tm fleet init` accepts one private repository at
/// `<dir>/local` and checks it on its own, but every other repository beneath
/// `dir` must still be found, so the scan cannot stop at `local`.
/// What: the entry whose path equals `except` is neither reported, probed,
/// charged to [`WORKSPACE_SCAN_BUDGET`], nor descended. Everything else is as
/// [`scan_for_child_repo`].
/// Test: `an_excepted_entry_is_skipped_and_its_siblings_are_still_found`.
pub fn scan_for_child_repo_except(dir: &Path, except: &Path) -> ChildRepoScan {
scan(dir, Some(except))
}
fn scan(dir: &Path, except: Option<&Path>) -> ChildRepoScan {
let mut queue = VecDeque::from([dir.to_path_buf()]);
let mut visited = 0usize;
while let Some(current) = queue.pop_front() {
match scan_level(dir, ¤t, except, &mut visited, &mut queue) {
Ok(None) => {}
Ok(Some(found)) => return ChildRepoScan::Found(found),
Err(stop) => return ChildRepoScan::Incomplete(stop),
}
}
// #7673: the only construction of `Clear`; every queued directory was checked.
ChildRepoScan::Clear
}
/// Check every entry of `current`, queueing the real directories to descend.
///
/// What: `Ok(None)` means every entry was checked, not that the scan is clear;
/// `Ok(Some)` is a repository; `Err` is "could not finish".
fn scan_level(
root: &Path,
current: &Path,
except: Option<&Path>,
visited: &mut usize,
queue: &mut VecDeque<PathBuf>,
) -> Result<Option<PathBuf>, ScanIncomplete> {
let children = match std::fs::read_dir(current) {
Ok(children) => children,
// #7673: a root that does not exist yet (a first-touch seed site the
// pipeline creates) queues nothing. A CHILD that vanished mid-walk is
// still incomplete.
Err(e) if e.kind() == io::ErrorKind::NotFound && current == root => return Ok(None),
Err(e) => return Err(unreadable(current, e)),
};
for entry in children {
let entry = entry.map_err(|e| unreadable(current, e))?;
let path = entry.path();
// #8878 R1: the caller checks the excepted entry itself.
if except == Some(path.as_path()) {
continue;
}
// `DirEntry::file_type` does not follow symlinks.
let kind = entry.file_type().map_err(|e| unreadable(&path, e))?;
let skip_listed = kind.is_dir() && SCAN_SKIP_DIRS.iter().any(|s| entry.file_name() == *s);
if kind.is_dir() && !skip_listed {
*visited += 1;
if *visited > WORKSPACE_SCAN_BUDGET {
return Err(ScanIncomplete::BudgetExhausted);
}
}
match probe(&path, kind)? {
Probe::Repository => return Ok(Some(path)),
Probe::NotDirectory => {}
// #7673: never descend a symlink; `probe` already checked its `.git`.
Probe::Directory if kind.is_symlink() => {}
// #7673: a skip-listed name is still checked, just not descended.
Probe::Directory if skip_listed => {
if let Some(found) = check_skip_listed(&path)? {
return Ok(Some(found));
}
}
Probe::Directory => queue.push_back(path),
}
}
Ok(None)
}
/// Check a skip-listed directory's immediate children without descending them.
///
/// What: the caller already probed `dir` itself. Each directory or symlink
/// child is probed; plain files are not. More than [`SKIP_DIR_CHECK_CAP`] such
/// children is [`ScanIncomplete::SkipDirTooWide`].
fn check_skip_listed(dir: &Path) -> Result<Option<PathBuf>, ScanIncomplete> {
let children = std::fs::read_dir(dir).map_err(|e| unreadable(dir, e))?;
let mut probed = 0usize;
for entry in children {
let entry = entry.map_err(|e| unreadable(dir, e))?;
let path = entry.path();
let kind = entry.file_type().map_err(|e| unreadable(&path, e))?;
if !kind.is_dir() && !kind.is_symlink() {
continue;
}
probed += 1;
if probed > SKIP_DIR_CHECK_CAP {
return Err(ScanIncomplete::SkipDirTooWide {
path: dir.to_path_buf(),
});
}
if let Probe::Repository = probe(&path, kind)? {
return Ok(Some(path));
}
}
Ok(None)
}
/// What one directory entry is, as far as a repository check cares.
enum Probe {
/// A directory (or a symlink to one) holding a `.git` entry.
Repository,
/// A directory (or a symlink to one) with no `.git` entry.
Directory,
/// Not a directory: a file, or a symlink to a file or to nothing.
NotDirectory,
}
/// Classify one entry without descending it; every stat error that is not
/// `NotFound` stops the scan.
///
/// What: a symlink is resolved with `metadata` first; a target that is not a
/// directory, or does not exist, cannot hold a `.git`. A directory, or a
/// symlink to one, is then checked with `symlink_metadata` on its `.git`:
/// `Ok` is a repository, `NotFound` is a plain directory.
fn probe(path: &Path, kind: FileType) -> Result<Probe, ScanIncomplete> {
if kind.is_symlink() {
match std::fs::metadata(path) {
Ok(target) if target.is_dir() => {}
Ok(_) => return Ok(Probe::NotDirectory),
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Probe::NotDirectory),
Err(e) => return Err(unreadable(path, e)),
}
} else if !kind.is_dir() {
return Ok(Probe::NotDirectory);
}
// #7673: `.exists()` folds a stat error into "absent"; map all three ways.
match std::fs::symlink_metadata(path.join(".git")) {
Ok(_) => Ok(Probe::Repository),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Probe::Directory),
Err(e) => Err(unreadable(path, e)),
}
}
/// An [`ScanIncomplete::Unreadable`] for `path`.
fn unreadable(path: &Path, err: io::Error) -> ScanIncomplete {
ScanIncomplete::Unreadable {
path: path.to_path_buf(),
error: err.to_string(),
}
}
#[cfg(test)]
#[path = "child_repo_scan_tests.rs"]
mod tests;