new: add upstream-aware orig tarball origins and the orig-vendor component

This commit is contained in:
2026-09-17 01:31:35 +02:00
parent 8e06b2074d
commit 77420e723a
19 changed files with 3270 additions and 184 deletions
+384 -24
View File
@@ -14,6 +14,7 @@ pub mod env;
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use std::error::Error; use std::error::Error;
use std::io::IsTerminal;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::Arc; use std::sync::Arc;
@@ -61,6 +62,12 @@ pub struct SourceBuildOutput {
/// bar + rolling pane) and tee'd to a log file; on failure the view prints a /// bar + rolling pane) and tee'd to a log file; on failure the view prints a
/// summary of the last captured errors. Without a UI, commands inherit the /// summary of the last captured errors. Without a UI, commands inherit the
/// terminal as before. /// terminal as before.
///
/// A `dpkg-source -b` failure is classified (see
/// [`classify_dpkg_source_failure`]); when the vendored rust dependencies
/// diverged from the orig-vendor component and a terminal is attached, the
/// flow offers to re-vendor, recreate the component and retry the build
/// exactly once.
pub fn build_source_package( pub fn build_source_package(
cwd: Option<&Path>, cwd: Option<&Path>,
ui: Option<Arc<DebUi>>, ui: Option<Arc<DebUi>>,
@@ -68,6 +75,9 @@ pub fn build_source_package(
let cwd = cwd.unwrap_or_else(|| Path::new(".")); let cwd = cwd.unwrap_or_else(|| Path::new("."));
let output = match run_source_build(cwd, &SourceBuildOptions::default(), ui.clone()) { let output = match run_source_build(cwd, &SourceBuildOptions::default(), ui.clone()) {
Ok(output) => output, Ok(output) => output,
Err(e) if e.downcast_ref::<VendorDriftError>().is_some() => {
return retry_after_revendor(cwd, ui, e);
}
Err(e) => { Err(e) => {
if let Some(u) = &ui { if let Some(u) = &ui {
u.finish_failure(); u.finish_failure();
@@ -106,6 +116,83 @@ pub fn build_source_package(
Ok(()) Ok(())
} }
/// The re-vendor retry hook for a [`VendorDriftError`]: on an interactive
/// terminal, offer to re-run the vendoring step (the same helper the rust
/// template uses at scaffold time), recreate the `orig-vendor` component
/// from the fresh `vendor/` tree and retry the source build exactly once.
/// Without a terminal (or on a declined offer) the original error is
/// returned untouched.
fn retry_after_revendor(
cwd: &Path,
ui: Option<Arc<DebUi>>,
original: Box<dyn Error>,
) -> Result<(), Box<dyn Error>> {
let interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
if !interactive {
if let Some(u) = &ui {
u.finish_failure();
}
return Err(original);
}
log::error!("{original}");
let retry = crate::ui::prompt::confirm(
"Re-vendor the Cargo dependencies and retry the build?",
false,
)
.unwrap_or(false);
if !retry {
if let Some(u) = &ui {
u.finish_failure();
}
return Err(original);
}
// 1. Re-vendor into the tree. The vendoring helper never overwrites an
// existing `.cargo/config.toml` (scaffold-time safety); in a package
// we vendored before, that config is ours — remove it so it is
// refreshed. A foreign config (no vendored-source marker) is left
// alone and makes the vendoring report incomplete below.
let config = cwd.join(".cargo/config.toml");
if config.exists()
&& std::fs::read_to_string(&config)
.is_ok_and(|content| content.contains("[source.crates-io]"))
{
std::fs::remove_file(&config)?;
}
if !crate::new::templates::rust::vendor_dependencies(cwd)? {
if let Some(u) = &ui {
u.finish_failure();
}
return Err(
"Re-vendoring did not complete: the tree is unchanged, fix the \
vendoring by hand and build again."
.into(),
);
}
// 2. Recreate the orig-vendor component from the fresh vendor/ tree,
// under the name dpkg-source globs for: the changelog version's
// UPSTREAM part (`0.14.0`, not the full `0.14.0-1` — the stale
// component would survive and the next build would fail again).
let entry = crate::debian::parse_changelog_entry(&cwd.join("debian/changelog"))?;
let uversion = crate::new::orig::component_upstream_version(&entry.version);
let component = crate::new::orig::vendor_component_path(cwd, &entry.source, uversion)
.ok_or_else(|| {
format!(
"cannot determine the output directory of '{}' ",
cwd.display()
)
})?;
if component.exists() {
std::fs::remove_file(&component)?;
}
crate::new::orig::create_vendor_component(cwd, &entry.source, uversion)?;
// 3. One retry.
run_source_build(cwd, &SourceBuildOptions::default(), ui).map(|_| ())
}
/// Run the full native source-build pipeline in `cwd`. /// Run the full native source-build pipeline in `cwd`.
/// ///
/// Steps (mirroring `dpkg-buildpackage -S -I -i -nc -d`): /// Steps (mirroring `dpkg-buildpackage -S -I -i -nc -d`):
@@ -280,13 +367,19 @@ pub fn run_source_build(
Box::new(DpkgSourceClassifier::new()), Box::new(DpkgSourceClassifier::new()),
); );
} }
run_command( if let Err(failure) = run_command_capturing(
cwd, cwd,
"dpkg-source", "dpkg-source",
&["-I", "-i", "-b", "."], &["-I", "-i", "-b", "."],
&pipeline_env, &pipeline_env,
sink.as_ref(), sink.as_ref(),
)?; ) {
return Err(dpkg_source_failure_error(
classify_dpkg_source_failure(&failure.stderr),
&failure.stderr,
failure.error,
));
}
if !dsc_path.exists() { if !dsc_path.exists() {
return Err(format!( return Err(format!(
@@ -603,19 +696,116 @@ fn parse_checksum_field(field: &str, value: &str) -> Result<Vec<ChecksumLine>, S
Ok(entries) Ok(entries)
} }
/// Why a `dpkg-source -b` run failed, classified from its captured stderr.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DpkgSourceFailure {
/// A path under `vendor/` (or the `orig-vendor` component itself) is
/// named in the error: the vendored dependencies changed since the
/// orig-vendor component was created.
VendorDrift,
/// Unrepresentable changes to source outside `vendor/`: the upstream
/// tree drifted beyond the orig snapshot.
UpstreamDrift,
/// Anything else (changelog errors, missing files, …).
Other,
}
/// Classify a captured `dpkg-source -b` stderr. Any error line naming a
/// `vendor/` path or the `orig-vendor` component wins (the vendored tree is
/// what the build choked on); the bare "unrepresentable changes to source"
/// summary without a vendor mention points at general upstream drift.
pub(crate) fn classify_dpkg_source_failure(stderr: &str) -> DpkgSourceFailure {
if stderr
.lines()
.any(|line| line.contains("vendor/") || line.contains("orig-vendor"))
{
return DpkgSourceFailure::VendorDrift;
}
if stderr.contains("unrepresentable changes to source") {
return DpkgSourceFailure::UpstreamDrift;
}
DpkgSourceFailure::Other
}
/// The typed error of the [`DpkgSourceFailure::VendorDrift`] case, letting
/// the build wrapper offer the re-vendor retry. The type must survive all
/// the way to [`build_source_package`], so the offending dpkg-source line
/// travels inside the error instead of being string-wrapped around it.
#[derive(Debug)]
pub(crate) struct VendorDriftError {
/// The offending dpkg-source stderr line, when one was captured.
detail: Option<String>,
}
impl std::fmt::Display for VendorDriftError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"dpkg-source failed: the vendored dependencies changed since \
the orig-vendor component was created. Re-vendor the tree \
(`cargo vendor`) and recreate the <name>_<version>.\
orig-vendor.tar.xz component, then build again."
)?;
if let Some(detail) = &self.detail {
write!(f, " (dpkg-source said: {detail})")?;
}
Ok(())
}
}
impl Error for VendorDriftError {}
/// The user-facing error for a classified `dpkg-source -b` failure: a
/// pointed explanation for the two known drift cases, the raw command
/// failure otherwise.
fn dpkg_source_failure_error(
failure: DpkgSourceFailure,
stderr: &str,
command_error: Box<dyn Error>,
) -> Box<dyn Error> {
match failure {
DpkgSourceFailure::VendorDrift => {
let detail = stderr
.lines()
.find(|line| line.contains("vendor"))
.map(str::trim)
.filter(|line| !line.is_empty());
Box::new(VendorDriftError {
detail: detail.map(str::to_string),
})
}
DpkgSourceFailure::UpstreamDrift => format!(
"dpkg-source failed: the upstream tree drifted beyond the orig \
snapshot — bump the version (`pkh chlog`) or re-scaffold. \
(raw error: {command_error})"
)
.into(),
DpkgSourceFailure::Other => command_error,
}
}
/// A command failure together with the stderr captured while it ran (empty
/// when the streams were inherited, e.g. verbose mode).
struct CommandFailure {
error: Box<dyn Error>,
stderr: String,
}
/// Run a build command in `cwd` with extra environment variables layered on /// Run a build command in `cwd` with extra environment variables layered on
/// top of the inherited environment. /// top of the inherited environment.
/// ///
/// When `sink` is set, stdout/stderr are piped and every line is forwarded to /// When `sink` is set, stdout/stderr are piped and every line is forwarded to
/// it (live view + tee log); otherwise stdio is inherited from the terminal. /// it (live view + tee log) while the stderr is additionally captured for
/// Returns an error on non-zero exit status. /// the failure classification; otherwise stdio is inherited from the
fn run_command( /// terminal. Returns an error (with the captured stderr) on non-zero exit
/// status.
fn run_command_capturing(
cwd: &Path, cwd: &Path,
program: &str, program: &str,
args: &[&str], args: &[&str],
env: &BTreeMap<String, String>, env: &BTreeMap<String, String>,
sink: Option<&Arc<dyn LineSink>>, sink: Option<&Arc<dyn LineSink>>,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), CommandFailure> {
log::debug!( log::debug!(
"running: {} {} (in {})", "running: {} {} (in {})",
program, program,
@@ -626,15 +816,21 @@ fn run_command(
let mut cmd = Command::new(program); let mut cmd = Command::new(program);
cmd.current_dir(cwd).envs(env).args(args); cmd.current_dir(cwd).envs(env).args(args);
// The last 64 KiB of stderr, kept for the dpkg-source failure
// classification.
let stderr_capture = Arc::new(std::sync::Mutex::new(String::new()));
let status = match sink { let status = match sink {
None => cmd None => cmd.status().map_err(|e| CommandFailure {
.status() error: format!("failed to run '{}': {}", program, e).into(),
.map_err(|e| format!("failed to run '{}': {}", program, e))?, stderr: String::new(),
})?,
Some(sink) => { Some(sink) => {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd let mut child = cmd.spawn().map_err(|e| CommandFailure {
.spawn() error: format!("failed to run '{}': {}", program, e).into(),
.map_err(|e| format!("failed to run '{}': {}", program, e))?; stderr: String::new(),
})?;
let stdout = child.stdout.take(); let stdout = child.stdout.take();
let stderr = child.stderr.take(); let stderr = child.stderr.take();
@@ -642,37 +838,89 @@ fn run_command(
// approximate (channel arrival order), acceptable for display. // approximate (channel arrival order), acceptable for display.
let out_sink = sink.clone(); let out_sink = sink.clone();
let err_sink = sink.clone(); let err_sink = sink.clone();
let err_capture = Arc::clone(&stderr_capture);
let out_thread = std::thread::spawn(move || { let out_thread = std::thread::spawn(move || {
if let Some(out) = stdout { if let Some(out) = stdout {
pump(out, Stream::Stdout, &*out_sink); pump(out, Stream::Stdout, &*out_sink);
} }
}); });
let err_thread = std::thread::spawn(move || { let err_thread = std::thread::spawn(move || {
if let Some(err) = stderr { if let Some(stderr) = stderr {
pump(err, Stream::Stderr, &*err_sink); pump(
stderr,
Stream::Stderr,
&CapturingSink {
inner: err_sink,
capture: err_capture,
},
);
} }
}); });
let _ = out_thread.join(); let _ = out_thread.join();
let _ = err_thread.join(); let _ = err_thread.join();
child child.wait().map_err(|e| CommandFailure {
.wait() error: format!("failed to wait for '{}': {}", program, e).into(),
.map_err(|e| format!("failed to wait for '{}': {}", program, e))? stderr: String::new(),
})?
} }
}; };
if !status.success() { if !status.success() {
return Err(format!( return Err(CommandFailure {
"'{} {}' failed with status: {}", error: format!(
program, "'{} {}' failed with status: {}",
args.join(" "), program,
status args.join(" "),
) status
.into()); )
.into(),
stderr: std::mem::take(&mut stderr_capture.lock().unwrap_or_else(|e| e.into_inner())),
});
} }
Ok(()) Ok(())
} }
/// Run a build command, discarding the captured stderr.
fn run_command(
cwd: &Path,
program: &str,
args: &[&str],
env: &BTreeMap<String, String>,
sink: Option<&Arc<dyn LineSink>>,
) -> Result<(), Box<dyn Error>> {
run_command_capturing(cwd, program, args, env, sink).map_err(|failure| failure.error)
}
/// A [`LineSink`] forwarding every line to the live view while appending
/// stderr lines to the classification buffer.
struct CapturingSink {
inner: Arc<dyn LineSink>,
capture: Arc<std::sync::Mutex<String>>,
}
const STDERR_CAPTURE_LIMIT: usize = 64 * 1024;
impl LineSink for CapturingSink {
fn line(&self, stream: Stream, line: &str) {
if stream == Stream::Stderr {
let mut buffer = self.capture.lock().unwrap_or_else(|e| e.into_inner());
buffer.push_str(line);
buffer.push('\n');
// Keep the tail: dpkg-source reports the offending path last.
if buffer.len() > STDERR_CAPTURE_LIMIT {
let cut = buffer.len() - STDERR_CAPTURE_LIMIT;
let start = buffer[cut..]
.find('\n')
.map(|index| cut + index + 1)
.unwrap_or(buffer.len());
buffer.drain(..start);
}
}
self.inner.line(stream, line);
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -757,6 +1005,118 @@ mod tests {
assert_eq!(entries.len(), 1); assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "hello.tar.xz"); assert_eq!(entries[0].name, "hello.tar.xz");
} }
/// Real-shape `dpkg-source -b` stderr excerpts and their class.
#[test]
fn dpkg_source_failure_classification() {
// Vendored dependencies diverged from the orig-vendor component
// (dpkg names the offending paths before the summary line).
let vendor = concat!(
"dpkg-source: info: building mytool using existing \
./mytool_0.1.0.orig.tar.xz\n",
"dpkg-source: info: using source format version 3.0 (quilt)\n",
"dpkg-source: error: cannot represent change to \
vendor/libc/src/unix/linux_like/mod.rs: binary file contents \
changed\n",
"dpkg-source: error: unrepresentable changes to source\n",
);
assert_eq!(
classify_dpkg_source_failure(vendor),
DpkgSourceFailure::VendorDrift
);
// A missing orig-vendor component makes the vendored files look
// "new"; still vendor drift.
let missing_component = concat!(
"dpkg-source: error: cannot represent change to \
vendor/libc/Cargo.toml: new file is binary\n",
"dpkg-source: error: unrepresentable changes to source\n",
);
assert_eq!(
classify_dpkg_source_failure(missing_component),
DpkgSourceFailure::VendorDrift
);
// The component named in the error is vendor drift too.
assert_eq!(
classify_dpkg_source_failure(
"dpkg-source: error: orig-vendor component tarball \
checksum mismatch\n",
),
DpkgSourceFailure::VendorDrift
);
// Upstream tree drift outside vendor/.
let upstream = concat!(
"dpkg-source: error: cannot represent change to \
assets/logo.png: binary file contents changed\n",
"dpkg-source: error: unrepresentable changes to source\n",
);
assert_eq!(
classify_dpkg_source_failure(upstream),
DpkgSourceFailure::UpstreamDrift
);
assert_eq!(
classify_dpkg_source_failure("dpkg-source: error: unrepresentable changes to source\n"),
DpkgSourceFailure::UpstreamDrift
);
// Anything else.
assert_eq!(
classify_dpkg_source_failure(
"dpkg-source: error: syntax error in debian/control at line 3\n"
),
DpkgSourceFailure::Other
);
assert_eq!(classify_dpkg_source_failure(""), DpkgSourceFailure::Other);
}
/// The user-facing wording of the classified failures.
#[test]
fn dpkg_source_failure_messages() {
let vendor = "dpkg-source: error: cannot represent change to \
vendor/serde/src/de/mod.rs: binary file contents changed\n\
dpkg-source: error: unrepresentable changes to source\n";
let error = dpkg_source_failure_error(
DpkgSourceFailure::VendorDrift,
vendor,
"'dpkg-source -I -i -b .' failed with status: exit status: 2".into(),
);
let message = error.to_string();
assert!(
message.contains("vendored dependencies changed"),
"{message}"
);
assert!(message.contains("orig-vendor"), "{message}");
// The offending dpkg line travels along as the detail.
assert!(message.contains("vendor/serde/src/de/mod.rs"), "{message}");
// Regression: the typed error must SURVIVE the message building, or
// the re-vendor retry hook never fires.
assert!(error.downcast_ref::<VendorDriftError>().is_some());
let upstream_error = dpkg_source_failure_error(
DpkgSourceFailure::UpstreamDrift,
"dpkg-source: error: unrepresentable changes to source\n",
"'dpkg-source -I -i -b .' failed with status: exit status: 2".into(),
);
let message = upstream_error.to_string();
assert!(
message.contains("drifted beyond the orig snapshot"),
"{message}"
);
assert!(message.contains("pkh chlog"), "{message}");
// Other: the raw command error passes through untouched.
let other = dpkg_source_failure_error(DpkgSourceFailure::Other, "", "boom".into());
assert_eq!(other.to_string(), "boom");
// A vendor-drift error (with or without detail) is recognized by
// the retry hook.
for detail in [None, Some("dpkg-source: error: nope".to_string())] {
let boxed: Box<dyn Error> = Box::new(VendorDriftError { detail });
assert!(boxed.downcast_ref::<VendorDriftError>().is_some());
}
}
} }
/// Differential tests: build synthetic (or real archive) source packages /// Differential tests: build synthetic (or real archive) source packages
+29 -1
View File
@@ -119,7 +119,29 @@ fn main() {
.help("Target series (default: the development series of --dist)"), .help("Target series (default: the development series of --dist)"),
) )
.arg(arg!(--release "Write the --series into debian/changelog instead of UNRELEASED").required(false)) .arg(arg!(--release "Write the --series into debian/changelog instead of UNRELEASED").required(false))
.arg(arg!(--native "Use the 3.0 (native) source format (no orig tarball)").required(false)) .arg(arg!(--native "Use the 3.0 (native) source format (default for a new project skeleton: no orig tarball)").required(false))
.arg(
clap::Arg::new("quilt")
.long("quilt")
.action(clap::ArgAction::SetTrue)
.conflicts_with("native")
.help("Use the 3.0 (quilt) source format with an orig tarball (default when packaging an existing project)"),
)
.arg(
clap::Arg::new("orig_from")
.long("orig-from")
.value_name("MODE")
.value_parser(["release", "git", "path", "snapshot"])
.conflicts_with("native")
.help("How to produce the orig tarball (quilt only): release (download the forge tarball of the tag; network), git (git archive of the tag), path (repack --orig-path), snapshot (tar the working tree). Default: git on a tag, snapshot otherwise"),
)
.arg(
clap::Arg::new("orig_path")
.long("orig-path")
.value_name("FILE|URL")
.conflicts_with("native")
.help("Tarball used by --orig-from path: a local file or an http(s) URL (.tar, .tar.gz, .tgz, .tar.bz2, .tbz2, .tar.xz), repacked to the orig"),
)
.arg( .arg(
clap::Arg::new("no_git") clap::Arg::new("no_git")
.long("no-git") .long("no-git")
@@ -273,6 +295,12 @@ fn main() {
.get_one::<bool>("native") .get_one::<bool>("native")
.copied() .copied()
.unwrap_or(false), .unwrap_or(false),
quilt: sub_matches
.get_one::<bool>("quilt")
.copied()
.unwrap_or(false),
orig_from: sub_matches.get_one::<String>("orig_from").cloned(),
orig_path: sub_matches.get_one::<String>("orig_path").cloned(),
git: !sub_matches git: !sub_matches
.get_one::<bool>("no_git") .get_one::<bool>("no_git")
.copied() .copied()
+81 -22
View File
@@ -12,12 +12,13 @@ use chrono::Datelike;
use tar::Builder; use tar::Builder;
use xz2::write::XzEncoder; use xz2::write::XzEncoder;
use super::options::NewOptions; use super::options::{NewOptions, SourceFormat};
use super::templates::{OutputFile, Template}; use super::templates::{OutputFile, Template};
/// `3.0 (quilt)` source format, the pkh new default. /// `3.0 (quilt)` source format, the default when packaging an existing
/// project.
pub const SOURCE_FORMAT_QUILT: &str = "3.0 (quilt)"; pub const SOURCE_FORMAT_QUILT: &str = "3.0 (quilt)";
/// `3.0 (native)` source format, selected by `--native`. /// `3.0 (native)` source format, the default for a fresh skeleton.
pub const SOURCE_FORMAT_NATIVE: &str = "3.0 (native)"; pub const SOURCE_FORMAT_NATIVE: &str = "3.0 (native)";
/// The three source formats pkh knows how to build. /// The three source formats pkh knows how to build.
@@ -59,7 +60,7 @@ pub fn files(opts: &NewOptions, template: &dyn Template) -> Vec<OutputFile> {
copyright(opts), copyright(opts),
debian_gitignore(opts), debian_gitignore(opts),
]; ];
if !opts.native { if opts.source_format == SourceFormat::Quilt {
files.push(local_options()); files.push(local_options());
} }
if opts.autopkgtest { if opts.autopkgtest {
@@ -94,19 +95,13 @@ fn autopkgtest_smoke(opts: &NewOptions) -> OutputFile {
) )
} }
/// `debian/source/format`: `3.0 (quilt)` by default, `3.0 (native)` with /// `debian/source/format`: `3.0 (native)` for a skeleton by default,
/// `--native`. /// `3.0 (quilt)` for an existing project; either can be forced with
/// `--native` / `--quilt`.
fn source_format(opts: &NewOptions) -> OutputFile { fn source_format(opts: &NewOptions) -> OutputFile {
OutputFile::new( OutputFile::new(
"debian/source/format", "debian/source/format",
format!( format!("{}\n", opts.source_format.deb_string()),
"{}\n",
if opts.native {
SOURCE_FORMAT_NATIVE
} else {
SOURCE_FORMAT_QUILT
}
),
) )
} }
@@ -369,6 +364,20 @@ pub fn create_orig_tarball(
tree: &Path, tree: &Path,
name: &str, name: &str,
upstream_version: &str, upstream_version: &str,
) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
create_orig_tarball_excluding(tree, name, upstream_version, false)
}
/// [`create_orig_tarball`] with the generated `vendor/` directory of a
/// vendored rust package excluded from the snapshot: its contents travel in
/// the separate `<name>_<uver>.orig-vendor.tar.xz` component instead (see
/// [`super::orig`]), so they can be regenerated independently of the
/// upstream sources.
pub fn create_orig_tarball_excluding(
tree: &Path,
name: &str,
upstream_version: &str,
exclude_vendor: bool,
) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> { ) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
let tarball_path = orig_tarball_path(tree, name, upstream_version).ok_or_else(|| { let tarball_path = orig_tarball_path(tree, name, upstream_version).ok_or_else(|| {
format!( format!(
@@ -393,7 +402,8 @@ pub fn create_orig_tarball(
let prefix = format!("{name}-{upstream_version}"); let prefix = format!("{name}-{upstream_version}");
// The single top-level directory dpkg-source expects. // The single top-level directory dpkg-source expects.
builder.append_dir(&prefix, tree)?; builder.append_dir(&prefix, tree)?;
append_tree(&mut builder, tree, &prefix, 0)?; let top_excludes: &[&str] = if exclude_vendor { &["vendor"] } else { &[] };
append_tree(&mut builder, tree, &prefix, 0, ORIG_EXCLUDE, top_excludes)?;
builder builder
.finish() .finish()
@@ -407,12 +417,15 @@ pub fn create_orig_tarball(
} }
/// Recursively append `dir` to the archive under `archive_path`, skipping /// Recursively append `dir` to the archive under `archive_path`, skipping
/// the [`ORIG_EXCLUDE`] names and non-regular files. /// non-regular files, the names of `excludes` at any depth, the `debian/`
fn append_tree( /// directory and the names of `top_excludes` at the top level (depth 0).
pub(crate) fn append_tree(
builder: &mut Builder<XzEncoder<std::fs::File>>, builder: &mut Builder<XzEncoder<std::fs::File>>,
dir: &Path, dir: &Path,
archive_path: &str, archive_path: &str,
depth: usize, depth: usize,
excludes: &[&str],
top_excludes: &[&str],
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let mut entries: Vec<std::fs::DirEntry> = std::fs::read_dir(dir)?.collect::<Result<_, _>>()?; let mut entries: Vec<std::fs::DirEntry> = std::fs::read_dir(dir)?.collect::<Result<_, _>>()?;
entries.sort_by_key(|entry| entry.file_name()); entries.sort_by_key(|entry| entry.file_name());
@@ -422,10 +435,10 @@ fn append_tree(
let file_name = entry.file_name(); let file_name = entry.file_name();
let name = file_name.to_string_lossy().into_owned(); let name = file_name.to_string_lossy().into_owned();
if depth == 0 && name == "debian" { if depth == 0 && (name == "debian" || top_excludes.contains(&name.as_str())) {
continue; continue;
} }
if ORIG_EXCLUDE.contains(&name.as_str()) { if excludes.contains(&name.as_str()) {
continue; continue;
} }
@@ -434,7 +447,14 @@ fn append_tree(
.map_err(|e| format!("cannot stat '{}': {}", path.display(), e))?; .map_err(|e| format!("cannot stat '{}': {}", path.display(), e))?;
if metadata.is_dir() { if metadata.is_dir() {
builder.append_dir(&entry_archive_path, &path)?; builder.append_dir(&entry_archive_path, &path)?;
append_tree(builder, &path, &entry_archive_path, depth + 1)?; append_tree(
builder,
&path,
&entry_archive_path,
depth + 1,
excludes,
top_excludes,
)?;
} else if metadata.is_file() { } else if metadata.is_file() {
// The mode (including the exec bit) travels through the header. // The mode (including the exec bit) travels through the header.
let mut header = tar::Header::new_gnu(); let mut header = tar::Header::new_gnu();
@@ -502,7 +522,8 @@ mod tests {
series: "resolute".into(), series: "resolute".into(),
release: false, release: false,
depends: Vec::new(), depends: Vec::new(),
native: false, source_format: SourceFormat::Quilt,
orig: None,
git: true, git: true,
autopkgtest: false, autopkgtest: false,
pkg_config: false, pkg_config: false,
@@ -528,7 +549,7 @@ mod tests {
); );
let native = NewOptions { let native = NewOptions {
native: true, source_format: SourceFormat::Native,
..opts() ..opts()
}; };
let files = super::files( let files = super::files(
@@ -799,6 +820,44 @@ mod tests {
assert!(err.to_string().contains("already exists")); assert!(err.to_string().contains("already exists"));
} }
/// The vendored-rust variant excludes the top-level `vendor/` (it
/// travels in the orig-vendor component) but keeps unrelated trees.
#[test]
fn orig_tarball_vendor_exclusion() {
let dir = tempfile::tempdir().unwrap();
let tree = dir.path().join("mytool");
std::fs::create_dir_all(tree.join("vendor/serde/src")).unwrap();
std::fs::create_dir_all(tree.join("src/vendor")).unwrap();
std::fs::write(tree.join("vendor/serde/src/lib.rs"), "code").unwrap();
std::fs::write(tree.join("src/vendor/mod.rs"), "code").unwrap();
std::fs::write(tree.join("Cargo.toml"), "[package]").unwrap();
let tarball = create_orig_tarball_excluding(&tree, "mytool", "0.1.0", true).unwrap();
let mut archive = tar::Archive::new(xz2::read::XzDecoder::new(
std::fs::File::open(&tarball).unwrap(),
));
let names: Vec<String> = archive
.entries()
.unwrap()
.map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
.collect();
// The generated vendored tree is out...
assert!(
!names.iter().any(|n| n.starts_with("mytool-0.1.0/vendor")),
"{names:?}"
);
// ...an unrelated nested vendor/ stays in...
assert!(
names.iter().any(|n| n == "mytool-0.1.0/src/vendor/mod.rs"),
"{names:?}"
);
// ...and normal files are unaffected.
assert!(
names.iter().any(|n| n == "mytool-0.1.0/Cargo.toml"),
"{names:?}"
);
}
#[test] #[test]
fn write_files_sets_exec_bit_and_parents() { fn write_files_sets_exec_bit_and_parents() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+240 -105
View File
@@ -3,8 +3,9 @@
//! //!
//! This module orchestrates a scaffold run: target directory checks, project //! This module orchestrates a scaffold run: target directory checks, project
//! detection, in-memory rendering of every file (all-or-nothing write), the //! detection, in-memory rendering of every file (all-or-nothing write), the
//! template post-write hook (e.g. `cargo vendor`), orig tarball creation, //! template post-write hook (e.g. `cargo vendor`), orig tarball creation
//! git initialization, structural verification and the next-steps message. //! (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 //! The interactive wizard ([`questions`]) fills a [`options::NewCli`] from
//! its answers on a TTY and reuses [`options::resolve`] as the single source //! its answers on a TTY and reuses [`options::resolve`] as the single source
//! of truth for defaults and validation; without a TTY the same resolution //! of truth for defaults and validation; without a TTY the same resolution
@@ -14,6 +15,8 @@ pub mod debian;
pub mod detect; pub mod detect;
pub mod git; pub mod git;
pub mod options; pub mod options;
pub mod orig;
pub mod origin;
pub mod questions; pub mod questions;
pub mod templates; pub mod templates;
pub mod verify; pub mod verify;
@@ -23,7 +26,7 @@ use std::time::Duration;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use options::NewOptions; use options::{NewOptions, SourceFormat};
use templates::{OutputFile, ScaffoldOutcome}; use templates::{OutputFile, ScaffoldOutcome};
/// Scaffold a full Debian source tree from `opts`. /// Scaffold a full Debian source tree from `opts`.
@@ -64,8 +67,8 @@ pub fn scaffold(
pb.finish_and_clear(); pb.finish_and_clear();
multi.remove(&pb); multi.remove(&pb);
if result.is_ok() { if let Ok(outcome) = &result {
print_success(&opts); print_success(&opts, outcome);
} }
result result
} }
@@ -127,7 +130,7 @@ fn scaffold_steps(opts: &NewOptions, pb: &ProgressBar) -> Result<ScaffoldOutcome
} }
// Fail before writing anything when the orig tarball already exists. // Fail before writing anything when the orig tarball already exists.
if !opts.native if opts.source_format == SourceFormat::Quilt
&& let Some(tarball) = && let Some(tarball) =
debian::orig_tarball_path(&target, &opts.name, &opts.upstream_version_no_epoch()) debian::orig_tarball_path(&target, &opts.name, &opts.upstream_version_no_epoch())
&& tarball.exists() && tarball.exists()
@@ -182,15 +185,34 @@ fn scaffold_steps(opts: &NewOptions, pb: &ProgressBar) -> Result<ScaffoldOutcome
// 5. Template post-write hook: run before the orig tarball is created, // 5. Template post-write hook: run before the orig tarball is created,
// so files added here (rust: vendor/ + .cargo/config.toml) land // so files added here (rust: vendor/ + .cargo/config.toml) land
// inside it. The outcome (e.g. a failed vendoring) is threaded back // inside it (or inside the orig-vendor component). The outcome
// to the caller. // (e.g. a failed vendoring) is threaded back to the caller.
pb.set_message("Running template hooks"); pb.set_message("Running template hooks");
let outcome = template.post_write(opts, &target)?; let mut outcome = template.post_write(opts, &target)?;
// 6. Orig tarball (quilt only). // 6. Orig tarball (quilt only), from the origin the run decided on.
if !opts.native { // A vendored rust tree gets its `vendor/` directory moved into the
// separate dpkg upstream component `orig-vendor`, regenerable
// independently of the upstream sources (native packages have no
// orig at all: vendor/ simply lives in the tree).
if opts.source_format == SourceFormat::Quilt {
pb.set_message("Creating orig tarball"); pb.set_message("Creating orig tarball");
debian::create_orig_tarball(&target, &opts.name, &opts.upstream_version_no_epoch())?; let vendored_rust =
template.id() == options::TemplateId::Rust && orig::has_vendored_dir(&target);
let created = orig::create_orig(
&target,
&opts.name,
&opts.upstream_version_no_epoch(),
opts.orig
.as_ref()
.ok_or("internal error: a quilt scaffold needs an orig-tarball plan")?,
vendored_rust,
)?;
outcome.orig_origin = Some(created.label);
if vendored_rust {
pb.set_message("Creating the orig-vendor component");
orig::create_vendor_component(&target, &opts.name, &opts.upstream_version_no_epoch())?;
}
} }
// 7. Git. // 7. Git.
@@ -205,7 +227,7 @@ fn scaffold_steps(opts: &NewOptions, pb: &ProgressBar) -> Result<ScaffoldOutcome
} }
/// The success message: what was created and the next steps. /// The success message: what was created and the next steps.
fn print_success(opts: &NewOptions) { fn print_success(opts: &NewOptions, outcome: &ScaffoldOutcome) {
let target = let target =
opts.target_dir(&std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))); opts.target_dir(&std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")));
// `display_path` yields an empty string when the target is the cwd // `display_path` yields an empty string when the target is the cwd
@@ -226,6 +248,9 @@ fn print_success(opts: &NewOptions) {
opts.series, opts.series,
opts.template opts.template
); );
if let Some(orig_origin) = &outcome.orig_origin {
log::info!("Orig tarball: {orig_origin}");
}
log::info!("Next steps:"); log::info!("Next steps:");
log::info!(" cd {}", if display.is_empty() { "." } else { &display }); log::info!(" cd {}", if display.is_empty() { "." } else { &display });
if opts.release { if opts.release {
@@ -244,11 +269,19 @@ fn print_success(opts: &NewOptions) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::new::options::{License, SourceDir, TemplateId}; use crate::new::options::{License, OrigOrigin, SourceDir, TemplateId};
use serial_test::serial; use serial_test::serial;
use tempfile::tempdir; use tempfile::tempdir;
fn opts(template: TemplateId, name: &str, source_dir: SourceDir) -> NewOptions { fn opts(template: TemplateId, name: &str, source_dir: SourceDir) -> NewOptions {
// Mirror the resolve() derivation: skeletons are native by default,
// existing projects quilt with a working-tree snapshot orig.
let (source_format, orig) = match source_dir {
SourceDir::Skeleton => (SourceFormat::Native, None),
SourceDir::Here | SourceDir::Path(_) => {
(SourceFormat::Quilt, Some(OrigOrigin::Snapshot))
}
};
NewOptions { NewOptions {
name: name.to_string(), name: name.to_string(),
template, template,
@@ -265,7 +298,8 @@ mod tests {
series: "resolute".into(), series: "resolute".into(),
release: false, release: false,
depends: Vec::new(), depends: Vec::new(),
native: false, source_format,
orig,
git: false, git: false,
autopkgtest: false, autopkgtest: false,
pkg_config: false, pkg_config: false,
@@ -305,7 +339,6 @@ mod tests {
"debian/rules", "debian/rules",
"debian/copyright", "debian/copyright",
"debian/source/format", "debian/source/format",
"debian/source/local-options",
"debian/.gitignore", "debian/.gitignore",
"debian/install", "debian/install",
"mytool.sh", "mytool.sh",
@@ -340,15 +373,14 @@ mod tests {
assert_eq!(version, "0.1.0-1"); assert_eq!(version, "0.1.0-1");
assert_eq!(distribution, "UNRELEASED"); assert_eq!(distribution, "UNRELEASED");
// source/format + local-options. // A fresh skeleton is 3.0 (native) by default: no local-options and
// no orig tarball anywhere.
assert_eq!( assert_eq!(
std::fs::read_to_string(tree.join("debian/source/format")).unwrap(), std::fs::read_to_string(tree.join("debian/source/format")).unwrap(),
"3.0 (quilt)\n" "3.0 (native)\n"
);
assert_eq!(
std::fs::read_to_string(tree.join("debian/source/local-options")).unwrap(),
"single-debian-patch\n"
); );
assert!(!tree.join("debian/source/local-options").exists());
assert!(!dir.path().join("mytool_0.1.0.orig.tar.xz").exists());
// install mapping. // install mapping.
assert_eq!( assert_eq!(
@@ -360,6 +392,28 @@ mod tests {
let gitignore = std::fs::read_to_string(tree.join(".gitignore")).unwrap(); let gitignore = std::fs::read_to_string(tree.join(".gitignore")).unwrap();
assert!(gitignore.contains("*.deb")); assert!(gitignore.contains("*.deb"));
assert!(gitignore.contains("target/")); assert!(gitignore.contains("target/"));
}
/// A skeleton forced to quilt keeps the snapshot behavior: orig tarball
/// with the skeleton files, `debian/` excluded, local-options present.
#[test]
#[serial]
fn scaffold_skeleton_forced_quilt_snapshots_the_tree() {
let dir = tempdir().unwrap();
let mut o = opts(TemplateId::Shell, "mytool", SourceDir::Skeleton);
o.source_format = SourceFormat::Quilt;
o.orig = Some(OrigOrigin::Snapshot);
scaffold_in(dir.path(), o).unwrap();
let tree = dir.path().join("mytool");
assert_eq!(
std::fs::read_to_string(tree.join("debian/source/format")).unwrap(),
"3.0 (quilt)\n"
);
assert_eq!(
std::fs::read_to_string(tree.join("debian/source/local-options")).unwrap(),
"single-debian-patch\n"
);
// Orig tarball: contains the skeleton file, excludes debian/. // Orig tarball: contains the skeleton file, excludes debian/.
let tarball = dir.path().join("mytool_0.1.0.orig.tar.xz"); let tarball = dir.path().join("mytool_0.1.0.orig.tar.xz");
@@ -403,29 +457,11 @@ mod tests {
); );
assert_eq!(control.binaries[0].get("Architecture"), Some("all")); assert_eq!(control.binaries[0].get("Architecture"), Some("all"));
// No install file, no build-system skeleton: the README stub only. // No install file, no build-system skeleton: the README stub only.
// A native skeleton carries no orig tarball: the README simply
// lives in the tree.
assert!(!tree.join("debian/install").exists()); assert!(!tree.join("debian/install").exists());
assert!(tree.join("README").exists()); assert!(tree.join("README").exists());
// The tarball excludes debian/ but carries the README. assert!(!dir.path().join("metapkg_0.1.0.orig.tar.xz").exists());
let tarball = dir.path().join("metapkg_0.1.0.orig.tar.xz");
let mut archive = tar::Archive::new(xz2::read::XzDecoder::new(
std::fs::File::open(&tarball).unwrap(),
));
let names: Vec<String> = archive
.entries()
.unwrap()
.map(|entry| {
entry
.unwrap()
.path()
.unwrap()
.to_string_lossy()
.into_owned()
})
.collect();
assert!(
names.iter().any(|n| n == "metapkg-0.1.0/README"),
"{names:?}"
);
// Empty base flavor: no depends, no Depends field. // Empty base flavor: no depends, no Depends field.
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
@@ -527,14 +563,13 @@ mod tests {
.unwrap_err(); .unwrap_err();
assert!(err.to_string().contains("not empty"), "{err}"); assert!(err.to_string().contains("not empty"), "{err}");
// Existing orig tarball: nothing gets written. // Existing orig tarball (quilt only): nothing gets written.
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
std::fs::write(dir.path().join("mytool_0.1.0.orig.tar.xz"), b"old").unwrap(); std::fs::write(dir.path().join("mytool_0.1.0.orig.tar.xz"), b"old").unwrap();
let err = scaffold_in( let mut o = opts(TemplateId::Shell, "mytool", SourceDir::Skeleton);
dir.path(), o.source_format = SourceFormat::Quilt;
opts(TemplateId::Shell, "mytool", SourceDir::Skeleton), o.orig = Some(OrigOrigin::Snapshot);
) let err = scaffold_in(dir.path(), o).unwrap_err();
.unwrap_err();
assert!(err.to_string().contains("already exists"), "{err}"); assert!(err.to_string().contains("already exists"), "{err}");
assert!(!dir.path().join("mytool/debian/control").exists()); assert!(!dir.path().join("mytool/debian/control").exists());
@@ -552,13 +587,17 @@ mod tests {
assert!(err.to_string().contains("does not exist"), "{err}"); assert!(err.to_string().contains("does not exist"), "{err}");
} }
/// The skeleton default is `3.0 (native)`: no orig tarball, no
/// `debian/source/local-options`.
#[test] #[test]
#[serial] #[serial]
fn scaffold_native_skips_tarball_and_local_options() { fn scaffold_skeleton_defaults_to_native() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let mut o = opts(TemplateId::Shell, "nativepkg", SourceDir::Skeleton); scaffold_in(
o.native = true; dir.path(),
scaffold_in(dir.path(), o).unwrap(); opts(TemplateId::Shell, "nativepkg", SourceDir::Skeleton),
)
.unwrap();
let tree = dir.path().join("nativepkg"); let tree = dir.path().join("nativepkg");
assert!(!dir.path().join("nativepkg_0.1.0.orig.tar.xz").exists()); assert!(!dir.path().join("nativepkg_0.1.0.orig.tar.xz").exists());
@@ -569,17 +608,17 @@ mod tests {
); );
} }
/// End-to-end rust skeleton: the vendoring hook runs before the orig /// End-to-end rust skeleton: the vendoring hook runs over the tree
/// tarball is created, so `.cargo/` (and `vendor/` when dependencies /// (native format: everything simply lives in the tree, no orig
/// exist) travel inside it. The vendoring step needs host cargo; on a /// tarball). The vendoring step needs host cargo; on a cargo-less host
/// cargo-less host the scaffold still succeeds with a warning and a /// the scaffold still succeeds with a warning and a `vendoring_failed`
/// `vendoring_failed` outcome. Keyed against the `RUSTUP_TOOLCHAIN` /// outcome. Keyed against the `RUSTUP_TOOLCHAIN` tests of the rust
/// tests of the rust template: they mutate the process-global /// template: they mutate the process-global environment the cargo shim
/// environment the cargo shim would pick up mid-vendoring. /// would pick up mid-vendoring.
#[test] #[test]
#[serial] #[serial]
#[serial(RUSTUP_TOOLCHAIN)] #[serial(RUSTUP_TOOLCHAIN)]
fn scaffold_rust_skeleton_vendors_before_tarball() { fn scaffold_rust_skeleton_vendors_into_the_tree() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let outcome = scaffold_in( let outcome = scaffold_in(
dir.path(), dir.path(),
@@ -589,6 +628,9 @@ mod tests {
let has_cargo = crate::new::templates::find_on_path("cargo").is_some(); let has_cargo = crate::new::templates::find_on_path("cargo").is_some();
assert_eq!(outcome.vendoring_failed, !has_cargo); assert_eq!(outcome.vendoring_failed, !has_cargo);
// Native skeleton: no orig tarball at all.
assert_eq!(outcome.orig_origin, None);
assert!(!dir.path().join("mytool_0.1.0.orig.tar.xz").exists());
let tree = dir.path().join("mytool"); let tree = dir.path().join("mytool");
assert!(tree.join("Cargo.toml").exists()); assert!(tree.join("Cargo.toml").exists());
@@ -613,14 +655,60 @@ mod tests {
Some("debhelper-compat (= 13),\ncargo:native,\nrustc:native") Some("debhelper-compat (= 13),\ncargo:native,\nrustc:native")
); );
// The offline config exists when host cargo vendored the skeleton, // The offline config exists in the tree when host cargo vendored
// and both it and the skeleton land inside the orig tarball. // the skeleton.
if has_cargo { if has_cargo {
let config = std::fs::read_to_string(tree.join(".cargo/config.toml")).unwrap(); let config = std::fs::read_to_string(tree.join(".cargo/config.toml")).unwrap();
assert!(config.contains("[source.crates-io]"), "{config}"); assert!(config.contains("[source.crates-io]"), "{config}");
assert!(config.contains("[net]\noffline = true"), "{config}"); assert!(config.contains("[net]\noffline = true"), "{config}");
} }
}
/// End-to-end vendored rust quilt package: the vendoring hook creates
/// `vendor/`, the main orig excludes it, the dpkg upstream component
/// `orig-vendor` carries it, and the real source build
/// (`run_source_build`) lists BOTH tarballs in the `.dsc` and succeeds.
/// Needs host cargo with a working crates.io sync (skipped gracefully
/// when either is unavailable) and the local dpkg tools.
#[test]
#[serial]
#[serial(RUSTUP_TOOLCHAIN)]
fn scaffold_rust_quilt_with_deps_vendors_into_a_component() {
let dir = tempdir().unwrap();
let source = dir.path().join("mytool");
std::fs::create_dir_all(source.join("src")).unwrap();
// `libc` resolves from the host's cargo registry cache; vendoring
// it needs one crates.io index sync.
std::fs::write(
source.join("Cargo.toml"),
"[package]\n\
name = \"mytool\"\n\
version = \"0.1.0\"\n\
edition = \"2021\"\n\
\n\
[dependencies]\n\
libc = \"0.2\"\n",
)
.unwrap();
std::fs::write(source.join("src/main.rs"), "fn main() {}\n").unwrap();
let mut o = opts(TemplateId::Rust, "mytool", SourceDir::Path(source.clone()));
o.source_format = SourceFormat::Quilt;
o.orig = Some(OrigOrigin::Snapshot);
let outcome = scaffold_in(dir.path(), o).unwrap();
if crate::new::templates::find_on_path("cargo").is_none() || outcome.vendoring_failed {
// No cargo on this host or the crates.io sync failed: the
// vendoring guarantees of this test cannot hold.
log::warn!("cargo/crates.io unavailable; skipping the vendored component checks");
return;
}
assert_eq!(
outcome.orig_origin,
Some("working tree snapshot".to_string())
);
// The main orig excludes vendor/ but carries the upstream files.
let tarball = dir.path().join("mytool_0.1.0.orig.tar.xz"); let tarball = dir.path().join("mytool_0.1.0.orig.tar.xz");
let mut archive = tar::Archive::new(xz2::read::XzDecoder::new( let mut archive = tar::Archive::new(xz2::read::XzDecoder::new(
std::fs::File::open(&tarball).unwrap(), std::fs::File::open(&tarball).unwrap(),
@@ -641,21 +729,58 @@ mod tests {
names.iter().any(|n| n == "mytool-0.1.0/Cargo.toml"), names.iter().any(|n| n == "mytool-0.1.0/Cargo.toml"),
"{names:?}" "{names:?}"
); );
assert!(!names.iter().any(|n| n.contains("vendor")), "{names:?}");
// The component carries vendor/ under a top-level vendor/ dir.
let component = dir.path().join("mytool_0.1.0.orig-vendor.tar.xz");
assert!(component.exists());
let mut archive = tar::Archive::new(xz2::read::XzDecoder::new(
std::fs::File::open(&component).unwrap(),
));
let names: Vec<String> = archive
.entries()
.unwrap()
.map(|entry| {
entry
.unwrap()
.path()
.unwrap()
.to_string_lossy()
.into_owned()
})
.collect();
assert!( assert!(
names.iter().any(|n| n == "mytool-0.1.0/src/main.rs"), names.iter().any(|n| n.starts_with("vendor/libc/")),
"{names:?}" "{names:?}"
); );
if has_cargo {
assert!( // The real source build: the .dsc references both tarballs.
names.iter().any(|n| n == "mytool-0.1.0/.cargo/config.toml"), let output = crate::build::run_source_build(
"{names:?}" &source,
); &crate::build::SourceBuildOptions::default(),
} None,
assert!(!names.iter().any(|n| n.contains("debian")), "{names:?}"); )
.unwrap();
let dsc = std::fs::read_to_string(&output.dsc).unwrap();
assert!(dsc.contains("mytool_0.1.0.orig.tar.xz"), "{dsc}");
assert!(dsc.contains("mytool_0.1.0.orig-vendor.tar.xz"), "{dsc}");
assert!(
output
.tarballs
.iter()
.any(|t| t.ends_with("mytool_0.1.0.orig.tar.xz"))
);
assert!(
output
.tarballs
.iter()
.any(|t| t.ends_with("mytool_0.1.0.orig-vendor.tar.xz"))
);
assert!(!output.signed);
} }
/// End-to-end python skeleton: pyproject-based Build-Depends and the /// End-to-end python skeleton: pyproject-based Build-Depends (native
/// module skeleton inside the orig tarball. /// skeleton: the upstream files live in the tree, no orig tarball).
#[test] #[test]
#[serial] #[serial]
fn scaffold_python_skeleton_tree() { fn scaffold_python_skeleton_tree() {
@@ -679,44 +804,22 @@ mod tests {
let rules = std::fs::read_to_string(tree.join("debian/rules")).unwrap(); let rules = std::fs::read_to_string(tree.join("debian/rules")).unwrap();
assert!(rules.contains("%:\n\tdh $@ --with python3 --buildsystem=pybuild\n")); assert!(rules.contains("%:\n\tdh $@ --with python3 --buildsystem=pybuild\n"));
let tarball = dir.path().join("mytool_0.1.0.orig.tar.xz"); assert!(tree.join("pyproject.toml").exists());
let mut archive = tar::Archive::new(xz2::read::XzDecoder::new( assert!(tree.join("mytool/__init__.py").exists());
std::fs::File::open(&tarball).unwrap(), assert!(!dir.path().join("mytool_0.1.0.orig.tar.xz").exists());
));
let names: Vec<String> = archive
.entries()
.unwrap()
.map(|entry| {
entry
.unwrap()
.path()
.unwrap()
.to_string_lossy()
.into_owned()
})
.collect();
assert!(
names.iter().any(|n| n == "mytool-0.1.0/pyproject.toml"),
"{names:?}"
);
assert!(
names.iter().any(|n| n == "mytool-0.1.0/mytool/__init__.py"),
"{names:?}"
);
} }
/// End-to-end: the scaffolded shell tree passes the real source build /// End-to-end: a quilt tree (Here mode over an existing source) passes
/// (`dpkg-source` and friends, same prerequisites as the differential /// the real source build (`dpkg-source` and friends, same prerequisites
/// tests). /// as the differential tests).
#[test] #[test]
#[serial] #[serial]
fn scaffold_then_source_build_produces_artifacts() { fn scaffold_then_source_build_produces_artifacts() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
scaffold_in( let tree = dir.path().join("mytool");
dir.path(), std::fs::create_dir_all(&tree).unwrap();
opts(TemplateId::Shell, "mytool", SourceDir::Skeleton), std::fs::write(tree.join("run.sh"), "#!/bin/sh\necho hi\n").unwrap();
) scaffold_in(&tree, opts(TemplateId::Shell, "mytool", SourceDir::Here)).unwrap();
.unwrap();
let output = crate::build::run_source_build( let output = crate::build::run_source_build(
&dir.path().join("mytool"), &dir.path().join("mytool"),
@@ -736,4 +839,36 @@ mod tests {
// UNRELEASED: nothing is signed. // UNRELEASED: nothing is signed.
assert!(!output.signed); assert!(!output.signed);
} }
/// End-to-end native: a self-authored skeleton (3.0 (native), no orig
/// tarball) builds into a .dsc without any tarball at all.
#[test]
#[serial]
fn scaffold_native_then_source_build_needs_no_tarball() {
let dir = tempdir().unwrap();
scaffold_in(
dir.path(),
opts(TemplateId::Shell, "mytool", SourceDir::Skeleton),
)
.unwrap();
let output = crate::build::run_source_build(
&dir.path().join("mytool"),
&crate::build::SourceBuildOptions::default(),
None,
)
.unwrap();
assert!(output.dsc.exists(), "{:?} missing", output.dsc);
assert!(output.changes.exists(), "{:?} missing", output.changes);
// 3.0 (native): one self-contained source tarball (debian/ inside),
// but no ORIG tarball.
assert_eq!(output.tarballs.len(), 1, "{:?}", output.tarballs);
assert!(
!output.tarballs[0]
.file_name()
.is_some_and(|name| name.to_string_lossy().contains("orig")),
"{:?}",
output.tarballs
);
}
} }
+571 -5
View File
@@ -15,6 +15,8 @@ use crate::debian::DebianVersion;
use crate::debian::deps::{Deps, ParseOpts}; use crate::debian::deps::{Deps, ParseOpts};
use crate::distro_info; use crate::distro_info;
use crate::new::detect::{self, Detection}; use crate::new::detect::{self, Detection};
use crate::new::origin::{Forge, GitOrigin};
use crate::new::templates;
/// Build systems / project kinds `pkh new` knows about. /// Build systems / project kinds `pkh new` knows about.
/// ///
@@ -133,6 +135,82 @@ pub enum SourceDir {
Path(PathBuf), Path(PathBuf),
} }
impl SourceDir {
/// The directory being packaged, when it already exists on disk (`None`
/// for the skeleton mode, whose sources are rendered in memory).
pub fn existing_dir(&self, cwd: &Path) -> Option<PathBuf> {
match self {
SourceDir::Skeleton => None,
SourceDir::Here => Some(cwd.to_path_buf()),
SourceDir::Path(path) => Some(path.clone()),
}
}
}
/// Debian source format of the scaffolded package: an explicit, derived
/// choice rather than a boolean afterthought.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceFormat {
/// `3.0 (native)`: no orig tarball at all. The default for a fresh,
/// self-authored skeleton.
Native,
/// `3.0 (quilt)`: upstream sources in the orig tarball, packaging in
/// `debian/`. The default when packaging an existing project.
Quilt,
}
impl SourceFormat {
/// The value written to `debian/source/format`.
pub fn deb_string(&self) -> &'static str {
match self {
SourceFormat::Native => super::debian::SOURCE_FORMAT_NATIVE,
SourceFormat::Quilt => super::debian::SOURCE_FORMAT_QUILT,
}
}
}
/// How the orig tarball of a quilt package is produced. The right answer
/// depends on where the upstream sources come from, so the wizard offers
/// them pre-ordered by what git origin detection found (release download
/// and `git archive` only exist when HEAD sits exactly on a tag).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OrigOrigin {
/// Download the upstream release tarball of `tag` from the forge of the
/// `origin` remote and repack it (explicit network consent).
Release {
/// Release tag the tarball is downloaded for.
tag: String,
/// Forge the tarball is downloaded from.
forge: Forge,
},
/// `git archive` the tag HEAD sits on: offline and deterministic.
GitArchive {
/// Release tag archived into the tarball.
tag: String,
},
/// Repack a tarball the user provides (a local path or an http(s) URL).
Provided {
/// Path or URL of the user-provided tarball.
source: String,
},
/// Snapshot the working tree into the orig tarball (the only option for
/// a skeleton, and the fallback when the upstream history is unknown).
Snapshot,
}
impl OrigOrigin {
/// Human-readable description used in the wizard summary and the
/// post-scaffold report.
pub fn label(&self) -> String {
match self {
OrigOrigin::Release { tag, .. } => format!("release download ({tag})"),
OrigOrigin::GitArchive { tag } => format!("git archive ({tag})"),
OrigOrigin::Provided { source } => format!("user tarball ({source})"),
OrigOrigin::Snapshot => "working tree snapshot".to_string(),
}
}
}
/// Upstream license of the package: a curated SPDX list plus a free-text /// Upstream license of the package: a curated SPDX list plus a free-text
/// fallback for anything else (including "unknown" until the user picks one). /// fallback for anything else (including "unknown" until the user picks one).
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -242,8 +320,13 @@ pub struct NewOptions {
/// Runtime Depends clauses of the metapackage flavor (canonically /// Runtime Depends clauses of the metapackage flavor (canonically
/// rendered; empty for every other flavor). /// rendered; empty for every other flavor).
pub depends: Vec<String>, pub depends: Vec<String>,
/// Use the `3.0 (native)` source format (no orig tarball). /// Debian source format: `3.0 (native)` for a fresh skeleton by
pub native: bool, /// default, `3.0 (quilt)` when packaging an existing project; either
/// can be forced with `--native` / `--quilt`.
pub source_format: SourceFormat,
/// How the orig tarball is produced (quilt only; `None` with the native
/// format, which has no orig tarball).
pub orig: Option<OrigOrigin>,
/// Initialize a git repository (gitignores are written regardless). /// Initialize a git repository (gitignores are written regardless).
pub git: bool, pub git: bool,
/// Write the autopkgtest smoke test (`debian/tests/control` + /// Write the autopkgtest smoke test (`debian/tests/control` +
@@ -320,6 +403,12 @@ pub struct NewCli {
pub release: bool, pub release: bool,
/// `--native`. /// `--native`.
pub native: bool, pub native: bool,
/// `--quilt` (mutually exclusive with `--native`).
pub quilt: bool,
/// `--orig-from <release|git|path|snapshot>`.
pub orig_from: Option<String>,
/// `--orig-path <file|url>` (required by `--orig-from path`).
pub orig_path: Option<String>,
/// True unless `--no-git`. /// True unless `--no-git`.
pub git: bool, pub git: bool,
/// `--defaults`. /// `--defaults`.
@@ -604,11 +693,65 @@ pub async fn resolve(cli: NewCli) -> Result<NewOptions, String> {
// Everything below has a default and is validated as it is resolved. // Everything below has a default and is validated as it is resolved.
validate_source_name(&name)?; validate_source_name(&name)?;
let upstream_version = cli.upstream_version.unwrap_or_else(|| "0.1.0".to_string()); // Source format: explicit flags win (--native / --quilt, mutually
// exclusive), otherwise the mode decides — a fresh, self-authored
// skeleton is native by default, an existing project is quilt.
let source_format = match (cli.native, cli.quilt) {
(true, true) => {
return Err("--native and --quilt are mutually exclusive".to_string());
}
(true, false) => SourceFormat::Native,
(false, true) => SourceFormat::Quilt,
(false, false) => match source_dir {
SourceDir::Skeleton => SourceFormat::Native,
SourceDir::Here | SourceDir::Path(_) => SourceFormat::Quilt,
},
};
// Git origin state of the packaged directory: needed for the version
// default (tag / +git scheme) and for the orig-tarball plan. Probing is
// purely local (git config and refs, no network).
let existing_dir = source_dir.existing_dir(&std::env::current_dir().unwrap_or_default());
let needs_origin = cli.upstream_version.is_none()
|| (source_format == SourceFormat::Quilt
&& (cli.orig_from.is_some() || existing_dir.is_some()));
let origin = if needs_origin {
existing_dir.as_deref().and_then(GitOrigin::detect)
} else {
None
};
// Upstream version default: probed project version, else the tag HEAD
// sits on, else `<lasttag>+git<YYYYMMDD>.<hash>`, else 0.1.0.
let upstream_version = match cli.upstream_version {
Some(ref version) => version.clone(),
None => {
let probed = template
.and_then(templates::get)
.and_then(|t| t.probe(existing_dir.as_deref()?))
.and_then(|probe| probe.version)
.filter(|version| {
validate_upstream_version(version, cli.revision.unwrap_or(1)).is_ok()
});
let derived = probed
.or_else(|| origin.as_ref().and_then(GitOrigin::head_tag_version))
.or_else(|| origin.as_ref().and_then(GitOrigin::git_version))
.unwrap_or_else(|| "0.1.0".to_string());
if derived != "0.1.0" {
log::info!("No --upstream-version given, defaulting to '{derived}'");
}
derived
}
};
let revision = cli.revision.unwrap_or(1); let revision = cli.revision.unwrap_or(1);
validate_upstream_version(&upstream_version, revision) validate_upstream_version(&upstream_version, revision)
.map_err(|e| format!("Invalid upstream version: {e}"))?; .map_err(|e| format!("Invalid upstream version: {e}"))?;
// Orig-tarball plan (quilt only). Without an explicit --orig-from the
// non-interactive default never touches the network: `git archive` of
// the tag when HEAD sits on one, a working-tree snapshot otherwise.
let orig = resolve_orig_origin(&cli, &source_dir, source_format, origin.as_ref())?;
let homepage = match &cli.homepage { let homepage = match &cli.homepage {
Some(h) => { Some(h) => {
validate_homepage(h)?; validate_homepage(h)?;
@@ -673,7 +816,8 @@ pub async fn resolve(cli: NewCli) -> Result<NewOptions, String> {
series, series,
release: cli.release, release: cli.release,
depends, depends,
native: cli.native, source_format,
orig,
git: cli.git, git: cli.git,
autopkgtest: false, autopkgtest: false,
pkg_config: false, pkg_config: false,
@@ -681,6 +825,111 @@ pub async fn resolve(cli: NewCli) -> Result<NewOptions, String> {
}) })
} }
/// Resolve the orig-tarball plan of a quilt scaffold from the CLI answers
/// and the detected git origin. Fails early with a pointed message when an
/// explicit `--orig-from` mode cannot apply to this tree; the implicit,
/// non-interactive default never touches the network (`git archive` of the
/// tag when HEAD sits exactly on one, a working-tree snapshot otherwise).
fn resolve_orig_origin(
cli: &NewCli,
source_dir: &SourceDir,
source_format: SourceFormat,
origin: Option<&GitOrigin>,
) -> Result<Option<OrigOrigin>, String> {
if source_format == SourceFormat::Native {
if cli.orig_from.is_some() || cli.orig_path.is_some() {
return Err(
"--orig-from/--orig-path need a quilt package (drop --native \
or pass --quilt to build with an orig tarball)"
.to_string(),
);
}
return Ok(None);
}
let skeleton = matches!(source_dir, SourceDir::Skeleton);
let head_tag = || origin.and_then(|o| o.head_tag.clone());
let plan = match cli.orig_from.as_deref() {
Some("snapshot") => OrigOrigin::Snapshot,
Some("path") => {
let source = cli.orig_path.clone().ok_or_else(|| {
"--orig-from path requires --orig-path <tarball file or URL>".to_string()
})?;
validate_orig_path(&source)?;
OrigOrigin::Provided { source }
}
Some("git") => {
if skeleton {
return Err("a fresh skeleton has no upstream git history; use \
--orig-from snapshot (or drop --quilt)"
.to_string());
}
match head_tag() {
Some(tag) => OrigOrigin::GitArchive { tag },
None => {
return Err("--orig-from git requires HEAD to be exactly on a \
release tag"
.to_string());
}
}
}
Some("release") => {
if skeleton {
return Err("a fresh skeleton has no upstream release to download; \
use --orig-from snapshot (or drop --quilt)"
.to_string());
}
let Some(tag) = head_tag() else {
return Err("--orig-from release requires HEAD to be exactly on a \
release tag"
.to_string());
};
let forge = origin.and_then(|o| o.forge.clone()).ok_or_else(|| {
"--orig-from release needs a github.com or gitlab.com origin \
remote (self-hosted forges are not recognized)"
.to_string()
})?;
OrigOrigin::Release { tag, forge }
}
Some(other) => {
return Err(format!(
"Unknown --orig-from value '{other}'. Supported: release, \
git, path, snapshot."
));
}
None => {
// Skeletons (and anything without an upstream history) can only
// be snapshotted; on a tag, git archive is the offline default.
match (skeleton, head_tag()) {
(false, Some(tag)) => OrigOrigin::GitArchive { tag },
_ => OrigOrigin::Snapshot,
}
}
};
if cli.orig_path.is_some() && !matches!(plan, OrigOrigin::Provided { .. }) {
return Err("--orig-path is only valid together with --orig-from path".to_string());
}
Ok(Some(plan))
}
/// Validate a `--orig-path` value (and the wizard's tarball answer): either
/// an http(s) URL (downloaded at scaffold time) or an existing local
/// tarball.
pub fn validate_orig_path(source: &str) -> Result<(), String> {
if source.starts_with("http://") || source.starts_with("https://") {
return Ok(());
}
if Path::new(source).is_file() {
return Ok(());
}
Err(format!(
"'{source}' is not an http(s) URL and not an existing file: \
--orig-path must point at a tarball to repack"
))
}
/// Set of names no template may produce twice (collision check). /// Set of names no template may produce twice (collision check).
pub(crate) fn check_file_collisions(paths: &[String]) -> Result<(), String> { pub(crate) fn check_file_collisions(paths: &[String]) -> Result<(), String> {
let mut seen: HashSet<&String> = HashSet::new(); let mut seen: HashSet<&String> = HashSet::new();
@@ -788,7 +1037,8 @@ mod tests {
series: "sid".into(), series: "sid".into(),
release: false, release: false,
depends: Vec::new(), depends: Vec::new(),
native: false, source_format: SourceFormat::Quilt,
orig: None,
git: false, git: false,
autopkgtest: false, autopkgtest: false,
pkg_config: false, pkg_config: false,
@@ -937,6 +1187,10 @@ mod tests {
assert_eq!(opts.depends, vec!["hello (>= 1.0)", "hello-data"]); assert_eq!(opts.depends, vec!["hello (>= 1.0)", "hello-data"]);
assert!(opts.git); assert!(opts.git);
assert!(!opts.release); assert!(!opts.release);
// An existing project (Path mode) is quilt by default; an empty,
// non-git directory has no better orig plan than a tree snapshot.
assert_eq!(opts.source_format, SourceFormat::Quilt);
assert_eq!(opts.orig, Some(OrigOrigin::Snapshot));
// Series: the development series of the current vendor (lowercased, // Series: the development series of the current vendor (lowercased,
// matching the distro-info keys). // matching the distro-info keys).
let dist = crate::build::env::current_vendor().to_lowercase(); let dist = crate::build::env::current_vendor().to_lowercase();
@@ -979,4 +1233,316 @@ mod tests {
let err = resolve(cli).await.unwrap_err(); let err = resolve(cli).await.unwrap_err();
assert!(err.contains("--release requires --series"), "{err}"); assert!(err.contains("--release requires --series"), "{err}");
} }
/// Minimal CLI answers for a scripted source directory.
fn cli_for(dir: &std::path::Path) -> NewCli {
NewCli {
name: Some("mytool".into()),
source: Some(dir.to_path_buf()),
lang: Some("empty".into()),
description: Some("A tool".into()),
maintainer: Some("Jane <jane@example.com>".into()),
git: true,
..Default::default()
}
}
/// A git repo fixture: init, commit, optional tag, optional extra
/// commit (needs the git CLI; returns (repo dir, HEAD short hash)).
fn git_repo(tag: Option<&str>, second_commit: bool) -> (tempfile::TempDir, String) {
let run = |dir: &std::path::Path, args: &[&str]| {
let status = std::process::Command::new("git")
.args([
"-c",
"user.name=T",
"-c",
"user.email=t@example.invalid",
"-c",
"commit.gpgsign=false",
])
.args(args)
.current_dir(dir)
.status()
.expect("git should be runnable");
assert!(status.success(), "git {args:?} failed");
};
let dir = tempfile::tempdir().unwrap();
run(dir.path(), &["init", "-q"]);
std::fs::write(dir.path().join("f"), "one\n").unwrap();
run(dir.path(), &["add", "f"]);
run(dir.path(), &["commit", "-q", "-m", "first"]);
if let Some(tag) = tag {
run(dir.path(), &["tag", tag]);
}
if second_commit {
std::fs::write(dir.path().join("f"), "two\n").unwrap();
run(dir.path(), &["add", "f"]);
run(dir.path(), &["commit", "-q", "-m", "second"]);
}
let hash = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.current_dir(dir.path())
.output()
.unwrap();
let hash = String::from_utf8_lossy(&hash.stdout).trim().to_string();
(dir, hash)
}
#[tokio::test]
async fn resolve_skeleton_is_native_without_orig_tarball() {
// A self-authored skeleton defaults to 3.0 (native): no orig
// tarball, no orig plan.
let cli = NewCli {
name: Some("mytool".into()),
lang: Some("shell".into()),
description: Some("A tool".into()),
maintainer: Some("Jane <jane@example.com>".into()),
git: true,
..Default::default()
};
let opts = resolve(cli).await.unwrap();
assert_eq!(opts.source_format, SourceFormat::Native);
assert_eq!(opts.orig, None);
}
#[tokio::test]
async fn resolve_skeleton_forced_quilt_snapshots_the_tree() {
let cli = NewCli {
name: Some("mytool".into()),
lang: Some("shell".into()),
description: Some("A tool".into()),
maintainer: Some("Jane <jane@example.com>".into()),
quilt: true,
git: true,
..Default::default()
};
let opts = resolve(cli).await.unwrap();
assert_eq!(opts.source_format, SourceFormat::Quilt);
assert_eq!(opts.orig, Some(OrigOrigin::Snapshot));
}
#[tokio::test]
async fn resolve_skeleton_rejects_history_dependent_orig_modes() {
// A skeleton has no upstream git history: only the snapshot mode
// makes sense.
for mode in ["git", "release"] {
let cli = NewCli {
name: Some("mytool".into()),
lang: Some("shell".into()),
description: Some("A tool".into()),
maintainer: Some("Jane <jane@example.com>".into()),
quilt: true,
orig_from: Some(mode.to_string()),
git: true,
..Default::default()
};
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("no upstream"), "{mode}: {err}");
}
}
#[tokio::test]
async fn resolve_existing_project_forced_native() {
let dir = tempfile::tempdir().unwrap();
let mut cli = cli_for(dir.path());
cli.native = true;
let opts = resolve(cli).await.unwrap();
assert_eq!(opts.source_format, SourceFormat::Native);
assert_eq!(opts.orig, None);
}
#[tokio::test]
async fn resolve_native_and_quilt_conflict() {
let cli = NewCli {
name: Some("mytool".into()),
lang: Some("shell".into()),
description: Some("A tool".into()),
maintainer: Some("Jane <jane@example.com>".into()),
native: true,
quilt: true,
..Default::default()
};
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("mutually exclusive"), "{err}");
}
#[tokio::test]
async fn resolve_head_on_tag_defaults_to_git_archive_and_tag_version() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
return;
}
let (dir, _hash) = git_repo(Some("v1.2.3"), false);
let opts = resolve(cli_for(dir.path())).await.unwrap();
assert_eq!(opts.source_format, SourceFormat::Quilt);
// Version default: the tag version, not 0.1.0.
assert_eq!(opts.upstream_version, "1.2.3");
// Non-interactive orig default on a tag: git archive (no network).
assert_eq!(
opts.orig,
Some(OrigOrigin::GitArchive {
tag: "v1.2.3".to_string()
})
);
}
#[tokio::test]
async fn resolve_between_releases_snapshots_with_git_version() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
return;
}
let (dir, _hash) = git_repo(Some("v1.2.3"), true);
let opts = resolve(cli_for(dir.path())).await.unwrap();
let expected = crate::new::origin::GitOrigin::detect(dir.path())
.unwrap()
.git_version()
.unwrap();
assert_eq!(opts.upstream_version, expected);
assert!(opts.upstream_version.starts_with("1.2.3+git"));
assert_eq!(opts.orig, Some(OrigOrigin::Snapshot));
}
#[tokio::test]
async fn resolve_probed_project_version_beats_the_tag() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
return;
}
let (dir, _) = git_repo(Some("v9.9.9"), false);
// A Cargo.toml without src/ makes `cargo metadata` fail, so the
// template probe falls back to its deterministic line parse.
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"x\"\nversion = \"3.3.3\"\n",
)
.unwrap();
let mut cli = cli_for(dir.path());
cli.lang = Some("rust".into());
let opts = resolve(cli).await.unwrap();
assert_eq!(opts.upstream_version, "3.3.3");
// The orig plan still comes from the tag.
assert_eq!(
opts.orig,
Some(OrigOrigin::GitArchive {
tag: "v9.9.9".to_string()
})
);
}
#[tokio::test]
async fn resolve_orig_from_modes() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
return;
}
let (dir, _) = git_repo(Some("v1.2.3"), false);
// snapshot is always allowed.
let mut cli = cli_for(dir.path());
cli.orig_from = Some("snapshot".into());
assert_eq!(resolve(cli).await.unwrap().orig, Some(OrigOrigin::Snapshot));
// release requires a recognized forge remote.
let mut cli = cli_for(dir.path());
cli.orig_from = Some("release".into());
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("github.com or gitlab.com"), "{err}");
// After adding a recognized remote the release plan resolves.
std::process::Command::new("git")
.args(["remote", "add", "origin", "https://github.com/foo/bar.git"])
.current_dir(dir.path())
.status()
.unwrap()
.success()
.then_some(())
.unwrap();
let mut cli = cli_for(dir.path());
cli.orig_from = Some("release".into());
assert_eq!(
resolve(cli).await.unwrap().orig,
Some(OrigOrigin::Release {
tag: "v1.2.3".to_string(),
forge: crate::new::origin::Forge::GitHub {
owner: "foo".into(),
repo: "bar".into()
}
})
);
// path requires --orig-path, and an existing file or a URL.
let mut cli = cli_for(dir.path());
cli.orig_from = Some("path".into());
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("--orig-path"), "{err}");
let tarball = dir.path().join("upstream.tar.gz");
std::fs::write(&tarball, b"not really a tarball").unwrap();
let mut cli = cli_for(dir.path());
cli.orig_from = Some("path".into());
cli.orig_path = Some(tarball.to_string_lossy().into_owned());
assert_eq!(
resolve(cli).await.unwrap().orig,
Some(OrigOrigin::Provided {
source: tarball.to_string_lossy().into_owned()
})
);
let mut cli = cli_for(dir.path());
cli.orig_from = Some("path".into());
cli.orig_path = Some("https://example.com/upstream.tar.gz".into());
assert_eq!(
resolve(cli).await.unwrap().orig,
Some(OrigOrigin::Provided {
source: "https://example.com/upstream.tar.gz".to_string()
})
);
// A missing local path is rejected early.
let mut cli = cli_for(dir.path());
cli.orig_from = Some("path".into());
cli.orig_path = Some(
dir.path()
.join("missing.tar.gz")
.to_string_lossy()
.into_owned(),
);
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("not an http(s) URL"), "{err}");
// --orig-path without --orig-from path is a misuse.
let mut cli = cli_for(dir.path());
cli.orig_path = Some(tarball.to_string_lossy().into_owned());
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("only valid together"), "{err}");
// git requires HEAD exactly on a tag.
let (off, _) = git_repo(Some("v1.0.0"), true);
let mut cli = cli_for(off.path());
cli.orig_from = Some("git".into());
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("exactly on a release tag"), "{err}");
// ... and the orig flags need quilt.
let mut cli = cli_for(dir.path());
cli.native = true;
cli.orig_from = Some("git".into());
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("need a quilt package"), "{err}");
}
} }
+1103
View File
File diff suppressed because it is too large Load Diff
+533
View File
@@ -0,0 +1,533 @@
//! Best-effort git origin detection for `pkh new`.
//!
//! [`GitOrigin::detect`] inspects the source directory through the `git`
//! CLI (fail-soft: any failed query just leaves the corresponding field
//! empty, and a non-repo yields `None`) and answers the questions driving
//! the source-format and orig-tarball decisions:
//!
//! - is HEAD exactly on a tag, and which upstream version does it name,
//! - which is the last tag reachable from HEAD (for the
//! `<tag>+git<YYYYMMDD>.<hash>` version scheme),
//! - where does the `origin` remote point: only `github.com` and
//! `gitlab.com` are recognized as forges — self-hosted GitLab instances
//! are deliberately not (the release-download URL shapes differ).
//!
//! Detection never touches the network: adding a remote stores its URL in
//! the local config only, which is all this module reads.
use std::path::Path;
use std::process::Command;
/// A forge hosting the project, parsed from the `origin` remote URL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Forge {
/// `github.com/<owner>/<repo>`
GitHub {
/// Repository owner (user or organization).
owner: String,
/// Repository name, without the `.git` suffix.
repo: String,
},
/// `gitlab.com/<owner>/<repo>`
GitLab {
/// Repository owner (user or group).
owner: String,
/// Repository name, without the `.git` suffix.
repo: String,
},
}
impl Forge {
/// Host name of the forge.
pub fn host(&self) -> &'static str {
match self {
Forge::GitHub { .. } => "github.com",
Forge::GitLab { .. } => "gitlab.com",
}
}
/// Parse a remote URL into a [`Forge`], accepting the `https://`,
/// `http://`, `git://` and `git@host:` spellings. Only `github.com` and
/// `gitlab.com` are recognized; anything else (self-hosted GitLab,
/// Bitbucket, plain URLs…) yields `None`.
pub fn parse(url: &str) -> Option<Forge> {
let url = url.trim();
// Normalize `git@host:path` to `host/path` and strip any scheme.
let (host, path) = match url.split_once("://") {
Some((_scheme, rest)) => rest.split_once('/')?,
None => {
let (scp, path) = url.split_once(':')?;
let host = scp.strip_prefix("git@").unwrap_or(scp);
(host, path)
}
};
let host = host.to_ascii_lowercase();
// Drop the leading user part of ssh URLs (`git@github.com` handled
// above; `ssh://git@github.com/path` keeps `git@github.com` here).
let host = host.rsplit('@').next().unwrap_or(&host);
let mut segments = path
.trim_end_matches('/')
.trim_end_matches(".git")
.split('/');
let owner = segments.next()?;
let repo = segments.next()?;
if owner.is_empty() || repo.is_empty() {
return None;
}
match host {
"github.com" => Some(Forge::GitHub {
owner: owner.to_string(),
repo: repo.to_string(),
}),
"gitlab.com" => Some(Forge::GitLab {
owner: owner.to_string(),
repo: repo.to_string(),
}),
_ => None,
}
}
/// Release-tarball URLs of `tag`, best candidate first. GitHub prefers
/// the codeload direct link (no redirect) and falls back to the
/// `github.com` archive URL; GitLab has a single archive URL.
pub fn release_tarball_urls(&self, tag: &str) -> Vec<String> {
match self {
Forge::GitHub { owner, repo } => vec![
format!("https://codeload.github.com/{owner}/{repo}/tar.gz/refs/tags/{tag}"),
format!("https://github.com/{owner}/{repo}/archive/refs/tags/{tag}.tar.gz"),
],
Forge::GitLab { owner, repo } => {
vec![format!(
"https://gitlab.com/{owner}/{repo}/-/archive/{tag}/{repo}-{tag}.tar.gz"
)]
}
}
}
}
/// Best-effort snapshot of the git state of a source directory (see the
/// module docs). Every field degrades to its empty value when the
/// corresponding query fails.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GitOrigin {
/// Forge of the `origin` remote, when it is one of the recognized ones.
pub forge: Option<Forge>,
/// Tag exactly at HEAD, when there is one (name as written, e.g. `v1.2.3`).
pub head_tag: Option<String>,
/// Every tag of the repository, sorted lexically.
pub tags: Vec<String>,
/// Last tag reachable from HEAD (used by [`GitOrigin::git_version`]).
pub last_tag: Option<String>,
/// HEAD commit date as `%Y%m%d`.
pub head_date: Option<String>,
/// HEAD commit short hash.
pub head_hash: Option<String>,
/// Whether the worktree carries uncommitted changes (`git status
/// --porcelain` non-empty).
pub dirty: bool,
}
impl GitOrigin {
/// Detect the git origin state of `dir`. Returns `None` when `dir` is
/// not inside a git work tree (or git cannot be run); every other
/// failure is fail-soft (the field stays empty).
pub fn detect(dir: &Path) -> Option<GitOrigin> {
// Inside a work tree? (fails outside git; prints "false" in a bare
// repository).
if git(dir, &["rev-parse", "--is-inside-work-tree"]).as_deref() != Some("true") {
return None;
}
let mut origin = GitOrigin {
head_tag: git(dir, &["describe", "--tags", "--exact-match", "HEAD"]),
tags: git(dir, &["tag", "--list"])
.map(|out| {
out.lines()
.map(str::to_string)
.filter(|line| !line.is_empty())
.collect()
})
.unwrap_or_default(),
last_tag: git(dir, &["describe", "--tags", "--abbrev=0"]),
head_date: git(dir, &["log", "-1", "--date=format:%Y%m%d", "--format=%cd"]),
head_hash: git(dir, &["rev-parse", "--short", "HEAD"]),
dirty: git(dir, &["status", "--porcelain"]).is_some_and(|out| !out.trim().is_empty()),
forge: None,
};
if let Some(url) = git(dir, &["remote", "get-url", "origin"])
.or_else(|| git(dir, &["config", "--get", "remote.origin.url"]))
{
origin.forge = Forge::parse(&url);
}
Some(origin)
}
/// Upstream version named by the tag at HEAD: the tag with a leading
/// `v`/`V` (before a digit) stripped, and only when the result is a
/// plausible Debian upstream version (no `-`, which is the revision
/// separator).
pub fn head_tag_version(&self) -> Option<String> {
sanitized_tag_version(self.head_tag.as_deref()?)
}
/// Version suggestion for a HEAD between releases:
/// `<lasttag>+git<YYYYMMDD>.<shorthash>` (e.g. `1.2.3+git20260916.4b8a2f1`),
/// or `None` without a reachable tag, tag-strippable version, date or hash.
pub fn git_version(&self) -> Option<String> {
let base = sanitized_tag_version(self.last_tag.as_deref()?)?;
let date = self.head_date.as_deref()?;
let hash = self.head_hash.as_deref()?;
Some(format!("{base}+git{date}.{hash}"))
}
/// The repository tag naming `version` (a leading `v`/`V` on the tag is
/// ignored), whatever the state of HEAD.
pub fn tag_for_version(&self, version: &str) -> Option<&str> {
self.tags
.iter()
.map(String::as_str)
.find(|tag| sanitized_tag_version(tag).as_deref() == Some(version))
}
}
/// Strip a leading `v`/`V` (before a digit) off a tag name and keep only
/// results usable as a Debian upstream version: no `-` (which is the revision
/// separator) and no leading non-digit.
fn sanitized_tag_version(tag: &str) -> Option<String> {
let stripped = tag
.strip_prefix(['v', 'V'])
.filter(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
.unwrap_or(tag);
if stripped.starts_with(|c: char| c.is_ascii_digit()) && !stripped.contains('-') {
Some(stripped.to_string())
} else {
None
}
}
/// Check out `tag` in the repository at `dir`, refusing a dirty worktree:
/// pkh never carries uncommitted changes across a checkout. Detached HEAD
/// is the expected outcome when packaging a release tag.
pub fn checkout_tag(dir: &Path, tag: &str) -> Result<(), Box<dyn std::error::Error>> {
if let Some(status) = git(dir, &["status", "--porcelain"])
&& !status.trim().is_empty()
{
return Err(format!(
"The working tree of '{}' has uncommitted changes: commit or \
stash them before checking out '{tag}' (pkh does not carry \
changes across a checkout)",
dir.display()
)
.into());
}
let output = Command::new("git")
.args(["checkout", tag])
.current_dir(dir)
.output()
.map_err(|e| format!("failed to run 'git checkout': {e}"))?;
if !output.status.success() {
return Err(format!(
"'git checkout {tag}' failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
)
.into());
}
Ok(())
}
/// Run `git` with `args` in `dir`, returning its trimmed stdout when it
/// exits successfully (empty output stays an empty string).
fn git(dir: &Path, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.ok()?;
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
/// Whether the host has a usable git CLI (every fixture below needs it).
fn have_git() -> bool {
Command::new("git")
.arg("--version")
.output()
.is_ok_and(|output| output.status.success())
}
/// Run git in `dir`, failing the test on error, with a deterministic
/// identity and no signing so host git config cannot break the fixture.
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args([
"-c",
"user.name=Pkh Origin",
"-c",
"user.email=pkhorigin@example.invalid",
"-c",
"commit.gpgsign=false",
])
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_DATE", "2026-09-15T12:00:00Z")
.env("GIT_COMMITTER_DATE", "2026-09-15T12:00:00Z")
.status()
.expect("git should be runnable");
assert!(status.success(), "git {args:?} failed");
}
/// A repository with a single commit on 2026-09-15, returning its
/// short HEAD hash.
fn init_repo(dir: &Path) -> String {
git(dir, &["init", "-q"]);
std::fs::write(dir.join("file.txt"), "one\n").unwrap();
git(dir, &["add", "file.txt"]);
git(dir, &["commit", "-q", "-m", "first"]);
git_out(dir, &["rev-parse", "--short", "HEAD"])
}
#[test]
fn forge_parses_remote_url_shapes() {
assert_eq!(
Forge::parse("https://github.com/foo/bar.git"),
Some(Forge::GitHub {
owner: "foo".into(),
repo: "bar".into()
})
);
assert_eq!(
Forge::parse("git@github.com:foo/bar.git"),
Some(Forge::GitHub {
owner: "foo".into(),
repo: "bar".into()
})
);
assert_eq!(
Forge::parse("git://github.com/foo/bar"),
Some(Forge::GitHub {
owner: "foo".into(),
repo: "bar".into()
})
);
assert_eq!(
Forge::parse("https://gitlab.com/foo/bar/-/tree/main"),
Some(Forge::GitLab {
owner: "foo".into(),
repo: "bar".into()
})
);
assert_eq!(
Forge::parse("ssh://git@gitlab.com/foo/bar.git"),
Some(Forge::GitLab {
owner: "foo".into(),
repo: "bar".into()
})
);
// Self-hosted GitLab instances and other hosts are not recognized.
assert_eq!(Forge::parse("https://gitlab.example.com/foo/bar.git"), None);
assert_eq!(Forge::parse("https://bitbucket.org/foo/bar.git"), None);
// Garbage.
assert_eq!(Forge::parse("not a url"), None);
assert_eq!(Forge::parse("https://github.com/onlyowner"), None);
}
#[test]
fn forge_release_tarball_urls() {
let gh = Forge::GitHub {
owner: "foo".into(),
repo: "bar".into(),
};
assert_eq!(
gh.release_tarball_urls("v1.2.3"),
vec![
"https://codeload.github.com/foo/bar/tar.gz/refs/tags/v1.2.3".to_string(),
"https://github.com/foo/bar/archive/refs/tags/v1.2.3.tar.gz".to_string(),
]
);
let gl = Forge::GitLab {
owner: "foo".into(),
repo: "bar".into(),
};
assert_eq!(
gl.release_tarball_urls("v1.2.3"),
vec!["https://gitlab.com/foo/bar/-/archive/v1.2.3/bar-v1.2.3.tar.gz".to_string()]
);
}
#[test]
fn sanitized_tag_versions() {
assert_eq!(sanitized_tag_version("v1.2.3"), Some("1.2.3".to_string()));
assert_eq!(sanitized_tag_version("V2.0"), Some("2.0".to_string()));
assert_eq!(sanitized_tag_version("1.2.3"), Some("1.2.3".to_string()));
// `v` followed by a non-digit is part of the name, not a marker.
assert_eq!(sanitized_tag_version("version-1"), None);
// `-` would collide with the Debian revision separator.
assert_eq!(sanitized_tag_version("v1.2.3-beta"), None);
}
#[test]
fn detect_outside_a_repository_is_none() {
if !have_git() {
return;
}
let dir = tempdir().unwrap();
assert_eq!(GitOrigin::detect(dir.path()), None);
// A bare repository is not a work tree either.
let bare = tempdir().unwrap();
git(bare.path(), &["init", "-q", "--bare"]);
assert_eq!(GitOrigin::detect(bare.path()), None);
}
#[test]
fn detect_plain_repo_without_tags_or_remote() {
if !have_git() {
return;
}
let dir = tempdir().unwrap();
let hash = init_repo(dir.path());
let origin = GitOrigin::detect(dir.path()).expect("detected");
assert_eq!(origin.forge, None);
assert_eq!(origin.head_tag, None);
assert!(origin.tags.is_empty());
assert_eq!(origin.last_tag, None);
assert_eq!(origin.head_hash.as_deref(), Some(hash.as_str()));
assert_eq!(origin.head_date.as_deref(), Some("20260915"));
assert!(!origin.dirty);
// No tags: no version can be derived.
assert_eq!(origin.head_tag_version(), None);
assert_eq!(origin.git_version(), None);
}
#[test]
fn detect_head_exactly_on_a_tag() {
if !have_git() {
return;
}
let dir = tempdir().unwrap();
init_repo(dir.path());
git(dir.path(), &["tag", "v1.2.3"]);
let origin = GitOrigin::detect(dir.path()).expect("detected");
assert_eq!(origin.head_tag.as_deref(), Some("v1.2.3"));
assert_eq!(origin.last_tag.as_deref(), Some("v1.2.3"));
assert_eq!(origin.head_tag_version().as_deref(), Some("1.2.3"));
// The query is the stripped version; the raw tag name is not one.
assert_eq!(origin.tag_for_version("1.2.3"), Some("v1.2.3"));
assert_eq!(origin.tag_for_version("v1.2.3"), None);
assert_eq!(origin.tag_for_version("9.9.9"), None);
}
#[test]
fn detect_between_releases_derives_git_version() {
if !have_git() {
return;
}
let dir = tempdir().unwrap();
init_repo(dir.path());
git(dir.path(), &["tag", "v1.2.3"]);
std::fs::write(dir.path().join("file.txt"), "two\n").unwrap();
git(dir.path(), &["add", "file.txt"]);
git(dir.path(), &["commit", "-q", "-m", "second"]);
let hash = git_out(dir.path(), &["rev-parse", "--short", "HEAD"]);
let origin = GitOrigin::detect(dir.path()).expect("detected");
assert_eq!(origin.head_tag, None);
assert_eq!(origin.last_tag.as_deref(), Some("v1.2.3"));
assert_eq!(origin.tag_for_version("1.2.3"), Some("v1.2.3"));
assert_eq!(
origin.git_version().as_deref(),
Some(format!("1.2.3+git20260915.{hash}").as_str())
);
}
#[test]
fn detect_tracks_the_origin_remote() {
if !have_git() {
return;
}
let dir = tempdir().unwrap();
init_repo(dir.path());
// A local path remote: configuring it never touches the network.
git(
dir.path(),
&["remote", "add", "origin", "https://github.com/foo/bar.git"],
);
let origin = GitOrigin::detect(dir.path()).expect("detected");
assert_eq!(
origin.forge,
Some(Forge::GitHub {
owner: "foo".into(),
repo: "bar".into()
})
);
}
#[test]
fn detect_reports_a_dirty_worktree() {
if !have_git() {
return;
}
let dir = tempdir().unwrap();
init_repo(dir.path());
std::fs::write(dir.path().join("file.txt"), "uncommitted\n").unwrap();
let origin = GitOrigin::detect(dir.path()).expect("detected");
assert!(origin.dirty);
}
#[test]
fn git_version_skips_unusable_tags() {
// A tag carrying `-` cannot become an upstream version: the scheme
// degrades to None instead of proposing an invalid version.
let origin = GitOrigin {
last_tag: Some("1.2.3-beta".into()),
head_date: Some("20260915".into()),
head_hash: Some("abc1234".into()),
..Default::default()
};
assert_eq!(origin.git_version(), None);
// Date or hash missing: nothing to propose either.
assert_eq!(
GitOrigin {
last_tag: Some("v2.0".into()),
..Default::default()
}
.git_version(),
None
);
}
/// `git` output trimmed (for the hash assertions above).
fn git_out(dir: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.unwrap();
assert!(output.status.success(), "git {args:?} failed");
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
/// The tag lookup ignores a `v` prefix in either direction.
#[test]
fn tag_for_version_matches_both_spellings() {
let origin = GitOrigin {
tags: vec!["1.0".to_string(), "v2.0".to_string()],
..Default::default()
};
assert_eq!(origin.tag_for_version("1.0"), Some("1.0"));
assert_eq!(origin.tag_for_version("2.0"), Some("v2.0"));
assert_eq!(origin.tag_for_version("3.0"), None);
}
}
+221 -7
View File
@@ -23,7 +23,8 @@ use std::path::PathBuf;
use indicatif::MultiProgress; use indicatif::MultiProgress;
use crate::new::detect::{self, Detection}; use crate::new::detect::{self, Detection};
use crate::new::options::{self, NewCli, NewOptions, SourceDir, TemplateId}; use crate::new::options::{self, NewCli, NewOptions, SourceDir, SourceFormat, TemplateId};
use crate::new::origin::GitOrigin;
use crate::new::templates::{self, ProbeResult, ScaffoldOutcome}; use crate::new::templates::{self, ProbeResult, ScaffoldOutcome};
use crate::ui::prompt; use crate::ui::prompt;
@@ -60,15 +61,17 @@ const SOURCE_LABEL: &str = "Where is the source code? ";
const LICENSE_LABEL: &str = "License: "; const LICENSE_LABEL: &str = "License: ";
const DIST_LABEL: &str = "Target distribution: "; const DIST_LABEL: &str = "Target distribution: ";
const SERIES_LABEL: &str = "Target series: "; const SERIES_LABEL: &str = "Target series: ";
const ORIG_LABEL: &str = "Where should the orig tarball come from? ";
/// All select labels, so the separator test can check them in one place. /// All select labels, so the separator test can check them in one place.
#[cfg(test)] #[cfg(test)]
const SELECT_LABELS: [&str; 5] = [ const SELECT_LABELS: [&str; 6] = [
LANGUAGE_LABEL, LANGUAGE_LABEL,
SOURCE_LABEL, SOURCE_LABEL,
LICENSE_LABEL, LICENSE_LABEL,
DIST_LABEL, DIST_LABEL,
SERIES_LABEL, SERIES_LABEL,
ORIG_LABEL,
]; ];
/// Run the `pkh new` flow: the wizard on an interactive terminal, plain /// Run the `pkh new` flow: the wizard on an interactive terminal, plain
@@ -88,8 +91,10 @@ fn is_interactive() -> bool {
} }
/// The wizard question flow (spec "Proposed UX"), in order: /// The wizard question flow (spec "Proposed UX"), in order:
/// package name, language/build system, source location, upstream version, /// package name, language/build system, source location, upstream version
/// Debian revision, one-line description, homepage, license, command name, /// (with the checkout-tag offer when the version names an existing tag),
/// the orig-tarball origin (quilt + existing project only), Debian
/// revision, one-line description, homepage, license, command name,
/// maintainer, target distribution, target series, metapackage Depends /// maintainer, target distribution, target series, metapackage Depends
/// (`empty` template only), git init — then the summary screen and the /// (`empty` template only), git init — then the summary screen and the
/// final `Generate?` confirmation. Every question with an explicit flag /// final `Generate?` confirmation. Every question with an explicit flag
@@ -205,17 +210,101 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
cli.source = Some(cwd.clone()); cli.source = Some(cwd.clone());
} }
// 4. Upstream version. // The source format this run will produce: explicit flags win, then the
// mode (skeleton → native, existing project → quilt). The wizard
// surfaces it in the summary; `options::resolve` re-derives it the same
// way for the non-interactive path.
let source_format = match (cli.native, cli.quilt) {
(true, true) => {
return Err("--native and --quilt are mutually exclusive".into());
}
(true, false) => SourceFormat::Native,
(false, true) => SourceFormat::Quilt,
(false, false) => {
if cli.source.is_none() {
SourceFormat::Native
} else {
SourceFormat::Quilt
}
}
};
// Git origin of the packaged directory: drives the version default, the
// checkout-tag offer and the orig-tarball question. Purely local.
let packaged_dir = cli.source.clone();
let mut origin = packaged_dir.as_deref().and_then(GitOrigin::detect);
// 4. Upstream version: probed project version, then the tag HEAD sits
// on, then `<lasttag>+git<YYYYMMDD>.<hash>`, then 0.1.0.
if cli.upstream_version.is_none() { if cli.upstream_version.is_none() {
let default = probe let default = probe
.as_ref() .as_ref()
.and_then(|p| p.version.clone()) .and_then(|p| p.version.clone())
.or_else(|| origin.as_ref().and_then(GitOrigin::head_tag_version))
.or_else(|| origin.as_ref().and_then(GitOrigin::git_version))
.unwrap_or_else(|| "0.1.0".to_string()); .unwrap_or_else(|| "0.1.0".to_string());
let revision = cli.revision.unwrap_or(1); let revision = cli.revision.unwrap_or(1);
let answer = ask_text("Upstream version", &default, move |version: &str| { let answer = ask_text("Upstream version", &default, move |version: &str| {
options::validate_upstream_version(version, revision) options::validate_upstream_version(version, revision)
})?; })?;
cli.upstream_version = Some(answer); cli.upstream_version = Some(answer.clone());
// The typed version names an existing tag HEAD is not on: offer to
// check the release out (the packaging then matches the version).
if let Some(origin_state) = &origin
&& let Some(tag) = origin_state.tag_for_version(&answer)
&& Some(tag) != origin_state.head_tag.as_deref()
{
let question = format!(
"Version {answer} matches tag {tag}, but HEAD is not that \
tag. Check out {tag} now?"
);
if prompt::confirm(&question, false)? {
let dir = packaged_dir.as_deref().expect("origin implies a directory");
crate::new::origin::checkout_tag(dir, tag)?;
log::info!(
"Checked out {tag} (detached HEAD — expected when \
packaging a release)"
);
// HEAD moved: the release/git-archive choices below must
// reflect the checkout.
origin = GitOrigin::detect(dir);
}
// Declined (or non-TTY): keep the current tree and the typed
// version.
}
}
// 4b. Orig-tarball origin (quilt + existing project only): one select
// question, options pre-ordered by what the detection found. The
// release download is explicit network consent; a skeleton quilt
// tree has no upstream history and is only ever snapshotted.
if source_format == SourceFormat::Quilt && packaged_dir.is_some() {
let choices = orig_origin_choices(origin.as_ref());
let labels: Vec<String> = choices.iter().map(|(label, _)| label.clone()).collect();
let head_tagged = origin
.as_ref()
.and_then(|o| o.head_tag.as_deref())
.is_some();
let default = if head_tagged {
labels[0].clone()
} else {
"Snapshot this working tree".to_string()
};
let answer = select_from(ORIG_LABEL, &labels, &default, |answer| {
labels.contains(&answer.to_string())
})?;
let chosen = choices
.iter()
.find(|(label, _)| label == &answer)
.map(|(_, value)| *value)
.expect("answer comes from the choice list");
cli.orig_from = Some(chosen.to_string());
if chosen == "path" {
let validator = |path: &str| options::validate_orig_path(path);
let path = prompt::text("Tarball path or URL", "", Some(&validator))?;
cli.orig_path = Some(path);
}
} }
// 5. Debian revision. // 5. Debian revision.
@@ -706,6 +795,37 @@ fn validate_revision_answer(answer: &str) -> Result<(), String> {
} }
} }
/// The choices of the orig-tarball-origin question for a quilt packaging of
/// an existing directory: `(menu label, --orig-from value)` pairs,
/// pre-ordered by what the git origin detection found — the release
/// download and `git archive` only exist when HEAD sits exactly on a tag,
/// the release download additionally needs a recognized forge. The working
/// tree snapshot is always available (and is the implicit default when HEAD
/// is not on a tag).
fn orig_origin_choices(origin: Option<&GitOrigin>) -> Vec<(String, &'static str)> {
let mut choices: Vec<(String, &'static str)> = Vec::new();
if let Some(origin) = origin
&& let Some(tag) = &origin.head_tag
{
if let Some(forge) = &origin.forge {
choices.push((
format!(
"Download the upstream release tarball from {} ({tag})",
forge.host()
),
"release",
));
}
choices.push((
format!("Create it from the git tag ({tag}, git archive)"),
"git",
));
}
choices.push(("Use a tarball I provide".to_string(), "path"));
choices.push(("Snapshot this working tree".to_string(), "snapshot"));
choices
}
/// A `debian/watch` template for GitHub/GitLab-hosted projects; `None` when /// A `debian/watch` template for GitHub/GitLab-hosted projects; `None` when
/// the homepage is not one of those hosts (the wizard skips the question). /// the homepage is not one of those hosts (the wizard skips the question).
pub fn watch_template(homepage: Option<&str>) -> Option<String> { pub fn watch_template(homepage: Option<&str>) -> Option<String> {
@@ -784,6 +904,13 @@ pub fn summary_text(opts: &NewOptions, toolchain_pin: Option<&str>) -> String {
lines.push(format!(" debian/rules {}", template.rules_dh_line())); lines.push(format!(" debian/rules {}", template.rules_dh_line()));
} }
} }
lines.push(format!(
" debian/source/format {}",
opts.source_format.deb_string()
));
if let Some(orig) = &opts.orig {
lines.push(format!(" orig tarball {}", orig.label()));
}
let distribution = if opts.release { let distribution = if opts.release {
opts.series.as_str() opts.series.as_str()
} else { } else {
@@ -850,7 +977,8 @@ mod tests {
series: "resolute".into(), series: "resolute".into(),
release: false, release: false,
depends: Vec::new(), depends: Vec::new(),
native: false, source_format: options::SourceFormat::Native,
orig: None,
git: true, git: true,
autopkgtest: false, autopkgtest: false,
pkg_config: false, pkg_config: false,
@@ -945,6 +1073,92 @@ mod tests {
assert!(watch_template(None).is_none()); assert!(watch_template(None).is_none());
} }
/// The orig-origin choices are pre-ordered by what the detection found:
/// release download only with a forge, git archive only on a tag,
/// tarball/snapshot always; snapshot last (the off-tag default).
#[test]
fn orig_origin_choices_preorder_by_detection() {
use crate::new::origin::Forge;
let tagged_forge = GitOrigin {
forge: Some(Forge::GitHub {
owner: "foo".into(),
repo: "bar".into(),
}),
head_tag: Some("v1.4.0".into()),
..Default::default()
};
let choices = orig_origin_choices(Some(&tagged_forge));
assert_eq!(choices.len(), 4);
assert_eq!(choices[0].1, "release");
assert!(choices[0].0.contains("github.com"));
assert!(choices[0].0.contains("v1.4.0"));
assert_eq!(choices[1].1, "git");
assert!(choices[1].0.contains("git archive"));
assert_eq!(choices[2].1, "path");
assert_eq!(choices[3].1, "snapshot");
// Tag without a recognized forge: no download option.
let tagged = GitOrigin {
head_tag: Some("1.0.0".into()),
..Default::default()
};
let choices = orig_origin_choices(Some(&tagged));
assert_eq!(choices.len(), 3);
assert_eq!(choices[0].1, "git");
assert_eq!(choices[1].1, "path");
assert_eq!(choices[2].1, "snapshot");
// Off a tag (or not even a repo): tarball + snapshot only.
let off_tag = GitOrigin {
last_tag: Some("v1.0.0".into()),
..Default::default()
};
let choices = orig_origin_choices(Some(&off_tag));
assert_eq!(choices.len(), 2);
assert_eq!(choices[0].1, "path");
assert_eq!(choices[1].1, "snapshot");
assert_eq!(orig_origin_choices(None).len(), 2);
}
/// The summary surfaces the derived source format and, for quilt, the
/// planned orig origin.
#[test]
fn summary_screen_shows_format_and_orig_origin() {
// Native skeleton: the format row, no orig row.
let text = summary_text(&opts(Tid::Shell), None);
assert!(text.contains("debian/source/format 3.0 (native)"), "{text}");
assert!(!text.contains("orig tarball"), "{text}");
// Quilt over an existing project: both rows.
let mut quilt = opts(Tid::Shell);
quilt.source_dir = options::SourceDir::Here;
quilt.source_format = options::SourceFormat::Quilt;
quilt.orig = Some(options::OrigOrigin::GitArchive {
tag: "v1.4.0".to_string(),
});
let text = summary_text(&quilt, None);
assert!(text.contains("debian/source/format 3.0 (quilt)"), "{text}");
assert!(
text.contains("orig tarball git archive (v1.4.0)"),
"{text}"
);
// The release-download label of the origin matrix.
let mut release = quilt.clone();
release.orig = Some(options::OrigOrigin::Release {
tag: "v0.14.0".to_string(),
forge: crate::new::origin::Forge::GitLab {
owner: "foo".into(),
repo: "bar".into(),
},
});
let text = summary_text(&release, None);
assert!(
text.contains("orig tarball release download (v0.14.0)"),
"{text}"
);
}
#[test] #[test]
fn summary_screen_skeleton() { fn summary_screen_skeleton() {
let text = summary_text(&opts(Tid::Makefile), None); let text = summary_text(&opts(Tid::Makefile), None);
+2 -1
View File
@@ -129,7 +129,8 @@ mod tests {
series: "resolute".into(), series: "resolute".into(),
release: false, release: false,
depends: Vec::new(), depends: Vec::new(),
native: false, source_format: crate::new::options::SourceFormat::Quilt,
orig: Some(crate::new::options::OrigOrigin::Snapshot),
git: true, git: true,
autopkgtest: false, autopkgtest: false,
pkg_config: false, pkg_config: false,
+2 -1
View File
@@ -104,7 +104,8 @@ mod tests {
series: "resolute".into(), series: "resolute".into(),
release: false, release: false,
depends: Vec::new(), depends: Vec::new(),
native: false, source_format: crate::new::options::SourceFormat::Quilt,
orig: Some(crate::new::options::OrigOrigin::Snapshot),
git: true, git: true,
autopkgtest: false, autopkgtest: false,
pkg_config: false, pkg_config: false,
+2 -1
View File
@@ -60,7 +60,8 @@ mod tests {
series: "sid".into(), series: "sid".into(),
release: false, release: false,
depends, depends,
native: false, source_format: crate::new::options::SourceFormat::Quilt,
orig: Some(crate::new::options::OrigOrigin::Snapshot),
git: true, git: true,
autopkgtest: false, autopkgtest: false,
pkg_config: false, pkg_config: false,
+2 -1
View File
@@ -134,7 +134,8 @@ mod tests {
series: "resolute".into(), series: "resolute".into(),
release: false, release: false,
depends: Vec::new(), depends: Vec::new(),
native: false, source_format: crate::new::options::SourceFormat::Quilt,
orig: Some(crate::new::options::OrigOrigin::Snapshot),
git: true, git: true,
autopkgtest: false, autopkgtest: false,
pkg_config: false, pkg_config: false,
+2 -1
View File
@@ -139,7 +139,8 @@ mod tests {
series: "resolute".into(), series: "resolute".into(),
release: false, release: false,
depends: Vec::new(), depends: Vec::new(),
native: false, source_format: crate::new::options::SourceFormat::Quilt,
orig: Some(crate::new::options::OrigOrigin::Snapshot),
git: true, git: true,
autopkgtest: false, autopkgtest: false,
pkg_config: false, pkg_config: false,
+2 -1
View File
@@ -118,7 +118,8 @@ mod tests {
series: "resolute".into(), series: "resolute".into(),
release: false, release: false,
depends: Vec::new(), depends: Vec::new(),
native: false, source_format: crate::new::options::SourceFormat::Quilt,
orig: Some(crate::new::options::OrigOrigin::Snapshot),
git: true, git: true,
autopkgtest: false, autopkgtest: false,
pkg_config: false, pkg_config: false,
+9 -3
View File
@@ -54,12 +54,16 @@ impl OutputFile {
/// What the template post-write hook did to the freshly written tree, /// What the template post-write hook did to the freshly written tree,
/// threaded through [`super::scaffold`] so the flow can react (e.g. word /// threaded through [`super::scaffold`] so the flow can react (e.g. word
/// the post-scaffold verification offer differently when vendoring failed). /// the post-scaffold verification offer differently when vendoring failed).
#[derive(Debug, Clone, Copy, Default)] #[derive(Debug, Clone, Default)]
pub struct ScaffoldOutcome { pub struct ScaffoldOutcome {
/// The vendoring step did not complete (host `cargo` missing, `cargo /// The vendoring step did not complete (host `cargo` missing, `cargo
/// vendor` failed, or the offline config could not be written): the /// vendor` failed, or the offline config could not be written): the
/// package will not build until the dependencies are vendored manually. /// package will not build until the dependencies are vendored manually.
pub vendoring_failed: bool, pub vendoring_failed: bool,
/// How the orig tarball was actually created (quilt only; `None` with
/// the native format, which has no orig tarball). Filled in by the
/// scaffold flow, not by the template hook.
pub orig_origin: Option<String>,
} }
/// Metadata extracted from an existing project by [`Template::probe`], used /// Metadata extracted from an existing project by [`Template::probe`], used
@@ -266,7 +270,8 @@ mod tests {
series: "resolute".into(), series: "resolute".into(),
release: false, release: false,
depends: Vec::new(), depends: Vec::new(),
native: false, source_format: crate::new::options::SourceFormat::Quilt,
orig: None,
git: false, git: false,
autopkgtest: false, autopkgtest: false,
pkg_config: false, pkg_config: false,
@@ -335,7 +340,8 @@ mod tests {
series: "resolute".into(), series: "resolute".into(),
release: false, release: false,
depends: Vec::new(), depends: Vec::new(),
native: false, source_format: crate::new::options::SourceFormat::Quilt,
orig: None,
git: false, git: false,
autopkgtest: false, autopkgtest: false,
pkg_config: false, pkg_config: false,
+2 -1
View File
@@ -361,7 +361,8 @@ mod tests {
series: "resolute".into(), series: "resolute".into(),
release: false, release: false,
depends: Vec::new(), depends: Vec::new(),
native: false, source_format: crate::new::options::SourceFormat::Quilt,
orig: Some(crate::new::options::OrigOrigin::Snapshot),
git: true, git: true,
autopkgtest: false, autopkgtest: false,
pkg_config: false, pkg_config: false,
+10 -3
View File
@@ -161,7 +161,10 @@ impl Template for Rust {
// `--locked` now that the outcome is known, so the final rules use // `--locked` now that the outcome is known, so the final rules use
// it exactly when the lockfile exists. // it exactly when the lockfile exists.
patch_rules_locked(tree)?; patch_rules_locked(tree)?;
Ok(ScaffoldOutcome { vendoring_failed }) Ok(ScaffoldOutcome {
vendoring_failed,
orig_origin: None,
})
} }
} }
@@ -169,7 +172,10 @@ impl Template for Rust {
/// offline source replacement. Returns whether the step completed; failures /// offline source replacement. Returns whether the step completed; failures
/// warn loudly and leave the tree for manual vendoring. I/O errors on the /// warn loudly and leave the tree for manual vendoring. I/O errors on the
/// freshly written tree are the exception: they fail the scaffold. /// freshly written tree are the exception: they fail the scaffold.
fn vendor_dependencies(tree: &Path) -> Result<bool, Box<dyn std::error::Error>> { ///
/// `pub(crate)` because the `pkh build` re-vendor retry (see
/// [`crate::build`]'s `VendorDriftError` hook) reruns exactly this step.
pub(crate) fn vendor_dependencies(tree: &Path) -> Result<bool, Box<dyn std::error::Error>> {
let Some(cargo) = find_on_path("cargo") else { let Some(cargo) = find_on_path("cargo") else {
log::warn!( log::warn!(
"cargo was not found on PATH: the Rust package will NOT build \ "cargo was not found on PATH: the Rust package will NOT build \
@@ -489,7 +495,8 @@ mod tests {
series: "resolute".into(), series: "resolute".into(),
release: false, release: false,
depends: Vec::new(), depends: Vec::new(),
native: false, source_format: crate::new::options::SourceFormat::Quilt,
orig: Some(crate::new::options::OrigOrigin::Snapshot),
git: true, git: true,
autopkgtest: false, autopkgtest: false,
pkg_config: false, pkg_config: false,
+2 -1
View File
@@ -79,7 +79,8 @@ mod tests {
series: "resolute".into(), series: "resolute".into(),
release: false, release: false,
depends: Vec::new(), depends: Vec::new(),
native: false, source_format: crate::new::options::SourceFormat::Quilt,
orig: Some(crate::new::options::OrigOrigin::Snapshot),
git: true, git: true,
autopkgtest: false, autopkgtest: false,
pkg_config: false, pkg_config: false,
+73 -6
View File
@@ -71,9 +71,9 @@ pub fn verify(tree: &Path) -> Result<(), Box<dyn std::error::Error>> {
// Quilt packages need their orig tarball next to the tree. // Quilt packages need their orig tarball next to the tree.
if format == super::debian::SOURCE_FORMAT_QUILT { if format == super::debian::SOURCE_FORMAT_QUILT {
let uversion = parsed_version.upstream; let uversion = super::orig::component_upstream_version(&parsed_version);
let tarball = let tarball =
super::debian::orig_tarball_path(tree, &source, &uversion).ok_or_else(|| { super::debian::orig_tarball_path(tree, &source, uversion).ok_or_else(|| {
format!( format!(
"cannot determine the parent directory of '{}'", "cannot determine the parent directory of '{}'",
tree.display() tree.display()
@@ -87,6 +87,29 @@ pub fn verify(tree: &Path) -> Result<(), Box<dyn std::error::Error>> {
) )
.into()); .into());
} }
// Vendored rust: the orig-vendor component is required exactly when
// the main orig exists and the tree carries a generated, non-empty
// `vendor/` directory (native packages have no tarballs at all, and
// trees without vendoring must not demand the component either).
if super::orig::has_vendored_dir(tree) {
let component = super::orig::vendor_component_path(tree, &source, uversion)
.ok_or_else(|| {
format!(
"cannot determine the parent directory of '{}'",
tree.display()
)
})?;
if !component.exists() {
return Err(format!(
"Quilt package with vendored dependencies but without \
the orig-vendor component: '{}' is missing. Re-run \
pkh new, or recreate it after re-vendoring.",
component.display()
)
.into());
}
}
} }
Ok(()) Ok(())
@@ -95,7 +118,9 @@ pub fn verify(tree: &Path) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::new::options::{License, NewOptions, SourceDir, TemplateId}; use crate::new::options::{
License, NewOptions, OrigOrigin, SourceDir, SourceFormat, TemplateId,
};
use tempfile::tempdir; use tempfile::tempdir;
fn opts() -> NewOptions { fn opts() -> NewOptions {
@@ -115,7 +140,8 @@ mod tests {
series: "sid".into(), series: "sid".into(),
release: false, release: false,
depends: Vec::new(), depends: Vec::new(),
native: false, source_format: SourceFormat::Quilt,
orig: Some(OrigOrigin::Snapshot),
git: false, git: false,
autopkgtest: false, autopkgtest: false,
pkg_config: false, pkg_config: false,
@@ -134,7 +160,7 @@ mod tests {
files.extend(template.skeleton(opts)); files.extend(template.skeleton(opts));
files.extend(template.debian(opts)); files.extend(template.debian(opts));
crate::new::debian::write_files(&tree, &files).unwrap(); crate::new::debian::write_files(&tree, &files).unwrap();
if !opts.native { if opts.source_format == SourceFormat::Quilt {
crate::new::debian::create_orig_tarball(&tree, &opts.name, &opts.upstream_version) crate::new::debian::create_orig_tarball(&tree, &opts.name, &opts.upstream_version)
.unwrap(); .unwrap();
} }
@@ -154,13 +180,54 @@ mod tests {
let tree = scaffold_tree( let tree = scaffold_tree(
dir.path(), dir.path(),
&NewOptions { &NewOptions {
native: true, source_format: SourceFormat::Native,
orig: None,
..opts() ..opts()
}, },
); );
verify(&tree).unwrap(); verify(&tree).unwrap();
} }
/// The orig-vendor component is demanded exactly when the tree carries
/// a non-empty vendor/ next to a quilt orig — and not otherwise.
#[test]
fn verify_demands_the_vendor_component_only_when_vendored() {
// Quilt tree with a populated vendor/: verify fails until the
// component exists.
let dir = tempdir().unwrap();
let tree = scaffold_tree(dir.path(), &opts());
std::fs::create_dir_all(tree.join("vendor/serde/src")).unwrap();
std::fs::write(tree.join("vendor/serde/src/lib.rs"), "code").unwrap();
let err = verify(&tree).unwrap_err().to_string();
assert!(err.contains("orig-vendor"), "{err}");
// Creating the component fixes it. (The main orig was snapshotted
// before vendor/ existed, so it holds no vendor/ — dpkg-wise the
// component alone owns the vendored tree.)
crate::new::orig::create_vendor_component(&tree, "mytool", "0.1.0").unwrap();
verify(&tree).unwrap();
// A quilt tree without vendor/ must not demand the component.
let dir = tempdir().unwrap();
let tree = scaffold_tree(dir.path(), &opts());
verify(&tree).unwrap();
assert!(!dir.path().join("mytool_0.1.0.orig-vendor.tar.xz").exists());
// A native tree with vendor/ has no tarballs at all: no demand.
let dir = tempdir().unwrap();
let tree = scaffold_tree(
dir.path(),
&NewOptions {
source_format: SourceFormat::Native,
orig: None,
..opts()
},
);
std::fs::create_dir_all(tree.join("vendor/serde/src")).unwrap();
std::fs::write(tree.join("vendor/serde/src/lib.rs"), "code").unwrap();
verify(&tree).unwrap();
}
#[test] #[test]
fn verify_names_the_broken_file() { fn verify_names_the_broken_file() {
// Each case needs its own tempdir: scaffolding refuses to overwrite // Each case needs its own tempdir: scaffolding refuses to overwrite