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 CHANGED
@@ -257,7 +257,7 @@ dependencies = [
257
257
 
258
258
  [[package]]
259
259
  name = "dump-shitty-claude-md"
260
- version = "0.1.0"
260
+ version = "0.1.1"
261
261
  dependencies = [
262
262
  "anyhow",
263
263
  "clap",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "dump-shitty-claude-md"
3
- version = "0.1.0"
3
+ version = "0.1.1"
4
4
  edition = "2021"
5
5
  description = "Migrate project CLAUDE.md files to AGENTS.md across all repos in your home directory"
6
6
  license = "MIT"
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
- dedupe ~/code/api-server identical content → delete CLAUDE.md
12
- ~ merge ~/code/dashboard content differs → append CLAUDE.md into AGENTS.md
13
- rename ~/code/cli-tool no AGENTS.md — rename
14
- ! skip ~/code/dotfiles CLAUDE.md is a symlink left alone
15
- ! stray ~/Desktop/playground/CLAUDE.md not inside a git repo
16
- · global ~/CLAUDE.md protected — left alone
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dump-shitty-claude-md",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Migrate project CLAUDE.md files to AGENTS.md across all repos in your home directory",
5
5
  "keywords": [
6
6
  "claude",
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
- fn print_row(repo: &str, file: &str, label: &str, reason: &str, warnings: &[String], nested: &[PathBuf]) {
275
- let mark = match label {
276
- "skip" => "!",
277
- "merge" | "replace" => "~",
278
- _ => "✓",
279
- };
280
- println!("{mark} {label:<8} {repo:<44} {reason}");
281
- if file != "CLAUDE.md" && !file.is_empty() {
282
- println!(" {:<10} {:<44} file: {file}", "", "");
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
- for w in warnings {
285
- println!(" {:<10} {:<44} {w}", "", "");
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
- for n in nested.iter().take(5) {
288
- println!(" {:<10} {:<44} nested: {}", "", "", shorten(n));
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
- if nested.len() > 5 {
291
- println!(" {:<10} {:<44} … +{} more nested", "", "", nested.len() - 5);
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
- if dry { "DRY RUN" } else { "RUN" },
365
- plans.len(),
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
- print_row(
373
- &shorten(&p.repo),
374
- &p.file.to_string_lossy(),
375
- p.action.label(),
376
- &p.reason,
377
- &p.warnings,
378
- &p.nested,
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
- println!("! stray {:<44} not inside a git repo", shorten(s));
494
+ rows.push(trow("!", "stray", "1;31", s, "not inside a git repo"));
383
495
  }
384
496
  for v in &scan.variants {
385
- println!("! variant {:<44} non-standard spelling — no agent reads this", shorten(v));
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
- println!("· global {:<44} protected — left alone", shorten(p));
524
+ irows.push(trow(", "global", "2", p, "protected"));
389
525
  }
390
526
  for g in &scan.gitignored {
391
- println!("· ignored {:<44} gitignored — personal file, left alone", shorten(g));
527
+ irows.push(trow(", "ignored", "2", g, "gitignored — personal file"));
392
528
  }
393
- for d in &scan.rule_dirs {
394
- println!("! rules {:<44} tool-specific rule dir — not migratable", shorten(d));
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
- for e in &scan.errors {
397
- eprintln!(" scan error: {e}");
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
- "\ndry run — rerun with --apply (files) or --pr (files + branch + PR){}",
403
- if strays.is_empty() {
404
- ""
405
- } else {
406
- " · --trash-strays removes strays"
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!("- held {:<44} merge — rerun without --trivial", shorten(&p.repo));
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!("✓ {:<8} {}{}", p.action.label(), shorten(&p.repo), file_suffix(p));
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!("✗ {:<44} {e:#}", shorten(&p.repo));
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!("- skipped {}", shorten(&repo));
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
- "! {:<44} dirty tree — in-place refused, use worktree",
466
- shorten(&repo)
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 mut line = format!("✓ migrated {}", shorten(&repo));
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
- line.push_str(&format!(" → {}", outcome.branch.clone().unwrap_or_default()));
651
+ detail.push_str(&format!(" → {}", outcome.branch.clone().unwrap_or_default()));
481
652
  }
482
653
  if outcome.pushed {
483
- line.push_str(" pushed");
654
+ detail.push_str(" pushed");
484
655
  }
485
656
  if let Some(u) = &outcome.pr_url {
486
- line.push_str(&format!(" PR: {u}"));
657
+ detail.push_str(&format!(" PR: {u}"));
487
658
  }
488
- println!("{line}");
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!("🗑 stray {} → Trash", shorten(s));
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!("✗ {:<44} trash failed: {e}", shorten(s));
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
- "\ndone: {} migrated, {} PRs, {} skipped{}",
527
- counts.0,
528
- counts.1,
529
- counts.2,
530
- if trashed > 0 {
531
- format!(", {trashed} trashed")
532
- } else {
533
- String::new()
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")));