diff --git a/src/cli.rs b/src/cli.rs index 5b6da01..1283405 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -51,7 +51,7 @@ pub fn run() -> ExitCode { let cli = Cli::parse(); let ui = ui::Ui::detect(); - let preflight = match env::run_preflight() { + let preflight = match ui.run_step("Checking repository and tools", env::run_preflight) { Ok(preflight) => preflight, Err(message) => { ui.error(&message); @@ -60,14 +60,14 @@ pub fn run() -> ExitCode { }; match cli.command { - Commands::Status => commands::run_status(&preflight), - Commands::New { branch } => commands::run_new(&preflight, &branch), - Commands::Submit { base } => commands::run_submit(&preflight, base.as_deref()), + Commands::Status => commands::run_status(&preflight, ui), + Commands::New { branch } => commands::run_new(&preflight, &branch, ui), + Commands::Submit { base } => commands::run_submit(&preflight, base.as_deref(), ui), Commands::Sync { continue_sync, reset_sync, - } => commands::run_sync(&preflight, continue_sync, reset_sync), - Commands::Push => commands::run_push(&preflight), + } => commands::run_sync(&preflight, continue_sync, reset_sync, ui), + Commands::Push => commands::run_push(&preflight, ui), } } diff --git a/src/commands.rs b/src/commands.rs index 46f9329..1eb3c80 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -10,6 +10,7 @@ use crate::stack; use crate::sync_state::{ self, LastSyncPlan, LocalBranchHead, PushState, RemoteBranchLease, SyncPlanScope, SyncState, }; +use crate::ui; struct ParentDrift { parent: String, @@ -150,7 +151,7 @@ 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) -> ExitCode { +pub(crate) fn run_status(preflight: &env::PreflightContext, ui: ui::Ui) -> ExitCode { if preflight.current_branch == preflight.default_branch { println!( "On default branch ({}). Run `stck new ` to start a new stack.", @@ -159,19 +160,20 @@ pub(crate) fn run_status(preflight: &env::PreflightContext) -> ExitCode { return ExitCode::SUCCESS; } - if let Err(message) = gitops::fetch_origin() { + if let Err(message) = ui.run_step("Fetching origin", gitops::fetch_origin) { eprintln!("error: {message}"); return ExitCode::from(1); } - let stack = - match github::discover_linear_stack(&preflight.current_branch, &preflight.default_branch) { - Ok(stack) => stack, - Err(message) => { - eprintln!("error: {message}"); - return ExitCode::from(1); - } - }; + let stack = match ui.run_step("Discovering stack", || { + github::discover_linear_stack(&preflight.current_branch, &preflight.default_branch) + }) { + Ok(stack) => stack, + Err(message) => { + eprintln!("error: {message}"); + return ExitCode::from(1); + } + }; let mut report = stack::build_status_report(&stack, &preflight.default_branch); let cached_plan = match sync_state::load_last_sync_plan() { Ok(plan) => plan, @@ -269,7 +271,7 @@ pub(crate) fn run_status(preflight: &env::PreflightContext) -> ExitCode { } /// Create the next branch in the stack and bootstrap the current branch PR when needed. -pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str) -> ExitCode { +pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str, ui: ui::Ui) -> ExitCode { let current_branch = &preflight.current_branch; let starting_from_default = current_branch == &preflight.default_branch; let pr_base_branch = if starting_from_default { @@ -326,8 +328,10 @@ pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str) -> Ex }; if !has_upstream { - println!("$ git push -u origin {}", current_branch); - if let Err(message) = gitops::push_set_upstream(current_branch) { + ui.command("git", &format!("push -u origin {current_branch}")); + if let Err(message) = ui.run_step(format!("Publishing {current_branch}"), || { + gitops::push_set_upstream(current_branch) + }) { eprintln!("error: {message}"); return ExitCode::from(1); } @@ -340,15 +344,19 @@ pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str) -> Ex } }; if needs_push { - println!("$ git push origin {}", current_branch); - if let Err(message) = gitops::push_branch(current_branch) { + ui.command("git", &format!("push origin {current_branch}")); + if let Err(message) = ui.run_step(format!("Publishing {current_branch}"), || { + gitops::push_branch(current_branch) + }) { eprintln!("error: {message}"); return ExitCode::from(1); } } } - let current_has_pr = match github::pr_exists_for_head(current_branch) { + let current_has_pr = match ui.run_step(format!("Checking PR for {current_branch}"), || { + github::pr_exists_for_head(current_branch) + }) { Ok(exists) => exists, Err(message) => { eprintln!("error: {message}"); @@ -357,35 +365,42 @@ pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str) -> Ex }; if !current_has_pr { - let bootstrap_base = match discover_parent_base(current_branch) { + let bootstrap_base = match ui + .run_step(format!("Discovering parent for {current_branch}"), || { + discover_parent_base(current_branch) + }) { Ok(base) => base.unwrap_or_else(|| preflight.default_branch.clone()), Err(message) => { eprintln!("error: {message}"); return ExitCode::from(1); } }; - println!( - "$ gh pr create --base {} --head {} --title {} --body \"\"", - bootstrap_base, current_branch, current_branch + ui.command( + "gh", + &format!( + "pr create --base {bootstrap_base} --head {current_branch} --title {current_branch} --body \"\"" + ), ); let body = github::stack_pr_body(&bootstrap_base, &preflight.default_branch); - if let Err(message) = + 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}"); return ExitCode::from(1); } } } - println!("$ git checkout -b {}", new_branch); + ui.command("git", &format!("checkout -b {new_branch}")); if let Err(message) = gitops::checkout_new_branch(new_branch) { eprintln!("error: {message}"); return ExitCode::from(1); } - println!("$ git push -u origin {}", new_branch); - if let Err(message) = gitops::push_set_upstream(new_branch) { + ui.command("git", &format!("push -u origin {new_branch}")); + if let Err(message) = ui.run_step(format!("Publishing {new_branch}"), || { + gitops::push_set_upstream(new_branch) + }) { eprintln!("error: {message}"); return ExitCode::from(1); } @@ -405,12 +420,16 @@ pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str) -> Ex return ExitCode::SUCCESS; } - println!( - "$ gh pr create --base {} --head {} --title {} --body \"\"", - pr_base_branch, new_branch, new_branch + ui.command( + "gh", + &format!( + "pr create --base {pr_base_branch} --head {new_branch} --title {new_branch} --body \"\"" + ), ); let body = github::stack_pr_body(pr_base_branch, &preflight.default_branch); - if let Err(message) = github::create_pr(pr_base_branch, new_branch, new_branch, &body) { + 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}"); return ExitCode::from(1); } @@ -474,6 +493,7 @@ fn discover_parent_base(branch: &str) -> Result, String> { pub(crate) fn run_submit( preflight: &env::PreflightContext, base_override: Option<&str>, + ui: ui::Ui, ) -> ExitCode { let current_branch = &preflight.current_branch; if current_branch == &preflight.default_branch { @@ -492,8 +512,10 @@ pub(crate) fn run_submit( } }; if !has_upstream { - println!("$ git push -u origin {}", current_branch); - if let Err(message) = gitops::push_set_upstream(current_branch) { + ui.command("git", &format!("push -u origin {current_branch}")); + if let Err(message) = ui.run_step(format!("Publishing {current_branch}"), || { + gitops::push_set_upstream(current_branch) + }) { eprintln!("error: {message}"); return ExitCode::from(1); } @@ -506,15 +528,19 @@ pub(crate) fn run_submit( } }; if needs_push { - println!("$ git push origin {}", current_branch); - if let Err(message) = gitops::push_branch(current_branch) { + ui.command("git", &format!("push origin {current_branch}")); + if let Err(message) = ui.run_step(format!("Publishing {current_branch}"), || { + gitops::push_branch(current_branch) + }) { eprintln!("error: {message}"); return ExitCode::from(1); } } } - let current_has_pr = match github::pr_exists_for_head(current_branch) { + let current_has_pr = match ui.run_step(format!("Checking PR for {current_branch}"), || { + github::pr_exists_for_head(current_branch) + }) { Ok(exists) => exists, Err(message) => { eprintln!("error: {message}"); @@ -530,7 +556,10 @@ pub(crate) fn run_submit( let base = if let Some(explicit) = base_override { explicit } else { - discovered_base = match discover_parent_base(current_branch) { + discovered_base = match ui + .run_step(format!("Discovering parent for {current_branch}"), || { + discover_parent_base(current_branch) + }) { Ok(base) => base.unwrap_or_else(|| preflight.default_branch.clone()), Err(message) => { eprintln!("error: {message}"); @@ -551,12 +580,16 @@ pub(crate) fn run_submit( &discovered_base }; - println!( - "$ gh pr create --base {} --head {} --title {} --body \"\"", - base, current_branch, current_branch + ui.command( + "gh", + &format!( + "pr create --base {base} --head {current_branch} --title {current_branch} --body \"\"" + ), ); let body = github::stack_pr_body(base, &preflight.default_branch); - if let Err(message) = github::create_pr(base, current_branch, current_branch, &body) { + 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}"); return ExitCode::from(1); } @@ -631,6 +664,7 @@ pub(crate) fn run_sync( preflight: &env::PreflightContext, continue_sync: bool, reset_sync: bool, + ui: ui::Ui, ) -> ExitCode { let original_branch = preflight.current_branch.clone(); @@ -684,15 +718,14 @@ pub(crate) fn run_sync( return ExitCode::from(1); } - if let Err(message) = gitops::fetch_origin() { + if let Err(message) = ui.run_step("Fetching origin", gitops::fetch_origin) { eprintln!("error: {message}"); return ExitCode::from(1); } - let stack = match github::discover_linear_stack( - &preflight.current_branch, - &preflight.default_branch, - ) { + let stack = match ui.run_step("Discovering stack", || { + github::discover_linear_stack(&preflight.current_branch, &preflight.default_branch) + }) { Ok(stack) => stack, Err(message) => { eprintln!("error: {message}"); @@ -930,11 +963,16 @@ pub(crate) fn run_sync( step.old_base_ref ); } - println!( - "$ git rebase --onto {} {} {}", - onto_ref, old_base_sha, step.branch + ui.command( + "git", + &format!( + "rebase --onto {} {} {}", + onto_ref, old_base_sha, step.branch + ), ); - if let Err(message) = gitops::rebase_onto(&onto_ref, &old_base_sha, &step.branch) { + if let Err(message) = ui.run_step(format!("Rebasing {}", step.branch), || { + gitops::rebase_onto(&onto_ref, &old_base_sha, &step.branch) + }) { state.failed_step = Some(index); state.failed_step_branch_head = Some(branch_head); if let Err(save_error) = sync_state::save_sync(&state) { @@ -962,7 +1000,7 @@ pub(crate) fn run_sync( } } - println!("$ git checkout {}", original_branch); + 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}"); @@ -1123,20 +1161,21 @@ fn first_push_parent_drift( } /// Push rewritten stack branches and retarget any affected pull requests. -pub(crate) fn run_push(preflight: &env::PreflightContext) -> ExitCode { - if let Err(message) = gitops::fetch_origin() { +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}"); return ExitCode::from(1); } - let stack = - match github::discover_linear_stack(&preflight.current_branch, &preflight.default_branch) { - Ok(stack) => stack, - Err(message) => { - eprintln!("error: {message}"); - return ExitCode::from(1); - } - }; + let stack = match ui.run_step("Discovering stack", || { + github::discover_linear_stack(&preflight.current_branch, &preflight.default_branch) + }) { + Ok(stack) => stack, + Err(message) => { + eprintln!("error: {message}"); + return ExitCode::from(1); + } + }; let existing_state = match sync_state::load_push() { Ok(state) => state, @@ -1339,10 +1378,10 @@ pub(crate) fn run_push(preflight: &env::PreflightContext) -> ExitCode { "--force-with-lease=refs/heads/{branch}:{}", expected_remote_head.as_deref().unwrap_or_default() ); - println!("$ git push {lease} origin {branch}"); - if let Err(message) = + ui.command("git", &format!("push {lease} origin {branch}")); + if let Err(message) = ui.run_step(format!("Pushing {branch}"), || { 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}"); return ExitCode::from(1); @@ -1370,11 +1409,16 @@ pub(crate) fn run_push(preflight: &env::PreflightContext) -> ExitCode { retarget.branch, retarget.new_base_ref ); - println!( - "$ gh pr edit {} --base {}", - retarget.branch, retarget.new_base_ref + ui.command( + "gh", + &format!( + "pr edit {} --base {}", + retarget.branch, retarget.new_base_ref + ), ); - if let Err(message) = github::retarget_pr_base(&retarget.branch, &retarget.new_base_ref) { + if let Err(message) = ui.run_step(format!("Retargeting PR for {}", retarget.branch), || { + github::retarget_pr_base(&retarget.branch, &retarget.new_base_ref) + }) { if let Err(save_error) = sync_state::save_push(&state) { eprintln!("error: {save_error}"); return ExitCode::from(1); diff --git a/src/github.rs b/src/github.rs index 6f92bfc..1571758 100644 --- a/src/github.rs +++ b/src/github.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use std::process::Command; -use crate::util::with_stderr; +use crate::util::{with_command_output, with_stderr}; /// The GitHub state of a pull request as returned by `gh`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] @@ -157,10 +157,12 @@ pub fn retarget_pr_base(branch: &str, new_base: &str) -> Result<(), String> { if output.status.success() { Ok(()) } else { - Err(with_stderr( + Err(with_command_output( &format!( "failed to retarget PR base for branch {branch} to {new_base}; fix the GitHub error and rerun `stck push`" ), + "gh", + &output.stdout, &output.stderr, )) } @@ -216,8 +218,10 @@ pub fn create_pr(base: &str, head: &str, title: &str, body: &str) -> Result<(), if output.status.success() { Ok(()) } else { - Err(with_stderr( + Err(with_command_output( &format!("failed to create PR for branch {head}; fix the GitHub error and retry"), + "gh", + &output.stdout, &output.stderr, )) } diff --git a/src/gitops.rs b/src/gitops.rs index 13e5126..241541c 100644 --- a/src/gitops.rs +++ b/src/gitops.rs @@ -1,9 +1,9 @@ //! Git subprocess helpers used by stack planning and command execution. -use std::process::{Command, Stdio}; +use std::process::Command; use std::{env, path::PathBuf}; -use crate::util::with_stderr; +use crate::util::{with_command_output, with_stderr}; /// Fetch updated refs from the `origin` remote. pub fn fetch_origin() -> Result<(), String> { @@ -235,20 +235,24 @@ pub fn branch_needs_sync_with_default(default_branch: &str, branch: &str) -> Res /// Rebase `branch` onto `new_base`, using `old_base` as the fork point. /// -/// Standard git rebase progress and conflict output is inherited directly so -/// the user can continue or abort with native git commands when needed. +/// Git output is captured so callers can frame it separately from `stck` +/// progress. On failure, both captured streams are included in the error. pub fn rebase_onto(new_base: &str, old_base: &str, branch: &str) -> Result<(), String> { - let status = Command::new("git") + let output = Command::new("git") .args(["rebase", "--onto", new_base, old_base, branch]) - .stderr(Stdio::inherit()) - .status() + .output() .map_err(|_| "failed to run `git rebase`; ensure this is a git repository".to_string())?; - if status.success() { + if output.status.success() { Ok(()) } else { - Err(format!( - "rebase failed for branch {branch}; resolve conflicts, run `git rebase --continue` or `git rebase --abort`, then rerun `stck sync`" + Err(with_command_output( + &format!( + "rebase failed for branch {branch}; resolve conflicts, run `git rebase --continue` or `git rebase --abort`, then rerun `stck sync`" + ), + "git", + &output.stdout, + &output.stderr, )) } } @@ -259,17 +263,19 @@ pub fn rebase_onto(new_base: &str, old_base: &str, branch: &str) -> Result<(), S /// A non-fast-forward push will fail, which is the desired safety behaviour /// when the caller simply wants to publish new local commits. pub fn push_branch(branch: &str) -> Result<(), String> { - let status = Command::new("git") + let output = Command::new("git") .args(["push", "origin", branch]) - .stderr(Stdio::inherit()) - .status() + .output() .map_err(|_| "failed to run `git push`; ensure this is a git repository".to_string())?; - if status.success() { + if output.status.success() { Ok(()) } else { - Err(format!( - "push failed for branch {branch}; fix the push error and retry" + Err(with_command_output( + &format!("push failed for branch {branch}; fix the push error and retry"), + "git", + &output.stdout, + &output.stderr, )) } } @@ -287,17 +293,19 @@ pub fn push_force_with_lease( "--force-with-lease=refs/heads/{branch}:{}", expected_remote_head.unwrap_or_default() ); - let status = Command::new("git") + let output = Command::new("git") .args(["push", &lease, "origin", branch]) - .stderr(Stdio::inherit()) - .status() + .output() .map_err(|_| "failed to run `git push`; ensure this is a git repository".to_string())?; - if status.success() { + if output.status.success() { Ok(()) } else { - Err(format!( - "push failed for branch {branch}; fix the push error and rerun `stck push`" + Err(with_command_output( + &format!("push failed for branch {branch}; fix the push error and rerun `stck push`"), + "git", + &output.stdout, + &output.stderr, )) } } @@ -350,8 +358,10 @@ pub fn push_set_upstream(branch: &str) -> Result<(), String> { if output.status.success() { Ok(()) } else { - Err(with_stderr( + Err(with_command_output( &format!("failed to push branch {branch} with upstream; fix the push error and retry"), + "git", + &output.stdout, &output.stderr, )) } @@ -369,10 +379,12 @@ pub fn checkout_new_branch(branch: &str) -> Result<(), String> { if output.status.success() { Ok(()) } else { - Err(with_stderr( + Err(with_command_output( &format!( "failed to create and checkout branch {branch}; ensure the branch name is valid and does not already exist" ), + "git", + &output.stdout, &output.stderr, )) } @@ -388,8 +400,10 @@ pub fn checkout_branch(branch: &str) -> Result<(), String> { if output.status.success() { Ok(()) } else { - Err(with_stderr( + Err(with_command_output( &format!("failed to checkout branch {branch}; switch branches manually and retry"), + "git", + &output.stdout, &output.stderr, )) } diff --git a/src/ui.rs b/src/ui.rs index abbb678..cbab8cb 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -5,10 +5,20 @@ //! ANSI escape sequences so existing grep-based workflows remain stable. use std::env; -use std::io::{self, IsTerminal}; +use std::io::{self, IsTerminal, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::Duration; +const ANSI_BOLD_CYAN: &str = "\u{1b}[1;36m"; const ANSI_BOLD_RED: &str = "\u{1b}[1;31m"; +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 CLEAR_CURRENT_LINE: &str = "\r\u{1b}[2K"; +const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Decoration { @@ -19,17 +29,25 @@ enum Decoration { /// Renderer for terminal-aware CLI messages. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct Ui { + stdout_decoration: Decoration, stderr_decoration: Decoration, + stderr_is_interactive: bool, } impl Ui { /// Detect terminal capabilities and environment overrides for this run. pub(crate) fn detect() -> Self { + let stdout_is_terminal = io::stdout().is_terminal(); let stderr_is_terminal = io::stderr().is_terminal(); let no_color = env::var_os("NO_COLOR").is_some(); let terminal_is_dumb = env::var_os("TERM").is_some_and(|term| term == "dumb"); - Self::from_environment(stderr_is_terminal, no_color, terminal_is_dumb) + Self::from_environment( + stdout_is_terminal, + stderr_is_terminal, + no_color, + terminal_is_dumb, + ) } /// Write an error to stderr, preserving the stable `error:` prefix. @@ -37,14 +55,53 @@ impl Ui { eprintln!("{}", self.render_error(message)); } - fn from_environment(stderr_is_terminal: bool, no_color: bool, terminal_is_dumb: bool) -> Self { - let stderr_decoration = if stderr_is_terminal && !no_color && !terminal_is_dumb { - Decoration::Styled - } else { - Decoration::Plain + /// Render a subprocess command with a distinct tool label. + /// + /// Redirected output retains the existing `$ ...` form. + pub(crate) fn command(self, program: &str, arguments: &str) { + println!("{}", self.render_command(program, arguments)); + } + + /// Run an operation while showing transient progress on an interactive TTY. + /// + /// Redirected and CI output remains silent and deterministic. + pub(crate) fn run_step( + self, + label: impl Into, + operation: impl FnOnce() -> Result, + ) -> Result { + let activity = Activity::start(self, label.into()); + match operation() { + Ok(value) => { + activity.finish(ActivityOutcome::Succeeded); + Ok(value) + } + Err(error) => { + activity.finish(ActivityOutcome::Failed); + Err(error) + } + } + } + + fn from_environment( + stdout_is_terminal: bool, + stderr_is_terminal: bool, + no_color: bool, + terminal_is_dumb: bool, + ) -> Self { + let decoration = |is_terminal| { + if is_terminal && !no_color && !terminal_is_dumb { + Decoration::Styled + } else { + Decoration::Plain + } }; - Self { stderr_decoration } + Self { + stdout_decoration: decoration(stdout_is_terminal), + stderr_decoration: decoration(stderr_is_terminal), + stderr_is_interactive: stderr_is_terminal && !terminal_is_dumb, + } } fn render_error(self, message: &str) -> String { @@ -55,15 +112,137 @@ impl Ui { } } } + + fn render_command(self, program: &str, arguments: &str) -> String { + match self.stdout_decoration { + Decoration::Plain => format!("$ {program} {arguments}"), + Decoration::Styled => { + format!( + " {ANSI_DIM}{program}{ANSI_RESET} {ANSI_BOLD_CYAN}›{ANSI_RESET} {arguments}" + ) + } + } + } + + fn render_activity(self, frame: &str, label: &str) -> String { + match self.stderr_decoration { + Decoration::Plain => format!("stck {frame} {label}"), + Decoration::Styled => { + format!("{ANSI_BOLD_CYAN}stck{ANSI_RESET} {frame} {label}") + } + } + } + + fn render_activity_result(self, outcome: ActivityOutcome, label: &str) -> String { + let (plain_marker, styled_marker) = match outcome { + ActivityOutcome::Succeeded => ("✓", format!("{ANSI_GREEN}✓{ANSI_RESET}")), + ActivityOutcome::Failed => ("✗", format!("{ANSI_RED}✗{ANSI_RESET}")), + }; + let marker = match self.stderr_decoration { + Decoration::Plain => plain_marker.to_string(), + Decoration::Styled => styled_marker, + }; + + self.render_activity(&marker, label) + } +} + +#[derive(Clone, Copy)] +enum ActivityOutcome { + Succeeded, + Failed, +} + +struct Activity { + ui: Ui, + label: String, + running: Option>, + thread: Option>, + finished: bool, +} + +impl Activity { + fn start(ui: Ui, label: String) -> Self { + if !ui.stderr_is_interactive { + return Self { + ui, + label, + running: None, + thread: None, + finished: false, + }; + } + + let running = Arc::new(AtomicBool::new(true)); + let spinner_running = Arc::clone(&running); + let spinner_label = label.clone(); + let thread = thread::spawn(move || { + let mut frame_index = 0; + while spinner_running.load(Ordering::Relaxed) { + write_stderr(&format!( + "{CLEAR_CURRENT_LINE}{}", + ui.render_activity(SPINNER_FRAMES[frame_index], &spinner_label) + )); + frame_index = (frame_index + 1) % SPINNER_FRAMES.len(); + thread::park_timeout(Duration::from_millis(80)); + } + }); + + Self { + ui, + label, + running: Some(running), + thread: Some(thread), + finished: false, + } + } + + fn finish(mut self, outcome: ActivityOutcome) { + self.stop(); + if self.ui.stderr_is_interactive { + write_stderr(&format!( + "{CLEAR_CURRENT_LINE}{}\n", + self.ui.render_activity_result(outcome, &self.label) + )); + } + self.finished = true; + } + + fn stop(&mut self) { + if let Some(running) = &self.running { + running.store(false, Ordering::Relaxed); + } + if let Some(thread) = self.thread.take() { + thread.thread().unpark(); + let _ = thread.join(); + } + } +} + +impl Drop for Activity { + fn drop(&mut self) { + if !self.finished { + self.stop(); + if self.ui.stderr_is_interactive { + write_stderr(CLEAR_CURRENT_LINE); + } + } + } +} + +fn write_stderr(message: &str) { + let mut stderr = io::stderr().lock(); + let _ = stderr.write_all(message.as_bytes()); + let _ = stderr.flush(); } #[cfg(test)] mod tests { - use super::Ui; + use super::{ActivityOutcome, Ui}; #[test] fn redirected_errors_remain_plain_and_grep_friendly() { - let ui = Ui::from_environment(false, false, false); + let ui = Ui::from_environment(false, false, false, false); assert_eq!( ui.render_error("something failed"), @@ -73,7 +252,7 @@ mod tests { #[test] fn interactive_errors_style_only_the_stable_prefix() { - let ui = Ui::from_environment(true, false, false); + let ui = Ui::from_environment(true, true, false, false); assert_eq!( ui.render_error("something failed"), @@ -83,7 +262,7 @@ mod tests { #[test] fn no_color_disables_styling_in_a_terminal() { - let ui = Ui::from_environment(true, true, false); + let ui = Ui::from_environment(true, true, true, false); assert_eq!( ui.render_error("something failed"), @@ -93,11 +272,76 @@ mod tests { #[test] fn dumb_terminal_disables_styling() { - let ui = Ui::from_environment(true, false, true); + let ui = Ui::from_environment(true, true, false, true); assert_eq!( ui.render_error("something failed"), "error: something failed" ); } + + #[test] + fn redirected_commands_preserve_the_existing_format() { + let ui = Ui::from_environment(false, false, false, false); + + assert_eq!( + ui.render_command("git", "fetch origin"), + "$ git fetch origin" + ); + } + + #[test] + fn interactive_commands_have_a_distinct_tool_label() { + let ui = Ui::from_environment(true, true, false, false); + + assert_eq!( + ui.render_command("git", "fetch origin"), + " \u{1b}[2mgit\u{1b}[0m \u{1b}[1;36m›\u{1b}[0m fetch origin" + ); + } + + #[test] + fn stdout_and_stderr_decoration_follow_their_own_terminal() { + let ui = Ui::from_environment(false, true, false, false); + + assert_eq!( + ui.render_command("git", "fetch origin"), + "$ git fetch origin" + ); + assert_eq!( + ui.render_error("something failed"), + "\u{1b}[1;31merror:\u{1b}[0m something failed" + ); + } + + #[test] + fn interactive_activity_results_have_a_stable_stck_label() { + let ui = Ui::from_environment(true, true, false, false); + + assert_eq!( + ui.render_activity_result(ActivityOutcome::Succeeded, "Fetching origin"), + "\u{1b}[1;36mstck\u{1b}[0m \u{1b}[32m✓\u{1b}[0m Fetching origin" + ); + assert_eq!( + ui.render_activity_result(ActivityOutcome::Failed, "Fetching origin"), + "\u{1b}[1;36mstck\u{1b}[0m \u{1b}[31m✗\u{1b}[0m Fetching origin" + ); + } + + #[test] + fn no_color_keeps_tty_progress_but_removes_color_sequences() { + let ui = Ui::from_environment(true, true, true, false); + + assert!(ui.stderr_is_interactive); + assert_eq!( + ui.render_activity_result(ActivityOutcome::Succeeded, "Fetching origin"), + "stck ✓ Fetching origin" + ); + } + + #[test] + fn redirected_and_dumb_term_progress_are_non_interactive() { + assert!(!Ui::from_environment(false, false, false, false).stderr_is_interactive); + assert!(!Ui::from_environment(true, true, false, true).stderr_is_interactive); + } } diff --git a/src/util.rs b/src/util.rs index 736f468..9e57ad9 100644 --- a/src/util.rs +++ b/src/util.rs @@ -10,6 +10,33 @@ pub fn with_stderr(base: &str, stderr: &[u8]) -> String { } } +/// Append captured subprocess streams as a clearly framed diagnostic block. +pub fn with_command_output(base: &str, command: &str, stdout: &[u8], stderr: &[u8]) -> String { + let stdout = String::from_utf8_lossy(stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(stderr).trim().to_string(); + if stdout.is_empty() && stderr.is_empty() { + return base.to_string(); + } + + let mut message = format!("{base}\n{command} output:"); + if !stdout.is_empty() { + message.push_str("\n stdout:"); + append_indented_lines(&mut message, &stdout); + } + if !stderr.is_empty() { + message.push_str("\n stderr:"); + append_indented_lines(&mut message, &stderr); + } + message +} + +fn append_indented_lines(message: &mut String, detail: &str) { + for line in detail.lines() { + message.push_str("\n "); + message.push_str(line); + } +} + #[cfg(test)] mod tests { use super::*; @@ -34,4 +61,38 @@ mod tests { "something failed; stderr: fatal: bad object" ); } + + #[test] + fn frames_captured_command_streams() { + assert_eq!( + with_command_output( + "rebase failed", + "git", + b"Auto-merging file\nCONFLICT in file\n", + b"Could not apply commit\n", + ), + concat!( + "rebase failed\n", + "git output:\n", + " stdout:\n", + " Auto-merging file\n", + " CONFLICT in file\n", + " stderr:\n", + " Could not apply commit", + ) + ); + } + + #[test] + fn command_output_omits_empty_streams() { + assert_eq!( + with_command_output("push failed", "git", b"", b"rejected\n"), + concat!( + "push failed\n", + "git output:\n", + " stderr:\n", + " rejected", + ) + ); + } } diff --git a/tests/harness/mod.rs b/tests/harness/mod.rs index 29c991c..10a8d8a 100644 --- a/tests/harness/mod.rs +++ b/tests/harness/mod.rs @@ -319,6 +319,10 @@ if [[ "${1:-}" == "rebase" && "${2:-}" == "--onto" ]]; then if [[ -n "${STCK_TEST_LOG:-}" ]]; then echo "$*" >> "${STCK_TEST_LOG}" fi + if [[ "${STCK_TEST_REBASE_OUTPUT:-0}" == "1" ]]; then + echo "Applied rewritten commits" + echo "Rebase progress detail" >&2 + fi if [[ "${STCK_TEST_REBASE_FAIL_STDERR:-0}" == "1" ]]; then echo "CONFLICT (content): Merge conflict in src/main.rs" >&2 fi @@ -339,6 +343,9 @@ if [[ "${1:-}" == "push" && "${2:-}" == --force-with-lease=* && "${3:-}" == "ori echo "$*" >> "${STCK_TEST_LOG}" fi if [[ "${STCK_TEST_PUSH_FAIL_BRANCH:-}" == "${branch}" ]]; then + if [[ "${STCK_TEST_PUSH_FAIL_STDERR:-0}" == "1" ]]; then + echo "remote rejected the update" >&2 + fi exit 1 fi exit 0 @@ -622,9 +629,15 @@ if [[ "${1:-}" == "pr" && "${2:-}" == "edit" ]]; then if [[ -n "${STCK_TEST_RETARGET_FAIL_ONCE_FILE:-}" && "${STCK_TEST_RETARGET_FAIL_ONCE_BRANCH:-}" == "${branch}" && ! -f "${STCK_TEST_RETARGET_FAIL_ONCE_FILE}" ]]; then mkdir -p "$(dirname "${STCK_TEST_RETARGET_FAIL_ONCE_FILE}")" touch "${STCK_TEST_RETARGET_FAIL_ONCE_FILE}" + if [[ "${STCK_TEST_RETARGET_FAIL_STDERR:-0}" == "1" ]]; then + echo "GraphQL request failed" >&2 + fi exit 1 fi if [[ "${STCK_TEST_RETARGET_FAIL_BRANCH:-}" == "${branch}" ]]; then + if [[ "${STCK_TEST_RETARGET_FAIL_STDERR:-0}" == "1" ]]; then + echo "GraphQL request failed" >&2 + fi exit 1 fi exit 0 diff --git a/tests/push.rs b/tests/push.rs index bb40911..2a1814f 100644 --- a/tests/push.rs +++ b/tests/push.rs @@ -76,11 +76,16 @@ fn push_stops_before_retarget_when_a_push_fails() { "feature-branch,feature-child", ); cmd.env("STCK_TEST_PUSH_FAIL_BRANCH", "feature-child"); + cmd.env("STCK_TEST_PUSH_FAIL_STDERR", "1"); cmd.arg("push"); - cmd.assert().code(1).stderr(predicate::str::contains( - "error: push failed for branch feature-child; fix the push error and rerun `stck push`", - )); + cmd.assert() + .code(1) + .stderr(predicate::str::contains( + "error: push failed for branch feature-child; fix the push error and rerun `stck push`", + )) + .stderr(predicate::str::contains("git output:")) + .stderr(predicate::str::contains("remote rejected the update")); let log = fs::read_to_string(&log_path).expect("push log should exist"); assert!( @@ -102,11 +107,17 @@ fn push_resumes_after_partial_retarget_failure() { first.env("STCK_TEST_FEATURE_CHILD_BASE", "main"); first.env("STCK_TEST_RETARGET_FAIL_ONCE_FILE", marker_path.as_os_str()); first.env("STCK_TEST_RETARGET_FAIL_ONCE_BRANCH", "feature-child"); + first.env("STCK_TEST_RETARGET_FAIL_STDERR", "1"); first.arg("push"); - first.assert().code(1).stderr(predicate::str::contains( - "error: failed to retarget PR base for branch feature-child to feature-branch; fix the GitHub error and rerun `stck push`", - )); + first + .assert() + .code(1) + .stderr(predicate::str::contains( + "error: failed to retarget PR base for branch feature-child to feature-branch; fix the GitHub error and rerun `stck push`", + )) + .stderr(predicate::str::contains("gh output:")) + .stderr(predicate::str::contains("GraphQL request failed")); let state_path = temp .path() diff --git a/tests/real_git.rs b/tests/real_git.rs index d7bc667..686cc02 100644 --- a/tests/real_git.rs +++ b/tests/real_git.rs @@ -905,7 +905,11 @@ fn sync_continues_after_a_real_git_conflict_is_resolved() { first .assert() .code(1) - .stdout(predicate::str::contains("CONFLICT")) + .stdout(predicate::str::contains("CONFLICT").not()) + .stderr(predicate::str::contains("git output:")) + .stderr(predicate::str::contains( + "CONFLICT (content): Merge conflict in shared.txt", + )) .stderr(predicate::str::contains( "Then run `stck sync --continue` to resume.", )); diff --git a/tests/status.rs b/tests/status.rs index a7e3e6c..7fa129b 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -24,7 +24,8 @@ fn status_discovers_linear_stack_in_order() { )) .stdout(predicate::str::contains( "Summary: 1 needs_sync, 0 needs_push, 0 base_mismatch", - )); + )) + .stderr(predicate::str::is_empty()); } #[test] diff --git a/tests/sync.rs b/tests/sync.rs index 6516188..6000e0f 100644 --- a/tests/sync.rs +++ b/tests/sync.rs @@ -120,9 +120,25 @@ fn sync_includes_rebase_stderr_on_failure() { cmd.env("STCK_TEST_REBASE_FAIL_STDERR", "1"); cmd.arg("sync"); - cmd.assert().code(1).stderr(predicate::str::contains( - "CONFLICT (content): Merge conflict in src/main.rs", - )); + cmd.assert() + .code(1) + .stderr(predicate::str::contains("git output:")) + .stderr(predicate::str::contains(" stderr:")) + .stderr(predicate::str::contains( + "CONFLICT (content): Merge conflict in src/main.rs", + )); +} + +#[test] +fn sync_suppresses_successful_git_chatter() { + let (_temp, mut cmd) = stck_cmd_with_stubbed_tools(); + cmd.env("STCK_TEST_REBASE_OUTPUT", "1"); + cmd.arg("sync"); + + cmd.assert() + .success() + .stdout(predicate::str::contains("Applied rewritten commits").not()) + .stderr(predicate::str::contains("Rebase progress detail").not()); } #[test]