use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use async_trait::async_trait;
use git2::{Repository, RepositoryState, Status, StatusOptions, WorktreeLockStatus};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio::sync::watch;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use crate::daemon::service::{
DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
};
use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
pub const SERVICE_NAME: &str = "worktrees";
const SUBMENU_TITLE: &str = "Worktrees";
const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
const MENU_REFRESH_INTERVAL: Duration = Duration::from_secs(2);
struct RefreshTask {
token: CancellationToken,
handle: JoinHandle<()>,
}
pub struct WorktreesService {
registry: Arc<WorktreesRegistry>,
menu_cache: Arc<Mutex<Option<Vec<MenuItem>>>>,
refresh: Mutex<Option<RefreshTask>>,
}
impl WorktreesService {
#[must_use]
pub fn new() -> Self {
Self {
registry: Arc::new(WorktreesRegistry::new()),
menu_cache: Arc::new(Mutex::new(None)),
refresh: Mutex::new(None),
}
}
pub fn start_menu_refresh(&self) {
if tokio::runtime::Handle::try_current().is_err() {
tracing::debug!("no tokio runtime; worktrees menu refresh not started");
return;
}
let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
if guard.is_some() {
return;
}
let token = CancellationToken::new();
let loop_token = token.clone();
let registry = self.registry.clone();
let cache = self.menu_cache.clone();
let handle = tokio::spawn(async move {
loop {
let entries = registry.list();
if let Ok(items) =
tokio::task::spawn_blocking(move || menu_items_for(&entries)).await
{
*cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
}
tokio::select! {
() = loop_token.cancelled() => break,
() = tokio::time::sleep(MENU_REFRESH_INTERVAL) => {}
}
}
});
*guard = Some(RefreshTask { token, handle });
}
async fn close(&self, req: CloseRequest) -> Result<Value> {
let entries = self.registry.list();
let scan_path = req.path.clone();
let open_windows =
tokio::task::spawn_blocking(move || windows_with_path(&entries, &scan_path))
.await
.unwrap_or_default();
let open = !open_windows.is_empty();
let window_key = open_windows.first().map(|(k, _)| k.clone());
let window_folder_count = open_windows.first().map_or(0, |(_, c)| *c);
if req.remove && !req.confirmed {
let path = req.path.clone();
let git = tokio::task::spawn_blocking(move || git_safety(&path))
.await
.map_err(|e| anyhow!("safety check task panicked: {e}"))??;
return Ok(serde_json::to_value(SafetyReport {
removable: git.removable,
is_main: git.is_main,
open,
window_key,
window_folder_count,
risks: git.risks,
info: git.info,
})
.unwrap_or_else(|_| json!({})));
}
let others: Vec<String> = open_windows
.iter()
.map(|(k, _)| k.clone())
.filter(|k| req.requester_key.as_deref() != Some(k))
.collect();
for key in &others {
self.registry.mark_close_pending(key);
}
if !others.is_empty() {
await_windows_closed(
&self.registry,
&req.path,
req.requester_key.as_deref(),
CLOSE_WAIT_TIMEOUT,
CLOSE_WAIT_POLL,
)
.await?;
}
if req.remove {
let path = req.path.clone();
tokio::task::spawn_blocking(move || remove_worktree(&path))
.await
.map_err(|e| anyhow!("worktree removal task panicked: {e}"))??;
Ok(json!({ "removed": true }))
} else {
Ok(json!({ "closed": true }))
}
}
}
impl Default for WorktreesService {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl DaemonService for WorktreesService {
fn name(&self) -> &'static str {
SERVICE_NAME
}
async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
match op {
"register" => {
let req: RegisterRequest =
serde_json::from_value(payload).context("invalid `register` payload")?;
if req.key.trim().is_empty() {
bail!("`register` requires a non-empty `key`");
}
self.registry.register(req);
Ok(json!({ "ok": true }))
}
"heartbeat" => {
let key = require_str(&payload, "key", "heartbeat")?;
let known = self.registry.heartbeat(key);
let mut reply = json!({ "known": known });
if self.registry.take_close_pending(key) {
reply["close"] = Value::Bool(true);
}
Ok(reply)
}
"unregister" => {
let key = require_str(&payload, "key", "unregister")?;
Ok(json!({ "removed": self.registry.unregister(key) }))
}
"list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
"tree" => {
let folders = self.registry.open_folders();
let windows = self.registry.list();
Ok(json!({ "repos": tree_repos(folders, windows).await }))
}
"open" => {
let path = require_str(&payload, "path", "open")?;
focus_window(Path::new(path))?;
Ok(json!({ "ok": true }))
}
"close" => {
let req: CloseRequest =
serde_json::from_value(payload).context("invalid `close` payload")?;
self.close(req).await
}
other => bail!("unknown worktrees op: {other}"),
}
}
fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
if op != "subscribe" {
return None;
}
Some(Box::new(WorktreesStream {
registry: self.registry.clone(),
changes: self.registry.subscribe_changes(),
}))
}
fn menu(&self) -> MenuSnapshot {
let cached = self
.menu_cache
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone();
let items = cached.unwrap_or_else(|| menu_items_for(&self.registry.list()));
MenuSnapshot {
title: SUBMENU_TITLE.to_string(),
items,
}
}
async fn menu_action(&self, action_id: &str) -> Result<()> {
if let Some(key) = action_id.strip_prefix("focus:") {
let folder = self
.registry
.first_folder(key)
.ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
focus_window(&folder)?;
return Ok(());
}
bail!("unknown worktrees menu action: {action_id}")
}
async fn status(&self) -> ServiceStatus {
let entries = self.registry.list();
let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
let windows = enriched_windows(entries).await;
ServiceStatus {
name: SERVICE_NAME.to_string(),
healthy: true,
summary,
detail: json!({ "windows": windows }),
}
}
async fn shutdown(&self) {
let task = self
.refresh
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take();
if let Some(task) = task {
task.token.cancel();
let _ = task.handle.await;
}
}
}
fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
payload
.get(field)
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
struct GitStatus {
#[serde(skip_serializing_if = "Option::is_none")]
branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
ahead: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
behind: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
main_repo: Option<String>,
#[serde(skip_serializing_if = "is_false")]
is_worktree: bool,
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(b: &bool) -> bool {
!*b
}
fn git_status(folder: &Path) -> GitStatus {
let Ok(repo) = Repository::discover(folder) else {
return GitStatus::default();
};
let base = GitStatus {
main_repo: main_repo_name(repo.commondir()),
is_worktree: repo.is_worktree(),
..GitStatus::default()
};
let Ok(head) = repo.head() else {
return base;
};
let Some(name) = head
.shorthand()
.ok()
.filter(|_| head.is_branch())
.map(str::to_string)
else {
return base;
};
let branch = git2::Branch::wrap(head);
let (ahead, behind) = match upstream_ahead_behind(&repo, &branch) {
Some((ahead, behind)) => (Some(ahead), Some(behind)),
None => (None, None),
};
GitStatus {
branch: Some(name),
ahead,
behind,
..base
}
}
fn main_repo_name(commondir: &Path) -> Option<String> {
let file_name = commondir.file_name()?.to_string_lossy().into_owned();
if file_name == ".git" {
commondir
.parent()
.and_then(Path::file_name)
.map(|n| n.to_string_lossy().into_owned())
} else {
Some(
file_name
.strip_suffix(".git")
.unwrap_or(&file_name)
.to_string(),
)
}
}
fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
let upstream = branch.upstream().ok()?;
let local_oid = branch.get().target()?;
let upstream_oid = upstream.get().target()?;
repo.graph_ahead_behind(local_oid, upstream_oid).ok()
}
#[derive(Serialize)]
struct EnrichedEntry<'a> {
#[serde(flatten)]
entry: &'a WindowEntry,
#[serde(flatten)]
git: GitStatus,
}
fn enriched_entry(entry: &WindowEntry) -> Value {
let git = entry
.folders
.first()
.map(|folder| git_status(folder))
.unwrap_or_default();
serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
}
async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
.await
.unwrap_or_default()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct GithubIdentity {
owner: String,
name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct TreeWorktree {
path: String,
#[serde(skip_serializing_if = "Option::is_none")]
branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
ahead: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
behind: Option<usize>,
is_main: bool,
open: bool,
#[serde(skip_serializing_if = "Option::is_none")]
window_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct TreeRepo {
main_repo: String,
#[serde(skip_serializing_if = "Option::is_none")]
github: Option<GithubIdentity>,
root: String,
worktrees: Vec<TreeWorktree>,
}
fn github_identity(url: &str) -> Option<GithubIdentity> {
let url = url.trim();
let rest = [
"https://github.com/",
"http://github.com/",
"ssh://git@github.com/",
"git://github.com/",
"git@github.com:",
]
.iter()
.find_map(|prefix| url.strip_prefix(prefix))?;
let rest = rest.strip_suffix(".git").unwrap_or(rest);
let rest = rest.trim_end_matches('/');
let mut parts = rest.splitn(2, '/');
let owner = parts.next()?.trim();
let name = parts.next()?.trim();
if owner.is_empty() || name.is_empty() || name.contains('/') {
return None;
}
Some(GithubIdentity {
owner: owner.to_string(),
name: name.to_string(),
})
}
fn remote_github_identity(repo: &Repository) -> Option<GithubIdentity> {
if let Ok(origin) = repo.find_remote("origin") {
if let Some(id) = origin.url().ok().and_then(github_identity) {
return Some(id);
}
}
let names = repo.remotes().ok();
names
.iter()
.flat_map(|arr| arr.iter())
.flatten()
.flatten()
.filter_map(|name| repo.find_remote(name).ok())
.find_map(|remote| remote.url().ok().and_then(github_identity))
}
fn canonical(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn open_window_index(entries: &[WindowEntry]) -> HashMap<PathBuf, String> {
let mut index = HashMap::new();
for entry in entries {
for folder in &entry.folders {
index
.entry(canonical(folder))
.or_insert_with(|| entry.key.clone());
}
}
index
}
fn worktree_entry(
path: &Path,
is_main: bool,
open_index: &HashMap<PathBuf, String>,
) -> TreeWorktree {
let status = git_status(path);
let window_key = open_index.get(&canonical(path)).cloned();
TreeWorktree {
path: path.display().to_string(),
branch: status.branch,
ahead: status.ahead,
behind: status.behind,
is_main,
open: window_key.is_some(),
window_key,
}
}
fn repo_tree(discovered: &Repository, open_index: &HashMap<PathBuf, String>) -> Option<TreeRepo> {
let commondir = canonical(discovered.commondir());
let main_root = commondir.parent()?.to_path_buf();
let main_repo = Repository::open(&main_root).ok()?;
let mut worktrees = vec![worktree_entry(&main_root, true, open_index)];
let names = main_repo.worktrees().ok();
let mut linked: Vec<PathBuf> = names
.iter()
.flat_map(|arr| arr.iter())
.flatten() .flatten() .filter_map(|name| main_repo.find_worktree(name).ok())
.map(|wt| wt.path().to_path_buf())
.collect();
linked.sort();
worktrees.extend(
linked
.iter()
.map(|path| worktree_entry(path, false, open_index)),
);
Some(TreeRepo {
main_repo: main_repo_name(&commondir)?,
github: remote_github_identity(&main_repo),
root: main_root.display().to_string(),
worktrees,
})
}
fn build_tree(folders: Vec<PathBuf>, windows: Vec<WindowEntry>) -> Vec<TreeRepo> {
let open_index = open_window_index(&windows);
let mut repos: BTreeMap<PathBuf, TreeRepo> = BTreeMap::new();
for folder in &folders {
let Ok(repo) = Repository::discover(folder) else {
continue;
};
let key = canonical(repo.commondir());
if repos.contains_key(&key) {
continue;
}
if let Some(tree) = repo_tree(&repo, &open_index) {
repos.insert(key, tree);
}
}
repos.into_values().collect()
}
async fn tree_repos(folders: Vec<PathBuf>, windows: Vec<WindowEntry>) -> Vec<Value> {
tokio::task::spawn_blocking(move || {
build_tree(folders, windows)
.iter()
.map(|repo| serde_json::to_value(repo).unwrap_or_else(|_| json!({})))
.collect()
})
.await
.unwrap_or_default()
}
struct WorktreesStream {
registry: Arc<WorktreesRegistry>,
changes: watch::Receiver<u64>,
}
#[async_trait]
impl ServiceStream for WorktreesStream {
async fn changed(&mut self) {
if self.changes.changed().await.is_err() {
std::future::pending::<()>().await;
}
}
async fn snapshot(&self) -> Value {
let folders = self.registry.open_folders();
let windows = self.registry.list();
json!({ "repos": tree_repos(folders, windows).await })
}
}
fn display_name(entry: &WindowEntry) -> String {
if let Some(repo) = &entry.repo {
return repo.clone();
}
if let Some(folder) = entry.folders.first() {
return folder.file_name().map_or_else(
|| folder.display().to_string(),
|n| n.to_string_lossy().into_owned(),
);
}
"(no folder)".to_string()
}
const REPO_SEP: char = '·';
const WORKTREE_SEP: char = 'â‘‚';
fn menu_items_for(entries: &[WindowEntry]) -> Vec<MenuItem> {
if entries.is_empty() {
vec![MenuItem::Label("No open windows".to_string())]
} else {
window_menu_items(entries)
}
}
fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
entries
.iter()
.map(|entry| {
let label = window_label(entry);
if entry.folders.is_empty() {
MenuItem::Label(label)
} else {
MenuItem::Action(MenuAction {
id: format!("focus:{}", entry.key),
label,
enabled: true,
})
}
})
.collect()
}
fn window_label(entry: &WindowEntry) -> String {
let status = entry
.folders
.first()
.map(|folder| git_status(folder))
.unwrap_or_default();
let name = status
.main_repo
.clone()
.unwrap_or_else(|| display_name(entry));
if let Some(branch) = &status.branch {
let sep = if status.is_worktree {
WORKTREE_SEP
} else {
REPO_SEP
};
return match sync_indicator(status.ahead, status.behind) {
Some(sync) => format!("{name} {sep} {branch} {sync}"),
None => format!("{name} {sep} {branch}"),
};
}
match &entry.title {
Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
_ => name,
}
}
fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
match (ahead, behind) {
(Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
_ => None,
}
}
const CODE_BINARY_CANDIDATES: &[&str] = &[
"/usr/local/bin/code",
"/opt/homebrew/bin/code",
"/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
"/usr/bin/code",
];
fn focus_window(folder: &Path) -> Result<()> {
focus_window_with(&resolve_code_binary(), folder)
}
fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
if !folder.is_absolute() {
bail!(
"refusing to focus a non-absolute folder path: {}",
folder.display()
);
}
if !folder.is_dir() {
bail!("worktree folder no longer exists: {}", folder.display());
}
let child = Command::new(program)
.arg(folder)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.with_context(|| {
format!(
"failed to launch `{}` to focus {}",
program.display(),
folder.display()
)
})?;
std::thread::spawn(move || {
let mut child = child;
let _ = child.wait();
});
Ok(())
}
fn resolve_code_binary() -> PathBuf {
resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
}
fn resolve_code_binary_from(
env_override: Option<std::ffi::OsString>,
candidates: &[&str],
) -> PathBuf {
if let Some(path) = env_override {
return PathBuf::from(path);
}
for candidate in candidates {
let path = Path::new(candidate);
if path.exists() {
return path.to_path_buf();
}
}
PathBuf::from("code")
}
#[derive(Debug, Clone, Deserialize)]
struct CloseRequest {
path: PathBuf,
#[serde(default)]
requester_key: Option<String>,
#[serde(default)]
remove: bool,
#[serde(default)]
confirmed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct Note {
kind: String,
detail: String,
}
impl Note {
fn new(kind: &str, detail: impl Into<String>) -> Self {
Self {
kind: kind.to_string(),
detail: detail.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct SafetyReport {
removable: bool,
is_main: bool,
open: bool,
#[serde(skip_serializing_if = "Option::is_none")]
window_key: Option<String>,
window_folder_count: usize,
risks: Vec<Note>,
info: Vec<Note>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GitSafety {
is_main: bool,
removable: bool,
risks: Vec<Note>,
info: Vec<Note>,
}
fn windows_with_path(entries: &[WindowEntry], path: &Path) -> Vec<(String, usize)> {
let target = canonical(path);
entries
.iter()
.filter(|e| e.folders.iter().any(|f| canonical(f) == target))
.map(|e| (e.key.clone(), e.folders.len()))
.collect()
}
const CLOSE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
const CLOSE_WAIT_POLL: Duration = Duration::from_millis(250);
async fn await_windows_closed(
registry: &WorktreesRegistry,
path: &Path,
requester: Option<&str>,
timeout: Duration,
poll: Duration,
) -> Result<()> {
let deadline = std::time::Instant::now() + timeout;
loop {
let entries = registry.list();
let path = path.to_path_buf();
let requester = requester.map(str::to_string);
let remaining: Vec<String> = tokio::task::spawn_blocking(move || {
windows_with_path(&entries, &path)
.into_iter()
.map(|(k, _)| k)
.filter(|k| requester.as_deref() != Some(k))
.collect()
})
.await
.unwrap_or_default();
if remaining.is_empty() {
return Ok(());
}
if std::time::Instant::now() >= deadline {
bail!("window(s) did not close in time: {}", remaining.join(", "));
}
tokio::time::sleep(poll).await;
}
}
fn git_safety(path: &Path) -> Result<GitSafety> {
if !path.exists() {
return Ok(GitSafety {
is_main: false,
removable: true,
risks: vec![],
info: vec![Note::new("already-removed", "worktree no longer exists")],
});
}
let repo = Repository::open(path)
.with_context(|| format!("not a git worktree: {}", path.display()))?;
if !repo.is_worktree() {
return Ok(GitSafety {
is_main: true,
removable: false,
risks: vec![],
info: vec![Note::new(
"main-working-tree",
"the repository's main working tree is never deleted",
)],
});
}
let mut risks = Vec::new();
let mut info = Vec::new();
let (dirty, untracked) = count_dirty_untracked(&repo);
if dirty > 0 {
risks.push(Note::new(
"dirty",
format!("{dirty} modified tracked file(s) would be lost"),
));
}
if untracked > 0 {
risks.push(Note::new(
"untracked",
format!("{untracked} untracked file(s) would be lost"),
));
}
let state = repo.state();
if state != RepositoryState::Clean {
risks.push(Note::new(
"in-progress",
format!("an in-progress {state:?} operation would be lost"),
));
}
if repo.head_detached().unwrap_or(false) {
let lost = unreachable_commit_count(&repo).unwrap_or(0);
if lost > 0 {
risks.push(Note::new(
"unreachable-commits",
format!("{lost} commit(s) on a detached HEAD will be permanently lost"),
));
}
}
if let Some(ahead) = current_branch_ahead(&repo) {
if ahead > 0 {
info.push(Note::new(
"unpushed",
format!("{ahead} unpushed commit(s) on the branch (kept — the branch survives)"),
));
}
}
Ok(GitSafety {
is_main: false,
removable: true,
risks,
info,
})
}
fn count_dirty_untracked(repo: &Repository) -> (usize, usize) {
let mut opts = StatusOptions::new();
opts.include_untracked(true)
.recurse_untracked_dirs(true)
.include_ignored(false)
.exclude_submodules(true);
let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
return (0, 0);
};
let tracked = Status::INDEX_NEW
| Status::INDEX_MODIFIED
| Status::INDEX_DELETED
| Status::INDEX_RENAMED
| Status::INDEX_TYPECHANGE
| Status::WT_MODIFIED
| Status::WT_DELETED
| Status::WT_TYPECHANGE
| Status::WT_RENAMED
| Status::CONFLICTED;
let mut dirty = 0;
let mut untracked = 0;
for entry in statuses.iter() {
let s = entry.status();
if s.contains(Status::WT_NEW) {
untracked += 1;
}
if s.intersects(tracked) {
dirty += 1;
}
}
(dirty, untracked)
}
fn unreachable_commit_count(repo: &Repository) -> Option<usize> {
let head_oid = repo.head().ok()?.target()?;
let mut walk = repo.revwalk().ok()?;
walk.push(head_oid).ok()?;
for reference in repo.references().ok()? {
let Ok(reference) = reference else { continue };
if matches!(reference.name(), Ok("HEAD")) {
continue;
}
if let Some(oid) = reference.target() {
let _ = walk.hide(oid);
}
}
Some(walk.flatten().count())
}
fn current_branch_ahead(repo: &Repository) -> Option<usize> {
let head = repo.head().ok()?;
if !head.is_branch() {
return None;
}
let branch = git2::Branch::wrap(head);
upstream_ahead_behind(repo, &branch).map(|(ahead, _behind)| ahead)
}
fn worktree_name_for_path(main_repo: &Repository, target: &Path) -> Result<String> {
let names = main_repo.worktrees()?;
names
.iter()
.flatten() .flatten() .find(|name| {
main_repo
.find_worktree(name)
.is_ok_and(|wt| canonical(wt.path()) == target)
})
.map(str::to_string)
.ok_or_else(|| {
anyhow!(
"worktree {} is not registered in {}",
target.display(),
main_repo.path().display()
)
})
}
fn remove_worktree(path: &Path) -> Result<()> {
if !path.exists() {
return Ok(());
}
let repo = Repository::open(path)
.with_context(|| format!("not a git worktree: {}", path.display()))?;
if !repo.is_worktree() {
bail!(
"refusing to delete the main working tree: {}",
path.display()
);
}
let commondir = canonical(repo.commondir());
let main_root = commondir
.parent()
.ok_or_else(|| anyhow!("no repository root for {}", path.display()))?
.to_path_buf();
drop(repo);
let main_repo = Repository::open(&main_root)
.with_context(|| format!("failed to open repository at {}", main_root.display()))?;
let name = worktree_name_for_path(&main_repo, &canonical(path))?;
let worktree = main_repo.find_worktree(&name)?;
if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
}
let mut opts = git2::WorktreePruneOptions::new();
opts.valid(true).working_tree(true);
worktree
.prune(Some(&mut opts))
.with_context(|| format!("failed to remove worktree {}", path.display()))?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use chrono::Utc;
fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
json!({
"key": key,
"folders": [folder],
"repo": repo,
"title": format!("{key}-title"),
"pid": 1234,
})
}
fn windows_of(payload: &Value) -> &Vec<Value> {
payload
.get("windows")
.and_then(Value::as_array)
.expect("windows array")
}
#[tokio::test]
async fn name_and_unknown_op() {
let svc = WorktreesService::new();
assert_eq!(svc.name(), "worktrees");
assert!(svc.handle("frobnicate", Value::Null).await.is_err());
}
#[tokio::test]
async fn handle_routes_ops_and_shapes_payloads() {
let svc = WorktreesService::new();
let payload = svc.handle("list", Value::Null).await.unwrap();
assert_eq!(payload, json!({ "windows": [] }));
let reply = svc
.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
.await
.unwrap();
assert_eq!(reply, json!({ "ok": true }));
let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
assert_eq!(windows.len(), 1);
assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
assert!(windows[0].get("last_seen").is_some());
let known = svc
.handle("heartbeat", json!({ "key": "w1" }))
.await
.unwrap();
assert_eq!(known, json!({ "known": true }));
let unknown = svc
.handle("heartbeat", json!({ "key": "nope" }))
.await
.unwrap();
assert_eq!(unknown, json!({ "known": false }));
let gone = svc
.handle("unregister", json!({ "key": "w1" }))
.await
.unwrap();
assert_eq!(gone, json!({ "removed": true }));
let again = svc
.handle("unregister", json!({ "key": "w1" }))
.await
.unwrap();
assert_eq!(again, json!({ "removed": false }));
}
#[tokio::test]
async fn handle_rejects_missing_or_empty_key() {
let svc = WorktreesService::new();
assert!(svc.handle("register", json!({})).await.is_err());
assert!(svc
.handle("register", json!({ "key": " " }))
.await
.is_err());
assert!(svc.handle("heartbeat", json!({})).await.is_err());
assert!(svc.handle("unregister", json!({})).await.is_err());
}
#[test]
fn display_name_prefers_repo_then_folder_basename() {
let base = WindowEntry {
key: "k".to_string(),
folders: vec![PathBuf::from("/home/me/project")],
repo: Some("my-repo".to_string()),
title: None,
pid: None,
last_seen: Utc::now(),
};
assert_eq!(display_name(&base), "my-repo");
let no_repo = WindowEntry {
repo: None,
..base.clone()
};
assert_eq!(display_name(&no_repo), "project");
let nothing = WindowEntry {
repo: None,
folders: vec![],
..base.clone()
};
assert_eq!(display_name(¬hing), "(no folder)");
let rootish = WindowEntry {
repo: None,
folders: vec![PathBuf::from("/")],
..base
};
assert_eq!(display_name(&rootish), "/");
}
#[test]
fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
let now = Utc::now();
let entries = vec![
WindowEntry {
key: "k2".to_string(),
folders: vec![],
repo: Some("solo".to_string()),
title: Some("solo".to_string()),
pid: None,
last_seen: now,
},
WindowEntry {
key: "k1".to_string(),
folders: vec![PathBuf::from("/tmp/a")],
repo: Some("repo".to_string()),
title: Some("a branch".to_string()),
pid: None,
last_seen: now,
},
];
let items = window_menu_items(&entries);
assert_eq!(items.len(), 2);
assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
let action = items
.iter()
.find_map(|i| match i {
MenuItem::Action(a) => Some(a),
_ => None,
})
.expect("a focus action");
assert_eq!(action.id, "focus:k1");
assert_eq!(action.label, "repo · a branch");
let labels: Vec<&str> = items
.iter()
.filter_map(|i| match i {
MenuItem::Label(t) => Some(t.as_str()),
_ => None,
})
.collect();
assert_eq!(labels, vec!["solo"]);
}
#[tokio::test]
async fn menu_and_status_shapes() {
let svc = WorktreesService::new();
let menu = svc.menu();
assert_eq!(menu.title, "Worktrees");
assert!(matches!(
menu.items.first(),
Some(MenuItem::Label(text)) if text == "No open windows"
));
let status = svc.status().await;
assert_eq!(status.name, "worktrees");
assert!(status.healthy);
assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
.await
.unwrap();
svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
.await
.unwrap();
svc.handle(
"register",
json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
)
.await
.unwrap();
let status = svc.status().await;
assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
let menu = svc.menu();
assert_eq!(menu.items.len(), 3);
assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
let action_ids: Vec<&str> = menu
.items
.iter()
.filter_map(|i| match i {
MenuItem::Action(a) => Some(a.id.as_str()),
_ => None,
})
.collect();
assert!(action_ids.contains(&"focus:w1"));
assert!(action_ids.contains(&"focus:w2"));
assert!(!action_ids.contains(&"focus:w3"));
}
#[test]
fn start_menu_refresh_is_a_noop_outside_a_runtime() {
let svc = WorktreesService::new();
svc.start_menu_refresh();
assert!(svc.refresh.lock().unwrap().is_none());
}
#[tokio::test]
async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
let svc = WorktreesService::new();
svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
.await
.unwrap();
assert!(svc.menu_cache.lock().unwrap().is_none());
svc.start_menu_refresh();
svc.start_menu_refresh();
let mut filled = false;
for _ in 0..100 {
if svc.menu_cache.lock().unwrap().is_some() {
filled = true;
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(filled, "background refresh should populate the menu cache");
let menu = svc.menu();
assert_eq!(menu.title, "Worktrees");
assert!(menu
.items
.iter()
.any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
svc.shutdown().await;
assert!(svc.refresh.lock().unwrap().is_none());
}
#[tokio::test]
async fn default_constructs_an_empty_service() {
let svc = WorktreesService::default();
let payload = svc.handle("list", Value::Null).await.unwrap();
assert_eq!(payload, json!({ "windows": [] }));
}
#[tokio::test]
async fn subscribe_streams_only_for_the_subscribe_op() {
let svc = WorktreesService::new();
assert!(svc.subscribe("subscribe", &Value::Null).is_some());
assert!(svc.subscribe("list", &Value::Null).is_none());
assert!(svc.subscribe("register", &Value::Null).is_none());
assert!(svc.subscribe("bogus", &Value::Null).is_none());
}
#[tokio::test]
async fn subscribe_snapshot_matches_the_tree_op() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
empty_commit(&repo, Some("refs/heads/main"), &[], "A");
repo.set_head("refs/heads/main").unwrap();
let svc = WorktreesService::new();
let stream = svc
.subscribe("subscribe", &Value::Null)
.expect("subscribe stream");
assert_eq!(stream.snapshot().await, json!({ "repos": [] }));
svc.handle(
"register",
json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
)
.await
.unwrap();
let snap = stream.snapshot().await;
let tree = svc.handle("tree", Value::Null).await.unwrap();
assert_eq!(snap, tree);
let repos = snap["repos"].as_array().expect("repos array");
assert_eq!(repos.len(), 1);
assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
}
#[tokio::test]
async fn subscribe_changed_wakes_on_register() {
let svc = WorktreesService::new();
let mut stream = svc
.subscribe("subscribe", &Value::Null)
.expect("subscribe stream");
tokio::select! {
() = stream.changed() => panic!("changed resolved with no registry change"),
() = tokio::time::sleep(Duration::from_millis(50)) => {}
}
svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
.await
.unwrap();
tokio::time::timeout(Duration::from_secs(1), stream.changed())
.await
.expect("changed should resolve after a register");
}
#[tokio::test]
async fn menu_action_rejects_unknown_and_missing_window() {
let svc = WorktreesService::new();
assert!(svc.menu_action("bogus").await.is_err());
assert!(svc.menu_action("focus:nope").await.is_err());
svc.shutdown().await;
}
struct VscodeBinGuard(Option<std::ffi::OsString>);
impl Drop for VscodeBinGuard {
fn drop(&mut self) {
match self.0.take() {
Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
None => std::env::remove_var(VSCODE_BIN_ENV),
}
}
}
#[tokio::test]
async fn menu_action_focus_resolves_folder_and_spawns() {
let dir = tempfile::tempdir().unwrap();
let svc = WorktreesService::new();
svc.handle(
"register",
json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
)
.await
.unwrap();
let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
svc.menu_action("focus:w1").await.unwrap();
}
#[tokio::test]
async fn open_rejects_missing_relative_or_nonexistent_path() {
let svc = WorktreesService::new();
assert!(svc.handle("open", json!({})).await.is_err());
assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
assert!(svc
.handle("open", json!({ "path": "relative/dir" }))
.await
.is_err());
assert!(svc
.handle("open", json!({ "path": "-flag" }))
.await
.is_err());
assert!(svc
.handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
.await
.is_err());
svc.shutdown().await;
}
#[tokio::test]
async fn open_focuses_an_existing_absolute_dir() {
let dir = tempfile::tempdir().unwrap();
let svc = WorktreesService::new();
let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
let reply = svc
.handle("open", json!({ "path": dir.path() }))
.await
.unwrap();
assert_eq!(reply, json!({ "ok": true }));
svc.shutdown().await;
}
#[test]
fn focus_window_with_validates_folder_then_spawns() {
let dir = tempfile::tempdir().unwrap();
assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
assert!(
focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
);
focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
}
#[test]
fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
assert_eq!(
resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
PathBuf::from("/custom/code")
);
let existing = tempfile::NamedTempFile::new().unwrap();
let existing_path = existing.path().to_str().unwrap();
assert_eq!(
resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
PathBuf::from(existing_path)
);
assert_eq!(
resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
PathBuf::from("code")
);
let _ = resolve_code_binary();
}
fn init_repo(dir: &Path) -> Repository {
let repo = Repository::init(dir).unwrap();
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "Test").unwrap();
cfg.set_str("user.email", "test@example.com").unwrap();
repo
}
fn empty_commit(
repo: &Repository,
refname: Option<&str>,
parents: &[&git2::Commit<'_>],
msg: &str,
) -> git2::Oid {
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
let tree = repo
.find_tree(repo.treebuilder(None).unwrap().write().unwrap())
.unwrap();
repo.commit(refname, &sig, &sig, msg, &tree, parents)
.unwrap()
}
fn commit_file(
repo: &Repository,
refname: &str,
name: &str,
content: &[u8],
msg: &str,
) -> git2::Oid {
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
let blob = repo.blob(content).unwrap();
let mut builder = repo.treebuilder(None).unwrap();
builder.insert(name, blob, 0o100_644).unwrap();
let tree = repo.find_tree(builder.write().unwrap()).unwrap();
let parent = repo
.refname_to_id(refname)
.ok()
.and_then(|oid| repo.find_commit(oid).ok());
let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
.unwrap()
}
fn diverging_repo(dir: &Path) -> Repository {
let repo = init_repo(dir);
let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
let a_commit = repo.find_commit(a).unwrap();
let c = empty_commit(&repo, None, &[&a_commit], "C");
repo.reference("refs/remotes/origin/main", c, true, "origin main")
.unwrap();
empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
drop(a_commit);
repo.set_head("refs/heads/main").unwrap();
let mut cfg = repo.config().unwrap();
cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
.unwrap();
cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
.unwrap();
cfg.set_str("branch.main.remote", "origin").unwrap();
cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
repo
}
#[test]
fn git_status_reads_branch_and_ahead_behind() {
let dir = tempfile::tempdir().unwrap();
let _repo = diverging_repo(dir.path());
let status = git_status(dir.path());
assert_eq!(status.branch.as_deref(), Some("main"));
assert_eq!(status.ahead, Some(1));
assert_eq!(status.behind, Some(1));
assert_eq!(
status.main_repo.as_deref(),
dir.path().file_name().and_then(|n| n.to_str())
);
assert!(!status.is_worktree);
}
#[test]
fn git_status_empty_repo_is_unborn() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
let status = git_status(dir.path());
assert_eq!(status.branch, None);
assert_eq!(status.ahead, None);
assert_eq!(status.behind, None);
assert_eq!(
status.main_repo.as_deref(),
dir.path().file_name().and_then(|n| n.to_str())
);
assert!(!status.is_worktree);
}
#[test]
fn git_status_no_upstream_reports_branch_only() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
empty_commit(&repo, Some("refs/heads/main"), &[], "A");
repo.set_head("refs/heads/main").unwrap();
let status = git_status(dir.path());
assert_eq!(status.branch.as_deref(), Some("main"));
assert_eq!(status.ahead, None);
assert_eq!(status.behind, None);
}
#[test]
fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
let plain = tempfile::tempdir().unwrap();
assert_eq!(git_status(plain.path()), GitStatus::default());
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
repo.set_head_detached(a).unwrap();
let status = git_status(dir.path());
assert_eq!(status.branch, None);
assert_eq!(status.ahead, None);
assert_eq!(status.behind, None);
assert_eq!(
status.main_repo.as_deref(),
dir.path().file_name().and_then(|n| n.to_str())
);
assert!(!status.is_worktree);
}
#[test]
fn sync_indicator_formats_only_with_upstream() {
assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
assert_eq!(sync_indicator(None, None), None);
assert_eq!(sync_indicator(Some(1), None), None);
}
#[tokio::test]
async fn list_enriches_entries_with_git_status() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
empty_commit(&repo, Some("refs/heads/main"), &[], "A");
repo.set_head("refs/heads/main").unwrap();
let svc = WorktreesService::new();
svc.handle(
"register",
json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
)
.await
.unwrap();
let payload = svc.handle("list", Value::Null).await.unwrap();
let windows = windows_of(&payload);
assert_eq!(windows.len(), 1);
assert_eq!(
windows[0].get("branch").and_then(Value::as_str),
Some("main")
);
assert!(windows[0].get("ahead").is_none());
assert!(windows[0].get("behind").is_none());
assert_eq!(
windows[0].get("main_repo").and_then(Value::as_str),
dir.path().file_name().and_then(|n| n.to_str())
);
let plain = tempfile::tempdir().unwrap();
svc.handle(
"register",
json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
)
.await
.unwrap();
let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
let w2 = windows
.iter()
.find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
.unwrap();
assert!(w2.get("branch").is_none());
assert!(w2.get("main_repo").is_none());
}
#[test]
fn window_label_prefers_git_branch_over_title() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
empty_commit(&repo, Some("refs/heads/main"), &[], "A");
repo.set_head("refs/heads/main").unwrap();
let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
let entry = WindowEntry {
key: "k".to_string(),
folders: vec![dir.path().to_path_buf()],
repo: Some("companion-repo".to_string()),
title: Some("ignored title".to_string()),
pid: None,
last_seen: Utc::now(),
};
assert_eq!(window_label(&entry), format!("{repo_name} · main"));
}
#[tokio::test]
async fn list_includes_ahead_behind_for_tracking_branch() {
let dir = tempfile::tempdir().unwrap();
let _repo = diverging_repo(dir.path());
let svc = WorktreesService::new();
svc.handle(
"register",
json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
)
.await
.unwrap();
let payload = svc.handle("list", Value::Null).await.unwrap();
let windows = windows_of(&payload);
assert_eq!(
windows[0].get("branch").and_then(Value::as_str),
Some("main")
);
assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
}
#[test]
fn window_label_includes_sync_for_tracking_branch() {
let dir = tempfile::tempdir().unwrap();
let _repo = diverging_repo(dir.path());
let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
let entry = WindowEntry {
key: "k".to_string(),
folders: vec![dir.path().to_path_buf()],
repo: Some("companion-repo".to_string()),
title: None,
pid: None,
last_seen: Utc::now(),
};
assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
}
fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
let commit = repo.find_commit(base).unwrap();
repo.branch(branch, &commit, false).unwrap();
let reference = repo
.find_reference(&format!("refs/heads/{branch}"))
.unwrap();
let mut opts = git2::WorktreeAddOptions::new();
opts.reference(Some(&reference));
repo.worktree(branch, wt_path, Some(&opts)).unwrap();
}
#[test]
fn git_status_marks_linked_worktree_and_names_parent_repo() {
let main_dir = tempfile::tempdir().unwrap();
let repo = init_repo(main_dir.path());
let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
repo.set_head("refs/heads/main").unwrap();
let wt_parent = tempfile::tempdir().unwrap();
let wt_path = wt_parent.path().join("feature-wt");
add_worktree(&repo, a, &wt_path, "feature");
let status = git_status(&wt_path);
assert!(status.is_worktree);
assert_eq!(status.branch.as_deref(), Some("feature"));
assert_eq!(
status.main_repo.as_deref(),
main_dir.path().file_name().and_then(|n| n.to_str())
);
let main_status = git_status(main_dir.path());
assert!(!main_status.is_worktree);
assert_eq!(main_status.main_repo, status.main_repo);
}
#[test]
fn window_label_marks_worktree_with_fork_glyph() {
let main_dir = tempfile::tempdir().unwrap();
let repo = init_repo(main_dir.path());
let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
repo.set_head("refs/heads/main").unwrap();
let wt_parent = tempfile::tempdir().unwrap();
let wt_path = wt_parent.path().join("feature-wt");
add_worktree(&repo, a, &wt_path, "feature");
let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
let entry = WindowEntry {
key: "k".to_string(),
folders: vec![wt_path],
repo: Some("feature-wt".to_string()),
title: None,
pid: None,
last_seen: Utc::now(),
};
assert_eq!(window_label(&entry), format!("{repo_name} â‘‚ feature"));
}
#[test]
fn main_repo_name_derives_from_common_dir() {
assert_eq!(
main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
Some("omni-dev")
);
assert_eq!(
main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
Some("omni-dev")
);
assert_eq!(
main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
Some("omni-dev")
);
assert_eq!(main_repo_name(Path::new("/.git")), None);
}
fn repos_of(payload: &Value) -> Vec<Value> {
payload
.get("repos")
.and_then(Value::as_array)
.expect("repos array")
.clone()
}
fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
Some(GithubIdentity {
owner: owner.to_string(),
name: name.to_string(),
})
}
#[test]
fn github_identity_parses_supported_forms() {
assert_eq!(
github_identity("https://github.com/rust-works/omni-dev.git"),
github("rust-works", "omni-dev")
);
assert_eq!(
github_identity("https://github.com/rust-works/omni-dev"),
github("rust-works", "omni-dev")
);
assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
assert_eq!(
github_identity("git@github.com:rust-works/omni-dev.git"),
github("rust-works", "omni-dev")
);
assert_eq!(
github_identity("ssh://git@github.com/o/r.git"),
github("o", "r")
);
assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
assert_eq!(
github_identity(" https://github.com/o/r/ "),
github("o", "r")
);
}
#[test]
fn github_identity_rejects_non_github_and_malformed() {
assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
assert_eq!(github_identity("git@example.com:o/r.git"), None);
assert_eq!(github_identity("https://github.com/onlyowner"), None);
assert_eq!(github_identity("https://github.com/o/r/extra"), None);
assert_eq!(github_identity("https://github.com/"), None);
assert_eq!(github_identity("not a url"), None);
}
#[test]
fn remote_github_identity_reads_origin_then_falls_back() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
assert_eq!(remote_github_identity(&repo), None);
repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
assert_eq!(remote_github_identity(&repo), None);
repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
.unwrap();
assert_eq!(
remote_github_identity(&repo),
github("rust-works", "omni-dev")
);
repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
.unwrap();
repo.remote("upstream", "https://github.com/other/proj.git")
.unwrap();
assert_eq!(remote_github_identity(&repo), github("other", "proj"));
}
#[tokio::test]
async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
let svc = WorktreesService::new();
assert_eq!(
svc.handle("tree", Value::Null).await.unwrap(),
json!({ "repos": [] })
);
let plain = tempfile::tempdir().unwrap();
svc.handle(
"register",
json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
)
.await
.unwrap();
assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
}
#[tokio::test]
async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
let main_dir = tempfile::tempdir().unwrap();
let repo = init_repo(main_dir.path());
let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
repo.set_head("refs/heads/main").unwrap();
repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
.unwrap();
let wt_parent = tempfile::tempdir().unwrap();
let wt_path = wt_parent.path().join("feature-wt");
add_worktree(&repo, a, &wt_path, "feature");
let svc = WorktreesService::new();
svc.handle(
"register",
json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
)
.await
.unwrap();
svc.handle(
"register",
json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
)
.await
.unwrap();
let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
assert_eq!(
repos.len(),
1,
"two worktrees of one repo dedupe: {repos:?}"
);
let repo0 = &repos[0];
assert_eq!(
repo0.get("main_repo").and_then(Value::as_str),
main_dir.path().file_name().and_then(|n| n.to_str())
);
assert_eq!(
repo0.pointer("/github/owner").and_then(Value::as_str),
Some("rust-works")
);
assert_eq!(
repo0.pointer("/github/name").and_then(Value::as_str),
Some("omni-dev")
);
assert!(repo0.get("root").and_then(Value::as_str).is_some());
let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
assert_eq!(worktrees.len(), 2);
let main_wt = &worktrees[0];
assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
assert_eq!(
main_wt.get("window_key").and_then(Value::as_str),
Some("wm")
);
assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
let linked = &worktrees[1];
assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
assert_eq!(
linked.get("branch").and_then(Value::as_str),
Some("feature")
);
}
#[tokio::test]
async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
let main_dir = tempfile::tempdir().unwrap();
let repo = init_repo(main_dir.path());
let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
repo.set_head("refs/heads/main").unwrap();
let wt_parent = tempfile::tempdir().unwrap();
let wt_path = wt_parent.path().join("feature-wt");
add_worktree(&repo, a, &wt_path, "feature");
let svc = WorktreesService::new();
svc.handle(
"register",
json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
)
.await
.unwrap();
let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
assert_eq!(repos.len(), 1);
assert!(repos[0].get("github").is_none(), "no remote → no github");
let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
let linked = worktrees
.iter()
.find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
.expect("the linked worktree");
assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
assert!(linked.get("window_key").is_none());
}
fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
let main_dir = tempfile::tempdir().unwrap();
let repo = init_repo(main_dir.path());
let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
repo.set_head("refs/heads/trunk").unwrap();
let wt_parent = tempfile::tempdir().unwrap();
let wt_path = wt_parent.path().join("feature-wt");
add_worktree(&repo, a, &wt_path, "feature");
(main_dir, wt_parent, wt_path)
}
#[tokio::test]
async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
let (_main, _wtp, wt_path) = repo_with_linked_worktree();
let svc = WorktreesService::new();
let report = svc
.handle("close", json!({ "path": wt_path, "remove": true }))
.await
.unwrap();
assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
assert!(report
.get("risks")
.and_then(Value::as_array)
.unwrap()
.is_empty());
assert!(wt_path.exists());
}
#[tokio::test]
async fn close_removes_a_clean_linked_worktree() {
let (_main, _wtp, wt_path) = repo_with_linked_worktree();
let svc = WorktreesService::new();
let reply = svc
.handle(
"close",
json!({ "path": wt_path, "remove": true, "confirmed": true }),
)
.await
.unwrap();
assert_eq!(reply, json!({ "removed": true }));
assert!(
!wt_path.exists(),
"the worktree directory should be deleted"
);
}
#[tokio::test]
async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
let (_main, _wtp, wt_path) = repo_with_linked_worktree();
std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
let svc = WorktreesService::new();
let report = svc
.handle("close", json!({ "path": wt_path, "remove": true }))
.await
.unwrap();
let risks = report.get("risks").and_then(Value::as_array).unwrap();
assert!(
risks
.iter()
.any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
"expected an untracked risk: {report}"
);
assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
assert!(wt_path.exists());
}
#[tokio::test]
async fn close_confirmed_removes_a_dirty_worktree() {
let (_main, _wtp, wt_path) = repo_with_linked_worktree();
std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
let svc = WorktreesService::new();
let reply = svc
.handle(
"close",
json!({ "path": wt_path, "remove": true, "confirmed": true }),
)
.await
.unwrap();
assert_eq!(reply, json!({ "removed": true }));
assert!(!wt_path.exists());
}
#[tokio::test]
async fn close_refuses_to_remove_the_main_working_tree() {
let (main, _wtp, _wt_path) = repo_with_linked_worktree();
let svc = WorktreesService::new();
let report = svc
.handle("close", json!({ "path": main.path(), "remove": true }))
.await
.unwrap();
assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
assert_eq!(
report.get("removable").and_then(Value::as_bool),
Some(false)
);
assert!(svc
.handle(
"close",
json!({ "path": main.path(), "remove": true, "confirmed": true }),
)
.await
.is_err());
assert!(main.path().exists());
}
#[tokio::test]
async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
let main_dir = tempfile::tempdir().unwrap();
let repo = init_repo(main_dir.path());
let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
repo.set_head("refs/heads/trunk").unwrap();
let wt_parent = tempfile::tempdir().unwrap();
let wt_path = wt_parent.path().join("main-wt");
add_worktree(&repo, a, &wt_path, "main");
let svc = WorktreesService::new();
let reply = svc
.handle(
"close",
json!({ "path": wt_path, "remove": true, "confirmed": true }),
)
.await
.unwrap();
assert_eq!(reply, json!({ "removed": true }));
assert!(!wt_path.exists());
assert!(
repo.find_branch("main", git2::BranchType::Local).is_ok(),
"the default branch must survive worktree removal"
);
}
#[tokio::test]
async fn close_is_idempotent_when_the_worktree_is_already_gone() {
let (_main, _wtp, wt_path) = repo_with_linked_worktree();
let svc = WorktreesService::new();
svc.handle(
"close",
json!({ "path": wt_path, "remove": true, "confirmed": true }),
)
.await
.unwrap();
let reply = svc
.handle(
"close",
json!({ "path": wt_path, "remove": true, "confirmed": true }),
)
.await
.unwrap();
assert_eq!(reply, json!({ "removed": true }));
}
#[tokio::test]
async fn close_safety_check_detects_detached_head_unreachable_commits() {
let (_main, _wtp, wt_path) = repo_with_linked_worktree();
let wt_repo = Repository::open(&wt_path).unwrap();
let parent_oid = wt_repo.head().unwrap().target().unwrap();
let parent = wt_repo.find_commit(parent_oid).unwrap();
let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
wt_repo.set_head_detached(orphan).unwrap();
let svc = WorktreesService::new();
let report = svc
.handle("close", json!({ "path": wt_path, "remove": true }))
.await
.unwrap();
let risks = report.get("risks").and_then(Value::as_array).unwrap();
assert!(
risks
.iter()
.any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
"expected an unreachable-commits risk: {report}"
);
}
#[tokio::test]
async fn close_self_close_removes_when_the_requester_owns_the_target() {
let (_main, _wtp, wt_path) = repo_with_linked_worktree();
let svc = WorktreesService::new();
svc.handle(
"register",
json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
)
.await
.unwrap();
let reply = svc
.handle(
"close",
json!({
"path": wt_path,
"remove": true,
"confirmed": true,
"requester_key": "w1",
}),
)
.await
.unwrap();
assert_eq!(reply, json!({ "removed": true }));
assert!(!wt_path.exists());
}
#[tokio::test]
async fn close_safety_check_surfaces_the_owning_window() {
let (_main, _wtp, wt_path) = repo_with_linked_worktree();
let svc = WorktreesService::new();
svc.handle(
"register",
json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
)
.await
.unwrap();
let report = svc
.handle("close", json!({ "path": wt_path, "remove": true }))
.await
.unwrap();
assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
assert_eq!(
report.get("window_folder_count").and_then(Value::as_u64),
Some(2)
);
}
#[tokio::test]
async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
let svc = WorktreesService::new();
svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
.await
.unwrap();
assert_eq!(
svc.handle("heartbeat", json!({ "key": "w1" }))
.await
.unwrap(),
json!({ "known": true })
);
svc.registry.mark_close_pending("w1");
assert_eq!(
svc.handle("heartbeat", json!({ "key": "w1" }))
.await
.unwrap(),
json!({ "known": true, "close": true })
);
assert_eq!(
svc.handle("heartbeat", json!({ "key": "w1" }))
.await
.unwrap(),
json!({ "known": true })
);
}
#[tokio::test]
async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
let (_main, _wtp, wt_path) = repo_with_linked_worktree();
let svc = Arc::new(WorktreesService::new());
svc.handle(
"register",
json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
)
.await
.unwrap();
let svc2 = svc.clone();
let path = wt_path.clone();
let close = tokio::spawn(async move {
svc2.handle(
"close",
json!({
"path": path,
"remove": true,
"confirmed": true,
"requester_key": "w1",
}),
)
.await
});
let mut saw_close = false;
for _ in 0..200 {
let hb = svc
.handle("heartbeat", json!({ "key": "w2" }))
.await
.unwrap();
if hb.get("close").and_then(Value::as_bool) == Some(true) {
saw_close = true;
svc.handle("unregister", json!({ "key": "w2" }))
.await
.unwrap();
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert!(saw_close, "w2 should have been told to close");
let reply = close.await.unwrap().unwrap();
assert_eq!(reply, json!({ "removed": true }));
assert!(!wt_path.exists());
}
#[tokio::test]
async fn await_windows_closed_times_out_when_a_window_never_closes() {
let (_main, _wtp, wt_path) = repo_with_linked_worktree();
let svc = WorktreesService::new();
svc.handle(
"register",
json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
)
.await
.unwrap();
let err = await_windows_closed(
&svc.registry,
&wt_path,
Some("w1"),
Duration::from_millis(150),
Duration::from_millis(25),
)
.await
.unwrap_err();
assert!(
err.to_string().contains("w2"),
"error names the window: {err}"
);
await_windows_closed(
&svc.registry,
&wt_path,
Some("w2"),
Duration::from_millis(150),
Duration::from_millis(25),
)
.await
.unwrap();
}
#[tokio::test]
async fn close_window_without_remove_replies_closed_and_never_deletes() {
let (main, _wtp, _wt_path) = repo_with_linked_worktree();
let svc = WorktreesService::new();
let reply = svc
.handle("close", json!({ "path": main.path(), "remove": false }))
.await
.unwrap();
assert_eq!(reply, json!({ "closed": true }));
assert!(main.path().exists());
}
#[tokio::test]
async fn close_safety_check_flags_modified_tracked_files() {
let main_dir = tempfile::tempdir().unwrap();
let repo = init_repo(main_dir.path());
let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
repo.set_head("refs/heads/trunk").unwrap();
let wt_parent = tempfile::tempdir().unwrap();
let wt_path = wt_parent.path().join("feature-wt");
add_worktree(&repo, a, &wt_path, "feature");
std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
let svc = WorktreesService::new();
let report = svc
.handle("close", json!({ "path": wt_path, "remove": true }))
.await
.unwrap();
let risks = report.get("risks").and_then(Value::as_array).unwrap();
assert!(
risks
.iter()
.any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
"expected a dirty risk: {report}"
);
}
#[tokio::test]
async fn close_safety_check_flags_an_in_progress_operation() {
let (_main, _wtp, wt_path) = repo_with_linked_worktree();
let wt_repo = Repository::open(&wt_path).unwrap();
let head = wt_repo.head().unwrap().target().unwrap();
std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
assert_ne!(wt_repo.state(), RepositoryState::Clean);
let svc = WorktreesService::new();
let report = svc
.handle("close", json!({ "path": wt_path, "remove": true }))
.await
.unwrap();
let risks = report.get("risks").and_then(Value::as_array).unwrap();
assert!(
risks
.iter()
.any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
"expected an in-progress risk: {report}"
);
}
#[tokio::test]
async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
let main_dir = tempfile::tempdir().unwrap();
let repo = init_repo(main_dir.path());
let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
repo.set_head("refs/heads/trunk").unwrap();
let a_commit = repo.find_commit(a).unwrap();
repo.branch("feature", &a_commit, false).unwrap();
repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
.unwrap();
empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
drop(a_commit);
let mut cfg = repo.config().unwrap();
cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
.unwrap();
cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
.unwrap();
cfg.set_str("branch.feature.remote", "origin").unwrap();
cfg.set_str("branch.feature.merge", "refs/heads/feature")
.unwrap();
let wt_parent = tempfile::tempdir().unwrap();
let wt_path = wt_parent.path().join("feature-wt");
let reference = repo.find_reference("refs/heads/feature").unwrap();
let mut opts = git2::WorktreeAddOptions::new();
opts.reference(Some(&reference));
repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
let svc = WorktreesService::new();
let report = svc
.handle("close", json!({ "path": wt_path, "remove": true }))
.await
.unwrap();
let info = report.get("info").and_then(Value::as_array).unwrap();
assert!(
info.iter()
.any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
"expected an unpushed info note: {report}"
);
assert!(
report
.get("risks")
.and_then(Value::as_array)
.unwrap()
.is_empty(),
"unpushed commits alone must not block: {report}"
);
assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
}
#[tokio::test]
async fn close_safety_check_ignores_gitignored_files() {
let main_dir = tempfile::tempdir().unwrap();
let repo = init_repo(main_dir.path());
let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
repo.set_head("refs/heads/trunk").unwrap();
let wt_parent = tempfile::tempdir().unwrap();
let wt_path = wt_parent.path().join("feature-wt");
add_worktree(&repo, a, &wt_path, "feature");
std::fs::create_dir(wt_path.join("build")).unwrap();
std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
let svc = WorktreesService::new();
let report = svc
.handle("close", json!({ "path": wt_path, "remove": true }))
.await
.unwrap();
assert!(
report
.get("risks")
.and_then(Value::as_array)
.unwrap()
.is_empty(),
"a gitignored file must not create a risk: {report}"
);
assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
}
#[tokio::test]
async fn close_safety_check_treats_a_missing_path_as_already_removed() {
let svc = WorktreesService::new();
let report = svc
.handle(
"close",
json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
)
.await
.unwrap();
assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
assert!(report
.get("risks")
.and_then(Value::as_array)
.unwrap()
.is_empty());
let info = report.get("info").and_then(Value::as_array).unwrap();
assert!(info
.iter()
.any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
}
#[tokio::test]
async fn close_refuses_a_locked_worktree() {
let (main, _wtp, wt_path) = repo_with_linked_worktree();
let main_repo = Repository::open(main.path()).unwrap();
main_repo
.find_worktree("feature")
.unwrap()
.lock(Some("under test"))
.unwrap();
let svc = WorktreesService::new();
let err = svc
.handle(
"close",
json!({ "path": wt_path, "remove": true, "confirmed": true }),
)
.await
.unwrap_err();
assert!(
err.to_string().contains("locked"),
"expected a locked error: {err}"
);
assert!(wt_path.exists(), "a locked worktree must not be removed");
}
#[tokio::test]
async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
let (_main, _wtp, wt_path) = repo_with_linked_worktree();
let wt_repo = Repository::open(&wt_path).unwrap();
let tip = wt_repo.head().unwrap().target().unwrap();
wt_repo.set_head_detached(tip).unwrap();
assert!(wt_repo.head_detached().unwrap());
let svc = WorktreesService::new();
let report = svc
.handle("close", json!({ "path": wt_path, "remove": true }))
.await
.unwrap();
let risks = report.get("risks").and_then(Value::as_array).unwrap();
assert!(
!risks
.iter()
.any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
"a detached HEAD reachable from a branch must not be flagged: {report}"
);
}
#[test]
fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
let (main, _wtp, wt_path) = repo_with_linked_worktree();
let main_repo = Repository::open(main.path()).unwrap();
assert_eq!(
worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
"feature"
);
let err =
worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
assert!(
err.to_string().contains("not registered"),
"expected a not-registered error: {err}"
);
}
#[test]
fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
let (_main, _wtp, wt_path) = repo_with_linked_worktree();
let repo = Repository::open(&wt_path).unwrap();
std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
assert!(
repo.statuses(Some(&mut StatusOptions::new())).is_err(),
"a corrupt index should make statuses() fail"
);
assert_eq!(count_dirty_untracked(&repo), (0, 0));
}
}