From a7260fb3fcade599234f64a1c6e13b12f933676e Mon Sep 17 00:00:00 2001 From: Scott Chacon Date: Mon, 22 Dec 2025 20:38:56 +0100 Subject: [PATCH 1/5] Add JSON output for `but base check` Provide a machine-readable JSON representation of the `but base check` command so callers can programmatically consume base branch info, upstream commits and branch mergeability status. Introduce serializable structs (BaseCheckOutput, BaseBranchInfo, UpstreamInfo, UpstreamCommit, BranchStatusInfo), fetch the data before deciding output mode, and serialize the gathered data (base branch details, upstream commit list and count, branch statuses, up_to_date and worktree conflict flags). The human-readable output path is preserved. --- crates/but/src/command/legacy/base.rs | 117 +++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 10 deletions(-) diff --git a/crates/but/src/command/legacy/base.rs b/crates/but/src/command/legacy/base.rs index fe6f5b4b35..c8c88f3865 100644 --- a/crates/but/src/command/legacy/base.rs +++ b/crates/but/src/command/legacy/base.rs @@ -6,9 +6,53 @@ use gitbutler_branch_actions::upstream_integration::{ Resolution, ResolutionApproach, StackStatuses::{UpToDate, UpdatesRequired}, }; +use serde::Serialize; use crate::{args::base, utils::OutputChannel}; +/// JSON output for `but base check` +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct BaseCheckOutput { + base_branch: BaseBranchInfo, + upstream_commits: UpstreamInfo, + branch_statuses: Vec, + up_to_date: bool, + has_worktree_conflicts: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct BaseBranchInfo { + name: String, + remote_name: String, + base_sha: String, + current_sha: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct UpstreamInfo { + count: usize, + commits: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct UpstreamCommit { + id: String, + description: String, + author_name: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct BranchStatusInfo { + name: String, + status: String, + rebasable: Option, +} + pub async fn handle( cmd: Subcommands, project: &LegacyProject, @@ -16,12 +60,70 @@ pub async fn handle( ) -> anyhow::Result<()> { match cmd { Subcommands::Check => { - if let Some(out) = out.for_human() { + let base_branch = but_api::legacy::virtual_branches::fetch_from_remotes( + project.id, + Some("auto".to_string()), + )?; + + let status = + but_api::legacy::virtual_branches::upstream_integration_statuses(project.id, None) + .await?; + + if let Some(out) = out.for_json() { + let (up_to_date, has_worktree_conflicts, branch_statuses) = match &status { + UpToDate => (true, false, vec![]), + UpdatesRequired { + worktree_conflicts, + statuses, + } => { + let branch_statuses: Vec = statuses + .iter() + .flat_map(|(_id, stack_status)| { + stack_status.branch_statuses.iter().map(|bs| { + let (status_str, rebasable) = match bs.status { + SaflyUpdatable => ("updatable", None), + Integrated => ("integrated", None), + Conflicted { rebasable } => ("conflicted", Some(rebasable)), + Empty => ("empty", None), + }; + BranchStatusInfo { + name: bs.name.clone(), + status: status_str.to_string(), + rebasable, + } + }) + }) + .collect(); + (false, !worktree_conflicts.is_empty(), branch_statuses) + } + }; + + let output = BaseCheckOutput { + base_branch: BaseBranchInfo { + name: base_branch.branch_name.clone(), + remote_name: base_branch.remote_name.clone(), + base_sha: base_branch.base_sha.to_string(), + current_sha: base_branch.current_sha.to_string(), + }, + upstream_commits: UpstreamInfo { + count: base_branch.behind, + commits: base_branch + .recent_commits + .iter() + .map(|c| UpstreamCommit { + id: c.id.clone(), + description: c.description.to_string(), + author_name: c.author.name.clone(), + }) + .collect(), + }, + branch_statuses, + up_to_date, + has_worktree_conflicts, + }; + out.write_value(output)?; + } else if let Some(out) = out.for_human() { writeln!(out, "šŸ” Checking base branch status...")?; - let base_branch = but_api::legacy::virtual_branches::fetch_from_remotes( - project.id, - Some("auto".to_string()), - )?; writeln!(out, "\nšŸ“ Base branch:\t\t{}", base_branch.branch_name)?; writeln!( out, @@ -51,11 +153,6 @@ pub async fn handle( )?; } - let status = but_api::legacy::virtual_branches::upstream_integration_statuses( - project.id, None, - ) - .await?; - match status { UpToDate => { writeln!(out, "\nāœ… Everything is up to date")?; From 0a243b5a09a910ac9f5869d29d1d6d624a529eb4 Mon Sep 17 00:00:00 2001 From: Scott Chacon Date: Mon, 22 Dec 2025 20:44:01 +0100 Subject: [PATCH 2/5] Use upstream_commits for base check JSON output Fix the JSON output for the "but base check" command to report only actual upstream commits. The code previously used recent_commits (which lists recent commits regardless of whether they're new upstream commits), causing the reported count to mismatch the listed commits. Switched to upstream_commits so the commits array matches the behind count and reflects only new upstream commits. --- crates/but/src/command/legacy/base.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/but/src/command/legacy/base.rs b/crates/but/src/command/legacy/base.rs index c8c88f3865..907e0628e3 100644 --- a/crates/but/src/command/legacy/base.rs +++ b/crates/but/src/command/legacy/base.rs @@ -108,7 +108,7 @@ pub async fn handle( upstream_commits: UpstreamInfo { count: base_branch.behind, commits: base_branch - .recent_commits + .upstream_commits .iter() .map(|c| UpstreamCommit { id: c.id.clone(), From d398bb82c8c780dc6bfcdbec0e31e6e8524e38ed Mon Sep 17 00:00:00 2001 From: Scott Chacon Date: Mon, 22 Dec 2025 20:45:13 +0100 Subject: [PATCH 3/5] Fix upstream commit display in human output Human-readable output for the "Upstream commits" section incorrectly used recent_commits, causing it to show recent commits instead of actual upstream commits. Change the code to use upstream_commits for the human output so both JSON and human-readable views consistently show only true upstream commits. --- crates/but/src/command/legacy/base.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/but/src/command/legacy/base.rs b/crates/but/src/command/legacy/base.rs index 907e0628e3..eba1542a86 100644 --- a/crates/but/src/command/legacy/base.rs +++ b/crates/but/src/command/legacy/base.rs @@ -130,7 +130,7 @@ pub async fn handle( "ā« Upstream commits:\t{} new commits on {}\n", base_branch.behind, base_branch.branch_name )?; - let commits = base_branch.recent_commits.iter().take(3); + let commits = base_branch.upstream_commits.iter().take(3); for commit in commits { writeln!( out, From 4d09071a26c984f7b7f7f5ca58bceb9716583f41 Mon Sep 17 00:00:00 2001 From: Scott Chacon Date: Mon, 22 Dec 2025 20:46:50 +0100 Subject: [PATCH 4/5] Use ANSI colors for 'but base check' output Replace emoji-based output with ANSI colored text for clearer, more professional terminal display. Base branch names are shown in cyan; the upstream count is yellow for >0 and green for 0. Commit SHAs are yellow with descriptions dimmed. Status indicators are now textual tags with color: [ok] green, [integrated] blue, [conflict] red/yellow, and [empty] dimmed. Headers are bold and labels dimmed for a cleaner visual hierarchy. This improves readability and removes distracting emoji icons. --- crates/but/src/command/legacy/base.rs | 111 ++++++++++++++------------ 1 file changed, 61 insertions(+), 50 deletions(-) diff --git a/crates/but/src/command/legacy/base.rs b/crates/but/src/command/legacy/base.rs index eba1542a86..d77a36fb5a 100644 --- a/crates/but/src/command/legacy/base.rs +++ b/crates/but/src/command/legacy/base.rs @@ -123,39 +123,59 @@ pub async fn handle( }; out.write_value(output)?; } else if let Some(out) = out.for_human() { - writeln!(out, "šŸ” Checking base branch status...")?; - writeln!(out, "\nšŸ“ Base branch:\t\t{}", base_branch.branch_name)?; + writeln!(out, "{}", "Checking base branch status...".bold())?; writeln!( out, - "ā« Upstream commits:\t{} new commits on {}\n", + "\n{}\t{}", + "Base branch:".dimmed(), + base_branch.branch_name.cyan() + )?; + let upstream_label = format!( + "{} new commits on {}", base_branch.behind, base_branch.branch_name + ); + writeln!( + out, + "{}\t{}", + "Upstream:".dimmed(), + if base_branch.behind > 0 { + upstream_label.yellow() + } else { + upstream_label.green() + } )?; - let commits = base_branch.upstream_commits.iter().take(3); - for commit in commits { - writeln!( - out, - "\t{} {}", - &commit.id[..7], - &commit - .description - .to_string() - .replace('\n', " ") - .chars() - .take(72) - .collect::() - )?; - } - let hidden_commits = base_branch.behind.saturating_sub(3); - if hidden_commits > 0 { - writeln!( - out, - "\t... ({hidden_commits} more - run `but base check --all` to see all)" - )?; + + if !base_branch.upstream_commits.is_empty() { + writeln!(out)?; + let commits = base_branch.upstream_commits.iter().take(3); + for commit in commits { + writeln!( + out, + " {} {}", + commit.id[..7].yellow(), + commit + .description + .to_string() + .replace('\n', " ") + .chars() + .take(72) + .collect::() + .dimmed() + )?; + } + let hidden_commits = base_branch.behind.saturating_sub(3); + if hidden_commits > 0 { + writeln!( + out, + " {}", + format!("... ({hidden_commits} more)").dimmed() + )?; + } } match status { UpToDate => { - writeln!(out, "\nāœ… Everything is up to date")?; + writeln!(out, "\n{}", "Up to date".green().bold())?; } UpdatesRequired { worktree_conflicts, @@ -164,48 +184,39 @@ pub async fn handle( if !worktree_conflicts.is_empty() { writeln!( out, - "\nā—ļø There are uncommitted changes in the worktree that may conflict with the updates." + "\n{}", + "Warning: uncommitted changes may conflict with updates." + .yellow() + .bold() )?; } if !statuses.is_empty() { - writeln!(out, "\n{}", "Active Branch Status".bold())?; + writeln!(out, "\n{}", "Branch Status".bold())?; for (_id, status) in statuses { for bs in status.branch_statuses { - let status_icon = match bs.status { - SaflyUpdatable => "āœ…".to_string(), - Integrated => "šŸ”„".to_string(), - Conflicted { rebasable } => { - if rebasable { - "āš ļø".to_string() - } else { - "ā—ļø".to_string() - } - } - Empty => "āœ…".to_string(), - }; let status_text = match bs.status { - SaflyUpdatable => "Updatable".green(), - Integrated => "Integrated".blue(), + SaflyUpdatable => "[ok]".green(), + Integrated => "[integrated]".blue(), Conflicted { rebasable } => { if rebasable { - "Conflicted (Rebasable)".yellow() + "[conflict - rebasable]".yellow() } else { - "Conflicted (Not Rebasable)".red() + "[conflict]".red() } } - Empty => "Nothing to do".normal(), + Empty => "[empty]".dimmed(), }; - writeln!( - out, - "\n{} {} ({})", - status_icon, bs.name, status_text - )?; + writeln!(out, " {} {}", status_text, bs.name)?; } } } + writeln!( + out, + "\n{}", + "Run `but base update` to update your branches".dimmed() + )?; } } - writeln!(out, "\nRun `but base update` to update your branches")?; } Ok(()) } From 1ab016260cf3b73ee7554fb7dac3c6120eeeac91 Mon Sep 17 00:00:00 2001 From: Scott Chacon Date: Wed, 24 Dec 2025 09:43:05 +0100 Subject: [PATCH 5/5] Split base.rs into base/mod.rs and base/json.rs Move JSON serde structs into a dedicated json.rs module to separate concerns and improve organization. The JSON output structs (BaseCheckOutput, BaseBranchInfo, UpstreamInfo, UpstreamCommit, BranchStatusInfo) were moved and made pub(super) so mod.rs can access them. The old base.rs file was removed. --- crates/but/src/command/legacy/base/json.rs | 46 ++++++++++++++++ .../command/legacy/{base.rs => base/mod.rs} | 53 ++----------------- 2 files changed, 50 insertions(+), 49 deletions(-) create mode 100644 crates/but/src/command/legacy/base/json.rs rename crates/but/src/command/legacy/{base.rs => base/mod.rs} (90%) diff --git a/crates/but/src/command/legacy/base/json.rs b/crates/but/src/command/legacy/base/json.rs new file mode 100644 index 0000000000..6bcb69ccb0 --- /dev/null +++ b/crates/but/src/command/legacy/base/json.rs @@ -0,0 +1,46 @@ +//! JSON output structures for `but base` commands. + +use serde::Serialize; + +/// JSON output for `but base check` +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct BaseCheckOutput { + pub base_branch: BaseBranchInfo, + pub upstream_commits: UpstreamInfo, + pub branch_statuses: Vec, + pub up_to_date: bool, + pub has_worktree_conflicts: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct BaseBranchInfo { + pub name: String, + pub remote_name: String, + pub base_sha: String, + pub current_sha: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct UpstreamInfo { + pub count: usize, + pub commits: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct UpstreamCommit { + pub id: String, + pub description: String, + pub author_name: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct BranchStatusInfo { + pub name: String, + pub status: String, + pub rebasable: Option, +} diff --git a/crates/but/src/command/legacy/base.rs b/crates/but/src/command/legacy/base/mod.rs similarity index 90% rename from crates/but/src/command/legacy/base.rs rename to crates/but/src/command/legacy/base/mod.rs index d77a36fb5a..c5214377e5 100644 --- a/crates/but/src/command/legacy/base.rs +++ b/crates/but/src/command/legacy/base/mod.rs @@ -1,3 +1,5 @@ +mod json; + use base::Subcommands; use but_ctx::LegacyProject; use colored::Colorize; @@ -6,52 +8,9 @@ use gitbutler_branch_actions::upstream_integration::{ Resolution, ResolutionApproach, StackStatuses::{UpToDate, UpdatesRequired}, }; -use serde::Serialize; use crate::{args::base, utils::OutputChannel}; - -/// JSON output for `but base check` -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct BaseCheckOutput { - base_branch: BaseBranchInfo, - upstream_commits: UpstreamInfo, - branch_statuses: Vec, - up_to_date: bool, - has_worktree_conflicts: bool, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct BaseBranchInfo { - name: String, - remote_name: String, - base_sha: String, - current_sha: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct UpstreamInfo { - count: usize, - commits: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct UpstreamCommit { - id: String, - description: String, - author_name: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct BranchStatusInfo { - name: String, - status: String, - rebasable: Option, -} +use json::{BaseBranchInfo, BaseCheckOutput, BranchStatusInfo, UpstreamCommit, UpstreamInfo}; pub async fn handle( cmd: Subcommands, @@ -165,11 +124,7 @@ pub async fn handle( } let hidden_commits = base_branch.behind.saturating_sub(3); if hidden_commits > 0 { - writeln!( - out, - " {}", - format!("... ({hidden_commits} more)").dimmed() - )?; + writeln!(out, " {}", format!("... ({hidden_commits} more)").dimmed())?; } }