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`, `.github/workflows/ci.yml`,
113    /// `foo/.hidden`) is classified before staging:
114    ///
115    /// * *tracked* → committed as usual, but recorded in
116    ///   [`CommitOutput::dotfile_warnings`] so pre-publish review can catch
117    ///   unintended edits to `.gitignore` / workflow files / etc.
118    /// * *untracked, not in `.gitignore`* → dropped from staging + recorded
119    ///   in both `dotfile_warnings` and [`CommitOutput::dotfile_skipped`].
120    /// * *untracked, in `.gitignore`* → silently skipped (matches git's
121    ///   default; `git status --porcelain` doesn't surface them either).
122    /// * `force_dot=true` → suppresses the entire mechanism: every candidate
123    ///   is staged verbatim and no warnings are emitted.
124    pub async fn commit(
125        &self,
126        working_dir: &Path,
127        message: &str,
128        only: Option<&[String]>,
129        other_staged: OtherStagedMode,
130        force_dot: bool,
131    ) -> Result<CommitOutput> {
132        self.ensure_session_scope(working_dir)?;
133
134        let (warnings, skipped) = match only {
135            None | Some([]) => {
136                let candidates = enumerate_changes(working_dir).await?;
137                if force_dot || !candidates.iter().any(|p| is_dotfile_path(p)) {
138                    // Fast path: preserves the original `git add -A` sweep
139                    // whenever no dotfile is involved (or the caller opted
140                    // out via force_dot).
141                    git_cmd(working_dir, &["add", "-A"], TIMEOUT_LOCAL).await?;
142                    (Vec::new(), Vec::new())
143                } else {
144                    let cls = classify_paths(working_dir, &candidates, false).await?;
145                    stage_add_all(working_dir, &cls.stage).await?;
146                    (cls.warnings, cls.skipped)
147                }
148            }
149            Some(ps) => {
150                let intruders = detect_other_staged(working_dir, ps).await?;
151                if !intruders.is_empty() {
152                    match other_staged {
153                        OtherStagedMode::Stop => {
154                            bail!(
155                                "commit aborted: index carries staged paths outside \
156                                 the requested set (other_staged=stop): {}",
157                                intruders.join(", ")
158                            );
159                        }
160                        OtherStagedMode::Restage => {
161                            unstage(working_dir, &intruders).await?;
162                            let cls = classify_paths(working_dir, ps, force_dot).await?;
163                            stage(working_dir, &cls.stage).await?;
164                            git_cmd(working_dir, &["commit", "-m", message], TIMEOUT_LOCAL).await?;
165                            let output =
166                                commit_output(working_dir, message, cls.warnings, cls.skipped)
167                                    .await;
168                            // Best-effort re-stage; propagate a stage failure
169                            // so the caller sees the intruders are now sitting
170                            // in the worktree instead of the index.
171                            stage(working_dir, &intruders).await?;
172                            return output;
173                        }
174                    }
175                }
176                let cls = classify_paths(working_dir, ps, force_dot).await?;
177                stage(working_dir, &cls.stage).await?;
178                (cls.warnings, cls.skipped)
179            }
180        };
181
182        git_cmd(working_dir, &["commit", "-m", message], TIMEOUT_LOCAL).await?;
183        commit_output(working_dir, message, warnings, skipped).await
184    }
185
186    /// Merge `branch` into `into_branch` via `--no-ff`. On failure, the
187    /// merge is auto-aborted and the original error surfaces.
188    pub async fn merge(
189        &self,
190        branch: &str,
191        into_branch: &str,
192        working_dir: &Path,
193    ) -> Result<MergeOutput> {
194        self.ensure_session_scope(working_dir)?;
195
196        let current = git_cmd(working_dir, &["branch", "--show-current"], TIMEOUT_LOCAL).await?;
197        if current != into_branch {
198            git_cmd(working_dir, &["checkout", into_branch], TIMEOUT_LOCAL).await?;
199        }
200
201        let merge_message = format!("Merge branch '{}' into {}", branch, into_branch);
202        match git_cmd(
203            working_dir,
204            &["merge", "--no-ff", branch, "-m", &merge_message],
205            TIMEOUT_LOCAL,
206        )
207        .await
208        {
209            Ok(raw) => {
210                let sha = git_cmd(working_dir, &["rev-parse", "HEAD"], TIMEOUT_LOCAL).await?;
211                let short_sha = sha[..7.min(sha.len())].to_string();
212                Ok(MergeOutput {
213                    branch: branch.to_string(),
214                    into_branch: into_branch.to_string(),
215                    sha,
216                    short_sha,
217                    raw,
218                })
219            }
220            Err(e) => {
221                let _ = git_cmd(working_dir, &["merge", "--abort"], TIMEOUT_LOCAL).await;
222                bail!("merge failed (aborted): {e}");
223            }
224        }
225    }
226
227    /// Delete a session-owned branch (refuses to delete unmerged work; use
228    /// `git branch -D` directly if you really need that).
229    pub async fn branch_delete(&self, branch: &str) -> Result<BranchDeleteOutput> {
230        self.ensure_branch_owned(branch)?;
231        git_cmd(
232            self.session().root(),
233            &["branch", "-d", branch],
234            TIMEOUT_LOCAL,
235        )
236        .await?;
237        Ok(BranchDeleteOutput {
238            branch: branch.to_string(),
239        })
240    }
241}
242
243/// Return staged paths (via `git diff --cached --name-only`) that are not
244/// in `only`. Empty result means the index matches the commit intent.
245async fn detect_other_staged(working_dir: &Path, only: &[String]) -> Result<Vec<String>> {
246    let staged_raw = git_cmd(
247        working_dir,
248        &["diff", "--cached", "--name-only"],
249        TIMEOUT_LOCAL,
250    )
251    .await?;
252    let only_set: std::collections::HashSet<&str> = only.iter().map(|s| s.as_str()).collect();
253    Ok(staged_raw
254        .lines()
255        .filter(|l| !l.is_empty())
256        .filter(|l| !only_set.contains(*l))
257        .map(|s| s.to_string())
258        .collect())
259}
260
261/// `git add -- <paths>`. Ignored when `paths` is empty (no-op is not an error).
262async fn stage(working_dir: &Path, paths: &[String]) -> Result<()> {
263    if paths.is_empty() {
264        return Ok(());
265    }
266    let mut args = vec!["add", "--"];
267    args.extend(paths.iter().map(|s| s.as_str()));
268    git_cmd(working_dir, &args, TIMEOUT_LOCAL).await?;
269    Ok(())
270}
271
272/// Unstage `paths` while keeping worktree changes intact. Uses `git reset --`
273/// (index-only) so both modified-tracked and newly-staged files fall out of
274/// the index without touching what's on disk.
275async fn unstage(working_dir: &Path, paths: &[String]) -> Result<()> {
276    if paths.is_empty() {
277        return Ok(());
278    }
279    let mut args = vec!["reset", "--"];
280    args.extend(paths.iter().map(|s| s.as_str()));
281    git_cmd(working_dir, &args, TIMEOUT_LOCAL).await?;
282    Ok(())
283}
284
285/// Build the [`CommitOutput`] for the commit that HEAD currently points at.
286async fn commit_output(
287    working_dir: &Path,
288    message: &str,
289    dotfile_warnings: Vec<DotfileWarning>,
290    dotfile_skipped: Vec<String>,
291) -> Result<CommitOutput> {
292    let sha = git_cmd(working_dir, &["rev-parse", "HEAD"], TIMEOUT_LOCAL).await?;
293    let short_sha = sha[..7.min(sha.len())].to_string();
294    let files_changed = match git_cmd(
295        working_dir,
296        &["diff", "--name-only", "HEAD~1..HEAD"],
297        TIMEOUT_LOCAL,
298    )
299    .await
300    {
301        Ok(out) => out.lines().filter(|l| !l.is_empty()).count(),
302        Err(e) => {
303            tracing::warn!(error = %e, "git diff HEAD~1..HEAD failed (initial commit?)");
304            0
305        }
306    };
307    Ok(CommitOutput {
308        sha,
309        short_sha,
310        message: message.to_string(),
311        files_changed,
312        dotfile_warnings,
313        dotfile_skipped,
314    })
315}
316
317/// Result of classifying a candidate path set against the dotfile safeguard.
318struct Classification {
319    /// Paths that should be staged and included in the commit. Contains every
320    /// non-dotfile candidate plus tracked dotfiles (surface change) plus
321    /// everything when `force_dot=true`.
322    stage: Vec<String>,
323    /// Dotfile paths observed during the walk (tracked + untracked-not-ignored).
324    /// Empty when `force_dot=true`.
325    warnings: Vec<DotfileWarning>,
326    /// Dotfile paths dropped from staging (untracked + not in `.gitignore`).
327    /// Silent-ignored dotfiles are not tracked here (they're already invisible
328    /// to `git add -A` and to `git status --porcelain`).
329    skipped: Vec<String>,
330}
331
332/// Split `candidates` into stage / warn / skip buckets per the dotfile safeguard.
333async fn classify_paths(
334    working_dir: &Path,
335    candidates: &[String],
336    force_dot: bool,
337) -> Result<Classification> {
338    let mut stage = Vec::new();
339    let mut warnings = Vec::new();
340    let mut skipped = Vec::new();
341
342    for p in candidates {
343        if force_dot || !is_dotfile_path(p) {
344            stage.push(p.clone());
345            continue;
346        }
347        // Dotfile: classify tracked vs untracked.
348        let tracked = is_tracked(working_dir, p).await?;
349        if tracked {
350            stage.push(p.clone());
351            warnings.push(DotfileWarning {
352                path: p.clone(),
353                tracked: true,
354                in_gitignore: false,
355            });
356        } else if is_ignored(working_dir, p).await {
357            // Silent skip — matches git's default behaviour.
358            skipped.push(p.clone());
359        } else {
360            skipped.push(p.clone());
361            warnings.push(DotfileWarning {
362                path: p.clone(),
363                tracked: false,
364                in_gitignore: false,
365            });
366        }
367    }
368
369    Ok(Classification {
370        stage,
371        warnings,
372        skipped,
373    })
374}
375
376/// `true` when any `/`-separated component of `p` starts with `.` (excluding
377/// `.` / `..` which aren't dotfiles). Matches `.env` at root, nested
378/// `foo/.env`, and `.github/workflows/ci.yml` where the dot component is not
379/// the basename.
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}