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, stage_on_index_with_pathspec_ignore_exempt, 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::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)]
82pub struct CommitAuthorInfo {
83    /// Author display name.
84    pub name: String,
85    /// Author email.
86    pub email: String,
87    /// Git timestamp suffix (`<unix> <+HHMM>`).
88    pub date: String,
89}
90
91fn guard_incomplete_rewrite_apply(repo: &Repository) -> Result<()> {
92    ensure_rewrite_apply_ready(repo).map_err(|err| match err {
93        HistoryError::RewriteApplyIncomplete => {
94            Error::OperationInProgress(InProgressOperation::RewriteApply)
95        }
96        other => Error::History(other),
97    })
98}
99
100/// Inputs for [`create_commit`].
101#[derive(Debug, Clone, Default)]
102pub struct CommitRequest {
103    /// Commit message body (first line becomes the subject). A trailing newline is added when missing.
104    ///
105    /// The message is also written to `.git/COMMIT_EDITMSG` for the `commit-msg` hook. The file is
106    /// read back only when that hook ran, so concurrent committers sharing the file never record
107    /// each other's message.
108    pub message: String,
109    /// Author to record. `None` keeps the replaced commit's author for [`Self::amend`] and the
110    /// source commit's author for [`Self::reuse_message_from`]; otherwise the repository's
111    /// author identity ([`Repository::identity`]) is used. [`Self::author_date`] and
112    /// [`Self::reset_author`] still apply.
113    pub author: Option<Signature>,
114    /// Committer to record; `None` uses the repository's committer identity
115    /// ([`Repository::identity`]).
116    pub committer: Option<Signature>,
117    /// When false (default), reject commits whose tree equals the parent's tree or is the empty tree on an unborn branch.
118    pub allow_empty: bool,
119    /// 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`.
120    pub sign_override: Option<bool>,
121    /// Replace the current branch tip: parents and author come from the tip being replaced; an empty
122    /// [`Self::message`] reuses that commit's message. Same-tree amends are allowed.
123    pub amend: bool,
124    /// When true, skip the post-commit full-tree index stat refresh because the caller already
125    /// synced the index from the worktree (for example via [`crate::porcelain::add::stage_for_commit`]).
126    ///
127    /// [`commit_worktree`] and every [`commit_with_scope`] scope set this themselves, since their
128    /// staging pass already refreshed the index; only [`create_commit`] callers need to choose.
129    pub assume_worktree_synced: bool,
130    /// When true with [`Self::assume_worktree_synced`], skip the pre-commit index↔parent tree
131    /// match; the post-`write_tree` parent comparison still rejects empty commits (issue #1064).
132    #[doc(hidden)]
133    pub skip_pre_commit_tree_match: bool,
134    /// When set with [`Self::skip_pre_commit_tree_match`], use this instead of a full tree diff for
135    /// [`CommitOutcome::changes`] (issue #1064).
136    #[doc(hidden)]
137    pub staged_change_count: Option<usize>,
138    /// Hook execution policy for `pre-commit` and `commit-msg`.
139    #[doc(alias = "no-verify")]
140    pub hooks: HookPolicy,
141    /// Author timestamp suffix (`<unix> <+HHMM>`) replacing the author line date when set.
142    pub author_date: Option<String>,
143    /// Trailers to append via [`process_trailers`] (same semantics as `git commit --trailer`).
144    pub trailers: Vec<(String, String)>,
145    /// Append `Signed-off-by` using the committer identity (`-s` / `--signoff`).
146    pub signoff: bool,
147    /// Use the committer as the author with a fresh author date (`--reset-author`).
148    pub reset_author: bool,
149    /// Reuse log message and author from this commit (`-C` / `--reuse-message`); copies get a new change id.
150    pub reuse_message_from: Option<ObjectId>,
151    /// When true, compute the commit tree and file list without writing objects, updating refs, or running hooks.
152    pub dry_run: bool,
153    /// When true, a [`Error::NothingToCommit`] is upgraded to [`Error::NothingStagedToCommit`] if the
154    /// work tree still has unstaged or untracked paths (index-only commit; issue #1969).
155    #[doc(hidden)]
156    pub index_only: bool,
157    /// Whether an amend runs `post-rewrite amend` ([`PostOperationHooks::Skip`] is Git's
158    /// `--no-post-rewrite`). `post-commit` runs either way.
159    pub post_rewrite: PostOperationHooks,
160}
161
162/// One path-level change that would be recorded in a commit.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct CommitFile {
165    /// Primary path (`git diff --name-status` path column).
166    pub path: String,
167    /// Kind of change vs the parent commit tree.
168    pub status: DiffStatus,
169    /// Previous path for renames and copies.
170    pub old_path: Option<String>,
171}
172
173/// Planned commit contents and metadata (see [`plan_commit`]).
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct CommitPlan {
176    /// Paths that differ from the parent tree, in `git diff --name-status` order.
177    pub files: Vec<CommitFile>,
178    /// Parent OIDs that would be stored on the new commit.
179    pub parents: Vec<ObjectId>,
180    /// Author line that would be recorded.
181    pub author: CommitAuthorInfo,
182    /// Whether the computed tree OID equals the parent tree.
183    pub tree_unchanged: bool,
184}
185
186/// Which paths are included when recording a commit ([`commit_with_scope`]).
187#[derive(Debug, Clone)]
188pub enum CommitScope {
189    /// Stage every worktree change, then commit (default `grit commit` behavior).
190    AutoStage,
191    /// Commit the index as-is (`git commit` without `-a`).
192    Staged,
193    /// Commit only matching paths from the worktree (`git commit --only --` paths).
194    Paths(ResolvedPathspecs),
195    /// Commit only hunks from a unified diff or stable hunk ids (`grit commit --hunks` / `--patch-file`).
196    Patch {
197        /// Hunk ids or pre-parsed file patches (exactly one is populated).
198        input: CommitPatchInput,
199        /// Optional pathspecs narrowing hunk resolution (same as `grit add --hunks`).
200        pathspecs: Vec<String>,
201    },
202}
203
204impl PartialEq for CommitScope {
205    fn eq(&self, other: &Self) -> bool {
206        match (self, other) {
207            (Self::AutoStage, Self::AutoStage) | (Self::Staged, Self::Staged) => true,
208            (Self::Paths(a), Self::Paths(b)) => a == b,
209            (
210                Self::Patch {
211                    input: a,
212                    pathspecs: ap,
213                },
214                Self::Patch {
215                    input: b,
216                    pathspecs: bp,
217                },
218            ) => ap == bp && patch_input_same_kind(a, b),
219            _ => false,
220        }
221    }
222}
223
224impl Eq for CommitScope {}
225
226fn patch_input_same_kind(a: &CommitPatchInput, b: &CommitPatchInput) -> bool {
227    matches!(
228        (a, b),
229        (CommitPatchInput::Hunks(_), CommitPatchInput::Hunks(_))
230            | (CommitPatchInput::Patches(_), CommitPatchInput::Patches(_))
231    )
232}
233
234/// Hunk ids or parsed patches for [`CommitScope::Patch`].
235#[derive(Debug, Clone)]
236pub enum CommitPatchInput {
237    /// Stable ids from `grit diff --json`.
238    Hunks(HunkSelection),
239    /// Unified diff hunks (`git apply --cached` preimage = index).
240    Patches(Vec<FilePatch>),
241}
242
243/// Failure while resolving or applying a patch-scoped commit.
244#[derive(Debug, Error)]
245pub enum CommitPatchError {
246    /// Hunk id resolution failed (unknown, stale, wrong side, binary).
247    #[error(transparent)]
248    Select(#[from] HunkSelectError),
249    /// Index apply failed after resolution.
250    #[error(transparent)]
251    Apply(#[from] ApplyError),
252    /// Commit, index, or repository error.
253    #[error(transparent)]
254    Commit(#[from] Error),
255}
256
257/// Default [`CommitScope`] from `commit.autoStage` (default true → [`CommitScope::AutoStage`]).
258///
259/// # Errors
260///
261/// Propagates config read failures from [`Repository::config`].
262pub fn default_commit_scope(repo: &Repository) -> Result<CommitScope> {
263    let config = repo.config()?;
264    Ok(default_commit_scope_from_config(config.as_ref()))
265}
266
267/// Read `commit.autoStage` (default true).
268#[must_use]
269pub fn default_commit_scope_from_config(config: &ConfigSet) -> CommitScope {
270    let auto_stage = config
271        .get_bool("commit.autoStage")
272        .and_then(|r| r.ok())
273        .unwrap_or(true);
274    if auto_stage {
275        CommitScope::AutoStage
276    } else {
277        CommitScope::Staged
278    }
279}
280
281/// Remove squash merge sentinel files after a successful commit (Git removes `SQUASH_MSG` on commit).
282///
283/// # Errors
284///
285/// Returns [`Error::Io`] if a present file cannot be removed.
286pub(crate) fn clear_squash_commit_state(git_dir: &std::path::Path) -> Result<()> {
287    let squash = git_dir.join("SQUASH_MSG");
288    if !squash.is_file() {
289        return Ok(());
290    }
291    fs::remove_file(&squash).map_err(Error::Io)?;
292    if !git_dir.join("MERGE_HEAD").is_file() {
293        let merge_msg = git_dir.join("MERGE_MSG");
294        if merge_msg.is_file() {
295            fs::remove_file(&merge_msg).map_err(Error::Io)?;
296        }
297    }
298    Ok(())
299}
300
301/// Default message for the next commit when `SQUASH_MSG` or merge `MERGE_MSG` is present.
302///
303/// # Errors
304///
305/// Propagates I/O and merge-state read failures.
306pub fn pending_commit_message(repo: &Repository) -> Result<Option<String>> {
307    let git_dir = repo.git_dir();
308    let squash = git_dir.join("SQUASH_MSG");
309    let merge_msg = git_dir.join("MERGE_MSG");
310    // Squash merge conflicts store the editable message (including `-m`) in `MERGE_MSG`.
311    if squash.is_file() && merge_msg.is_file() {
312        return fs::read_to_string(merge_msg).map_err(Error::Io).map(Some);
313    }
314    if squash.is_file() {
315        return fs::read_to_string(squash).map_err(Error::Io).map(Some);
316    }
317    if !read_merge_heads(git_dir)?.is_empty() {
318        return read_merge_msg(git_dir);
319    }
320    Ok(None)
321}
322
323/// Record a commit using `scope` to decide how the index is prepared.
324///
325/// # Errors
326///
327/// Same as [`create_commit`] and [`commit_worktree`], plus [`Error::PartialCommitDuringMerge`]
328/// for [`CommitScope::Paths`] during an in-progress merge, and pathspec validation errors.
329pub fn commit_with_scope(
330    repo: &Repository,
331    req: &CommitRequest,
332    scope: CommitScope,
333    progress: &mut dyn ProgressSink,
334) -> Result<CommitOutcome> {
335    guard_incomplete_rewrite_apply(repo)?;
336    match scope {
337        CommitScope::AutoStage => commit_worktree(repo, req, progress),
338        CommitScope::Staged => {
339            let mut req = req.clone();
340            // Index stats were refreshed when paths were staged; skip full-tree refresh (#1622).
341            req.assume_worktree_synced = true;
342            req.index_only = true;
343            create_commit(repo, &req, progress)
344        }
345        CommitScope::Paths(paths) => commit_paths_scope(repo, req, &paths, progress),
346        CommitScope::Patch { input, pathspecs } => {
347            commit_with_patch(repo, req, input, &pathspecs, progress).map_err(|e| match e {
348                CommitPatchError::Commit(err) => err,
349                other => Error::Message(other.to_string()),
350            })
351        }
352    }
353}
354
355/// Record a commit after applying `input` to the current index (partial stage + commit).
356///
357/// Applies the selection in memory, commits the resulting tree, then writes the updated index.
358/// Does not auto-stage the worktree (same effective scope as [`CommitScope::Staged`]).
359///
360/// # Errors
361///
362/// [`CommitPatchError::Select`] for hunk id failures; [`CommitPatchError::Apply`] for patch apply;
363/// same commit errors as [`create_commit`].
364pub fn commit_with_patch(
365    repo: &Repository,
366    req: &CommitRequest,
367    input: CommitPatchInput,
368    pathspecs: &[String],
369    progress: &mut dyn ProgressSink,
370) -> std::result::Result<CommitOutcome, CommitPatchError> {
371    guard_incomplete_rewrite_apply(repo).map_err(CommitPatchError::Commit)?;
372    commit_patch_scope(repo, req, input, pathspecs, progress)
373}
374
375fn commit_patch_scope(
376    repo: &Repository,
377    req: &CommitRequest,
378    input: CommitPatchInput,
379    pathspecs: &[String],
380    progress: &mut dyn ProgressSink,
381) -> std::result::Result<CommitOutcome, CommitPatchError> {
382    if !read_merge_heads(repo.git_dir())?.is_empty() {
383        return Err(Error::PartialCommitDuringMerge.into());
384    }
385    let index_lock = (!req.dry_run)
386        .then(|| repo.begin_index_update())
387        .transpose()?;
388
389    let patches = match input {
390        CommitPatchInput::Hunks(selection) => {
391            if selection.ids.is_empty() && selection.ranges.is_empty() {
392                return Err(Error::NothingToCommit.into());
393            }
394            resolve_selection(
395                repo,
396                crate::diff::hunk_id::DiffSide::Unstaged,
397                &selection,
398                pathspecs,
399                diff_options_for_hunk_select(repo).map_err(CommitPatchError::Commit)?,
400            )?
401        }
402        CommitPatchInput::Patches(patches) => {
403            if patches.is_empty() {
404                return Err(Error::NothingToCommit.into());
405            }
406            patches
407        }
408    };
409
410    let head = resolve_head_for_repository(repo).map_err(CommitPatchError::Commit)?;
411    let mut index = repo.load_index().map_err(CommitPatchError::Commit)?;
412    if index.has_unmerged_entries() {
413        return Err(Error::IndexUnmerged.into());
414    }
415
416    let dry_run = req.dry_run;
417    let _patch_overlay = if dry_run {
418        repo.odb().enable_mem_overlay();
419        Some(MemOverlayGuard(repo.odb()))
420    } else {
421        None
422    };
423
424    apply_patch_to_index(
425        repo,
426        &mut index,
427        &patches,
428        &IndexApplyOptions {
429            dry_run: false,
430            ..IndexApplyOptions::default()
431        },
432    )?;
433
434    let mut req = req.clone();
435    req.assume_worktree_synced = true;
436    let persist = index_lock.map(|lock| LockedIndex { index: None, lock });
437    create_commit_with_index(repo, head, &mut index, &req, progress, persist)
438        .map_err(CommitPatchError::Commit)
439}
440
441/// Preview a commit for `scope` without mutating the repository (objects, index on disk, refs, hooks).
442///
443/// Uses in-memory blob hashing during staging (no new loose objects) and returns the same validation
444/// errors as [`commit_with_scope`] for unmerged index and nothing-to-commit.
445///
446/// # Errors
447///
448/// Same as [`commit_with_scope`].
449pub fn plan_commit(
450    repo: &Repository,
451    req: &CommitRequest,
452    scope: CommitScope,
453    progress: &mut dyn ProgressSink,
454) -> Result<CommitPlan> {
455    let mut req = req.clone();
456    req.dry_run = true;
457    let outcome = commit_with_scope(repo, &req, scope, progress)?;
458    Ok(CommitPlan {
459        files: outcome.files,
460        parents: outcome.parents,
461        author: outcome.author,
462        tree_unchanged: outcome.changes == 0,
463    })
464}
465
466/// Copy stage-0 rows from `source` into `commit_index` when they are absent from the parent-tree
467/// snapshot but needed for path-scoped staging.
468///
469/// Path-scoped commits build `commit_index` from the parent tree. Ignored paths that are
470/// already staged in the repository index (force-added or tracked) must appear there before
471/// worktree staging runs, or ignored untracked collection skips them. Rows already present from
472/// the parent tree are left untouched so explicitly excluded paths keep their HEAD versions.
473fn seed_commit_index_stage0_from_index(
474    repo: &Repository,
475    commit_index: &mut Index,
476    source: &Index,
477    paths: &ResolvedPathspecs,
478) -> Result<()> {
479    if paths.specs.is_empty() {
480        return Ok(());
481    }
482    let ctx = repo.pathspec_match_context();
483    let mut seeded = false;
484    for entry in &source.entries {
485        if entry.stage() != 0 {
486            continue;
487        }
488        let path = match std::str::from_utf8(&entry.path) {
489            Ok(p) if !p.is_empty() => p,
490            _ => continue,
491        };
492        if commit_index.get(path.as_bytes(), 0).is_some() {
493            continue;
494        }
495        if !matches_pathspec_list_with_context(path, &paths.specs, ctx) {
496            continue;
497        }
498        commit_index.stage_file(entry.clone());
499        seeded = true;
500    }
501    if seeded {
502        commit_index.ensure_canonical_order();
503    }
504    Ok(())
505}
506
507fn commit_paths_scope(
508    repo: &Repository,
509    req: &CommitRequest,
510    paths: &ResolvedPathspecs,
511    progress: &mut dyn ProgressSink,
512) -> Result<CommitOutcome> {
513    if paths.specs.is_empty() {
514        return Err(Error::Message("you must specify path(s)".into()));
515    }
516    if !read_merge_heads(repo.git_dir())?.is_empty() {
517        return Err(Error::PartialCommitDuringMerge);
518    }
519    validate_commit_pathspecs(repo, paths)?;
520
521    // Lock before reading HEAD and the index so nothing visible (objects, ref, reflog) is written
522    // when another process holds it, and so nobody can commit or change the index under us.
523    let index_lock = (!req.dry_run)
524        .then(|| repo.begin_index_update())
525        .transpose()?;
526    let head = resolve_head_for_repository(repo)?;
527    let parent_tree = match &head {
528        HeadState::Branch { oid: Some(p), .. } | HeadState::Detached { oid: p } => {
529            Some(commit_tree_oid(repo, p)?)
530        }
531        HeadState::Branch { oid: None, .. } => None,
532        HeadState::Invalid => {
533            return Err(Error::Message("HEAD is in an unknown state".into()));
534        }
535    };
536
537    let mut real_index = repo.load_index()?;
538    if real_index.has_unmerged_entries() {
539        return Err(Error::IndexUnmerged);
540    }
541    let mut stage_opts = StageOptions {
542        pathspecs: paths.specs.clone(),
543        pathspec_sources: if paths.sources.is_empty() {
544            paths.specs.clone()
545        } else {
546            paths.sources.clone()
547        },
548        mode: StageMode::All,
549        dry_run: req.dry_run,
550        ..StageOptions::default()
551    };
552
553    {
554        let mut validate_opts = stage_opts.clone();
555        validate_opts.dry_run = true;
556        let mut probe = real_index.clone();
557        stage_on_index_with_pathspec_ignore_exempt(
558            repo,
559            &mut probe,
560            &validate_opts,
561            progress,
562            Some(&real_index),
563        )?;
564    }
565    stage_opts.skip_pathspec_ignore_check = true;
566
567    let mut commit_index = match parent_tree.as_ref() {
568        Some(tree) => index_from_tree_with_seeded_worktree_stats(repo, tree, &real_index)?,
569        None => Index::new(),
570    };
571    seed_commit_index_stage0_from_index(repo, &mut commit_index, &real_index, paths)?;
572
573    let staged_commit = stage_on_index(repo, &mut commit_index, &stage_opts, progress)?;
574    let real_index = match index_lock {
575        Some(lock) => {
576            stage_on_index(repo, &mut real_index, &stage_opts, progress)?;
577            Some(LockedIndex {
578                index: Some(real_index),
579                lock,
580            })
581        }
582        None => None,
583    };
584
585    let mut req = req.clone();
586    req.assume_worktree_synced = true;
587    req.skip_pre_commit_tree_match = false;
588    req.staged_change_count = Some(staged_commit.total().max(1));
589    req.index_only = true;
590
591    create_commit_with_index(repo, head, &mut commit_index, &req, progress, real_index)
592}
593
594/// The repository index to persist after a commit, with the lock taken before the commit started.
595///
596/// Hooks read a temporary copy of the commit index; the repository index is written through the
597/// lock only after the ref moved. Dropping it unwritten releases the lock and leaves the on-disk
598/// index untouched.
599struct LockedIndex {
600    /// Index to persist when it differs from the committed one (path-scoped commits keep other
601    /// staged changes); `None` persists the committed index itself.
602    index: Option<Index>,
603    lock: IndexLock,
604}
605
606fn validate_commit_pathspecs(repo: &Repository, paths: &ResolvedPathspecs) -> Result<()> {
607    let pairs: Vec<(&str, &str)> =
608        if paths.sources.len() == paths.specs.len() && !paths.sources.is_empty() {
609            paths
610                .sources
611                .iter()
612                .zip(paths.specs.iter())
613                .map(|(a, b)| (a.as_str(), b.as_str()))
614                .collect()
615        } else {
616            paths
617                .specs
618                .iter()
619                .map(|s| (s.as_str(), s.as_str()))
620                .collect()
621        };
622    let mut checker = KnownPathspecChecker::new(repo);
623    for (source, resolved) in pairs {
624        if pathspec_is_exclude(source) {
625            continue;
626        }
627        checker.ensure(source, resolved)?;
628    }
629    Ok(())
630}
631
632/// Normalize a commit message body the same way [`create_commit`] does before hooks: stripspace,
633/// reject empty bodies, ensure a trailing newline.
634///
635/// # Errors
636///
637/// Returns [`Error::EmptyCommitMessage`] when the cleaned body would be empty.
638pub fn normalize_commit_message(raw: &str) -> Result<String> {
639    let normalized = raw.replace("\r\n", "\n").replace('\r', "\n");
640    let processed = process(normalized.as_bytes(), &Mode::Default);
641    let message = String::from_utf8(processed)
642        .map_err(|_| Error::Message("invalid commit message".into()))?;
643    if message.is_empty() {
644        return Err(Error::EmptyCommitMessage);
645    }
646    let mut message = message;
647    while message.ends_with("\n\n") {
648        message.pop();
649    }
650    if !message.ends_with('\n') {
651        message.push('\n');
652    }
653    Ok(message)
654}
655
656fn run_commit_hook_recorded(
657    repo: &Repository,
658    hook: HookName,
659    args: &[&str],
660    stdin_data: Option<&[u8]>,
661    commit_env: &CommitHookEnv<'_>,
662) -> std::result::Result<bool, HookError> {
663    run_commit_hook(repo, hook.as_str(), args, stdin_data, commit_env)?.check(hook.as_str())
664}
665
666fn author_info_from_header_line(line: &str) -> CommitAuthorInfo {
667    let line = line.trim();
668    let date = parse_signature_times(line)
669        .map(|p| {
670            let tz = &line[p.tz_hhmm_range];
671            format!("{} {tz}", p.unix_seconds)
672        })
673        .unwrap_or_default();
674    let contact_end = line.rfind('>').map(|i| i + 1).unwrap_or(line.len());
675    let contact = line[..contact_end].trim();
676    let (name, email) = if let Some(lt) = contact.find('<') {
677        if let Some(gt) = contact.find('>') {
678            (contact[..lt].trim(), contact[lt + 1..gt].trim())
679        } else {
680            (contact, "")
681        }
682    } else if contact.contains('@') && !contact.chars().any(char::is_whitespace) {
683        ("", contact)
684    } else {
685        (contact, "")
686    };
687    CommitAuthorInfo {
688        name: name.to_owned(),
689        email: email.to_owned(),
690        date,
691    }
692}
693
694fn author_line_from_committer(
695    committer: &str,
696    author_date: Option<&str>,
697    now: time::OffsetDateTime,
698) -> String {
699    let info = author_info_from_header_line(committer);
700    assemble_identity(&info.name, &info.email, author_date, now)
701}
702
703fn enrich_commit_message(
704    message: String,
705    trailers: &[(String, String)],
706    signoff: bool,
707    committer: &str,
708    config: &ConfigSet,
709    git_dir: &Path,
710) -> Result<String> {
711    let mut out = message;
712    if signoff {
713        let info = author_info_from_header_line(committer);
714        let sob = format_signoff_line(&info.name, &info.email);
715        append_signoff_trailer_with_dedup(&mut out, &sob, config, true);
716    }
717    if !trailers.is_empty() {
718        let new_args: Vec<NewTrailerArg> = trailers
719            .iter()
720            .map(|(key, value)| NewTrailerArg {
721                text: if value.is_empty() {
722                    format!("{key}:")
723                } else {
724                    format!("{key}: {value}")
725                },
726                where_: TrailerWhere::End,
727                if_exists: TrailerIfExists::AddIfDifferent,
728                if_missing: TrailerIfMissing::Add,
729            })
730            .collect();
731        let opts = ProcessTrailerOptions {
732            only_trailers: false,
733            trim_empty: true,
734            only_input: false,
735            unfold: false,
736            no_divider: false,
737        };
738        out = process_trailers(&out, &opts, &new_args, Some(git_dir));
739    }
740    Ok(out)
741}
742
743/// Result of [`create_commit`].
744#[derive(Debug, Clone, PartialEq, Eq)]
745pub struct CommitOutcome {
746    /// OID of the new commit object written to the object database.
747    pub oid: ObjectId,
748    /// Short name of the branch the commit advanced (e.g. `main`), or `None` when `HEAD` is
749    /// detached and only `HEAD` moved.
750    pub branch: Option<String>,
751    /// Parent commit OID, or `None` for the root commit on an unborn branch.
752    pub parent: Option<ObjectId>,
753    /// Every parent OID stored in the new commit object (empty only before the first commit is written).
754    pub parents: Vec<ObjectId>,
755    /// Author recorded on the commit object.
756    pub author: CommitAuthorInfo,
757    /// Hooks that were installed and ran during this commit: `pre-commit`, `commit-msg`,
758    /// `post-commit`, and `post-rewrite` (with `amend`) when [`CommitRequest::amend`] is set.
759    pub hooks_run: Vec<HookName>,
760    /// Number of path-level changes between the parent commit tree and this commit's tree.
761    pub changes: usize,
762    /// Stored `change-id` header when present on the new commit object.
763    pub change_id: Option<ChangeId>,
764    /// Path-level changes vs the parent tree (`git diff --name-status` between parent and new tree).
765    pub files: Vec<CommitFile>,
766}
767
768impl CommitOutcome {
769    /// Whether the commit was made on a detached `HEAD` ([`Self::branch`] is `None`).
770    #[must_use]
771    pub fn is_detached(&self) -> bool {
772        self.branch.is_none()
773    }
774}
775
776/// Write a commit from the current index and advance the checked-out branch.
777///
778/// Loads the index from disk, builds the commit tree with an incremental cache-tree
779/// write-tree, writes the commit object, writes the index back with an updated cache-tree,
780/// then atomically updates the branch ref and reflogs (see [`crate::refs::update_branch_for_commit_on_store`]).
781///
782/// # Parameters
783///
784/// - `repo` — open repository with a work tree and branch `HEAD`.
785/// - `req` — message, author/committer identities, and empty-commit policy.
786/// - `progress` — optional progress sink (single `commit` phase).
787///
788/// # Errors
789///
790/// - [`Error::AmendUnborn`] when [`CommitRequest::amend`] is set but the branch has no commits yet.
791/// - [`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).
792/// - [`Error::IndexUnmerged`] when the index has conflict stages.
793/// - Index write or diff failures before the branch is updated; the branch tip is not advanced.
794/// - 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.
795pub fn create_commit(
796    repo: &Repository,
797    req: &CommitRequest,
798    progress: &mut dyn ProgressSink,
799) -> Result<CommitOutcome> {
800    guard_incomplete_rewrite_apply(repo)?;
801    let head = resolve_head_for_repository(repo)?;
802    let mut index = repo.load_index()?;
803    create_commit_with_index(repo, head, &mut index, req, progress, None)
804}
805
806/// Stage every worktree change and record a commit using a single in-memory index (issue #1064).
807///
808/// Avoids an extra index load/write between implicit staging and commit tree construction. The
809/// staging pass is the only work-tree scan: it refreshes the stat data of every tracked entry, so
810/// the commit always runs as if [`CommitRequest::assume_worktree_synced`] were set.
811///
812/// When staging changed the index but the result matches the parent tree (for example a new
813/// file that was staged and then deleted from the work tree), the staged index is still written
814/// before [`Error::NothingToCommit`] is returned, so the on-disk index matches `HEAD` afterwards.
815/// [`CommitRequest::dry_run`] never writes the index.
816///
817/// # Errors
818///
819/// Same as [`create_commit`], plus staging failures from [`crate::porcelain::add::stage_for_commit`].
820pub fn commit_worktree(
821    repo: &Repository,
822    req: &CommitRequest,
823    progress: &mut dyn ProgressSink,
824) -> Result<CommitOutcome> {
825    guard_incomplete_rewrite_apply(repo)?;
826    let head = resolve_head_for_repository(repo)?;
827    let mut index = repo.load_index()?;
828    let staged =
829        crate::porcelain::add::stage_for_commit_on_index(repo, &mut index, progress, req.dry_run)?;
830    if index.has_unmerged_entries() {
831        return Err(Error::IndexUnmerged);
832    }
833    let mut req = req.clone();
834    req.assume_worktree_synced = true;
835    if req.skip_pre_commit_tree_match {
836        req.staged_change_count = Some(staged.total());
837    }
838    let result = create_commit_with_index(repo, head, &mut index, &req, progress, None);
839    if matches!(result, Err(Error::NothingToCommit)) && !req.dry_run && staged.total() > 0 {
840        repo.write_index(&mut index)?;
841    }
842    result
843}
844
845fn commit_files_from_diff_entries(entries: &[DiffEntry]) -> Vec<CommitFile> {
846    let mut files: Vec<CommitFile> = entries
847        .iter()
848        .filter(|e| e.status != DiffStatus::Unmerged)
849        .map(|e| {
850            let (path, old_path) = match e.status {
851                DiffStatus::Renamed | DiffStatus::Copied => {
852                    (e.new_path.clone().unwrap_or_default(), e.old_path.clone())
853                }
854                DiffStatus::Deleted => (e.old_path.clone().unwrap_or_default(), None),
855                _ => (e.new_path.clone().unwrap_or_default(), None),
856            };
857            CommitFile {
858                path,
859                status: e.status,
860                old_path,
861            }
862        })
863        .collect();
864    files.sort_by(|a, b| a.path.cmp(&b.path));
865    files
866}
867
868fn commit_files_between_trees(
869    odb: &crate::odb::Odb,
870    old_tree: Option<&ObjectId>,
871    new_tree: &ObjectId,
872) -> Result<Vec<CommitFile>> {
873    let entries = diff_trees_with_renames(odb, old_tree, Some(new_tree), "", 50)?;
874    Ok(commit_files_from_diff_entries(&entries))
875}
876
877fn commit_files_index_vs_parent_tree(
878    odb: &crate::odb::Odb,
879    index: &Index,
880    parent_tree: Option<&ObjectId>,
881) -> Result<Vec<CommitFile>> {
882    let mut parent: BTreeMap<String, ObjectId> = BTreeMap::new();
883    if let Some(tree) = parent_tree {
884        for (path, _mode, oid) in flatten_tree_entries(odb, tree)? {
885            parent.insert(path, oid);
886        }
887    }
888    let mut files = Vec::new();
889    for entry in index.entries() {
890        if entry.stage() != 0 || entry.intent_to_add() {
891            continue;
892        }
893        let path = String::from_utf8_lossy(&entry.path).into_owned();
894        match parent.remove(&path) {
895            None => files.push(CommitFile {
896                path,
897                status: DiffStatus::Added,
898                old_path: None,
899            }),
900            Some(old_oid) if old_oid != entry.oid => files.push(CommitFile {
901                path,
902                status: DiffStatus::Modified,
903                old_path: None,
904            }),
905            _ => {}
906        }
907    }
908    for (path, _) in parent {
909        files.push(CommitFile {
910            path,
911            status: DiffStatus::Deleted,
912            old_path: None,
913        });
914    }
915    files.sort_by(|a, b| a.path.cmp(&b.path));
916    Ok(files)
917}
918
919struct MemOverlayGuard<'a>(&'a Odb);
920
921impl Drop for MemOverlayGuard<'_> {
922    fn drop(&mut self) {
923        self.0.disable_mem_overlay();
924    }
925}
926
927fn materialize_missing_index_blobs_for_overlay(repo: &Repository, index: &Index) -> Result<()> {
928    let odb = repo.odb();
929    let work_tree = repo.work_tree().ok_or(Error::RequiresWorkTree)?;
930    let rules = WorktreeRules::from_repository(repo, index)?;
931    for entry in index
932        .entries
933        .iter()
934        .filter(|e| e.stage() == 0 && !e.intent_to_add())
935    {
936        if odb.contains(&entry.oid)? {
937            continue;
938        }
939        let rel = String::from_utf8_lossy(&entry.path);
940        let abs = crate::index::worktree_path_from_index_rel(work_tree, &entry.path);
941        let meta = fs::metadata(&abs).map_err(Error::Io)?;
942        let file_attrs = rules.file_attrs(&rel, false);
943        let stored = materialize_worktree_blob(
944            odb,
945            &abs,
946            &meta,
947            rules.conversion(),
948            &file_attrs,
949            &rel,
950            Some(entry),
951            Some(rules.filter_process()),
952        )?;
953        if stored != entry.oid {
954            return Err(Error::Message(format!(
955                "internal error: staged oid mismatch for {rel}"
956            )));
957        }
958    }
959    Ok(())
960}
961
962#[allow(clippy::too_many_arguments)]
963fn finish_commit_dry_run(
964    repo: &Repository,
965    index: &mut Index,
966    req: &CommitRequest,
967    short_name: Option<String>,
968    parent: Option<ObjectId>,
969    parent_tree: Option<ObjectId>,
970    merge_heads: Vec<ObjectId>,
971    concluding_merge: bool,
972    amend_author: Option<String>,
973    amend_author_raw: Vec<u8>,
974    amend_parents: Vec<ObjectId>,
975    amend_default_message: Option<String>,
976    req_amend: bool,
977    reuse_author: Option<String>,
978    reuse_author_raw: Vec<u8>,
979    reuse_message: Option<String>,
980) -> Result<CommitOutcome> {
981    let odb = repo.odb();
982    odb.enable_mem_overlay();
983    let _overlay = MemOverlayGuard(odb);
984    materialize_missing_index_blobs_for_overlay(repo, index)?;
985    let files = commit_files_index_vs_parent_tree(odb, index, parent_tree.as_ref())?;
986
987    if !req.allow_empty && !req_amend {
988        if parent_tree.is_none() && index.entries.is_empty() {
989            return Err(empty_commit_error(repo, index, req));
990        }
991        if !concluding_merge && files.is_empty() {
992            return Err(empty_commit_error(repo, index, req));
993        }
994    }
995
996    let mut message = if !req.message.trim().is_empty() {
997        normalize_commit_message(&req.message)?
998    } else if let Some(reuse_msg) = reuse_message {
999        reuse_msg
1000    } else if req_amend {
1001        amend_default_message.unwrap_or_default()
1002    } else {
1003        return Err(Error::EmptyCommitMessage);
1004    };
1005    if message.trim().is_empty() {
1006        return Err(Error::EmptyCommitMessage);
1007    }
1008    if !message.ends_with('\n') {
1009        message.push('\n');
1010    }
1011    let config = repo.config()?;
1012    let now = repo.identity_now();
1013    let committer = commit_identity_line(repo, req.committer.as_ref(), IdentRole::Committer, now)?;
1014    let _message_with_trailers = enrich_commit_message(
1015        message,
1016        &req.trailers,
1017        req.signoff,
1018        &committer,
1019        config.as_ref(),
1020        repo.git_dir(),
1021    )?;
1022
1023    let (author, _author_raw) = resolve_author_for_commit(
1024        repo,
1025        req,
1026        &committer,
1027        amend_author,
1028        amend_author_raw,
1029        reuse_author,
1030        reuse_author_raw,
1031        req_amend,
1032    )?;
1033    let mut commit_parents = if req_amend {
1034        amend_parents
1035    } else {
1036        parent.into_iter().collect()
1037    };
1038    if !req_amend {
1039        for merge_head in merge_heads {
1040            if !commit_parents.iter().any(|p| p == &merge_head) {
1041                commit_parents.push(merge_head);
1042            }
1043        }
1044    }
1045    let author_info = author_info_from_header_line(&author);
1046    let changes = files.len();
1047
1048    Ok(CommitOutcome {
1049        oid: zero_oid(),
1050        branch: short_name,
1051        parent,
1052        parents: commit_parents,
1053        author: author_info,
1054        hooks_run: Vec::new(),
1055        changes,
1056        change_id: None,
1057        files,
1058    })
1059}
1060
1061#[allow(clippy::too_many_arguments)]
1062fn resolve_author_for_commit(
1063    repo: &Repository,
1064    req: &CommitRequest,
1065    committer: &str,
1066    amend_author: Option<String>,
1067    amend_author_raw: Vec<u8>,
1068    reuse_author: Option<String>,
1069    reuse_author_raw: Vec<u8>,
1070    req_amend: bool,
1071) -> Result<(String, Vec<u8>)> {
1072    let now = repo.identity_now();
1073    if req.reset_author {
1074        return Ok((
1075            author_line_from_committer(committer, req.author_date.as_deref(), now),
1076            Vec::new(),
1077        ));
1078    }
1079    if let Some(author) = req.author.as_ref() {
1080        author.validate()?;
1081        let author =
1082            prepare_commit_identity_line(&author.header_line(), req.author_date.as_deref(), now)?;
1083        return Ok((author, Vec::new()));
1084    }
1085    if let Some(a) = reuse_author {
1086        return Ok(prepare_commit_author_from_parent(
1087            &a,
1088            &reuse_author_raw,
1089            req.author_date.as_deref(),
1090            now,
1091        )?);
1092    }
1093    if req_amend {
1094        let author = amend_author.unwrap_or_default();
1095        return Ok(prepare_commit_author_from_parent(
1096            &author,
1097            &amend_author_raw,
1098            req.author_date.as_deref(),
1099            now,
1100        )?);
1101    }
1102    let author = repo.identity(IdentRole::Author, now)?.header_line();
1103    let author = prepare_commit_identity_line(&author, req.author_date.as_deref(), now)?;
1104    Ok((author, Vec::new()))
1105}
1106
1107/// The committer (or author) header line for a new commit: `identity` when given, otherwise
1108/// the repository's identity for `role`.
1109fn commit_identity_line(
1110    repo: &Repository,
1111    identity: Option<&Signature>,
1112    role: IdentRole,
1113    now: time::OffsetDateTime,
1114) -> Result<String> {
1115    match identity {
1116        Some(signature) => {
1117            signature.validate()?;
1118            Ok(signature.header_line())
1119        }
1120        None => Ok(repo.identity(role, now)?.header_line()),
1121    }
1122}
1123
1124/// Removes a partial-commit hook index file on drop (Git `next-index-<pid>`).
1125struct TempHookIndexFile(PathBuf);
1126
1127impl Drop for TempHookIndexFile {
1128    fn drop(&mut self) {
1129        let _ = fs::remove_file(&self.0);
1130    }
1131}
1132
1133/// Write a merge-continue (or other) commit from a caller-owned index.
1134///
1135/// The index must have no unmerged stages; it is written back with the new cache-tree.
1136pub(crate) fn create_commit_with_loaded_index(
1137    repo: &Repository,
1138    index: &mut Index,
1139    req: &CommitRequest,
1140    progress: &mut dyn ProgressSink,
1141) -> Result<CommitOutcome> {
1142    if index.has_unmerged_entries() {
1143        return Err(Error::IndexUnmerged);
1144    }
1145    let head = resolve_head_for_repository(repo)?;
1146    create_commit_with_index(repo, head, index, req, progress, None)
1147}
1148
1149/// Record a commit of `index` on top of `head`.
1150///
1151/// `head` must be read before the content `index` was built from: the branch update then
1152/// compares against that same tip, so a commit landed by another process in between makes this
1153/// one fail instead of silently reverting it.
1154fn create_commit_with_index(
1155    repo: &Repository,
1156    head: HeadState,
1157    index: &mut Index,
1158    req: &CommitRequest,
1159    _progress: &mut dyn ProgressSink,
1160    persist_index: Option<LockedIndex>,
1161) -> Result<CommitOutcome> {
1162    let (refname, short_name, mut parent) = match head {
1163        HeadState::Branch {
1164            refname,
1165            short_name,
1166            oid,
1167        } => (refname, Some(short_name), oid),
1168        HeadState::Detached { oid } => ("HEAD".to_owned(), None, Some(oid)),
1169        HeadState::Invalid => {
1170            return Err(Error::Message("HEAD is in an unknown state".into()));
1171        }
1172    };
1173
1174    let mut amend_parents = Vec::new();
1175    let mut amend_author = None;
1176    let mut amend_author_raw = Vec::new();
1177    let mut amend_extra_headers = Vec::new();
1178    let mut amend_default_message = None;
1179    let mut replaced_tip = None;
1180    let mut reuse_author = None;
1181    let mut reuse_author_raw = Vec::new();
1182    let mut reuse_message = None;
1183    if let Some(reuse_oid) = req.reuse_message_from {
1184        let obj = repo.odb().read(&reuse_oid)?;
1185        if obj.kind != ObjectKind::Commit {
1186            return Err(Error::CorruptObject(format!(
1187                "expected commit for reuse-message, got {}",
1188                obj.kind.as_str()
1189            )));
1190        }
1191        let source = parse_commit(&obj.data)?;
1192        reuse_message = Some(source.message);
1193        reuse_author = Some(source.author);
1194        reuse_author_raw = source.author_raw;
1195    }
1196    if req.amend {
1197        let Some(tip) = parent.take() else {
1198            return Err(Error::AmendUnborn);
1199        };
1200        let obj = repo.odb().read(&tip)?;
1201        if obj.kind != ObjectKind::Commit {
1202            return Err(Error::CorruptObject(format!(
1203                "expected commit, got {}",
1204                obj.kind.as_str()
1205            )));
1206        }
1207        let old = parse_commit(&obj.data)?;
1208        amend_parents = old.parents.clone();
1209        parent = old.parents.first().copied();
1210        amend_author = Some(old.author);
1211        amend_author_raw = old.author_raw;
1212        amend_extra_headers = extra_headers_without_commit_signatures(&old.extra_headers);
1213        amend_default_message = Some(old.message);
1214        replaced_tip = Some(tip);
1215    }
1216
1217    if index.has_unmerged_entries() {
1218        return Err(Error::IndexUnmerged);
1219    }
1220    let merge_heads = read_merge_heads(repo.git_dir())?;
1221    let concluding_merge = !merge_heads.is_empty();
1222    let index_path = repo.git_dir().join("index");
1223    let parent_tree = parent
1224        .as_ref()
1225        .map(|parent_oid| commit_tree_oid(repo, parent_oid))
1226        .transpose()?;
1227
1228    if !req.allow_empty && !req.amend && !concluding_merge {
1229        let skip_tree_match = req.assume_worktree_synced && req.skip_pre_commit_tree_match;
1230        if !skip_tree_match {
1231            if let Some(ref old) = parent_tree {
1232                if index_staged_tree_matches(repo.odb(), index, old) {
1233                    return Err(empty_commit_error(repo, index, req));
1234                }
1235            } else if index.entries.is_empty() {
1236                return Err(empty_commit_error(repo, index, req));
1237            }
1238        }
1239    }
1240
1241    if req.dry_run {
1242        return finish_commit_dry_run(
1243            repo,
1244            index,
1245            req,
1246            short_name,
1247            parent,
1248            parent_tree,
1249            merge_heads,
1250            concluding_merge,
1251            amend_author,
1252            amend_author_raw,
1253            amend_parents,
1254            amend_default_message,
1255            req.amend,
1256            reuse_author,
1257            reuse_author_raw,
1258            reuse_message,
1259        );
1260    }
1261
1262    let persist_through_lock = persist_index.is_some();
1263    let _temp_hook_index_guard = if persist_through_lock {
1264        let path = repo.git_dir().join(format!("next-index-{}", process::id()));
1265        repo.write_index_at(&path, index)?;
1266        Some(TempHookIndexFile(path))
1267    } else {
1268        repo.write_index(index)?;
1269        None
1270    };
1271
1272    let temp_hook_index_path = _temp_hook_index_guard.as_ref().map(|g| g.0.clone());
1273
1274    let hook_index_path: PathBuf = temp_hook_index_path
1275        .clone()
1276        .unwrap_or_else(|| index_path.clone());
1277
1278    let commit_env = CommitHookEnv {
1279        index_file: Some(hook_index_path.as_path()),
1280        git_editor: Some(":"),
1281        git_prefix: None,
1282        extra_env: &[],
1283    };
1284    let mut hooks_run = Vec::new();
1285    if req.hooks != HookPolicy::SkipVerify
1286        && run_commit_hook_recorded(repo, HookName::PreCommit, &[], None, &commit_env)?
1287    {
1288        hooks_run.push(HookName::PreCommit);
1289    }
1290
1291    let tree = write_tree_update_index(repo.odb(), index, "", WriteTreeFlags::silent())?;
1292    let commit_files = commit_files_between_trees(repo.odb(), parent_tree.as_ref(), &tree)?;
1293
1294    if !req.allow_empty && !req.amend {
1295        if parent_tree.is_none() && is_empty_tree_oid(repo.odb(), &tree) {
1296            return Err(empty_commit_error(repo, index, req));
1297        }
1298        if !concluding_merge {
1299            if let Some(ref old) = parent_tree {
1300                if old == &tree {
1301                    return Err(empty_commit_error(repo, index, req));
1302                }
1303            }
1304        }
1305    }
1306
1307    let mut message = if !req.message.trim().is_empty() {
1308        normalize_commit_message(&req.message)?
1309    } else if let Some(reuse_msg) = reuse_message {
1310        reuse_msg
1311    } else if req.amend {
1312        amend_default_message.unwrap_or_default()
1313    } else if let Some(pending) = pending_commit_message(repo)? {
1314        pending
1315    } else {
1316        return Err(Error::EmptyCommitMessage);
1317    };
1318    if message.trim().is_empty() {
1319        return Err(Error::EmptyCommitMessage);
1320    }
1321    if !message.ends_with('\n') {
1322        message.push('\n');
1323    }
1324    let config = repo.config()?;
1325    let now = repo.identity_now();
1326    let committer = commit_identity_line(repo, req.committer.as_ref(), IdentRole::Committer, now)?;
1327    message = enrich_commit_message(
1328        message,
1329        &req.trailers,
1330        req.signoff,
1331        &committer,
1332        config.as_ref(),
1333        repo.git_dir(),
1334    )?;
1335    let editmsg_path = repo.git_dir().join("COMMIT_EDITMSG");
1336    fs::write(&editmsg_path, &message).map_err(Error::Io)?;
1337    let editmsg_arg = commit_editmsg_hook_argument(repo, &editmsg_path);
1338    if req.hooks != HookPolicy::SkipVerify
1339        && run_commit_hook_recorded(
1340            repo,
1341            HookName::CommitMsg,
1342            &[editmsg_arg.as_str()],
1343            None,
1344            &commit_env,
1345        )?
1346    {
1347        hooks_run.push(HookName::CommitMsg);
1348        // COMMIT_EDITMSG is shared by every committer in the repository, so it is only
1349        // read back when a hook may have edited it; otherwise the in-memory message wins.
1350        message = fs::read_to_string(&editmsg_path).map_err(Error::Io)?;
1351        if message.trim().is_empty() {
1352            return Err(Error::EmptyCommitMessage);
1353        }
1354        if !message.ends_with('\n') {
1355            message.push('\n');
1356        }
1357    }
1358
1359    let (author, author_raw) = resolve_author_for_commit(
1360        repo,
1361        req,
1362        &committer,
1363        amend_author,
1364        amend_author_raw,
1365        reuse_author,
1366        reuse_author_raw,
1367        req.amend,
1368    )?;
1369    let mut commit_parents = if req.amend {
1370        amend_parents
1371    } else {
1372        parent.into_iter().collect()
1373    };
1374    if !req.amend {
1375        for merge_head in merge_heads {
1376            if !commit_parents.iter().any(|p| p == &merge_head) {
1377                commit_parents.push(merge_head);
1378            }
1379        }
1380    }
1381    let author_info = author_info_from_header_line(&author);
1382    let mut commit_data = CommitData {
1383        tree,
1384        parents: commit_parents.clone(),
1385        author,
1386        committer: committer.clone(),
1387        author_raw,
1388        committer_raw: Vec::new(),
1389        encoding: None,
1390        message,
1391        raw_message: None,
1392        extra_headers: amend_extra_headers,
1393    };
1394    let policy = ChangeIdPolicy::from_config(config.as_ref());
1395    if policy.enabled() {
1396        if req.amend {
1397            if change_id(&commit_data).is_none() {
1398                if let Some(tip) = replaced_tip {
1399                    set_change_id(&mut commit_data, &ChangeId::derived_from_commit_id(&tip));
1400                }
1401            }
1402        } else {
1403            stamp_change_id_on_new_commit(repo, &policy, &mut commit_data)?;
1404        }
1405    }
1406    let bytes = prepare_commit_bytes(repo, config.as_ref(), &commit_data, req.sign_override)?;
1407    fsck_commit_idents(&bytes, repo.odb().hash_algo())
1408        .map_err(|detail| Error::CorruptObject(format!("commit would fail fsck: {detail}")))?;
1409    let oid = repo.odb().write(ObjectKind::Commit, &bytes)?;
1410
1411    let previous_tip = replaced_tip.or(parent);
1412    let reflog_old = previous_tip.unwrap_or_else(zero_oid);
1413    let subject = commit_subject(&commit_data.message);
1414    let reflog_msg = if req.amend {
1415        format!("commit (amend): {subject}")
1416    } else if parent.is_some() {
1417        format!("commit: {subject}")
1418    } else {
1419        format!("commit (initial): {subject}")
1420    };
1421
1422    let expected_old = previous_tip.or_else(|| {
1423        matches!(repo.references().resolve_if_exists(&refname), Ok(Some(_))).then_some(reflog_old)
1424    });
1425
1426    if !persist_through_lock {
1427        if !req.assume_worktree_synced {
1428            if let Some(work_tree) = repo.work_tree() {
1429                let rules = WorktreeRules::from_repository(repo, index).ok();
1430                let rules_arc = rules.map(|r| Arc::new(Mutex::new(r)));
1431                let _ = refresh_index_stat_content_verified_with_rules(
1432                    repo.odb(),
1433                    repo.git_dir(),
1434                    index,
1435                    work_tree,
1436                    None,
1437                    Some(config.as_ref()),
1438                    None,
1439                    rules_arc.as_ref(),
1440                )?;
1441            }
1442        }
1443        repo.write_index(index)?;
1444    }
1445
1446    let changes = if parent_tree.as_ref() == Some(&tree) {
1447        0
1448    } else if !commit_files.is_empty() {
1449        commit_files.len()
1450    } else if req.assume_worktree_synced && req.skip_pre_commit_tree_match {
1451        // Large-index commits skip the tree diff for speed (issue #1064); smaller repos keep
1452        // accurate path counts for pre-staged and JSON output.
1453        const LARGE_INDEX_CHANGE_COUNT_FAST_PATH: usize = 4096;
1454        let large_index = index.entries.len() >= LARGE_INDEX_CHANGE_COUNT_FAST_PATH;
1455        if large_index {
1456            if let Some(n) = req.staged_change_count.filter(|&n| n > 0) {
1457                n
1458            } else {
1459                diff_tree_path_change_count(repo.odb(), parent_tree.as_ref(), Some(&tree), "")?
1460            }
1461        } else {
1462            diff_tree_path_change_count(repo.odb(), parent_tree.as_ref(), Some(&tree), "")?
1463        }
1464    } else {
1465        diff_tree_path_change_count(repo.odb(), parent_tree.as_ref(), Some(&tree), "")?
1466    };
1467
1468    if short_name.is_none() {
1469        update_detached_head_for_commit_on_store(
1470            repo.refs(),
1471            expected_old,
1472            oid,
1473            &committer,
1474            &reflog_msg,
1475            config.as_ref(),
1476        )?;
1477    } else {
1478        update_branch_for_commit_on_store(
1479            repo.refs(),
1480            &BranchCommitRefUpdate {
1481                branch_ref: &refname,
1482                expected_old,
1483                new_oid: oid,
1484                identity: &committer,
1485                reflog_message: &reflog_msg,
1486            },
1487            config.as_ref(),
1488        )?;
1489    }
1490
1491    if concluding_merge {
1492        let _ = finish_merge_state(repo)?;
1493    }
1494    clear_squash_commit_state(repo.git_dir())?;
1495
1496    if let Some(LockedIndex {
1497        index: mut separate,
1498        mut lock,
1499    }) = persist_index
1500    {
1501        let persist = separate.as_mut().unwrap_or(index);
1502        if !req.assume_worktree_synced {
1503            if let Some(work_tree) = repo.work_tree() {
1504                let rules = WorktreeRules::from_repository(repo, persist).ok();
1505                let rules_arc = rules.map(|r| Arc::new(Mutex::new(r)));
1506                let _ = refresh_index_stat_content_verified_with_rules(
1507                    repo.odb(),
1508                    repo.git_dir(),
1509                    persist,
1510                    work_tree,
1511                    None,
1512                    Some(config.as_ref()),
1513                    None,
1514                    rules_arc.as_ref(),
1515                )?;
1516            }
1517        }
1518        repo.write_index_with_lock(&mut lock, persist)?;
1519    }
1520
1521    let post_commit_env = CommitHookEnv {
1522        index_file: Some(
1523            temp_hook_index_path
1524                .as_deref()
1525                .unwrap_or(index_path.as_path()),
1526        ),
1527        git_editor: Some(":"),
1528        git_prefix: None,
1529        extra_env: &[],
1530    };
1531    if run_advisory_hook(
1532        repo,
1533        HookName::PostCommit,
1534        &[],
1535        None,
1536        Some(&post_commit_env),
1537    ) {
1538        hooks_run.push(HookName::PostCommit);
1539    }
1540    if let Some(old_tip) = replaced_tip.filter(|_| req.post_rewrite.enabled()) {
1541        if run_post_rewrite(repo, RewriteKind::Amend, &[(old_tip, oid)]) {
1542            hooks_run.push(HookName::PostRewrite);
1543        }
1544    }
1545
1546    let stored_change_id = change_id(&commit_data);
1547    Ok(CommitOutcome {
1548        oid,
1549        branch: short_name,
1550        parent,
1551        parents: commit_data.parents,
1552        author: author_info,
1553        hooks_run,
1554        changes,
1555        change_id: stored_change_id,
1556        files: commit_files,
1557    })
1558}
1559
1560/// When [`ChangeIdPolicy`] is enabled, assign a fresh id to a newly written commit.
1561///
1562/// Does not run on amends or history rewrites; callers pass an empty `extra_headers`
1563/// so source change ids never carry over (pick, revert, clean merge).
1564///
1565/// # Errors
1566///
1567/// Propagates change-id generation failures as [`Error::Message`].
1568pub(crate) fn stamp_change_id_on_new_commit(
1569    repo: &Repository,
1570    policy: &ChangeIdPolicy,
1571    data: &mut CommitData,
1572) -> Result<()> {
1573    if let Some(id) = policy
1574        .id_for_new_commit(
1575            data,
1576            repo.odb().hash_algo(),
1577            repo.change_id_generator().as_ref(),
1578        )
1579        .map_err(|e| Error::Message(format!("change id: {e}")))?
1580    {
1581        set_change_id(data, &id);
1582    }
1583    Ok(())
1584}
1585
1586/// Serialize `data` and sign the bytes when [`should_sign_commit`] says so.
1587///
1588/// Does not assign change ids. Callers that need a `change-id` header (for example
1589/// [`create_commit`]) set it with [`crate::change_id::set_change_id`] first. Stash,
1590/// notes, and other auxiliary commits use this path without adding ids.
1591///
1592/// # Errors
1593///
1594/// Propagates signing and config errors from [`GpgConfig::from_config`] and
1595/// [`sign_serialized_commit`].
1596pub fn prepare_commit_bytes(
1597    repo: &Repository,
1598    config: &ConfigSet,
1599    data: &CommitData,
1600    sign_override: Option<bool>,
1601) -> Result<Vec<u8>> {
1602    let unsigned = serialize_commit(data);
1603    if !should_sign_commit(config, sign_override)? {
1604        return Ok(unsigned);
1605    }
1606    let gpg = GpgConfig::from_config(config)?.with_command_runner(repo.command_runner());
1607    sign_serialized_commit(
1608        &gpg,
1609        &unsigned,
1610        &data.committer,
1611        None,
1612        repo.odb().hash_algo(),
1613    )
1614}
1615
1616/// Write a commit object to the ODB, honoring `commit.gpgsign` and `sign_override`.
1617///
1618/// Does not assign change ids; see [`prepare_commit_bytes`].
1619///
1620/// # Errors
1621///
1622/// Same as [`prepare_commit_bytes`], plus object-database write failures.
1623pub fn write_commit_object(
1624    repo: &Repository,
1625    data: &CommitData,
1626    sign_override: Option<bool>,
1627) -> Result<ObjectId> {
1628    let config = repo.config()?;
1629    let bytes = prepare_commit_bytes(repo, config.as_ref(), data, sign_override)?;
1630    repo.odb().write(ObjectKind::Commit, &bytes)
1631}
1632
1633fn empty_commit_error(repo: &Repository, index: &mut Index, req: &CommitRequest) -> Error {
1634    if req.index_only {
1635        let mut worktree_index = match repo.load_index() {
1636            Ok(real) => real,
1637            Err(_) => index.clone(),
1638        };
1639        if let Ok((unstaged, untracked)) =
1640            crate::porcelain::status::count_unstaged_and_untracked_paths(repo, &mut worktree_index)
1641        {
1642            if unstaged + untracked > 0 {
1643                return Error::NothingStagedToCommit {
1644                    unstaged,
1645                    untracked,
1646                };
1647            }
1648        }
1649    }
1650    Error::NothingToCommit
1651}
1652
1653/// True when stage-0 index entries describe `tree_oid`, using a valid cache-tree when present.
1654fn index_staged_tree_matches(
1655    odb: &crate::odb::Odb,
1656    index: &crate::index::Index,
1657    tree_oid: &ObjectId,
1658) -> bool {
1659    if index.cache_tree_root == Some(*tree_oid)
1660        && cache_tree_extension_valid(index.cache_tree.as_ref())
1661    {
1662        return true;
1663    }
1664    crate::diff::diff_index_to_tree(odb, index, Some(tree_oid), false)
1665        .map(|d| d.is_empty())
1666        .unwrap_or(false)
1667}
1668
1669fn commit_tree_oid(repo: &Repository, commit_oid: &ObjectId) -> Result<ObjectId> {
1670    let obj = repo.odb().read(commit_oid)?;
1671    if obj.kind != ObjectKind::Commit {
1672        return Err(Error::CorruptObject(format!(
1673            "expected commit, got {}",
1674            obj.kind.as_str()
1675        )));
1676    }
1677    Ok(parse_commit(&obj.data)?.tree)
1678}
1679
1680fn commit_subject(message: &str) -> &str {
1681    message.split('\n').next().unwrap_or("").trim_end()
1682}
1683
1684/// Path passed as `argv[1]` to the `commit-msg` hook (Git: `.git/COMMIT_EDITMSG` when possible).
1685fn commit_editmsg_hook_argument(repo: &Repository, editmsg_path: &Path) -> String {
1686    if let Some(wt) = repo.work_tree() {
1687        if let Ok(rel) = editmsg_path.strip_prefix(wt) {
1688            let s = rel.to_string_lossy();
1689            if !s.is_empty() {
1690                return s.replace('\\', "/");
1691            }
1692        }
1693    }
1694    editmsg_path.to_string_lossy().into_owned()
1695}
1696
1697#[cfg(test)]
1698mod tests {
1699    #![allow(clippy::expect_used, clippy::unwrap_used)]
1700
1701    use crate::environment::RepositoryOptions;
1702
1703    use super::*;
1704    use crate::index::{entry_from_stat, Index, MODE_REGULAR};
1705    use crate::objects::ObjectKind;
1706    use crate::pathspec::ResolvedPathspecs;
1707    use crate::porcelain::add::{stage, StageOptions};
1708    use crate::progress::NullProgress;
1709    use crate::reflog::read_reflog;
1710    use crate::refs::{resolve_ref, set_test_inject_reflog_fail};
1711    use crate::repo::set_test_inject_index_write_fail;
1712    use crate::write_tree::{cache_tree_fully_valid, verify_cache_tree};
1713    use std::fs;
1714    use std::path::Path;
1715    use std::process::Command;
1716    use std::str::FromStr;
1717    use tempfile::TempDir;
1718
1719    fn init_repo(root: &Path) -> Repository {
1720        let git = root.join(".git");
1721        fs::create_dir_all(git.join("objects")).unwrap();
1722        fs::create_dir_all(git.join("refs/heads")).unwrap();
1723        fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
1724        fs::write(
1725            git.join("config"),
1726            "[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tlogAllRefUpdates = true\n",
1727        )
1728        .unwrap();
1729        Repository::open(&git, &RepositoryOptions::empty().with_work_tree(root)).unwrap()
1730    }
1731
1732    fn ident() -> String {
1733        "Test User <t@example.com> 1 +0000".to_owned()
1734    }
1735
1736    fn stage_file_in_index(repo: &Repository, index: &mut Index, rel: &str, contents: &[u8]) {
1737        let wt = repo.work_tree().unwrap();
1738        let abs = wt.join(rel);
1739        if let Some(parent) = abs.parent() {
1740            fs::create_dir_all(parent).unwrap();
1741        }
1742        fs::write(&abs, contents).unwrap();
1743        let oid = repo.odb().write(ObjectKind::Blob, contents).unwrap();
1744        let entry = entry_from_stat(&abs, rel.as_bytes(), oid, MODE_REGULAR).unwrap();
1745        index.add_or_replace(entry);
1746    }
1747
1748    fn write_index(repo: &Repository, index: &mut Index) {
1749        index.sort();
1750        repo.write_index(index).unwrap();
1751    }
1752
1753    fn commit_req(message: &str) -> CommitRequest {
1754        let ident = ident();
1755        CommitRequest {
1756            message: message.to_owned(),
1757            author: Some(ident.parse().expect("signature")),
1758            committer: Some(ident.parse().expect("signature")),
1759            ..Default::default()
1760        }
1761    }
1762
1763    fn repo_snapshot(repo: &Repository) -> (Vec<u8>, String, usize, bool, usize) {
1764        let index_path = repo.git_dir().join("index");
1765        let index_bytes = fs::read(&index_path).unwrap_or_default();
1766        let head = fs::read_to_string(repo.git_dir().join("HEAD")).unwrap_or_default();
1767        let reflog_len = read_reflog(repo.git_dir(), "refs/heads/main")
1768            .map(|e| e.len())
1769            .unwrap_or(0);
1770        let editmsg = repo.git_dir().join("COMMIT_EDITMSG").exists();
1771        let mut objects = 0usize;
1772        if let Ok(entries) = fs::read_dir(repo.git_dir().join("objects")) {
1773            for entry in entries.flatten() {
1774                let path = entry.path();
1775                if path.is_dir() && path.file_name().is_some_and(|n| n.len() == 2) {
1776                    objects += fs::read_dir(path).map(|d| d.count()).unwrap_or(0);
1777                } else if path.is_file() {
1778                    objects += 1;
1779                }
1780            }
1781        }
1782        (index_bytes, head, reflog_len, editmsg, objects)
1783    }
1784
1785    fn dry_run_req(message: &str) -> CommitRequest {
1786        let mut req = commit_req(message);
1787        req.dry_run = true;
1788        req
1789    }
1790
1791    #[test]
1792    fn create_commit_initial() {
1793        let tmp = TempDir::new().unwrap();
1794        let root = tmp.path();
1795        let repo = init_repo(root);
1796        fs::write(root.join("README"), b"hello\n").unwrap();
1797        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1798
1799        let outcome = create_commit(&repo, &commit_req("initial"), &mut NullProgress).unwrap();
1800        assert!(outcome.parent.is_none());
1801        assert_eq!(outcome.branch.as_deref(), Some("main"));
1802        assert!(!outcome.is_detached());
1803        assert_eq!(outcome.changes, 1);
1804        assert_eq!(
1805            resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
1806            outcome.oid
1807        );
1808    }
1809
1810    #[test]
1811    fn create_commit_rejects_empty_initial_index() {
1812        let tmp = TempDir::new().unwrap();
1813        let repo = init_repo(tmp.path());
1814        let err = create_commit(&repo, &commit_req("empty"), &mut NullProgress).unwrap_err();
1815        assert!(matches!(err, Error::NothingToCommit));
1816        assert!(resolve_ref(&repo.git_dir(), "refs/heads/main").is_err());
1817    }
1818
1819    #[test]
1820    fn create_commit_normal() {
1821        let tmp = TempDir::new().unwrap();
1822        let root = tmp.path();
1823        let repo = init_repo(root);
1824        fs::write(root.join("a.txt"), b"1\n").unwrap();
1825        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1826        create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
1827
1828        fs::write(root.join("a.txt"), b"2\n").unwrap();
1829        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1830        let outcome = create_commit(&repo, &commit_req("second"), &mut NullProgress).unwrap();
1831        assert_eq!(outcome.changes, 1);
1832        assert!(outcome.parent.is_some());
1833    }
1834
1835    #[test]
1836    fn create_commit_nothing_to_commit() {
1837        let tmp = TempDir::new().unwrap();
1838        let root = tmp.path();
1839        let repo = init_repo(root);
1840        fs::write(root.join("a.txt"), b"1\n").unwrap();
1841        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1842        create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
1843
1844        let err = create_commit(&repo, &commit_req("noop"), &mut NullProgress).unwrap_err();
1845        assert!(matches!(err, Error::NothingToCommit));
1846    }
1847
1848    #[test]
1849    fn commit_worktree_nothing_to_commit_persists_staged_deletion() {
1850        let tmp = TempDir::new().unwrap();
1851        let root = tmp.path();
1852        let repo = init_repo(root);
1853        fs::write(root.join("a.txt"), b"1\n").unwrap();
1854        commit_worktree(&repo, &commit_req("first"), &mut NullProgress).unwrap();
1855
1856        let mut index = repo.load_index().unwrap();
1857        stage_file_in_index(&repo, &mut index, "n.txt", b"staged\n");
1858        write_index(&repo, &mut index);
1859        fs::remove_file(root.join("n.txt")).unwrap();
1860
1861        let err = commit_worktree(&repo, &dry_run_req("noop"), &mut NullProgress).unwrap_err();
1862        assert!(matches!(err, Error::NothingToCommit));
1863        assert!(repo.load_index().unwrap().get(b"n.txt", 0).is_some());
1864
1865        let err = commit_worktree(&repo, &commit_req("noop"), &mut NullProgress).unwrap_err();
1866        assert!(matches!(err, Error::NothingToCommit));
1867        let index = repo.load_index().unwrap();
1868        assert!(index.get(b"n.txt", 0).is_none());
1869        assert!(index.get(b"a.txt", 0).is_some());
1870    }
1871
1872    #[test]
1873    fn create_commit_detached_head_advances_head() {
1874        let tmp = TempDir::new().unwrap();
1875        let root = tmp.path();
1876        let repo = init_repo(root);
1877        fs::write(root.join("a.txt"), b"1\n").unwrap();
1878        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1879        let first = create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
1880        fs::write(repo.git_dir().join("HEAD"), first.oid.to_hex()).unwrap();
1881
1882        fs::write(root.join("b.txt"), b"2\n").unwrap();
1883        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1884        let second = create_commit(&repo, &commit_req("second"), &mut NullProgress).unwrap();
1885        assert_eq!(second.branch, None);
1886        assert!(second.is_detached());
1887        assert_eq!(second.parent, Some(first.oid));
1888        let head = fs::read_to_string(repo.git_dir().join("HEAD")).unwrap();
1889        assert_eq!(head.trim(), second.oid.to_hex());
1890        let main_tip = crate::refs::resolve_ref(repo.git_dir(), "refs/heads/main").unwrap();
1891        assert_eq!(main_tip, first.oid, "branch tip must stay unchanged");
1892    }
1893
1894    #[test]
1895    fn create_commit_updates_reflogs() {
1896        let tmp = TempDir::new().unwrap();
1897        let root = tmp.path();
1898        let repo = init_repo(root);
1899        fs::write(root.join("a.txt"), b"1\n").unwrap();
1900        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1901        let outcome = create_commit(&repo, &commit_req("subject line"), &mut NullProgress).unwrap();
1902
1903        let branch_log = read_reflog(&repo.git_dir(), "refs/heads/main").unwrap();
1904        assert_eq!(branch_log.len(), 1);
1905        assert_eq!(branch_log[0].new_oid, outcome.oid);
1906        assert_eq!(branch_log[0].message, "commit (initial): subject line");
1907
1908        let head_log = read_reflog(&repo.git_dir(), "HEAD").unwrap();
1909        assert_eq!(head_log.len(), 1);
1910        assert_eq!(head_log[0].new_oid, outcome.oid);
1911    }
1912
1913    #[test]
1914    fn create_commit_index_write_failure_does_not_advance_branch() {
1915        let tmp = TempDir::new().unwrap();
1916        let root = tmp.path();
1917        let repo = init_repo(root);
1918        fs::write(root.join("a.txt"), b"1\n").unwrap();
1919        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1920
1921        set_test_inject_index_write_fail(true);
1922        let err = create_commit(&repo, &commit_req("fail"), &mut NullProgress).unwrap_err();
1923        set_test_inject_index_write_fail(false);
1924        assert!(!matches!(err, Error::NothingToCommit));
1925
1926        assert!(
1927            resolve_ref(&repo.git_dir(), "refs/heads/main").is_err(),
1928            "branch must not advance when index write fails"
1929        );
1930    }
1931
1932    #[test]
1933    fn create_commit_rolls_back_branch_when_head_reflog_fails() {
1934        let tmp = TempDir::new().unwrap();
1935        let root = tmp.path();
1936        let repo = init_repo(root);
1937        fs::write(root.join("a.txt"), b"1\n").unwrap();
1938        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1939
1940        set_test_inject_reflog_fail(Some("HEAD"));
1941        let err = create_commit(&repo, &commit_req("fail"), &mut NullProgress).unwrap_err();
1942        set_test_inject_reflog_fail(None);
1943        assert!(!matches!(err, Error::NothingToCommit));
1944
1945        assert!(
1946            resolve_ref(&repo.git_dir(), "refs/heads/main").is_err(),
1947            "branch must not advance when HEAD reflog fails"
1948        );
1949        assert!(read_reflog(&repo.git_dir(), "refs/heads/main")
1950            .unwrap()
1951            .is_empty());
1952        assert!(read_reflog(&repo.git_dir(), "HEAD").unwrap().is_empty());
1953    }
1954
1955    #[test]
1956    fn create_commit_leaves_valid_cache_tree() {
1957        let tmp = TempDir::new().unwrap();
1958        let root = tmp.path();
1959        let repo = init_repo(root);
1960        let mut index = Index::new();
1961        stage_file_in_index(&repo, &mut index, "dir/one.txt", b"1\n");
1962        stage_file_in_index(&repo, &mut index, "dir/two.txt", b"2\n");
1963        write_index(&repo, &mut index);
1964
1965        let outcome = create_commit(&repo, &commit_req("tree"), &mut NullProgress).unwrap();
1966
1967        let index = repo.load_index().unwrap();
1968        verify_cache_tree(&index).expect("cache-tree valid");
1969        assert!(cache_tree_fully_valid(
1970            &repo.odb(),
1971            index.cache_tree.as_ref()
1972        ));
1973        let commit_obj = repo.odb().read(&outcome.oid).unwrap();
1974        let tree = parse_commit(&commit_obj.data).unwrap().tree;
1975        assert_eq!(index.cache_tree_root, Some(tree));
1976    }
1977
1978    #[test]
1979    fn create_commit_amend_same_tree() {
1980        let tmp = TempDir::new().unwrap();
1981        let root = tmp.path();
1982        let repo = init_repo(root);
1983        fs::write(root.join("a.txt"), b"1\n").unwrap();
1984        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1985        create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
1986        fs::write(root.join("a.txt"), b"2\n").unwrap();
1987        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1988        create_commit(&repo, &commit_req("second"), &mut NullProgress).unwrap();
1989
1990        let mut req = commit_req("");
1991        req.amend = true;
1992        let tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
1993        let outcome = create_commit(&repo, &req, &mut NullProgress).unwrap();
1994        assert_eq!(
1995            outcome.oid, tip,
1996            "same tree and message with fixed committer yields the same object id"
1997        );
1998        assert!(outcome.parent.is_some());
1999
2000        let branch_log = read_reflog(&repo.git_dir(), "refs/heads/main").unwrap();
2001        assert_eq!(branch_log.len(), 3);
2002        assert_eq!(branch_log[2].message, "commit (amend): second");
2003    }
2004
2005    #[test]
2006    fn create_commit_amend_preserves_message_bytes() {
2007        let tmp = TempDir::new().unwrap();
2008        let root = tmp.path();
2009        let repo = init_repo(root);
2010        fs::write(root.join("a.txt"), b"a\n").unwrap();
2011        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2012        create_commit(&repo, &commit_req("oneline subject"), &mut NullProgress).unwrap();
2013        let baseline = {
2014            let oid = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
2015            parse_commit(&repo.odb().read(&oid).unwrap().data)
2016                .unwrap()
2017                .message
2018        };
2019
2020        for extra in 1..=2 {
2021            fs::write(root.join("a.txt"), format!("a\n{extra}\n")).unwrap();
2022            stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2023            let mut req = commit_req("");
2024            req.amend = true;
2025            create_commit(&repo, &req, &mut NullProgress).unwrap();
2026            let oid = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
2027            let message = parse_commit(&repo.odb().read(&oid).unwrap().data)
2028                .unwrap()
2029                .message;
2030            assert_eq!(message, baseline, "after amend {extra}");
2031        }
2032    }
2033
2034    #[test]
2035    fn create_commit_amend_unborn() {
2036        let tmp = TempDir::new().unwrap();
2037        let repo = init_repo(tmp.path());
2038        fs::write(tmp.path().join("a.txt"), b"1\n").unwrap();
2039        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2040        let mut req = commit_req("nope");
2041        req.amend = true;
2042        let err = create_commit(&repo, &req, &mut NullProgress).unwrap_err();
2043        assert!(matches!(err, Error::AmendUnborn));
2044    }
2045
2046    #[test]
2047    fn create_commit_allow_empty() {
2048        let tmp = TempDir::new().unwrap();
2049        let root = tmp.path();
2050        let repo = init_repo(root);
2051        fs::write(root.join("a.txt"), b"1\n").unwrap();
2052        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2053        create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
2054
2055        let mut req = commit_req("empty");
2056        req.allow_empty = true;
2057        let parent = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
2058        let outcome = create_commit(&repo, &req, &mut NullProgress).unwrap();
2059        assert_eq!(outcome.changes, 0);
2060        assert_eq!(outcome.parent, Some(parent));
2061        assert_ne!(outcome.oid, parent);
2062    }
2063
2064    #[test]
2065    fn scope_staged_commits_index_only() {
2066        let tmp = TempDir::new().unwrap();
2067        let root = tmp.path();
2068        let repo = init_repo(root);
2069        fs::write(root.join("a.txt"), b"1\n").unwrap();
2070        fs::write(root.join("b.txt"), b"1\n").unwrap();
2071        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2072        create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
2073
2074        fs::write(root.join("a.txt"), b"2\n").unwrap();
2075        fs::write(root.join("b.txt"), b"2\n").unwrap();
2076        let mut index = repo.load_index().unwrap();
2077        stage_file_in_index(&repo, &mut index, "a.txt", b"2\n");
2078        write_index(&repo, &mut index);
2079
2080        let outcome = commit_with_scope(
2081            &repo,
2082            &commit_req("only a"),
2083            CommitScope::Staged,
2084            &mut NullProgress,
2085        )
2086        .unwrap();
2087        assert_eq!(outcome.changes, 1);
2088        let index = repo.load_index().unwrap();
2089        let b_oid = index.get(b"b.txt", 0).unwrap().oid;
2090        let b_blob = repo.odb().read(&b_oid).unwrap();
2091        assert_eq!(b_blob.data, b"1\n", "unstaged paths stay in the index");
2092    }
2093
2094    #[test]
2095    fn scope_paths_leaves_other_staged_entries() {
2096        let tmp = TempDir::new().unwrap();
2097        let root = tmp.path();
2098        let repo = init_repo(root);
2099        fs::write(root.join("file"), b"1\n").unwrap();
2100        fs::write(root.join("baz"), b"1\n").unwrap();
2101        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2102        create_commit(&repo, &commit_req("initial"), &mut NullProgress).unwrap();
2103
2104        fs::write(root.join("file"), b"2\n").unwrap();
2105        fs::write(root.join("baz"), b"2\n").unwrap();
2106        stage(
2107            &repo,
2108            &StageOptions {
2109                pathspecs: vec!["baz".into()],
2110                ..StageOptions::default()
2111            },
2112            &mut NullProgress,
2113        )
2114        .unwrap();
2115
2116        let paths = ResolvedPathspecs {
2117            specs: vec!["file".into()],
2118            sources: vec!["file".into()],
2119            scoped: true,
2120        };
2121        commit_with_scope(
2122            &repo,
2123            &commit_req("file only"),
2124            CommitScope::Paths(paths),
2125            &mut NullProgress,
2126        )
2127        .unwrap();
2128
2129        let index = repo.load_index().unwrap();
2130        let baz_oid = index.get(b"baz", 0).unwrap().oid;
2131        assert_eq!(repo.odb().read(&baz_oid).unwrap().data, b"2\n");
2132    }
2133
2134    #[test]
2135    fn scope_paths_deleted_and_untracked() {
2136        let tmp = TempDir::new().unwrap();
2137        let root = tmp.path();
2138        let repo = init_repo(root);
2139        fs::write(root.join("tracked.txt"), b"1\n").unwrap();
2140        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2141        create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
2142
2143        fs::write(root.join("new.txt"), b"new\n").unwrap();
2144        fs::remove_file(root.join("tracked.txt")).unwrap();
2145
2146        let paths = ResolvedPathspecs {
2147            specs: vec!["new.txt".into(), "tracked.txt".into()],
2148            sources: vec!["new.txt".into(), "tracked.txt".into()],
2149            scoped: true,
2150        };
2151        commit_with_scope(
2152            &repo,
2153            &commit_req("add and remove"),
2154            CommitScope::Paths(paths),
2155            &mut NullProgress,
2156        )
2157        .unwrap();
2158
2159        let index = repo.load_index().unwrap();
2160        assert!(index.get(b"tracked.txt", 0).is_none());
2161        assert!(index.get(b"new.txt", 0).is_some());
2162    }
2163
2164    #[test]
2165    fn scope_paths_nested_dirs_round_trip_with_git() {
2166        use std::process::Command;
2167
2168        let tmp = TempDir::new().unwrap();
2169        let root = tmp.path();
2170        let git = |args: &[&str]| -> String {
2171            let out = Command::new("git")
2172                .env("GIT_CONFIG_NOSYSTEM", "1")
2173                .env("GIT_CONFIG_GLOBAL", "/dev/null")
2174                .env("GIT_AUTHOR_NAME", "Test")
2175                .env("GIT_AUTHOR_EMAIL", "test@example.com")
2176                .env("GIT_COMMITTER_NAME", "Test")
2177                .env("GIT_COMMITTER_EMAIL", "test@example.com")
2178                .current_dir(root)
2179                .args(args)
2180                .output()
2181                .unwrap();
2182            assert!(
2183                out.status.success(),
2184                "git {args:?}: {}",
2185                String::from_utf8_lossy(&out.stderr)
2186            );
2187            String::from_utf8(out.stdout).unwrap()
2188        };
2189        git(&["init", "-q", "-b", "main"]);
2190        for (rel, body) in [
2191            ("a/x.txt", "x1\n"),
2192            ("a/y.txt", "y1\n"),
2193            ("b/c/z.txt", "z1\n"),
2194            ("b/w.txt", "w1\n"),
2195            ("d/e.txt", "e1\n"),
2196            ("top.txt", "t1\n"),
2197        ] {
2198            fs::create_dir_all(root.join(rel).parent().unwrap()).unwrap();
2199            fs::write(root.join(rel), body).unwrap();
2200        }
2201        git(&["add", "-A"]);
2202        git(&["commit", "-qm", "init"]);
2203
2204        fs::write(root.join("a/x.txt"), b"x2\n").unwrap();
2205        fs::write(root.join("b/c/z.txt"), b"z2\n").unwrap();
2206        fs::write(root.join("b/w.txt"), b"w2\n").unwrap();
2207        fs::write(root.join("top.txt"), b"t2\n").unwrap();
2208        git(&["add", "b/w.txt"]);
2209
2210        let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).unwrap();
2211        let specs = vec!["a/x.txt".to_owned(), "b/c/z.txt".to_owned()];
2212        commit_with_scope(
2213            &repo,
2214            &commit_req("partial"),
2215            CommitScope::Paths(ResolvedPathspecs {
2216                specs: specs.clone(),
2217                sources: specs,
2218                scoped: true,
2219            }),
2220            &mut NullProgress,
2221        )
2222        .unwrap();
2223
2224        git(&["fsck", "--strict", "--no-progress"]);
2225        assert_eq!(
2226            git(&["diff", "--name-only", "HEAD~1", "HEAD"]),
2227            "a/x.txt\nb/c/z.txt\n"
2228        );
2229        assert_eq!(git(&["show", "HEAD:a/x.txt"]), "x2\n");
2230        assert_eq!(git(&["show", "HEAD:b/c/z.txt"]), "z2\n");
2231        assert_eq!(git(&["show", "HEAD:b/w.txt"]), "w1\n");
2232        assert_eq!(
2233            git(&["rev-parse", "HEAD:d"]),
2234            git(&["rev-parse", "HEAD~1:d"])
2235        );
2236        assert_eq!(git(&["diff", "--cached", "--name-only"]), "b/w.txt\n");
2237        assert_eq!(git(&["diff", "--name-only"]), "top.txt\n");
2238    }
2239
2240    #[test]
2241    fn scope_paths_no_match_errors() {
2242        let tmp = TempDir::new().unwrap();
2243        let root = tmp.path();
2244        let repo = init_repo(root);
2245        fs::write(root.join("a.txt"), b"1\n").unwrap();
2246        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2247        create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
2248
2249        let paths = ResolvedPathspecs {
2250            specs: vec!["missing.txt".into()],
2251            sources: vec!["missing.txt".into()],
2252            scoped: true,
2253        };
2254        let err = commit_with_scope(
2255            &repo,
2256            &commit_req("nope"),
2257            CommitScope::Paths(paths),
2258            &mut NullProgress,
2259        )
2260        .unwrap_err();
2261        assert!(matches!(err, Error::PathspecNoMatch { .. }));
2262    }
2263
2264    #[test]
2265    fn scope_paths_refused_during_merge() {
2266        let tmp = TempDir::new().unwrap();
2267        let root = tmp.path();
2268        let repo = init_repo(root);
2269        fs::write(root.join("a.txt"), b"1\n").unwrap();
2270        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2271        create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
2272        let tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
2273        fs::write(repo.git_dir().join("MERGE_HEAD"), format!("{tip}\n")).unwrap();
2274
2275        let paths = ResolvedPathspecs {
2276            specs: vec!["a.txt".into()],
2277            sources: vec!["a.txt".into()],
2278            scoped: true,
2279        };
2280        let err = commit_with_scope(
2281            &repo,
2282            &commit_req("partial"),
2283            CommitScope::Paths(paths),
2284            &mut NullProgress,
2285        )
2286        .unwrap_err();
2287        assert!(matches!(err, Error::PartialCommitDuringMerge));
2288    }
2289
2290    fn system_git(root: &Path, args: &[&str]) -> String {
2291        let out = Command::new("git")
2292            .env("GIT_CONFIG_NOSYSTEM", "1")
2293            .env("GIT_CONFIG_GLOBAL", "/dev/null")
2294            .env("GIT_AUTHOR_NAME", "Test")
2295            .env("GIT_AUTHOR_EMAIL", "test@example.com")
2296            .env("GIT_COMMITTER_NAME", "Test")
2297            .env("GIT_COMMITTER_EMAIL", "test@example.com")
2298            .current_dir(root)
2299            .args(args)
2300            .output()
2301            .unwrap();
2302        assert!(
2303            out.status.success(),
2304            "git {args:?}: {}",
2305            String::from_utf8_lossy(&out.stderr)
2306        );
2307        String::from_utf8(out.stdout).unwrap()
2308    }
2309
2310    /// `git init` + one commit of `f1.txt`, then `f1.txt` modified in the worktree (issue #1789).
2311    fn git_repo_with_modified_file(root: &Path) -> Repository {
2312        system_git(root, &["init", "-q", "-b", "main"]);
2313        fs::write(root.join("f1.txt"), b"a\n").unwrap();
2314        system_git(root, &["add", "."]);
2315        system_git(root, &["commit", "-qm", "init"]);
2316        fs::write(root.join("f1.txt"), b"a\nb\n").unwrap();
2317        Repository::discover(Some(root), &RepositoryOptions::empty()).unwrap()
2318    }
2319
2320    fn f1_paths() -> ResolvedPathspecs {
2321        ResolvedPathspecs {
2322            specs: vec!["f1.txt".into()],
2323            sources: vec!["f1.txt".into()],
2324            scoped: true,
2325        }
2326    }
2327
2328    /// Run `scope` while a foreign `index.lock` exists and check nothing became visible.
2329    ///
2330    /// `objects_untouched` additionally requires that no loose object was written; scopes that
2331    /// hash content before writing the index may leave unreferenced blobs.
2332    fn assert_commit_refused_by_foreign_lock(
2333        root: &Path,
2334        repo: &Repository,
2335        scope: CommitScope,
2336        objects_untouched: bool,
2337    ) {
2338        let lock_path = repo.git_dir().join("index.lock");
2339        fs::write(&lock_path, b"foreign\n").unwrap();
2340        let before = repo_snapshot(repo);
2341        let tip_before = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
2342
2343        let err = commit_with_scope(repo, &commit_req("x"), scope, &mut NullProgress).unwrap_err();
2344        match &err {
2345            Error::LockHeld {
2346                path: reported,
2347                holder,
2348            } => {
2349                assert_eq!(reported, &lock_path);
2350                assert_eq!(*holder, None);
2351            }
2352            other => panic!("expected Error::LockHeld, got {other:?}"),
2353        }
2354        assert!(err.to_string().contains("index.lock"), "{err}");
2355
2356        let (index, head, reflog, _editmsg, objects) = repo_snapshot(repo);
2357        assert_eq!(index, before.0, "index bytes must be unchanged");
2358        assert_eq!(head, before.1, "HEAD must be unchanged");
2359        assert_eq!(reflog, before.2, "no reflog entry may be written");
2360        if objects_untouched {
2361            assert_eq!(objects, before.4, "no objects may be written");
2362        }
2363        assert_eq!(
2364            resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
2365            tip_before
2366        );
2367        assert_eq!(
2368            fs::read(&lock_path).unwrap(),
2369            b"foreign\n",
2370            "someone else's lock must be left alone"
2371        );
2372        let leftovers: Vec<_> = fs::read_dir(repo.git_dir())
2373            .unwrap()
2374            .flatten()
2375            .filter(|e| e.file_name().to_string_lossy().starts_with("next-index-"))
2376            .collect();
2377        assert!(leftovers.is_empty(), "temporary hook index left behind");
2378
2379        fs::remove_file(&lock_path).unwrap();
2380        assert_eq!(system_git(root, &["status", "--porcelain"]), " M f1.txt\n");
2381        system_git(root, &["fsck", "--strict", "--no-progress"]);
2382    }
2383
2384    #[test]
2385    fn scope_paths_with_held_index_lock_creates_nothing() {
2386        let tmp = TempDir::new().unwrap();
2387        let root = tmp.path();
2388        let repo = git_repo_with_modified_file(root);
2389        assert_commit_refused_by_foreign_lock(root, &repo, CommitScope::Paths(f1_paths()), true);
2390
2391        let outcome = commit_with_scope(
2392            &repo,
2393            &commit_req("x"),
2394            CommitScope::Paths(f1_paths()),
2395            &mut NullProgress,
2396        )
2397        .unwrap();
2398        assert!(!repo.git_dir().join("index.lock").exists());
2399        assert_eq!(
2400            system_git(root, &["rev-parse", "HEAD"]).trim(),
2401            outcome.oid.to_hex()
2402        );
2403        assert_eq!(system_git(root, &["status", "--porcelain"]), "");
2404        system_git(root, &["fsck", "--strict", "--no-progress"]);
2405    }
2406
2407    #[test]
2408    fn scope_staged_and_auto_stage_with_held_index_lock_create_nothing() {
2409        let tmp = TempDir::new().unwrap();
2410        let root = tmp.path();
2411        let repo = git_repo_with_modified_file(root);
2412        assert_commit_refused_by_foreign_lock(root, &repo, CommitScope::AutoStage, false);
2413
2414        system_git(root, &["add", "f1.txt"]);
2415        let lock_path = repo.git_dir().join("index.lock");
2416        fs::write(&lock_path, b"foreign\n").unwrap();
2417        let tip_before = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
2418        let err = commit_with_scope(
2419            &repo,
2420            &commit_req("x"),
2421            CommitScope::Staged,
2422            &mut NullProgress,
2423        )
2424        .unwrap_err();
2425        assert!(
2426            matches!(&err, Error::LockHeld { path, .. } if *path == repo.git_dir().join("index.lock")),
2427            "{err:?}"
2428        );
2429        assert_eq!(
2430            resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
2431            tip_before
2432        );
2433        assert_eq!(fs::read(&lock_path).unwrap(), b"foreign\n");
2434    }
2435
2436    #[test]
2437    fn scope_patch_with_held_index_lock_creates_nothing() {
2438        use crate::diff::hunk_id::{hunk_ids_with_duplicate_peers, DiffSide};
2439        use crate::diff::{unstaged_file_diffs_with_peer_hunks, DiffOptions};
2440        use crate::porcelain::hunk_select::HunkSelection;
2441
2442        let tmp = TempDir::new().unwrap();
2443        let root = tmp.path();
2444        let repo = git_repo_with_modified_file(root);
2445        let (files, peers) =
2446            unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
2447        let ids = hunk_ids_with_duplicate_peers(
2448            DiffSide::Unstaged,
2449            "f1.txt",
2450            &files[0].hunks,
2451            peers.get("f1.txt").map(|v| v.as_slice()).unwrap_or(&[]),
2452        );
2453        let scope = CommitScope::Patch {
2454            input: CommitPatchInput::Hunks(HunkSelection {
2455                ids: vec![ids[0].clone()],
2456                ranges: Vec::new(),
2457            }),
2458            pathspecs: Vec::new(),
2459        };
2460        assert_commit_refused_by_foreign_lock(root, &repo, scope.clone(), true);
2461
2462        let outcome = commit_with_scope(&repo, &commit_req("x"), scope, &mut NullProgress).unwrap();
2463        assert!(!repo.git_dir().join("index.lock").exists());
2464        assert_eq!(
2465            system_git(root, &["rev-parse", "HEAD"]).trim(),
2466            outcome.oid.to_hex()
2467        );
2468        assert_eq!(system_git(root, &["status", "--porcelain"]), "");
2469        let index = repo.load_index().unwrap();
2470        verify_cache_tree(&index).expect("cache-tree valid");
2471        assert!(cache_tree_fully_valid(
2472            &repo.odb(),
2473            index.cache_tree.as_ref()
2474        ));
2475        let tree = parse_commit(&repo.odb().read(&outcome.oid).unwrap().data)
2476            .unwrap()
2477            .tree;
2478        assert_eq!(index.cache_tree_root, Some(tree));
2479        system_git(root, &["fsck", "--strict", "--no-progress"]);
2480    }
2481
2482    #[test]
2483    fn scope_paths_split_index_round_trips_with_git() {
2484        let tmp = TempDir::new().unwrap();
2485        let root = tmp.path();
2486        system_git(root, &["init", "-q", "-b", "main"]);
2487        system_git(root, &["config", "core.splitIndex", "true"]);
2488        system_git(root, &["config", "splitIndex.maxPercentChange", "100"]);
2489        for name in ["a.txt", "b.txt", "c.txt"] {
2490            fs::write(root.join(name), b"1\n").unwrap();
2491        }
2492        system_git(root, &["add", "."]);
2493        system_git(root, &["commit", "-qm", "init"]);
2494        fs::write(root.join("a.txt"), b"2\n").unwrap();
2495        fs::write(root.join("b.txt"), b"2\n").unwrap();
2496        system_git(root, &["add", "b.txt"]);
2497
2498        let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).unwrap();
2499        let specs = vec!["a.txt".to_owned()];
2500        commit_with_scope(
2501            &repo,
2502            &commit_req("partial"),
2503            CommitScope::Paths(ResolvedPathspecs {
2504                specs: specs.clone(),
2505                sources: specs,
2506                scoped: true,
2507            }),
2508            &mut NullProgress,
2509        )
2510        .unwrap();
2511
2512        assert!(!repo.git_dir().join("index.lock").exists());
2513        assert!(
2514            system_git(root, &["ls-files", "--debug"]).contains("a.txt"),
2515            "git must read the split index"
2516        );
2517        assert_eq!(system_git(root, &["status", "--porcelain"]), "M  b.txt\n");
2518        assert_eq!(system_git(root, &["show", "HEAD:a.txt"]), "2\n");
2519        assert_eq!(system_git(root, &["show", "HEAD:b.txt"]), "1\n");
2520        system_git(root, &["fsck", "--strict", "--no-progress"]);
2521        let shared: Vec<_> = fs::read_dir(repo.git_dir())
2522            .unwrap()
2523            .flatten()
2524            .filter(|e| e.file_name().to_string_lossy().starts_with("sharedindex."))
2525            .collect();
2526        assert!(!shared.is_empty(), "index must stay split");
2527    }
2528
2529    #[test]
2530    fn commit_on_split_index_with_skip_hash_stays_readable_by_git() {
2531        for key in ["index.skipHash", "feature.manyFiles"] {
2532            let tmp = TempDir::new().unwrap();
2533            let root = tmp.path();
2534            system_git(root, &["init", "-q", "-b", "main"]);
2535            fs::write(root.join("a"), b"1\n").unwrap();
2536            system_git(root, &["add", "a"]);
2537            system_git(root, &["commit", "-qm", "c1"]);
2538            system_git(root, &["config", "core.splitIndex", "true"]);
2539            system_git(root, &["update-index", "--split-index"]);
2540            fs::write(root.join("a"), b"1\n2\n").unwrap();
2541            system_git(root, &["add", "a"]);
2542            system_git(root, &["commit", "-qm", "c2"]);
2543            system_git(root, &["config", key, "true"]);
2544            assert_eq!(system_git(root, &["status", "--short"]), "");
2545            fs::write(root.join("n"), b"n\n").unwrap();
2546
2547            let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).unwrap();
2548            let outcome = commit_with_scope(
2549                &repo,
2550                &commit_req("g"),
2551                CommitScope::AutoStage,
2552                &mut NullProgress,
2553            )
2554            .unwrap();
2555
2556            assert_eq!(system_git(root, &["ls-files"]), "a\nn\n", "{key}");
2557            // Keep git from rebuilding the shared index itself: under skipHash it names it
2558            // `sharedindex.000…0` and then reads its own index as unsplit.
2559            system_git(root, &["config", "splitIndex.maxPercentChange", "100"]);
2560            assert_eq!(system_git(root, &["status", "--porcelain"]), "", "{key}");
2561            assert_eq!(
2562                system_git(root, &["rev-parse", "HEAD"]).trim(),
2563                outcome.oid.to_hex()
2564            );
2565            system_git(root, &["fsck", "--strict", "--no-progress"]);
2566            fs::write(root.join("m"), b"m\n").unwrap();
2567            system_git(root, &["add", "-A"]);
2568            system_git(root, &["commit", "-qm", "git after grit"]);
2569            assert_eq!(system_git(root, &["ls-files"]), "a\nm\nn\n", "{key}");
2570            assert_eq!(repo.load_index().unwrap().entries.len(), 3, "{key}");
2571        }
2572    }
2573
2574    fn author_info_from_commit(repo: &Repository, oid: ObjectId) -> CommitAuthorInfo {
2575        let data = repo.odb().read(&oid).unwrap().data;
2576        author_info_from_header_line(&parse_commit(&data).unwrap().author)
2577    }
2578
2579    fn git_head_commit_message(repo: &std::path::Path) -> String {
2580        let out = std::process::Command::new("git")
2581            .args(["cat-file", "commit", "HEAD"])
2582            .current_dir(repo)
2583            .output()
2584            .expect("git cat-file commit");
2585        assert!(out.status.success());
2586        let bytes = out.stdout;
2587        let mut i = 0usize;
2588        while i < bytes.len() {
2589            if i + 1 < bytes.len() && bytes[i] == b'\n' && bytes[i + 1] == b'\n' {
2590                i += 2;
2591                break;
2592            }
2593            i += 1;
2594        }
2595        String::from_utf8(bytes[i..].to_vec()).expect("utf-8 commit message")
2596    }
2597
2598    #[test]
2599    fn message_file_stripspace() {
2600        let raw = "  subject  \n\n  body line  \n\n";
2601        let msg = normalize_commit_message(raw).unwrap();
2602        let tmp = TempDir::new().unwrap();
2603        let msg_path = tmp.path().join("msg.txt");
2604        std::fs::write(&msg_path, raw).unwrap();
2605        std::process::Command::new("git")
2606            .args(["init", "-q"])
2607            .current_dir(tmp.path())
2608            .status()
2609            .unwrap();
2610        std::process::Command::new("git")
2611            .args(["config", "user.name", "T"])
2612            .current_dir(tmp.path())
2613            .status()
2614            .unwrap();
2615        std::process::Command::new("git")
2616            .args(["config", "user.email", "t@e.com"])
2617            .current_dir(tmp.path())
2618            .status()
2619            .unwrap();
2620        std::fs::write(tmp.path().join("f"), "x\n").unwrap();
2621        std::process::Command::new("git")
2622            .args(["add", "f"])
2623            .current_dir(tmp.path())
2624            .status()
2625            .unwrap();
2626        std::process::Command::new("git")
2627            .args(["commit", "-F"])
2628            .arg(&msg_path)
2629            .current_dir(tmp.path())
2630            .status()
2631            .unwrap();
2632        let git_body = git_head_commit_message(tmp.path());
2633        assert_eq!(msg, git_body);
2634    }
2635
2636    #[test]
2637    fn message_normalization_crlf_and_trailing_blanks_match_git_commit_f() {
2638        let raw = "\r\n  title  \r\n\r\n  body  \r\n\r\n\r\n";
2639        let grit_msg = normalize_commit_message(raw).unwrap();
2640        let tmp = tempfile::TempDir::new().unwrap();
2641        let msg_path = tmp.path().join("msg.txt");
2642        std::fs::write(&msg_path, raw).unwrap();
2643        let git_msg = std::process::Command::new("git")
2644            .args(["init", "-q"])
2645            .current_dir(tmp.path())
2646            .status()
2647            .unwrap();
2648        assert!(git_msg.success());
2649        std::process::Command::new("git")
2650            .args(["config", "user.name", "T"])
2651            .current_dir(tmp.path())
2652            .status()
2653            .unwrap();
2654        std::process::Command::new("git")
2655            .args(["config", "user.email", "t@e.com"])
2656            .current_dir(tmp.path())
2657            .status()
2658            .unwrap();
2659        std::fs::write(tmp.path().join("f"), "x\n").unwrap();
2660        std::process::Command::new("git")
2661            .args(["add", "f"])
2662            .current_dir(tmp.path())
2663            .status()
2664            .unwrap();
2665        std::process::Command::new("git")
2666            .args(["commit", "-F"])
2667            .arg(&msg_path)
2668            .current_dir(tmp.path())
2669            .status()
2670            .unwrap();
2671        let git_body = git_head_commit_message(tmp.path());
2672        assert_eq!(grit_msg, git_body);
2673    }
2674
2675    #[test]
2676    fn no_verify_skips_pre_commit_and_commit_msg() {
2677        let tmp = TempDir::new().unwrap();
2678        let root = tmp.path();
2679        let repo = init_repo(root);
2680        std::fs::write(
2681            repo.git_dir().join("config"),
2682            "[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",
2683        )
2684        .unwrap();
2685        fs::write(root.join("a.txt"), b"1\n").unwrap();
2686        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2687
2688        let mut req = commit_req("blocked");
2689        req.hooks = HookPolicy::Run;
2690        assert!(create_commit(&repo, &req, &mut NullProgress).is_err());
2691
2692        req.hooks = HookPolicy::SkipVerify;
2693        let outcome = create_commit(&repo, &req, &mut NullProgress).unwrap();
2694        assert!(outcome.hooks_run.is_empty());
2695    }
2696
2697    #[test]
2698    fn post_commit_hook_failure_does_not_fail_commit() {
2699        use crate::hooks::HookName;
2700
2701        let tmp = TempDir::new().unwrap();
2702        let root = tmp.path();
2703        let repo = init_repo(root);
2704        std::fs::write(
2705            repo.git_dir().join("config"),
2706            "[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tlogAllRefUpdates = true\n[hook \"hc\"]\n\tcommand = exit 9\n\tevent = post-commit\n",
2707        )
2708        .unwrap();
2709        fs::write(root.join("a.txt"), b"1\n").unwrap();
2710        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2711        let outcome = create_commit(&repo, &commit_req("after post"), &mut NullProgress).unwrap();
2712        assert_eq!(
2713            resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
2714            outcome.oid
2715        );
2716        assert!(outcome.hooks_run.contains(&HookName::PostCommit));
2717    }
2718
2719    #[test]
2720    fn hooks_run_lists_executed_hooks() {
2721        use crate::hooks::HookName;
2722
2723        let tmp = TempDir::new().unwrap();
2724        let root = tmp.path();
2725        let repo = init_repo(root);
2726        std::fs::write(
2727            repo.git_dir().join("config"),
2728            "[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",
2729        )
2730        .unwrap();
2731        fs::write(root.join("a.txt"), b"1\n").unwrap();
2732        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2733        let outcome = create_commit(&repo, &commit_req("hooks"), &mut NullProgress).unwrap();
2734        assert_eq!(
2735            outcome.hooks_run,
2736            vec![
2737                HookName::PreCommit,
2738                HookName::CommitMsg,
2739                HookName::PostCommit
2740            ]
2741        );
2742    }
2743
2744    #[test]
2745    fn author_date_override() {
2746        let tmp = TempDir::new().unwrap();
2747        let root = tmp.path();
2748        let repo = init_repo(root);
2749        fs::write(root.join("a.txt"), b"1\n").unwrap();
2750        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2751        let mut req = commit_req("dated");
2752        req.author_date = Some("946684800 +0000".to_owned());
2753        let outcome = create_commit(&repo, &req, &mut NullProgress).unwrap();
2754        assert_eq!(outcome.author.date, "946684800 +0000");
2755    }
2756
2757    #[test]
2758    fn trailers_appended_and_deduped() {
2759        let tmp = TempDir::new().unwrap();
2760        let root = tmp.path();
2761        let repo = init_repo(root);
2762        fs::write(root.join("a.txt"), b"1\n").unwrap();
2763        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2764        let mut req = commit_req("trailers");
2765        req.trailers = vec![
2766            ("Acked-by".to_owned(), "Alice".to_owned()),
2767            ("Acked-by".to_owned(), "Alice".to_owned()),
2768        ];
2769        let outcome = create_commit(&repo, &req, &mut NullProgress).unwrap();
2770        let msg = parse_commit(&repo.odb().read(&outcome.oid).unwrap().data)
2771            .unwrap()
2772            .message;
2773        assert_eq!(msg.matches("Acked-by: Alice").count(), 1);
2774    }
2775
2776    #[test]
2777    fn signoff_uses_committer() {
2778        let tmp = TempDir::new().unwrap();
2779        let root = tmp.path();
2780        let repo = init_repo(root);
2781        fs::write(root.join("a.txt"), b"1\n").unwrap();
2782        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2783        let mut req = commit_req("signoff");
2784        req.committer = Some(
2785            "Committer Name <committer@example.com> 2 +0000"
2786                .parse()
2787                .expect("signature"),
2788        );
2789        req.signoff = true;
2790        let outcome = create_commit(&repo, &req, &mut NullProgress).unwrap();
2791        let msg = parse_commit(&repo.odb().read(&outcome.oid).unwrap().data)
2792            .unwrap()
2793            .message;
2794        assert!(msg.contains("Signed-off-by: Committer Name <committer@example.com>"));
2795    }
2796
2797    #[test]
2798    fn signoff_before_explicit_trailers() {
2799        let tmp = TempDir::new().unwrap();
2800        let root = tmp.path();
2801        let repo = init_repo(root);
2802        fs::write(root.join("a.txt"), b"1\n").unwrap();
2803        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2804        let mut req = commit_req("subject\n");
2805        req.committer = Some(
2806            "Committer Name <committer@example.com> 2 +0000"
2807                .parse()
2808                .expect("signature"),
2809        );
2810        req.signoff = true;
2811        req.trailers = vec![("Acked-by".to_owned(), "Alice".to_owned())];
2812        let outcome = create_commit(&repo, &req, &mut NullProgress).unwrap();
2813        let msg = parse_commit(&repo.odb().read(&outcome.oid).unwrap().data)
2814            .unwrap()
2815            .message;
2816        let sob = msg.find("Signed-off-by:").expect("signoff trailer");
2817        let ack = msg.find("Acked-by:").expect("acked trailer");
2818        assert!(sob < ack, "signoff must precede explicit trailers:\n{msg}");
2819    }
2820
2821    #[test]
2822    fn amend_reuse_message_from_source() {
2823        let tmp = TempDir::new().unwrap();
2824        let root = tmp.path();
2825        let repo = init_repo(root);
2826        fs::write(root.join("a.txt"), b"1\n").unwrap();
2827        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2828        let mut source = commit_req("source subject\n");
2829        source.author = Some(
2830            "Source Author <author@example.com> 978307200 +0000"
2831                .parse()
2832                .expect("signature"),
2833        );
2834        source.committer = Some(ident().parse().expect("signature"));
2835        let source_out = create_commit(&repo, &source, &mut NullProgress).unwrap();
2836
2837        fs::write(root.join("a.txt"), b"2\n").unwrap();
2838        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2839        let mut tip = commit_req("tip subject\n");
2840        tip.author = Some(
2841            "Tip Author <tip@example.com> 1792591163 +0000"
2842                .parse()
2843                .expect("signature"),
2844        );
2845        create_commit(&repo, &tip, &mut NullProgress).unwrap();
2846
2847        fs::write(root.join("a.txt"), b"3\n").unwrap();
2848        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2849        let mut amend = commit_req("");
2850        amend.amend = true;
2851        amend.author = None;
2852        amend.reuse_message_from = Some(source_out.oid);
2853        let outcome = create_commit(&repo, &amend, &mut NullProgress).unwrap();
2854        let author = author_info_from_commit(&repo, outcome.oid);
2855        assert_eq!(author.name, "Source Author");
2856        assert_eq!(author.email, "author@example.com");
2857        assert_eq!(author.date, "978307200 +0000");
2858        let msg = parse_commit(&repo.odb().read(&outcome.oid).unwrap().data)
2859            .unwrap()
2860            .message;
2861        assert!(msg.starts_with("source subject"));
2862    }
2863
2864    #[test]
2865    fn amend_reuse_message_reset_author_uses_committer() {
2866        let tmp = TempDir::new().unwrap();
2867        let root = tmp.path();
2868        let repo = init_repo(root);
2869        fs::write(root.join("a.txt"), b"1\n").unwrap();
2870        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2871        let mut source = commit_req("source subject\n");
2872        source.author = Some(
2873            "Source Author <author@example.com> 978307200 +0000"
2874                .parse()
2875                .expect("signature"),
2876        );
2877        let source_out = create_commit(&repo, &source, &mut NullProgress).unwrap();
2878
2879        fs::write(root.join("a.txt"), b"2\n").unwrap();
2880        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2881        create_commit(&repo, &commit_req("tip"), &mut NullProgress).unwrap();
2882
2883        fs::write(root.join("a.txt"), b"3\n").unwrap();
2884        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2885        let mut amend = commit_req("");
2886        amend.amend = true;
2887        amend.author = None;
2888        amend.reset_author = true;
2889        amend.committer = Some(
2890            "Reset Author <reset@example.com> 1800000000 +0000"
2891                .parse()
2892                .expect("signature"),
2893        );
2894        amend.reuse_message_from = Some(source_out.oid);
2895        let outcome = create_commit(&repo, &amend, &mut NullProgress).unwrap();
2896        let author = author_info_from_commit(&repo, outcome.oid);
2897        assert_eq!(author.name, "Reset Author");
2898        assert_eq!(author.email, "reset@example.com");
2899    }
2900
2901    #[test]
2902    fn reuse_message_keeps_author() {
2903        let tmp = TempDir::new().unwrap();
2904        let root = tmp.path();
2905        let repo = init_repo(root);
2906        fs::write(root.join("a.txt"), b"1\n").unwrap();
2907        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2908        let mut first = commit_req("source");
2909        first.author = Some(
2910            "Source Author <source@example.com> 99 +0000"
2911                .parse()
2912                .expect("signature"),
2913        );
2914        first.committer = Some(ident().parse().expect("signature"));
2915        let first_out = create_commit(&repo, &first, &mut NullProgress).unwrap();
2916
2917        fs::write(root.join("a.txt"), b"2\n").unwrap();
2918        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2919        let mut req = commit_req("");
2920        req.author = None;
2921        req.reuse_message_from = Some(first_out.oid);
2922        let outcome = create_commit(&repo, &req, &mut NullProgress).unwrap();
2923        let author = author_info_from_commit(&repo, outcome.oid);
2924        assert_eq!(author.name, "Source Author");
2925        assert_eq!(author.email, "source@example.com");
2926        assert_eq!(author.date, "99 +0000");
2927    }
2928
2929    #[test]
2930    fn reset_author_with_reuse_and_amend() {
2931        let tmp = TempDir::new().unwrap();
2932        let root = tmp.path();
2933        let repo = init_repo(root);
2934        fs::write(root.join("a.txt"), b"1\n").unwrap();
2935        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2936        let mut first = commit_req("source");
2937        first.author = Some("Old <old@example.com> 1 +0000".parse().expect("signature"));
2938        create_commit(&repo, &first, &mut NullProgress).unwrap();
2939
2940        fs::write(root.join("a.txt"), b"2\n").unwrap();
2941        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2942        create_commit(&repo, &commit_req("second"), &mut NullProgress).unwrap();
2943
2944        let mut amend = commit_req("");
2945        amend.amend = true;
2946        amend.author = None;
2947        amend.reset_author = true;
2948        amend.committer = Some(
2949            "New Committer <new@example.com> 2000 +0000"
2950                .parse()
2951                .expect("signature"),
2952        );
2953        let outcome = create_commit(&repo, &amend, &mut NullProgress).unwrap();
2954        let author = author_info_from_commit(&repo, outcome.oid);
2955        assert_eq!(author.name, "New Committer");
2956        assert_eq!(author.email, "new@example.com");
2957    }
2958
2959    #[test]
2960    fn reuse_message_gets_new_change_id() {
2961        use crate::change_id::{change_id, ChangeIdError, ChangeIdGenerator};
2962        use std::sync::Arc;
2963
2964        struct FixedGenerator([u8; 16]);
2965        impl ChangeIdGenerator for FixedGenerator {
2966            fn random_bytes(&self) -> std::result::Result<[u8; 16], ChangeIdError> {
2967                Ok(self.0)
2968            }
2969        }
2970
2971        let tmp = TempDir::new().unwrap();
2972        let root = tmp.path();
2973        let gen1: Arc<dyn ChangeIdGenerator> = Arc::new(FixedGenerator([0x11; 16]));
2974        let git = root.join(".git");
2975        fs::create_dir_all(git.join("objects")).unwrap();
2976        fs::create_dir_all(git.join("refs/heads")).unwrap();
2977        fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
2978        fs::write(
2979            git.join("config"),
2980            "[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tlogAllRefUpdates = true\n",
2981        )
2982        .unwrap();
2983        let repo = Repository::open(
2984            &git,
2985            &RepositoryOptions::empty()
2986                .with_work_tree(root)
2987                .with_change_id_generator(gen1),
2988        )
2989        .unwrap();
2990        fs::write(root.join("a.txt"), b"1\n").unwrap();
2991        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2992        let source = create_commit(&repo, &commit_req("source"), &mut NullProgress).unwrap();
2993        let source_data = parse_commit(&repo.odb().read(&source.oid).unwrap().data).unwrap();
2994        let source_id = change_id(&source_data).expect("source change id");
2995
2996        let gen2: Arc<dyn ChangeIdGenerator> = Arc::new(FixedGenerator([0x22; 16]));
2997        let repo = Repository::open(
2998            &git,
2999            &RepositoryOptions::empty()
3000                .with_work_tree(root)
3001                .with_change_id_generator(gen2),
3002        )
3003        .unwrap();
3004        fs::write(root.join("a.txt"), b"2\n").unwrap();
3005        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3006        let mut req = commit_req("");
3007        req.author = None;
3008        req.reuse_message_from = Some(source.oid);
3009        let copy = create_commit(&repo, &req, &mut NullProgress).unwrap();
3010        let copy_data = parse_commit(&repo.odb().read(&copy.oid).unwrap().data).unwrap();
3011        let copy_id = change_id(&copy_data).expect("copy change id");
3012        assert_ne!(copy_id, source_id);
3013    }
3014
3015    #[test]
3016    fn outcome_parents_merge_commit() {
3017        let tmp = TempDir::new().unwrap();
3018        let root = tmp.path();
3019        let repo = init_repo(root);
3020        fs::write(root.join("a.txt"), b"1\n").unwrap();
3021        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3022        let first = create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
3023
3024        fs::write(root.join("b.txt"), b"2\n").unwrap();
3025        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3026        let second = create_commit(&repo, &commit_req("second"), &mut NullProgress).unwrap();
3027
3028        fs::write(
3029            repo.git_dir().join("MERGE_HEAD"),
3030            format!("{}\n", first.oid.to_hex()),
3031        )
3032        .unwrap();
3033        fs::write(root.join("a.txt"), b"merged\n").unwrap();
3034        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3035        let merge = create_commit(&repo, &commit_req("merge"), &mut NullProgress).unwrap();
3036        assert_eq!(merge.parents.len(), 2);
3037        assert_eq!(merge.parents[0], second.oid);
3038        assert_eq!(merge.parents[1], first.oid);
3039    }
3040
3041    #[test]
3042    fn auto_stage_config_false_defaults_to_staged() {
3043        let tmp = TempDir::new().unwrap();
3044        let root = tmp.path();
3045        let git = root.join(".git");
3046        fs::create_dir_all(git.join("objects")).unwrap();
3047        fs::create_dir_all(git.join("refs/heads")).unwrap();
3048        fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
3049        fs::write(
3050            git.join("config"),
3051            "[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tlogAllRefUpdates = true\n[commit]\n\tautoStage = false\n",
3052        )
3053        .unwrap();
3054        let repo =
3055            Repository::open(&git, &RepositoryOptions::empty().with_work_tree(root)).unwrap();
3056        assert_eq!(
3057            default_commit_scope_from_config(&repo.config().unwrap()),
3058            CommitScope::Staged
3059        );
3060    }
3061
3062    #[test]
3063    fn dry_run_path_scope_includes_untracked_add() {
3064        let tmp = TempDir::new().unwrap();
3065        let root = tmp.path();
3066        let repo = init_repo(root);
3067        fs::write(root.join("tracked.txt"), b"1\n").unwrap();
3068        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3069        create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
3070        fs::write(root.join("new.txt"), b"new\n").unwrap();
3071        let paths = ResolvedPathspecs {
3072            specs: vec!["new.txt".into()],
3073            sources: vec!["new.txt".into()],
3074            scoped: true,
3075        };
3076        let before = repo_snapshot(&repo);
3077        let plan = plan_commit(
3078            &repo,
3079            &dry_run_req("add new"),
3080            CommitScope::Paths(paths),
3081            &mut NullProgress,
3082        )
3083        .unwrap();
3084        assert_eq!(plan.files.len(), 1);
3085        assert_eq!(plan.files[0].path, "new.txt");
3086        assert_eq!(repo_snapshot(&repo), before);
3087    }
3088
3089    #[test]
3090    fn dry_run_amend_honors_author_override() {
3091        let tmp = TempDir::new().unwrap();
3092        let root = tmp.path();
3093        let repo = init_repo(root);
3094        fs::write(root.join("a.txt"), b"1\n").unwrap();
3095        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3096        create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
3097        fs::write(root.join("a.txt"), b"2\n").unwrap();
3098        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3099        let mut req = dry_run_req("amend");
3100        req.amend = true;
3101        req.author = Some(
3102            "Override <o@example.com> 1700000000 +0000"
3103                .parse()
3104                .expect("signature"),
3105        );
3106        let outcome =
3107            commit_with_scope(&repo, &req, CommitScope::Staged, &mut NullProgress).unwrap();
3108        assert_eq!(outcome.author.email, "o@example.com");
3109        assert_eq!(outcome.author.name, "Override");
3110    }
3111
3112    fn git(dir: &std::path::Path, args: &[&str]) {
3113        let out = Command::new("git")
3114            .current_dir(dir)
3115            .args(args)
3116            .env("GIT_CONFIG_GLOBAL", "/dev/null")
3117            .env("GIT_CONFIG_SYSTEM", "/dev/null")
3118            .output()
3119            .expect("git");
3120        assert!(
3121            out.status.success(),
3122            "git {args:?}: {}",
3123            String::from_utf8_lossy(&out.stderr)
3124        );
3125    }
3126
3127    fn two_hunk_file() -> (String, String) {
3128        let mut base = String::new();
3129        for i in 0..24 {
3130            base.push_str(&format!("line{i}\n"));
3131        }
3132        let mut edited = base.clone();
3133        edited = edited.replacen("line5\n", "LINE5\n", 1);
3134        edited = edited.replacen("line18\n", "LINE18\n", 1);
3135        (base, edited)
3136    }
3137
3138    fn tree_blob(repo: &Repository, tree_oid: &ObjectId, path: &str) -> Vec<u8> {
3139        for (p, _mode, oid) in flatten_tree_entries(repo.odb(), tree_oid).unwrap() {
3140            if p == path {
3141                return repo.odb().read(&oid).unwrap().data;
3142            }
3143        }
3144        panic!("missing {path} in tree");
3145    }
3146
3147    #[test]
3148    fn commit_hunks_only_selected_in_tree() {
3149        use crate::diff::hunk_id::{hunk_ids_with_duplicate_peers, DiffSide};
3150        use crate::diff::{unstaged_file_diffs_with_peer_hunks, DiffOptions};
3151        use crate::porcelain::hunk_select::HunkSelection;
3152
3153        let tmp = TempDir::new().unwrap();
3154        let root = tmp.path();
3155        let repo = init_repo(root);
3156        let (base, edited) = two_hunk_file();
3157        fs::write(root.join("f.txt"), base).unwrap();
3158        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3159        create_commit(&repo, &commit_req("init"), &mut NullProgress).unwrap();
3160        fs::write(root.join("f.txt"), edited).unwrap();
3161
3162        let (files, peers) =
3163            unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
3164        let ids = hunk_ids_with_duplicate_peers(
3165            DiffSide::Unstaged,
3166            "f.txt",
3167            &files[0].hunks,
3168            peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
3169        );
3170        commit_with_patch(
3171            &repo,
3172            &commit_req("one hunk"),
3173            CommitPatchInput::Hunks(HunkSelection {
3174                ids: vec![ids[0].clone()],
3175                ranges: Vec::new(),
3176            }),
3177            &[],
3178            &mut NullProgress,
3179        )
3180        .unwrap();
3181
3182        let tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
3183        let tree = parse_commit(&repo.odb().read(&tip).unwrap().data)
3184            .unwrap()
3185            .tree;
3186        let blob = tree_blob(&repo, &tree, "f.txt");
3187        let text = String::from_utf8_lossy(&blob);
3188        assert!(text.contains("LINE5"), "first hunk in commit tree");
3189        assert!(
3190            !text.contains("LINE18"),
3191            "second hunk must not be in commit tree: {text}"
3192        );
3193    }
3194
3195    #[test]
3196    fn commit_hunks_leaves_other_unstaged() {
3197        use crate::diff::hunk_id::{hunk_ids_with_duplicate_peers, DiffSide};
3198        use crate::diff::{unstaged_file_diffs_with_peer_hunks, DiffOptions};
3199        use crate::porcelain::hunk_select::HunkSelection;
3200
3201        let tmp = TempDir::new().unwrap();
3202        let root = tmp.path();
3203        let repo = init_repo(root);
3204        let (base, edited) = two_hunk_file();
3205        fs::write(root.join("f.txt"), base).unwrap();
3206        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3207        create_commit(&repo, &commit_req("init"), &mut NullProgress).unwrap();
3208        fs::write(root.join("f.txt"), edited).unwrap();
3209
3210        let (files, peers) =
3211            unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
3212        let ids = hunk_ids_with_duplicate_peers(
3213            DiffSide::Unstaged,
3214            "f.txt",
3215            &files[0].hunks,
3216            peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
3217        );
3218        commit_with_patch(
3219            &repo,
3220            &commit_req("partial"),
3221            CommitPatchInput::Hunks(HunkSelection {
3222                ids: vec![ids[0].clone()],
3223                ranges: Vec::new(),
3224            }),
3225            &[],
3226            &mut NullProgress,
3227        )
3228        .unwrap();
3229
3230        let diff = run_grit_status_unstaged(root);
3231        assert!(
3232            diff.contains("line18") || diff.contains("LINE18"),
3233            "remaining hunk still unstaged: {diff}"
3234        );
3235        git(root, &["fsck", "--strict"]);
3236    }
3237
3238    fn run_grit_status_unstaged(root: &std::path::Path) -> String {
3239        let out = Command::new("git")
3240            .current_dir(root)
3241            .args(["diff"])
3242            .env("GIT_CONFIG_GLOBAL", "/dev/null")
3243            .env("GIT_CONFIG_SYSTEM", "/dev/null")
3244            .output()
3245            .unwrap();
3246        String::from_utf8_lossy(&out.stdout).into_owned()
3247    }
3248
3249    fn format_patch_for_git(patch: &FilePatch) -> String {
3250        use crate::apply::HunkLine;
3251        let mut out = String::new();
3252        let path = patch.effective_path().unwrap_or("f");
3253        out.push_str(&format!("diff --git a/{path} b/{path}\n"));
3254        if patch.is_new {
3255            out.push_str("new file mode 100644\n");
3256        }
3257        let old = if patch.is_new {
3258            "/dev/null".to_string()
3259        } else {
3260            format!("a/{}", patch.old_path.as_deref().unwrap_or(path))
3261        };
3262        let new = if patch.is_deleted {
3263            "/dev/null".to_string()
3264        } else {
3265            format!("b/{}", patch.new_path.as_deref().unwrap_or(path))
3266        };
3267        out.push_str(&format!("--- {old}\n"));
3268        out.push_str(&format!("+++ {new}\n"));
3269        for h in &patch.hunks {
3270            out.push_str(&format!(
3271                "@@ -{},{} +{},{} @@\n",
3272                h.old_start, h.old_count, h.new_start, h.new_count
3273            ));
3274            for line in &h.lines {
3275                match line {
3276                    HunkLine::Context(s) => {
3277                        out.push(' ');
3278                        out.push_str(s);
3279                        if !s.ends_with('\n') {
3280                            out.push('\n');
3281                        }
3282                    }
3283                    HunkLine::Add(s) => {
3284                        out.push('+');
3285                        out.push_str(s);
3286                        if !s.ends_with('\n') {
3287                            out.push('\n');
3288                        }
3289                    }
3290                    HunkLine::Remove(s) => {
3291                        out.push('-');
3292                        out.push_str(s);
3293                        if !s.ends_with('\n') {
3294                            out.push('\n');
3295                        }
3296                    }
3297                    HunkLine::NoNewline => out.push_str("\\ No newline at end of file\n"),
3298                }
3299            }
3300        }
3301        out
3302    }
3303
3304    #[test]
3305    fn commit_patch_file_matches_git_apply_cached_then_commit() {
3306        use crate::diff::hunk_id::{hunk_ids_with_duplicate_peers, DiffSide};
3307        use crate::diff::{unstaged_file_diffs_with_peer_hunks, DiffOptions};
3308        use crate::porcelain::hunk_select::{resolve_selection, HunkSelection};
3309
3310        let tmp = TempDir::new().unwrap();
3311        let root = tmp.path();
3312        let repo = init_repo(root);
3313        let (base, edited) = two_hunk_file();
3314        fs::write(root.join("f.txt"), base.clone()).unwrap();
3315        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3316        create_commit(&repo, &commit_req("init"), &mut NullProgress).unwrap();
3317        fs::write(root.join("f.txt"), edited.clone()).unwrap();
3318
3319        let (files, peers) =
3320            unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
3321        let ids = hunk_ids_with_duplicate_peers(
3322            DiffSide::Unstaged,
3323            "f.txt",
3324            &files[0].hunks,
3325            peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
3326        );
3327        let patch = resolve_selection(
3328            &repo,
3329            DiffSide::Unstaged,
3330            &HunkSelection {
3331                ids: vec![ids[0].clone()],
3332                ranges: Vec::new(),
3333            },
3334            &[],
3335            DiffOptions::default(),
3336        )
3337        .unwrap();
3338        let patch_text = patch.iter().map(format_patch_for_git).collect::<String>();
3339
3340        commit_with_patch(
3341            &repo,
3342            &commit_req("grit partial"),
3343            CommitPatchInput::Patches(patch),
3344            &[],
3345            &mut NullProgress,
3346        )
3347        .unwrap();
3348        let grit_tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
3349        let grit_tree = parse_commit(&repo.odb().read(&grit_tip).unwrap().data)
3350            .unwrap()
3351            .tree;
3352
3353        let git_dir = tempfile::tempdir().unwrap();
3354        git(git_dir.path(), &["init", "-q", "-b", "main"]);
3355        fs::write(git_dir.path().join("f.txt"), base).unwrap();
3356        git(git_dir.path(), &["add", "f.txt"]);
3357        git(git_dir.path(), &["commit", "-qm", "init"]);
3358        fs::write(git_dir.path().join("f.txt"), edited).unwrap();
3359        fs::write(git_dir.path().join("partial.patch"), &patch_text).unwrap();
3360        git(git_dir.path(), &["apply", "--cached", "partial.patch"]);
3361        git(git_dir.path(), &["commit", "-qm", "git partial"]);
3362        let git_tip = Command::new("git")
3363            .current_dir(git_dir.path())
3364            .args(["rev-parse", "HEAD^{tree}"])
3365            .env("GIT_CONFIG_GLOBAL", "/dev/null")
3366            .env("GIT_CONFIG_SYSTEM", "/dev/null")
3367            .output()
3368            .unwrap();
3369        let git_tree_hex = String::from_utf8_lossy(&git_tip.stdout);
3370        let git_tree = ObjectId::from_hex(git_tree_hex.trim()).unwrap();
3371        assert_eq!(grit_tree, git_tree);
3372        git(root, &["fsck", "--strict"]);
3373    }
3374
3375    fn loose_object_count(repo: &Repository) -> usize {
3376        use crate::objects::odb::store::loose::enumerate_loose_objects;
3377        enumerate_loose_objects(repo.odb().objects_dir(), repo.odb().hash_algo())
3378            .map(|v| v.len())
3379            .unwrap_or(0)
3380    }
3381
3382    #[test]
3383    fn commit_patch_dry_run_writes_no_loose_objects() {
3384        use crate::diff::hunk_id::{hunk_ids_with_duplicate_peers, DiffSide};
3385        use crate::diff::{unstaged_file_diffs_with_peer_hunks, DiffOptions};
3386        use crate::porcelain::hunk_select::{resolve_selection, HunkSelection};
3387
3388        let tmp = TempDir::new().unwrap();
3389        let root = tmp.path();
3390        let repo = init_repo(root);
3391        let (base, edited) = two_hunk_file();
3392        fs::write(root.join("f.txt"), base).unwrap();
3393        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3394        create_commit(&repo, &commit_req("init"), &mut NullProgress).unwrap();
3395        fs::write(root.join("f.txt"), edited).unwrap();
3396
3397        let before_loose = loose_object_count(&repo);
3398        let (files, peers) =
3399            unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
3400        let ids = hunk_ids_with_duplicate_peers(
3401            DiffSide::Unstaged,
3402            "f.txt",
3403            &files[0].hunks,
3404            peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
3405        );
3406        let patch = resolve_selection(
3407            &repo,
3408            DiffSide::Unstaged,
3409            &HunkSelection {
3410                ids: vec![ids[0].clone()],
3411                ranges: Vec::new(),
3412            },
3413            &[],
3414            DiffOptions::default(),
3415        )
3416        .unwrap();
3417        let before_tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
3418        let before_index = repo.load_index().unwrap().entries[0].oid;
3419
3420        let mut req = commit_req("dry preview");
3421        req.dry_run = true;
3422        commit_with_patch(
3423            &repo,
3424            &req,
3425            CommitPatchInput::Patches(patch),
3426            &[],
3427            &mut NullProgress,
3428        )
3429        .unwrap();
3430
3431        assert_eq!(loose_object_count(&repo), before_loose);
3432        assert_eq!(
3433            resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
3434            before_tip
3435        );
3436        assert_eq!(repo.load_index().unwrap().entries[0].oid, before_index);
3437    }
3438
3439    #[test]
3440    fn commit_hunks_stale_id_no_commit() {
3441        use crate::diff::hunk_id::HunkId;
3442        use crate::porcelain::hunk_select::HunkSelection;
3443
3444        let tmp = TempDir::new().unwrap();
3445        let root = tmp.path();
3446        let repo = init_repo(root);
3447        fs::write(root.join("f.txt"), b"one\n").unwrap();
3448        stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3449        create_commit(&repo, &commit_req("init"), &mut NullProgress).unwrap();
3450        let before_tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
3451        let before_index_oid = repo.load_index().unwrap().entries[0].oid;
3452
3453        fs::write(root.join("f.txt"), b"two\n").unwrap();
3454        let err = commit_with_patch(
3455            &repo,
3456            &commit_req("nope"),
3457            CommitPatchInput::Hunks(HunkSelection {
3458                ids: vec![HunkId::from_str("000000000001").unwrap()],
3459                ranges: Vec::new(),
3460            }),
3461            &[],
3462            &mut NullProgress,
3463        )
3464        .unwrap_err();
3465        assert!(matches!(err, CommitPatchError::Select(_)));
3466        assert_eq!(
3467            resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
3468            before_tip
3469        );
3470        assert_eq!(repo.load_index().unwrap().entries[0].oid, before_index_oid);
3471    }
3472}