Skip to main content

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}