Skip to main content

grit_lib/porcelain/
commit.rs

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