use super::normalize_search_dir;
use crate::tools::GitCommand;
use crate::workspace::{WorkspaceError, validate_workspace_name};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Git {
repo_root: PathBuf,
worktree_root: PathBuf,
}
impl Git {
pub fn from_dir(dir: &Path) -> Result<Self, WorkspaceError> {
let normalized = normalize_search_dir(dir)?;
if !normalized.exists() {
return Err(WorkspaceError::NotInGitRepo);
}
let worktree_root = GitCommand::new().current_dir(&normalized).show_toplevel()?;
let worktrees = GitCommand::new()
.current_dir(&worktree_root)
.worktree_list()?;
let repo_root = worktrees
.first()
.map(|wt| wt.path.clone())
.unwrap_or_else(|| worktree_root.clone());
Ok(Self {
repo_root,
worktree_root,
})
}
#[must_use]
pub fn repo_root(&self) -> &Path {
&self.repo_root
}
#[must_use]
pub fn worktree_root(&self) -> &Path {
&self.worktree_root
}
pub fn is_workspace_registered(&self, workspace_name: &str) -> Result<bool, WorkspaceError> {
GitCommand::new()
.current_dir(&self.repo_root)
.worktree_prune()?;
let worktrees = GitCommand::new()
.current_dir(&self.repo_root)
.worktree_list()?;
let target_path = self.repo_root.join(".workspaces").join(workspace_name);
let target_canonical = target_path.canonicalize().ok();
Ok(worktrees.iter().any(|wt| {
wt.path == target_path
|| (target_canonical.is_some() && wt.path.canonicalize().ok() == target_canonical)
|| wt.path.ends_with(format!(".workspaces/{workspace_name}"))
}))
}
pub fn add_workspace(
&self,
rel_workspace_path: &Path,
_workspace_name: &str,
) -> Result<(), WorkspaceError> {
GitCommand::new()
.current_dir(&self.repo_root)
.worktree_prune()?;
GitCommand::new()
.current_dir(&self.repo_root)
.worktree_add(rel_workspace_path)
}
pub fn forget_workspace(&self, workspace_name: &str) -> Result<(), WorkspaceError> {
let ws_path = self.repo_root.join(".workspaces").join(workspace_name);
GitCommand::new()
.current_dir(&self.repo_root)
.worktree_remove(&ws_path, true)?;
GitCommand::new()
.current_dir(&self.repo_root)
.worktree_prune()?;
Ok(())
}
#[must_use]
pub fn workspace_exists(&self, workspace_path: &Path) -> bool {
workspace_path.join(".git").exists()
}
pub fn list_workspaces(&self) -> Result<Vec<String>, WorkspaceError> {
GitCommand::new()
.current_dir(&self.repo_root)
.worktree_prune()?;
let worktrees = GitCommand::new()
.current_dir(&self.repo_root)
.worktree_list()?;
let workspaces_dir = self.repo_root.join(".workspaces");
let workspaces_dir_canonical = workspaces_dir.canonicalize().ok();
let workspaces = worktrees
.into_iter()
.filter(|wt| {
wt.path
.parent()
.map(|parent| {
parent == workspaces_dir
|| (workspaces_dir_canonical.is_some()
&& parent.canonicalize().ok() == workspaces_dir_canonical)
|| parent.ends_with(".workspaces")
})
.unwrap_or_default()
})
.filter_map(|wt| {
let file_name = wt.path.file_name()?.to_str()?;
validate_workspace_name(file_name).ok()?;
self.workspace_exists(&wt.path)
.then(|| file_name.to_string())
})
.collect();
Ok(workspaces)
}
}
#[cfg(test)]
mod tests {
use super::*;
use googletest::prelude::*;
fn init_test_git_repo(path: &Path) {
let output = std::process::Command::new("git")
.args(["init", "-b", "main"])
.arg(path)
.output()
.expect("git init");
assert!(output.status.success());
drop(
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(path)
.output(),
);
drop(
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(path)
.output(),
);
std::fs::write(path.join("README.md"), "# Test\n").expect("write file");
drop(
std::process::Command::new("git")
.args(["add", "README.md"])
.current_dir(path)
.output(),
);
drop(
std::process::Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(path)
.output(),
);
}
type TestResult = Result<(), Box<dyn std::error::Error>>;
#[googletest::test]
fn from_dir_at_repo_root_discovers_git_root() -> TestResult {
let dir = tempfile::tempdir()?;
let repo_root = dir.path();
init_test_git_repo(repo_root);
let git = Git::from_dir(repo_root)?;
let canonical_root = repo_root
.canonicalize()
.unwrap_or_else(|_| repo_root.to_path_buf());
expect_that!(git.repo_root(), eq(&canonical_root));
Ok(())
}
#[googletest::test]
fn from_dir_in_deep_subdirectory_discovers_git_root() -> TestResult {
let dir = tempfile::tempdir()?;
let repo_root = dir.path();
init_test_git_repo(repo_root);
let sub = repo_root.join("deep").join("nested");
std::fs::create_dir_all(&sub)?;
let git = Git::from_dir(&sub)?;
let canonical_root = repo_root
.canonicalize()
.unwrap_or_else(|_| repo_root.to_path_buf());
expect_that!(git.repo_root(), eq(&canonical_root));
Ok(())
}
#[googletest::test]
fn from_dir_outside_repo_returns_not_in_git_repo() -> TestResult {
let dir = tempfile::tempdir()?;
let non_repo = dir.path();
let res = Git::from_dir(non_repo);
expect_that!(
res,
matches_pattern!(Err(matches_pattern!(WorkspaceError::NotInGitRepo)))
);
Ok(())
}
#[googletest::test]
fn git_worktree_lifecycle_operations() -> TestResult {
let dir = tempfile::tempdir()?;
let repo_root = dir.path();
init_test_git_repo(repo_root);
let git = Git::from_dir(repo_root)?;
let ws_name = "test-ws";
let rel_ws_path = Path::new(".workspaces").join(ws_name);
let abs_ws_path = git.repo_root().join(&rel_ws_path);
expect_that!(git.is_workspace_registered(ws_name)?, is_false());
expect_that!(git.workspace_exists(&abs_ws_path), is_false());
std::fs::create_dir_all(git.repo_root().join(".workspaces"))?;
git.add_workspace(&rel_ws_path, ws_name)?;
expect_that!(git.is_workspace_registered(ws_name)?, is_true());
expect_that!(git.workspace_exists(&abs_ws_path), is_true());
git.forget_workspace(ws_name)?;
expect_that!(git.is_workspace_registered(ws_name)?, is_false());
Ok(())
}
#[googletest::test]
fn re_adding_workspace_after_forget_succeeds() -> TestResult {
let dir = tempfile::tempdir()?;
let repo_root = dir.path();
init_test_git_repo(repo_root);
let git = Git::from_dir(repo_root)?;
let ws_name = "readd-ws";
let rel_ws_path = Path::new(".workspaces").join(ws_name);
std::fs::create_dir_all(git.repo_root().join(".workspaces"))?;
git.add_workspace(&rel_ws_path, ws_name)?;
git.forget_workspace(ws_name)?;
let second_add = git.add_workspace(&rel_ws_path, ws_name);
expect_that!(second_add, ok(anything()));
expect_that!(git.is_workspace_registered(ws_name)?, is_true());
Ok(())
}
#[googletest::test]
fn git_list_workspaces_lists_added_worktrees() -> TestResult {
let dir = tempfile::tempdir()?;
let repo_root = dir.path();
init_test_git_repo(repo_root);
let git = Git::from_dir(repo_root)?;
let initial = git.list_workspaces()?;
expect_that!(initial, is_empty());
let rel1 = Path::new(".workspaces").join("git-ws-1");
let rel2 = Path::new(".workspaces").join("git-ws-2");
std::fs::create_dir_all(git.repo_root().join(".workspaces"))?;
git.add_workspace(&rel1, "git-ws-1")?;
git.add_workspace(&rel2, "git-ws-2")?;
let listed = git.list_workspaces()?;
expect_that!(
listed,
unordered_elements_are![eq("git-ws-1"), eq("git-ws-2")]
);
let ws1_path = git.repo_root().join(&rel1);
let git_from_ws = Git::from_dir(&ws1_path)?;
expect_that!(git_from_ws.repo_root(), eq(git.repo_root()));
let listed_from_ws = git_from_ws.list_workspaces()?;
expect_that!(
listed_from_ws,
unordered_elements_are![eq("git-ws-1"), eq("git-ws-2")]
);
git.forget_workspace("git-ws-1")?;
let listed_after_forget = git.list_workspaces()?;
expect_that!(listed_after_forget, elements_are![eq("git-ws-2")]);
Ok(())
}
}