1use std::collections::{BTreeMap, BTreeSet, HashMap};
34use std::path::{Path, PathBuf};
35use std::process::{Command, Stdio};
36use std::sync::atomic::{AtomicU64, Ordering};
37use std::sync::{Arc, Mutex, PoisonError};
38use std::time::{Duration, Instant};
39
40use anyhow::{anyhow, bail, Context, Result};
41use async_trait::async_trait;
42use git2::{Repository, RepositoryState, Status, StatusOptions, WorktreeLockStatus};
43use serde::{Deserialize, Serialize};
44use serde_json::{json, Value};
45use tokio::sync::watch;
46use tokio::sync::Mutex as AsyncMutex;
47use tokio::task::JoinHandle;
48use tokio_util::sync::CancellationToken;
49
50use crate::daemon::service::{
51 DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
52};
53use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
54
55pub const SERVICE_NAME: &str = "worktrees";
57
58const SUBMENU_TITLE: &str = "Worktrees";
60
61const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
64
65const MENU_REFRESH_INTERVAL: Duration = Duration::from_secs(2);
70
71struct RefreshTask {
73 token: CancellationToken,
75 handle: JoinHandle<()>,
77}
78
79pub struct WorktreesService {
81 registry: Arc<WorktreesRegistry>,
84 menu_cache: Arc<Mutex<Option<Vec<MenuItem>>>>,
90 refresh: Mutex<Option<RefreshTask>>,
92 tree_cache: Arc<TreeSnapshotCache>,
98}
99
100impl WorktreesService {
101 #[must_use]
106 pub fn new() -> Self {
107 let registry = Arc::new(WorktreesRegistry::new());
108 Self {
109 registry: registry.clone(),
110 menu_cache: Arc::new(Mutex::new(None)),
111 refresh: Mutex::new(None),
112 tree_cache: Arc::new(TreeSnapshotCache::new(registry)),
113 }
114 }
115
116 pub fn start_menu_refresh(&self) {
124 if tokio::runtime::Handle::try_current().is_err() {
125 tracing::debug!("no tokio runtime; worktrees menu refresh not started");
126 return;
127 }
128 let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
129 if guard.is_some() {
130 return;
131 }
132 let token = CancellationToken::new();
133 let loop_token = token.clone();
134 let registry = self.registry.clone();
135 let cache = self.menu_cache.clone();
136 let handle = tokio::spawn(async move {
137 loop {
138 let entries = registry.list();
142 if let Ok(items) =
143 tokio::task::spawn_blocking(move || menu_items_for(&entries)).await
144 {
145 *cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
146 }
147 tokio::select! {
148 () = loop_token.cancelled() => break,
149 () = tokio::time::sleep(MENU_REFRESH_INTERVAL) => {}
150 }
151 }
152 });
153 *guard = Some(RefreshTask { token, handle });
154 }
155
156 async fn close(&self, req: CloseRequest) -> Result<Value> {
171 let entries = self.registry.list();
175 let scan_path = req.path.clone();
176 let open_windows =
177 tokio::task::spawn_blocking(move || windows_with_path(&entries, &scan_path))
178 .await
179 .unwrap_or_default();
180 let open = !open_windows.is_empty();
181 let window_key = open_windows.first().map(|(k, _)| k.clone());
182 let window_folder_count = open_windows.first().map_or(0, |(_, c)| *c);
183
184 if req.remove && !req.confirmed {
188 let path = req.path.clone();
189 let git = tokio::task::spawn_blocking(move || git_safety(&path))
190 .await
191 .map_err(|e| anyhow!("safety check task panicked: {e}"))??;
192 return Ok(serde_json::to_value(SafetyReport {
193 removable: git.removable,
194 is_main: git.is_main,
195 open,
196 window_key,
197 window_folder_count,
198 risks: git.risks,
199 info: git.info,
200 })
201 .unwrap_or_else(|_| json!({})));
202 }
203
204 let others: Vec<String> = open_windows
211 .iter()
212 .map(|(k, _)| k.clone())
213 .filter(|k| req.requester_key.as_deref() != Some(k))
214 .collect();
215 for key in &others {
216 self.registry.mark_close_pending(key);
217 }
218 if !others.is_empty() {
219 await_windows_closed(
220 &self.registry,
221 &req.path,
222 req.requester_key.as_deref(),
223 CLOSE_WAIT_TIMEOUT,
224 CLOSE_WAIT_POLL,
225 )
226 .await?;
227 }
228
229 if req.remove {
230 let path = req.path.clone();
231 tokio::task::spawn_blocking(move || remove_worktree(&path))
232 .await
233 .map_err(|e| anyhow!("worktree removal task panicked: {e}"))??;
234 Ok(json!({ "removed": true }))
235 } else {
236 Ok(json!({ "closed": true }))
239 }
240 }
241}
242
243impl Default for WorktreesService {
244 fn default() -> Self {
245 Self::new()
246 }
247}
248
249#[async_trait]
250impl DaemonService for WorktreesService {
251 fn name(&self) -> &'static str {
252 SERVICE_NAME
253 }
254
255 async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
256 match op {
257 "register" => {
258 let req: RegisterRequest =
259 serde_json::from_value(payload).context("invalid `register` payload")?;
260 if req.key.trim().is_empty() {
261 bail!("`register` requires a non-empty `key`");
262 }
263 self.registry.register(req);
264 Ok(json!({ "ok": true }))
265 }
266 "heartbeat" => {
267 let key = require_str(&payload, "key", "heartbeat")?;
268 let known = self.registry.heartbeat(key);
269 let mut reply = json!({ "known": known });
274 if self.registry.take_close_pending(key) {
275 reply["close"] = Value::Bool(true);
276 }
277 Ok(reply)
278 }
279 "unregister" => {
280 let key = require_str(&payload, "key", "unregister")?;
281 Ok(json!({ "removed": self.registry.unregister(key) }))
282 }
283 "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
284 "tree" => {
285 Ok(tree_snapshot(&self.registry).await)
293 }
294 "ahead-behind" => {
295 let paths = payload
302 .get("paths")
303 .and_then(Value::as_array)
304 .map(|arr| {
305 arr.iter()
306 .filter_map(Value::as_str)
307 .map(PathBuf::from)
308 .collect::<Vec<_>>()
309 })
310 .unwrap_or_default();
311 Ok(json!({ "results": ahead_behind_results(paths).await }))
312 }
313 "set-show-closed" => {
314 let show_closed = payload
319 .get("show_closed")
320 .and_then(Value::as_bool)
321 .ok_or_else(|| anyhow!("`set-show-closed` requires a boolean `show_closed`"))?;
322 self.registry.set_show_closed(show_closed);
323 Ok(json!({ "ok": true }))
324 }
325 "open" => {
326 let path = require_str(&payload, "path", "open")?;
335 focus_window(Path::new(path))?;
336 Ok(json!({ "ok": true }))
337 }
338 "close" => {
339 let req: CloseRequest =
345 serde_json::from_value(payload).context("invalid `close` payload")?;
346 self.close(req).await
347 }
348 other => bail!("unknown worktrees op: {other}"),
349 }
350 }
351
352 fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
353 if op != "subscribe" {
356 return None;
357 }
358 Some(Box::new(WorktreesStream {
359 cache: self.tree_cache.clone(),
362 changes: self.registry.subscribe_changes(),
365 }))
366 }
367
368 fn menu(&self) -> MenuSnapshot {
369 let cached = self
374 .menu_cache
375 .lock()
376 .unwrap_or_else(PoisonError::into_inner)
377 .clone();
378 let items = cached.unwrap_or_else(|| menu_items_for(&self.registry.list()));
379 MenuSnapshot {
380 title: SUBMENU_TITLE.to_string(),
381 items,
382 }
383 }
384
385 async fn menu_action(&self, action_id: &str) -> Result<()> {
386 if let Some(key) = action_id.strip_prefix("focus:") {
387 let folder = self
390 .registry
391 .first_folder(key)
392 .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
393 focus_window(&folder)?;
394 return Ok(());
395 }
396 bail!("unknown worktrees menu action: {action_id}")
397 }
398
399 async fn status(&self) -> ServiceStatus {
400 let entries = self.registry.list();
401 let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
402 let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
403 let windows = enriched_windows(entries).await;
404 ServiceStatus {
405 name: SERVICE_NAME.to_string(),
406 healthy: true,
407 summary,
408 detail: json!({ "windows": windows }),
409 }
410 }
411
412 async fn shutdown(&self) {
413 let task = self
417 .refresh
418 .lock()
419 .unwrap_or_else(PoisonError::into_inner)
420 .take();
421 if let Some(task) = task {
422 task.token.cancel();
423 let _ = task.handle.await;
424 }
425 }
426}
427
428fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
432 payload
433 .get(field)
434 .and_then(Value::as_str)
435 .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
436}
437
438#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
449struct GitStatus {
450 #[serde(skip_serializing_if = "Option::is_none")]
452 branch: Option<String>,
453 #[serde(skip_serializing_if = "Option::is_none")]
455 ahead: Option<usize>,
456 #[serde(skip_serializing_if = "Option::is_none")]
458 behind: Option<usize>,
459 #[serde(skip_serializing_if = "Option::is_none")]
464 main_repo: Option<String>,
465 #[serde(skip_serializing_if = "is_false")]
468 is_worktree: bool,
469}
470
471#[allow(clippy::trivially_copy_pass_by_ref)]
475fn is_false(b: &bool) -> bool {
476 !*b
477}
478
479fn git_status(folder: &Path) -> GitStatus {
485 git_status_impl(folder, true)
486}
487
488fn git_status_cheap(folder: &Path) -> GitStatus {
496 git_status_impl(folder, false)
497}
498
499fn git_status_impl(folder: &Path, with_ahead_behind: bool) -> GitStatus {
505 let Ok(repo) = Repository::discover(folder) else {
506 return GitStatus::default();
507 };
508 let base = GitStatus {
511 main_repo: main_repo_name(repo.commondir()),
512 is_worktree: repo.is_worktree(),
513 ..GitStatus::default()
514 };
515 let Ok(head) = repo.head() else {
516 return base;
518 };
519 let Some(name) = head
523 .shorthand()
524 .ok()
525 .filter(|_| head.is_branch())
526 .map(str::to_string)
527 else {
528 return base;
529 };
530 let (ahead, behind) = if with_ahead_behind {
533 let branch = git2::Branch::wrap(head);
534 match upstream_ahead_behind(&repo, &branch) {
535 Some((ahead, behind)) => (Some(ahead), Some(behind)),
536 None => (None, None),
537 }
538 } else {
539 (None, None)
540 };
541 GitStatus {
542 branch: Some(name),
543 ahead,
544 behind,
545 ..base
546 }
547}
548
549fn folder_ahead_behind(folder: &Path) -> Option<(usize, usize)> {
556 let repo = Repository::discover(folder).ok()?;
557 let head = repo.head().ok()?;
558 if !head.is_branch() {
559 return None;
560 }
561 let branch = git2::Branch::wrap(head);
562 upstream_ahead_behind(&repo, &branch)
563}
564
565fn main_repo_name(commondir: &Path) -> Option<String> {
571 let file_name = commondir.file_name()?.to_string_lossy().into_owned();
572 if file_name == ".git" {
573 commondir
575 .parent()
576 .and_then(Path::file_name)
577 .map(|n| n.to_string_lossy().into_owned())
578 } else {
579 Some(
581 file_name
582 .strip_suffix(".git")
583 .unwrap_or(&file_name)
584 .to_string(),
585 )
586 }
587}
588
589fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
592 let upstream = branch.upstream().ok()?;
593 let local_oid = branch.get().target()?;
594 let upstream_oid = upstream.get().target()?;
595 repo.graph_ahead_behind(local_oid, upstream_oid).ok()
596}
597
598#[derive(Serialize)]
604struct EnrichedEntry<'a> {
605 #[serde(flatten)]
606 entry: &'a WindowEntry,
607 #[serde(flatten)]
608 git: GitStatus,
609}
610
611fn enriched_entry(entry: &WindowEntry) -> Value {
616 let git = entry
617 .folders
618 .first()
619 .map(|folder| git_status(folder))
620 .unwrap_or_default();
621 serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
622}
623
624async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
628 tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
629 .await
630 .unwrap_or_default()
631}
632
633#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
639struct GithubIdentity {
640 owner: String,
642 name: String,
644}
645
646#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
656struct TreeWorktree {
657 path: String,
659 #[serde(skip_serializing_if = "Option::is_none")]
661 branch: Option<String>,
662 is_main: bool,
664 open: bool,
666 #[serde(skip_serializing_if = "Option::is_none")]
669 window_key: Option<String>,
670}
671
672#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
676struct TreeRepo {
677 main_repo: String,
679 #[serde(skip_serializing_if = "Option::is_none")]
681 github: Option<GithubIdentity>,
682 root: String,
684 worktrees: Vec<TreeWorktree>,
687}
688
689fn github_identity(url: &str) -> Option<GithubIdentity> {
696 let url = url.trim();
697 let rest = [
699 "https://github.com/",
700 "http://github.com/",
701 "ssh://git@github.com/",
702 "git://github.com/",
703 "git@github.com:",
704 ]
705 .iter()
706 .find_map(|prefix| url.strip_prefix(prefix))?;
707 let rest = rest.strip_suffix(".git").unwrap_or(rest);
708 let rest = rest.trim_end_matches('/');
709 let mut parts = rest.splitn(2, '/');
710 let owner = parts.next()?.trim();
711 let name = parts.next()?.trim();
712 if owner.is_empty() || name.is_empty() || name.contains('/') {
714 return None;
715 }
716 Some(GithubIdentity {
717 owner: owner.to_string(),
718 name: name.to_string(),
719 })
720}
721
722fn remote_github_identity(repo: &Repository) -> Option<GithubIdentity> {
725 if let Ok(origin) = repo.find_remote("origin") {
726 if let Some(id) = origin.url().ok().and_then(github_identity) {
727 return Some(id);
728 }
729 }
730 let names = repo.remotes().ok();
734 names
735 .iter()
736 .flat_map(|arr| arr.iter())
737 .flatten()
738 .flatten()
739 .filter_map(|name| repo.find_remote(name).ok())
740 .find_map(|remote| remote.url().ok().and_then(github_identity))
741}
742
743fn canonical(path: &Path) -> PathBuf {
747 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
748}
749
750fn open_window_index(entries: &[WindowEntry]) -> HashMap<PathBuf, String> {
755 let mut index = HashMap::new();
756 for entry in entries {
757 for folder in &entry.folders {
758 index
759 .entry(canonical(folder))
760 .or_insert_with(|| entry.key.clone());
761 }
762 }
763 index
764}
765
766fn worktree_entry(
771 path: &Path,
772 is_main: bool,
773 open_index: &HashMap<PathBuf, String>,
774) -> TreeWorktree {
775 let status = git_status_cheap(path);
776 let window_key = open_index.get(&canonical(path)).cloned();
777 TreeWorktree {
778 path: path.display().to_string(),
779 branch: status.branch,
780 is_main,
781 open: window_key.is_some(),
782 window_key,
783 }
784}
785
786fn repo_tree(discovered: &Repository, open_index: &HashMap<PathBuf, String>) -> Option<TreeRepo> {
792 let commondir = canonical(discovered.commondir());
795 let main_root = commondir.parent()?.to_path_buf();
796 let main_repo = Repository::open(&main_root).ok()?;
797
798 let mut worktrees = vec![worktree_entry(&main_root, true, open_index)];
800 let names = main_repo.worktrees().ok();
805 let mut linked: Vec<PathBuf> = names
806 .iter()
807 .flat_map(|arr| arr.iter())
808 .flatten() .flatten() .filter_map(|name| main_repo.find_worktree(name).ok())
811 .map(|wt| wt.path().to_path_buf())
812 .collect();
813 linked.sort();
814 worktrees.extend(
815 linked
816 .iter()
817 .map(|path| worktree_entry(path, false, open_index)),
818 );
819
820 Some(TreeRepo {
821 main_repo: main_repo_name(&commondir)?,
822 github: remote_github_identity(&main_repo),
823 root: main_root.display().to_string(),
824 worktrees,
825 })
826}
827
828fn build_tree(folders: Vec<PathBuf>, windows: Vec<WindowEntry>) -> Vec<TreeRepo> {
834 let open_index = open_window_index(&windows);
835 let mut repos: BTreeMap<PathBuf, TreeRepo> = BTreeMap::new();
836 for folder in &folders {
837 let Ok(repo) = Repository::discover(folder) else {
838 continue;
839 };
840 let key = canonical(repo.commondir());
841 if repos.contains_key(&key) {
842 continue;
843 }
844 if let Some(tree) = repo_tree(&repo, &open_index) {
845 repos.insert(key, tree);
846 }
847 }
848 repos.into_values().collect()
849}
850
851async fn tree_repos(folders: Vec<PathBuf>, windows: Vec<WindowEntry>) -> Vec<Value> {
856 tokio::task::spawn_blocking(move || {
857 build_tree(folders, windows)
858 .iter()
859 .map(|repo| serde_json::to_value(repo).unwrap_or_else(|_| json!({})))
860 .collect()
861 })
862 .await
863 .unwrap_or_default()
864}
865
866async fn ahead_behind_results(paths: Vec<PathBuf>) -> Value {
880 tokio::task::spawn_blocking(move || {
881 let mut results = serde_json::Map::new();
882 for path in paths {
883 if let Some((ahead, behind)) = folder_ahead_behind(&path) {
884 results.insert(
885 path.display().to_string(),
886 json!({ "ahead": ahead, "behind": behind }),
887 );
888 }
889 }
890 Value::Object(results)
891 })
892 .await
893 .unwrap_or_else(|_| json!({}))
894}
895
896struct WorktreesStream {
910 cache: Arc<TreeSnapshotCache>,
913 changes: watch::Receiver<u64>,
917}
918
919#[async_trait]
920impl ServiceStream for WorktreesStream {
921 async fn changed(&mut self) {
922 if self.changes.changed().await.is_err() {
928 std::future::pending::<()>().await;
929 }
930 }
931
932 async fn snapshot(&self) -> Value {
933 self.cache.snapshot().await
938 }
939}
940
941struct TreeSnapshotCache {
965 registry: Arc<WorktreesRegistry>,
968 ttl: Duration,
972 state: AsyncMutex<Option<CachedTree>>,
977 computes: AtomicU64,
981}
982
983struct CachedTree {
986 generation: u64,
990 computed_at: Instant,
992 value: Arc<Value>,
995}
996
997impl TreeSnapshotCache {
998 fn new(registry: Arc<WorktreesRegistry>) -> Self {
1002 Self::with_ttl(registry, crate::daemon::server::STREAM_TICK)
1003 }
1004
1005 fn with_ttl(registry: Arc<WorktreesRegistry>, ttl: Duration) -> Self {
1008 Self {
1009 registry,
1010 ttl,
1011 state: AsyncMutex::new(None),
1012 computes: AtomicU64::new(0),
1013 }
1014 }
1015
1016 async fn snapshot(&self) -> Value {
1020 let mut state = self.state.lock().await;
1025 let generation = self.registry.change_generation();
1026 let fresh = state.as_ref().and_then(|cached| {
1029 (cached.generation == generation && cached.computed_at.elapsed() < self.ttl)
1030 .then(|| Arc::clone(&cached.value))
1031 });
1032 let value = if let Some(value) = fresh {
1033 value
1034 } else {
1035 let value = Arc::new(tree_snapshot(&self.registry).await);
1036 self.computes.fetch_add(1, Ordering::Relaxed);
1037 *state = Some(CachedTree {
1038 generation,
1039 computed_at: Instant::now(),
1040 value: Arc::clone(&value),
1041 });
1042 value
1043 };
1044 drop(state);
1046 (*value).clone()
1047 }
1048
1049 #[cfg(test)]
1052 fn compute_count(&self) -> u64 {
1053 self.computes.load(Ordering::Relaxed)
1054 }
1055}
1056
1057async fn tree_snapshot(registry: &WorktreesRegistry) -> Value {
1063 let folders = registry.open_folders();
1064 let windows = registry.list();
1065 let show_closed = registry.show_closed();
1066 json!({ "repos": tree_repos(folders, windows).await, "show_closed": show_closed })
1067}
1068
1069fn display_name(entry: &WindowEntry) -> String {
1072 if let Some(repo) = &entry.repo {
1073 return repo.clone();
1074 }
1075 if let Some(folder) = entry.folders.first() {
1076 return folder.file_name().map_or_else(
1077 || folder.display().to_string(),
1078 |n| n.to_string_lossy().into_owned(),
1079 );
1080 }
1081 "(no folder)".to_string()
1082}
1083
1084const REPO_SEP: char = '·';
1086const WORKTREE_SEP: char = '⑂';
1089
1090fn menu_items_for(entries: &[WindowEntry]) -> Vec<MenuItem> {
1095 if entries.is_empty() {
1096 vec![MenuItem::Label("No open windows".to_string())]
1097 } else {
1098 window_menu_items(entries)
1099 }
1100}
1101
1102fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
1109 entries
1110 .iter()
1111 .map(|entry| {
1112 let label = window_label(entry);
1113 if entry.folders.is_empty() {
1114 MenuItem::Label(label)
1115 } else {
1116 MenuItem::Action(MenuAction {
1117 id: format!("focus:{}", entry.key),
1118 label,
1119 enabled: true,
1120 })
1121 }
1122 })
1123 .collect()
1124}
1125
1126fn window_label(entry: &WindowEntry) -> String {
1132 let status = entry
1133 .folders
1134 .first()
1135 .map(|folder| git_status(folder))
1136 .unwrap_or_default();
1137 let name = status
1140 .main_repo
1141 .clone()
1142 .unwrap_or_else(|| display_name(entry));
1143 if let Some(branch) = &status.branch {
1144 let sep = if status.is_worktree {
1145 WORKTREE_SEP
1146 } else {
1147 REPO_SEP
1148 };
1149 return match sync_indicator(status.ahead, status.behind) {
1150 Some(sync) => format!("{name} {sep} {branch} {sync}"),
1151 None => format!("{name} {sep} {branch}"),
1152 };
1153 }
1154 match &entry.title {
1156 Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
1157 _ => name,
1158 }
1159}
1160
1161fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
1164 match (ahead, behind) {
1165 (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
1166 _ => None,
1167 }
1168}
1169
1170const CODE_BINARY_CANDIDATES: &[&str] = &[
1173 "/usr/local/bin/code",
1174 "/opt/homebrew/bin/code",
1175 "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
1176 "/usr/bin/code",
1177];
1178
1179fn focus_window(folder: &Path) -> Result<()> {
1182 focus_window_with(&resolve_code_binary(), folder)
1183}
1184
1185fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
1192 if !folder.is_absolute() {
1197 bail!(
1198 "refusing to focus a non-absolute folder path: {}",
1199 folder.display()
1200 );
1201 }
1202 if !folder.is_dir() {
1203 bail!("worktree folder no longer exists: {}", folder.display());
1204 }
1205 let child = Command::new(program)
1208 .arg(folder)
1209 .stdin(Stdio::null())
1210 .stdout(Stdio::null())
1211 .stderr(Stdio::null())
1212 .spawn()
1213 .with_context(|| {
1214 format!(
1215 "failed to launch `{}` to focus {}",
1216 program.display(),
1217 folder.display()
1218 )
1219 })?;
1220 std::thread::spawn(move || {
1222 let mut child = child;
1223 let _ = child.wait();
1224 });
1225 Ok(())
1226}
1227
1228fn resolve_code_binary() -> PathBuf {
1233 resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
1234}
1235
1236fn resolve_code_binary_from(
1239 env_override: Option<std::ffi::OsString>,
1240 candidates: &[&str],
1241) -> PathBuf {
1242 if let Some(path) = env_override {
1243 return PathBuf::from(path);
1244 }
1245 for candidate in candidates {
1246 let path = Path::new(candidate);
1247 if path.exists() {
1248 return path.to_path_buf();
1249 }
1250 }
1251 PathBuf::from("code")
1252}
1253
1254#[derive(Debug, Clone, Deserialize)]
1260struct CloseRequest {
1261 path: PathBuf,
1263 #[serde(default)]
1267 requester_key: Option<String>,
1268 #[serde(default)]
1272 remove: bool,
1273 #[serde(default)]
1276 confirmed: bool,
1277}
1278
1279#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1284struct Note {
1285 kind: String,
1287 detail: String,
1289}
1290
1291impl Note {
1292 fn new(kind: &str, detail: impl Into<String>) -> Self {
1293 Self {
1294 kind: kind.to_string(),
1295 detail: detail.into(),
1296 }
1297 }
1298}
1299
1300#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1304struct SafetyReport {
1305 removable: bool,
1308 is_main: bool,
1310 open: bool,
1312 #[serde(skip_serializing_if = "Option::is_none")]
1314 window_key: Option<String>,
1315 window_folder_count: usize,
1318 risks: Vec<Note>,
1321 info: Vec<Note>,
1324}
1325
1326#[derive(Debug, Clone, PartialEq, Eq)]
1329struct GitSafety {
1330 is_main: bool,
1331 removable: bool,
1332 risks: Vec<Note>,
1333 info: Vec<Note>,
1334}
1335
1336fn windows_with_path(entries: &[WindowEntry], path: &Path) -> Vec<(String, usize)> {
1340 let target = canonical(path);
1341 entries
1342 .iter()
1343 .filter(|e| e.folders.iter().any(|f| canonical(f) == target))
1344 .map(|e| (e.key.clone(), e.folders.len()))
1345 .collect()
1346}
1347
1348const CLOSE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
1355
1356const CLOSE_WAIT_POLL: Duration = Duration::from_millis(250);
1359
1360async fn await_windows_closed(
1367 registry: &WorktreesRegistry,
1368 path: &Path,
1369 requester: Option<&str>,
1370 timeout: Duration,
1371 poll: Duration,
1372) -> Result<()> {
1373 let deadline = std::time::Instant::now() + timeout;
1374 loop {
1375 let entries = registry.list();
1379 let path = path.to_path_buf();
1380 let requester = requester.map(str::to_string);
1381 let remaining: Vec<String> = tokio::task::spawn_blocking(move || {
1382 windows_with_path(&entries, &path)
1383 .into_iter()
1384 .map(|(k, _)| k)
1385 .filter(|k| requester.as_deref() != Some(k))
1386 .collect()
1387 })
1388 .await
1389 .unwrap_or_default();
1390
1391 if remaining.is_empty() {
1392 return Ok(());
1393 }
1394 if std::time::Instant::now() >= deadline {
1395 bail!("window(s) did not close in time: {}", remaining.join(", "));
1396 }
1397 tokio::time::sleep(poll).await;
1398 }
1399}
1400
1401fn git_safety(path: &Path) -> Result<GitSafety> {
1408 if !path.exists() {
1409 return Ok(GitSafety {
1410 is_main: false,
1411 removable: true,
1412 risks: vec![],
1413 info: vec![Note::new("already-removed", "worktree no longer exists")],
1414 });
1415 }
1416 let repo = Repository::open(path)
1417 .with_context(|| format!("not a git worktree: {}", path.display()))?;
1418 if !repo.is_worktree() {
1420 return Ok(GitSafety {
1421 is_main: true,
1422 removable: false,
1423 risks: vec![],
1424 info: vec![Note::new(
1425 "main-working-tree",
1426 "the repository's main working tree is never deleted",
1427 )],
1428 });
1429 }
1430
1431 let mut risks = Vec::new();
1432 let mut info = Vec::new();
1433
1434 let (dirty, untracked) = count_dirty_untracked(&repo);
1435 if dirty > 0 {
1436 risks.push(Note::new(
1437 "dirty",
1438 format!("{dirty} modified tracked file(s) would be lost"),
1439 ));
1440 }
1441 if untracked > 0 {
1442 risks.push(Note::new(
1443 "untracked",
1444 format!("{untracked} untracked file(s) would be lost"),
1445 ));
1446 }
1447
1448 let state = repo.state();
1450 if state != RepositoryState::Clean {
1451 risks.push(Note::new(
1452 "in-progress",
1453 format!("an in-progress {state:?} operation would be lost"),
1454 ));
1455 }
1456
1457 if repo.head_detached().unwrap_or(false) {
1461 let lost = unreachable_commit_count(&repo).unwrap_or(0);
1462 if lost > 0 {
1463 risks.push(Note::new(
1464 "unreachable-commits",
1465 format!("{lost} commit(s) on a detached HEAD will be permanently lost"),
1466 ));
1467 }
1468 }
1469
1470 if let Some(ahead) = current_branch_ahead(&repo) {
1473 if ahead > 0 {
1474 info.push(Note::new(
1475 "unpushed",
1476 format!("{ahead} unpushed commit(s) on the branch (kept — the branch survives)"),
1477 ));
1478 }
1479 }
1480
1481 Ok(GitSafety {
1482 is_main: false,
1483 removable: true,
1484 risks,
1485 info,
1486 })
1487}
1488
1489fn count_dirty_untracked(repo: &Repository) -> (usize, usize) {
1496 let mut opts = StatusOptions::new();
1497 opts.include_untracked(true)
1498 .recurse_untracked_dirs(true)
1499 .include_ignored(false)
1500 .exclude_submodules(true);
1501 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
1502 return (0, 0);
1503 };
1504 let tracked = Status::INDEX_NEW
1507 | Status::INDEX_MODIFIED
1508 | Status::INDEX_DELETED
1509 | Status::INDEX_RENAMED
1510 | Status::INDEX_TYPECHANGE
1511 | Status::WT_MODIFIED
1512 | Status::WT_DELETED
1513 | Status::WT_TYPECHANGE
1514 | Status::WT_RENAMED
1515 | Status::CONFLICTED;
1516 let mut dirty = 0;
1517 let mut untracked = 0;
1518 for entry in statuses.iter() {
1519 let s = entry.status();
1520 if s.contains(Status::WT_NEW) {
1521 untracked += 1;
1522 }
1523 if s.intersects(tracked) {
1524 dirty += 1;
1525 }
1526 }
1527 (dirty, untracked)
1528}
1529
1530fn unreachable_commit_count(repo: &Repository) -> Option<usize> {
1537 let head_oid = repo.head().ok()?.target()?;
1538 let mut walk = repo.revwalk().ok()?;
1539 walk.push(head_oid).ok()?;
1540 for reference in repo.references().ok()? {
1541 let Ok(reference) = reference else { continue };
1542 if matches!(reference.name(), Ok("HEAD")) {
1545 continue;
1546 }
1547 if let Some(oid) = reference.target() {
1548 let _ = walk.hide(oid);
1549 }
1550 }
1551 Some(walk.flatten().count())
1552}
1553
1554fn current_branch_ahead(repo: &Repository) -> Option<usize> {
1558 let head = repo.head().ok()?;
1559 if !head.is_branch() {
1560 return None;
1561 }
1562 let branch = git2::Branch::wrap(head);
1563 upstream_ahead_behind(repo, &branch).map(|(ahead, _behind)| ahead)
1564}
1565
1566fn worktree_name_for_path(main_repo: &Repository, target: &Path) -> Result<String> {
1572 let names = main_repo.worktrees()?;
1573 names
1574 .iter()
1575 .flatten() .flatten() .find(|name| {
1578 main_repo
1579 .find_worktree(name)
1580 .is_ok_and(|wt| canonical(wt.path()) == target)
1581 })
1582 .map(str::to_string)
1583 .ok_or_else(|| {
1584 anyhow!(
1585 "worktree {} is not registered in {}",
1586 target.display(),
1587 main_repo.path().display()
1588 )
1589 })
1590}
1591
1592fn remove_worktree(path: &Path) -> Result<()> {
1599 if !path.exists() {
1600 return Ok(());
1601 }
1602 let repo = Repository::open(path)
1603 .with_context(|| format!("not a git worktree: {}", path.display()))?;
1604 if !repo.is_worktree() {
1605 bail!(
1606 "refusing to delete the main working tree: {}",
1607 path.display()
1608 );
1609 }
1610 let commondir = canonical(repo.commondir());
1613 let main_root = commondir
1614 .parent()
1615 .ok_or_else(|| anyhow!("no repository root for {}", path.display()))?
1616 .to_path_buf();
1617 drop(repo);
1619 let main_repo = Repository::open(&main_root)
1620 .with_context(|| format!("failed to open repository at {}", main_root.display()))?;
1621 let name = worktree_name_for_path(&main_repo, &canonical(path))?;
1622 let worktree = main_repo.find_worktree(&name)?;
1623
1624 if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
1626 let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
1627 bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
1628 }
1629
1630 let mut opts = git2::WorktreePruneOptions::new();
1634 opts.valid(true).working_tree(true);
1635 worktree
1636 .prune(Some(&mut opts))
1637 .with_context(|| format!("failed to remove worktree {}", path.display()))?;
1638 Ok(())
1639}
1640
1641#[cfg(test)]
1642#[allow(clippy::unwrap_used, clippy::expect_used)]
1643mod tests {
1644 use super::*;
1645 use chrono::Utc;
1646
1647 fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
1648 json!({
1649 "key": key,
1650 "folders": [folder],
1651 "repo": repo,
1652 "title": format!("{key}-title"),
1653 "pid": 1234,
1654 })
1655 }
1656
1657 fn windows_of(payload: &Value) -> &Vec<Value> {
1659 payload
1660 .get("windows")
1661 .and_then(Value::as_array)
1662 .expect("windows array")
1663 }
1664
1665 #[tokio::test]
1666 async fn name_and_unknown_op() {
1667 let svc = WorktreesService::new();
1668 assert_eq!(svc.name(), "worktrees");
1669 assert!(svc.handle("frobnicate", Value::Null).await.is_err());
1670 }
1671
1672 #[tokio::test]
1673 async fn handle_routes_ops_and_shapes_payloads() {
1674 let svc = WorktreesService::new();
1675 let payload = svc.handle("list", Value::Null).await.unwrap();
1677 assert_eq!(payload, json!({ "windows": [] }));
1678
1679 let reply = svc
1681 .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
1682 .await
1683 .unwrap();
1684 assert_eq!(reply, json!({ "ok": true }));
1685 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
1686 assert_eq!(windows.len(), 1);
1687 assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
1688 assert!(windows[0].get("last_seen").is_some());
1689
1690 let known = svc
1692 .handle("heartbeat", json!({ "key": "w1" }))
1693 .await
1694 .unwrap();
1695 assert_eq!(known, json!({ "known": true }));
1696 let unknown = svc
1697 .handle("heartbeat", json!({ "key": "nope" }))
1698 .await
1699 .unwrap();
1700 assert_eq!(unknown, json!({ "known": false }));
1701
1702 let gone = svc
1704 .handle("unregister", json!({ "key": "w1" }))
1705 .await
1706 .unwrap();
1707 assert_eq!(gone, json!({ "removed": true }));
1708 let again = svc
1709 .handle("unregister", json!({ "key": "w1" }))
1710 .await
1711 .unwrap();
1712 assert_eq!(again, json!({ "removed": false }));
1713 }
1714
1715 #[tokio::test]
1716 async fn handle_rejects_missing_or_empty_key() {
1717 let svc = WorktreesService::new();
1718 assert!(svc.handle("register", json!({})).await.is_err());
1720 assert!(svc
1721 .handle("register", json!({ "key": " " }))
1722 .await
1723 .is_err());
1724 assert!(svc.handle("heartbeat", json!({})).await.is_err());
1726 assert!(svc.handle("unregister", json!({})).await.is_err());
1727 }
1728
1729 #[test]
1730 fn display_name_prefers_repo_then_folder_basename() {
1731 let base = WindowEntry {
1732 key: "k".to_string(),
1733 folders: vec![PathBuf::from("/home/me/project")],
1734 repo: Some("my-repo".to_string()),
1735 title: None,
1736 pid: None,
1737 last_seen: Utc::now(),
1738 };
1739 assert_eq!(display_name(&base), "my-repo");
1740
1741 let no_repo = WindowEntry {
1742 repo: None,
1743 ..base.clone()
1744 };
1745 assert_eq!(display_name(&no_repo), "project");
1746
1747 let nothing = WindowEntry {
1748 repo: None,
1749 folders: vec![],
1750 ..base.clone()
1751 };
1752 assert_eq!(display_name(¬hing), "(no folder)");
1753
1754 let rootish = WindowEntry {
1757 repo: None,
1758 folders: vec![PathBuf::from("/")],
1759 ..base
1760 };
1761 assert_eq!(display_name(&rootish), "/");
1762 }
1763
1764 #[test]
1765 fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
1766 let now = Utc::now();
1767 let entries = vec![
1768 WindowEntry {
1773 key: "k2".to_string(),
1774 folders: vec![],
1775 repo: Some("solo".to_string()),
1776 title: Some("solo".to_string()),
1777 pid: None,
1778 last_seen: now,
1779 },
1780 WindowEntry {
1783 key: "k1".to_string(),
1784 folders: vec![PathBuf::from("/tmp/a")],
1785 repo: Some("repo".to_string()),
1786 title: Some("a branch".to_string()),
1787 pid: None,
1788 last_seen: now,
1789 },
1790 ];
1791 let items = window_menu_items(&entries);
1792 assert_eq!(items.len(), 2);
1794 assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
1795
1796 let action = items
1799 .iter()
1800 .find_map(|i| match i {
1801 MenuItem::Action(a) => Some(a),
1802 _ => None,
1803 })
1804 .expect("a focus action");
1805 assert_eq!(action.id, "focus:k1");
1806 assert_eq!(action.label, "repo · a branch");
1807
1808 let labels: Vec<&str> = items
1810 .iter()
1811 .filter_map(|i| match i {
1812 MenuItem::Label(t) => Some(t.as_str()),
1813 _ => None,
1814 })
1815 .collect();
1816 assert_eq!(labels, vec!["solo"]);
1817 }
1818
1819 #[tokio::test]
1820 async fn menu_and_status_shapes() {
1821 let svc = WorktreesService::new();
1822 let menu = svc.menu();
1824 assert_eq!(menu.title, "Worktrees");
1825 assert!(matches!(
1826 menu.items.first(),
1827 Some(MenuItem::Label(text)) if text == "No open windows"
1828 ));
1829 let status = svc.status().await;
1830 assert_eq!(status.name, "worktrees");
1831 assert!(status.healthy);
1832 assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
1833
1834 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
1837 .await
1838 .unwrap();
1839 svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
1840 .await
1841 .unwrap();
1842 svc.handle(
1843 "register",
1844 json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
1845 )
1846 .await
1847 .unwrap();
1848 let status = svc.status().await;
1849 assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
1850
1851 let menu = svc.menu();
1852 assert_eq!(menu.items.len(), 3);
1854 assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
1855 let action_ids: Vec<&str> = menu
1856 .items
1857 .iter()
1858 .filter_map(|i| match i {
1859 MenuItem::Action(a) => Some(a.id.as_str()),
1860 _ => None,
1861 })
1862 .collect();
1863 assert!(action_ids.contains(&"focus:w1"));
1866 assert!(action_ids.contains(&"focus:w2"));
1867 assert!(!action_ids.contains(&"focus:w3"));
1868 }
1869
1870 #[test]
1871 fn start_menu_refresh_is_a_noop_outside_a_runtime() {
1872 let svc = WorktreesService::new();
1875 svc.start_menu_refresh();
1876 assert!(svc.refresh.lock().unwrap().is_none());
1877 }
1878
1879 #[tokio::test]
1880 async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
1881 let svc = WorktreesService::new();
1882 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
1883 .await
1884 .unwrap();
1885 assert!(svc.menu_cache.lock().unwrap().is_none());
1887
1888 svc.start_menu_refresh();
1889 svc.start_menu_refresh();
1891
1892 let mut filled = false;
1894 for _ in 0..100 {
1895 if svc.menu_cache.lock().unwrap().is_some() {
1896 filled = true;
1897 break;
1898 }
1899 tokio::time::sleep(Duration::from_millis(10)).await;
1900 }
1901 assert!(filled, "background refresh should populate the menu cache");
1902
1903 let menu = svc.menu();
1905 assert_eq!(menu.title, "Worktrees");
1906 assert!(menu
1907 .items
1908 .iter()
1909 .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
1910
1911 svc.shutdown().await;
1913 assert!(svc.refresh.lock().unwrap().is_none());
1914 }
1915
1916 #[tokio::test]
1917 async fn default_constructs_an_empty_service() {
1918 let svc = WorktreesService::default();
1919 let payload = svc.handle("list", Value::Null).await.unwrap();
1920 assert_eq!(payload, json!({ "windows": [] }));
1921 }
1922
1923 #[tokio::test]
1926 async fn subscribe_streams_only_for_the_subscribe_op() {
1927 let svc = WorktreesService::new();
1928 assert!(svc.subscribe("subscribe", &Value::Null).is_some());
1932 assert!(svc.subscribe("list", &Value::Null).is_none());
1933 assert!(svc.subscribe("register", &Value::Null).is_none());
1934 assert!(svc.subscribe("bogus", &Value::Null).is_none());
1935 }
1936
1937 #[tokio::test]
1938 async fn subscribe_snapshot_matches_the_tree_op() {
1939 let dir = tempfile::tempdir().unwrap();
1940 let repo = init_repo(dir.path());
1941 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
1942 repo.set_head("refs/heads/main").unwrap();
1943
1944 let svc = WorktreesService::new();
1945 let stream = svc
1946 .subscribe("subscribe", &Value::Null)
1947 .expect("subscribe stream");
1948 assert_eq!(
1951 stream.snapshot().await,
1952 json!({ "repos": [], "show_closed": true })
1953 );
1954
1955 svc.handle(
1958 "register",
1959 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
1960 )
1961 .await
1962 .unwrap();
1963 let snap = stream.snapshot().await;
1964 let tree = svc.handle("tree", Value::Null).await.unwrap();
1965 assert_eq!(snap, tree);
1966 let repos = snap["repos"].as_array().expect("repos array");
1967 assert_eq!(repos.len(), 1);
1968 assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
1969 }
1970
1971 #[tokio::test]
1972 async fn subscribe_changed_wakes_on_register() {
1973 let svc = WorktreesService::new();
1974 let mut stream = svc
1975 .subscribe("subscribe", &Value::Null)
1976 .expect("subscribe stream");
1977 tokio::select! {
1979 () = stream.changed() => panic!("changed resolved with no registry change"),
1980 () = tokio::time::sleep(Duration::from_millis(50)) => {}
1981 }
1982 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
1984 .await
1985 .unwrap();
1986 tokio::time::timeout(Duration::from_secs(1), stream.changed())
1987 .await
1988 .expect("changed should resolve after a register");
1989 }
1990
1991 #[tokio::test]
1994 async fn tree_cache_coalesces_reads_within_ttl_and_generation() {
1995 let reg = Arc::new(WorktreesRegistry::new());
1996 let cache = TreeSnapshotCache::with_ttl(reg, Duration::from_secs(60));
1998 let first = cache.snapshot().await;
2000 assert_eq!(cache.compute_count(), 1);
2001 let second = cache.snapshot().await;
2004 assert_eq!(
2005 cache.compute_count(),
2006 1,
2007 "an unchanged read must not rebuild"
2008 );
2009 assert_eq!(first, second);
2010 }
2011
2012 #[tokio::test]
2013 async fn tree_cache_single_flights_a_read_burst() {
2014 let reg = Arc::new(WorktreesRegistry::new());
2015 let cache = Arc::new(TreeSnapshotCache::with_ttl(reg, Duration::from_secs(60)));
2016 let mut handles = Vec::new();
2020 for _ in 0..16 {
2021 let cache = cache.clone();
2022 handles.push(tokio::spawn(async move { cache.snapshot().await }));
2023 }
2024 let mut results = Vec::new();
2025 for handle in handles {
2026 results.push(handle.await.unwrap());
2027 }
2028 assert_eq!(
2029 cache.compute_count(),
2030 1,
2031 "a concurrent read burst must build the tree once"
2032 );
2033 assert!(
2034 results.windows(2).all(|w| w[0] == w[1]),
2035 "every reader must observe the identical snapshot"
2036 );
2037 }
2038
2039 #[tokio::test]
2040 async fn tree_cache_rebuilds_on_registry_change() {
2041 let reg = Arc::new(WorktreesRegistry::new());
2042 let cache = TreeSnapshotCache::with_ttl(reg.clone(), Duration::from_secs(60));
2043 cache.snapshot().await;
2044 assert_eq!(cache.compute_count(), 1);
2045 assert!(reg.set_show_closed(false));
2049 cache.snapshot().await;
2050 assert_eq!(
2051 cache.compute_count(),
2052 2,
2053 "a generation bump must force a rebuild"
2054 );
2055 }
2056
2057 #[tokio::test]
2058 async fn tree_cache_rebuilds_after_ttl_expiry() {
2059 let reg = Arc::new(WorktreesRegistry::new());
2060 let cache = TreeSnapshotCache::with_ttl(reg, Duration::ZERO);
2063 cache.snapshot().await;
2064 cache.snapshot().await;
2065 assert_eq!(
2066 cache.compute_count(),
2067 2,
2068 "an expired TTL must force a rebuild each read"
2069 );
2070 }
2071
2072 #[tokio::test]
2073 async fn subscribe_streams_share_one_build_per_generation() {
2074 let svc = WorktreesService::new();
2075 let s1 = svc
2076 .subscribe("subscribe", &Value::Null)
2077 .expect("subscribe stream");
2078 let s2 = svc
2079 .subscribe("subscribe", &Value::Null)
2080 .expect("subscribe stream");
2081 let a = s1.snapshot().await;
2084 let b = s2.snapshot().await;
2085 assert_eq!(a, b);
2086 assert_eq!(
2087 svc.tree_cache.compute_count(),
2088 1,
2089 "N streams on one generation must share a single build"
2090 );
2091 }
2092
2093 #[tokio::test]
2096 async fn set_show_closed_toggles_the_snapshot_field() {
2097 let svc = WorktreesService::new();
2098 assert_eq!(
2100 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
2101 json!(true)
2102 );
2103 let reply = svc
2105 .handle("set-show-closed", json!({ "show_closed": false }))
2106 .await
2107 .unwrap();
2108 assert_eq!(reply, json!({ "ok": true }));
2109 assert_eq!(
2110 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
2111 json!(false)
2112 );
2113 }
2114
2115 #[tokio::test]
2116 async fn set_show_closed_rejects_a_non_boolean_payload() {
2117 let svc = WorktreesService::new();
2118 assert!(svc.handle("set-show-closed", json!({})).await.is_err());
2119 assert!(svc
2120 .handle("set-show-closed", json!({ "show_closed": "yes" }))
2121 .await
2122 .is_err());
2123 }
2124
2125 #[tokio::test]
2126 async fn set_show_closed_wakes_the_subscription() {
2127 let svc = WorktreesService::new();
2128 let mut stream = svc
2129 .subscribe("subscribe", &Value::Null)
2130 .expect("subscribe stream");
2131 svc.handle("set-show-closed", json!({ "show_closed": false }))
2133 .await
2134 .unwrap();
2135 tokio::time::timeout(Duration::from_secs(1), stream.changed())
2136 .await
2137 .expect("changed should resolve after a toggle flip");
2138 assert_eq!(stream.snapshot().await["show_closed"], json!(false));
2140 }
2141
2142 #[tokio::test]
2143 async fn menu_action_rejects_unknown_and_missing_window() {
2144 let svc = WorktreesService::new();
2145 assert!(svc.menu_action("bogus").await.is_err());
2146 assert!(svc.menu_action("focus:nope").await.is_err());
2148 svc.shutdown().await;
2149 }
2150
2151 struct VscodeBinGuard(Option<std::ffi::OsString>);
2158 impl Drop for VscodeBinGuard {
2159 fn drop(&mut self) {
2160 match self.0.take() {
2161 Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
2162 None => std::env::remove_var(VSCODE_BIN_ENV),
2163 }
2164 }
2165 }
2166
2167 #[tokio::test]
2168 async fn menu_action_focus_resolves_folder_and_spawns() {
2169 let dir = tempfile::tempdir().unwrap();
2170 let svc = WorktreesService::new();
2171 svc.handle(
2172 "register",
2173 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
2174 )
2175 .await
2176 .unwrap();
2177
2178 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
2181 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
2182 svc.menu_action("focus:w1").await.unwrap();
2183 }
2184
2185 #[tokio::test]
2186 async fn open_rejects_missing_relative_or_nonexistent_path() {
2187 let svc = WorktreesService::new();
2188 assert!(svc.handle("open", json!({})).await.is_err());
2190 assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
2191 assert!(svc
2194 .handle("open", json!({ "path": "relative/dir" }))
2195 .await
2196 .is_err());
2197 assert!(svc
2198 .handle("open", json!({ "path": "-flag" }))
2199 .await
2200 .is_err());
2201 assert!(svc
2204 .handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
2205 .await
2206 .is_err());
2207 svc.shutdown().await;
2208 }
2209
2210 #[tokio::test]
2211 async fn open_focuses_an_existing_absolute_dir() {
2212 let dir = tempfile::tempdir().unwrap();
2213 let svc = WorktreesService::new();
2214 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
2219 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
2220 let reply = svc
2221 .handle("open", json!({ "path": dir.path() }))
2222 .await
2223 .unwrap();
2224 assert_eq!(reply, json!({ "ok": true }));
2225 svc.shutdown().await;
2226 }
2227
2228 #[test]
2229 fn focus_window_with_validates_folder_then_spawns() {
2230 let dir = tempfile::tempdir().unwrap();
2231 assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
2233 assert!(
2234 focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
2235 );
2236 focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
2238 assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
2240 }
2241
2242 #[test]
2243 fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
2244 assert_eq!(
2246 resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
2247 PathBuf::from("/custom/code")
2248 );
2249 let existing = tempfile::NamedTempFile::new().unwrap();
2251 let existing_path = existing.path().to_str().unwrap();
2252 assert_eq!(
2253 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
2254 PathBuf::from(existing_path)
2255 );
2256 assert_eq!(
2258 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
2259 PathBuf::from("code")
2260 );
2261 let _ = resolve_code_binary();
2263 }
2264
2265 fn init_repo(dir: &Path) -> Repository {
2270 let repo = Repository::init(dir).unwrap();
2271 let mut cfg = repo.config().unwrap();
2272 cfg.set_str("user.name", "Test").unwrap();
2273 cfg.set_str("user.email", "test@example.com").unwrap();
2274 repo
2275 }
2276
2277 fn empty_commit(
2280 repo: &Repository,
2281 refname: Option<&str>,
2282 parents: &[&git2::Commit<'_>],
2283 msg: &str,
2284 ) -> git2::Oid {
2285 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
2286 let tree = repo
2287 .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
2288 .unwrap();
2289 repo.commit(refname, &sig, &sig, msg, &tree, parents)
2290 .unwrap()
2291 }
2292
2293 fn commit_file(
2298 repo: &Repository,
2299 refname: &str,
2300 name: &str,
2301 content: &[u8],
2302 msg: &str,
2303 ) -> git2::Oid {
2304 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
2305 let blob = repo.blob(content).unwrap();
2306 let mut builder = repo.treebuilder(None).unwrap();
2307 builder.insert(name, blob, 0o100_644).unwrap();
2308 let tree = repo.find_tree(builder.write().unwrap()).unwrap();
2309 let parent = repo
2310 .refname_to_id(refname)
2311 .ok()
2312 .and_then(|oid| repo.find_commit(oid).ok());
2313 let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
2314 repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
2315 .unwrap()
2316 }
2317
2318 fn diverging_repo(dir: &Path) -> Repository {
2321 let repo = init_repo(dir);
2322 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2324 let a_commit = repo.find_commit(a).unwrap();
2325 let c = empty_commit(&repo, None, &[&a_commit], "C");
2327 repo.reference("refs/remotes/origin/main", c, true, "origin main")
2328 .unwrap();
2329 empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
2331 drop(a_commit);
2333 repo.set_head("refs/heads/main").unwrap();
2334 let mut cfg = repo.config().unwrap();
2336 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
2337 .unwrap();
2338 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
2339 .unwrap();
2340 cfg.set_str("branch.main.remote", "origin").unwrap();
2341 cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
2342 repo
2343 }
2344
2345 #[test]
2346 fn git_status_reads_branch_and_ahead_behind() {
2347 let dir = tempfile::tempdir().unwrap();
2348 let _repo = diverging_repo(dir.path());
2349 let status = git_status(dir.path());
2350 assert_eq!(status.branch.as_deref(), Some("main"));
2351 assert_eq!(status.ahead, Some(1));
2352 assert_eq!(status.behind, Some(1));
2353 assert_eq!(
2355 status.main_repo.as_deref(),
2356 dir.path().file_name().and_then(|n| n.to_str())
2357 );
2358 assert!(!status.is_worktree);
2359 }
2360
2361 #[test]
2362 fn git_status_empty_repo_is_unborn() {
2363 let dir = tempfile::tempdir().unwrap();
2367 init_repo(dir.path());
2368 let status = git_status(dir.path());
2369 assert_eq!(status.branch, None);
2370 assert_eq!(status.ahead, None);
2371 assert_eq!(status.behind, None);
2372 assert_eq!(
2373 status.main_repo.as_deref(),
2374 dir.path().file_name().and_then(|n| n.to_str())
2375 );
2376 assert!(!status.is_worktree);
2377 }
2378
2379 #[test]
2380 fn git_status_no_upstream_reports_branch_only() {
2381 let dir = tempfile::tempdir().unwrap();
2382 let repo = init_repo(dir.path());
2383 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2384 repo.set_head("refs/heads/main").unwrap();
2385 let status = git_status(dir.path());
2386 assert_eq!(status.branch.as_deref(), Some("main"));
2387 assert_eq!(status.ahead, None);
2389 assert_eq!(status.behind, None);
2390 }
2391
2392 #[test]
2393 fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
2394 let plain = tempfile::tempdir().unwrap();
2396 assert_eq!(git_status(plain.path()), GitStatus::default());
2397
2398 let dir = tempfile::tempdir().unwrap();
2401 let repo = init_repo(dir.path());
2402 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2403 repo.set_head_detached(a).unwrap();
2404 let status = git_status(dir.path());
2405 assert_eq!(status.branch, None);
2406 assert_eq!(status.ahead, None);
2407 assert_eq!(status.behind, None);
2408 assert_eq!(
2409 status.main_repo.as_deref(),
2410 dir.path().file_name().and_then(|n| n.to_str())
2411 );
2412 assert!(!status.is_worktree);
2413 }
2414
2415 #[test]
2418 fn git_status_cheap_reads_branch_but_skips_the_divergence_walk() {
2419 let dir = tempfile::tempdir().unwrap();
2423 let _repo = diverging_repo(dir.path());
2424 let status = git_status_cheap(dir.path());
2425 assert_eq!(status.branch.as_deref(), Some("main"));
2426 assert_eq!(status.ahead, None);
2427 assert_eq!(status.behind, None);
2428 assert_eq!(
2429 status.main_repo.as_deref(),
2430 dir.path().file_name().and_then(|n| n.to_str())
2431 );
2432 }
2433
2434 #[test]
2435 fn folder_ahead_behind_computes_divergence_and_degrades() {
2436 let dir = tempfile::tempdir().unwrap();
2438 let _repo = diverging_repo(dir.path());
2439 assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
2440
2441 let no_up = tempfile::tempdir().unwrap();
2443 let repo = init_repo(no_up.path());
2444 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2445 repo.set_head("refs/heads/main").unwrap();
2446 assert_eq!(folder_ahead_behind(no_up.path()), None);
2447
2448 let detached = tempfile::tempdir().unwrap();
2450 let drepo = init_repo(detached.path());
2451 let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
2452 drepo.set_head_detached(a).unwrap();
2453 assert_eq!(folder_ahead_behind(detached.path()), None);
2454 let plain = tempfile::tempdir().unwrap();
2455 assert_eq!(folder_ahead_behind(plain.path()), None);
2456 }
2457
2458 #[tokio::test]
2459 async fn ahead_behind_op_returns_divergence_keyed_by_path_and_omits_no_upstream() {
2460 let diverging = tempfile::tempdir().unwrap();
2461 let _d = diverging_repo(diverging.path());
2462 let no_up = tempfile::tempdir().unwrap();
2463 let repo = init_repo(no_up.path());
2464 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2465 repo.set_head("refs/heads/main").unwrap();
2466
2467 let svc = WorktreesService::new();
2468 let diverging_path = diverging.path().display().to_string();
2469 let no_up_path = no_up.path().display().to_string();
2470 let reply = svc
2471 .handle(
2472 "ahead-behind",
2473 json!({ "paths": [&diverging_path, &no_up_path] }),
2474 )
2475 .await
2476 .unwrap();
2477 let results = reply.get("results").unwrap();
2478 let d = results.get(diverging_path.as_str()).unwrap();
2480 assert_eq!(d.get("ahead").and_then(Value::as_u64), Some(1));
2481 assert_eq!(d.get("behind").and_then(Value::as_u64), Some(1));
2482 assert!(results.get(no_up_path.as_str()).is_none(), "{results:?}");
2484
2485 let empty = svc.handle("ahead-behind", json!({})).await.unwrap();
2487 assert_eq!(empty.get("results"), Some(&json!({})));
2488 }
2489
2490 #[tokio::test]
2491 async fn tree_snapshot_omits_ahead_behind_for_a_diverging_worktree() {
2492 let dir = tempfile::tempdir().unwrap();
2494 let _repo = diverging_repo(dir.path());
2495 let svc = WorktreesService::new();
2496 svc.handle(
2497 "register",
2498 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
2499 )
2500 .await
2501 .unwrap();
2502
2503 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
2504 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
2505 let main_wt = &worktrees[0];
2506 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
2509 assert!(main_wt.get("ahead").is_none(), "{main_wt:?}");
2510 assert!(main_wt.get("behind").is_none(), "{main_wt:?}");
2511 }
2512
2513 #[test]
2514 fn sync_indicator_formats_only_with_upstream() {
2515 assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
2516 assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
2517 assert_eq!(sync_indicator(None, None), None);
2518 assert_eq!(sync_indicator(Some(1), None), None);
2520 }
2521
2522 #[tokio::test]
2523 async fn list_enriches_entries_with_git_status() {
2524 let dir = tempfile::tempdir().unwrap();
2525 let repo = init_repo(dir.path());
2526 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2527 repo.set_head("refs/heads/main").unwrap();
2528
2529 let svc = WorktreesService::new();
2530 svc.handle(
2531 "register",
2532 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
2533 )
2534 .await
2535 .unwrap();
2536 let payload = svc.handle("list", Value::Null).await.unwrap();
2537 let windows = windows_of(&payload);
2538 assert_eq!(windows.len(), 1);
2539 assert_eq!(
2540 windows[0].get("branch").and_then(Value::as_str),
2541 Some("main")
2542 );
2543 assert!(windows[0].get("ahead").is_none());
2545 assert!(windows[0].get("behind").is_none());
2546 assert_eq!(
2548 windows[0].get("main_repo").and_then(Value::as_str),
2549 dir.path().file_name().and_then(|n| n.to_str())
2550 );
2551
2552 let plain = tempfile::tempdir().unwrap();
2554 svc.handle(
2555 "register",
2556 json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
2557 )
2558 .await
2559 .unwrap();
2560 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
2561 let w2 = windows
2562 .iter()
2563 .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
2564 .unwrap();
2565 assert!(w2.get("branch").is_none());
2566 assert!(w2.get("main_repo").is_none());
2567 }
2568
2569 #[test]
2570 fn window_label_prefers_git_branch_over_title() {
2571 let dir = tempfile::tempdir().unwrap();
2572 let repo = init_repo(dir.path());
2573 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2574 repo.set_head("refs/heads/main").unwrap();
2575 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
2576 let entry = WindowEntry {
2577 key: "k".to_string(),
2578 folders: vec![dir.path().to_path_buf()],
2579 repo: Some("companion-repo".to_string()),
2582 title: Some("ignored title".to_string()),
2583 pid: None,
2584 last_seen: Utc::now(),
2585 };
2586 assert_eq!(window_label(&entry), format!("{repo_name} · main"));
2588 }
2589
2590 #[tokio::test]
2591 async fn list_includes_ahead_behind_for_tracking_branch() {
2592 let dir = tempfile::tempdir().unwrap();
2593 let _repo = diverging_repo(dir.path());
2594
2595 let svc = WorktreesService::new();
2596 svc.handle(
2597 "register",
2598 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
2599 )
2600 .await
2601 .unwrap();
2602 let payload = svc.handle("list", Value::Null).await.unwrap();
2603 let windows = windows_of(&payload);
2604 assert_eq!(
2606 windows[0].get("branch").and_then(Value::as_str),
2607 Some("main")
2608 );
2609 assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
2610 assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
2611 }
2612
2613 #[test]
2614 fn window_label_includes_sync_for_tracking_branch() {
2615 let dir = tempfile::tempdir().unwrap();
2616 let _repo = diverging_repo(dir.path());
2617 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
2618 let entry = WindowEntry {
2619 key: "k".to_string(),
2620 folders: vec![dir.path().to_path_buf()],
2621 repo: Some("companion-repo".to_string()),
2622 title: None,
2623 pid: None,
2624 last_seen: Utc::now(),
2625 };
2626 assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
2628 }
2629
2630 fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
2634 let commit = repo.find_commit(base).unwrap();
2635 repo.branch(branch, &commit, false).unwrap();
2636 let reference = repo
2637 .find_reference(&format!("refs/heads/{branch}"))
2638 .unwrap();
2639 let mut opts = git2::WorktreeAddOptions::new();
2640 opts.reference(Some(&reference));
2641 repo.worktree(branch, wt_path, Some(&opts)).unwrap();
2642 }
2643
2644 #[test]
2645 fn git_status_marks_linked_worktree_and_names_parent_repo() {
2646 let main_dir = tempfile::tempdir().unwrap();
2647 let repo = init_repo(main_dir.path());
2648 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2649 repo.set_head("refs/heads/main").unwrap();
2650
2651 let wt_parent = tempfile::tempdir().unwrap();
2654 let wt_path = wt_parent.path().join("feature-wt");
2655 add_worktree(&repo, a, &wt_path, "feature");
2656
2657 let status = git_status(&wt_path);
2658 assert!(status.is_worktree);
2659 assert_eq!(status.branch.as_deref(), Some("feature"));
2660 assert_eq!(
2662 status.main_repo.as_deref(),
2663 main_dir.path().file_name().and_then(|n| n.to_str())
2664 );
2665
2666 let main_status = git_status(main_dir.path());
2668 assert!(!main_status.is_worktree);
2669 assert_eq!(main_status.main_repo, status.main_repo);
2670 }
2671
2672 #[test]
2673 fn window_label_marks_worktree_with_fork_glyph() {
2674 let main_dir = tempfile::tempdir().unwrap();
2675 let repo = init_repo(main_dir.path());
2676 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2677 repo.set_head("refs/heads/main").unwrap();
2678 let wt_parent = tempfile::tempdir().unwrap();
2679 let wt_path = wt_parent.path().join("feature-wt");
2680 add_worktree(&repo, a, &wt_path, "feature");
2681
2682 let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
2683 let entry = WindowEntry {
2684 key: "k".to_string(),
2685 folders: vec![wt_path],
2686 repo: Some("feature-wt".to_string()),
2687 title: None,
2688 pid: None,
2689 last_seen: Utc::now(),
2690 };
2691 assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
2694 }
2695
2696 #[test]
2697 fn main_repo_name_derives_from_common_dir() {
2698 assert_eq!(
2700 main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
2701 Some("omni-dev")
2702 );
2703 assert_eq!(
2705 main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
2706 Some("omni-dev")
2707 );
2708 assert_eq!(
2710 main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
2711 Some("omni-dev")
2712 );
2713 assert_eq!(main_repo_name(Path::new("/.git")), None);
2715 }
2716
2717 fn repos_of(payload: &Value) -> Vec<Value> {
2722 payload
2723 .get("repos")
2724 .and_then(Value::as_array)
2725 .expect("repos array")
2726 .clone()
2727 }
2728
2729 fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
2730 Some(GithubIdentity {
2731 owner: owner.to_string(),
2732 name: name.to_string(),
2733 })
2734 }
2735
2736 #[test]
2737 fn github_identity_parses_supported_forms() {
2738 assert_eq!(
2740 github_identity("https://github.com/rust-works/omni-dev.git"),
2741 github("rust-works", "omni-dev")
2742 );
2743 assert_eq!(
2744 github_identity("https://github.com/rust-works/omni-dev"),
2745 github("rust-works", "omni-dev")
2746 );
2747 assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
2748 assert_eq!(
2750 github_identity("git@github.com:rust-works/omni-dev.git"),
2751 github("rust-works", "omni-dev")
2752 );
2753 assert_eq!(
2754 github_identity("ssh://git@github.com/o/r.git"),
2755 github("o", "r")
2756 );
2757 assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
2758 assert_eq!(
2760 github_identity(" https://github.com/o/r/ "),
2761 github("o", "r")
2762 );
2763 }
2764
2765 #[test]
2766 fn github_identity_rejects_non_github_and_malformed() {
2767 assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
2769 assert_eq!(github_identity("git@example.com:o/r.git"), None);
2770 assert_eq!(github_identity("https://github.com/onlyowner"), None);
2772 assert_eq!(github_identity("https://github.com/o/r/extra"), None);
2773 assert_eq!(github_identity("https://github.com/"), None);
2774 assert_eq!(github_identity("not a url"), None);
2776 }
2777
2778 #[test]
2779 fn remote_github_identity_reads_origin_then_falls_back() {
2780 let dir = tempfile::tempdir().unwrap();
2781 let repo = init_repo(dir.path());
2782 assert_eq!(remote_github_identity(&repo), None);
2784 repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
2786 assert_eq!(remote_github_identity(&repo), None);
2787 repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
2789 .unwrap();
2790 assert_eq!(
2791 remote_github_identity(&repo),
2792 github("rust-works", "omni-dev")
2793 );
2794
2795 repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
2798 .unwrap();
2799 repo.remote("upstream", "https://github.com/other/proj.git")
2800 .unwrap();
2801 assert_eq!(remote_github_identity(&repo), github("other", "proj"));
2802 }
2803
2804 #[tokio::test]
2805 async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
2806 let svc = WorktreesService::new();
2807 assert_eq!(
2809 svc.handle("tree", Value::Null).await.unwrap(),
2810 json!({ "repos": [], "show_closed": true })
2811 );
2812 let plain = tempfile::tempdir().unwrap();
2814 svc.handle(
2815 "register",
2816 json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
2817 )
2818 .await
2819 .unwrap();
2820 assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
2821 }
2822
2823 #[tokio::test]
2824 async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
2825 let main_dir = tempfile::tempdir().unwrap();
2826 let repo = init_repo(main_dir.path());
2827 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2828 repo.set_head("refs/heads/main").unwrap();
2829 repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
2831 .unwrap();
2832
2833 let wt_parent = tempfile::tempdir().unwrap();
2836 let wt_path = wt_parent.path().join("feature-wt");
2837 add_worktree(&repo, a, &wt_path, "feature");
2838
2839 let svc = WorktreesService::new();
2840 svc.handle(
2843 "register",
2844 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
2845 )
2846 .await
2847 .unwrap();
2848 svc.handle(
2849 "register",
2850 json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
2851 )
2852 .await
2853 .unwrap();
2854
2855 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
2856 assert_eq!(
2857 repos.len(),
2858 1,
2859 "two worktrees of one repo dedupe: {repos:?}"
2860 );
2861 let repo0 = &repos[0];
2862 assert_eq!(
2864 repo0.get("main_repo").and_then(Value::as_str),
2865 main_dir.path().file_name().and_then(|n| n.to_str())
2866 );
2867 assert_eq!(
2868 repo0.pointer("/github/owner").and_then(Value::as_str),
2869 Some("rust-works")
2870 );
2871 assert_eq!(
2872 repo0.pointer("/github/name").and_then(Value::as_str),
2873 Some("omni-dev")
2874 );
2875 assert!(repo0.get("root").and_then(Value::as_str).is_some());
2876
2877 let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
2878 assert_eq!(worktrees.len(), 2);
2879 let main_wt = &worktrees[0];
2881 assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
2882 assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
2883 assert_eq!(
2884 main_wt.get("window_key").and_then(Value::as_str),
2885 Some("wm")
2886 );
2887 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
2888 let linked = &worktrees[1];
2890 assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
2891 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
2892 assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
2893 assert_eq!(
2894 linked.get("branch").and_then(Value::as_str),
2895 Some("feature")
2896 );
2897 }
2898
2899 #[tokio::test]
2900 async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
2901 let main_dir = tempfile::tempdir().unwrap();
2902 let repo = init_repo(main_dir.path());
2903 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2904 repo.set_head("refs/heads/main").unwrap();
2905 let wt_parent = tempfile::tempdir().unwrap();
2907 let wt_path = wt_parent.path().join("feature-wt");
2908 add_worktree(&repo, a, &wt_path, "feature");
2909
2910 let svc = WorktreesService::new();
2911 svc.handle(
2913 "register",
2914 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
2915 )
2916 .await
2917 .unwrap();
2918
2919 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
2920 assert_eq!(repos.len(), 1);
2921 assert!(repos[0].get("github").is_none(), "no remote → no github");
2922 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
2923 let linked = worktrees
2924 .iter()
2925 .find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
2926 .expect("the linked worktree");
2927 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
2929 assert!(linked.get("window_key").is_none());
2930 }
2931
2932 fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
2938 let main_dir = tempfile::tempdir().unwrap();
2939 let repo = init_repo(main_dir.path());
2940 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
2941 repo.set_head("refs/heads/trunk").unwrap();
2942 let wt_parent = tempfile::tempdir().unwrap();
2943 let wt_path = wt_parent.path().join("feature-wt");
2944 add_worktree(&repo, a, &wt_path, "feature");
2945 (main_dir, wt_parent, wt_path)
2946 }
2947
2948 #[tokio::test]
2949 async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
2950 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
2951 let svc = WorktreesService::new();
2952 let report = svc
2955 .handle("close", json!({ "path": wt_path, "remove": true }))
2956 .await
2957 .unwrap();
2958 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
2959 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
2960 assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
2961 assert!(report
2962 .get("risks")
2963 .and_then(Value::as_array)
2964 .unwrap()
2965 .is_empty());
2966 assert!(wt_path.exists());
2968 }
2969
2970 #[tokio::test]
2971 async fn close_removes_a_clean_linked_worktree() {
2972 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
2973 let svc = WorktreesService::new();
2974 let reply = svc
2975 .handle(
2976 "close",
2977 json!({ "path": wt_path, "remove": true, "confirmed": true }),
2978 )
2979 .await
2980 .unwrap();
2981 assert_eq!(reply, json!({ "removed": true }));
2982 assert!(
2983 !wt_path.exists(),
2984 "the worktree directory should be deleted"
2985 );
2986 }
2987
2988 #[tokio::test]
2989 async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
2990 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
2991 std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
2993
2994 let svc = WorktreesService::new();
2995 let report = svc
2996 .handle("close", json!({ "path": wt_path, "remove": true }))
2997 .await
2998 .unwrap();
2999 let risks = report.get("risks").and_then(Value::as_array).unwrap();
3000 assert!(
3001 risks
3002 .iter()
3003 .any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
3004 "expected an untracked risk: {report}"
3005 );
3006 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
3008 assert!(wt_path.exists());
3010 }
3011
3012 #[tokio::test]
3013 async fn close_confirmed_removes_a_dirty_worktree() {
3014 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3015 std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
3016 let svc = WorktreesService::new();
3017 let reply = svc
3019 .handle(
3020 "close",
3021 json!({ "path": wt_path, "remove": true, "confirmed": true }),
3022 )
3023 .await
3024 .unwrap();
3025 assert_eq!(reply, json!({ "removed": true }));
3026 assert!(!wt_path.exists());
3027 }
3028
3029 #[tokio::test]
3030 async fn close_refuses_to_remove_the_main_working_tree() {
3031 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
3032 let svc = WorktreesService::new();
3033 let report = svc
3035 .handle("close", json!({ "path": main.path(), "remove": true }))
3036 .await
3037 .unwrap();
3038 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
3039 assert_eq!(
3040 report.get("removable").and_then(Value::as_bool),
3041 Some(false)
3042 );
3043 assert!(svc
3046 .handle(
3047 "close",
3048 json!({ "path": main.path(), "remove": true, "confirmed": true }),
3049 )
3050 .await
3051 .is_err());
3052 assert!(main.path().exists());
3053 }
3054
3055 #[tokio::test]
3056 async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
3057 let main_dir = tempfile::tempdir().unwrap();
3061 let repo = init_repo(main_dir.path());
3062 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
3063 repo.set_head("refs/heads/trunk").unwrap();
3064 let wt_parent = tempfile::tempdir().unwrap();
3065 let wt_path = wt_parent.path().join("main-wt");
3066 add_worktree(&repo, a, &wt_path, "main");
3067
3068 let svc = WorktreesService::new();
3069 let reply = svc
3070 .handle(
3071 "close",
3072 json!({ "path": wt_path, "remove": true, "confirmed": true }),
3073 )
3074 .await
3075 .unwrap();
3076 assert_eq!(reply, json!({ "removed": true }));
3077 assert!(!wt_path.exists());
3078 assert!(
3080 repo.find_branch("main", git2::BranchType::Local).is_ok(),
3081 "the default branch must survive worktree removal"
3082 );
3083 }
3084
3085 #[tokio::test]
3086 async fn close_is_idempotent_when_the_worktree_is_already_gone() {
3087 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3088 let svc = WorktreesService::new();
3089 svc.handle(
3091 "close",
3092 json!({ "path": wt_path, "remove": true, "confirmed": true }),
3093 )
3094 .await
3095 .unwrap();
3096 let reply = svc
3099 .handle(
3100 "close",
3101 json!({ "path": wt_path, "remove": true, "confirmed": true }),
3102 )
3103 .await
3104 .unwrap();
3105 assert_eq!(reply, json!({ "removed": true }));
3106 }
3107
3108 #[tokio::test]
3109 async fn close_safety_check_detects_detached_head_unreachable_commits() {
3110 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3111 let wt_repo = Repository::open(&wt_path).unwrap();
3114 let parent_oid = wt_repo.head().unwrap().target().unwrap();
3115 let parent = wt_repo.find_commit(parent_oid).unwrap();
3116 let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
3117 wt_repo.set_head_detached(orphan).unwrap();
3118
3119 let svc = WorktreesService::new();
3120 let report = svc
3121 .handle("close", json!({ "path": wt_path, "remove": true }))
3122 .await
3123 .unwrap();
3124 let risks = report.get("risks").and_then(Value::as_array).unwrap();
3125 assert!(
3126 risks
3127 .iter()
3128 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
3129 "expected an unreachable-commits risk: {report}"
3130 );
3131 }
3132
3133 #[tokio::test]
3134 async fn close_self_close_removes_when_the_requester_owns_the_target() {
3135 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3136 let svc = WorktreesService::new();
3137 svc.handle(
3141 "register",
3142 json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
3143 )
3144 .await
3145 .unwrap();
3146 let reply = svc
3147 .handle(
3148 "close",
3149 json!({
3150 "path": wt_path,
3151 "remove": true,
3152 "confirmed": true,
3153 "requester_key": "w1",
3154 }),
3155 )
3156 .await
3157 .unwrap();
3158 assert_eq!(reply, json!({ "removed": true }));
3159 assert!(!wt_path.exists());
3160 }
3161
3162 #[tokio::test]
3163 async fn close_safety_check_surfaces_the_owning_window() {
3164 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3165 let svc = WorktreesService::new();
3166 svc.handle(
3169 "register",
3170 json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
3171 )
3172 .await
3173 .unwrap();
3174 let report = svc
3175 .handle("close", json!({ "path": wt_path, "remove": true }))
3176 .await
3177 .unwrap();
3178 assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
3179 assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
3180 assert_eq!(
3181 report.get("window_folder_count").and_then(Value::as_u64),
3182 Some(2)
3183 );
3184 }
3185
3186 #[tokio::test]
3187 async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
3188 let svc = WorktreesService::new();
3189 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
3190 .await
3191 .unwrap();
3192 assert_eq!(
3194 svc.handle("heartbeat", json!({ "key": "w1" }))
3195 .await
3196 .unwrap(),
3197 json!({ "known": true })
3198 );
3199 svc.registry.mark_close_pending("w1");
3201 assert_eq!(
3202 svc.handle("heartbeat", json!({ "key": "w1" }))
3203 .await
3204 .unwrap(),
3205 json!({ "known": true, "close": true })
3206 );
3207 assert_eq!(
3208 svc.handle("heartbeat", json!({ "key": "w1" }))
3209 .await
3210 .unwrap(),
3211 json!({ "known": true })
3212 );
3213 }
3214
3215 #[tokio::test]
3216 async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
3217 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3218 let svc = Arc::new(WorktreesService::new());
3219 svc.handle(
3221 "register",
3222 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
3223 )
3224 .await
3225 .unwrap();
3226
3227 let svc2 = svc.clone();
3230 let path = wt_path.clone();
3231 let close = tokio::spawn(async move {
3232 svc2.handle(
3233 "close",
3234 json!({
3235 "path": path,
3236 "remove": true,
3237 "confirmed": true,
3238 "requester_key": "w1",
3239 }),
3240 )
3241 .await
3242 });
3243
3244 let mut saw_close = false;
3247 for _ in 0..200 {
3248 let hb = svc
3249 .handle("heartbeat", json!({ "key": "w2" }))
3250 .await
3251 .unwrap();
3252 if hb.get("close").and_then(Value::as_bool) == Some(true) {
3253 saw_close = true;
3254 svc.handle("unregister", json!({ "key": "w2" }))
3255 .await
3256 .unwrap();
3257 break;
3258 }
3259 tokio::time::sleep(Duration::from_millis(5)).await;
3260 }
3261 assert!(saw_close, "w2 should have been told to close");
3262
3263 let reply = close.await.unwrap().unwrap();
3265 assert_eq!(reply, json!({ "removed": true }));
3266 assert!(!wt_path.exists());
3267 }
3268
3269 #[tokio::test]
3270 async fn await_windows_closed_times_out_when_a_window_never_closes() {
3271 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3272 let svc = WorktreesService::new();
3273 svc.handle(
3274 "register",
3275 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
3276 )
3277 .await
3278 .unwrap();
3279 let err = await_windows_closed(
3282 &svc.registry,
3283 &wt_path,
3284 Some("w1"),
3285 Duration::from_millis(150),
3286 Duration::from_millis(25),
3287 )
3288 .await
3289 .unwrap_err();
3290 assert!(
3291 err.to_string().contains("w2"),
3292 "error names the window: {err}"
3293 );
3294 await_windows_closed(
3296 &svc.registry,
3297 &wt_path,
3298 Some("w2"),
3299 Duration::from_millis(150),
3300 Duration::from_millis(25),
3301 )
3302 .await
3303 .unwrap();
3304 }
3305
3306 #[tokio::test]
3307 async fn close_window_without_remove_replies_closed_and_never_deletes() {
3308 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
3309 let svc = WorktreesService::new();
3310 let reply = svc
3312 .handle("close", json!({ "path": main.path(), "remove": false }))
3313 .await
3314 .unwrap();
3315 assert_eq!(reply, json!({ "closed": true }));
3316 assert!(main.path().exists());
3317 }
3318
3319 #[tokio::test]
3320 async fn close_safety_check_flags_modified_tracked_files() {
3321 let main_dir = tempfile::tempdir().unwrap();
3325 let repo = init_repo(main_dir.path());
3326 let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
3327 repo.set_head("refs/heads/trunk").unwrap();
3328 let wt_parent = tempfile::tempdir().unwrap();
3329 let wt_path = wt_parent.path().join("feature-wt");
3330 add_worktree(&repo, a, &wt_path, "feature");
3331 std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
3332
3333 let svc = WorktreesService::new();
3334 let report = svc
3335 .handle("close", json!({ "path": wt_path, "remove": true }))
3336 .await
3337 .unwrap();
3338 let risks = report.get("risks").and_then(Value::as_array).unwrap();
3339 assert!(
3340 risks
3341 .iter()
3342 .any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
3343 "expected a dirty risk: {report}"
3344 );
3345 }
3346
3347 #[tokio::test]
3348 async fn close_safety_check_flags_an_in_progress_operation() {
3349 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3352 let wt_repo = Repository::open(&wt_path).unwrap();
3353 let head = wt_repo.head().unwrap().target().unwrap();
3354 std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
3355 assert_ne!(wt_repo.state(), RepositoryState::Clean);
3356
3357 let svc = WorktreesService::new();
3358 let report = svc
3359 .handle("close", json!({ "path": wt_path, "remove": true }))
3360 .await
3361 .unwrap();
3362 let risks = report.get("risks").and_then(Value::as_array).unwrap();
3363 assert!(
3364 risks
3365 .iter()
3366 .any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
3367 "expected an in-progress risk: {report}"
3368 );
3369 }
3370
3371 #[tokio::test]
3372 async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
3373 let main_dir = tempfile::tempdir().unwrap();
3377 let repo = init_repo(main_dir.path());
3378 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
3379 repo.set_head("refs/heads/trunk").unwrap();
3380 let a_commit = repo.find_commit(a).unwrap();
3381 repo.branch("feature", &a_commit, false).unwrap();
3382 repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
3383 .unwrap();
3384 empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
3386 drop(a_commit);
3387 let mut cfg = repo.config().unwrap();
3388 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
3389 .unwrap();
3390 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
3391 .unwrap();
3392 cfg.set_str("branch.feature.remote", "origin").unwrap();
3393 cfg.set_str("branch.feature.merge", "refs/heads/feature")
3394 .unwrap();
3395 let wt_parent = tempfile::tempdir().unwrap();
3398 let wt_path = wt_parent.path().join("feature-wt");
3399 let reference = repo.find_reference("refs/heads/feature").unwrap();
3400 let mut opts = git2::WorktreeAddOptions::new();
3401 opts.reference(Some(&reference));
3402 repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
3403
3404 let svc = WorktreesService::new();
3405 let report = svc
3406 .handle("close", json!({ "path": wt_path, "remove": true }))
3407 .await
3408 .unwrap();
3409 let info = report.get("info").and_then(Value::as_array).unwrap();
3412 assert!(
3413 info.iter()
3414 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
3415 "expected an unpushed info note: {report}"
3416 );
3417 assert!(
3418 report
3419 .get("risks")
3420 .and_then(Value::as_array)
3421 .unwrap()
3422 .is_empty(),
3423 "unpushed commits alone must not block: {report}"
3424 );
3425 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
3426 }
3427
3428 #[tokio::test]
3429 async fn close_safety_check_ignores_gitignored_files() {
3430 let main_dir = tempfile::tempdir().unwrap();
3434 let repo = init_repo(main_dir.path());
3435 let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
3436 repo.set_head("refs/heads/trunk").unwrap();
3437 let wt_parent = tempfile::tempdir().unwrap();
3438 let wt_path = wt_parent.path().join("feature-wt");
3439 add_worktree(&repo, a, &wt_path, "feature");
3440 std::fs::create_dir(wt_path.join("build")).unwrap();
3441 std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
3442
3443 let svc = WorktreesService::new();
3444 let report = svc
3445 .handle("close", json!({ "path": wt_path, "remove": true }))
3446 .await
3447 .unwrap();
3448 assert!(
3449 report
3450 .get("risks")
3451 .and_then(Value::as_array)
3452 .unwrap()
3453 .is_empty(),
3454 "a gitignored file must not create a risk: {report}"
3455 );
3456 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
3457 }
3458
3459 #[tokio::test]
3460 async fn close_safety_check_treats_a_missing_path_as_already_removed() {
3461 let svc = WorktreesService::new();
3464 let report = svc
3465 .handle(
3466 "close",
3467 json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
3468 )
3469 .await
3470 .unwrap();
3471 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
3472 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
3473 assert!(report
3474 .get("risks")
3475 .and_then(Value::as_array)
3476 .unwrap()
3477 .is_empty());
3478 let info = report.get("info").and_then(Value::as_array).unwrap();
3479 assert!(info
3480 .iter()
3481 .any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
3482 }
3483
3484 #[tokio::test]
3485 async fn close_refuses_a_locked_worktree() {
3486 let (main, _wtp, wt_path) = repo_with_linked_worktree();
3489 let main_repo = Repository::open(main.path()).unwrap();
3490 main_repo
3491 .find_worktree("feature")
3492 .unwrap()
3493 .lock(Some("under test"))
3494 .unwrap();
3495
3496 let svc = WorktreesService::new();
3497 let err = svc
3498 .handle(
3499 "close",
3500 json!({ "path": wt_path, "remove": true, "confirmed": true }),
3501 )
3502 .await
3503 .unwrap_err();
3504 assert!(
3505 err.to_string().contains("locked"),
3506 "expected a locked error: {err}"
3507 );
3508 assert!(wt_path.exists(), "a locked worktree must not be removed");
3509 }
3510
3511 #[tokio::test]
3512 async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
3513 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3517 let wt_repo = Repository::open(&wt_path).unwrap();
3518 let tip = wt_repo.head().unwrap().target().unwrap();
3521 wt_repo.set_head_detached(tip).unwrap();
3522 assert!(wt_repo.head_detached().unwrap());
3523
3524 let svc = WorktreesService::new();
3525 let report = svc
3526 .handle("close", json!({ "path": wt_path, "remove": true }))
3527 .await
3528 .unwrap();
3529 let risks = report.get("risks").and_then(Value::as_array).unwrap();
3530 assert!(
3531 !risks
3532 .iter()
3533 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
3534 "a detached HEAD reachable from a branch must not be flagged: {report}"
3535 );
3536 }
3537
3538 #[test]
3539 fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
3540 let (main, _wtp, wt_path) = repo_with_linked_worktree();
3541 let main_repo = Repository::open(main.path()).unwrap();
3542 assert_eq!(
3544 worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
3545 "feature"
3546 );
3547 let err =
3550 worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
3551 assert!(
3552 err.to_string().contains("not registered"),
3553 "expected a not-registered error: {err}"
3554 );
3555 }
3556
3557 #[test]
3558 fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
3559 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3562 let repo = Repository::open(&wt_path).unwrap();
3563 std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
3564 assert!(
3567 repo.statuses(Some(&mut StatusOptions::new())).is_err(),
3568 "a corrupt index should make statuses() fail"
3569 );
3570 assert_eq!(count_dirty_untracked(&repo), (0, 0));
3571 }
3572}