use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, bail, Context, Result};
use super::placement::{self, Placement};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommitInfo {
pub short: String,
pub subject: String,
pub committed_at: i64,
}
impl CommitInfo {
pub fn age_label(&self) -> String {
format_age(self.committed_at)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeEntry {
pub path: PathBuf,
pub head: String,
pub branch: Option<String>,
pub is_main: bool,
pub placement: Option<Placement>,
pub dirty: bool,
pub ahead: Option<u32>,
pub behind: Option<u32>,
pub mr: Option<super::mr::MrRef>,
pub commit: Option<CommitInfo>,
}
#[derive(Debug, Clone)]
pub struct RepoContext {
pub main_path: PathBuf,
pub repo_name: String,
pub global_root: PathBuf,
}
impl RepoContext {
pub fn discover(cwd: &Path, global_root: PathBuf) -> Result<Self> {
let main_path = main_worktree_path(cwd)?;
let repo_name = main_path
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow!("无法解析仓库目录名"))?
.to_string();
Ok(Self {
main_path,
repo_name,
global_root,
})
}
pub fn list_managed_fast(&self) -> Result<Vec<WorktreeEntry>> {
let raw = list_porcelain(&self.main_path)?;
let mut out = Vec::new();
for mut entry in raw {
entry.is_main = paths_equal(&entry.path, &self.main_path);
if entry.is_main {
entry.placement = None;
out.push(entry);
continue;
}
if let Some(p) = placement::classify(
&entry.path,
&self.main_path,
&self.global_root,
&self.repo_name,
) {
entry.placement = Some(p);
out.push(entry);
}
}
out.sort_by(|a, b| match (a.is_main, b.is_main) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.path.cmp(&b.path),
});
Ok(out)
}
pub fn enrich_status(&self, entries: &mut [WorktreeEntry]) {
let base = divergence_base(&self.main_path, entries);
const PARALLEL: usize = 4;
let mut start = 0;
while start < entries.len() {
let end = (start + PARALLEL).min(entries.len());
let chunk = &mut entries[start..end];
std::thread::scope(|scope| {
for entry in chunk.iter_mut() {
let base = base.clone();
scope.spawn(move || {
let _ = enrich_local(entry, base.as_deref());
});
}
});
start = end;
}
}
pub fn enrich_commits(&self, entries: &mut [WorktreeEntry]) {
let mut shas: Vec<String> = entries
.iter()
.map(|e| e.head.clone())
.filter(|h| !h.is_empty())
.collect();
shas.sort();
shas.dedup();
let map = commit_details_many(&self.main_path, &shas);
for entry in entries.iter_mut() {
if let Some(info) = map.get(&entry.head) {
entry.commit = Some(info.clone());
}
}
}
}
fn enrich_local(entry: &mut WorktreeEntry, base: Option<&str>) -> Result<()> {
entry.dirty = is_dirty(&entry.path)?;
if let Some(base) = base {
if let Some((ahead, behind)) = ahead_behind(&entry.path, base) {
entry.ahead = Some(ahead);
entry.behind = Some(behind);
}
}
Ok(())
}
pub fn commit_details_many(cwd: &Path, shas: &[String]) -> HashMap<String, CommitInfo> {
if shas.is_empty() {
return HashMap::new();
}
let mut args: Vec<String> = vec![
"log".into(),
"--no-walk".into(),
"--no-show-signature".into(),
"--format=%H%x00%h%x00%ct%x00%s".into(),
];
args.extend(shas.iter().cloned());
let Ok(out) = git_in_owned(cwd, &args) else {
return HashMap::new();
};
parse_commit_details(&out)
}
pub fn commit_stat(cwd: &Path, sha: &str) -> Result<String> {
if sha.is_empty() {
bail!("空 commit SHA");
}
let out = git_in(
cwd,
&["show", "--stat", "--format=", "--no-color", "--no-ext-diff", sha],
)?;
Ok(out.trim_end().to_string())
}
pub fn commit_patch(cwd: &Path, sha: &str) -> Result<String> {
if sha.is_empty() {
bail!("空 commit SHA");
}
let out = git_in(
cwd,
&[
"show",
"--format=",
"--no-color",
"--no-ext-diff",
"-U3",
sha,
],
)?;
Ok(out.trim_end().to_string())
}
fn parse_commit_details(out: &str) -> HashMap<String, CommitInfo> {
let mut map = HashMap::new();
for line in out.lines() {
let mut parts = line.split('\0');
let Some(full) = parts.next().filter(|s| !s.is_empty()) else {
continue;
};
let Some(short) = parts.next() else {
continue;
};
let Some(ct) = parts.next().and_then(|s| s.parse::<i64>().ok()) else {
continue;
};
let subject = parts.next().unwrap_or("").to_string();
map.insert(
full.to_string(),
CommitInfo {
short: short.to_string(),
subject,
committed_at: ct,
},
);
}
map
}
fn format_age(committed_at: i64) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(committed_at);
let secs = (now - committed_at).max(0);
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86_400 {
format!("{}h", secs / 3600)
} else if secs < 86_400 * 30 {
format!("{}d", secs / 86_400)
} else {
format!("{}mo", secs / (86_400 * 30))
}
}
fn divergence_base(main_path: &Path, entries: &[WorktreeEntry]) -> Option<String> {
if let Some(branch) = entries.iter().find(|e| e.is_main).and_then(|e| e.branch.clone()) {
return Some(branch);
}
if let Ok(b) = git_in(main_path, &["rev-parse", "--abbrev-ref", "HEAD"]) {
let b = b.trim();
if !b.is_empty() && b != "HEAD" {
return Some(b.to_string());
}
}
if let Ok(sym) = git_in(
main_path,
&["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
) {
let sym = sym.trim();
if let Some(short) = sym.strip_prefix("origin/") {
if rev_exists(main_path, short) {
return Some(short.to_string());
}
return Some(sym.to_string());
}
if !sym.is_empty() {
return Some(sym.to_string());
}
}
for candidate in ["main", "master"] {
if rev_exists(main_path, candidate) {
return Some(candidate.to_string());
}
}
None
}
fn rev_exists(cwd: &Path, rev: &str) -> bool {
git_in(cwd, &["rev-parse", "--verify", "--quiet", rev]).is_ok()
}
fn ahead_behind(worktree: &Path, base: &str) -> Option<(u32, u32)> {
let range = format!("{base}...HEAD");
let out = git_in(
worktree,
&["rev-list", "--left-right", "--count", &range],
)
.ok()?;
let mut parts = out.split_whitespace();
let behind = parts.next()?.parse().ok()?;
let ahead = parts.next()?.parse().ok()?;
Some((ahead, behind))
}
pub fn git_common_dir(cwd: &Path) -> Result<PathBuf> {
let out = git_in(cwd, &["rev-parse", "--path-format=absolute", "--git-common-dir"])?;
Ok(PathBuf::from(out.trim()))
}
pub fn main_worktree_path(cwd: &Path) -> Result<PathBuf> {
let common = git_common_dir(cwd)?;
let main = if common.file_name().and_then(|s| s.to_str()) == Some(".git") {
common
.parent()
.ok_or_else(|| anyhow!("无法从 git-common-dir 推导 Main Worktree"))?
.to_path_buf()
} else {
list_porcelain(cwd)?
.into_iter()
.next()
.map(|e| e.path)
.ok_or_else(|| anyhow!("当前不在 git 仓库内"))?
};
Ok(main)
}
pub fn list_all(cwd: &Path) -> Result<Vec<WorktreeEntry>> {
list_porcelain(cwd)
}
pub fn worktree_for_branch(cwd: &Path, branch: &str) -> Result<Option<PathBuf>> {
Ok(list_all(cwd)?
.into_iter()
.find(|e| e.branch.as_deref() == Some(branch))
.map(|e| e.path))
}
pub fn current_toplevel(cwd: &Path) -> Result<PathBuf> {
let out = git_in(cwd, &["rev-parse", "--path-format=absolute", "--show-toplevel"])?;
Ok(PathBuf::from(out.trim()))
}
pub fn list_local_branches(cwd: &Path) -> Result<Vec<String>> {
let out = git_in(cwd, &["branch", "--format=%(refname:short)"])?;
Ok(nonempty_lines(&out))
}
pub fn list_remote_branches(cwd: &Path) -> Result<Vec<String>> {
let out = git_in(cwd, &["branch", "-r", "--format=%(refname:short)"])?;
Ok(nonempty_lines(&out)
.into_iter()
.filter(|b| !b.ends_with("/HEAD"))
.collect())
}
pub fn default_base_branch(cwd: &Path) -> Result<String> {
if let Ok(out) = git_in(cwd, &["symbolic-ref", "refs/remotes/origin/HEAD"]) {
if let Some(name) = out.trim().rsplit('/').next() {
if !name.is_empty() {
return Ok(name.to_string());
}
}
}
for candidate in ["main", "master"] {
if git_in(cwd, &["show-ref", "--verify", "--quiet", &format!("refs/heads/{candidate}")])
.is_ok()
{
return Ok(candidate.to_string());
}
}
let cur = git_in(cwd, &["branch", "--show-current"])?;
let cur = cur.trim();
if cur.is_empty() {
bail!("无法探测默认主分支");
}
Ok(cur.to_string())
}
pub fn is_dirty(worktree: &Path) -> Result<bool> {
let out = git_in(worktree, &["status", "--porcelain"])?;
Ok(!out.trim().is_empty())
}
pub fn is_merged_into(cwd: &Path, branch: &str, into: &str) -> Result<bool> {
match Command::new("git")
.args(["merge-base", "--is-ancestor", branch, into])
.current_dir(cwd)
.status()
{
Ok(status) if status.success() => Ok(true),
Ok(_) => Ok(false),
Err(err) => Err(err).context("检查分支合并状态失败"),
}
}
pub struct AddNewBranch {
pub path: PathBuf,
pub branch: String,
pub base: String,
}
pub struct AddExistingBranch {
pub path: PathBuf,
pub branch: String,
pub start_point: Option<String>,
}
pub fn add_new_branch(cwd: &Path, opts: &AddNewBranch) -> Result<()> {
if let Some(parent) = opts.path.parent() {
fs_create_dir_all(parent)?;
}
git_in(
cwd,
&[
"worktree",
"add",
"-b",
&opts.branch,
opts.path.to_str().ok_or_else(|| anyhow!("路径含非法 UTF-8"))?,
&opts.base,
],
)?;
Ok(())
}
pub fn add_existing_branch(cwd: &Path, opts: &AddExistingBranch) -> Result<()> {
if let Some(parent) = opts.path.parent() {
fs_create_dir_all(parent)?;
}
let path = opts
.path
.to_str()
.ok_or_else(|| anyhow!("路径含非法 UTF-8"))?;
match &opts.start_point {
Some(start) => {
git_in(
cwd,
&[
"worktree",
"add",
"--track",
"-b",
&opts.branch,
path,
start,
],
)?;
}
None => {
git_in(cwd, &["worktree", "add", path, &opts.branch])?;
}
}
Ok(())
}
pub fn remove_worktree(cwd: &Path, path: &Path, force: bool) -> Result<()> {
if path.exists() {
if let Ok(()) = remove_worktree_fast(cwd, path) {
return Ok(());
}
} else {
let _ = git_in(cwd, &["worktree", "prune"]);
return Ok(());
}
remove_worktree_git(cwd, path, force)
}
fn remove_worktree_git(cwd: &Path, path: &Path, force: bool) -> Result<()> {
let path_s = path
.to_str()
.ok_or_else(|| anyhow!("路径含非法 UTF-8"))?;
let mut args = vec!["worktree", "remove"];
if force {
args.push("--force");
}
args.push(path_s);
git_in(cwd, &args)?;
Ok(())
}
fn remove_worktree_fast(cwd: &Path, path: &Path) -> Result<()> {
let common = git_common_dir(cwd)?;
let trash_root = common.join("wt").join("trash");
fs::create_dir_all(&trash_root)
.with_context(|| format!("创建 trash 失败:{}", trash_root.display()))?;
let base = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("worktree");
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let staged = trash_root.join(format!("{stamp}-{base}"));
fs::rename(path, &staged)
.with_context(|| format!("移入 trash 失败:{} → {}", path.display(), staged.display()))?;
if let Ok(admin) = linked_worktree_admin(&staged) {
let _ = fs::remove_dir_all(admin);
}
let _ = git_in(cwd, &["worktree", "prune"]);
spawn_dir_cleanup(staged);
sweep_stale_trash(&trash_root);
Ok(())
}
fn linked_worktree_admin(worktree_path: &Path) -> Result<PathBuf> {
let gitfile = worktree_path.join(".git");
let content = fs::read_to_string(&gitfile)
.with_context(|| format!("读取 {} 失败", gitfile.display()))?;
for line in content.lines() {
if let Some(rest) = line.strip_prefix("gitdir:") {
let admin = PathBuf::from(rest.trim());
if admin.is_absolute() {
return Ok(admin);
}
return Ok(worktree_path.join(admin));
}
}
bail!(".git 文件中无 gitdir")
}
fn spawn_dir_cleanup(path: PathBuf) {
thread::spawn(move || {
let _ = fs::remove_dir_all(&path);
});
}
fn sweep_stale_trash(trash_root: &Path) {
let Ok(entries) = fs::read_dir(trash_root) else {
return;
};
let now = SystemTime::now();
for entry in entries.flatten() {
let path = entry.path();
let Ok(meta) = entry.metadata() else {
continue;
};
let Ok(modified) = meta.modified() else {
continue;
};
let Ok(age) = now.duration_since(modified) else {
continue;
};
if age.as_secs() >= 24 * 3600 {
spawn_dir_cleanup(path);
}
}
}
pub fn delete_branch(cwd: &Path, branch: &str, force: bool) -> Result<()> {
let flag = if force { "-D" } else { "-d" };
git_in(cwd, &["branch", flag, branch])?;
Ok(())
}
fn list_porcelain(cwd: &Path) -> Result<Vec<WorktreeEntry>> {
let out = git_in(cwd, &["worktree", "list", "--porcelain"])?;
parse_porcelain(&out)
}
pub fn parse_porcelain(out: &str) -> Result<Vec<WorktreeEntry>> {
let mut entries = Vec::new();
let mut path: Option<PathBuf> = None;
let mut head = String::new();
let mut branch: Option<String> = None;
let push = |entries: &mut Vec<WorktreeEntry>,
path: &mut Option<PathBuf>,
head: &mut String,
branch: &mut Option<String>| {
if let Some(p) = path.take() {
entries.push(WorktreeEntry {
path: p,
head: std::mem::take(head),
branch: branch.take(),
is_main: false,
placement: None,
dirty: false,
ahead: None,
behind: None,
mr: None,
commit: None,
});
}
};
for line in out.lines() {
if line.is_empty() {
push(&mut entries, &mut path, &mut head, &mut branch);
continue;
}
if let Some(rest) = line.strip_prefix("worktree ") {
push(&mut entries, &mut path, &mut head, &mut branch);
path = Some(PathBuf::from(rest));
} else if let Some(rest) = line.strip_prefix("HEAD ") {
head = rest.to_string();
} else if let Some(rest) = line.strip_prefix("branch ") {
branch = Some(
rest.strip_prefix("refs/heads/")
.unwrap_or(rest)
.to_string(),
);
} else if line == "detached" {
branch = None;
}
}
push(&mut entries, &mut path, &mut head, &mut branch);
Ok(entries)
}
fn git_in(cwd: &Path, args: &[&str]) -> Result<String> {
let owned: Vec<String> = args.iter().map(|s| (*s).to_string()).collect();
git_in_owned(cwd, &owned)
}
fn git_in_owned(cwd: &Path, args: &[String]) -> Result<String> {
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.with_context(|| format!("执行 git {} 失败", args.join(" ")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git {} 失败:{}", args.join(" "), stderr.trim());
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn nonempty_lines(s: &str) -> Vec<String> {
s.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
}
fn paths_equal(a: &Path, b: &Path) -> bool {
a == b
}
fn fs_create_dir_all(path: &Path) -> Result<()> {
std::fs::create_dir_all(path).with_context(|| format!("创建目录失败:{}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_porcelain_main_and_linked() {
let raw = "\
worktree /proj/repo
HEAD abc
branch refs/heads/main
worktree /proj/repo/.worktrees/feat-x
HEAD def
branch refs/heads/feat/x
worktree /proj/repo/.worktrees/detached-one
HEAD ghi
detached
";
let entries = parse_porcelain(raw).unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].path, PathBuf::from("/proj/repo"));
assert_eq!(entries[0].branch.as_deref(), Some("main"));
assert_eq!(entries[1].branch.as_deref(), Some("feat/x"));
assert_eq!(entries[2].branch, None);
}
#[test]
fn parses_commit_details_batch() {
let raw = "\
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x00aaaaaaa\x001700000000\x00Initial commit
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\x00bbbbbbb\x001700000100\x00Add feature
";
let map = parse_commit_details(raw);
assert_eq!(map.len(), 2);
let a = &map["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"];
assert_eq!(a.short, "aaaaaaa");
assert_eq!(a.subject, "Initial commit");
assert_eq!(a.committed_at, 1_700_000_000);
}
}