diff --git a/src/deb/local.rs b/src/deb/local.rs index 1c05552..9743b3e 100644 --- a/src/deb/local.rs +++ b/src/deb/local.rs @@ -512,6 +512,23 @@ fn apply_quilt_patches( return Ok(()); } + // A `single-debian-patch` tree is already patched by construction: + // `dpkg-source -b` folds the working-tree delta into + // 'debian/patches/debian-changes' and registers it in the series + // WITHOUT applying it — the content stays ambient in the working tree + // (e.g. the vendored rust '.cargo/config.toml'). Raw `quilt push -a` + // would then refuse the patch ('file already exists'); only dpkg's own + // patch(1)-based application tolerates that. Since `dpkg-source -b` + // regenerates the patch from the working tree at source-build time, + // applying patches is wrong in principle here: skip the step. + if uses_single_debian_patch(&ctx, Path::new(package_dir)) { + log::info!( + "Tree uses single-debian-patch: the working tree already \ + carries the patch content, skipping quilt patch application" + ); + return Ok(()); + } + // Make sure quilt is available in the build context log::debug!("Installing quilt for patch application..."); let status = cap( @@ -552,6 +569,39 @@ fn apply_quilt_patches( Ok(()) } +/// Whether the source options of the package tree at `package_dir` (inside +/// the build context) declare the `single-debian-patch` mode: either +/// `debian/source/local-options` or `debian/source/options` contains a +/// line whose trimmed content — after stripping a leading `--` long-option +/// dash — is exactly `single-debian-patch` (both spellings exist in the +/// wild). A line merely containing the token as a substring (e.g. +/// `--single-debian-patch-foo`) does not count. +fn uses_single_debian_patch(ctx: &Context, package_dir: &Path) -> bool { + let local_options = ctx + .read_file(&package_dir.join("debian/source/local-options")) + .ok(); + let options = ctx + .read_file(&package_dir.join("debian/source/options")) + .ok(); + options_declare_single_debian_patch(local_options.as_deref(), options.as_deref()) +} + +/// Pure decision core of [`uses_single_debian_patch`]: do the (optional) +/// contents of `debian/source/local-options` / `debian/source/options` +/// declare `single-debian-patch`? +fn options_declare_single_debian_patch(local_options: Option<&str>, options: Option<&str>) -> bool { + let declares = |content: Option<&str>| { + content.is_some_and(|content| { + content.lines().any(|line| { + let line = line.trim(); + let line = line.strip_prefix("--").unwrap_or(line); + line == "single-debian-patch" + }) + }) + }; + declares(local_options) || declares(options) +} + /// Pin a 'NotAutomatic' pocket (e.g. '-proposed') so apt takes it into /// account during dependency resolution. /// @@ -685,3 +735,116 @@ fn dose3_explain_dependencies( cmd.status()?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::ContextConfig; + + #[test] + fn detector_matches_local_options_options_and_dashed_spellings() { + // The pkh scaffold spelling: bare token in local-options. + assert!(options_declare_single_debian_patch( + Some("single-debian-patch\n"), + None + )); + // The other common spelling in either file: `--`-prefixed. + assert!(options_declare_single_debian_patch( + Some("--single-debian-patch\n"), + None + )); + assert!(options_declare_single_debian_patch( + None, + Some("--single-debian-patch\n") + )); + // Trailing whitespace and blank lines around the token. + assert!(options_declare_single_debian_patch( + Some("\n single-debian-patch \n"), + None + )); + // Declared in options while local-options carries other options. + assert!(options_declare_single_debian_patch( + Some("--extend-diff-ignore='^vendor/'\n"), + Some("single-debian-patch\n") + )); + } + + #[test] + fn detector_rejects_substrings_empty_and_absent_files() { + // The token must not match as a substring of another option. + assert!(!options_declare_single_debian_patch( + Some("--single-debian-patch-ignore=^foo\n"), + None + )); + assert!(!options_declare_single_debian_patch( + Some("--no-single-debian-patch\n"), + Some("single-debian-patching\n") + )); + // Absent or empty files: not single-debian-patch. + assert!(!options_declare_single_debian_patch(None, None)); + assert!(!options_declare_single_debian_patch(Some(""), Some(""))); + // Unrelated content. + assert!(!options_declare_single_debian_patch( + Some("--extend-diff-ignore='^vendor/'\n"), + Some("tar-ignore = .git\n") + )); + } + + /// A tree with `single-debian-patch` in its local-options is detected + /// through the real build-context file access; a tree without the + /// declaration is not. + #[test] + fn detector_on_a_real_tree_through_the_context() { + let ctx = Context::new(ContextConfig::Local).unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let tree = dir.path().join("pkg"); + std::fs::create_dir_all(tree.join("debian/source")).unwrap(); + std::fs::write( + tree.join("debian/source/local-options"), + "single-debian-patch\n", + ) + .unwrap(); + assert!(uses_single_debian_patch(&ctx, &tree)); + + // No source options at all. + let dir = tempfile::tempdir().unwrap(); + let tree = dir.path().join("pkg"); + std::fs::create_dir_all(tree.join("debian/source")).unwrap(); + assert!(!uses_single_debian_patch(&ctx, &tree)); + } + + /// Direct call of the patch-application phase on a tree simulating the + /// `dpkg-source -b` state of a single-debian-patch scaffold: + /// 'debian-changes' is registered in the series while its content is + /// already ambient in the working tree (raw `quilt push -a` would + /// refuse it with 'file already exists'). The phase must skip the + /// application entirely and succeed. + #[test] + fn apply_phase_skips_single_debian_patch_trees() { + let ctx = Arc::new(Context::new(ContextConfig::Local).unwrap()); + + // Dashed spelling through debian/source/options this time. + let dir = tempfile::tempdir().unwrap(); + let tree = dir.path().join("pkg"); + std::fs::create_dir_all(tree.join("debian/patches")).unwrap(); + std::fs::create_dir_all(tree.join("debian/source")).unwrap(); + // The touched file already carries the patched content: applying + // the patch would fail ('README' already exists / differs only in + // the patch's imagination). + std::fs::write(tree.join("README"), "patched\n").unwrap(); + std::fs::write( + tree.join("debian/patches/debian-changes"), + "--- a/README\n+++ b/README\n@@ -1 +1 @@\n-orig\n+patched\n", + ) + .unwrap(); + std::fs::write(tree.join("debian/patches/series"), "debian-changes\n").unwrap(); + std::fs::write( + tree.join("debian/source/options"), + "--single-debian-patch\n", + ) + .unwrap(); + + apply_quilt_patches(tree.to_str().unwrap(), &HashMap::new(), ctx, &None, &None).unwrap(); + } +}