use crate::error::{GwmError, Result};
use crate::github::{self, BranchLink, IssueState, PrState};
use git2::{BranchType, Repository, StatusOptions, WorktreeAddOptions, WorktreePruneOptions};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{LazyLock, Mutex, MutexGuard};
use std::time::Duration;
const TRUNK_CANDIDATES: &[&str] = &["main", "master", "dev"];
const COMMON_TRUNKS: &[&str] = &["main", "master", "dev", "develop", "trunk"];
const BRANCH_CREATED_AT_CONFIG_KEY: &str = "gwm-created-at";
const RECENT_COMMITS_CACHE_MAX_ENTRIES: usize = 64;
type RecentCommitCacheKey = (PathBuf, git2::Oid, usize);
static RECENT_COMMITS_CACHE: LazyLock<Mutex<HashMap<RecentCommitCacheKey, Vec<CommitRow>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Debug, Clone)]
pub struct WorktreeInfo {
pub name: String,
pub id: String,
pub path: PathBuf,
pub branch: Option<String>,
pub head: Option<String>,
pub is_main: bool,
pub is_locked: bool,
pub is_prunable: bool,
pub status: BranchStatus,
pub link: BranchLink,
pub issue_state: Option<IssueState>,
pub pr_state: Option<PrState>,
pub age: Option<Duration>,
}
#[cfg(test)]
mod tests {
use super::parse_git_log_with_author_output;
#[test]
fn parse_git_log_error_includes_invalid_commit_oid_text() {
let err = parse_git_log_with_author_output("not-an-oid\u{0}Ada\u{0}\u{0}subject\n").unwrap_err();
let rendered = err.to_string();
assert!(
rendered.contains("not-an-oid"),
"invalid commit oid should be included in the error, got: {}",
rendered
);
}
#[test]
fn parse_git_log_error_includes_invalid_parent_oid_text() {
let raw = "0123456789abcdef0123456789abcdef01234567\u{0}Ada\u{0}bad-parent\u{0}subject\n";
let err = parse_git_log_with_author_output(raw).unwrap_err();
let rendered = err.to_string();
assert!(
rendered.contains("bad-parent"),
"invalid parent oid should be included in the error, got: {}",
rendered
);
}
}
#[derive(Debug, Clone, Default)]
pub struct BranchStatus {
pub is_dirty: bool,
pub has_upstream: bool,
pub ahead: usize,
pub behind: usize,
pub unknown: bool,
}
impl BranchStatus {
pub fn synced(&self) -> bool {
self.has_upstream && self.ahead == 0 && self.behind == 0
}
}
pub fn is_dirty(repo: &Repository) -> Result<bool> {
let mut opts = StatusOptions::new();
opts
.include_untracked(true)
.include_ignored(false)
.recurse_untracked_dirs(true);
let statuses = repo.statuses(Some(&mut opts))?;
Ok(!statuses.is_empty())
}
fn compute_status(repo: &Repository) -> BranchStatus {
let mut out = BranchStatus::default();
match is_dirty(repo) {
Ok(dirty) => out.is_dirty = dirty,
Err(_) => out.unknown = true,
}
if let Ok(head_ref) = repo.head() {
if let Ok(shorthand) = head_ref.shorthand() {
if let Ok(local_branch) = repo.find_branch(shorthand, BranchType::Local) {
if let Ok(upstream) = local_branch.upstream() {
if let (Some(local_oid), Some(up_oid)) = (head_ref.target(), upstream.into_reference().target()) {
out.has_upstream = true;
if let Ok((ahead, behind)) = repo.graph_ahead_behind(local_oid, up_oid) {
out.ahead = ahead;
out.behind = behind;
}
}
}
}
}
}
out
}
pub fn discover_repo(start: Option<&Path>) -> Result<Repository> {
let from = match start {
Some(p) => p.to_path_buf(),
None => std::env::current_dir()?,
};
let repo = Repository::discover(&from).map_err(|_| GwmError::NotInGitRepo)?;
if repo.is_worktree() {
let wt_admin = repo.path().to_path_buf();
if let Some(git_dir) = wt_admin.parent().and_then(|p| p.parent()) {
if let Some(main_workdir) = git_dir.parent() {
if let Ok(main) = Repository::open(main_workdir) {
return Ok(main);
}
}
}
}
Ok(repo)
}
pub fn repo_name(repo: &Repository) -> String {
repo
.workdir()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "repo".into())
}
pub fn list(repo: &Repository) -> Result<Vec<WorktreeInfo>> {
let mut out = Vec::new();
let parser = crate::naming::BranchParser::for_repo(repo);
if let Some(workdir) = repo.workdir() {
let head_ref = repo.head().ok();
let branch = head_ref
.as_ref()
.and_then(|r| r.shorthand().ok().map(|s| s.to_string()));
let head = head_ref.as_ref().and_then(|r| r.target().map(|o| o.to_string()));
let link = branch
.as_deref()
.and_then(|b| github::read_link_with(repo, b, &parser).ok())
.unwrap_or_else(BranchLink::empty);
let age = branch.as_deref().and_then(|b| branch_age(repo, b));
let main_name = workdir
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "main".into());
out.push(WorktreeInfo {
id: main_name.clone(),
name: main_name,
path: workdir.to_path_buf(),
branch,
head,
is_main: true,
is_locked: false,
is_prunable: false,
status: compute_status(repo),
issue_state: link.issue_state,
pr_state: link.pr_state,
link,
age,
});
}
let names = repo.worktrees()?;
for name in names.iter().filter_map(|r| r.ok().flatten()) {
let wt = match repo.find_worktree(name) {
Ok(w) => w,
Err(_) => continue,
};
let path = wt.path().to_path_buf();
let is_locked = matches!(wt.is_locked(), Ok(git2::WorktreeLockStatus::Locked(_)));
let is_prunable = matches!(wt.is_prunable(None), Ok(p) if p);
let (branch, head, status, age) = match Repository::open(&path) {
Ok(sub) => {
let head_ref = sub.head().ok();
let b = head_ref
.as_ref()
.and_then(|r| r.shorthand().ok().map(|s| s.to_string()));
let h = head_ref.as_ref().and_then(|r| r.target().map(|o| o.to_string()));
let s = compute_status(&sub);
let a = b.as_deref().and_then(|name| branch_age(&sub, name));
(b, h, s, a)
}
Err(_) => (
None,
None,
BranchStatus {
unknown: true,
..Default::default()
},
None,
),
};
let link = branch
.as_deref()
.and_then(|b| github::read_link_with(repo, b, &parser).ok())
.unwrap_or_else(BranchLink::empty);
let display_name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| name.to_string());
out.push(WorktreeInfo {
name: display_name,
id: name.to_string(),
path,
branch,
head,
is_main: false,
is_locked,
is_prunable,
status,
issue_state: link.issue_state,
pr_state: link.pr_state,
link,
age,
});
}
Ok(out)
}
pub fn add(
repo: &Repository,
name: &str,
target_path: &Path,
branch_name: &str,
reuse_branch: bool,
) -> Result<PathBuf> {
if target_path.exists() {
return Err(GwmError::WorktreeExists(name.into(), target_path.display().to_string()));
}
if let Some(parent) = target_path.parent() {
std::fs::create_dir_all(parent)?;
}
let head_ref = repo.head()?;
let head_short = head_ref.shorthand().ok().map(|s| s.to_string());
let head_commit = head_ref.peel_to_commit()?;
let (branch, created_branch) = match repo.find_branch(branch_name, git2::BranchType::Local) {
Ok(b) => {
if !reuse_branch {
let oid = b
.get()
.target()
.map(|o| o.to_string())
.unwrap_or_else(|| "<unresolved>".into());
return Err(GwmError::BranchExists {
name: branch_name.into(),
oid,
});
}
(b, false)
}
Err(_) => (repo.branch(branch_name, &head_commit, false)?, true),
};
if created_branch {
let _ = write_branch_created_at(repo, branch_name, chrono::Utc::now().timestamp());
}
let reference = branch.into_reference();
let mut opts = WorktreeAddOptions::new();
opts.reference(Some(&reference));
if let Err(e) = repo.worktree(name, target_path, Some(&opts)) {
if created_branch && !branch_is_checked_out_anywhere(repo, branch_name) {
if let Ok(b) = repo.find_branch(branch_name, git2::BranchType::Local) {
if b.get().target() == Some(head_commit.id()) {
let mut r = b.into_reference();
if r.delete().is_ok() {
let _ = remove_branch_created_at(repo, branch_name);
}
}
}
}
return Err(e.into());
}
if let Some(parent_ref) = head_short {
let _ = crate::launcher::write_gwm_base(repo, branch_name, &parent_ref);
}
Ok(target_path.to_path_buf())
}
fn branch_config_key(branch: &str, leaf: &str) -> String {
format!("branch.{}.{}", branch, leaf)
}
fn write_branch_created_at(repo: &Repository, branch: &str, unix_secs: i64) -> Result<()> {
let mut cfg = repo.config()?;
cfg.set_str(
&branch_config_key(branch, BRANCH_CREATED_AT_CONFIG_KEY),
&unix_secs.to_string(),
)?;
Ok(())
}
fn branch_is_checked_out_anywhere(repo: &Repository, branch: &str) -> bool {
let want = format!("ref: refs/heads/{}", branch);
let names_branch = |p: PathBuf| match std::fs::read_to_string(p) {
Ok(s) => s.trim() == want,
Err(e) => e.kind() != std::io::ErrorKind::NotFound,
};
let common = repo.commondir().to_path_buf();
if names_branch(common.join("HEAD")) {
return true;
}
match std::fs::read_dir(common.join("worktrees")) {
Ok(mut entries) => entries.any(|e| match e {
Ok(entry) => names_branch(entry.path().join("HEAD")),
Err(_) => true,
}),
Err(e) => e.kind() != std::io::ErrorKind::NotFound,
}
}
fn remove_branch_created_at(repo: &Repository, branch: &str) -> Result<()> {
let mut cfg = repo.config()?;
cfg.remove(&branch_config_key(branch, BRANCH_CREATED_AT_CONFIG_KEY))?;
Ok(())
}
fn branch_created_age(repo: &Repository, branch: &str) -> Option<Duration> {
let cfg = repo.config().ok()?;
let key = branch_config_key(branch, BRANCH_CREATED_AT_CONFIG_KEY);
let raw = cfg.get_string(&key).ok()?;
let created = raw.trim().parse::<i64>().ok()?;
let now = chrono::Utc::now().timestamp();
Some(Duration::from_secs((now - created).max(0) as u64))
}
pub fn remove(repo: &Repository, name: &str, delete_branch: bool) -> Result<()> {
let wt = repo
.find_worktree(name)
.map_err(|_| GwmError::WorktreeNotFound(name.into()))?;
let path = wt.path().to_path_buf();
let branch_name = match Repository::open(&path) {
Ok(sub) => sub.head().ok().and_then(|r| r.shorthand().ok().map(|s| s.to_string())),
Err(_) => None,
};
let mut opts = WorktreePruneOptions::new();
opts.valid(true).locked(true).working_tree(true);
wt.prune(Some(&mut opts))?;
if path.exists() {
std::fs::remove_dir_all(&path)?;
}
if delete_branch {
if let Some(b) = branch_name {
if let Ok(mut branch) = repo.find_branch(&b, git2::BranchType::Local) {
let _ = branch.delete();
}
}
}
Ok(())
}
fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
let mut cmd = Command::new("git");
cmd.args(args).current_dir(dir);
let out = crate::command_log::run_logged(&mut cmd, format!("git {}", args.join(" ")))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
Err(GwmError::CommandFailed(
String::from_utf8_lossy(&out.stderr).trim().to_string(),
))
}
}
pub fn rename_worktree(
workdir: &Path,
old_path: &Path,
old_branch: &str,
new_path: &Path,
new_branch: &str,
) -> Result<bool> {
let moves = new_path != old_path;
if moves && new_path.exists() {
return Err(GwmError::CommandFailed(format!(
"target path already exists: {}",
new_path.display()
)));
}
if moves {
git_in(
workdir,
&[
"worktree",
"move",
&old_path.to_string_lossy(),
&new_path.to_string_lossy(),
],
)
.map_err(|e| GwmError::CommandFailed(format!("worktree move failed: {e}")))?;
}
let branch_dir = if moves { new_path } else { old_path };
let rollback_move = || {
if moves {
let _ = git_in(
workdir,
&[
"worktree",
"move",
&new_path.to_string_lossy(),
&old_path.to_string_lossy(),
],
);
}
};
let renames_branch = new_branch != old_branch;
if !renames_branch {
return Ok(false);
}
if let Err(e) = git_in(branch_dir, &["branch", "-m", old_branch, new_branch]) {
rollback_move();
return Err(GwmError::CommandFailed(format!("local rename failed: {e}")));
}
let has_origin = Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(branch_dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let remote_exists = if has_origin {
let ls = Command::new("git")
.args(["ls-remote", "--exit-code", "--heads", "origin", old_branch])
.current_dir(branch_dir)
.output();
match ls {
Ok(o) if o.status.success() => true,
Ok(o) if o.status.code() == Some(2) => false,
other => {
let detail = match other {
Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(),
Err(e) => e.to_string(),
};
let _ = git_in(branch_dir, &["branch", "-m", new_branch, old_branch]);
rollback_move();
return Err(GwmError::CommandFailed(format!("remote lookup failed: {detail}")));
}
}
} else {
false
};
let mut remote_renamed = false;
if remote_exists {
let _ = git_in(branch_dir, &["fetch", "origin", old_branch]);
let remote_tip = Command::new("git")
.args(["rev-parse", "FETCH_HEAD"])
.current_dir(branch_dir)
.output();
let fetched_old_tip = match &remote_tip {
Ok(o) if o.status.success() => Some(String::from_utf8_lossy(&o.stdout).trim().to_string()),
_ => None,
};
let up_to_date = match &fetched_old_tip {
Some(tip) => {
Command::new("git")
.args(["merge-base", "--is-ancestor", tip, new_branch])
.current_dir(branch_dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
None => false,
};
if !up_to_date {
let _ = git_in(branch_dir, &["branch", "-m", new_branch, old_branch]);
rollback_move();
return Err(GwmError::CommandFailed(format!(
"origin/{old_branch} has commits not in your local branch; fetch/merge before renaming"
)));
}
let target_exists = Command::new("git")
.args([
"ls-remote",
"--exit-code",
"origin",
&format!("refs/heads/{new_branch}"),
])
.current_dir(branch_dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if target_exists {
let _ = git_in(branch_dir, &["branch", "-m", new_branch, old_branch]);
rollback_move();
return Err(GwmError::CommandFailed(format!(
"origin/{new_branch} already exists; choose another name or delete it on the remote first"
)));
}
let lease = fetched_old_tip
.as_deref()
.map(|tip| format!("--force-with-lease={old_branch}:{tip}"));
let new_absence_lease = format!("--force-with-lease={new_branch}:{}", "0".repeat(40));
let mut push_args: Vec<&str> = vec!["push", "--atomic"];
if let Some(lease) = lease.as_deref() {
push_args.push(lease);
}
push_args.push(&new_absence_lease);
let old_refspec = format!(":{old_branch}");
let new_refspec = format!("{new_branch}:{new_branch}");
push_args.extend(["origin", &old_refspec, &new_refspec]);
if let Err(e) = git_in(branch_dir, &push_args) {
let _ = git_in(branch_dir, &["branch", "-m", new_branch, old_branch]);
rollback_move();
return Err(GwmError::CommandFailed(format!("remote rename failed: {e}")));
}
let _ = git_in(
branch_dir,
&[
"branch",
"--set-upstream-to",
&format!("origin/{new_branch}"),
new_branch,
],
);
remote_renamed = true;
}
Ok(remote_renamed)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrunableEntry {
pub name: String,
pub path: PathBuf,
pub reason: String,
}
pub fn prunable_worktrees(repo: &Repository) -> Result<Vec<PrunableEntry>> {
let names = repo.worktrees()?;
let mut out = Vec::new();
for name in names.iter().filter_map(|r| r.ok().flatten()) {
let wt = match repo.find_worktree(name) {
Ok(w) => w,
Err(_) => continue,
};
if !matches!(wt.is_prunable(None), Ok(p) if p) {
continue;
}
out.push(PrunableEntry {
name: name.to_string(),
path: wt.path().to_path_buf(),
reason: "working dir missing".to_string(),
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
pub fn prune(repo: &Repository) -> Result<usize> {
let plan = prunable_worktrees(repo)?;
let mut pruned = 0usize;
for entry in plan {
let wt = match repo.find_worktree(&entry.name) {
Ok(w) => w,
Err(_) => continue,
};
let mut opts = WorktreePruneOptions::new();
opts.valid(true).locked(true).working_tree(true);
if wt.prune(Some(&mut opts)).is_ok() {
pruned += 1;
}
}
Ok(pruned)
}
pub fn remove_dry_run(repo: &Repository, name: &str) -> Result<()> {
repo
.find_worktree(name)
.map_err(|_| GwmError::WorktreeNotFound(name.into()))?;
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommitRow {
pub hash: git2::Oid,
pub author: String,
pub parents: Vec<git2::Oid>,
pub subject: String,
}
pub fn git_log_with_author(path: &Path, n: usize) -> Result<Vec<CommitRow>> {
let repo = Repository::open(path)?;
let tip = repo.head()?.target().ok_or_else(|| GwmError::UnbornHead {
reason: "HEAD does not point at a commit".into(),
})?;
recent_commits_revwalk(&repo, tip, n)
}
pub fn recent_commits_cached(w: &WorktreeInfo, limit: usize) -> Result<Vec<CommitRow>> {
let tip = worktree_head_oid(w)?;
let key = (recent_commits_cache_repo_key(&w.path), tip, limit);
if let Some(rows) = recent_commits_cache().get(&key).cloned() {
return Ok(rows);
}
let repo = Repository::open(&w.path)?;
let rows = recent_commits_revwalk(&repo, tip, limit)?;
let mut cache = recent_commits_cache();
if cache.len() >= RECENT_COMMITS_CACHE_MAX_ENTRIES {
if let Some(oldest_key) = cache.keys().next().cloned() {
cache.remove(&oldest_key);
}
}
cache.insert(key, rows.clone());
Ok(rows)
}
fn recent_commits_cache() -> MutexGuard<'static, HashMap<RecentCommitCacheKey, Vec<CommitRow>>> {
match RECENT_COMMITS_CACHE.lock() {
Ok(cache) => cache,
Err(poisoned) => poisoned.into_inner(),
}
}
fn recent_commits_cache_repo_key(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn worktree_head_oid(w: &WorktreeInfo) -> Result<git2::Oid> {
if let Some(head) = &w.head {
return git2::Oid::from_str(head)
.map_err(|e| GwmError::Other(format!("cached worktree head '{}' is not an oid: {}", head, e)));
}
let repo = Repository::open(&w.path)?;
let head_ref = repo.head()?;
head_ref.target().ok_or_else(|| GwmError::UnbornHead {
reason: "HEAD does not point at a commit".into(),
})
}
fn recent_commits_revwalk(repo: &Repository, tip: git2::Oid, limit: usize) -> Result<Vec<CommitRow>> {
let mut walker = repo.revwalk()?;
walker.set_sorting(git2::Sort::TIME | git2::Sort::TOPOLOGICAL)?;
walker.push(tip)?;
let mut rows = Vec::new();
for oid in walker.take(limit) {
let oid = oid?;
let commit = repo.find_commit(oid)?;
rows.push(CommitRow {
hash: oid,
author: commit.author().name().unwrap_or("").to_string(),
parents: commit.parent_ids().collect(),
subject: commit.summary().ok().flatten().unwrap_or("").to_string(),
});
}
Ok(rows)
}
#[cfg(test)]
fn parse_git_log_with_author_output(raw: &str) -> Result<Vec<CommitRow>> {
let mut rows = Vec::new();
for line in raw.lines() {
let mut parts = line.splitn(4, '\u{0}');
let hash = parts.next().unwrap_or("");
let author = parts.next().unwrap_or("").to_string();
let parents_field = parts.next().unwrap_or("");
let subject = parts.next().unwrap_or("").to_string();
if hash.is_empty() {
continue;
}
let hash = git2::Oid::from_str(hash)
.map_err(|e| GwmError::CommandFailed(format!("git log returned invalid commit oid '{}': {}", hash, e)))?;
let parents: Vec<git2::Oid> = parents_field
.split_whitespace()
.map(|s| {
git2::Oid::from_str(s)
.map_err(|e| GwmError::CommandFailed(format!("git log returned invalid parent oid '{}': {}", s, e)))
})
.collect::<Result<Vec<_>>>()?;
rows.push(CommitRow {
hash,
author,
parents,
subject,
});
}
Ok(rows)
}
pub fn run_git(dir: &Path, args: &[&str]) -> Result<String> {
run_git_inner(dir, args, false)
}
pub fn run_git_logged(dir: &Path, args: &[&str]) -> Result<String> {
run_git_inner(dir, args, true)
}
fn run_git_inner(dir: &Path, args: &[&str], log: bool) -> Result<String> {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(dir).args(args);
let out = if log {
crate::command_log::run_logged(&mut cmd, format!("git {}", args.join(" ")))
} else {
cmd.output()
}
.map_err(|e| GwmError::CommandFailed(format!("git {} failed to spawn: {}", args.join(" "), e)))?;
if !out.status.success() {
return Err(GwmError::CommandFailed(format!(
"git {} exited {}: {}",
args.join(" "),
out.status,
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub fn git_log_oneline(path: &Path, n: usize) -> Result<String> {
let n = n.to_string();
run_git(path, &["log", "--oneline", "-n", &n])
}
pub fn git_log_subject_between(path: &Path, base: &str, head: &str) -> Result<String> {
let range = format!("{}..{}", base, head);
let out = run_git(path, &["log", "--pretty=format:- %s", &range])?;
Ok(out.trim_end().to_string())
}
pub fn git_diff_stat_between(path: &Path, base: &str, head: &str, max_lines: usize) -> Result<String> {
let range = format!("{}..{}", base, head);
let raw = run_git(path, &["diff", "--stat", &range])?;
let mut lines: Vec<&str> = raw.lines().collect();
let truncated = lines.len() > max_lines;
if truncated {
lines.truncate(max_lines);
}
let mut out = lines.join("\n");
if truncated {
out.push_str(&format!(
"\n… ({} more line{} trimmed)",
raw.lines().count() - max_lines,
if raw.lines().count() - max_lines == 1 { "" } else { "s" }
));
}
Ok(out)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DiffLineStat {
pub insertions: usize,
pub deletions: usize,
}
impl DiffLineStat {
pub fn is_empty(&self) -> bool {
self.insertions == 0 && self.deletions == 0
}
}
pub fn parse_diff_shortstat(raw: &str) -> DiffLineStat {
let mut out = DiffLineStat::default();
for part in raw.split(',') {
let part = part.trim();
if let Some(n) = part
.strip_suffix("insertions(+)")
.or_else(|| part.strip_suffix("insertion(+)"))
{
out.insertions = n.trim().parse().unwrap_or(0);
} else if let Some(n) = part
.strip_suffix("deletions(-)")
.or_else(|| part.strip_suffix("deletion(-)"))
{
out.deletions = n.trim().parse().unwrap_or(0);
}
}
out
}
pub fn is_trunk_branch(branch: &str, configured: &[String]) -> bool {
configured.iter().any(|t| t == branch) || COMMON_TRUNKS.contains(&branch)
}
pub fn git_diff_stat_vs_base(path: &Path, trunks: &[String]) -> Result<Option<DiffLineStat>> {
let repo = match Repository::open(path) {
Ok(r) => r,
Err(_) => return Ok(None),
};
if let Ok(head) = repo.head() {
if let Ok(branch) = head.shorthand() {
if is_trunk_branch(branch, trunks) {
return Ok(None);
}
}
}
let base = match resolve_trunk(&repo, trunks) {
Some(b) => b,
None => return Ok(None),
};
let range = format!("{}...HEAD", base);
let raw = run_git(path, &["diff", "--shortstat", &range])?;
Ok(Some(parse_diff_shortstat(&raw)))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StashEntry {
pub ref_name: String,
pub subject: String,
}
pub fn git_stash_list(path: &Path, limit: usize) -> Result<Vec<StashEntry>> {
let limit_arg = format!("-n{}", limit);
let raw = run_git(path, &["stash", "list", "--pretty=format:%gd\x1f%s", &limit_arg])?;
let entries = raw
.lines()
.filter(|line| !line.is_empty())
.take(limit)
.filter_map(|line| {
let mut parts = line.splitn(2, '\x1f');
let ref_name = parts.next()?.to_string();
let subject = parts.next().unwrap_or("").to_string();
Some(StashEntry { ref_name, subject })
})
.collect();
Ok(entries)
}
pub const STATUS_SCAN_CAP: usize = 5000;
pub fn git_status_short(path: &Path) -> Result<(String, bool)> {
git_status_short_capped(path, STATUS_SCAN_CAP)
}
pub fn git_status_short_capped(path: &Path, cap: usize) -> Result<(String, bool)> {
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
let mut child = Command::new("git")
.arg("--no-optional-locks")
.arg("-C")
.arg(path)
.args(["status", "--porcelain", "-z", "--untracked-files=all"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| GwmError::CommandFailed(format!("git status failed to spawn: {}", e)))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| GwmError::CommandFailed("git status: stdout pipe missing".to_string()))?;
let stderr_reader = child.stderr.take().map(|mut stderr| {
std::thread::spawn(move || {
use std::io::Read;
let mut buf = String::new();
let _ = stderr.read_to_string(&mut buf);
buf
})
});
let mut reader = BufReader::new(stdout);
let mut collected: Vec<u8> = Vec::new();
let mut segment: Vec<u8> = Vec::new();
let mut records = 0usize;
let mut truncated = false;
loop {
if records >= cap {
truncated = true;
break;
}
segment.clear();
let n = reader
.read_until(0u8, &mut segment)
.map_err(|e| GwmError::CommandFailed(format!("git status: read failed: {}", e)))?;
if n == 0 {
break; }
collected.extend_from_slice(&segment);
if segment.last() == Some(&0) {
records += 1;
}
}
if truncated {
let _ = child.kill();
}
let status = child
.wait()
.map_err(|e| GwmError::CommandFailed(format!("git status: wait failed: {}", e)))?;
let stderr = stderr_reader.and_then(|h| h.join().ok()).unwrap_or_default();
if !truncated && !status.success() {
return Err(GwmError::CommandFailed(format!(
"git status exited {}: {}",
status,
stderr.trim()
)));
}
Ok((String::from_utf8_lossy(&collected).into_owned(), truncated))
}
pub fn branch_age(repo: &Repository, branch: &str) -> Option<Duration> {
if TRUNK_CANDIDATES.contains(&branch) {
return None;
}
if let Some(age) = branch_created_age(repo, branch) {
return Some(age);
}
let local = repo.find_branch(branch, BranchType::Local).ok()?;
let head_oid = local.into_reference().target()?;
let mut walker = repo.revwalk().ok()?;
walker.push(head_oid).ok()?;
let mut hidden_any = false;
for trunk in TRUNK_CANDIDATES {
if let Ok(t) = repo.find_branch(trunk, BranchType::Local) {
if let Some(oid) = t.into_reference().target() {
if walker.hide(oid).is_ok() {
hidden_any = true;
}
}
}
}
if !hidden_any {
return None;
}
let mut oldest_secs: Option<i64> = None;
for oid in walker.flatten() {
if let Ok(commit) = repo.find_commit(oid) {
let t = commit.time().seconds();
oldest_secs = Some(oldest_secs.map_or(t, |x| x.min(t)));
}
}
let oldest = oldest_secs?;
let now = chrono::Utc::now().timestamp();
let elapsed = (now - oldest).max(0) as u64;
Some(Duration::from_secs(elapsed))
}
pub fn format_relative_duration(d: Duration) -> String {
const MINUTE: u64 = 60;
const HOUR: u64 = 60 * MINUTE;
const DAY: u64 = 24 * HOUR;
const WEEK: u64 = 7 * DAY;
const MONTH: u64 = 30 * DAY + 6 * HOUR;
const YEAR: u64 = 365 * DAY + 6 * HOUR;
let s = d.as_secs();
if s < MINUTE {
format!("{}s", s)
} else if s < HOUR {
format!("{}m", s / MINUTE)
} else if s < DAY {
format!("{}h", s / HOUR)
} else if s < WEEK {
format!("{}d", s / DAY)
} else if s < MONTH {
format!("{}w", s / WEEK)
} else if s < YEAR {
format!("{}M", s / MONTH)
} else {
format!("{}y", s / YEAR)
}
}
pub fn find_fuzzy(repo: &Repository, pattern: &str) -> Result<WorktreeInfo> {
let all = list(repo)?;
let exact: Vec<&WorktreeInfo> = all.iter().filter(|w| w.name == pattern && !w.is_main).collect();
match exact.len() {
1 => {
if let Some(by_id) = all
.iter()
.find(|w| w.id == pattern && w.id != exact[0].id && !w.is_main)
{
return Err(GwmError::Other(format!(
"'{}' is ambiguous: the display name of '{}' and the id of '{}'; target one by its unique id",
pattern, exact[0].id, by_id.id
)));
}
return Ok(exact[0].clone());
}
n if n > 1 => {
let ids = exact.iter().map(|w| w.id.as_str()).collect::<Vec<_>>().join(", ");
return Err(GwmError::Other(format!(
"name '{}' is ambiguous ({} worktrees share it); target one by id: {}",
pattern, n, ids
)));
}
_ => {
if let Some(by_id) = all.iter().find(|w| w.id == pattern && !w.is_main) {
return Ok(by_id.clone());
}
}
}
let pat = pattern.to_lowercase();
let mut matches: Vec<&WorktreeInfo> = all
.iter()
.filter(|w| !w.is_main && w.name.to_lowercase().contains(&pat))
.collect();
match matches.len() {
0 => Err(GwmError::WorktreeNotFound(pattern.into())),
1 => Ok(matches.remove(0).clone()),
_ => Err(GwmError::Other(format!(
"pattern '{}' is ambiguous, candidates: {}",
pattern,
matches.iter().map(|w| w.name.as_str()).collect::<Vec<_>>().join(", ")
))),
}
}
pub fn resolve_trunk(repo: &Repository, configured: &[String]) -> Option<String> {
for trunk in configured {
if repo.find_branch(trunk, BranchType::Local).is_ok() {
return Some(trunk.clone());
}
}
for trunk in COMMON_TRUNKS {
if configured.iter().any(|t| t == trunk) {
continue; }
if repo.find_branch(trunk, BranchType::Local).is_ok() {
return Some((*trunk).to_string());
}
}
None
}