Skip to main content

lds_git/
lib.rs

1//! Git operations backed by [`git2`], with session-scoped write safety.
2//!
3//! Every public method returns a typed [`output`] struct wrapped in
4//! [`anyhow::Result`]. The lds MCP layer serialises these structs with
5//! `serde_json::to_string_pretty` so callers receive a stable JSON shape and
6//! can access fields directly instead of parsing free-form text.
7//!
8//! Read operations (status, log, diff, worktree_list, remote inspection) are
9//! always available. Write operations (commit, merge, worktree add/remove,
10//! branch delete, reset) require the target path / branch to have been
11//! created — or formally adopted via [`GitModule::session_release`] — by the
12//! current session.
13
14use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17use std::sync::Arc;
18
19use anyhow::{Context, Result, bail};
20use lds_core::Session;
21
22pub mod output;
23mod read;
24mod remote;
25mod reset;
26mod session;
27mod write;
28
29pub use output::{
30    BranchDeleteOutput, BranchStatusOutput, CommitEntry, CommitOutput, DiffOutput, EntryStatus,
31    FetchOutput, IsPushedOutput, LogOutput, MergeOutput, RemoteEntry, RemoteListOutput, ResetMode,
32    ResetOutput, SessionReleaseOutput, StatusKind, StatusOutput, TagPushedOutput,
33    UnpushedCommitsOutput, WorktreeAddOutput, WorktreeEntry, WorktreeListOutput,
34    WorktreeRemoveOutput, WorktreeStateOutput,
35};
36
37/// Git module instance, tied to a [`Session`].
38///
39/// Tracks which worktrees and branches were created by this session.
40/// Write operations check ownership before proceeding; read operations
41/// bypass the check entirely.
42#[derive(Debug)]
43pub struct GitModule {
44    session: Arc<Session>,
45    /// Worktrees created by this session — only these can be committed to / removed.
46    owned_worktrees: HashSet<PathBuf>,
47    /// Branches created by this session — only these can be deleted / merged.
48    owned_branches: HashSet<String>,
49}
50
51impl GitModule {
52    pub fn new(session: Arc<Session>) -> Self {
53        Self {
54            session,
55            owned_worktrees: HashSet::new(),
56            owned_branches: HashSet::new(),
57        }
58    }
59
60    pub fn register_worktree(&mut self, path: PathBuf) {
61        self.owned_worktrees.insert(path);
62    }
63
64    pub fn is_owned(&self, path: &PathBuf) -> bool {
65        self.owned_worktrees.contains(path)
66    }
67
68    pub fn ensure_owned(&self, path: &PathBuf) -> Result<()> {
69        if !self.is_owned(path) {
70            bail!(
71                "worktree not owned by this session ({}): {}",
72                self.session.id(),
73                path.display()
74            );
75        }
76        Ok(())
77    }
78
79    pub(crate) fn ensure_branch_owned(&self, branch: &str) -> Result<()> {
80        if !self.owned_branches.contains(branch) {
81            bail!(
82                "branch not owned by this session ({}): {}",
83                self.session.id(),
84                branch,
85            );
86        }
87        Ok(())
88    }
89
90    pub(crate) fn worktrees_dir(&self) -> PathBuf {
91        self.session.root().join(".worktrees")
92    }
93
94    pub(crate) fn ensure_session_scope(&self, working_dir: &Path) -> Result<()> {
95        if working_dir == self.session.root() {
96            return Ok(());
97        }
98        let canon = working_dir
99            .canonicalize()
100            .unwrap_or_else(|_| working_dir.to_path_buf());
101        if self.owned_worktrees.contains(&canon) {
102            return Ok(());
103        }
104        if self.owned_worktrees.contains(working_dir) {
105            return Ok(());
106        }
107        bail!(
108            "working_dir not owned by this session ({}): {}",
109            self.session.id(),
110            working_dir.display(),
111        );
112    }
113
114    pub(crate) fn session(&self) -> &Session {
115        &self.session
116    }
117
118    pub(crate) fn register_branch(&mut self, branch: String) {
119        self.owned_branches.insert(branch);
120    }
121
122    pub(crate) fn forget_worktree(&mut self, path: &Path) {
123        let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
124        self.owned_worktrees.remove(&canon);
125        self.owned_worktrees.remove(path);
126    }
127}
128
129/// Run `git <args>` inside `cwd`, returning trimmed stdout on success.
130///
131/// Shared by every module that needs to shell out (fetch, ls-remote,
132/// for-each-ref, worktree, commit, merge, reset). git2-rs is preferred for
133/// pure read paths (statuses, revwalk, diff, graph_ahead_behind) because it
134/// avoids spawning a subprocess and exposes typed data — but anything that
135/// touches credentials, refspecs, or worktree-level porcelain is delegated
136/// here to keep the implementation honest about what stock `git` would do.
137pub(crate) fn git_cmd(cwd: &Path, args: &[&str]) -> Result<String> {
138    let output = Command::new("git")
139        .args(args)
140        .current_dir(cwd)
141        .output()
142        .with_context(|| format!("failed to run git {}", args.first().unwrap_or(&"")))?;
143
144    if !output.status.success() {
145        let stderr = String::from_utf8_lossy(&output.stderr);
146        bail!("git {}: {}", args.first().unwrap_or(&""), stderr.trim());
147    }
148    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
149}
150
151/// Variant of [`git_cmd`] that merges stdout + stderr so callers can capture
152/// transport diagnostics (typical for `git fetch`).
153pub(crate) fn git_cmd_combined(cwd: &Path, args: &[&str]) -> Result<String> {
154    let output = Command::new("git")
155        .args(args)
156        .current_dir(cwd)
157        .output()
158        .with_context(|| format!("failed to run git {}", args.first().unwrap_or(&"")))?;
159
160    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
161    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
162
163    if !output.status.success() {
164        let combined = if stderr.is_empty() { stdout } else { stderr };
165        bail!("git {}: {}", args.first().unwrap_or(&""), combined);
166    }
167
168    Ok(match (stdout.is_empty(), stderr.is_empty()) {
169        (true, true) => String::new(),
170        (false, true) => stdout,
171        (true, false) => stderr,
172        (false, false) => format!("{stdout}\n{stderr}"),
173    })
174}