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