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}