build: live view for source builds, pin LC_ALL for the pipeline
Route 'pkh build' through the DebUi capture machinery 'pkh deb' already uses instead of letting dpkg-source inherit the terminal: - pin LANG=C and LC_ALL=C so dpkg-source emits deterministic English diagnostics regardless of the session locale; - new DpkgSourceClassifier rewrites info:/warning:/error: lines into colored pane entries, telling benign tar warnings from failures; - DebUi generalizes for reuse (arbitrary phase labels, build-specific log naming); run_source_build() drives phases and pipes subprocess output through the sink when a UI is present; - glyph-free house-style summaries: 'Built in Ns:' plus artifact paths relative to cwd; failures print captured errors + log path; - drop/capitalize pipeline chatter, add 'pkh build --verbose' to bypass the view like 'pkh deb --verbose'.
This commit is contained in:
+10
-2
@@ -12,20 +12,26 @@ pub fn num_parallel() -> usize {
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// Compute the environment variables exported by `dpkg-buildpackage` before
|
||||
/// running any build step.
|
||||
/// Compute the environment variables exported before running any build step.
|
||||
///
|
||||
/// Mirrors dpkg behavior:
|
||||
/// - `SOURCE_DATE_EPOCH` from the changelog entry timestamp
|
||||
/// (<https://reproducible-builds.org/specs/source-date-epoch/>),
|
||||
/// - `DEB_BUILD_OPTIONS=parallel=N` (auto-detected job count),
|
||||
/// - `DEB_BUILD_PROFILES` when non-default profiles are requested.
|
||||
///
|
||||
/// The locale is pinned to `C` (`LC_ALL`, which takes precedence over any
|
||||
/// inherited session setting, plus `LANG`) so build tools emit deterministic,
|
||||
/// English diagnostics — required for reliable log classification and
|
||||
/// reproducible builds.
|
||||
pub fn build_env(
|
||||
source_date_epoch: i64,
|
||||
parallel: usize,
|
||||
build_profiles: &[String],
|
||||
) -> BTreeMap<String, String> {
|
||||
let mut env = BTreeMap::new();
|
||||
env.insert("LANG".to_string(), "C".to_string());
|
||||
env.insert("LC_ALL".to_string(), "C".to_string());
|
||||
env.insert(
|
||||
"SOURCE_DATE_EPOCH".to_string(),
|
||||
source_date_epoch.to_string(),
|
||||
@@ -257,6 +263,8 @@ mod tests {
|
||||
#[test]
|
||||
fn build_env_values() {
|
||||
let env = build_env(1787392800, 16, &[]);
|
||||
assert_eq!(env.get("LANG").unwrap(), "C");
|
||||
assert_eq!(env.get("LC_ALL").unwrap(), "C");
|
||||
assert_eq!(env.get("SOURCE_DATE_EPOCH").unwrap(), "1787392800");
|
||||
assert_eq!(env.get("DEB_BUILD_OPTIONS").unwrap(), "parallel=16");
|
||||
assert!(!env.contains_key("DEB_BUILD_PROFILES"));
|
||||
|
||||
+133
-22
@@ -15,11 +15,16 @@ pub mod env;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::error::Error;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::context::capture::pump;
|
||||
use crate::context::{LineSink, Stream};
|
||||
use crate::debian::{
|
||||
ChecksumEntry, ControlInfo, FileChecksums, FilesEntry, FilesList, parse_paragraphs,
|
||||
};
|
||||
use crate::ui::deb::DebUi;
|
||||
use crate::ui::logfmt::{DpkgSourceClassifier, GenericClassifier};
|
||||
|
||||
/// Options for a native source-package build.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -52,11 +57,46 @@ pub struct SourceBuildOutput {
|
||||
|
||||
/// Build a Debian source package (to a .dsc) using the native pipeline.
|
||||
///
|
||||
/// Keeps the historical pkh entry-point signature; see [`run_source_build`]
|
||||
/// for the configurable version.
|
||||
pub fn build_source_package(cwd: Option<&Path>) -> Result<(), Box<dyn Error>> {
|
||||
/// When `ui` is set, subprocess output is captured into a live view (status
|
||||
/// 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
|
||||
/// terminal as before.
|
||||
pub fn build_source_package(
|
||||
cwd: Option<&Path>,
|
||||
ui: Option<Arc<DebUi>>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let cwd = cwd.unwrap_or_else(|| Path::new("."));
|
||||
let output = run_source_build(cwd, &SourceBuildOptions::default())?;
|
||||
let output = match run_source_build(cwd, &SourceBuildOptions::default(), ui.clone()) {
|
||||
Ok(output) => output,
|
||||
Err(e) => {
|
||||
if let Some(u) = &ui {
|
||||
u.finish_failure();
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Artifact listing in dpkg order: dsc → tarballs → buildinfo → changes.
|
||||
let mut artifacts = Vec::with_capacity(3 + output.tarballs.len());
|
||||
artifacts.push(output.dsc.clone());
|
||||
artifacts.extend(output.tarballs.iter().cloned());
|
||||
artifacts.push(output.buildinfo.clone());
|
||||
artifacts.push(output.changes.clone());
|
||||
|
||||
// The live view lists the artifacts itself when it renders; otherwise
|
||||
// (verbose mode or non-TTY stdout) print them as plain lines.
|
||||
let listed = match &ui {
|
||||
Some(u) if u.is_enabled() => {
|
||||
u.finish_success(&artifacts, u.elapsed());
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !listed {
|
||||
for artifact in &artifacts {
|
||||
println!(" {}", crate::ui::display_path(artifact));
|
||||
}
|
||||
}
|
||||
|
||||
if output.signed {
|
||||
println!("Package built and signed successfully!");
|
||||
@@ -81,7 +121,9 @@ pub fn build_source_package(cwd: Option<&Path>) -> Result<(), Box<dyn Error>> {
|
||||
pub fn run_source_build(
|
||||
cwd: &Path,
|
||||
opts: &SourceBuildOptions,
|
||||
ui: Option<Arc<DebUi>>,
|
||||
) -> Result<SourceBuildOutput, Box<dyn Error>> {
|
||||
let sink: Option<Arc<dyn LineSink>> = ui.as_ref().map(|u| u.sink());
|
||||
// ------------------------------------------------------------------
|
||||
// 1. Sanity checks
|
||||
// ------------------------------------------------------------------
|
||||
@@ -116,9 +158,9 @@ pub fn run_source_build(
|
||||
let entry = crate::debian::parse_changelog_entry(&changelog_path)?;
|
||||
let ctrl = ControlInfo::parse(&control_path)?;
|
||||
|
||||
log::info!("source package {}", entry.source);
|
||||
log::info!("source version {}", entry.version.full());
|
||||
log::info!("source distribution {}", entry.distribution);
|
||||
if let Some(u) = &ui {
|
||||
u.set_build_target(&entry.source, &entry.version.full(), &entry.distribution);
|
||||
}
|
||||
|
||||
// binNMU builds reference the *previous* (source) version in their
|
||||
// artifact metadata, like dpkg-genchanges/genbuildinfo do.
|
||||
@@ -167,19 +209,19 @@ pub fn run_source_build(
|
||||
if signing_key.is_none() {
|
||||
match crate::utils::gpg::find_signing_key_for_email(&entry.maintainer_email) {
|
||||
Ok(Some(key)) => {
|
||||
log::info!("using GPG key {} for signing", key);
|
||||
log::info!("Using GPG key {} for signing", key);
|
||||
signing_key = Some(key);
|
||||
}
|
||||
Ok(None) => {
|
||||
log::warn!(
|
||||
"no GPG secret key found for {} <{}>, building without signing",
|
||||
"No GPG secret key found for {} <{}>, building without signing",
|
||||
entry.maintainer_name,
|
||||
entry.maintainer_email
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"failed to check for GPG key: {}, building without signing",
|
||||
"Failed to check for GPG key: {}, building without signing",
|
||||
e
|
||||
);
|
||||
}
|
||||
@@ -188,7 +230,7 @@ pub fn run_source_build(
|
||||
let do_sign = match &signing_key {
|
||||
None => false,
|
||||
Some(_) if entry.distribution == "UNRELEASED" && !opts.force_sign => {
|
||||
log::warn!("not signing UNRELEASED build; use force_sign to override");
|
||||
log::warn!("Not signing UNRELEASED build; use force_sign to override");
|
||||
false
|
||||
}
|
||||
Some(_) => true,
|
||||
@@ -197,17 +239,24 @@ pub fn run_source_build(
|
||||
// ------------------------------------------------------------------
|
||||
// 5. dpkg-source lifecycle: before-build + source build
|
||||
// ------------------------------------------------------------------
|
||||
if let Some(u) = &ui {
|
||||
u.phase_custom("Applying patches", Box::new(DpkgSourceClassifier::new()));
|
||||
}
|
||||
run_command(
|
||||
cwd,
|
||||
"dpkg-source",
|
||||
&["-I", "-i", "--before-build", "."],
|
||||
&pipeline_env,
|
||||
sink.as_ref(),
|
||||
)?;
|
||||
|
||||
// Build-dependency check (native dpkg-checkbuilddeps equivalent).
|
||||
// dpkg-buildpackage skips it entirely for source-only builds unless
|
||||
// forced with -D; unsatisfied dependencies abort with exit status 3.
|
||||
if opts.force_dep_check {
|
||||
if let Some(u) = &ui {
|
||||
u.progress_message("Checking build dependencies");
|
||||
}
|
||||
let check_opts = crate::debian::deps::CheckOpts {
|
||||
host_arch: arch_vars
|
||||
.get("DEB_HOST_ARCH")
|
||||
@@ -225,7 +274,19 @@ pub fn run_source_build(
|
||||
}
|
||||
}
|
||||
|
||||
run_command(cwd, "dpkg-source", &["-I", "-i", "-b", "."], &pipeline_env)?;
|
||||
if let Some(u) = &ui {
|
||||
u.phase_custom(
|
||||
"Building source package",
|
||||
Box::new(DpkgSourceClassifier::new()),
|
||||
);
|
||||
}
|
||||
run_command(
|
||||
cwd,
|
||||
"dpkg-source",
|
||||
&["-I", "-i", "-b", "."],
|
||||
&pipeline_env,
|
||||
sink.as_ref(),
|
||||
)?;
|
||||
|
||||
if !dsc_path.exists() {
|
||||
return Err(format!(
|
||||
@@ -259,6 +320,9 @@ pub fn run_source_build(
|
||||
// ------------------------------------------------------------------
|
||||
// 6. .buildinfo generation (native dpkg-genbuildinfo equivalent)
|
||||
// ------------------------------------------------------------------
|
||||
if let Some(u) = &ui {
|
||||
u.progress_message("Generating .buildinfo");
|
||||
}
|
||||
let mut checksums = FileChecksums::new();
|
||||
checksums.add_file_as(&ref_dsc_path, &ref_dsc_name)?;
|
||||
|
||||
@@ -304,6 +368,9 @@ pub fn run_source_build(
|
||||
// ------------------------------------------------------------------
|
||||
// 7. .changes generation (native dpkg-genchanges equivalent)
|
||||
// ------------------------------------------------------------------
|
||||
if let Some(u) = &ui {
|
||||
u.progress_message("Generating .changes");
|
||||
}
|
||||
// Pull the tarball checksums out of the referenced .dsc so they are
|
||||
// distributed through the .changes like dpkg-genchanges does, in the
|
||||
// order the .dsc itself lists them.
|
||||
@@ -416,11 +483,15 @@ pub fn run_source_build(
|
||||
// ------------------------------------------------------------------
|
||||
// 8. dpkg-source after-build (unapplies quilt patches it applied)
|
||||
// ------------------------------------------------------------------
|
||||
if let Some(u) = &ui {
|
||||
u.phase_custom("Restoring patches", Box::new(DpkgSourceClassifier::new()));
|
||||
}
|
||||
run_command(
|
||||
cwd,
|
||||
"dpkg-source",
|
||||
&["-I", "-i", "--after-build", "."],
|
||||
&pipeline_env,
|
||||
sink.as_ref(),
|
||||
)?;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -430,7 +501,11 @@ pub fn run_source_build(
|
||||
if let Some(keyid) = signing_key.filter(|_| do_sign) {
|
||||
crate::utils::gpg::validate_key_id(&keyid)?;
|
||||
|
||||
println!("signfile {}", dsc_name);
|
||||
if let Some(u) = &ui {
|
||||
u.phase_custom("Signing artifacts", Box::new(GenericClassifier::new()));
|
||||
}
|
||||
|
||||
log::info!("Signing {}", dsc_name);
|
||||
crate::utils::gpg::clearsign_file(&dsc_path, &keyid)?;
|
||||
// The freshly built .dsc changed: refresh its checksums inside the
|
||||
// .buildinfo. For binary-only builds the metadata references the
|
||||
@@ -441,13 +516,13 @@ pub fn run_source_build(
|
||||
}
|
||||
buildinfo::save_buildinfo(&buildinfo_path, &render_buildinfo_doc(&checksums))?;
|
||||
|
||||
println!("signfile {}", buildinfo_name);
|
||||
log::info!("Signing {}", buildinfo_name);
|
||||
crate::utils::gpg::clearsign_file(&buildinfo_path, &keyid)?;
|
||||
// Both .dsc and .buildinfo changed: refresh the .changes.
|
||||
checksums.add_file_as(&buildinfo_path, &buildinfo_name)?;
|
||||
changes::save_changes(&changes_path, &render_changes_doc(&checksums))?;
|
||||
|
||||
println!("signfile {}", changes_name);
|
||||
log::info!("Signing {}", changes_name);
|
||||
crate::utils::gpg::clearsign_file(&changes_path, &keyid)?;
|
||||
|
||||
signed = true;
|
||||
@@ -472,13 +547,17 @@ struct PartialChecksum {
|
||||
}
|
||||
|
||||
/// Run a build command in `cwd` with extra environment variables layered on
|
||||
/// top of the inherited environment, with stdio attached to the terminal.
|
||||
/// top of the inherited environment.
|
||||
///
|
||||
/// 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.
|
||||
/// Returns an error on non-zero exit status.
|
||||
fn run_command(
|
||||
cwd: &Path,
|
||||
program: &str,
|
||||
args: &[&str],
|
||||
env: &BTreeMap<String, String>,
|
||||
sink: Option<&Arc<dyn LineSink>>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
log::debug!(
|
||||
"running: {} {} (in {})",
|
||||
@@ -486,12 +565,44 @@ fn run_command(
|
||||
args.join(" "),
|
||||
cwd.display()
|
||||
);
|
||||
let status = Command::new(program)
|
||||
.current_dir(cwd)
|
||||
.envs(env)
|
||||
.args(args)
|
||||
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.current_dir(cwd).envs(env).args(args);
|
||||
|
||||
let status = match sink {
|
||||
None => cmd
|
||||
.status()
|
||||
.map_err(|e| format!("failed to run '{}': {}", program, e))?,
|
||||
Some(sink) => {
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("failed to run '{}': {}", program, e))?;
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
// One reader thread per stream; interleaving across streams is
|
||||
// approximate (channel arrival order), acceptable for display.
|
||||
let out_sink = sink.clone();
|
||||
let err_sink = sink.clone();
|
||||
let out_thread = std::thread::spawn(move || {
|
||||
if let Some(out) = stdout {
|
||||
pump(out, Stream::Stdout, &*out_sink);
|
||||
}
|
||||
});
|
||||
let err_thread = std::thread::spawn(move || {
|
||||
if let Some(err) = stderr {
|
||||
pump(err, Stream::Stderr, &*err_sink);
|
||||
}
|
||||
});
|
||||
let _ = out_thread.join();
|
||||
let _ = err_thread.join();
|
||||
|
||||
child
|
||||
.wait()
|
||||
.map_err(|e| format!("failed to wait for '{}': {}", program, e))?
|
||||
}
|
||||
};
|
||||
|
||||
if !status.success() {
|
||||
return Err(format!(
|
||||
@@ -852,7 +963,7 @@ mod differential_tests {
|
||||
let ours_tree = ours_root.join(&tree_name);
|
||||
|
||||
run_dpkg(&golden_tree);
|
||||
run_source_build(&ours_tree, &SourceBuildOptions::default())
|
||||
run_source_build(&ours_tree, &SourceBuildOptions::default(), None)
|
||||
.expect("native source pipeline failed");
|
||||
|
||||
let entry =
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
mod api;
|
||||
mod capture;
|
||||
pub(crate) mod capture;
|
||||
mod local;
|
||||
mod manager;
|
||||
mod schroot;
|
||||
|
||||
+20
-3
@@ -58,7 +58,11 @@ fn main() {
|
||||
.arg(arg!(--backport "This changelog is for a backport entry").required(false))
|
||||
.arg(arg!(-v --version <version> "Target version").required(false)),
|
||||
)
|
||||
.subcommand(Command::new("build").about("Build the source package (into a .dsc)"))
|
||||
.subcommand(
|
||||
Command::new("build")
|
||||
.about("Build the source package (into a .dsc)")
|
||||
.arg(arg!(--verbose "Show raw tool output instead of the live build view").required(false)),
|
||||
)
|
||||
.subcommand(
|
||||
Command::new("deb")
|
||||
.about("Build the source package into binary package (.deb)")
|
||||
@@ -248,9 +252,22 @@ fn main() {
|
||||
std::process::exit(1);
|
||||
});
|
||||
}
|
||||
Some(("build", _sub_matches)) => {
|
||||
Some(("build", sub_matches)) => {
|
||||
let cwd = current_dir_or_exit();
|
||||
if let Err(e) = pkh::build::build_source_package(Some(&cwd)) {
|
||||
let verbose = sub_matches
|
||||
.get_one::<bool>("verbose")
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
|
||||
// Live build view: disabled by --verbose or when stdout is not a
|
||||
// terminal (DebUi handles the non-TTY case itself)
|
||||
let ui = if verbose {
|
||||
None
|
||||
} else {
|
||||
Some(std::sync::Arc::new(pkh::ui::deb::DebUi::new(&multi)))
|
||||
};
|
||||
|
||||
if let Err(e) = pkh::build::build_source_package(Some(&cwd), ui) {
|
||||
error!("{}", e);
|
||||
// Unmet build dependencies/conflicts exit with status 3,
|
||||
// like dpkg-buildpackage does.
|
||||
|
||||
@@ -13,8 +13,27 @@ use crossterm::{
|
||||
};
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Render a path for terminal display: relative to the current working
|
||||
/// directory when the target lives inside it or directly next to it
|
||||
/// (`../name`, the usual layout of build artifacts), absolute otherwise.
|
||||
pub fn display_path(path: &Path) -> String {
|
||||
let Ok(cwd) = std::env::current_dir() else {
|
||||
return path.display().to_string();
|
||||
};
|
||||
if let Ok(rel) = path.strip_prefix(&cwd) {
|
||||
return rel.display().to_string();
|
||||
}
|
||||
if let Some(parent) = cwd.parent()
|
||||
&& let Ok(rel) = path.strip_prefix(parent)
|
||||
{
|
||||
return format!("../{}", rel.display());
|
||||
}
|
||||
path.display().to_string()
|
||||
}
|
||||
|
||||
/// Create a spinner-style progress bar attached to `multi`, returning the bar
|
||||
/// and a callback compatible with [`crate::ProgressCallback`]
|
||||
pub fn create_progress_bar(
|
||||
|
||||
+45
-20
@@ -1,6 +1,6 @@
|
||||
//! Live UI for `pkh deb`: a status bar with the current build phase on top
|
||||
//! and a rolling pane of rewritten log lines below ("a terminal in the
|
||||
//! terminal").
|
||||
//! Live build view (`pkh deb`, `pkh build`): a status bar with the current
|
||||
//! build phase on top and a rolling pane of rewritten log lines below
|
||||
//! ("a terminal in the terminal").
|
||||
//!
|
||||
//! Subprocess output is captured through a [`LineSink`] implementation,
|
||||
//! rewritten by classifiers ([`crate::ui::logfmt`]) and rendered in place
|
||||
@@ -127,7 +127,7 @@ struct Shared {
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
/// Live build view for `pkh deb`
|
||||
/// Live build view for `pkh deb` / `pkh build`
|
||||
///
|
||||
/// Create one per build (disabled automatically when stdout is not a TTY or
|
||||
/// when the user requests verbose output), pass it down as
|
||||
@@ -202,20 +202,35 @@ impl DebUi {
|
||||
ui
|
||||
}
|
||||
|
||||
/// Identify the package being built; names the log file and the status bar
|
||||
/// Identify the binary package being built; names the log file and the
|
||||
/// status bar
|
||||
pub fn set_target(&self, package: &str, version: &str, series: &str, arch: &str) {
|
||||
if self.shared.enabled {
|
||||
self.shared.top.set_prefix(format!(
|
||||
"Building {package} ({version}) for {series}/{arch}"
|
||||
));
|
||||
}
|
||||
self.open_log("deb", package, version, &format!("for {series}/{arch}"));
|
||||
}
|
||||
|
||||
// Rename the log file to include the package identity (best-effort),
|
||||
// then open it so subsequent captured lines are tee'd.
|
||||
/// Identify the source package being built; names the log file
|
||||
/// (`build-<package>-<version>-<timestamp>.log`) and the status bar
|
||||
pub fn set_build_target(&self, package: &str, version: &str, distribution: &str) {
|
||||
if self.shared.enabled {
|
||||
self.shared.top.set_prefix(format!(
|
||||
"Building source package {package} ({version}) for {distribution}"
|
||||
));
|
||||
}
|
||||
self.open_log("build", package, version, &format!("for {distribution}"));
|
||||
}
|
||||
|
||||
/// Rename the placeholder log file to include the build identity
|
||||
/// (best-effort), then open it so subsequent captured lines are tee'd
|
||||
fn open_log(&self, kind: &str, package: &str, version: &str, detail: &str) {
|
||||
let old_path = self.shared.log_path.lock().unwrap().clone();
|
||||
let log_path = match old_path.parent() {
|
||||
Some(dir) => dir.join(format!(
|
||||
"deb-{package}-{version}-{}.log",
|
||||
"{kind}-{package}-{version}-{}.log",
|
||||
self.shared.timestamp
|
||||
)),
|
||||
None => old_path.clone(),
|
||||
@@ -231,11 +246,7 @@ impl DebUi {
|
||||
Ok(mut file) => {
|
||||
let _ = writeln!(
|
||||
file,
|
||||
"# pkh deb {} ({}) for {}/{} started {}",
|
||||
package,
|
||||
version,
|
||||
series,
|
||||
arch,
|
||||
"# pkh {kind} {package} ({version}) {detail} started {}",
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
);
|
||||
*self.shared.tee.lock().unwrap() = Some(file);
|
||||
@@ -252,12 +263,18 @@ impl DebUi {
|
||||
|
||||
/// Switch to a phase, installing its default classifier
|
||||
pub fn phase(&self, phase: Phase) {
|
||||
self.phase_with(phase, default_classifier(phase));
|
||||
self.phase_custom(phase.label(), default_classifier(phase));
|
||||
}
|
||||
|
||||
/// Switch to a phase with a custom classifier (e.g. quilt with a known
|
||||
/// patch count)
|
||||
pub fn phase_with(&self, phase: Phase, classifier: Box<dyn Classifier>) {
|
||||
self.phase_custom(phase.label(), classifier);
|
||||
}
|
||||
|
||||
/// Switch to an arbitrary status label with a custom classifier; used by
|
||||
/// flows whose phases are not part of [`Phase`] (e.g. source builds)
|
||||
pub fn phase_custom(&self, label: &str, classifier: Box<dyn Classifier>) {
|
||||
{
|
||||
let mut st = self.shared.state.lock().unwrap();
|
||||
st.classifier = classifier;
|
||||
@@ -267,7 +284,7 @@ impl DebUi {
|
||||
}
|
||||
if self.shared.enabled {
|
||||
self.shared.top.set_style(spinner_style());
|
||||
self.shared.top.set_message(phase.label());
|
||||
self.shared.top.set_message(label.to_string());
|
||||
self.shared.pane.set_message("");
|
||||
}
|
||||
}
|
||||
@@ -299,6 +316,12 @@ impl DebUi {
|
||||
self.shared.enabled && !self.shared.suspended.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Whether the widget renders at all (false on non-TTY stdout); callers
|
||||
/// use this to fall back to plain-line summaries
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.shared.enabled
|
||||
}
|
||||
|
||||
/// Obtain a sink feeding this view; pass it to `ContextCommand::capture`
|
||||
pub fn sink(self: &Arc<Self>) -> Arc<dyn LineSink> {
|
||||
Arc::new(Sink {
|
||||
@@ -325,14 +348,15 @@ impl DebUi {
|
||||
self.shared.pane.finish_and_clear();
|
||||
}
|
||||
|
||||
/// Clear the widget and print a success summary with the artifacts
|
||||
/// Clear the widget and print a success summary with the artifacts,
|
||||
/// rendered relative to the working directory when possible
|
||||
pub fn finish_success(&self, artifacts: &[PathBuf], elapsed: Duration) {
|
||||
self.suspend();
|
||||
if self.shared.enabled && !artifacts.is_empty() {
|
||||
println!("Built in {}s:", elapsed.as_secs());
|
||||
for artifact in artifacts {
|
||||
println!(" → {}", artifact.display());
|
||||
println!(" {}", crate::ui::display_path(artifact));
|
||||
}
|
||||
println!(" ✔ Built in {}s", elapsed.as_secs());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,12 +518,13 @@ fn is_stdout_tty() -> bool {
|
||||
unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 }
|
||||
}
|
||||
|
||||
/// Default log file path for a given timestamp
|
||||
/// Default (placeholder) log file path for a given timestamp; renamed by
|
||||
/// [`DebUi::set_target`] / [`DebUi::set_build_target`] once the target is known
|
||||
fn default_log_path(timestamp: &str) -> PathBuf {
|
||||
let dir = ProjectDirs::from("com", "pkh", "pkh")
|
||||
.map(|dirs| dirs.cache_dir().join("logs"))
|
||||
.unwrap_or_else(std::env::temp_dir);
|
||||
dir.join(format!("deb-{timestamp}.log"))
|
||||
dir.join(format!("pkh-{timestamp}.log"))
|
||||
}
|
||||
|
||||
static SIGINT_LOG_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
|
||||
|
||||
@@ -282,6 +282,54 @@ impl Classifier for MakeClassifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifier for `dpkg-source` output (source-build phases)
|
||||
///
|
||||
/// The build pipeline pins `LC_ALL=C`, so dpkg-source emits stable English
|
||||
/// messages prefixed with `info:` / `warning:` / `error:`; the prefix is
|
||||
/// stripped and the severity drives the pane color. Raw `tar:` diagnostics
|
||||
/// emitted while repacking tarballs are surfaced too.
|
||||
#[derive(Default)]
|
||||
pub struct DpkgSourceClassifier {}
|
||||
|
||||
impl DpkgSourceClassifier {
|
||||
/// Create a new classifier
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Classifier for DpkgSourceClassifier {
|
||||
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
|
||||
const PREFIX: &str = "dpkg-source: ";
|
||||
let rest = line.strip_prefix(PREFIX).unwrap_or(line);
|
||||
|
||||
if let Some(rest) = rest.strip_prefix("info: ") {
|
||||
vec![Action::Shown(truncate(rest))]
|
||||
} else if let Some(rest) = rest.strip_prefix("warning: ") {
|
||||
vec![Action::Warning(truncate(rest))]
|
||||
} else if let Some(rest) = rest.strip_prefix("error: ") {
|
||||
vec![Action::Error(truncate(rest))]
|
||||
} else if let Some(tar) = rest.strip_prefix("tar: ") {
|
||||
// Diagnostics from the tarball repacking subprocess; warnings
|
||||
// about unknown header keywords are benign, real failures are not.
|
||||
let lower = tar.to_lowercase();
|
||||
if ["error", "cannot", "failed", "exited"]
|
||||
.iter()
|
||||
.any(|m| lower.contains(m))
|
||||
{
|
||||
vec![Action::Error(truncate(tar))]
|
||||
} else {
|
||||
vec![Action::Warning(truncate(tar))]
|
||||
}
|
||||
} else if line == PREFIX.trim_end() || rest.is_empty() {
|
||||
vec![Action::Hidden]
|
||||
} else {
|
||||
// Unprefixed output from a foreign subprocess: keep it visible
|
||||
vec![Action::Shown(truncate(line))]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifier for `mmdebstrap` output (chroot tarball creation)
|
||||
///
|
||||
/// mmdebstrap prefixes its own messages with `I:` / `W:` / `E:`; everything
|
||||
@@ -499,6 +547,88 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dpkg_source_severity_prefixes() {
|
||||
let mut c = DpkgSourceClassifier::new();
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"dpkg-source: info: using patch list from debian/patches/series"
|
||||
),
|
||||
vec![Action::Shown(
|
||||
"using patch list from debian/patches/series".to_string()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"dpkg-source: info: applying patch debian/patches/reproducible.patch"
|
||||
),
|
||||
vec![Action::Shown(
|
||||
"applying patch debian/patches/reproducible.patch".to_string()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"dpkg-source: info: building hello in ../hello_2.10-5.dsc"
|
||||
),
|
||||
vec![Action::Shown(
|
||||
"building hello in ../hello_2.10-5.dsc".to_string()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"dpkg-source: warning: upstream signing key but no upstream signature"
|
||||
),
|
||||
vec![Action::Warning(
|
||||
"upstream signing key but no upstream signature".to_string()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"dpkg-source: error: unrepresentable changes to source"
|
||||
),
|
||||
vec![Action::Error(
|
||||
"unrepresentable changes to source".to_string()
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dpkg_source_tar_and_unknown_lines() {
|
||||
let mut c = DpkgSourceClassifier::new();
|
||||
// Benign tar header-keyword warnings stay yellow
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"tar: Ignoring unknown extended header keyword 'SCHILY.xattr.user.foo'"
|
||||
),
|
||||
vec![Action::Warning(
|
||||
"Ignoring unknown extended header keyword 'SCHILY.xattr.user.foo'".to_string()
|
||||
)]
|
||||
);
|
||||
// Real tar failures are errors
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"tar: ../hello_2.10.orig.tar.xz: Cannot open: No such file or directory"
|
||||
),
|
||||
vec![Action::Error(
|
||||
"../hello_2.10.orig.tar.xz: Cannot open: No such file or directory".to_string()
|
||||
)]
|
||||
);
|
||||
// Unprefixed foreign output stays visible
|
||||
assert_eq!(
|
||||
feed_one(&mut c, "gpgv: Signature made Tue 01 Jan 2026"),
|
||||
vec![Action::Shown(
|
||||
"gpgv: Signature made Tue 01 Jan 2026".to_string()
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_long_lines() {
|
||||
let long = "x".repeat(300);
|
||||
|
||||
Reference in New Issue
Block a user