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};
41
42use crate::pr_status::{PrBadge, PrCheckState, PrStatusCache, PrTarget};
43use async_trait::async_trait;
44use git2::{Repository, RepositoryState, Status, StatusOptions, WorktreeLockStatus};
45use serde::{Deserialize, Serialize};
46use serde_json::{json, Value};
47use tokio::sync::watch;
48use tokio::sync::Mutex as AsyncMutex;
49use tokio::task::JoinHandle;
50use tokio_util::sync::CancellationToken;
51
52use crate::daemon::service::{
53 DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
54};
55use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
56
57pub const SERVICE_NAME: &str = "worktrees";
59
60const SUBMENU_TITLE: &str = "Worktrees";
62
63const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
66
67const ENV_MENU_REFRESH_INTERVAL: &str = "OMNI_DEV_DAEMON_MENU_REFRESH";
70
71const DEFAULT_MENU_REFRESH_INTERVAL: Duration = Duration::from_secs(10);
83
84fn menu_refresh_interval() -> Duration {
87 crate::daemon::server::duration_secs_from_env(
88 ENV_MENU_REFRESH_INTERVAL,
89 DEFAULT_MENU_REFRESH_INTERVAL,
90 )
91}
92
93const ENV_PR_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_PR_POLL";
97
98const DEFAULT_PR_POLL_INTERVAL: Duration = Duration::from_secs(10);
106
107const MAX_PR_POLL_INTERVAL: Duration = Duration::from_secs(30 * 60);
115
116fn pr_poll_interval() -> Duration {
119 crate::daemon::server::duration_secs_from_env(ENV_PR_POLL_INTERVAL, DEFAULT_PR_POLL_INTERVAL)
120}
121
122struct RefreshTask {
124 token: CancellationToken,
126 handle: JoinHandle<()>,
128}
129
130fn pr_should_fetch(moved: bool, since_last_fetch: Option<Duration>, backoff: Duration) -> bool {
142 moved || since_last_fetch.map_or(true, |elapsed| elapsed >= backoff)
145}
146
147fn next_pr_poll_delay(current: Duration, base: Duration, pending: bool) -> Duration {
155 if pending {
156 base
157 } else {
158 current.saturating_mul(2).min(MAX_PR_POLL_INTERVAL)
159 }
160}
161
162struct PollerTask {
164 token: CancellationToken,
166 handle: JoinHandle<()>,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
182struct PrWatch {
183 target: PrTarget,
185 head_sha: Option<String>,
187 upstream_sha: Option<String>,
189}
190
191fn pr_watch_from_snapshot(snapshot: &Value) -> Vec<PrWatch> {
200 let mut out = Vec::new();
201 for repo in snapshot
202 .get("repos")
203 .and_then(Value::as_array)
204 .into_iter()
205 .flatten()
206 {
207 let Some(github) = repo.get("github") else {
208 continue;
209 };
210 let (Some(owner), Some(name)) = (
211 github.get("owner").and_then(Value::as_str),
212 github.get("name").and_then(Value::as_str),
213 ) else {
214 continue;
215 };
216 for wt in repo
217 .get("worktrees")
218 .and_then(Value::as_array)
219 .into_iter()
220 .flatten()
221 {
222 if let Some(branch) = wt.get("branch").and_then(Value::as_str) {
223 out.push(PrWatch {
224 head_sha: wt
225 .get("head_sha")
226 .and_then(Value::as_str)
227 .map(str::to_string),
228 upstream_sha: wt
229 .get("upstream_sha")
230 .and_then(Value::as_str)
231 .map(str::to_string),
232 target: PrTarget {
233 owner: owner.to_string(),
234 name: name.to_string(),
235 branch: branch.to_string(),
236 },
237 });
238 }
239 }
240 }
241 out.sort();
242 out.dedup();
243 out
244}
245
246#[cfg(test)]
249fn pr_targets_from_snapshot(snapshot: &Value) -> Vec<PrTarget> {
250 pr_watch_from_snapshot(snapshot)
251 .into_iter()
252 .map(|w| w.target)
253 .collect()
254}
255
256pub struct WorktreesService {
258 registry: Arc<WorktreesRegistry>,
261 menu_cache: Arc<Mutex<Option<Vec<MenuItem>>>>,
267 refresh: Mutex<Option<RefreshTask>>,
269 pr_cache: Arc<PrStatusCache>,
275 poller: Mutex<Option<PollerTask>>,
278 tree_cache: Arc<TreeSnapshotCache>,
284}
285
286impl WorktreesService {
287 #[must_use]
292 pub fn new() -> Self {
293 let registry = Arc::new(WorktreesRegistry::new());
294 let pr_cache = Arc::new(PrStatusCache::new());
295 Self {
296 registry: registry.clone(),
297 menu_cache: Arc::new(Mutex::new(None)),
298 refresh: Mutex::new(None),
299 pr_cache: pr_cache.clone(),
300 poller: Mutex::new(None),
301 tree_cache: Arc::new(TreeSnapshotCache::new(registry, pr_cache)),
302 }
303 }
304
305 pub fn start_menu_refresh(&self) {
313 if tokio::runtime::Handle::try_current().is_err() {
314 tracing::debug!("no tokio runtime; worktrees menu refresh not started");
315 return;
316 }
317 let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
318 if guard.is_some() {
319 return;
320 }
321 let token = CancellationToken::new();
322 let loop_token = token.clone();
323 let registry = self.registry.clone();
324 let cache = self.menu_cache.clone();
325 let interval = menu_refresh_interval();
328 let handle = tokio::spawn(async move {
329 loop {
330 let entries = registry.list();
334 if let Ok(items) =
335 tokio::task::spawn_blocking(move || menu_items_for(&entries)).await
336 {
337 *cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
338 }
339 tokio::select! {
340 () = loop_token.cancelled() => break,
341 () = tokio::time::sleep(interval) => {}
342 }
343 }
344 });
345 *guard = Some(RefreshTask { token, handle });
346 }
347
348 pub fn start_pr_poller(&self) {
371 self.start_pr_poller_with(pr_poll_interval(), crate::pr_status::resolve_gh_binary());
374 }
375
376 fn start_pr_poller_with(&self, base: Duration, gh_bin: PathBuf) {
383 if tokio::runtime::Handle::try_current().is_err() {
384 tracing::debug!("no tokio runtime; worktrees PR poller not started");
385 return;
386 }
387 let mut guard = self.poller.lock().unwrap_or_else(PoisonError::into_inner);
388 if guard.is_some() {
389 return;
390 }
391 let token = CancellationToken::new();
392 let loop_token = token.clone();
393 let registry = self.registry.clone();
394 let tree_cache = self.tree_cache.clone();
395 let pr_cache = self.pr_cache.clone();
396 let mut changes = self.registry.subscribe_changes();
399 let handle = tokio::spawn(async move {
400 let mut backoff = base;
411 let mut last_poll: Option<Instant> = None;
412 let mut watched: Option<Vec<PrWatch>> = None;
413 loop {
414 tokio::select! {
417 () = loop_token.cancelled() => break,
418 () = tokio::time::sleep(base) => {}
419 result = changes.changed() => {
422 if result.is_err() {
430 break;
431 }
432 }
433 }
434 let snapshot = tree_cache.snapshot().await;
437 let watch = pr_watch_from_snapshot(&snapshot);
438 if watch.is_empty() {
439 backoff = base;
443 last_poll = None;
444 watched = None;
445 continue;
446 }
447 let moved = watched.as_ref() != Some(&watch);
451 if !pr_should_fetch(moved, last_poll.map(|at| at.elapsed()), backoff) {
452 continue;
453 }
454 if moved {
455 backoff = base;
458 }
459 let targets: Vec<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
460 let bin = gh_bin.clone();
465 let resolved = tokio::task::spawn_blocking(move || {
466 crate::pr_status::resolve_with(&bin, &targets)
467 })
468 .await
469 .unwrap_or_else(|err| Err(anyhow!("blocking poll task failed: {err}")));
470 let pending = match resolved {
475 Ok(badges) => {
476 if pr_cache.replace(badges) {
479 registry.bump();
480 }
481 pr_cache.any_pending()
482 }
483 Err(err) => {
484 tracing::debug!("PR badge poll failed: {err:#}");
485 false
486 }
487 };
488 last_poll = Some(Instant::now());
489 watched = Some(watch);
492 backoff = next_pr_poll_delay(backoff, base, pending);
493 }
494 });
495 *guard = Some(PollerTask { token, handle });
496 }
497
498 async fn close(&self, req: CloseRequest) -> Result<Value> {
513 let entries = self.registry.list();
517 let scan_path = req.path.clone();
518 let open_windows =
519 tokio::task::spawn_blocking(move || windows_with_path(&entries, &scan_path))
520 .await
521 .unwrap_or_default();
522 let open = !open_windows.is_empty();
523 let window_key = open_windows.first().map(|(k, _)| k.clone());
524 let window_folder_count = open_windows.first().map_or(0, |(_, c)| *c);
525
526 if req.remove && !req.confirmed {
530 let path = req.path.clone();
531 let git = tokio::task::spawn_blocking(move || git_safety(&path))
532 .await
533 .map_err(|e| anyhow!("safety check task panicked: {e}"))??;
534 return Ok(serde_json::to_value(SafetyReport {
535 removable: git.removable,
536 is_main: git.is_main,
537 open,
538 window_key,
539 window_folder_count,
540 risks: git.risks,
541 info: git.info,
542 })
543 .unwrap_or_else(|_| json!({})));
544 }
545
546 let others: Vec<String> = open_windows
553 .iter()
554 .map(|(k, _)| k.clone())
555 .filter(|k| req.requester_key.as_deref() != Some(k))
556 .collect();
557 for key in &others {
558 self.registry.mark_close_pending(key);
559 }
560 if !others.is_empty() {
561 await_windows_closed(
562 &self.registry,
563 &req.path,
564 req.requester_key.as_deref(),
565 CLOSE_WAIT_TIMEOUT,
566 CLOSE_WAIT_POLL,
567 )
568 .await?;
569 }
570
571 if req.remove {
572 let path = req.path.clone();
573 tokio::task::spawn_blocking(move || remove_worktree(&path))
574 .await
575 .map_err(|e| anyhow!("worktree removal task panicked: {e}"))??;
576 Ok(json!({ "removed": true }))
577 } else {
578 Ok(json!({ "closed": true }))
581 }
582 }
583}
584
585impl Default for WorktreesService {
586 fn default() -> Self {
587 Self::new()
588 }
589}
590
591#[async_trait]
592impl DaemonService for WorktreesService {
593 fn name(&self) -> &'static str {
594 SERVICE_NAME
595 }
596
597 async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
598 match op {
599 "register" => {
600 let req: RegisterRequest =
601 serde_json::from_value(payload).context("invalid `register` payload")?;
602 if req.key.trim().is_empty() {
603 bail!("`register` requires a non-empty `key`");
604 }
605 self.registry.register(req);
606 Ok(json!({ "ok": true }))
607 }
608 "heartbeat" => {
609 let key = require_str(&payload, "key", "heartbeat")?;
610 let known = self.registry.heartbeat(key);
611 let mut reply = json!({ "known": known });
616 if self.registry.take_close_pending(key) {
617 reply["close"] = Value::Bool(true);
618 }
619 Ok(reply)
620 }
621 "unregister" => {
622 let key = require_str(&payload, "key", "unregister")?;
623 Ok(json!({ "removed": self.registry.unregister(key) }))
624 }
625 "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
626 "tree" => {
627 Ok(tree_snapshot(&self.registry, self.pr_cache.clone()).await)
635 }
636 "ahead-behind" => {
637 let paths = payload
644 .get("paths")
645 .and_then(Value::as_array)
646 .map(|arr| {
647 arr.iter()
648 .filter_map(Value::as_str)
649 .map(PathBuf::from)
650 .collect::<Vec<_>>()
651 })
652 .unwrap_or_default();
653 Ok(json!({ "results": ahead_behind_results(paths).await }))
654 }
655 "set-show-closed" => {
656 let show_closed = payload
661 .get("show_closed")
662 .and_then(Value::as_bool)
663 .ok_or_else(|| anyhow!("`set-show-closed` requires a boolean `show_closed`"))?;
664 self.registry.set_show_closed(show_closed);
665 Ok(json!({ "ok": true }))
666 }
667 "open" => {
668 let path = require_str(&payload, "path", "open")?;
677 focus_window(Path::new(path))?;
678 Ok(json!({ "ok": true }))
679 }
680 "close" => {
681 let req: CloseRequest =
687 serde_json::from_value(payload).context("invalid `close` payload")?;
688 self.close(req).await
689 }
690 other => bail!("unknown worktrees op: {other}"),
691 }
692 }
693
694 fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
695 if op != "subscribe" {
698 return None;
699 }
700 Some(Box::new(WorktreesStream {
701 cache: self.tree_cache.clone(),
704 changes: self.registry.subscribe_changes(),
707 }))
708 }
709
710 fn menu(&self) -> MenuSnapshot {
711 let cached = self
716 .menu_cache
717 .lock()
718 .unwrap_or_else(PoisonError::into_inner)
719 .clone();
720 let items = cached.unwrap_or_else(|| menu_items_for(&self.registry.list()));
721 MenuSnapshot {
722 title: SUBMENU_TITLE.to_string(),
723 items,
724 }
725 }
726
727 async fn menu_action(&self, action_id: &str) -> Result<()> {
728 if let Some(key) = action_id.strip_prefix("focus:") {
729 let folder = self
732 .registry
733 .first_folder(key)
734 .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
735 focus_window(&folder)?;
736 return Ok(());
737 }
738 bail!("unknown worktrees menu action: {action_id}")
739 }
740
741 async fn status(&self) -> ServiceStatus {
742 let entries = self.registry.list();
743 let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
744 let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
745 let windows = enriched_windows(entries).await;
746 ServiceStatus {
747 name: SERVICE_NAME.to_string(),
748 healthy: true,
749 summary,
750 detail: json!({ "windows": windows }),
751 }
752 }
753
754 async fn shutdown(&self) {
755 let task = self
759 .refresh
760 .lock()
761 .unwrap_or_else(PoisonError::into_inner)
762 .take();
763 if let Some(task) = task {
764 task.token.cancel();
765 let _ = task.handle.await;
766 }
767 let poller = self
770 .poller
771 .lock()
772 .unwrap_or_else(PoisonError::into_inner)
773 .take();
774 if let Some(poller) = poller {
775 poller.token.cancel();
776 let _ = poller.handle.await;
777 }
778 }
779}
780
781fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
785 payload
786 .get(field)
787 .and_then(Value::as_str)
788 .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
789}
790
791#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
802struct GitStatus {
803 #[serde(skip_serializing_if = "Option::is_none")]
805 branch: Option<String>,
806 #[serde(skip_serializing_if = "Option::is_none")]
812 head_sha: Option<String>,
813 #[serde(skip_serializing_if = "Option::is_none")]
821 upstream_sha: Option<String>,
822 #[serde(skip_serializing_if = "Option::is_none")]
824 ahead: Option<usize>,
825 #[serde(skip_serializing_if = "Option::is_none")]
827 behind: Option<usize>,
828 #[serde(skip_serializing_if = "Option::is_none")]
833 main_repo: Option<String>,
834 #[serde(skip_serializing_if = "is_false")]
837 is_worktree: bool,
838}
839
840#[allow(clippy::trivially_copy_pass_by_ref)]
844fn is_false(b: &bool) -> bool {
845 !*b
846}
847
848fn git_status(folder: &Path) -> GitStatus {
854 git_status_impl(folder, true)
855}
856
857fn git_status_cheap(folder: &Path) -> GitStatus {
865 git_status_impl(folder, false)
866}
867
868fn git_status_impl(folder: &Path, with_ahead_behind: bool) -> GitStatus {
874 let Ok(repo) = Repository::discover(folder) else {
875 return GitStatus::default();
876 };
877 let base = GitStatus {
880 main_repo: main_repo_name(repo.commondir()),
881 is_worktree: repo.is_worktree(),
882 ..GitStatus::default()
883 };
884 let Ok(head) = repo.head() else {
885 return base;
887 };
888 let base = GitStatus {
894 head_sha: head.target().map(|oid| oid.to_string()),
895 ..base
896 };
897 let Some(name) = head
901 .shorthand()
902 .ok()
903 .filter(|_| head.is_branch())
904 .map(str::to_string)
905 else {
906 return base;
907 };
908 let branch = git2::Branch::wrap(head);
913 let upstream_sha = upstream_target(&branch);
916 let (ahead, behind) = if with_ahead_behind {
919 match upstream_ahead_behind(&repo, &branch) {
920 Some((ahead, behind)) => (Some(ahead), Some(behind)),
921 None => (None, None),
922 }
923 } else {
924 (None, None)
925 };
926 GitStatus {
927 branch: Some(name),
928 upstream_sha,
929 ahead,
930 behind,
931 ..base
932 }
933}
934
935fn upstream_target(branch: &git2::Branch<'_>) -> Option<String> {
945 Some(branch.upstream().ok()?.get().target()?.to_string())
946}
947
948fn folder_ahead_behind(folder: &Path) -> Option<(usize, usize)> {
955 let repo = Repository::discover(folder).ok()?;
956 let head = repo.head().ok()?;
957 if !head.is_branch() {
958 return None;
959 }
960 let branch = git2::Branch::wrap(head);
961 upstream_ahead_behind(&repo, &branch)
962}
963
964fn main_repo_name(commondir: &Path) -> Option<String> {
970 let file_name = commondir.file_name()?.to_string_lossy().into_owned();
971 if file_name == ".git" {
972 commondir
974 .parent()
975 .and_then(Path::file_name)
976 .map(|n| n.to_string_lossy().into_owned())
977 } else {
978 Some(
980 file_name
981 .strip_suffix(".git")
982 .unwrap_or(&file_name)
983 .to_string(),
984 )
985 }
986}
987
988fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
991 let upstream = branch.upstream().ok()?;
992 let local_oid = branch.get().target()?;
993 let upstream_oid = upstream.get().target()?;
994 repo.graph_ahead_behind(local_oid, upstream_oid).ok()
995}
996
997#[derive(Serialize)]
1003struct EnrichedEntry<'a> {
1004 #[serde(flatten)]
1005 entry: &'a WindowEntry,
1006 #[serde(flatten)]
1007 git: GitStatus,
1008}
1009
1010fn enriched_entry(entry: &WindowEntry) -> Value {
1015 let git = entry
1016 .folders
1017 .first()
1018 .map(|folder| git_status(folder))
1019 .unwrap_or_default();
1020 serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
1021}
1022
1023async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
1027 tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
1028 .await
1029 .unwrap_or_default()
1030}
1031
1032#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1038struct GithubIdentity {
1039 owner: String,
1041 name: String,
1043}
1044
1045#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1061struct TreeWorktree {
1062 path: String,
1064 #[serde(skip_serializing_if = "Option::is_none")]
1066 branch: Option<String>,
1067 #[serde(skip_serializing_if = "Option::is_none")]
1072 head_sha: Option<String>,
1073 #[serde(skip_serializing_if = "Option::is_none")]
1079 upstream_sha: Option<String>,
1080 is_main: bool,
1082 open: bool,
1084 #[serde(skip_serializing_if = "Option::is_none")]
1087 window_key: Option<String>,
1088 #[serde(skip_serializing_if = "Option::is_none")]
1094 pr: Option<PrBadge>,
1095}
1096
1097#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1101struct TreeRepo {
1102 main_repo: String,
1104 #[serde(skip_serializing_if = "Option::is_none")]
1106 github: Option<GithubIdentity>,
1107 root: String,
1109 worktrees: Vec<TreeWorktree>,
1112}
1113
1114fn github_identity(url: &str) -> Option<GithubIdentity> {
1121 let url = url.trim();
1122 let rest = [
1124 "https://github.com/",
1125 "http://github.com/",
1126 "ssh://git@github.com/",
1127 "git://github.com/",
1128 "git@github.com:",
1129 ]
1130 .iter()
1131 .find_map(|prefix| url.strip_prefix(prefix))?;
1132 let rest = rest.strip_suffix(".git").unwrap_or(rest);
1133 let rest = rest.trim_end_matches('/');
1134 let mut parts = rest.splitn(2, '/');
1135 let owner = parts.next()?.trim();
1136 let name = parts.next()?.trim();
1137 if owner.is_empty() || name.is_empty() || name.contains('/') {
1139 return None;
1140 }
1141 Some(GithubIdentity {
1142 owner: owner.to_string(),
1143 name: name.to_string(),
1144 })
1145}
1146
1147fn remote_github_identity(repo: &Repository) -> Option<GithubIdentity> {
1150 if let Ok(origin) = repo.find_remote("origin") {
1151 if let Some(id) = origin.url().ok().and_then(github_identity) {
1152 return Some(id);
1153 }
1154 }
1155 let names = repo.remotes().ok();
1159 names
1160 .iter()
1161 .flat_map(|arr| arr.iter())
1162 .flatten()
1163 .flatten()
1164 .filter_map(|name| repo.find_remote(name).ok())
1165 .find_map(|remote| remote.url().ok().and_then(github_identity))
1166}
1167
1168fn canonical(path: &Path) -> PathBuf {
1172 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1173}
1174
1175fn open_window_index(entries: &[WindowEntry]) -> HashMap<PathBuf, String> {
1180 let mut index = HashMap::new();
1181 for entry in entries {
1182 for folder in &entry.folders {
1183 index
1184 .entry(canonical(folder))
1185 .or_insert_with(|| entry.key.clone());
1186 }
1187 }
1188 index
1189}
1190
1191fn worktree_entry(
1196 path: &Path,
1197 is_main: bool,
1198 open_index: &HashMap<PathBuf, String>,
1199) -> TreeWorktree {
1200 let status = git_status_cheap(path);
1201 let window_key = open_index.get(&canonical(path)).cloned();
1202 TreeWorktree {
1203 path: path.display().to_string(),
1204 branch: status.branch,
1205 head_sha: status.head_sha,
1206 upstream_sha: status.upstream_sha,
1207 is_main,
1208 open: window_key.is_some(),
1209 window_key,
1210 pr: None,
1213 }
1214}
1215
1216fn fold_pr_badges(repos: &mut [TreeRepo], pr_cache: &PrStatusCache) {
1231 for repo in repos {
1232 let Some(github) = repo.github.clone() else {
1233 continue;
1234 };
1235 for worktree in &mut repo.worktrees {
1236 let Some(branch) = &worktree.branch else {
1237 continue;
1238 };
1239 let Some(mut badge) = pr_cache.get(&github.owner, &github.name, branch) else {
1240 continue;
1241 };
1242 if badge.is_stale_for(worktree.head_sha.as_deref()) {
1243 badge.checks = PrCheckState::Pending;
1244 }
1245 worktree.pr = Some(badge);
1246 }
1247 }
1248}
1249
1250fn repo_tree(discovered: &Repository, open_index: &HashMap<PathBuf, String>) -> Option<TreeRepo> {
1256 let commondir = canonical(discovered.commondir());
1259 let main_root = commondir.parent()?.to_path_buf();
1260 let main_repo = Repository::open(&main_root).ok()?;
1261
1262 let mut worktrees = vec![worktree_entry(&main_root, true, open_index)];
1264 let names = main_repo.worktrees().ok();
1269 let mut linked: Vec<PathBuf> = names
1270 .iter()
1271 .flat_map(|arr| arr.iter())
1272 .flatten() .flatten() .filter_map(|name| main_repo.find_worktree(name).ok())
1275 .map(|wt| wt.path().to_path_buf())
1276 .collect();
1277 linked.sort();
1278 worktrees.extend(
1279 linked
1280 .iter()
1281 .map(|path| worktree_entry(path, false, open_index)),
1282 );
1283
1284 Some(TreeRepo {
1285 main_repo: main_repo_name(&commondir)?,
1286 github: remote_github_identity(&main_repo),
1287 root: main_root.display().to_string(),
1288 worktrees,
1289 })
1290}
1291
1292fn build_tree(folders: Vec<PathBuf>, windows: Vec<WindowEntry>) -> Vec<TreeRepo> {
1298 let open_index = open_window_index(&windows);
1299 let mut repos: BTreeMap<PathBuf, TreeRepo> = BTreeMap::new();
1300 for folder in &folders {
1301 let Ok(repo) = Repository::discover(folder) else {
1302 continue;
1303 };
1304 let key = canonical(repo.commondir());
1305 if repos.contains_key(&key) {
1306 continue;
1307 }
1308 if let Some(tree) = repo_tree(&repo, &open_index) {
1309 repos.insert(key, tree);
1310 }
1311 }
1312 repos.into_values().collect()
1313}
1314
1315async fn tree_repos(
1320 folders: Vec<PathBuf>,
1321 windows: Vec<WindowEntry>,
1322 pr_cache: Arc<PrStatusCache>,
1323) -> Vec<Value> {
1324 tokio::task::spawn_blocking(move || {
1325 let mut repos = build_tree(folders, windows);
1326 fold_pr_badges(&mut repos, &pr_cache);
1327 repos
1328 .iter()
1329 .map(|repo| serde_json::to_value(repo).unwrap_or_else(|_| json!({})))
1330 .collect()
1331 })
1332 .await
1333 .unwrap_or_default()
1334}
1335
1336async fn ahead_behind_results(paths: Vec<PathBuf>) -> Value {
1350 tokio::task::spawn_blocking(move || {
1351 let mut results = serde_json::Map::new();
1352 for path in paths {
1353 if let Some((ahead, behind)) = folder_ahead_behind(&path) {
1354 results.insert(
1355 path.display().to_string(),
1356 json!({ "ahead": ahead, "behind": behind }),
1357 );
1358 }
1359 }
1360 Value::Object(results)
1361 })
1362 .await
1363 .unwrap_or_else(|_| json!({}))
1364}
1365
1366struct WorktreesStream {
1380 cache: Arc<TreeSnapshotCache>,
1383 changes: watch::Receiver<u64>,
1387}
1388
1389#[async_trait]
1390impl ServiceStream for WorktreesStream {
1391 async fn changed(&mut self) {
1392 if self.changes.changed().await.is_err() {
1398 std::future::pending::<()>().await;
1399 }
1400 }
1401
1402 async fn snapshot(&self) -> Value {
1403 self.cache.snapshot().await
1408 }
1409}
1410
1411struct TreeSnapshotCache {
1435 registry: Arc<WorktreesRegistry>,
1438 pr_cache: Arc<PrStatusCache>,
1441 ttl: Duration,
1445 state: AsyncMutex<Option<CachedTree>>,
1450 computes: AtomicU64,
1454}
1455
1456struct CachedTree {
1459 generation: u64,
1463 computed_at: Instant,
1465 value: Arc<Value>,
1468}
1469
1470impl TreeSnapshotCache {
1471 fn new(registry: Arc<WorktreesRegistry>, pr_cache: Arc<PrStatusCache>) -> Self {
1475 Self::with_ttl(registry, pr_cache, crate::daemon::server::stream_tick())
1476 }
1477
1478 fn with_ttl(
1481 registry: Arc<WorktreesRegistry>,
1482 pr_cache: Arc<PrStatusCache>,
1483 ttl: Duration,
1484 ) -> Self {
1485 Self {
1486 registry,
1487 pr_cache,
1488 ttl,
1489 state: AsyncMutex::new(None),
1490 computes: AtomicU64::new(0),
1491 }
1492 }
1493
1494 async fn snapshot(&self) -> Value {
1498 let mut state = self.state.lock().await;
1503 let generation = self.registry.change_generation();
1504 let fresh = state.as_ref().and_then(|cached| {
1507 (cached.generation == generation && cached.computed_at.elapsed() < self.ttl)
1508 .then(|| Arc::clone(&cached.value))
1509 });
1510 let value = if let Some(value) = fresh {
1511 value
1512 } else {
1513 let value = Arc::new(tree_snapshot(&self.registry, self.pr_cache.clone()).await);
1514 self.computes.fetch_add(1, Ordering::Relaxed);
1515 *state = Some(CachedTree {
1516 generation,
1517 computed_at: Instant::now(),
1518 value: Arc::clone(&value),
1519 });
1520 value
1521 };
1522 drop(state);
1524 (*value).clone()
1525 }
1526
1527 #[cfg(test)]
1530 fn compute_count(&self) -> u64 {
1531 self.computes.load(Ordering::Relaxed)
1532 }
1533}
1534
1535async fn tree_snapshot(registry: &WorktreesRegistry, pr_cache: Arc<PrStatusCache>) -> Value {
1541 let folders = registry.open_folders();
1542 let windows = registry.list();
1543 let show_closed = registry.show_closed();
1544 json!({
1545 "repos": tree_repos(folders, windows, pr_cache).await,
1546 "show_closed": show_closed,
1547 })
1548}
1549
1550fn display_name(entry: &WindowEntry) -> String {
1553 if let Some(repo) = &entry.repo {
1554 return repo.clone();
1555 }
1556 if let Some(folder) = entry.folders.first() {
1557 return folder.file_name().map_or_else(
1558 || folder.display().to_string(),
1559 |n| n.to_string_lossy().into_owned(),
1560 );
1561 }
1562 "(no folder)".to_string()
1563}
1564
1565const REPO_SEP: char = '·';
1567const WORKTREE_SEP: char = '⑂';
1570
1571fn menu_items_for(entries: &[WindowEntry]) -> Vec<MenuItem> {
1576 if entries.is_empty() {
1577 vec![MenuItem::Label("No open windows".to_string())]
1578 } else {
1579 window_menu_items(entries)
1580 }
1581}
1582
1583fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
1590 entries
1591 .iter()
1592 .map(|entry| {
1593 let label = window_label(entry);
1594 if entry.folders.is_empty() {
1595 MenuItem::Label(label)
1596 } else {
1597 MenuItem::Action(MenuAction {
1598 id: format!("focus:{}", entry.key),
1599 label,
1600 enabled: true,
1601 })
1602 }
1603 })
1604 .collect()
1605}
1606
1607fn window_label(entry: &WindowEntry) -> String {
1613 let status = entry
1614 .folders
1615 .first()
1616 .map(|folder| git_status(folder))
1617 .unwrap_or_default();
1618 let name = status
1621 .main_repo
1622 .clone()
1623 .unwrap_or_else(|| display_name(entry));
1624 if let Some(branch) = &status.branch {
1625 let sep = if status.is_worktree {
1626 WORKTREE_SEP
1627 } else {
1628 REPO_SEP
1629 };
1630 return match sync_indicator(status.ahead, status.behind) {
1631 Some(sync) => format!("{name} {sep} {branch} {sync}"),
1632 None => format!("{name} {sep} {branch}"),
1633 };
1634 }
1635 match &entry.title {
1637 Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
1638 _ => name,
1639 }
1640}
1641
1642fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
1645 match (ahead, behind) {
1646 (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
1647 _ => None,
1648 }
1649}
1650
1651const CODE_BINARY_CANDIDATES: &[&str] = &[
1654 "/usr/local/bin/code",
1655 "/opt/homebrew/bin/code",
1656 "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
1657 "/usr/bin/code",
1658];
1659
1660pub(crate) fn focus_window(folder: &Path) -> Result<()> {
1665 focus_window_with(&resolve_code_binary(), folder)
1666}
1667
1668fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
1675 if !folder.is_absolute() {
1680 bail!(
1681 "refusing to focus a non-absolute folder path: {}",
1682 folder.display()
1683 );
1684 }
1685 if !folder.is_dir() {
1686 bail!("worktree folder no longer exists: {}", folder.display());
1687 }
1688 let child = Command::new(program)
1691 .arg(folder)
1692 .stdin(Stdio::null())
1693 .stdout(Stdio::null())
1694 .stderr(Stdio::null())
1695 .spawn()
1696 .with_context(|| {
1697 format!(
1698 "failed to launch `{}` to focus {}",
1699 program.display(),
1700 folder.display()
1701 )
1702 })?;
1703 std::thread::spawn(move || {
1705 let mut child = child;
1706 let _ = child.wait();
1707 });
1708 Ok(())
1709}
1710
1711fn resolve_code_binary() -> PathBuf {
1716 resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
1717}
1718
1719fn resolve_code_binary_from(
1722 env_override: Option<std::ffi::OsString>,
1723 candidates: &[&str],
1724) -> PathBuf {
1725 if let Some(path) = env_override {
1726 return PathBuf::from(path);
1727 }
1728 for candidate in candidates {
1729 let path = Path::new(candidate);
1730 if path.exists() {
1731 return path.to_path_buf();
1732 }
1733 }
1734 PathBuf::from("code")
1735}
1736
1737#[derive(Debug, Clone, Deserialize)]
1743struct CloseRequest {
1744 path: PathBuf,
1746 #[serde(default)]
1750 requester_key: Option<String>,
1751 #[serde(default)]
1755 remove: bool,
1756 #[serde(default)]
1759 confirmed: bool,
1760}
1761
1762#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1767struct Note {
1768 kind: String,
1770 detail: String,
1772}
1773
1774impl Note {
1775 fn new(kind: &str, detail: impl Into<String>) -> Self {
1776 Self {
1777 kind: kind.to_string(),
1778 detail: detail.into(),
1779 }
1780 }
1781}
1782
1783#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1787struct SafetyReport {
1788 removable: bool,
1791 is_main: bool,
1793 open: bool,
1795 #[serde(skip_serializing_if = "Option::is_none")]
1797 window_key: Option<String>,
1798 window_folder_count: usize,
1801 risks: Vec<Note>,
1804 info: Vec<Note>,
1807}
1808
1809#[derive(Debug, Clone, PartialEq, Eq)]
1812struct GitSafety {
1813 is_main: bool,
1814 removable: bool,
1815 risks: Vec<Note>,
1816 info: Vec<Note>,
1817}
1818
1819fn windows_with_path(entries: &[WindowEntry], path: &Path) -> Vec<(String, usize)> {
1823 let target = canonical(path);
1824 entries
1825 .iter()
1826 .filter(|e| e.folders.iter().any(|f| canonical(f) == target))
1827 .map(|e| (e.key.clone(), e.folders.len()))
1828 .collect()
1829}
1830
1831const CLOSE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
1838
1839const CLOSE_WAIT_POLL: Duration = Duration::from_millis(250);
1842
1843async fn await_windows_closed(
1850 registry: &WorktreesRegistry,
1851 path: &Path,
1852 requester: Option<&str>,
1853 timeout: Duration,
1854 poll: Duration,
1855) -> Result<()> {
1856 let deadline = std::time::Instant::now() + timeout;
1857 loop {
1858 let entries = registry.list();
1862 let path = path.to_path_buf();
1863 let requester = requester.map(str::to_string);
1864 let remaining: Vec<String> = tokio::task::spawn_blocking(move || {
1865 windows_with_path(&entries, &path)
1866 .into_iter()
1867 .map(|(k, _)| k)
1868 .filter(|k| requester.as_deref() != Some(k))
1869 .collect()
1870 })
1871 .await
1872 .unwrap_or_default();
1873
1874 if remaining.is_empty() {
1875 return Ok(());
1876 }
1877 if std::time::Instant::now() >= deadline {
1878 bail!("window(s) did not close in time: {}", remaining.join(", "));
1879 }
1880 tokio::time::sleep(poll).await;
1881 }
1882}
1883
1884fn git_safety(path: &Path) -> Result<GitSafety> {
1891 if !path.exists() {
1892 return Ok(GitSafety {
1893 is_main: false,
1894 removable: true,
1895 risks: vec![],
1896 info: vec![Note::new("already-removed", "worktree no longer exists")],
1897 });
1898 }
1899 let repo = Repository::open(path)
1900 .with_context(|| format!("not a git worktree: {}", path.display()))?;
1901 if !repo.is_worktree() {
1903 return Ok(GitSafety {
1904 is_main: true,
1905 removable: false,
1906 risks: vec![],
1907 info: vec![Note::new(
1908 "main-working-tree",
1909 "the repository's main working tree is never deleted",
1910 )],
1911 });
1912 }
1913
1914 let mut risks = Vec::new();
1915 let mut info = Vec::new();
1916
1917 let (dirty, untracked) = count_dirty_untracked(&repo);
1918 if dirty > 0 {
1919 risks.push(Note::new(
1920 "dirty",
1921 format!("{dirty} modified tracked file(s) would be lost"),
1922 ));
1923 }
1924 if untracked > 0 {
1925 risks.push(Note::new(
1926 "untracked",
1927 format!("{untracked} untracked file(s) would be lost"),
1928 ));
1929 }
1930
1931 let state = repo.state();
1933 if state != RepositoryState::Clean {
1934 risks.push(Note::new(
1935 "in-progress",
1936 format!("an in-progress {state:?} operation would be lost"),
1937 ));
1938 }
1939
1940 if repo.head_detached().unwrap_or(false) {
1944 let lost = unreachable_commit_count(&repo).unwrap_or(0);
1945 if lost > 0 {
1946 risks.push(Note::new(
1947 "unreachable-commits",
1948 format!("{lost} commit(s) on a detached HEAD will be permanently lost"),
1949 ));
1950 }
1951 }
1952
1953 if let Some(ahead) = current_branch_ahead(&repo) {
1956 if ahead > 0 {
1957 info.push(Note::new(
1958 "unpushed",
1959 format!("{ahead} unpushed commit(s) on the branch (kept — the branch survives)"),
1960 ));
1961 }
1962 }
1963
1964 Ok(GitSafety {
1965 is_main: false,
1966 removable: true,
1967 risks,
1968 info,
1969 })
1970}
1971
1972fn count_dirty_untracked(repo: &Repository) -> (usize, usize) {
1979 let mut opts = StatusOptions::new();
1980 opts.include_untracked(true)
1981 .recurse_untracked_dirs(true)
1982 .include_ignored(false)
1983 .exclude_submodules(true);
1984 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
1985 return (0, 0);
1986 };
1987 let tracked = Status::INDEX_NEW
1990 | Status::INDEX_MODIFIED
1991 | Status::INDEX_DELETED
1992 | Status::INDEX_RENAMED
1993 | Status::INDEX_TYPECHANGE
1994 | Status::WT_MODIFIED
1995 | Status::WT_DELETED
1996 | Status::WT_TYPECHANGE
1997 | Status::WT_RENAMED
1998 | Status::CONFLICTED;
1999 let mut dirty = 0;
2000 let mut untracked = 0;
2001 for entry in statuses.iter() {
2002 let s = entry.status();
2003 if s.contains(Status::WT_NEW) {
2004 untracked += 1;
2005 }
2006 if s.intersects(tracked) {
2007 dirty += 1;
2008 }
2009 }
2010 (dirty, untracked)
2011}
2012
2013fn unreachable_commit_count(repo: &Repository) -> Option<usize> {
2020 let head_oid = repo.head().ok()?.target()?;
2021 let mut walk = repo.revwalk().ok()?;
2022 walk.push(head_oid).ok()?;
2023 for reference in repo.references().ok()? {
2024 let Ok(reference) = reference else { continue };
2025 if matches!(reference.name(), Ok("HEAD")) {
2028 continue;
2029 }
2030 if let Some(oid) = reference.target() {
2031 let _ = walk.hide(oid);
2032 }
2033 }
2034 Some(walk.flatten().count())
2035}
2036
2037fn current_branch_ahead(repo: &Repository) -> Option<usize> {
2041 let head = repo.head().ok()?;
2042 if !head.is_branch() {
2043 return None;
2044 }
2045 let branch = git2::Branch::wrap(head);
2046 upstream_ahead_behind(repo, &branch).map(|(ahead, _behind)| ahead)
2047}
2048
2049fn worktree_name_for_path(main_repo: &Repository, target: &Path) -> Result<String> {
2055 let names = main_repo.worktrees()?;
2056 names
2057 .iter()
2058 .flatten() .flatten() .find(|name| {
2061 main_repo
2062 .find_worktree(name)
2063 .is_ok_and(|wt| canonical(wt.path()) == target)
2064 })
2065 .map(str::to_string)
2066 .ok_or_else(|| {
2067 anyhow!(
2068 "worktree {} is not registered in {}",
2069 target.display(),
2070 main_repo.path().display()
2071 )
2072 })
2073}
2074
2075const WORKTREE_RMDIR_BACKOFF: &[Duration] = &[
2084 Duration::from_millis(250),
2085 Duration::from_millis(500),
2086 Duration::from_secs(1),
2087 Duration::from_secs(1),
2088];
2089
2090fn is_transient_rmdir_error(e: &std::io::Error) -> bool {
2096 matches!(
2097 e.raw_os_error(),
2098 Some(nix::libc::ENOTEMPTY | nix::libc::EEXIST | nix::libc::EBUSY)
2099 )
2100}
2101
2102fn remove_dir_all_retrying(dir: &Path) -> Result<()> {
2108 remove_dir_all_retrying_with(dir, WORKTREE_RMDIR_BACKOFF, || std::fs::remove_dir_all(dir))
2109}
2110
2111fn remove_dir_all_retrying_with(
2117 dir: &Path,
2118 backoff: &[Duration],
2119 mut remove: impl FnMut() -> std::io::Result<()>,
2120) -> Result<()> {
2121 let mut backoff = backoff.iter();
2122 loop {
2123 match remove() {
2124 Ok(()) => return Ok(()),
2125 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
2126 Err(e) => {
2127 if is_transient_rmdir_error(&e) {
2128 if let Some(delay) = backoff.next() {
2129 std::thread::sleep(*delay);
2130 continue;
2131 }
2132 }
2133 return Err(e).with_context(|| {
2134 format!("failed to remove worktree directory {}", dir.display())
2135 });
2136 }
2137 }
2138 }
2139}
2140
2141fn is_orphaned_worktree(path: &Path) -> bool {
2150 let Ok(contents) = std::fs::read_to_string(path.join(".git")) else {
2153 return false;
2154 };
2155 let Some(admin) = contents.strip_prefix("gitdir:").map(str::trim) else {
2156 return false;
2157 };
2158 let admin = Path::new(admin);
2159 admin.components().any(|c| c.as_os_str() == "worktrees") && !admin.exists()
2161}
2162
2163fn remove_worktree(path: &Path) -> Result<()> {
2180 if !path.exists() {
2181 return Ok(());
2182 }
2183 let repo = match Repository::open(path) {
2184 Ok(repo) => repo,
2185 Err(_) if is_orphaned_worktree(path) => return remove_dir_all_retrying(path),
2188 Err(e) => return Err(e).context(format!("not a git worktree: {}", path.display())),
2189 };
2190 if !repo.is_worktree() {
2191 bail!(
2192 "refusing to delete the main working tree: {}",
2193 path.display()
2194 );
2195 }
2196 let commondir = canonical(repo.commondir());
2199 let main_root = commondir
2200 .parent()
2201 .ok_or_else(|| anyhow!("no repository root for {}", path.display()))?
2202 .to_path_buf();
2203 drop(repo);
2205 let main_repo = Repository::open(&main_root)
2206 .with_context(|| format!("failed to open repository at {}", main_root.display()))?;
2207 let name = worktree_name_for_path(&main_repo, &canonical(path))?;
2208 let worktree = main_repo.find_worktree(&name)?;
2209
2210 if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
2212 let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
2213 bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
2214 }
2215
2216 remove_dir_all_retrying(path)?;
2219
2220 let mut opts = git2::WorktreePruneOptions::new();
2225 opts.valid(true).working_tree(false);
2226 worktree
2227 .prune(Some(&mut opts))
2228 .with_context(|| format!("failed to prune worktree metadata for {}", path.display()))?;
2229 Ok(())
2230}
2231
2232#[cfg(test)]
2233#[allow(clippy::unwrap_used, clippy::expect_used)]
2234mod tests {
2235 use super::*;
2236 use crate::test_support::shim::{shim_lock, write_exec_script};
2237 use chrono::Utc;
2238 use std::sync::MutexGuard;
2239
2240 fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
2241 json!({
2242 "key": key,
2243 "folders": [folder],
2244 "repo": repo,
2245 "title": format!("{key}-title"),
2246 "pid": 1234,
2247 })
2248 }
2249
2250 fn windows_of(payload: &Value) -> &Vec<Value> {
2252 payload
2253 .get("windows")
2254 .and_then(Value::as_array)
2255 .expect("windows array")
2256 }
2257
2258 #[tokio::test]
2259 async fn name_and_unknown_op() {
2260 let svc = WorktreesService::new();
2261 assert_eq!(svc.name(), "worktrees");
2262 assert!(svc.handle("frobnicate", Value::Null).await.is_err());
2263 }
2264
2265 #[tokio::test]
2266 async fn handle_routes_ops_and_shapes_payloads() {
2267 let svc = WorktreesService::new();
2268 let payload = svc.handle("list", Value::Null).await.unwrap();
2270 assert_eq!(payload, json!({ "windows": [] }));
2271
2272 let reply = svc
2274 .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
2275 .await
2276 .unwrap();
2277 assert_eq!(reply, json!({ "ok": true }));
2278 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
2279 assert_eq!(windows.len(), 1);
2280 assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
2281 assert!(windows[0].get("last_seen").is_some());
2282
2283 let known = svc
2285 .handle("heartbeat", json!({ "key": "w1" }))
2286 .await
2287 .unwrap();
2288 assert_eq!(known, json!({ "known": true }));
2289 let unknown = svc
2290 .handle("heartbeat", json!({ "key": "nope" }))
2291 .await
2292 .unwrap();
2293 assert_eq!(unknown, json!({ "known": false }));
2294
2295 let gone = svc
2297 .handle("unregister", json!({ "key": "w1" }))
2298 .await
2299 .unwrap();
2300 assert_eq!(gone, json!({ "removed": true }));
2301 let again = svc
2302 .handle("unregister", json!({ "key": "w1" }))
2303 .await
2304 .unwrap();
2305 assert_eq!(again, json!({ "removed": false }));
2306 }
2307
2308 #[tokio::test]
2309 async fn handle_rejects_missing_or_empty_key() {
2310 let svc = WorktreesService::new();
2311 assert!(svc.handle("register", json!({})).await.is_err());
2313 assert!(svc
2314 .handle("register", json!({ "key": " " }))
2315 .await
2316 .is_err());
2317 assert!(svc.handle("heartbeat", json!({})).await.is_err());
2319 assert!(svc.handle("unregister", json!({})).await.is_err());
2320 }
2321
2322 #[test]
2323 fn display_name_prefers_repo_then_folder_basename() {
2324 let base = WindowEntry {
2325 key: "k".to_string(),
2326 folders: vec![PathBuf::from("/home/me/project")],
2327 repo: Some("my-repo".to_string()),
2328 title: None,
2329 pid: None,
2330 last_seen: Utc::now(),
2331 };
2332 assert_eq!(display_name(&base), "my-repo");
2333
2334 let no_repo = WindowEntry {
2335 repo: None,
2336 ..base.clone()
2337 };
2338 assert_eq!(display_name(&no_repo), "project");
2339
2340 let nothing = WindowEntry {
2341 repo: None,
2342 folders: vec![],
2343 ..base.clone()
2344 };
2345 assert_eq!(display_name(¬hing), "(no folder)");
2346
2347 let rootish = WindowEntry {
2350 repo: None,
2351 folders: vec![PathBuf::from("/")],
2352 ..base
2353 };
2354 assert_eq!(display_name(&rootish), "/");
2355 }
2356
2357 #[test]
2358 fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
2359 let now = Utc::now();
2360 let entries = vec![
2361 WindowEntry {
2366 key: "k2".to_string(),
2367 folders: vec![],
2368 repo: Some("solo".to_string()),
2369 title: Some("solo".to_string()),
2370 pid: None,
2371 last_seen: now,
2372 },
2373 WindowEntry {
2376 key: "k1".to_string(),
2377 folders: vec![PathBuf::from("/tmp/a")],
2378 repo: Some("repo".to_string()),
2379 title: Some("a branch".to_string()),
2380 pid: None,
2381 last_seen: now,
2382 },
2383 ];
2384 let items = window_menu_items(&entries);
2385 assert_eq!(items.len(), 2);
2387 assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
2388
2389 let action = items
2392 .iter()
2393 .find_map(|i| match i {
2394 MenuItem::Action(a) => Some(a),
2395 _ => None,
2396 })
2397 .expect("a focus action");
2398 assert_eq!(action.id, "focus:k1");
2399 assert_eq!(action.label, "repo · a branch");
2400
2401 let labels: Vec<&str> = items
2403 .iter()
2404 .filter_map(|i| match i {
2405 MenuItem::Label(t) => Some(t.as_str()),
2406 _ => None,
2407 })
2408 .collect();
2409 assert_eq!(labels, vec!["solo"]);
2410 }
2411
2412 #[tokio::test]
2413 async fn menu_and_status_shapes() {
2414 let svc = WorktreesService::new();
2415 let menu = svc.menu();
2417 assert_eq!(menu.title, "Worktrees");
2418 assert!(matches!(
2419 menu.items.first(),
2420 Some(MenuItem::Label(text)) if text == "No open windows"
2421 ));
2422 let status = svc.status().await;
2423 assert_eq!(status.name, "worktrees");
2424 assert!(status.healthy);
2425 assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
2426
2427 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
2430 .await
2431 .unwrap();
2432 svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
2433 .await
2434 .unwrap();
2435 svc.handle(
2436 "register",
2437 json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
2438 )
2439 .await
2440 .unwrap();
2441 let status = svc.status().await;
2442 assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
2443
2444 let menu = svc.menu();
2445 assert_eq!(menu.items.len(), 3);
2447 assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
2448 let action_ids: Vec<&str> = menu
2449 .items
2450 .iter()
2451 .filter_map(|i| match i {
2452 MenuItem::Action(a) => Some(a.id.as_str()),
2453 _ => None,
2454 })
2455 .collect();
2456 assert!(action_ids.contains(&"focus:w1"));
2459 assert!(action_ids.contains(&"focus:w2"));
2460 assert!(!action_ids.contains(&"focus:w3"));
2461 }
2462
2463 #[test]
2464 fn start_menu_refresh_is_a_noop_outside_a_runtime() {
2465 let svc = WorktreesService::new();
2468 svc.start_menu_refresh();
2469 assert!(svc.refresh.lock().unwrap().is_none());
2470 }
2471
2472 #[tokio::test]
2473 async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
2474 let svc = WorktreesService::new();
2475 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
2476 .await
2477 .unwrap();
2478 assert!(svc.menu_cache.lock().unwrap().is_none());
2480
2481 svc.start_menu_refresh();
2482 svc.start_menu_refresh();
2484
2485 let mut filled = false;
2487 for _ in 0..100 {
2488 if svc.menu_cache.lock().unwrap().is_some() {
2489 filled = true;
2490 break;
2491 }
2492 tokio::time::sleep(Duration::from_millis(10)).await;
2493 }
2494 assert!(filled, "background refresh should populate the menu cache");
2495
2496 let menu = svc.menu();
2498 assert_eq!(menu.title, "Worktrees");
2499 assert!(menu
2500 .items
2501 .iter()
2502 .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
2503
2504 svc.shutdown().await;
2506 assert!(svc.refresh.lock().unwrap().is_none());
2507 }
2508
2509 #[tokio::test]
2510 async fn default_constructs_an_empty_service() {
2511 let svc = WorktreesService::default();
2512 let payload = svc.handle("list", Value::Null).await.unwrap();
2513 assert_eq!(payload, json!({ "windows": [] }));
2514 }
2515
2516 #[tokio::test]
2519 async fn subscribe_streams_only_for_the_subscribe_op() {
2520 let svc = WorktreesService::new();
2521 assert!(svc.subscribe("subscribe", &Value::Null).is_some());
2525 assert!(svc.subscribe("list", &Value::Null).is_none());
2526 assert!(svc.subscribe("register", &Value::Null).is_none());
2527 assert!(svc.subscribe("bogus", &Value::Null).is_none());
2528 }
2529
2530 #[tokio::test]
2531 async fn subscribe_snapshot_matches_the_tree_op() {
2532 let dir = tempfile::tempdir().unwrap();
2533 let repo = init_repo(dir.path());
2534 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2535 repo.set_head("refs/heads/main").unwrap();
2536
2537 let svc = WorktreesService::new();
2538 let stream = svc
2539 .subscribe("subscribe", &Value::Null)
2540 .expect("subscribe stream");
2541 assert_eq!(
2544 stream.snapshot().await,
2545 json!({ "repos": [], "show_closed": true })
2546 );
2547
2548 svc.handle(
2551 "register",
2552 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
2553 )
2554 .await
2555 .unwrap();
2556 let snap = stream.snapshot().await;
2557 let tree = svc.handle("tree", Value::Null).await.unwrap();
2558 assert_eq!(snap, tree);
2559 let repos = snap["repos"].as_array().expect("repos array");
2560 assert_eq!(repos.len(), 1);
2561 assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
2562 }
2563
2564 #[tokio::test]
2565 async fn subscribe_changed_wakes_on_register() {
2566 let svc = WorktreesService::new();
2567 let mut stream = svc
2568 .subscribe("subscribe", &Value::Null)
2569 .expect("subscribe stream");
2570 tokio::select! {
2572 () = stream.changed() => panic!("changed resolved with no registry change"),
2573 () = tokio::time::sleep(Duration::from_millis(50)) => {}
2574 }
2575 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
2577 .await
2578 .unwrap();
2579 tokio::time::timeout(Duration::from_secs(1), stream.changed())
2580 .await
2581 .expect("changed should resolve after a register");
2582 }
2583
2584 #[tokio::test]
2587 async fn tree_cache_coalesces_reads_within_ttl_and_generation() {
2588 let reg = Arc::new(WorktreesRegistry::new());
2589 let cache = TreeSnapshotCache::with_ttl(
2591 reg,
2592 Arc::new(PrStatusCache::new()),
2593 Duration::from_secs(60),
2594 );
2595 let first = cache.snapshot().await;
2597 assert_eq!(cache.compute_count(), 1);
2598 let second = cache.snapshot().await;
2601 assert_eq!(
2602 cache.compute_count(),
2603 1,
2604 "an unchanged read must not rebuild"
2605 );
2606 assert_eq!(first, second);
2607 }
2608
2609 #[tokio::test]
2610 async fn tree_cache_single_flights_a_read_burst() {
2611 let reg = Arc::new(WorktreesRegistry::new());
2612 let cache = Arc::new(TreeSnapshotCache::with_ttl(
2613 reg,
2614 Arc::new(PrStatusCache::new()),
2615 Duration::from_secs(60),
2616 ));
2617 let mut handles = Vec::new();
2621 for _ in 0..16 {
2622 let cache = cache.clone();
2623 handles.push(tokio::spawn(async move { cache.snapshot().await }));
2624 }
2625 let mut results = Vec::new();
2626 for handle in handles {
2627 results.push(handle.await.unwrap());
2628 }
2629 assert_eq!(
2630 cache.compute_count(),
2631 1,
2632 "a concurrent read burst must build the tree once"
2633 );
2634 assert!(
2635 results.windows(2).all(|w| w[0] == w[1]),
2636 "every reader must observe the identical snapshot"
2637 );
2638 }
2639
2640 #[tokio::test]
2641 async fn tree_cache_rebuilds_on_registry_change() {
2642 let reg = Arc::new(WorktreesRegistry::new());
2643 let cache = TreeSnapshotCache::with_ttl(
2644 reg.clone(),
2645 Arc::new(PrStatusCache::new()),
2646 Duration::from_secs(60),
2647 );
2648 cache.snapshot().await;
2649 assert_eq!(cache.compute_count(), 1);
2650 assert!(reg.set_show_closed(false));
2654 cache.snapshot().await;
2655 assert_eq!(
2656 cache.compute_count(),
2657 2,
2658 "a generation bump must force a rebuild"
2659 );
2660 }
2661
2662 #[tokio::test]
2663 async fn tree_cache_rebuilds_after_ttl_expiry() {
2664 let reg = Arc::new(WorktreesRegistry::new());
2665 let cache =
2668 TreeSnapshotCache::with_ttl(reg, Arc::new(PrStatusCache::new()), Duration::ZERO);
2669 cache.snapshot().await;
2670 cache.snapshot().await;
2671 assert_eq!(
2672 cache.compute_count(),
2673 2,
2674 "an expired TTL must force a rebuild each read"
2675 );
2676 }
2677
2678 #[tokio::test]
2679 async fn subscribe_streams_share_one_build_per_generation() {
2680 let svc = WorktreesService::new();
2681 let s1 = svc
2682 .subscribe("subscribe", &Value::Null)
2683 .expect("subscribe stream");
2684 let s2 = svc
2685 .subscribe("subscribe", &Value::Null)
2686 .expect("subscribe stream");
2687 let a = s1.snapshot().await;
2690 let b = s2.snapshot().await;
2691 assert_eq!(a, b);
2692 assert_eq!(
2693 svc.tree_cache.compute_count(),
2694 1,
2695 "N streams on one generation must share a single build"
2696 );
2697 }
2698
2699 #[tokio::test]
2702 async fn set_show_closed_toggles_the_snapshot_field() {
2703 let svc = WorktreesService::new();
2704 assert_eq!(
2706 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
2707 json!(true)
2708 );
2709 let reply = svc
2711 .handle("set-show-closed", json!({ "show_closed": false }))
2712 .await
2713 .unwrap();
2714 assert_eq!(reply, json!({ "ok": true }));
2715 assert_eq!(
2716 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
2717 json!(false)
2718 );
2719 }
2720
2721 #[tokio::test]
2722 async fn set_show_closed_rejects_a_non_boolean_payload() {
2723 let svc = WorktreesService::new();
2724 assert!(svc.handle("set-show-closed", json!({})).await.is_err());
2725 assert!(svc
2726 .handle("set-show-closed", json!({ "show_closed": "yes" }))
2727 .await
2728 .is_err());
2729 }
2730
2731 #[tokio::test]
2732 async fn set_show_closed_wakes_the_subscription() {
2733 let svc = WorktreesService::new();
2734 let mut stream = svc
2735 .subscribe("subscribe", &Value::Null)
2736 .expect("subscribe stream");
2737 svc.handle("set-show-closed", json!({ "show_closed": false }))
2739 .await
2740 .unwrap();
2741 tokio::time::timeout(Duration::from_secs(1), stream.changed())
2742 .await
2743 .expect("changed should resolve after a toggle flip");
2744 assert_eq!(stream.snapshot().await["show_closed"], json!(false));
2746 }
2747
2748 #[tokio::test]
2749 async fn menu_action_rejects_unknown_and_missing_window() {
2750 let svc = WorktreesService::new();
2751 assert!(svc.menu_action("bogus").await.is_err());
2752 assert!(svc.menu_action("focus:nope").await.is_err());
2754 svc.shutdown().await;
2755 }
2756
2757 struct VscodeBinGuard(Option<std::ffi::OsString>);
2764 impl Drop for VscodeBinGuard {
2765 fn drop(&mut self) {
2766 match self.0.take() {
2767 Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
2768 None => std::env::remove_var(VSCODE_BIN_ENV),
2769 }
2770 }
2771 }
2772
2773 #[tokio::test]
2774 async fn menu_action_focus_resolves_folder_and_spawns() {
2775 let dir = tempfile::tempdir().unwrap();
2776 let svc = WorktreesService::new();
2777 svc.handle(
2778 "register",
2779 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
2780 )
2781 .await
2782 .unwrap();
2783
2784 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
2787 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
2788 svc.menu_action("focus:w1").await.unwrap();
2789 }
2790
2791 #[tokio::test]
2792 async fn open_rejects_missing_relative_or_nonexistent_path() {
2793 let svc = WorktreesService::new();
2794 assert!(svc.handle("open", json!({})).await.is_err());
2796 assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
2797 assert!(svc
2800 .handle("open", json!({ "path": "relative/dir" }))
2801 .await
2802 .is_err());
2803 assert!(svc
2804 .handle("open", json!({ "path": "-flag" }))
2805 .await
2806 .is_err());
2807 assert!(svc
2810 .handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
2811 .await
2812 .is_err());
2813 svc.shutdown().await;
2814 }
2815
2816 #[tokio::test]
2817 async fn open_focuses_an_existing_absolute_dir() {
2818 let dir = tempfile::tempdir().unwrap();
2819 let svc = WorktreesService::new();
2820 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
2825 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
2826 let reply = svc
2827 .handle("open", json!({ "path": dir.path() }))
2828 .await
2829 .unwrap();
2830 assert_eq!(reply, json!({ "ok": true }));
2831 svc.shutdown().await;
2832 }
2833
2834 #[test]
2835 fn focus_window_with_validates_folder_then_spawns() {
2836 let dir = tempfile::tempdir().unwrap();
2837 assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
2839 assert!(
2840 focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
2841 );
2842 focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
2844 assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
2846 }
2847
2848 #[test]
2849 fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
2850 assert_eq!(
2852 resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
2853 PathBuf::from("/custom/code")
2854 );
2855 let existing = tempfile::NamedTempFile::new().unwrap();
2857 let existing_path = existing.path().to_str().unwrap();
2858 assert_eq!(
2859 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
2860 PathBuf::from(existing_path)
2861 );
2862 assert_eq!(
2864 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
2865 PathBuf::from("code")
2866 );
2867 let _ = resolve_code_binary();
2869 }
2870
2871 fn init_repo(dir: &Path) -> Repository {
2876 let repo = Repository::init(dir).unwrap();
2877 let mut cfg = repo.config().unwrap();
2878 cfg.set_str("user.name", "Test").unwrap();
2879 cfg.set_str("user.email", "test@example.com").unwrap();
2880 repo
2881 }
2882
2883 fn empty_commit(
2886 repo: &Repository,
2887 refname: Option<&str>,
2888 parents: &[&git2::Commit<'_>],
2889 msg: &str,
2890 ) -> git2::Oid {
2891 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
2892 let tree = repo
2893 .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
2894 .unwrap();
2895 repo.commit(refname, &sig, &sig, msg, &tree, parents)
2896 .unwrap()
2897 }
2898
2899 fn commit_file(
2904 repo: &Repository,
2905 refname: &str,
2906 name: &str,
2907 content: &[u8],
2908 msg: &str,
2909 ) -> git2::Oid {
2910 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
2911 let blob = repo.blob(content).unwrap();
2912 let mut builder = repo.treebuilder(None).unwrap();
2913 builder.insert(name, blob, 0o100_644).unwrap();
2914 let tree = repo.find_tree(builder.write().unwrap()).unwrap();
2915 let parent = repo
2916 .refname_to_id(refname)
2917 .ok()
2918 .and_then(|oid| repo.find_commit(oid).ok());
2919 let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
2920 repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
2921 .unwrap()
2922 }
2923
2924 fn diverging_repo(dir: &Path) -> Repository {
2927 let repo = init_repo(dir);
2928 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2930 let a_commit = repo.find_commit(a).unwrap();
2931 let c = empty_commit(&repo, None, &[&a_commit], "C");
2933 repo.reference("refs/remotes/origin/main", c, true, "origin main")
2934 .unwrap();
2935 empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
2937 drop(a_commit);
2939 repo.set_head("refs/heads/main").unwrap();
2940 let mut cfg = repo.config().unwrap();
2942 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
2943 .unwrap();
2944 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
2945 .unwrap();
2946 cfg.set_str("branch.main.remote", "origin").unwrap();
2947 cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
2948 repo
2949 }
2950
2951 #[test]
2952 fn git_status_reads_branch_and_ahead_behind() {
2953 let dir = tempfile::tempdir().unwrap();
2954 let _repo = diverging_repo(dir.path());
2955 let status = git_status(dir.path());
2956 assert_eq!(status.branch.as_deref(), Some("main"));
2957 assert_eq!(status.ahead, Some(1));
2958 assert_eq!(status.behind, Some(1));
2959 assert_eq!(
2961 status.main_repo.as_deref(),
2962 dir.path().file_name().and_then(|n| n.to_str())
2963 );
2964 assert!(!status.is_worktree);
2965 }
2966
2967 #[test]
2968 fn git_status_empty_repo_is_unborn() {
2969 let dir = tempfile::tempdir().unwrap();
2973 init_repo(dir.path());
2974 let status = git_status(dir.path());
2975 assert_eq!(status.branch, None);
2976 assert_eq!(status.head_sha, None);
2978 assert_eq!(status.ahead, None);
2979 assert_eq!(status.behind, None);
2980 assert_eq!(
2981 status.main_repo.as_deref(),
2982 dir.path().file_name().and_then(|n| n.to_str())
2983 );
2984 assert!(!status.is_worktree);
2985 }
2986
2987 #[test]
2988 fn git_status_no_upstream_reports_branch_only() {
2989 let dir = tempfile::tempdir().unwrap();
2990 let repo = init_repo(dir.path());
2991 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2992 repo.set_head("refs/heads/main").unwrap();
2993 let status = git_status(dir.path());
2994 assert_eq!(status.branch.as_deref(), Some("main"));
2995 assert_eq!(status.ahead, None);
2997 assert_eq!(status.behind, None);
2998 assert_eq!(status.upstream_sha, None);
3001 }
3002
3003 #[test]
3004 fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
3005 let plain = tempfile::tempdir().unwrap();
3007 assert_eq!(git_status(plain.path()), GitStatus::default());
3008
3009 let dir = tempfile::tempdir().unwrap();
3012 let repo = init_repo(dir.path());
3013 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
3014 repo.set_head_detached(a).unwrap();
3015 let status = git_status(dir.path());
3016 assert_eq!(status.branch, None);
3017 assert_eq!(status.head_sha.as_deref(), Some(a.to_string().as_str()));
3020 assert_eq!(status.ahead, None);
3021 assert_eq!(status.behind, None);
3022 assert_eq!(status.upstream_sha, None);
3025 assert_eq!(
3026 status.main_repo.as_deref(),
3027 dir.path().file_name().and_then(|n| n.to_str())
3028 );
3029 assert!(!status.is_worktree);
3030 }
3031
3032 #[test]
3035 fn git_status_cheap_reads_branch_but_skips_the_divergence_walk() {
3036 let dir = tempfile::tempdir().unwrap();
3040 let repo = diverging_repo(dir.path());
3041 let status = git_status_cheap(dir.path());
3042 assert_eq!(status.branch.as_deref(), Some("main"));
3043 assert_eq!(status.ahead, None);
3044 assert_eq!(status.behind, None);
3045 assert_eq!(
3046 status.main_repo.as_deref(),
3047 dir.path().file_name().and_then(|n| n.to_str())
3048 );
3049 let head = repo.head().unwrap().target().unwrap();
3052 assert_eq!(status.head_sha.as_deref(), Some(head.to_string().as_str()));
3053 }
3054
3055 #[test]
3058 fn git_status_head_sha_tracks_new_commits() {
3059 let dir = tempfile::tempdir().unwrap();
3065 let repo = init_repo(dir.path());
3066 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
3067 repo.set_head("refs/heads/main").unwrap();
3068 let before = git_status_cheap(dir.path());
3069 assert_eq!(before.head_sha.as_deref(), Some(a.to_string().as_str()));
3070
3071 let head = repo.find_commit(a).unwrap();
3072 let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
3073 let after = git_status_cheap(dir.path());
3074 assert_eq!(after.head_sha.as_deref(), Some(b.to_string().as_str()));
3075 assert_ne!(before.head_sha, after.head_sha);
3076 assert_eq!(before.branch, after.branch);
3079 }
3080
3081 fn simulate_push(repo: &Repository, oid: git2::Oid) {
3087 repo.reference("refs/remotes/origin/main", oid, true, "push")
3088 .unwrap();
3089 }
3090
3091 #[test]
3092 fn git_status_upstream_sha_tracks_a_push() {
3093 let dir = tempfile::tempdir().unwrap();
3100 let repo = diverging_repo(dir.path());
3101 let before = git_status(dir.path());
3102 assert_eq!(before.ahead, Some(1));
3103 assert_eq!(before.behind, Some(1));
3104
3105 let head = repo.head().unwrap().target().unwrap();
3106 simulate_push(&repo, head);
3107 let after = git_status(dir.path());
3108
3109 assert_eq!(
3111 after.upstream_sha.as_deref(),
3112 Some(head.to_string().as_str())
3113 );
3114 assert_ne!(before.upstream_sha, after.upstream_sha);
3115 assert_eq!(after.ahead, Some(0));
3116 assert_eq!(after.behind, Some(0));
3117 assert_eq!(before.branch, after.branch);
3121 assert_eq!(before.head_sha, after.head_sha);
3122 }
3123
3124 #[test]
3125 fn git_status_cheap_reports_upstream_sha() {
3126 let dir = tempfile::tempdir().unwrap();
3131 let repo = diverging_repo(dir.path());
3132 let status = git_status_cheap(dir.path());
3133 let upstream = repo
3134 .find_branch("origin/main", git2::BranchType::Remote)
3135 .unwrap()
3136 .get()
3137 .target()
3138 .unwrap();
3139 assert_eq!(
3140 status.upstream_sha.as_deref(),
3141 Some(upstream.to_string().as_str())
3142 );
3143 assert_eq!(status.ahead, None);
3144 assert_eq!(status.behind, None);
3145 }
3146
3147 #[test]
3148 fn folder_ahead_behind_computes_divergence_and_degrades() {
3149 let dir = tempfile::tempdir().unwrap();
3151 let _repo = diverging_repo(dir.path());
3152 assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
3153
3154 let no_up = tempfile::tempdir().unwrap();
3156 let repo = init_repo(no_up.path());
3157 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
3158 repo.set_head("refs/heads/main").unwrap();
3159 assert_eq!(folder_ahead_behind(no_up.path()), None);
3160
3161 let detached = tempfile::tempdir().unwrap();
3163 let drepo = init_repo(detached.path());
3164 let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
3165 drepo.set_head_detached(a).unwrap();
3166 assert_eq!(folder_ahead_behind(detached.path()), None);
3167 let plain = tempfile::tempdir().unwrap();
3168 assert_eq!(folder_ahead_behind(plain.path()), None);
3169 }
3170
3171 #[tokio::test]
3172 async fn ahead_behind_op_returns_divergence_keyed_by_path_and_omits_no_upstream() {
3173 let diverging = tempfile::tempdir().unwrap();
3174 let _d = diverging_repo(diverging.path());
3175 let no_up = tempfile::tempdir().unwrap();
3176 let repo = init_repo(no_up.path());
3177 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
3178 repo.set_head("refs/heads/main").unwrap();
3179
3180 let svc = WorktreesService::new();
3181 let diverging_path = diverging.path().display().to_string();
3182 let no_up_path = no_up.path().display().to_string();
3183 let reply = svc
3184 .handle(
3185 "ahead-behind",
3186 json!({ "paths": [&diverging_path, &no_up_path] }),
3187 )
3188 .await
3189 .unwrap();
3190 let results = reply.get("results").unwrap();
3191 let d = results.get(diverging_path.as_str()).unwrap();
3193 assert_eq!(d.get("ahead").and_then(Value::as_u64), Some(1));
3194 assert_eq!(d.get("behind").and_then(Value::as_u64), Some(1));
3195 assert!(results.get(no_up_path.as_str()).is_none(), "{results:?}");
3197
3198 let empty = svc.handle("ahead-behind", json!({})).await.unwrap();
3200 assert_eq!(empty.get("results"), Some(&json!({})));
3201 }
3202
3203 #[tokio::test]
3204 async fn tree_snapshot_omits_ahead_behind_for_a_diverging_worktree() {
3205 let dir = tempfile::tempdir().unwrap();
3207 let _repo = diverging_repo(dir.path());
3208 let svc = WorktreesService::new();
3209 svc.handle(
3210 "register",
3211 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
3212 )
3213 .await
3214 .unwrap();
3215
3216 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
3217 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
3218 let main_wt = &worktrees[0];
3219 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
3222 assert!(main_wt.get("ahead").is_none(), "{main_wt:?}");
3223 assert!(main_wt.get("behind").is_none(), "{main_wt:?}");
3224 }
3225
3226 #[tokio::test]
3227 async fn tree_snapshot_carries_head_sha_so_a_commit_is_a_real_delta() {
3228 let dir = tempfile::tempdir().unwrap();
3233 let repo = init_repo(dir.path());
3234 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
3235 repo.set_head("refs/heads/main").unwrap();
3236
3237 let svc = WorktreesService::new();
3238 svc.handle(
3239 "register",
3240 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
3241 )
3242 .await
3243 .unwrap();
3244
3245 let before = svc.handle("tree", Value::Null).await.unwrap();
3246 let wt = &repos_of(&before)[0]["worktrees"][0];
3247 assert_eq!(
3248 wt.get("head_sha").and_then(Value::as_str),
3249 Some(a.to_string().as_str())
3250 );
3251
3252 let head = repo.find_commit(a).unwrap();
3255 let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
3256 let after = svc.handle("tree", Value::Null).await.unwrap();
3257 assert_eq!(
3258 repos_of(&after)[0]["worktrees"][0]
3259 .get("head_sha")
3260 .and_then(Value::as_str),
3261 Some(b.to_string().as_str())
3262 );
3263 assert_ne!(before, after, "a commit must be a visible snapshot delta");
3264 }
3265
3266 #[tokio::test]
3267 async fn tree_snapshot_omits_head_sha_for_an_unborn_repo() {
3268 let dir = tempfile::tempdir().unwrap();
3271 init_repo(dir.path());
3272 let svc = WorktreesService::new();
3273 svc.handle(
3274 "register",
3275 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
3276 )
3277 .await
3278 .unwrap();
3279 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3280 assert!(wt.get("head_sha").is_none(), "{wt:?}");
3281 }
3282
3283 #[tokio::test]
3286 async fn tree_snapshot_carries_upstream_sha_so_a_push_is_a_real_delta() {
3287 let dir = tempfile::tempdir().unwrap();
3293 let repo = diverging_repo(dir.path());
3294 let svc = WorktreesService::new();
3295 svc.handle(
3296 "register",
3297 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
3298 )
3299 .await
3300 .unwrap();
3301
3302 let before = svc.handle("tree", Value::Null).await.unwrap();
3303 let head = repo.head().unwrap().target().unwrap();
3304 assert_ne!(
3305 repos_of(&before)[0]["worktrees"][0]
3306 .get("upstream_sha")
3307 .and_then(Value::as_str),
3308 Some(head.to_string().as_str()),
3309 "the fixture must start un-pushed for this to prove anything"
3310 );
3311
3312 simulate_push(&repo, head);
3314 let after = svc.handle("tree", Value::Null).await.unwrap();
3315 let wt = &repos_of(&after)[0]["worktrees"][0];
3316 assert_eq!(
3317 wt.get("upstream_sha").and_then(Value::as_str),
3318 Some(head.to_string().as_str())
3319 );
3320 assert_eq!(
3323 wt.get("head_sha").and_then(Value::as_str),
3324 repos_of(&before)[0]["worktrees"][0]
3325 .get("head_sha")
3326 .and_then(Value::as_str)
3327 );
3328 assert_ne!(before, after, "a push must be a visible snapshot delta");
3329 }
3330
3331 #[tokio::test]
3332 async fn tree_snapshot_omits_upstream_sha_without_an_upstream() {
3333 let dir = tempfile::tempdir().unwrap();
3337 let repo = init_repo(dir.path());
3338 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
3339 repo.set_head("refs/heads/main").unwrap();
3340 let svc = WorktreesService::new();
3341 svc.handle(
3342 "register",
3343 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
3344 )
3345 .await
3346 .unwrap();
3347 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3348 assert!(wt.get("upstream_sha").is_none(), "{wt:?}");
3349 assert!(wt.get("head_sha").is_some(), "{wt:?}");
3351 }
3352
3353 fn fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>) {
3363 let guard = shim_lock();
3364 let path = dir.join("fake-gh");
3365 write_exec_script(&path, &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\n"));
3366 (path, guard)
3367 }
3368
3369 fn github_repo(dir: &Path) -> Repository {
3371 let repo = init_repo(dir);
3372 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
3373 repo.set_head("refs/heads/main").unwrap();
3374 repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
3375 .unwrap();
3376 repo
3377 }
3378
3379 fn pending_badge(number: u64, head_oid: &str) -> PrBadge {
3384 PrBadge {
3385 number,
3386 is_draft: false,
3387 checks: PrCheckState::Pending,
3388 url: "u".into(),
3389 head_oid: head_oid.to_string(),
3390 }
3391 }
3392
3393 #[test]
3394 fn pr_targets_from_snapshot_reads_github_branches_and_dedupes() {
3395 let snapshot = json!({"repos":[
3396 {
3397 "main_repo":"omni-dev",
3398 "github":{"owner":"rust-works","name":"omni-dev"},
3399 "root":"/r",
3400 "worktrees":[
3402 {"path":"/r","branch":"main","is_main":true,"open":true},
3403 {"path":"/w1","branch":"main","is_main":false,"open":true},
3404 {"path":"/w2","branch":"feature","is_main":false,"open":true},
3405 {"path":"/w3","is_main":false,"open":true}
3407 ]
3408 },
3409 {
3410 "main_repo":"local","root":"/l",
3412 "worktrees":[{"path":"/l","branch":"main","is_main":true,"open":true}]
3413 }
3414 ]});
3415 let targets = pr_targets_from_snapshot(&snapshot);
3416 assert_eq!(
3417 targets,
3418 vec![
3419 PrTarget {
3420 owner: "rust-works".into(),
3421 name: "omni-dev".into(),
3422 branch: "feature".into()
3423 },
3424 PrTarget {
3425 owner: "rust-works".into(),
3426 name: "omni-dev".into(),
3427 branch: "main".into()
3428 },
3429 ]
3430 );
3431 }
3432
3433 #[test]
3434 fn pr_targets_from_snapshot_is_empty_without_repos() {
3435 assert!(pr_targets_from_snapshot(&json!({"repos":[]})).is_empty());
3436 assert!(pr_targets_from_snapshot(&json!({})).is_empty());
3437 }
3438
3439 #[test]
3440 fn pr_targets_from_snapshot_skips_a_malformed_github_identity() {
3441 for github in [
3444 json!({}),
3445 json!({"owner": "o"}),
3446 json!({"owner": 1, "name": 2}),
3447 ] {
3448 let snapshot = json!({"repos":[{
3449 "main_repo":"r","github":github,"root":"/r",
3450 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
3451 }]});
3452 assert!(
3453 pr_targets_from_snapshot(&snapshot).is_empty(),
3454 "{snapshot:?}"
3455 );
3456 }
3457 }
3458
3459 #[test]
3460 fn pr_should_fetch_on_a_moved_tree_or_an_elapsed_backoff() {
3461 let backoff = Duration::from_secs(600);
3462 assert!(pr_should_fetch(false, None, backoff));
3464 assert!(!pr_should_fetch(
3467 false,
3468 Some(Duration::from_secs(1)),
3469 backoff
3470 ));
3471 assert!(pr_should_fetch(false, Some(backoff), backoff));
3473 assert!(pr_should_fetch(false, Some(backoff * 2), backoff));
3474 assert!(pr_should_fetch(true, Some(Duration::ZERO), backoff));
3478 assert!(pr_should_fetch(
3479 true,
3480 Some(Duration::from_millis(1)),
3481 backoff
3482 ));
3483 }
3484
3485 #[test]
3486 fn next_pr_poll_delay_holds_fast_while_pending_and_backs_off_to_the_ceiling() {
3487 let base = Duration::from_secs(10);
3488 assert_eq!(next_pr_poll_delay(base, base, true), base);
3491 assert_eq!(next_pr_poll_delay(MAX_PR_POLL_INTERVAL, base, true), base);
3492 assert_eq!(next_pr_poll_delay(base, base, false), base * 2);
3494 assert_eq!(next_pr_poll_delay(base * 2, base, false), base * 4);
3495 assert_eq!(
3497 next_pr_poll_delay(MAX_PR_POLL_INTERVAL, base, false),
3498 MAX_PR_POLL_INTERVAL
3499 );
3500 assert_eq!(
3501 next_pr_poll_delay(Duration::MAX, base, false),
3502 MAX_PR_POLL_INTERVAL
3503 );
3504 }
3505
3506 #[tokio::test]
3507 async fn tree_snapshot_folds_cached_pr_badges_onto_matching_branches() {
3508 let dir = tempfile::tempdir().unwrap();
3509 let repo = github_repo(dir.path());
3510 let head = repo.head().unwrap().target().unwrap().to_string();
3511 let svc = WorktreesService::new();
3512 svc.handle(
3513 "register",
3514 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3515 )
3516 .await
3517 .unwrap();
3518
3519 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3521 assert!(wt.get("pr").is_none(), "{wt:?}");
3522
3523 let mut badges = HashMap::new();
3525 badges.insert(
3526 PrTarget {
3527 owner: "rust-works".into(),
3528 name: "omni-dev".into(),
3529 branch: "main".into(),
3530 },
3531 pending_badge(1337, &head),
3532 );
3533 assert!(svc.pr_cache.replace(badges));
3534
3535 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3536 assert_eq!(wt["pr"]["number"], json!(1337));
3537 assert_eq!(wt["pr"]["checks"], json!("pending"));
3538 assert_eq!(wt["pr"]["isDraft"], json!(false));
3540 }
3541
3542 #[tokio::test]
3543 async fn tree_snapshot_omits_a_badge_for_a_detached_worktree() {
3544 let dir = tempfile::tempdir().unwrap();
3549 let repo = github_repo(dir.path());
3550 let head = repo.head().unwrap().target().unwrap();
3551 repo.set_head_detached(head).unwrap();
3552
3553 let svc = WorktreesService::new();
3554 svc.handle(
3555 "register",
3556 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3557 )
3558 .await
3559 .unwrap();
3560 let mut badges = HashMap::new();
3563 badges.insert(
3564 PrTarget {
3565 owner: "rust-works".into(),
3566 name: "omni-dev".into(),
3567 branch: "main".into(),
3568 },
3569 pending_badge(1, &head.to_string()),
3570 );
3571 svc.pr_cache.replace(badges);
3572
3573 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3574 assert!(wt.get("branch").is_none(), "{wt:?}");
3575 assert_eq!(
3577 wt.get("head_sha").and_then(Value::as_str),
3578 Some(head.to_string().as_str())
3579 );
3580 assert!(wt.get("pr").is_none(), "{wt:?}");
3581 }
3582
3583 #[tokio::test]
3584 async fn tree_snapshot_omits_a_badge_for_an_unmatched_branch() {
3585 let dir = tempfile::tempdir().unwrap();
3586 github_repo(dir.path());
3587 let svc = WorktreesService::new();
3588 svc.handle(
3589 "register",
3590 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3591 )
3592 .await
3593 .unwrap();
3594 let mut badges = HashMap::new();
3596 badges.insert(
3597 PrTarget {
3598 owner: "rust-works".into(),
3599 name: "omni-dev".into(),
3600 branch: "other".into(),
3601 },
3602 pending_badge(1, "irrelevant"),
3603 );
3604 svc.pr_cache.replace(badges);
3605 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3606 assert!(wt.get("pr").is_none(), "{wt:?}");
3607 }
3608
3609 #[tokio::test]
3610 async fn pr_poller_asks_nothing_while_no_window_is_registered() {
3611 let bin_dir = tempfile::tempdir().unwrap();
3615 let marker = bin_dir.path().join("spawned");
3616 let fake = bin_dir.path().join("fake-gh");
3617 std::fs::write(
3618 &fake,
3619 format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
3620 )
3621 .unwrap();
3622 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
3623 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
3624 std::fs::set_permissions(&fake, perms).unwrap();
3625
3626 let svc = WorktreesService::new();
3627 svc.start_pr_poller_with(Duration::from_millis(20), fake);
3628 tokio::time::sleep(Duration::from_millis(200)).await;
3629 svc.shutdown().await;
3630 assert!(
3631 !marker.exists(),
3632 "the poller must not spawn gh with no windows registered"
3633 );
3634 }
3635
3636 #[tokio::test]
3637 async fn pr_poller_survives_a_failing_gh_and_keeps_the_last_good_badges() {
3638 let dir = tempfile::tempdir().unwrap();
3641 let repo = github_repo(dir.path());
3642 let head = repo.head().unwrap().target().unwrap().to_string();
3643 let bin_dir = tempfile::tempdir().unwrap();
3644 let fake = bin_dir.path().join("fake-gh");
3645 std::fs::write(
3647 &fake,
3648 "#!/bin/sh\necho 'gh: not authenticated' >&2\nexit 1\n",
3649 )
3650 .unwrap();
3651 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
3652 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
3653 std::fs::set_permissions(&fake, perms).unwrap();
3654
3655 let svc = WorktreesService::new();
3656 svc.handle(
3657 "register",
3658 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3659 )
3660 .await
3661 .unwrap();
3662 let mut seeded = HashMap::new();
3664 seeded.insert(
3665 PrTarget {
3666 owner: "rust-works".into(),
3667 name: "omni-dev".into(),
3668 branch: "main".into(),
3669 },
3670 pending_badge(7, &head),
3671 );
3672 svc.pr_cache.replace(seeded);
3673
3674 svc.start_pr_poller_with(Duration::from_millis(20), fake);
3675 tokio::time::sleep(Duration::from_millis(200)).await;
3676
3677 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3679 assert_eq!(wt["pr"]["number"], json!(7));
3680 svc.shutdown().await;
3681 }
3682
3683 #[tokio::test]
3684 #[allow(clippy::await_holding_lock)]
3691 async fn pr_poller_wakes_when_the_first_window_opens_after_an_idle_start() {
3692 let dir = tempfile::tempdir().unwrap();
3698 github_repo(dir.path());
3699 let bin_dir = tempfile::tempdir().unwrap();
3700 let (fake, _shim) = fake_gh(
3701 bin_dir.path(),
3702 r#"{"data":{"r0":{"b0":{
3703 "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
3704 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
3705 ]}}},
3706 "associatedPullRequests":{"nodes":[{"number":99,"isDraft":false,"url":"u"}]}
3707 }}}}"#,
3708 );
3709
3710 let svc = WorktreesService::new();
3711 svc.start_pr_poller_with(Duration::from_millis(50), fake);
3713 tokio::time::sleep(Duration::from_millis(150)).await;
3714
3715 svc.handle(
3717 "register",
3718 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3719 )
3720 .await
3721 .unwrap();
3722
3723 let badge = tokio::time::timeout(Duration::from_secs(30), async {
3727 loop {
3728 if let Some(badge) = svc.pr_cache.get("rust-works", "omni-dev", "main") {
3729 return badge;
3730 }
3731 tokio::time::sleep(Duration::from_millis(25)).await;
3732 }
3733 })
3734 .await
3735 .expect("a window opening must wake the poller out of its idle backoff");
3736 assert_eq!(badge.number, 99);
3737 svc.shutdown().await;
3738 }
3739
3740 #[tokio::test]
3741 async fn a_commit_invalidates_the_previous_verdict_without_a_poll() {
3742 let dir = tempfile::tempdir().unwrap();
3749 let repo = github_repo(dir.path());
3750 let first = repo.head().unwrap().target().unwrap();
3751
3752 let svc = WorktreesService::new();
3753 svc.handle(
3754 "register",
3755 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3756 )
3757 .await
3758 .unwrap();
3759
3760 let mut badges = HashMap::new();
3762 badges.insert(
3763 PrTarget {
3764 owner: "rust-works".into(),
3765 name: "omni-dev".into(),
3766 branch: "main".into(),
3767 },
3768 PrBadge {
3769 number: 1337,
3770 is_draft: false,
3771 checks: PrCheckState::Success,
3772 url: "u".into(),
3773 head_oid: first.to_string(),
3774 },
3775 );
3776 svc.pr_cache.replace(badges);
3777
3778 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3779 assert_eq!(
3780 wt["pr"]["checks"],
3781 json!("success"),
3782 "green for its own commit"
3783 );
3784
3785 let head = repo.find_commit(first).unwrap();
3787 empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
3788
3789 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3790 assert_eq!(
3791 wt["pr"]["checks"],
3792 json!("pending"),
3793 "the previous commit's ✓ must not stand after a new commit"
3794 );
3795 assert_eq!(wt["pr"]["number"], json!(1337));
3798 }
3799
3800 #[test]
3801 fn is_stale_for_compares_the_commit_the_verdict_describes() {
3802 let badge = pending_badge(1, "aaa");
3803 assert!(!badge.is_stale_for(Some("aaa")));
3804 assert!(badge.is_stale_for(Some("bbb")));
3805 assert!(!badge.is_stale_for(None));
3807 }
3808
3809 #[test]
3810 fn pr_watch_tracks_the_head_so_a_commit_is_visible_to_the_poller() {
3811 let snap = |sha: &str| {
3812 json!({"repos":[{
3813 "main_repo":"omni-dev",
3814 "github":{"owner":"rust-works","name":"omni-dev"},
3815 "root":"/r",
3816 "worktrees":[{"path":"/r","branch":"main","head_sha":sha,"is_main":true,"open":true}]
3817 }]})
3818 };
3819 let before = pr_watch_from_snapshot(&snap("aaa"));
3820 let after = pr_watch_from_snapshot(&snap("bbb"));
3821 assert_eq!(before.len(), 1);
3823 assert_eq!(before[0].target, after[0].target);
3824 assert_ne!(before, after);
3825 assert_eq!(before, pr_watch_from_snapshot(&snap("aaa")));
3827 }
3828
3829 #[test]
3830 fn pr_watch_tracks_the_upstream_so_a_push_is_visible_to_the_poller() {
3831 let snap = |upstream: &str| {
3836 json!({"repos":[{
3837 "main_repo":"omni-dev",
3838 "github":{"owner":"rust-works","name":"omni-dev"},
3839 "root":"/r",
3840 "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa",
3841 "upstream_sha":upstream,"is_main":true,"open":true}]
3842 }]})
3843 };
3844 let before = pr_watch_from_snapshot(&snap("aaa"));
3845 let after = pr_watch_from_snapshot(&snap("bbb"));
3846 assert_eq!(before.len(), 1);
3849 assert_eq!(before[0].target, after[0].target);
3850 assert_eq!(before[0].head_sha, after[0].head_sha);
3851 assert_ne!(before, after);
3852 assert_eq!(before, pr_watch_from_snapshot(&snap("aaa")));
3854 }
3855
3856 #[test]
3857 fn pr_watch_omits_an_absent_upstream_rather_than_erroring() {
3858 let snap = json!({"repos":[{
3861 "main_repo":"omni-dev",
3862 "github":{"owner":"rust-works","name":"omni-dev"},
3863 "root":"/r",
3864 "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa","is_main":true,"open":true}]
3865 }]});
3866 let watch = pr_watch_from_snapshot(&snap);
3867 assert_eq!(watch.len(), 1);
3868 assert_eq!(watch[0].upstream_sha, None);
3869 assert_eq!(watch[0].head_sha.as_deref(), Some("aaa"));
3870 }
3871
3872 #[test]
3873 fn start_pr_poller_is_a_noop_outside_a_runtime() {
3874 let svc = WorktreesService::new();
3875 svc.start_pr_poller();
3876 assert!(svc
3877 .poller
3878 .lock()
3879 .unwrap_or_else(PoisonError::into_inner)
3880 .is_none());
3881 }
3882
3883 #[tokio::test]
3884 async fn start_pr_poller_is_idempotent_and_shutdown_stops_it() {
3885 let svc = WorktreesService::new();
3886 svc.start_pr_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
3887 let token = svc
3888 .poller
3889 .lock()
3890 .unwrap_or_else(PoisonError::into_inner)
3891 .as_ref()
3892 .map(|t| t.token.clone())
3893 .expect("poller started");
3894
3895 token.cancel();
3898 svc.start_pr_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
3899 assert!(svc
3900 .poller
3901 .lock()
3902 .unwrap_or_else(PoisonError::into_inner)
3903 .as_ref()
3904 .is_some_and(|t| t.token.is_cancelled()));
3905
3906 svc.shutdown().await;
3907 assert!(svc
3908 .poller
3909 .lock()
3910 .unwrap_or_else(PoisonError::into_inner)
3911 .is_none());
3912 }
3913
3914 #[tokio::test]
3915 #[allow(clippy::await_holding_lock)]
3917 async fn pr_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
3918 let dir = tempfile::tempdir().unwrap();
3919 github_repo(dir.path());
3920 let bin_dir = tempfile::tempdir().unwrap();
3921 let (fake, _shim) = fake_gh(
3924 bin_dir.path(),
3925 r#"{"data":{"r0":{"b0":{
3926 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
3927 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
3928 ]}}},
3929 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
3930 }}}}"#,
3931 );
3932 let svc = WorktreesService::new();
3933 svc.handle(
3934 "register",
3935 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3936 )
3937 .await
3938 .unwrap();
3939 svc.start_pr_poller_with(Duration::from_millis(50), fake.clone());
3940
3941 let badge = tokio::time::timeout(Duration::from_secs(30), async {
3945 loop {
3946 if let Some(badge) = svc.pr_cache.get("rust-works", "omni-dev", "main") {
3947 return badge;
3948 }
3949 tokio::time::sleep(Duration::from_millis(25)).await;
3950 }
3951 })
3952 .await
3953 .expect("poller should resolve a badge through the fake gh");
3954 assert_eq!(badge.number, 1337);
3955 assert_eq!(badge.checks, crate::pr_status::PrCheckState::Pending);
3956
3957 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3959 assert_eq!(wt["pr"]["number"], json!(1337));
3960
3961 svc.shutdown().await;
3963 let generation = svc.registry.change_generation();
3964 tokio::time::sleep(Duration::from_millis(120)).await;
3965 assert_eq!(
3966 svc.registry.change_generation(),
3967 generation,
3968 "no bumps after shutdown"
3969 );
3970 }
3971
3972 #[tokio::test]
3973 #[allow(clippy::await_holding_lock)]
3975 async fn pr_poller_bumps_only_when_a_verdict_actually_moves() {
3976 let dir = tempfile::tempdir().unwrap();
3979 github_repo(dir.path());
3980 let bin_dir = tempfile::tempdir().unwrap();
3981 let (fake, _shim) = fake_gh(
3982 bin_dir.path(),
3983 r#"{"data":{"r0":{"b0":{
3984 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
3985 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
3986 ]}}},
3987 "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"u"}]}
3988 }}}}"#,
3989 );
3990 let svc = WorktreesService::new();
3991 svc.handle(
3992 "register",
3993 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3994 )
3995 .await
3996 .unwrap();
3997 svc.start_pr_poller_with(Duration::from_millis(50), fake.clone());
3998
3999 tokio::time::timeout(Duration::from_secs(30), async {
4000 loop {
4001 if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
4002 return;
4003 }
4004 tokio::time::sleep(Duration::from_millis(25)).await;
4005 }
4006 })
4007 .await
4008 .expect("poller should resolve a badge through the fake gh");
4009 let settled = svc.registry.change_generation();
4012 tokio::time::sleep(Duration::from_millis(150)).await;
4013 assert_eq!(
4014 svc.registry.change_generation(),
4015 settled,
4016 "an unchanged poll must not bump the change-notify"
4017 );
4018 svc.shutdown().await;
4019 }
4020
4021 #[test]
4022 fn sync_indicator_formats_only_with_upstream() {
4023 assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
4024 assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
4025 assert_eq!(sync_indicator(None, None), None);
4026 assert_eq!(sync_indicator(Some(1), None), None);
4028 }
4029
4030 #[tokio::test]
4031 async fn list_enriches_entries_with_git_status() {
4032 let dir = tempfile::tempdir().unwrap();
4033 let repo = init_repo(dir.path());
4034 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4035 repo.set_head("refs/heads/main").unwrap();
4036
4037 let svc = WorktreesService::new();
4038 svc.handle(
4039 "register",
4040 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
4041 )
4042 .await
4043 .unwrap();
4044 let payload = svc.handle("list", Value::Null).await.unwrap();
4045 let windows = windows_of(&payload);
4046 assert_eq!(windows.len(), 1);
4047 assert_eq!(
4048 windows[0].get("branch").and_then(Value::as_str),
4049 Some("main")
4050 );
4051 assert!(windows[0].get("ahead").is_none());
4053 assert!(windows[0].get("behind").is_none());
4054 assert_eq!(
4056 windows[0].get("main_repo").and_then(Value::as_str),
4057 dir.path().file_name().and_then(|n| n.to_str())
4058 );
4059
4060 let plain = tempfile::tempdir().unwrap();
4062 svc.handle(
4063 "register",
4064 json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
4065 )
4066 .await
4067 .unwrap();
4068 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
4069 let w2 = windows
4070 .iter()
4071 .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
4072 .unwrap();
4073 assert!(w2.get("branch").is_none());
4074 assert!(w2.get("main_repo").is_none());
4075 }
4076
4077 #[test]
4078 fn window_label_prefers_git_branch_over_title() {
4079 let dir = tempfile::tempdir().unwrap();
4080 let repo = init_repo(dir.path());
4081 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4082 repo.set_head("refs/heads/main").unwrap();
4083 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
4084 let entry = WindowEntry {
4085 key: "k".to_string(),
4086 folders: vec![dir.path().to_path_buf()],
4087 repo: Some("companion-repo".to_string()),
4090 title: Some("ignored title".to_string()),
4091 pid: None,
4092 last_seen: Utc::now(),
4093 };
4094 assert_eq!(window_label(&entry), format!("{repo_name} · main"));
4096 }
4097
4098 #[tokio::test]
4099 async fn list_includes_ahead_behind_for_tracking_branch() {
4100 let dir = tempfile::tempdir().unwrap();
4101 let _repo = diverging_repo(dir.path());
4102
4103 let svc = WorktreesService::new();
4104 svc.handle(
4105 "register",
4106 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
4107 )
4108 .await
4109 .unwrap();
4110 let payload = svc.handle("list", Value::Null).await.unwrap();
4111 let windows = windows_of(&payload);
4112 assert_eq!(
4114 windows[0].get("branch").and_then(Value::as_str),
4115 Some("main")
4116 );
4117 assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
4118 assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
4119 }
4120
4121 #[test]
4122 fn window_label_includes_sync_for_tracking_branch() {
4123 let dir = tempfile::tempdir().unwrap();
4124 let _repo = diverging_repo(dir.path());
4125 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
4126 let entry = WindowEntry {
4127 key: "k".to_string(),
4128 folders: vec![dir.path().to_path_buf()],
4129 repo: Some("companion-repo".to_string()),
4130 title: None,
4131 pid: None,
4132 last_seen: Utc::now(),
4133 };
4134 assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
4136 }
4137
4138 fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
4142 let commit = repo.find_commit(base).unwrap();
4143 repo.branch(branch, &commit, false).unwrap();
4144 let reference = repo
4145 .find_reference(&format!("refs/heads/{branch}"))
4146 .unwrap();
4147 let mut opts = git2::WorktreeAddOptions::new();
4148 opts.reference(Some(&reference));
4149 repo.worktree(branch, wt_path, Some(&opts)).unwrap();
4150 }
4151
4152 #[test]
4153 fn git_status_marks_linked_worktree_and_names_parent_repo() {
4154 let main_dir = tempfile::tempdir().unwrap();
4155 let repo = init_repo(main_dir.path());
4156 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4157 repo.set_head("refs/heads/main").unwrap();
4158
4159 let wt_parent = tempfile::tempdir().unwrap();
4162 let wt_path = wt_parent.path().join("feature-wt");
4163 add_worktree(&repo, a, &wt_path, "feature");
4164
4165 let status = git_status(&wt_path);
4166 assert!(status.is_worktree);
4167 assert_eq!(status.branch.as_deref(), Some("feature"));
4168 assert_eq!(
4170 status.main_repo.as_deref(),
4171 main_dir.path().file_name().and_then(|n| n.to_str())
4172 );
4173
4174 let main_status = git_status(main_dir.path());
4176 assert!(!main_status.is_worktree);
4177 assert_eq!(main_status.main_repo, status.main_repo);
4178 }
4179
4180 #[test]
4181 fn window_label_marks_worktree_with_fork_glyph() {
4182 let main_dir = tempfile::tempdir().unwrap();
4183 let repo = init_repo(main_dir.path());
4184 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4185 repo.set_head("refs/heads/main").unwrap();
4186 let wt_parent = tempfile::tempdir().unwrap();
4187 let wt_path = wt_parent.path().join("feature-wt");
4188 add_worktree(&repo, a, &wt_path, "feature");
4189
4190 let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
4191 let entry = WindowEntry {
4192 key: "k".to_string(),
4193 folders: vec![wt_path],
4194 repo: Some("feature-wt".to_string()),
4195 title: None,
4196 pid: None,
4197 last_seen: Utc::now(),
4198 };
4199 assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
4202 }
4203
4204 #[test]
4205 fn main_repo_name_derives_from_common_dir() {
4206 assert_eq!(
4208 main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
4209 Some("omni-dev")
4210 );
4211 assert_eq!(
4213 main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
4214 Some("omni-dev")
4215 );
4216 assert_eq!(
4218 main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
4219 Some("omni-dev")
4220 );
4221 assert_eq!(main_repo_name(Path::new("/.git")), None);
4223 }
4224
4225 fn repos_of(payload: &Value) -> Vec<Value> {
4230 payload
4231 .get("repos")
4232 .and_then(Value::as_array)
4233 .expect("repos array")
4234 .clone()
4235 }
4236
4237 fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
4238 Some(GithubIdentity {
4239 owner: owner.to_string(),
4240 name: name.to_string(),
4241 })
4242 }
4243
4244 #[test]
4245 fn github_identity_parses_supported_forms() {
4246 assert_eq!(
4248 github_identity("https://github.com/rust-works/omni-dev.git"),
4249 github("rust-works", "omni-dev")
4250 );
4251 assert_eq!(
4252 github_identity("https://github.com/rust-works/omni-dev"),
4253 github("rust-works", "omni-dev")
4254 );
4255 assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
4256 assert_eq!(
4258 github_identity("git@github.com:rust-works/omni-dev.git"),
4259 github("rust-works", "omni-dev")
4260 );
4261 assert_eq!(
4262 github_identity("ssh://git@github.com/o/r.git"),
4263 github("o", "r")
4264 );
4265 assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
4266 assert_eq!(
4268 github_identity(" https://github.com/o/r/ "),
4269 github("o", "r")
4270 );
4271 }
4272
4273 #[test]
4274 fn github_identity_rejects_non_github_and_malformed() {
4275 assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
4277 assert_eq!(github_identity("git@example.com:o/r.git"), None);
4278 assert_eq!(github_identity("https://github.com/onlyowner"), None);
4280 assert_eq!(github_identity("https://github.com/o/r/extra"), None);
4281 assert_eq!(github_identity("https://github.com/"), None);
4282 assert_eq!(github_identity("not a url"), None);
4284 }
4285
4286 #[test]
4287 fn remote_github_identity_reads_origin_then_falls_back() {
4288 let dir = tempfile::tempdir().unwrap();
4289 let repo = init_repo(dir.path());
4290 assert_eq!(remote_github_identity(&repo), None);
4292 repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
4294 assert_eq!(remote_github_identity(&repo), None);
4295 repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
4297 .unwrap();
4298 assert_eq!(
4299 remote_github_identity(&repo),
4300 github("rust-works", "omni-dev")
4301 );
4302
4303 repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
4306 .unwrap();
4307 repo.remote("upstream", "https://github.com/other/proj.git")
4308 .unwrap();
4309 assert_eq!(remote_github_identity(&repo), github("other", "proj"));
4310 }
4311
4312 #[tokio::test]
4313 async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
4314 let svc = WorktreesService::new();
4315 assert_eq!(
4317 svc.handle("tree", Value::Null).await.unwrap(),
4318 json!({ "repos": [], "show_closed": true })
4319 );
4320 let plain = tempfile::tempdir().unwrap();
4322 svc.handle(
4323 "register",
4324 json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
4325 )
4326 .await
4327 .unwrap();
4328 assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
4329 }
4330
4331 #[tokio::test]
4332 async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
4333 let main_dir = tempfile::tempdir().unwrap();
4334 let repo = init_repo(main_dir.path());
4335 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4336 repo.set_head("refs/heads/main").unwrap();
4337 repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
4339 .unwrap();
4340
4341 let wt_parent = tempfile::tempdir().unwrap();
4344 let wt_path = wt_parent.path().join("feature-wt");
4345 add_worktree(&repo, a, &wt_path, "feature");
4346
4347 let svc = WorktreesService::new();
4348 svc.handle(
4351 "register",
4352 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
4353 )
4354 .await
4355 .unwrap();
4356 svc.handle(
4357 "register",
4358 json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
4359 )
4360 .await
4361 .unwrap();
4362
4363 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
4364 assert_eq!(
4365 repos.len(),
4366 1,
4367 "two worktrees of one repo dedupe: {repos:?}"
4368 );
4369 let repo0 = &repos[0];
4370 assert_eq!(
4372 repo0.get("main_repo").and_then(Value::as_str),
4373 main_dir.path().file_name().and_then(|n| n.to_str())
4374 );
4375 assert_eq!(
4376 repo0.pointer("/github/owner").and_then(Value::as_str),
4377 Some("rust-works")
4378 );
4379 assert_eq!(
4380 repo0.pointer("/github/name").and_then(Value::as_str),
4381 Some("omni-dev")
4382 );
4383 assert!(repo0.get("root").and_then(Value::as_str).is_some());
4384
4385 let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
4386 assert_eq!(worktrees.len(), 2);
4387 let main_wt = &worktrees[0];
4389 assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
4390 assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
4391 assert_eq!(
4392 main_wt.get("window_key").and_then(Value::as_str),
4393 Some("wm")
4394 );
4395 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
4396 let linked = &worktrees[1];
4398 assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
4399 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
4400 assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
4401 assert_eq!(
4402 linked.get("branch").and_then(Value::as_str),
4403 Some("feature")
4404 );
4405 }
4406
4407 #[tokio::test]
4408 async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
4409 let main_dir = tempfile::tempdir().unwrap();
4410 let repo = init_repo(main_dir.path());
4411 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4412 repo.set_head("refs/heads/main").unwrap();
4413 let wt_parent = tempfile::tempdir().unwrap();
4415 let wt_path = wt_parent.path().join("feature-wt");
4416 add_worktree(&repo, a, &wt_path, "feature");
4417
4418 let svc = WorktreesService::new();
4419 svc.handle(
4421 "register",
4422 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
4423 )
4424 .await
4425 .unwrap();
4426
4427 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
4428 assert_eq!(repos.len(), 1);
4429 assert!(repos[0].get("github").is_none(), "no remote → no github");
4430 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
4431 let linked = worktrees
4432 .iter()
4433 .find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
4434 .expect("the linked worktree");
4435 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
4437 assert!(linked.get("window_key").is_none());
4438 }
4439
4440 fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
4446 let main_dir = tempfile::tempdir().unwrap();
4447 let repo = init_repo(main_dir.path());
4448 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
4449 repo.set_head("refs/heads/trunk").unwrap();
4450 let wt_parent = tempfile::tempdir().unwrap();
4451 let wt_path = wt_parent.path().join("feature-wt");
4452 add_worktree(&repo, a, &wt_path, "feature");
4453 (main_dir, wt_parent, wt_path)
4454 }
4455
4456 #[tokio::test]
4457 async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
4458 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4459 let svc = WorktreesService::new();
4460 let report = svc
4463 .handle("close", json!({ "path": wt_path, "remove": true }))
4464 .await
4465 .unwrap();
4466 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
4467 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
4468 assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
4469 assert!(report
4470 .get("risks")
4471 .and_then(Value::as_array)
4472 .unwrap()
4473 .is_empty());
4474 assert!(wt_path.exists());
4476 }
4477
4478 #[tokio::test]
4479 async fn close_removes_a_clean_linked_worktree() {
4480 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4481 let svc = WorktreesService::new();
4482 let reply = svc
4483 .handle(
4484 "close",
4485 json!({ "path": wt_path, "remove": true, "confirmed": true }),
4486 )
4487 .await
4488 .unwrap();
4489 assert_eq!(reply, json!({ "removed": true }));
4490 assert!(
4491 !wt_path.exists(),
4492 "the worktree directory should be deleted"
4493 );
4494 }
4495
4496 #[test]
4497 fn remove_worktree_deletes_the_directory_and_prunes_the_admin_metadata() {
4498 let (main, _wtp, wt_path) = repo_with_linked_worktree();
4502 let admin = main.path().join(".git").join("worktrees").join("feature");
4503 assert!(admin.exists(), "admin metadata should exist before removal");
4504
4505 remove_worktree(&wt_path).unwrap();
4506
4507 assert!(!wt_path.exists(), "the working directory should be gone");
4508 assert!(!admin.exists(), "the admin metadata should be pruned");
4509 let main_repo = Repository::open(main.path()).unwrap();
4510 assert_eq!(
4511 main_repo.worktrees().unwrap().len(),
4512 0,
4513 "git should no longer track the worktree"
4514 );
4515 }
4516
4517 #[test]
4518 fn remove_worktree_recovers_a_half_removed_orphan() {
4519 let (main, _wtp, wt_path) = repo_with_linked_worktree();
4524 let admin = main.path().join(".git").join("worktrees").join("feature");
4525 std::fs::remove_dir_all(&admin).unwrap();
4527 assert!(wt_path.join(".git").is_file(), "dangling gitlink remains");
4528 assert!(
4529 Repository::open(&wt_path).is_err(),
4530 "the orphan should not open as a repo"
4531 );
4532
4533 remove_worktree(&wt_path).unwrap();
4534 assert!(
4535 !wt_path.exists(),
4536 "the leftover directory should be removed"
4537 );
4538 }
4539
4540 #[test]
4541 fn is_orphaned_worktree_only_matches_a_dangling_linked_gitlink() {
4542 let (main, _wtp, wt_path) = repo_with_linked_worktree();
4543 assert!(!is_orphaned_worktree(&wt_path));
4545 assert!(!is_orphaned_worktree(main.path()));
4547 std::fs::remove_dir_all(main.path().join(".git").join("worktrees").join("feature"))
4549 .unwrap();
4550 assert!(is_orphaned_worktree(&wt_path));
4551 }
4552
4553 #[test]
4554 fn remove_dir_all_retrying_is_idempotent_on_a_missing_directory() {
4555 let tmp = tempfile::tempdir().unwrap();
4556 let missing = tmp.path().join("gone");
4557 assert!(remove_dir_all_retrying(&missing).is_ok());
4558 }
4559
4560 #[test]
4561 fn is_transient_rmdir_error_matches_only_the_repopulated_directory_race() {
4562 use std::io::Error;
4563 for errno in [nix::libc::ENOTEMPTY, nix::libc::EEXIST, nix::libc::EBUSY] {
4564 assert!(
4565 is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
4566 "errno {errno} is the concurrent-writer race and must be retried"
4567 );
4568 }
4569 for errno in [
4572 nix::libc::EACCES,
4573 nix::libc::EPERM,
4574 nix::libc::EROFS,
4575 nix::libc::ENOTDIR,
4576 ] {
4577 assert!(
4578 !is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
4579 "errno {errno} is permanent and must not be retried"
4580 );
4581 }
4582 assert!(!is_transient_rmdir_error(&Error::other("synthetic")));
4584 }
4585
4586 #[test]
4587 fn remove_dir_all_retrying_surfaces_a_non_transient_error_without_retrying() {
4588 let tmp = tempfile::tempdir().unwrap();
4592 let file = tmp.path().join("not-a-directory");
4593 std::fs::write(&file, b"x").unwrap();
4594
4595 let mut attempts = 0;
4596 let err = remove_dir_all_retrying_with(&file, WORKTREE_RMDIR_BACKOFF, || {
4597 attempts += 1;
4598 std::fs::remove_dir_all(&file)
4599 })
4600 .unwrap_err();
4601
4602 assert_eq!(attempts, 1, "a permanent error must not be retried");
4603 assert!(
4604 err.to_string()
4605 .contains("failed to remove worktree directory"),
4606 "unexpected error: {err:#}"
4607 );
4608 assert!(err.source().is_some(), "the io::Error cause is preserved");
4609 assert!(file.exists());
4610 }
4611
4612 #[test]
4613 fn remove_dir_all_retrying_gives_up_after_the_backoff_is_exhausted() {
4614 let tmp = tempfile::tempdir().unwrap();
4618 let mut attempts = 0;
4619 let backoff = [Duration::ZERO, Duration::ZERO];
4620 let err = remove_dir_all_retrying_with(tmp.path(), &backoff, || {
4621 attempts += 1;
4622 Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
4623 })
4624 .unwrap_err();
4625
4626 assert_eq!(attempts, backoff.len() + 1);
4628 assert!(
4629 err.to_string()
4630 .contains("failed to remove worktree directory"),
4631 "unexpected error: {err:#}"
4632 );
4633 }
4634
4635 #[test]
4636 fn remove_dir_all_retrying_succeeds_once_the_writer_quiesces() {
4637 let tmp = tempfile::tempdir().unwrap();
4640 let mut attempts = 0;
4641 let result = remove_dir_all_retrying_with(tmp.path(), WORKTREE_RMDIR_BACKOFF, || {
4642 attempts += 1;
4643 if attempts < 3 {
4644 Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
4645 } else {
4646 Ok(())
4647 }
4648 });
4649 assert!(result.is_ok(), "{result:?}");
4650 assert_eq!(attempts, 3);
4651 }
4652
4653 #[test]
4654 fn is_orphaned_worktree_ignores_a_git_file_that_is_not_a_gitlink() {
4655 let tmp = tempfile::tempdir().unwrap();
4658 std::fs::write(tmp.path().join(".git"), b"not a gitlink\n").unwrap();
4659 assert!(!is_orphaned_worktree(tmp.path()));
4660 }
4661
4662 #[test]
4663 fn remove_worktree_rejects_a_path_that_is_not_a_worktree() {
4664 let tmp = tempfile::tempdir().unwrap();
4667 let plain = tmp.path().join("plain");
4668 std::fs::create_dir(&plain).unwrap();
4669
4670 let err = remove_worktree(&plain).unwrap_err();
4671
4672 assert!(
4673 err.to_string().contains("not a git worktree"),
4674 "unexpected error: {err:#}"
4675 );
4676 assert!(plain.exists(), "a non-worktree path must be left alone");
4677 }
4678
4679 #[test]
4680 fn remove_worktree_succeeds_while_a_concurrent_writer_winds_down() {
4681 use std::sync::atomic::{AtomicBool, Ordering};
4687 use std::sync::Arc;
4688
4689 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4690 let target = wt_path.join("target");
4691 std::fs::create_dir_all(&target).unwrap();
4692
4693 let stop = Arc::new(AtomicBool::new(false));
4694 let writer_stop = Arc::clone(&stop);
4695 let writer_dir = target;
4696 let writer = std::thread::spawn(move || {
4697 let mut n = 0u64;
4698 let deadline = std::time::Instant::now() + Duration::from_millis(400);
4701 while !writer_stop.load(Ordering::Relaxed) && std::time::Instant::now() < deadline {
4702 let nested = writer_dir.join("nested");
4703 let _ = std::fs::create_dir_all(&nested);
4705 let _ = std::fs::write(nested.join(format!("artifact-{n}.tmp")), b"x");
4706 n += 1;
4707 }
4708 });
4709
4710 let result = remove_worktree(&wt_path);
4711 stop.store(true, Ordering::Relaxed);
4712 writer.join().unwrap();
4713
4714 assert!(
4715 result.is_ok(),
4716 "removal should retry past the writer: {result:?}"
4717 );
4718 assert!(!wt_path.exists(), "the worktree directory should be gone");
4719 }
4720
4721 #[tokio::test]
4722 async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
4723 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4724 std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
4726
4727 let svc = WorktreesService::new();
4728 let report = svc
4729 .handle("close", json!({ "path": wt_path, "remove": true }))
4730 .await
4731 .unwrap();
4732 let risks = report.get("risks").and_then(Value::as_array).unwrap();
4733 assert!(
4734 risks
4735 .iter()
4736 .any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
4737 "expected an untracked risk: {report}"
4738 );
4739 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
4741 assert!(wt_path.exists());
4743 }
4744
4745 #[tokio::test]
4746 async fn close_confirmed_removes_a_dirty_worktree() {
4747 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4748 std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
4749 let svc = WorktreesService::new();
4750 let reply = svc
4752 .handle(
4753 "close",
4754 json!({ "path": wt_path, "remove": true, "confirmed": true }),
4755 )
4756 .await
4757 .unwrap();
4758 assert_eq!(reply, json!({ "removed": true }));
4759 assert!(!wt_path.exists());
4760 }
4761
4762 #[tokio::test]
4763 async fn close_refuses_to_remove_the_main_working_tree() {
4764 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
4765 let svc = WorktreesService::new();
4766 let report = svc
4768 .handle("close", json!({ "path": main.path(), "remove": true }))
4769 .await
4770 .unwrap();
4771 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
4772 assert_eq!(
4773 report.get("removable").and_then(Value::as_bool),
4774 Some(false)
4775 );
4776 assert!(svc
4779 .handle(
4780 "close",
4781 json!({ "path": main.path(), "remove": true, "confirmed": true }),
4782 )
4783 .await
4784 .is_err());
4785 assert!(main.path().exists());
4786 }
4787
4788 #[tokio::test]
4789 async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
4790 let main_dir = tempfile::tempdir().unwrap();
4794 let repo = init_repo(main_dir.path());
4795 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
4796 repo.set_head("refs/heads/trunk").unwrap();
4797 let wt_parent = tempfile::tempdir().unwrap();
4798 let wt_path = wt_parent.path().join("main-wt");
4799 add_worktree(&repo, a, &wt_path, "main");
4800
4801 let svc = WorktreesService::new();
4802 let reply = svc
4803 .handle(
4804 "close",
4805 json!({ "path": wt_path, "remove": true, "confirmed": true }),
4806 )
4807 .await
4808 .unwrap();
4809 assert_eq!(reply, json!({ "removed": true }));
4810 assert!(!wt_path.exists());
4811 assert!(
4813 repo.find_branch("main", git2::BranchType::Local).is_ok(),
4814 "the default branch must survive worktree removal"
4815 );
4816 }
4817
4818 #[tokio::test]
4819 async fn close_is_idempotent_when_the_worktree_is_already_gone() {
4820 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4821 let svc = WorktreesService::new();
4822 svc.handle(
4824 "close",
4825 json!({ "path": wt_path, "remove": true, "confirmed": true }),
4826 )
4827 .await
4828 .unwrap();
4829 let reply = svc
4832 .handle(
4833 "close",
4834 json!({ "path": wt_path, "remove": true, "confirmed": true }),
4835 )
4836 .await
4837 .unwrap();
4838 assert_eq!(reply, json!({ "removed": true }));
4839 }
4840
4841 #[tokio::test]
4842 async fn close_safety_check_detects_detached_head_unreachable_commits() {
4843 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4844 let wt_repo = Repository::open(&wt_path).unwrap();
4847 let parent_oid = wt_repo.head().unwrap().target().unwrap();
4848 let parent = wt_repo.find_commit(parent_oid).unwrap();
4849 let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
4850 wt_repo.set_head_detached(orphan).unwrap();
4851
4852 let svc = WorktreesService::new();
4853 let report = svc
4854 .handle("close", json!({ "path": wt_path, "remove": true }))
4855 .await
4856 .unwrap();
4857 let risks = report.get("risks").and_then(Value::as_array).unwrap();
4858 assert!(
4859 risks
4860 .iter()
4861 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
4862 "expected an unreachable-commits risk: {report}"
4863 );
4864 }
4865
4866 #[tokio::test]
4867 async fn close_self_close_removes_when_the_requester_owns_the_target() {
4868 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4869 let svc = WorktreesService::new();
4870 svc.handle(
4874 "register",
4875 json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
4876 )
4877 .await
4878 .unwrap();
4879 let reply = svc
4880 .handle(
4881 "close",
4882 json!({
4883 "path": wt_path,
4884 "remove": true,
4885 "confirmed": true,
4886 "requester_key": "w1",
4887 }),
4888 )
4889 .await
4890 .unwrap();
4891 assert_eq!(reply, json!({ "removed": true }));
4892 assert!(!wt_path.exists());
4893 }
4894
4895 #[tokio::test]
4896 async fn close_safety_check_surfaces_the_owning_window() {
4897 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4898 let svc = WorktreesService::new();
4899 svc.handle(
4902 "register",
4903 json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
4904 )
4905 .await
4906 .unwrap();
4907 let report = svc
4908 .handle("close", json!({ "path": wt_path, "remove": true }))
4909 .await
4910 .unwrap();
4911 assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
4912 assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
4913 assert_eq!(
4914 report.get("window_folder_count").and_then(Value::as_u64),
4915 Some(2)
4916 );
4917 }
4918
4919 #[tokio::test]
4920 async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
4921 let svc = WorktreesService::new();
4922 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
4923 .await
4924 .unwrap();
4925 assert_eq!(
4927 svc.handle("heartbeat", json!({ "key": "w1" }))
4928 .await
4929 .unwrap(),
4930 json!({ "known": true })
4931 );
4932 svc.registry.mark_close_pending("w1");
4934 assert_eq!(
4935 svc.handle("heartbeat", json!({ "key": "w1" }))
4936 .await
4937 .unwrap(),
4938 json!({ "known": true, "close": true })
4939 );
4940 assert_eq!(
4941 svc.handle("heartbeat", json!({ "key": "w1" }))
4942 .await
4943 .unwrap(),
4944 json!({ "known": true })
4945 );
4946 }
4947
4948 #[tokio::test]
4949 async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
4950 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4951 let svc = Arc::new(WorktreesService::new());
4952 svc.handle(
4954 "register",
4955 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
4956 )
4957 .await
4958 .unwrap();
4959
4960 let svc2 = svc.clone();
4963 let path = wt_path.clone();
4964 let close = tokio::spawn(async move {
4965 svc2.handle(
4966 "close",
4967 json!({
4968 "path": path,
4969 "remove": true,
4970 "confirmed": true,
4971 "requester_key": "w1",
4972 }),
4973 )
4974 .await
4975 });
4976
4977 let mut saw_close = false;
4980 for _ in 0..200 {
4981 let hb = svc
4982 .handle("heartbeat", json!({ "key": "w2" }))
4983 .await
4984 .unwrap();
4985 if hb.get("close").and_then(Value::as_bool) == Some(true) {
4986 saw_close = true;
4987 svc.handle("unregister", json!({ "key": "w2" }))
4988 .await
4989 .unwrap();
4990 break;
4991 }
4992 tokio::time::sleep(Duration::from_millis(5)).await;
4993 }
4994 assert!(saw_close, "w2 should have been told to close");
4995
4996 let reply = close.await.unwrap().unwrap();
4998 assert_eq!(reply, json!({ "removed": true }));
4999 assert!(!wt_path.exists());
5000 }
5001
5002 #[tokio::test]
5003 async fn await_windows_closed_times_out_when_a_window_never_closes() {
5004 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
5005 let svc = WorktreesService::new();
5006 svc.handle(
5007 "register",
5008 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
5009 )
5010 .await
5011 .unwrap();
5012 let err = await_windows_closed(
5015 &svc.registry,
5016 &wt_path,
5017 Some("w1"),
5018 Duration::from_millis(150),
5019 Duration::from_millis(25),
5020 )
5021 .await
5022 .unwrap_err();
5023 assert!(
5024 err.to_string().contains("w2"),
5025 "error names the window: {err}"
5026 );
5027 await_windows_closed(
5029 &svc.registry,
5030 &wt_path,
5031 Some("w2"),
5032 Duration::from_millis(150),
5033 Duration::from_millis(25),
5034 )
5035 .await
5036 .unwrap();
5037 }
5038
5039 #[tokio::test]
5040 async fn close_window_without_remove_replies_closed_and_never_deletes() {
5041 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
5042 let svc = WorktreesService::new();
5043 let reply = svc
5045 .handle("close", json!({ "path": main.path(), "remove": false }))
5046 .await
5047 .unwrap();
5048 assert_eq!(reply, json!({ "closed": true }));
5049 assert!(main.path().exists());
5050 }
5051
5052 #[tokio::test]
5053 async fn close_safety_check_flags_modified_tracked_files() {
5054 let main_dir = tempfile::tempdir().unwrap();
5058 let repo = init_repo(main_dir.path());
5059 let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
5060 repo.set_head("refs/heads/trunk").unwrap();
5061 let wt_parent = tempfile::tempdir().unwrap();
5062 let wt_path = wt_parent.path().join("feature-wt");
5063 add_worktree(&repo, a, &wt_path, "feature");
5064 std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
5065
5066 let svc = WorktreesService::new();
5067 let report = svc
5068 .handle("close", json!({ "path": wt_path, "remove": true }))
5069 .await
5070 .unwrap();
5071 let risks = report.get("risks").and_then(Value::as_array).unwrap();
5072 assert!(
5073 risks
5074 .iter()
5075 .any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
5076 "expected a dirty risk: {report}"
5077 );
5078 }
5079
5080 #[tokio::test]
5081 async fn close_safety_check_flags_an_in_progress_operation() {
5082 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
5085 let wt_repo = Repository::open(&wt_path).unwrap();
5086 let head = wt_repo.head().unwrap().target().unwrap();
5087 std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
5088 assert_ne!(wt_repo.state(), RepositoryState::Clean);
5089
5090 let svc = WorktreesService::new();
5091 let report = svc
5092 .handle("close", json!({ "path": wt_path, "remove": true }))
5093 .await
5094 .unwrap();
5095 let risks = report.get("risks").and_then(Value::as_array).unwrap();
5096 assert!(
5097 risks
5098 .iter()
5099 .any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
5100 "expected an in-progress risk: {report}"
5101 );
5102 }
5103
5104 #[tokio::test]
5105 async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
5106 let main_dir = tempfile::tempdir().unwrap();
5110 let repo = init_repo(main_dir.path());
5111 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
5112 repo.set_head("refs/heads/trunk").unwrap();
5113 let a_commit = repo.find_commit(a).unwrap();
5114 repo.branch("feature", &a_commit, false).unwrap();
5115 repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
5116 .unwrap();
5117 empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
5119 drop(a_commit);
5120 let mut cfg = repo.config().unwrap();
5121 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
5122 .unwrap();
5123 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
5124 .unwrap();
5125 cfg.set_str("branch.feature.remote", "origin").unwrap();
5126 cfg.set_str("branch.feature.merge", "refs/heads/feature")
5127 .unwrap();
5128 let wt_parent = tempfile::tempdir().unwrap();
5131 let wt_path = wt_parent.path().join("feature-wt");
5132 let reference = repo.find_reference("refs/heads/feature").unwrap();
5133 let mut opts = git2::WorktreeAddOptions::new();
5134 opts.reference(Some(&reference));
5135 repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
5136
5137 let svc = WorktreesService::new();
5138 let report = svc
5139 .handle("close", json!({ "path": wt_path, "remove": true }))
5140 .await
5141 .unwrap();
5142 let info = report.get("info").and_then(Value::as_array).unwrap();
5145 assert!(
5146 info.iter()
5147 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
5148 "expected an unpushed info note: {report}"
5149 );
5150 assert!(
5151 report
5152 .get("risks")
5153 .and_then(Value::as_array)
5154 .unwrap()
5155 .is_empty(),
5156 "unpushed commits alone must not block: {report}"
5157 );
5158 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
5159 }
5160
5161 #[tokio::test]
5162 async fn close_safety_check_ignores_gitignored_files() {
5163 let main_dir = tempfile::tempdir().unwrap();
5167 let repo = init_repo(main_dir.path());
5168 let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
5169 repo.set_head("refs/heads/trunk").unwrap();
5170 let wt_parent = tempfile::tempdir().unwrap();
5171 let wt_path = wt_parent.path().join("feature-wt");
5172 add_worktree(&repo, a, &wt_path, "feature");
5173 std::fs::create_dir(wt_path.join("build")).unwrap();
5174 std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
5175
5176 let svc = WorktreesService::new();
5177 let report = svc
5178 .handle("close", json!({ "path": wt_path, "remove": true }))
5179 .await
5180 .unwrap();
5181 assert!(
5182 report
5183 .get("risks")
5184 .and_then(Value::as_array)
5185 .unwrap()
5186 .is_empty(),
5187 "a gitignored file must not create a risk: {report}"
5188 );
5189 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
5190 }
5191
5192 #[tokio::test]
5193 async fn close_safety_check_treats_a_missing_path_as_already_removed() {
5194 let svc = WorktreesService::new();
5197 let report = svc
5198 .handle(
5199 "close",
5200 json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
5201 )
5202 .await
5203 .unwrap();
5204 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
5205 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
5206 assert!(report
5207 .get("risks")
5208 .and_then(Value::as_array)
5209 .unwrap()
5210 .is_empty());
5211 let info = report.get("info").and_then(Value::as_array).unwrap();
5212 assert!(info
5213 .iter()
5214 .any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
5215 }
5216
5217 #[tokio::test]
5218 async fn close_refuses_a_locked_worktree() {
5219 let (main, _wtp, wt_path) = repo_with_linked_worktree();
5222 let main_repo = Repository::open(main.path()).unwrap();
5223 main_repo
5224 .find_worktree("feature")
5225 .unwrap()
5226 .lock(Some("under test"))
5227 .unwrap();
5228
5229 let svc = WorktreesService::new();
5230 let err = svc
5231 .handle(
5232 "close",
5233 json!({ "path": wt_path, "remove": true, "confirmed": true }),
5234 )
5235 .await
5236 .unwrap_err();
5237 assert!(
5238 err.to_string().contains("locked"),
5239 "expected a locked error: {err}"
5240 );
5241 assert!(wt_path.exists(), "a locked worktree must not be removed");
5242 }
5243
5244 #[tokio::test]
5245 async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
5246 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
5250 let wt_repo = Repository::open(&wt_path).unwrap();
5251 let tip = wt_repo.head().unwrap().target().unwrap();
5254 wt_repo.set_head_detached(tip).unwrap();
5255 assert!(wt_repo.head_detached().unwrap());
5256
5257 let svc = WorktreesService::new();
5258 let report = svc
5259 .handle("close", json!({ "path": wt_path, "remove": true }))
5260 .await
5261 .unwrap();
5262 let risks = report.get("risks").and_then(Value::as_array).unwrap();
5263 assert!(
5264 !risks
5265 .iter()
5266 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
5267 "a detached HEAD reachable from a branch must not be flagged: {report}"
5268 );
5269 }
5270
5271 #[test]
5272 fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
5273 let (main, _wtp, wt_path) = repo_with_linked_worktree();
5274 let main_repo = Repository::open(main.path()).unwrap();
5275 assert_eq!(
5277 worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
5278 "feature"
5279 );
5280 let err =
5283 worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
5284 assert!(
5285 err.to_string().contains("not registered"),
5286 "expected a not-registered error: {err}"
5287 );
5288 }
5289
5290 #[test]
5291 fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
5292 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
5295 let repo = Repository::open(&wt_path).unwrap();
5296 std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
5297 assert!(
5300 repo.statuses(Some(&mut StatusOptions::new())).is_err(),
5301 "a corrupt index should make statuses() fail"
5302 );
5303 assert_eq!(count_dirty_untracked(&repo), (0, 0));
5304 }
5305}