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, OtherStagedMode, WorktreeAddOutput,
14    WorktreeRemoveOutput,
15};
16use crate::{GitModule, git_cmd};
17
18impl GitModule {
19    /// Create a new worktree under `.worktrees/<name>` on a new branch.
20    pub fn worktree_add(
21        &mut self,
22        name: &str,
23        branch: &str,
24        base_branch: Option<&str>,
25    ) -> Result<WorktreeAddOutput> {
26        let wt_dir = self.worktrees_dir();
27        std::fs::create_dir_all(&wt_dir).context("failed to create .worktrees directory")?;
28
29        let wt_path = wt_dir.join(name);
30        if wt_path.exists() {
31            bail!("worktree already exists: {}", wt_path.display());
32        }
33
34        let path_str = wt_path.to_str().unwrap_or(name);
35        if let Some(base) = base_branch {
36            git_cmd(
37                self.session().root(),
38                &["worktree", "add", "-b", branch, path_str, base],
39            )?;
40        } else {
41            git_cmd(
42                self.session().root(),
43                &["worktree", "add", "-b", branch, path_str],
44            )?;
45        }
46
47        let canon = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
48        self.register_worktree(canon);
49        self.register_branch(branch.to_string());
50
51        Ok(WorktreeAddOutput {
52            path: wt_path,
53            branch: branch.to_string(),
54            session: self.session().id().to_string(),
55        })
56    }
57
58    /// Remove a session-owned worktree (force-removed, then forgotten).
59    pub fn worktree_remove(&mut self, name: &str) -> Result<WorktreeRemoveOutput> {
60        let wt_path = self.worktrees_dir().join(name);
61        let canon = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
62
63        self.ensure_owned(&canon)
64            .or_else(|_| self.ensure_owned(&wt_path))?;
65
66        git_cmd(
67            self.session().root(),
68            &[
69                "worktree",
70                "remove",
71                "--force",
72                wt_path.to_str().unwrap_or(name),
73            ],
74        )?;
75
76        self.forget_worktree(&canon);
77        self.forget_worktree(&wt_path);
78
79        Ok(WorktreeRemoveOutput { path: wt_path })
80    }
81
82    /// Stage and commit changes in `working_dir`.
83    ///
84    /// - `only == None` (or an empty slice): sweeps every change with
85    ///   `git add -A` and commits. `other_staged` is ignored. Kept as the
86    ///   backward-compatible default so pre-existing callers see identical
87    ///   behaviour.
88    /// - `only == Some(paths)`: commits exactly `paths`. When the index
89    ///   already carries other staged work, the call is *transactional*
90    ///   under `other_staged`:
91    ///   * `Stop` — return an error, leave the index untouched. Safe
92    ///     default, forces the caller to reconcile explicitly.
93    ///   * `Restage` — unstage the intruding paths, stage + commit `only`,
94    ///     then re-stage the intruders. The resulting commit contains
95    ///     exactly `only`; the pre-existing staged work stays in the index.
96    ///
97    /// Untracked files listed in `only` are staged and committed like any
98    /// other path; anything not in `only` is never touched.
99    pub fn commit(
100        &self,
101        working_dir: &Path,
102        message: &str,
103        only: Option<&[String]>,
104        other_staged: OtherStagedMode,
105    ) -> Result<CommitOutput> {
106        self.ensure_session_scope(working_dir)?;
107
108        match only {
109            None | Some([]) => {
110                git_cmd(working_dir, &["add", "-A"])?;
111            }
112            Some(ps) => {
113                let intruders = detect_other_staged(working_dir, ps)?;
114                if !intruders.is_empty() {
115                    match other_staged {
116                        OtherStagedMode::Stop => {
117                            bail!(
118                                "commit aborted: index carries staged paths outside \
119                                 the requested set (other_staged=stop): {}",
120                                intruders.join(", ")
121                            );
122                        }
123                        OtherStagedMode::Restage => {
124                            unstage(working_dir, &intruders)?;
125                            stage(working_dir, ps)?;
126                            git_cmd(working_dir, &["commit", "-m", message])?;
127                            let output = commit_output(working_dir, message);
128                            // Best-effort re-stage; propagate a stage failure
129                            // so the caller sees the intruders are now sitting
130                            // in the worktree instead of the index.
131                            stage(working_dir, &intruders)?;
132                            return output;
133                        }
134                    }
135                }
136                stage(working_dir, ps)?;
137            }
138        }
139
140        git_cmd(working_dir, &["commit", "-m", message])?;
141        commit_output(working_dir, message)
142    }
143
144    /// Merge `branch` into `into_branch` via `--no-ff`. On failure, the
145    /// merge is auto-aborted and the original error surfaces.
146    pub fn merge(
147        &self,
148        branch: &str,
149        into_branch: &str,
150        working_dir: &Path,
151    ) -> Result<MergeOutput> {
152        self.ensure_session_scope(working_dir)?;
153
154        let current = git_cmd(working_dir, &["branch", "--show-current"])?;
155        if current != into_branch {
156            git_cmd(working_dir, &["checkout", into_branch])?;
157        }
158
159        let merge_message = format!("Merge branch '{}' into {}", branch, into_branch);
160        match git_cmd(
161            working_dir,
162            &["merge", "--no-ff", branch, "-m", &merge_message],
163        ) {
164            Ok(raw) => {
165                let sha = git_cmd(working_dir, &["rev-parse", "HEAD"])?;
166                let short_sha = sha[..7.min(sha.len())].to_string();
167                Ok(MergeOutput {
168                    branch: branch.to_string(),
169                    into_branch: into_branch.to_string(),
170                    sha,
171                    short_sha,
172                    raw,
173                })
174            }
175            Err(e) => {
176                let _ = git_cmd(working_dir, &["merge", "--abort"]);
177                bail!("merge failed (aborted): {e}");
178            }
179        }
180    }
181
182    /// Delete a session-owned branch (refuses to delete unmerged work; use
183    /// `git branch -D` directly if you really need that).
184    pub fn branch_delete(&self, branch: &str) -> Result<BranchDeleteOutput> {
185        self.ensure_branch_owned(branch)?;
186        git_cmd(self.session().root(), &["branch", "-d", branch])?;
187        Ok(BranchDeleteOutput {
188            branch: branch.to_string(),
189        })
190    }
191}
192
193/// Return staged paths (via `git diff --cached --name-only`) that are not
194/// in `only`. Empty result means the index matches the commit intent.
195fn detect_other_staged(working_dir: &Path, only: &[String]) -> Result<Vec<String>> {
196    let staged_raw = git_cmd(working_dir, &["diff", "--cached", "--name-only"])?;
197    let only_set: std::collections::HashSet<&str> = only.iter().map(|s| s.as_str()).collect();
198    Ok(staged_raw
199        .lines()
200        .filter(|l| !l.is_empty())
201        .filter(|l| !only_set.contains(*l))
202        .map(|s| s.to_string())
203        .collect())
204}
205
206/// `git add -- <paths>`. Ignored when `paths` is empty (no-op is not an error).
207fn stage(working_dir: &Path, paths: &[String]) -> Result<()> {
208    if paths.is_empty() {
209        return Ok(());
210    }
211    let mut args = vec!["add", "--"];
212    args.extend(paths.iter().map(|s| s.as_str()));
213    git_cmd(working_dir, &args)?;
214    Ok(())
215}
216
217/// Unstage `paths` while keeping worktree changes intact. Uses `git reset --`
218/// (index-only) so both modified-tracked and newly-staged files fall out of
219/// the index without touching what's on disk.
220fn unstage(working_dir: &Path, paths: &[String]) -> Result<()> {
221    if paths.is_empty() {
222        return Ok(());
223    }
224    let mut args = vec!["reset", "--"];
225    args.extend(paths.iter().map(|s| s.as_str()));
226    git_cmd(working_dir, &args)?;
227    Ok(())
228}
229
230/// Build the [`CommitOutput`] for the commit that HEAD currently points at.
231fn commit_output(working_dir: &Path, message: &str) -> Result<CommitOutput> {
232    let sha = git_cmd(working_dir, &["rev-parse", "HEAD"])?;
233    let short_sha = sha[..7.min(sha.len())].to_string();
234    let files_changed = git_cmd(working_dir, &["diff", "--name-only", "HEAD~1..HEAD"])
235        .map(|out| out.lines().filter(|l| !l.is_empty()).count())
236        .unwrap_or_else(|e| {
237            tracing::warn!(error = %e, "git diff HEAD~1..HEAD failed (initial commit?)");
238            0
239        });
240    Ok(CommitOutput {
241        sha,
242        short_sha,
243        message: message.to_string(),
244        files_changed,
245    })
246}