use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
use tokio::process::Command;
const GIT_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone)]
pub struct GitInfo {
pub branch: String,
pub dirty: bool,
pub is_worktree: bool,
pub common_dir: Option<String>,
}
pub struct GitCache {
cache: HashMap<String, (Option<GitInfo>, Instant)>,
ttl_secs: u64,
}
impl Default for GitCache {
fn default() -> Self {
Self::new()
}
}
impl GitCache {
pub fn new() -> Self {
Self {
cache: HashMap::new(),
ttl_secs: 10,
}
}
pub async fn get_info(&mut self, dir: &str) -> Option<GitInfo> {
if let Some((info, ts)) = self.cache.get(dir) {
if ts.elapsed().as_secs() < self.ttl_secs {
return info.clone();
}
}
let info = fetch_git_info(dir).await;
self.cache
.insert(dir.to_string(), (info.clone(), Instant::now()));
info
}
pub fn get_cached(&self, dir: &str) -> Option<GitInfo> {
if let Some((info, ts)) = self.cache.get(dir) {
if ts.elapsed().as_secs() < self.ttl_secs {
return info.clone();
}
}
None
}
pub fn cleanup(&mut self) {
self.cache
.retain(|_, (_, ts)| ts.elapsed().as_secs() < self.ttl_secs * 3);
}
}
async fn fetch_git_info(dir: &str) -> Option<GitInfo> {
let branch = fetch_branch(dir).await?;
let (dirty, (is_worktree, common_dir)) =
tokio::join!(fetch_dirty(dir), fetch_worktree_info(dir));
Some(GitInfo {
branch,
dirty,
is_worktree,
common_dir,
})
}
async fn fetch_branch(dir: &str) -> Option<String> {
let output = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args(["-C", dir, "rev-parse", "--abbrev-ref", "HEAD"])
.output(),
)
.await
.ok()?
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
async fn fetch_dirty(dir: &str) -> bool {
let output = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args(["-C", dir, "status", "--porcelain"])
.output(),
)
.await;
match output {
Ok(Ok(o)) => !o.stdout.is_empty(),
_ => false,
}
}
async fn fetch_worktree_info(dir: &str) -> (bool, Option<String>) {
let results = tokio::join!(
tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args(["-C", dir, "rev-parse", "--git-dir"])
.output(),
),
tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args(["-C", dir, "rev-parse", "--git-common-dir"])
.output(),
),
);
match results {
(Ok(Ok(gd)), Ok(Ok(cd))) => {
let gd_str = String::from_utf8_lossy(&gd.stdout).trim().to_string();
let cd_str = String::from_utf8_lossy(&cd.stdout).trim().to_string();
let is_worktree = gd_str != cd_str;
let common_dir_path = std::path::Path::new(dir).join(&cd_str);
let common_dir = common_dir_path
.canonicalize()
.ok()
.map(|p| p.to_string_lossy().to_string())
.map(|s| {
s.strip_suffix("/.git")
.or_else(|| s.strip_suffix("/.git/"))
.unwrap_or(&s)
.to_string()
});
(is_worktree, common_dir)
}
_ => (false, None),
}
}
#[derive(Debug, Clone)]
pub struct WorktreeEntry {
pub path: String,
pub branch: Option<String>,
pub is_bare: bool,
pub is_main: bool,
}
pub async fn list_worktrees(repo_dir: &str) -> Vec<WorktreeEntry> {
let output = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args(["-C", repo_dir, "worktree", "list", "--porcelain"])
.output(),
)
.await;
match output {
Ok(Ok(o)) if o.status.success() => parse_worktree_list(&String::from_utf8_lossy(&o.stdout)),
_ => Vec::new(),
}
}
fn parse_worktree_list(output: &str) -> Vec<WorktreeEntry> {
let mut entries = Vec::new();
let mut current_path: Option<String> = None;
let mut current_branch: Option<String> = None;
let mut is_bare = false;
let mut is_first = true;
for line in output.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
if let Some(prev_path) = current_path.take() {
entries.push(WorktreeEntry {
path: prev_path,
branch: current_branch.take(),
is_bare,
is_main: entries.is_empty() && is_first,
});
is_first = false;
}
current_path = Some(path.to_string());
current_branch = None;
is_bare = false;
} else if let Some(branch_ref) = line.strip_prefix("branch ") {
current_branch = Some(
branch_ref
.strip_prefix("refs/heads/")
.unwrap_or(branch_ref)
.to_string(),
);
} else if line == "bare" {
is_bare = true;
} else if line == "detached" {
}
}
if let Some(path) = current_path.take() {
entries.push(WorktreeEntry {
path,
branch: current_branch.take(),
is_bare,
is_main: entries.is_empty() && is_first,
});
}
entries
}
#[derive(Debug, Clone, Default)]
pub struct DiffSummary {
pub files_changed: usize,
pub insertions: usize,
pub deletions: usize,
}
pub async fn fetch_diff_stat(dir: &str, base_branch: &str) -> Option<DiffSummary> {
if !is_safe_git_ref(base_branch) {
return None;
}
let diff_spec = format!("{}...HEAD", base_branch);
let output = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args(["-C", dir, "diff", "--shortstat", &diff_spec])
.output(),
)
.await
.ok()?
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
parse_shortstat(&text)
}
pub async fn fetch_branch_diff_stat(
dir: &str,
branch: &str,
base_branch: &str,
) -> Option<DiffSummary> {
if !is_safe_git_ref(base_branch) || !is_safe_git_ref(branch) {
return None;
}
let diff_spec = format!("{}...{}", base_branch, branch);
let output = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args(["-C", dir, "diff", "--shortstat", &diff_spec])
.output(),
)
.await
.ok()?
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
parse_shortstat(&text)
}
fn parse_shortstat(text: &str) -> Option<DiffSummary> {
let text = text.trim();
if text.is_empty() {
return None;
}
let mut summary = DiffSummary::default();
for part in text.split(',') {
let part = part.trim();
let num_str: String = part.chars().take_while(|c| c.is_ascii_digit()).collect();
let num: usize = num_str.parse().unwrap_or(0);
if part.contains("file") {
summary.files_changed = num;
} else if part.contains("insertion") {
summary.insertions = num;
} else if part.contains("deletion") {
summary.deletions = num;
}
}
Some(summary)
}
pub async fn fetch_full_diff(dir: &str, base_branch: &str) -> Option<String> {
if !is_safe_git_ref(base_branch) {
return None;
}
let diff_spec = format!("{}...HEAD", base_branch);
let output = tokio::time::timeout(
Duration::from_secs(10),
Command::new("git")
.args(["-C", dir, "diff", &diff_spec, "--stat", "--patch"])
.output(),
)
.await
.ok()?
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout).to_string();
if text.trim().is_empty() {
return None;
}
const MAX_DIFF_SIZE: usize = 100 * 1024;
if text.len() > MAX_DIFF_SIZE {
let mut truncated = text[..MAX_DIFF_SIZE].to_string();
truncated.push_str("\n\n... (diff truncated at 100KB) ...\n");
Some(truncated)
} else {
Some(text)
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct RemoteTrackingInfo {
pub remote_branch: String,
pub ahead: usize,
pub behind: usize,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct BranchListResult {
pub default_branch: String,
pub current_branch: Option<String>,
pub branches: Vec<String>,
#[serde(default)]
pub parents: HashMap<String, String>,
#[serde(default)]
pub ahead_behind: HashMap<String, (usize, usize)>,
#[serde(default)]
pub remote_tracking: HashMap<String, RemoteTrackingInfo>,
#[serde(default)]
pub remote_only_branches: Vec<String>,
pub last_fetch: Option<u64>,
#[serde(default)]
pub last_commit_times: HashMap<String, i64>,
}
pub async fn list_branches(repo_dir: &str) -> Option<BranchListResult> {
let output = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args(["-C", repo_dir, "branch", "--format=%(refname:short)"])
.output(),
)
.await
.ok()?
.ok()?;
if !output.status.success() {
return None;
}
let branches: Vec<String> = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let default_branch = detect_default_branch(repo_dir).await.unwrap_or_else(|| {
if branches.contains(&"main".to_string()) {
"main".to_string()
} else if branches.contains(&"master".to_string()) {
"master".to_string()
} else {
branches
.first()
.cloned()
.unwrap_or_else(|| "main".to_string())
}
});
let current_branch = fetch_branch(repo_dir).await;
let parents = compute_branch_parents(repo_dir, &branches, &default_branch).await;
let mut ab_map = HashMap::new();
for branch in &branches {
if branch == &default_branch {
continue;
}
if let Some((a, b)) = ahead_behind(repo_dir, branch, &default_branch).await {
ab_map.insert(branch.clone(), (a, b));
}
}
let remote_tracking = fetch_remote_tracking(repo_dir).await;
let remote_only_branches = fetch_remote_only_branches(repo_dir, &branches).await;
let last_fetch = fetch_head_time(repo_dir);
let last_commit_times =
fetch_last_commit_times(repo_dir, &branches, &remote_only_branches).await;
Some(BranchListResult {
default_branch,
current_branch,
branches,
parents,
ahead_behind: ab_map,
remote_tracking,
remote_only_branches,
last_fetch,
last_commit_times,
})
}
async fn fetch_remote_tracking(repo_dir: &str) -> HashMap<String, RemoteTrackingInfo> {
let output = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args([
"-C",
repo_dir,
"for-each-ref",
"--format=%(refname:short)\t%(upstream:short)\t%(upstream:track)",
"refs/heads/",
])
.output(),
)
.await
.ok()
.and_then(|r| r.ok());
let mut result = HashMap::new();
let Some(output) = output else {
return result;
};
if !output.status.success() {
return result;
}
for line in String::from_utf8_lossy(&output.stdout).lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 2 {
continue;
}
let branch = parts[0].trim();
let upstream = parts[1].trim();
let track = parts.get(2).map(|s| s.trim()).unwrap_or("");
if upstream.is_empty() {
continue;
}
let (ahead, behind) = parse_track(track);
result.insert(
branch.to_string(),
RemoteTrackingInfo {
remote_branch: upstream.to_string(),
ahead,
behind,
},
);
}
result
}
async fn fetch_remote_only_branches(repo_dir: &str, local_branches: &[String]) -> Vec<String> {
let output = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args(["-C", repo_dir, "branch", "-r", "--format=%(refname:short)"])
.output(),
)
.await
.ok()
.and_then(|r| r.ok());
let Some(output) = output else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let local_set: std::collections::HashSet<&str> =
local_branches.iter().map(|s| s.as_str()).collect();
String::from_utf8_lossy(&output.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| {
if s.is_empty() || s.contains("->") {
return false;
}
let short = s.split('/').skip(1).collect::<Vec<_>>().join("/");
!local_set.contains(short.as_str())
})
.collect()
}
fn parse_track(track: &str) -> (usize, usize) {
let mut ahead = 0usize;
let mut behind = 0usize;
let inner = track
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or("");
for part in inner.split(',') {
let part = part.trim();
if let Some(n) = part.strip_prefix("ahead ") {
ahead = n.trim().parse().unwrap_or(0);
} else if let Some(n) = part.strip_prefix("behind ") {
behind = n.trim().parse().unwrap_or(0);
}
}
(ahead, behind)
}
fn fetch_head_time(repo_dir: &str) -> Option<u64> {
let fetch_head = std::path::Path::new(repo_dir).join(".git/FETCH_HEAD");
let meta = std::fs::metadata(fetch_head).ok()?;
let modified = meta.modified().ok()?;
modified
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs())
}
async fn fetch_last_commit_times(
repo_dir: &str,
branches: &[String],
remote_only_branches: &[String],
) -> HashMap<String, i64> {
let mut result = HashMap::new();
if !branches.is_empty() {
if let Ok(Ok(output)) = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args([
"-C",
repo_dir,
"for-each-ref",
"--format=%(refname:short) %(committerdate:unix)",
"refs/heads/",
])
.output(),
)
.await
{
if output.status.success() {
for line in String::from_utf8_lossy(&output.stdout).lines() {
if let Some((name, ts_str)) = line.rsplit_once(' ') {
if let Ok(ts) = ts_str.parse::<i64>() {
result.insert(name.to_string(), ts);
}
}
}
}
}
}
if !remote_only_branches.is_empty() {
if let Ok(Ok(output)) = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args([
"-C",
repo_dir,
"for-each-ref",
"--format=%(refname:short) %(committerdate:unix)",
"refs/remotes/",
])
.output(),
)
.await
{
if output.status.success() {
let remote_set: std::collections::HashSet<&str> =
remote_only_branches.iter().map(|s| s.as_str()).collect();
for line in String::from_utf8_lossy(&output.stdout).lines() {
if let Some((name, ts_str)) = line.rsplit_once(' ') {
let short = name.strip_prefix("origin/").unwrap_or(name);
if remote_set.contains(short) {
if let Ok(ts) = ts_str.parse::<i64>() {
result.insert(short.to_string(), ts);
}
}
}
}
}
}
}
result
}
async fn compute_branch_parents(
repo_dir: &str,
branches: &[String],
default_branch: &str,
) -> HashMap<String, String> {
if branches.len() > 100 {
return HashMap::new();
}
let branch_set: HashSet<&str> = branches.iter().map(|s| s.as_str()).collect();
let mut join_set = tokio::task::JoinSet::new();
for branch in branches {
if branch == default_branch {
continue;
}
let branch = branch.clone();
let branches_owned: Vec<String> = branches.to_vec();
let default_branch_owned = default_branch.to_string();
let repo_dir_owned = repo_dir.to_string();
let branch_set_owned: HashSet<String> = branch_set.iter().map(|s| s.to_string()).collect();
join_set.spawn(async move {
let known: HashSet<&str> = branch_set_owned.iter().map(|s| s.as_str()).collect();
if let Some(parent) =
reflog_created_from(&repo_dir_owned, &branch, &known, &default_branch_owned).await
{
return (branch, parent);
}
let parent = find_closest_parent_branch(
&repo_dir_owned,
&branch,
&branches_owned,
&default_branch_owned,
)
.await;
(branch, parent)
});
}
let mut parents = HashMap::new();
while let Some(result) = join_set.join_next().await {
if let Ok((branch, parent)) = result {
parents.insert(branch, parent);
}
}
parents
}
async fn find_closest_parent_branch(
repo_dir: &str,
branch: &str,
branches: &[String],
default_branch: &str,
) -> String {
let ancestors = get_ancestor_branches(repo_dir, branch).await;
let candidates: Vec<&String> = branches
.iter()
.filter(|c| c.as_str() != branch && ancestors.contains(c.as_str()))
.collect();
if candidates.is_empty() {
return default_branch.to_string();
}
let futures: Vec<_> = candidates
.into_iter()
.map(|candidate| {
let repo = repo_dir.to_string();
let cand = candidate.clone();
let br = branch.to_string();
async move {
let count = git_output(
&repo,
&["rev-list", "--count", &format!("{}..{}", cand, br)],
)
.await
.and_then(|s| s.parse::<u32>().ok());
(cand, count)
}
})
.collect();
let results = futures_util::future::join_all(futures).await;
let mut best_parent = default_branch.to_string();
let mut best_count = u32::MAX;
for (candidate, count) in results {
if let Some(c) = count {
if c > 0 && c < best_count {
best_count = c;
best_parent = candidate;
}
}
}
best_parent
}
async fn get_ancestor_branches(repo_dir: &str, branch: &str) -> HashSet<String> {
let output = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args([
"-C",
repo_dir,
"for-each-ref",
&format!("--merged={}", branch),
"--format=%(refname:short)",
"refs/heads/",
])
.output(),
)
.await
.ok()
.and_then(|r| r.ok());
match output {
Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect(),
_ => HashSet::new(),
}
}
async fn git_output(repo_dir: &str, args: &[&str]) -> Option<String> {
let mut cmd_args = vec!["-C", repo_dir];
cmd_args.extend_from_slice(args);
tokio::time::timeout(GIT_TIMEOUT, Command::new("git").args(&cmd_args).output())
.await
.ok()
.and_then(|r| r.ok())
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
}
async fn reflog_created_from(
repo_dir: &str,
branch: &str,
known_branches: &HashSet<&str>,
default_branch: &str,
) -> Option<String> {
let output = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args(["-C", repo_dir, "reflog", "show", branch, "--format=%H %gs"])
.output(),
)
.await
.ok()?
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let last_line = text.lines().last()?;
let (sha, action) = last_line.split_once(' ')?;
let raw_source = action.strip_prefix("branch: Created from ")?.trim();
let source = raw_source.strip_prefix("refs/heads/").unwrap_or(raw_source);
if source == "HEAD" {
resolve_branch_at_commit(repo_dir, sha, branch, known_branches, default_branch).await
} else if known_branches.contains(source) {
Some(source.to_string())
} else {
None
}
}
async fn resolve_branch_at_commit(
repo_dir: &str,
sha: &str,
exclude_branch: &str,
known_branches: &HashSet<&str>,
default_branch: &str,
) -> Option<String> {
let output = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args([
"-C",
repo_dir,
"branch",
"--points-at",
sha,
"--format=%(refname:short)",
])
.output(),
)
.await
.ok()?
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let candidates: Vec<&str> = text
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && *l != exclude_branch && known_branches.contains(l))
.collect();
if candidates.is_empty() {
return None;
}
if candidates.contains(&default_branch) {
Some(default_branch.to_string())
} else {
Some(candidates[0].to_string())
}
}
async fn detect_default_branch(repo_dir: &str) -> Option<String> {
let output = tokio::time::timeout(
Duration::from_secs(3),
Command::new("git")
.args([
"-C",
repo_dir,
"symbolic-ref",
"refs/remotes/origin/HEAD",
"--short",
])
.output(),
)
.await
.ok()?
.ok()?;
if !output.status.success() {
return None;
}
let refname = String::from_utf8_lossy(&output.stdout).trim().to_string();
refname
.strip_prefix("origin/")
.map(|s| s.to_string())
.or(Some(refname))
.filter(|s| !s.is_empty())
}
pub fn is_valid_worktree_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
pub fn extract_claude_worktree_name(cwd: &str) -> Option<String> {
let marker = "/.claude/worktrees/";
let idx = cwd.find(marker)?;
let after = &cwd[idx + marker.len()..];
let name = after.split('/').next().filter(|s| !s.is_empty())?;
Some(name.to_string())
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CommitEntry {
pub sha: String,
pub subject: String,
pub body: String,
}
pub async fn log_commits(
repo_dir: &str,
base: &str,
branch: &str,
max_count: usize,
) -> Vec<CommitEntry> {
if !is_safe_git_ref(base) || !is_safe_git_ref(branch) {
return Vec::new();
}
let output = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args([
"-C",
repo_dir,
"log",
"--format=%H\t%s\t%b%x1e",
&format!("--max-count={}", max_count),
&format!("{}..{}", base, branch),
])
.output(),
)
.await
.ok()
.and_then(|r| r.ok());
match output {
Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.split('\x1e')
.filter_map(|entry| {
let entry = entry.trim();
if entry.is_empty() {
return None;
}
let mut parts = entry.splitn(3, '\t');
let sha = parts.next()?.trim().to_string();
let subject = parts.next()?.trim().to_string();
let body = parts.next().unwrap_or("").trim().to_string();
Some(CommitEntry { sha, subject, body })
})
.collect(),
_ => Vec::new(),
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct GraphCommit {
pub sha: String,
pub parents: Vec<String>,
pub refs: Vec<String>,
pub subject: String,
pub authored_date: i64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct GraphData {
pub commits: Vec<GraphCommit>,
pub total_count: usize,
}
pub async fn log_graph(repo_dir: &str, max_commits: usize) -> Option<GraphData> {
let output = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args([
"-C",
repo_dir,
"log",
"--all",
"--topo-order",
&format!("--max-count={}", max_commits),
"--format=%H\t%P\t%D\t%s\t%at",
])
.output(),
)
.await
.ok()
.and_then(|r| r.ok())?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let commits: Vec<GraphCommit> = stdout
.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() {
return None;
}
let mut parts = line.splitn(5, '\t');
let sha = parts.next()?.to_string();
let parents: Vec<String> = parts
.next()
.unwrap_or("")
.split_whitespace()
.map(|s| s.to_string())
.collect();
let refs: Vec<String> = parts
.next()
.unwrap_or("")
.split(", ")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let subject = parts.next().unwrap_or("").to_string();
let authored_date = parts.next().unwrap_or("0").parse::<i64>().unwrap_or(0);
Some(GraphCommit {
sha,
parents,
refs,
subject,
authored_date,
})
})
.collect();
let total_count = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args(["-C", repo_dir, "rev-list", "--all", "--count"])
.output(),
)
.await
.ok()
.and_then(|r| r.ok())
.and_then(|o| {
String::from_utf8_lossy(&o.stdout)
.trim()
.parse::<usize>()
.ok()
})
.unwrap_or(commits.len());
Some(GraphData {
commits,
total_count,
})
}
pub fn strip_git_suffix(path: &str) -> &str {
path.strip_suffix("/.git")
.or_else(|| path.strip_suffix("/.git/"))
.unwrap_or(path)
}
pub fn is_safe_git_ref(name: &str) -> bool {
!name.is_empty() && !name.starts_with('-')
}
pub fn repo_name_from_common_dir(common_dir: &str) -> String {
let stripped = common_dir
.strip_suffix("/.git")
.or_else(|| common_dir.strip_suffix("/.git/"))
.unwrap_or(common_dir);
let trimmed = stripped.trim_end_matches('/');
trimmed
.rsplit('/')
.next()
.filter(|s| !s.is_empty())
.unwrap_or(trimmed)
.to_string()
}
pub async fn delete_branch(
repo_dir: &str,
branch: &str,
force: bool,
delete_remote: bool,
) -> Result<(), String> {
if !is_safe_git_ref(branch) {
return Err("Invalid branch name".to_string());
}
let flag = if force { "-D" } else { "-d" };
let output = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args(["-C", repo_dir, "branch", flag, branch])
.output(),
)
.await
.map_err(|_| "Git command timed out".to_string())?
.map_err(|e| format!("Failed to run git: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(stderr.trim().to_string());
}
if delete_remote {
delete_remote_branch(repo_dir, branch).await;
}
Ok(())
}
async fn delete_remote_branch(repo_dir: &str, branch: &str) {
let _ = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args(["-C", repo_dir, "push", "origin", "--delete", branch])
.output(),
)
.await;
}
pub async fn checkout_branch(repo_dir: &str, branch: &str) -> Result<(), String> {
if !is_safe_git_ref(branch) {
return Err("Invalid branch name".to_string());
}
let output = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args(["-C", repo_dir, "checkout", branch])
.output(),
)
.await
.map_err(|_| "Git command timed out".to_string())?
.map_err(|e| format!("Failed to run git: {}", e))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(stderr.trim().to_string())
}
}
pub async fn create_branch(repo_dir: &str, name: &str, base: Option<&str>) -> Result<(), String> {
if !is_safe_git_ref(name) {
return Err("Invalid branch name".to_string());
}
if let Some(b) = base {
if !is_safe_git_ref(b) {
return Err("Invalid base branch name".to_string());
}
}
let mut args = vec!["-C", repo_dir, "branch", name];
if let Some(b) = base {
args.push(b);
}
let output = tokio::time::timeout(GIT_TIMEOUT, Command::new("git").args(&args).output())
.await
.map_err(|_| "Git command timed out".to_string())?
.map_err(|e| format!("Failed to run git: {}", e))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(stderr.trim().to_string())
}
}
pub async fn fetch_remote(repo_dir: &str, remote: Option<&str>) -> Result<String, String> {
let remote = remote.unwrap_or("origin");
if !is_safe_git_ref(remote) {
return Err("Invalid remote name".to_string());
}
let output = tokio::time::timeout(
Duration::from_secs(30), Command::new("git")
.args(["-C", repo_dir, "fetch", remote, "--prune"])
.output(),
)
.await
.map_err(|_| "Git fetch timed out".to_string())?
.map_err(|e| format!("Failed to run git: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.success() {
Ok(format!("{}{}", stdout.trim(), stderr.trim()))
} else {
Err(stderr.trim().to_string())
}
}
pub async fn pull(repo_dir: &str) -> Result<String, String> {
let output = tokio::time::timeout(
Duration::from_secs(30),
Command::new("git")
.args(["-C", repo_dir, "pull", "--ff-only"])
.output(),
)
.await
.map_err(|_| "Git pull timed out".to_string())?
.map_err(|e| format!("Failed to run git: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.success() {
Ok(stdout.trim().to_string())
} else {
Err(format!("{}\n{}", stdout.trim(), stderr.trim())
.trim()
.to_string())
}
}
pub async fn merge_branch(repo_dir: &str, branch: &str) -> Result<String, String> {
if !is_safe_git_ref(branch) {
return Err("Invalid branch name".to_string());
}
let output = tokio::time::timeout(
Duration::from_secs(15),
Command::new("git")
.args(["-C", repo_dir, "merge", branch, "--no-edit"])
.output(),
)
.await
.map_err(|_| "Git merge timed out".to_string())?
.map_err(|e| format!("Failed to run git: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.success() {
Ok(stdout.trim().to_string())
} else {
Err(format!("{}\n{}", stdout.trim(), stderr.trim())
.trim()
.to_string())
}
}
pub async fn ahead_behind(repo_dir: &str, branch: &str, base: &str) -> Option<(usize, usize)> {
if !is_safe_git_ref(branch) || !is_safe_git_ref(base) {
return None;
}
let output = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args([
"-C",
repo_dir,
"rev-list",
"--left-right",
"--count",
&format!("{}...{}", base, branch),
])
.output(),
)
.await
.ok()?
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let parts: Vec<&str> = text.trim().split('\t').collect();
if parts.len() == 2 {
let behind = parts[0].parse().ok()?;
let ahead = parts[1].parse().ok()?;
Some((ahead, behind))
} else {
None
}
}
pub async fn resolve_remote_head(repo_dir: &str) -> Option<String> {
let default_branch = detect_default_branch(repo_dir).await?;
let remote_ref = format!("origin/{}", default_branch);
let output = tokio::time::timeout(
GIT_TIMEOUT,
Command::new("git")
.args(["-C", repo_dir, "rev-parse", &remote_ref])
.output(),
)
.await
.ok()?
.ok()?;
if !output.status.success() {
return None;
}
let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
if sha.is_empty() {
None
} else {
Some(sha)
}
}
#[derive(Debug, Clone)]
pub struct RebaseResult {
pub branch: String,
pub worktree_path: String,
pub success: bool,
pub error: Option<String>,
}
pub async fn rebase_worktree_branches(
repo_dir: &str,
open_prs: &std::collections::HashMap<String, crate::github::PrInfo>,
) -> Vec<RebaseResult> {
let default_branch = match detect_default_branch(repo_dir).await {
Some(b) => b,
None => return Vec::new(),
};
let _ = tokio::time::timeout(
Duration::from_secs(30),
Command::new("git")
.args(["-C", repo_dir, "fetch", "origin", &default_branch])
.output(),
)
.await;
let worktrees = list_worktrees(repo_dir).await;
let mut results = Vec::new();
for wt in &worktrees {
if wt.is_main || wt.is_bare {
continue;
}
if extract_claude_worktree_name(&wt.path).is_none() {
continue;
}
let branch = match &wt.branch {
Some(b) => b.clone(),
None => continue, };
let pr = match open_prs.get(&branch) {
Some(pr) => pr,
None => continue,
};
if pr.base_branch != default_branch {
continue;
}
let behind =
match ahead_behind(&wt.path, &branch, &format!("origin/{}", default_branch)).await {
Some((_, behind)) if behind > 0 => behind,
_ => continue, };
tracing::info!(
branch = %branch,
behind = behind,
worktree = %wt.path,
"auto-rebasing worktree branch onto {}",
default_branch
);
let result = rebase_single_branch(&wt.path, &branch, &default_branch).await;
results.push(result);
}
results
}
async fn rebase_single_branch(
worktree_path: &str,
branch: &str,
default_branch: &str,
) -> RebaseResult {
let target = format!("origin/{}", default_branch);
let rebase_output = tokio::time::timeout(
Duration::from_secs(60),
Command::new("git")
.args(["-C", worktree_path, "rebase", &target])
.output(),
)
.await;
let rebase_ok = match &rebase_output {
Ok(Ok(o)) => o.status.success(),
_ => false,
};
if !rebase_ok {
let _ = tokio::time::timeout(
Duration::from_secs(10),
Command::new("git")
.args(["-C", worktree_path, "rebase", "--abort"])
.output(),
)
.await;
let error_msg = match rebase_output {
Ok(Ok(o)) => {
let stderr = String::from_utf8_lossy(&o.stderr);
let stdout = String::from_utf8_lossy(&o.stdout);
format!("{}\n{}", stdout.trim(), stderr.trim())
.trim()
.to_string()
}
Ok(Err(e)) => format!("Failed to run git: {}", e),
Err(_) => "Git rebase timed out".to_string(),
};
tracing::warn!(
branch = %branch,
worktree = %worktree_path,
error = %error_msg,
"rebase conflict, aborted"
);
return RebaseResult {
branch: branch.to_string(),
worktree_path: worktree_path.to_string(),
success: false,
error: Some(error_msg),
};
}
let push_output = tokio::time::timeout(
Duration::from_secs(30),
Command::new("git")
.args([
"-C",
worktree_path,
"push",
"--force-with-lease",
"origin",
branch,
])
.output(),
)
.await;
let push_ok = match &push_output {
Ok(Ok(o)) => o.status.success(),
_ => false,
};
if !push_ok {
let error_msg = match push_output {
Ok(Ok(o)) => {
let stderr = String::from_utf8_lossy(&o.stderr);
format!("force-push failed: {}", stderr.trim())
}
Ok(Err(e)) => format!("Failed to run git push: {}", e),
Err(_) => "Git push timed out".to_string(),
};
tracing::warn!(
branch = %branch,
worktree = %worktree_path,
error = %error_msg,
"rebase succeeded but push failed"
);
return RebaseResult {
branch: branch.to_string(),
worktree_path: worktree_path.to_string(),
success: false,
error: Some(error_msg),
};
}
tracing::info!(
branch = %branch,
worktree = %worktree_path,
"rebase + force-push succeeded"
);
RebaseResult {
branch: branch.to_string(),
worktree_path: worktree_path.to_string(),
success: true,
error: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_claude_worktree_name_valid() {
assert_eq!(
extract_claude_worktree_name("/home/user/my-app/.claude/worktrees/feature-a"),
Some("feature-a".to_string())
);
assert_eq!(
extract_claude_worktree_name("/home/user/my-app/.claude/worktrees/feature-a/src"),
Some("feature-a".to_string())
);
}
#[test]
fn test_extract_claude_worktree_name_invalid() {
assert_eq!(extract_claude_worktree_name("/home/user/my-app"), None);
assert_eq!(
extract_claude_worktree_name("/home/user/my-app/.claude/"),
None
);
assert_eq!(
extract_claude_worktree_name("/home/user/my-app/.claude/worktrees/"),
None
);
}
#[test]
fn test_repo_name_from_common_dir() {
assert_eq!(
repo_name_from_common_dir("/home/user/my-app/.git"),
"my-app"
);
assert_eq!(
repo_name_from_common_dir("/home/user/my-app/.git/"),
"my-app"
);
}
#[test]
fn test_repo_name_from_common_dir_no_git_suffix() {
assert_eq!(repo_name_from_common_dir("/home/user/my-app"), "my-app");
}
#[test]
fn test_repo_name_from_common_dir_bare() {
assert_eq!(repo_name_from_common_dir("my-repo/.git"), "my-repo");
}
#[test]
fn test_parse_worktree_list_normal() {
let output = "\
worktree /home/user/my-app
HEAD abc123def456
branch refs/heads/main
worktree /home/user/my-app/.claude/worktrees/feature-a
HEAD def456abc789
branch refs/heads/feature-a
";
let entries = parse_worktree_list(output);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].path, "/home/user/my-app");
assert_eq!(entries[0].branch.as_deref(), Some("main"));
assert!(!entries[0].is_bare);
assert!(entries[0].is_main);
assert_eq!(
entries[1].path,
"/home/user/my-app/.claude/worktrees/feature-a"
);
assert_eq!(entries[1].branch.as_deref(), Some("feature-a"));
assert!(!entries[1].is_bare);
assert!(!entries[1].is_main);
}
#[test]
fn test_parse_worktree_list_detached_head() {
let output = "\
worktree /home/user/my-app
HEAD abc123
branch refs/heads/main
worktree /home/user/my-app/.claude/worktrees/temp
HEAD def456
detached
";
let entries = parse_worktree_list(output);
assert_eq!(entries.len(), 2);
assert_eq!(entries[1].branch, None);
assert!(!entries[1].is_main);
}
#[test]
fn test_parse_worktree_list_bare_repo() {
let output = "\
worktree /home/user/bare-repo
HEAD abc123
bare
";
let entries = parse_worktree_list(output);
assert_eq!(entries.len(), 1);
assert!(entries[0].is_bare);
assert!(entries[0].is_main);
}
#[test]
fn test_parse_worktree_list_empty() {
let entries = parse_worktree_list("");
assert!(entries.is_empty());
}
#[test]
fn test_parse_worktree_list_single() {
let output = "\
worktree /home/user/project
HEAD abc123
branch refs/heads/main
";
let entries = parse_worktree_list(output);
assert_eq!(entries.len(), 1);
assert!(entries[0].is_main);
assert_eq!(entries[0].branch.as_deref(), Some("main"));
}
#[test]
fn test_parse_shortstat_normal() {
let input = " 3 files changed, 45 insertions(+), 12 deletions(-)\n";
let summary = parse_shortstat(input).unwrap();
assert_eq!(summary.files_changed, 3);
assert_eq!(summary.insertions, 45);
assert_eq!(summary.deletions, 12);
}
#[test]
fn test_parse_shortstat_insertions_only() {
let input = " 1 file changed, 10 insertions(+)\n";
let summary = parse_shortstat(input).unwrap();
assert_eq!(summary.files_changed, 1);
assert_eq!(summary.insertions, 10);
assert_eq!(summary.deletions, 0);
}
#[test]
fn test_parse_shortstat_deletions_only() {
let input = " 2 files changed, 5 deletions(-)\n";
let summary = parse_shortstat(input).unwrap();
assert_eq!(summary.files_changed, 2);
assert_eq!(summary.insertions, 0);
assert_eq!(summary.deletions, 5);
}
#[test]
fn test_parse_shortstat_empty() {
assert!(parse_shortstat("").is_none());
assert!(parse_shortstat(" \n").is_none());
}
#[test]
fn test_is_valid_worktree_name() {
assert!(is_valid_worktree_name("feature-auth"));
assert!(is_valid_worktree_name("fix_bug_123"));
assert!(is_valid_worktree_name("a"));
assert!(is_valid_worktree_name("my-worktree"));
assert!(!is_valid_worktree_name(""));
assert!(!is_valid_worktree_name("foo; rm -rf /"));
assert!(!is_valid_worktree_name("$(evil)"));
assert!(!is_valid_worktree_name("foo`whoami`"));
assert!(!is_valid_worktree_name("a|b"));
assert!(!is_valid_worktree_name("a&b"));
assert!(!is_valid_worktree_name("../../../etc"));
assert!(!is_valid_worktree_name("foo/bar"));
assert!(!is_valid_worktree_name("foo bar"));
assert!(!is_valid_worktree_name(&"a".repeat(65)));
assert!(is_valid_worktree_name(&"a".repeat(64)));
}
#[test]
fn test_strip_git_suffix() {
assert_eq!(
strip_git_suffix("/home/user/my-app/.git"),
"/home/user/my-app"
);
assert_eq!(
strip_git_suffix("/home/user/my-app/.git/"),
"/home/user/my-app"
);
assert_eq!(strip_git_suffix("/home/user/my-app"), "/home/user/my-app");
assert_eq!(strip_git_suffix(""), "");
}
#[test]
fn test_is_safe_git_ref() {
assert!(is_safe_git_ref("main"));
assert!(is_safe_git_ref("feature/auth"));
assert!(is_safe_git_ref("v1.0"));
assert!(!is_safe_git_ref(""));
assert!(!is_safe_git_ref("-flag"));
assert!(!is_safe_git_ref("--exec=evil"));
}
#[tokio::test]
async fn test_log_graph_returns_data_for_this_repo() {
let repo = env!("CARGO_MANIFEST_DIR");
let result = log_graph(repo, 10).await;
assert!(result.is_some());
let data = result.unwrap();
assert!(!data.commits.is_empty());
assert!(!data.commits[0].sha.is_empty());
assert!(data.commits[0].authored_date > 0);
}
#[tokio::test]
async fn test_log_graph_invalid_dir_returns_none() {
let result = log_graph("/nonexistent/path", 10).await;
assert!(result.is_none());
}
#[tokio::test]
async fn test_fetch_last_commit_times_returns_timestamps() {
let repo = env!("CARGO_MANIFEST_DIR");
let branches_output = Command::new("git")
.args(["-C", repo, "branch", "--format=%(refname:short)"])
.output()
.await
.unwrap();
let branches: Vec<String> = String::from_utf8_lossy(&branches_output.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let times = fetch_last_commit_times(repo, &branches, &[]).await;
assert!(
!times.is_empty(),
"should have at least one branch timestamp"
);
for ts in times.values() {
assert!(*ts > 0, "timestamps should be positive Unix seconds");
}
}
#[tokio::test]
async fn test_fetch_last_commit_times_invalid_dir() {
let times = fetch_last_commit_times("/nonexistent/path", &["main".to_string()], &[]).await;
assert!(times.is_empty());
}
#[tokio::test]
async fn test_list_branches_includes_commit_times() {
let repo = env!("CARGO_MANIFEST_DIR");
let result = list_branches(repo).await;
assert!(result.is_some());
let data = result.unwrap();
assert!(
!data.last_commit_times.is_empty(),
"last_commit_times should not be empty"
);
assert!(
data.last_commit_times.contains_key(&data.default_branch),
"default branch should have a commit time"
);
}
#[tokio::test]
async fn test_compute_branch_parents_returns_map() {
let repo = env!("CARGO_MANIFEST_DIR");
let branches_output = Command::new("git")
.args(["-C", repo, "branch", "--format=%(refname:short)"])
.output()
.await
.unwrap();
let branches: Vec<String> = String::from_utf8_lossy(&branches_output.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if branches.len() < 2 {
return;
}
let default_branch = detect_default_branch(repo)
.await
.unwrap_or_else(|| "main".to_string());
let parents = compute_branch_parents(repo, &branches, &default_branch).await;
assert!(
!parents.contains_key(&default_branch),
"default branch should not appear as key"
);
for branch in &branches {
if branch != &default_branch {
assert!(
parents.contains_key(branch),
"branch {} should have a parent",
branch
);
}
}
}
#[tokio::test]
async fn test_compute_branch_parents_empty_branches() {
let repo = env!("CARGO_MANIFEST_DIR");
let parents = compute_branch_parents(repo, &[], "main").await;
assert!(parents.is_empty());
}
#[tokio::test]
async fn test_compute_branch_parents_skips_over_limit() {
let repo = env!("CARGO_MANIFEST_DIR");
let branches: Vec<String> = (0..101).map(|i| format!("branch-{}", i)).collect();
let parents = compute_branch_parents(repo, &branches, "main").await;
assert!(
parents.is_empty(),
"should return empty map for >100 branches"
);
}
#[tokio::test]
async fn test_get_ancestor_branches_returns_self() {
let repo = env!("CARGO_MANIFEST_DIR");
let current = fetch_branch(repo).await.expect("should be on a branch");
let ancestors = get_ancestor_branches(repo, ¤t).await;
assert!(
ancestors.contains(¤t),
"ancestors should include the branch itself"
);
}
#[tokio::test]
async fn test_get_ancestor_branches_invalid_dir() {
let ancestors = get_ancestor_branches("/nonexistent/path", "main").await;
assert!(ancestors.is_empty());
}
#[tokio::test]
async fn test_find_closest_parent_branch_defaults() {
let parent = find_closest_parent_branch(
"/nonexistent/path",
"feature",
&["main".to_string(), "feature".to_string()],
"main",
)
.await;
assert_eq!(parent, "main");
}
#[tokio::test]
async fn test_resolve_remote_head_invalid_dir() {
let result = resolve_remote_head("/nonexistent/path").await;
assert!(result.is_none());
}
#[tokio::test]
async fn test_rebase_worktree_branches_no_worktrees() {
let prs = std::collections::HashMap::new();
let results = rebase_worktree_branches("/nonexistent/path", &prs).await;
assert!(results.is_empty());
}
#[tokio::test]
async fn test_rebase_worktree_branches_empty_prs() {
let repo = env!("CARGO_MANIFEST_DIR");
let prs = std::collections::HashMap::new();
let results = rebase_worktree_branches(repo, &prs).await;
assert!(results.is_empty());
}
#[test]
fn test_rebase_result_fields() {
let result = RebaseResult {
branch: "feat-x".to_string(),
worktree_path: "/tmp/wt".to_string(),
success: true,
error: None,
};
assert!(result.success);
assert!(result.error.is_none());
assert_eq!(result.branch, "feat-x");
let fail_result = RebaseResult {
branch: "feat-y".to_string(),
worktree_path: "/tmp/wt2".to_string(),
success: false,
error: Some("CONFLICT in file.rs".to_string()),
};
assert!(!fail_result.success);
assert!(fail_result.error.unwrap().contains("CONFLICT"));
}
}