dump-shitty-claude-md 0.1.0 → 0.1.1
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 +1 -1
- package/Cargo.toml +1 -1
- package/README.md +13 -6
- package/bin/darwin-arm64/dump-shitty-claude-md +0 -0
- package/bin/darwin-x64/dump-shitty-claude-md +0 -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 +1 -1
- package/src/main.rs +275 -63
- package/src/scan.rs +70 -1
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/README.md
CHANGED
|
@@ -8,14 +8,20 @@ bunx dump-shitty-claude-md
|
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
DRY RUN — 290 CLAUDE.md in repos, 25 strays
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
✓
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
MIGRATION PLAN & NOTES
|
|
12
|
+
ACTION PATH REASON
|
|
13
|
+
✓ dedupe ~/code/api-server identical content → delete CLAUDE.md
|
|
14
|
+
~ merge ~/code/dashboard content differs → append CLAUDE.md into AGENTS.md
|
|
15
|
+
✓ rename ~/code/cli-tool no AGENTS.md — rename
|
|
16
|
+
! skip ~/code/dotfiles CLAUDE.md is a symlink — left alone
|
|
17
|
+
! stray ~/Desktop/playground/CLAUDE.md not inside a git repo
|
|
18
|
+
IGNORED — left alone
|
|
19
|
+
ACTION PATH REASON
|
|
20
|
+
· global ~/CLAUDE.md protected
|
|
17
21
|
```
|
|
18
22
|
|
|
23
|
+
Output is colored in terminals (`--color always|auto|never`, `NO_COLOR` honored); ignored files are grouped separately.
|
|
24
|
+
|
|
19
25
|
## 🧠 The decision matrix
|
|
20
26
|
|
|
21
27
|
Every file is classified before anything is touched:
|
|
@@ -30,6 +36,7 @@ Every file is classified before anything is touched:
|
|
|
30
36
|
| symlink | any | skip + report |
|
|
31
37
|
| bare repo / FIFO / socket | — | skip + report |
|
|
32
38
|
| outside a git repo | — | never touched (Trash is opt-in) |
|
|
39
|
+
| inside a linked worktree | — | ignored — the main checkout gets migrated |
|
|
33
40
|
| `~/CLAUDE.md`, `~/.claude/**` | — | protected, never touched |
|
|
34
41
|
|
|
35
42
|
Also warns on: `@import` syntax, `CLAUDE.local.md`, `AGENTS.local.md`,
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/src/main.rs
CHANGED
|
@@ -69,6 +69,11 @@ struct Cli {
|
|
|
69
69
|
/// Descend into symlinked directories (default: skip — avoids loops)
|
|
70
70
|
#[arg(long)]
|
|
71
71
|
follow_links: bool,
|
|
72
|
+
|
|
73
|
+
/// When to use ANSI colors [default: auto — on for terminals, off when
|
|
74
|
+
/// piped; NO_COLOR env also disables]
|
|
75
|
+
#[arg(long, value_enum, default_value = "auto")]
|
|
76
|
+
color: ColorWhen,
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
#[derive(Serialize)]
|
|
@@ -271,24 +276,107 @@ fn build_plans(
|
|
|
271
276
|
(plans, strays)
|
|
272
277
|
}
|
|
273
278
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
279
|
+
#[derive(Clone, Copy, clap::ValueEnum)]
|
|
280
|
+
enum ColorWhen {
|
|
281
|
+
Auto,
|
|
282
|
+
Always,
|
|
283
|
+
Never,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
fn colorize(value: &str, color: &str, enabled: bool) -> String {
|
|
287
|
+
if enabled {
|
|
288
|
+
format!("\x1b[{color}m{value}\x1b[0m")
|
|
289
|
+
} else {
|
|
290
|
+
value.to_owned()
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/// Fixed-width "✓ label " badge — every row starts with one so columns
|
|
295
|
+
/// line up across plan/stray/variant/ignored lines.
|
|
296
|
+
fn badge(mark: &str, label: &str, code: &str, on: bool) -> String {
|
|
297
|
+
colorize(&format!("{mark} {label:<8}"), code, on)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/// One table row: badge + path + reason, plus indented detail lines
|
|
301
|
+
/// (file:/⚠/nested:) printed under the reason column.
|
|
302
|
+
struct TRow {
|
|
303
|
+
mark: &'static str,
|
|
304
|
+
label: String,
|
|
305
|
+
code: &'static str,
|
|
306
|
+
path: String,
|
|
307
|
+
reason: String,
|
|
308
|
+
subs: Vec<(String, bool)>, // (text, is_warning)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
fn mark_code(label: &str) -> (&'static str, &'static str) {
|
|
312
|
+
match label {
|
|
313
|
+
"skip" => ("!", "1;31"),
|
|
314
|
+
"merge" | "replace" => ("~", "1;33"),
|
|
315
|
+
_ => ("✓", "1;32"),
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/// Middle-ellipsis for paths longer than the column: keeps the repo name
|
|
320
|
+
/// (the end) and the leading ~/ (the start) visible.
|
|
321
|
+
fn ellipsize(s: &str, max: usize) -> String {
|
|
322
|
+
let n = s.chars().count();
|
|
323
|
+
if n <= max {
|
|
324
|
+
return s.to_owned();
|
|
283
325
|
}
|
|
284
|
-
|
|
285
|
-
|
|
326
|
+
let keep = max - 1;
|
|
327
|
+
let head = keep / 2;
|
|
328
|
+
let tail = keep - head;
|
|
329
|
+
let h: String = s.chars().take(head).collect();
|
|
330
|
+
let t: String = s.chars().skip(n - tail).collect();
|
|
331
|
+
format!("{h}…{t}")
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
fn print_table(rows: &[TRow], on: bool) {
|
|
335
|
+
if rows.is_empty() {
|
|
336
|
+
return;
|
|
286
337
|
}
|
|
287
|
-
|
|
288
|
-
|
|
338
|
+
let w = rows
|
|
339
|
+
.iter()
|
|
340
|
+
.map(|r| r.path.chars().count())
|
|
341
|
+
.max()
|
|
342
|
+
.unwrap_or(4)
|
|
343
|
+
.max(4)
|
|
344
|
+
.min(60);
|
|
345
|
+
println!(
|
|
346
|
+
"{}",
|
|
347
|
+
colorize(&format!(" {:<8} {:<w$} REASON", "ACTION", "PATH"), "1", on)
|
|
348
|
+
);
|
|
349
|
+
for r in rows {
|
|
350
|
+
println!(
|
|
351
|
+
"{} {} {}",
|
|
352
|
+
badge(r.mark, &r.label, r.code, on),
|
|
353
|
+
colorize(&format!("{:<w$}", ellipsize(&r.path, w)), "36", on),
|
|
354
|
+
colorize(&r.reason, "2", on),
|
|
355
|
+
);
|
|
356
|
+
for (sub, warn) in &r.subs {
|
|
357
|
+
let code = if *warn { "33" } else { "2" };
|
|
358
|
+
println!(
|
|
359
|
+
"{}",
|
|
360
|
+
colorize(&format!("{}{}", " ".repeat(12 + w), sub), code, on)
|
|
361
|
+
);
|
|
362
|
+
}
|
|
289
363
|
}
|
|
290
|
-
|
|
291
|
-
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
fn trow(
|
|
367
|
+
mark: &'static str,
|
|
368
|
+
label: &str,
|
|
369
|
+
code: &'static str,
|
|
370
|
+
path: &std::path::Path,
|
|
371
|
+
reason: &str,
|
|
372
|
+
) -> TRow {
|
|
373
|
+
TRow {
|
|
374
|
+
mark,
|
|
375
|
+
label: label.into(),
|
|
376
|
+
code,
|
|
377
|
+
path: shorten(path),
|
|
378
|
+
reason: reason.into(),
|
|
379
|
+
subs: vec![],
|
|
292
380
|
}
|
|
293
381
|
}
|
|
294
382
|
|
|
@@ -353,58 +441,116 @@ fn main() -> anyhow::Result<()> {
|
|
|
353
441
|
"rule_dirs": scan.rule_dirs,
|
|
354
442
|
"gitignored": scan.gitignored,
|
|
355
443
|
"ignored_agents": scan.ignored_agents,
|
|
444
|
+
"worktrees": scan.worktrees,
|
|
356
445
|
"scan_errors": scan.errors,
|
|
357
446
|
}))?
|
|
358
447
|
);
|
|
359
448
|
return Ok(());
|
|
360
449
|
}
|
|
361
450
|
|
|
451
|
+
let color_enabled = match cli.color {
|
|
452
|
+
ColorWhen::Always => true,
|
|
453
|
+
ColorWhen::Never => false,
|
|
454
|
+
ColorWhen::Auto => {
|
|
455
|
+
std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
let path_c = |p: &std::path::Path| colorize(&format!("{:<44}", shorten(p)), "36", color_enabled);
|
|
459
|
+
let dim = |s: &str| colorize(s, "2", color_enabled);
|
|
460
|
+
|
|
362
461
|
println!(
|
|
363
462
|
"{} — {} instruction file{} in repos, {} stray{}",
|
|
364
|
-
|
|
365
|
-
|
|
463
|
+
colorize(
|
|
464
|
+
if dry { "DRY RUN" } else { "RUN" },
|
|
465
|
+
if dry { "1;36" } else { "1;33" },
|
|
466
|
+
color_enabled
|
|
467
|
+
),
|
|
468
|
+
colorize(&plans.len().to_string(), "1", color_enabled),
|
|
366
469
|
if plans.len() == 1 { "" } else { "s" },
|
|
367
|
-
strays.len(),
|
|
470
|
+
colorize(&strays.len().to_string(), "1", color_enabled),
|
|
368
471
|
if strays.len() == 1 { "" } else { "s" }
|
|
369
472
|
);
|
|
370
473
|
|
|
474
|
+
let mut rows: Vec<TRow> = Vec::new();
|
|
371
475
|
for p in &plans {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
476
|
+
let (mark, code) = mark_code(p.action.label());
|
|
477
|
+
let mut r = trow(mark, p.action.label(), code, &p.repo, &p.reason);
|
|
478
|
+
let file = p.file.to_string_lossy();
|
|
479
|
+
if file != "CLAUDE.md" && !file.is_empty() {
|
|
480
|
+
r.subs.push((format!("file: {file}"), false));
|
|
481
|
+
}
|
|
482
|
+
for w in &p.warnings {
|
|
483
|
+
r.subs.push((format!("⚠ {w}"), true));
|
|
484
|
+
}
|
|
485
|
+
for n in p.nested.iter().take(5) {
|
|
486
|
+
r.subs.push((format!("nested: {}", shorten(n)), false));
|
|
487
|
+
}
|
|
488
|
+
if p.nested.len() > 5 {
|
|
489
|
+
r.subs.push((format!("… +{} more nested", p.nested.len() - 5), false));
|
|
490
|
+
}
|
|
491
|
+
rows.push(r);
|
|
380
492
|
}
|
|
381
493
|
for s in &strays {
|
|
382
|
-
|
|
494
|
+
rows.push(trow("!", "stray", "1;31", s, "not inside a git repo"));
|
|
383
495
|
}
|
|
384
496
|
for v in &scan.variants {
|
|
385
|
-
|
|
497
|
+
rows.push(trow(
|
|
498
|
+
"!",
|
|
499
|
+
"variant",
|
|
500
|
+
"1;31",
|
|
501
|
+
v,
|
|
502
|
+
"non-standard spelling — no agent reads this",
|
|
503
|
+
));
|
|
386
504
|
}
|
|
505
|
+
for d in &scan.rule_dirs {
|
|
506
|
+
rows.push(trow(
|
|
507
|
+
"!",
|
|
508
|
+
"rules",
|
|
509
|
+
"1;31",
|
|
510
|
+
d,
|
|
511
|
+
"tool-specific rule dir — not migratable",
|
|
512
|
+
));
|
|
513
|
+
}
|
|
514
|
+
if !rows.is_empty() || !scan.errors.is_empty() {
|
|
515
|
+
println!("\n{}", colorize("MIGRATION PLAN & NOTES", "1;36", color_enabled));
|
|
516
|
+
print_table(&rows, color_enabled);
|
|
517
|
+
}
|
|
518
|
+
for e in &scan.errors {
|
|
519
|
+
eprintln!("{}", colorize(&format!(" scan error: {e}"), "31", color_enabled));
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
let mut irows: Vec<TRow> = Vec::new();
|
|
387
523
|
for p in &scan.protected {
|
|
388
|
-
|
|
524
|
+
irows.push(trow("·", "global", "2", p, "protected"));
|
|
389
525
|
}
|
|
390
526
|
for g in &scan.gitignored {
|
|
391
|
-
|
|
527
|
+
irows.push(trow("·", "ignored", "2", g, "gitignored — personal file"));
|
|
392
528
|
}
|
|
393
|
-
for
|
|
394
|
-
|
|
529
|
+
for w in &scan.worktrees {
|
|
530
|
+
irows.push(trow(
|
|
531
|
+
"·",
|
|
532
|
+
"worktree",
|
|
533
|
+
"2",
|
|
534
|
+
w,
|
|
535
|
+
"linked worktree — migrate the main checkout",
|
|
536
|
+
));
|
|
395
537
|
}
|
|
396
|
-
|
|
397
|
-
|
|
538
|
+
if !irows.is_empty() {
|
|
539
|
+
println!("\n{}", colorize("IGNORED — left alone", "1;36", color_enabled));
|
|
540
|
+
print_table(&irows, color_enabled);
|
|
398
541
|
}
|
|
399
542
|
|
|
400
543
|
if dry {
|
|
401
544
|
println!(
|
|
402
|
-
"
|
|
403
|
-
|
|
404
|
-
""
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
545
|
+
"{}",
|
|
546
|
+
dim(&format!(
|
|
547
|
+
"\ndry run — rerun with --apply (files) or --pr (files + branch + PR){}",
|
|
548
|
+
if strays.is_empty() {
|
|
549
|
+
""
|
|
550
|
+
} else {
|
|
551
|
+
" · --trash-strays removes strays"
|
|
552
|
+
}
|
|
553
|
+
))
|
|
408
554
|
);
|
|
409
555
|
return Ok(());
|
|
410
556
|
}
|
|
@@ -419,7 +565,12 @@ fn main() -> anyhow::Result<()> {
|
|
|
419
565
|
continue;
|
|
420
566
|
}
|
|
421
567
|
if cli.trivial && !p.action.is_trivial() {
|
|
422
|
-
println!(
|
|
568
|
+
println!(
|
|
569
|
+
"{} {} {}",
|
|
570
|
+
badge("-", "held", "1;33", color_enabled),
|
|
571
|
+
path_c(&p.repo),
|
|
572
|
+
dim("merge — rerun without --trivial")
|
|
573
|
+
);
|
|
423
574
|
counts.2 += 1;
|
|
424
575
|
continue;
|
|
425
576
|
}
|
|
@@ -431,11 +582,20 @@ fn main() -> anyhow::Result<()> {
|
|
|
431
582
|
match plan::apply(&p.repo, &p.file, p.action) {
|
|
432
583
|
Ok(()) => {
|
|
433
584
|
counts.0 += 1;
|
|
434
|
-
println!(
|
|
585
|
+
println!(
|
|
586
|
+
"{} {}{}",
|
|
587
|
+
badge("✓", p.action.label(), "1;32", color_enabled),
|
|
588
|
+
colorize(&shorten(&p.repo), "36", color_enabled),
|
|
589
|
+
dim(&file_suffix(p))
|
|
590
|
+
);
|
|
435
591
|
}
|
|
436
592
|
Err(e) => {
|
|
437
593
|
failures += 1;
|
|
438
|
-
eprintln!(
|
|
594
|
+
eprintln!(
|
|
595
|
+
"{} {}",
|
|
596
|
+
colorize("✗", "1;31", color_enabled),
|
|
597
|
+
colorize(&format!("{:<44} {e:#}", shorten(&p.repo)), "31", color_enabled)
|
|
598
|
+
);
|
|
439
599
|
}
|
|
440
600
|
}
|
|
441
601
|
}
|
|
@@ -455,15 +615,21 @@ fn main() -> anyhow::Result<()> {
|
|
|
455
615
|
}) {
|
|
456
616
|
Some(s) => s,
|
|
457
617
|
None => {
|
|
458
|
-
println!(
|
|
618
|
+
println!(
|
|
619
|
+
"{} {}",
|
|
620
|
+
badge("-", "skipped", "1;33", color_enabled),
|
|
621
|
+
path_c(&repo)
|
|
622
|
+
);
|
|
459
623
|
counts.2 += 1;
|
|
460
624
|
continue;
|
|
461
625
|
}
|
|
462
626
|
};
|
|
463
627
|
if strategy == gitops::Strategy::InPlace && !clean {
|
|
464
628
|
println!(
|
|
465
|
-
"
|
|
466
|
-
|
|
629
|
+
"{} {} {}",
|
|
630
|
+
badge("!", "skip", "1;31", color_enabled),
|
|
631
|
+
path_c(&repo),
|
|
632
|
+
dim("dirty tree — in-place refused, use worktree")
|
|
467
633
|
);
|
|
468
634
|
counts.2 += 1;
|
|
469
635
|
continue;
|
|
@@ -475,19 +641,24 @@ fn main() -> anyhow::Result<()> {
|
|
|
475
641
|
} else {
|
|
476
642
|
counts.0 += 1;
|
|
477
643
|
}
|
|
478
|
-
let
|
|
644
|
+
let line = format!(
|
|
645
|
+
"{} {}",
|
|
646
|
+
colorize("✓ migrated", "1;32", color_enabled),
|
|
647
|
+
colorize(&shorten(&repo), "36", color_enabled)
|
|
648
|
+
);
|
|
649
|
+
let mut detail = String::new();
|
|
479
650
|
if outcome.committed {
|
|
480
|
-
|
|
651
|
+
detail.push_str(&format!(" → {}", outcome.branch.clone().unwrap_or_default()));
|
|
481
652
|
}
|
|
482
653
|
if outcome.pushed {
|
|
483
|
-
|
|
654
|
+
detail.push_str(" pushed");
|
|
484
655
|
}
|
|
485
656
|
if let Some(u) = &outcome.pr_url {
|
|
486
|
-
|
|
657
|
+
detail.push_str(&format!(" PR: {u}"));
|
|
487
658
|
}
|
|
488
|
-
println!("{
|
|
659
|
+
println!("{}{}", line, dim(&detail));
|
|
489
660
|
for w in &outcome.warnings {
|
|
490
|
-
println!(" ⚠ {w}");
|
|
661
|
+
println!("{}", colorize(&format!(" ⚠ {w}"), "33", color_enabled));
|
|
491
662
|
}
|
|
492
663
|
}
|
|
493
664
|
}
|
|
@@ -511,11 +682,24 @@ fn main() -> anyhow::Result<()> {
|
|
|
511
682
|
match trash::delete(s) {
|
|
512
683
|
Ok(()) => {
|
|
513
684
|
trashed += 1;
|
|
514
|
-
println!(
|
|
685
|
+
println!(
|
|
686
|
+
"{} {} {}",
|
|
687
|
+
colorize("🗑 stray", "1;35", color_enabled),
|
|
688
|
+
colorize(&shorten(s), "36", color_enabled),
|
|
689
|
+
dim("→ Trash")
|
|
690
|
+
);
|
|
515
691
|
}
|
|
516
692
|
Err(e) => {
|
|
517
693
|
failures += 1;
|
|
518
|
-
eprintln!(
|
|
694
|
+
eprintln!(
|
|
695
|
+
"{} {}",
|
|
696
|
+
colorize("✗", "1;31", color_enabled),
|
|
697
|
+
colorize(
|
|
698
|
+
&format!("{:<44} trash failed: {e}", shorten(s)),
|
|
699
|
+
"31",
|
|
700
|
+
color_enabled
|
|
701
|
+
)
|
|
702
|
+
);
|
|
519
703
|
}
|
|
520
704
|
}
|
|
521
705
|
}
|
|
@@ -523,15 +707,22 @@ fn main() -> anyhow::Result<()> {
|
|
|
523
707
|
}
|
|
524
708
|
|
|
525
709
|
println!(
|
|
526
|
-
"
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
710
|
+
"{}",
|
|
711
|
+
colorize(
|
|
712
|
+
&format!(
|
|
713
|
+
"\ndone: {} migrated, {} PRs, {} skipped{}",
|
|
714
|
+
counts.0,
|
|
715
|
+
counts.1,
|
|
716
|
+
counts.2,
|
|
717
|
+
if trashed > 0 {
|
|
718
|
+
format!(", {trashed} trashed")
|
|
719
|
+
} else {
|
|
720
|
+
String::new()
|
|
721
|
+
}
|
|
722
|
+
),
|
|
723
|
+
if failures == 0 { "1;32" } else { "1;33" },
|
|
724
|
+
color_enabled
|
|
725
|
+
)
|
|
535
726
|
);
|
|
536
727
|
if failures > 0 {
|
|
537
728
|
std::process::exit(1);
|
|
@@ -548,3 +739,24 @@ fn file_suffix(p: &Plan) -> String {
|
|
|
548
739
|
format!(" ({})", p.file.display())
|
|
549
740
|
}
|
|
550
741
|
}
|
|
742
|
+
|
|
743
|
+
#[cfg(test)]
|
|
744
|
+
mod output_tests {
|
|
745
|
+
use super::{badge, colorize};
|
|
746
|
+
|
|
747
|
+
#[test]
|
|
748
|
+
fn badge_pads_label_to_fixed_width() {
|
|
749
|
+
assert_eq!(badge("!", "stray", "1;31", false), "! stray ");
|
|
750
|
+
assert_eq!(badge("✓", "rename", "1;32", false).chars().count(), 10);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
#[test]
|
|
754
|
+
fn colorize_omits_ansi_when_output_is_not_a_terminal() {
|
|
755
|
+
assert_eq!(colorize("✓ rename", "32", false), "✓ rename");
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
#[test]
|
|
759
|
+
fn colorize_wraps_terminal_output_in_ansi_color() {
|
|
760
|
+
assert_eq!(colorize("✓ rename", "32", true), "\x1b[32m✓ rename\x1b[0m");
|
|
761
|
+
}
|
|
762
|
+
}
|
package/src/scan.rs
CHANGED
|
@@ -268,6 +268,10 @@ pub struct ScanResult {
|
|
|
268
268
|
/// Repo roots whose AGENTS.md path is gitignored — a merge/rename there
|
|
269
269
|
/// produces a file `--pr` can't commit. Surfaced as a plan warning.
|
|
270
270
|
pub ignored_agents: BTreeSet<PathBuf>,
|
|
271
|
+
/// Linked-worktree checkouts (`.git` file → `…/.git/worktrees/…`) — their
|
|
272
|
+
/// instruction files are diverted here, never migrated: the main
|
|
273
|
+
/// checkout is the one that gets the AGENTS.md.
|
|
274
|
+
pub worktrees: Vec<PathBuf>,
|
|
271
275
|
/// Walker errors (permission denied etc.), capped.
|
|
272
276
|
pub errors: Vec<String>,
|
|
273
277
|
}
|
|
@@ -379,11 +383,31 @@ pub fn scan(root: &Path, excludes: &[PathBuf], follow_links: bool) -> Result<Sca
|
|
|
379
383
|
|
|
380
384
|
let mut found = found.into_inner().unwrap();
|
|
381
385
|
found.sort_by(|a, b| a.path.cmp(&b.path));
|
|
386
|
+
|
|
387
|
+
// Linked worktrees share the main checkout's AGENTS.md — migrating them
|
|
388
|
+
// separately would double-apply. Drop their files entirely; report the
|
|
389
|
+
// worktree root so the user sees it was deliberately ignored.
|
|
390
|
+
let worktrees: BTreeSet<PathBuf> = found
|
|
391
|
+
.iter()
|
|
392
|
+
.filter_map(|f| f.repo_root.clone())
|
|
393
|
+
.filter(|r| is_linked_worktree(r))
|
|
394
|
+
.collect();
|
|
395
|
+
if !worktrees.is_empty() {
|
|
396
|
+
found.retain(|f| {
|
|
397
|
+
f.repo_root
|
|
398
|
+
.as_ref()
|
|
399
|
+
.is_none_or(|r| !worktrees.contains(r))
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
let worktrees: Vec<PathBuf> = worktrees.into_iter().collect();
|
|
403
|
+
|
|
382
404
|
let mut variants = variants.into_inner().unwrap();
|
|
383
405
|
variants.sort();
|
|
384
|
-
let mut rule_dirs = rule_dirs.into_inner().unwrap();
|
|
406
|
+
let mut rule_dirs: Vec<PathBuf> = rule_dirs.into_inner().unwrap();
|
|
385
407
|
rule_dirs.sort();
|
|
386
408
|
rule_dirs.dedup();
|
|
409
|
+
// Rule dirs inside a worktree are ignored along with the worktree.
|
|
410
|
+
rule_dirs.retain(|d| !worktrees.iter().any(|w| d.starts_with(w)));
|
|
387
411
|
|
|
388
412
|
// .gitignore evaluation — a gitignored root instruction file is a
|
|
389
413
|
// personal file; renaming it would un-ignore (and possibly commit) it.
|
|
@@ -426,10 +450,26 @@ pub fn scan(root: &Path, excludes: &[PathBuf], follow_links: bool) -> Result<Sca
|
|
|
426
450
|
rule_dirs,
|
|
427
451
|
gitignored,
|
|
428
452
|
ignored_agents,
|
|
453
|
+
worktrees,
|
|
429
454
|
errors: errors.into_inner().unwrap(),
|
|
430
455
|
})
|
|
431
456
|
}
|
|
432
457
|
|
|
458
|
+
/// A linked worktree's `.git` is a file containing
|
|
459
|
+
/// `gitdir: <main>/.git/worktrees/<name>`. Submodules point into
|
|
460
|
+
/// `.git/modules/` instead — those are real repos and stay in scope.
|
|
461
|
+
/// Anything else (`.git` dir, unreadable/foreign content) is a normal repo.
|
|
462
|
+
fn is_linked_worktree(root: &Path) -> bool {
|
|
463
|
+
let git = root.join(".git");
|
|
464
|
+
if !git.is_file() {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
std::fs::read_to_string(&git)
|
|
468
|
+
.ok()
|
|
469
|
+
.and_then(|s| s.trim().strip_prefix("gitdir:").map(str::trim).map(String::from))
|
|
470
|
+
.is_some_and(|d| d.replace('\\', "/").contains("/worktrees/"))
|
|
471
|
+
}
|
|
472
|
+
|
|
433
473
|
/// Nearest ancestor (starting at `dir`) containing a `.git` entry
|
|
434
474
|
/// or looking like a bare repo (HEAD + objects, no worktree).
|
|
435
475
|
/// `cache` memoizes per-directory answers across the parallel walk.
|
|
@@ -487,6 +527,35 @@ mod tests {
|
|
|
487
527
|
}
|
|
488
528
|
}
|
|
489
529
|
|
|
530
|
+
#[test]
|
|
531
|
+
fn linked_worktree_detection() {
|
|
532
|
+
let base = std::env::temp_dir().join(format!("dscm-wt-{}", std::process::id()));
|
|
533
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
534
|
+
|
|
535
|
+
// Linked worktree: .git file → gitdir: …/.git/worktrees/x
|
|
536
|
+
let wt = base.join("wt");
|
|
537
|
+
std::fs::create_dir_all(&wt).unwrap();
|
|
538
|
+
std::fs::write(
|
|
539
|
+
wt.join(".git"),
|
|
540
|
+
"gitdir: /repos/main/.git/worktrees/wt\n",
|
|
541
|
+
)
|
|
542
|
+
.unwrap();
|
|
543
|
+
assert!(is_linked_worktree(&wt));
|
|
544
|
+
|
|
545
|
+
// Submodule: .git file → gitdir: …/.git/modules/x — real repo, kept.
|
|
546
|
+
let sub = base.join("sub");
|
|
547
|
+
std::fs::create_dir_all(&sub).unwrap();
|
|
548
|
+
std::fs::write(sub.join(".git"), "gitdir: ../.git/modules/sub\n").unwrap();
|
|
549
|
+
assert!(!is_linked_worktree(&sub));
|
|
550
|
+
|
|
551
|
+
// Normal checkout: .git directory.
|
|
552
|
+
let main = base.join("main");
|
|
553
|
+
std::fs::create_dir_all(main.join(".git")).unwrap();
|
|
554
|
+
assert!(!is_linked_worktree(&main));
|
|
555
|
+
|
|
556
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
557
|
+
}
|
|
558
|
+
|
|
490
559
|
#[test]
|
|
491
560
|
fn prune_rules() {
|
|
492
561
|
assert!(prune_by_suffix(Path::new("/a/go/pkg/mod")));
|