1use std::collections::{BTreeMap, BTreeSet, HashMap};
34use std::path::{Path, PathBuf};
35use std::process::{Command, Stdio};
36use std::sync::{Arc, Mutex, PoisonError};
37use std::time::Duration;
38
39use anyhow::{anyhow, bail, Context, Result};
40use async_trait::async_trait;
41use git2::{Repository, RepositoryState, Status, StatusOptions, WorktreeLockStatus};
42use serde::{Deserialize, Serialize};
43use serde_json::{json, Value};
44use tokio::sync::watch;
45use tokio::task::JoinHandle;
46use tokio_util::sync::CancellationToken;
47
48use crate::daemon::service::{
49 DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
50};
51use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
52
53pub const SERVICE_NAME: &str = "worktrees";
55
56const SUBMENU_TITLE: &str = "Worktrees";
58
59const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
62
63const MENU_REFRESH_INTERVAL: Duration = Duration::from_secs(2);
68
69struct RefreshTask {
71 token: CancellationToken,
73 handle: JoinHandle<()>,
75}
76
77pub struct WorktreesService {
79 registry: Arc<WorktreesRegistry>,
82 menu_cache: Arc<Mutex<Option<Vec<MenuItem>>>>,
88 refresh: Mutex<Option<RefreshTask>>,
90}
91
92impl WorktreesService {
93 #[must_use]
98 pub fn new() -> Self {
99 Self {
100 registry: Arc::new(WorktreesRegistry::new()),
101 menu_cache: Arc::new(Mutex::new(None)),
102 refresh: Mutex::new(None),
103 }
104 }
105
106 pub fn start_menu_refresh(&self) {
114 if tokio::runtime::Handle::try_current().is_err() {
115 tracing::debug!("no tokio runtime; worktrees menu refresh not started");
116 return;
117 }
118 let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
119 if guard.is_some() {
120 return;
121 }
122 let token = CancellationToken::new();
123 let loop_token = token.clone();
124 let registry = self.registry.clone();
125 let cache = self.menu_cache.clone();
126 let handle = tokio::spawn(async move {
127 loop {
128 let entries = registry.list();
132 if let Ok(items) =
133 tokio::task::spawn_blocking(move || menu_items_for(&entries)).await
134 {
135 *cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
136 }
137 tokio::select! {
138 () = loop_token.cancelled() => break,
139 () = tokio::time::sleep(MENU_REFRESH_INTERVAL) => {}
140 }
141 }
142 });
143 *guard = Some(RefreshTask { token, handle });
144 }
145
146 async fn close(&self, req: CloseRequest) -> Result<Value> {
161 let entries = self.registry.list();
165 let scan_path = req.path.clone();
166 let open_windows =
167 tokio::task::spawn_blocking(move || windows_with_path(&entries, &scan_path))
168 .await
169 .unwrap_or_default();
170 let open = !open_windows.is_empty();
171 let window_key = open_windows.first().map(|(k, _)| k.clone());
172 let window_folder_count = open_windows.first().map_or(0, |(_, c)| *c);
173
174 if req.remove && !req.confirmed {
178 let path = req.path.clone();
179 let git = tokio::task::spawn_blocking(move || git_safety(&path))
180 .await
181 .map_err(|e| anyhow!("safety check task panicked: {e}"))??;
182 return Ok(serde_json::to_value(SafetyReport {
183 removable: git.removable,
184 is_main: git.is_main,
185 open,
186 window_key,
187 window_folder_count,
188 risks: git.risks,
189 info: git.info,
190 })
191 .unwrap_or_else(|_| json!({})));
192 }
193
194 let others: Vec<String> = open_windows
201 .iter()
202 .map(|(k, _)| k.clone())
203 .filter(|k| req.requester_key.as_deref() != Some(k))
204 .collect();
205 for key in &others {
206 self.registry.mark_close_pending(key);
207 }
208 if !others.is_empty() {
209 await_windows_closed(
210 &self.registry,
211 &req.path,
212 req.requester_key.as_deref(),
213 CLOSE_WAIT_TIMEOUT,
214 CLOSE_WAIT_POLL,
215 )
216 .await?;
217 }
218
219 if req.remove {
220 let path = req.path.clone();
221 tokio::task::spawn_blocking(move || remove_worktree(&path))
222 .await
223 .map_err(|e| anyhow!("worktree removal task panicked: {e}"))??;
224 Ok(json!({ "removed": true }))
225 } else {
226 Ok(json!({ "closed": true }))
229 }
230 }
231}
232
233impl Default for WorktreesService {
234 fn default() -> Self {
235 Self::new()
236 }
237}
238
239#[async_trait]
240impl DaemonService for WorktreesService {
241 fn name(&self) -> &'static str {
242 SERVICE_NAME
243 }
244
245 async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
246 match op {
247 "register" => {
248 let req: RegisterRequest =
249 serde_json::from_value(payload).context("invalid `register` payload")?;
250 if req.key.trim().is_empty() {
251 bail!("`register` requires a non-empty `key`");
252 }
253 self.registry.register(req);
254 Ok(json!({ "ok": true }))
255 }
256 "heartbeat" => {
257 let key = require_str(&payload, "key", "heartbeat")?;
258 let known = self.registry.heartbeat(key);
259 let mut reply = json!({ "known": known });
264 if self.registry.take_close_pending(key) {
265 reply["close"] = Value::Bool(true);
266 }
267 Ok(reply)
268 }
269 "unregister" => {
270 let key = require_str(&payload, "key", "unregister")?;
271 Ok(json!({ "removed": self.registry.unregister(key) }))
272 }
273 "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
274 "tree" => {
275 let folders = self.registry.open_folders();
281 let windows = self.registry.list();
282 Ok(json!({ "repos": tree_repos(folders, windows).await }))
283 }
284 "open" => {
285 let path = require_str(&payload, "path", "open")?;
294 focus_window(Path::new(path))?;
295 Ok(json!({ "ok": true }))
296 }
297 "close" => {
298 let req: CloseRequest =
304 serde_json::from_value(payload).context("invalid `close` payload")?;
305 self.close(req).await
306 }
307 other => bail!("unknown worktrees op: {other}"),
308 }
309 }
310
311 fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
312 if op != "subscribe" {
315 return None;
316 }
317 Some(Box::new(WorktreesStream {
318 registry: self.registry.clone(),
319 changes: self.registry.subscribe_changes(),
322 }))
323 }
324
325 fn menu(&self) -> MenuSnapshot {
326 let cached = self
331 .menu_cache
332 .lock()
333 .unwrap_or_else(PoisonError::into_inner)
334 .clone();
335 let items = cached.unwrap_or_else(|| menu_items_for(&self.registry.list()));
336 MenuSnapshot {
337 title: SUBMENU_TITLE.to_string(),
338 items,
339 }
340 }
341
342 async fn menu_action(&self, action_id: &str) -> Result<()> {
343 if let Some(key) = action_id.strip_prefix("focus:") {
344 let folder = self
347 .registry
348 .first_folder(key)
349 .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
350 focus_window(&folder)?;
351 return Ok(());
352 }
353 bail!("unknown worktrees menu action: {action_id}")
354 }
355
356 async fn status(&self) -> ServiceStatus {
357 let entries = self.registry.list();
358 let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
359 let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
360 let windows = enriched_windows(entries).await;
361 ServiceStatus {
362 name: SERVICE_NAME.to_string(),
363 healthy: true,
364 summary,
365 detail: json!({ "windows": windows }),
366 }
367 }
368
369 async fn shutdown(&self) {
370 let task = self
374 .refresh
375 .lock()
376 .unwrap_or_else(PoisonError::into_inner)
377 .take();
378 if let Some(task) = task {
379 task.token.cancel();
380 let _ = task.handle.await;
381 }
382 }
383}
384
385fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
389 payload
390 .get(field)
391 .and_then(Value::as_str)
392 .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
393}
394
395#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
406struct GitStatus {
407 #[serde(skip_serializing_if = "Option::is_none")]
409 branch: Option<String>,
410 #[serde(skip_serializing_if = "Option::is_none")]
412 ahead: Option<usize>,
413 #[serde(skip_serializing_if = "Option::is_none")]
415 behind: Option<usize>,
416 #[serde(skip_serializing_if = "Option::is_none")]
421 main_repo: Option<String>,
422 #[serde(skip_serializing_if = "is_false")]
425 is_worktree: bool,
426}
427
428#[allow(clippy::trivially_copy_pass_by_ref)]
432fn is_false(b: &bool) -> bool {
433 !*b
434}
435
436fn git_status(folder: &Path) -> GitStatus {
441 let Ok(repo) = Repository::discover(folder) else {
442 return GitStatus::default();
443 };
444 let base = GitStatus {
447 main_repo: main_repo_name(repo.commondir()),
448 is_worktree: repo.is_worktree(),
449 ..GitStatus::default()
450 };
451 let Ok(head) = repo.head() else {
452 return base;
454 };
455 let Some(name) = head
459 .shorthand()
460 .ok()
461 .filter(|_| head.is_branch())
462 .map(str::to_string)
463 else {
464 return base;
465 };
466 let branch = git2::Branch::wrap(head);
467 let (ahead, behind) = match upstream_ahead_behind(&repo, &branch) {
468 Some((ahead, behind)) => (Some(ahead), Some(behind)),
469 None => (None, None),
470 };
471 GitStatus {
472 branch: Some(name),
473 ahead,
474 behind,
475 ..base
476 }
477}
478
479fn main_repo_name(commondir: &Path) -> Option<String> {
485 let file_name = commondir.file_name()?.to_string_lossy().into_owned();
486 if file_name == ".git" {
487 commondir
489 .parent()
490 .and_then(Path::file_name)
491 .map(|n| n.to_string_lossy().into_owned())
492 } else {
493 Some(
495 file_name
496 .strip_suffix(".git")
497 .unwrap_or(&file_name)
498 .to_string(),
499 )
500 }
501}
502
503fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
506 let upstream = branch.upstream().ok()?;
507 let local_oid = branch.get().target()?;
508 let upstream_oid = upstream.get().target()?;
509 repo.graph_ahead_behind(local_oid, upstream_oid).ok()
510}
511
512#[derive(Serialize)]
518struct EnrichedEntry<'a> {
519 #[serde(flatten)]
520 entry: &'a WindowEntry,
521 #[serde(flatten)]
522 git: GitStatus,
523}
524
525fn enriched_entry(entry: &WindowEntry) -> Value {
530 let git = entry
531 .folders
532 .first()
533 .map(|folder| git_status(folder))
534 .unwrap_or_default();
535 serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
536}
537
538async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
542 tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
543 .await
544 .unwrap_or_default()
545}
546
547#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
553struct GithubIdentity {
554 owner: String,
556 name: String,
558}
559
560#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
565struct TreeWorktree {
566 path: String,
568 #[serde(skip_serializing_if = "Option::is_none")]
570 branch: Option<String>,
571 #[serde(skip_serializing_if = "Option::is_none")]
573 ahead: Option<usize>,
574 #[serde(skip_serializing_if = "Option::is_none")]
576 behind: Option<usize>,
577 is_main: bool,
579 open: bool,
581 #[serde(skip_serializing_if = "Option::is_none")]
584 window_key: Option<String>,
585}
586
587#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
591struct TreeRepo {
592 main_repo: String,
594 #[serde(skip_serializing_if = "Option::is_none")]
596 github: Option<GithubIdentity>,
597 root: String,
599 worktrees: Vec<TreeWorktree>,
602}
603
604fn github_identity(url: &str) -> Option<GithubIdentity> {
611 let url = url.trim();
612 let rest = [
614 "https://github.com/",
615 "http://github.com/",
616 "ssh://git@github.com/",
617 "git://github.com/",
618 "git@github.com:",
619 ]
620 .iter()
621 .find_map(|prefix| url.strip_prefix(prefix))?;
622 let rest = rest.strip_suffix(".git").unwrap_or(rest);
623 let rest = rest.trim_end_matches('/');
624 let mut parts = rest.splitn(2, '/');
625 let owner = parts.next()?.trim();
626 let name = parts.next()?.trim();
627 if owner.is_empty() || name.is_empty() || name.contains('/') {
629 return None;
630 }
631 Some(GithubIdentity {
632 owner: owner.to_string(),
633 name: name.to_string(),
634 })
635}
636
637fn remote_github_identity(repo: &Repository) -> Option<GithubIdentity> {
640 if let Ok(origin) = repo.find_remote("origin") {
641 if let Some(id) = origin.url().ok().and_then(github_identity) {
642 return Some(id);
643 }
644 }
645 let names = repo.remotes().ok();
649 names
650 .iter()
651 .flat_map(|arr| arr.iter())
652 .flatten()
653 .flatten()
654 .filter_map(|name| repo.find_remote(name).ok())
655 .find_map(|remote| remote.url().ok().and_then(github_identity))
656}
657
658fn canonical(path: &Path) -> PathBuf {
662 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
663}
664
665fn open_window_index(entries: &[WindowEntry]) -> HashMap<PathBuf, String> {
670 let mut index = HashMap::new();
671 for entry in entries {
672 for folder in &entry.folders {
673 index
674 .entry(canonical(folder))
675 .or_insert_with(|| entry.key.clone());
676 }
677 }
678 index
679}
680
681fn worktree_entry(
685 path: &Path,
686 is_main: bool,
687 open_index: &HashMap<PathBuf, String>,
688) -> TreeWorktree {
689 let status = git_status(path);
690 let window_key = open_index.get(&canonical(path)).cloned();
691 TreeWorktree {
692 path: path.display().to_string(),
693 branch: status.branch,
694 ahead: status.ahead,
695 behind: status.behind,
696 is_main,
697 open: window_key.is_some(),
698 window_key,
699 }
700}
701
702fn repo_tree(discovered: &Repository, open_index: &HashMap<PathBuf, String>) -> Option<TreeRepo> {
708 let commondir = canonical(discovered.commondir());
711 let main_root = commondir.parent()?.to_path_buf();
712 let main_repo = Repository::open(&main_root).ok()?;
713
714 let mut worktrees = vec![worktree_entry(&main_root, true, open_index)];
716 let names = main_repo.worktrees().ok();
721 let mut linked: Vec<PathBuf> = names
722 .iter()
723 .flat_map(|arr| arr.iter())
724 .flatten() .flatten() .filter_map(|name| main_repo.find_worktree(name).ok())
727 .map(|wt| wt.path().to_path_buf())
728 .collect();
729 linked.sort();
730 worktrees.extend(
731 linked
732 .iter()
733 .map(|path| worktree_entry(path, false, open_index)),
734 );
735
736 Some(TreeRepo {
737 main_repo: main_repo_name(&commondir)?,
738 github: remote_github_identity(&main_repo),
739 root: main_root.display().to_string(),
740 worktrees,
741 })
742}
743
744fn build_tree(folders: Vec<PathBuf>, windows: Vec<WindowEntry>) -> Vec<TreeRepo> {
750 let open_index = open_window_index(&windows);
751 let mut repos: BTreeMap<PathBuf, TreeRepo> = BTreeMap::new();
752 for folder in &folders {
753 let Ok(repo) = Repository::discover(folder) else {
754 continue;
755 };
756 let key = canonical(repo.commondir());
757 if repos.contains_key(&key) {
758 continue;
759 }
760 if let Some(tree) = repo_tree(&repo, &open_index) {
761 repos.insert(key, tree);
762 }
763 }
764 repos.into_values().collect()
765}
766
767async fn tree_repos(folders: Vec<PathBuf>, windows: Vec<WindowEntry>) -> Vec<Value> {
772 tokio::task::spawn_blocking(move || {
773 build_tree(folders, windows)
774 .iter()
775 .map(|repo| serde_json::to_value(repo).unwrap_or_else(|_| json!({})))
776 .collect()
777 })
778 .await
779 .unwrap_or_default()
780}
781
782struct WorktreesStream {
791 registry: Arc<WorktreesRegistry>,
793 changes: watch::Receiver<u64>,
797}
798
799#[async_trait]
800impl ServiceStream for WorktreesStream {
801 async fn changed(&mut self) {
802 if self.changes.changed().await.is_err() {
808 std::future::pending::<()>().await;
809 }
810 }
811
812 async fn snapshot(&self) -> Value {
813 let folders = self.registry.open_folders();
817 let windows = self.registry.list();
818 json!({ "repos": tree_repos(folders, windows).await })
819 }
820}
821
822fn display_name(entry: &WindowEntry) -> String {
825 if let Some(repo) = &entry.repo {
826 return repo.clone();
827 }
828 if let Some(folder) = entry.folders.first() {
829 return folder.file_name().map_or_else(
830 || folder.display().to_string(),
831 |n| n.to_string_lossy().into_owned(),
832 );
833 }
834 "(no folder)".to_string()
835}
836
837const REPO_SEP: char = '·';
839const WORKTREE_SEP: char = '⑂';
842
843fn menu_items_for(entries: &[WindowEntry]) -> Vec<MenuItem> {
848 if entries.is_empty() {
849 vec![MenuItem::Label("No open windows".to_string())]
850 } else {
851 window_menu_items(entries)
852 }
853}
854
855fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
862 entries
863 .iter()
864 .map(|entry| {
865 let label = window_label(entry);
866 if entry.folders.is_empty() {
867 MenuItem::Label(label)
868 } else {
869 MenuItem::Action(MenuAction {
870 id: format!("focus:{}", entry.key),
871 label,
872 enabled: true,
873 })
874 }
875 })
876 .collect()
877}
878
879fn window_label(entry: &WindowEntry) -> String {
885 let status = entry
886 .folders
887 .first()
888 .map(|folder| git_status(folder))
889 .unwrap_or_default();
890 let name = status
893 .main_repo
894 .clone()
895 .unwrap_or_else(|| display_name(entry));
896 if let Some(branch) = &status.branch {
897 let sep = if status.is_worktree {
898 WORKTREE_SEP
899 } else {
900 REPO_SEP
901 };
902 return match sync_indicator(status.ahead, status.behind) {
903 Some(sync) => format!("{name} {sep} {branch} {sync}"),
904 None => format!("{name} {sep} {branch}"),
905 };
906 }
907 match &entry.title {
909 Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
910 _ => name,
911 }
912}
913
914fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
917 match (ahead, behind) {
918 (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
919 _ => None,
920 }
921}
922
923const CODE_BINARY_CANDIDATES: &[&str] = &[
926 "/usr/local/bin/code",
927 "/opt/homebrew/bin/code",
928 "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
929 "/usr/bin/code",
930];
931
932fn focus_window(folder: &Path) -> Result<()> {
935 focus_window_with(&resolve_code_binary(), folder)
936}
937
938fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
945 if !folder.is_absolute() {
950 bail!(
951 "refusing to focus a non-absolute folder path: {}",
952 folder.display()
953 );
954 }
955 if !folder.is_dir() {
956 bail!("worktree folder no longer exists: {}", folder.display());
957 }
958 let child = Command::new(program)
961 .arg(folder)
962 .stdin(Stdio::null())
963 .stdout(Stdio::null())
964 .stderr(Stdio::null())
965 .spawn()
966 .with_context(|| {
967 format!(
968 "failed to launch `{}` to focus {}",
969 program.display(),
970 folder.display()
971 )
972 })?;
973 std::thread::spawn(move || {
975 let mut child = child;
976 let _ = child.wait();
977 });
978 Ok(())
979}
980
981fn resolve_code_binary() -> PathBuf {
986 resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
987}
988
989fn resolve_code_binary_from(
992 env_override: Option<std::ffi::OsString>,
993 candidates: &[&str],
994) -> PathBuf {
995 if let Some(path) = env_override {
996 return PathBuf::from(path);
997 }
998 for candidate in candidates {
999 let path = Path::new(candidate);
1000 if path.exists() {
1001 return path.to_path_buf();
1002 }
1003 }
1004 PathBuf::from("code")
1005}
1006
1007#[derive(Debug, Clone, Deserialize)]
1013struct CloseRequest {
1014 path: PathBuf,
1016 #[serde(default)]
1020 requester_key: Option<String>,
1021 #[serde(default)]
1025 remove: bool,
1026 #[serde(default)]
1029 confirmed: bool,
1030}
1031
1032#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1037struct Note {
1038 kind: String,
1040 detail: String,
1042}
1043
1044impl Note {
1045 fn new(kind: &str, detail: impl Into<String>) -> Self {
1046 Self {
1047 kind: kind.to_string(),
1048 detail: detail.into(),
1049 }
1050 }
1051}
1052
1053#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1057struct SafetyReport {
1058 removable: bool,
1061 is_main: bool,
1063 open: bool,
1065 #[serde(skip_serializing_if = "Option::is_none")]
1067 window_key: Option<String>,
1068 window_folder_count: usize,
1071 risks: Vec<Note>,
1074 info: Vec<Note>,
1077}
1078
1079#[derive(Debug, Clone, PartialEq, Eq)]
1082struct GitSafety {
1083 is_main: bool,
1084 removable: bool,
1085 risks: Vec<Note>,
1086 info: Vec<Note>,
1087}
1088
1089fn windows_with_path(entries: &[WindowEntry], path: &Path) -> Vec<(String, usize)> {
1093 let target = canonical(path);
1094 entries
1095 .iter()
1096 .filter(|e| e.folders.iter().any(|f| canonical(f) == target))
1097 .map(|e| (e.key.clone(), e.folders.len()))
1098 .collect()
1099}
1100
1101const CLOSE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
1108
1109const CLOSE_WAIT_POLL: Duration = Duration::from_millis(250);
1112
1113async fn await_windows_closed(
1120 registry: &WorktreesRegistry,
1121 path: &Path,
1122 requester: Option<&str>,
1123 timeout: Duration,
1124 poll: Duration,
1125) -> Result<()> {
1126 let deadline = std::time::Instant::now() + timeout;
1127 loop {
1128 let entries = registry.list();
1132 let path = path.to_path_buf();
1133 let requester = requester.map(str::to_string);
1134 let remaining: Vec<String> = tokio::task::spawn_blocking(move || {
1135 windows_with_path(&entries, &path)
1136 .into_iter()
1137 .map(|(k, _)| k)
1138 .filter(|k| requester.as_deref() != Some(k))
1139 .collect()
1140 })
1141 .await
1142 .unwrap_or_default();
1143
1144 if remaining.is_empty() {
1145 return Ok(());
1146 }
1147 if std::time::Instant::now() >= deadline {
1148 bail!("window(s) did not close in time: {}", remaining.join(", "));
1149 }
1150 tokio::time::sleep(poll).await;
1151 }
1152}
1153
1154fn git_safety(path: &Path) -> Result<GitSafety> {
1161 if !path.exists() {
1162 return Ok(GitSafety {
1163 is_main: false,
1164 removable: true,
1165 risks: vec![],
1166 info: vec![Note::new("already-removed", "worktree no longer exists")],
1167 });
1168 }
1169 let repo = Repository::open(path)
1170 .with_context(|| format!("not a git worktree: {}", path.display()))?;
1171 if !repo.is_worktree() {
1173 return Ok(GitSafety {
1174 is_main: true,
1175 removable: false,
1176 risks: vec![],
1177 info: vec![Note::new(
1178 "main-working-tree",
1179 "the repository's main working tree is never deleted",
1180 )],
1181 });
1182 }
1183
1184 let mut risks = Vec::new();
1185 let mut info = Vec::new();
1186
1187 let (dirty, untracked) = count_dirty_untracked(&repo);
1188 if dirty > 0 {
1189 risks.push(Note::new(
1190 "dirty",
1191 format!("{dirty} modified tracked file(s) would be lost"),
1192 ));
1193 }
1194 if untracked > 0 {
1195 risks.push(Note::new(
1196 "untracked",
1197 format!("{untracked} untracked file(s) would be lost"),
1198 ));
1199 }
1200
1201 let state = repo.state();
1203 if state != RepositoryState::Clean {
1204 risks.push(Note::new(
1205 "in-progress",
1206 format!("an in-progress {state:?} operation would be lost"),
1207 ));
1208 }
1209
1210 if repo.head_detached().unwrap_or(false) {
1214 let lost = unreachable_commit_count(&repo).unwrap_or(0);
1215 if lost > 0 {
1216 risks.push(Note::new(
1217 "unreachable-commits",
1218 format!("{lost} commit(s) on a detached HEAD will be permanently lost"),
1219 ));
1220 }
1221 }
1222
1223 if let Some(ahead) = current_branch_ahead(&repo) {
1226 if ahead > 0 {
1227 info.push(Note::new(
1228 "unpushed",
1229 format!("{ahead} unpushed commit(s) on the branch (kept — the branch survives)"),
1230 ));
1231 }
1232 }
1233
1234 Ok(GitSafety {
1235 is_main: false,
1236 removable: true,
1237 risks,
1238 info,
1239 })
1240}
1241
1242fn count_dirty_untracked(repo: &Repository) -> (usize, usize) {
1249 let mut opts = StatusOptions::new();
1250 opts.include_untracked(true)
1251 .recurse_untracked_dirs(true)
1252 .include_ignored(false)
1253 .exclude_submodules(true);
1254 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
1255 return (0, 0);
1256 };
1257 let tracked = Status::INDEX_NEW
1260 | Status::INDEX_MODIFIED
1261 | Status::INDEX_DELETED
1262 | Status::INDEX_RENAMED
1263 | Status::INDEX_TYPECHANGE
1264 | Status::WT_MODIFIED
1265 | Status::WT_DELETED
1266 | Status::WT_TYPECHANGE
1267 | Status::WT_RENAMED
1268 | Status::CONFLICTED;
1269 let mut dirty = 0;
1270 let mut untracked = 0;
1271 for entry in statuses.iter() {
1272 let s = entry.status();
1273 if s.contains(Status::WT_NEW) {
1274 untracked += 1;
1275 }
1276 if s.intersects(tracked) {
1277 dirty += 1;
1278 }
1279 }
1280 (dirty, untracked)
1281}
1282
1283fn unreachable_commit_count(repo: &Repository) -> Option<usize> {
1290 let head_oid = repo.head().ok()?.target()?;
1291 let mut walk = repo.revwalk().ok()?;
1292 walk.push(head_oid).ok()?;
1293 for reference in repo.references().ok()? {
1294 let Ok(reference) = reference else { continue };
1295 if matches!(reference.name(), Ok("HEAD")) {
1298 continue;
1299 }
1300 if let Some(oid) = reference.target() {
1301 let _ = walk.hide(oid);
1302 }
1303 }
1304 Some(walk.flatten().count())
1305}
1306
1307fn current_branch_ahead(repo: &Repository) -> Option<usize> {
1311 let head = repo.head().ok()?;
1312 if !head.is_branch() {
1313 return None;
1314 }
1315 let branch = git2::Branch::wrap(head);
1316 upstream_ahead_behind(repo, &branch).map(|(ahead, _behind)| ahead)
1317}
1318
1319fn worktree_name_for_path(main_repo: &Repository, target: &Path) -> Result<String> {
1325 let names = main_repo.worktrees()?;
1326 names
1327 .iter()
1328 .flatten() .flatten() .find(|name| {
1331 main_repo
1332 .find_worktree(name)
1333 .is_ok_and(|wt| canonical(wt.path()) == target)
1334 })
1335 .map(str::to_string)
1336 .ok_or_else(|| {
1337 anyhow!(
1338 "worktree {} is not registered in {}",
1339 target.display(),
1340 main_repo.path().display()
1341 )
1342 })
1343}
1344
1345fn remove_worktree(path: &Path) -> Result<()> {
1352 if !path.exists() {
1353 return Ok(());
1354 }
1355 let repo = Repository::open(path)
1356 .with_context(|| format!("not a git worktree: {}", path.display()))?;
1357 if !repo.is_worktree() {
1358 bail!(
1359 "refusing to delete the main working tree: {}",
1360 path.display()
1361 );
1362 }
1363 let commondir = canonical(repo.commondir());
1366 let main_root = commondir
1367 .parent()
1368 .ok_or_else(|| anyhow!("no repository root for {}", path.display()))?
1369 .to_path_buf();
1370 drop(repo);
1372 let main_repo = Repository::open(&main_root)
1373 .with_context(|| format!("failed to open repository at {}", main_root.display()))?;
1374 let name = worktree_name_for_path(&main_repo, &canonical(path))?;
1375 let worktree = main_repo.find_worktree(&name)?;
1376
1377 if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
1379 let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
1380 bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
1381 }
1382
1383 let mut opts = git2::WorktreePruneOptions::new();
1387 opts.valid(true).working_tree(true);
1388 worktree
1389 .prune(Some(&mut opts))
1390 .with_context(|| format!("failed to remove worktree {}", path.display()))?;
1391 Ok(())
1392}
1393
1394#[cfg(test)]
1395#[allow(clippy::unwrap_used, clippy::expect_used)]
1396mod tests {
1397 use super::*;
1398 use chrono::Utc;
1399
1400 fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
1401 json!({
1402 "key": key,
1403 "folders": [folder],
1404 "repo": repo,
1405 "title": format!("{key}-title"),
1406 "pid": 1234,
1407 })
1408 }
1409
1410 fn windows_of(payload: &Value) -> &Vec<Value> {
1412 payload
1413 .get("windows")
1414 .and_then(Value::as_array)
1415 .expect("windows array")
1416 }
1417
1418 #[tokio::test]
1419 async fn name_and_unknown_op() {
1420 let svc = WorktreesService::new();
1421 assert_eq!(svc.name(), "worktrees");
1422 assert!(svc.handle("frobnicate", Value::Null).await.is_err());
1423 }
1424
1425 #[tokio::test]
1426 async fn handle_routes_ops_and_shapes_payloads() {
1427 let svc = WorktreesService::new();
1428 let payload = svc.handle("list", Value::Null).await.unwrap();
1430 assert_eq!(payload, json!({ "windows": [] }));
1431
1432 let reply = svc
1434 .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
1435 .await
1436 .unwrap();
1437 assert_eq!(reply, json!({ "ok": true }));
1438 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
1439 assert_eq!(windows.len(), 1);
1440 assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
1441 assert!(windows[0].get("last_seen").is_some());
1442
1443 let known = svc
1445 .handle("heartbeat", json!({ "key": "w1" }))
1446 .await
1447 .unwrap();
1448 assert_eq!(known, json!({ "known": true }));
1449 let unknown = svc
1450 .handle("heartbeat", json!({ "key": "nope" }))
1451 .await
1452 .unwrap();
1453 assert_eq!(unknown, json!({ "known": false }));
1454
1455 let gone = svc
1457 .handle("unregister", json!({ "key": "w1" }))
1458 .await
1459 .unwrap();
1460 assert_eq!(gone, json!({ "removed": true }));
1461 let again = svc
1462 .handle("unregister", json!({ "key": "w1" }))
1463 .await
1464 .unwrap();
1465 assert_eq!(again, json!({ "removed": false }));
1466 }
1467
1468 #[tokio::test]
1469 async fn handle_rejects_missing_or_empty_key() {
1470 let svc = WorktreesService::new();
1471 assert!(svc.handle("register", json!({})).await.is_err());
1473 assert!(svc
1474 .handle("register", json!({ "key": " " }))
1475 .await
1476 .is_err());
1477 assert!(svc.handle("heartbeat", json!({})).await.is_err());
1479 assert!(svc.handle("unregister", json!({})).await.is_err());
1480 }
1481
1482 #[test]
1483 fn display_name_prefers_repo_then_folder_basename() {
1484 let base = WindowEntry {
1485 key: "k".to_string(),
1486 folders: vec![PathBuf::from("/home/me/project")],
1487 repo: Some("my-repo".to_string()),
1488 title: None,
1489 pid: None,
1490 last_seen: Utc::now(),
1491 };
1492 assert_eq!(display_name(&base), "my-repo");
1493
1494 let no_repo = WindowEntry {
1495 repo: None,
1496 ..base.clone()
1497 };
1498 assert_eq!(display_name(&no_repo), "project");
1499
1500 let nothing = WindowEntry {
1501 repo: None,
1502 folders: vec![],
1503 ..base.clone()
1504 };
1505 assert_eq!(display_name(¬hing), "(no folder)");
1506
1507 let rootish = WindowEntry {
1510 repo: None,
1511 folders: vec![PathBuf::from("/")],
1512 ..base
1513 };
1514 assert_eq!(display_name(&rootish), "/");
1515 }
1516
1517 #[test]
1518 fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
1519 let now = Utc::now();
1520 let entries = vec![
1521 WindowEntry {
1526 key: "k2".to_string(),
1527 folders: vec![],
1528 repo: Some("solo".to_string()),
1529 title: Some("solo".to_string()),
1530 pid: None,
1531 last_seen: now,
1532 },
1533 WindowEntry {
1536 key: "k1".to_string(),
1537 folders: vec![PathBuf::from("/tmp/a")],
1538 repo: Some("repo".to_string()),
1539 title: Some("a branch".to_string()),
1540 pid: None,
1541 last_seen: now,
1542 },
1543 ];
1544 let items = window_menu_items(&entries);
1545 assert_eq!(items.len(), 2);
1547 assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
1548
1549 let action = items
1552 .iter()
1553 .find_map(|i| match i {
1554 MenuItem::Action(a) => Some(a),
1555 _ => None,
1556 })
1557 .expect("a focus action");
1558 assert_eq!(action.id, "focus:k1");
1559 assert_eq!(action.label, "repo · a branch");
1560
1561 let labels: Vec<&str> = items
1563 .iter()
1564 .filter_map(|i| match i {
1565 MenuItem::Label(t) => Some(t.as_str()),
1566 _ => None,
1567 })
1568 .collect();
1569 assert_eq!(labels, vec!["solo"]);
1570 }
1571
1572 #[tokio::test]
1573 async fn menu_and_status_shapes() {
1574 let svc = WorktreesService::new();
1575 let menu = svc.menu();
1577 assert_eq!(menu.title, "Worktrees");
1578 assert!(matches!(
1579 menu.items.first(),
1580 Some(MenuItem::Label(text)) if text == "No open windows"
1581 ));
1582 let status = svc.status().await;
1583 assert_eq!(status.name, "worktrees");
1584 assert!(status.healthy);
1585 assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
1586
1587 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
1590 .await
1591 .unwrap();
1592 svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
1593 .await
1594 .unwrap();
1595 svc.handle(
1596 "register",
1597 json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
1598 )
1599 .await
1600 .unwrap();
1601 let status = svc.status().await;
1602 assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
1603
1604 let menu = svc.menu();
1605 assert_eq!(menu.items.len(), 3);
1607 assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
1608 let action_ids: Vec<&str> = menu
1609 .items
1610 .iter()
1611 .filter_map(|i| match i {
1612 MenuItem::Action(a) => Some(a.id.as_str()),
1613 _ => None,
1614 })
1615 .collect();
1616 assert!(action_ids.contains(&"focus:w1"));
1619 assert!(action_ids.contains(&"focus:w2"));
1620 assert!(!action_ids.contains(&"focus:w3"));
1621 }
1622
1623 #[test]
1624 fn start_menu_refresh_is_a_noop_outside_a_runtime() {
1625 let svc = WorktreesService::new();
1628 svc.start_menu_refresh();
1629 assert!(svc.refresh.lock().unwrap().is_none());
1630 }
1631
1632 #[tokio::test]
1633 async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
1634 let svc = WorktreesService::new();
1635 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
1636 .await
1637 .unwrap();
1638 assert!(svc.menu_cache.lock().unwrap().is_none());
1640
1641 svc.start_menu_refresh();
1642 svc.start_menu_refresh();
1644
1645 let mut filled = false;
1647 for _ in 0..100 {
1648 if svc.menu_cache.lock().unwrap().is_some() {
1649 filled = true;
1650 break;
1651 }
1652 tokio::time::sleep(Duration::from_millis(10)).await;
1653 }
1654 assert!(filled, "background refresh should populate the menu cache");
1655
1656 let menu = svc.menu();
1658 assert_eq!(menu.title, "Worktrees");
1659 assert!(menu
1660 .items
1661 .iter()
1662 .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
1663
1664 svc.shutdown().await;
1666 assert!(svc.refresh.lock().unwrap().is_none());
1667 }
1668
1669 #[tokio::test]
1670 async fn default_constructs_an_empty_service() {
1671 let svc = WorktreesService::default();
1672 let payload = svc.handle("list", Value::Null).await.unwrap();
1673 assert_eq!(payload, json!({ "windows": [] }));
1674 }
1675
1676 #[tokio::test]
1679 async fn subscribe_streams_only_for_the_subscribe_op() {
1680 let svc = WorktreesService::new();
1681 assert!(svc.subscribe("subscribe", &Value::Null).is_some());
1685 assert!(svc.subscribe("list", &Value::Null).is_none());
1686 assert!(svc.subscribe("register", &Value::Null).is_none());
1687 assert!(svc.subscribe("bogus", &Value::Null).is_none());
1688 }
1689
1690 #[tokio::test]
1691 async fn subscribe_snapshot_matches_the_tree_op() {
1692 let dir = tempfile::tempdir().unwrap();
1693 let repo = init_repo(dir.path());
1694 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
1695 repo.set_head("refs/heads/main").unwrap();
1696
1697 let svc = WorktreesService::new();
1698 let stream = svc
1699 .subscribe("subscribe", &Value::Null)
1700 .expect("subscribe stream");
1701 assert_eq!(stream.snapshot().await, json!({ "repos": [] }));
1703
1704 svc.handle(
1707 "register",
1708 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
1709 )
1710 .await
1711 .unwrap();
1712 let snap = stream.snapshot().await;
1713 let tree = svc.handle("tree", Value::Null).await.unwrap();
1714 assert_eq!(snap, tree);
1715 let repos = snap["repos"].as_array().expect("repos array");
1716 assert_eq!(repos.len(), 1);
1717 assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
1718 }
1719
1720 #[tokio::test]
1721 async fn subscribe_changed_wakes_on_register() {
1722 let svc = WorktreesService::new();
1723 let mut stream = svc
1724 .subscribe("subscribe", &Value::Null)
1725 .expect("subscribe stream");
1726 tokio::select! {
1728 () = stream.changed() => panic!("changed resolved with no registry change"),
1729 () = tokio::time::sleep(Duration::from_millis(50)) => {}
1730 }
1731 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
1733 .await
1734 .unwrap();
1735 tokio::time::timeout(Duration::from_secs(1), stream.changed())
1736 .await
1737 .expect("changed should resolve after a register");
1738 }
1739
1740 #[tokio::test]
1741 async fn menu_action_rejects_unknown_and_missing_window() {
1742 let svc = WorktreesService::new();
1743 assert!(svc.menu_action("bogus").await.is_err());
1744 assert!(svc.menu_action("focus:nope").await.is_err());
1746 svc.shutdown().await;
1747 }
1748
1749 struct VscodeBinGuard(Option<std::ffi::OsString>);
1756 impl Drop for VscodeBinGuard {
1757 fn drop(&mut self) {
1758 match self.0.take() {
1759 Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
1760 None => std::env::remove_var(VSCODE_BIN_ENV),
1761 }
1762 }
1763 }
1764
1765 #[tokio::test]
1766 async fn menu_action_focus_resolves_folder_and_spawns() {
1767 let dir = tempfile::tempdir().unwrap();
1768 let svc = WorktreesService::new();
1769 svc.handle(
1770 "register",
1771 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
1772 )
1773 .await
1774 .unwrap();
1775
1776 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
1779 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
1780 svc.menu_action("focus:w1").await.unwrap();
1781 }
1782
1783 #[tokio::test]
1784 async fn open_rejects_missing_relative_or_nonexistent_path() {
1785 let svc = WorktreesService::new();
1786 assert!(svc.handle("open", json!({})).await.is_err());
1788 assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
1789 assert!(svc
1792 .handle("open", json!({ "path": "relative/dir" }))
1793 .await
1794 .is_err());
1795 assert!(svc
1796 .handle("open", json!({ "path": "-flag" }))
1797 .await
1798 .is_err());
1799 assert!(svc
1802 .handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
1803 .await
1804 .is_err());
1805 svc.shutdown().await;
1806 }
1807
1808 #[tokio::test]
1809 async fn open_focuses_an_existing_absolute_dir() {
1810 let dir = tempfile::tempdir().unwrap();
1811 let svc = WorktreesService::new();
1812 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
1817 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
1818 let reply = svc
1819 .handle("open", json!({ "path": dir.path() }))
1820 .await
1821 .unwrap();
1822 assert_eq!(reply, json!({ "ok": true }));
1823 svc.shutdown().await;
1824 }
1825
1826 #[test]
1827 fn focus_window_with_validates_folder_then_spawns() {
1828 let dir = tempfile::tempdir().unwrap();
1829 assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
1831 assert!(
1832 focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
1833 );
1834 focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
1836 assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
1838 }
1839
1840 #[test]
1841 fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
1842 assert_eq!(
1844 resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
1845 PathBuf::from("/custom/code")
1846 );
1847 let existing = tempfile::NamedTempFile::new().unwrap();
1849 let existing_path = existing.path().to_str().unwrap();
1850 assert_eq!(
1851 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
1852 PathBuf::from(existing_path)
1853 );
1854 assert_eq!(
1856 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
1857 PathBuf::from("code")
1858 );
1859 let _ = resolve_code_binary();
1861 }
1862
1863 fn init_repo(dir: &Path) -> Repository {
1868 let repo = Repository::init(dir).unwrap();
1869 let mut cfg = repo.config().unwrap();
1870 cfg.set_str("user.name", "Test").unwrap();
1871 cfg.set_str("user.email", "test@example.com").unwrap();
1872 repo
1873 }
1874
1875 fn empty_commit(
1878 repo: &Repository,
1879 refname: Option<&str>,
1880 parents: &[&git2::Commit<'_>],
1881 msg: &str,
1882 ) -> git2::Oid {
1883 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
1884 let tree = repo
1885 .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
1886 .unwrap();
1887 repo.commit(refname, &sig, &sig, msg, &tree, parents)
1888 .unwrap()
1889 }
1890
1891 fn commit_file(
1896 repo: &Repository,
1897 refname: &str,
1898 name: &str,
1899 content: &[u8],
1900 msg: &str,
1901 ) -> git2::Oid {
1902 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
1903 let blob = repo.blob(content).unwrap();
1904 let mut builder = repo.treebuilder(None).unwrap();
1905 builder.insert(name, blob, 0o100_644).unwrap();
1906 let tree = repo.find_tree(builder.write().unwrap()).unwrap();
1907 let parent = repo
1908 .refname_to_id(refname)
1909 .ok()
1910 .and_then(|oid| repo.find_commit(oid).ok());
1911 let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
1912 repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
1913 .unwrap()
1914 }
1915
1916 fn diverging_repo(dir: &Path) -> Repository {
1919 let repo = init_repo(dir);
1920 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
1922 let a_commit = repo.find_commit(a).unwrap();
1923 let c = empty_commit(&repo, None, &[&a_commit], "C");
1925 repo.reference("refs/remotes/origin/main", c, true, "origin main")
1926 .unwrap();
1927 empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
1929 drop(a_commit);
1931 repo.set_head("refs/heads/main").unwrap();
1932 let mut cfg = repo.config().unwrap();
1934 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
1935 .unwrap();
1936 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
1937 .unwrap();
1938 cfg.set_str("branch.main.remote", "origin").unwrap();
1939 cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
1940 repo
1941 }
1942
1943 #[test]
1944 fn git_status_reads_branch_and_ahead_behind() {
1945 let dir = tempfile::tempdir().unwrap();
1946 let _repo = diverging_repo(dir.path());
1947 let status = git_status(dir.path());
1948 assert_eq!(status.branch.as_deref(), Some("main"));
1949 assert_eq!(status.ahead, Some(1));
1950 assert_eq!(status.behind, Some(1));
1951 assert_eq!(
1953 status.main_repo.as_deref(),
1954 dir.path().file_name().and_then(|n| n.to_str())
1955 );
1956 assert!(!status.is_worktree);
1957 }
1958
1959 #[test]
1960 fn git_status_empty_repo_is_unborn() {
1961 let dir = tempfile::tempdir().unwrap();
1965 init_repo(dir.path());
1966 let status = git_status(dir.path());
1967 assert_eq!(status.branch, None);
1968 assert_eq!(status.ahead, None);
1969 assert_eq!(status.behind, None);
1970 assert_eq!(
1971 status.main_repo.as_deref(),
1972 dir.path().file_name().and_then(|n| n.to_str())
1973 );
1974 assert!(!status.is_worktree);
1975 }
1976
1977 #[test]
1978 fn git_status_no_upstream_reports_branch_only() {
1979 let dir = tempfile::tempdir().unwrap();
1980 let repo = init_repo(dir.path());
1981 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
1982 repo.set_head("refs/heads/main").unwrap();
1983 let status = git_status(dir.path());
1984 assert_eq!(status.branch.as_deref(), Some("main"));
1985 assert_eq!(status.ahead, None);
1987 assert_eq!(status.behind, None);
1988 }
1989
1990 #[test]
1991 fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
1992 let plain = tempfile::tempdir().unwrap();
1994 assert_eq!(git_status(plain.path()), GitStatus::default());
1995
1996 let dir = tempfile::tempdir().unwrap();
1999 let repo = init_repo(dir.path());
2000 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2001 repo.set_head_detached(a).unwrap();
2002 let status = git_status(dir.path());
2003 assert_eq!(status.branch, None);
2004 assert_eq!(status.ahead, None);
2005 assert_eq!(status.behind, None);
2006 assert_eq!(
2007 status.main_repo.as_deref(),
2008 dir.path().file_name().and_then(|n| n.to_str())
2009 );
2010 assert!(!status.is_worktree);
2011 }
2012
2013 #[test]
2014 fn sync_indicator_formats_only_with_upstream() {
2015 assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
2016 assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
2017 assert_eq!(sync_indicator(None, None), None);
2018 assert_eq!(sync_indicator(Some(1), None), None);
2020 }
2021
2022 #[tokio::test]
2023 async fn list_enriches_entries_with_git_status() {
2024 let dir = tempfile::tempdir().unwrap();
2025 let repo = init_repo(dir.path());
2026 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2027 repo.set_head("refs/heads/main").unwrap();
2028
2029 let svc = WorktreesService::new();
2030 svc.handle(
2031 "register",
2032 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
2033 )
2034 .await
2035 .unwrap();
2036 let payload = svc.handle("list", Value::Null).await.unwrap();
2037 let windows = windows_of(&payload);
2038 assert_eq!(windows.len(), 1);
2039 assert_eq!(
2040 windows[0].get("branch").and_then(Value::as_str),
2041 Some("main")
2042 );
2043 assert!(windows[0].get("ahead").is_none());
2045 assert!(windows[0].get("behind").is_none());
2046 assert_eq!(
2048 windows[0].get("main_repo").and_then(Value::as_str),
2049 dir.path().file_name().and_then(|n| n.to_str())
2050 );
2051
2052 let plain = tempfile::tempdir().unwrap();
2054 svc.handle(
2055 "register",
2056 json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
2057 )
2058 .await
2059 .unwrap();
2060 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
2061 let w2 = windows
2062 .iter()
2063 .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
2064 .unwrap();
2065 assert!(w2.get("branch").is_none());
2066 assert!(w2.get("main_repo").is_none());
2067 }
2068
2069 #[test]
2070 fn window_label_prefers_git_branch_over_title() {
2071 let dir = tempfile::tempdir().unwrap();
2072 let repo = init_repo(dir.path());
2073 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2074 repo.set_head("refs/heads/main").unwrap();
2075 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
2076 let entry = WindowEntry {
2077 key: "k".to_string(),
2078 folders: vec![dir.path().to_path_buf()],
2079 repo: Some("companion-repo".to_string()),
2082 title: Some("ignored title".to_string()),
2083 pid: None,
2084 last_seen: Utc::now(),
2085 };
2086 assert_eq!(window_label(&entry), format!("{repo_name} · main"));
2088 }
2089
2090 #[tokio::test]
2091 async fn list_includes_ahead_behind_for_tracking_branch() {
2092 let dir = tempfile::tempdir().unwrap();
2093 let _repo = diverging_repo(dir.path());
2094
2095 let svc = WorktreesService::new();
2096 svc.handle(
2097 "register",
2098 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
2099 )
2100 .await
2101 .unwrap();
2102 let payload = svc.handle("list", Value::Null).await.unwrap();
2103 let windows = windows_of(&payload);
2104 assert_eq!(
2106 windows[0].get("branch").and_then(Value::as_str),
2107 Some("main")
2108 );
2109 assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
2110 assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
2111 }
2112
2113 #[test]
2114 fn window_label_includes_sync_for_tracking_branch() {
2115 let dir = tempfile::tempdir().unwrap();
2116 let _repo = diverging_repo(dir.path());
2117 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
2118 let entry = WindowEntry {
2119 key: "k".to_string(),
2120 folders: vec![dir.path().to_path_buf()],
2121 repo: Some("companion-repo".to_string()),
2122 title: None,
2123 pid: None,
2124 last_seen: Utc::now(),
2125 };
2126 assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
2128 }
2129
2130 fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
2134 let commit = repo.find_commit(base).unwrap();
2135 repo.branch(branch, &commit, false).unwrap();
2136 let reference = repo
2137 .find_reference(&format!("refs/heads/{branch}"))
2138 .unwrap();
2139 let mut opts = git2::WorktreeAddOptions::new();
2140 opts.reference(Some(&reference));
2141 repo.worktree(branch, wt_path, Some(&opts)).unwrap();
2142 }
2143
2144 #[test]
2145 fn git_status_marks_linked_worktree_and_names_parent_repo() {
2146 let main_dir = tempfile::tempdir().unwrap();
2147 let repo = init_repo(main_dir.path());
2148 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2149 repo.set_head("refs/heads/main").unwrap();
2150
2151 let wt_parent = tempfile::tempdir().unwrap();
2154 let wt_path = wt_parent.path().join("feature-wt");
2155 add_worktree(&repo, a, &wt_path, "feature");
2156
2157 let status = git_status(&wt_path);
2158 assert!(status.is_worktree);
2159 assert_eq!(status.branch.as_deref(), Some("feature"));
2160 assert_eq!(
2162 status.main_repo.as_deref(),
2163 main_dir.path().file_name().and_then(|n| n.to_str())
2164 );
2165
2166 let main_status = git_status(main_dir.path());
2168 assert!(!main_status.is_worktree);
2169 assert_eq!(main_status.main_repo, status.main_repo);
2170 }
2171
2172 #[test]
2173 fn window_label_marks_worktree_with_fork_glyph() {
2174 let main_dir = tempfile::tempdir().unwrap();
2175 let repo = init_repo(main_dir.path());
2176 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2177 repo.set_head("refs/heads/main").unwrap();
2178 let wt_parent = tempfile::tempdir().unwrap();
2179 let wt_path = wt_parent.path().join("feature-wt");
2180 add_worktree(&repo, a, &wt_path, "feature");
2181
2182 let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
2183 let entry = WindowEntry {
2184 key: "k".to_string(),
2185 folders: vec![wt_path],
2186 repo: Some("feature-wt".to_string()),
2187 title: None,
2188 pid: None,
2189 last_seen: Utc::now(),
2190 };
2191 assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
2194 }
2195
2196 #[test]
2197 fn main_repo_name_derives_from_common_dir() {
2198 assert_eq!(
2200 main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
2201 Some("omni-dev")
2202 );
2203 assert_eq!(
2205 main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
2206 Some("omni-dev")
2207 );
2208 assert_eq!(
2210 main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
2211 Some("omni-dev")
2212 );
2213 assert_eq!(main_repo_name(Path::new("/.git")), None);
2215 }
2216
2217 fn repos_of(payload: &Value) -> Vec<Value> {
2222 payload
2223 .get("repos")
2224 .and_then(Value::as_array)
2225 .expect("repos array")
2226 .clone()
2227 }
2228
2229 fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
2230 Some(GithubIdentity {
2231 owner: owner.to_string(),
2232 name: name.to_string(),
2233 })
2234 }
2235
2236 #[test]
2237 fn github_identity_parses_supported_forms() {
2238 assert_eq!(
2240 github_identity("https://github.com/rust-works/omni-dev.git"),
2241 github("rust-works", "omni-dev")
2242 );
2243 assert_eq!(
2244 github_identity("https://github.com/rust-works/omni-dev"),
2245 github("rust-works", "omni-dev")
2246 );
2247 assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
2248 assert_eq!(
2250 github_identity("git@github.com:rust-works/omni-dev.git"),
2251 github("rust-works", "omni-dev")
2252 );
2253 assert_eq!(
2254 github_identity("ssh://git@github.com/o/r.git"),
2255 github("o", "r")
2256 );
2257 assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
2258 assert_eq!(
2260 github_identity(" https://github.com/o/r/ "),
2261 github("o", "r")
2262 );
2263 }
2264
2265 #[test]
2266 fn github_identity_rejects_non_github_and_malformed() {
2267 assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
2269 assert_eq!(github_identity("git@example.com:o/r.git"), None);
2270 assert_eq!(github_identity("https://github.com/onlyowner"), None);
2272 assert_eq!(github_identity("https://github.com/o/r/extra"), None);
2273 assert_eq!(github_identity("https://github.com/"), None);
2274 assert_eq!(github_identity("not a url"), None);
2276 }
2277
2278 #[test]
2279 fn remote_github_identity_reads_origin_then_falls_back() {
2280 let dir = tempfile::tempdir().unwrap();
2281 let repo = init_repo(dir.path());
2282 assert_eq!(remote_github_identity(&repo), None);
2284 repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
2286 assert_eq!(remote_github_identity(&repo), None);
2287 repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
2289 .unwrap();
2290 assert_eq!(
2291 remote_github_identity(&repo),
2292 github("rust-works", "omni-dev")
2293 );
2294
2295 repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
2298 .unwrap();
2299 repo.remote("upstream", "https://github.com/other/proj.git")
2300 .unwrap();
2301 assert_eq!(remote_github_identity(&repo), github("other", "proj"));
2302 }
2303
2304 #[tokio::test]
2305 async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
2306 let svc = WorktreesService::new();
2307 assert_eq!(
2309 svc.handle("tree", Value::Null).await.unwrap(),
2310 json!({ "repos": [] })
2311 );
2312 let plain = tempfile::tempdir().unwrap();
2314 svc.handle(
2315 "register",
2316 json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
2317 )
2318 .await
2319 .unwrap();
2320 assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
2321 }
2322
2323 #[tokio::test]
2324 async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
2325 let main_dir = tempfile::tempdir().unwrap();
2326 let repo = init_repo(main_dir.path());
2327 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2328 repo.set_head("refs/heads/main").unwrap();
2329 repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
2331 .unwrap();
2332
2333 let wt_parent = tempfile::tempdir().unwrap();
2336 let wt_path = wt_parent.path().join("feature-wt");
2337 add_worktree(&repo, a, &wt_path, "feature");
2338
2339 let svc = WorktreesService::new();
2340 svc.handle(
2343 "register",
2344 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
2345 )
2346 .await
2347 .unwrap();
2348 svc.handle(
2349 "register",
2350 json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
2351 )
2352 .await
2353 .unwrap();
2354
2355 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
2356 assert_eq!(
2357 repos.len(),
2358 1,
2359 "two worktrees of one repo dedupe: {repos:?}"
2360 );
2361 let repo0 = &repos[0];
2362 assert_eq!(
2364 repo0.get("main_repo").and_then(Value::as_str),
2365 main_dir.path().file_name().and_then(|n| n.to_str())
2366 );
2367 assert_eq!(
2368 repo0.pointer("/github/owner").and_then(Value::as_str),
2369 Some("rust-works")
2370 );
2371 assert_eq!(
2372 repo0.pointer("/github/name").and_then(Value::as_str),
2373 Some("omni-dev")
2374 );
2375 assert!(repo0.get("root").and_then(Value::as_str).is_some());
2376
2377 let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
2378 assert_eq!(worktrees.len(), 2);
2379 let main_wt = &worktrees[0];
2381 assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
2382 assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
2383 assert_eq!(
2384 main_wt.get("window_key").and_then(Value::as_str),
2385 Some("wm")
2386 );
2387 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
2388 let linked = &worktrees[1];
2390 assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
2391 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
2392 assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
2393 assert_eq!(
2394 linked.get("branch").and_then(Value::as_str),
2395 Some("feature")
2396 );
2397 }
2398
2399 #[tokio::test]
2400 async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
2401 let main_dir = tempfile::tempdir().unwrap();
2402 let repo = init_repo(main_dir.path());
2403 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2404 repo.set_head("refs/heads/main").unwrap();
2405 let wt_parent = tempfile::tempdir().unwrap();
2407 let wt_path = wt_parent.path().join("feature-wt");
2408 add_worktree(&repo, a, &wt_path, "feature");
2409
2410 let svc = WorktreesService::new();
2411 svc.handle(
2413 "register",
2414 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
2415 )
2416 .await
2417 .unwrap();
2418
2419 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
2420 assert_eq!(repos.len(), 1);
2421 assert!(repos[0].get("github").is_none(), "no remote → no github");
2422 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
2423 let linked = worktrees
2424 .iter()
2425 .find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
2426 .expect("the linked worktree");
2427 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
2429 assert!(linked.get("window_key").is_none());
2430 }
2431
2432 fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
2438 let main_dir = tempfile::tempdir().unwrap();
2439 let repo = init_repo(main_dir.path());
2440 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
2441 repo.set_head("refs/heads/trunk").unwrap();
2442 let wt_parent = tempfile::tempdir().unwrap();
2443 let wt_path = wt_parent.path().join("feature-wt");
2444 add_worktree(&repo, a, &wt_path, "feature");
2445 (main_dir, wt_parent, wt_path)
2446 }
2447
2448 #[tokio::test]
2449 async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
2450 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
2451 let svc = WorktreesService::new();
2452 let report = svc
2455 .handle("close", json!({ "path": wt_path, "remove": true }))
2456 .await
2457 .unwrap();
2458 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
2459 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
2460 assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
2461 assert!(report
2462 .get("risks")
2463 .and_then(Value::as_array)
2464 .unwrap()
2465 .is_empty());
2466 assert!(wt_path.exists());
2468 }
2469
2470 #[tokio::test]
2471 async fn close_removes_a_clean_linked_worktree() {
2472 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
2473 let svc = WorktreesService::new();
2474 let reply = svc
2475 .handle(
2476 "close",
2477 json!({ "path": wt_path, "remove": true, "confirmed": true }),
2478 )
2479 .await
2480 .unwrap();
2481 assert_eq!(reply, json!({ "removed": true }));
2482 assert!(
2483 !wt_path.exists(),
2484 "the worktree directory should be deleted"
2485 );
2486 }
2487
2488 #[tokio::test]
2489 async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
2490 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
2491 std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
2493
2494 let svc = WorktreesService::new();
2495 let report = svc
2496 .handle("close", json!({ "path": wt_path, "remove": true }))
2497 .await
2498 .unwrap();
2499 let risks = report.get("risks").and_then(Value::as_array).unwrap();
2500 assert!(
2501 risks
2502 .iter()
2503 .any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
2504 "expected an untracked risk: {report}"
2505 );
2506 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
2508 assert!(wt_path.exists());
2510 }
2511
2512 #[tokio::test]
2513 async fn close_confirmed_removes_a_dirty_worktree() {
2514 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
2515 std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
2516 let svc = WorktreesService::new();
2517 let reply = svc
2519 .handle(
2520 "close",
2521 json!({ "path": wt_path, "remove": true, "confirmed": true }),
2522 )
2523 .await
2524 .unwrap();
2525 assert_eq!(reply, json!({ "removed": true }));
2526 assert!(!wt_path.exists());
2527 }
2528
2529 #[tokio::test]
2530 async fn close_refuses_to_remove_the_main_working_tree() {
2531 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
2532 let svc = WorktreesService::new();
2533 let report = svc
2535 .handle("close", json!({ "path": main.path(), "remove": true }))
2536 .await
2537 .unwrap();
2538 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
2539 assert_eq!(
2540 report.get("removable").and_then(Value::as_bool),
2541 Some(false)
2542 );
2543 assert!(svc
2546 .handle(
2547 "close",
2548 json!({ "path": main.path(), "remove": true, "confirmed": true }),
2549 )
2550 .await
2551 .is_err());
2552 assert!(main.path().exists());
2553 }
2554
2555 #[tokio::test]
2556 async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
2557 let main_dir = tempfile::tempdir().unwrap();
2561 let repo = init_repo(main_dir.path());
2562 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
2563 repo.set_head("refs/heads/trunk").unwrap();
2564 let wt_parent = tempfile::tempdir().unwrap();
2565 let wt_path = wt_parent.path().join("main-wt");
2566 add_worktree(&repo, a, &wt_path, "main");
2567
2568 let svc = WorktreesService::new();
2569 let reply = svc
2570 .handle(
2571 "close",
2572 json!({ "path": wt_path, "remove": true, "confirmed": true }),
2573 )
2574 .await
2575 .unwrap();
2576 assert_eq!(reply, json!({ "removed": true }));
2577 assert!(!wt_path.exists());
2578 assert!(
2580 repo.find_branch("main", git2::BranchType::Local).is_ok(),
2581 "the default branch must survive worktree removal"
2582 );
2583 }
2584
2585 #[tokio::test]
2586 async fn close_is_idempotent_when_the_worktree_is_already_gone() {
2587 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
2588 let svc = WorktreesService::new();
2589 svc.handle(
2591 "close",
2592 json!({ "path": wt_path, "remove": true, "confirmed": true }),
2593 )
2594 .await
2595 .unwrap();
2596 let reply = svc
2599 .handle(
2600 "close",
2601 json!({ "path": wt_path, "remove": true, "confirmed": true }),
2602 )
2603 .await
2604 .unwrap();
2605 assert_eq!(reply, json!({ "removed": true }));
2606 }
2607
2608 #[tokio::test]
2609 async fn close_safety_check_detects_detached_head_unreachable_commits() {
2610 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
2611 let wt_repo = Repository::open(&wt_path).unwrap();
2614 let parent_oid = wt_repo.head().unwrap().target().unwrap();
2615 let parent = wt_repo.find_commit(parent_oid).unwrap();
2616 let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
2617 wt_repo.set_head_detached(orphan).unwrap();
2618
2619 let svc = WorktreesService::new();
2620 let report = svc
2621 .handle("close", json!({ "path": wt_path, "remove": true }))
2622 .await
2623 .unwrap();
2624 let risks = report.get("risks").and_then(Value::as_array).unwrap();
2625 assert!(
2626 risks
2627 .iter()
2628 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
2629 "expected an unreachable-commits risk: {report}"
2630 );
2631 }
2632
2633 #[tokio::test]
2634 async fn close_self_close_removes_when_the_requester_owns_the_target() {
2635 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
2636 let svc = WorktreesService::new();
2637 svc.handle(
2641 "register",
2642 json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
2643 )
2644 .await
2645 .unwrap();
2646 let reply = svc
2647 .handle(
2648 "close",
2649 json!({
2650 "path": wt_path,
2651 "remove": true,
2652 "confirmed": true,
2653 "requester_key": "w1",
2654 }),
2655 )
2656 .await
2657 .unwrap();
2658 assert_eq!(reply, json!({ "removed": true }));
2659 assert!(!wt_path.exists());
2660 }
2661
2662 #[tokio::test]
2663 async fn close_safety_check_surfaces_the_owning_window() {
2664 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
2665 let svc = WorktreesService::new();
2666 svc.handle(
2669 "register",
2670 json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
2671 )
2672 .await
2673 .unwrap();
2674 let report = svc
2675 .handle("close", json!({ "path": wt_path, "remove": true }))
2676 .await
2677 .unwrap();
2678 assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
2679 assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
2680 assert_eq!(
2681 report.get("window_folder_count").and_then(Value::as_u64),
2682 Some(2)
2683 );
2684 }
2685
2686 #[tokio::test]
2687 async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
2688 let svc = WorktreesService::new();
2689 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
2690 .await
2691 .unwrap();
2692 assert_eq!(
2694 svc.handle("heartbeat", json!({ "key": "w1" }))
2695 .await
2696 .unwrap(),
2697 json!({ "known": true })
2698 );
2699 svc.registry.mark_close_pending("w1");
2701 assert_eq!(
2702 svc.handle("heartbeat", json!({ "key": "w1" }))
2703 .await
2704 .unwrap(),
2705 json!({ "known": true, "close": true })
2706 );
2707 assert_eq!(
2708 svc.handle("heartbeat", json!({ "key": "w1" }))
2709 .await
2710 .unwrap(),
2711 json!({ "known": true })
2712 );
2713 }
2714
2715 #[tokio::test]
2716 async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
2717 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
2718 let svc = Arc::new(WorktreesService::new());
2719 svc.handle(
2721 "register",
2722 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
2723 )
2724 .await
2725 .unwrap();
2726
2727 let svc2 = svc.clone();
2730 let path = wt_path.clone();
2731 let close = tokio::spawn(async move {
2732 svc2.handle(
2733 "close",
2734 json!({
2735 "path": path,
2736 "remove": true,
2737 "confirmed": true,
2738 "requester_key": "w1",
2739 }),
2740 )
2741 .await
2742 });
2743
2744 let mut saw_close = false;
2747 for _ in 0..200 {
2748 let hb = svc
2749 .handle("heartbeat", json!({ "key": "w2" }))
2750 .await
2751 .unwrap();
2752 if hb.get("close").and_then(Value::as_bool) == Some(true) {
2753 saw_close = true;
2754 svc.handle("unregister", json!({ "key": "w2" }))
2755 .await
2756 .unwrap();
2757 break;
2758 }
2759 tokio::time::sleep(Duration::from_millis(5)).await;
2760 }
2761 assert!(saw_close, "w2 should have been told to close");
2762
2763 let reply = close.await.unwrap().unwrap();
2765 assert_eq!(reply, json!({ "removed": true }));
2766 assert!(!wt_path.exists());
2767 }
2768
2769 #[tokio::test]
2770 async fn await_windows_closed_times_out_when_a_window_never_closes() {
2771 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
2772 let svc = WorktreesService::new();
2773 svc.handle(
2774 "register",
2775 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
2776 )
2777 .await
2778 .unwrap();
2779 let err = await_windows_closed(
2782 &svc.registry,
2783 &wt_path,
2784 Some("w1"),
2785 Duration::from_millis(150),
2786 Duration::from_millis(25),
2787 )
2788 .await
2789 .unwrap_err();
2790 assert!(
2791 err.to_string().contains("w2"),
2792 "error names the window: {err}"
2793 );
2794 await_windows_closed(
2796 &svc.registry,
2797 &wt_path,
2798 Some("w2"),
2799 Duration::from_millis(150),
2800 Duration::from_millis(25),
2801 )
2802 .await
2803 .unwrap();
2804 }
2805
2806 #[tokio::test]
2807 async fn close_window_without_remove_replies_closed_and_never_deletes() {
2808 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
2809 let svc = WorktreesService::new();
2810 let reply = svc
2812 .handle("close", json!({ "path": main.path(), "remove": false }))
2813 .await
2814 .unwrap();
2815 assert_eq!(reply, json!({ "closed": true }));
2816 assert!(main.path().exists());
2817 }
2818
2819 #[tokio::test]
2820 async fn close_safety_check_flags_modified_tracked_files() {
2821 let main_dir = tempfile::tempdir().unwrap();
2825 let repo = init_repo(main_dir.path());
2826 let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
2827 repo.set_head("refs/heads/trunk").unwrap();
2828 let wt_parent = tempfile::tempdir().unwrap();
2829 let wt_path = wt_parent.path().join("feature-wt");
2830 add_worktree(&repo, a, &wt_path, "feature");
2831 std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
2832
2833 let svc = WorktreesService::new();
2834 let report = svc
2835 .handle("close", json!({ "path": wt_path, "remove": true }))
2836 .await
2837 .unwrap();
2838 let risks = report.get("risks").and_then(Value::as_array).unwrap();
2839 assert!(
2840 risks
2841 .iter()
2842 .any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
2843 "expected a dirty risk: {report}"
2844 );
2845 }
2846
2847 #[tokio::test]
2848 async fn close_safety_check_flags_an_in_progress_operation() {
2849 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
2852 let wt_repo = Repository::open(&wt_path).unwrap();
2853 let head = wt_repo.head().unwrap().target().unwrap();
2854 std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
2855 assert_ne!(wt_repo.state(), RepositoryState::Clean);
2856
2857 let svc = WorktreesService::new();
2858 let report = svc
2859 .handle("close", json!({ "path": wt_path, "remove": true }))
2860 .await
2861 .unwrap();
2862 let risks = report.get("risks").and_then(Value::as_array).unwrap();
2863 assert!(
2864 risks
2865 .iter()
2866 .any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
2867 "expected an in-progress risk: {report}"
2868 );
2869 }
2870
2871 #[tokio::test]
2872 async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
2873 let main_dir = tempfile::tempdir().unwrap();
2877 let repo = init_repo(main_dir.path());
2878 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
2879 repo.set_head("refs/heads/trunk").unwrap();
2880 let a_commit = repo.find_commit(a).unwrap();
2881 repo.branch("feature", &a_commit, false).unwrap();
2882 repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
2883 .unwrap();
2884 empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
2886 drop(a_commit);
2887 let mut cfg = repo.config().unwrap();
2888 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
2889 .unwrap();
2890 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
2891 .unwrap();
2892 cfg.set_str("branch.feature.remote", "origin").unwrap();
2893 cfg.set_str("branch.feature.merge", "refs/heads/feature")
2894 .unwrap();
2895 let wt_parent = tempfile::tempdir().unwrap();
2898 let wt_path = wt_parent.path().join("feature-wt");
2899 let reference = repo.find_reference("refs/heads/feature").unwrap();
2900 let mut opts = git2::WorktreeAddOptions::new();
2901 opts.reference(Some(&reference));
2902 repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
2903
2904 let svc = WorktreesService::new();
2905 let report = svc
2906 .handle("close", json!({ "path": wt_path, "remove": true }))
2907 .await
2908 .unwrap();
2909 let info = report.get("info").and_then(Value::as_array).unwrap();
2912 assert!(
2913 info.iter()
2914 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
2915 "expected an unpushed info note: {report}"
2916 );
2917 assert!(
2918 report
2919 .get("risks")
2920 .and_then(Value::as_array)
2921 .unwrap()
2922 .is_empty(),
2923 "unpushed commits alone must not block: {report}"
2924 );
2925 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
2926 }
2927
2928 #[tokio::test]
2929 async fn close_safety_check_ignores_gitignored_files() {
2930 let main_dir = tempfile::tempdir().unwrap();
2934 let repo = init_repo(main_dir.path());
2935 let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
2936 repo.set_head("refs/heads/trunk").unwrap();
2937 let wt_parent = tempfile::tempdir().unwrap();
2938 let wt_path = wt_parent.path().join("feature-wt");
2939 add_worktree(&repo, a, &wt_path, "feature");
2940 std::fs::create_dir(wt_path.join("build")).unwrap();
2941 std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
2942
2943 let svc = WorktreesService::new();
2944 let report = svc
2945 .handle("close", json!({ "path": wt_path, "remove": true }))
2946 .await
2947 .unwrap();
2948 assert!(
2949 report
2950 .get("risks")
2951 .and_then(Value::as_array)
2952 .unwrap()
2953 .is_empty(),
2954 "a gitignored file must not create a risk: {report}"
2955 );
2956 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
2957 }
2958
2959 #[tokio::test]
2960 async fn close_safety_check_treats_a_missing_path_as_already_removed() {
2961 let svc = WorktreesService::new();
2964 let report = svc
2965 .handle(
2966 "close",
2967 json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
2968 )
2969 .await
2970 .unwrap();
2971 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
2972 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
2973 assert!(report
2974 .get("risks")
2975 .and_then(Value::as_array)
2976 .unwrap()
2977 .is_empty());
2978 let info = report.get("info").and_then(Value::as_array).unwrap();
2979 assert!(info
2980 .iter()
2981 .any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
2982 }
2983
2984 #[tokio::test]
2985 async fn close_refuses_a_locked_worktree() {
2986 let (main, _wtp, wt_path) = repo_with_linked_worktree();
2989 let main_repo = Repository::open(main.path()).unwrap();
2990 main_repo
2991 .find_worktree("feature")
2992 .unwrap()
2993 .lock(Some("under test"))
2994 .unwrap();
2995
2996 let svc = WorktreesService::new();
2997 let err = svc
2998 .handle(
2999 "close",
3000 json!({ "path": wt_path, "remove": true, "confirmed": true }),
3001 )
3002 .await
3003 .unwrap_err();
3004 assert!(
3005 err.to_string().contains("locked"),
3006 "expected a locked error: {err}"
3007 );
3008 assert!(wt_path.exists(), "a locked worktree must not be removed");
3009 }
3010
3011 #[tokio::test]
3012 async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
3013 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3017 let wt_repo = Repository::open(&wt_path).unwrap();
3018 let tip = wt_repo.head().unwrap().target().unwrap();
3021 wt_repo.set_head_detached(tip).unwrap();
3022 assert!(wt_repo.head_detached().unwrap());
3023
3024 let svc = WorktreesService::new();
3025 let report = svc
3026 .handle("close", json!({ "path": wt_path, "remove": true }))
3027 .await
3028 .unwrap();
3029 let risks = report.get("risks").and_then(Value::as_array).unwrap();
3030 assert!(
3031 !risks
3032 .iter()
3033 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
3034 "a detached HEAD reachable from a branch must not be flagged: {report}"
3035 );
3036 }
3037
3038 #[test]
3039 fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
3040 let (main, _wtp, wt_path) = repo_with_linked_worktree();
3041 let main_repo = Repository::open(main.path()).unwrap();
3042 assert_eq!(
3044 worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
3045 "feature"
3046 );
3047 let err =
3050 worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
3051 assert!(
3052 err.to_string().contains("not registered"),
3053 "expected a not-registered error: {err}"
3054 );
3055 }
3056
3057 #[test]
3058 fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
3059 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3062 let repo = Repository::open(&wt_path).unwrap();
3063 std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
3064 assert!(
3067 repo.statuses(Some(&mut StatusOptions::new())).is_err(),
3068 "a corrupt index should make statuses() fail"
3069 );
3070 assert_eq!(count_dirty_untracked(&repo), (0, 0));
3071 }
3072}