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