use anyhow::Result;
use assert_cmd::Command;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn create_test_manifest(gitignore: bool, source_dir: &Path) -> String {
let source_path = source_dir.display().to_string().replace('\\', "/");
format!(
r#"
[sources]
[target]
agents = ".claude/agents"
snippets = ".claude/ccpm/snippets"
commands = ".claude/commands"
gitignore = {}
[agents.test-agent]
path = "{}/agents/test.md"
[snippets.test-snippet]
path = "{}/snippets/test.md"
[commands.test-command]
path = "{}/commands/test.md"
"#,
gitignore, source_path, source_path, source_path
)
}
fn create_test_manifest_default(source_dir: &Path) -> String {
let source_path = source_dir.display().to_string().replace('\\', "/");
format!(
r#"
[sources]
[target]
agents = ".claude/agents"
snippets = ".claude/ccpm/snippets"
commands = ".claude/commands"
[agents.test-agent]
path = "{}/agents/test.md"
"#,
source_path
)
}
fn create_test_lockfile() -> String {
r#"
version = 1
[[agents]]
name = "test-agent"
path = "source/agents/test.md"
checksum = ""
installed_at = ".claude/agents/test-agent.md"
[[snippets]]
name = "test-snippet"
path = "source/snippets/test.md"
checksum = ""
installed_at = ".claude/ccpm/snippets/test-snippet.md"
[[commands]]
name = "test-command"
path = "source/commands/test.md"
checksum = ""
installed_at = ".claude/commands/test-command.md"
"#
.to_string()
}
fn create_test_source_files(source_dir: &Path) -> Result<()> {
fs::create_dir_all(source_dir.join("agents"))?;
fs::create_dir_all(source_dir.join("snippets"))?;
fs::create_dir_all(source_dir.join("commands"))?;
fs::write(source_dir.join("agents/test.md"), "# Test Agent\n")?;
fs::write(source_dir.join("snippets/test.md"), "# Test Snippet\n")?;
fs::write(source_dir.join("commands/test.md"), "# Test Command\n")?;
Ok(())
}
#[test]
fn test_gitignore_enabled_by_default() {
ccpm::test_utils::init_test_logging(None);
let temp = TempDir::new().unwrap();
let project_dir = temp.path();
let source_dir = temp.path().join("source");
create_test_source_files(&source_dir).unwrap();
let manifest_path = project_dir.join("ccpm.toml");
fs::write(&manifest_path, create_test_manifest_default(&source_dir)).unwrap();
let lockfile_path = project_dir.join("ccpm.lock");
fs::write(&lockfile_path, create_test_lockfile()).unwrap();
Command::cargo_bin("ccpm")
.unwrap()
.arg("install")
.arg("--quiet")
.arg("--force")
.current_dir(project_dir)
.assert();
let gitignore_path = project_dir.join(".gitignore");
assert!(
gitignore_path.exists(),
"Gitignore should be created by default"
);
let content = fs::read_to_string(&gitignore_path).unwrap();
assert!(content.contains("CCPM managed entries"));
assert!(content.contains("# End of CCPM managed entries"));
}
#[test]
fn test_gitignore_explicitly_enabled() {
ccpm::test_utils::init_test_logging(None);
let temp = TempDir::new().unwrap();
let project_dir = temp.path();
let source_dir = temp.path().join("source");
create_test_source_files(&source_dir).unwrap();
let manifest_path = project_dir.join("ccpm.toml");
fs::write(&manifest_path, create_test_manifest(true, &source_dir)).unwrap();
let lockfile_path = project_dir.join("ccpm.lock");
fs::write(&lockfile_path, create_test_lockfile()).unwrap();
Command::cargo_bin("ccpm")
.unwrap()
.arg("install")
.arg("--quiet")
.arg("--force")
.current_dir(project_dir)
.assert();
let gitignore_path = project_dir.join(".gitignore");
assert!(gitignore_path.exists(), "Gitignore should be created");
let content = fs::read_to_string(&gitignore_path).unwrap();
assert!(content.contains("CCPM managed entries"));
assert!(content.contains("CCPM managed entries - do not edit below this line"));
assert!(content.contains("# End of CCPM managed entries"));
}
#[test]
fn test_gitignore_disabled() {
ccpm::test_utils::init_test_logging(None);
let temp = TempDir::new().unwrap();
let project_dir = temp.path();
let source_dir = temp.path().join("source");
create_test_source_files(&source_dir).unwrap();
let manifest_path = project_dir.join("ccpm.toml");
fs::write(&manifest_path, create_test_manifest(false, &source_dir)).unwrap();
let lockfile_path = project_dir.join("ccpm.lock");
fs::write(&lockfile_path, create_test_lockfile()).unwrap();
Command::cargo_bin("ccpm")
.unwrap()
.arg("install")
.arg("--quiet")
.arg("--force")
.current_dir(project_dir)
.assert();
let gitignore_path = project_dir.join(".gitignore");
assert!(
!gitignore_path.exists(),
"Gitignore should not be created when disabled"
);
}
#[test]
fn test_gitignore_preserves_user_entries() {
ccpm::test_utils::init_test_logging(None);
let temp = TempDir::new().unwrap();
let project_dir = temp.path();
let source_dir = temp.path().join("source");
create_test_source_files(&source_dir).unwrap();
fs::create_dir_all(project_dir.join(".claude")).unwrap();
let gitignore_path = project_dir.join(".gitignore");
let user_content = r#"# User's custom comment
*.backup
user-file.txt
temp/
# CCPM managed entries - do not edit below this line
.claude/agents/old-agent.md
# End of CCPM managed entries
"#;
fs::write(&gitignore_path, user_content).unwrap();
let manifest_path = project_dir.join("ccpm.toml");
fs::write(&manifest_path, create_test_manifest(true, &source_dir)).unwrap();
let lockfile_path = project_dir.join("ccpm.lock");
fs::write(&lockfile_path, create_test_lockfile()).unwrap();
Command::cargo_bin("ccpm")
.unwrap()
.arg("install")
.arg("--quiet")
.arg("--force")
.current_dir(project_dir)
.assert();
let updated_content = fs::read_to_string(&gitignore_path).unwrap();
assert!(updated_content.contains("# User's custom comment"));
assert!(updated_content.contains("*.backup"));
assert!(updated_content.contains("user-file.txt"));
assert!(updated_content.contains("temp/"));
assert!(updated_content.contains("CCPM managed entries"));
assert!(updated_content.contains("# End of CCPM managed entries"));
assert!(updated_content.contains(".claude/ccpm/snippets/test-snippet.md"));
}
#[test]
fn test_gitignore_preserves_content_after_ccpm_section() {
ccpm::test_utils::init_test_logging(None);
let temp = TempDir::new().unwrap();
let project_dir = temp.path();
let source_dir = temp.path().join("source");
create_test_source_files(&source_dir).unwrap();
fs::create_dir_all(project_dir.join(".claude")).unwrap();
let gitignore_path = project_dir.join(".gitignore");
let user_content = r#"# Project gitignore
*.backup
temp/
# CCPM managed entries - do not edit below this line
.claude/agents/old-agent.md
# End of CCPM managed entries
# Additional entries after CCPM section
local-config.json
debug/
# End comment
"#;
fs::write(&gitignore_path, user_content).unwrap();
let manifest_path = project_dir.join("ccpm.toml");
fs::write(&manifest_path, create_test_manifest(true, &source_dir)).unwrap();
let lockfile_path = project_dir.join("ccpm.lock");
fs::write(&lockfile_path, create_test_lockfile()).unwrap();
Command::cargo_bin("ccpm")
.unwrap()
.arg("install")
.arg("--quiet")
.arg("--force")
.current_dir(project_dir)
.assert();
let updated_content = fs::read_to_string(&gitignore_path).unwrap();
assert!(updated_content.contains("# Project gitignore"));
assert!(updated_content.contains("*.backup"));
assert!(updated_content.contains("temp/"));
assert!(updated_content.contains("CCPM managed entries"));
assert!(updated_content.contains("# End of CCPM managed entries"));
assert!(updated_content.contains(".claude/ccpm/snippets/test-snippet.md"));
assert!(updated_content.contains("# Additional entries after CCPM section"));
assert!(updated_content.contains("local-config.json"));
assert!(updated_content.contains("debug/"));
assert!(updated_content.contains("# End comment"));
assert!(!updated_content.contains(".claude/agents/old-agent.md"));
}
#[test]
fn test_gitignore_update_command() {
ccpm::test_utils::init_test_logging(None);
let temp = TempDir::new().unwrap();
let project_dir = temp.path();
let source_dir = temp.path().join("source");
create_test_source_files(&source_dir).unwrap();
let manifest_path = project_dir.join("ccpm.toml");
fs::write(&manifest_path, create_test_manifest(true, &source_dir)).unwrap();
let lockfile_path = project_dir.join("ccpm.lock");
fs::write(&lockfile_path, create_test_lockfile()).unwrap();
Command::cargo_bin("ccpm")
.unwrap()
.arg("update")
.arg("--quiet")
.current_dir(project_dir)
.assert();
let gitignore_path = project_dir.join(".gitignore");
if gitignore_path.exists() {
let content = fs::read_to_string(&gitignore_path).unwrap();
assert!(content.contains("CCPM managed entries"));
}
}
#[test]
fn test_gitignore_handles_external_paths() {
ccpm::test_utils::init_test_logging(None);
let temp = TempDir::new().unwrap();
let project_dir = temp.path();
let manifest_path = project_dir.join("ccpm.toml");
let manifest_content = r#"
[sources]
test-source = "https://github.com/test/repo.git"
[target]
gitignore = true
[scripts.external-script]
source = "test-source"
path = "scripts/test.sh"
version = "v1.0.0"
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let lockfile_content = r#"
version = 1
[[sources]]
name = "test-source"
url = "https://github.com/test/repo.git"
commit = "abc123"
[[scripts]]
name = "external-script"
source = "test-source"
url = "https://github.com/test/repo.git"
path = "scripts/test.sh"
version = "v1.0.0"
resolved_commit = "abc123"
checksum = "sha256:test"
installed_at = "scripts/external.sh"
[[agents]]
name = "internal-agent"
source = "test-source"
url = "https://github.com/test/repo.git"
path = "agents/test.md"
version = "v1.0.0"
resolved_commit = "abc123"
checksum = "sha256:test"
installed_at = ".claude/agents/internal.md"
"#;
let lockfile_path = project_dir.join("ccpm.lock");
fs::write(&lockfile_path, lockfile_content).unwrap();
fs::create_dir_all(project_dir.join(".claude/agents")).unwrap();
fs::create_dir_all(project_dir.join("scripts")).unwrap();
fs::write(project_dir.join("scripts/external.sh"), "#!/bin/bash\n").unwrap();
fs::write(
project_dir.join(".claude/agents/internal.md"),
"# Internal\n",
)
.unwrap();
Command::cargo_bin("ccpm")
.unwrap()
.arg("install")
.arg("--quiet")
.arg("--force")
.current_dir(project_dir)
.assert();
let gitignore_path = project_dir.join(".gitignore");
if gitignore_path.exists() {
let content = fs::read_to_string(&gitignore_path).unwrap();
assert!(
content.contains("../scripts/external.sh"),
"External paths should use ../ prefix"
);
assert!(
content.contains(".claude/agents/internal.md"),
"Internal paths should use / prefix"
);
}
}
#[test]
fn test_gitignore_empty_lockfile() {
ccpm::test_utils::init_test_logging(None);
let temp = TempDir::new().unwrap();
let project_dir = temp.path();
let source_dir = temp.path().join("source");
create_test_source_files(&source_dir).unwrap();
let manifest_path = project_dir.join("ccpm.toml");
fs::write(&manifest_path, create_test_manifest(true, &source_dir)).unwrap();
let lockfile_path = project_dir.join("ccpm.lock");
fs::write(&lockfile_path, "version = 1\n").unwrap();
Command::cargo_bin("ccpm")
.unwrap()
.arg("install")
.arg("--quiet")
.arg("--force")
.current_dir(project_dir)
.assert();
let gitignore_path = project_dir.join(".gitignore");
assert!(
gitignore_path.exists(),
"Gitignore should be created even with empty lockfile"
);
let content = fs::read_to_string(&gitignore_path).unwrap();
assert!(content.contains("CCPM managed entries"));
assert!(content.contains("# End of CCPM managed entries"));
}
#[test]
fn test_gitignore_idempotent() {
ccpm::test_utils::init_test_logging(None);
let temp = TempDir::new().unwrap();
let project_dir = temp.path();
let source_dir = temp.path().join("source");
create_test_source_files(&source_dir).unwrap();
let manifest_path = project_dir.join("ccpm.toml");
fs::write(&manifest_path, create_test_manifest(true, &source_dir)).unwrap();
let lockfile_path = project_dir.join("ccpm.lock");
fs::write(&lockfile_path, create_test_lockfile()).unwrap();
Command::cargo_bin("ccpm")
.unwrap()
.arg("install")
.arg("--quiet")
.arg("--force")
.current_dir(project_dir)
.assert();
let gitignore_path = project_dir.join(".gitignore");
let first_content = if gitignore_path.exists() {
fs::read_to_string(&gitignore_path).unwrap()
} else {
String::new()
};
Command::cargo_bin("ccpm")
.unwrap()
.arg("install")
.arg("--quiet")
.arg("--force")
.current_dir(project_dir)
.assert();
let second_content = if gitignore_path.exists() {
fs::read_to_string(&gitignore_path).unwrap()
} else {
String::new()
};
assert_eq!(
first_content, second_content,
"Gitignore should be idempotent"
);
}
#[test]
fn test_gitignore_switch_enabled_disabled() {
ccpm::test_utils::init_test_logging(None);
let temp = TempDir::new().unwrap();
let project_dir = temp.path();
let source_dir = temp.path().join("source");
create_test_source_files(&source_dir).unwrap();
let manifest_path = project_dir.join("ccpm.toml");
fs::write(&manifest_path, create_test_manifest(true, &source_dir)).unwrap();
let lockfile_path = project_dir.join("ccpm.lock");
fs::write(&lockfile_path, create_test_lockfile()).unwrap();
Command::cargo_bin("ccpm")
.unwrap()
.arg("install")
.arg("--quiet")
.arg("--force")
.current_dir(project_dir)
.assert();
let gitignore_path = project_dir.join(".gitignore");
assert!(gitignore_path.exists(), "Gitignore should be created");
fs::write(&manifest_path, create_test_manifest(false, &source_dir)).unwrap();
Command::cargo_bin("ccpm")
.unwrap()
.arg("install")
.arg("--quiet")
.arg("--force")
.current_dir(project_dir)
.assert();
assert!(
gitignore_path.exists(),
"Gitignore should still exist when disabled"
);
fs::write(&manifest_path, create_test_manifest(true, &source_dir)).unwrap();
let content = fs::read_to_string(&gitignore_path).unwrap();
let modified_content = content.replace(
"# CCPM managed entries",
"user-custom.txt\n\n# CCPM managed entries",
);
fs::write(&gitignore_path, modified_content).unwrap();
Command::cargo_bin("ccpm")
.unwrap()
.arg("install")
.arg("--quiet")
.arg("--force")
.current_dir(project_dir)
.assert();
let final_content = fs::read_to_string(&gitignore_path).unwrap();
assert!(
final_content.contains("user-custom.txt"),
"User entries should be preserved when re-enabling"
);
}
#[test]
fn test_gitignore_actually_ignored_by_git() {
ccpm::test_utils::init_test_logging(None);
use std::process::Command as StdCommand;
let temp = TempDir::new().unwrap();
let project_dir = temp.path();
let source_dir = temp.path().join("source");
create_test_source_files(&source_dir).unwrap();
StdCommand::new("git")
.arg("init")
.current_dir(project_dir)
.output()
.expect("Failed to initialize git repo");
let manifest_path = project_dir.join("ccpm.toml");
fs::write(&manifest_path, create_test_manifest(true, &source_dir)).unwrap();
let lockfile_path = project_dir.join("ccpm.lock");
fs::write(&lockfile_path, create_test_lockfile()).unwrap();
Command::cargo_bin("ccpm")
.unwrap()
.arg("install")
.arg("--quiet")
.arg("--force")
.current_dir(project_dir)
.assert();
assert!(project_dir.join(".claude/agents/test-agent.md").exists());
assert!(
project_dir
.join(".claude/ccpm/snippets/test-snippet.md")
.exists()
);
assert!(
project_dir
.join(".claude/commands/test-command.md")
.exists()
);
StdCommand::new("git")
.arg("add")
.arg(".")
.current_dir(project_dir)
.output()
.expect("Failed to stage files");
let output = StdCommand::new("git")
.arg("status")
.arg("--porcelain")
.current_dir(project_dir)
.output()
.expect("Failed to get git status");
let status = String::from_utf8_lossy(&output.stdout);
assert!(
!status.contains("agents/test-agent.md"),
"Agent file should be ignored by git\nGit status:\n{}",
status
);
assert!(
!status.contains("snippets/test-snippet.md"),
"Snippet file should be ignored by git\nGit status:\n{}",
status
);
assert!(
!status.contains("commands/test-command.md"),
"Command file should be ignored by git\nGit status:\n{}",
status
);
assert!(
status.contains(".gitignore"),
"Gitignore file should be tracked by git\nGit status:\n{}",
status
);
assert!(
status.contains("ccpm.toml"),
"Manifest should be tracked by git\nGit status:\n{}",
status
);
assert!(
status.contains("ccpm.lock"),
"Lockfile should be tracked by git\nGit status:\n{}",
status
);
let check_agent = StdCommand::new("git")
.arg("check-ignore")
.arg(".claude/agents/test-agent.md")
.current_dir(project_dir)
.output()
.expect("Failed to check-ignore");
assert!(
check_agent.status.success(),
"Agent file should be ignored by git check-ignore"
);
let check_snippet = StdCommand::new("git")
.arg("check-ignore")
.arg(".claude/ccpm/snippets/test-snippet.md")
.current_dir(project_dir)
.output()
.expect("Failed to check-ignore");
assert!(
check_snippet.status.success(),
"Snippet file should be ignored by git check-ignore"
);
let check_command = StdCommand::new("git")
.arg("check-ignore")
.arg(".claude/commands/test-command.md")
.current_dir(project_dir)
.output()
.expect("Failed to check-ignore");
assert!(
check_command.status.success(),
"Command file should be ignored by git check-ignore"
);
}
#[test]
fn test_gitignore_disabled_files_not_ignored_by_git() {
ccpm::test_utils::init_test_logging(None);
use std::process::Command as StdCommand;
let temp = TempDir::new().unwrap();
let project_dir = temp.path();
let source_dir = temp.path().join("source");
create_test_source_files(&source_dir).unwrap();
StdCommand::new("git")
.arg("init")
.current_dir(project_dir)
.output()
.expect("Failed to initialize git repo");
let manifest_path = project_dir.join("ccpm.toml");
fs::write(&manifest_path, create_test_manifest(false, &source_dir)).unwrap();
let lockfile_path = project_dir.join("ccpm.lock");
fs::write(&lockfile_path, create_test_lockfile()).unwrap();
Command::cargo_bin("ccpm")
.unwrap()
.arg("install")
.arg("--quiet")
.arg("--force")
.current_dir(project_dir)
.assert();
assert!(project_dir.join(".claude/agents/test-agent.md").exists());
assert!(
project_dir
.join(".claude/ccpm/snippets/test-snippet.md")
.exists()
);
assert!(
project_dir
.join(".claude/commands/test-command.md")
.exists()
);
StdCommand::new("git")
.arg("add")
.arg(".")
.current_dir(project_dir)
.output()
.expect("Failed to stage files");
let output = StdCommand::new("git")
.arg("status")
.arg("--porcelain")
.current_dir(project_dir)
.output()
.expect("Failed to get git status");
let status = String::from_utf8_lossy(&output.stdout);
assert!(
status.contains("agents/test-agent.md"),
"Agent file should NOT be ignored when gitignore is disabled\nGit status:\n{}",
status
);
assert!(
status.contains("snippets/test-snippet.md"),
"Snippet file should NOT be ignored when gitignore is disabled\nGit status:\n{}",
status
);
assert!(
status.contains("commands/test-command.md"),
"Command file should NOT be ignored when gitignore is disabled\nGit status:\n{}",
status
);
let check_agent = StdCommand::new("git")
.arg("check-ignore")
.arg(".claude/agents/test-agent.md")
.current_dir(project_dir)
.output()
.expect("Failed to check-ignore");
assert!(
!check_agent.status.success(),
"Agent file should NOT be ignored when gitignore is disabled"
);
}
#[test]
fn test_gitignore_malformed_existing() {
ccpm::test_utils::init_test_logging(None);
let temp = TempDir::new().unwrap();
let project_dir = temp.path();
let source_dir = temp.path().join("source");
create_test_source_files(&source_dir).unwrap();
fs::create_dir_all(project_dir.join(".claude")).unwrap();
let gitignore_path = project_dir.join(".gitignore");
let malformed_content = r#"# Some content
user-file.txt
# CCPM managed entries - do not edit below this line
/old/entry.md
# Missing end marker!
"#;
fs::write(&gitignore_path, malformed_content).unwrap();
let manifest_path = project_dir.join("ccpm.toml");
fs::write(&manifest_path, create_test_manifest(true, &source_dir)).unwrap();
let lockfile_path = project_dir.join("ccpm.lock");
fs::write(&lockfile_path, create_test_lockfile()).unwrap();
Command::cargo_bin("ccpm")
.unwrap()
.arg("install")
.arg("--quiet")
.arg("--force")
.current_dir(project_dir)
.assert();
let updated_content = fs::read_to_string(&gitignore_path).unwrap();
assert!(updated_content.contains("# End of CCPM managed entries"));
assert!(updated_content.contains("user-file.txt"));
assert!(updated_content.contains("CCPM managed entries"));
}