1mod clean;
4mod common;
5mod status;
6mod sync;
7
8use anyhow::{Context, Result};
9use clap::{Parser, Subcommand};
10use serde::Serialize;
11
12pub use common::OutputFormat;
13
14pub type SkillsFormat = OutputFormat;
19
20#[derive(Parser)]
22pub struct SkillsCommand {
23 #[command(subcommand)]
25 pub command: SkillsSubcommands,
26
27 #[command(flatten)]
29 pub repo: crate::cli::repo_arg::RepoArg,
30}
31
32#[derive(Subcommand)]
34pub enum SkillsSubcommands {
35 Sync(sync::SyncCommand),
37 Clean(clean::CleanCommand),
39 Status(status::StatusCommand),
41}
42
43impl SkillsCommand {
44 pub fn execute(self) -> Result<()> {
49 let repo = self.repo.path();
50 match self.command {
51 SkillsSubcommands::Sync(cmd) => cmd.execute(repo),
52 SkillsSubcommands::Clean(cmd) => cmd.execute(repo),
53 SkillsSubcommands::Status(cmd) => cmd.execute(repo),
54 }
55 }
56}
57
58pub fn run_sync(
74 base_dir: Option<&std::path::Path>,
75 worktrees: bool,
76 format: OutputFormat,
77) -> Result<String> {
78 let base = resolve_base_dir(base_dir)?;
79 let source_root = common::resolve_toplevel(&base)?;
80 let target_root = source_root.clone();
81
82 let mut targets = vec![target_root.clone()];
83 if worktrees {
84 for wt in common::list_worktrees(&target_root)? {
85 if !targets.iter().any(|t| t == &wt) {
86 targets.push(wt);
87 }
88 }
89 }
90
91 let report = sync::run_sync(&source_root, &targets, false)?;
92 render_sync_report(&report, format)
93}
94
95pub fn run_clean(
99 base_dir: Option<&std::path::Path>,
100 worktrees: bool,
101 format: OutputFormat,
102) -> Result<String> {
103 let base = resolve_base_dir(base_dir)?;
104 let target_root = common::resolve_toplevel(&base)?;
105
106 let mut targets = vec![target_root.clone()];
107 if worktrees {
108 for wt in common::list_worktrees(&target_root)? {
109 if !targets.iter().any(|t| t == &wt) {
110 targets.push(wt);
111 }
112 }
113 }
114
115 let report = clean::run_clean(&targets, false)?;
116 render_clean_report(&report, format)
117}
118
119pub fn run_status(
123 base_dir: Option<&std::path::Path>,
124 worktrees: bool,
125 format: OutputFormat,
126) -> Result<String> {
127 let base = resolve_base_dir(base_dir)?;
128 let target_root = common::resolve_toplevel(&base)?;
129
130 let mut targets = vec![target_root.clone()];
131 if worktrees {
132 for wt in common::list_worktrees(&target_root)? {
133 if !targets.iter().any(|t| t == &wt) {
134 targets.push(wt);
135 }
136 }
137 }
138
139 let report = status::run_status(&targets)?;
140 render_status_report(&report, format)
141}
142
143fn resolve_base_dir(base_dir: Option<&std::path::Path>) -> Result<std::path::PathBuf> {
144 match base_dir {
145 Some(p) => Ok(p.to_path_buf()),
146 None => std::env::current_dir().context("Failed to determine current directory"),
147 }
148}
149
150#[derive(Serialize)]
151struct SyncOutput<'a> {
152 dry_run: bool,
153 actions: &'a [sync::SyncAction],
154 errors: &'a [sync::SyncError],
155}
156
157#[derive(Serialize)]
158struct CleanOutput<'a> {
159 dry_run: bool,
160 actions: &'a [clean::CleanAction],
161}
162
163#[derive(Serialize)]
164struct StatusOutput<'a> {
165 targets: &'a [status::TargetStatus],
166}
167
168fn render_sync_report(report: &sync::SyncReport, format: OutputFormat) -> Result<String> {
169 match format {
170 OutputFormat::Text => Ok(render_sync_text(report)),
171 OutputFormat::Yaml => {
172 let output = SyncOutput {
173 dry_run: false,
174 actions: &report.actions,
175 errors: &report.errors,
176 };
177 serde_yaml::to_string(&output).context("Failed to serialize sync report as YAML")
178 }
179 }
180}
181
182fn render_sync_text(report: &sync::SyncReport) -> String {
183 use std::fmt::Write;
184 let mut out = String::new();
185 for action in &report.actions {
186 match action {
187 sync::SyncAction::Linked { link, points_to } => {
188 let _ = writeln!(out, "linked {} -> {}", link.display(), points_to.display());
189 }
190 sync::SyncAction::Relinked { link, points_to } => {
191 let _ = writeln!(
192 out,
193 "relinked {} -> {}",
194 link.display(),
195 points_to.display()
196 );
197 }
198 sync::SyncAction::Excluded {
199 exclude_file,
200 entry,
201 } => {
202 let _ = writeln!(out, "excluded {} in {}", entry, exclude_file.display());
203 }
204 sync::SyncAction::SkippedSameTarget { target } => {
205 let _ = writeln!(out, "skipped {} (target equals source)", target.display());
206 }
207 }
208 }
209 for err in &report.errors {
210 let _ = writeln!(out, "error: {} -- {}", err.target.display(), err.reason);
211 }
212 out
213}
214
215fn render_clean_report(report: &clean::CleanReport, format: OutputFormat) -> Result<String> {
216 match format {
217 OutputFormat::Text => Ok(render_clean_text(report)),
218 OutputFormat::Yaml => {
219 let output = CleanOutput {
220 dry_run: false,
221 actions: &report.actions,
222 };
223 serde_yaml::to_string(&output).context("Failed to serialize clean report as YAML")
224 }
225 }
226}
227
228fn render_clean_text(report: &clean::CleanReport) -> String {
229 use std::fmt::Write;
230 let mut out = String::new();
231 for action in &report.actions {
232 match action {
233 clean::CleanAction::Unlinked { link } => {
234 let _ = writeln!(out, "unlinked {}", link.display());
235 }
236 clean::CleanAction::Preserved { path, reason } => {
237 let _ = writeln!(out, "preserved {} ({reason})", path.display());
238 }
239 clean::CleanAction::ExcludeRemoved {
240 exclude_file,
241 entry,
242 } => {
243 let _ = writeln!(
244 out,
245 "removed exclude entry {} from {}",
246 entry,
247 exclude_file.display()
248 );
249 }
250 clean::CleanAction::DirectoryRemoved { path } => {
251 let _ = writeln!(out, "removed empty {}", path.display());
252 }
253 }
254 }
255 out
256}
257
258fn render_status_report(report: &status::StatusReport, format: OutputFormat) -> Result<String> {
259 match format {
260 OutputFormat::Text => Ok(render_status_text(report)),
261 OutputFormat::Yaml => {
262 let output = StatusOutput {
263 targets: &report.targets,
264 };
265 serde_yaml::to_string(&output).context("Failed to serialize status report as YAML")
266 }
267 }
268}
269
270fn render_status_text(report: &status::StatusReport) -> String {
271 report
272 .targets
273 .iter()
274 .filter(|t| !(t.symlinks.is_empty() && t.exclude_entries.is_empty()))
275 .map(render_status_target)
276 .collect::<Vec<_>>()
277 .join("\n")
278}
279
280fn render_status_target(target: &status::TargetStatus) -> String {
281 use std::fmt::Write;
282 let mut out = String::new();
283 let _ = writeln!(out, "{}", target.root.display());
284 if !target.symlinks.is_empty() {
285 out.push_str(" symlinks:\n");
286 for link in &target.symlinks {
287 let _ = writeln!(
288 out,
289 " {} -> {}",
290 link.path.display(),
291 link.points_to.display()
292 );
293 }
294 }
295 if !target.exclude_entries.is_empty() {
296 let _ = writeln!(out, " exclude block ({}):", target.exclude_file.display());
297 for entry in &target.exclude_entries {
298 let _ = writeln!(out, " {entry}");
299 }
300 }
301 out
302}
303
304#[cfg(test)]
305#[allow(clippy::unwrap_used, clippy::expect_used)]
306mod skills_api_tests {
307 use super::*;
308
309 use std::fs;
310 use std::path::{Path, PathBuf};
311
312 use tempfile::TempDir;
313
314 use super::common::test_git::{init_repo, init_repo_with_commit, worktree_add};
315
316 fn tempdir() -> TempDir {
317 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
320 fs::create_dir_all(&root).ok();
321 TempDir::new_in(&root).unwrap()
322 }
323
324 fn make_source_skills(root: &Path, names: &[&str]) {
325 let dir = root.join(".claude/skills");
326 fs::create_dir_all(&dir).unwrap();
327 for n in names {
328 let d = dir.join(n);
329 fs::create_dir_all(&d).unwrap();
330 fs::write(d.join("SKILL.md"), format!("# {n}")).unwrap();
331 }
332 }
333
334 #[test]
335 fn run_sync_mcp_with_worktrees_links_skills_and_returns_yaml() {
336 let src = tempdir();
337 let wt_parent = tempdir();
338 let linked = wt_parent.path().join("linked");
339 init_repo_with_commit(src.path());
340 make_source_skills(src.path(), &["alpha"]);
341 worktree_add(src.path(), &linked);
342
343 let out = run_sync(Some(&linked), true, OutputFormat::Yaml).unwrap();
344 assert!(out.contains("dry_run: false"), "missing dry_run: {out}");
345 assert!(out.contains("actions:"), "missing actions: {out}");
346 }
347
348 #[test]
349 fn run_sync_mcp_same_source_target_reports_skipped() {
350 let src = tempdir();
351 init_repo(src.path());
352 make_source_skills(src.path(), &["alpha"]);
353 let out = run_sync(Some(src.path()), false, OutputFormat::Text).unwrap();
354 assert!(out.contains("skipped"), "expected skipped action: {out}");
355 }
356
357 #[test]
358 fn run_clean_mcp_empty_repo_returns_empty_string_text() {
359 let tgt = tempdir();
360 init_repo(tgt.path());
361 let out = run_clean(Some(tgt.path()), false, OutputFormat::Text).unwrap();
362 assert!(out.is_empty(), "expected no actions, got: {out}");
363 }
364
365 #[test]
366 fn run_clean_mcp_yaml_reports_empty_actions() {
367 let tgt = tempdir();
368 init_repo(tgt.path());
369 let out = run_clean(Some(tgt.path()), false, OutputFormat::Yaml).unwrap();
370 assert!(out.contains("dry_run: false"), "missing dry_run: {out}");
371 assert!(out.contains("actions:"), "missing actions: {out}");
372 }
373
374 #[test]
375 fn run_clean_mcp_with_worktrees_covers_all_worktrees() {
376 let main = tempdir();
377 init_repo_with_commit(main.path());
378 let wt_parent = tempdir();
379 let linked = wt_parent.path().join("linked");
380 worktree_add(main.path(), &linked);
381
382 let out = run_clean(Some(main.path()), true, OutputFormat::Yaml).unwrap();
383 assert!(out.contains("actions:"), "missing actions: {out}");
384 }
385
386 #[test]
387 fn run_status_mcp_empty_repo_text_is_empty() {
388 let tgt = tempdir();
389 init_repo(tgt.path());
390 let out = run_status(Some(tgt.path()), false, OutputFormat::Text).unwrap();
391 assert!(out.is_empty(), "expected no residue, got: {out}");
392 }
393
394 #[test]
395 fn run_status_mcp_yaml_emits_targets_array() {
396 let tgt = tempdir();
397 init_repo(tgt.path());
398 let out = run_status(Some(tgt.path()), false, OutputFormat::Yaml).unwrap();
399 assert!(out.contains("targets:"), "missing targets: {out}");
400 }
401
402 #[cfg(unix)]
403 #[test]
404 fn run_status_mcp_reports_symlinks_in_text() {
405 let src = tempdir();
406 let tgt = tempdir();
407 init_repo(tgt.path());
408 let src_skill = src.path().join("alpha");
409 fs::create_dir_all(&src_skill).unwrap();
410 let skills_dir = tgt.path().join(".claude/skills");
411 fs::create_dir_all(&skills_dir).unwrap();
412 std::os::unix::fs::symlink(&src_skill, skills_dir.join("alpha")).unwrap();
413
414 let out = run_status(Some(tgt.path()), false, OutputFormat::Text).unwrap();
415 assert!(out.contains(".claude/skills/alpha"), "got: {out}");
416 }
417
418 #[test]
419 fn run_status_mcp_includes_worktrees_when_requested() {
420 let tgt_main = tempdir();
421 let wt_parent = tempdir();
422 let linked = wt_parent.path().join("linked");
423 init_repo_with_commit(tgt_main.path());
424 worktree_add(tgt_main.path(), &linked);
425
426 let out = run_status(Some(tgt_main.path()), true, OutputFormat::Yaml).unwrap();
427 assert!(out.contains("targets:"), "missing targets: {out}");
428 }
429
430 #[test]
431 fn run_sync_mcp_errors_outside_repo() {
432 let plain = TempDir::new().unwrap();
433 let err = run_sync(Some(plain.path()), false, OutputFormat::Text).unwrap_err();
434 let msg = format!("{err:?}");
435 assert!(
436 msg.contains("git rev-parse --show-toplevel failed")
437 || msg.contains("Failed to run git"),
438 "unexpected error: {msg}"
439 );
440 }
441
442 #[test]
443 fn run_clean_mcp_errors_outside_repo() {
444 let plain = TempDir::new().unwrap();
445 let err = run_clean(Some(plain.path()), false, OutputFormat::Text).unwrap_err();
446 let msg = format!("{err:?}");
447 assert!(msg.contains("git rev-parse"), "unexpected: {msg}");
448 }
449
450 #[test]
451 fn run_status_mcp_errors_outside_repo() {
452 let plain = TempDir::new().unwrap();
453 let err = run_status(Some(plain.path()), false, OutputFormat::Text).unwrap_err();
454 let msg = format!("{err:?}");
455 assert!(msg.contains("git rev-parse"), "unexpected: {msg}");
456 }
457
458 #[test]
459 fn run_status_mcp_defaults_base_dir_to_cwd() {
460 let _ = run_status(None, false, OutputFormat::Text);
463 }
464
465 #[test]
466 fn render_sync_text_covers_all_variants() {
467 let report = sync::SyncReport {
468 actions: vec![
469 sync::SyncAction::Linked {
470 link: PathBuf::from("/a"),
471 points_to: PathBuf::from("/b"),
472 },
473 sync::SyncAction::Relinked {
474 link: PathBuf::from("/c"),
475 points_to: PathBuf::from("/d"),
476 },
477 sync::SyncAction::Excluded {
478 exclude_file: PathBuf::from("/e"),
479 entry: ".claude/skills/x/".into(),
480 },
481 sync::SyncAction::SkippedSameTarget {
482 target: PathBuf::from("/f"),
483 },
484 ],
485 errors: vec![sync::SyncError {
486 target: PathBuf::from("/g"),
487 reason: "blocked".into(),
488 }],
489 };
490 let out = render_sync_text(&report);
491 for s in [
492 "linked /a -> /b",
493 "relinked /c -> /d",
494 "excluded .claude/skills/x/ in /e",
495 "skipped /f",
496 "error: /g",
497 ] {
498 assert!(out.contains(s), "missing {s}: {out}");
499 }
500 }
501
502 #[test]
503 fn render_clean_text_covers_all_variants() {
504 let report = clean::CleanReport {
505 actions: vec![
506 clean::CleanAction::Unlinked {
507 link: PathBuf::from("/a"),
508 },
509 clean::CleanAction::Preserved {
510 path: PathBuf::from("/b"),
511 reason: "real file".into(),
512 },
513 clean::CleanAction::ExcludeRemoved {
514 exclude_file: PathBuf::from("/c"),
515 entry: ".claude/skills/x/".into(),
516 },
517 clean::CleanAction::DirectoryRemoved {
518 path: PathBuf::from("/d"),
519 },
520 ],
521 };
522 let out = render_clean_text(&report);
523 for s in [
524 "unlinked /a",
525 "preserved /b (real file)",
526 "removed exclude entry .claude/skills/x/ from /c",
527 "removed empty /d",
528 ] {
529 assert!(out.contains(s), "missing {s}: {out}");
530 }
531 }
532
533 #[test]
534 fn render_status_text_covers_mixed_targets() {
535 let report = status::StatusReport {
536 targets: vec![
537 status::TargetStatus {
538 root: PathBuf::from("/empty"),
539 symlinks: Vec::new(),
540 exclude_file: PathBuf::from("/empty/.git/info/exclude"),
541 exclude_entries: Vec::new(),
542 },
543 status::TargetStatus {
544 root: PathBuf::from("/first"),
545 symlinks: Vec::new(),
546 exclude_file: PathBuf::from("/first/.git/info/exclude"),
547 exclude_entries: vec![".claude/skills/beta/".into()],
548 },
549 status::TargetStatus {
550 root: PathBuf::from("/both"),
551 symlinks: vec![status::SymlinkInfo {
552 path: PathBuf::from("/both/.claude/skills/alpha"),
553 points_to: PathBuf::from("/src/alpha"),
554 }],
555 exclude_file: PathBuf::from("/both/.git/info/exclude"),
556 exclude_entries: vec![".claude/skills/alpha/".into()],
557 },
558 ],
559 };
560 let out = render_status_text(&report);
561 assert!(out.contains("/first"), "missing /first: {out}");
562 assert!(out.contains("/both"), "missing /both: {out}");
563 assert!(out.contains("symlinks:"), "missing symlinks: {out}");
564 assert!(
565 out.contains("exclude block"),
566 "missing exclude block: {out}"
567 );
568 assert!(
570 out.contains("\n\n"),
571 "missing blank-line separator between targets: {out}"
572 );
573 assert!(
574 !out.contains("/empty\n"),
575 "should skip empty target header: {out}"
576 );
577 }
578
579 #[test]
580 fn render_sync_yaml_contains_dry_run_and_actions_keys() {
581 let report = sync::SyncReport {
582 actions: Vec::new(),
583 errors: Vec::new(),
584 };
585 let out = render_sync_report(&report, OutputFormat::Yaml).unwrap();
586 assert!(out.contains("dry_run: false"));
587 assert!(out.contains("actions:"));
588 assert!(out.contains("errors:"));
589 }
590
591 #[test]
592 fn render_clean_yaml_contains_dry_run_and_actions_keys() {
593 let report = clean::CleanReport {
594 actions: Vec::new(),
595 };
596 let out = render_clean_report(&report, OutputFormat::Yaml).unwrap();
597 assert!(out.contains("dry_run: false"));
598 assert!(out.contains("actions:"));
599 }
600
601 #[test]
602 fn render_status_yaml_contains_targets_key() {
603 let report = status::StatusReport {
604 targets: Vec::new(),
605 };
606 let out = render_status_report(&report, OutputFormat::Yaml).unwrap();
607 assert!(out.contains("targets:"));
608 }
609
610 #[allow(dead_code)]
612 fn _unused(_: PathBuf) {}
613}
614
615#[cfg(test)]
616#[allow(clippy::unwrap_used, clippy::expect_used)]
617mod tests {
618 use super::*;
619
620 use std::fs;
621 use std::path::Path;
622
623 use tempfile::TempDir;
624
625 use super::common::test_git::{init_repo, init_repo_with_commit, worktree_add};
626 use common::OutputFormat;
627
628 fn tempdir() -> TempDir {
629 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
632 fs::create_dir_all(&root).ok();
633 TempDir::new_in(&root).unwrap()
634 }
635
636 #[test]
637 fn dispatch_sync() {
638 let src = tempdir();
639 let tgt = tempdir();
640 init_repo(src.path());
641 init_repo(tgt.path());
642 let skills_dir = src.path().join(".claude/skills/alpha");
643 fs::create_dir_all(&skills_dir).unwrap();
644 fs::write(skills_dir.join("SKILL.md"), "# alpha").unwrap();
645
646 let cmd = SkillsCommand {
647 repo: crate::cli::repo_arg::RepoArg::default(),
648 command: SkillsSubcommands::Sync(sync::SyncCommand {
649 source: Some(src.path().to_path_buf()),
650 target: Some(tgt.path().to_path_buf()),
651 worktrees: false,
652 dry_run: false,
653 format: OutputFormat::Text,
654 }),
655 };
656 cmd.execute().unwrap();
657 assert!(tgt.path().join(".claude/skills/alpha").exists());
658 }
659
660 #[test]
661 fn dispatch_clean() {
662 let tgt = tempdir();
663 init_repo(tgt.path());
664
665 let cmd = SkillsCommand {
666 repo: crate::cli::repo_arg::RepoArg::default(),
667 command: SkillsSubcommands::Clean(clean::CleanCommand {
668 target: Some(tgt.path().to_path_buf()),
669 worktrees: false,
670 dry_run: false,
671 format: OutputFormat::Text,
672 }),
673 };
674 cmd.execute().unwrap();
675 }
676
677 #[test]
678 fn dispatch_status() {
679 let tgt = tempdir();
680 init_repo(tgt.path());
681
682 let cmd = SkillsCommand {
683 repo: crate::cli::repo_arg::RepoArg::default(),
684 command: SkillsSubcommands::Status(status::StatusCommand {
685 target: Some(tgt.path().to_path_buf()),
686 worktrees: false,
687 format: OutputFormat::Text,
688 }),
689 };
690 cmd.execute().unwrap();
691 }
692
693 fn parse(argv: &[&str]) -> SkillsCommand {
694 SkillsCommand::try_parse_from(std::iter::once("skills").chain(argv.iter().copied()))
695 .unwrap()
696 }
697
698 fn seed_skill(root: &Path, name: &str) {
699 let dir = root.join(".claude/skills").join(name);
700 fs::create_dir_all(&dir).unwrap();
701 fs::write(dir.join("SKILL.md"), format!("# {name}")).unwrap();
702 }
703
704 #[test]
705 fn repo_flag_parses_before_and_after_the_leaf() {
706 for flag in ["-C", "--repo"] {
707 for argv in [["status", flag, "/tmp/r"], [flag, "/tmp/r", "status"]] {
708 let cmd = parse(&argv);
709 assert_eq!(cmd.repo.path(), Some(Path::new("/tmp/r")), "{argv:?}");
710 }
711 }
712 assert!(parse(&["status"]).repo.path().is_none());
713 }
714
715 #[test]
718 fn sync_uses_repo_as_source_and_base_for_relative_target() {
719 let parent = tempdir();
720 let src = parent.path().join("src");
721 let tgt = parent.path().join("tgt");
722 for d in [&src, &tgt] {
723 fs::create_dir_all(d).unwrap();
724 init_repo(d);
725 }
726 seed_skill(&src, "alpha");
727
728 let cmd = parse(&["sync", "-C", src.to_str().unwrap(), "--target", "../tgt"]);
729 cmd.execute().unwrap();
730 assert!(tgt.join(".claude/skills/alpha").exists());
731 }
732
733 #[test]
734 fn clean_acts_on_repo_not_cwd() {
735 let src = tempdir();
736 let tgt = tempdir();
737 init_repo(src.path());
738 init_repo(tgt.path());
739 seed_skill(src.path(), "alpha");
740 parse(&[
741 "sync",
742 "--source",
743 src.path().to_str().unwrap(),
744 "--target",
745 tgt.path().to_str().unwrap(),
746 ])
747 .execute()
748 .unwrap();
749 let link = tgt.path().join(".claude/skills/alpha");
750 assert!(link.symlink_metadata().is_ok());
751
752 parse(&["clean", "-C", tgt.path().to_str().unwrap()])
753 .execute()
754 .unwrap();
755 assert!(link.symlink_metadata().is_err(), "symlink not cleaned");
756 }
757
758 #[test]
761 fn status_resolves_repo_flag_instead_of_cwd() {
762 let plain = TempDir::new().unwrap();
763 let err = parse(&["status", "-C", plain.path().to_str().unwrap()])
764 .execute()
765 .unwrap_err();
766 assert!(format!("{err:?}").contains("git rev-parse"), "{err:?}");
767 }
768
769 #[test]
771 fn sync_resolves_relative_source_against_repo() {
772 let parent = tempdir();
773 for name in ["src", "tgt"] {
774 let d = parent.path().join(name);
775 fs::create_dir_all(&d).unwrap();
776 init_repo(&d);
777 }
778 seed_skill(&parent.path().join("src"), "alpha");
779
780 parse(&[
781 "sync",
782 "-C",
783 parent.path().to_str().unwrap(),
784 "--source",
785 "src",
786 "--target",
787 "tgt",
788 ])
789 .execute()
790 .unwrap();
791 assert!(parent.path().join("tgt/.claude/skills/alpha").exists());
792 }
793
794 #[test]
796 fn clean_with_worktrees_covers_the_repo_flag_worktrees() {
797 let src = tempdir();
798 let main = tempdir();
799 let wt_parent = tempdir();
800 let linked = wt_parent.path().join("linked");
801 init_repo(src.path());
802 init_repo_with_commit(main.path());
803 worktree_add(main.path(), &linked);
804 seed_skill(src.path(), "alpha");
805 parse(&[
806 "sync",
807 "--source",
808 src.path().to_str().unwrap(),
809 "--target",
810 linked.to_str().unwrap(),
811 ])
812 .execute()
813 .unwrap();
814 let link = linked.join(".claude/skills/alpha");
815 assert!(link.symlink_metadata().is_ok());
816
817 parse(&["clean", "--worktrees", "-C", main.path().to_str().unwrap()])
818 .execute()
819 .unwrap();
820 assert!(
821 link.symlink_metadata().is_err(),
822 "linked worktree not cleaned"
823 );
824 }
825}