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