diff --git a/src/new/debian.rs b/src/new/debian.rs index e77948e..681874a 100644 --- a/src/new/debian.rs +++ b/src/new/debian.rs @@ -323,15 +323,25 @@ pub const ROOT_GITIGNORE_ENTRIES: [&str; 6] = [ "target/", ]; -/// Merge the root `.gitignore` entries into `existing` (the current file -/// contents, when there is one): missing entries are appended, an existing -/// file is never overwritten just to duplicate entries. Returns the new -/// contents, or `None` when nothing has to be written. -pub fn merge_root_gitignore(existing: Option<&str>) -> Option { +/// Comment heading a root `.gitignore` freshly created by pkh (the skeleton +/// build-artifact section). +pub const ROOT_GITIGNORE_HEADER: &str = "# pkh build artifacts"; + +/// Merge `entries` into the root `.gitignore` contents `existing` (the +/// current file contents, when there is one): missing entries are appended, +/// an existing file is never overwritten just to duplicate entries. A fresh +/// file is headed by the `header` comment when one is given; appending to a +/// user file adds bare entries. Returns the new contents, or `None` when +/// nothing has to be written. +pub fn merge_gitignore_entries( + existing: Option<&str>, + entries: &[&str], + header: Option<&str>, +) -> Option { let have: HashSet<&str> = existing .map(|content| content.lines().map(str::trim).collect()) .unwrap_or_default(); - let missing: Vec<&str> = ROOT_GITIGNORE_ENTRIES + let missing: Vec<&str> = entries .iter() .copied() .filter(|entry| !have.contains(entry)) @@ -346,8 +356,11 @@ pub fn merge_root_gitignore(existing: Option<&str>) -> Option { } // Section comment only for a fresh file; appending to a user file adds // bare entries. - if existing.is_none() { - out.push_str("# pkh build artifacts\n"); + if existing.is_none() + && let Some(header) = header + { + out.push_str(header); + out.push('\n'); } for entry in missing { out.push_str(entry); @@ -746,17 +759,30 @@ mod tests { } #[test] - fn root_gitignore_merge() { + fn gitignore_merge() { // Fresh file: header + all entries. - let fresh = merge_root_gitignore(None).unwrap(); + let fresh = + merge_gitignore_entries(None, &ROOT_GITIGNORE_ENTRIES, Some(ROOT_GITIGNORE_HEADER)) + .unwrap(); assert!(fresh.starts_with("# pkh build artifacts\n")); for entry in ROOT_GITIGNORE_ENTRIES { assert!(fresh.contains(entry), "{entry} missing"); } + // A fresh file without a header carries the bare entries. + assert_eq!( + merge_gitignore_entries(None, &["a/", "b"], None).unwrap(), + "a/\nb\n" + ); + // Existing file: only the missing entries are appended, nothing lost. let existing = "*.deb\nnode_modules/\n"; - let merged = merge_root_gitignore(Some(existing)).unwrap(); + let merged = merge_gitignore_entries( + Some(existing), + &ROOT_GITIGNORE_ENTRIES, + Some(ROOT_GITIGNORE_HEADER), + ) + .unwrap(); assert!(merged.starts_with(existing)); assert!(merged.contains("*.dsc\n")); assert!(!merged.contains("*.deb\n*.deb")); @@ -766,7 +792,31 @@ mod tests { .iter() .map(|e| format!("{e}\n")) .collect(); - assert!(merge_root_gitignore(Some(&full)).is_none()); + assert!( + merge_gitignore_entries( + Some(&full), + &ROOT_GITIGNORE_ENTRIES, + Some(ROOT_GITIGNORE_HEADER), + ) + .is_none() + ); + } + + /// The vendoring entries of the rust template merge into an existing + /// user `.gitignore` like any other entry set: appended after the + /// user's lines, no header comment, idempotent. + #[test] + fn gitignore_merge_appends_template_entries() { + let entries = ["vendor/", ".cargo/config.toml"]; + let merged = + merge_gitignore_entries(Some("# my project\n*.log\n"), &entries, None).unwrap(); + assert_eq!(merged, "# my project\n*.log\nvendor/\n.cargo/config.toml\n"); + + // Already ignored: nothing to write. + assert!( + merge_gitignore_entries(Some("vendor/\n.cargo/config.toml\n"), &entries, None) + .is_none() + ); } #[test] diff --git a/src/new/mod.rs b/src/new/mod.rs index 383b152..8a603d2 100644 --- a/src/new/mod.rs +++ b/src/new/mod.rs @@ -3,7 +3,9 @@ //! //! This module orchestrates a scaffold run: target directory checks, project //! detection, in-memory rendering of every file (all-or-nothing write), the -//! template post-write hook (e.g. `cargo vendor`), orig tarball creation +//! root `.gitignore` merge (skeleton build artifacts plus the template's own +//! entries, e.g. the rust vendored layout), the template post-write hook +//! (e.g. `cargo vendor`), orig tarball creation //! (from the origin the run decided on — see [`origin`] and [`orig`]), git //! initialization, structural verification and the next-steps message. //! The interactive wizard ([`questions`]) fills a [`options::NewCli`] from @@ -35,8 +37,10 @@ use templates::{OutputFile, ScaffoldOutcome}; /// 1. resolve the template from the registry, /// 2. check the target directory (refuse an existing `debian/control`), /// 3. render every file in memory and check for collisions, -/// 4. write the files all-or-nothing (plus the root `.gitignore` in skeleton -/// mode, appending to an existing one), +/// 4. write the files all-or-nothing (plus the root `.gitignore` merge: +/// the skeleton build-artifact entries in skeleton mode and the +/// template's own entries — rust: the vendored layout — in every mode, +/// appending to an existing file), /// 5. run the template post-write hook (e.g. `cargo vendor`, so the vendored /// sources land inside the orig tarball created next), /// 6. create the orig tarball (quilt only, refusing overwrites), @@ -171,13 +175,25 @@ fn scaffold_steps(opts: &NewOptions, pb: &ProgressBar) -> Result = Vec::new(); + let mut header = None; + if skeleton { + entries.extend(debian::ROOT_GITIGNORE_ENTRIES); + header = Some(debian::ROOT_GITIGNORE_HEADER); + } + entries.extend(template_gitignore.iter().map(String::as_str)); + if !entries.is_empty() + && let Some(contents) = debian::merge_gitignore_entries( std::fs::read_to_string(target.join(".gitignore")) .ok() .as_deref(), + &entries, + header, ) { std::fs::write(target.join(".gitignore"), contents)?; @@ -502,7 +518,8 @@ mod tests { scaffold_in(&tree, opts(TemplateId::Shell, "runtool", SourceDir::Here)).unwrap(); // debian/ lands directly in the directory; no skeleton file, no - // root .gitignore (skeleton mode only), no debian/install (the + // root .gitignore (the shell template contributes none and the + // artifact entries are skeleton-only), no debian/install (the // generated one would reference the non-existent skeleton script), // and the existing script is left alone. assert!(tree.join("debian/control").exists()); @@ -662,6 +679,40 @@ mod tests { assert!(config.contains("[source.crates-io]"), "{config}"); assert!(config.contains("[net]\noffline = true"), "{config}"); } + + // Root .gitignore: the skeleton build-artifact entries plus the + // template's vendoring entries (contributed in every mode). + let gitignore = std::fs::read_to_string(tree.join(".gitignore")).unwrap(); + assert!(gitignore.contains("# pkh build artifacts"), "{gitignore}"); + assert!(gitignore.contains("*.deb"), "{gitignore}"); + assert!(gitignore.contains("target/"), "{gitignore}"); + assert!(gitignore.contains("vendor/\n"), "{gitignore}"); + assert!(gitignore.contains(".cargo/config.toml\n"), "{gitignore}"); + } + + /// The rust template's vendoring entries land in the root `.gitignore` + /// in every mode: a Here-mode tree gets them appended to its existing + /// file (custom lines kept, no header comment), while the skeleton-only + /// build-artifact entries do not appear. + #[test] + #[serial] + fn scaffold_rust_here_merges_vendoring_gitignore_entries() { + let dir = tempdir().unwrap(); + let tree = dir.path().join("packdir"); + std::fs::create_dir_all(&tree).unwrap(); + std::fs::write(tree.join(".gitignore"), "# my project\n*.log\n").unwrap(); + scaffold_in(&tree, opts(TemplateId::Rust, "mytool", SourceDir::Here)).unwrap(); + + let gitignore = std::fs::read_to_string(tree.join(".gitignore")).unwrap(); + assert!( + gitignore.starts_with("# my project\n*.log\n"), + "{gitignore}" + ); + assert!(gitignore.contains("vendor/\n"), "{gitignore}"); + assert!(gitignore.contains(".cargo/config.toml\n"), "{gitignore}"); + // The build-artifact entries stay skeleton-only. + assert!(!gitignore.contains("*.deb"), "{gitignore}"); + assert!(!gitignore.contains("# pkh build artifacts"), "{gitignore}"); } /// End-to-end vendored rust quilt package: the vendoring hook creates diff --git a/src/new/templates/mod.rs b/src/new/templates/mod.rs index b7cd2d5..d5f9e68 100644 --- a/src/new/templates/mod.rs +++ b/src/new/templates/mod.rs @@ -137,6 +137,15 @@ pub trait Template: Sync { Vec::new() } + /// Root `.gitignore` entries the template contributes in every mode + /// (the skeleton-mode build-artifact entries of + /// [`crate::new::debian::ROOT_GITIGNORE_ENTRIES`] are separate), merged + /// into the root `.gitignore` after the files are written — missing + /// ones appended, an existing file never overwritten. Default: none. + fn gitignore_entries(&self, _opts: &NewOptions) -> Vec { + Vec::new() + } + /// Extra defaults derived from project metadata in `dir` (detect.rs); /// `None` when the project carries nothing this template can read. fn probe(&self, _dir: &Path) -> Option { @@ -248,6 +257,48 @@ mod tests { ); } + /// Only the rust template contributes root `.gitignore` entries of its + /// own — exactly the vendoring pair, and unconditionally (its vendoring + /// hook runs in every mode); every other template contributes nothing. + #[test] + fn gitignore_entries_are_rust_only() { + let o = NewOptions { + name: "mytool".into(), + template: TemplateId::Rust, + source_dir: SourceDir::Here, + upstream_version: "0.1.0".into(), + revision: 1, + summary: "A tool".into(), + long_description: "A tool".into(), + homepage: None, + license: crate::new::options::License::Mit, + command: "mytool".into(), + maintainer: ("Jane".into(), "jane@example.com".into()), + dist: "ubuntu".into(), + series: "resolute".into(), + release: false, + depends: Vec::new(), + source_format: crate::new::options::SourceFormat::Quilt, + orig: None, + git: false, + autopkgtest: false, + pkg_config: false, + watch: None, + }; + + for id in TemplateId::all() { + let entries = get(id).unwrap().gitignore_entries(&o); + if id == TemplateId::Rust { + assert_eq!( + entries, + vec!["vendor/".to_string(), ".cargo/config.toml".to_string()] + ); + } else { + assert!(entries.is_empty(), "{id} contributes {entries:?}"); + } + } + } + /// The final `debian/rules` of every template must be valid-looking /// make: `#!/usr/bin/make -f` shebang, exactly one `%:` target whose /// recipe is the template's dh line, tab-indented recipes only, and no diff --git a/src/new/templates/rust.rs b/src/new/templates/rust.rs index 249996d..3501561 100644 --- a/src/new/templates/rust.rs +++ b/src/new/templates/rust.rs @@ -75,6 +75,17 @@ impl Template for Rust { vec!["cargo:native".to_string(), "rustc:native".to_string()] } + /// Root `.gitignore` entries contributed in every mode: the vendoring + /// hook runs unconditionally for this template, so `vendor/` and + /// `.cargo/config.toml` exist (or will exist) in every scaffolded rust + /// tree and must stay out of the repository. `.cargo/config.toml` is + /// the subtle one: it points cargo at `vendor/`, so committing it would + /// break plain `cargo build` for anyone cloning the repository without + /// the vendored sources. + fn gitignore_entries(&self, _opts: &NewOptions) -> Vec { + vec!["vendor/".to_string(), ".cargo/config.toml".to_string()] + } + fn architecture(&self, _opts: &NewOptions) -> &'static str { "any" }