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