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::sync::Arc;
17use std::time::Duration;
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 read::LogFilters;
30
31pub use output::{
32    BranchDeleteOutput, BranchStatusOutput, CommitEntry, CommitOutput, DiffOutput, EntryStatus,
33    FetchOutput, IsPushedOutput, LogOutput, MergeOutput, OtherStagedMode, RemoteEntry,
34    RemoteListOutput, ResetMode, ResetOutput, SessionReleaseOutput, StatusKind, StatusOutput,
35    TagPushedOutput, UnpushedCommitsOutput, WorktreeAddOutput, WorktreeEntry, WorktreeListOutput,
36    WorktreeRemoveOutput, WorktreeStateOutput,
37};
38
39/// Git module instance, tied to a [`Session`].
40///
41/// Tracks which worktrees and branches were created by this session.
42/// Write operations check ownership before proceeding; read operations
43/// bypass the check entirely.
44#[derive(Debug)]
45pub struct GitModule {
46    session: Arc<Session>,
47    /// Worktrees created by this session — only these can be committed to / removed.
48    owned_worktrees: HashSet<PathBuf>,
49    /// Branches created by this session — only these can be deleted / merged.
50    owned_branches: HashSet<String>,
51}
52
53impl GitModule {
54    pub fn new(session: Arc<Session>) -> Self {
55        Self {
56            session,
57            owned_worktrees: HashSet::new(),
58            owned_branches: HashSet::new(),
59        }
60    }
61
62    pub fn register_worktree(&mut self, path: PathBuf) {
63        self.owned_worktrees.insert(path);
64    }
65
66    pub fn is_owned(&self, path: &PathBuf) -> bool {
67        self.owned_worktrees.contains(path)
68    }
69
70    pub fn ensure_owned(&self, path: &PathBuf) -> Result<()> {
71        if !self.is_owned(path) {
72            bail!(
73                "worktree not owned by this session ({}): {}",
74                self.session.id(),
75                path.display()
76            );
77        }
78        Ok(())
79    }
80
81    pub(crate) fn ensure_branch_owned(&self, branch: &str) -> Result<()> {
82        if !self.owned_branches.contains(branch) {
83            bail!(
84                "branch not owned by this session ({}): {}",
85                self.session.id(),
86                branch,
87            );
88        }
89        Ok(())
90    }
91
92    /// Session-scoped worktree root — the directory where
93    /// [`GitModule::worktree_add`] places worktrees on newly-created
94    /// branches. Sourced from [`Session::worktrees_dir`], which resolves
95    /// [`SessionConfig::worktrees_dir`] (explicit) → env
96    /// `LDS_WORKTREES_DIR` → `<session_root>/[`lds_core::DEFAULT_WORKTREES_SUBDIR`]`
97    /// (default). See [`SessionConfig::worktrees_dir`] for the setup
98    /// expectation (parent-repo gitignore requirement).
99    pub(crate) fn worktrees_dir(&self) -> PathBuf {
100        self.session.worktrees_dir().to_path_buf()
101    }
102
103    pub(crate) fn ensure_session_scope(&self, working_dir: &Path) -> Result<()> {
104        if working_dir == self.session.root() {
105            return Ok(());
106        }
107        let canon = working_dir
108            .canonicalize()
109            .unwrap_or_else(|_| working_dir.to_path_buf());
110        if self.owned_worktrees.contains(&canon) {
111            return Ok(());
112        }
113        if self.owned_worktrees.contains(working_dir) {
114            return Ok(());
115        }
116        bail!(
117            "working_dir not owned by this session ({}): {}",
118            self.session.id(),
119            working_dir.display(),
120        );
121    }
122
123    pub(crate) fn session(&self) -> &Session {
124        &self.session
125    }
126
127    pub(crate) fn register_branch(&mut self, branch: String) {
128        self.owned_branches.insert(branch);
129    }
130
131    pub(crate) fn forget_worktree(&mut self, path: &Path) {
132        let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
133        self.owned_worktrees.remove(&canon);
134        self.owned_worktrees.remove(path);
135    }
136}
137
138/// Spawn `cmd` in a new process group (Unix), pipe stdout/stderr, wait for
139/// completion with a timeout. On timeout, sends `SIGKILL` to the entire
140/// process group so grandchildren (pre-commit hooks, gpg, pinentry, husky,
141/// etc.) are killed together — plain `kill_on_drop` only reaps the direct
142/// child, letting grandchildren survive as orphans and appear as zombies.
143///
144/// `display` is used as the subcommand label in the timeout error message
145/// (typically the first arg, e.g. `"commit"` for `git commit`).
146///
147/// Returns raw [`std::process::Output`]; callers inspect `status` themselves
148/// (needed for `git check-ignore` where exit 1 is data, `git rev-parse
149/// @{upstream}` where exit 128 is data, etc.).
150pub(crate) async fn spawn_output(
151    cmd: &mut tokio::process::Command,
152    display: &str,
153    timeout: Duration,
154) -> Result<std::process::Output> {
155    use std::process::Stdio;
156
157    cmd.stdin(Stdio::null())
158        .stdout(Stdio::piped())
159        .stderr(Stdio::piped())
160        .kill_on_drop(true);
161    #[cfg(unix)]
162    cmd.process_group(0);
163
164    let child = cmd
165        .spawn()
166        .with_context(|| format!("failed to spawn git {display}"))?;
167    let pid = child.id();
168
169    match tokio::time::timeout(timeout, child.wait_with_output()).await {
170        Ok(Ok(output)) => Ok(output),
171        Ok(Err(e)) => {
172            Err(anyhow::Error::from(e)).with_context(|| format!("failed to wait on git {display}"))
173        }
174        Err(_elapsed) => {
175            // SIGKILL the whole process group so grandchildren (hook / gpg /
176            // pinentry) die together. `kill_on_drop` already fired when the
177            // inner future was dropped, but only reaches the direct child.
178            #[cfg(unix)]
179            if let Some(pid) = pid {
180                // SAFETY: killpg with SIGKILL is always safe; worst case the
181                // group has already exited and we get ESRCH which we ignore.
182                unsafe {
183                    libc::killpg(pid as i32, libc::SIGKILL);
184                }
185            }
186            #[cfg(not(unix))]
187            let _ = pid;
188            bail!(
189                "git {}: timed out after {}s (SIGKILL sent to process group)",
190                display,
191                timeout.as_secs()
192            );
193        }
194    }
195}
196
197/// Run `git <args>` inside `cwd`, returning trimmed stdout on success.
198///
199/// Shared by every module that needs to shell out (fetch, ls-remote,
200/// for-each-ref, worktree, commit, merge, reset). git2-rs is preferred for
201/// pure read paths (statuses, revwalk, diff, graph_ahead_behind) because it
202/// avoids spawning a subprocess and exposes typed data — but anything that
203/// touches credentials, refspecs, or worktree-level porcelain is delegated
204/// here to keep the implementation honest about what stock `git` would do.
205pub(crate) async fn git_cmd(cwd: &Path, args: &[&str], timeout: Duration) -> Result<String> {
206    let mut cmd = tokio::process::Command::new("git");
207    cmd.args(args).current_dir(cwd);
208    let display = args.first().copied().unwrap_or("");
209    let output = spawn_output(&mut cmd, display, timeout).await?;
210
211    if !output.status.success() {
212        let stderr = String::from_utf8_lossy(&output.stderr);
213        bail!("git {}: {}", display, stderr.trim());
214    }
215    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
216}
217
218/// Variant of [`git_cmd`] that merges stdout + stderr so callers can capture
219/// transport diagnostics (typical for `git fetch`).
220pub(crate) async fn git_cmd_combined(
221    cwd: &Path,
222    args: &[&str],
223    timeout: Duration,
224) -> Result<String> {
225    let mut cmd = tokio::process::Command::new("git");
226    cmd.args(args).current_dir(cwd);
227    let display = args.first().copied().unwrap_or("");
228    let output = spawn_output(&mut cmd, display, timeout).await?;
229
230    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
231    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
232
233    if !output.status.success() {
234        let combined = if stderr.is_empty() { stdout } else { stderr };
235        bail!("git {}: {}", display, combined);
236    }
237
238    Ok(match (stdout.is_empty(), stderr.is_empty()) {
239        (true, true) => String::new(),
240        (false, true) => stdout,
241        (true, false) => stderr,
242        (false, false) => format!("{stdout}\n{stderr}"),
243    })
244}
245
246/// Timeout for git subcommands that only touch local refs / on-disk state
247/// (rev-parse HEAD, status, log, diff, branch --show-current, worktree list,
248/// commit, merge, worktree add/remove, branch -d, reset, check-ignore,
249/// rev-parse @{upstream}, for-each-ref, rev-list --count).
250///
251/// 30s is a "should never realistically be hit" ceiling — normal local git
252/// completes in milliseconds. Callers that legitimately need longer (e.g.
253/// grep over a multi-GB working tree) should thread a custom `Duration`
254/// through `git_cmd` / `spawn_output` explicitly.
255pub(crate) const TIMEOUT_LOCAL: Duration = Duration::from_secs(30);
256
257/// Timeout for git subcommands that hit the network (fetch, ls-remote,
258/// anything that would talk to a remote transport).
259pub(crate) const TIMEOUT_NETWORK: Duration = Duration::from_secs(60);
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    /// A pathologically short timeout should return an anyhow Error whose
266    /// message contains the `timed out after {}s` literal. This is the
267    /// grep-cross-check for the message contract (Acceptance 8) — the same
268    /// path fetch / ls-remote take on network hang, exercised without an
269    /// actually hanging endpoint.
270    #[tokio::test]
271    async fn git_cmd_reports_timeout_with_literal_message() {
272        let tmp = std::env::temp_dir();
273        let err = git_cmd(&tmp, &["version"], Duration::from_nanos(1))
274            .await
275            .expect_err("nanosecond timeout must trip");
276        let msg = err.to_string();
277        assert!(
278            msg.contains("timed out after"),
279            "expected 'timed out after' literal, got: {msg}"
280        );
281        assert!(
282            msg.contains("git version"),
283            "expected subcommand name in message, got: {msg}"
284        );
285    }
286
287    #[tokio::test]
288    async fn git_cmd_combined_reports_timeout_with_literal_message() {
289        let tmp = std::env::temp_dir();
290        let err = git_cmd_combined(&tmp, &["version"], Duration::from_nanos(1))
291            .await
292            .expect_err("nanosecond timeout must trip");
293        assert!(
294            err.to_string().contains("timed out after"),
295            "expected 'timed out after' literal, got: {err}"
296        );
297    }
298}