Skip to main content

lds_git/
write.rs

1//! Mutating operations: commit, merge, branch delete, worktree add/remove.
2//!
3//! Every method here calls [`GitModule::ensure_session_scope`] or
4//! [`GitModule::ensure_branch_owned`] (directly or via `worktree_remove` /
5//! `branch_delete`) before touching state — that's what makes a multi-agent
6//! setup safe.
7
8use std::path::Path;
9
10use anyhow::{Context, Result, bail};
11
12use crate::output::{
13    BranchDeleteOutput, CommitOutput, MergeOutput, WorktreeAddOutput, WorktreeRemoveOutput,
14};
15use crate::{GitModule, git_cmd};
16
17impl GitModule {
18    /// Create a new worktree under `.worktrees/<name>` on a new branch.
19    pub fn worktree_add(
20        &mut self,
21        name: &str,
22        branch: &str,
23        base_branch: Option<&str>,
24    ) -> Result<WorktreeAddOutput> {
25        let wt_dir = self.worktrees_dir();
26        std::fs::create_dir_all(&wt_dir).context("failed to create .worktrees directory")?;
27
28        let wt_path = wt_dir.join(name);
29        if wt_path.exists() {
30            bail!("worktree already exists: {}", wt_path.display());
31        }
32
33        let path_str = wt_path.to_str().unwrap_or(name);
34        if let Some(base) = base_branch {
35            git_cmd(
36                self.session().root(),
37                &["worktree", "add", "-b", branch, path_str, base],
38            )?;
39        } else {
40            git_cmd(
41                self.session().root(),
42                &["worktree", "add", "-b", branch, path_str],
43            )?;
44        }
45
46        let canon = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
47        self.register_worktree(canon);
48        self.register_branch(branch.to_string());
49
50        Ok(WorktreeAddOutput {
51            path: wt_path,
52            branch: branch.to_string(),
53            session: self.session().id().to_string(),
54        })
55    }
56
57    /// Remove a session-owned worktree (force-removed, then forgotten).
58    pub fn worktree_remove(&mut self, name: &str) -> Result<WorktreeRemoveOutput> {
59        let wt_path = self.worktrees_dir().join(name);
60        let canon = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
61
62        self.ensure_owned(&canon)
63            .or_else(|_| self.ensure_owned(&wt_path))?;
64
65        git_cmd(
66            self.session().root(),
67            &[
68                "worktree",
69                "remove",
70                "--force",
71                wt_path.to_str().unwrap_or(name),
72            ],
73        )?;
74
75        self.forget_worktree(&canon);
76        self.forget_worktree(&wt_path);
77
78        Ok(WorktreeRemoveOutput { path: wt_path })
79    }
80
81    /// Stage and commit changes in `working_dir`.
82    ///
83    /// When `paths` is `None` or empty, all changes (`git add -A`) are
84    /// staged; otherwise only the listed paths are.
85    pub fn commit(
86        &self,
87        working_dir: &Path,
88        message: &str,
89        paths: Option<&[String]>,
90    ) -> Result<CommitOutput> {
91        self.ensure_session_scope(working_dir)?;
92
93        match paths {
94            Some(ps) if !ps.is_empty() => {
95                let mut args = vec!["add", "--"];
96                let owned: Vec<&str> = ps.iter().map(|s| s.as_str()).collect();
97                args.extend(owned);
98                git_cmd(working_dir, &args)?;
99            }
100            _ => {
101                git_cmd(working_dir, &["add", "-A"])?;
102            }
103        }
104
105        git_cmd(working_dir, &["commit", "-m", message])?;
106
107        let sha = git_cmd(working_dir, &["rev-parse", "HEAD"])?;
108        let short_sha = sha[..7.min(sha.len())].to_string();
109        let files_changed = git_cmd(working_dir, &["diff", "--name-only", "HEAD~1..HEAD"])
110            .map(|out| out.lines().filter(|l| !l.is_empty()).count())
111            .unwrap_or_else(|e| {
112                tracing::warn!(error = %e, "git diff HEAD~1..HEAD failed (initial commit?)");
113                0
114            });
115
116        Ok(CommitOutput {
117            sha,
118            short_sha,
119            message: message.to_string(),
120            files_changed,
121        })
122    }
123
124    /// Merge `branch` into `into_branch` via `--no-ff`. On failure, the
125    /// merge is auto-aborted and the original error surfaces.
126    pub fn merge(
127        &self,
128        branch: &str,
129        into_branch: &str,
130        working_dir: &Path,
131    ) -> Result<MergeOutput> {
132        self.ensure_session_scope(working_dir)?;
133
134        let current = git_cmd(working_dir, &["branch", "--show-current"])?;
135        if current != into_branch {
136            git_cmd(working_dir, &["checkout", into_branch])?;
137        }
138
139        let merge_message = format!("Merge branch '{}' into {}", branch, into_branch);
140        match git_cmd(
141            working_dir,
142            &["merge", "--no-ff", branch, "-m", &merge_message],
143        ) {
144            Ok(raw) => {
145                let sha = git_cmd(working_dir, &["rev-parse", "HEAD"])?;
146                let short_sha = sha[..7.min(sha.len())].to_string();
147                Ok(MergeOutput {
148                    branch: branch.to_string(),
149                    into_branch: into_branch.to_string(),
150                    sha,
151                    short_sha,
152                    raw,
153                })
154            }
155            Err(e) => {
156                let _ = git_cmd(working_dir, &["merge", "--abort"]);
157                bail!("merge failed (aborted): {e}");
158            }
159        }
160    }
161
162    /// Delete a session-owned branch (refuses to delete unmerged work; use
163    /// `git branch -D` directly if you really need that).
164    pub fn branch_delete(&self, branch: &str) -> Result<BranchDeleteOutput> {
165        self.ensure_branch_owned(branch)?;
166        git_cmd(self.session().root(), &["branch", "-d", branch])?;
167        Ok(BranchDeleteOutput {
168            branch: branch.to_string(),
169        })
170    }
171}