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