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