Skip to main content

rtb_vcs/git/
commit.rs

1//! `Repo::commit` — stage paths and create a commit on HEAD.
2//!
3//! v0.5 commit 4. Per the A8 wrap-not-leak contract, the
4//! implementation choice (`git` CLI shell-out vs pure-gix index
5//! manipulation) is internal. **Current choice: shell out to `git
6//! add <paths> && git commit -m <message>`.** Rationale:
7//!
8//! - gix 0.72 has no high-level "stage these paths and commit"
9//!   helper; building one from `index_or_load_from_head_or_empty`,
10//!   blob writes, and `Repository::commit` is ~50 lines of fiddly
11//!   plumbing.
12//! - The scaffolder is the primary consumer; it already lives
13//!   alongside `git` on every supported platform.
14//! - Migration to pure-gix is internal — the public `Repo::commit`
15//!   signature and `RepoError::CommitFailed` shape don't change.
16//!
17//! Author / committer identity is sourced from gix config the way
18//! `git` itself does (worktree → global → system → environment).
19//! Callers that need to set identity should configure it before
20//! calling — same as plain `git commit`.
21
22use std::path::{Path, PathBuf};
23use std::process::Command;
24
25use super::{Repo, RepoError};
26
27impl Repo {
28    /// Stage `paths` and create a commit on HEAD with `message`.
29    ///
30    /// Paths are interpreted relative to the repository's working
31    /// tree. Paths that exist on disk are staged as modifications
32    /// or additions; paths that don't exist (e.g. just deleted) are
33    /// staged as deletions. This matches `git add <paths>`
34    /// semantics.
35    ///
36    /// Returns the new commit's OID as a 40-character hex string.
37    ///
38    /// # Errors
39    ///
40    /// - [`RepoError::CommitFailed`] — empty `paths`, `git` not on
41    ///   PATH, no author / committer configured, or any other
42    ///   underlying `git add` / `git commit` failure. The `cause`
43    ///   field carries the underlying error message.
44    pub async fn commit(&self, paths: &[&Path], message: &str) -> Result<String, RepoError> {
45        if paths.is_empty() {
46            return Err(RepoError::CommitFailed {
47                cause: "no paths supplied — commit requires at least one path to stage".into(),
48            });
49        }
50        let workdir = self.path().to_path_buf();
51        let paths_owned: Vec<PathBuf> = paths.iter().map(|p| p.to_path_buf()).collect();
52        let message = message.to_string();
53        tokio::task::spawn_blocking(move || run_commit(&workdir, &paths_owned, &message))
54            .await
55            .map_err(|join| RepoError::CommitFailed {
56                cause: format!("spawn_blocking join: {join}"),
57            })?
58    }
59}
60
61fn run_commit(workdir: &Path, paths: &[PathBuf], message: &str) -> Result<String, RepoError> {
62    // `git add -- <paths...>`. The `--` guards against paths that
63    // start with `-`.
64    let mut add = Command::new("git");
65    add.arg("add").arg("--").current_dir(workdir);
66    for p in paths {
67        add.arg(p);
68    }
69    let add_out = add
70        .output()
71        .map_err(|e| RepoError::CommitFailed { cause: format!("spawn `git add`: {e}") })?;
72    if !add_out.status.success() {
73        return Err(RepoError::CommitFailed {
74            cause: format!("git add: {}", String::from_utf8_lossy(&add_out.stderr).trim()),
75        });
76    }
77
78    // `git commit -m <message>`. `--allow-empty` is *not* set —
79    // an empty staged change is an error, matching git's default.
80    let commit_out = Command::new("git")
81        .arg("commit")
82        .arg("-m")
83        .arg(message)
84        .current_dir(workdir)
85        .output()
86        .map_err(|e| RepoError::CommitFailed { cause: format!("spawn `git commit`: {e}") })?;
87    if !commit_out.status.success() {
88        return Err(RepoError::CommitFailed {
89            cause: format!("git commit: {}", String::from_utf8_lossy(&commit_out.stderr).trim()),
90        });
91    }
92
93    // `git rev-parse HEAD` to read the new commit's OID.
94    let rev_out =
95        Command::new("git").arg("rev-parse").arg("HEAD").current_dir(workdir).output().map_err(
96            |e| RepoError::CommitFailed { cause: format!("spawn `git rev-parse`: {e}") },
97        )?;
98    if !rev_out.status.success() {
99        return Err(RepoError::CommitFailed {
100            cause: format!("git rev-parse: {}", String::from_utf8_lossy(&rev_out.stderr).trim()),
101        });
102    }
103    let oid = String::from_utf8(rev_out.stdout)
104        .map_err(|e| RepoError::CommitFailed { cause: format!("oid utf-8: {e}") })?
105        .trim()
106        .to_string();
107    Ok(oid)
108}