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
+221 -7
View File
@@ -23,7 +23,8 @@ use std::path::PathBuf;
use indicatif::MultiProgress;
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::ui::prompt;
@@ -60,15 +61,17 @@ const SOURCE_LABEL: &str = "Where is the source code? ";
const LICENSE_LABEL: &str = "License: ";
const DIST_LABEL: &str = "Target distribution: ";
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.
#[cfg(test)]
const SELECT_LABELS: [&str; 5] = [
const SELECT_LABELS: [&str; 6] = [
LANGUAGE_LABEL,
SOURCE_LABEL,
LICENSE_LABEL,
DIST_LABEL,
SERIES_LABEL,
ORIG_LABEL,
];
/// 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:
/// package name, language/build system, source location, upstream version,
/// Debian revision, one-line description, homepage, license, command name,
/// package name, language/build system, source location, upstream version
/// (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
/// (`empty` template only), git init — then the summary screen and the
/// 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());
}
// 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() {
let default = probe
.as_ref()
.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());
let revision = cli.revision.unwrap_or(1);
let answer = ask_text("Upstream version", &default, move |version: &str| {
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.
@@ -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
/// the homepage is not one of those hosts (the wizard skips the question).
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/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 {
opts.series.as_str()
} else {
@@ -850,7 +977,8 @@ mod tests {
series: "resolute".into(),
release: false,
depends: Vec::new(),
native: false,
source_format: options::SourceFormat::Native,
orig: None,
git: true,
autopkgtest: false,
pkg_config: false,
@@ -945,6 +1073,92 @@ mod tests {
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]
fn summary_screen_skeleton() {
let text = summary_text(&opts(Tid::Makefile), None);