Skip to main content

lds_git/
lib.rs

1//! Git operations backed by [`git2`], with session-scoped write safety.
2//!
3//! Read operations (status, log, diff) are always available. Write
4//! operations (commit, merge, worktree add/remove) are restricted to
5//! worktrees created by the current session — preventing one agent from
6//! destroying another's work.
7
8use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use std::sync::Arc;
12
13use anyhow::{Context, Result, bail};
14use lds_core::Session;
15
16/// Git module instance, tied to a [`Session`].
17///
18/// Tracks which worktrees were created by this session via
19/// `owned_worktrees`. Write operations check ownership before
20/// proceeding; read operations bypass the check entirely.
21#[derive(Debug)]
22pub struct GitModule {
23    session: Arc<Session>,
24    /// Worktrees created by this session — only these can be committed to / removed.
25    owned_worktrees: HashSet<PathBuf>,
26    /// Branches created by this session — only these can be deleted / merged.
27    owned_branches: HashSet<String>,
28}
29
30impl GitModule {
31    pub fn new(session: Arc<Session>) -> Self {
32        Self {
33            session,
34            owned_worktrees: HashSet::new(),
35            owned_branches: HashSet::new(),
36        }
37    }
38
39    // -- Read operations (no scope check) --
40
41    pub fn status(&self) -> Result<String> {
42        let repo = git2::Repository::open(self.session.root())?;
43        let statuses = repo.statuses(None)?;
44        let mut out = String::new();
45        for entry in statuses.iter() {
46            let path = entry.path().unwrap_or("???");
47            let st = entry.status();
48            out.push_str(&format!("{st:?} {path}\n"));
49        }
50        Ok(out)
51    }
52
53    pub fn log(&self, max_count: usize) -> Result<String> {
54        let repo = git2::Repository::open(self.session.root())?;
55        let mut revwalk = repo.revwalk()?;
56        revwalk.push_head()?;
57        let mut out = String::new();
58        for (i, oid) in revwalk.enumerate() {
59            if i >= max_count {
60                break;
61            }
62            let oid = oid?;
63            let commit = repo.find_commit(oid)?;
64            let summary = commit.summary().unwrap_or("");
65            out.push_str(&format!("{} {summary}\n", &oid.to_string()[..7]));
66        }
67        Ok(out)
68    }
69
70    pub fn diff(&self) -> Result<String> {
71        let repo = git2::Repository::open(self.session.root())?;
72        let head = repo.head()?.peel_to_tree()?;
73        let diff = repo.diff_tree_to_workdir_with_index(Some(&head), None)?;
74        let mut out = String::new();
75        diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
76            let origin = line.origin();
77            if matches!(origin, '+' | '-' | ' ') {
78                out.push(origin);
79            }
80            out.push_str(std::str::from_utf8(line.content()).unwrap_or(""));
81            true
82        })?;
83        Ok(out)
84    }
85
86    // -- Write operations (scope-checked) --
87
88    pub fn register_worktree(&mut self, path: PathBuf) {
89        self.owned_worktrees.insert(path);
90    }
91
92    pub fn is_owned(&self, path: &PathBuf) -> bool {
93        self.owned_worktrees.contains(path)
94    }
95
96    pub fn ensure_owned(&self, path: &PathBuf) -> Result<()> {
97        if !self.is_owned(path) {
98            bail!(
99                "worktree not owned by this session ({}): {}",
100                self.session.id(),
101                path.display()
102            );
103        }
104        Ok(())
105    }
106
107    fn ensure_branch_owned(&self, branch: &str) -> Result<()> {
108        if !self.owned_branches.contains(branch) {
109            bail!(
110                "branch not owned by this session ({}): {}",
111                self.session.id(),
112                branch,
113            );
114        }
115        Ok(())
116    }
117
118    fn worktrees_dir(&self) -> PathBuf {
119        self.session.root().join(".worktrees")
120    }
121
122    fn ensure_session_scope(&self, working_dir: &Path) -> Result<()> {
123        if working_dir == self.session.root() {
124            return Ok(());
125        }
126        let canon = working_dir
127            .canonicalize()
128            .unwrap_or_else(|_| working_dir.to_path_buf());
129        if self.owned_worktrees.contains(&canon) {
130            return Ok(());
131        }
132        if self.owned_worktrees.contains(working_dir) {
133            return Ok(());
134        }
135        bail!(
136            "working_dir not owned by this session ({}): {}",
137            self.session.id(),
138            working_dir.display(),
139        );
140    }
141
142    // -- Write operations --
143
144    pub fn worktree_list(&self) -> Result<String> {
145        let out = git_cmd(self.session.root(), &["worktree", "list", "--porcelain"])?;
146        let mut entries = Vec::new();
147        let mut current: Vec<String> = Vec::new();
148        for line in out.lines() {
149            if line.is_empty() {
150                if !current.is_empty() {
151                    let entry_text = current.join("\n");
152                    let path_line = current.first().and_then(|l| l.strip_prefix("worktree "));
153                    let owned = path_line
154                        .map(|p| {
155                            let pb = PathBuf::from(p);
156                            self.owned_worktrees.contains(&pb)
157                        })
158                        .unwrap_or(false);
159                    entries.push(format!("{entry_text}\nowned: {owned}"));
160                    current.clear();
161                }
162            } else {
163                current.push(line.to_string());
164            }
165        }
166        if !current.is_empty() {
167            let entry_text = current.join("\n");
168            let path_line = current.first().and_then(|l| l.strip_prefix("worktree "));
169            let owned = path_line
170                .map(|p| self.owned_worktrees.contains(&PathBuf::from(p)))
171                .unwrap_or(false);
172            entries.push(format!("{entry_text}\nowned: {owned}"));
173        }
174        Ok(entries.join("\n\n"))
175    }
176
177    pub fn worktree_add(
178        &mut self,
179        name: &str,
180        branch: &str,
181        base_branch: Option<&str>,
182    ) -> Result<String> {
183        let wt_dir = self.worktrees_dir();
184        std::fs::create_dir_all(&wt_dir).context("failed to create .worktrees directory")?;
185
186        let wt_path = wt_dir.join(name);
187        if wt_path.exists() {
188            bail!("worktree already exists: {}", wt_path.display());
189        }
190
191        if let Some(base) = base_branch {
192            git_cmd(
193                self.session.root(),
194                &[
195                    "worktree",
196                    "add",
197                    "-b",
198                    branch,
199                    wt_path.to_str().unwrap_or(name),
200                    base,
201                ],
202            )?;
203        } else {
204            git_cmd(
205                self.session.root(),
206                &[
207                    "worktree",
208                    "add",
209                    "-b",
210                    branch,
211                    wt_path.to_str().unwrap_or(name),
212                ],
213            )?;
214        }
215
216        let canon = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
217        self.owned_worktrees.insert(canon);
218        self.owned_branches.insert(branch.to_string());
219
220        Ok(format!(
221            "worktree created: path={}, branch={}, session={}",
222            wt_path.display(),
223            branch,
224            self.session.id(),
225        ))
226    }
227
228    pub fn worktree_remove(&mut self, name: &str) -> Result<String> {
229        let wt_path = self.worktrees_dir().join(name);
230        let canon = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
231
232        self.ensure_owned(&canon)
233            .or_else(|_| self.ensure_owned(&wt_path))?;
234
235        git_cmd(
236            self.session.root(),
237            &[
238                "worktree",
239                "remove",
240                "--force",
241                wt_path.to_str().unwrap_or(name),
242            ],
243        )?;
244
245        self.owned_worktrees.remove(&canon);
246        self.owned_worktrees.remove(&wt_path);
247
248        Ok(format!("worktree removed: {}", wt_path.display()))
249    }
250
251    pub fn commit(
252        &self,
253        working_dir: &Path,
254        message: &str,
255        paths: Option<&[String]>,
256    ) -> Result<String> {
257        self.ensure_session_scope(working_dir)?;
258
259        match paths {
260            Some(ps) if !ps.is_empty() => {
261                let mut args = vec!["add", "--"];
262                let owned: Vec<&str> = ps.iter().map(|s| s.as_str()).collect();
263                args.extend(owned);
264                git_cmd(working_dir, &args)?;
265            }
266            _ => {
267                git_cmd(working_dir, &["add", "-A"])?;
268            }
269        }
270
271        git_cmd(working_dir, &["commit", "-m", message])?;
272
273        let hash = git_cmd(working_dir, &["rev-parse", "--short", "HEAD"])?;
274        let files_changed = git_cmd(working_dir, &["diff", "--name-only", "HEAD~1..HEAD"])
275            .unwrap_or_else(|e| {
276                tracing::warn!(error = %e, "git diff HEAD~1..HEAD failed (initial commit?)");
277                String::new()
278            });
279
280        let count = files_changed.lines().count();
281        Ok(format!(
282            "committed: hash={hash}, message={message}, files_changed={count}"
283        ))
284    }
285
286    pub fn merge(&self, branch: &str, into_branch: &str, working_dir: &Path) -> Result<String> {
287        self.ensure_session_scope(working_dir)?;
288
289        let current = git_cmd(working_dir, &["branch", "--show-current"])?;
290        if current != into_branch {
291            git_cmd(working_dir, &["checkout", into_branch])?;
292        }
293
294        let result = git_cmd(
295            working_dir,
296            &[
297                "merge",
298                "--no-ff",
299                branch,
300                "-m",
301                &format!("Merge branch '{}' into {}", branch, into_branch),
302            ],
303        );
304
305        match result {
306            Ok(out) => {
307                let hash = git_cmd(working_dir, &["rev-parse", "--short", "HEAD"])?;
308                Ok(format!(
309                    "merged: {branch} -> {into_branch}, hash={hash}\n{out}"
310                ))
311            }
312            Err(e) => {
313                let _ = git_cmd(working_dir, &["merge", "--abort"]);
314                bail!("merge failed (aborted): {e}");
315            }
316        }
317    }
318
319    pub fn branch_delete(&self, branch: &str) -> Result<String> {
320        self.ensure_branch_owned(branch)?;
321
322        git_cmd(self.session.root(), &["branch", "-d", branch])?;
323        Ok(format!("branch deleted: {branch}"))
324    }
325}
326
327fn git_cmd(cwd: &Path, args: &[&str]) -> Result<String> {
328    let output = Command::new("git")
329        .args(args)
330        .current_dir(cwd)
331        .output()
332        .with_context(|| format!("failed to run git {}", args.first().unwrap_or(&"")))?;
333
334    if !output.status.success() {
335        let stderr = String::from_utf8_lossy(&output.stderr);
336        bail!("git {}: {}", args.first().unwrap_or(&""), stderr.trim());
337    }
338    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
339}