Skip to content

Commit 34370e0

Browse files
committed
added stats and auto-cleanup
1 parent 98577ff commit 34370e0

3 files changed

Lines changed: 188 additions & 86 deletions

File tree

src/git.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,37 @@ pub(crate) fn git_sha(branch: &str) -> Result<String> {
127127
run_git(&["rev-parse", branch])?.output_or("No sha found")
128128
}
129129

130+
/// Get diff stats (additions, deletions) between two commits.
131+
/// Runs: git log --numstat --pretty="" <base>..<head>
132+
pub(crate) fn git_diff_stats(base: &str, head: &str) -> Result<(usize, usize)> {
133+
let start = std::time::Instant::now();
134+
let range = format!("{}..{}", base, head);
135+
let output = run_git(&["log", "--numstat", "--pretty=", &range])?;
136+
137+
let mut additions = 0usize;
138+
let mut deletions = 0usize;
139+
140+
for line in output.stdout.lines() {
141+
// Format: "additions\tdeletions\tfilename" or "-\t-\tbinary"
142+
let parts: Vec<&str> = line.split('\t').collect();
143+
if parts.len() >= 2
144+
&& let (Ok(add), Ok(del)) = (parts[0].parse::<usize>(), parts[1].parse::<usize>())
145+
{
146+
additions += add;
147+
deletions += del;
148+
}
149+
// Skip binary files (shown as "-\t-")
150+
}
151+
152+
tracing::debug!(
153+
"git_diff_stats({}, {}) took {:?}",
154+
base,
155+
head,
156+
start.elapsed()
157+
);
158+
Ok((additions, deletions))
159+
}
160+
130161
pub(crate) fn git_branch_status(
131162
parent_branch: Option<&str>,
132163
branch: &str,

src/main.rs

Lines changed: 114 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use git::{
1010
after_text,
1111
git_branch_status,
1212
git_checkout_main,
13+
git_diff_stats,
1314
git_fetch,
1415
git_get_upstream,
1516
git_remote_main,
@@ -33,7 +34,7 @@ const CREATE_BACKUP: bool = false;
3334
#[derive(Parser)]
3435
#[command(author, version, about)]
3536
struct Args {
36-
#[arg(long, short, help = "Enable verbose output")]
37+
#[arg(long, short, global = true, help = "Enable verbose output")]
3738
verbose: bool,
3839

3940
/// Subcommand to run.
@@ -185,7 +186,7 @@ fn inner_main() -> Result<()> {
185186
}
186187
Some(Command::Status { fetch }) => {
187188
state.try_auto_mount(&repo, &current_branch)?;
188-
status(state, &repo, &current_branch, fetch)
189+
status(state, &repo, &current_branch, fetch, args.verbose)
189190
}
190191
Some(Command::Delete { branch_name }) => state.delete_branch(&repo, &branch_name),
191192
Some(Command::Cleanup { dry_run, all }) => {
@@ -212,7 +213,7 @@ fn inner_main() -> Result<()> {
212213
}
213214
None => {
214215
state.try_auto_mount(&repo, &current_branch)?;
215-
status(state, &repo, &current_branch, false)
216+
status(state, &repo, &current_branch, false, args.verbose)
216217
}
217218
}
218219
}
@@ -279,6 +280,7 @@ fn recur_tree(
279280
depth: usize,
280281
orig_branch: &str,
281282
parent_branch: Option<&str>,
283+
verbose: bool,
282284
) -> Result<()> {
283285
let Ok(branch_status) = git_branch_status(parent_branch, &branch.name).with_context(|| {
284286
format!(
@@ -301,84 +303,116 @@ fn recur_tree(
301303
print!("{}", "┃ ".truecolor(55, 55, 50));
302304
}
303305

304-
println!(
305-
"{} ({}) {}{}{}{}",
306-
match (is_current_branch, branch_status.is_descendent) {
307-
(true, true) => branch.name.truecolor(142, 192, 124).bold(),
308-
(true, false) => branch.name.truecolor(215, 153, 33).bold(),
309-
(false, true) => branch.name.truecolor(142, 192, 124),
310-
(false, false) => branch.name.truecolor(215, 153, 33),
311-
},
312-
branch_status.sha[..8].truecolor(215, 153, 33),
313-
{
314-
let details: String = if branch_status.exists {
315-
if branch_status.is_descendent {
316-
format!(
317-
"{} {}",
318-
"is stacked on".truecolor(90, 120, 87),
319-
branch_status.parent_branch.yellow()
320-
)
306+
// Branch name coloring: green for synced, red for diverged, bold for current branch
307+
let branch_name_colored = match (is_current_branch, branch_status.is_descendent) {
308+
(true, true) => branch.name.truecolor(142, 192, 124).bold(),
309+
(true, false) => branch.name.red().bold(),
310+
(false, true) => branch.name.truecolor(142, 192, 124),
311+
(false, false) => branch.name.red(),
312+
};
313+
314+
// Get diff stats from LKG ancestor to current branch
315+
let diff_stats = if let Some(lkg_parent) = branch.lkg_parent.as_ref() {
316+
match git_diff_stats(lkg_parent, &branch_status.sha) {
317+
Ok((adds, dels)) => format!(
318+
" {} {}",
319+
format!("+{}", adds).green(),
320+
format!("-{}", dels).red()
321+
),
322+
Err(_) => String::new(), // Silently skip on error
323+
}
324+
} else {
325+
String::new() // No LKG = no stats (e.g., trunk root)
326+
};
327+
328+
if verbose {
329+
println!(
330+
"{}{} ({}) {}{}{}{}",
331+
branch_name_colored,
332+
diff_stats,
333+
branch_status.sha[..8].truecolor(215, 153, 33),
334+
{
335+
let details: String = if branch_status.exists {
336+
if branch_status.is_descendent {
337+
format!(
338+
"{} {}",
339+
"is stacked on".truecolor(90, 120, 87),
340+
branch_status.parent_branch.yellow()
341+
)
342+
} else {
343+
format!(
344+
"{} {}",
345+
"diverges from".red(),
346+
branch_status.parent_branch.yellow()
347+
)
348+
}
321349
} else {
350+
"does not exist!".bright_red().to_string()
351+
};
352+
details
353+
},
354+
{
355+
if let Some(upstream_status) = branch_status.upstream_status {
322356
format!(
323-
"{} {}",
324-
"diverges from".red(),
325-
branch_status.parent_branch.yellow()
357+
" (upstream {} is {})",
358+
upstream_status.symbolic_name.truecolor(88, 88, 88),
359+
if upstream_status.synced {
360+
"synced".truecolor(142, 192, 124)
361+
} else {
362+
"not synced".bright_red()
363+
}
326364
)
365+
} else {
366+
format!(" ({})", "no upstream".truecolor(215, 153, 33))
327367
}
328-
} else {
329-
"does not exist!".bright_red().to_string()
330-
};
331-
details
332-
},
333-
{
334-
if let Some(upstream_status) = branch_status.upstream_status {
335-
format!(
336-
" (upstream {} is {})",
337-
upstream_status.symbolic_name.truecolor(88, 88, 88),
338-
if upstream_status.synced {
339-
"synced".truecolor(142, 192, 124)
340-
} else {
341-
"not synced".bright_red()
342-
}
343-
)
344-
} else {
345-
format!(" ({})", "no upstream".truecolor(215, 153, 33))
346-
}
347-
},
348-
{
349-
if let Some(lkg_parent) = branch.lkg_parent.as_ref() {
350-
format!(" (lkg parent {})", lkg_parent[..8].truecolor(215, 153, 33))
351-
} else {
352-
String::new()
368+
},
369+
{
370+
if let Some(lkg_parent) = branch.lkg_parent.as_ref() {
371+
format!(" (lkg parent {})", lkg_parent[..8].truecolor(215, 153, 33))
372+
} else {
373+
String::new()
374+
}
375+
},
376+
match branch.stack_method {
377+
StackMethod::ApplyMerge => " (apply-merge)".truecolor(142, 192, 124),
378+
StackMethod::Merge => " (merge)".truecolor(142, 192, 124),
379+
},
380+
);
381+
if let Some(note) = &branch.note {
382+
print!(" ");
383+
for _ in 0..depth {
384+
print!("{}", "┃ ".truecolor(55, 55, 50));
353385
}
354-
},
355-
match branch.stack_method {
356-
StackMethod::ApplyMerge => " (apply-merge)".truecolor(142, 192, 124),
357-
StackMethod::Merge => " (merge)".truecolor(142, 192, 124),
358-
},
359-
);
360-
if let Some(note) = &branch.note {
361-
print!(" ");
362-
for _ in 0..depth {
363-
print!("{}", "┃ ".truecolor(55, 55, 50));
364-
}
365386

366-
let first_line = note.lines().next().unwrap_or("");
367-
println!(
368-
" {} {}",
369-
"›".truecolor(55, 55, 50),
370-
if is_current_branch {
371-
first_line.bright_blue().bold()
372-
} else {
373-
first_line.blue()
374-
}
375-
);
387+
let first_line = note.lines().next().unwrap_or("");
388+
println!(
389+
" {} {}",
390+
"›".truecolor(55, 55, 50),
391+
if is_current_branch {
392+
first_line.bright_blue().bold()
393+
} else {
394+
first_line.blue()
395+
}
396+
);
397+
}
398+
} else {
399+
println!("{}{}", branch_name_colored, diff_stats);
376400
}
377401

378402
let mut branches_sorted = branch.branches.iter().collect::<Vec<_>>();
403+
// Pre-compute is_ancestor results to avoid repeated git merge-base calls during sorting
404+
let ancestor_cache: std::collections::HashMap<&str, bool> = branches_sorted
405+
.iter()
406+
.map(|b| {
407+
(
408+
b.name.as_str(),
409+
is_ancestor(&b.name, orig_branch).unwrap_or(false),
410+
)
411+
})
412+
.collect();
379413
branches_sorted.sort_by(|&a, &b| {
380-
let a_is_ancestor = is_ancestor(&a.name, orig_branch).unwrap_or(false);
381-
let b_is_ancestor = is_ancestor(&b.name, orig_branch).unwrap_or(false);
414+
let a_is_ancestor = ancestor_cache.get(a.name.as_str()).copied().unwrap_or(false);
415+
let b_is_ancestor = ancestor_cache.get(b.name.as_str()).copied().unwrap_or(false);
382416
match (a_is_ancestor, b_is_ancestor) {
383417
(true, true) => a.name.cmp(&b.name),
384418
(true, false) => std::cmp::Ordering::Less,
@@ -387,25 +421,23 @@ fn recur_tree(
387421
}
388422
});
389423
for child in branches_sorted {
390-
recur_tree(child, depth + 1, orig_branch, Some(branch.name.as_ref()))?;
424+
recur_tree(child, depth + 1, orig_branch, Some(branch.name.as_ref()), verbose)?;
391425
}
392426
Ok(())
393427
}
394428

395-
fn status(mut state: State, repo: &str, orig_branch: &str, fetch: bool) -> Result<()> {
429+
fn status(mut state: State, repo: &str, orig_branch: &str, fetch: bool, verbose: bool) -> Result<()> {
396430
if fetch {
397431
git_fetch()?;
398432
}
399-
let trunk = state.ensure_trunk(repo)?;
433+
// ensure_trunk creates the tree if it doesn't exist
434+
let _trunk = state.ensure_trunk(repo)?;
400435

401-
let Some(tree) = state.get_tree_mut(repo) else {
402-
eprintln!(
403-
"No stack tree found for repo {repo}.",
404-
repo = repo.truecolor(178, 178, 218)
405-
);
406-
return Ok(());
407-
};
408-
recur_tree(tree, 0, orig_branch, None)?;
436+
// Auto-cleanup any missing branches before displaying the tree
437+
state.auto_cleanup_missing_branches(repo)?;
438+
439+
let tree = state.get_tree_mut(repo).expect("tree exists after ensure_trunk");
440+
recur_tree(tree, 0, orig_branch, None, verbose)?;
409441
if !state.branch_exists_in_tree(repo, orig_branch) {
410442
eprintln!(
411443
"The current branch {} is not in the stack tree.",

src/state.rs

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,44 @@ impl State {
252252
}
253253
}
254254

255+
/// Auto-cleanup missing branches silently during status display.
256+
/// Returns true if any branches were cleaned up.
257+
pub(crate) fn auto_cleanup_missing_branches(&mut self, repo: &str) -> Result<bool> {
258+
let Some(tree) = self.trees.get_mut(repo) else {
259+
return Ok(false);
260+
};
261+
262+
let mut removed_branches = Vec::new();
263+
let mut remounted_branches = Vec::new();
264+
265+
cleanup_tree_recursive(tree, &mut removed_branches, &mut remounted_branches);
266+
267+
if removed_branches.is_empty() {
268+
return Ok(false);
269+
}
270+
271+
// Print brief summary of auto-cleanup
272+
for branch_name in &removed_branches {
273+
println!(
274+
"{} {} (branch no longer exists)",
275+
"Auto-removed:".truecolor(90, 90, 90),
276+
branch_name.red()
277+
);
278+
}
279+
for (branch_name, new_parent) in &remounted_branches {
280+
println!(
281+
"{} {} {} {}",
282+
"Auto-remounted:".truecolor(90, 90, 90),
283+
branch_name.yellow(),
284+
"→".truecolor(90, 90, 90),
285+
new_parent.green()
286+
);
287+
}
288+
println!();
289+
290+
Ok(true)
291+
}
292+
255293
fn cleanup_single_tree(&mut self, repo: &str, dry_run: bool) -> Result<()> {
256294
let Some(tree) = self.trees.get_mut(repo) else {
257295
println!("No stack tree found for repo {}", repo.yellow());
@@ -592,6 +630,9 @@ impl State {
592630
/// Returns Ok(true) if the branch was auto-mounted, Ok(false) if it was already in the tree,
593631
/// or Err if auto-mount failed.
594632
pub(crate) fn try_auto_mount(&mut self, repo: &str, branch_name: &str) -> Result<bool> {
633+
// Ensure the tree exists for this repo
634+
self.ensure_trunk(repo)?;
635+
595636
// Check if the branch is already in the tree
596637
if self.branch_exists_in_tree(repo, branch_name) {
597638
return Ok(false);
@@ -602,10 +643,8 @@ impl State {
602643
bail!("Branch {branch_name} does not exist in git");
603644
}
604645

605-
// Get the tree for this repo
606-
let Some(tree) = self.get_tree(repo) else {
607-
bail!("No tree found for repo {repo}");
608-
};
646+
// Get the tree for this repo (guaranteed to exist after ensure_trunk)
647+
let tree = self.get_tree(repo).expect("tree exists after ensure_trunk");
609648

610649
// Collect all mounted branches
611650
let mut all_branches = Vec::new();

0 commit comments

Comments
 (0)