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