dump-shitty-claude-md 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1049 -0
- package/Cargo.toml +20 -0
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/bin/darwin-arm64/dump-shitty-claude-md +0 -0
- package/bin/darwin-x64/dump-shitty-claude-md +0 -0
- package/bin/dump-shitty-claude-md.js +26 -0
- package/bin/linux-arm64/dump-shitty-claude-md +0 -0
- package/bin/linux-x64/dump-shitty-claude-md +0 -0
- package/bin/win32-x64/dump-shitty-claude-md.exe +0 -0
- package/package.json +38 -0
- package/src/gitops.rs +336 -0
- package/src/main.rs +550 -0
- package/src/plan.rs +467 -0
- package/src/scan.rs +501 -0
package/src/scan.rs
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
use ignore::gitignore::{Gitignore, GitignoreBuilder};
|
|
3
|
+
use ignore::{WalkBuilder, WalkState};
|
|
4
|
+
use std::collections::{BTreeSet, HashMap};
|
|
5
|
+
use std::ffi::OsStr;
|
|
6
|
+
use std::path::{Path, PathBuf};
|
|
7
|
+
use std::sync::Mutex;
|
|
8
|
+
|
|
9
|
+
/// A vendor-specific instruction file the tool can consolidate into AGENTS.md.
|
|
10
|
+
/// `Claude` is always migrated; every other format is opt-in.
|
|
11
|
+
#[derive(
|
|
12
|
+
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, clap::ValueEnum, serde::Serialize,
|
|
13
|
+
)]
|
|
14
|
+
#[serde(rename_all = "kebab-case")]
|
|
15
|
+
#[value(rename_all = "kebab-case")]
|
|
16
|
+
pub enum Format {
|
|
17
|
+
/// CLAUDE.md — Claude Code (AGENTS.md fallback ≥2.1.277)
|
|
18
|
+
Claude,
|
|
19
|
+
/// .cursorrules — deprecated Cursor legacy file
|
|
20
|
+
Cursor,
|
|
21
|
+
/// .windsurfrules — Windsurf legacy file
|
|
22
|
+
Windsurf,
|
|
23
|
+
/// .clinerules — Cline single-file rules
|
|
24
|
+
Cline,
|
|
25
|
+
/// .github/copilot-instructions.md — GitHub Copilot repo-wide
|
|
26
|
+
Copilot,
|
|
27
|
+
/// GEMINI.md — Gemini CLI (migrated only when context.fileName allows AGENTS.md)
|
|
28
|
+
Gemini,
|
|
29
|
+
/// .rules — Zed legacy rules file
|
|
30
|
+
Zed,
|
|
31
|
+
/// CONVENTIONS.md / conventions.md — Aider
|
|
32
|
+
Aider,
|
|
33
|
+
/// .roorules — Roo Code legacy file
|
|
34
|
+
Roo,
|
|
35
|
+
/// .kilocoderules — Kilo Code legacy file
|
|
36
|
+
Kilo,
|
|
37
|
+
/// .goosehints — Goose (Block) hints file
|
|
38
|
+
Goose,
|
|
39
|
+
/// WARP.md — Warp terminal agent
|
|
40
|
+
Warp,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
impl Format {
|
|
44
|
+
/// Every opt-in format — everything except `Claude`, which is always in
|
|
45
|
+
/// scope. Drives `--all-formats` and the interactive picker.
|
|
46
|
+
pub const OPT_IN: &'static [Format] = &[
|
|
47
|
+
Format::Cursor,
|
|
48
|
+
Format::Windsurf,
|
|
49
|
+
Format::Cline,
|
|
50
|
+
Format::Copilot,
|
|
51
|
+
Format::Gemini,
|
|
52
|
+
Format::Zed,
|
|
53
|
+
Format::Aider,
|
|
54
|
+
Format::Roo,
|
|
55
|
+
Format::Kilo,
|
|
56
|
+
Format::Goose,
|
|
57
|
+
Format::Warp,
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/// Display name used in reports and merge markers.
|
|
61
|
+
pub fn label(self) -> &'static str {
|
|
62
|
+
match self {
|
|
63
|
+
Format::Claude => "CLAUDE.md",
|
|
64
|
+
Format::Cursor => ".cursorrules",
|
|
65
|
+
Format::Windsurf => ".windsurfrules",
|
|
66
|
+
Format::Cline => ".clinerules",
|
|
67
|
+
Format::Copilot => ".github/copilot-instructions.md",
|
|
68
|
+
Format::Gemini => "GEMINI.md",
|
|
69
|
+
Format::Zed => ".rules",
|
|
70
|
+
Format::Aider => "CONVENTIONS.md",
|
|
71
|
+
Format::Roo => ".roorules",
|
|
72
|
+
Format::Kilo => ".kilocoderules",
|
|
73
|
+
Format::Goose => ".goosehints",
|
|
74
|
+
Format::Warp => "WARP.md",
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Map a file path to its instruction format, if it is one we handle.
|
|
80
|
+
/// `copilot-instructions.md` only counts inside a `.github` directory.
|
|
81
|
+
fn format_for(path: &Path) -> Option<Format> {
|
|
82
|
+
let name = path.file_name()?.to_str()?;
|
|
83
|
+
match name {
|
|
84
|
+
"CLAUDE.md" => Some(Format::Claude),
|
|
85
|
+
".cursorrules" => Some(Format::Cursor),
|
|
86
|
+
".windsurfrules" => Some(Format::Windsurf),
|
|
87
|
+
".clinerules" => Some(Format::Cline),
|
|
88
|
+
".roorules" => Some(Format::Roo),
|
|
89
|
+
".kilocoderules" => Some(Format::Kilo),
|
|
90
|
+
".goosehints" => Some(Format::Goose),
|
|
91
|
+
"WARP.md" => Some(Format::Warp),
|
|
92
|
+
".rules" => Some(Format::Zed),
|
|
93
|
+
"GEMINI.md" => Some(Format::Gemini),
|
|
94
|
+
// Aider's documented name is uppercase; a lowercase conventions.md is
|
|
95
|
+
// usually a generic doc, not an agent file — don't touch it.
|
|
96
|
+
"CONVENTIONS.md" => Some(Format::Aider),
|
|
97
|
+
"copilot-instructions.md" => path
|
|
98
|
+
.parent()
|
|
99
|
+
.and_then(|p| p.file_name())
|
|
100
|
+
.and_then(|n| n.to_str())
|
|
101
|
+
.filter(|n| *n == ".github")
|
|
102
|
+
.map(|_| Format::Copilot),
|
|
103
|
+
_ => None,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/// An instruction file found during the walk, classified by repo relationship.
|
|
108
|
+
#[derive(Debug)]
|
|
109
|
+
pub struct FoundFile {
|
|
110
|
+
pub path: PathBuf,
|
|
111
|
+
/// Nearest ancestor directory containing `.git` (dir or file).
|
|
112
|
+
/// `None` → stray file outside any repo.
|
|
113
|
+
pub repo_root: Option<PathBuf>,
|
|
114
|
+
pub format: Format,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
impl FoundFile {
|
|
118
|
+
/// True when the file sits at its format's canonical position at the repo
|
|
119
|
+
/// root (`.github/copilot-instructions.md` counts as root-level).
|
|
120
|
+
pub fn is_repo_root_file(&self) -> bool {
|
|
121
|
+
let Some(root) = self.repo_root.as_deref() else {
|
|
122
|
+
return false;
|
|
123
|
+
};
|
|
124
|
+
match self.format {
|
|
125
|
+
Format::Copilot => self.path == root.join(".github/copilot-instructions.md"),
|
|
126
|
+
_ => self.path.parent() == Some(root),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Path relative to the repo root, e.g. `CLAUDE.md` or
|
|
131
|
+
/// `.github/copilot-instructions.md`.
|
|
132
|
+
pub fn rel(&self) -> PathBuf {
|
|
133
|
+
self.repo_root
|
|
134
|
+
.as_deref()
|
|
135
|
+
.and_then(|r| self.path.strip_prefix(r).ok())
|
|
136
|
+
.unwrap_or(&self.path)
|
|
137
|
+
.to_path_buf()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// Directories never descended into (matched on dir name).
|
|
142
|
+
const PRUNE_NAMES: &[&str] = &[
|
|
143
|
+
".git",
|
|
144
|
+
"node_modules",
|
|
145
|
+
"Library",
|
|
146
|
+
".Trash",
|
|
147
|
+
".cache",
|
|
148
|
+
".cargo",
|
|
149
|
+
".rustup",
|
|
150
|
+
".npm",
|
|
151
|
+
".bun",
|
|
152
|
+
".nvm",
|
|
153
|
+
".pyenv",
|
|
154
|
+
".rbenv",
|
|
155
|
+
".volta",
|
|
156
|
+
".deno",
|
|
157
|
+
".gradle",
|
|
158
|
+
".m2",
|
|
159
|
+
".ollama",
|
|
160
|
+
"miniconda3",
|
|
161
|
+
"anaconda3",
|
|
162
|
+
".conda",
|
|
163
|
+
"OrbStack",
|
|
164
|
+
// Vendored dependencies + build output across ecosystems: a CLAUDE.md in
|
|
165
|
+
// these is generated/vendored content, not a user file. Generic names
|
|
166
|
+
// like `dist`/`build` stay scannable — they can hold real docs.
|
|
167
|
+
"Pods",
|
|
168
|
+
"Carthage",
|
|
169
|
+
"vendor",
|
|
170
|
+
"deps",
|
|
171
|
+
"target",
|
|
172
|
+
".venv",
|
|
173
|
+
"venv",
|
|
174
|
+
"site-packages",
|
|
175
|
+
"bower_components",
|
|
176
|
+
"jspm_packages",
|
|
177
|
+
".terraform",
|
|
178
|
+
"DerivedData",
|
|
179
|
+
"__pycache__",
|
|
180
|
+
".tox",
|
|
181
|
+
".dart_tool",
|
|
182
|
+
".pub-cache",
|
|
183
|
+
"elm-stuff",
|
|
184
|
+
".stack-work",
|
|
185
|
+
".pnpm-store",
|
|
186
|
+
".next",
|
|
187
|
+
".nuxt",
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
/// Path prefixes never scanned, regardless of flags.
|
|
191
|
+
fn always_excluded(home: &Path, p: &Path) -> bool {
|
|
192
|
+
p.starts_with(home.join(".claude"))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/// Prune on a path *suffix* (component-wise), for vendored/derived trees whose
|
|
196
|
+
/// dirname alone is too generic to prune by name.
|
|
197
|
+
const PRUNE_SUFFIXES: &[&[&str]] = &[&["go", "pkg", "mod"], &["Library", "Caches"]];
|
|
198
|
+
|
|
199
|
+
fn prune_by_suffix(p: &Path) -> bool {
|
|
200
|
+
let depth = p.components().count();
|
|
201
|
+
PRUNE_SUFFIXES.iter().any(|suffix| {
|
|
202
|
+
suffix.len() <= depth
|
|
203
|
+
&& p.components()
|
|
204
|
+
.rev()
|
|
205
|
+
.take(suffix.len())
|
|
206
|
+
.zip(suffix.iter().rev())
|
|
207
|
+
.all(|(c, s)| c.as_os_str() == OsStr::new(s))
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// True for directories that hold *non-migratable* rule formats — structured
|
|
212
|
+
/// rules with frontmatter/globs that AGENTS.md cannot express. Report-only.
|
|
213
|
+
fn is_rule_dir(path: &Path) -> bool {
|
|
214
|
+
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
|
215
|
+
return false;
|
|
216
|
+
};
|
|
217
|
+
let parent = || {
|
|
218
|
+
path.parent()
|
|
219
|
+
.and_then(|p| p.file_name())
|
|
220
|
+
.and_then(|n| n.to_str())
|
|
221
|
+
.unwrap_or("")
|
|
222
|
+
};
|
|
223
|
+
match name {
|
|
224
|
+
"rules" => matches!(
|
|
225
|
+
parent(),
|
|
226
|
+
".cursor" | ".windsurf" | ".roo" | ".continue" | ".claude" | ".kilocode" | ".trae"
|
|
227
|
+
),
|
|
228
|
+
"steering" => parent() == ".kiro",
|
|
229
|
+
"microagents" => parent() == ".openhands",
|
|
230
|
+
"instructions" | "agents" => parent() == ".github",
|
|
231
|
+
".clinerules" | ".roo" | ".junie" | ".amazonq" | ".openhands" => true,
|
|
232
|
+
_ => false,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/// .gitignore matcher for a repo — root .gitignore + .git/info/exclude +
|
|
237
|
+
/// the conventional global ignore (~/.config/git/ignore). Approximation:
|
|
238
|
+
/// nested .gitignore files and a custom core.excludesFile aren't consulted.
|
|
239
|
+
fn repo_gitignore(root: &Path) -> Gitignore {
|
|
240
|
+
let mut b = GitignoreBuilder::new(root);
|
|
241
|
+
for f in [
|
|
242
|
+
root.join(".gitignore"),
|
|
243
|
+
root.join(".git/info/exclude"),
|
|
244
|
+
dirs_home().join(".config/git/ignore"),
|
|
245
|
+
] {
|
|
246
|
+
if f.is_file() {
|
|
247
|
+
b.add(f);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
b.build().unwrap_or_else(|_| Gitignore::empty())
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
pub struct ScanResult {
|
|
254
|
+
pub found: Vec<FoundFile>,
|
|
255
|
+
/// Files whose name is a case/spelling variant of CLAUDE.md or AGENTS.md —
|
|
256
|
+
/// dead weight (no agent reads them), report-only.
|
|
257
|
+
pub variants: Vec<PathBuf>,
|
|
258
|
+
/// Global-ish CLAUDE.md files we refuse to touch (e.g. `~/CLAUDE.md`),
|
|
259
|
+
/// listed so the user knows they exist.
|
|
260
|
+
pub protected: Vec<PathBuf>,
|
|
261
|
+
/// Non-migratable rule directories inside repos (.cursor/rules,
|
|
262
|
+
/// .github/instructions, .junie, …) — reported so nothing is missed.
|
|
263
|
+
pub rule_dirs: Vec<PathBuf>,
|
|
264
|
+
/// Repo-root instruction files matched by .gitignore — personal files
|
|
265
|
+
/// the user deliberately keeps out of git; migrating them to AGENTS.md
|
|
266
|
+
/// would un-ignore (and potentially commit) them. Report-only.
|
|
267
|
+
pub gitignored: Vec<PathBuf>,
|
|
268
|
+
/// Repo roots whose AGENTS.md path is gitignored — a merge/rename there
|
|
269
|
+
/// produces a file `--pr` can't commit. Surfaced as a plan warning.
|
|
270
|
+
pub ignored_agents: BTreeSet<PathBuf>,
|
|
271
|
+
/// Walker errors (permission denied etc.), capped.
|
|
272
|
+
pub errors: Vec<String>,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/// Walk `root` in parallel, yielding every known instruction file.
|
|
276
|
+
pub fn scan(root: &Path, excludes: &[PathBuf], follow_links: bool) -> Result<ScanResult> {
|
|
277
|
+
let home = dirs_home();
|
|
278
|
+
let excludes: Vec<PathBuf> = excludes
|
|
279
|
+
.iter()
|
|
280
|
+
.map(|e| e.canonicalize().unwrap_or_else(|_| e.clone()))
|
|
281
|
+
.collect();
|
|
282
|
+
|
|
283
|
+
let found = Mutex::new(Vec::new());
|
|
284
|
+
let variants = Mutex::new(Vec::new());
|
|
285
|
+
let protected = Mutex::new(Vec::new());
|
|
286
|
+
let rule_dirs = Mutex::new(Vec::new());
|
|
287
|
+
let errors = Mutex::new(Vec::new());
|
|
288
|
+
let repo_cache = Mutex::new(HashMap::new());
|
|
289
|
+
let home_claude = home.join("CLAUDE.md");
|
|
290
|
+
|
|
291
|
+
let walker = WalkBuilder::new(root)
|
|
292
|
+
.follow_links(follow_links)
|
|
293
|
+
.hidden(false)
|
|
294
|
+
.git_ignore(false)
|
|
295
|
+
.git_global(false)
|
|
296
|
+
.git_exclude(false)
|
|
297
|
+
.require_git(false)
|
|
298
|
+
.threads(0)
|
|
299
|
+
.build_parallel();
|
|
300
|
+
|
|
301
|
+
walker.run(|| {
|
|
302
|
+
let found = &found;
|
|
303
|
+
let variants = &variants;
|
|
304
|
+
let protected = &protected;
|
|
305
|
+
let rule_dirs = &rule_dirs;
|
|
306
|
+
let errors = &errors;
|
|
307
|
+
let excludes = &excludes;
|
|
308
|
+
let home = &home;
|
|
309
|
+
let home_claude = &home_claude;
|
|
310
|
+
let repo_cache = &repo_cache;
|
|
311
|
+
Box::new(move |entry| {
|
|
312
|
+
let entry = match entry {
|
|
313
|
+
Ok(e) => e,
|
|
314
|
+
Err(err) => {
|
|
315
|
+
let mut errs = errors.lock().unwrap();
|
|
316
|
+
if errs.len() < 50 {
|
|
317
|
+
errs.push(err.to_string());
|
|
318
|
+
}
|
|
319
|
+
return WalkState::Continue;
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
let path = entry.path();
|
|
324
|
+
|
|
325
|
+
if always_excluded(home, path) || excludes.iter().any(|e| path.starts_with(e)) {
|
|
326
|
+
return WalkState::Skip;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
|
|
330
|
+
if is_dir {
|
|
331
|
+
if path != root
|
|
332
|
+
&& (prune_by_suffix(path)
|
|
333
|
+
|| path
|
|
334
|
+
.file_name()
|
|
335
|
+
.and_then(|n| n.to_str())
|
|
336
|
+
.is_some_and(|name| PRUNE_NAMES.contains(&name)))
|
|
337
|
+
{
|
|
338
|
+
return WalkState::Skip;
|
|
339
|
+
}
|
|
340
|
+
if is_rule_dir(path) && find_repo_root(path, repo_cache).is_some() {
|
|
341
|
+
rule_dirs.lock().unwrap().push(path.to_path_buf());
|
|
342
|
+
return WalkState::Skip;
|
|
343
|
+
}
|
|
344
|
+
return WalkState::Continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ~/CLAUDE.md is a global instruction file — report, never touch.
|
|
348
|
+
if path == home_claude.as_path() {
|
|
349
|
+
protected.lock().unwrap().push(path.to_path_buf());
|
|
350
|
+
return WalkState::Continue;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
|
354
|
+
return WalkState::Continue;
|
|
355
|
+
};
|
|
356
|
+
if let Some(format) = format_for(path) {
|
|
357
|
+
let repo_root = find_repo_root(path.parent().unwrap_or(Path::new("/")), repo_cache);
|
|
358
|
+
// Non-Claude formats only matter inside repos — outside one
|
|
359
|
+
// they are dead files nobody loads, not worth reporting.
|
|
360
|
+
if format != Format::Claude && repo_root.is_none() {
|
|
361
|
+
return WalkState::Continue;
|
|
362
|
+
}
|
|
363
|
+
found.lock().unwrap().push(FoundFile {
|
|
364
|
+
path: path.to_path_buf(),
|
|
365
|
+
repo_root,
|
|
366
|
+
format,
|
|
367
|
+
});
|
|
368
|
+
} else if name != "AGENTS.md"
|
|
369
|
+
&& (name.eq_ignore_ascii_case("claude.md")
|
|
370
|
+
|| name.eq_ignore_ascii_case("agents.md")
|
|
371
|
+
|| name.eq_ignore_ascii_case("agent.md"))
|
|
372
|
+
{
|
|
373
|
+
// Non-exact spellings — dead weight on case-sensitive systems.
|
|
374
|
+
variants.lock().unwrap().push(path.to_path_buf());
|
|
375
|
+
}
|
|
376
|
+
WalkState::Continue
|
|
377
|
+
})
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
let mut found = found.into_inner().unwrap();
|
|
381
|
+
found.sort_by(|a, b| a.path.cmp(&b.path));
|
|
382
|
+
let mut variants = variants.into_inner().unwrap();
|
|
383
|
+
variants.sort();
|
|
384
|
+
let mut rule_dirs = rule_dirs.into_inner().unwrap();
|
|
385
|
+
rule_dirs.sort();
|
|
386
|
+
rule_dirs.dedup();
|
|
387
|
+
|
|
388
|
+
// .gitignore evaluation — a gitignored root instruction file is a
|
|
389
|
+
// personal file; renaming it would un-ignore (and possibly commit) it.
|
|
390
|
+
// Also flag repos where the AGENTS.md *target* is ignored.
|
|
391
|
+
let mut matchers: HashMap<PathBuf, Gitignore> = HashMap::new();
|
|
392
|
+
let mut ignored_agents = BTreeSet::new();
|
|
393
|
+
let mut gitignored = Vec::new();
|
|
394
|
+
for root in found
|
|
395
|
+
.iter()
|
|
396
|
+
.filter_map(|f| f.repo_root.clone())
|
|
397
|
+
.collect::<BTreeSet<_>>()
|
|
398
|
+
{
|
|
399
|
+
let m = matchers
|
|
400
|
+
.entry(root.clone())
|
|
401
|
+
.or_insert_with(|| repo_gitignore(&root));
|
|
402
|
+
if m.matched(root.join(crate::plan::AGENTS), false).is_ignore() {
|
|
403
|
+
ignored_agents.insert(root);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
let found: Vec<FoundFile> = found
|
|
407
|
+
.into_iter()
|
|
408
|
+
.filter(|f| {
|
|
409
|
+
if f.is_repo_root_file()
|
|
410
|
+
&& f.repo_root
|
|
411
|
+
.as_ref()
|
|
412
|
+
.and_then(|r| matchers.get(r))
|
|
413
|
+
.is_some_and(|m| m.matched(&f.path, false).is_ignore())
|
|
414
|
+
{
|
|
415
|
+
gitignored.push(f.path.clone());
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
true
|
|
419
|
+
})
|
|
420
|
+
.collect();
|
|
421
|
+
|
|
422
|
+
Ok(ScanResult {
|
|
423
|
+
found,
|
|
424
|
+
variants,
|
|
425
|
+
protected: protected.into_inner().unwrap(),
|
|
426
|
+
rule_dirs,
|
|
427
|
+
gitignored,
|
|
428
|
+
ignored_agents,
|
|
429
|
+
errors: errors.into_inner().unwrap(),
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/// Nearest ancestor (starting at `dir`) containing a `.git` entry
|
|
434
|
+
/// or looking like a bare repo (HEAD + objects, no worktree).
|
|
435
|
+
/// `cache` memoizes per-directory answers across the parallel walk.
|
|
436
|
+
fn find_repo_root(dir: &Path, cache: &Mutex<HashMap<PathBuf, Option<PathBuf>>>) -> Option<PathBuf> {
|
|
437
|
+
let mut trail = Vec::new();
|
|
438
|
+
let mut cur = Some(dir);
|
|
439
|
+
let hit = loop {
|
|
440
|
+
let Some(d) = cur else { break None };
|
|
441
|
+
if let Some(cached) = cache.lock().unwrap().get(d) {
|
|
442
|
+
break cached.clone();
|
|
443
|
+
}
|
|
444
|
+
if d.join(".git").symlink_metadata().is_ok() || is_bare_repo(d) {
|
|
445
|
+
break Some(d.to_path_buf());
|
|
446
|
+
}
|
|
447
|
+
trail.push(d.to_path_buf());
|
|
448
|
+
cur = d.parent();
|
|
449
|
+
};
|
|
450
|
+
// Every directory on the trail resolves to the same nearest repo.
|
|
451
|
+
let mut c = cache.lock().unwrap();
|
|
452
|
+
for d in trail {
|
|
453
|
+
c.insert(d, hit.clone());
|
|
454
|
+
}
|
|
455
|
+
hit
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/// Bare repo heuristic: has HEAD + objects + refs but no `.git`.
|
|
459
|
+
pub fn is_bare_repo(dir: &Path) -> bool {
|
|
460
|
+
dir.join("HEAD").is_file()
|
|
461
|
+
&& dir.join("objects").is_dir()
|
|
462
|
+
&& dir.join("refs").is_dir()
|
|
463
|
+
&& !dir.join(".git").exists()
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
fn dirs_home() -> PathBuf {
|
|
467
|
+
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
#[cfg(test)]
|
|
471
|
+
mod tests {
|
|
472
|
+
use super::*;
|
|
473
|
+
|
|
474
|
+
/// Every format's canonical file must map back to that format — keeps
|
|
475
|
+
/// `label()` and `format_for()` from drifting apart.
|
|
476
|
+
#[test]
|
|
477
|
+
fn format_label_roundtrip() {
|
|
478
|
+
let root = Path::new("/repo");
|
|
479
|
+
for f in std::iter::once(Format::Claude).chain(Format::OPT_IN.iter().copied()) {
|
|
480
|
+
assert_eq!(
|
|
481
|
+
format_for(&root.join(f.label())),
|
|
482
|
+
Some(f),
|
|
483
|
+
"{:?} label {:?} not recognized",
|
|
484
|
+
f,
|
|
485
|
+
f.label()
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
#[test]
|
|
491
|
+
fn prune_rules() {
|
|
492
|
+
assert!(prune_by_suffix(Path::new("/a/go/pkg/mod")));
|
|
493
|
+
assert!(prune_by_suffix(Path::new("/a/Library/Caches")));
|
|
494
|
+
assert!(!prune_by_suffix(Path::new("/a/mod")));
|
|
495
|
+
assert!(!prune_by_suffix(Path::new("/a/Library")));
|
|
496
|
+
assert!(is_rule_dir(Path::new("/r/.cursor/rules")));
|
|
497
|
+
assert!(is_rule_dir(Path::new("/r/.kiro/steering")));
|
|
498
|
+
assert!(!is_rule_dir(Path::new("/r/rules")));
|
|
499
|
+
assert!(!is_rule_dir(Path::new("/r/src/rules")));
|
|
500
|
+
}
|
|
501
|
+
}
|