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