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