diff --git a/.gitignore b/.gitignore index da8d682..406752a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,24 +4,10 @@ # Local environment (secrets) .env -# App specific +# App specific (root-level; story-kit subdirectory patterns live in .story_kit/.gitignore) store.json .story_kit_port -# Bot config (contains credentials) -.story_kit/bot.toml - -# Matrix SDK state store -.story_kit/matrix_store/ -.story_kit/matrix_device_id - -# Agent worktrees and merge workspace (managed by the server, not tracked in git) -.story_kit/worktrees/ -.story_kit/merge_workspace/ - -# Coverage reports (generated by cargo-llvm-cov, not tracked in git) -.story_kit/coverage/ - # Rust stuff target diff --git a/.story_kit/.gitignore b/.story_kit/.gitignore new file mode 100644 index 0000000..2e94b45 --- /dev/null +++ b/.story_kit/.gitignore @@ -0,0 +1,13 @@ +# Bot config (contains credentials) +bot.toml + +# Matrix SDK state store +matrix_store/ +matrix_device_id + +# Agent worktrees and merge workspace (managed by the server, not tracked in git) +worktrees/ +merge_workspace/ + +# Coverage reports (generated by cargo-llvm-cov, not tracked in git) +coverage/ diff --git a/server/src/io/fs.rs b/server/src/io/fs.rs index ee8da97..3682149 100644 --- a/server/src/io/fs.rs +++ b/server/src/io/fs.rs @@ -313,17 +313,61 @@ fn write_script_if_missing(path: &Path, content: &str) -> Result<(), String> { Ok(()) } -/// Append Story Kit entries to `.gitignore` (or create one if missing). -/// Does not duplicate entries already present. -fn append_gitignore_entries(root: &Path) -> Result<(), String> { +/// Write (or idempotently update) `.story_kit/.gitignore` with Story Kit–specific +/// ignore patterns for files that live inside the `.story_kit/` directory. +/// Patterns are relative to `.story_kit/` as git resolves `.gitignore` files +/// relative to the directory that contains them. +fn write_story_kit_gitignore(root: &Path) -> Result<(), String> { + // Entries that belong inside .story_kit/.gitignore (relative to .story_kit/). let entries = [ - ".story_kit/worktrees/", - ".story_kit/merge_workspace/", - ".story_kit/coverage/", - ".story_kit_port", - "store.json", + "bot.toml", + "matrix_store/", + "matrix_device_id", + "worktrees/", + "merge_workspace/", + "coverage/", ]; + let gitignore_path = root.join(".story_kit").join(".gitignore"); + let existing = if gitignore_path.exists() { + fs::read_to_string(&gitignore_path) + .map_err(|e| format!("Failed to read .story_kit/.gitignore: {}", e))? + } else { + String::new() + }; + + let missing: Vec<&str> = entries + .iter() + .copied() + .filter(|e| !existing.lines().any(|l| l.trim() == *e)) + .collect(); + + if missing.is_empty() { + return Ok(()); + } + + let mut new_content = existing; + if !new_content.is_empty() && !new_content.ends_with('\n') { + new_content.push('\n'); + } + for entry in missing { + new_content.push_str(entry); + new_content.push('\n'); + } + + fs::write(&gitignore_path, new_content) + .map_err(|e| format!("Failed to write .story_kit/.gitignore: {}", e))?; + + Ok(()) +} + +/// Append root-level Story Kit entries to the project `.gitignore`. +/// Only `store.json` and `.story_kit_port` remain here because they live at +/// the project root and git does not support `../` patterns in `.gitignore` +/// files, so they cannot be expressed in `.story_kit/.gitignore`. +fn append_root_gitignore_entries(root: &Path) -> Result<(), String> { + let entries = [".story_kit_port", "store.json"]; + let gitignore_path = root.join(".gitignore"); let existing = if gitignore_path.exists() { fs::read_to_string(&gitignore_path) @@ -402,7 +446,8 @@ fn scaffold_story_kit(root: &Path) -> Result<(), String> { .map_err(|e| format!("Failed to create .claude/ directory: {}", e))?; write_file_if_missing(&claude_dir.join("settings.json"), STORY_KIT_CLAUDE_SETTINGS)?; - append_gitignore_entries(root)?; + write_story_kit_gitignore(root)?; + append_root_gitignore_entries(root)?; // Run `git init` if the directory is not already a git repo, then make an initial commit if !root.join(".git").exists() { @@ -1122,12 +1167,17 @@ mod tests { toml_content ); - let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); - let count = gitignore + let story_kit_gitignore = + fs::read_to_string(dir.path().join(".story_kit/.gitignore")).unwrap(); + let count = story_kit_gitignore .lines() - .filter(|l| l.trim() == ".story_kit/worktrees/") + .filter(|l| l.trim() == "worktrees/") .count(); - assert_eq!(count, 1, ".gitignore should not have duplicate entries"); + assert_eq!( + count, + 1, + ".story_kit/.gitignore should not have duplicate entries" + ); } #[test] @@ -1173,53 +1223,56 @@ mod tests { } #[test] - fn scaffold_creates_gitignore_with_story_kit_entries() { + fn scaffold_creates_story_kit_gitignore_with_relative_entries() { let dir = tempdir().unwrap(); scaffold_story_kit(dir.path()).unwrap(); - let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); - assert!(content.contains(".story_kit/worktrees/")); - assert!(content.contains(".story_kit/merge_workspace/")); - assert!(content.contains(".story_kit/coverage/")); - assert!(content.contains(".story_kit_port")); - assert!(content.contains("store.json")); + // .story_kit/.gitignore must contain relative patterns for files under .story_kit/ + let sk_content = + fs::read_to_string(dir.path().join(".story_kit/.gitignore")).unwrap(); + assert!(sk_content.contains("worktrees/")); + assert!(sk_content.contains("merge_workspace/")); + assert!(sk_content.contains("coverage/")); + // Must NOT contain absolute .story_kit/ prefixed paths + assert!(!sk_content.contains(".story_kit/")); + + // Root .gitignore must contain root-level story-kit entries + let root_content = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); + assert!(root_content.contains(".story_kit_port")); + assert!(root_content.contains("store.json")); + // Root .gitignore must NOT contain .story_kit/ sub-directory patterns + assert!(!root_content.contains(".story_kit/worktrees/")); + assert!(!root_content.contains(".story_kit/merge_workspace/")); + assert!(!root_content.contains(".story_kit/coverage/")); } #[test] - fn scaffold_gitignore_does_not_duplicate_existing_entries() { + fn scaffold_story_kit_gitignore_does_not_duplicate_existing_entries() { let dir = tempdir().unwrap(); - // Pre-create .gitignore with some Story Kit entries already present + // Pre-create .story_kit dir and .gitignore with some entries already present + fs::create_dir_all(dir.path().join(".story_kit")).unwrap(); fs::write( - dir.path().join(".gitignore"), - ".story_kit/worktrees/\n.story_kit/coverage/\n", + dir.path().join(".story_kit/.gitignore"), + "worktrees/\ncoverage/\n", ) .unwrap(); scaffold_story_kit(dir.path()).unwrap(); - let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); + let content = + fs::read_to_string(dir.path().join(".story_kit/.gitignore")).unwrap(); let worktrees_count = content .lines() - .filter(|l| l.trim() == ".story_kit/worktrees/") + .filter(|l| l.trim() == "worktrees/") .count(); - assert_eq!( - worktrees_count, - 1, - ".story_kit/worktrees/ should not be duplicated" - ); + assert_eq!(worktrees_count, 1, "worktrees/ should not be duplicated"); let coverage_count = content .lines() - .filter(|l| l.trim() == ".story_kit/coverage/") + .filter(|l| l.trim() == "coverage/") .count(); - assert_eq!( - coverage_count, - 1, - ".story_kit/coverage/ should not be duplicated" - ); - // The missing entries must have been added - assert!(content.contains(".story_kit/merge_workspace/")); - assert!(content.contains(".story_kit_port")); - assert!(content.contains("store.json")); + assert_eq!(coverage_count, 1, "coverage/ should not be duplicated"); + // The missing entry must have been added + assert!(content.contains("merge_workspace/")); } // --- CLAUDE.md scaffold ---