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