use serde::Serialize;
use sha1::{Digest, Sha1};
use std::path::{Path, PathBuf};
use crate::git;
use crate::models::project::Project;
#[derive(Debug, Clone, Serialize)]
pub struct Workspace {
pub id: String,
pub project_id: String,
pub path: String,
pub label: String,
pub branch: Option<String>,
pub is_main: bool,
pub is_git_repo: bool,
pub is_git_worktree: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum CoverKind {
ExactPath,
WorktreeChild,
}
fn workspace_id(project_id: &str, path: &str) -> String {
let mut hasher = Sha1::new();
hasher.update(format!("{}:{}", project_id, path));
let hash = format!("{:x}", hasher.finalize());
hash[..12].to_string()
}
fn canonical(path: &Path) -> Option<PathBuf> {
std::fs::canonicalize(path).ok()
}
pub async fn find_covering_project(
new_path: &Path,
projects: &[Project],
) -> anyhow::Result<Option<(Project, CoverKind)>> {
let new_canon = match canonical(new_path) {
Some(p) => p,
None => return Ok(None), };
for project in projects {
let proj_canon = match canonical(Path::new(&project.path)) {
Some(p) => p,
None => continue, };
if proj_canon == new_canon {
return Ok(Some((project.clone(), CoverKind::ExactPath)));
}
if !git::is_git_repo(&project.path).await {
continue;
}
match git::discover_worktrees(&project.path).await {
Ok(worktrees) => {
for wt in worktrees {
if let Some(wt_canon) = canonical(Path::new(&wt.path)) {
if wt_canon == new_canon {
return Ok(Some((project.clone(), CoverKind::WorktreeChild)));
}
}
}
}
Err(e) => {
tracing::warn!(
"failed to list worktrees for project {} ({}); skipping coverage check for it",
project.path,
e
);
}
}
}
Ok(None)
}
pub async fn list_workspaces(project: &Project) -> Vec<Workspace> {
let is_git = git::is_git_repo(&project.path).await;
if !is_git {
return vec![single_workspace(project, false)];
}
let worktrees = match git::discover_worktrees(&project.path).await {
Ok(wt) => wt,
Err(e) => {
tracing::warn!("failed to discover worktrees for {}: {}", project.path, e);
return vec![single_workspace(project, true)];
}
};
if worktrees.is_empty() {
return vec![single_workspace(project, true)];
}
worktrees
.into_iter()
.map(|w| {
let leaf_name = Path::new(&w.path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| w.path.clone());
let label = w
.branch
.clone()
.unwrap_or_else(|| {
if w.detached {
"detached".to_string()
} else {
leaf_name
}
});
Workspace {
id: workspace_id(&project.id, &w.path),
project_id: project.id.clone(),
path: w.path.clone(),
label,
branch: w.branch,
is_main: w.path == project.path,
is_git_repo: true,
is_git_worktree: true,
}
})
.collect()
}
fn single_workspace(project: &Project, is_git: bool) -> Workspace {
Workspace {
id: workspace_id(&project.id, &project.path),
project_id: project.id.clone(),
path: project.path.clone(),
label: project.name.clone(),
branch: None,
is_main: true,
is_git_repo: is_git,
is_git_worktree: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
async fn make_git_repo(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"omniterm-test-{}-{}-{}",
label,
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
));
std::fs::create_dir_all(&dir).expect("create temp dir");
let run = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(&dir)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git command")
};
run(&["init", "-q", "-b", "main"]);
std::fs::write(dir.join("README.md"), "init").unwrap();
run(&["add", "."]);
run(&["commit", "-q", "-m", "init"]);
dir
}
async fn add_worktree(repo: &Path, branch: &str, target: &Path) {
let status = Command::new("git")
.args([
"-C",
repo.to_str().unwrap(),
"worktree",
"add",
"-b",
branch,
target.to_str().unwrap(),
])
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.status()
.expect("git worktree add");
assert!(status.success(), "git worktree add failed");
}
fn cleanup(path: &Path) {
let _ = std::fs::remove_dir_all(path);
}
fn dummy_project(id: &str, path: &str) -> Project {
Project {
id: id.to_string(),
target_id: None,
name: id.to_string(),
path: path.to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
}
}
#[tokio::test]
async fn empty_projects_returns_none() {
let dir = std::env::temp_dir().join("omniterm-empty-projects");
std::fs::create_dir_all(&dir).unwrap();
let result = find_covering_project(&dir, &[]).await.unwrap();
assert!(result.is_none());
cleanup(&dir);
}
#[tokio::test]
async fn nonexistent_new_path_returns_none() {
let projects = vec![dummy_project("p1", "/tmp/some-real-path")];
let result = find_covering_project(Path::new("/tmp/does-not-exist-xyz-123"), &projects)
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn exact_path_match_detected() {
let repo = make_git_repo("exact").await;
let repo_str = repo.to_str().unwrap().to_string();
let projects = vec![dummy_project("p1", &repo_str)];
let result = find_covering_project(&repo, &projects).await.unwrap();
assert!(matches!(
result,
Some((_, CoverKind::ExactPath))
));
cleanup(&repo);
}
#[tokio::test]
async fn worktree_of_existing_toplevel_detected() {
let repo = make_git_repo("wt-child").await;
let wt = repo.join("worktree-feat");
std::fs::create_dir_all(&wt).unwrap();
let wt = std::fs::canonicalize(&wt).unwrap();
add_worktree(&repo, "feat", &wt).await;
let projects = vec![dummy_project("p1", repo.to_str().unwrap())];
let result = find_covering_project(&wt, &projects).await.unwrap();
assert!(
matches!(result, Some((_, CoverKind::WorktreeChild))),
"expected WorktreeChild, got {:?}",
result
);
cleanup(&repo);
cleanup(&wt);
}
#[tokio::test]
async fn toplevel_of_existing_worktree_detected() {
let repo = make_git_repo("wt-parent").await;
let wt = repo.join("worktree-feat");
std::fs::create_dir_all(&wt).unwrap();
let wt = std::fs::canonicalize(&wt).unwrap();
add_worktree(&repo, "feat", &wt).await;
let projects = vec![dummy_project("p1", wt.to_str().unwrap())];
let result = find_covering_project(&repo, &projects).await.unwrap();
assert!(
matches!(result, Some((_, CoverKind::WorktreeChild))),
"expected WorktreeChild, got {:?}",
result
);
cleanup(&repo);
cleanup(&wt);
}
#[tokio::test]
async fn unrelated_git_repos_returns_none() {
let repo_a = make_git_repo("unrel-a").await;
let repo_b = make_git_repo("unrel-b").await;
let projects = vec![dummy_project("p_a", repo_a.to_str().unwrap())];
let result = find_covering_project(&repo_b, &projects).await.unwrap();
assert!(result.is_none(), "got {:?}", result);
cleanup(&repo_a);
cleanup(&repo_b);
}
#[tokio::test]
async fn non_git_project_skips_worktree_check() {
let dir = std::env::temp_dir().join("omniterm-non-git-proj");
std::fs::create_dir_all(&dir).unwrap();
let other = std::env::temp_dir().join("omniterm-non-git-other");
std::fs::create_dir_all(&other).unwrap();
let projects = vec![dummy_project("p1", dir.to_str().unwrap())];
let result = find_covering_project(&other, &projects).await.unwrap();
assert!(result.is_none());
cleanup(&dir);
cleanup(&other);
}
#[tokio::test]
async fn symlink_path_canonicalized_to_match() {
let repo = make_git_repo("symlink").await;
let link_dir = std::env::temp_dir().join(format!(
"omniterm-symlink-link-{}-{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
));
std::fs::create_dir_all(&link_dir).unwrap();
let link = link_dir.join("link-to-repo");
std::os::unix::fs::symlink(&repo, &link).unwrap();
let projects = vec![dummy_project("p1", repo.to_str().unwrap())];
let result = find_covering_project(&link, &projects).await.unwrap();
assert!(
matches!(result, Some((_, CoverKind::ExactPath))),
"expected ExactPath via symlink, got {:?}",
result
);
cleanup(&repo);
cleanup(&link_dir);
}
#[tokio::test]
async fn broken_existing_project_path_is_skipped() {
let dir = std::env::temp_dir().join("omniterm-broken-proj-target");
std::fs::create_dir_all(&dir).unwrap();
let projects = vec![dummy_project("broken", "/tmp/omniterm-truly-missing-xyz")];
let result = find_covering_project(&dir, &projects).await.unwrap();
assert!(result.is_none());
cleanup(&dir);
}
}