use std::path::{Path, PathBuf};
use tokio::process::Command;
use crate::error::{Result, RftError};
#[derive(Debug, Clone)]
pub struct WorktreeInfo {
pub path: PathBuf,
pub branch: String,
pub is_main: bool,
pub index: usize,
}
#[derive(Debug, Clone)]
pub struct RepoIdentity {
pub working_root: PathBuf,
pub project_name: String,
}
pub async fn resolve_repo_identity(cwd: &Path) -> Result<RepoIdentity> {
let common_dir = get_git_common_dir(cwd).await?;
let project_name = project_name_from_common_dir(&common_dir);
let working_root = match try_show_toplevel(cwd).await {
Some(root) => root,
None => find_main_worktree_path(cwd).await?,
};
Ok(RepoIdentity {
working_root,
project_name,
})
}
async fn get_git_common_dir(cwd: &Path) -> Result<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--git-common-dir"])
.current_dir(cwd)
.output()
.await?;
if !output.status.success() {
return Err(RftError::NotAGitRepo);
}
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
let path = PathBuf::from(&raw);
if path.is_absolute() {
Ok(path)
} else {
Ok(cwd
.join(&path)
.canonicalize()
.unwrap_or_else(|_| cwd.join(&path)))
}
}
fn project_name_from_common_dir(common_dir: &Path) -> String {
let Some(dir_name) = common_dir.file_name() else {
return "unknown".to_string();
};
let dir_name = dir_name.to_string_lossy();
if dir_name.starts_with('.') {
common_dir
.parent()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "unknown".to_string())
} else {
dir_name.into_owned()
}
}
async fn try_show_toplevel(cwd: &Path) -> Option<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(cwd)
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
Some(PathBuf::from(root))
}
async fn find_main_worktree_path(cwd: &Path) -> Result<PathBuf> {
let output = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(cwd)
.output()
.await?;
if !output.status.success() {
return Err(RftError::NotAGitRepo);
}
let raw = String::from_utf8_lossy(&output.stdout);
let worktrees = parse_porcelain_output(&raw, None)?;
worktrees
.iter()
.find(|wt| wt.is_main)
.or_else(|| worktrees.first())
.map(|wt| wt.path.clone())
.ok_or(RftError::NoMainWorktree)
}
pub async fn get_worktrees(
repo_root: &Path,
main_branch: Option<&str>,
) -> Result<Vec<WorktreeInfo>> {
let output = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(repo_root)
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return Err(RftError::CommandFailed {
cmd: "git worktree list --porcelain".to_string(),
stderr,
});
}
let raw = String::from_utf8_lossy(&output.stdout);
parse_porcelain_output(&raw, main_branch)
}
pub async fn get_worktree_by_index(
repo_root: &Path,
index: usize,
main_branch: Option<&str>,
) -> Result<WorktreeInfo> {
let worktrees = get_worktrees(repo_root, main_branch).await?;
worktrees
.into_iter()
.find(|wt| !wt.is_main && wt.index == index)
.ok_or(RftError::WorktreeNotFound { index })
}
const MAIN_BRANCH_NAMES: &[&str] = &["main", "master"];
fn parse_porcelain_output(raw: &str, main_branch: Option<&str>) -> Result<Vec<WorktreeInfo>> {
let mut worktrees = Vec::new();
let blocks = raw.split("\n\n").filter(|block| !block.trim().is_empty());
let mut non_main_counter = 0usize;
for block in blocks {
let mut path: Option<PathBuf> = None;
let mut branch = String::from("detached");
let mut is_bare = false;
for line in block.lines() {
if let Some(worktree_path) = line.strip_prefix("worktree ") {
path = Some(PathBuf::from(worktree_path));
} else if let Some(branch_ref) = line.strip_prefix("branch ") {
branch = branch_ref
.strip_prefix("refs/heads/")
.unwrap_or(branch_ref)
.to_string();
} else if line.trim() == "bare" {
is_bare = true;
}
}
if is_bare {
continue;
}
let is_main = match main_branch {
Some(name) => branch == name,
None => MAIN_BRANCH_NAMES.contains(&branch.as_str()),
};
if let Some(path) = path {
let index = if is_main {
0
} else {
non_main_counter += 1;
non_main_counter
};
worktrees.push(WorktreeInfo {
path,
branch,
is_main,
index,
});
}
}
Ok(worktrees)
}
#[cfg(test)]
mod tests {
use super::*;
const PORCELAIN_TWO_WORKTREES: &str = "\
worktree /home/user/project
HEAD abc123def456
branch refs/heads/main
worktree /home/user/project-feature
HEAD 789def012abc
branch refs/heads/feature/login
";
const PORCELAIN_WITH_DETACHED: &str = "\
worktree /home/user/project
HEAD abc123def456
branch refs/heads/main
worktree /home/user/project-detached
HEAD 789def012abc
detached
";
const PORCELAIN_SINGLE: &str = "\
worktree /home/user/project
HEAD abc123def456
branch refs/heads/main
";
const PORCELAIN_BARE_REPO: &str = "\
worktree /home/user/project/.bare
bare
worktree /home/user/project/main
HEAD abc123def456
branch refs/heads/main
worktree /home/user/project/feature-auth
HEAD 789def012abc
branch refs/heads/feature/auth
";
const PORCELAIN_MASTER_BRANCH: &str = "\
worktree /home/user/project
HEAD abc123def456
branch refs/heads/master
worktree /home/user/project-feature
HEAD 789def012abc
branch refs/heads/feature/login
";
#[test]
fn parse_two_worktrees() {
let worktrees = parse_porcelain_output(PORCELAIN_TWO_WORKTREES, None).unwrap();
assert_eq!(worktrees.len(), 2);
assert_eq!(worktrees[0].path, PathBuf::from("/home/user/project"));
assert_eq!(worktrees[0].branch, "main");
assert!(worktrees[0].is_main);
assert_eq!(worktrees[0].index, 0);
assert_eq!(
worktrees[1].path,
PathBuf::from("/home/user/project-feature")
);
assert_eq!(worktrees[1].branch, "feature/login");
assert!(!worktrees[1].is_main);
assert_eq!(
worktrees[1].index, 1,
"first non-main worktree should be 1-indexed"
);
}
#[test]
fn parse_detached_head() {
let worktrees = parse_porcelain_output(PORCELAIN_WITH_DETACHED, None).unwrap();
assert_eq!(worktrees.len(), 2);
assert_eq!(worktrees[1].branch, "detached");
assert!(!worktrees[1].is_main);
assert_eq!(
worktrees[1].index, 1,
"detached non-main worktree is 1-indexed"
);
}
#[test]
fn parse_single_worktree() {
let worktrees = parse_porcelain_output(PORCELAIN_SINGLE, None).unwrap();
assert_eq!(worktrees.len(), 1);
assert!(worktrees[0].is_main);
assert_eq!(worktrees[0].branch, "main");
}
#[test]
fn parse_empty_output() {
let worktrees = parse_porcelain_output("", None).unwrap();
assert!(worktrees.is_empty());
}
#[test]
fn parse_bare_repo_skips_bare_entry() {
let worktrees = parse_porcelain_output(PORCELAIN_BARE_REPO, None).unwrap();
assert_eq!(worktrees.len(), 2, "bare entry should be skipped");
assert_eq!(worktrees[0].branch, "main");
assert!(worktrees[0].is_main);
assert_eq!(worktrees[0].index, 0);
assert_eq!(worktrees[1].branch, "feature/auth");
assert!(!worktrees[1].is_main);
assert_eq!(worktrees[1].index, 1);
}
#[test]
fn parse_master_branch_is_main() {
let worktrees = parse_porcelain_output(PORCELAIN_MASTER_BRANCH, None).unwrap();
assert_eq!(worktrees.len(), 2);
assert!(worktrees[0].is_main, "master should be detected as main");
assert_eq!(worktrees[0].index, 0);
assert!(!worktrees[1].is_main);
assert_eq!(worktrees[1].index, 1);
}
#[test]
fn custom_main_branch_from_config() {
let porcelain = "\
worktree /home/user/project
HEAD abc123def456
branch refs/heads/develop
worktree /home/user/project-feature
HEAD 789def012abc
branch refs/heads/feature/login
";
let worktrees = parse_porcelain_output(porcelain, Some("develop")).unwrap();
assert_eq!(worktrees.len(), 2);
assert!(
worktrees[0].is_main,
"develop should be main when configured"
);
assert_eq!(worktrees[0].index, 0);
assert!(!worktrees[1].is_main);
}
#[test]
fn custom_main_branch_does_not_match_default() {
let worktrees = parse_porcelain_output(PORCELAIN_TWO_WORKTREES, Some("develop")).unwrap();
assert!(
!worktrees[0].is_main,
"main branch should not match when config says develop"
);
}
#[test]
fn project_name_from_dot_git() {
let name = project_name_from_common_dir(Path::new("/home/user/myapp/.git"));
assert_eq!(name, "myapp");
}
#[test]
fn project_name_from_dot_bare() {
let name = project_name_from_common_dir(Path::new("/home/user/myapp/.bare"));
assert_eq!(name, "myapp");
}
#[test]
fn project_name_from_plain_bare() {
let name = project_name_from_common_dir(Path::new("/home/user/boss"));
assert_eq!(name, "boss");
}
#[test]
fn project_name_from_root_path() {
let name = project_name_from_common_dir(Path::new("/"));
assert_eq!(name, "unknown");
}
#[test]
fn project_name_from_dot_hidden_custom() {
let name = project_name_from_common_dir(Path::new("/projects/myapp/.gitdata"));
assert_eq!(name, "myapp");
}
}