Skip to content

Commit f314ded

Browse files
committed
Show author and commit time for Git branches
1 parent fead159 commit f314ded

File tree

3 files changed

+134
-61
lines changed

3 files changed

+134
-61
lines changed

Cargo.lock

Lines changed: 21 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "git-del-branches"
3-
version = "0.4.0"
3+
version = "1.0.0"
44
authors = ["Nguyễn Hồng Quân <ng.hong.quan@gmail.com>"]
55
license = "GPL-3.0-or-later"
66
edition = "2024"
@@ -21,4 +21,6 @@ eyre = "0.6.12"
2121
format-bytes = "0.3.0"
2222
git2 = "0.20.2"
2323
git2_credentials = "0.15.0"
24+
human-units = "0.3.0"
2425
inquire = "0.7.5"
26+
verynicetable = "0.6.2"

src/main.rs

Lines changed: 110 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,106 @@
1+
use std::fmt;
2+
use std::time::{Duration, SystemTime};
3+
14
use clap::Parser;
25
use color_eyre::Result;
36
use console::{Emoji, style};
47
use eyre::Context;
5-
use git2::{Branch, BranchType, PushOptions, Remote, RemoteCallbacks, Repository};
8+
use git2::{Branch, BranchType, Error, PushOptions, Remote, RemoteCallbacks, Repository};
69
use git2_credentials::CredentialHandler;
10+
use human_units::FormatDuration;
711
use inquire::error::InquireError;
12+
use inquire::list_option::ListOption;
813
use inquire::ui::{RenderConfig, Styled};
914
use inquire::{Confirm, MultiSelect};
15+
use verynicetable::Table;
1016

1117
const EXCLUDES: &[&str] = &["master", "main", "develop", "development"];
1218

1319
#[derive(Parser)]
1420
#[command(author, version, about)]
1521
struct Cli {}
1622

17-
fn get_branches(repo: &Repository, names: Vec<String>) -> Vec<Branch> {
18-
names
19-
.into_iter()
20-
.filter_map(|n| repo.find_branch(&n, BranchType::Local).ok())
21-
.collect::<Vec<Branch>>()
23+
struct BranchChoice<'repo> {
24+
local: Branch<'repo>,
25+
upstream: Option<Branch<'repo>>,
26+
branch_name: String,
27+
author_name: Option<String>,
28+
commit_time: SystemTime,
29+
}
30+
31+
impl<'repo> fmt::Display for BranchChoice<'repo> {
32+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33+
let upstream = if self.upstream.is_some() {
34+
" (🔭)"
35+
} else {
36+
""
37+
};
38+
let author = self.author_name.as_deref().unwrap_or("no-name");
39+
let dur = SystemTime::now()
40+
.duration_since(self.commit_time)
41+
.unwrap_or_default();
42+
let ago = human_units::Duration(dur).format_duration();
43+
write!(
44+
f,
45+
"{}{} 🧒 {} ⏰ {} ago",
46+
self.branch_name, upstream, author, ago
47+
)
48+
}
2249
}
2350

24-
fn show_list_of_branches(branch_pairs: &Vec<(Branch, Option<Branch>)>) {
25-
let lines: Vec<String> = branch_pairs
51+
fn format_final_answers(opts: &[ListOption<&BranchChoice>]) -> String {
52+
let data: Vec<_> = opts
2653
.iter()
27-
.filter_map(|(lb, rb)| {
28-
let local_name = lb.name().ok()??;
29-
let upstream_name = rb.as_ref().and_then(|n| n.name().ok()).flatten();
30-
let line = match upstream_name {
31-
Some(name) => format!(" {local_name} ({name})"),
32-
None => format!(" {local_name}"),
33-
};
34-
Some(line)
54+
.map(|o| {
55+
let c = o.value;
56+
let remote_name = c
57+
.upstream
58+
.as_ref()
59+
.and_then(|b| b.name().ok())
60+
.flatten()
61+
.unwrap_or_default();
62+
let author = c.author_name.as_deref().unwrap_or_default();
63+
vec![c.branch_name.as_str(), author, remote_name]
3564
})
3665
.collect();
37-
eprintln!("{}", lines.join("\n"));
66+
let mut table = Table::new();
67+
table.headers(&["Local", "Author", "Remote"]).data(&data);
68+
format!("\n{table}")
69+
}
70+
71+
fn get_branch_choices(repo: &Repository) -> Result<Vec<BranchChoice>, Error> {
72+
let branches = repo.branches(Some(BranchType::Local))?;
73+
let mut choices: Vec<_> = branches
74+
.flatten()
75+
.filter_map(|(branch, _t)| {
76+
if branch.is_head() {
77+
return None;
78+
}
79+
let branch_name = branch.name().ok().flatten()?;
80+
if EXCLUDES.contains(&branch_name) {
81+
return None;
82+
}
83+
let branch_name = branch_name.to_string();
84+
let upstream = branch.upstream().ok();
85+
let commit = branch.get().peel_to_commit().ok()?;
86+
let secs = u64::try_from(commit.time().seconds()).unwrap_or_default();
87+
let commit_time = SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(secs))?;
88+
let author = commit.author();
89+
let author_name = author
90+
.name()
91+
.or_else(|| author.email().and_then(|s| s.split('@').next()))
92+
.map(|s| s.to_string());
93+
Some(BranchChoice {
94+
local: branch,
95+
upstream,
96+
branch_name,
97+
author_name,
98+
commit_time,
99+
})
100+
})
101+
.collect();
102+
choices.sort_unstable_by_key(|c| c.commit_time);
103+
Ok(choices)
38104
}
39105

40106
fn get_local_name<'a>(branch: &'a Branch) -> Option<&'a str> {
@@ -55,7 +121,7 @@ fn delete_upstream_branch(
55121
let msg = format!("Failed to delete upstream branch {}", branch_name);
56122
eprintln!("{} {}", Emoji("⚠️", "!"), style(msg).yellow());
57123
}
58-
branch.delete().ok()
124+
branch.delete().map_err(|e| eprintln!("{e}")).ok()
59125
}
60126

