diff --git a/src/build/env.rs b/src/build/env.rs
index 24ef5cd..e1cd431 100644
--- a/src/build/env.rs
+++ b/src/build/env.rs
@@ -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
/// (),
/// - `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 {
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"));
diff --git a/src/build/mod.rs b/src/build/mod.rs
index 574ceb2..66857ca 100644
--- a/src/build/mod.rs
+++ b/src/build/mod.rs
@@ -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> {
+/// 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>,
+) -> Result<(), Box> {
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> {
pub fn run_source_build(
cwd: &Path,
opts: &SourceBuildOptions,
+ ui: Option>,
) -> Result> {
+ let sink: Option> = 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,
+ sink: Option<&Arc>,
) -> Result<(), Box> {
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)
- .status()
- .map_err(|e| format!("failed to run '{}': {}", program, e))?;
+
+ 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 =
diff --git a/src/context/mod.rs b/src/context/mod.rs
index 77643ab..862c217 100644
--- a/src/context/mod.rs
+++ b/src/context/mod.rs
@@ -1,5 +1,5 @@
mod api;
-mod capture;
+pub(crate) mod capture;
mod local;
mod manager;
mod schroot;
diff --git a/src/main.rs b/src/main.rs
index 9fc07a7..8c8b682 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -58,7 +58,11 @@ fn main() {
.arg(arg!(--backport "This changelog is for a backport entry").required(false))
.arg(arg!(-v --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::("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.
diff --git a/src/ui.rs b/src/ui.rs
index 71af7d2..85eca22 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -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(
diff --git a/src/ui/deb.rs b/src/ui/deb.rs
index 334d406..265c88f 100644
--- a/src/ui/deb.rs
+++ b/src/ui/deb.rs
@@ -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---.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) {
+ 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) {
{
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) -> Arc {
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