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;
9use std::process::Command;
10
11use anyhow::{Context, Result, bail};
12
13use crate::output::{
14    BranchDeleteOutput, CommitOutput, DotfileWarning, MergeOutput, OtherStagedMode,
15    WorktreeAddOutput, WorktreeRemoveOutput,
16};
17use crate::{GitModule, git_cmd};
18
19impl GitModule {
20    /// Create a new worktree under `.worktrees/<name>` on a new branch.
21    pub fn worktree_add(
22        &mut self,
23        name: &str,
24        branch: &str,
25        base_branch: Option<&str>,
26    ) -> Result<WorktreeAddOutput> {
27        let wt_dir = self.worktrees_dir();
28        std::fs::create_dir_all(&wt_dir).context("failed to create .worktrees directory")?;
29
30        let wt_path = wt_dir.join(name);
31        if wt_path.exists() {
32            bail!("worktree already exists: {}", wt_path.display());
33        }
34
35        let path_str = wt_path.to_str().unwrap_or(name);
36        if let Some(base) = base_branch {
37            git_cmd(
38                self.session().root(),
39                &["worktree", "add", "-b", branch, path_str, base],
40            )?;
41        } else {
42            git_cmd(
43                self.session().root(),
44                &["worktree", "add", "-b", branch, path_str],
45            )?;
46        }
47
48        let canon = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
49        self.register_worktree(canon);
50        self.register_branch(branch.to_string());
51
52        Ok(WorktreeAddOutput {
53            path: wt_path,
54            branch: branch.to_string(),
55            session: self.session().id().to_string(),
56        })
57    }
58
59    /// Remove a session-owned worktree (force-removed, then forgotten).
60    pub fn worktree_remove(&mut self, name: &str) -> Result<WorktreeRemoveOutput> {
61        let wt_path = self.worktrees_dir().join(name);
62        let canon = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
63
64        self.ensure_owned(&canon)
65            .or_else(|_| self.ensure_owned(&wt_path))?;
66
67        git_cmd(
68            self.session().root(),
69            &[
70                "worktree",
71                "remove",
72                "--force",
73                wt_path.to_str().unwrap_or(name),
74            ],
75        )?;
76
77        self.forget_worktree(&canon);
78        self.forget_worktree(&wt_path);
79
80        Ok(WorktreeRemoveOutput { path: wt_path })
81    }
82
83    /// Stage and commit changes in `working_dir`.
84    ///
85    /// - `only == None` (or an empty slice): sweeps every change with
86    ///   `git add -A` and commits. `other_staged` is ignored. Kept as the
87    ///   backward-compatible default so pre-existing callers see identical
88    ///   behaviour.
89    /// - `only == Some(paths)`: commits exactly `paths`. When the index
90    ///   already carries other staged work, the call is *transactional*
91    ///   under `other_staged`:
92    ///   * `Stop` — return an error, leave the index untouched. Safe
93    ///     default, forces the caller to reconcile explicitly.
94    ///   * `Restage` — unstage the intruding paths, stage + commit `only`,
95    ///     then re-stage the intruders. The resulting commit contains
96    ///     exactly `only`; the pre-existing staged work stays in the index.
97    ///
98    /// Untracked files listed in `only` are staged and committed like any
99    /// other path; anything not in `only` is never touched.
100    ///
101    /// **Dotfile / dot-dir safeguard.** Any candidate whose path contains a
102    /// `.`-prefixed component (`.env`, `.claude/CLAUDE.md`,
103    /// `.github/workflows/ci.yml`, `foo/.hidden`) is classified before
104    /// staging:
105    ///
106    /// * *tracked* → committed as usual, but recorded in
107    ///   [`CommitOutput::dotfile_warnings`] so pre-publish review can catch
108    ///   unintended edits to `.gitignore` / workflow files / etc.
109    /// * *untracked, not in `.gitignore`* → dropped from staging + recorded
110    ///   in both `dotfile_warnings` and [`CommitOutput::dotfile_skipped`].
111    /// * *untracked, in `.gitignore`* → silently skipped (matches git's
112    ///   default; `git status --porcelain` doesn't surface them either).
113    /// * `force_dot=true` → suppresses the entire mechanism: every candidate
114    ///   is staged verbatim and no warnings are emitted.
115    pub fn commit(
116        &self,
117        working_dir: &Path,
118        message: &str,
119        only: Option<&[String]>,
120        other_staged: OtherStagedMode,
121        force_dot: bool,
122    ) -> Result<CommitOutput> {
123        self.ensure_session_scope(working_dir)?;
124
125        let (warnings, skipped) = match only {
126            None | Some([]) => {
127                let candidates = enumerate_changes(working_dir)?;
128                if force_dot || !candidates.iter().any(|p| is_dotfile_path(p)) {
129                    // Fast path: preserves the original `git add -A` sweep
130                    // whenever no dotfile is involved (or the caller opted
131                    // out via force_dot).
132                    git_cmd(working_dir, &["add", "-A"])?;
133                    (Vec::new(), Vec::new())
134                } else {
135                    let cls = classify_paths(working_dir, &candidates, false)?;
136                    stage_add_all(working_dir, &cls.stage)?;
137                    (cls.warnings, cls.skipped)
138                }
139            }
140            Some(ps) => {
141                let intruders = detect_other_staged(working_dir, ps)?;
142                if !intruders.is_empty() {
143                    match other_staged {
144                        OtherStagedMode::Stop => {
145                            bail!(
146                                "commit aborted: index carries staged paths outside \
147                                 the requested set (other_staged=stop): {}",
148                                intruders.join(", ")
149                            );
150                        }
151                        OtherStagedMode::Restage => {
152                            unstage(working_dir, &intruders)?;
153                            let cls = classify_paths(working_dir, ps, force_dot)?;
154                            stage(working_dir, &cls.stage)?;
155                            git_cmd(working_dir, &["commit", "-m", message])?;
156                            let output =
157                                commit_output(working_dir, message, cls.warnings, cls.skipped);
158                            // Best-effort re-stage; propagate a stage failure
159                            // so the caller sees the intruders are now sitting
160                            // in the worktree instead of the index.
161                            stage(working_dir, &intruders)?;
162                            return output;
163                        }
164                    }
165                }
166                let cls = classify_paths(working_dir, ps, force_dot)?;
167                stage(working_dir, &cls.stage)?;
168                (cls.warnings, cls.skipped)
169            }
170        };
171
172        git_cmd(working_dir, &["commit", "-m", message])?;
173        commit_output(working_dir, message, warnings, skipped)
174    }
175
176    /// Merge `branch` into `into_branch` via `--no-ff`. On failure, the
177    /// merge is auto-aborted and the original error surfaces.
178    pub fn merge(
179        &self,
180        branch: &str,
181        into_branch: &str,
182        working_dir: &Path,
183    ) -> Result<MergeOutput> {
184        self.ensure_session_scope(working_dir)?;
185
186        let current = git_cmd(working_dir, &["branch", "--show-current"])?;
187        if current != into_branch {
188            git_cmd(working_dir, &["checkout", into_branch])?;
189        }
190
191        let merge_message = format!("Merge branch '{}' into {}", branch, into_branch);
192        match git_cmd(
193            working_dir,
194            &["merge", "--no-ff", branch, "-m", &merge_message],
195        ) {
196            Ok(raw) => {
197                let sha = git_cmd(working_dir, &["rev-parse", "HEAD"])?;
198                let short_sha = sha[..7.min(sha.len())].to_string();
199                Ok(MergeOutput {
200                    branch: branch.to_string(),
201                    into_branch: into_branch.to_string(),
202                    sha,
203                    short_sha,
204                    raw,
205                })
206            }
207            Err(e) => {
208                let _ = git_cmd(working_dir, &["merge", "--abort"]);
209                bail!("merge failed (aborted): {e}");
210            }
211        }
212    }
213
214    /// Delete a session-owned branch (refuses to delete unmerged work; use
215    /// `git branch -D` directly if you really need that).
216    pub fn branch_delete(&self, branch: &str) -> Result<BranchDeleteOutput> {
217        self.ensure_branch_owned(branch)?;
218        git_cmd(self.session().root(), &["branch", "-d", branch])?;
219        Ok(BranchDeleteOutput {
220            branch: branch.to_string(),
221        })
222    }
223}
224
225/// Return staged paths (via `git diff --cached --name-only`) that are not
226/// in `only`. Empty result means the index matches the commit intent.
227fn detect_other_staged(working_dir: &Path, only: &[String]) -> Result<Vec<String>> {
228    let staged_raw = git_cmd(working_dir, &["diff", "--cached", "--name-only"])?;
229    let only_set: std::collections::HashSet<&str> = only.iter().map(|s| s.as_str()).collect();
230    Ok(staged_raw
231        .lines()
232        .filter(|l| !l.is_empty())
233        .filter(|l| !only_set.contains(*l))
234        .map(|s| s.to_string())
235        .collect())
236}
237
238/// `git add -- <paths>`. Ignored when `paths` is empty (no-op is not an error).
239fn stage(working_dir: &Path, paths: &[String]) -> Result<()> {
240    if paths.is_empty() {
241        return Ok(());
242    }
243    let mut args = vec!["add", "--"];
244    args.extend(paths.iter().map(|s| s.as_str()));
245    git_cmd(working_dir, &args)?;
246    Ok(())
247}
248
249/// Unstage `paths` while keeping worktree changes intact. Uses `git reset --`
250/// (index-only) so both modified-tracked and newly-staged files fall out of
251/// the index without touching what's on disk.
252fn unstage(working_dir: &Path, paths: &[String]) -> Result<()> {
253    if paths.is_empty() {
254        return Ok(());
255    }
256    let mut args = vec!["reset", "--"];
257    args.extend(paths.iter().map(|s| s.as_str()));
258    git_cmd(working_dir, &args)?;
259    Ok(())
260}
261
262/// Build the [`CommitOutput`] for the commit that HEAD currently points at.
263fn commit_output(
264    working_dir: &Path,
265    message: &str,
266    dotfile_warnings: Vec<DotfileWarning>,
267    dotfile_skipped: Vec<String>,
268) -> Result<CommitOutput> {
269    let sha = git_cmd(working_dir, &["rev-parse", "HEAD"])?;
270    let short_sha = sha[..7.min(sha.len())].to_string();
271    let files_changed = git_cmd(working_dir, &["diff", "--name-only", "HEAD~1..HEAD"])
272        .map(|out| out.lines().filter(|l| !l.is_empty()).count())
273        .unwrap_or_else(|e| {
274            tracing::warn!(error = %e, "git diff HEAD~1..HEAD failed (initial commit?)");
275            0
276        });
277    Ok(CommitOutput {
278        sha,
279        short_sha,
280        message: message.to_string(),
281        files_changed,
282        dotfile_warnings,
283        dotfile_skipped,
284    })
285}
286
287/// Result of classifying a candidate path set against the dotfile safeguard.
288struct Classification {
289    /// Paths that should be staged and included in the commit. Contains every
290    /// non-dotfile candidate plus tracked dotfiles (surface change) plus
291    /// everything when `force_dot=true`.
292    stage: Vec<String>,
293    /// Dotfile paths observed during the walk (tracked + untracked-not-ignored).
294    /// Empty when `force_dot=true`.
295    warnings: Vec<DotfileWarning>,
296    /// Dotfile paths dropped from staging (untracked + not in `.gitignore`).
297    /// Silent-ignored dotfiles are not tracked here (they're already invisible
298    /// to `git add -A` and to `git status --porcelain`).
299    skipped: Vec<String>,
300}
301
302/// Split `candidates` into stage / warn / skip buckets per the dotfile safeguard.
303fn classify_paths(
304    working_dir: &Path,
305    candidates: &[String],
306    force_dot: bool,
307) -> Result<Classification> {
308    let mut stage = Vec::new();
309    let mut warnings = Vec::new();
310    let mut skipped = Vec::new();
311
312    for p in candidates {
313        if force_dot || !is_dotfile_path(p) {
314            stage.push(p.clone());
315            continue;
316        }
317        // Dotfile: classify tracked vs untracked.
318        let tracked = is_tracked(working_dir, p)?;
319        if tracked {
320            stage.push(p.clone());
321            warnings.push(DotfileWarning {
322                path: p.clone(),
323                tracked: true,
324                in_gitignore: false,
325            });
326        } else if is_ignored(working_dir, p) {
327            // Silent skip — matches git's default behaviour.
328            skipped.push(p.clone());
329        } else {
330            skipped.push(p.clone());
331            warnings.push(DotfileWarning {
332                path: p.clone(),
333                tracked: false,
334                in_gitignore: false,
335            });
336        }
337    }
338
339    Ok(Classification {
340        stage,
341        warnings,
342        skipped,
343    })
344}
345
346/// `true` when any `/`-separated component of `p` starts with `.` (excluding
347/// `.` / `..` which aren't dotfiles). Matches `.env` at root, nested
348/// `foo/.env`, `.claude/CLAUDE.md`, `.github/workflows/ci.yml`.
349fn is_dotfile_path(p: &str) -> bool {
350    p.split('/')
351        .any(|c| c.starts_with('.') && c != "." && c != "..")
352}
353
354/// Enumerate the paths that `git add -A` would sweep — worktree modifications
355/// plus untracked-and-not-ignored files. Ignored files never surface here (nor
356/// in `git add -A`).
357///
358/// Uses `--porcelain=v1 -z --untracked-files=all` so the output is
359/// nul-delimited, column-stable, AND expands untracked directories into their
360/// contained paths. Without `-uall`, git collapses an untracked directory to a
361/// single `?? dir/` entry — a nested dotfile like `workspace/.journal.db`
362/// then never reaches [`classify_paths`], and the safeguard silently misses
363/// it. `git_cmd`'s trimmed stdout would also strip the leading space from a
364/// single-line ` M path` status code and break the XY-column offset.
365fn enumerate_changes(working_dir: &Path) -> Result<Vec<String>> {
366    let output = Command::new("git")
367        .args(["status", "--porcelain=v1", "-z", "--untracked-files=all"])
368        .current_dir(working_dir)
369        .output()
370        .context("failed to run git status --porcelain")?;
371    if !output.status.success() {
372        let stderr = String::from_utf8_lossy(&output.stderr);
373        bail!("git status --porcelain: {}", stderr.trim());
374    }
375
376    let raw = String::from_utf8_lossy(&output.stdout);
377    let mut paths = Vec::new();
378    // Records are nul-terminated. Rename records emit two records back-to-back:
379    // "R  newpath\0oldpath\0" — we only care about the new path, so the oldpath
380    // record is consumed via the peek-and-skip below.
381    let mut it = raw.split('\0').peekable();
382    while let Some(rec) = it.next() {
383        if rec.len() < 4 {
384            continue;
385        }
386        let status = &rec[..2];
387        let path = &rec[3..];
388        if status.starts_with('R') || status.starts_with('C') {
389            // Rename / copy: current record is the NEW path, next record is
390            // the OLD path — skip it.
391            it.next();
392        }
393        if !path.is_empty() {
394            paths.push(path.to_string());
395        }
396    }
397    Ok(paths)
398}
399
400/// `true` when `path` is present in the index (i.e. `git ls-files` returns it).
401/// Covers both files with an existing HEAD entry and freshly-`git add`ed files.
402fn is_tracked(working_dir: &Path, path: &str) -> Result<bool> {
403    let out = git_cmd(working_dir, &["ls-files", "--", path])?;
404    Ok(!out.trim().is_empty())
405}
406
407/// `true` when `git check-ignore` marks `path` as ignored. `check-ignore`
408/// uses exit 1 as "not ignored" (a normal signal, not an error), so this
409/// bypasses [`git_cmd`] and inspects the exit status directly.
410fn is_ignored(working_dir: &Path, path: &str) -> bool {
411    let output = Command::new("git")
412        .args(["check-ignore", "--quiet", "--", path])
413        .current_dir(working_dir)
414        .output();
415    match output {
416        Ok(o) => o.status.code() == Some(0),
417        Err(_) => false,
418    }
419}
420
421/// `git add -A -- <paths>`. Handles deletions in addition to
422/// modifications / additions, unlike plain [`stage`].
423fn stage_add_all(working_dir: &Path, paths: &[String]) -> Result<()> {
424    if paths.is_empty() {
425        return Ok(());
426    }
427    let mut args = vec!["add", "-A", "--"];
428    args.extend(paths.iter().map(|s| s.as_str()));
429    git_cmd(working_dir, &args)?;
430    Ok(())
431}