Skip to main content

grit_lib/porcelain/
commit.rs

1//! Create a commit from the current index and move the checked-out branch.
2//!
3//! [`create_commit`] performs incremental cache-tree write-tree, writes the commit
4//! object, persists the index with a valid cache-tree, then updates the branch and `HEAD`
5//! reflogs through [`crate::refs::update_branch_for_commit_on_store`].
6
7use crate::apply::{apply_patch_to_index, FilePatch, IndexApplyOptions};
8use crate::change_id::{change_id, set_change_id, ChangeId, ChangeIdPolicy};
9use crate::commit::assemble_identity;
10use crate::commit::trailers::interpret::{
11    process_trailers, NewTrailerArg, ProcessTrailerOptions, TrailerIfExists, TrailerIfMissing,
12    TrailerWhere,
13};
14use crate::commit_trailers::{append_signoff_trailer_with_dedup, format_signoff_line};
15use crate::config::ident_resolve::IdentRole;
16use crate::config::ConfigSet;
17use crate::diff::refresh_index_stat_content_verified_with_rules;
18use crate::diff::{
19    diff_tree_path_change_count, diff_trees_with_renames, flatten_tree_entries,
20    materialize_worktree_blob, zero_oid, DiffEntry, DiffStatus,
21};
22use crate::error::ApplyError;
23use crate::error::{Error, Result};
24use crate::history::{
25    ensure_rewrite_apply_ready, persist_rebase_stopped_head_if_missing, HistoryError,
26};
27use crate::hooks::{
28    run_advisory_hook, run_commit_hook, run_post_rewrite, CommitHookEnv, HookError, HookName,
29    PostOperationHooks, RewriteKind,
30};
31use crate::ident::{
32    fsck_commit_idents, parse_signature_times, prepare_commit_author_from_parent,
33    prepare_commit_identity_line, replace_identity_date_suffix, Signature,
34};
35use crate::index::{Index, IndexLock};
36use crate::objects::odb::Odb;
37use crate::objects::{parse_commit, serialize_commit, CommitData, FileMode, ObjectId, ObjectKind};
38use crate::pathspec::{matches_pathspec_list_with_context, pathspec_is_exclude, ResolvedPathspecs};
39use crate::porcelain::add::{
40    stage_on_index, validate_commit_staging_pathspecs, StageMode, StageOptions,
41};
42use crate::porcelain::hunk_select::{
43    diff_options_for_hunk_select, resolve_selection, HunkSelectError, HunkSelection,
44};
45use crate::porcelain::index_from_tree::index_from_tree_with_seeded_worktree_stats;
46use crate::porcelain::pathspec_match::KnownPathspecChecker;
47use crate::porcelain::replay_sequence::finish_replay_commit_state;
48use crate::progress::{poll_cancel, Cancel, ProgressSink};
49use crate::refs::{
50    update_branch_for_commit_on_store, update_detached_head_for_commit_on_store,
51    BranchCommitRefUpdate,
52};
53use crate::repo::Repository;
54use crate::signing::{
55    extra_headers_without_commit_signatures, should_sign_commit, sign_serialized_commit, GpgConfig,
56};
57use crate::state::{
58    finish_merge_state, grit_rebase_conflict_stop_active, read_cherry_pick_head,
59    read_grit_rebase_stopped_source, read_merge_heads, read_merge_msg, read_revert_head,
60    resolve_head_for_repository, HeadState, InProgressOperation,
61};
62use crate::stripspace::{process, Mode};
63use crate::worktree_rules::WorktreeRules;
64use crate::write_tree::{
65    cache_tree_extension_valid, is_empty_tree_oid, write_tree_update_index, WriteTreeFlags,
66};
67use std::collections::BTreeMap;
68use std::fs;
69use std::path::{Path, PathBuf};
70use std::process;
71use std::sync::{Arc, Mutex};
72use thiserror::Error;
73
74/// Whether the verifying hooks run before a commit object is written.
75///
76/// The verifying hooks are `pre-commit` (or `pre-merge-commit` for a merge) and `commit-msg`.
77/// `prepare-commit-msg` and the `post-*` hooks run under either policy, as in Git.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub enum HookPolicy {
80    /// Run the verifying hooks when installed (default).
81    #[default]
82    Run,
83    /// Skip the verifying hooks (`--no-verify`).
84    SkipVerify,
85}
86
87/// Where a commit message came from, as `prepare-commit-msg` is told in its second (and third)
88/// argument.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub(crate) enum MessageSource {
91    /// Given by the caller (`-m`, `-F`).
92    Message,
93    /// Reused from this commit (`--amend`, `-C`).
94    Commit(ObjectId),
95    /// The merge message in `MERGE_MSG`.
96    Merge,
97    /// The squash message in `SQUASH_MSG`.
98    Squash,
99}
100
101/// Run `prepare-commit-msg` and then, unless `verify` is [`HookPolicy::SkipVerify`],
102/// `commit-msg` on the message file at `msg_path`, appending each hook that ran to `hooks_run`.
103///
104/// Returns the file's contents (with a trailing newline) when either hook ran, since a hook
105/// may have rewritten it, and `None` when neither is installed.
106///
107/// # Errors
108///
109/// [`Error::Hook`] when a hook fails or cannot start, [`Error::Io`] when the file cannot be
110/// read back, and [`Error::EmptyCommitMessage`] when a hook left it empty.
111pub(crate) fn run_commit_message_hooks(
112    repo: &Repository,
113    msg_path: &Path,
114    source: MessageSource,
115    verify: HookPolicy,
116    commit_env: &CommitHookEnv<'_>,
117    hooks_run: &mut Vec<HookName>,
118) -> Result<Option<String>> {
119    let path_arg = commit_editmsg_hook_argument(repo, msg_path);
120    let source_oid;
121    let prepare_args: Vec<&str> = match source {
122        MessageSource::Message => vec![&path_arg, "message"],
123        MessageSource::Commit(oid) => {
124            source_oid = oid.to_hex();
125            vec![&path_arg, "commit", &source_oid]
126        }
127        MessageSource::Merge => vec![&path_arg, "merge"],
128        MessageSource::Squash => vec![&path_arg, "squash"],
129    };
130    let ran_before = hooks_run.len();
131    if run_commit_hook_recorded(
132        repo,
133        HookName::PrepareCommitMsg,
134        &prepare_args,
135        None,
136        commit_env,
137    )? {
138        hooks_run.push(HookName::PrepareCommitMsg);
139    }
140    if verify != HookPolicy::SkipVerify
141        && run_commit_hook_recorded(repo, HookName::CommitMsg, &[&path_arg], None, commit_env)?
142    {
143        hooks_run.push(HookName::CommitMsg);
144    }
145    if hooks_run.len() == ran_before {
146        return Ok(None);
147    }
148    let mut message = fs::read_to_string(msg_path).map_err(Error::Io)?;
149    if message.trim().is_empty() {
150        return Err(Error::EmptyCommitMessage);
151    }
152    if !message.ends_with('\n') {
153        message.push('\n');
154    }
155    Ok(Some(message))
156}
157
158/// Author name, email, and date suffix from a written commit header line.
159#[derive(Debug, Clone, PartialEq, Eq)]
160#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
161pub struct CommitAuthorInfo {
162    /// Author display name.
163    pub name: String,
164    /// Author email.
165    pub email: String,
166    /// Git timestamp suffix (`<unix> <+HHMM>`).
167    pub date: String,
168}
169
170fn guard_incomplete_rewrite_apply(repo: &Repository) -> Result<()> {
171    ensure_rewrite_apply_ready(repo).map_err(|err| match err {
172        HistoryError::RewriteApplyIncomplete => {
173            Error::OperationInProgress(InProgressOperation::RewriteApply)
174        }
175        other => Error::History(other),
176    })
177}
178
179/// Inputs for [`create_commit`].
180#[derive(Debug, Clone, Default)]
181pub struct CommitRequest {
182    /// Commit message body (first line becomes the subject). A trailing newline is added when missing.
183    ///
184    /// The message is also written to `.git/COMMIT_EDITMSG` for the `prepare-commit-msg` and
185    /// `commit-msg` hooks. The file is read back only when one of them ran, so concurrent
186    /// committers sharing the file never record each other's message.
187    pub message: String,
188    /// Tell `prepare-commit-msg` that [`Self::message`] is the in-progress merge's `MERGE_MSG`
189    /// (source `merge` instead of `message`), as when finishing a merge.
190    pub(crate) message_from_merge: bool,
191    /// Author to record. `None` keeps the replaced commit's author for [`Self::amend`] and the
192    /// source commit's author for [`Self::reuse_message_from`]; otherwise the repository's
193    /// author identity ([`Repository::identity`]) is used. [`Self::author_date`] and
194    /// [`Self::reset_author`] still apply.
195    pub author: Option<Signature>,
196    /// Committer to record; `None` uses the repository's committer identity
197    /// ([`Repository::identity`]).
198    pub committer: Option<Signature>,
199    /// When false (default), reject commits whose tree equals the parent's tree or is the empty tree on an unborn branch.
200    pub allow_empty: bool,
201    /// When `Some(true)`, sign even if `commit.gpgsign` is false; when `Some(false)`, skip signing even when config requests it. When `None`, honor `commit.gpgsign`.
202    pub sign_override: Option<bool>,
203    /// Replace the current branch tip: parents and author come from the tip being replaced; an empty
204    /// [`Self::message`] reuses that commit's message. Same-tree amends are allowed.
205    pub amend: bool,
206    /// When true, skip the post-commit full-tree index stat refresh because the caller already
207    /// synced the index from the worktree (for example via [`crate::porcelain::add::stage_for_commit`]).
208    ///
209    /// [`commit_worktree`] and every [`commit_with_scope`] scope set this themselves, since their
210    /// staging pass already refreshed the index; only [`create_commit`] callers need to choose.
211    pub assume_worktree_synced: bool,
212    /// When true with [`Self::assume_worktree_synced`], skip the pre-commit index↔parent tree
213    /// match; the post-`write_tree` parent comparison still rejects empty commits.
214    pub(crate) skip_pre_commit_tree_match: bool,
215    /// When set with [`Self::skip_pre_commit_tree_match`], use this instead of a full tree diff for
216    /// [`CommitOutcome::changes`].
217    pub(crate) staged_change_count: Option<usize>,
218    /// Hook execution policy for `pre-commit` and `commit-msg`; `prepare-commit-msg` runs either
219    /// way.
220    #[doc(alias = "no-verify")]
221    pub hooks: HookPolicy,
222    /// Author timestamp suffix (`<unix> <+HHMM>`) replacing the author line date when set.
223    pub author_date: Option<String>,
224    /// Trailers to append via [`process_trailers`] (same semantics as `git commit --trailer`).
225    pub trailers: Vec<(String, String)>,
226    /// Append `Signed-off-by` using the committer identity (`-s` / `--signoff`).
227    pub signoff: bool,
228    /// Use the committer as the author with a fresh author date (`--reset-author`).
229    pub reset_author: bool,
230    /// Reuse log message and author from this commit (`-C` / `--reuse-message`); copies get a new change id.
231    pub reuse_message_from: Option<ObjectId>,
232    /// When true, compute the commit tree and file list without writing objects, updating refs, or running hooks.
233    pub dry_run: bool,
234    /// When true, a [`Error::NothingToCommit`] is upgraded to [`Error::NothingStagedToCommit`] if the
235    /// work tree still has unstaged or untracked paths (index-only commit).
236    pub(crate) index_only: bool,
237    /// Whether an amend runs `post-rewrite amend` ([`PostOperationHooks::Skip`] is Git's
238    /// `--no-post-rewrite`). `post-commit` runs either way.
239    pub post_rewrite: PostOperationHooks,
240}
241
242impl CommitRequest {
243    /// Start a commit request with `message`; set other fields on the returned value.
244    #[must_use]
245    pub fn new(message: impl Into<String>) -> Self {
246        Self {
247            message: message.into(),
248            ..Self::default()
249        }
250    }
251
252    /// Skip the pre-commit index↔parent tree match (integration tests and scoped commits).
253    #[doc(hidden)]
254    pub fn skip_pre_commit_tree_match(mut self, skip: bool) -> Self {
255        self.skip_pre_commit_tree_match = skip;
256        self
257    }
258
259    /// Index-only commit scope (see [`Self::index_only`]).
260    #[doc(hidden)]
261    pub fn index_only_commit(mut self, index_only: bool) -> Self {
262        self.index_only = index_only;
263        self
264    }
265}
266
267/// One path-level change that would be recorded in a commit.
268#[derive(Debug, Clone, PartialEq, Eq)]
269#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
270pub struct CommitFile {
271    /// Primary path (`git diff --name-status` path column).
272    pub path: String,
273    /// Kind of change vs the parent commit tree.
274    pub status: DiffStatus,
275    /// Previous path for renames and copies.
276    pub old_path: Option<String>,
277}
278
279/// Planned commit contents and metadata (see [`plan_commit`]).
280#[derive(Debug, Clone, PartialEq, Eq)]
281pub struct CommitPlan {
282    /// Paths that differ from the parent tree, in `git diff --name-status` order.
283    pub files: Vec<CommitFile>,
284    /// Parent OIDs that would be stored on the new commit.
285    pub parents: Vec<ObjectId>,
286    /// Author line that would be recorded.
287    pub author: CommitAuthorInfo,
288    /// Whether the computed tree OID equals the parent tree.
289    pub tree_unchanged: bool,
290}
291
292/// Which paths are included when recording a commit ([`commit_with_scope`]).
293#[derive(Debug, Clone)]
294pub enum CommitScope {
295    /// Stage every worktree change, then commit (default `grit commit` behavior).
296    AutoStage,
297    /// Commit the index as-is (`git commit` without `-a`).
298    Staged,
299    /// Commit only matching paths from the worktree (`git commit --only --` paths).
300    Paths(ResolvedPathspecs),
301    /// Commit only hunks from a unified diff or stable hunk ids (`grit commit --hunks` / `--patch-file`).
302    Patch {
303        /// Hunk ids or pre-parsed file patches (exactly one is populated).
304        input: CommitPatchInput,
305        /// Optional pathspecs narrowing hunk resolution (same as `grit add --hunks`).
306        pathspecs: Vec<String>,
307    },
308}
309
310impl PartialEq for CommitScope {
311    fn eq(&self, other: &Self) -> bool {
312        match (self, other) {
313            (Self::AutoStage, Self::AutoStage) | (Self::Staged, Self::Staged) => true,
314            (Self::Paths(a), Self::Paths(b)) => a == b,
315            (
316                Self::Patch {
317                    input: a,
318                    pathspecs: ap,
319                },
320                Self::Patch {
321                    input: b,
322                    pathspecs: bp,
323                },
324            ) => ap == bp && patch_input_same_kind(a, b),
325            _ => false,
326        }
327    }
328}
329
330impl Eq for CommitScope {}
331
332fn patch_input_same_kind(a: &CommitPatchInput, b: &CommitPatchInput) -> bool {
333    matches!(
334        (a, b),
335        (CommitPatchInput::Hunks(_), CommitPatchInput::Hunks(_))
336            | (CommitPatchInput::Patches(_), CommitPatchInput::Patches(_))
337    )
338}
339
340/// Hunk ids or parsed patches for [`CommitScope::Patch`].
341#[derive(Debug, Clone)]
342pub enum CommitPatchInput {
343    /// Stable ids from `grit diff --json`.
344    Hunks(HunkSelection),
345    /// Unified diff hunks (`git apply --cached` preimage = index).
346    Patches(Vec<FilePatch>),
347}
348
349/// Failure while resolving or applying a patch-scoped commit.
350#[derive(Debug, Error)]
351pub enum CommitPatchError {
352    /// Hunk id resolution failed (unknown, stale, wrong side, binary).
353    #[error(transparent)]
354    Select(#[from] HunkSelectError),
355    /// Index apply failed after resolution.
356    #[error(transparent)]
357    Apply(#[from] ApplyError),
358    /// Commit, index, or repository error.
359    #[error(transparent)]
360    Commit(#[from] Error),
361}
362
363/// Default [`CommitScope`] from `commit.autoStage` (default true → [`CommitScope::AutoStage`]).
364///
365/// # Errors
366///
367/// Propagates config read failures from [`Repository::config`].
368pub fn default_commit_scope(repo: &Repository) -> Result<CommitScope> {
369    let config = repo.config()?;
370    Ok(default_commit_scope_from_config(config.as_ref()))
371}
372
373/// Read `commit.autoStage` (default true).
374#[must_use]
375pub fn default_commit_scope_from_config(config: &ConfigSet) -> CommitScope {
376    let auto_stage = config
377        .get_bool("commit.autoStage")
378        .and_then(|r| r.ok())
379        .unwrap_or(true);
380    if auto_stage {
381        CommitScope::AutoStage
382    } else {
383        CommitScope::Staged
384    }
385}
386
387/// Remove squash merge sentinel files after a successful commit (Git removes `SQUASH_MSG` on commit).
388///
389/// # Errors
390///
391/// Returns [`Error::Io`] if a present file cannot be removed.
392pub(crate) fn clear_squash_commit_state(git_dir: &std::path::Path) -> Result<()> {
393    let squash = git_dir.join("SQUASH_MSG");
394    if !squash.is_file() {
395        return Ok(());
396    }
397    fs::remove_file(&squash).map_err(Error::Io)?;
398    if !git_dir.join("MERGE_HEAD").is_file() {
399        let merge_msg = git_dir.join("MERGE_MSG");
400        if merge_msg.is_file() {
401            fs::remove_file(&merge_msg).map_err(Error::Io)?;
402        }
403    }
404    Ok(())
405}
406
407/// Default message for the next commit when `SQUASH_MSG` or merge `MERGE_MSG` is present.
408///
409/// # Errors
410///
411/// Propagates I/O and merge-state read failures.
412pub fn pending_commit_message(repo: &Repository) -> Result<Option<String>> {
413    let git_dir = repo.git_dir();
414    let squash = git_dir.join("SQUASH_MSG");
415    let merge_msg = git_dir.join("MERGE_MSG");
416    // Squash merge conflicts store the editable message (including `-m`) in `MERGE_MSG`.
417    if squash.is_file() && merge_msg.is_file() {
418        return fs::read_to_string(merge_msg).map_err(Error::Io).map(Some);
419    }
420    if squash.is_file() {
421        return fs::read_to_string(squash).map_err(Error::Io).map(Some);
422    }
423    if !read_merge_heads(git_dir)?.is_empty() {
424        return read_merge_msg(git_dir);
425    }
426    if read_cherry_pick_head(git_dir)?.is_some() || read_revert_head(git_dir)?.is_some() {
427        let Some(raw) = read_merge_msg(git_dir)? else {
428            return Ok(None);
429        };
430        let processed = process(raw.as_bytes(), &Mode::StripComments("#".to_owned()));
431        let message = String::from_utf8(processed).map_err(|_| Error::EmptyCommitMessage)?;
432        if message.trim().is_empty() {
433            return Ok(None);
434        }
435        return Ok(Some(message));
436    }
437    if grit_rebase_conflict_stop_active(git_dir) {
438        let Some(raw) = read_merge_msg(git_dir)? else {
439            return Ok(None);
440        };
441        let processed = process(raw.as_bytes(), &Mode::StripComments("#".to_owned()));
442        let message = String::from_utf8(processed).map_err(|_| Error::EmptyCommitMessage)?;
443        if message.trim().is_empty() {
444            return Ok(None);
445        }
446        return Ok(Some(message));
447    }
448    Ok(None)
449}
450
451fn replay_blocks_partial_commit(git_dir: &std::path::Path) -> Result<bool> {
452    Ok(read_cherry_pick_head(git_dir)?.is_some() || read_revert_head(git_dir)?.is_some())
453}
454
455/// Record a commit using `scope` to decide how the index is prepared.
456///
457/// Staging reports [`crate::progress::ProgressPhase::StagingFiles`] to `progress`; `cancel` (or
458/// [`ProgressSink::should_cancel`]) stops the commit with [`Error::Cancelled`] up to the point
459/// the commit object is written, leaving `HEAD` and the branch where they were.
460///
461/// # Errors
462///
463/// Same as [`create_commit`] and [`commit_worktree`], plus:
464///
465/// - [`Error::CommitPathsRequired`] for an empty [`CommitScope::Paths`] list.
466/// - [`Error::PartialCommitDuringMerge`] for [`CommitScope::Paths`] or [`CommitScope::Patch`]
467///   during an in-progress merge, and pathspec validation errors.
468/// - [`Error::HunkSelection`] when a [`CommitScope::Patch`] hunk id is unknown, stale, binary, or
469///   for the wrong side, and [`Error::Apply`] when the selected patch does not apply to the
470///   index. These carry the same values as [`CommitPatchError::Select`] and
471///   [`CommitPatchError::Apply`] from [`commit_with_patch`].
472pub fn commit_with_scope(
473    repo: &Repository,
474    req: &CommitRequest,
475    scope: CommitScope,
476    progress: &mut dyn ProgressSink,
477    cancel: &dyn Cancel,
478) -> Result<CommitOutcome> {
479    guard_incomplete_rewrite_apply(repo)?;
480    match scope {
481        CommitScope::AutoStage => commit_worktree(repo, req, progress, cancel),
482        CommitScope::Staged => {
483            let mut req = req.clone();
484            // Index stats were refreshed when paths were staged; skip full-tree refresh (#1622).
485            req.assume_worktree_synced = true;
486            req.index_only = true;
487            create_commit(repo, &req, progress, cancel)
488        }
489        CommitScope::Paths(paths) => commit_paths_scope(repo, req, &paths, progress, cancel),
490        CommitScope::Patch { input, pathspecs } => {
491            commit_with_patch(repo, req, input, &pathspecs, progress, cancel).map_err(|e| match e {
492                CommitPatchError::Select(err) => Error::HunkSelection(err),
493                CommitPatchError::Apply(err) => Error::Apply(err),
494                CommitPatchError::Commit(err) => err,
495            })
496        }
497    }
498}
499
500/// Record a commit after applying `input` to the current index (partial stage + commit).
501///
502/// Applies the selection in memory, commits the resulting tree, then writes the updated index.
503/// Does not auto-stage the worktree (same effective scope as [`CommitScope::Staged`]).
504///
505/// # Errors
506///
507/// [`CommitPatchError::Select`] for hunk id failures; [`CommitPatchError::Apply`] for patch apply;
508/// same commit errors as [`create_commit`].
509pub fn commit_with_patch(
510    repo: &Repository,
511    req: &CommitRequest,
512    input: CommitPatchInput,
513    pathspecs: &[String],
514    progress: &mut dyn ProgressSink,
515    cancel: &dyn Cancel,
516) -> std::result::Result<CommitOutcome, CommitPatchError> {
517    guard_incomplete_rewrite_apply(repo).map_err(CommitPatchError::Commit)?;
518    commit_patch_scope(repo, req, input, pathspecs, progress, cancel)
519}
520
521fn commit_patch_scope(
522    repo: &Repository,
523    req: &CommitRequest,
524    input: CommitPatchInput,
525    pathspecs: &[String],
526    progress: &mut dyn ProgressSink,
527    cancel: &dyn Cancel,
528) -> std::result::Result<CommitOutcome, CommitPatchError> {
529    if !read_merge_heads(repo.git_dir())?.is_empty() {
530        return Err(Error::PartialCommitDuringMerge.into());
531    }
532    if replay_blocks_partial_commit(repo.git_dir())? {
533        return Err(Error::PartialCommitDuringReplay.into());
534    }
535    let index_lock = (!req.dry_run)
536        .then(|| repo.begin_index_update())
537        .transpose()?;
538
539    let patches = match input {
540        CommitPatchInput::Hunks(selection) => {
541            if selection.ids.is_empty() && selection.ranges.is_empty() {
542                return Err(Error::NothingToCommit.into());
543            }
544            resolve_selection(
545                repo,
546                crate::diff::hunk_id::DiffSide::Unstaged,
547                &selection,
548                pathspecs,
549                diff_options_for_hunk_select(repo).map_err(CommitPatchError::Commit)?,
550            )?
551        }
552        CommitPatchInput::Patches(patches) => {
553            if patches.is_empty() {
554                return Err(Error::NothingToCommit.into());
555            }
556            patches
557        }
558    };
559
560    let head = resolve_head_for_repository(repo).map_err(CommitPatchError::Commit)?;
561    let mut index = repo.load_index().map_err(CommitPatchError::Commit)?;
562    if index.has_unmerged_entries() {
563        return Err(Error::IndexUnmerged.into());
564    }
565
566    let dry_run = req.dry_run;
567    let _patch_overlay = if dry_run {
568        repo.odb().enable_mem_overlay();
569        Some(MemOverlayGuard(repo.odb()))
570    } else {
571        None
572    };
573
574    apply_patch_to_index(
575        repo,
576        &mut index,
577        &patches,
578        &IndexApplyOptions {
579            dry_run: false,
580            ..IndexApplyOptions::default()
581        },
582    )?;
583
584    let mut req = req.clone();
585    req.assume_worktree_synced = true;
586    let persist = index_lock.map(|lock| LockedIndex { index: None, lock });
587    create_commit_with_index(repo, head, &mut index, &req, progress, cancel, persist)
588        .map_err(CommitPatchError::Commit)
589}
590
591/// Preview a commit for `scope` without mutating the repository (objects, index on disk, refs, hooks).
592///
593/// Uses in-memory blob hashing during staging (no new loose objects) and returns the same validation
594/// errors as [`commit_with_scope`] for unmerged index and nothing-to-commit.
595///
596/// # Errors
597///
598/// Same as [`commit_with_scope`].
599pub fn plan_commit(
600    repo: &Repository,
601    req: &CommitRequest,
602    scope: CommitScope,
603    progress: &mut dyn ProgressSink,
604    cancel: &dyn Cancel,
605) -> Result<CommitPlan> {
606    let mut req = req.clone();
607    req.dry_run = true;
608    let outcome = commit_with_scope(repo, &req, scope, progress, cancel)?;
609    Ok(CommitPlan {
610        files: outcome.files,
611        parents: outcome.parents,
612        author: outcome.author,
613        tree_unchanged: outcome.changes == 0,
614    })
615}
616
617/// Copy stage-0 rows from `source` into `commit_index` when they are absent from the parent-tree
618/// snapshot but needed for path-scoped staging.
619///
620/// Path-scoped commits build `commit_index` from the parent tree. Ignored paths that are
621/// already staged in the repository index (force-added or tracked) must appear there before
622/// worktree staging runs, or ignored untracked collection skips them. Rows already present from
623/// the parent tree are left untouched so explicitly excluded paths keep their HEAD versions.
624fn seed_commit_index_stage0_from_index(
625    repo: &Repository,
626    commit_index: &mut Index,
627    source: &Index,
628    paths: &ResolvedPathspecs,
629) -> Result<()> {
630    if paths.specs.is_empty() {
631        return Ok(());
632    }
633    let ctx = repo.pathspec_match_context();
634    let mut seeded = false;
635    for entry in &source.entries {
636        if entry.stage() != 0 {
637            continue;
638        }
639        let path = match std::str::from_utf8(&entry.path) {
640            Ok(p) if !p.is_empty() => p,
641            _ => continue,
642        };
643        if commit_index.get(path.as_bytes(), 0).is_some() {
644            continue;
645        }
646        if !matches_pathspec_list_with_context(path, &paths.specs, ctx) {
647            continue;
648        }
649        commit_index.stage_file(entry.clone());
650        seeded = true;
651    }
652    if seeded {
653        commit_index.ensure_canonical_order();
654    }
655    Ok(())
656}
657
658fn commit_paths_scope(
659    repo: &Repository,
660    req: &CommitRequest,
661    paths: &ResolvedPathspecs,
662    progress: &mut dyn ProgressSink,
663    cancel: &dyn Cancel,
664) -> Result<CommitOutcome> {
665    if paths.specs.is_empty() {
666        return Err(Error::CommitPathsRequired);
667    }
668    if !read_merge_heads(repo.git_dir())?.is_empty() {
669        return Err(Error::PartialCommitDuringMerge);
670    }
671    if replay_blocks_partial_commit(repo.git_dir())? {
672        return Err(Error::PartialCommitDuringReplay);
673    }
674    validate_commit_pathspecs(repo, paths)?;
675
676    // Lock before reading HEAD and the index so nothing visible (objects, ref, reflog) is written
677    // when another process holds it, and so nobody can commit or change the index under us.
678    let index_lock = (!req.dry_run)
679        .then(|| repo.begin_index_update())
680        .transpose()?;
681    let head = resolve_head_for_repository(repo)?;
682    let parent_tree = match &head {
683        HeadState::Branch { oid: Some(p), .. } | HeadState::Detached { oid: p } => {
684            Some(repo.odb().commit_tree(p)?)
685        }
686        HeadState::Branch { oid: None, .. } => None,
687        HeadState::Invalid => {
688            return Err(Error::InvalidHead);
689        }
690    };
691
692    let mut real_index = repo.load_index()?;
693    if real_index.has_unmerged_entries() {
694        return Err(Error::IndexUnmerged);
695    }
696    let mut stage_opts = StageOptions {
697        pathspecs: paths.specs.clone(),
698        pathspec_sources: if paths.sources.is_empty() {
699            paths.specs.clone()
700        } else {
701            paths.sources.clone()
702        },
703        mode: StageMode::All,
704        dry_run: req.dry_run,
705        ..StageOptions::default()
706    };
707
708    validate_commit_staging_pathspecs(repo, &real_index, &stage_opts)?;
709    stage_opts.skip_pathspec_ignore_check = true;
710
711    let mut commit_index = match parent_tree.as_ref() {
712        Some(tree) => index_from_tree_with_seeded_worktree_stats(repo, tree, &real_index)?,
713        None => Index::new(),
714    };
715    seed_commit_index_stage0_from_index(repo, &mut commit_index, &real_index, paths)?;
716
717    let staged_commit = stage_on_index(repo, &mut commit_index, &stage_opts, progress, cancel)?;
718    let real_index = match index_lock {
719        Some(lock) => {
720            stage_on_index(repo, &mut real_index, &stage_opts, progress, cancel)?;
721            Some(LockedIndex {
722                index: Some(real_index),
723                lock,
724            })
725        }
726        None => None,
727    };
728
729    let mut req = req.clone();
730    req.assume_worktree_synced = true;
731    req.skip_pre_commit_tree_match = false;
732    req.staged_change_count = Some(staged_commit.total().max(1));
733    req.index_only = true;
734
735    create_commit_with_index(
736        repo,
737        head,
738        &mut commit_index,
739        &req,
740        progress,
741        cancel,
742        real_index,
743    )
744}
745
746/// The repository index to persist after a commit, with the lock taken before the commit started.
747///
748/// Hooks read a temporary copy of the commit index; the repository index is written through the
749/// lock only after the ref moved. Dropping it unwritten releases the lock and leaves the on-disk
750/// index untouched.
751struct LockedIndex {
752    /// Index to persist when it differs from the committed one (path-scoped commits keep other
753    /// staged changes); `None` persists the committed index itself.
754    index: Option<Index>,
755    lock: IndexLock,
756}
757
758fn validate_commit_pathspecs(repo: &Repository, paths: &ResolvedPathspecs) -> Result<()> {
759    let pairs: Vec<(&str, &str)> =
760        if paths.sources.len() == paths.specs.len() && !paths.sources.is_empty() {
761            paths
762                .sources
763                .iter()
764                .zip(paths.specs.iter())
765                .map(|(a, b)| (a.as_str(), b.as_str()))
766                .collect()
767        } else {
768            paths
769                .specs
770                .iter()
771                .map(|s| (s.as_str(), s.as_str()))
772                .collect()
773        };
774    let mut checker = KnownPathspecChecker::new(repo);
775    for (source, resolved) in pairs {
776        if pathspec_is_exclude(source) {
777            continue;
778        }
779        checker.ensure(source, resolved)?;
780    }
781    Ok(())
782}
783
784/// Normalize a commit message body the same way [`create_commit`] does before hooks: stripspace,
785/// reject empty bodies, ensure a trailing newline.
786///
787/// # Errors
788///
789/// Returns [`Error::EmptyCommitMessage`] when the cleaned body would be empty.
790pub fn normalize_commit_message(raw: &str) -> Result<String> {
791    let normalized = raw.replace("\r\n", "\n").replace('\r', "\n");
792    let processed = process(normalized.as_bytes(), &Mode::Default);
793    let message = String::from_utf8(processed)
794        .map_err(|_| Error::Message("invalid commit message".into()))?;
795    if message.is_empty() {
796        return Err(Error::EmptyCommitMessage);
797    }
798    let mut message = message;
799    while message.ends_with("\n\n") {
800        message.pop();
801    }
802    if !message.ends_with('\n') {
803        message.push('\n');
804    }
805    Ok(message)
806}
807
808fn run_commit_hook_recorded(
809    repo: &Repository,
810    hook: HookName,
811    args: &[&str],
812    stdin_data: Option<&[u8]>,
813    commit_env: &CommitHookEnv<'_>,
814) -> std::result::Result<bool, HookError> {
815    run_commit_hook(repo, hook.as_str(), args, stdin_data, commit_env)?.check(hook.as_str())
816}
817
818fn author_info_from_header_line(line: &str) -> CommitAuthorInfo {
819    let line = line.trim();
820    let date = parse_signature_times(line)
821        .map(|p| {
822            let tz = &line[p.tz_hhmm_range];
823            format!("{} {tz}", p.unix_seconds)
824        })
825        .unwrap_or_default();
826    let contact_end = line.rfind('>').map(|i| i + 1).unwrap_or(line.len());
827    let contact = line[..contact_end].trim();
828    let (name, email) = if let Some(lt) = contact.find('<') {
829        if let Some(gt) = contact.find('>') {
830            (contact[..lt].trim(), contact[lt + 1..gt].trim())
831        } else {
832            (contact, "")
833        }
834    } else if contact.contains('@') && !contact.chars().any(char::is_whitespace) {
835        ("", contact)
836    } else {
837        (contact, "")
838    };
839    CommitAuthorInfo {
840        name: name.to_owned(),
841        email: email.to_owned(),
842        date,
843    }
844}
845
846fn author_line_from_committer(
847    committer: &str,
848    author_date: Option<&str>,
849    now: time::OffsetDateTime,
850) -> String {
851    let info = author_info_from_header_line(committer);
852    assemble_identity(&info.name, &info.email, author_date, now)
853}
854
855fn enrich_commit_message(
856    message: String,
857    trailers: &[(String, String)],
858    signoff: bool,
859    committer: &str,
860    config: &ConfigSet,
861    git_dir: &Path,
862) -> Result<String> {
863    let mut out = message;
864    if signoff {
865        let info = author_info_from_header_line(committer);
866        let sob = format_signoff_line(&info.name, &info.email);
867        append_signoff_trailer_with_dedup(&mut out, &sob, config, true);
868    }
869    if !trailers.is_empty() {
870        let new_args: Vec<NewTrailerArg> = trailers
871            .iter()
872            .map(|(key, value)| NewTrailerArg {
873                text: if value.is_empty() {
874                    format!("{key}:")
875                } else {
876                    format!("{key}: {value}")
877                },
878                where_: TrailerWhere::End,
879                if_exists: TrailerIfExists::AddIfDifferent,
880                if_missing: TrailerIfMissing::Add,
881            })
882            .collect();
883        let opts = ProcessTrailerOptions {
884            only_trailers: false,
885            trim_empty: true,
886            only_input: false,
887            unfold: false,
888            no_divider: false,
889        };
890        out = process_trailers(&out, &opts, &new_args, Some(git_dir));
891    }
892    Ok(out)
893}
894
895/// Result of [`create_commit`].
896#[derive(Debug, Clone, PartialEq, Eq)]
897#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
898pub struct CommitOutcome {
899    /// OID of the new commit object written to the object database.
900    pub oid: ObjectId,
901    /// Short name of the branch the commit advanced (e.g. `main`), or `None` when `HEAD` is
902    /// detached and only `HEAD` moved.
903    pub branch: Option<String>,
904    /// Parent commit OID, or `None` for the root commit on an unborn branch.
905    pub parent: Option<ObjectId>,
906    /// Every parent OID stored in the new commit object (empty only before the first commit is written).
907    pub parents: Vec<ObjectId>,
908    /// Author recorded on the commit object.
909    pub author: CommitAuthorInfo,
910    /// Hooks that were installed and ran during this commit, in order: `pre-commit`,
911    /// `prepare-commit-msg`, `commit-msg`, `post-commit`, and `post-rewrite` (with `amend`) when
912    /// [`CommitRequest::amend`] is set.
913    pub hooks_run: Vec<HookName>,
914    /// Number of path-level changes between the parent commit tree and this commit's tree.
915    pub changes: usize,
916    /// Stored `change-id` header when present on the new commit object.
917    pub change_id: Option<ChangeId>,
918    /// Path-level changes vs the parent tree (`git diff --name-status` between parent and new tree).
919    pub files: Vec<CommitFile>,
920}
921
922impl CommitOutcome {
923    /// Whether the commit was made on a detached `HEAD` ([`Self::branch`] is `None`).
924    #[must_use]
925    pub fn is_detached(&self) -> bool {
926        self.branch.is_none()
927    }
928}
929
930/// Write a commit from the current index and advance the checked-out branch.
931///
932/// Loads the index from disk, builds the commit tree with an incremental cache-tree
933/// write-tree, writes the commit object, writes the index back with an updated cache-tree,
934/// then atomically updates the branch ref and reflogs (see [`crate::refs::update_branch_for_commit_on_store`]).
935///
936/// # Parameters
937///
938/// - `repo` — open repository with a work tree and branch `HEAD`.
939/// - `req` — message, author/committer identities, and empty-commit policy.
940/// - `progress` — progress sink; staging callers see [`crate::progress::ProgressPhase::StagingFiles`].
941/// - `cancel` — checked before anything is written and again before the commit object is
942///   written; after that the branch update always completes.
943///
944/// # Errors
945///
946/// - [`Error::Cancelled`] when `cancel` or [`ProgressSink::should_cancel`] fired before the
947///   commit object was written; `HEAD` and the branch are unchanged. Hook failures and cancel
948///   after hooks leave the on-disk index unchanged when the caller holds an index lock (for
949///   example [`commit_worktree`] and path-scoped commits).
950/// - [`Error::AmendUnborn`] when [`CommitRequest::amend`] is set but the branch has no commits yet.
951/// - [`Error::NothingToCommit`] when the new tree is empty (unborn branch) or matches the parent tree and [`CommitRequest::allow_empty`] is false (not applied when amending).
952/// - [`Error::IndexUnmerged`] when the index has conflict stages.
953/// - Index write or diff failures before the branch is updated; the branch tip is not advanced.
954/// - Ref/reflog failures from [`crate::refs::update_branch_for_commit_on_store`]; the branch tip is not left advanced without reflogs when logging is enabled.
955pub fn create_commit(
956    repo: &Repository,
957    req: &CommitRequest,
958    progress: &mut dyn ProgressSink,
959    cancel: &dyn Cancel,
960) -> Result<CommitOutcome> {
961    guard_incomplete_rewrite_apply(repo)?;
962    let head = resolve_head_for_repository(repo)?;
963    let mut index = repo.load_index()?;
964    create_commit_with_index(repo, head, &mut index, req, progress, cancel, None)
965}
966
967/// Stage every worktree change and record a commit using a single in-memory index (issue #1064).
968///
969/// Avoids an extra index load/write between implicit staging and commit tree construction. The
970/// staging pass is the only work-tree scan: it refreshes the stat data of every tracked entry, so
971/// the commit always runs as if [`CommitRequest::assume_worktree_synced`] were set.
972///
973/// When staging changed the index but the result matches the parent tree (for example a new
974/// file that was staged and then deleted from the work tree), the staged index is still written
975/// before [`Error::NothingToCommit`] is returned, so the on-disk index matches `HEAD` afterwards.
976/// [`CommitRequest::dry_run`] never writes the index, refs, or stored objects. Symlinks are
977/// compared and previewed as link-target bytes, including directory and dangling links.
978///
979/// Staging reports [`crate::progress::ProgressPhase::StagingFiles`] and polls `cancel`; a cancel
980/// during staging returns [`Error::Cancelled`] with the on-disk index, `HEAD` and refs unchanged.
981/// A failing `pre-commit`, `prepare-commit-msg`, or `commit-msg` hook also leaves the on-disk
982/// index and worktree unchanged (issue #2253): auto-staging runs in memory and hooks read a
983/// temporary index file until the ref moves.
984///
985/// # Errors
986///
987/// Same as [`create_commit`], plus staging failures from [`crate::porcelain::add::stage_for_commit`],
988/// including [`Error::UnmergedConflictMarkers`] when a path's conflict stages were dropped
989/// (after a merge, pick, revert, rebase or stash pop) while its worktree file still has
990/// a conflict-marker block that its `HEAD` version does not. Neither the index nor `HEAD` moves in that case.
991pub fn commit_worktree(
992    repo: &Repository,
993    req: &CommitRequest,
994    progress: &mut dyn ProgressSink,
995    cancel: &dyn Cancel,
996) -> Result<CommitOutcome> {
997    guard_incomplete_rewrite_apply(repo)?;
998    let index_lock = (!req.dry_run)
999        .then(|| repo.begin_index_update())
1000        .transpose()?;
1001    let head = resolve_head_for_repository(repo)?;
1002    let mut index = repo.load_index()?;
1003    let staged = crate::porcelain::add::stage_for_commit_on_index(
1004        repo,
1005        &mut index,
1006        progress,
1007        cancel,
1008        req.dry_run,
1009    )?;
1010    let staged_count = staged.total();
1011    let stat_refreshed = staged.stat_refreshed;
1012    if index.has_unmerged_entries() {
1013        return Err(Error::IndexUnmerged);
1014    }
1015    let mut req = req.clone();
1016    req.assume_worktree_synced = true;
1017    if req.skip_pre_commit_tree_match {
1018        req.staged_change_count = Some(staged_count);
1019    }
1020    let persist = index_lock.map(|lock| LockedIndex { index: None, lock });
1021    let result = create_commit_with_index(repo, head, &mut index, &req, progress, cancel, persist);
1022    if matches!(result, Err(Error::NothingToCommit))
1023        && !req.dry_run
1024        && (staged_count > 0 || stat_refreshed)
1025    {
1026        repo.write_index(&mut index)?;
1027    }
1028    if matches!(result, Err(Error::NothingToCommit)) && staged_count == 0 {
1029        let mut worktree_index = match repo.load_index() {
1030            Ok(real) => real,
1031            Err(_) => index.clone(),
1032        };
1033        if let Ok((unstaged, untracked)) =
1034            crate::porcelain::status::count_unstaged_and_untracked_paths(repo, &mut worktree_index)
1035        {
1036            if unstaged + untracked > 0 {
1037                return Err(Error::NothingStagedToCommit {
1038                    unstaged,
1039                    untracked,
1040                });
1041            }
1042        }
1043    }
1044    result
1045}
1046
1047fn commit_files_from_diff_entries(entries: &[DiffEntry]) -> Vec<CommitFile> {
1048    let mut files: Vec<CommitFile> = entries
1049        .iter()
1050        .filter(|e| e.status != DiffStatus::Unmerged)
1051        .map(|e| {
1052            let (path, old_path) = match e.status {
1053                DiffStatus::Renamed | DiffStatus::Copied => {
1054                    (e.new_path.clone().unwrap_or_default(), e.old_path.clone())
1055                }
1056                DiffStatus::Deleted => (e.old_path.clone().unwrap_or_default(), None),
1057                _ => (e.new_path.clone().unwrap_or_default(), None),
1058            };
1059            CommitFile {
1060                path,
1061                status: e.status,
1062                old_path,
1063            }
1064        })
1065        .collect();
1066    files.sort_by(|a, b| a.path.cmp(&b.path));
1067    files
1068}
1069
1070fn commit_files_between_trees(
1071    odb: &crate::odb::Odb,
1072    old_tree: Option<&ObjectId>,
1073    new_tree: &ObjectId,
1074) -> Result<Vec<CommitFile>> {
1075    let entries = diff_trees_with_renames(odb, old_tree, Some(new_tree), "", 50)?;
1076    Ok(commit_files_from_diff_entries(&entries))
1077}
1078
1079/// Files of a root commit, read from the index its tree was just written from: every path is
1080/// added, so this skips the whole-tree diff and its per-path entries.
1081///
1082/// Returns `None` when the index holds sparse-directory placeholders, whose files only the
1083/// tree lists.
1084fn root_commit_files_from_index(index: &Index) -> Option<Vec<CommitFile>> {
1085    let mut files = Vec::with_capacity(index.entries().len());
1086    for entry in index.entries() {
1087        if entry.mode == crate::index::MODE_TREE {
1088            return None;
1089        }
1090        if entry.stage() != 0 || entry.intent_to_add() {
1091            continue;
1092        }
1093        files.push(CommitFile {
1094            path: crate::repo_relpath::storage_string(&entry.path),
1095            status: DiffStatus::Added,
1096            old_path: None,
1097        });
1098    }
1099    files.sort_by(|a, b| a.path.cmp(&b.path));
1100    Some(files)
1101}
1102
1103fn commit_files_index_vs_parent_tree(
1104    odb: &crate::odb::Odb,
1105    index: &Index,
1106    parent_tree: Option<&ObjectId>,
1107) -> Result<Vec<CommitFile>> {
1108    let mut parent: BTreeMap<String, (FileMode, ObjectId)> = BTreeMap::new();
1109    if let Some(tree) = parent_tree {
1110        for (path, mode, oid) in flatten_tree_entries(odb, tree)? {
1111            parent.insert(path, (mode, oid));
1112        }
1113    }
1114    let mut files = Vec::new();
1115    for entry in index.entries() {
1116        if entry.stage() != 0 || entry.intent_to_add() {
1117            continue;
1118        }
1119        let path = String::from_utf8_lossy(&entry.path).into_owned();
1120        match parent.remove(&path) {
1121            None => files.push(CommitFile {
1122                path,
1123                status: DiffStatus::Added,
1124                old_path: None,
1125            }),
1126            Some((old_mode, old_oid)) if old_oid != entry.oid || entry.mode != old_mode.bits() => {
1127                files.push(CommitFile {
1128                    path,
1129                    status: DiffStatus::for_content_change(old_mode.bits(), entry.mode),
1130                    old_path: None,
1131                });
1132            }
1133            _ => {}
1134        }
1135    }
1136    for (path, _) in parent {
1137        files.push(CommitFile {
1138            path,
1139            status: DiffStatus::Deleted,
1140            old_path: None,
1141        });
1142    }
1143    files.sort_by(|a, b| a.path.cmp(&b.path));
1144    Ok(files)
1145}
1146
1147struct MemOverlayGuard<'a>(&'a Odb);
1148
1149impl Drop for MemOverlayGuard<'_> {
1150    fn drop(&mut self) {
1151        self.0.disable_mem_overlay();
1152    }
1153}
1154
1155fn materialize_missing_index_blobs_for_overlay(repo: &Repository, index: &Index) -> Result<()> {
1156    let odb = repo.odb();
1157    let work_tree = repo.work_tree().ok_or(Error::RequiresWorkTree)?;
1158    let rules = WorktreeRules::from_repository(repo, index)?;
1159    for entry in index
1160        .entries
1161        .iter()
1162        .filter(|e| e.stage() == 0 && !e.intent_to_add())
1163    {
1164        if odb.contains(&entry.oid)? {
1165            continue;
1166        }
1167        let rel = String::from_utf8_lossy(&entry.path);
1168        let abs = crate::index::worktree_path_from_index_rel(work_tree, &entry.path);
1169        let meta = fs::symlink_metadata(&abs).map_err(Error::Io)?;
1170        let file_attrs = rules.file_attrs(&rel, false);
1171        let stored = materialize_worktree_blob(
1172            odb,
1173            &abs,
1174            &meta,
1175            rules.conversion(),
1176            &file_attrs,
1177            &rel,
1178            Some(entry),
1179            Some(rules.filter_process()),
1180        )?;
1181        if stored != entry.oid {
1182            return Err(Error::Message(format!(
1183                "internal error: staged oid mismatch for {rel}"
1184            )));
1185        }
1186    }
1187    Ok(())
1188}
1189
1190#[allow(clippy::too_many_arguments)]
1191fn finish_commit_dry_run(
1192    repo: &Repository,
1193    index: &mut Index,
1194    req: &CommitRequest,
1195    short_name: Option<String>,
1196    parent: Option<ObjectId>,
1197    parent_tree: Option<ObjectId>,
1198    merge_heads: Vec<ObjectId>,
1199    concluding_merge: bool,
1200    amend_author: Option<String>,
1201    amend_author_raw: Vec<u8>,
1202    amend_parents: Vec<ObjectId>,
1203    amend_default_message: Option<String>,
1204    req_amend: bool,
1205    reuse_author: Option<String>,
1206    reuse_author_raw: Vec<u8>,
1207    reuse_message: Option<String>,
1208) -> Result<CommitOutcome> {
1209    let odb = repo.odb();
1210    odb.enable_mem_overlay();
1211    let _overlay = MemOverlayGuard(odb);
1212    materialize_missing_index_blobs_for_overlay(repo, index)?;
1213    let files = commit_files_index_vs_parent_tree(odb, index, parent_tree.as_ref())?;
1214
1215    if !req.allow_empty && !req_amend {
1216        if parent_tree.is_none() && index.entries.is_empty() {
1217            return Err(empty_commit_error(repo, index, req));
1218        }
1219        if !concluding_merge && files.is_empty() {
1220            return Err(empty_commit_error(repo, index, req));
1221        }
1222    }
1223
1224    let mut message = if !req.message.trim().is_empty() {
1225        normalize_commit_message(&req.message)?
1226    } else if let Some(reuse_msg) = reuse_message {
1227        reuse_msg
1228    } else if req_amend {
1229        amend_default_message.unwrap_or_default()
1230    } else if let Some(pending) = pending_commit_message(repo)? {
1231        normalize_commit_message(&pending)?
1232    } else {
1233        return Err(Error::EmptyCommitMessage);
1234    };
1235    if message.trim().is_empty() {
1236        return Err(Error::EmptyCommitMessage);
1237    }
1238    if !message.ends_with('\n') {
1239        message.push('\n');
1240    }
1241    let config = repo.config()?;
1242    let now = repo.identity_now();
1243    let committer = commit_identity_line(repo, req.committer.as_ref(), IdentRole::Committer, now)?;
1244    let _message_with_trailers = enrich_commit_message(
1245        message,
1246        &req.trailers,
1247        req.signoff,
1248        &committer,
1249        config.as_ref(),
1250        repo.git_dir(),
1251    )?;
1252
1253    let (author, _author_raw) = resolve_author_for_commit(
1254        repo,
1255        req,
1256        &committer,
1257        amend_author,
1258        amend_author_raw,
1259        reuse_author,
1260        reuse_author_raw,
1261        req_amend,
1262    )?;
1263    let mut commit_parents = if req_amend {
1264        amend_parents
1265    } else {
1266        parent.into_iter().collect()
1267    };
1268    if !req_amend {
1269        for merge_head in merge_heads {
1270            if !commit_parents.iter().any(|p| p == &merge_head) {
1271                commit_parents.push(merge_head);
1272            }
1273        }
1274    }
1275    let author_info = author_info_from_header_line(&author);
1276    let changes = files.len();
1277
1278    Ok(CommitOutcome {
1279        oid: zero_oid(),
1280        branch: short_name,
1281        parent,
1282        parents: commit_parents,
1283        author: author_info,
1284        hooks_run: Vec::new(),
1285        changes,
1286        change_id: None,
1287        files,
1288    })
1289}
1290
1291#[allow(clippy::too_many_arguments)]
1292fn resolve_author_for_commit(
1293    repo: &Repository,
1294    req: &CommitRequest,
1295    committer: &str,
1296    amend_author: Option<String>,
1297    amend_author_raw: Vec<u8>,
1298    reuse_author: Option<String>,
1299    reuse_author_raw: Vec<u8>,
1300    req_amend: bool,
1301) -> Result<(String, Vec<u8>)> {
1302    let now = repo.identity_now();
1303    if req.reset_author {
1304        return Ok((
1305            author_line_from_committer(committer, req.author_date.as_deref(), now),
1306            Vec::new(),
1307        ));
1308    }
1309    if let Some(author) = req.author.as_ref() {
1310        author.validate()?;
1311        let user_line = author.header_line();
1312        if req.author_date.is_none() {
1313            if let Some(picked) = read_cherry_pick_head(repo.git_dir())? {
1314                let obj = repo.odb().read(&picked)?;
1315                if obj.kind == ObjectKind::Commit {
1316                    let picked_commit = parse_commit(&obj.data)?;
1317                    if let Ok((picked_line, _)) = prepare_commit_author_from_parent(
1318                        &picked_commit.author,
1319                        &picked_commit.author_raw,
1320                        None,
1321                        now,
1322                    ) {
1323                        if let Some(date_suffix) = picked_line.split('>').nth(1).map(str::trim) {
1324                            if !date_suffix.is_empty() {
1325                                return Ok((
1326                                    replace_identity_date_suffix(&user_line, date_suffix),
1327                                    Vec::new(),
1328                                ));
1329                            }
1330                        }
1331                    }
1332                }
1333            }
1334        }
1335        let author = prepare_commit_identity_line(&user_line, req.author_date.as_deref(), now)?;
1336        return Ok((author, Vec::new()));
1337    }
1338    if let Some(a) = reuse_author {
1339        return Ok(prepare_commit_author_from_parent(
1340            &a,
1341            &reuse_author_raw,
1342            req.author_date.as_deref(),
1343            now,
1344        )?);
1345    }
1346    if !req_amend && !req.reset_author && req.author.is_none() {
1347        if let Some(picked) = read_cherry_pick_head(repo.git_dir())? {
1348            let picked_commit = repo.odb().read_commit(&picked)?;
1349            return Ok(prepare_commit_author_from_parent(
1350                &picked_commit.author,
1351                &picked_commit.author_raw,
1352                req.author_date.as_deref(),
1353                now,
1354            )?);
1355        }
1356        if let Some(stopped) = read_grit_rebase_stopped_source(repo.git_dir())? {
1357            let stopped_commit = repo.odb().read_commit(&stopped)?;
1358            return Ok(prepare_commit_author_from_parent(
1359                &stopped_commit.author,
1360                &stopped_commit.author_raw,
1361                req.author_date.as_deref(),
1362                now,
1363            )?);
1364        }
1365    }
1366    if req_amend {
1367        let author = amend_author.unwrap_or_default();
1368        return Ok(prepare_commit_author_from_parent(
1369            &author,
1370            &amend_author_raw,
1371            req.author_date.as_deref(),
1372            now,
1373        )?);
1374    }
1375    let author = repo.identity(IdentRole::Author, now)?.header_line();
1376    let author = prepare_commit_identity_line(&author, req.author_date.as_deref(), now)?;
1377    Ok((author, Vec::new()))
1378}
1379
1380/// The committer (or author) header line for a new commit: `identity` when given, otherwise
1381/// the repository's identity for `role`.
1382fn commit_identity_line(
1383    repo: &Repository,
1384    identity: Option<&Signature>,
1385    role: IdentRole,
1386    now: time::OffsetDateTime,
1387) -> Result<String> {
1388    match identity {
1389        Some(signature) => {
1390            signature.validate()?;
1391            Ok(signature.header_line())
1392        }
1393        None => Ok(repo.identity(role, now)?.header_line()),
1394    }
1395}
1396
1397/// Removes a partial-commit hook index file on drop (Git `next-index-<pid>`).
1398struct TempHookIndexFile(PathBuf);
1399
1400impl Drop for TempHookIndexFile {
1401    fn drop(&mut self) {
1402        let _ = fs::remove_file(&self.0);
1403    }
1404}
1405
1406/// Write a merge-continue (or other) commit from a caller-owned index.
1407///
1408/// The index must have no unmerged stages; it is written back with the new cache-tree.
1409pub(crate) fn create_commit_with_loaded_index(
1410    repo: &Repository,
1411    index: &mut Index,
1412    req: &CommitRequest,
1413    progress: &mut dyn ProgressSink,
1414    cancel: &dyn Cancel,
1415) -> Result<CommitOutcome> {
1416    if index.has_unmerged_entries() {
1417        return Err(Error::IndexUnmerged);
1418    }
1419    let head = resolve_head_for_repository(repo)?;
1420    create_commit_with_index(repo, head, index, req, progress, cancel, None)
1421}
1422
1423/// Record a commit of `index` on top of `head`.
1424///
1425/// `head` must be read before the content `index` was built from: the branch update then
1426/// compares against that same tip, so a commit landed by another process in between makes this
1427/// one fail instead of silently reverting it.
1428///
1429/// Cancellation is checked before anything is written and again after the pre-commit and
1430/// commit-msg hooks, before the commit object; once the object is written the ref update always
1431/// follows.
1432#[allow(clippy::too_many_arguments)]
1433fn create_commit_with_index(
1434    repo: &Repository,
1435    head: HeadState,
1436    index: &mut Index,
1437    req: &CommitRequest,
1438    progress: &mut dyn ProgressSink,
1439    cancel: &dyn Cancel,
1440    persist_index: Option<LockedIndex>,
1441) -> Result<CommitOutcome> {
1442    poll_cancel(progress, cancel)?;
1443    let (refname, short_name, mut parent) = match head {
1444        HeadState::Branch {
1445            refname,
1446            short_name,
1447            oid,
1448        } => (refname, Some(short_name), oid),
1449        HeadState::Detached { oid } => ("HEAD".to_owned(), None, Some(oid)),
1450        HeadState::Invalid => {
1451            return Err(Error::InvalidHead);
1452        }
1453    };
1454
1455    let mut amend_parents = Vec::new();
1456    let mut amend_author = None;
1457    let mut amend_author_raw = Vec::new();
1458    let mut amend_extra_headers = Vec::new();
1459    let mut amend_default_message = None;
1460    let mut replaced_tip = None;
1461    let mut reuse_author = None;
1462    let mut reuse_author_raw = Vec::new();
1463    let mut reuse_message = None;
1464    if let Some(reuse_oid) = req.reuse_message_from {
1465        let source = repo.odb().read_commit(&reuse_oid)?;
1466        reuse_message = Some(source.message);
1467        reuse_author = Some(source.author);
1468        reuse_author_raw = source.author_raw;
1469    }
1470    if req.amend {
1471        if replay_blocks_partial_commit(repo.git_dir())? {
1472            return Err(Error::PartialCommitDuringReplay);
1473        }
1474        let Some(tip) = parent.take() else {
1475            return Err(Error::AmendUnborn);
1476        };
1477        let old = repo.odb().read_commit(&tip)?;
1478        amend_parents = old.parents.clone();
1479        parent = old.parents.first().copied();
1480        amend_author = Some(old.author);
1481        amend_author_raw = old.author_raw;
1482        amend_extra_headers = extra_headers_without_commit_signatures(&old.extra_headers);
1483        amend_default_message = Some(old.message);
1484        replaced_tip = Some(tip);
1485    }
1486
1487    if index.has_unmerged_entries() {
1488        return Err(Error::IndexUnmerged);
1489    }
1490    let merge_heads = read_merge_heads(repo.git_dir())?;
1491    let concluding_merge = !merge_heads.is_empty();
1492    let index_path = repo.git_dir().join("index");
1493    let parent_tree = parent
1494        .as_ref()
1495        .map(|parent_oid| repo.odb().commit_tree(parent_oid))
1496        .transpose()?;
1497
1498    if !req.allow_empty && !req.amend && !concluding_merge {
1499        let skip_tree_match = req.assume_worktree_synced && req.skip_pre_commit_tree_match;
1500        if !skip_tree_match {
1501            if let Some(ref old) = parent_tree {
1502                if index_staged_tree_matches(repo.odb(), index, old) {
1503                    return Err(empty_commit_error(repo, index, req));
1504                }
1505            } else if index.entries.is_empty() {
1506                return Err(empty_commit_error(repo, index, req));
1507            }
1508        }
1509    }
1510
1511    if req.dry_run {
1512        return finish_commit_dry_run(
1513            repo,
1514            index,
1515            req,
1516            short_name,
1517            parent,
1518            parent_tree,
1519            merge_heads,
1520            concluding_merge,
1521            amend_author,
1522            amend_author_raw,
1523            amend_parents,
1524            amend_default_message,
1525            req.amend,
1526            reuse_author,
1527            reuse_author_raw,
1528            reuse_message,
1529        );
1530    }
1531
1532    let persist_through_lock = persist_index.is_some();
1533    let _temp_hook_index_guard = if persist_through_lock {
1534        let path = repo.git_dir().join(format!("next-index-{}", process::id()));
1535        repo.write_index_at(&path, index)?;
1536        Some(TempHookIndexFile(path))
1537    } else {
1538        repo.write_index(index)?;
1539        None
1540    };
1541
1542    let temp_hook_index_path = _temp_hook_index_guard.as_ref().map(|g| g.0.clone());
1543
1544    let hook_index_path: PathBuf = temp_hook_index_path
1545        .clone()
1546        .unwrap_or_else(|| index_path.clone());
1547
1548    let commit_env = CommitHookEnv {
1549        index_file: Some(hook_index_path.as_path()),
1550        git_editor: Some(":"),
1551        git_prefix: None,
1552        extra_env: &[],
1553    };
1554    let mut hooks_run = Vec::new();
1555    if req.hooks != HookPolicy::SkipVerify
1556        && run_commit_hook_recorded(repo, HookName::PreCommit, &[], None, &commit_env)?
1557    {
1558        hooks_run.push(HookName::PreCommit);
1559    }
1560
1561    if !req.amend && grit_rebase_conflict_stop_active(repo.git_dir()) {
1562        persist_rebase_stopped_head_if_missing(repo)
1563            .map_err(|e| Error::Message(format!("rebase resolution commit: {e}")))?;
1564    }
1565
1566    let message_source = if req.message_from_merge {
1567        MessageSource::Merge
1568    } else if !req.message.trim().is_empty() {
1569        MessageSource::Message
1570    } else if let Some(oid) = req.reuse_message_from.or(replaced_tip) {
1571        MessageSource::Commit(oid)
1572    } else if repo.git_dir().join("SQUASH_MSG").is_file() {
1573        MessageSource::Squash
1574    } else {
1575        MessageSource::Merge
1576    };
1577    let mut message = if !req.message.trim().is_empty() {
1578        normalize_commit_message(&req.message)?
1579    } else if let Some(reuse_msg) = reuse_message {
1580        reuse_msg
1581    } else if req.amend {
1582        amend_default_message.unwrap_or_default()
1583    } else if let Some(pending) = pending_commit_message(repo)? {
1584        normalize_commit_message(&pending)?
1585    } else {
1586        return Err(Error::EmptyCommitMessage);
1587    };
1588    if message.trim().is_empty() {
1589        return Err(Error::EmptyCommitMessage);
1590    }
1591    if !message.ends_with('\n') {
1592        message.push('\n');
1593    }
1594    let config = repo.config()?;
1595    let now = repo.identity_now();
1596    let committer = commit_identity_line(repo, req.committer.as_ref(), IdentRole::Committer, now)?;
1597    message = enrich_commit_message(
1598        message,
1599        &req.trailers,
1600        req.signoff,
1601        &committer,
1602        config.as_ref(),
1603        repo.git_dir(),
1604    )?;
1605    let editmsg_path = repo.git_dir().join("COMMIT_EDITMSG");
1606    fs::write(&editmsg_path, &message).map_err(Error::Io)?;
1607    // COMMIT_EDITMSG is shared by every committer in the repository, so it is only read back
1608    // when a hook may have edited it; otherwise the in-memory message wins.
1609    if let Some(edited) = run_commit_message_hooks(
1610        repo,
1611        &editmsg_path,
1612        message_source,
1613        req.hooks,
1614        &commit_env,
1615        &mut hooks_run,
1616    )? {
1617        message = edited;
1618    }
1619
1620    let (author, author_raw) = resolve_author_for_commit(
1621        repo,
1622        req,
1623        &committer,
1624        amend_author,
1625        amend_author_raw,
1626        reuse_author,
1627        reuse_author_raw,
1628        req.amend,
1629    )?;
1630
1631    let tree = write_tree_update_index(repo.odb(), index, "", WriteTreeFlags::silent())?;
1632    let root_files = parent_tree
1633        .is_none()
1634        .then(|| root_commit_files_from_index(index))
1635        .flatten();
1636    let commit_files = match root_files {
1637        Some(files) => files,
1638        None => commit_files_between_trees(repo.odb(), parent_tree.as_ref(), &tree)?,
1639    };
1640
1641    if !req.allow_empty && !req.amend {
1642        if parent_tree.is_none() && is_empty_tree_oid(repo.odb(), &tree) {
1643            return Err(empty_commit_error(repo, index, req));
1644        }
1645        if !concluding_merge {
1646            if let Some(ref old) = parent_tree {
1647                if old == &tree {
1648                    return Err(empty_commit_error(repo, index, req));
1649                }
1650            }
1651        }
1652    }
1653
1654    let mut commit_parents = if req.amend {
1655        amend_parents
1656    } else {
1657        parent.into_iter().collect()
1658    };
1659    if !req.amend {
1660        for merge_head in merge_heads {
1661            if !commit_parents.iter().any(|p| p == &merge_head) {
1662                commit_parents.push(merge_head);
1663            }
1664        }
1665    }
1666    let author_info = author_info_from_header_line(&author);
1667    let mut commit_data = CommitData {
1668        tree,
1669        parents: commit_parents.clone(),
1670        author,
1671        committer: committer.clone(),
1672        author_raw,
1673        committer_raw: Vec::new(),
1674        encoding: None,
1675        message,
1676        raw_message: None,
1677        extra_headers: amend_extra_headers,
1678    };
1679    let policy = ChangeIdPolicy::from_config(config.as_ref());
1680    if policy.enabled() {
1681        if req.amend {
1682            if change_id(&commit_data).is_none() {
1683                if let Some(tip) = replaced_tip {
1684                    set_change_id(&mut commit_data, &ChangeId::derived_from_commit_id(&tip));
1685                }
1686            }
1687        } else if let Some(stopped) = read_grit_rebase_stopped_source(repo.git_dir())? {
1688            let template = repo.odb().read_commit(&stopped)?;
1689            let id =
1690                change_id(&template).unwrap_or_else(|| ChangeId::derived_from_commit_id(&stopped));
1691            set_change_id(&mut commit_data, &id);
1692        } else {
1693            stamp_change_id_on_new_commit(repo, &policy, &mut commit_data)?;
1694        }
1695    }
1696    let bytes = prepare_commit_bytes(repo, config.as_ref(), &commit_data, req.sign_override)?;
1697    fsck_commit_idents(&bytes, repo.odb().hash_algo())
1698        .map_err(|detail| Error::CorruptObject(format!("commit would fail fsck: {detail}")))?;
1699    poll_cancel(progress, cancel)?;
1700    let oid = repo.odb().write(ObjectKind::Commit, &bytes)?;
1701
1702    let concluding_replay = read_cherry_pick_head(repo.git_dir())?.is_some()
1703        || read_revert_head(repo.git_dir())?.is_some();
1704    let previous_tip = replaced_tip.or(parent);
1705    let reflog_old = previous_tip.unwrap_or_else(zero_oid);
1706    let subject = commit_subject(&commit_data.message);
1707    let reflog_msg = if req.amend {
1708        format!("commit (amend): {subject}")
1709    } else if concluding_replay && read_cherry_pick_head(repo.git_dir())?.is_some() {
1710        format!("commit (cherry-pick): {subject}")
1711    } else if parent.is_some() {
1712        format!("commit: {subject}")
1713    } else {
1714        format!("commit (initial): {subject}")
1715    };
1716
1717    let expected_old = previous_tip.or_else(|| {
1718        matches!(repo.references().resolve_if_exists(&refname), Ok(Some(_))).then_some(reflog_old)
1719    });
1720
1721    if !persist_through_lock {
1722        if !req.assume_worktree_synced {
1723            if let Some(work_tree) = repo.work_tree() {
1724                let rules = WorktreeRules::from_repository(repo, index).ok();
1725                let rules_arc = rules.map(|r| Arc::new(Mutex::new(r)));
1726                let _ = refresh_index_stat_content_verified_with_rules(
1727                    repo.odb(),
1728                    repo.git_dir(),
1729                    index,
1730                    work_tree,
1731                    None,
1732                    Some(config.as_ref()),
1733                    None,
1734                    rules_arc.as_ref(),
1735                )?;
1736            }
1737        }
1738        repo.write_index(index)?;
1739    }
1740
1741    let changes = if parent_tree.as_ref() == Some(&tree) {
1742        0
1743    } else if !commit_files.is_empty() {
1744        commit_files.len()
1745    } else if req.assume_worktree_synced && req.skip_pre_commit_tree_match {
1746        // Large-index commits skip the tree diff for speed (issue #1064); smaller repos keep
1747        // accurate path counts for pre-staged and JSON output.
1748        const LARGE_INDEX_CHANGE_COUNT_FAST_PATH: usize = 4096;
1749        let large_index = index.entries.len() >= LARGE_INDEX_CHANGE_COUNT_FAST_PATH;
1750        if large_index {
1751            if let Some(n) = req.staged_change_count.filter(|&n| n > 0) {
1752                n
1753            } else {
1754                diff_tree_path_change_count(repo.odb(), parent_tree.as_ref(), Some(&tree), "")?
1755            }
1756        } else {
1757            diff_tree_path_change_count(repo.odb(), parent_tree.as_ref(), Some(&tree), "")?
1758        }
1759    } else {
1760        diff_tree_path_change_count(repo.odb(), parent_tree.as_ref(), Some(&tree), "")?
1761    };
1762
1763    if short_name.is_none() {
1764        update_detached_head_for_commit_on_store(
1765            repo.refs(),
1766            expected_old,
1767            oid,
1768            &committer,
1769            &reflog_msg,
1770            config.as_ref(),
1771        )?;
1772    } else {
1773        update_branch_for_commit_on_store(
1774            repo.refs(),
1775            &BranchCommitRefUpdate {
1776                branch_ref: &refname,
1777                expected_old,
1778                new_oid: oid,
1779                identity: &committer,
1780                reflog_message: &reflog_msg,
1781            },
1782            config.as_ref(),
1783        )?;
1784    }
1785
1786    if concluding_merge {
1787        let _ = finish_merge_state(repo)?;
1788    }
1789    if concluding_replay {
1790        finish_replay_commit_state(repo, oid)?;
1791    }
1792    clear_squash_commit_state(repo.git_dir())?;
1793
1794    if let Some(LockedIndex {
1795        index: mut separate,
1796        mut lock,
1797    }) = persist_index
1798    {
1799        let persist = separate.as_mut().unwrap_or(index);
1800        if !req.assume_worktree_synced {
1801            if let Some(work_tree) = repo.work_tree() {
1802                let rules = WorktreeRules::from_repository(repo, persist).ok();
1803                let rules_arc = rules.map(|r| Arc::new(Mutex::new(r)));
1804                let _ = refresh_index_stat_content_verified_with_rules(
1805                    repo.odb(),
1806                    repo.git_dir(),
1807                    persist,
1808                    work_tree,
1809                    None,
1810                    Some(config.as_ref()),
1811                    None,
1812                    rules_arc.as_ref(),
1813                )?;
1814            }
1815        }
1816        repo.write_index_with_lock(&mut lock, persist, false)?;
1817    }
1818
1819    let post_commit_env = CommitHookEnv {
1820        index_file: Some(
1821            temp_hook_index_path
1822                .as_deref()
1823                .unwrap_or(index_path.as_path()),
1824        ),
1825        git_editor: Some(":"),
1826        git_prefix: None,
1827        extra_env: &[],
1828    };
1829    if run_advisory_hook(
1830        repo,
1831        HookName::PostCommit,
1832        &[],
1833        None,
1834        Some(&post_commit_env),
1835    ) {
1836        hooks_run.push(HookName::PostCommit);
1837    }
1838    if let Some(old_tip) = replaced_tip.filter(|_| req.post_rewrite.enabled()) {
1839        if run_post_rewrite(repo, RewriteKind::Amend, &[(old_tip, oid)]) {
1840            hooks_run.push(HookName::PostRewrite);
1841        }
1842    }
1843
1844    let stored_change_id = change_id(&commit_data);
1845    Ok(CommitOutcome {
1846        oid,
1847        branch: short_name,
1848        parent,
1849        parents: commit_data.parents,
1850        author: author_info,
1851        hooks_run,
1852        changes,
1853        change_id: stored_change_id,
1854        files: commit_files,
1855    })
1856}
1857
1858/// When [`ChangeIdPolicy`] is enabled, assign a fresh id to a newly written commit.
1859///
1860/// Does not run on amends or history rewrites; callers pass an empty `extra_headers`
1861/// so source change ids never carry over (pick, revert, clean merge).
1862///
1863/// # Errors
1864///
1865/// Propagates change-id generation failures as [`Error::Message`].
1866pub(crate) fn stamp_change_id_on_new_commit(
1867    repo: &Repository,
1868    policy: &ChangeIdPolicy,
1869    data: &mut CommitData,
1870) -> Result<()> {
1871    if let Some(id) = policy
1872        .id_for_new_commit(
1873            data,
1874            repo.odb().hash_algo(),
1875            repo.change_id_generator().as_ref(),
1876        )
1877        .map_err(|e| Error::Message(format!("change id: {e}")))?
1878    {
1879        set_change_id(data, &id);
1880    }
1881    Ok(())
1882}
1883
1884/// Serialize `data` and sign the bytes when [`should_sign_commit`] says so.
1885///
1886/// Does not assign change ids. Callers that need a `change-id` header (for example
1887/// [`create_commit`]) set it with [`crate::change_id::set_change_id`] first. Stash,
1888/// notes, and other auxiliary commits use this path without adding ids.
1889///
1890/// # Errors
1891///
1892/// Propagates signing and config errors from [`GpgConfig::from_config`] and
1893/// [`sign_serialized_commit`].
1894pub fn prepare_commit_bytes(
1895    repo: &Repository,
1896    config: &ConfigSet,
1897    data: &CommitData,
1898    sign_override: Option<bool>,
1899) -> Result<Vec<u8>> {
1900    let unsigned = serialize_commit(data);
1901    if !should_sign_commit(config, sign_override)? {
1902        return Ok(unsigned);
1903    }
1904    let gpg = GpgConfig::from_config(config)?.with_command_runner(repo.command_runner());
1905    sign_serialized_commit(
1906        &gpg,
1907        &unsigned,
1908        &data.committer,
1909        None,
1910        repo.odb().hash_algo(),
1911    )
1912}
1913
1914/// Write a commit object to the ODB, honoring `commit.gpgsign` and `sign_override`.
1915///
1916/// Does not assign change ids; see [`prepare_commit_bytes`].
1917///
1918/// # Errors
1919///
1920/// Same as [`prepare_commit_bytes`], plus object-database write failures.
1921pub fn write_commit_object(
1922    repo: &Repository,
1923    data: &CommitData,
1924    sign_override: Option<bool>,
1925) -> Result<ObjectId> {
1926    let config = repo.config()?;
1927    let bytes = prepare_commit_bytes(repo, config.as_ref(), data, sign_override)?;
1928    repo.odb().write(ObjectKind::Commit, &bytes)
1929}
1930
1931fn empty_commit_error(repo: &Repository, index: &mut Index, req: &CommitRequest) -> Error {
1932    if req.index_only {
1933        let mut worktree_index = match repo.load_index() {
1934            Ok(real) => real,
1935            Err(_) => index.clone(),
1936        };
1937        if let Ok((unstaged, untracked)) =
1938            crate::porcelain::status::count_unstaged_and_untracked_paths(repo, &mut worktree_index)
1939        {
1940            if unstaged + untracked > 0 {
1941                return Error::NothingStagedToCommit {
1942                    unstaged,
1943                    untracked,
1944                };
1945            }
1946        }
1947    }
1948    Error::NothingToCommit
1949}
1950
1951/// True when stage-0 index entries describe `tree_oid`, using a valid cache-tree when present.
1952fn index_staged_tree_matches(
1953    odb: &crate::odb::Odb,
1954    index: &crate::index::Index,
1955    tree_oid: &ObjectId,
1956) -> bool {
1957    if index.cache_tree_root == Some(*tree_oid)
1958        && cache_tree_extension_valid(index.cache_tree.as_ref())
1959    {
1960        return true;
1961    }
1962    crate::diff::diff_index_to_tree(odb, index, Some(tree_oid), false)
1963        .map(|d| d.is_empty())
1964        .unwrap_or(false)
1965}
1966
1967fn commit_subject(message: &str) -> &str {
1968    message.split('\n').next().unwrap_or("").trim_end()
1969}
1970
1971/// Path passed as `argv[1]` to the `commit-msg` hook (Git: `.git/COMMIT_EDITMSG` when possible).
1972fn commit_editmsg_hook_argument(repo: &Repository, editmsg_path: &Path) -> String {
1973    if let Some(wt) = repo.work_tree() {
1974        if let Ok(rel) = editmsg_path.strip_prefix(wt) {
1975            let s = rel.to_string_lossy();
1976            if !s.is_empty() {
1977                return s.replace('\\', "/");
1978            }
1979        }
1980    }
1981    editmsg_path.to_string_lossy().into_owned()
1982}
1983
1984#[cfg(test)]
1985mod tests {
1986    #![allow(clippy::expect_used, clippy::unwrap_used)]
1987
1988    use crate::environment::RepositoryOptions;
1989
1990    use super::*;
1991    use crate::index::{entry_from_stat, Index, MODE_REGULAR};
1992    use crate::objects::ObjectKind;
1993    use crate::pathspec::ResolvedPathspecs;
1994    use crate::porcelain::add::{stage, StageOptions};
1995    use crate::progress::NullProgress;
1996    use crate::reflog::read_reflog;
1997    use crate::refs::{resolve_ref, set_test_inject_reflog_fail};
1998    use crate::repo::set_test_inject_index_write_fail;
1999    use crate::write_tree::{cache_tree_fully_valid, verify_cache_tree};
2000    use std::fs;
2001    use std::io::Write;
2002    use std::path::Path;
2003    use std::process::Command;
2004    use std::str::FromStr;
2005    use tempfile::TempDir;
2006
2007    fn init_repo(root: &Path) -> Repository {
2008        let git = root.join(".git");
2009        fs::create_dir_all(git.join("objects")).unwrap();
2010        fs::create_dir_all(git.join("refs/heads")).unwrap();
2011        fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
2012        fs::write(
2013            git.join("config"),
2014            "[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tlogAllRefUpdates = true\n",
2015        )
2016        .unwrap();
2017        Repository::open(&git, &RepositoryOptions::empty().with_work_tree(root)).unwrap()
2018    }
2019
2020    fn ident() -> String {
2021        "Test User <t@example.com> 1 +0000".to_owned()
2022    }
2023
2024    fn stage_file_in_index(repo: &Repository, index: &mut Index, rel: &str, contents: &[u8]) {
2025        let wt = repo.work_tree().unwrap();
2026        let abs = wt.join(rel);
2027        if let Some(parent) = abs.parent() {
2028            fs::create_dir_all(parent).unwrap();
2029        }
2030        fs::write(&abs, contents).unwrap();
2031        let oid = repo.odb().write(ObjectKind::Blob, contents).unwrap();
2032        let entry = entry_from_stat(&abs, rel.as_bytes(), oid, MODE_REGULAR).unwrap();
2033        index.add_or_replace(entry);
2034    }
2035
2036    fn write_index(repo: &Repository, index: &mut Index) {
2037        index.sort();
2038        repo.write_index(index).unwrap();
2039    }
2040
2041    #[test]
2042    fn root_commit_files_from_index_match_the_tree_diff() {
2043        let dir = TempDir::new().unwrap();
2044        let repo = init_repo(dir.path());
2045        let mut index = Index::new();
2046        for rel in ["a.txt", "dir/sub/b.txt", "dir-x.txt", "z/last.txt"] {
2047            stage_file_in_index(&repo, &mut index, rel, rel.as_bytes());
2048        }
2049        let template = index.get(b"a.txt", 0).unwrap().clone();
2050        let with_path = |path: &[u8], mode: u32, oid: ObjectId| {
2051            let mut entry = template.clone();
2052            entry.path = path.to_vec();
2053            entry.flags = path.len() as u16;
2054            entry.mode = mode;
2055            entry.oid = oid;
2056            entry
2057        };
2058        let blob = repo.odb().write(ObjectKind::Blob, b"raw\n").unwrap();
2059        index.add_or_replace(with_path(b"bin\xff.txt", MODE_REGULAR, blob));
2060        let gitlink = ObjectId::from_hex("1111111111111111111111111111111111111111").unwrap();
2061        index.add_or_replace(with_path(b"module", crate::index::MODE_GITLINK, gitlink));
2062        let mut ita = with_path(b"ita.txt", MODE_REGULAR, blob);
2063        ita.set_intent_to_add(true);
2064        index.add_or_replace(ita);
2065        index.sort();
2066
2067        let tree =
2068            write_tree_update_index(repo.odb(), &mut index, "", WriteTreeFlags::silent()).unwrap();
2069        let from_tree = commit_files_between_trees(repo.odb(), None, &tree).unwrap();
2070        let from_index = root_commit_files_from_index(&index).expect("no sparse placeholders");
2071        assert_eq!(from_index.len(), 6);
2072        assert_eq!(from_index, from_tree);
2073
2074        let mut placeholder = with_path(b"sparse/", crate::index::MODE_TREE, tree);
2075        placeholder.set_skip_worktree(true);
2076        index.add_or_replace(placeholder);
2077        assert!(root_commit_files_from_index(&index).is_none());
2078    }
2079
2080    fn commit_req(message: &str) -> CommitRequest {
2081        let ident = ident();
2082        let sig: crate::ident::Signature = ident.parse().expect("signature");
2083        let mut req = CommitRequest::new(message);
2084        req.author = Some(sig.clone());
2085        req.committer = Some(sig);
2086        req
2087    }
2088
2089    fn repo_snapshot(repo: &Repository) -> (Vec<u8>, String, usize, bool, usize) {
2090        let index_path = repo.git_dir().join("index");
2091        let index_bytes = fs::read(&index_path).unwrap_or_default();
2092        let head = fs::read_to_string(repo.git_dir().join("HEAD")).unwrap_or_default();
2093        let reflog_len = read_reflog(repo.git_dir(), "refs/heads/main")
2094            .map(|e| e.len())
2095            .unwrap_or(0);
2096        let editmsg = repo.git_dir().join("COMMIT_EDITMSG").exists();
2097        let mut objects = 0usize;
2098        if let Ok(entries) = fs::read_dir(repo.git_dir().join("objects")) {
2099            for entry in entries.flatten() {
2100                let path = entry.path();
2101                if path.is_dir() && path.file_name().is_some_and(|n| n.len() == 2) {
2102                    objects += fs::read_dir(path).map(|d| d.count()).unwrap_or(0);
2103                } else if path.is_file() {
2104                    objects += 1;
2105                }
2106            }
2107        }
2108        (index_bytes, head, reflog_len, editmsg, objects)
2109    }
2110
2111    fn dry_run_req(message: &str) -> CommitRequest {
2112        let mut req = commit_req(message);
2113        req.dry_run = true;
2114        req
2115    }
2116
2117    #[cfg(unix)]
2118    #[test]
2119    fn dry_run_includes_mode_only_and_mixed_worktree_changes() {
2120        use std::os::unix::fs::PermissionsExt;
2121
2122        let tmp = TempDir::new().unwrap();
2123        let root = tmp.path();
2124        let repo = init_repo(root);
2125        fs::write(root.join("c"), b"c\n").unwrap();
2126        fs::write(root.join("s"), b"s\n").unwrap();
2127        commit_worktree(
2128            &repo,
2129            &commit_req("base"),
2130            &mut NullProgress,
2131            &crate::progress::NeverCancel,
2132        )
2133        .unwrap();
2134        let before = repo_snapshot(&repo);
2135        let before_tip = resolve_ref(repo.git_dir(), "refs/heads/main").unwrap();
2136
2137        let mut perms = fs::metadata(root.join("c")).unwrap().permissions();
2138        perms.set_mode(0o100755);
2139        fs::set_permissions(root.join("c"), perms).unwrap();
2140
2141        let preview = commit_worktree(
2142            &repo,
2143            &dry_run_req("mode"),
2144            &mut NullProgress,
2145            &crate::progress::NeverCancel,
2146        )
2147        .unwrap();
2148        assert_eq!(preview.changes, 1);
2149        assert_eq!(preview.files.len(), 1);
2150        assert_eq!(preview.files[0].path, "c");
2151        assert_eq!(preview.files[0].status, DiffStatus::Modified);
2152        assert_eq!(repo_snapshot(&repo), before);
2153        assert_eq!(
2154            resolve_ref(repo.git_dir(), "refs/heads/main").unwrap(),
2155            before_tip
2156        );
2157
2158        fs::OpenOptions::new()
2159            .append(true)
2160            .open(root.join("s"))
2161            .unwrap()
2162            .write_all(b"n\n")
2163            .unwrap();
2164
2165        let preview = commit_worktree(
2166            &repo,
2167            &dry_run_req("mixed"),
2168            &mut NullProgress,
2169            &crate::progress::NeverCancel,
2170        )
2171        .unwrap();
2172        assert_eq!(preview.changes, 2);
2173        let paths: Vec<_> = preview.files.iter().map(|f| f.path.as_str()).collect();
2174        assert!(paths.contains(&"c"));
2175        assert!(paths.contains(&"s"));
2176
2177        let committed = commit_worktree(
2178            &repo,
2179            &commit_req("mixed"),
2180            &mut NullProgress,
2181            &crate::progress::NeverCancel,
2182        )
2183        .unwrap();
2184        assert_eq!(committed.files, preview.files);
2185    }
2186
2187    #[cfg(unix)]
2188    #[test]
2189    fn parallel_symlink_worktree_preview_is_clean_or_matches_commit() {
2190        use crate::parallel::ThreadPolicy;
2191        use std::os::unix::fs::symlink;
2192
2193        let tmp = TempDir::new().unwrap();
2194        let root = tmp.path();
2195        let repo = init_repo(root);
2196        fs::create_dir(root.join("files")).unwrap();
2197        for n in 0..1024 {
2198            fs::write(root.join(format!("files/{n}")), b"data\n").unwrap();
2199        }
2200        for (path, target) in [
2201            ("linkdir", "files"),
2202            ("linkfile", "files/0"),
2203            ("linkmissing", "missing"),
2204        ] {
2205            symlink(target, root.join(path)).unwrap();
2206        }
2207        commit_worktree(
2208            &repo,
2209            &commit_req("initial"),
2210            &mut NullProgress,
2211            &crate::progress::NeverCancel,
2212        )
2213        .unwrap();
2214        let mut index = repo.load_index().unwrap();
2215        for entry in &mut index.entries {
2216            entry.mtime_sec = 0;
2217            entry.mtime_nsec = 0;
2218            entry.size = 0;
2219        }
2220        repo.write_index(&mut index).unwrap();
2221        let before = repo_snapshot(&repo);
2222        let before_tip = resolve_ref(repo.git_dir(), "refs/heads/main").unwrap();
2223        for threads in [1, 4] {
2224            let repo = Repository::open(
2225                repo.git_dir(),
2226                &RepositoryOptions::empty().with_work_tree(root),
2227            )
2228            .unwrap()
2229            .with_thread_policy(ThreadPolicy::with_threads_override(Some(threads)));
2230            let err = commit_worktree(
2231                &repo,
2232                &dry_run_req("clean"),
2233                &mut NullProgress,
2234                &crate::progress::NeverCancel,
2235            )
2236            .unwrap_err();
2237            assert!(matches!(err, Error::NothingToCommit), "{err:?}");
2238            assert_eq!(repo_snapshot(&repo), before);
2239            assert_eq!(
2240                resolve_ref(repo.git_dir(), "refs/heads/main").unwrap(),
2241                before_tip
2242            );
2243        }
2244        for (path, target) in [
2245            ("linkdir", "."),
2246            ("linkfile", "files/1"),
2247            ("linkmissing", "absent"),
2248        ] {
2249            fs::remove_file(root.join(path)).unwrap();
2250            symlink(target, root.join(path)).unwrap();
2251        }
2252        let mut preview_files = None;
2253        for threads in [1, 4] {
2254            let repo = Repository::open(
2255                repo.git_dir(),
2256                &RepositoryOptions::empty().with_work_tree(root),
2257            )
2258            .unwrap()
2259            .with_thread_policy(ThreadPolicy::with_threads_override(Some(threads)));
2260            let preview = commit_worktree(
2261                &repo,
2262                &dry_run_req("retarget"),
2263                &mut NullProgress,
2264                &crate::progress::NeverCancel,
2265            )
2266            .unwrap();
2267            assert_eq!(preview.changes, 3);
2268            if let Some(expected) = &preview_files {
2269                assert_eq!(&preview.files, expected);
2270            }
2271            preview_files = Some(preview.files);
2272            assert_eq!(repo_snapshot(&repo), before);
2273            assert_eq!(
2274                resolve_ref(repo.git_dir(), "refs/heads/main").unwrap(),
2275                before_tip
2276            );
2277        }
2278        let committed = commit_worktree(
2279            &repo,
2280            &commit_req("retarget"),
2281            &mut NullProgress,
2282            &crate::progress::NeverCancel,
2283        )
2284        .unwrap();
2285        assert_eq!(Some(committed.files), preview_files);
2286    }
2287
2288    #[test]
2289    fn create_commit_initial() {
2290        let tmp = TempDir::new().unwrap();
2291        let root = tmp.path();
2292        let repo = init_repo(root);
2293        fs::write(root.join("README"), b"hello\n").unwrap();
2294        stage(
2295            &repo,
2296            &StageOptions::default(),
2297            &mut NullProgress,
2298            &crate::progress::NeverCancel,
2299        )
2300        .unwrap();
2301
2302        let outcome = create_commit(
2303            &repo,
2304            &commit_req("initial"),
2305            &mut NullProgress,
2306            &crate::progress::NeverCancel,
2307        )
2308        .unwrap();
2309        assert!(outcome.parent.is_none());
2310        assert_eq!(outcome.branch.as_deref(), Some("main"));
2311        assert!(!outcome.is_detached());
2312        assert_eq!(outcome.changes, 1);
2313        assert_eq!(
2314            resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
2315            outcome.oid
2316        );
2317    }
2318
2319    #[test]
2320    fn create_commit_rejects_empty_initial_index() {
2321        let tmp = TempDir::new().unwrap();
2322        let repo = init_repo(tmp.path());
2323        let err = create_commit(
2324            &repo,
2325            &commit_req("empty"),
2326            &mut NullProgress,
2327            &crate::progress::NeverCancel,
2328        )
2329        .unwrap_err();
2330        assert!(matches!(err, Error::NothingToCommit));
2331        assert!(resolve_ref(&repo.git_dir(), "refs/heads/main").is_err());
2332    }
2333
2334    #[test]
2335    fn create_commit_normal() {
2336        let tmp = TempDir::new().unwrap();
2337        let root = tmp.path();
2338        let repo = init_repo(root);
2339        fs::write(root.join("a.txt"), b"1\n").unwrap();
2340        stage(
2341            &repo,
2342            &StageOptions::default(),
2343            &mut NullProgress,
2344            &crate::progress::NeverCancel,
2345        )
2346        .unwrap();
2347        create_commit(
2348            &repo,
2349            &commit_req("first"),
2350            &mut NullProgress,
2351            &crate::progress::NeverCancel,
2352        )
2353        .unwrap();
2354
2355        fs::write(root.join("a.txt"), b"2\n").unwrap();
2356        stage(
2357            &repo,
2358            &StageOptions::default(),
2359            &mut NullProgress,
2360            &crate::progress::NeverCancel,
2361        )
2362        .unwrap();
2363        let outcome = create_commit(
2364            &repo,
2365            &commit_req("second"),
2366            &mut NullProgress,
2367            &crate::progress::NeverCancel,
2368        )
2369        .unwrap();
2370        assert_eq!(outcome.changes, 1);
2371        assert!(outcome.parent.is_some());
2372    }
2373
2374    #[test]
2375    fn create_commit_nothing_to_commit() {
2376        let tmp = TempDir::new().unwrap();
2377        let root = tmp.path();
2378        let repo = init_repo(root);
2379        fs::write(root.join("a.txt"), b"1\n").unwrap();
2380        stage(
2381            &repo,
2382            &StageOptions::default(),
2383            &mut NullProgress,
2384            &crate::progress::NeverCancel,
2385        )
2386        .unwrap();
2387        create_commit(
2388            &repo,
2389            &commit_req("first"),
2390            &mut NullProgress,
2391            &crate::progress::NeverCancel,
2392        )
2393        .unwrap();
2394
2395        let err = create_commit(
2396            &repo,
2397            &commit_req("noop"),
2398            &mut NullProgress,
2399            &crate::progress::NeverCancel,
2400        )
2401        .unwrap_err();
2402        assert!(matches!(err, Error::NothingToCommit));
2403    }
2404
2405    #[test]
2406    fn missing_identity_does_not_write_dangling_tree() {
2407        let tmp = TempDir::new().unwrap();
2408        let root = tmp.path();
2409        let repo = init_repo(root);
2410        fs::write(root.join("a.txt"), b"1\n").unwrap();
2411        stage(
2412            &repo,
2413            &StageOptions::default(),
2414            &mut NullProgress,
2415            &crate::progress::NeverCancel,
2416        )
2417        .unwrap();
2418        let objects_before = fs::read_dir(repo.git_dir().join("objects"))
2419            .unwrap()
2420            .filter_map(|e| e.ok())
2421            .count();
2422        let err = commit_worktree(
2423            &repo,
2424            &CommitRequest {
2425                message: "first".into(),
2426                ..CommitRequest::default()
2427            },
2428            &mut NullProgress,
2429            &crate::progress::NeverCancel,
2430        )
2431        .unwrap_err();
2432        assert!(matches!(
2433            err,
2434            Error::Identity(
2435                crate::config::ident_resolve::IdentityError::MissingConfiguredEmail { .. }
2436            )
2437        ));
2438        let objects_after = fs::read_dir(repo.git_dir().join("objects"))
2439            .unwrap()
2440            .filter_map(|e| e.ok())
2441            .count();
2442        assert_eq!(
2443            objects_before, objects_after,
2444            "commit must not store objects"
2445        );
2446        let fsck = Command::new("git")
2447            .args(["--git-dir", &repo.git_dir().to_string_lossy(), "fsck"])
2448            .output()
2449            .unwrap();
2450        assert!(
2451            fsck.status.success(),
2452            "{}",
2453            String::from_utf8_lossy(&fsck.stderr)
2454        );
2455        let stderr = String::from_utf8_lossy(&fsck.stderr);
2456        assert!(
2457            !stderr.contains("dangling tree"),
2458            "unexpected dangling tree: {stderr}"
2459        );
2460    }
2461
2462    #[test]
2463    fn commit_worktree_nothing_to_commit_persists_staged_deletion() {
2464        let tmp = TempDir::new().unwrap();
2465        let root = tmp.path();
2466        let repo = init_repo(root);
2467        fs::write(root.join("a.txt"), b"1\n").unwrap();
2468        commit_worktree(
2469            &repo,
2470            &commit_req("first"),
2471            &mut NullProgress,
2472            &crate::progress::NeverCancel,
2473        )
2474        .unwrap();
2475
2476        let mut index = repo.load_index().unwrap();
2477        stage_file_in_index(&repo, &mut index, "n.txt", b"staged\n");
2478        write_index(&repo, &mut index);
2479        fs::remove_file(root.join("n.txt")).unwrap();
2480
2481        let err = commit_worktree(
2482            &repo,
2483            &dry_run_req("noop"),
2484            &mut NullProgress,
2485            &crate::progress::NeverCancel,
2486        )
2487        .unwrap_err();
2488        assert!(matches!(err, Error::NothingToCommit));
2489        assert!(repo.load_index().unwrap().get(b"n.txt", 0).is_some());
2490
2491        let err = commit_worktree(
2492            &repo,
2493            &commit_req("noop"),
2494            &mut NullProgress,
2495            &crate::progress::NeverCancel,
2496        )
2497        .unwrap_err();
2498        assert!(matches!(err, Error::NothingToCommit));
2499        let index = repo.load_index().unwrap();
2500        assert!(index.get(b"n.txt", 0).is_none());
2501        assert!(index.get(b"a.txt", 0).is_some());
2502    }
2503
2504    #[test]
2505    fn create_commit_detached_head_advances_head() {
2506        let tmp = TempDir::new().unwrap();
2507        let root = tmp.path();
2508        let repo = init_repo(root);
2509        fs::write(root.join("a.txt"), b"1\n").unwrap();
2510        stage(
2511            &repo,
2512            &StageOptions::default(),
2513            &mut NullProgress,
2514            &crate::progress::NeverCancel,
2515        )
2516        .unwrap();
2517        let first = create_commit(
2518            &repo,
2519            &commit_req("first"),
2520            &mut NullProgress,
2521            &crate::progress::NeverCancel,
2522        )
2523        .unwrap();
2524        fs::write(repo.git_dir().join("HEAD"), first.oid.to_hex()).unwrap();
2525
2526        fs::write(root.join("b.txt"), b"2\n").unwrap();
2527        stage(
2528            &repo,
2529            &StageOptions::default(),
2530            &mut NullProgress,
2531            &crate::progress::NeverCancel,
2532        )
2533        .unwrap();
2534        let second = create_commit(
2535            &repo,
2536            &commit_req("second"),
2537            &mut NullProgress,
2538            &crate::progress::NeverCancel,
2539        )
2540        .unwrap();
2541        assert_eq!(second.branch, None);
2542        assert!(second.is_detached());
2543        assert_eq!(second.parent, Some(first.oid));
2544        let head = fs::read_to_string(repo.git_dir().join("HEAD")).unwrap();
2545        assert_eq!(head.trim(), second.oid.to_hex());
2546        let main_tip = crate::refs::resolve_ref(repo.git_dir(), "refs/heads/main").unwrap();
2547        assert_eq!(main_tip, first.oid, "branch tip must stay unchanged");
2548    }
2549
2550    #[test]
2551    fn create_commit_updates_reflogs() {
2552        let tmp = TempDir::new().unwrap();
2553        let root = tmp.path();
2554        let repo = init_repo(root);
2555        fs::write(root.join("a.txt"), b"1\n").unwrap();
2556        stage(
2557            &repo,
2558            &StageOptions::default(),
2559            &mut NullProgress,
2560            &crate::progress::NeverCancel,
2561        )
2562        .unwrap();
2563        let outcome = create_commit(
2564            &repo,
2565            &commit_req("subject line"),
2566            &mut NullProgress,
2567            &crate::progress::NeverCancel,
2568        )
2569        .unwrap();
2570
2571        let branch_log = read_reflog(&repo.git_dir(), "refs/heads/main").unwrap();
2572        assert_eq!(branch_log.len(), 1);
2573        assert_eq!(branch_log[0].new_oid, outcome.oid);
2574        assert_eq!(branch_log[0].message, "commit (initial): subject line");
2575
2576        let head_log = read_reflog(&repo.git_dir(), "HEAD").unwrap();
2577        assert_eq!(head_log.len(), 1);
2578        assert_eq!(head_log[0].new_oid, outcome.oid);
2579    }
2580
2581    #[test]
2582    fn create_commit_index_write_failure_does_not_advance_branch() {
2583        let tmp = TempDir::new().unwrap();
2584        let root = tmp.path();
2585        let repo = init_repo(root);
2586        fs::write(root.join("a.txt"), b"1\n").unwrap();
2587        stage(
2588            &repo,
2589            &StageOptions::default(),
2590            &mut NullProgress,
2591            &crate::progress::NeverCancel,
2592        )
2593        .unwrap();
2594
2595        set_test_inject_index_write_fail(true);
2596        let err = create_commit(
2597            &repo,
2598            &commit_req("fail"),
2599            &mut NullProgress,
2600            &crate::progress::NeverCancel,
2601        )
2602        .unwrap_err();
2603        set_test_inject_index_write_fail(false);
2604        assert!(!matches!(err, Error::NothingToCommit));
2605
2606        assert!(
2607            resolve_ref(&repo.git_dir(), "refs/heads/main").is_err(),
2608            "branch must not advance when index write fails"
2609        );
2610    }
2611
2612    #[test]
2613    fn create_commit_rolls_back_branch_when_head_reflog_fails() {
2614        let tmp = TempDir::new().unwrap();
2615        let root = tmp.path();
2616        let repo = init_repo(root);
2617        fs::write(root.join("a.txt"), b"1\n").unwrap();
2618        stage(
2619            &repo,
2620            &StageOptions::default(),
2621            &mut NullProgress,
2622            &crate::progress::NeverCancel,
2623        )
2624        .unwrap();
2625
2626        set_test_inject_reflog_fail(Some("HEAD"));
2627        let err = create_commit(
2628            &repo,
2629            &commit_req("fail"),
2630            &mut NullProgress,
2631            &crate::progress::NeverCancel,
2632        )
2633        .unwrap_err();
2634        set_test_inject_reflog_fail(None);
2635        assert!(!matches!(err, Error::NothingToCommit));
2636
2637        assert!(
2638            resolve_ref(&repo.git_dir(), "refs/heads/main").is_err(),
2639            "branch must not advance when HEAD reflog fails"
2640        );
2641        assert!(read_reflog(&repo.git_dir(), "refs/heads/main")
2642            .unwrap()
2643            .is_empty());
2644        assert!(read_reflog(&repo.git_dir(), "HEAD").unwrap().is_empty());
2645    }
2646
2647    #[test]
2648    fn create_commit_leaves_valid_cache_tree() {
2649        let tmp = TempDir::new().unwrap();
2650        let root = tmp.path();
2651        let repo = init_repo(root);
2652        let mut index = Index::new();
2653        stage_file_in_index(&repo, &mut index, "dir/one.txt", b"1\n");
2654        stage_file_in_index(&repo, &mut index, "dir/two.txt", b"2\n");
2655        write_index(&repo, &mut index);
2656
2657        let outcome = create_commit(
2658            &repo,
2659            &commit_req("tree"),
2660            &mut NullProgress,
2661            &crate::progress::NeverCancel,
2662        )
2663        .unwrap();
2664
2665        let index = repo.load_index().unwrap();
2666        verify_cache_tree(&index).expect("cache-tree valid");
2667        assert!(cache_tree_fully_valid(
2668            &repo.odb(),
2669            index.cache_tree.as_ref()
2670        ));
2671        let commit_obj = repo.odb().read(&outcome.oid).unwrap();
2672        let tree = parse_commit(&commit_obj.data).unwrap().tree;
2673        assert_eq!(index.cache_tree_root, Some(tree));
2674    }
2675
2676    #[test]
2677    fn create_commit_amend_same_tree() {
2678        let tmp = TempDir::new().unwrap();
2679        let root = tmp.path();
2680        let repo = init_repo(root);
2681        fs::write(root.join("a.txt"), b"1\n").unwrap();
2682        stage(
2683            &repo,
2684            &StageOptions::default(),
2685            &mut NullProgress,
2686            &crate::progress::NeverCancel,
2687        )
2688        .unwrap();
2689        create_commit(
2690            &repo,
2691            &commit_req("first"),
2692            &mut NullProgress,
2693            &crate::progress::NeverCancel,
2694        )
2695        .unwrap();
2696        fs::write(root.join("a.txt"), b"2\n").unwrap();
2697        stage(
2698            &repo,
2699            &StageOptions::default(),
2700            &mut NullProgress,
2701            &crate::progress::NeverCancel,
2702        )
2703        .unwrap();
2704        create_commit(
2705            &repo,
2706            &commit_req("second"),
2707            &mut NullProgress,
2708            &crate::progress::NeverCancel,
2709        )
2710        .unwrap();
2711
2712        let mut req = commit_req("");
2713        req.amend = true;
2714        let tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
2715        let outcome = create_commit(
2716            &repo,
2717            &req,
2718            &mut NullProgress,
2719            &crate::progress::NeverCancel,
2720        )
2721        .unwrap();
2722        assert_eq!(
2723            outcome.oid, tip,
2724            "same tree and message with fixed committer yields the same object id"
2725        );
2726        assert!(outcome.parent.is_some());
2727
2728        let branch_log = read_reflog(&repo.git_dir(), "refs/heads/main").unwrap();
2729        assert_eq!(branch_log.len(), 3);
2730        assert_eq!(branch_log[2].message, "commit (amend): second");
2731    }
2732
2733    #[test]
2734    fn create_commit_amend_preserves_message_bytes() {
2735        let tmp = TempDir::new().unwrap();
2736        let root = tmp.path();
2737        let repo = init_repo(root);
2738        fs::write(root.join("a.txt"), b"a\n").unwrap();
2739        stage(
2740            &repo,
2741            &StageOptions::default(),
2742            &mut NullProgress,
2743            &crate::progress::NeverCancel,
2744        )
2745        .unwrap();
2746        create_commit(
2747            &repo,
2748            &commit_req("oneline subject"),
2749            &mut NullProgress,
2750            &crate::progress::NeverCancel,
2751        )
2752        .unwrap();
2753        let baseline = {
2754            let oid = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
2755            parse_commit(&repo.odb().read(&oid).unwrap().data)
2756                .unwrap()
2757                .message
2758        };
2759
2760        for extra in 1..=2 {
2761            fs::write(root.join("a.txt"), format!("a\n{extra}\n")).unwrap();
2762            stage(
2763                &repo,
2764                &StageOptions::default(),
2765                &mut NullProgress,
2766                &crate::progress::NeverCancel,
2767            )
2768            .unwrap();
2769            let mut req = commit_req("");
2770            req.amend = true;
2771            create_commit(
2772                &repo,
2773                &req,
2774                &mut NullProgress,
2775                &crate::progress::NeverCancel,
2776            )
2777            .unwrap();
2778            let oid = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
2779            let message = parse_commit(&repo.odb().read(&oid).unwrap().data)
2780                .unwrap()
2781                .message;
2782            assert_eq!(message, baseline, "after amend {extra}");
2783        }
2784    }
2785
2786    #[test]
2787    fn create_commit_amend_unborn() {
2788        let tmp = TempDir::new().unwrap();
2789        let repo = init_repo(tmp.path());
2790        fs::write(tmp.path().join("a.txt"), b"1\n").unwrap();
2791        stage(
2792            &repo,
2793            &StageOptions::default(),
2794            &mut NullProgress,
2795            &crate::progress::NeverCancel,
2796        )
2797        .unwrap();
2798        let mut req = commit_req("nope");
2799        req.amend = true;
2800        let err = create_commit(
2801            &repo,
2802            &req,
2803            &mut NullProgress,
2804            &crate::progress::NeverCancel,
2805        )
2806        .unwrap_err();
2807        assert!(matches!(err, Error::AmendUnborn));
2808    }
2809
2810    #[test]
2811    fn create_commit_allow_empty() {
2812        let tmp = TempDir::new().unwrap();
2813        let root = tmp.path();
2814        let repo = init_repo(root);
2815        fs::write(root.join("a.txt"), b"1\n").unwrap();
2816        stage(
2817            &repo,
2818            &StageOptions::default(),
2819            &mut NullProgress,
2820            &crate::progress::NeverCancel,
2821        )
2822        .unwrap();
2823        create_commit(
2824            &repo,
2825            &commit_req("first"),
2826            &mut NullProgress,
2827            &crate::progress::NeverCancel,
2828        )
2829        .unwrap();
2830
2831        let mut req = commit_req("empty");
2832        req.allow_empty = true;
2833        let parent = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
2834        let outcome = create_commit(
2835            &repo,
2836            &req,
2837            &mut NullProgress,
2838            &crate::progress::NeverCancel,
2839        )
2840        .unwrap();
2841        assert_eq!(outcome.changes, 0);
2842        assert_eq!(outcome.parent, Some(parent));
2843        assert_ne!(outcome.oid, parent);
2844    }
2845
2846    #[test]
2847    fn scope_staged_commits_index_only() {
2848        let tmp = TempDir::new().unwrap();
2849        let root = tmp.path();
2850        let repo = init_repo(root);
2851        fs::write(root.join("a.txt"), b"1\n").unwrap();
2852        fs::write(root.join("b.txt"), b"1\n").unwrap();
2853        stage(
2854            &repo,
2855            &StageOptions::default(),
2856            &mut NullProgress,
2857            &crate::progress::NeverCancel,
2858        )
2859        .unwrap();
2860        create_commit(
2861            &repo,
2862            &commit_req("first"),
2863            &mut NullProgress,
2864            &crate::progress::NeverCancel,
2865        )
2866        .unwrap();
2867
2868        fs::write(root.join("a.txt"), b"2\n").unwrap();
2869        fs::write(root.join("b.txt"), b"2\n").unwrap();
2870        let mut index = repo.load_index().unwrap();
2871        stage_file_in_index(&repo, &mut index, "a.txt", b"2\n");
2872        write_index(&repo, &mut index);
2873
2874        let outcome = commit_with_scope(
2875            &repo,
2876            &commit_req("only a"),
2877            CommitScope::Staged,
2878            &mut NullProgress,
2879            &crate::progress::NeverCancel,
2880        )
2881        .unwrap();
2882        assert_eq!(outcome.changes, 1);
2883        let index = repo.load_index().unwrap();
2884        let b_oid = index.get(b"b.txt", 0).unwrap().oid;
2885        let b_blob = repo.odb().read(&b_oid).unwrap();
2886        assert_eq!(b_blob.data, b"1\n", "unstaged paths stay in the index");
2887    }
2888
2889    #[test]
2890    fn scope_paths_does_not_hash_a_throwaway_validation_index() {
2891        let tmp = TempDir::new().unwrap();
2892        let root = tmp.path();
2893        let repo = init_repo(root);
2894        let mut bytes = vec![b'x'; 2 * 1024 * 1024];
2895        fs::write(root.join("large"), &bytes).unwrap();
2896        stage(
2897            &repo,
2898            &StageOptions::default(),
2899            &mut NullProgress,
2900            &crate::progress::NeverCancel,
2901        )
2902        .unwrap();
2903        create_commit(
2904            &repo,
2905            &commit_req("initial"),
2906            &mut NullProgress,
2907            &crate::progress::NeverCancel,
2908        )
2909        .unwrap();
2910        bytes.push(b'y');
2911        fs::write(root.join("large"), &bytes).unwrap();
2912        let passes = repo.odb().hot_path_metrics().staging_passes();
2913        let result = commit_with_scope(
2914            &repo,
2915            &commit_req("selected path"),
2916            CommitScope::Paths(ResolvedPathspecs {
2917                specs: vec!["large".into()],
2918                sources: vec!["large".into()],
2919                scoped: true,
2920            }),
2921            &mut NullProgress,
2922            &crate::progress::NeverCancel,
2923        )
2924        .unwrap();
2925        assert_eq!(result.changes, 1);
2926        // The commit view and repository view each stage once. Validation must not
2927        // add a third pass, regardless of extra hashing needed for racy stat rows.
2928        assert_eq!(repo.odb().hot_path_metrics().staging_passes(), passes + 2);
2929        let index = repo.load_index().unwrap();
2930        let oid = index.get(b"large", 0).unwrap().oid;
2931        assert_eq!(repo.odb().read(&oid).unwrap().data, bytes);
2932    }
2933
2934    #[test]
2935    fn scope_paths_leaves_other_staged_entries() {
2936        let tmp = TempDir::new().unwrap();
2937        let root = tmp.path();
2938        let repo = init_repo(root);
2939        fs::write(root.join("file"), b"1\n").unwrap();
2940        fs::write(root.join("baz"), b"1\n").unwrap();
2941        stage(
2942            &repo,
2943            &StageOptions::default(),
2944            &mut NullProgress,
2945            &crate::progress::NeverCancel,
2946        )
2947        .unwrap();
2948        create_commit(
2949            &repo,
2950            &commit_req("initial"),
2951            &mut NullProgress,
2952            &crate::progress::NeverCancel,
2953        )
2954        .unwrap();
2955
2956        fs::write(root.join("file"), b"2\n").unwrap();
2957        fs::write(root.join("baz"), b"2\n").unwrap();
2958        stage(
2959            &repo,
2960            &StageOptions {
2961                pathspecs: vec!["baz".into()],
2962                ..StageOptions::default()
2963            },
2964            &mut NullProgress,
2965            &crate::progress::NeverCancel,
2966        )
2967        .unwrap();
2968
2969        let paths = ResolvedPathspecs {
2970            specs: vec!["file".into()],
2971            sources: vec!["file".into()],
2972            scoped: true,
2973        };
2974        commit_with_scope(
2975            &repo,
2976            &commit_req("file only"),
2977            CommitScope::Paths(paths),
2978            &mut NullProgress,
2979            &crate::progress::NeverCancel,
2980        )
2981        .unwrap();
2982
2983        let index = repo.load_index().unwrap();
2984        let baz_oid = index.get(b"baz", 0).unwrap().oid;
2985        assert_eq!(repo.odb().read(&baz_oid).unwrap().data, b"2\n");
2986    }
2987
2988    #[test]
2989    fn scope_paths_deleted_and_untracked() {
2990        let tmp = TempDir::new().unwrap();
2991        let root = tmp.path();
2992        let repo = init_repo(root);
2993        fs::write(root.join("tracked.txt"), b"1\n").unwrap();
2994        stage(
2995            &repo,
2996            &StageOptions::default(),
2997            &mut NullProgress,
2998            &crate::progress::NeverCancel,
2999        )
3000        .unwrap();
3001        create_commit(
3002            &repo,
3003            &commit_req("first"),
3004            &mut NullProgress,
3005            &crate::progress::NeverCancel,
3006        )
3007        .unwrap();
3008
3009        fs::write(root.join("new.txt"), b"new\n").unwrap();
3010        fs::remove_file(root.join("tracked.txt")).unwrap();
3011
3012        let paths = ResolvedPathspecs {
3013            specs: vec!["new.txt".into(), "tracked.txt".into()],
3014            sources: vec!["new.txt".into(), "tracked.txt".into()],
3015            scoped: true,
3016        };
3017        commit_with_scope(
3018            &repo,
3019            &commit_req("add and remove"),
3020            CommitScope::Paths(paths),
3021            &mut NullProgress,
3022            &crate::progress::NeverCancel,
3023        )
3024        .unwrap();
3025
3026        let index = repo.load_index().unwrap();
3027        assert!(index.get(b"tracked.txt", 0).is_none());
3028        assert!(index.get(b"new.txt", 0).is_some());
3029    }
3030
3031    #[cfg_attr(
3032        not(feature = "full-tests"),
3033        ignore = "starts system git or is heavy; run with --features full-tests"
3034    )]
3035    #[test]
3036    fn scope_paths_nested_dirs_round_trip_with_git() {
3037        use std::process::Command;
3038
3039        let tmp = TempDir::new().unwrap();
3040        let root = tmp.path();
3041        let git = |args: &[&str]| -> String {
3042            let out = Command::new("git")
3043                .env("GIT_CONFIG_NOSYSTEM", "1")
3044                .env("GIT_CONFIG_GLOBAL", "/dev/null")
3045                .env("GIT_AUTHOR_NAME", "Test")
3046                .env("GIT_AUTHOR_EMAIL", "test@example.com")
3047                .env("GIT_COMMITTER_NAME", "Test")
3048                .env("GIT_COMMITTER_EMAIL", "test@example.com")
3049                .current_dir(root)
3050                .args(args)
3051                .output()
3052                .unwrap();
3053            assert!(
3054                out.status.success(),
3055                "git {args:?}: {}",
3056                String::from_utf8_lossy(&out.stderr)
3057            );
3058            String::from_utf8(out.stdout).unwrap()
3059        };
3060        git(&["init", "-q", "-b", "main"]);
3061        for (rel, body) in [
3062            ("a/x.txt", "x1\n"),
3063            ("a/y.txt", "y1\n"),
3064            ("b/c/z.txt", "z1\n"),
3065            ("b/w.txt", "w1\n"),
3066            ("d/e.txt", "e1\n"),
3067            ("top.txt", "t1\n"),
3068        ] {
3069            fs::create_dir_all(root.join(rel).parent().unwrap()).unwrap();
3070            fs::write(root.join(rel), body).unwrap();
3071        }
3072        git(&["add", "-A"]);
3073        git(&["commit", "-qm", "init"]);
3074
3075        fs::write(root.join("a/x.txt"), b"x2\n").unwrap();
3076        fs::write(root.join("b/c/z.txt"), b"z2\n").unwrap();
3077        fs::write(root.join("b/w.txt"), b"w2\n").unwrap();
3078        fs::write(root.join("top.txt"), b"t2\n").unwrap();
3079        git(&["add", "b/w.txt"]);
3080
3081        let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).unwrap();
3082        let specs = vec!["a/x.txt".to_owned(), "b/c/z.txt".to_owned()];
3083        commit_with_scope(
3084            &repo,
3085            &commit_req("partial"),
3086            CommitScope::Paths(ResolvedPathspecs {
3087                specs: specs.clone(),
3088                sources: specs,
3089                scoped: true,
3090            }),
3091            &mut NullProgress,
3092            &crate::progress::NeverCancel,
3093        )
3094        .unwrap();
3095
3096        git(&["fsck", "--strict", "--no-progress"]);
3097        assert_eq!(
3098            git(&["diff", "--name-only", "HEAD~1", "HEAD"]),
3099            "a/x.txt\nb/c/z.txt\n"
3100        );
3101        assert_eq!(git(&["show", "HEAD:a/x.txt"]), "x2\n");
3102        assert_eq!(git(&["show", "HEAD:b/c/z.txt"]), "z2\n");
3103        assert_eq!(git(&["show", "HEAD:b/w.txt"]), "w1\n");
3104        assert_eq!(
3105            git(&["rev-parse", "HEAD:d"]),
3106            git(&["rev-parse", "HEAD~1:d"])
3107        );
3108        assert_eq!(git(&["diff", "--cached", "--name-only"]), "b/w.txt\n");
3109        assert_eq!(git(&["diff", "--name-only"]), "top.txt\n");
3110    }
3111
3112    #[test]
3113    fn scope_paths_no_match_errors() {
3114        let tmp = TempDir::new().unwrap();
3115        let root = tmp.path();
3116        let repo = init_repo(root);
3117        fs::write(root.join("a.txt"), b"1\n").unwrap();
3118        stage(
3119            &repo,
3120            &StageOptions::default(),
3121            &mut NullProgress,
3122            &crate::progress::NeverCancel,
3123        )
3124        .unwrap();
3125        create_commit(
3126            &repo,
3127            &commit_req("first"),
3128            &mut NullProgress,
3129            &crate::progress::NeverCancel,
3130        )
3131        .unwrap();
3132
3133        let paths = ResolvedPathspecs {
3134            specs: vec!["missing.txt".into()],
3135            sources: vec!["missing.txt".into()],
3136            scoped: true,
3137        };
3138        let err = commit_with_scope(
3139            &repo,
3140            &commit_req("nope"),
3141            CommitScope::Paths(paths),
3142            &mut NullProgress,
3143            &crate::progress::NeverCancel,
3144        )
3145        .unwrap_err();
3146        assert!(matches!(err, Error::PathspecNoMatch { .. }));
3147    }
3148
3149    #[test]
3150    fn scope_paths_refused_during_merge() {
3151        let tmp = TempDir::new().unwrap();
3152        let root = tmp.path();
3153        let repo = init_repo(root);
3154        fs::write(root.join("a.txt"), b"1\n").unwrap();
3155        stage(
3156            &repo,
3157            &StageOptions::default(),
3158            &mut NullProgress,
3159            &crate::progress::NeverCancel,
3160        )
3161        .unwrap();
3162        create_commit(
3163            &repo,
3164            &commit_req("first"),
3165            &mut NullProgress,
3166            &crate::progress::NeverCancel,
3167        )
3168        .unwrap();
3169        let tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
3170        fs::write(repo.git_dir().join("MERGE_HEAD"), format!("{tip}\n")).unwrap();
3171
3172        let paths = ResolvedPathspecs {
3173            specs: vec!["a.txt".into()],
3174            sources: vec!["a.txt".into()],
3175            scoped: true,
3176        };
3177        let err = commit_with_scope(
3178            &repo,
3179            &commit_req("partial"),
3180            CommitScope::Paths(paths),
3181            &mut NullProgress,
3182            &crate::progress::NeverCancel,
3183        )
3184        .unwrap_err();
3185        assert!(matches!(err, Error::PartialCommitDuringMerge));
3186    }
3187
3188    fn system_git(root: &Path, args: &[&str]) -> String {
3189        let out = grit_test_support::merge_git_config_env(&mut Command::new("git"), &[])
3190            .env("GIT_CONFIG_NOSYSTEM", "1")
3191            .env("GIT_CONFIG_GLOBAL", "/dev/null")
3192            .env("GIT_AUTHOR_NAME", "Test")
3193            .env("GIT_AUTHOR_EMAIL", "test@example.com")
3194            .env("GIT_COMMITTER_NAME", "Test")
3195            .env("GIT_COMMITTER_EMAIL", "test@example.com")
3196            .current_dir(root)
3197            .args(args)
3198            .output()
3199            .unwrap();
3200        assert!(
3201            out.status.success(),
3202            "git {args:?}: {}",
3203            String::from_utf8_lossy(&out.stderr)
3204        );
3205        String::from_utf8(out.stdout).unwrap()
3206    }
3207
3208    fn sharedindex_file_paths(git_dir: &Path) -> Vec<PathBuf> {
3209        fs::read_dir(git_dir)
3210            .unwrap()
3211            .flatten()
3212            .filter_map(|e| {
3213                let name = e.file_name();
3214                let s = name.to_string_lossy();
3215                if s.starts_with("sharedindex.") {
3216                    Some(e.path())
3217                } else {
3218                    None
3219                }
3220            })
3221            .collect()
3222    }
3223
3224    /// `git init` + one commit of `f1.txt`, then `f1.txt` modified in the worktree (issue #1789).
3225    fn git_repo_with_modified_file(root: &Path) -> Repository {
3226        system_git(root, &["init", "-q", "-b", "main"]);
3227        fs::write(root.join("f1.txt"), b"a\n").unwrap();
3228        system_git(root, &["add", "."]);
3229        system_git(root, &["commit", "-qm", "init"]);
3230        fs::write(root.join("f1.txt"), b"a\nb\n").unwrap();
3231        Repository::discover(Some(root), &RepositoryOptions::empty()).unwrap()
3232    }
3233
3234    fn f1_paths() -> ResolvedPathspecs {
3235        ResolvedPathspecs {
3236            specs: vec!["f1.txt".into()],
3237            sources: vec!["f1.txt".into()],
3238            scoped: true,
3239        }
3240    }
3241
3242    /// Run `scope` while a foreign `index.lock` exists and check nothing became visible.
3243    ///
3244    /// `objects_untouched` additionally requires that no loose object was written; scopes that
3245    /// hash content before writing the index may leave unreferenced blobs.
3246    fn assert_commit_refused_by_foreign_lock(
3247        root: &Path,
3248        repo: &Repository,
3249        scope: CommitScope,
3250        objects_untouched: bool,
3251    ) {
3252        let lock_path = repo.git_dir().join("index.lock");
3253        fs::write(&lock_path, b"foreign\n").unwrap();
3254        let before = repo_snapshot(repo);
3255        let tip_before = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
3256
3257        let err = commit_with_scope(
3258            repo,
3259            &commit_req("x"),
3260            scope,
3261            &mut NullProgress,
3262            &crate::progress::NeverCancel,
3263        )
3264        .unwrap_err();
3265        match &err {
3266            Error::LockHeld {
3267                path: reported,
3268                holder,
3269            } => {
3270                assert_eq!(reported, &lock_path);
3271                assert_eq!(*holder, None);
3272            }
3273            other => panic!("expected Error::LockHeld, got {other:?}"),
3274        }
3275        assert!(err.to_string().contains("index.lock"), "{err}");
3276
3277        let (index, head, reflog, _editmsg, objects) = repo_snapshot(repo);
3278        assert_eq!(index, before.0, "index bytes must be unchanged");
3279        assert_eq!(head, before.1, "HEAD must be unchanged");
3280        assert_eq!(reflog, before.2, "no reflog entry may be written");
3281        if objects_untouched {
3282            assert_eq!(objects, before.4, "no objects may be written");
3283        }
3284        assert_eq!(
3285            resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
3286            tip_before
3287        );
3288        assert_eq!(
3289            fs::read(&lock_path).unwrap(),
3290            b"foreign\n",
3291            "someone else's lock must be left alone"
3292        );
3293        let leftovers: Vec<_> = fs::read_dir(repo.git_dir())
3294            .unwrap()
3295            .flatten()
3296            .filter(|e| e.file_name().to_string_lossy().starts_with("next-index-"))
3297            .collect();
3298        assert!(leftovers.is_empty(), "temporary hook index left behind");
3299
3300        fs::remove_file(&lock_path).unwrap();
3301        assert_eq!(system_git(root, &["status", "--porcelain"]), " M f1.txt\n");
3302        system_git(root, &["fsck", "--strict", "--no-progress"]);
3303    }
3304
3305    #[cfg_attr(
3306        not(feature = "full-tests"),
3307        ignore = "starts system git or is heavy; run with --features full-tests"
3308    )]
3309    #[test]
3310    fn scope_paths_with_held_index_lock_creates_nothing() {
3311        let tmp = TempDir::new().unwrap();
3312        let root = tmp.path();
3313        let repo = git_repo_with_modified_file(root);
3314        assert_commit_refused_by_foreign_lock(root, &repo, CommitScope::Paths(f1_paths()), true);
3315
3316        let outcome = commit_with_scope(
3317            &repo,
3318            &commit_req("x"),
3319            CommitScope::Paths(f1_paths()),
3320            &mut NullProgress,
3321            &crate::progress::NeverCancel,
3322        )
3323        .unwrap();
3324        assert!(!repo.git_dir().join("index.lock").exists());
3325        assert_eq!(
3326            system_git(root, &["rev-parse", "HEAD"]).trim(),
3327            outcome.oid.to_hex()
3328        );
3329        assert_eq!(system_git(root, &["status", "--porcelain"]), "");
3330        system_git(root, &["fsck", "--strict", "--no-progress"]);
3331    }
3332
3333    #[cfg_attr(
3334        not(feature = "full-tests"),
3335        ignore = "starts system git or is heavy; run with --features full-tests"
3336    )]
3337    #[test]
3338    fn scope_staged_and_auto_stage_with_held_index_lock_create_nothing() {
3339        let tmp = TempDir::new().unwrap();
3340        let root = tmp.path();
3341        let repo = git_repo_with_modified_file(root);
3342        assert_commit_refused_by_foreign_lock(root, &repo, CommitScope::AutoStage, true);
3343
3344        system_git(root, &["add", "f1.txt"]);
3345        let lock_path = repo.git_dir().join("index.lock");
3346        fs::write(&lock_path, b"foreign\n").unwrap();
3347        let tip_before = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
3348        let err = commit_with_scope(
3349            &repo,
3350            &commit_req("x"),
3351            CommitScope::Staged,
3352            &mut NullProgress,
3353            &crate::progress::NeverCancel,
3354        )
3355        .unwrap_err();
3356        assert!(
3357            matches!(&err, Error::LockHeld { path, .. } if *path == repo.git_dir().join("index.lock")),
3358            "{err:?}"
3359        );
3360        assert_eq!(
3361            resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
3362            tip_before
3363        );
3364        assert_eq!(fs::read(&lock_path).unwrap(), b"foreign\n");
3365    }
3366
3367    #[cfg_attr(
3368        not(feature = "full-tests"),
3369        ignore = "starts system git or is heavy; run with --features full-tests"
3370    )]
3371    #[test]
3372    fn scope_patch_with_held_index_lock_creates_nothing() {
3373        use crate::diff::hunk_id::{hunk_ids_with_duplicate_peers, DiffSide};
3374        use crate::diff::{unstaged_file_diffs_with_peer_hunks, DiffOptions};
3375        use crate::porcelain::hunk_select::HunkSelection;
3376
3377        let tmp = TempDir::new().unwrap();
3378        let root = tmp.path();
3379        let repo = git_repo_with_modified_file(root);
3380        let (files, peers) =
3381            unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
3382        let ids = hunk_ids_with_duplicate_peers(
3383            DiffSide::Unstaged,
3384            "f1.txt",
3385            &files[0].hunks,
3386            peers.get("f1.txt").map(|v| v.as_slice()).unwrap_or(&[]),
3387        );
3388        let scope = CommitScope::Patch {
3389            input: CommitPatchInput::Hunks(HunkSelection {
3390                ids: vec![ids[0].clone()],
3391                ranges: Vec::new(),
3392            }),
3393            pathspecs: Vec::new(),
3394        };
3395        assert_commit_refused_by_foreign_lock(root, &repo, scope.clone(), true);
3396
3397        let outcome = commit_with_scope(
3398            &repo,
3399            &commit_req("x"),
3400            scope,
3401            &mut NullProgress,
3402            &crate::progress::NeverCancel,
3403        )
3404        .unwrap();
3405        assert!(!repo.git_dir().join("index.lock").exists());
3406        assert_eq!(
3407            system_git(root, &["rev-parse", "HEAD"]).trim(),
3408            outcome.oid.to_hex()
3409        );
3410        assert_eq!(system_git(root, &["status", "--porcelain"]), "");
3411        let index = repo.load_index().unwrap();
3412        verify_cache_tree(&index).expect("cache-tree valid");
3413        assert!(cache_tree_fully_valid(
3414            &repo.odb(),
3415            index.cache_tree.as_ref()
3416        ));
3417        let tree = parse_commit(&repo.odb().read(&outcome.oid).unwrap().data)
3418            .unwrap()
3419            .tree;
3420        assert_eq!(index.cache_tree_root, Some(tree));
3421        system_git(root, &["fsck", "--strict", "--no-progress"]);
3422    }
3423
3424    #[cfg_attr(
3425        not(feature = "full-tests"),
3426        ignore = "starts system git or is heavy; run with --features full-tests"
3427    )]
3428    #[test]
3429    fn scope_paths_split_index_round_trips_with_git() {
3430        let tmp = TempDir::new().unwrap();
3431        let root = tmp.path();
3432        system_git(root, &["init", "-q", "-b", "main"]);
3433        system_git(root, &["config", "core.splitIndex", "true"]);
3434        system_git(root, &["config", "splitIndex.maxPercentChange", "100"]);
3435        for name in ["a.txt", "b.txt", "c.txt"] {
3436            fs::write(root.join(name), b"1\n").unwrap();
3437        }
3438        system_git(root, &["add", "."]);
3439        system_git(root, &["commit", "-qm", "init"]);
3440        fs::write(root.join("a.txt"), b"2\n").unwrap();
3441        fs::write(root.join("b.txt"), b"2\n").unwrap();
3442        system_git(root, &["add", "b.txt"]);
3443
3444        let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).unwrap();
3445        let specs = vec!["a.txt".to_owned()];
3446        commit_with_scope(
3447            &repo,
3448            &commit_req("partial"),
3449            CommitScope::Paths(ResolvedPathspecs {
3450                specs: specs.clone(),
3451                sources: specs,
3452                scoped: true,
3453            }),
3454            &mut NullProgress,
3455            &crate::progress::NeverCancel,
3456        )
3457        .unwrap();
3458
3459        assert!(!repo.git_dir().join("index.lock").exists());
3460        assert!(
3461            system_git(root, &["ls-files", "--debug"]).contains("a.txt"),
3462            "git must read the split index"
3463        );
3464        assert_eq!(system_git(root, &["status", "--porcelain"]), "M  b.txt\n");
3465        assert_eq!(system_git(root, &["show", "HEAD:a.txt"]), "2\n");
3466        assert_eq!(system_git(root, &["show", "HEAD:b.txt"]), "1\n");
3467        system_git(root, &["fsck", "--strict", "--no-progress"]);
3468        let shared: Vec<_> = fs::read_dir(repo.git_dir())
3469            .unwrap()
3470            .flatten()
3471            .filter(|e| e.file_name().to_string_lossy().starts_with("sharedindex."))
3472            .collect();
3473        assert!(!shared.is_empty(), "index must stay split");
3474    }
3475
3476    #[cfg_attr(
3477        not(feature = "full-tests"),
3478        ignore = "starts system git or is heavy; run with --features full-tests"
3479    )]
3480    #[test]
3481    fn commit_worktree_reuses_shared_index_on_small_change() {
3482        let tmp = TempDir::new().unwrap();
3483        let root = tmp.path();
3484        system_git(root, &["init", "-q", "-b", "main"]);
3485        for i in 0..30 {
3486            fs::write(root.join(format!("f{i:02}.txt")), format!("{i}\n")).unwrap();
3487        }
3488        system_git(root, &["add", "."]);
3489        system_git(root, &["commit", "-qm", "init"]);
3490        system_git(root, &["config", "core.splitIndex", "true"]);
3491        system_git(root, &["update-index", "--split-index"]);
3492
3493        let before = sharedindex_file_paths(&root.join(".git"));
3494        assert_eq!(before.len(), 1, "git must create one shared index");
3495
3496        fs::write(root.join("f00.txt"), b"changed\n").unwrap();
3497        let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).unwrap();
3498        commit_worktree(
3499            &repo,
3500            &commit_req("one file"),
3501            &mut NullProgress,
3502            &crate::progress::NeverCancel,
3503        )
3504        .unwrap();
3505
3506        let after = sharedindex_file_paths(repo.git_dir());
3507        assert_eq!(
3508            before.len(),
3509            after.len(),
3510            "small commit must not write a new sharedindex"
3511        );
3512        assert!(
3513            system_git(root, &["ls-files", "--debug"]).contains("f00.txt"),
3514            "split link must remain readable by git"
3515        );
3516        assert_eq!(system_git(root, &["status", "--porcelain"]), "");
3517        system_git(root, &["fsck", "--strict", "--no-progress"]);
3518    }
3519
3520    #[cfg_attr(
3521        not(feature = "full-tests"),
3522        ignore = "starts system git or is heavy; run with --features full-tests"
3523    )]
3524    #[test]
3525    fn commit_worktree_rebuilds_shared_index_when_max_percent_exceeded() {
3526        let tmp = TempDir::new().unwrap();
3527        let root = tmp.path();
3528        system_git(root, &["init", "-q", "-b", "main"]);
3529        for i in 0..10 {
3530            fs::write(root.join(format!("f{i}.txt")), format!("{i}\n")).unwrap();
3531        }
3532        system_git(root, &["add", "."]);
3533        system_git(root, &["commit", "-qm", "init"]);
3534        system_git(root, &["config", "core.splitIndex", "true"]);
3535        system_git(root, &["config", "splitIndex.maxPercentChange", "20"]);
3536        system_git(root, &["update-index", "--split-index"]);
3537
3538        let first = sharedindex_file_paths(&root.join(".git"));
3539        assert_eq!(first.len(), 1);
3540        let first_oid = first[0]
3541            .file_name()
3542            .unwrap()
3543            .to_string_lossy()
3544            .strip_prefix("sharedindex.")
3545            .unwrap()
3546            .to_string();
3547
3548        for i in 0..4 {
3549            fs::write(root.join(format!("n{i}.txt")), format!("n{i}\n")).unwrap();
3550        }
3551        let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).unwrap();
3552        commit_worktree(
3553            &repo,
3554            &commit_req("many new files"),
3555            &mut NullProgress,
3556            &crate::progress::NeverCancel,
3557        )
3558        .unwrap();
3559
3560        let shared = sharedindex_file_paths(repo.git_dir());
3561        let second_oid = shared
3562            .iter()
3563            .map(|p| {
3564                p.file_name()
3565                    .unwrap()
3566                    .to_string_lossy()
3567                    .strip_prefix("sharedindex.")
3568                    .unwrap()
3569                    .to_string()
3570            })
3571            .find(|oid| oid != &first_oid)
3572            .expect("maxPercentChange should force a new shared index base");
3573        assert_ne!(first_oid, second_oid);
3574        system_git(root, &["fsck", "--strict", "--no-progress"]);
3575    }
3576
3577    #[cfg_attr(
3578        not(feature = "full-tests"),
3579        ignore = "starts system git or is heavy; run with --features full-tests"
3580    )]
3581    #[test]
3582    fn commit_worktree_failed_pre_commit_leaves_split_index_unchanged() {
3583        let tmp = TempDir::new().unwrap();
3584        let root = tmp.path();
3585        system_git(root, &["init", "-q", "-b", "main"]);
3586        for i in 0..12 {
3587            fs::write(root.join(format!("f{i}.txt")), format!("{i}\n")).unwrap();
3588        }
3589        system_git(root, &["add", "."]);
3590        system_git(root, &["commit", "-qm", "init"]);
3591        system_git(root, &["config", "core.splitIndex", "true"]);
3592        system_git(root, &["update-index", "--split-index"]);
3593        fs::write(
3594            root.join(".git/config"),
3595            format!(
3596                "{}\n[hook \"hc\"]\n\tcommand = exit 1\n\tevent = pre-commit\n",
3597                fs::read_to_string(root.join(".git/config")).unwrap()
3598            ),
3599        )
3600        .unwrap();
3601
3602        fs::write(root.join("f0.txt"), b"blocked\n").unwrap();
3603        let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).unwrap();
3604        let index_path = repo.git_dir().join("index");
3605        let index_before = fs::read(&index_path).unwrap();
3606        let shared_before = sharedindex_file_paths(repo.git_dir());
3607        let tip_before = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
3608
3609        let mut req = commit_req("blocked");
3610        req.hooks = HookPolicy::Run;
3611        assert!(commit_worktree(
3612            &repo,
3613            &req,
3614            &mut NullProgress,
3615            &crate::progress::NeverCancel,
3616        )
3617        .is_err());
3618
3619        assert_eq!(fs::read(&index_path).unwrap(), index_before);
3620        assert_eq!(sharedindex_file_paths(repo.git_dir()), shared_before);
3621        assert_eq!(
3622            resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
3623            tip_before
3624        );
3625        system_git(root, &["fsck", "--strict", "--no-progress"]);
3626    }
3627
3628    #[cfg_attr(
3629        not(feature = "full-tests"),
3630        ignore = "starts system git or is heavy; run with --features full-tests"
3631    )]
3632    #[test]
3633    fn commit_on_split_index_with_skip_hash_stays_readable_by_git() {
3634        for key in ["index.skipHash", "feature.manyFiles"] {
3635            let tmp = TempDir::new().unwrap();
3636            let root = tmp.path();
3637            system_git(root, &["init", "-q", "-b", "main"]);
3638            fs::write(root.join("a"), b"1\n").unwrap();
3639            system_git(root, &["add", "a"]);
3640            system_git(root, &["commit", "-qm", "c1"]);
3641            system_git(root, &["config", "core.splitIndex", "true"]);
3642            system_git(root, &["update-index", "--split-index"]);
3643            fs::write(root.join("a"), b"1\n2\n").unwrap();
3644            system_git(root, &["add", "a"]);
3645            system_git(root, &["commit", "-qm", "c2"]);
3646            system_git(root, &["config", key, "true"]);
3647            assert_eq!(system_git(root, &["status", "--short"]), "");
3648            fs::write(root.join("n"), b"n\n").unwrap();
3649
3650            let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).unwrap();
3651            let outcome = commit_with_scope(
3652                &repo,
3653                &commit_req("g"),
3654                CommitScope::AutoStage,
3655                &mut NullProgress,
3656                &crate::progress::NeverCancel,
3657            )
3658            .unwrap();
3659
3660            assert_eq!(system_git(root, &["ls-files"]), "a\nn\n", "{key}");
3661            // Keep git from rebuilding the shared index itself: under skipHash it names it
3662            // `sharedindex.000…0` and then reads its own index as unsplit.
3663            system_git(root, &["config", "splitIndex.maxPercentChange", "100"]);
3664            assert_eq!(system_git(root, &["status", "--porcelain"]), "", "{key}");
3665            assert_eq!(
3666                system_git(root, &["rev-parse", "HEAD"]).trim(),
3667                outcome.oid.to_hex()
3668            );
3669            system_git(root, &["fsck", "--strict", "--no-progress"]);
3670            fs::write(root.join("m"), b"m\n").unwrap();
3671            system_git(root, &["add", "-A"]);
3672            system_git(root, &["commit", "-qm", "git after grit"]);
3673            assert_eq!(system_git(root, &["ls-files"]), "a\nm\nn\n", "{key}");
3674            assert_eq!(repo.load_index().unwrap().entries.len(), 3, "{key}");
3675        }
3676    }
3677
3678    fn author_info_from_commit(repo: &Repository, oid: ObjectId) -> CommitAuthorInfo {
3679        let data = repo.odb().read(&oid).unwrap().data;
3680        author_info_from_header_line(&parse_commit(&data).unwrap().author)
3681    }
3682
3683    fn git_head_commit_message(repo: &std::path::Path) -> String {
3684        let out = std::process::Command::new("git")
3685            .args(["cat-file", "commit", "HEAD"])
3686            .current_dir(repo)
3687            .output()
3688            .expect("git cat-file commit");
3689        assert!(out.status.success());
3690        let bytes = out.stdout;
3691        let mut i = 0usize;
3692        while i < bytes.len() {
3693            if i + 1 < bytes.len() && bytes[i] == b'\n' && bytes[i + 1] == b'\n' {
3694                i += 2;
3695                break;
3696            }
3697            i += 1;
3698        }
3699        String::from_utf8(bytes[i..].to_vec()).expect("utf-8 commit message")
3700    }
3701
3702    #[cfg_attr(
3703        not(feature = "full-tests"),
3704        ignore = "starts system git or is heavy; run with --features full-tests"
3705    )]
3706    #[test]
3707    fn message_file_stripspace() {
3708        let raw = "  subject  \n\n  body line  \n\n";
3709        let msg = normalize_commit_message(raw).unwrap();
3710        let tmp = TempDir::new().unwrap();
3711        let msg_path = tmp.path().join("msg.txt");
3712        std::fs::write(&msg_path, raw).unwrap();
3713        std::process::Command::new("git")
3714            .args(["init", "-q"])
3715            .current_dir(tmp.path())
3716            .status()
3717            .unwrap();
3718        std::process::Command::new("git")
3719            .args(["config", "user.name", "T"])
3720            .current_dir(tmp.path())
3721            .status()
3722            .unwrap();
3723        std::process::Command::new("git")
3724            .args(["config", "user.email", "t@e.com"])
3725            .current_dir(tmp.path())
3726            .status()
3727            .unwrap();
3728        std::fs::write(tmp.path().join("f"), "x\n").unwrap();
3729        std::process::Command::new("git")
3730            .args(["add", "f"])
3731            .current_dir(tmp.path())
3732            .status()
3733            .unwrap();
3734        std::process::Command::new("git")
3735            .args(["commit", "-F"])
3736            .arg(&msg_path)
3737            .current_dir(tmp.path())
3738            .status()
3739            .unwrap();
3740        let git_body = git_head_commit_message(tmp.path());
3741        assert_eq!(msg, git_body);
3742    }
3743
3744    #[cfg_attr(
3745        not(feature = "full-tests"),
3746        ignore = "starts system git or is heavy; run with --features full-tests"
3747    )]
3748    #[test]
3749    fn message_normalization_crlf_and_trailing_blanks_match_git_commit_f() {
3750        let raw = "\r\n  title  \r\n\r\n  body  \r\n\r\n\r\n";
3751        let grit_msg = normalize_commit_message(raw).unwrap();
3752        let tmp = tempfile::TempDir::new().unwrap();
3753        let msg_path = tmp.path().join("msg.txt");
3754        std::fs::write(&msg_path, raw).unwrap();
3755        let git_msg = std::process::Command::new("git")
3756            .args(["init", "-q"])
3757            .current_dir(tmp.path())
3758            .status()
3759            .unwrap();
3760        assert!(git_msg.success());
3761        std::process::Command::new("git")
3762            .args(["config", "user.name", "T"])
3763            .current_dir(tmp.path())
3764            .status()
3765            .unwrap();
3766        std::process::Command::new("git")
3767            .args(["config", "user.email", "t@e.com"])
3768            .current_dir(tmp.path())
3769            .status()
3770            .unwrap();
3771        std::fs::write(tmp.path().join("f"), "x\n").unwrap();
3772        std::process::Command::new("git")
3773            .args(["add", "f"])
3774            .current_dir(tmp.path())
3775            .status()
3776            .unwrap();
3777        std::process::Command::new("git")
3778            .args(["commit", "-F"])
3779            .arg(&msg_path)
3780            .current_dir(tmp.path())
3781            .status()
3782            .unwrap();
3783        let git_body = git_head_commit_message(tmp.path());
3784        assert_eq!(grit_msg, git_body);
3785    }
3786
3787    #[test]
3788    fn no_verify_skips_pre_commit_and_commit_msg() {
3789        let tmp = TempDir::new().unwrap();
3790        let root = tmp.path();
3791        let repo = init_repo(root);
3792        std::fs::write(
3793            repo.git_dir().join("config"),
3794            "[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tlogAllRefUpdates = true\n[hook \"hc\"]\n\tcommand = exit 1\n\tevent = pre-commit\n[hook \"hc2\"]\n\tcommand = exit 1\n\tevent = commit-msg\n",
3795        )
3796        .unwrap();
3797        fs::write(root.join("a.txt"), b"1\n").unwrap();
3798        stage(
3799            &repo,
3800            &StageOptions::default(),
3801            &mut NullProgress,
3802            &crate::progress::NeverCancel,
3803        )
3804        .unwrap();
3805
3806        let mut req = commit_req("blocked");
3807        req.hooks = HookPolicy::Run;
3808        assert!(create_commit(
3809            &repo,
3810            &req,
3811            &mut NullProgress,
3812            &crate::progress::NeverCancel
3813        )
3814        .is_err());
3815
3816        req.hooks = HookPolicy::SkipVerify;
3817        let outcome = create_commit(
3818            &repo,
3819            &req,
3820            &mut NullProgress,
3821            &crate::progress::NeverCancel,
3822        )
3823        .unwrap();
3824        assert!(outcome.hooks_run.is_empty());
3825    }
3826
3827    #[test]
3828    fn post_commit_hook_failure_does_not_fail_commit() {
3829        use crate::hooks::HookName;
3830
3831        let tmp = TempDir::new().unwrap();
3832        let root = tmp.path();
3833        let repo = init_repo(root);
3834        std::fs::write(
3835            repo.git_dir().join("config"),
3836            "[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tlogAllRefUpdates = true\n[hook \"hc\"]\n\tcommand = exit 9\n\tevent = post-commit\n",
3837        )
3838        .unwrap();
3839        fs::write(root.join("a.txt"), b"1\n").unwrap();
3840        stage(
3841            &repo,
3842            &StageOptions::default(),
3843            &mut NullProgress,
3844            &crate::progress::NeverCancel,
3845        )
3846        .unwrap();
3847        let outcome = create_commit(
3848            &repo,
3849            &commit_req("after post"),
3850            &mut NullProgress,
3851            &crate::progress::NeverCancel,
3852        )
3853        .unwrap();
3854        assert_eq!(
3855            resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
3856            outcome.oid
3857        );
3858        assert!(outcome.hooks_run.contains(&HookName::PostCommit));
3859    }
3860
3861    #[test]
3862    fn hooks_run_lists_executed_hooks() {
3863        use crate::hooks::HookName;
3864
3865        let tmp = TempDir::new().unwrap();
3866        let root = tmp.path();
3867        let repo = init_repo(root);
3868        std::fs::write(
3869            repo.git_dir().join("config"),
3870            "[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tlogAllRefUpdates = true\n[hook \"hc\"]\n\tcommand = true\n\tevent = pre-commit\n[hook \"hc2\"]\n\tcommand = true\n\tevent = commit-msg\n[hook \"hc3\"]\n\tcommand = true\n\tevent = post-commit\n[hook \"hp\"]\n\tcommand = echo \"prepared $2\" >>$1\n\tevent = prepare-commit-msg\n",
3871        )
3872        .unwrap();
3873        fs::write(root.join("a.txt"), b"1\n").unwrap();
3874        stage(
3875            &repo,
3876            &StageOptions::default(),
3877            &mut NullProgress,
3878            &crate::progress::NeverCancel,
3879        )
3880        .unwrap();
3881        let outcome = create_commit(
3882            &repo,
3883            &commit_req("hooks"),
3884            &mut NullProgress,
3885            &crate::progress::NeverCancel,
3886        )
3887        .unwrap();
3888        assert_eq!(
3889            outcome.hooks_run,
3890            vec![
3891                HookName::PreCommit,
3892                HookName::PrepareCommitMsg,
3893                HookName::CommitMsg,
3894                HookName::PostCommit
3895            ]
3896        );
3897        let commit = parse_commit(&repo.odb().read(&outcome.oid).unwrap().data).unwrap();
3898        assert_eq!(commit.message, "hooks\nprepared message\n");
3899
3900        fs::write(root.join("a.txt"), b"2\n").unwrap();
3901        stage(
3902            &repo,
3903            &StageOptions::default(),
3904            &mut NullProgress,
3905            &crate::progress::NeverCancel,
3906        )
3907        .unwrap();
3908        let mut req = commit_req("skip verify");
3909        req.hooks = HookPolicy::SkipVerify;
3910        let outcome = create_commit(
3911            &repo,
3912            &req,
3913            &mut NullProgress,
3914            &crate::progress::NeverCancel,
3915        )
3916        .unwrap();
3917        assert_eq!(
3918            outcome.hooks_run,
3919            vec![HookName::PrepareCommitMsg, HookName::PostCommit]
3920        );
3921    }
3922
3923    #[test]
3924    fn author_date_override() {
3925        let tmp = TempDir::new().unwrap();
3926        let root = tmp.path();
3927        let repo = init_repo(root);
3928        fs::write(root.join("a.txt"), b"1\n").unwrap();
3929        stage(
3930            &repo,
3931            &StageOptions::default(),
3932            &mut NullProgress,
3933            &crate::progress::NeverCancel,
3934        )
3935        .unwrap();
3936        let mut req = commit_req("dated");
3937        req.author_date = Some("946684800 +0000".to_owned());
3938        let outcome = create_commit(
3939            &repo,
3940            &req,
3941            &mut NullProgress,
3942            &crate::progress::NeverCancel,
3943        )
3944        .unwrap();
3945        assert_eq!(outcome.author.date, "946684800 +0000");
3946    }
3947
3948    #[test]
3949    fn trailers_appended_and_deduped() {
3950        let tmp = TempDir::new().unwrap();
3951        let root = tmp.path();
3952        let repo = init_repo(root);
3953        fs::write(root.join("a.txt"), b"1\n").unwrap();
3954        stage(
3955            &repo,
3956            &StageOptions::default(),
3957            &mut NullProgress,
3958            &crate::progress::NeverCancel,
3959        )
3960        .unwrap();
3961        let mut req = commit_req("trailers");
3962        req.trailers = vec![
3963            ("Acked-by".to_owned(), "Alice".to_owned()),
3964            ("Acked-by".to_owned(), "Alice".to_owned()),
3965        ];
3966        let outcome = create_commit(
3967            &repo,
3968            &req,
3969            &mut NullProgress,
3970            &crate::progress::NeverCancel,
3971        )
3972        .unwrap();
3973        let msg = parse_commit(&repo.odb().read(&outcome.oid).unwrap().data)
3974            .unwrap()
3975            .message;
3976        assert_eq!(msg.matches("Acked-by: Alice").count(), 1);
3977    }
3978
3979    #[test]
3980    fn signoff_uses_committer() {
3981        let tmp = TempDir::new().unwrap();
3982        let root = tmp.path();
3983        let repo = init_repo(root);
3984        fs::write(root.join("a.txt"), b"1\n").unwrap();
3985        stage(
3986            &repo,
3987            &StageOptions::default(),
3988            &mut NullProgress,
3989            &crate::progress::NeverCancel,
3990        )
3991        .unwrap();
3992        let mut req = commit_req("signoff");
3993        req.committer = Some(
3994            "Committer Name <committer@example.com> 2 +0000"
3995                .parse()
3996                .expect("signature"),
3997        );
3998        req.signoff = true;
3999        let outcome = create_commit(
4000            &repo,
4001            &req,
4002            &mut NullProgress,
4003            &crate::progress::NeverCancel,
4004        )
4005        .unwrap();
4006        let msg = parse_commit(&repo.odb().read(&outcome.oid).unwrap().data)
4007            .unwrap()
4008            .message;
4009        assert!(msg.contains("Signed-off-by: Committer Name <committer@example.com>"));
4010    }
4011
4012    #[test]
4013    fn signoff_before_explicit_trailers() {
4014        let tmp = TempDir::new().unwrap();
4015        let root = tmp.path();
4016        let repo = init_repo(root);
4017        fs::write(root.join("a.txt"), b"1\n").unwrap();
4018        stage(
4019            &repo,
4020            &StageOptions::default(),
4021            &mut NullProgress,
4022            &crate::progress::NeverCancel,
4023        )
4024        .unwrap();
4025        let mut req = commit_req("subject\n");
4026        req.committer = Some(
4027            "Committer Name <committer@example.com> 2 +0000"
4028                .parse()
4029                .expect("signature"),
4030        );
4031        req.signoff = true;
4032        req.trailers = vec![("Acked-by".to_owned(), "Alice".to_owned())];
4033        let outcome = create_commit(
4034            &repo,
4035            &req,
4036            &mut NullProgress,
4037            &crate::progress::NeverCancel,
4038        )
4039        .unwrap();
4040        let msg = parse_commit(&repo.odb().read(&outcome.oid).unwrap().data)
4041            .unwrap()
4042            .message;
4043        let sob = msg.find("Signed-off-by:").expect("signoff trailer");
4044        let ack = msg.find("Acked-by:").expect("acked trailer");
4045        assert!(sob < ack, "signoff must precede explicit trailers:\n{msg}");
4046    }
4047
4048    #[test]
4049    fn amend_reuse_message_from_source() {
4050        let tmp = TempDir::new().unwrap();
4051        let root = tmp.path();
4052        let repo = init_repo(root);
4053        fs::write(root.join("a.txt"), b"1\n").unwrap();
4054        stage(
4055            &repo,
4056            &StageOptions::default(),
4057            &mut NullProgress,
4058            &crate::progress::NeverCancel,
4059        )
4060        .unwrap();
4061        let mut source = commit_req("source subject\n");
4062        source.author = Some(
4063            "Source Author <author@example.com> 978307200 +0000"
4064                .parse()
4065                .expect("signature"),
4066        );
4067        source.committer = Some(ident().parse().expect("signature"));
4068        let source_out = create_commit(
4069            &repo,
4070            &source,
4071            &mut NullProgress,
4072            &crate::progress::NeverCancel,
4073        )
4074        .unwrap();
4075
4076        fs::write(root.join("a.txt"), b"2\n").unwrap();
4077        stage(
4078            &repo,
4079            &StageOptions::default(),
4080            &mut NullProgress,
4081            &crate::progress::NeverCancel,
4082        )
4083        .unwrap();
4084        let mut tip = commit_req("tip subject\n");
4085        tip.author = Some(
4086            "Tip Author <tip@example.com> 1792591163 +0000"
4087                .parse()
4088                .expect("signature"),
4089        );
4090        create_commit(
4091            &repo,
4092            &tip,
4093            &mut NullProgress,
4094            &crate::progress::NeverCancel,
4095        )
4096        .unwrap();
4097
4098        fs::write(root.join("a.txt"), b"3\n").unwrap();
4099        stage(
4100            &repo,
4101            &StageOptions::default(),
4102            &mut NullProgress,
4103            &crate::progress::NeverCancel,
4104        )
4105        .unwrap();
4106        let mut amend = commit_req("");
4107        amend.amend = true;
4108        amend.author = None;
4109        amend.reuse_message_from = Some(source_out.oid);
4110        let outcome = create_commit(
4111            &repo,
4112            &amend,
4113            &mut NullProgress,
4114            &crate::progress::NeverCancel,
4115        )
4116        .unwrap();
4117        let author = author_info_from_commit(&repo, outcome.oid);
4118        assert_eq!(author.name, "Source Author");
4119        assert_eq!(author.email, "author@example.com");
4120        assert_eq!(author.date, "978307200 +0000");
4121        let msg = parse_commit(&repo.odb().read(&outcome.oid).unwrap().data)
4122            .unwrap()
4123            .message;
4124        assert!(msg.starts_with("source subject"));
4125    }
4126
4127    #[test]
4128    fn amend_reuse_message_reset_author_uses_committer() {
4129        let tmp = TempDir::new().unwrap();
4130        let root = tmp.path();
4131        let repo = init_repo(root);
4132        fs::write(root.join("a.txt"), b"1\n").unwrap();
4133        stage(
4134            &repo,
4135            &StageOptions::default(),
4136            &mut NullProgress,
4137            &crate::progress::NeverCancel,
4138        )
4139        .unwrap();
4140        let mut source = commit_req("source subject\n");
4141        source.author = Some(
4142            "Source Author <author@example.com> 978307200 +0000"
4143                .parse()
4144                .expect("signature"),
4145        );
4146        let source_out = create_commit(
4147            &repo,
4148            &source,
4149            &mut NullProgress,
4150            &crate::progress::NeverCancel,
4151        )
4152        .unwrap();
4153
4154        fs::write(root.join("a.txt"), b"2\n").unwrap();
4155        stage(
4156            &repo,
4157            &StageOptions::default(),
4158            &mut NullProgress,
4159            &crate::progress::NeverCancel,
4160        )
4161        .unwrap();
4162        create_commit(
4163            &repo,
4164            &commit_req("tip"),
4165            &mut NullProgress,
4166            &crate::progress::NeverCancel,
4167        )
4168        .unwrap();
4169
4170        fs::write(root.join("a.txt"), b"3\n").unwrap();
4171        stage(
4172            &repo,
4173            &StageOptions::default(),
4174            &mut NullProgress,
4175            &crate::progress::NeverCancel,
4176        )
4177        .unwrap();
4178        let mut amend = commit_req("");
4179        amend.amend = true;
4180        amend.author = None;
4181        amend.reset_author = true;
4182        amend.committer = Some(
4183            "Reset Author <reset@example.com> 1800000000 +0000"
4184                .parse()
4185                .expect("signature"),
4186        );
4187        amend.reuse_message_from = Some(source_out.oid);
4188        let outcome = create_commit(
4189            &repo,
4190            &amend,
4191            &mut NullProgress,
4192            &crate::progress::NeverCancel,
4193        )
4194        .unwrap();
4195        let author = author_info_from_commit(&repo, outcome.oid);
4196        assert_eq!(author.name, "Reset Author");
4197        assert_eq!(author.email, "reset@example.com");
4198    }
4199
4200    #[test]
4201    fn reuse_message_keeps_author() {
4202        let tmp = TempDir::new().unwrap();
4203        let root = tmp.path();
4204        let repo = init_repo(root);
4205        fs::write(root.join("a.txt"), b"1\n").unwrap();
4206        stage(
4207            &repo,
4208            &StageOptions::default(),
4209            &mut NullProgress,
4210            &crate::progress::NeverCancel,
4211        )
4212        .unwrap();
4213        let mut first = commit_req("source");
4214        first.author = Some(
4215            "Source Author <source@example.com> 99 +0000"
4216                .parse()
4217                .expect("signature"),
4218        );
4219        first.committer = Some(ident().parse().expect("signature"));
4220        let first_out = create_commit(
4221            &repo,
4222            &first,
4223            &mut NullProgress,
4224            &crate::progress::NeverCancel,
4225        )
4226        .unwrap();
4227
4228        fs::write(root.join("a.txt"), b"2\n").unwrap();
4229        stage(
4230            &repo,
4231            &StageOptions::default(),
4232            &mut NullProgress,
4233            &crate::progress::NeverCancel,
4234        )
4235        .unwrap();
4236        let mut req = commit_req("");
4237        req.author = None;
4238        req.reuse_message_from = Some(first_out.oid);
4239        let outcome = create_commit(
4240            &repo,
4241            &req,
4242            &mut NullProgress,
4243            &crate::progress::NeverCancel,
4244        )
4245        .unwrap();
4246        let author = author_info_from_commit(&repo, outcome.oid);
4247        assert_eq!(author.name, "Source Author");
4248        assert_eq!(author.email, "source@example.com");
4249        assert_eq!(author.date, "99 +0000");
4250    }
4251
4252    #[test]
4253    fn reset_author_with_reuse_and_amend() {
4254        let tmp = TempDir::new().unwrap();
4255        let root = tmp.path();
4256        let repo = init_repo(root);
4257        fs::write(root.join("a.txt"), b"1\n").unwrap();
4258        stage(
4259            &repo,
4260            &StageOptions::default(),
4261            &mut NullProgress,
4262            &crate::progress::NeverCancel,
4263        )
4264        .unwrap();
4265        let mut first = commit_req("source");
4266        first.author = Some("Old <old@example.com> 1 +0000".parse().expect("signature"));
4267        create_commit(
4268            &repo,
4269            &first,
4270            &mut NullProgress,
4271            &crate::progress::NeverCancel,
4272        )
4273        .unwrap();
4274
4275        fs::write(root.join("a.txt"), b"2\n").unwrap();
4276        stage(
4277            &repo,
4278            &StageOptions::default(),
4279            &mut NullProgress,
4280            &crate::progress::NeverCancel,
4281        )
4282        .unwrap();
4283        create_commit(
4284            &repo,
4285            &commit_req("second"),
4286            &mut NullProgress,
4287            &crate::progress::NeverCancel,
4288        )
4289        .unwrap();
4290
4291        let mut amend = commit_req("");
4292        amend.amend = true;
4293        amend.author = None;
4294        amend.reset_author = true;
4295        amend.committer = Some(
4296            "New Committer <new@example.com> 2000 +0000"
4297                .parse()
4298                .expect("signature"),
4299        );
4300        let outcome = create_commit(
4301            &repo,
4302            &amend,
4303            &mut NullProgress,
4304            &crate::progress::NeverCancel,
4305        )
4306        .unwrap();
4307        let author = author_info_from_commit(&repo, outcome.oid);
4308        assert_eq!(author.name, "New Committer");
4309        assert_eq!(author.email, "new@example.com");
4310    }
4311
4312    #[test]
4313    fn reuse_message_gets_new_change_id() {
4314        use crate::change_id::{change_id, ChangeIdError, ChangeIdGenerator};
4315        use std::sync::Arc;
4316
4317        struct FixedGenerator([u8; 16]);
4318        impl ChangeIdGenerator for FixedGenerator {
4319            fn random_bytes(&self) -> std::result::Result<[u8; 16], ChangeIdError> {
4320                Ok(self.0)
4321            }
4322        }
4323
4324        let tmp = TempDir::new().unwrap();
4325        let root = tmp.path();
4326        let gen1: Arc<dyn ChangeIdGenerator> = Arc::new(FixedGenerator([0x11; 16]));
4327        let git = root.join(".git");
4328        fs::create_dir_all(git.join("objects")).unwrap();
4329        fs::create_dir_all(git.join("refs/heads")).unwrap();
4330        fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
4331        fs::write(
4332            git.join("config"),
4333            "[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tlogAllRefUpdates = true\n",
4334        )
4335        .unwrap();
4336        let repo = Repository::open(
4337            &git,
4338            &RepositoryOptions::empty()
4339                .with_work_tree(root)
4340                .with_change_id_generator(gen1),
4341        )
4342        .unwrap();
4343        fs::write(root.join("a.txt"), b"1\n").unwrap();
4344        stage(
4345            &repo,
4346            &StageOptions::default(),
4347            &mut NullProgress,
4348            &crate::progress::NeverCancel,
4349        )
4350        .unwrap();
4351        let source = create_commit(
4352            &repo,
4353            &commit_req("source"),
4354            &mut NullProgress,
4355            &crate::progress::NeverCancel,
4356        )
4357        .unwrap();
4358        let source_data = parse_commit(&repo.odb().read(&source.oid).unwrap().data).unwrap();
4359        let source_id = change_id(&source_data).expect("source change id");
4360
4361        let gen2: Arc<dyn ChangeIdGenerator> = Arc::new(FixedGenerator([0x22; 16]));
4362        let repo = Repository::open(
4363            &git,
4364            &RepositoryOptions::empty()
4365                .with_work_tree(root)
4366                .with_change_id_generator(gen2),
4367        )
4368        .unwrap();
4369        fs::write(root.join("a.txt"), b"2\n").unwrap();
4370        stage(
4371            &repo,
4372            &StageOptions::default(),
4373            &mut NullProgress,
4374            &crate::progress::NeverCancel,
4375        )
4376        .unwrap();
4377        let mut req = commit_req("");
4378        req.author = None;
4379        req.reuse_message_from = Some(source.oid);
4380        let copy = create_commit(
4381            &repo,
4382            &req,
4383            &mut NullProgress,
4384            &crate::progress::NeverCancel,
4385        )
4386        .unwrap();
4387        let copy_data = parse_commit(&repo.odb().read(&copy.oid).unwrap().data).unwrap();
4388        let copy_id = change_id(&copy_data).expect("copy change id");
4389        assert_ne!(copy_id, source_id);
4390    }
4391
4392    #[test]
4393    fn outcome_parents_merge_commit() {
4394        let tmp = TempDir::new().unwrap();
4395        let root = tmp.path();
4396        let repo = init_repo(root);
4397        fs::write(root.join("a.txt"), b"1\n").unwrap();
4398        stage(
4399            &repo,
4400            &StageOptions::default(),
4401            &mut NullProgress,
4402            &crate::progress::NeverCancel,
4403        )
4404        .unwrap();
4405        let first = create_commit(
4406            &repo,
4407            &commit_req("first"),
4408            &mut NullProgress,
4409            &crate::progress::NeverCancel,
4410        )
4411        .unwrap();
4412
4413        fs::write(root.join("b.txt"), b"2\n").unwrap();
4414        stage(
4415            &repo,
4416            &StageOptions::default(),
4417            &mut NullProgress,
4418            &crate::progress::NeverCancel,
4419        )
4420        .unwrap();
4421        let second = create_commit(
4422            &repo,
4423            &commit_req("second"),
4424            &mut NullProgress,
4425            &crate::progress::NeverCancel,
4426        )
4427        .unwrap();
4428
4429        fs::write(
4430            repo.git_dir().join("MERGE_HEAD"),
4431            format!("{}\n", first.oid.to_hex()),
4432        )
4433        .unwrap();
4434        fs::write(root.join("a.txt"), b"merged\n").unwrap();
4435        stage(
4436            &repo,
4437            &StageOptions::default(),
4438            &mut NullProgress,
4439            &crate::progress::NeverCancel,
4440        )
4441        .unwrap();
4442        let merge = create_commit(
4443            &repo,
4444            &commit_req("merge"),
4445            &mut NullProgress,
4446            &crate::progress::NeverCancel,
4447        )
4448        .unwrap();
4449        assert_eq!(merge.parents.len(), 2);
4450        assert_eq!(merge.parents[0], second.oid);
4451        assert_eq!(merge.parents[1], first.oid);
4452    }
4453
4454    #[test]
4455    fn auto_stage_config_false_defaults_to_staged() {
4456        let tmp = TempDir::new().unwrap();
4457        let root = tmp.path();
4458        let git = root.join(".git");
4459        fs::create_dir_all(git.join("objects")).unwrap();
4460        fs::create_dir_all(git.join("refs/heads")).unwrap();
4461        fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
4462        fs::write(
4463            git.join("config"),
4464            "[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tlogAllRefUpdates = true\n[commit]\n\tautoStage = false\n",
4465        )
4466        .unwrap();
4467        let repo =
4468            Repository::open(&git, &RepositoryOptions::empty().with_work_tree(root)).unwrap();
4469        assert_eq!(
4470            default_commit_scope_from_config(&repo.config().unwrap()),
4471            CommitScope::Staged
4472        );
4473    }
4474
4475    #[test]
4476    fn dry_run_path_scope_includes_untracked_add() {
4477        let tmp = TempDir::new().unwrap();
4478        let root = tmp.path();
4479        let repo = init_repo(root);
4480        fs::write(root.join("tracked.txt"), b"1\n").unwrap();
4481        stage(
4482            &repo,
4483            &StageOptions::default(),
4484            &mut NullProgress,
4485            &crate::progress::NeverCancel,
4486        )
4487        .unwrap();
4488        create_commit(
4489            &repo,
4490            &commit_req("first"),
4491            &mut NullProgress,
4492            &crate::progress::NeverCancel,
4493        )
4494        .unwrap();
4495        fs::write(root.join("new.txt"), b"new\n").unwrap();
4496        let paths = ResolvedPathspecs {
4497            specs: vec!["new.txt".into()],
4498            sources: vec!["new.txt".into()],
4499            scoped: true,
4500        };
4501        let before = repo_snapshot(&repo);
4502        let plan = plan_commit(
4503            &repo,
4504            &dry_run_req("add new"),
4505            CommitScope::Paths(paths),
4506            &mut NullProgress,
4507            &crate::progress::NeverCancel,
4508        )
4509        .unwrap();
4510        assert_eq!(plan.files.len(), 1);
4511        assert_eq!(plan.files[0].path, "new.txt");
4512        assert_eq!(repo_snapshot(&repo), before);
4513    }
4514
4515    #[test]
4516    fn dry_run_amend_honors_author_override() {
4517        let tmp = TempDir::new().unwrap();
4518        let root = tmp.path();
4519        let repo = init_repo(root);
4520        fs::write(root.join("a.txt"), b"1\n").unwrap();
4521        stage(
4522            &repo,
4523            &StageOptions::default(),
4524            &mut NullProgress,
4525            &crate::progress::NeverCancel,
4526        )
4527        .unwrap();
4528        create_commit(
4529            &repo,
4530            &commit_req("first"),
4531            &mut NullProgress,
4532            &crate::progress::NeverCancel,
4533        )
4534        .unwrap();
4535        fs::write(root.join("a.txt"), b"2\n").unwrap();
4536        stage(
4537            &repo,
4538            &StageOptions::default(),
4539            &mut NullProgress,
4540            &crate::progress::NeverCancel,
4541        )
4542        .unwrap();
4543        let mut req = dry_run_req("amend");
4544        req.amend = true;
4545        req.author = Some(
4546            "Override <o@example.com> 1700000000 +0000"
4547                .parse()
4548                .expect("signature"),
4549        );
4550        let outcome = commit_with_scope(
4551            &repo,
4552            &req,
4553            CommitScope::Staged,
4554            &mut NullProgress,
4555            &crate::progress::NeverCancel,
4556        )
4557        .unwrap();
4558        assert_eq!(outcome.author.email, "o@example.com");
4559        assert_eq!(outcome.author.name, "Override");
4560    }
4561
4562    fn git(dir: &std::path::Path, args: &[&str]) {
4563        let out = grit_test_support::merge_git_config_env(&mut Command::new("git"), &[])
4564            .current_dir(dir)
4565            .args(args)
4566            .env("GIT_CONFIG_GLOBAL", "/dev/null")
4567            .env("GIT_CONFIG_SYSTEM", "/dev/null")
4568            .output()
4569            .expect("git");
4570        assert!(
4571            out.status.success(),
4572            "git {args:?}: {}",
4573            String::from_utf8_lossy(&out.stderr)
4574        );
4575    }
4576
4577    fn two_hunk_file() -> (String, String) {
4578        let mut base = String::new();
4579        for i in 0..24 {
4580            base.push_str(&format!("line{i}\n"));
4581        }
4582        let mut edited = base.clone();
4583        edited = edited.replacen("line5\n", "LINE5\n", 1);
4584        edited = edited.replacen("line18\n", "LINE18\n", 1);
4585        (base, edited)
4586    }
4587
4588    fn tree_blob(repo: &Repository, tree_oid: &ObjectId, path: &str) -> Vec<u8> {
4589        for (p, _mode, oid) in flatten_tree_entries(repo.odb(), tree_oid).unwrap() {
4590            if p == path {
4591                return repo.odb().read(&oid).unwrap().data;
4592            }
4593        }
4594        panic!("missing {path} in tree");
4595    }
4596
4597    #[test]
4598    fn commit_hunks_only_selected_in_tree() {
4599        use crate::diff::hunk_id::{hunk_ids_with_duplicate_peers, DiffSide};
4600        use crate::diff::{unstaged_file_diffs_with_peer_hunks, DiffOptions};
4601        use crate::porcelain::hunk_select::HunkSelection;
4602
4603        let tmp = TempDir::new().unwrap();
4604        let root = tmp.path();
4605        let repo = init_repo(root);
4606        let (base, edited) = two_hunk_file();
4607        fs::write(root.join("f.txt"), base).unwrap();
4608        stage(
4609            &repo,
4610            &StageOptions::default(),
4611            &mut NullProgress,
4612            &crate::progress::NeverCancel,
4613        )
4614        .unwrap();
4615        create_commit(
4616            &repo,
4617            &commit_req("init"),
4618            &mut NullProgress,
4619            &crate::progress::NeverCancel,
4620        )
4621        .unwrap();
4622        fs::write(root.join("f.txt"), edited).unwrap();
4623
4624        let (files, peers) =
4625            unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
4626        let ids = hunk_ids_with_duplicate_peers(
4627            DiffSide::Unstaged,
4628            "f.txt",
4629            &files[0].hunks,
4630            peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
4631        );
4632        commit_with_patch(
4633            &repo,
4634            &commit_req("one hunk"),
4635            CommitPatchInput::Hunks(HunkSelection {
4636                ids: vec![ids[0].clone()],
4637                ranges: Vec::new(),
4638            }),
4639            &[],
4640            &mut NullProgress,
4641            &crate::progress::NeverCancel,
4642        )
4643        .unwrap();
4644
4645        let tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
4646        let tree = parse_commit(&repo.odb().read(&tip).unwrap().data)
4647            .unwrap()
4648            .tree;
4649        let blob = tree_blob(&repo, &tree, "f.txt");
4650        let text = String::from_utf8_lossy(&blob);
4651        assert!(text.contains("LINE5"), "first hunk in commit tree");
4652        assert!(
4653            !text.contains("LINE18"),
4654            "second hunk must not be in commit tree: {text}"
4655        );
4656    }
4657
4658    #[cfg_attr(
4659        not(feature = "full-tests"),
4660        ignore = "starts system git or is heavy; run with --features full-tests"
4661    )]
4662    #[test]
4663    fn commit_hunks_leaves_other_unstaged() {
4664        use crate::diff::hunk_id::{hunk_ids_with_duplicate_peers, DiffSide};
4665        use crate::diff::{unstaged_file_diffs_with_peer_hunks, DiffOptions};
4666        use crate::porcelain::hunk_select::HunkSelection;
4667
4668        let tmp = TempDir::new().unwrap();
4669        let root = tmp.path();
4670        let repo = init_repo(root);
4671        let (base, edited) = two_hunk_file();
4672        fs::write(root.join("f.txt"), base).unwrap();
4673        stage(
4674            &repo,
4675            &StageOptions::default(),
4676            &mut NullProgress,
4677            &crate::progress::NeverCancel,
4678        )
4679        .unwrap();
4680        create_commit(
4681            &repo,
4682            &commit_req("init"),
4683            &mut NullProgress,
4684            &crate::progress::NeverCancel,
4685        )
4686        .unwrap();
4687        fs::write(root.join("f.txt"), edited).unwrap();
4688
4689        let (files, peers) =
4690            unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
4691        let ids = hunk_ids_with_duplicate_peers(
4692            DiffSide::Unstaged,
4693            "f.txt",
4694            &files[0].hunks,
4695            peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
4696        );
4697        commit_with_patch(
4698            &repo,
4699            &commit_req("partial"),
4700            CommitPatchInput::Hunks(HunkSelection {
4701                ids: vec![ids[0].clone()],
4702                ranges: Vec::new(),
4703            }),
4704            &[],
4705            &mut NullProgress,
4706            &crate::progress::NeverCancel,
4707        )
4708        .unwrap();
4709
4710        let diff = run_grit_status_unstaged(root);
4711        assert!(
4712            diff.contains("line18") || diff.contains("LINE18"),
4713            "remaining hunk still unstaged: {diff}"
4714        );
4715        git(root, &["fsck", "--strict"]);
4716    }
4717
4718    fn run_grit_status_unstaged(root: &std::path::Path) -> String {
4719        let out = Command::new("git")
4720            .current_dir(root)
4721            .args(["diff"])
4722            .env("GIT_CONFIG_GLOBAL", "/dev/null")
4723            .env("GIT_CONFIG_SYSTEM", "/dev/null")
4724            .output()
4725            .unwrap();
4726        String::from_utf8_lossy(&out.stdout).into_owned()
4727    }
4728
4729    fn format_patch_for_git(patch: &FilePatch) -> String {
4730        use crate::apply::HunkLine;
4731        let mut out = String::new();
4732        let path = patch.effective_path().unwrap_or("f");
4733        out.push_str(&format!("diff --git a/{path} b/{path}\n"));
4734        if patch.is_new {
4735            out.push_str("new file mode 100644\n");
4736        }
4737        let old = if patch.is_new {
4738            "/dev/null".to_string()
4739        } else {
4740            format!("a/{}", patch.old_path.as_deref().unwrap_or(path))
4741        };
4742        let new = if patch.is_deleted {
4743            "/dev/null".to_string()
4744        } else {
4745            format!("b/{}", patch.new_path.as_deref().unwrap_or(path))
4746        };
4747        out.push_str(&format!("--- {old}\n"));
4748        out.push_str(&format!("+++ {new}\n"));
4749        for h in &patch.hunks {
4750            out.push_str(&format!(
4751                "@@ -{},{} +{},{} @@\n",
4752                h.old_start, h.old_count, h.new_start, h.new_count
4753            ));
4754            for line in &h.lines {
4755                match line {
4756                    HunkLine::Context(s) => {
4757                        out.push(' ');
4758                        out.push_str(s);
4759                        if !s.ends_with('\n') {
4760                            out.push('\n');
4761                        }
4762                    }
4763                    HunkLine::Add(s) => {
4764                        out.push('+');
4765                        out.push_str(s);
4766                        if !s.ends_with('\n') {
4767                            out.push('\n');
4768                        }
4769                    }
4770                    HunkLine::Remove(s) => {
4771                        out.push('-');
4772                        out.push_str(s);
4773                        if !s.ends_with('\n') {
4774                            out.push('\n');
4775                        }
4776                    }
4777                    HunkLine::NoNewline => out.push_str("\\ No newline at end of file\n"),
4778                }
4779            }
4780        }
4781        out
4782    }
4783
4784    #[cfg_attr(
4785        not(feature = "full-tests"),
4786        ignore = "starts system git or is heavy; run with --features full-tests"
4787    )]
4788    #[test]
4789    fn commit_patch_file_matches_git_apply_cached_then_commit() {
4790        use crate::diff::hunk_id::{hunk_ids_with_duplicate_peers, DiffSide};
4791        use crate::diff::{unstaged_file_diffs_with_peer_hunks, DiffOptions};
4792        use crate::porcelain::hunk_select::{resolve_selection, HunkSelection};
4793
4794        let tmp = TempDir::new().unwrap();
4795        let root = tmp.path();
4796        let repo = init_repo(root);
4797        let (base, edited) = two_hunk_file();
4798        fs::write(root.join("f.txt"), base.clone()).unwrap();
4799        stage(
4800            &repo,
4801            &StageOptions::default(),
4802            &mut NullProgress,
4803            &crate::progress::NeverCancel,
4804        )
4805        .unwrap();
4806        create_commit(
4807            &repo,
4808            &commit_req("init"),
4809            &mut NullProgress,
4810            &crate::progress::NeverCancel,
4811        )
4812        .unwrap();
4813        fs::write(root.join("f.txt"), edited.clone()).unwrap();
4814
4815        let (files, peers) =
4816            unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
4817        let ids = hunk_ids_with_duplicate_peers(
4818            DiffSide::Unstaged,
4819            "f.txt",
4820            &files[0].hunks,
4821            peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
4822        );
4823        let patch = resolve_selection(
4824            &repo,
4825            DiffSide::Unstaged,
4826            &HunkSelection {
4827                ids: vec![ids[0].clone()],
4828                ranges: Vec::new(),
4829            },
4830            &[],
4831            DiffOptions::default(),
4832        )
4833        .unwrap();
4834        let patch_text = patch.iter().map(format_patch_for_git).collect::<String>();
4835
4836        commit_with_patch(
4837            &repo,
4838            &commit_req("grit partial"),
4839            CommitPatchInput::Patches(patch),
4840            &[],
4841            &mut NullProgress,
4842            &crate::progress::NeverCancel,
4843        )
4844        .unwrap();
4845        let grit_tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
4846        let grit_tree = parse_commit(&repo.odb().read(&grit_tip).unwrap().data)
4847            .unwrap()
4848            .tree;
4849
4850        let git_dir = tempfile::tempdir().unwrap();
4851        git(git_dir.path(), &["init", "-q", "-b", "main"]);
4852        fs::write(git_dir.path().join("f.txt"), base).unwrap();
4853        git(git_dir.path(), &["add", "f.txt"]);
4854        git(git_dir.path(), &["commit", "-qm", "init"]);
4855        fs::write(git_dir.path().join("f.txt"), edited).unwrap();
4856        fs::write(git_dir.path().join("partial.patch"), &patch_text).unwrap();
4857        git(git_dir.path(), &["apply", "--cached", "partial.patch"]);
4858        git(git_dir.path(), &["commit", "-qm", "git partial"]);
4859        let git_tip = Command::new("git")
4860            .current_dir(git_dir.path())
4861            .args(["rev-parse", "HEAD^{tree}"])
4862            .env("GIT_CONFIG_GLOBAL", "/dev/null")
4863            .env("GIT_CONFIG_SYSTEM", "/dev/null")
4864            .output()
4865            .unwrap();
4866        let git_tree_hex = String::from_utf8_lossy(&git_tip.stdout);
4867        let git_tree = ObjectId::from_hex(git_tree_hex.trim()).unwrap();
4868        assert_eq!(grit_tree, git_tree);
4869        git(root, &["fsck", "--strict"]);
4870    }
4871
4872    fn loose_object_count(repo: &Repository) -> usize {
4873        repo.odb().count_loose_objects().unwrap_or(0)
4874    }
4875
4876    #[test]
4877    fn commit_patch_dry_run_writes_no_loose_objects() {
4878        use crate::diff::hunk_id::{hunk_ids_with_duplicate_peers, DiffSide};
4879        use crate::diff::{unstaged_file_diffs_with_peer_hunks, DiffOptions};
4880        use crate::porcelain::hunk_select::{resolve_selection, HunkSelection};
4881
4882        let tmp = TempDir::new().unwrap();
4883        let root = tmp.path();
4884        let repo = init_repo(root);
4885        let (base, edited) = two_hunk_file();
4886        fs::write(root.join("f.txt"), base).unwrap();
4887        stage(
4888            &repo,
4889            &StageOptions::default(),
4890            &mut NullProgress,
4891            &crate::progress::NeverCancel,
4892        )
4893        .unwrap();
4894        create_commit(
4895            &repo,
4896            &commit_req("init"),
4897            &mut NullProgress,
4898            &crate::progress::NeverCancel,
4899        )
4900        .unwrap();
4901        fs::write(root.join("f.txt"), edited).unwrap();
4902
4903        let before_loose = loose_object_count(&repo);
4904        let (files, peers) =
4905            unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
4906        let ids = hunk_ids_with_duplicate_peers(
4907            DiffSide::Unstaged,
4908            "f.txt",
4909            &files[0].hunks,
4910            peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
4911        );
4912        let patch = resolve_selection(
4913            &repo,
4914            DiffSide::Unstaged,
4915            &HunkSelection {
4916                ids: vec![ids[0].clone()],
4917                ranges: Vec::new(),
4918            },
4919            &[],
4920            DiffOptions::default(),
4921        )
4922        .unwrap();
4923        let before_tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
4924        let before_index = repo.load_index().unwrap().entries[0].oid;
4925
4926        let mut req = commit_req("dry preview");
4927        req.dry_run = true;
4928        commit_with_patch(
4929            &repo,
4930            &req,
4931            CommitPatchInput::Patches(patch),
4932            &[],
4933            &mut NullProgress,
4934            &crate::progress::NeverCancel,
4935        )
4936        .unwrap();
4937
4938        assert_eq!(loose_object_count(&repo), before_loose);
4939        assert_eq!(
4940            resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
4941            before_tip
4942        );
4943        assert_eq!(repo.load_index().unwrap().entries[0].oid, before_index);
4944    }
4945
4946    #[test]
4947    fn commit_hunks_stale_id_no_commit() {
4948        use crate::diff::hunk_id::HunkId;
4949        use crate::porcelain::hunk_select::HunkSelection;
4950
4951        let tmp = TempDir::new().unwrap();
4952        let root = tmp.path();
4953        let repo = init_repo(root);
4954        fs::write(root.join("f.txt"), b"one\n").unwrap();
4955        stage(
4956            &repo,
4957            &StageOptions::default(),
4958            &mut NullProgress,
4959            &crate::progress::NeverCancel,
4960        )
4961        .unwrap();
4962        create_commit(
4963            &repo,
4964            &commit_req("init"),
4965            &mut NullProgress,
4966            &crate::progress::NeverCancel,
4967        )
4968        .unwrap();
4969        let before_tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
4970        let before_index_oid = repo.load_index().unwrap().entries[0].oid;
4971
4972        fs::write(root.join("f.txt"), b"two\n").unwrap();
4973        let err = commit_with_patch(
4974            &repo,
4975            &commit_req("nope"),
4976            CommitPatchInput::Hunks(HunkSelection {
4977                ids: vec![HunkId::from_str("000000000001").unwrap()],
4978                ranges: Vec::new(),
4979            }),
4980            &[],
4981            &mut NullProgress,
4982            &crate::progress::NeverCancel,
4983        )
4984        .unwrap_err();
4985        assert!(matches!(err, CommitPatchError::Select(_)));
4986        assert_eq!(
4987            resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
4988            before_tip
4989        );
4990        assert_eq!(repo.load_index().unwrap().entries[0].oid, before_index_oid);
4991    }
4992
4993    #[test]
4994    fn commit_with_scope_stale_hunk_id_is_typed() {
4995        use crate::diff::hunk_id::HunkId;
4996        use crate::porcelain::hunk_select::{HunkSelectError, HunkSelection};
4997
4998        let tmp = TempDir::new().unwrap();
4999        let root = tmp.path();
5000        let repo = init_repo(root);
5001        fs::write(root.join("a.txt"), b"one\ntwo\n").unwrap();
5002        commit_with_scope(
5003            &repo,
5004            &commit_req("first"),
5005            CommitScope::AutoStage,
5006            &mut NullProgress,
5007            &crate::progress::NeverCancel,
5008        )
5009        .unwrap();
5010        let before_tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
5011        let before_index_oid = repo.load_index().unwrap().entries[0].oid;
5012
5013        fs::write(root.join("a.txt"), b"one\ntwo\nthree\n").unwrap();
5014        let stale = HunkId::from_str("000000000000").unwrap();
5015        let err = commit_with_scope(
5016            &repo,
5017            &commit_req("bogus"),
5018            CommitScope::Patch {
5019                input: CommitPatchInput::Hunks(HunkSelection {
5020                    ids: vec![stale.clone()],
5021                    ranges: Vec::new(),
5022                }),
5023                pathspecs: Vec::new(),
5024            },
5025            &mut NullProgress,
5026            &crate::progress::NeverCancel,
5027        )
5028        .unwrap_err();
5029        match &err {
5030            Error::HunkSelection(HunkSelectError::StaleId { id, .. }) => assert_eq!(id, &stale),
5031            other => panic!("expected stale hunk id error, got {other:?}"),
5032        }
5033        assert_eq!(err.to_string(), "stale hunk id 000000000000");
5034        assert_eq!(err.kind_name(), "hunk_selection");
5035        assert_eq!(
5036            resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
5037            before_tip
5038        );
5039        assert_eq!(repo.load_index().unwrap().entries[0].oid, before_index_oid);
5040
5041        let plan_err = plan_commit(
5042            &repo,
5043            &commit_req("bogus"),
5044            CommitScope::Patch {
5045                input: CommitPatchInput::Hunks(HunkSelection {
5046                    ids: vec![stale],
5047                    ranges: Vec::new(),
5048                }),
5049                pathspecs: Vec::new(),
5050            },
5051            &mut NullProgress,
5052            &crate::progress::NeverCancel,
5053        )
5054        .unwrap_err();
5055        assert!(matches!(
5056            plan_err,
5057            Error::HunkSelection(HunkSelectError::StaleId { .. })
5058        ));
5059    }
5060
5061    #[test]
5062    fn commit_with_scope_patch_apply_failure_is_typed() {
5063        use crate::diff::hunk_id::{hunk_ids_with_duplicate_peers, DiffSide};
5064        use crate::diff::{unstaged_file_diffs_with_peer_hunks, DiffOptions};
5065        use crate::porcelain::hunk_select::{resolve_selection, HunkSelection};
5066
5067        let tmp = TempDir::new().unwrap();
5068        let root = tmp.path();
5069        let repo = init_repo(root);
5070        let (base, edited) = two_hunk_file();
5071        fs::write(root.join("f.txt"), base).unwrap();
5072        stage(
5073            &repo,
5074            &StageOptions::default(),
5075            &mut NullProgress,
5076            &crate::progress::NeverCancel,
5077        )
5078        .unwrap();
5079        create_commit(
5080            &repo,
5081            &commit_req("init"),
5082            &mut NullProgress,
5083            &crate::progress::NeverCancel,
5084        )
5085        .unwrap();
5086        fs::write(root.join("f.txt"), edited).unwrap();
5087
5088        let (files, peers) =
5089            unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
5090        let ids = hunk_ids_with_duplicate_peers(
5091            DiffSide::Unstaged,
5092            "f.txt",
5093            &files[0].hunks,
5094            peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
5095        );
5096        let patch = resolve_selection(
5097            &repo,
5098            DiffSide::Unstaged,
5099            &HunkSelection {
5100                ids: vec![ids[0].clone()],
5101                ranges: Vec::new(),
5102            },
5103            &[],
5104            DiffOptions::default(),
5105        )
5106        .unwrap();
5107        let patch_scope = |patch: Vec<FilePatch>| CommitScope::Patch {
5108            input: CommitPatchInput::Patches(patch),
5109            pathspecs: Vec::new(),
5110        };
5111        commit_with_scope(
5112            &repo,
5113            &commit_req("first hunk"),
5114            patch_scope(patch.clone()),
5115            &mut NullProgress,
5116            &crate::progress::NeverCancel,
5117        )
5118        .unwrap();
5119        let before_tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
5120
5121        // The index already holds the postimage, so the same patch no longer applies.
5122        let err = commit_with_scope(
5123            &repo,
5124            &commit_req("again"),
5125            patch_scope(patch),
5126            &mut NullProgress,
5127            &crate::progress::NeverCancel,
5128        )
5129        .unwrap_err();
5130        assert!(matches!(err, Error::Apply(_)), "got {err:?}");
5131        assert_eq!(err.kind_name(), "apply");
5132        assert_eq!(
5133            resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
5134            before_tip
5135        );
5136    }
5137
5138    #[test]
5139    fn commit_with_scope_empty_paths_is_typed() {
5140        let tmp = TempDir::new().unwrap();
5141        let root = tmp.path();
5142        let repo = init_repo(root);
5143        fs::write(root.join("a.txt"), b"one\n").unwrap();
5144        commit_with_scope(
5145            &repo,
5146            &commit_req("first"),
5147            CommitScope::AutoStage,
5148            &mut NullProgress,
5149            &crate::progress::NeverCancel,
5150        )
5151        .unwrap();
5152        let before_tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
5153
5154        fs::write(root.join("a.txt"), b"two\n").unwrap();
5155        let err = commit_with_scope(
5156            &repo,
5157            &commit_req("x"),
5158            CommitScope::Paths(ResolvedPathspecs::default()),
5159            &mut NullProgress,
5160            &crate::progress::NeverCancel,
5161        )
5162        .unwrap_err();
5163        assert!(matches!(err, Error::CommitPathsRequired), "got {err:?}");
5164        assert_eq!(err.to_string(), "you must specify path(s)");
5165        assert_eq!(err.kind_name(), "commit_paths_required");
5166        assert_eq!(
5167            resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
5168            before_tip
5169        );
5170    }
5171}