61127
fn get_render_config() -> RenderConfig<'static> {
@@ -71,23 +137,9 @@ fn main() -> Result<()> {
71137
Cli::parse();
72138
inquire::set_global_render_config(get_render_config());
73139
let repo = Repository::discover(".").wrap_err("Not a Git working folder")?;
74-
let branches = repo.branches(Some(BranchType::Local))?;
75140
let staying_in_branch = repo.head().ok().map(|r| r.is_branch()).unwrap_or(false);
76-
let names: Vec<String> = branches
77-
.flatten()
78-
.filter_map(|(branch, _type)| {
79-
if branch.is_head() {
80-
return None;
81-
}
82-
let n = branch.name().ok()??;
83-
if EXCLUDES.contains(&n) {
84-
None
85-
} else {
86-
Some(n.to_string())
87-
}
88-
})
89-
.collect();
90-
if names.is_empty() {
141+
let branch_choices = get_branch_choices(&repo)?;
142+
if branch_choices.is_empty() {
91143
eprintln!("No branches eligible to delete.");
92144
if staying_in_branch {
93145
eprintln!(
@@ -100,34 +152,33 @@ fn main() -> Result<()> {
100152
}
101153
return Ok(());
102154
}
103-
let ans_branches = match MultiSelect::new("Select branches to delete", names).prompt() {
155+
let ans_branches = match MultiSelect::new("Select branches to delete", branch_choices)
156+
.with_formatter(&format_final_answers)
157+
.prompt()
158+
{
104159
Ok(ans) => ans,
105160
Err(InquireError::OperationCanceled) => return Ok(()),
106161
Err(e) => return Err(e.into()),
107162
};
108-
let ans_up = match Confirm::new("Do you want to delete the upstream branches also")
163+
let ans_up = match Confirm::new("Do you want to delete the upstream branches also?")
109164
.with_default(false)
110165
.prompt()
111166
{
112167
Ok(ans) => ans,
113168
Err(InquireError::OperationCanceled) => return Ok(()),
114169
Err(e) => return Err(e.into()),
115170
};
116-
let msg = if ans_up {
117-
"To delete these branches and their upstream:"
118-
} else {
119-
"To delete these branches:"
171+
let ans_again = match Confirm::new("Ready to delete?")
172+
.with_default(false)
173+
.prompt()
174+
{
175+
Ok(ans) => ans,
176+
Err(InquireError::OperationCanceled) => return Ok(()),
177+
Err(e) => return Err(e.into()),
120178
};
121-
eprintln!("{}", style(msg).blue());
122-
let local_branches = get_branches(&repo, ans_branches);
123-
let branch_pairs: Vec<(Branch, Option<Branch>)> = local_branches
124-
.into_iter()
125-
.map(|b| {
126-
let upstream = b.upstream().ok();
127-
(b, upstream)
128-
})
129-
.collect();
130-
show_list_of_branches(&branch_pairs);
179+
if !ans_again {
180+
return Ok(());
181+
}
131182
let mut remote_callback = RemoteCallbacks::new();
132183
let git_config = git2::Config::open_default()?;
133184
let mut credential_handler = CredentialHandler::new(git_config);
@@ -146,9 +197,15 @@ fn main() -> Result<()> {
146197
let mut origin = repo.find_remote("origin").ok();
147198
let mut opts = PushOptions::new();
148199
opts.remote_callbacks(remote_callback);
149-
for (mut lb, rb) in branch_pairs {
150-
lb.delete().ok();
151-
if let Some((orig, branch)) = origin.as_mut().zip(rb) {
200+
for mut c in ans_branches {
201+
c.local
202+
.delete()
203+
.map_err(|e| eprintln!("{e}"))
204+
.unwrap_or_default();
205+
if !ans_up {
206+
continue;
207+
}
208+
if let Some((orig, branch)) = origin.as_mut().zip(c.upstream) {
152209
delete_upstream_branch(branch, orig, &mut opts);
153210
};
154211
}

0 commit comments

Comments
 (0)