use std::io::Write as _;
use std::path::Path;
pub const SCAFFOLD_GITIGNORE_BEGIN: &str =
"# >>> trusty-mpm harness scaffolding (auto-managed by tm, issue #3427) >>>";
pub const SCAFFOLD_GITIGNORE_END: &str = "# <<< trusty-mpm harness scaffolding <<<";
pub const SCAFFOLD_IGNORED_PATHS: &[&str] = &[
".claude/agents/",
".claude/skills/",
".claude/output-styles/",
];
pub fn ensure_scaffold_gitignored(project_dir: &Path) -> std::io::Result<bool> {
if !project_dir.join(".git").exists() {
return Ok(false);
}
let gitignore_path = project_dir.join(".gitignore");
let existing = std::fs::read_to_string(&gitignore_path).unwrap_or_default();
if existing
.lines()
.any(|line| line == SCAFFOLD_GITIGNORE_BEGIN)
{
return Ok(false);
}
let mut block = String::new();
if !existing.is_empty() && !existing.ends_with('\n') {
block.push('\n');
}
if !existing.is_empty() {
block.push('\n');
}
block.push_str(SCAFFOLD_GITIGNORE_BEGIN);
block.push('\n');
for path in SCAFFOLD_IGNORED_PATHS {
block.push_str(path);
block.push('\n');
}
block.push_str(SCAFFOLD_GITIGNORE_END);
block.push('\n');
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&gitignore_path)?;
file.write_all(block.as_bytes())?;
tracing::info!(
path = %gitignore_path.display(),
"added tm harness-scaffolding block to .gitignore (issue #3427)"
);
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
fn init_git_repo(dir: &Path) {
std::fs::create_dir_all(dir.join(".git")).unwrap();
}
#[test]
fn writes_block_to_fresh_gitignore() {
let tmp = crate::test_support::hermetic_temp_dir();
init_git_repo(tmp.path());
let wrote = ensure_scaffold_gitignored(tmp.path()).unwrap();
assert!(wrote, "first call must write the block");
let content = std::fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
assert!(content.contains(SCAFFOLD_GITIGNORE_BEGIN));
assert!(content.contains(SCAFFOLD_GITIGNORE_END));
for path in SCAFFOLD_IGNORED_PATHS {
assert!(
content.contains(path),
"missing {path} in written .gitignore:\n{content}"
);
}
assert!(!content.lines().any(|l| l.trim() == ".claude/"));
}
#[test]
fn idempotent_on_repeat_call() {
let tmp = crate::test_support::hermetic_temp_dir();
init_git_repo(tmp.path());
assert!(ensure_scaffold_gitignored(tmp.path()).unwrap());
let first = std::fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
assert!(!ensure_scaffold_gitignored(tmp.path()).unwrap());
assert!(!ensure_scaffold_gitignored(tmp.path()).unwrap());
let second = std::fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
assert_eq!(first, second, "repeat calls must not modify the file");
assert_eq!(
second.matches(SCAFFOLD_GITIGNORE_BEGIN).count(),
1,
"the begin marker must appear exactly once"
);
}
#[test]
fn preserves_unrelated_existing_content() {
let tmp = crate::test_support::hermetic_temp_dir();
init_git_repo(tmp.path());
let gitignore_path = tmp.path().join(".gitignore");
std::fs::write(&gitignore_path, "node_modules/\ntarget/\n").unwrap();
ensure_scaffold_gitignored(tmp.path()).unwrap();
let content = std::fs::read_to_string(&gitignore_path).unwrap();
assert!(content.starts_with("node_modules/\ntarget/\n"));
assert!(content.contains(SCAFFOLD_GITIGNORE_BEGIN));
}
#[test]
fn appends_newline_when_existing_file_lacks_trailing_newline() {
let tmp = crate::test_support::hermetic_temp_dir();
init_git_repo(tmp.path());
let gitignore_path = tmp.path().join(".gitignore");
std::fs::write(&gitignore_path, "node_modules/").unwrap();
ensure_scaffold_gitignored(tmp.path()).unwrap();
let content = std::fs::read_to_string(&gitignore_path).unwrap();
assert!(content.starts_with("node_modules/\n"));
assert!(
!content.contains("node_modules/#"),
"must not glue lines together"
);
}
#[test]
fn noop_when_not_a_git_repo() {
let tmp = crate::test_support::hermetic_temp_dir();
let wrote = ensure_scaffold_gitignored(tmp.path()).unwrap();
assert!(!wrote, "must not write when project_dir is not a git repo");
assert!(!tmp.path().join(".gitignore").exists());
}
}