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