diff --git a/src/commands/project.rs b/src/commands/project.rs index f7deb8b..6b6317d 100644 --- a/src/commands/project.rs +++ b/src/commands/project.rs @@ -9,7 +9,9 @@ //! checking — [`validate`] for problems that must be fixed and [`lint`] for //! item-writing guidance. [`catalog`] summarizes the pool that results. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; use coursebank::assessment::AssessmentFile; use coursebank::bank::BankFile; @@ -44,6 +46,9 @@ reports/ *.swp "; +/// Marks the block `init` prepends to a `.gitignore` that was already there. +const GITIGNORE_HEADER: &str = "# Added by coursebank init."; + /// `init`: create a new course directory, refusing to clobber an existing one. pub(crate) fn init(cli: &Cli, args: &InitArgs) -> Result { let layout = Layout::new(&cli.course); @@ -70,7 +75,7 @@ pub(crate) fn init(cli: &Cli, args: &InitArgs) -> Result { println!("wrote {}", bank_path.display()); } - yaml::write_text(&cli.course.join(".gitignore"), GITIGNORE)?; + write_gitignore(&cli.course.join(".gitignore"))?; println!( "\nNext: edit {} to add your learning objectives and lectures, then\n \ coursebank bank new unit-1 --title \"Unit 1\"\n coursebank validate", @@ -79,6 +84,152 @@ pub(crate) fn init(cli: &Cli, args: &InitArgs) -> Result { Ok(Outcome::Ok) } +/// What reconciling [`GITIGNORE`] against a file already on disk would do. +struct GitignoreMerge { + /// The file to write. Identical to the input when nothing was missing. + text: String, + /// The patterns that were missing, in the order [`GITIGNORE`] lists them. + added: Vec, + /// Patterns the file un-ignores with a `!` rule, which are left out. Git + /// applies the last matching rule, so a line added at the top would lose. + negated: Vec, +} + +/// The comparison key for one `.gitignore` line, or `None` for a blank or comment. +/// +/// `build`, `build/`, and `/build/` are one pattern spelled three ways, so the key +/// drops the slashes. A leading `!` stays, because `!build` is the opposite of +/// `build` rather than a restatement of it. +fn pattern_key(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + let (bang, rest) = match trimmed.strip_prefix('!') { + Some(rest) => ("!", rest.trim_start()), + None => ("", trimmed), + }; + let rest = rest.trim_start_matches('/').trim_end_matches('/'); + if rest.is_empty() { + return None; + } + Some(format!("{bang}{rest}")) +} + +/// Reconciles [`GITIGNORE`] against a `.gitignore` that is already on disk. +/// +/// Patterns the file already has are skipped, and a block of [`GITIGNORE`] left +/// with no patterns loses its comment too, so nobody ends up with a heading over +/// nothing. What survives goes above the existing content, which is copied +/// through unchanged, including its line endings. +fn merge_gitignore(existing: &str) -> GitignoreMerge { + let keys: BTreeSet = existing.lines().filter_map(pattern_key).collect(); + let crlf = existing.contains("\r\n"); + let newline = if crlf { "\r\n" } else { "\n" }; + + let mut block: Vec<&str> = Vec::new(); + let mut pending: Vec<&str> = Vec::new(); + let mut added: Vec = Vec::new(); + let mut negated: Vec = Vec::new(); + let mut kept_in_block = false; + + for line in GITIGNORE.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + // A blank line starts a new block, so any comment still waiting for a + // pattern belonged to a block that was dropped entirely. + pending.clear(); + kept_in_block = false; + continue; + } + if trimmed.starts_with('#') { + pending.push(trimmed); + continue; + } + let Some(key) = pattern_key(trimmed) else { + continue; + }; + if keys.contains(&key) { + continue; + } + if keys.contains(&format!("!{key}")) { + negated.push(trimmed.to_string()); + continue; + } + if !kept_in_block && !block.is_empty() { + block.push(""); + } + block.append(&mut pending); + kept_in_block = true; + block.push(trimmed); + added.push(trimmed.to_string()); + } + + let mut text = String::new(); + if !block.is_empty() { + for line in std::iter::once(GITIGNORE_HEADER).chain(block).chain([""]) { + text.push_str(line); + text.push_str(newline); + } + } + text.push_str(existing); + + GitignoreMerge { + text, + added, + negated, + } +} + +/// Writes the `.gitignore`, merging into one that is already there. +/// +/// `init` is often run in a repository that already has a `.gitignore`. +/// An existing file keeps everything it had and gains only the patterns +/// it was missing, at the top where they are easy to see in the diff. +fn write_gitignore(path: &Path) -> Result<()> { + let existing = match fs::read_to_string(path) { + Ok(text) => text, + // Nothing to merge with. Stay quiet about it. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return yaml::write_text(path, GITIGNORE); + } + // A file that is there but unreadable, or not UTF-8, is not one to + // replace on a guess. + Err(e) => return Err(Error::io(path, e)), + }; + + let merge = merge_gitignore(&existing); + if merge.added.is_empty() { + println!( + "{} already has every pattern init would add", + path.display() + ); + } else { + yaml::write_text(path, &merge.text)?; + println!( + "added {} pattern(s) to the top of {}: {}", + merge.added.len(), + path.display(), + merge.added.join(" ") + ); + } + + for pattern in &merge.negated { + println!( + "note: {} un-ignores `{pattern}`, and the last matching rule wins, \ + so init did not add it", + path.display() + ); + if pattern.contains("salt") { + println!( + " that rule will commit the pseudonymization salt; \ + remove it before you push" + ); + } + } + Ok(()) +} + /// `schema`: (re)write the JSON Schemas an editor uses to validate the YAML. pub(crate) fn schema(cli: &Cli) -> Result { let layout = Layout::new(&cli.course); @@ -271,4 +422,74 @@ mod tests { assert!(GITIGNORE.contains(".coursebank-salt")); assert!(GITIGNORE.contains("build/")); } + + #[test] + fn merging_keeps_the_existing_file_and_adds_only_what_was_missing() { + let existing = "# rules I wrote\ntarget/\nbuild/\n*.pdf\n"; + let merge = merge_gitignore(existing); + + assert!( + merge.text.ends_with(existing), + "the existing file must survive byte for byte:\n{}", + merge.text + ); + assert!(merge.text.starts_with(GITIGNORE_HEADER)); + assert!(!merge.added.iter().any(|p| p == "build/" || p == "*.pdf")); + assert!(merge.added.iter().any(|p| p == "reports/")); + assert!(merge.added.iter().any(|p| p == ".coursebank-salt")); + } + + #[test] + fn a_block_with_nothing_left_to_add_loses_its_comment() { + let merge = merge_gitignore("build/\nreports/\n"); + assert!(!merge.text.contains("# Generated output")); + assert!(merge.text.contains("# Typst and PDF artifacts.")); + } + + #[test] + fn slashes_do_not_make_a_pattern_look_new() { + let merge = merge_gitignore("/build\nreports\n/data/\n"); + assert!( + !merge.added.iter().any(|p| p.contains("build")), + "`/build` already covers `build/`, so it must not be added again" + ); + assert!(!merge.added.iter().any(|p| p.contains("reports"))); + } + + #[test] + fn a_complete_file_is_left_exactly_as_it_was() { + let merge = merge_gitignore(GITIGNORE); + assert!(merge.added.is_empty()); + assert_eq!(merge.text, GITIGNORE); + } + + #[test] + fn a_negated_pattern_is_reported_instead_of_reinserted() { + let merge = merge_gitignore("*.pdf\n!*.salt\n"); + assert_eq!(merge.negated, vec!["*.salt".to_string()]); + assert!(!merge.added.iter().any(|p| p == "*.salt")); + // The un-ignore covers one spelling of the salt, not the other. + assert!(merge.added.iter().any(|p| p == ".coursebank-salt")); + } + + #[test] + fn line_endings_follow_the_file_being_merged_into() { + let merge = merge_gitignore("target/\r\n"); + assert_eq!( + merge.text.matches('\n').count(), + merge.text.matches("\r\n").count(), + "a CRLF file must not gain bare LF lines:\n{:?}", + merge.text + ); + } + + #[test] + fn comments_and_blank_lines_are_not_patterns() { + assert_eq!(pattern_key(" build/ ").as_deref(), Some("build")); + assert_eq!(pattern_key("/build/").as_deref(), Some("build")); + assert_eq!(pattern_key("!build").as_deref(), Some("!build")); + assert_eq!(pattern_key("# build/"), None); + assert_eq!(pattern_key(" "), None); + assert_eq!(pattern_key("/"), None); + } }