lds_git/session.rs
1//! Session helpers: adoption of orphan worktrees / branches that a previous
2//! session owned but never released.
3//!
4//! `session_release` is the counterpart to `worktree_add` — when an MCP
5//! client crashes and leaves worktree entries inside the session-scoped
6//! worktrees directory (see [`Session::worktrees_dir`]), the next session
7//! can adopt them (and the branches checked out inside them) so that the
8//! normal `worktree_remove` / `branch_delete` ownership checks pass.
9
10use std::path::PathBuf;
11
12use anyhow::Result;
13
14use crate::output::SessionReleaseOutput;
15use crate::{GitModule, TIMEOUT_LOCAL, git_cmd};
16
17impl GitModule {
18 /// Adopt orphan worktrees under the session-scoped worktrees dir
19 /// (those known to `git worktree list` but not yet owned by this
20 /// session). Any branches currently checked out inside them are
21 /// adopted at the same time so the normal `branch_delete` ownership
22 /// check will pass. The worktrees dir is resolved by
23 /// [`Session::worktrees_dir`].
24 ///
25 /// Returns the set of worktrees / branches that were newly adopted.
26 /// Already-owned entries are skipped silently — calling this repeatedly
27 /// is idempotent.
28 pub async fn session_release(&mut self) -> Result<SessionReleaseOutput> {
29 let raw = git_cmd(
30 self.session().root(),
31 &["worktree", "list", "--porcelain"],
32 TIMEOUT_LOCAL,
33 )
34 .await?;
35
36 let mut adopted_worktrees: Vec<PathBuf> = Vec::new();
37 let mut adopted_branches: Vec<String> = Vec::new();
38
39 let worktrees_dir = self.worktrees_dir();
40 let root = self.session().root().to_path_buf();
41
42 // Canonicalise once up-front so symlink-resolved tempdirs (e.g. macOS
43 // `/var/...` vs `/private/var/...`) don't make `starts_with` lie.
44 let canonical_worktrees_dir = worktrees_dir
45 .canonicalize()
46 .unwrap_or_else(|_| worktrees_dir.clone());
47 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.clone());
48
49 let mut current_path: Option<PathBuf> = None;
50 let mut current_branch: Option<String> = None;
51
52 let entries: Vec<(PathBuf, Option<String>)> = {
53 let mut acc = Vec::new();
54 for line in raw.lines() {
55 if line.is_empty() {
56 if let Some(p) = current_path.take() {
57 acc.push((p, current_branch.take()));
58 }
59 continue;
60 }
61 if let Some(p) = line.strip_prefix("worktree ") {
62 current_path = Some(PathBuf::from(p));
63 } else if let Some(b) = line.strip_prefix("branch ") {
64 current_branch = Some(b.trim_start_matches("refs/heads/").to_string());
65 }
66 }
67 if let Some(p) = current_path {
68 acc.push((p, current_branch));
69 }
70 acc
71 };
72
73 for (path, branch) in entries {
74 let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
75
76 // Never adopt the main worktree — that's the session root, which
77 // is implicitly addressable but not ownership-tracked.
78 if canonical_path == canonical_root {
79 continue;
80 }
81 // Only adopt worktrees under the session-scoped worktrees dir —
82 // anything outside is probably user-managed and shouldn't be
83 // silently claimed.
84 if !canonical_path.starts_with(&canonical_worktrees_dir) {
85 continue;
86 }
87 if self.is_owned(&canonical_path) || self.is_owned(&path) {
88 continue;
89 }
90 self.register_worktree(canonical_path.clone());
91 adopted_worktrees.push(path.clone());
92 if let Some(b) = branch
93 && !self.owned_branches.contains(&b)
94 {
95 self.register_branch(b.clone());
96 adopted_branches.push(b);
97 }
98 }
99
100 Ok(SessionReleaseOutput {
101 adopted_worktrees,
102 adopted_branches,
103 })
104 }
105}