From e57730bb38f85d83cb69d0b0b757a8b764ee2855 Mon Sep 17 00:00:00 2001 From: Bram Date: Fri, 24 Jul 2026 15:44:12 +0200 Subject: [PATCH] Format command output for terminal workflows --- USAGE.md | 14 ++ src/commands.rs | 380 ++++++++++++++++++++++++++---------------------- src/ui.rs | 321 ++++++++++++++++++++++++++++++++++++++-- tests/status.rs | 1 + 4 files changed, 531 insertions(+), 185 deletions(-) diff --git a/USAGE.md b/USAGE.md index 0804743..8dca6a6 100644 --- a/USAGE.md +++ b/USAGE.md @@ -25,6 +25,20 @@ If installed via Homebrew, the Git subcommand entrypoint also works: git stck ``` +## Output Behavior + +When attached to a terminal, `stck` uses concise progress indicators for +long-running Git and GitHub operations. Durable `stck`, `git`, and `gh` lines +have distinct labels, and successful subprocess chatter is hidden. + +If a subprocess fails, its captured stdout and stderr are printed in a labeled +diagnostic block. Git recovery instructions remain visible below that block. + +Redirected output and CI logs never contain spinners or ANSI escape sequences. +The established line-oriented command, error, and `Summary:` formats remain +stable for grep-based workflows. Set `NO_COLOR` to retain the interactive +layout without color. + ## Typical Flow ### 1. Inspect current state diff --git a/src/commands.rs b/src/commands.rs index 1eb3c80..fd3629c 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -153,15 +153,15 @@ fn analyze_open_parent_ancestry( /// Print the detected stack, its PR state, and any local follow-up actions. pub(crate) fn run_status(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitCode { if preflight.current_branch == preflight.default_branch { - println!( + ui.info(&format!( "On default branch ({}). Run `stck new ` to start a new stack.", preflight.default_branch - ); + )); return ExitCode::SUCCESS; } if let Err(message) = ui.run_step("Fetching origin", gitops::fetch_origin) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } @@ -170,7 +170,7 @@ pub(crate) fn run_status(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitC }) { Ok(stack) => stack, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -178,7 +178,7 @@ pub(crate) fn run_status(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitC let cached_plan = match sync_state::load_last_sync_plan() { Ok(plan) => plan, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -190,7 +190,7 @@ pub(crate) fn run_status(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitC ) { Ok(result) => result, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -202,7 +202,7 @@ pub(crate) fn run_status(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitC ) { Ok(analysis) => analysis, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -226,7 +226,7 @@ pub(crate) fn run_status(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitC let needs_push = match gitops::branch_needs_push(&line.branch) { Ok(needs_push) => needs_push, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -243,28 +243,23 @@ pub(crate) fn run_status(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitC .collect::>() .join(" <- "); - println!("Stack: {} <- {}", preflight.default_branch, branch_chain); + ui.status_header(&preflight.default_branch, &branch_chain); for line in report.lines { - let marker = if line.branch == preflight.current_branch { - "* " - } else { - " " - }; - let flags = if line.flags.is_empty() { - String::new() - } else { - format!(" [{}]", line.flags.join(", ")) - }; - println!( - "{}{} PR #{} {} base={}{}", - marker, line.branch, line.number, line.state, line.base, flags + ui.status_branch( + line.branch == preflight.current_branch, + &line.branch, + line.number, + &line.state.to_string(), + &line.base, + &line.flags, ); } - println!( - "Summary: {} needs_sync, {} needs_push, {} base_mismatch", - report.summary.needs_sync, report.summary.needs_push, report.summary.base_mismatch + ui.status_summary( + report.summary.needs_sync, + report.summary.needs_push, + report.summary.base_mismatch, ); ExitCode::SUCCESS @@ -283,13 +278,13 @@ pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str, ui: u match gitops::is_valid_branch_name(new_branch) { Ok(true) => {} Ok(false) => { - eprintln!( - "error: `{new_branch}` is not a valid branch name; use only alphanumeric characters, hyphens, underscores, and slashes" - ); + ui.error(&format!( + "`{new_branch}` is not a valid branch name; use only alphanumeric characters, hyphens, underscores, and slashes" + )); return ExitCode::from(1); } Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } } @@ -297,24 +292,28 @@ pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str, ui: u let local_exists = match gitops::local_branch_exists(new_branch) { Ok(exists) => exists, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; if local_exists { - eprintln!("error: branch {new_branch} already exists locally; choose a different name"); + ui.error(&format!( + "branch {new_branch} already exists locally; choose a different name" + )); return ExitCode::from(1); } let remote_exists = match gitops::remote_branch_exists(new_branch) { Ok(exists) => exists, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; if remote_exists { - eprintln!("error: branch {new_branch} already exists on origin; choose a different name"); + ui.error(&format!( + "branch {new_branch} already exists on origin; choose a different name" + )); return ExitCode::from(1); } @@ -322,7 +321,7 @@ pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str, ui: u let has_upstream = match gitops::branch_has_upstream(current_branch) { Ok(has_upstream) => has_upstream, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -332,14 +331,14 @@ pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str, ui: u if let Err(message) = ui.run_step(format!("Publishing {current_branch}"), || { gitops::push_set_upstream(current_branch) }) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } } else { let needs_push = match gitops::branch_needs_push(current_branch) { Ok(needs_push) => needs_push, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -348,7 +347,7 @@ pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str, ui: u if let Err(message) = ui.run_step(format!("Publishing {current_branch}"), || { gitops::push_branch(current_branch) }) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } } @@ -359,7 +358,7 @@ pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str, ui: u }) { Ok(exists) => exists, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -371,7 +370,7 @@ pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str, ui: u }) { Ok(base) => base.unwrap_or_else(|| preflight.default_branch.clone()), Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -385,7 +384,7 @@ pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str, ui: u if let Err(message) = ui.run_step(format!("Creating PR for {current_branch}"), || { github::create_pr(&bootstrap_base, current_branch, current_branch, &body) }) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } } @@ -393,7 +392,7 @@ pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str, ui: u ui.command("git", &format!("checkout -b {new_branch}")); if let Err(message) = gitops::checkout_new_branch(new_branch) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } @@ -401,22 +400,22 @@ pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str, ui: u if let Err(message) = ui.run_step(format!("Publishing {new_branch}"), || { gitops::push_set_upstream(new_branch) }) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } let has_commits = match gitops::has_commits_between(current_branch, new_branch) { Ok(has_commits) => has_commits, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; if !has_commits { - println!( + ui.info(&format!( "No branch-only commits in {} yet. Add commits, then run: stck submit --base {}", new_branch, pr_base_branch - ); + )); return ExitCode::SUCCESS; } @@ -430,14 +429,14 @@ pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str, ui: u if let Err(message) = ui.run_step(format!("Creating PR for {new_branch}"), || { github::create_pr(pr_base_branch, new_branch, new_branch, &body) }) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } - println!( + ui.success(&format!( "Created branch {} and opened a stacked PR targeting {}.", new_branch, pr_base_branch - ); + )); ExitCode::SUCCESS } @@ -497,17 +496,17 @@ pub(crate) fn run_submit( ) -> ExitCode { let current_branch = &preflight.current_branch; if current_branch == &preflight.default_branch { - eprintln!( - "error: cannot submit PR for default branch {}; checkout a feature branch and retry", + ui.error(&format!( + "cannot submit PR for default branch {}; checkout a feature branch and retry", preflight.default_branch - ); + )); return ExitCode::from(1); } let has_upstream = match gitops::branch_has_upstream(current_branch) { Ok(has_upstream) => has_upstream, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -516,14 +515,14 @@ pub(crate) fn run_submit( if let Err(message) = ui.run_step(format!("Publishing {current_branch}"), || { gitops::push_set_upstream(current_branch) }) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } } else { let needs_push = match gitops::branch_needs_push(current_branch) { Ok(needs_push) => needs_push, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -532,7 +531,7 @@ pub(crate) fn run_submit( if let Err(message) = ui.run_step(format!("Publishing {current_branch}"), || { gitops::push_branch(current_branch) }) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } } @@ -543,12 +542,12 @@ pub(crate) fn run_submit( }) { Ok(exists) => exists, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; if current_has_pr { - println!("Branch {} already has an open PR.", current_branch); + ui.info(&format!("Branch {current_branch} already has an open PR.")); return ExitCode::SUCCESS; } @@ -562,20 +561,20 @@ pub(crate) fn run_submit( }) { Ok(base) => base.unwrap_or_else(|| preflight.default_branch.clone()), Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; if discovered_base == preflight.default_branch { - println!( + ui.info(&format!( "No --base provided. Defaulting PR base to {}.", preflight.default_branch - ); + )); } else { - println!( + ui.info(&format!( "No --base provided. Detected stack parent: {}.", discovered_base - ); + )); } &discovered_base }; @@ -590,11 +589,13 @@ pub(crate) fn run_submit( if let Err(message) = ui.run_step(format!("Creating PR for {current_branch}"), || { github::create_pr(base, current_branch, current_branch, &body) }) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } - println!("Created PR for {} targeting {}.", current_branch, base); + ui.success(&format!( + "Created PR for {current_branch} targeting {base}." + )); ExitCode::SUCCESS } @@ -671,7 +672,7 @@ pub(crate) fn run_sync( let mut existing_state = match sync_state::load_sync() { Ok(state) => state, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -679,12 +680,12 @@ pub(crate) fn run_sync( if reset_sync { if existing_state.is_some() { if let Err(message) = sync_state::clear() { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } - println!("Cleared previous sync state. Recomputing from scratch."); + ui.info("Cleared previous sync state. Recomputing from scratch."); } else { - println!("No existing sync state found. Computing sync plan from scratch."); + ui.info("No existing sync state found. Computing sync plan from scratch."); } existing_state = None; } @@ -692,7 +693,7 @@ pub(crate) fn run_sync( let mut state = match existing_state { Some(state) => { if !continue_sync { - println!( + ui.info( "Resuming previous sync operation from saved state. Use `stck sync --reset` to discard saved state and recompute." ); } @@ -700,26 +701,26 @@ pub(crate) fn run_sync( } None => { if continue_sync { - eprintln!("error: no sync state found; run `stck sync` to compute a new plan"); + ui.error("no sync state found; run `stck sync` to compute a new plan"); return ExitCode::from(1); } let rebase_in_progress = match gitops::rebase_in_progress() { Ok(in_progress) => in_progress, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; if rebase_in_progress { - eprintln!( - "error: rebase is already in progress; run `git rebase --continue` or `git rebase --abort` before starting a new `stck sync`" + ui.error( + "rebase is already in progress; run `git rebase --continue` or `git rebase --abort` before starting a new `stck sync`" ); return ExitCode::from(1); } if let Err(message) = ui.run_step("Fetching origin", gitops::fetch_origin) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } @@ -728,14 +729,14 @@ pub(crate) fn run_sync( }) { Ok(stack) => stack, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; let cached_plan = match sync_state::load_last_sync_plan() { Ok(plan) => plan, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -747,7 +748,7 @@ pub(crate) fn run_sync( ) { Ok(result) => result, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -758,7 +759,7 @@ pub(crate) fn run_sync( sync_state::clear_last_sync_plan() }; if let Err(message) = update_result { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } } @@ -770,7 +771,7 @@ pub(crate) fn run_sync( ) { Ok(analysis) => analysis, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -784,11 +785,11 @@ pub(crate) fn run_sync( if steps.is_empty() { if valid_cached_plan.is_none() { if let Err(message) = sync_state::clear_last_sync_plan() { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } } - println!("Stack is already up to date. No sync needed."); + ui.success("Stack is already up to date. No sync needed."); return ExitCode::SUCCESS; } @@ -808,7 +809,7 @@ pub(crate) fn run_sync( let new_push_leases = match capture_remote_branch_leases(&lease_branches) { Ok(push_leases) => push_leases, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -835,7 +836,7 @@ pub(crate) fn run_sync( prior_retargets, }; if let Err(message) = sync_state::save_sync(&state) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } state @@ -846,13 +847,13 @@ pub(crate) fn run_sync( let rebase_in_progress = match gitops::rebase_in_progress() { Ok(in_progress) => in_progress, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; if rebase_in_progress { - eprintln!("error: rebase is still in progress; run `git rebase --continue` (or `git rebase --abort`) before rerunning `stck sync`"); + ui.error("rebase is still in progress; run `git rebase --continue` (or `git rebase --abort`) before rerunning `stck sync`"); return ExitCode::from(1); } @@ -862,18 +863,20 @@ pub(crate) fn run_sync( let current_head = match gitops::resolve_ref(&branch_ref) { Ok(sha) => sha, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; let Some(failed_head) = state.failed_step_branch_head.as_deref() else { - eprintln!("error: sync state is missing failed-step branch head; rerun `stck sync` to retry"); + ui.error( + "sync state is missing failed-step branch head; rerun `stck sync` to retry", + ); return ExitCode::from(1); }; if current_head == failed_head { - eprintln!("error: no completed rebase detected for {}; resolve with `git rebase --continue` (or rerun `stck sync` to retry the step)", step.branch); + ui.error(&format!("no completed rebase detected for {}; resolve with `git rebase --continue` (or rerun `stck sync` to retry the step)", step.branch)); return ExitCode::from(1); } @@ -882,16 +885,16 @@ pub(crate) fn run_sync( } } else { let step = &state.steps[failed_step]; - eprintln!( - "error: sync stopped at failed step for {}; run `stck sync --continue` after completing the rebase, or `stck sync --reset` to discard saved state and recompute", + ui.error(&format!( + "sync stopped at failed step for {}; run `stck sync --continue` after completing the rebase, or `stck sync --reset` to discard saved state and recompute", step.branch - ); + )); return ExitCode::from(1); } state.failed_step = None; state.failed_step_branch_head = None; if let Err(message) = sync_state::save_sync(&state) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } } @@ -915,7 +918,7 @@ pub(crate) fn run_sync( let branch_head = match gitops::resolve_ref(&branch_ref) { Ok(sha) => sha, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -926,7 +929,7 @@ pub(crate) fn run_sync( ) { Ok(sha) => sha, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -938,31 +941,44 @@ pub(crate) fn run_sync( match gitops::resolve_onto_ref(&step.new_base_ref) { Ok(r) => r, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } } }; let total_steps = state.steps.len(); - if step.old_base_ref == step.new_base_ref { - println!( - "Step {}/{}: rebasing {} onto {} (dropping already-upstream commits)", - index + 1, - total_steps, - step.branch, - step.new_base_ref - ); + let (plain_step, interactive_step) = if step.old_base_ref == step.new_base_ref { + ( + format!( + "Step {}/{}: rebasing {} onto {} (dropping already-upstream commits)", + index + 1, + total_steps, + step.branch, + step.new_base_ref + ), + format!( + "Rebase {} → {} (drop upstream commits)", + step.branch, step.new_base_ref + ), + ) } else { - println!( - "Step {}/{}: rebasing {} onto {} (from {})", - index + 1, - total_steps, - step.branch, - step.new_base_ref, - step.old_base_ref - ); - } + ( + format!( + "Step {}/{}: rebasing {} onto {} (from {})", + index + 1, + total_steps, + step.branch, + step.new_base_ref, + step.old_base_ref + ), + format!( + "Rebase {} {} → {}", + step.branch, step.old_base_ref, step.new_base_ref + ), + ) + }; + ui.operation(index + 1, total_steps, &plain_step, &interactive_step); ui.command( "git", &format!( @@ -976,16 +992,16 @@ pub(crate) fn run_sync( state.failed_step = Some(index); state.failed_step_branch_head = Some(branch_head); if let Err(save_error) = sync_state::save_sync(&state) { - eprintln!("error: {save_error}"); + ui.error(&save_error); return ExitCode::from(1); } - eprintln!("error: {message}"); - eprintln!(); - eprintln!("To recover:"); - eprintln!(" 1. Resolve conflicts and run `git rebase --continue`"); - eprintln!(" Then run `stck sync --continue` to resume."); - eprintln!( - " 2. Or run `git rebase --abort` and then `stck sync --reset` to start over." + ui.error(&message); + ui.diagnostic_separator(); + ui.diagnostic_heading("To recover:"); + ui.diagnostic(" 1. Resolve conflicts and run `git rebase --continue`"); + ui.diagnostic(" Then run `stck sync --continue` to resume."); + ui.diagnostic( + " 2. Or run `git rebase --abort` and then `stck sync --reset` to start over.", ); return ExitCode::from(1); } @@ -995,7 +1011,7 @@ pub(crate) fn run_sync( state.failed_step = None; state.failed_step_branch_head = None; if let Err(message) = sync_state::save_sync(&state) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } } @@ -1003,10 +1019,10 @@ pub(crate) fn run_sync( ui.command("git", &format!("checkout {original_branch}")); if let Err(message) = gitops::checkout_branch(&original_branch) { if let Err(clear_error) = sync_state::clear() { - eprintln!("error: {clear_error}"); + ui.error(&clear_error); return ExitCode::from(1); } - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } @@ -1014,7 +1030,7 @@ pub(crate) fn run_sync( let local_heads = match capture_local_branch_heads(scope.stack()) { Ok(local_heads) => local_heads, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -1039,22 +1055,22 @@ pub(crate) fn run_sync( retargets, }; if let Err(message) = sync_state::save_last_sync_plan(&last_plan) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } } else if let Err(message) = sync_state::clear_last_sync_plan() { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } if let Err(message) = sync_state::clear() { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } if state.steps.is_empty() { - println!("Stack is already up to date. No sync needed."); + ui.success("Stack is already up to date. No sync needed."); } else { - println!("Sync succeeded locally. Run `stck push` to update remotes + PR bases."); + ui.success("Sync succeeded locally. Run `stck push` to update remotes + PR bases."); } ExitCode::SUCCESS } @@ -1163,7 +1179,7 @@ fn first_push_parent_drift( /// Push rewritten stack branches and retarget any affected pull requests. pub(crate) fn run_push(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitCode { if let Err(message) = ui.run_step("Fetching origin", gitops::fetch_origin) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } @@ -1172,7 +1188,7 @@ pub(crate) fn run_push(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitCod }) { Ok(stack) => stack, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -1180,7 +1196,7 @@ pub(crate) fn run_push(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitCod let existing_state = match sync_state::load_push() { Ok(state) => state, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -1195,7 +1211,7 @@ pub(crate) fn run_push(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitCod state.completed_retargets = 0; if let Err(message) = sync_state::save_push(&state) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } } @@ -1206,7 +1222,7 @@ pub(crate) fn run_push(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitCod let cached_plan = match sync_state::load_last_sync_plan() { Ok(plan) => plan, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -1216,7 +1232,7 @@ pub(crate) fn run_push(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitCod (plan.retargets, push_leases) } else { if let Err(message) = sync_state::clear_last_sync_plan() { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } ( @@ -1236,7 +1252,7 @@ pub(crate) fn run_push(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitCod let needs_push = match gitops::branch_needs_push(&branch) { Ok(needs_push) => needs_push, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; @@ -1254,7 +1270,7 @@ pub(crate) fn run_push(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitCod completed_retargets: 0, }; if let Err(message) = sync_state::save_push(&state) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } state @@ -1263,26 +1279,26 @@ pub(crate) fn run_push(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitCod let parent_drift = match first_push_parent_drift(&stack, &preflight.default_branch, &state) { Ok(drift) => drift, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; if let Some(drift) = parent_drift { if state.completed_pushes == 0 { if let Err(message) = sync_state::clear() { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } - eprintln!( - "error: cannot push because branch `{}` does not contain its intended parent `{}`; run `stck sync` before pushing", + ui.error(&format!( + "cannot push because branch `{}` does not contain its intended parent `{}`; run `stck sync` before pushing", drift.child, drift.parent - ); + )); } else { - eprintln!( - "error: cannot resume push because branch `{}` does not contain the current remote parent `origin/{}`; \ + ui.error(&format!( + "cannot resume push because branch `{}` does not contain the current remote parent `origin/{}`; \ align the child with that parent, then rerun `stck push`", drift.child, drift.parent - ); + )); } return ExitCode::from(1); } @@ -1296,23 +1312,25 @@ pub(crate) fn run_push(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitCod let local_head = match gitops::resolve_ref(&local_ref) { Ok(head) => head, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; let remote_head = match gitops::remote_branch_head(&branch) { Ok(head) => head, Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } }; if remote_head.as_deref() == Some(local_head.as_str()) { - println!("Branch {branch} already matches origin; skipping."); + ui.info(&format!( + "Branch {branch} already matches origin; skipping." + )); state.completed_pushes = index + 1; if let Err(message) = sync_state::save_push(&state) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } continue; @@ -1327,18 +1345,18 @@ pub(crate) fn run_push(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitCod Some(expected_remote_head) => { if remote_head != expected_remote_head { if let Err(clear_error) = sync_state::clear() { - eprintln!("error: {clear_error}"); + ui.error(&clear_error); return ExitCode::from(1); } if let Err(clear_error) = sync_state::clear_last_sync_plan() { - eprintln!("error: {clear_error}"); + ui.error(&clear_error); return ExitCode::from(1); } let expected = expected_remote_head.as_deref().unwrap_or("missing"); let found = remote_head.as_deref().unwrap_or("missing"); - eprintln!( - "error: remote branch `origin/{branch}` changed since sync; expected {expected}, found {found}; integrate the remote changes locally, then rerun `stck sync` before pushing" - ); + ui.error(&format!( + "remote branch `origin/{branch}` changed since sync; expected {expected}, found {found}; integrate the remote changes locally, then rerun `stck sync` before pushing" + )); return ExitCode::from(1); } expected_remote_head @@ -1349,17 +1367,17 @@ pub(crate) fn run_push(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitCod Ok(true) => {} Ok(false) => { if let Err(save_error) = sync_state::save_push(&state) { - eprintln!("error: {save_error}"); + ui.error(&save_error); return ExitCode::from(1); } - eprintln!( - "error: remote branch `origin/{branch}` has commits not in local `{branch}`; \ + ui.error(&format!( + "remote branch `origin/{branch}` has commits not in local `{branch}`; \ pull or rebase to integrate remote changes before pushing" - ); + )); return ExitCode::from(1); } Err(message) => { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } } @@ -1368,12 +1386,18 @@ pub(crate) fn run_push(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitCod } }; - println!( + let plain_step = format!( "Pushing branch {}/{}: {}", index + 1, state.push_branches.len(), branch ); + ui.operation( + index + 1, + state.push_branches.len(), + &plain_step, + &format!("Push {branch} → origin"), + ); let lease = format!( "--force-with-lease=refs/heads/{branch}:{}", expected_remote_head.as_deref().unwrap_or_default() @@ -1383,32 +1407,38 @@ pub(crate) fn run_push(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitCod gitops::push_force_with_lease(&branch, expected_remote_head.as_deref()) }) { if let Err(save_error) = sync_state::save_push(&state) { - eprintln!("error: {save_error}"); + ui.error(&save_error); return ExitCode::from(1); } - eprintln!("error: {message}"); - eprintln!(); - eprintln!("Fix the push error and rerun `stck push` to resume."); + ui.error(&message); + ui.diagnostic_separator(); + ui.diagnostic("Fix the push error and rerun `stck push` to resume."); return ExitCode::from(1); } state.completed_pushes = index + 1; pushed_this_run += 1; if let Err(message) = sync_state::save_push(&state) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } } for index in state.completed_retargets..state.retargets.len() { let retarget = &state.retargets[index]; - println!( + let plain_step = format!( "Retargeting PR {}/{}: {} -> {}", index + 1, state.retargets.len(), retarget.branch, retarget.new_base_ref ); + ui.operation( + index + 1, + state.retargets.len(), + &plain_step, + &format!("Retarget {} → {}", retarget.branch, retarget.new_base_ref), + ); ui.command( "gh", &format!( @@ -1420,37 +1450,37 @@ pub(crate) fn run_push(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitCod github::retarget_pr_base(&retarget.branch, &retarget.new_base_ref) }) { if let Err(save_error) = sync_state::save_push(&state) { - eprintln!("error: {save_error}"); + ui.error(&save_error); return ExitCode::from(1); } - eprintln!("error: {message}"); - eprintln!(); - eprintln!("Fix the GitHub error and rerun `stck push` to resume."); + ui.error(&message); + ui.diagnostic_separator(); + ui.diagnostic("Fix the GitHub error and rerun `stck push` to resume."); return ExitCode::from(1); } state.completed_retargets = index + 1; if let Err(message) = sync_state::save_push(&state) { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } } if let Err(message) = sync_state::clear() { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } if let Err(message) = sync_state::clear_last_sync_plan() { - eprintln!("error: {message}"); + ui.error(&message); return ExitCode::from(1); } let retargeted_this_run = state .completed_retargets .saturating_sub(starting_completed_retargets); - println!( + ui.success(&format!( "Push succeeded. Pushed {} branch(es) and applied {} PR base update(s) in this run.", pushed_this_run, retargeted_this_run - ); + )); ExitCode::SUCCESS } diff --git a/src/ui.rs b/src/ui.rs index cbab8cb..20183ec 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,8 +1,8 @@ //! Terminal-aware rendering for user-facing CLI output. //! -//! Human-oriented decoration is enabled only when stderr is attached to an -//! interactive terminal. Redirected output stays deterministic and free of -//! ANSI escape sequences so existing grep-based workflows remain stable. +//! Human-oriented decoration follows each output stream's terminal capability. +//! Redirected output stays deterministic and free of ANSI escape sequences so +//! existing grep-based workflows remain stable. use std::env; use std::io::{self, IsTerminal, Write}; @@ -13,16 +13,20 @@ use std::time::Duration; const ANSI_BOLD_CYAN: &str = "\u{1b}[1;36m"; const ANSI_BOLD_RED: &str = "\u{1b}[1;31m"; +const ANSI_BOLD: &str = "\u{1b}[1m"; +const ANSI_CYAN: &str = "\u{1b}[36m"; const ANSI_DIM: &str = "\u{1b}[2m"; const ANSI_GREEN: &str = "\u{1b}[32m"; const ANSI_RED: &str = "\u{1b}[31m"; const ANSI_RESET: &str = "\u{1b}[0m"; +const ANSI_YELLOW: &str = "\u{1b}[33m"; const CLEAR_CURRENT_LINE: &str = "\r\u{1b}[2K"; const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Decoration { Plain, + Uncolored, Styled, } @@ -55,6 +59,75 @@ impl Ui { eprintln!("{}", self.render_error(message)); } + /// Write a durable `stck` informational line. + pub(crate) fn info(self, message: &str) { + println!("{}", self.render_info(message)); + } + + /// Write a successful command result. + pub(crate) fn success(self, message: &str) { + println!("{}", self.render_success(message)); + } + + /// Write one durable step in a multi-step operation. + /// + /// `plain` retains the established redirected-output contract while + /// `interactive` is the concise TTY representation. + pub(crate) fn operation(self, current: usize, total: usize, plain: &str, interactive: &str) { + println!( + "{}", + self.render_operation(current, total, plain, interactive) + ); + } + + /// Write a supporting diagnostic line to stderr. + pub(crate) fn diagnostic(self, message: &str) { + eprintln!("{}", self.render_diagnostic(message)); + } + + /// Write a visually distinct recovery heading to stderr. + pub(crate) fn diagnostic_heading(self, heading: &str) { + eprintln!("{}", self.render_diagnostic_heading(heading)); + } + + /// Write a blank diagnostic separator. + pub(crate) fn diagnostic_separator(self) { + eprintln!(); + } + + /// Write the detected stack heading. + pub(crate) fn status_header(self, default_branch: &str, branch_chain: &str) { + println!( + "{}", + self.render_status_header(default_branch, branch_chain) + ); + } + + /// Write one pull request in the detected stack. + #[allow(clippy::too_many_arguments)] + pub(crate) fn status_branch( + self, + is_current: bool, + branch: &str, + number: u64, + state: &str, + base: &str, + flags: &[&str], + ) { + println!( + "{}", + self.render_status_branch(is_current, branch, number, state, base, flags) + ); + } + + /// Write the stable status summary. + pub(crate) fn status_summary(self, needs_sync: usize, needs_push: usize, base_mismatch: usize) { + println!( + "{}", + self.render_status_summary(needs_sync, needs_push, base_mismatch) + ); + } + /// Render a subprocess command with a distinct tool label. /// /// Redirected output retains the existing `$ ...` form. @@ -89,11 +162,13 @@ impl Ui { no_color: bool, terminal_is_dumb: bool, ) -> Self { - let decoration = |is_terminal| { - if is_terminal && !no_color && !terminal_is_dumb { - Decoration::Styled - } else { + let decoration = |is_terminal: bool| { + if !is_terminal || terminal_is_dumb { Decoration::Plain + } else if no_color { + Decoration::Uncolored + } else { + Decoration::Styled } }; @@ -106,16 +181,72 @@ impl Ui { fn render_error(self, message: &str) -> String { match self.stderr_decoration { - Decoration::Plain => format!("error: {message}"), + Decoration::Plain | Decoration::Uncolored => format!("error: {message}"), Decoration::Styled => { format!("{ANSI_BOLD_RED}error:{ANSI_RESET} {message}") } } } + fn render_info(self, message: &str) -> String { + match self.stdout_decoration { + Decoration::Plain => message.to_string(), + Decoration::Uncolored => format!("stck {message}"), + Decoration::Styled => { + format!("{ANSI_BOLD_CYAN}stck{ANSI_RESET} {message}") + } + } + } + + fn render_success(self, message: &str) -> String { + match self.stdout_decoration { + Decoration::Plain => message.to_string(), + Decoration::Uncolored => format!("stck ✓ {message}"), + Decoration::Styled => { + format!("{ANSI_BOLD_CYAN}stck{ANSI_RESET} {ANSI_GREEN}✓{ANSI_RESET} {message}") + } + } + } + + fn render_operation( + self, + current: usize, + total: usize, + plain: &str, + interactive: &str, + ) -> String { + match self.stdout_decoration { + Decoration::Plain => plain.to_string(), + Decoration::Uncolored => { + format!("stck [{current}/{total}] {interactive}") + } + Decoration::Styled => format!( + "{ANSI_BOLD_CYAN}stck{ANSI_RESET} {ANSI_DIM}[{current}/{total}]{ANSI_RESET} {interactive}" + ), + } + } + + fn render_diagnostic(self, message: &str) -> String { + match self.stderr_decoration { + Decoration::Plain => message.to_string(), + Decoration::Uncolored if message.is_empty() => String::new(), + Decoration::Uncolored => format!(" {message}"), + Decoration::Styled if message.is_empty() => String::new(), + Decoration::Styled => format!(" {message}"), + } + } + + fn render_diagnostic_heading(self, heading: &str) -> String { + match self.stderr_decoration { + Decoration::Plain | Decoration::Uncolored => heading.to_string(), + Decoration::Styled => format!("{ANSI_BOLD}{ANSI_YELLOW}{heading}{ANSI_RESET}"), + } + } + fn render_command(self, program: &str, arguments: &str) -> String { match self.stdout_decoration { Decoration::Plain => format!("$ {program} {arguments}"), + Decoration::Uncolored => format!(" {program} › {arguments}"), Decoration::Styled => { format!( " {ANSI_DIM}{program}{ANSI_RESET} {ANSI_BOLD_CYAN}›{ANSI_RESET} {arguments}" @@ -126,7 +257,7 @@ impl Ui { fn render_activity(self, frame: &str, label: &str) -> String { match self.stderr_decoration { - Decoration::Plain => format!("stck {frame} {label}"), + Decoration::Plain | Decoration::Uncolored => format!("stck {frame} {label}"), Decoration::Styled => { format!("{ANSI_BOLD_CYAN}stck{ANSI_RESET} {frame} {label}") } @@ -139,12 +270,107 @@ impl Ui { ActivityOutcome::Failed => ("✗", format!("{ANSI_RED}✗{ANSI_RESET}")), }; let marker = match self.stderr_decoration { - Decoration::Plain => plain_marker.to_string(), + Decoration::Plain | Decoration::Uncolored => plain_marker.to_string(), Decoration::Styled => styled_marker, }; self.render_activity(&marker, label) } + + fn render_status_header(self, default_branch: &str, branch_chain: &str) -> String { + let interactive_chain = branch_chain.replace(" <- ", " ← "); + match self.stdout_decoration { + Decoration::Plain => format!("Stack: {default_branch} <- {branch_chain}"), + Decoration::Uncolored => { + format!("stck Stack {default_branch} ← {interactive_chain}") + } + Decoration::Styled => format!( + "{ANSI_BOLD_CYAN}stck{ANSI_RESET} {ANSI_BOLD}Stack{ANSI_RESET} \ + {ANSI_DIM}{default_branch}{ANSI_RESET} {ANSI_CYAN}←{ANSI_RESET} {interactive_chain}" + ), + } + } + + fn render_status_branch( + self, + is_current: bool, + branch: &str, + number: u64, + state: &str, + base: &str, + flags: &[&str], + ) -> String { + if self.stdout_decoration == Decoration::Plain { + let marker = if is_current { "* " } else { " " }; + let flags = if flags.is_empty() { + String::new() + } else { + format!(" [{}]", flags.join(", ")) + }; + return format!("{marker}{branch} PR #{number} {state} base={base}{flags}"); + } + + let plain_marker = if is_current { "●" } else { "○" }; + let marker = match self.stdout_decoration { + Decoration::Uncolored => plain_marker.to_string(), + Decoration::Styled if is_current => format!("{ANSI_CYAN}●{ANSI_RESET}"), + Decoration::Styled => format!("{ANSI_DIM}○{ANSI_RESET}"), + Decoration::Plain => unreachable!("plain status returned above"), + }; + let state_color = if state == "MERGED" { + ANSI_GREEN + } else { + ANSI_CYAN + }; + let human_flags = flags + .iter() + .map(|flag| flag.replace('_', " ")) + .collect::>() + .join(" · "); + let flags = match (self.stdout_decoration, human_flags.is_empty()) { + (_, true) => String::new(), + (Decoration::Uncolored, false) => format!(" {human_flags}"), + (Decoration::Styled, false) => { + format!(" {ANSI_YELLOW}{human_flags}{ANSI_RESET}") + } + (Decoration::Plain, false) => unreachable!("plain status returned above"), + }; + + match self.stdout_decoration { + Decoration::Uncolored => { + format!(" {marker} {branch} #{number} {state} ← {base}{flags}") + } + Decoration::Styled => format!( + " {marker} {ANSI_BOLD}{branch}{ANSI_RESET} {ANSI_DIM}#{number}{ANSI_RESET} \ + {state_color}{state}{ANSI_RESET} {ANSI_DIM}← {base}{ANSI_RESET}{flags}" + ), + Decoration::Plain => unreachable!("plain status returned above"), + } + } + + fn render_status_summary( + self, + needs_sync: usize, + needs_push: usize, + base_mismatch: usize, + ) -> String { + let plain = format!( + "Summary: {needs_sync} needs_sync, {needs_push} needs_push, {base_mismatch} base_mismatch" + ); + match self.stdout_decoration { + Decoration::Plain => plain, + Decoration::Uncolored => format!( + "stck Summary {needs_sync} need sync · {needs_push} need push · \ + {base_mismatch} base mismatch" + ), + Decoration::Styled => format!( + "{ANSI_BOLD_CYAN}stck{ANSI_RESET} {ANSI_BOLD}Summary{ANSI_RESET} \ + {needs_sync} need sync {ANSI_DIM}·{ANSI_RESET} \ + {needs_push} need push {ANSI_DIM}·{ANSI_RESET} \ + {base_mismatch} base mismatch" + ), + } + } } #[derive(Clone, Copy)] @@ -290,6 +516,45 @@ mod tests { ); } + #[test] + fn redirected_command_output_contract_remains_exact() { + let ui = Ui::from_environment(false, false, false, false); + + assert_eq!(ui.render_info("Planning stack"), "Planning stack"); + assert_eq!( + ui.render_success("Sync succeeded locally."), + "Sync succeeded locally." + ); + assert_eq!( + ui.render_operation( + 1, + 2, + "Step 1/2: rebasing child onto parent", + "Rebase child → parent", + ), + "Step 1/2: rebasing child onto parent" + ); + assert_eq!( + ui.render_status_header("main", "parent <- child"), + "Stack: main <- parent <- child" + ); + assert_eq!( + ui.render_status_branch( + true, + "child", + 44, + "OPEN", + "parent", + &["needs_sync", "needs_push"], + ), + "* child PR #44 OPEN base=parent [needs_sync, needs_push]" + ); + assert_eq!( + ui.render_status_summary(1, 2, 0), + "Summary: 1 needs_sync, 2 needs_push, 0 base_mismatch" + ); + } + #[test] fn interactive_commands_have_a_distinct_tool_label() { let ui = Ui::from_environment(true, true, false, false); @@ -337,6 +602,42 @@ mod tests { ui.render_activity_result(ActivityOutcome::Succeeded, "Fetching origin"), "stck ✓ Fetching origin" ); + assert_eq!( + ui.render_command("git", "fetch origin"), + " git › fetch origin" + ); + assert_eq!( + ui.render_success("Sync succeeded locally."), + "stck ✓ Sync succeeded locally." + ); + assert_eq!( + ui.render_operation( + 2, + 3, + "Step 2/3: rebasing child onto parent", + "Rebase child → parent", + ), + "stck [2/3] Rebase child → parent" + ); + assert_eq!( + ui.render_status_header("main", "parent <- child"), + "stck Stack main ← parent ← child" + ); + assert_eq!( + ui.render_status_branch( + true, + "child", + 44, + "OPEN", + "parent", + &["needs_sync", "needs_push"], + ), + " ● child #44 OPEN ← parent needs sync · needs push" + ); + assert_eq!( + ui.render_status_summary(1, 2, 0), + "stck Summary 1 need sync · 2 need push · 0 base mismatch" + ); } #[test] diff --git a/tests/status.rs b/tests/status.rs index 7fa129b..1d85ee0 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -25,6 +25,7 @@ fn status_discovers_linear_stack_in_order() { .stdout(predicate::str::contains( "Summary: 1 needs_sync, 0 needs_push, 0 base_mismatch", )) + .stdout(predicate::str::contains("\u{1b}[").not()) .stderr(predicate::str::is_empty()); }