Skip to main content

omni_dev/cli/ai/claude/skills/
common.rs

1//! Shared helpers for skills sync, clean, and status commands.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use anyhow::{Context, Result};
8use clap::ValueEnum;
9use serde::Serialize;
10
11/// Skills directory name relative to a repository or worktree root.
12pub(super) const SKILLS_SUBPATH: &str = ".claude/skills";
13
14/// Prefix used for entries written to `.git/info/exclude`.
15pub(super) const EXCLUDE_PREFIX: &str = ".claude/skills/";
16
17/// Opening marker for the managed block inside `.git/info/exclude`. Changing
18/// this string would orphan blocks written by prior versions — forward
19/// compatibility commitment.
20pub(super) const BLOCK_BEGIN: &str = "# BEGIN omni-dev-skills (managed — do not edit)";
21
22/// Closing marker for the managed block inside `.git/info/exclude`.
23pub(super) const BLOCK_END: &str = "# END omni-dev-skills";
24
25/// Output format shared by `sync`, `clean`, and `status`.
26#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "snake_case")]
28pub enum OutputFormat {
29    /// Human-readable lines, one per action.
30    #[default]
31    Text,
32    /// Machine-readable YAML document.
33    Yaml,
34}
35
36/// Builds a `git` [`Command`] whose child receives an explicit, lock-synchronized
37/// copy of the current environment rather than inheriting the live process-global
38/// `environ` table at `exec` time.
39///
40/// `env_clear` flips `std` onto the path where it constructs the child's `envp`
41/// **in the parent** — `env::vars_os` snapshots the environment under `std`'s
42/// internal env lock, and the post-`fork` child execs with that captured array,
43/// never reading the global `environ`. That removes these `git` spawns from the
44/// data race against a concurrent `std::env::set_var`/`remove_var` on another
45/// thread (the cause of the intermittent skills-test failures in issue #1022):
46/// a writer mutating the global table is invisible to a child whose `envp` was
47/// already captured. The snapshot preserves the inherited environment, so
48/// production behaviour is unchanged.
49pub(super) fn git_command() -> Command {
50    let mut cmd = Command::new("git");
51    cmd.env_clear();
52    cmd.envs(std::env::vars_os());
53    cmd
54}
55
56/// Runs `git rev-parse --show-toplevel` from `path` and returns the absolute root.
57pub(super) fn resolve_toplevel(path: &Path) -> Result<PathBuf> {
58    let output = git_command()
59        .args(["rev-parse", "--show-toplevel"])
60        .current_dir(path)
61        .output()
62        .with_context(|| ctx_spawn_failure("git rev-parse --show-toplevel", path))?;
63    if !output.status.success() {
64        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
65        anyhow::bail!(
66            "git rev-parse --show-toplevel failed in {}: {err}",
67            path.display()
68        );
69    }
70    let stdout = String::from_utf8(output.stdout)
71        .context("git rev-parse --show-toplevel output was not UTF-8")?;
72    Ok(PathBuf::from(stdout.trim()))
73}
74
75/// Runs `git rev-parse --git-common-dir` from `path` and returns an absolute path.
76///
77/// The command may return a relative path (e.g. `.git`) when executed inside the
78/// main worktree; this helper resolves any such relative path against `path`.
79pub(super) fn resolve_git_common_dir(path: &Path) -> Result<PathBuf> {
80    let output = git_command()
81        .args(["rev-parse", "--git-common-dir"])
82        .current_dir(path)
83        .output()
84        .with_context(|| ctx_spawn_failure("git rev-parse --git-common-dir", path))?;
85    if !output.status.success() {
86        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
87        anyhow::bail!(
88            "git rev-parse --git-common-dir failed in {}: {err}",
89            path.display()
90        );
91    }
92    let stdout = String::from_utf8(output.stdout)
93        .context("git rev-parse --git-common-dir output was not UTF-8")?;
94    let raw = PathBuf::from(stdout.trim());
95    if raw.is_absolute() {
96        Ok(raw)
97    } else {
98        Ok(path.join(raw))
99    }
100}
101
102/// Lists all worktree root paths for the repository containing `path`.
103pub(super) fn list_worktrees(path: &Path) -> Result<Vec<PathBuf>> {
104    let output = git_command()
105        .args(["worktree", "list", "--porcelain"])
106        .current_dir(path)
107        .output()
108        .with_context(|| format!("Failed to run git worktree list in {}", path.display()))?;
109    if !output.status.success() {
110        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
111        anyhow::bail!("git worktree list failed in {}: {err}", path.display());
112    }
113    let stdout =
114        String::from_utf8(output.stdout).context("git worktree list output was not UTF-8")?;
115    Ok(parse_worktree_list(&stdout))
116}
117
118/// Parses porcelain output from `git worktree list --porcelain` into root paths.
119pub(super) fn parse_worktree_list(output: &str) -> Vec<PathBuf> {
120    let mut roots = Vec::new();
121    for line in output.lines() {
122        if let Some(rest) = line.strip_prefix("worktree ") {
123            roots.push(PathBuf::from(rest));
124        }
125    }
126    roots
127}
128
129/// Lists skill directories in `source_skills_dir`, sorted by name.
130pub(super) fn enumerate_skills(source_skills_dir: &Path) -> Result<Vec<(String, PathBuf)>> {
131    let mut skills = Vec::new();
132    if !source_skills_dir.exists() {
133        return Ok(skills);
134    }
135    let entries = fs::read_dir(source_skills_dir)
136        .with_context(|| format!("Failed to read {}", source_skills_dir.display()))?;
137    let dir_label = source_skills_dir.display();
138    for entry in entries {
139        let entry =
140            entry.with_context(|| format!("Failed to read directory entry in {dir_label}"))?;
141        let path = entry.path();
142        if !path.is_dir() {
143            continue;
144        }
145        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
146            continue;
147        };
148        skills.push((name.to_string(), path));
149    }
150    skills.sort_by(|a, b| a.0.cmp(&b.0));
151    Ok(skills)
152}
153
154/// Returns the `.git/info/exclude` path for a target directory, using the common git dir.
155pub(super) fn exclude_file_for(target_root: &Path) -> Result<PathBuf> {
156    let common = resolve_git_common_dir(target_root)?;
157    Ok(common.join("info").join("exclude"))
158}
159
160/// Returns the exclude-file entry for a given skill name.
161pub(super) fn exclude_entry_for(skill_name: &str) -> String {
162    format!("{EXCLUDE_PREFIX}{skill_name}/")
163}
164
165/// Upserts the managed skills block inside `.git/info/exclude`.
166///
167/// Foreign lines outside the block are preserved verbatim. Hand-edits inside
168/// the block are not preserved — the block is rewritten with the union of its
169/// existing entries and `entries`. Returns the entries newly added.
170pub(super) fn upsert_skills_block(
171    exclude_file: &Path,
172    entries: &[String],
173    dry_run: bool,
174) -> Result<Vec<String>> {
175    let content = read_existing_content(exclude_file)?;
176    let lines: Vec<&str> = content.lines().collect();
177    let block = find_block(&lines);
178
179    let existing: Vec<String> = match &block {
180        Some(b) => lines[b.begin + 1..b.end]
181            .iter()
182            .filter(|l| **l != BLOCK_BEGIN && **l != BLOCK_END)
183            .map(|&s| s.to_string())
184            .collect(),
185        None => Vec::new(),
186    };
187
188    let mut additions: Vec<String> = Vec::new();
189    for entry in entries {
190        if !existing.iter().any(|e| e == entry) && !additions.iter().any(|e| e == entry) {
191            additions.push(entry.clone());
192        }
193    }
194
195    if additions.is_empty() || dry_run {
196        return Ok(additions);
197    }
198
199    let mut out_lines: Vec<String> = Vec::new();
200    if let Some(b) = block {
201        out_lines.extend(lines[..b.begin].iter().map(|&s| s.to_string()));
202        out_lines.push(BLOCK_BEGIN.to_string());
203        out_lines.extend(existing.iter().cloned());
204        out_lines.extend(additions.iter().cloned());
205        out_lines.push(BLOCK_END.to_string());
206        out_lines.extend(lines[b.end + 1..].iter().map(|&s| s.to_string()));
207    } else {
208        out_lines.extend(lines.iter().map(|&s| s.to_string()));
209        out_lines.push(BLOCK_BEGIN.to_string());
210        out_lines.extend(additions.iter().cloned());
211        out_lines.push(BLOCK_END.to_string());
212    }
213
214    write_exclude_file(exclude_file, &out_lines)?;
215    Ok(additions)
216}
217
218/// Removes the managed skills block from `.git/info/exclude` entirely.
219///
220/// Returns every line that was inside the block. Foreign lines outside the
221/// block are preserved. No-op (returning empty) if the file or block is absent.
222pub(super) fn remove_skills_block(exclude_file: &Path, dry_run: bool) -> Result<Vec<String>> {
223    if !exclude_file.exists() {
224        return Ok(Vec::new());
225    }
226    let content = fs::read_to_string(exclude_file)
227        .with_context(|| format!("Failed to read {}", exclude_file.display()))?;
228    let lines: Vec<&str> = content.lines().collect();
229    let Some(block) = find_block(&lines) else {
230        return Ok(Vec::new());
231    };
232    let removed: Vec<String> = lines[block.begin + 1..block.end]
233        .iter()
234        .map(|&s| s.to_string())
235        .collect();
236
237    if dry_run {
238        return Ok(removed);
239    }
240
241    let mut out_lines: Vec<String> = Vec::new();
242    out_lines.extend(lines[..block.begin].iter().map(|&s| s.to_string()));
243    out_lines.extend(lines[block.end + 1..].iter().map(|&s| s.to_string()));
244
245    write_exclude_file(exclude_file, &out_lines)?;
246    Ok(removed)
247}
248
249/// Reads the entries currently inside the managed skills block, if any.
250pub(super) fn read_skills_block_entries(exclude_file: &Path) -> Result<Vec<String>> {
251    if !exclude_file.exists() {
252        return Ok(Vec::new());
253    }
254    let content = fs::read_to_string(exclude_file)
255        .with_context(|| format!("Failed to read {}", exclude_file.display()))?;
256    let lines: Vec<&str> = content.lines().collect();
257    let Some(block) = find_block(&lines) else {
258        return Ok(Vec::new());
259    };
260    Ok(lines[block.begin + 1..block.end]
261        .iter()
262        .map(|&s| s.to_string())
263        .collect())
264}
265
266struct BlockBounds {
267    begin: usize,
268    end: usize,
269}
270
271fn find_block(lines: &[&str]) -> Option<BlockBounds> {
272    let begin = lines.iter().position(|l| *l == BLOCK_BEGIN)?;
273    let end_offset = lines[begin + 1..].iter().position(|l| *l == BLOCK_END)?;
274    Some(BlockBounds {
275        begin,
276        end: begin + 1 + end_offset,
277    })
278}
279
280fn read_existing_content(exclude_file: &Path) -> Result<String> {
281    if exclude_file.exists() {
282        fs::read_to_string(exclude_file)
283            .with_context(|| format!("Failed to read {}", exclude_file.display()))
284    } else {
285        Ok(String::new())
286    }
287}
288
289fn write_exclude_file(exclude_file: &Path, lines: &[String]) -> Result<()> {
290    if let Some(parent) = exclude_file.parent() {
291        fs::create_dir_all(parent)
292            .with_context(|| format!("Failed to create {}", parent.display()))?;
293    }
294    let output = if lines.is_empty() {
295        String::new()
296    } else {
297        let mut s = lines.join("\n");
298        s.push('\n');
299        s
300    };
301    fs::write(exclude_file, output)
302        .with_context(|| format!("Failed to write {}", exclude_file.display()))
303}
304
305fn ctx_spawn_failure(command: &str, path: &Path) -> String {
306    format!("Failed to run {command} in {}", path.display())
307}
308
309/// Git-repository fixtures shared by every skills command's test module.
310///
311/// All spawns route through [`git_command`] so the child gets an explicit,
312/// race-free environment — see that function for why this matters under the
313/// parallel test run (issue #1022). Centralizing these here also retires the
314/// per-module copies of `init_repo`/`init_repo_with_commit` that previously
315/// each spawned `git` directly.
316#[cfg(test)]
317#[allow(clippy::unwrap_used, clippy::expect_used)]
318pub(crate) mod test_git {
319    use super::git_command;
320    use std::path::Path;
321
322    /// `git init <dir>`; panics on a spawn failure or non-zero exit.
323    pub(crate) fn init_repo(dir: &Path) {
324        let status = git_command()
325            .arg("init")
326            .arg(dir)
327            .output()
328            .expect("git init failed to spawn");
329        assert!(status.status.success(), "git init failed: {status:?}");
330    }
331
332    /// `init_repo` plus a single committed `README.md`. Identity and signing
333    /// are pinned inline (`-c`) so the fixture neither needs ambient git user
334    /// config nor invokes the developer's commit-signing key — a throwaway test
335    /// commit must not depend on (or flake on) the host's `commit.gpgsign`.
336    pub(crate) fn init_repo_with_commit(dir: &Path) {
337        init_repo(dir);
338        std::fs::write(dir.join("README.md"), "readme").expect("write README");
339        let add = git_command()
340            .args(["add", "README.md"])
341            .current_dir(dir)
342            .output()
343            .expect("git add failed to spawn");
344        assert!(add.status.success(), "git add failed: {add:?}");
345        let commit = git_command()
346            .args([
347                "-c",
348                "user.email=x@x",
349                "-c",
350                "user.name=x",
351                "-c",
352                "commit.gpgsign=false",
353                "commit",
354                "-q",
355                "-m",
356                "init",
357            ])
358            .current_dir(dir)
359            .output()
360            .expect("git commit failed to spawn");
361        assert!(commit.status.success(), "git commit failed: {commit:?}");
362    }
363
364    /// `git worktree add -q <linked>` run from inside `repo`.
365    pub(crate) fn worktree_add(repo: &Path, linked: &Path) {
366        let add = git_command()
367            .args(["worktree", "add", "-q"])
368            .arg(linked)
369            .current_dir(repo)
370            .output()
371            .expect("git worktree add failed to spawn");
372        assert!(add.status.success(), "git worktree add failed: {add:?}");
373    }
374}
375
376#[cfg(test)]
377#[allow(clippy::unwrap_used, clippy::expect_used)]
378mod tests {
379    use super::*;
380
381    use tempfile::TempDir;
382
383    use super::test_git::{init_repo, init_repo_with_commit, worktree_add};
384
385    fn tempdir() -> TempDir {
386        std::fs::create_dir_all("tmp").ok();
387        TempDir::new_in("tmp").unwrap()
388    }
389
390    #[test]
391    fn resolve_toplevel_returns_repo_root() {
392        let dir = tempdir();
393        init_repo(dir.path());
394        let expected = fs::canonicalize(dir.path()).unwrap();
395        let result = resolve_toplevel(dir.path()).unwrap();
396        assert_eq!(fs::canonicalize(result).unwrap(), expected);
397    }
398
399    #[test]
400    fn resolve_toplevel_from_subdir_returns_repo_root() {
401        let dir = tempdir();
402        init_repo(dir.path());
403        let sub = dir.path().join("sub/dir");
404        fs::create_dir_all(&sub).unwrap();
405        let expected = fs::canonicalize(dir.path()).unwrap();
406        let result = resolve_toplevel(&sub).unwrap();
407        assert_eq!(fs::canonicalize(result).unwrap(), expected);
408    }
409
410    #[test]
411    fn resolve_toplevel_outside_repo_fails() {
412        let dir = TempDir::new().unwrap();
413        let err = resolve_toplevel(dir.path()).unwrap_err().to_string();
414        assert!(
415            err.contains("git rev-parse --show-toplevel failed"),
416            "unexpected error: {err}"
417        );
418    }
419
420    #[test]
421    fn resolve_git_common_dir_in_main_worktree() {
422        let dir = tempdir();
423        init_repo(dir.path());
424        let common = resolve_git_common_dir(dir.path()).unwrap();
425        assert!(common.ends_with(".git"), "got {}", common.display());
426    }
427
428    #[test]
429    fn resolve_git_common_dir_from_linked_worktree_points_at_main() {
430        let main = tempdir();
431        init_repo_with_commit(main.path());
432        let wt = tempdir();
433        let linked = wt.path().join("linked");
434        worktree_add(main.path(), &linked);
435
436        let common = resolve_git_common_dir(&linked).unwrap();
437        let main_git = fs::canonicalize(main.path().join(".git")).unwrap();
438        assert_eq!(fs::canonicalize(&common).unwrap(), main_git);
439    }
440
441    #[test]
442    fn resolve_git_common_dir_outside_repo_fails() {
443        let dir = TempDir::new().unwrap();
444        let err = resolve_git_common_dir(dir.path()).unwrap_err().to_string();
445        assert!(
446            err.contains("git rev-parse --git-common-dir failed"),
447            "unexpected error: {err}"
448        );
449    }
450
451    #[test]
452    fn list_worktrees_returns_single_for_plain_repo() {
453        let dir = tempdir();
454        init_repo(dir.path());
455        let trees = list_worktrees(dir.path()).unwrap();
456        assert_eq!(trees.len(), 1);
457        assert_eq!(
458            fs::canonicalize(&trees[0]).unwrap(),
459            fs::canonicalize(dir.path()).unwrap()
460        );
461    }
462
463    #[test]
464    fn list_worktrees_returns_multiple_with_linked_worktree() {
465        let main = tempdir();
466        init_repo_with_commit(main.path());
467        let wt = tempdir();
468        let linked = wt.path().join("linked");
469        worktree_add(main.path(), &linked);
470
471        let trees = list_worktrees(main.path()).unwrap();
472        assert_eq!(trees.len(), 2);
473    }
474
475    #[test]
476    fn list_worktrees_outside_repo_fails() {
477        let dir = TempDir::new().unwrap();
478        let err = list_worktrees(dir.path()).unwrap_err().to_string();
479        assert!(
480            err.contains("git worktree list failed"),
481            "unexpected error: {err}"
482        );
483    }
484
485    #[cfg(target_os = "linux")]
486    #[test]
487    fn enumerate_skills_skips_directory_with_non_utf8_name() {
488        use std::ffi::OsStr;
489        use std::os::unix::ffi::OsStrExt;
490
491        let dir = tempdir();
492        let skills = dir.path().join("skills");
493        fs::create_dir_all(&skills).unwrap();
494        fs::create_dir_all(skills.join("alpha")).unwrap();
495        let bad = OsStr::from_bytes(b"bad\xffname");
496        fs::create_dir_all(skills.join(bad)).unwrap();
497
498        let result = enumerate_skills(&skills).unwrap();
499        let names: Vec<_> = result.iter().map(|(n, _)| n.clone()).collect();
500        assert_eq!(names, vec!["alpha"]);
501    }
502
503    #[test]
504    fn exclude_file_for_points_to_info_exclude_under_common_dir() {
505        let dir = tempdir();
506        init_repo(dir.path());
507        let path = exclude_file_for(dir.path()).unwrap();
508        assert!(
509            path.ends_with(".git/info/exclude"),
510            "got {}",
511            path.display()
512        );
513    }
514
515    #[test]
516    fn parse_worktree_list_single() {
517        let out = "worktree /path/to/repo\nHEAD abc123\nbranch refs/heads/main\n";
518        let roots = parse_worktree_list(out);
519        assert_eq!(roots, vec![PathBuf::from("/path/to/repo")]);
520    }
521
522    #[test]
523    fn parse_worktree_list_multiple() {
524        let out = "worktree /a/main\nHEAD abc\nbranch refs/heads/main\n\nworktree /a/feature\nHEAD def\nbranch refs/heads/feature\n";
525        let roots = parse_worktree_list(out);
526        assert_eq!(
527            roots,
528            vec![PathBuf::from("/a/main"), PathBuf::from("/a/feature")]
529        );
530    }
531
532    #[test]
533    fn parse_worktree_list_empty() {
534        assert!(parse_worktree_list("").is_empty());
535    }
536
537    #[test]
538    fn exclude_entry_format() {
539        assert_eq!(exclude_entry_for("review"), ".claude/skills/review/");
540    }
541
542    #[test]
543    fn enumerate_skills_missing_dir_returns_empty() {
544        let dir = tempdir();
545        let result = enumerate_skills(&dir.path().join("missing")).unwrap();
546        assert!(result.is_empty());
547    }
548
549    #[test]
550    fn enumerate_skills_lists_sorted_directories() {
551        let dir = tempdir();
552        let skills_dir = dir.path().join("skills");
553        fs::create_dir_all(skills_dir.join("charlie")).unwrap();
554        fs::create_dir_all(skills_dir.join("alpha")).unwrap();
555        fs::create_dir_all(skills_dir.join("bravo")).unwrap();
556        fs::write(skills_dir.join("README.md"), "hi").unwrap();
557        let result = enumerate_skills(&skills_dir).unwrap();
558        let names: Vec<_> = result.iter().map(|(n, _)| n.clone()).collect();
559        assert_eq!(names, vec!["alpha", "bravo", "charlie"]);
560    }
561
562    #[test]
563    fn upsert_skills_block_creates_block_when_absent() {
564        let dir = tempdir();
565        let exclude = dir.path().join("info").join("exclude");
566        let added = upsert_skills_block(
567            &exclude,
568            &[exclude_entry_for("review"), exclude_entry_for("init")],
569            false,
570        )
571        .unwrap();
572        assert_eq!(
573            added,
574            vec![
575                ".claude/skills/review/".to_string(),
576                ".claude/skills/init/".to_string()
577            ]
578        );
579        let content = fs::read_to_string(&exclude).unwrap();
580        let expected =
581            format!("{BLOCK_BEGIN}\n.claude/skills/review/\n.claude/skills/init/\n{BLOCK_END}\n");
582        assert_eq!(content, expected);
583    }
584
585    #[test]
586    fn upsert_skills_block_appends_to_existing_block() {
587        let dir = tempdir();
588        let exclude = dir.path().join("info").join("exclude");
589        upsert_skills_block(&exclude, &[exclude_entry_for("review")], false).unwrap();
590        let added = upsert_skills_block(&exclude, &[exclude_entry_for("init")], false).unwrap();
591        assert_eq!(added, vec![".claude/skills/init/".to_string()]);
592        let content = fs::read_to_string(&exclude).unwrap();
593        assert!(content.contains(".claude/skills/review/"));
594        assert!(content.contains(".claude/skills/init/"));
595        assert_eq!(content.matches(BLOCK_BEGIN).count(), 1);
596        assert_eq!(content.matches(BLOCK_END).count(), 1);
597    }
598
599    #[test]
600    fn upsert_skills_block_does_not_duplicate() {
601        let dir = tempdir();
602        let exclude = dir.path().join("info").join("exclude");
603        upsert_skills_block(&exclude, &[exclude_entry_for("review")], false).unwrap();
604        let added = upsert_skills_block(&exclude, &[exclude_entry_for("review")], false).unwrap();
605        assert!(added.is_empty());
606        let content = fs::read_to_string(&exclude).unwrap();
607        assert_eq!(content.matches(".claude/skills/review/").count(), 1);
608    }
609
610    #[test]
611    fn upsert_skills_block_dry_run_does_not_write() {
612        let dir = tempdir();
613        let exclude = dir.path().join("info").join("exclude");
614        let added = upsert_skills_block(&exclude, &[exclude_entry_for("review")], true).unwrap();
615        assert_eq!(added, vec![".claude/skills/review/".to_string()]);
616        assert!(!exclude.exists());
617    }
618
619    #[test]
620    fn upsert_skills_block_preserves_foreign_lines_before_and_after() {
621        let dir = tempdir();
622        let exclude = dir.path().join("info").join("exclude");
623        fs::create_dir_all(exclude.parent().unwrap()).unwrap();
624        let original = format!(
625            "# user comment\n*.tmp\n{BLOCK_BEGIN}\n.claude/skills/review/\n{BLOCK_END}\n*.log\n"
626        );
627        fs::write(&exclude, &original).unwrap();
628        upsert_skills_block(&exclude, &[exclude_entry_for("init")], false).unwrap();
629        let content = fs::read_to_string(&exclude).unwrap();
630        assert!(content.starts_with("# user comment\n*.tmp\n"));
631        assert!(content.ends_with("*.log\n"));
632        assert!(content.contains(".claude/skills/review/"));
633        assert!(content.contains(".claude/skills/init/"));
634    }
635
636    #[test]
637    fn upsert_skills_block_returns_empty_when_input_empty() {
638        let dir = tempdir();
639        let exclude = dir.path().join("info").join("exclude");
640        let added = upsert_skills_block(&exclude, &[], false).unwrap();
641        assert!(added.is_empty());
642        assert!(!exclude.exists());
643    }
644
645    #[test]
646    fn upsert_skills_block_dedupes_within_input() {
647        let dir = tempdir();
648        let exclude = dir.path().join("info").join("exclude");
649        let added = upsert_skills_block(
650            &exclude,
651            &[exclude_entry_for("review"), exclude_entry_for("review")],
652            false,
653        )
654        .unwrap();
655        assert_eq!(added.len(), 1);
656        let content = fs::read_to_string(&exclude).unwrap();
657        assert_eq!(content.matches(".claude/skills/review/").count(), 1);
658    }
659
660    #[test]
661    fn upsert_skills_block_appends_after_foreign_lines_in_new_file() {
662        let dir = tempdir();
663        let exclude = dir.path().join("info").join("exclude");
664        fs::create_dir_all(exclude.parent().unwrap()).unwrap();
665        fs::write(&exclude, "*.log\n").unwrap();
666        upsert_skills_block(&exclude, &[exclude_entry_for("review")], false).unwrap();
667        let content = fs::read_to_string(&exclude).unwrap();
668        assert!(content.starts_with("*.log\n"));
669        assert!(content.contains(BLOCK_BEGIN));
670        assert!(content.ends_with(&format!("{BLOCK_END}\n")));
671    }
672
673    #[test]
674    fn upsert_skills_block_propagates_create_dir_all_failure() {
675        let dir = tempdir();
676        let parent_path = dir.path().join("info");
677        fs::write(&parent_path, "block").unwrap();
678        let exclude = parent_path.join("exclude");
679        let err = upsert_skills_block(&exclude, &[exclude_entry_for("a")], false)
680            .unwrap_err()
681            .to_string();
682        assert!(err.contains("Failed to create"), "unexpected error: {err}");
683    }
684
685    #[cfg(unix)]
686    #[test]
687    fn upsert_skills_block_propagates_write_failure() {
688        use std::os::unix::fs::PermissionsExt;
689
690        let dir = tempdir();
691        let info = dir.path().join("info");
692        fs::create_dir_all(&info).unwrap();
693        let exclude = info.join("exclude");
694        let mut perms = fs::metadata(&info).unwrap().permissions();
695        perms.set_mode(0o500);
696        fs::set_permissions(&info, perms).unwrap();
697
698        let result = upsert_skills_block(&exclude, &[exclude_entry_for("a")], false);
699
700        let mut perms = fs::metadata(&info).unwrap().permissions();
701        perms.set_mode(0o700);
702        fs::set_permissions(&info, perms).unwrap();
703
704        let err = result.unwrap_err().to_string();
705        assert!(err.contains("Failed to write"), "unexpected error: {err}");
706    }
707
708    #[test]
709    fn remove_skills_block_removes_block_and_reports_entries() {
710        let dir = tempdir();
711        let exclude = dir.path().join("info").join("exclude");
712        fs::create_dir_all(exclude.parent().unwrap()).unwrap();
713        let content = format!(
714            "# comment\n*.tmp\n{BLOCK_BEGIN}\n.claude/skills/review/\n.claude/skills/init/\n{BLOCK_END}\n"
715        );
716        fs::write(&exclude, &content).unwrap();
717
718        let removed = remove_skills_block(&exclude, false).unwrap();
719        assert_eq!(
720            removed,
721            vec![
722                ".claude/skills/review/".to_string(),
723                ".claude/skills/init/".to_string()
724            ]
725        );
726        let new_content = fs::read_to_string(&exclude).unwrap();
727        assert_eq!(new_content, "# comment\n*.tmp\n");
728    }
729
730    #[test]
731    fn remove_skills_block_missing_file_is_noop() {
732        let dir = tempdir();
733        let exclude = dir.path().join("info").join("exclude");
734        let removed = remove_skills_block(&exclude, false).unwrap();
735        assert!(removed.is_empty());
736    }
737
738    #[test]
739    fn remove_skills_block_missing_block_is_noop() {
740        let dir = tempdir();
741        let exclude = dir.path().join("info").join("exclude");
742        fs::create_dir_all(exclude.parent().unwrap()).unwrap();
743        fs::write(&exclude, "# comment\n*.tmp\n").unwrap();
744        let removed = remove_skills_block(&exclude, false).unwrap();
745        assert!(removed.is_empty());
746        let content = fs::read_to_string(&exclude).unwrap();
747        assert_eq!(content, "# comment\n*.tmp\n");
748    }
749
750    #[test]
751    fn remove_skills_block_dry_run_does_not_modify() {
752        let dir = tempdir();
753        let exclude = dir.path().join("info").join("exclude");
754        fs::create_dir_all(exclude.parent().unwrap()).unwrap();
755        let content = format!("{BLOCK_BEGIN}\n.claude/skills/review/\n{BLOCK_END}\n");
756        fs::write(&exclude, &content).unwrap();
757        let removed = remove_skills_block(&exclude, true).unwrap();
758        assert_eq!(removed, vec![".claude/skills/review/".to_string()]);
759        assert_eq!(fs::read_to_string(&exclude).unwrap(), content);
760    }
761
762    #[test]
763    fn remove_skills_block_empties_file_when_only_block_present() {
764        let dir = tempdir();
765        let exclude = dir.path().join("info").join("exclude");
766        fs::create_dir_all(exclude.parent().unwrap()).unwrap();
767        let content = format!("{BLOCK_BEGIN}\n.claude/skills/review/\n{BLOCK_END}\n");
768        fs::write(&exclude, &content).unwrap();
769        remove_skills_block(&exclude, false).unwrap();
770        let new_content = fs::read_to_string(&exclude).unwrap();
771        assert_eq!(new_content, "");
772    }
773
774    #[cfg(unix)]
775    #[test]
776    fn remove_skills_block_propagates_write_failure() {
777        use std::os::unix::fs::PermissionsExt;
778
779        let dir = tempdir();
780        let info = dir.path().join("info");
781        fs::create_dir_all(&info).unwrap();
782        let exclude = info.join("exclude");
783        fs::write(
784            &exclude,
785            format!("{BLOCK_BEGIN}\n.claude/skills/a/\n{BLOCK_END}\n"),
786        )
787        .unwrap();
788        let mut perms = fs::metadata(&exclude).unwrap().permissions();
789        perms.set_mode(0o400);
790        fs::set_permissions(&exclude, perms).unwrap();
791
792        let result = remove_skills_block(&exclude, false);
793
794        let mut perms = fs::metadata(&exclude).unwrap().permissions();
795        perms.set_mode(0o600);
796        fs::set_permissions(&exclude, perms).unwrap();
797
798        let err = result.unwrap_err().to_string();
799        assert!(err.contains("Failed to write"), "unexpected error: {err}");
800    }
801
802    #[test]
803    fn read_skills_block_entries_returns_entries() {
804        let dir = tempdir();
805        let exclude = dir.path().join("info").join("exclude");
806        fs::create_dir_all(exclude.parent().unwrap()).unwrap();
807        let content = format!(
808            "# comment\n{BLOCK_BEGIN}\n.claude/skills/alpha/\n.claude/skills/bravo/\n{BLOCK_END}\n"
809        );
810        fs::write(&exclude, content).unwrap();
811        let entries = read_skills_block_entries(&exclude).unwrap();
812        assert_eq!(
813            entries,
814            vec![
815                ".claude/skills/alpha/".to_string(),
816                ".claude/skills/bravo/".to_string()
817            ]
818        );
819    }
820
821    #[test]
822    fn read_skills_block_entries_missing_file_returns_empty() {
823        let dir = tempdir();
824        let exclude = dir.path().join("info").join("exclude");
825        let entries = read_skills_block_entries(&exclude).unwrap();
826        assert!(entries.is_empty());
827    }
828
829    #[test]
830    fn read_skills_block_entries_missing_block_returns_empty() {
831        let dir = tempdir();
832        let exclude = dir.path().join("info").join("exclude");
833        fs::create_dir_all(exclude.parent().unwrap()).unwrap();
834        fs::write(&exclude, "*.log\n").unwrap();
835        let entries = read_skills_block_entries(&exclude).unwrap();
836        assert!(entries.is_empty());
837    }
838
839    #[test]
840    fn find_block_requires_both_markers() {
841        let only_begin = vec![BLOCK_BEGIN, ".claude/skills/a/"];
842        assert!(find_block(&only_begin).is_none());
843        let only_end = vec!["foo", BLOCK_END];
844        assert!(find_block(&only_end).is_none());
845    }
846
847    #[test]
848    fn find_block_returns_none_for_reversed_markers() {
849        let reversed = vec![BLOCK_END, "middle", BLOCK_BEGIN];
850        assert!(find_block(&reversed).is_none());
851    }
852
853    #[test]
854    fn resolve_toplevel_propagates_spawn_failure() {
855        let err = resolve_toplevel(Path::new("/this/path/should/not/exist/skills_test_spawn"))
856            .unwrap_err()
857            .to_string();
858        assert!(
859            err.contains("Failed to run git rev-parse --show-toplevel"),
860            "unexpected error: {err}"
861        );
862    }
863
864    #[test]
865    fn resolve_git_common_dir_propagates_spawn_failure() {
866        let err =
867            resolve_git_common_dir(Path::new("/this/path/should/not/exist/skills_test_spawn"))
868                .unwrap_err()
869                .to_string();
870        assert!(
871            err.contains("Failed to run git rev-parse --git-common-dir"),
872            "unexpected error: {err}"
873        );
874    }
875}