use std::path::{Path, PathBuf};
use rayon::prelude::*;
use super::core::is_git_repository;
use super::types::{ChangeStatus, ChangedFile, SyncStatus, Worktree, WorktreeStatus};
use crate::error::{GwmError, Result};
use crate::shell::exec;
use chrono::{DateTime, Utc};
fn parse_worktrees_raw(output: &str) -> Vec<Worktree> {
let mut worktrees = Vec::new();
let mut current: Option<WorktreeBuilder> = None;
for line in output.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
if let Some(builder) = current.take() {
worktrees.push(builder.build());
}
current = Some(WorktreeBuilder::new(PathBuf::from(path)));
} else if let Some(ref mut builder) = current {
if let Some(head) = line.strip_prefix("HEAD ") {
builder.head = Some(head.to_string());
} else if let Some(branch) = line.strip_prefix("branch ") {
builder.branch = Some(branch.to_string());
} else if line == "bare" {
builder.branch = Some("(bare)".to_string());
builder.is_main = true;
} else if line == "detached" {
builder.branch = Some("(detached)".to_string());
}
}
}
if let Some(builder) = current {
worktrees.push(builder.build());
}
worktrees
}
pub fn parse_worktrees(output: &str) -> Vec<Worktree> {
use crate::config::load_config;
let config = load_config();
let mut worktrees = parse_worktrees_raw(output);
set_worktree_statuses(&mut worktrees, &config.main_branches);
worktrees
}
struct WorktreeBuilder {
path: PathBuf,
head: Option<String>,
branch: Option<String>,
is_main: bool,
}
impl WorktreeBuilder {
fn new(path: PathBuf) -> Self {
Self {
path,
head: None,
branch: None,
is_main: false,
}
}
fn build(self) -> Worktree {
Worktree {
path: self.path,
branch: self.branch.unwrap_or_else(|| "(detached)".to_string()),
head: self.head.unwrap_or_else(|| "UNKNOWN".to_string()),
status: if self.is_main {
WorktreeStatus::Main
} else {
WorktreeStatus::Other
},
is_main: self.is_main,
sync_status: None,
change_status: None,
last_activity: None,
commit_date: None,
committer_name: None,
commit_message: None,
}
}
}
fn set_worktree_statuses(worktrees: &mut [Worktree], main_branches: &[String]) {
if let Some(first) = worktrees.first_mut() {
first.is_main = true;
if first.status != WorktreeStatus::Main {
first.status = WorktreeStatus::Main;
}
}
for worktree in worktrees.iter_mut() {
let branch = worktree.display_branch();
if main_branches.iter().any(|main| main == branch) {
worktree.is_main = true;
worktree.status = WorktreeStatus::Main;
}
}
let Some(current_dir) = std::env::current_dir().ok() else {
return;
};
let current_canonical = current_dir.canonicalize().ok();
for worktree in worktrees.iter_mut() {
let is_match = match ¤t_canonical {
Some(canonical_current) => {
worktree
.path
.canonicalize()
.ok()
.is_some_and(|canonical_wt| canonical_wt == *canonical_current)
}
None => {
worktree.path == current_dir
}
};
if is_match {
worktree.status = WorktreeStatus::Active;
break;
}
}
}
pub fn get_worktrees() -> Result<Vec<Worktree>> {
if !is_git_repository() {
return Err(GwmError::NotGitRepository);
}
let output = exec("git", &["worktree", "list", "--porcelain"], None)?;
Ok(parse_worktrees(&output))
}
pub fn get_main_worktree_path() -> Option<PathBuf> {
get_worktrees()
.ok()?
.into_iter()
.find(|w| w.status == WorktreeStatus::Main)
.map(|w| w.path)
}
pub fn get_worktrees_with_details() -> Result<Vec<Worktree>> {
let worktrees = get_worktrees()?;
let details: Vec<_> = worktrees
.par_iter()
.map(|wt| {
let sync = get_sync_status(&wt.path, wt.display_branch());
let change = get_change_status(&wt.path);
let activity = get_last_activity(&wt.path);
let commit = get_commit_info(&wt.path);
(sync, change, activity, commit)
})
.collect();
let mut worktrees = worktrees;
for (i, (sync, change, activity, commit)) in details.into_iter().enumerate() {
worktrees[i].sync_status = sync;
worktrees[i].change_status = change;
worktrees[i].last_activity = activity;
if let Some(commit_info) = commit {
worktrees[i].commit_date = Some(commit_info.date);
worktrees[i].committer_name = Some(commit_info.committer_name);
worktrees[i].commit_message = Some(commit_info.message);
}
}
Ok(worktrees)
}
fn get_sync_status(path: &Path, branch: &str) -> Option<SyncStatus> {
let output = match exec(
"git",
&[
"-C",
&path.display().to_string(),
"rev-list",
"--left-right",
"--count",
&format!("HEAD...origin/{}", branch),
],
None,
) {
Ok(out) => out,
Err(e) => {
let err_str = e.to_string();
if !err_str.contains("unknown revision") && !err_str.contains("ambiguous argument") {
#[cfg(debug_assertions)]
eprintln!(
"Debug: get_sync_status failed for {} at {:?}: {}",
branch, path, e
);
}
return None;
}
};
let parts: Vec<&str> = output.trim().split('\t').collect();
if parts.len() == 2 {
let ahead = parts[0].parse().unwrap_or(0);
let behind = parts[1].parse().unwrap_or(0);
Some(SyncStatus { ahead, behind })
} else {
#[cfg(debug_assertions)]
eprintln!(
"Debug: Unexpected rev-list output format for {} at {:?}: {:?}",
branch, path, output
);
None
}
}
fn get_change_status(path: &Path) -> Option<ChangeStatus> {
let output = match exec(
"git",
&["-C", &path.display().to_string(), "status", "--porcelain"],
None,
) {
Ok(out) => out,
Err(e) => {
#[cfg(debug_assertions)]
eprintln!("Debug: get_change_status failed at {:?}: {}", path, e);
return None;
}
};
let mut status = ChangeStatus::default();
let mut files: Vec<ChangedFile> = Vec::new();
for line in output.lines() {
if line.len() < 3 {
continue;
}
let index_status = line.chars().next().unwrap_or(' ');
let worktree_status = line.chars().nth(1).unwrap_or(' ');
let file_path = line[3..].to_string();
let display_status = match (index_status, worktree_status) {
('?', '?') => {
status.untracked += 1;
'?'
}
('M', _) | (_, 'M') => {
status.modified += 1;
'M'
}
('A', _) => {
status.added += 1;
'A'
}
('D', _) | (_, 'D') => {
status.deleted += 1;
'D'
}
_ => continue,
};
if files.len() < 5 {
files.push(ChangedFile {
status: display_status,
path: file_path,
});
}
}
status.changed_files = files;
Some(status)
}
fn get_last_activity(path: &Path) -> Option<String> {
let output = match exec(
"git",
&[
"-C",
&path.display().to_string(),
"log",
"-1",
"--format=%cI", ],
None,
) {
Ok(out) => out,
Err(e) => {
#[cfg(debug_assertions)]
eprintln!("Debug: get_last_activity failed at {:?}: {}", path, e);
return None;
}
};
let timestamp_str = output.trim();
if timestamp_str.is_empty() {
return None;
}
let commit_time = match DateTime::parse_from_rfc3339(timestamp_str) {
Ok(dt) => dt.with_timezone(&Utc),
Err(e) => {
#[cfg(debug_assertions)]
eprintln!(
"Debug: Failed to parse timestamp '{}' at {:?}: {}",
timestamp_str, path, e
);
return None;
}
};
let now = Utc::now();
let duration = now.signed_duration_since(commit_time);
Some(format_relative_time(duration))
}
#[derive(Debug, Clone)]
pub struct CommitInfo {
pub date: String,
pub committer_name: String,
pub message: String,
}
fn get_commit_info(path: &Path) -> Option<CommitInfo> {
let output = match exec(
"git",
&[
"-C",
&path.display().to_string(),
"log",
"-1",
"--format=%cI|%cn|%s", ],
None,
) {
Ok(out) => out,
Err(e) => {
#[cfg(debug_assertions)]
eprintln!("Debug: get_commit_info failed at {:?}: {}", path, e);
return None;
}
};
let output = output.trim();
if output.is_empty() {
return None;
}
let parts: Vec<&str> = output.splitn(3, '|').collect();
if parts.len() >= 3 {
Some(CommitInfo {
date: parts[0].to_string(),
committer_name: parts[1].to_string(),
message: parts[2].to_string(),
})
} else {
#[cfg(debug_assertions)]
eprintln!(
"Debug: Unexpected commit info format at {:?}: {:?}",
path, output
);
None
}
}
const MINUTE: i64 = 60;
const HOUR: i64 = 3600;
const DAY: i64 = 86400;
const WEEK: i64 = 604800;
const MONTH: i64 = 2592000;
const YEAR: i64 = 31536000;
fn format_relative_time(duration: chrono::Duration) -> String {
let seconds = duration.num_seconds();
match seconds {
s if s < MINUTE => "just now".to_string(),
s if s < HOUR => format!("{}m ago", s / MINUTE),
s if s < DAY => format!("{}h ago", s / HOUR),
s if s < WEEK => format!("{}d ago", s / DAY),
s if s < MONTH => format!("{}w ago", s / WEEK),
s if s < YEAR => format!("{}mo ago", s / MONTH),
s => format!("{}y ago", s / YEAR),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_worktrees_single() {
let output = "worktree /path/to/main\nHEAD abc1234567890\nbranch refs/heads/main\n";
let worktrees = parse_worktrees(output);
assert_eq!(worktrees.len(), 1);
assert_eq!(worktrees[0].path, PathBuf::from("/path/to/main"));
assert_eq!(worktrees[0].branch, "refs/heads/main");
assert_eq!(worktrees[0].head, "abc1234567890");
}
#[test]
fn test_parse_worktrees_multiple() {
let output = r#"worktree /path/to/main
HEAD abc1234
branch refs/heads/main
worktree /path/to/feature
HEAD def5678
branch refs/heads/feature/test
"#;
let worktrees = parse_worktrees(output);
assert_eq!(worktrees.len(), 2);
assert_eq!(worktrees[0].path, PathBuf::from("/path/to/main"));
assert_eq!(worktrees[1].path, PathBuf::from("/path/to/feature"));
}
#[test]
fn test_parse_worktrees_detached() {
let output = "worktree /path/to/detached\nHEAD 1234567\ndetached\n";
let worktrees = parse_worktrees(output);
assert_eq!(worktrees.len(), 1);
assert_eq!(worktrees[0].branch, "(detached)");
}
#[test]
fn test_parse_worktrees_bare() {
let output = "worktree /path/to/bare\nHEAD 1234567\nbare\n";
let worktrees = parse_worktrees(output);
assert_eq!(worktrees.len(), 1);
assert_eq!(worktrees[0].branch, "(bare)");
assert_eq!(worktrees[0].status, WorktreeStatus::Main);
}
#[test]
fn test_parse_worktrees_with_locked() {
let output = "worktree /path/to/main\nHEAD abc1234\nbranch refs/heads/main\nlocked\n";
let worktrees = parse_worktrees(output);
assert_eq!(worktrees.len(), 1);
assert_eq!(worktrees[0].branch, "refs/heads/main");
}
#[test]
fn test_first_worktree_is_main() {
let output = r#"worktree /path/to/first
HEAD abc1234
branch refs/heads/main
worktree /path/to/second
HEAD def5678
branch refs/heads/feature
"#;
let worktrees = parse_worktrees(output);
assert_eq!(worktrees[0].status, WorktreeStatus::Main);
assert_eq!(worktrees[1].status, WorktreeStatus::Other);
}
#[test]
fn test_parse_worktrees_missing_head() {
let output = "worktree /path/to/main\nbranch refs/heads/main\n";
let worktrees = parse_worktrees(output);
assert_eq!(worktrees.len(), 1);
assert_eq!(worktrees[0].head, "UNKNOWN");
}
#[test]
fn test_parse_worktrees_missing_branch() {
let output = "worktree /path/to/main\nHEAD abc1234\n";
let worktrees = parse_worktrees(output);
assert_eq!(worktrees.len(), 1);
assert_eq!(worktrees[0].branch, "(detached)");
}
#[test]
fn test_display_branch() {
let worktree = Worktree {
path: PathBuf::from("/test"),
branch: "refs/heads/feature/test".to_string(),
head: "abc1234".to_string(),
status: WorktreeStatus::Other,
is_main: false,
sync_status: None,
change_status: None,
last_activity: None,
commit_date: None,
committer_name: None,
commit_message: None,
};
assert_eq!(worktree.display_branch(), "feature/test");
}
#[test]
fn test_short_head() {
let worktree = Worktree {
path: PathBuf::from("/test"),
branch: "main".to_string(),
head: "abc1234567890".to_string(),
status: WorktreeStatus::Main,
is_main: true,
sync_status: None,
change_status: None,
last_activity: None,
commit_date: None,
committer_name: None,
commit_message: None,
};
assert_eq!(worktree.short_head(), "abc1234");
}
}