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, stage_on_index_with_pathspec_ignore_exempt, 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::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)]
82pub struct CommitAuthorInfo {
83 pub name: String,
85 pub email: String,
87 pub date: String,
89}
90
91fn guard_incomplete_rewrite_apply(repo: &Repository) -> Result<()> {
92 ensure_rewrite_apply_ready(repo).map_err(|err| match err {
93 HistoryError::RewriteApplyIncomplete => {
94 Error::OperationInProgress(InProgressOperation::RewriteApply)
95 }
96 other => Error::History(other),
97 })
98}
99
100#[derive(Debug, Clone, Default)]
102pub struct CommitRequest {
103 pub message: String,
109 pub author: Option<Signature>,
114 pub committer: Option<Signature>,
117 pub allow_empty: bool,
119 pub sign_override: Option<bool>,
121 pub amend: bool,
124 pub assume_worktree_synced: bool,
130 #[doc(hidden)]
133 pub skip_pre_commit_tree_match: bool,
134 #[doc(hidden)]
137 pub staged_change_count: Option<usize>,
138 #[doc(alias = "no-verify")]
140 pub hooks: HookPolicy,
141 pub author_date: Option<String>,
143 pub trailers: Vec<(String, String)>,
145 pub signoff: bool,
147 pub reset_author: bool,
149 pub reuse_message_from: Option<ObjectId>,
151 pub dry_run: bool,
153 #[doc(hidden)]
156 pub index_only: bool,
157 pub post_rewrite: PostOperationHooks,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct CommitFile {
165 pub path: String,
167 pub status: DiffStatus,
169 pub old_path: Option<String>,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct CommitPlan {
176 pub files: Vec<CommitFile>,
178 pub parents: Vec<ObjectId>,
180 pub author: CommitAuthorInfo,
182 pub tree_unchanged: bool,
184}
185
186#[derive(Debug, Clone)]
188pub enum CommitScope {
189 AutoStage,
191 Staged,
193 Paths(ResolvedPathspecs),
195 Patch {
197 input: CommitPatchInput,
199 pathspecs: Vec<String>,
201 },
202}
203
204impl PartialEq for CommitScope {
205 fn eq(&self, other: &Self) -> bool {
206 match (self, other) {
207 (Self::AutoStage, Self::AutoStage) | (Self::Staged, Self::Staged) => true,
208 (Self::Paths(a), Self::Paths(b)) => a == b,
209 (
210 Self::Patch {
211 input: a,
212 pathspecs: ap,
213 },
214 Self::Patch {
215 input: b,
216 pathspecs: bp,
217 },
218 ) => ap == bp && patch_input_same_kind(a, b),
219 _ => false,
220 }
221 }
222}
223
224impl Eq for CommitScope {}
225
226fn patch_input_same_kind(a: &CommitPatchInput, b: &CommitPatchInput) -> bool {
227 matches!(
228 (a, b),
229 (CommitPatchInput::Hunks(_), CommitPatchInput::Hunks(_))
230 | (CommitPatchInput::Patches(_), CommitPatchInput::Patches(_))
231 )
232}
233
234#[derive(Debug, Clone)]
236pub enum CommitPatchInput {
237 Hunks(HunkSelection),
239 Patches(Vec<FilePatch>),
241}
242
243#[derive(Debug, Error)]
245pub enum CommitPatchError {
246 #[error(transparent)]
248 Select(#[from] HunkSelectError),
249 #[error(transparent)]
251 Apply(#[from] ApplyError),
252 #[error(transparent)]
254 Commit(#[from] Error),
255}
256
257pub fn default_commit_scope(repo: &Repository) -> Result<CommitScope> {
263 let config = repo.config()?;
264 Ok(default_commit_scope_from_config(config.as_ref()))
265}
266
267#[must_use]
269pub fn default_commit_scope_from_config(config: &ConfigSet) -> CommitScope {
270 let auto_stage = config
271 .get_bool("commit.autoStage")
272 .and_then(|r| r.ok())
273 .unwrap_or(true);
274 if auto_stage {
275 CommitScope::AutoStage
276 } else {
277 CommitScope::Staged
278 }
279}
280
281pub(crate) fn clear_squash_commit_state(git_dir: &std::path::Path) -> Result<()> {
287 let squash = git_dir.join("SQUASH_MSG");
288 if !squash.is_file() {
289 return Ok(());
290 }
291 fs::remove_file(&squash).map_err(Error::Io)?;
292 if !git_dir.join("MERGE_HEAD").is_file() {
293 let merge_msg = git_dir.join("MERGE_MSG");
294 if merge_msg.is_file() {
295 fs::remove_file(&merge_msg).map_err(Error::Io)?;
296 }
297 }
298 Ok(())
299}
300
301pub fn pending_commit_message(repo: &Repository) -> Result<Option<String>> {
307 let git_dir = repo.git_dir();
308 let squash = git_dir.join("SQUASH_MSG");
309 let merge_msg = git_dir.join("MERGE_MSG");
310 if squash.is_file() && merge_msg.is_file() {
312 return fs::read_to_string(merge_msg).map_err(Error::Io).map(Some);
313 }
314 if squash.is_file() {
315 return fs::read_to_string(squash).map_err(Error::Io).map(Some);
316 }
317 if !read_merge_heads(git_dir)?.is_empty() {
318 return read_merge_msg(git_dir);
319 }
320 Ok(None)
321}
322
323pub fn commit_with_scope(
330 repo: &Repository,
331 req: &CommitRequest,
332 scope: CommitScope,
333 progress: &mut dyn ProgressSink,
334) -> Result<CommitOutcome> {
335 guard_incomplete_rewrite_apply(repo)?;
336 match scope {
337 CommitScope::AutoStage => commit_worktree(repo, req, progress),
338 CommitScope::Staged => {
339 let mut req = req.clone();
340 req.assume_worktree_synced = true;
342 req.index_only = true;
343 create_commit(repo, &req, progress)
344 }
345 CommitScope::Paths(paths) => commit_paths_scope(repo, req, &paths, progress),
346 CommitScope::Patch { input, pathspecs } => {
347 commit_with_patch(repo, req, input, &pathspecs, progress).map_err(|e| match e {
348 CommitPatchError::Commit(err) => err,
349 other => Error::Message(other.to_string()),
350 })
351 }
352 }
353}
354
355pub fn commit_with_patch(
365 repo: &Repository,
366 req: &CommitRequest,
367 input: CommitPatchInput,
368 pathspecs: &[String],
369 progress: &mut dyn ProgressSink,
370) -> std::result::Result<CommitOutcome, CommitPatchError> {
371 guard_incomplete_rewrite_apply(repo).map_err(CommitPatchError::Commit)?;
372 commit_patch_scope(repo, req, input, pathspecs, progress)
373}
374
375fn commit_patch_scope(
376 repo: &Repository,
377 req: &CommitRequest,
378 input: CommitPatchInput,
379 pathspecs: &[String],
380 progress: &mut dyn ProgressSink,
381) -> std::result::Result<CommitOutcome, CommitPatchError> {
382 if !read_merge_heads(repo.git_dir())?.is_empty() {
383 return Err(Error::PartialCommitDuringMerge.into());
384 }
385 let index_lock = (!req.dry_run)
386 .then(|| repo.begin_index_update())
387 .transpose()?;
388
389 let patches = match input {
390 CommitPatchInput::Hunks(selection) => {
391 if selection.ids.is_empty() && selection.ranges.is_empty() {
392 return Err(Error::NothingToCommit.into());
393 }
394 resolve_selection(
395 repo,
396 crate::diff::hunk_id::DiffSide::Unstaged,
397 &selection,
398 pathspecs,
399 diff_options_for_hunk_select(repo).map_err(CommitPatchError::Commit)?,
400 )?
401 }
402 CommitPatchInput::Patches(patches) => {
403 if patches.is_empty() {
404 return Err(Error::NothingToCommit.into());
405 }
406 patches
407 }
408 };
409
410 let head = resolve_head_for_repository(repo).map_err(CommitPatchError::Commit)?;
411 let mut index = repo.load_index().map_err(CommitPatchError::Commit)?;
412 if index.has_unmerged_entries() {
413 return Err(Error::IndexUnmerged.into());
414 }
415
416 let dry_run = req.dry_run;
417 let _patch_overlay = if dry_run {
418 repo.odb().enable_mem_overlay();
419 Some(MemOverlayGuard(repo.odb()))
420 } else {
421 None
422 };
423
424 apply_patch_to_index(
425 repo,
426 &mut index,
427 &patches,
428 &IndexApplyOptions {
429 dry_run: false,
430 ..IndexApplyOptions::default()
431 },
432 )?;
433
434 let mut req = req.clone();
435 req.assume_worktree_synced = true;
436 let persist = index_lock.map(|lock| LockedIndex { index: None, lock });
437 create_commit_with_index(repo, head, &mut index, &req, progress, persist)
438 .map_err(CommitPatchError::Commit)
439}
440
441pub fn plan_commit(
450 repo: &Repository,
451 req: &CommitRequest,
452 scope: CommitScope,
453 progress: &mut dyn ProgressSink,
454) -> Result<CommitPlan> {
455 let mut req = req.clone();
456 req.dry_run = true;
457 let outcome = commit_with_scope(repo, &req, scope, progress)?;
458 Ok(CommitPlan {
459 files: outcome.files,
460 parents: outcome.parents,
461 author: outcome.author,
462 tree_unchanged: outcome.changes == 0,
463 })
464}
465
466fn seed_commit_index_stage0_from_index(
474 repo: &Repository,
475 commit_index: &mut Index,
476 source: &Index,
477 paths: &ResolvedPathspecs,
478) -> Result<()> {
479 if paths.specs.is_empty() {
480 return Ok(());
481 }
482 let ctx = repo.pathspec_match_context();
483 let mut seeded = false;
484 for entry in &source.entries {
485 if entry.stage() != 0 {
486 continue;
487 }
488 let path = match std::str::from_utf8(&entry.path) {
489 Ok(p) if !p.is_empty() => p,
490 _ => continue,
491 };
492 if commit_index.get(path.as_bytes(), 0).is_some() {
493 continue;
494 }
495 if !matches_pathspec_list_with_context(path, &paths.specs, ctx) {
496 continue;
497 }
498 commit_index.stage_file(entry.clone());
499 seeded = true;
500 }
501 if seeded {
502 commit_index.ensure_canonical_order();
503 }
504 Ok(())
505}
506
507fn commit_paths_scope(
508 repo: &Repository,
509 req: &CommitRequest,
510 paths: &ResolvedPathspecs,
511 progress: &mut dyn ProgressSink,
512) -> Result<CommitOutcome> {
513 if paths.specs.is_empty() {
514 return Err(Error::Message("you must specify path(s)".into()));
515 }
516 if !read_merge_heads(repo.git_dir())?.is_empty() {
517 return Err(Error::PartialCommitDuringMerge);
518 }
519 validate_commit_pathspecs(repo, paths)?;
520
521 let index_lock = (!req.dry_run)
524 .then(|| repo.begin_index_update())
525 .transpose()?;
526 let head = resolve_head_for_repository(repo)?;
527 let parent_tree = match &head {
528 HeadState::Branch { oid: Some(p), .. } | HeadState::Detached { oid: p } => {
529 Some(commit_tree_oid(repo, p)?)
530 }
531 HeadState::Branch { oid: None, .. } => None,
532 HeadState::Invalid => {
533 return Err(Error::Message("HEAD is in an unknown state".into()));
534 }
535 };
536
537 let mut real_index = repo.load_index()?;
538 if real_index.has_unmerged_entries() {
539 return Err(Error::IndexUnmerged);
540 }
541 let mut stage_opts = StageOptions {
542 pathspecs: paths.specs.clone(),
543 pathspec_sources: if paths.sources.is_empty() {
544 paths.specs.clone()
545 } else {
546 paths.sources.clone()
547 },
548 mode: StageMode::All,
549 dry_run: req.dry_run,
550 ..StageOptions::default()
551 };
552
553 {
554 let mut validate_opts = stage_opts.clone();
555 validate_opts.dry_run = true;
556 let mut probe = real_index.clone();
557 stage_on_index_with_pathspec_ignore_exempt(
558 repo,
559 &mut probe,
560 &validate_opts,
561 progress,
562 Some(&real_index),
563 )?;
564 }
565 stage_opts.skip_pathspec_ignore_check = true;
566
567 let mut commit_index = match parent_tree.as_ref() {
568 Some(tree) => index_from_tree_with_seeded_worktree_stats(repo, tree, &real_index)?,
569 None => Index::new(),
570 };
571 seed_commit_index_stage0_from_index(repo, &mut commit_index, &real_index, paths)?;
572
573 let staged_commit = stage_on_index(repo, &mut commit_index, &stage_opts, progress)?;
574 let real_index = match index_lock {
575 Some(lock) => {
576 stage_on_index(repo, &mut real_index, &stage_opts, progress)?;
577 Some(LockedIndex {
578 index: Some(real_index),
579 lock,
580 })
581 }
582 None => None,
583 };
584
585 let mut req = req.clone();
586 req.assume_worktree_synced = true;
587 req.skip_pre_commit_tree_match = false;
588 req.staged_change_count = Some(staged_commit.total().max(1));
589 req.index_only = true;
590
591 create_commit_with_index(repo, head, &mut commit_index, &req, progress, real_index)
592}
593
594struct LockedIndex {
600 index: Option<Index>,
603 lock: IndexLock,
604}
605
606fn validate_commit_pathspecs(repo: &Repository, paths: &ResolvedPathspecs) -> Result<()> {
607 let pairs: Vec<(&str, &str)> =
608 if paths.sources.len() == paths.specs.len() && !paths.sources.is_empty() {
609 paths
610 .sources
611 .iter()
612 .zip(paths.specs.iter())
613 .map(|(a, b)| (a.as_str(), b.as_str()))
614 .collect()
615 } else {
616 paths
617 .specs
618 .iter()
619 .map(|s| (s.as_str(), s.as_str()))
620 .collect()
621 };
622 let mut checker = KnownPathspecChecker::new(repo);
623 for (source, resolved) in pairs {
624 if pathspec_is_exclude(source) {
625 continue;
626 }
627 checker.ensure(source, resolved)?;
628 }
629 Ok(())
630}
631
632pub fn normalize_commit_message(raw: &str) -> Result<String> {
639 let normalized = raw.replace("\r\n", "\n").replace('\r', "\n");
640 let processed = process(normalized.as_bytes(), &Mode::Default);
641 let message = String::from_utf8(processed)
642 .map_err(|_| Error::Message("invalid commit message".into()))?;
643 if message.is_empty() {
644 return Err(Error::EmptyCommitMessage);
645 }
646 let mut message = message;
647 while message.ends_with("\n\n") {
648 message.pop();
649 }
650 if !message.ends_with('\n') {
651 message.push('\n');
652 }
653 Ok(message)
654}
655
656fn run_commit_hook_recorded(
657 repo: &Repository,
658 hook: HookName,
659 args: &[&str],
660 stdin_data: Option<&[u8]>,
661 commit_env: &CommitHookEnv<'_>,
662) -> std::result::Result<bool, HookError> {
663 run_commit_hook(repo, hook.as_str(), args, stdin_data, commit_env)?.check(hook.as_str())
664}
665
666fn author_info_from_header_line(line: &str) -> CommitAuthorInfo {
667 let line = line.trim();
668 let date = parse_signature_times(line)
669 .map(|p| {
670 let tz = &line[p.tz_hhmm_range];
671 format!("{} {tz}", p.unix_seconds)
672 })
673 .unwrap_or_default();
674 let contact_end = line.rfind('>').map(|i| i + 1).unwrap_or(line.len());
675 let contact = line[..contact_end].trim();
676 let (name, email) = if let Some(lt) = contact.find('<') {
677 if let Some(gt) = contact.find('>') {
678 (contact[..lt].trim(), contact[lt + 1..gt].trim())
679 } else {
680 (contact, "")
681 }
682 } else if contact.contains('@') && !contact.chars().any(char::is_whitespace) {
683 ("", contact)
684 } else {
685 (contact, "")
686 };
687 CommitAuthorInfo {
688 name: name.to_owned(),
689 email: email.to_owned(),
690 date,
691 }
692}
693
694fn author_line_from_committer(
695 committer: &str,
696 author_date: Option<&str>,
697 now: time::OffsetDateTime,
698) -> String {
699 let info = author_info_from_header_line(committer);
700 assemble_identity(&info.name, &info.email, author_date, now)
701}
702
703fn enrich_commit_message(
704 message: String,
705 trailers: &[(String, String)],
706 signoff: bool,
707 committer: &str,
708 config: &ConfigSet,
709 git_dir: &Path,
710) -> Result<String> {
711 let mut out = message;
712 if signoff {
713 let info = author_info_from_header_line(committer);
714 let sob = format_signoff_line(&info.name, &info.email);
715 append_signoff_trailer_with_dedup(&mut out, &sob, config, true);
716 }
717 if !trailers.is_empty() {
718 let new_args: Vec<NewTrailerArg> = trailers
719 .iter()
720 .map(|(key, value)| NewTrailerArg {
721 text: if value.is_empty() {
722 format!("{key}:")
723 } else {
724 format!("{key}: {value}")
725 },
726 where_: TrailerWhere::End,
727 if_exists: TrailerIfExists::AddIfDifferent,
728 if_missing: TrailerIfMissing::Add,
729 })
730 .collect();
731 let opts = ProcessTrailerOptions {
732 only_trailers: false,
733 trim_empty: true,
734 only_input: false,
735 unfold: false,
736 no_divider: false,
737 };
738 out = process_trailers(&out, &opts, &new_args, Some(git_dir));
739 }
740 Ok(out)
741}
742
743#[derive(Debug, Clone, PartialEq, Eq)]
745pub struct CommitOutcome {
746 pub oid: ObjectId,
748 pub branch: Option<String>,
751 pub parent: Option<ObjectId>,
753 pub parents: Vec<ObjectId>,
755 pub author: CommitAuthorInfo,
757 pub hooks_run: Vec<HookName>,
760 pub changes: usize,
762 pub change_id: Option<ChangeId>,
764 pub files: Vec<CommitFile>,
766}
767
768impl CommitOutcome {
769 #[must_use]
771 pub fn is_detached(&self) -> bool {
772 self.branch.is_none()
773 }
774}
775
776pub fn create_commit(
796 repo: &Repository,
797 req: &CommitRequest,
798 progress: &mut dyn ProgressSink,
799) -> Result<CommitOutcome> {
800 guard_incomplete_rewrite_apply(repo)?;
801 let head = resolve_head_for_repository(repo)?;
802 let mut index = repo.load_index()?;
803 create_commit_with_index(repo, head, &mut index, req, progress, None)
804}
805
806pub fn commit_worktree(
821 repo: &Repository,
822 req: &CommitRequest,
823 progress: &mut dyn ProgressSink,
824) -> Result<CommitOutcome> {
825 guard_incomplete_rewrite_apply(repo)?;
826 let head = resolve_head_for_repository(repo)?;
827 let mut index = repo.load_index()?;
828 let staged =
829 crate::porcelain::add::stage_for_commit_on_index(repo, &mut index, progress, req.dry_run)?;
830 if index.has_unmerged_entries() {
831 return Err(Error::IndexUnmerged);
832 }
833 let mut req = req.clone();
834 req.assume_worktree_synced = true;
835 if req.skip_pre_commit_tree_match {
836 req.staged_change_count = Some(staged.total());
837 }
838 let result = create_commit_with_index(repo, head, &mut index, &req, progress, None);
839 if matches!(result, Err(Error::NothingToCommit)) && !req.dry_run && staged.total() > 0 {
840 repo.write_index(&mut index)?;
841 }
842 result
843}
844
845fn commit_files_from_diff_entries(entries: &[DiffEntry]) -> Vec<CommitFile> {
846 let mut files: Vec<CommitFile> = entries
847 .iter()
848 .filter(|e| e.status != DiffStatus::Unmerged)
849 .map(|e| {
850 let (path, old_path) = match e.status {
851 DiffStatus::Renamed | DiffStatus::Copied => {
852 (e.new_path.clone().unwrap_or_default(), e.old_path.clone())
853 }
854 DiffStatus::Deleted => (e.old_path.clone().unwrap_or_default(), None),
855 _ => (e.new_path.clone().unwrap_or_default(), None),
856 };
857 CommitFile {
858 path,
859 status: e.status,
860 old_path,
861 }
862 })
863 .collect();
864 files.sort_by(|a, b| a.path.cmp(&b.path));
865 files
866}
867
868fn commit_files_between_trees(
869 odb: &crate::odb::Odb,
870 old_tree: Option<&ObjectId>,
871 new_tree: &ObjectId,
872) -> Result<Vec<CommitFile>> {
873 let entries = diff_trees_with_renames(odb, old_tree, Some(new_tree), "", 50)?;
874 Ok(commit_files_from_diff_entries(&entries))
875}
876
877fn commit_files_index_vs_parent_tree(
878 odb: &crate::odb::Odb,
879 index: &Index,
880 parent_tree: Option<&ObjectId>,
881) -> Result<Vec<CommitFile>> {
882 let mut parent: BTreeMap<String, ObjectId> = BTreeMap::new();
883 if let Some(tree) = parent_tree {
884 for (path, _mode, oid) in flatten_tree_entries(odb, tree)? {
885 parent.insert(path, oid);
886 }
887 }
888 let mut files = Vec::new();
889 for entry in index.entries() {
890 if entry.stage() != 0 || entry.intent_to_add() {
891 continue;
892 }
893 let path = String::from_utf8_lossy(&entry.path).into_owned();
894 match parent.remove(&path) {
895 None => files.push(CommitFile {
896 path,
897 status: DiffStatus::Added,
898 old_path: None,
899 }),
900 Some(old_oid) if old_oid != entry.oid => files.push(CommitFile {
901 path,
902 status: DiffStatus::Modified,
903 old_path: None,
904 }),
905 _ => {}
906 }
907 }
908 for (path, _) in parent {
909 files.push(CommitFile {
910 path,
911 status: DiffStatus::Deleted,
912 old_path: None,
913 });
914 }
915 files.sort_by(|a, b| a.path.cmp(&b.path));
916 Ok(files)
917}
918
919struct MemOverlayGuard<'a>(&'a Odb);
920
921impl Drop for MemOverlayGuard<'_> {
922 fn drop(&mut self) {
923 self.0.disable_mem_overlay();
924 }
925}
926
927fn materialize_missing_index_blobs_for_overlay(repo: &Repository, index: &Index) -> Result<()> {
928 let odb = repo.odb();
929 let work_tree = repo.work_tree().ok_or(Error::RequiresWorkTree)?;
930 let rules = WorktreeRules::from_repository(repo, index)?;
931 for entry in index
932 .entries
933 .iter()
934 .filter(|e| e.stage() == 0 && !e.intent_to_add())
935 {
936 if odb.contains(&entry.oid)? {
937 continue;
938 }
939 let rel = String::from_utf8_lossy(&entry.path);
940 let abs = crate::index::worktree_path_from_index_rel(work_tree, &entry.path);
941 let meta = fs::metadata(&abs).map_err(Error::Io)?;
942 let file_attrs = rules.file_attrs(&rel, false);
943 let stored = materialize_worktree_blob(
944 odb,
945 &abs,
946 &meta,
947 rules.conversion(),
948 &file_attrs,
949 &rel,
950 Some(entry),
951 Some(rules.filter_process()),
952 )?;
953 if stored != entry.oid {
954 return Err(Error::Message(format!(
955 "internal error: staged oid mismatch for {rel}"
956 )));
957 }
958 }
959 Ok(())
960}
961
962#[allow(clippy::too_many_arguments)]
963fn finish_commit_dry_run(
964 repo: &Repository,
965 index: &mut Index,
966 req: &CommitRequest,
967 short_name: Option<String>,
968 parent: Option<ObjectId>,
969 parent_tree: Option<ObjectId>,
970 merge_heads: Vec<ObjectId>,
971 concluding_merge: bool,
972 amend_author: Option<String>,
973 amend_author_raw: Vec<u8>,
974 amend_parents: Vec<ObjectId>,
975 amend_default_message: Option<String>,
976 req_amend: bool,
977 reuse_author: Option<String>,
978 reuse_author_raw: Vec<u8>,
979 reuse_message: Option<String>,
980) -> Result<CommitOutcome> {
981 let odb = repo.odb();
982 odb.enable_mem_overlay();
983 let _overlay = MemOverlayGuard(odb);
984 materialize_missing_index_blobs_for_overlay(repo, index)?;
985 let files = commit_files_index_vs_parent_tree(odb, index, parent_tree.as_ref())?;
986
987 if !req.allow_empty && !req_amend {
988 if parent_tree.is_none() && index.entries.is_empty() {
989 return Err(empty_commit_error(repo, index, req));
990 }
991 if !concluding_merge && files.is_empty() {
992 return Err(empty_commit_error(repo, index, req));
993 }
994 }
995
996 let mut message = if !req.message.trim().is_empty() {
997 normalize_commit_message(&req.message)?
998 } else if let Some(reuse_msg) = reuse_message {
999 reuse_msg
1000 } else if req_amend {
1001 amend_default_message.unwrap_or_default()
1002 } else {
1003 return Err(Error::EmptyCommitMessage);
1004 };
1005 if message.trim().is_empty() {
1006 return Err(Error::EmptyCommitMessage);
1007 }
1008 if !message.ends_with('\n') {
1009 message.push('\n');
1010 }
1011 let config = repo.config()?;
1012 let now = repo.identity_now();
1013 let committer = commit_identity_line(repo, req.committer.as_ref(), IdentRole::Committer, now)?;
1014 let _message_with_trailers = enrich_commit_message(
1015 message,
1016 &req.trailers,
1017 req.signoff,
1018 &committer,
1019 config.as_ref(),
1020 repo.git_dir(),
1021 )?;
1022
1023 let (author, _author_raw) = resolve_author_for_commit(
1024 repo,
1025 req,
1026 &committer,
1027 amend_author,
1028 amend_author_raw,
1029 reuse_author,
1030 reuse_author_raw,
1031 req_amend,
1032 )?;
1033 let mut commit_parents = if req_amend {
1034 amend_parents
1035 } else {
1036 parent.into_iter().collect()
1037 };
1038 if !req_amend {
1039 for merge_head in merge_heads {
1040 if !commit_parents.iter().any(|p| p == &merge_head) {
1041 commit_parents.push(merge_head);
1042 }
1043 }
1044 }
1045 let author_info = author_info_from_header_line(&author);
1046 let changes = files.len();
1047
1048 Ok(CommitOutcome {
1049 oid: zero_oid(),
1050 branch: short_name,
1051 parent,
1052 parents: commit_parents,
1053 author: author_info,
1054 hooks_run: Vec::new(),
1055 changes,
1056 change_id: None,
1057 files,
1058 })
1059}
1060
1061#[allow(clippy::too_many_arguments)]
1062fn resolve_author_for_commit(
1063 repo: &Repository,
1064 req: &CommitRequest,
1065 committer: &str,
1066 amend_author: Option<String>,
1067 amend_author_raw: Vec<u8>,
1068 reuse_author: Option<String>,
1069 reuse_author_raw: Vec<u8>,
1070 req_amend: bool,
1071) -> Result<(String, Vec<u8>)> {
1072 let now = repo.identity_now();
1073 if req.reset_author {
1074 return Ok((
1075 author_line_from_committer(committer, req.author_date.as_deref(), now),
1076 Vec::new(),
1077 ));
1078 }
1079 if let Some(author) = req.author.as_ref() {
1080 author.validate()?;
1081 let author =
1082 prepare_commit_identity_line(&author.header_line(), req.author_date.as_deref(), now)?;
1083 return Ok((author, Vec::new()));
1084 }
1085 if let Some(a) = reuse_author {
1086 return Ok(prepare_commit_author_from_parent(
1087 &a,
1088 &reuse_author_raw,
1089 req.author_date.as_deref(),
1090 now,
1091 )?);
1092 }
1093 if req_amend {
1094 let author = amend_author.unwrap_or_default();
1095 return Ok(prepare_commit_author_from_parent(
1096 &author,
1097 &amend_author_raw,
1098 req.author_date.as_deref(),
1099 now,
1100 )?);
1101 }
1102 let author = repo.identity(IdentRole::Author, now)?.header_line();
1103 let author = prepare_commit_identity_line(&author, req.author_date.as_deref(), now)?;
1104 Ok((author, Vec::new()))
1105}
1106
1107fn commit_identity_line(
1110 repo: &Repository,
1111 identity: Option<&Signature>,
1112 role: IdentRole,
1113 now: time::OffsetDateTime,
1114) -> Result<String> {
1115 match identity {
1116 Some(signature) => {
1117 signature.validate()?;
1118 Ok(signature.header_line())
1119 }
1120 None => Ok(repo.identity(role, now)?.header_line()),
1121 }
1122}
1123
1124struct TempHookIndexFile(PathBuf);
1126
1127impl Drop for TempHookIndexFile {
1128 fn drop(&mut self) {
1129 let _ = fs::remove_file(&self.0);
1130 }
1131}
1132
1133pub(crate) fn create_commit_with_loaded_index(
1137 repo: &Repository,
1138 index: &mut Index,
1139 req: &CommitRequest,
1140 progress: &mut dyn ProgressSink,
1141) -> Result<CommitOutcome> {
1142 if index.has_unmerged_entries() {
1143 return Err(Error::IndexUnmerged);
1144 }
1145 let head = resolve_head_for_repository(repo)?;
1146 create_commit_with_index(repo, head, index, req, progress, None)
1147}
1148
1149fn create_commit_with_index(
1155 repo: &Repository,
1156 head: HeadState,
1157 index: &mut Index,
1158 req: &CommitRequest,
1159 _progress: &mut dyn ProgressSink,
1160 persist_index: Option<LockedIndex>,
1161) -> Result<CommitOutcome> {
1162 let (refname, short_name, mut parent) = match head {
1163 HeadState::Branch {
1164 refname,
1165 short_name,
1166 oid,
1167 } => (refname, Some(short_name), oid),
1168 HeadState::Detached { oid } => ("HEAD".to_owned(), None, Some(oid)),
1169 HeadState::Invalid => {
1170 return Err(Error::Message("HEAD is in an unknown state".into()));
1171 }
1172 };
1173
1174 let mut amend_parents = Vec::new();
1175 let mut amend_author = None;
1176 let mut amend_author_raw = Vec::new();
1177 let mut amend_extra_headers = Vec::new();
1178 let mut amend_default_message = None;
1179 let mut replaced_tip = None;
1180 let mut reuse_author = None;
1181 let mut reuse_author_raw = Vec::new();
1182 let mut reuse_message = None;
1183 if let Some(reuse_oid) = req.reuse_message_from {
1184 let obj = repo.odb().read(&reuse_oid)?;
1185 if obj.kind != ObjectKind::Commit {
1186 return Err(Error::CorruptObject(format!(
1187 "expected commit for reuse-message, got {}",
1188 obj.kind.as_str()
1189 )));
1190 }
1191 let source = parse_commit(&obj.data)?;
1192 reuse_message = Some(source.message);
1193 reuse_author = Some(source.author);
1194 reuse_author_raw = source.author_raw;
1195 }
1196 if req.amend {
1197 let Some(tip) = parent.take() else {
1198 return Err(Error::AmendUnborn);
1199 };
1200 let obj = repo.odb().read(&tip)?;
1201 if obj.kind != ObjectKind::Commit {
1202 return Err(Error::CorruptObject(format!(
1203 "expected commit, got {}",
1204 obj.kind.as_str()
1205 )));
1206 }
1207 let old = parse_commit(&obj.data)?;
1208 amend_parents = old.parents.clone();
1209 parent = old.parents.first().copied();
1210 amend_author = Some(old.author);
1211 amend_author_raw = old.author_raw;
1212 amend_extra_headers = extra_headers_without_commit_signatures(&old.extra_headers);
1213 amend_default_message = Some(old.message);
1214 replaced_tip = Some(tip);
1215 }
1216
1217 if index.has_unmerged_entries() {
1218 return Err(Error::IndexUnmerged);
1219 }
1220 let merge_heads = read_merge_heads(repo.git_dir())?;
1221 let concluding_merge = !merge_heads.is_empty();
1222 let index_path = repo.git_dir().join("index");
1223 let parent_tree = parent
1224 .as_ref()
1225 .map(|parent_oid| commit_tree_oid(repo, parent_oid))
1226 .transpose()?;
1227
1228 if !req.allow_empty && !req.amend && !concluding_merge {
1229 let skip_tree_match = req.assume_worktree_synced && req.skip_pre_commit_tree_match;
1230 if !skip_tree_match {
1231 if let Some(ref old) = parent_tree {
1232 if index_staged_tree_matches(repo.odb(), index, old) {
1233 return Err(empty_commit_error(repo, index, req));
1234 }
1235 } else if index.entries.is_empty() {
1236 return Err(empty_commit_error(repo, index, req));
1237 }
1238 }
1239 }
1240
1241 if req.dry_run {
1242 return finish_commit_dry_run(
1243 repo,
1244 index,
1245 req,
1246 short_name,
1247 parent,
1248 parent_tree,
1249 merge_heads,
1250 concluding_merge,
1251 amend_author,
1252 amend_author_raw,
1253 amend_parents,
1254 amend_default_message,
1255 req.amend,
1256 reuse_author,
1257 reuse_author_raw,
1258 reuse_message,
1259 );
1260 }
1261
1262 let persist_through_lock = persist_index.is_some();
1263 let _temp_hook_index_guard = if persist_through_lock {
1264 let path = repo.git_dir().join(format!("next-index-{}", process::id()));
1265 repo.write_index_at(&path, index)?;
1266 Some(TempHookIndexFile(path))
1267 } else {
1268 repo.write_index(index)?;
1269 None
1270 };
1271
1272 let temp_hook_index_path = _temp_hook_index_guard.as_ref().map(|g| g.0.clone());
1273
1274 let hook_index_path: PathBuf = temp_hook_index_path
1275 .clone()
1276 .unwrap_or_else(|| index_path.clone());
1277
1278 let commit_env = CommitHookEnv {
1279 index_file: Some(hook_index_path.as_path()),
1280 git_editor: Some(":"),
1281 git_prefix: None,
1282 extra_env: &[],
1283 };
1284 let mut hooks_run = Vec::new();
1285 if req.hooks != HookPolicy::SkipVerify
1286 && run_commit_hook_recorded(repo, HookName::PreCommit, &[], None, &commit_env)?
1287 {
1288 hooks_run.push(HookName::PreCommit);
1289 }
1290
1291 let tree = write_tree_update_index(repo.odb(), index, "", WriteTreeFlags::silent())?;
1292 let commit_files = commit_files_between_trees(repo.odb(), parent_tree.as_ref(), &tree)?;
1293
1294 if !req.allow_empty && !req.amend {
1295 if parent_tree.is_none() && is_empty_tree_oid(repo.odb(), &tree) {
1296 return Err(empty_commit_error(repo, index, req));
1297 }
1298 if !concluding_merge {
1299 if let Some(ref old) = parent_tree {
1300 if old == &tree {
1301 return Err(empty_commit_error(repo, index, req));
1302 }
1303 }
1304 }
1305 }
1306
1307 let mut message = if !req.message.trim().is_empty() {
1308 normalize_commit_message(&req.message)?
1309 } else if let Some(reuse_msg) = reuse_message {
1310 reuse_msg
1311 } else if req.amend {
1312 amend_default_message.unwrap_or_default()
1313 } else if let Some(pending) = pending_commit_message(repo)? {
1314 pending
1315 } else {
1316 return Err(Error::EmptyCommitMessage);
1317 };
1318 if message.trim().is_empty() {
1319 return Err(Error::EmptyCommitMessage);
1320 }
1321 if !message.ends_with('\n') {
1322 message.push('\n');
1323 }
1324 let config = repo.config()?;
1325 let now = repo.identity_now();
1326 let committer = commit_identity_line(repo, req.committer.as_ref(), IdentRole::Committer, now)?;
1327 message = enrich_commit_message(
1328 message,
1329 &req.trailers,
1330 req.signoff,
1331 &committer,
1332 config.as_ref(),
1333 repo.git_dir(),
1334 )?;
1335 let editmsg_path = repo.git_dir().join("COMMIT_EDITMSG");
1336 fs::write(&editmsg_path, &message).map_err(Error::Io)?;
1337 let editmsg_arg = commit_editmsg_hook_argument(repo, &editmsg_path);
1338 if req.hooks != HookPolicy::SkipVerify
1339 && run_commit_hook_recorded(
1340 repo,
1341 HookName::CommitMsg,
1342 &[editmsg_arg.as_str()],
1343 None,
1344 &commit_env,
1345 )?
1346 {
1347 hooks_run.push(HookName::CommitMsg);
1348 message = fs::read_to_string(&editmsg_path).map_err(Error::Io)?;
1351 if message.trim().is_empty() {
1352 return Err(Error::EmptyCommitMessage);
1353 }
1354 if !message.ends_with('\n') {
1355 message.push('\n');
1356 }
1357 }
1358
1359 let (author, author_raw) = resolve_author_for_commit(
1360 repo,
1361 req,
1362 &committer,
1363 amend_author,
1364 amend_author_raw,
1365 reuse_author,
1366 reuse_author_raw,
1367 req.amend,
1368 )?;
1369 let mut commit_parents = if req.amend {
1370 amend_parents
1371 } else {
1372 parent.into_iter().collect()
1373 };
1374 if !req.amend {
1375 for merge_head in merge_heads {
1376 if !commit_parents.iter().any(|p| p == &merge_head) {
1377 commit_parents.push(merge_head);
1378 }
1379 }
1380 }
1381 let author_info = author_info_from_header_line(&author);
1382 let mut commit_data = CommitData {
1383 tree,
1384 parents: commit_parents.clone(),
1385 author,
1386 committer: committer.clone(),
1387 author_raw,
1388 committer_raw: Vec::new(),
1389 encoding: None,
1390 message,
1391 raw_message: None,
1392 extra_headers: amend_extra_headers,
1393 };
1394 let policy = ChangeIdPolicy::from_config(config.as_ref());
1395 if policy.enabled() {
1396 if req.amend {
1397 if change_id(&commit_data).is_none() {
1398 if let Some(tip) = replaced_tip {
1399 set_change_id(&mut commit_data, &ChangeId::derived_from_commit_id(&tip));
1400 }
1401 }
1402 } else {
1403 stamp_change_id_on_new_commit(repo, &policy, &mut commit_data)?;
1404 }
1405 }
1406 let bytes = prepare_commit_bytes(repo, config.as_ref(), &commit_data, req.sign_override)?;
1407 fsck_commit_idents(&bytes, repo.odb().hash_algo())
1408 .map_err(|detail| Error::CorruptObject(format!("commit would fail fsck: {detail}")))?;
1409 let oid = repo.odb().write(ObjectKind::Commit, &bytes)?;
1410
1411 let previous_tip = replaced_tip.or(parent);
1412 let reflog_old = previous_tip.unwrap_or_else(zero_oid);
1413 let subject = commit_subject(&commit_data.message);
1414 let reflog_msg = if req.amend {
1415 format!("commit (amend): {subject}")
1416 } else if parent.is_some() {
1417 format!("commit: {subject}")
1418 } else {
1419 format!("commit (initial): {subject}")
1420 };
1421
1422 let expected_old = previous_tip.or_else(|| {
1423 matches!(repo.references().resolve_if_exists(&refname), Ok(Some(_))).then_some(reflog_old)
1424 });
1425
1426 if !persist_through_lock {
1427 if !req.assume_worktree_synced {
1428 if let Some(work_tree) = repo.work_tree() {
1429 let rules = WorktreeRules::from_repository(repo, index).ok();
1430 let rules_arc = rules.map(|r| Arc::new(Mutex::new(r)));
1431 let _ = refresh_index_stat_content_verified_with_rules(
1432 repo.odb(),
1433 repo.git_dir(),
1434 index,
1435 work_tree,
1436 None,
1437 Some(config.as_ref()),
1438 None,
1439 rules_arc.as_ref(),
1440 )?;
1441 }
1442 }
1443 repo.write_index(index)?;
1444 }
1445
1446 let changes = if parent_tree.as_ref() == Some(&tree) {
1447 0
1448 } else if !commit_files.is_empty() {
1449 commit_files.len()
1450 } else if req.assume_worktree_synced && req.skip_pre_commit_tree_match {
1451 const LARGE_INDEX_CHANGE_COUNT_FAST_PATH: usize = 4096;
1454 let large_index = index.entries.len() >= LARGE_INDEX_CHANGE_COUNT_FAST_PATH;
1455 if large_index {
1456 if let Some(n) = req.staged_change_count.filter(|&n| n > 0) {
1457 n
1458 } else {
1459 diff_tree_path_change_count(repo.odb(), parent_tree.as_ref(), Some(&tree), "")?
1460 }
1461 } else {
1462 diff_tree_path_change_count(repo.odb(), parent_tree.as_ref(), Some(&tree), "")?
1463 }
1464 } else {
1465 diff_tree_path_change_count(repo.odb(), parent_tree.as_ref(), Some(&tree), "")?
1466 };
1467
1468 if short_name.is_none() {
1469 update_detached_head_for_commit_on_store(
1470 repo.refs(),
1471 expected_old,
1472 oid,
1473 &committer,
1474 &reflog_msg,
1475 config.as_ref(),
1476 )?;
1477 } else {
1478 update_branch_for_commit_on_store(
1479 repo.refs(),
1480 &BranchCommitRefUpdate {
1481 branch_ref: &refname,
1482 expected_old,
1483 new_oid: oid,
1484 identity: &committer,
1485 reflog_message: &reflog_msg,
1486 },
1487 config.as_ref(),
1488 )?;
1489 }
1490
1491 if concluding_merge {
1492 let _ = finish_merge_state(repo)?;
1493 }
1494 clear_squash_commit_state(repo.git_dir())?;
1495
1496 if let Some(LockedIndex {
1497 index: mut separate,
1498 mut lock,
1499 }) = persist_index
1500 {
1501 let persist = separate.as_mut().unwrap_or(index);
1502 if !req.assume_worktree_synced {
1503 if let Some(work_tree) = repo.work_tree() {
1504 let rules = WorktreeRules::from_repository(repo, persist).ok();
1505 let rules_arc = rules.map(|r| Arc::new(Mutex::new(r)));
1506 let _ = refresh_index_stat_content_verified_with_rules(
1507 repo.odb(),
1508 repo.git_dir(),
1509 persist,
1510 work_tree,
1511 None,
1512 Some(config.as_ref()),
1513 None,
1514 rules_arc.as_ref(),
1515 )?;
1516 }
1517 }
1518 repo.write_index_with_lock(&mut lock, persist)?;
1519 }
1520
1521 let post_commit_env = CommitHookEnv {
1522 index_file: Some(
1523 temp_hook_index_path
1524 .as_deref()
1525 .unwrap_or(index_path.as_path()),
1526 ),
1527 git_editor: Some(":"),
1528 git_prefix: None,
1529 extra_env: &[],
1530 };
1531 if run_advisory_hook(
1532 repo,
1533 HookName::PostCommit,
1534 &[],
1535 None,
1536 Some(&post_commit_env),
1537 ) {
1538 hooks_run.push(HookName::PostCommit);
1539 }
1540 if let Some(old_tip) = replaced_tip.filter(|_| req.post_rewrite.enabled()) {
1541 if run_post_rewrite(repo, RewriteKind::Amend, &[(old_tip, oid)]) {
1542 hooks_run.push(HookName::PostRewrite);
1543 }
1544 }
1545
1546 let stored_change_id = change_id(&commit_data);
1547 Ok(CommitOutcome {
1548 oid,
1549 branch: short_name,
1550 parent,
1551 parents: commit_data.parents,
1552 author: author_info,
1553 hooks_run,
1554 changes,
1555 change_id: stored_change_id,
1556 files: commit_files,
1557 })
1558}
1559
1560pub(crate) fn stamp_change_id_on_new_commit(
1569 repo: &Repository,
1570 policy: &ChangeIdPolicy,
1571 data: &mut CommitData,
1572) -> Result<()> {
1573 if let Some(id) = policy
1574 .id_for_new_commit(
1575 data,
1576 repo.odb().hash_algo(),
1577 repo.change_id_generator().as_ref(),
1578 )
1579 .map_err(|e| Error::Message(format!("change id: {e}")))?
1580 {
1581 set_change_id(data, &id);
1582 }
1583 Ok(())
1584}
1585
1586pub fn prepare_commit_bytes(
1597 repo: &Repository,
1598 config: &ConfigSet,
1599 data: &CommitData,
1600 sign_override: Option<bool>,
1601) -> Result<Vec<u8>> {
1602 let unsigned = serialize_commit(data);
1603 if !should_sign_commit(config, sign_override)? {
1604 return Ok(unsigned);
1605 }
1606 let gpg = GpgConfig::from_config(config)?.with_command_runner(repo.command_runner());
1607 sign_serialized_commit(
1608 &gpg,
1609 &unsigned,
1610 &data.committer,
1611 None,
1612 repo.odb().hash_algo(),
1613 )
1614}
1615
1616pub fn write_commit_object(
1624 repo: &Repository,
1625 data: &CommitData,
1626 sign_override: Option<bool>,
1627) -> Result<ObjectId> {
1628 let config = repo.config()?;
1629 let bytes = prepare_commit_bytes(repo, config.as_ref(), data, sign_override)?;
1630 repo.odb().write(ObjectKind::Commit, &bytes)
1631}
1632
1633fn empty_commit_error(repo: &Repository, index: &mut Index, req: &CommitRequest) -> Error {
1634 if req.index_only {
1635 let mut worktree_index = match repo.load_index() {
1636 Ok(real) => real,
1637 Err(_) => index.clone(),
1638 };
1639 if let Ok((unstaged, untracked)) =
1640 crate::porcelain::status::count_unstaged_and_untracked_paths(repo, &mut worktree_index)
1641 {
1642 if unstaged + untracked > 0 {
1643 return Error::NothingStagedToCommit {
1644 unstaged,
1645 untracked,
1646 };
1647 }
1648 }
1649 }
1650 Error::NothingToCommit
1651}
1652
1653fn index_staged_tree_matches(
1655 odb: &crate::odb::Odb,
1656 index: &crate::index::Index,
1657 tree_oid: &ObjectId,
1658) -> bool {
1659 if index.cache_tree_root == Some(*tree_oid)
1660 && cache_tree_extension_valid(index.cache_tree.as_ref())
1661 {
1662 return true;
1663 }
1664 crate::diff::diff_index_to_tree(odb, index, Some(tree_oid), false)
1665 .map(|d| d.is_empty())
1666 .unwrap_or(false)
1667}
1668
1669fn commit_tree_oid(repo: &Repository, commit_oid: &ObjectId) -> Result<ObjectId> {
1670 let obj = repo.odb().read(commit_oid)?;
1671 if obj.kind != ObjectKind::Commit {
1672 return Err(Error::CorruptObject(format!(
1673 "expected commit, got {}",
1674 obj.kind.as_str()
1675 )));
1676 }
1677 Ok(parse_commit(&obj.data)?.tree)
1678}
1679
1680fn commit_subject(message: &str) -> &str {
1681 message.split('\n').next().unwrap_or("").trim_end()
1682}
1683
1684fn commit_editmsg_hook_argument(repo: &Repository, editmsg_path: &Path) -> String {
1686 if let Some(wt) = repo.work_tree() {
1687 if let Ok(rel) = editmsg_path.strip_prefix(wt) {
1688 let s = rel.to_string_lossy();
1689 if !s.is_empty() {
1690 return s.replace('\\', "/");
1691 }
1692 }
1693 }
1694 editmsg_path.to_string_lossy().into_owned()
1695}
1696
1697#[cfg(test)]
1698mod tests {
1699 #![allow(clippy::expect_used, clippy::unwrap_used)]
1700
1701 use crate::environment::RepositoryOptions;
1702
1703 use super::*;
1704 use crate::index::{entry_from_stat, Index, MODE_REGULAR};
1705 use crate::objects::ObjectKind;
1706 use crate::pathspec::ResolvedPathspecs;
1707 use crate::porcelain::add::{stage, StageOptions};
1708 use crate::progress::NullProgress;
1709 use crate::reflog::read_reflog;
1710 use crate::refs::{resolve_ref, set_test_inject_reflog_fail};
1711 use crate::repo::set_test_inject_index_write_fail;
1712 use crate::write_tree::{cache_tree_fully_valid, verify_cache_tree};
1713 use std::fs;
1714 use std::path::Path;
1715 use std::process::Command;
1716 use std::str::FromStr;
1717 use tempfile::TempDir;
1718
1719 fn init_repo(root: &Path) -> Repository {
1720 let git = root.join(".git");
1721 fs::create_dir_all(git.join("objects")).unwrap();
1722 fs::create_dir_all(git.join("refs/heads")).unwrap();
1723 fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
1724 fs::write(
1725 git.join("config"),
1726 "[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tlogAllRefUpdates = true\n",
1727 )
1728 .unwrap();
1729 Repository::open(&git, &RepositoryOptions::empty().with_work_tree(root)).unwrap()
1730 }
1731
1732 fn ident() -> String {
1733 "Test User <t@example.com> 1 +0000".to_owned()
1734 }
1735
1736 fn stage_file_in_index(repo: &Repository, index: &mut Index, rel: &str, contents: &[u8]) {
1737 let wt = repo.work_tree().unwrap();
1738 let abs = wt.join(rel);
1739 if let Some(parent) = abs.parent() {
1740 fs::create_dir_all(parent).unwrap();
1741 }
1742 fs::write(&abs, contents).unwrap();
1743 let oid = repo.odb().write(ObjectKind::Blob, contents).unwrap();
1744 let entry = entry_from_stat(&abs, rel.as_bytes(), oid, MODE_REGULAR).unwrap();
1745 index.add_or_replace(entry);
1746 }
1747
1748 fn write_index(repo: &Repository, index: &mut Index) {
1749 index.sort();
1750 repo.write_index(index).unwrap();
1751 }
1752
1753 fn commit_req(message: &str) -> CommitRequest {
1754 let ident = ident();
1755 CommitRequest {
1756 message: message.to_owned(),
1757 author: Some(ident.parse().expect("signature")),
1758 committer: Some(ident.parse().expect("signature")),
1759 ..Default::default()
1760 }
1761 }
1762
1763 fn repo_snapshot(repo: &Repository) -> (Vec<u8>, String, usize, bool, usize) {
1764 let index_path = repo.git_dir().join("index");
1765 let index_bytes = fs::read(&index_path).unwrap_or_default();
1766 let head = fs::read_to_string(repo.git_dir().join("HEAD")).unwrap_or_default();
1767 let reflog_len = read_reflog(repo.git_dir(), "refs/heads/main")
1768 .map(|e| e.len())
1769 .unwrap_or(0);
1770 let editmsg = repo.git_dir().join("COMMIT_EDITMSG").exists();
1771 let mut objects = 0usize;
1772 if let Ok(entries) = fs::read_dir(repo.git_dir().join("objects")) {
1773 for entry in entries.flatten() {
1774 let path = entry.path();
1775 if path.is_dir() && path.file_name().is_some_and(|n| n.len() == 2) {
1776 objects += fs::read_dir(path).map(|d| d.count()).unwrap_or(0);
1777 } else if path.is_file() {
1778 objects += 1;
1779 }
1780 }
1781 }
1782 (index_bytes, head, reflog_len, editmsg, objects)
1783 }
1784
1785 fn dry_run_req(message: &str) -> CommitRequest {
1786 let mut req = commit_req(message);
1787 req.dry_run = true;
1788 req
1789 }
1790
1791 #[test]
1792 fn create_commit_initial() {
1793 let tmp = TempDir::new().unwrap();
1794 let root = tmp.path();
1795 let repo = init_repo(root);
1796 fs::write(root.join("README"), b"hello\n").unwrap();
1797 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1798
1799 let outcome = create_commit(&repo, &commit_req("initial"), &mut NullProgress).unwrap();
1800 assert!(outcome.parent.is_none());
1801 assert_eq!(outcome.branch.as_deref(), Some("main"));
1802 assert!(!outcome.is_detached());
1803 assert_eq!(outcome.changes, 1);
1804 assert_eq!(
1805 resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
1806 outcome.oid
1807 );
1808 }
1809
1810 #[test]
1811 fn create_commit_rejects_empty_initial_index() {
1812 let tmp = TempDir::new().unwrap();
1813 let repo = init_repo(tmp.path());
1814 let err = create_commit(&repo, &commit_req("empty"), &mut NullProgress).unwrap_err();
1815 assert!(matches!(err, Error::NothingToCommit));
1816 assert!(resolve_ref(&repo.git_dir(), "refs/heads/main").is_err());
1817 }
1818
1819 #[test]
1820 fn create_commit_normal() {
1821 let tmp = TempDir::new().unwrap();
1822 let root = tmp.path();
1823 let repo = init_repo(root);
1824 fs::write(root.join("a.txt"), b"1\n").unwrap();
1825 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1826 create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
1827
1828 fs::write(root.join("a.txt"), b"2\n").unwrap();
1829 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1830 let outcome = create_commit(&repo, &commit_req("second"), &mut NullProgress).unwrap();
1831 assert_eq!(outcome.changes, 1);
1832 assert!(outcome.parent.is_some());
1833 }
1834
1835 #[test]
1836 fn create_commit_nothing_to_commit() {
1837 let tmp = TempDir::new().unwrap();
1838 let root = tmp.path();
1839 let repo = init_repo(root);
1840 fs::write(root.join("a.txt"), b"1\n").unwrap();
1841 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1842 create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
1843
1844 let err = create_commit(&repo, &commit_req("noop"), &mut NullProgress).unwrap_err();
1845 assert!(matches!(err, Error::NothingToCommit));
1846 }
1847
1848 #[test]
1849 fn commit_worktree_nothing_to_commit_persists_staged_deletion() {
1850 let tmp = TempDir::new().unwrap();
1851 let root = tmp.path();
1852 let repo = init_repo(root);
1853 fs::write(root.join("a.txt"), b"1\n").unwrap();
1854 commit_worktree(&repo, &commit_req("first"), &mut NullProgress).unwrap();
1855
1856 let mut index = repo.load_index().unwrap();
1857 stage_file_in_index(&repo, &mut index, "n.txt", b"staged\n");
1858 write_index(&repo, &mut index);
1859 fs::remove_file(root.join("n.txt")).unwrap();
1860
1861 let err = commit_worktree(&repo, &dry_run_req("noop"), &mut NullProgress).unwrap_err();
1862 assert!(matches!(err, Error::NothingToCommit));
1863 assert!(repo.load_index().unwrap().get(b"n.txt", 0).is_some());
1864
1865 let err = commit_worktree(&repo, &commit_req("noop"), &mut NullProgress).unwrap_err();
1866 assert!(matches!(err, Error::NothingToCommit));
1867 let index = repo.load_index().unwrap();
1868 assert!(index.get(b"n.txt", 0).is_none());
1869 assert!(index.get(b"a.txt", 0).is_some());
1870 }
1871
1872 #[test]
1873 fn create_commit_detached_head_advances_head() {
1874 let tmp = TempDir::new().unwrap();
1875 let root = tmp.path();
1876 let repo = init_repo(root);
1877 fs::write(root.join("a.txt"), b"1\n").unwrap();
1878 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1879 let first = create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
1880 fs::write(repo.git_dir().join("HEAD"), first.oid.to_hex()).unwrap();
1881
1882 fs::write(root.join("b.txt"), b"2\n").unwrap();
1883 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1884 let second = create_commit(&repo, &commit_req("second"), &mut NullProgress).unwrap();
1885 assert_eq!(second.branch, None);
1886 assert!(second.is_detached());
1887 assert_eq!(second.parent, Some(first.oid));
1888 let head = fs::read_to_string(repo.git_dir().join("HEAD")).unwrap();
1889 assert_eq!(head.trim(), second.oid.to_hex());
1890 let main_tip = crate::refs::resolve_ref(repo.git_dir(), "refs/heads/main").unwrap();
1891 assert_eq!(main_tip, first.oid, "branch tip must stay unchanged");
1892 }
1893
1894 #[test]
1895 fn create_commit_updates_reflogs() {
1896 let tmp = TempDir::new().unwrap();
1897 let root = tmp.path();
1898 let repo = init_repo(root);
1899 fs::write(root.join("a.txt"), b"1\n").unwrap();
1900 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1901 let outcome = create_commit(&repo, &commit_req("subject line"), &mut NullProgress).unwrap();
1902
1903 let branch_log = read_reflog(&repo.git_dir(), "refs/heads/main").unwrap();
1904 assert_eq!(branch_log.len(), 1);
1905 assert_eq!(branch_log[0].new_oid, outcome.oid);
1906 assert_eq!(branch_log[0].message, "commit (initial): subject line");
1907
1908 let head_log = read_reflog(&repo.git_dir(), "HEAD").unwrap();
1909 assert_eq!(head_log.len(), 1);
1910 assert_eq!(head_log[0].new_oid, outcome.oid);
1911 }
1912
1913 #[test]
1914 fn create_commit_index_write_failure_does_not_advance_branch() {
1915 let tmp = TempDir::new().unwrap();
1916 let root = tmp.path();
1917 let repo = init_repo(root);
1918 fs::write(root.join("a.txt"), b"1\n").unwrap();
1919 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1920
1921 set_test_inject_index_write_fail(true);
1922 let err = create_commit(&repo, &commit_req("fail"), &mut NullProgress).unwrap_err();
1923 set_test_inject_index_write_fail(false);
1924 assert!(!matches!(err, Error::NothingToCommit));
1925
1926 assert!(
1927 resolve_ref(&repo.git_dir(), "refs/heads/main").is_err(),
1928 "branch must not advance when index write fails"
1929 );
1930 }
1931
1932 #[test]
1933 fn create_commit_rolls_back_branch_when_head_reflog_fails() {
1934 let tmp = TempDir::new().unwrap();
1935 let root = tmp.path();
1936 let repo = init_repo(root);
1937 fs::write(root.join("a.txt"), b"1\n").unwrap();
1938 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1939
1940 set_test_inject_reflog_fail(Some("HEAD"));
1941 let err = create_commit(&repo, &commit_req("fail"), &mut NullProgress).unwrap_err();
1942 set_test_inject_reflog_fail(None);
1943 assert!(!matches!(err, Error::NothingToCommit));
1944
1945 assert!(
1946 resolve_ref(&repo.git_dir(), "refs/heads/main").is_err(),
1947 "branch must not advance when HEAD reflog fails"
1948 );
1949 assert!(read_reflog(&repo.git_dir(), "refs/heads/main")
1950 .unwrap()
1951 .is_empty());
1952 assert!(read_reflog(&repo.git_dir(), "HEAD").unwrap().is_empty());
1953 }
1954
1955 #[test]
1956 fn create_commit_leaves_valid_cache_tree() {
1957 let tmp = TempDir::new().unwrap();
1958 let root = tmp.path();
1959 let repo = init_repo(root);
1960 let mut index = Index::new();
1961 stage_file_in_index(&repo, &mut index, "dir/one.txt", b"1\n");
1962 stage_file_in_index(&repo, &mut index, "dir/two.txt", b"2\n");
1963 write_index(&repo, &mut index);
1964
1965 let outcome = create_commit(&repo, &commit_req("tree"), &mut NullProgress).unwrap();
1966
1967 let index = repo.load_index().unwrap();
1968 verify_cache_tree(&index).expect("cache-tree valid");
1969 assert!(cache_tree_fully_valid(
1970 &repo.odb(),
1971 index.cache_tree.as_ref()
1972 ));
1973 let commit_obj = repo.odb().read(&outcome.oid).unwrap();
1974 let tree = parse_commit(&commit_obj.data).unwrap().tree;
1975 assert_eq!(index.cache_tree_root, Some(tree));
1976 }
1977
1978 #[test]
1979 fn create_commit_amend_same_tree() {
1980 let tmp = TempDir::new().unwrap();
1981 let root = tmp.path();
1982 let repo = init_repo(root);
1983 fs::write(root.join("a.txt"), b"1\n").unwrap();
1984 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1985 create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
1986 fs::write(root.join("a.txt"), b"2\n").unwrap();
1987 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
1988 create_commit(&repo, &commit_req("second"), &mut NullProgress).unwrap();
1989
1990 let mut req = commit_req("");
1991 req.amend = true;
1992 let tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
1993 let outcome = create_commit(&repo, &req, &mut NullProgress).unwrap();
1994 assert_eq!(
1995 outcome.oid, tip,
1996 "same tree and message with fixed committer yields the same object id"
1997 );
1998 assert!(outcome.parent.is_some());
1999
2000 let branch_log = read_reflog(&repo.git_dir(), "refs/heads/main").unwrap();
2001 assert_eq!(branch_log.len(), 3);
2002 assert_eq!(branch_log[2].message, "commit (amend): second");
2003 }
2004
2005 #[test]
2006 fn create_commit_amend_preserves_message_bytes() {
2007 let tmp = TempDir::new().unwrap();
2008 let root = tmp.path();
2009 let repo = init_repo(root);
2010 fs::write(root.join("a.txt"), b"a\n").unwrap();
2011 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2012 create_commit(&repo, &commit_req("oneline subject"), &mut NullProgress).unwrap();
2013 let baseline = {
2014 let oid = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
2015 parse_commit(&repo.odb().read(&oid).unwrap().data)
2016 .unwrap()
2017 .message
2018 };
2019
2020 for extra in 1..=2 {
2021 fs::write(root.join("a.txt"), format!("a\n{extra}\n")).unwrap();
2022 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2023 let mut req = commit_req("");
2024 req.amend = true;
2025 create_commit(&repo, &req, &mut NullProgress).unwrap();
2026 let oid = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
2027 let message = parse_commit(&repo.odb().read(&oid).unwrap().data)
2028 .unwrap()
2029 .message;
2030 assert_eq!(message, baseline, "after amend {extra}");
2031 }
2032 }
2033
2034 #[test]
2035 fn create_commit_amend_unborn() {
2036 let tmp = TempDir::new().unwrap();
2037 let repo = init_repo(tmp.path());
2038 fs::write(tmp.path().join("a.txt"), b"1\n").unwrap();
2039 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2040 let mut req = commit_req("nope");
2041 req.amend = true;
2042 let err = create_commit(&repo, &req, &mut NullProgress).unwrap_err();
2043 assert!(matches!(err, Error::AmendUnborn));
2044 }
2045
2046 #[test]
2047 fn create_commit_allow_empty() {
2048 let tmp = TempDir::new().unwrap();
2049 let root = tmp.path();
2050 let repo = init_repo(root);
2051 fs::write(root.join("a.txt"), b"1\n").unwrap();
2052 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2053 create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
2054
2055 let mut req = commit_req("empty");
2056 req.allow_empty = true;
2057 let parent = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
2058 let outcome = create_commit(&repo, &req, &mut NullProgress).unwrap();
2059 assert_eq!(outcome.changes, 0);
2060 assert_eq!(outcome.parent, Some(parent));
2061 assert_ne!(outcome.oid, parent);
2062 }
2063
2064 #[test]
2065 fn scope_staged_commits_index_only() {
2066 let tmp = TempDir::new().unwrap();
2067 let root = tmp.path();
2068 let repo = init_repo(root);
2069 fs::write(root.join("a.txt"), b"1\n").unwrap();
2070 fs::write(root.join("b.txt"), b"1\n").unwrap();
2071 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2072 create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
2073
2074 fs::write(root.join("a.txt"), b"2\n").unwrap();
2075 fs::write(root.join("b.txt"), b"2\n").unwrap();
2076 let mut index = repo.load_index().unwrap();
2077 stage_file_in_index(&repo, &mut index, "a.txt", b"2\n");
2078 write_index(&repo, &mut index);
2079
2080 let outcome = commit_with_scope(
2081 &repo,
2082 &commit_req("only a"),
2083 CommitScope::Staged,
2084 &mut NullProgress,
2085 )
2086 .unwrap();
2087 assert_eq!(outcome.changes, 1);
2088 let index = repo.load_index().unwrap();
2089 let b_oid = index.get(b"b.txt", 0).unwrap().oid;
2090 let b_blob = repo.odb().read(&b_oid).unwrap();
2091 assert_eq!(b_blob.data, b"1\n", "unstaged paths stay in the index");
2092 }
2093
2094 #[test]
2095 fn scope_paths_leaves_other_staged_entries() {
2096 let tmp = TempDir::new().unwrap();
2097 let root = tmp.path();
2098 let repo = init_repo(root);
2099 fs::write(root.join("file"), b"1\n").unwrap();
2100 fs::write(root.join("baz"), b"1\n").unwrap();
2101 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2102 create_commit(&repo, &commit_req("initial"), &mut NullProgress).unwrap();
2103
2104 fs::write(root.join("file"), b"2\n").unwrap();
2105 fs::write(root.join("baz"), b"2\n").unwrap();
2106 stage(
2107 &repo,
2108 &StageOptions {
2109 pathspecs: vec!["baz".into()],
2110 ..StageOptions::default()
2111 },
2112 &mut NullProgress,
2113 )
2114 .unwrap();
2115
2116 let paths = ResolvedPathspecs {
2117 specs: vec!["file".into()],
2118 sources: vec!["file".into()],
2119 scoped: true,
2120 };
2121 commit_with_scope(
2122 &repo,
2123 &commit_req("file only"),
2124 CommitScope::Paths(paths),
2125 &mut NullProgress,
2126 )
2127 .unwrap();
2128
2129 let index = repo.load_index().unwrap();
2130 let baz_oid = index.get(b"baz", 0).unwrap().oid;
2131 assert_eq!(repo.odb().read(&baz_oid).unwrap().data, b"2\n");
2132 }
2133
2134 #[test]
2135 fn scope_paths_deleted_and_untracked() {
2136 let tmp = TempDir::new().unwrap();
2137 let root = tmp.path();
2138 let repo = init_repo(root);
2139 fs::write(root.join("tracked.txt"), b"1\n").unwrap();
2140 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2141 create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
2142
2143 fs::write(root.join("new.txt"), b"new\n").unwrap();
2144 fs::remove_file(root.join("tracked.txt")).unwrap();
2145
2146 let paths = ResolvedPathspecs {
2147 specs: vec!["new.txt".into(), "tracked.txt".into()],
2148 sources: vec!["new.txt".into(), "tracked.txt".into()],
2149 scoped: true,
2150 };
2151 commit_with_scope(
2152 &repo,
2153 &commit_req("add and remove"),
2154 CommitScope::Paths(paths),
2155 &mut NullProgress,
2156 )
2157 .unwrap();
2158
2159 let index = repo.load_index().unwrap();
2160 assert!(index.get(b"tracked.txt", 0).is_none());
2161 assert!(index.get(b"new.txt", 0).is_some());
2162 }
2163
2164 #[test]
2165 fn scope_paths_nested_dirs_round_trip_with_git() {
2166 use std::process::Command;
2167
2168 let tmp = TempDir::new().unwrap();
2169 let root = tmp.path();
2170 let git = |args: &[&str]| -> String {
2171 let out = Command::new("git")
2172 .env("GIT_CONFIG_NOSYSTEM", "1")
2173 .env("GIT_CONFIG_GLOBAL", "/dev/null")
2174 .env("GIT_AUTHOR_NAME", "Test")
2175 .env("GIT_AUTHOR_EMAIL", "test@example.com")
2176 .env("GIT_COMMITTER_NAME", "Test")
2177 .env("GIT_COMMITTER_EMAIL", "test@example.com")
2178 .current_dir(root)
2179 .args(args)
2180 .output()
2181 .unwrap();
2182 assert!(
2183 out.status.success(),
2184 "git {args:?}: {}",
2185 String::from_utf8_lossy(&out.stderr)
2186 );
2187 String::from_utf8(out.stdout).unwrap()
2188 };
2189 git(&["init", "-q", "-b", "main"]);
2190 for (rel, body) in [
2191 ("a/x.txt", "x1\n"),
2192 ("a/y.txt", "y1\n"),
2193 ("b/c/z.txt", "z1\n"),
2194 ("b/w.txt", "w1\n"),
2195 ("d/e.txt", "e1\n"),
2196 ("top.txt", "t1\n"),
2197 ] {
2198 fs::create_dir_all(root.join(rel).parent().unwrap()).unwrap();
2199 fs::write(root.join(rel), body).unwrap();
2200 }
2201 git(&["add", "-A"]);
2202 git(&["commit", "-qm", "init"]);
2203
2204 fs::write(root.join("a/x.txt"), b"x2\n").unwrap();
2205 fs::write(root.join("b/c/z.txt"), b"z2\n").unwrap();
2206 fs::write(root.join("b/w.txt"), b"w2\n").unwrap();
2207 fs::write(root.join("top.txt"), b"t2\n").unwrap();
2208 git(&["add", "b/w.txt"]);
2209
2210 let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).unwrap();
2211 let specs = vec!["a/x.txt".to_owned(), "b/c/z.txt".to_owned()];
2212 commit_with_scope(
2213 &repo,
2214 &commit_req("partial"),
2215 CommitScope::Paths(ResolvedPathspecs {
2216 specs: specs.clone(),
2217 sources: specs,
2218 scoped: true,
2219 }),
2220 &mut NullProgress,
2221 )
2222 .unwrap();
2223
2224 git(&["fsck", "--strict", "--no-progress"]);
2225 assert_eq!(
2226 git(&["diff", "--name-only", "HEAD~1", "HEAD"]),
2227 "a/x.txt\nb/c/z.txt\n"
2228 );
2229 assert_eq!(git(&["show", "HEAD:a/x.txt"]), "x2\n");
2230 assert_eq!(git(&["show", "HEAD:b/c/z.txt"]), "z2\n");
2231 assert_eq!(git(&["show", "HEAD:b/w.txt"]), "w1\n");
2232 assert_eq!(
2233 git(&["rev-parse", "HEAD:d"]),
2234 git(&["rev-parse", "HEAD~1:d"])
2235 );
2236 assert_eq!(git(&["diff", "--cached", "--name-only"]), "b/w.txt\n");
2237 assert_eq!(git(&["diff", "--name-only"]), "top.txt\n");
2238 }
2239
2240 #[test]
2241 fn scope_paths_no_match_errors() {
2242 let tmp = TempDir::new().unwrap();
2243 let root = tmp.path();
2244 let repo = init_repo(root);
2245 fs::write(root.join("a.txt"), b"1\n").unwrap();
2246 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2247 create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
2248
2249 let paths = ResolvedPathspecs {
2250 specs: vec!["missing.txt".into()],
2251 sources: vec!["missing.txt".into()],
2252 scoped: true,
2253 };
2254 let err = commit_with_scope(
2255 &repo,
2256 &commit_req("nope"),
2257 CommitScope::Paths(paths),
2258 &mut NullProgress,
2259 )
2260 .unwrap_err();
2261 assert!(matches!(err, Error::PathspecNoMatch { .. }));
2262 }
2263
2264 #[test]
2265 fn scope_paths_refused_during_merge() {
2266 let tmp = TempDir::new().unwrap();
2267 let root = tmp.path();
2268 let repo = init_repo(root);
2269 fs::write(root.join("a.txt"), b"1\n").unwrap();
2270 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2271 create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
2272 let tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
2273 fs::write(repo.git_dir().join("MERGE_HEAD"), format!("{tip}\n")).unwrap();
2274
2275 let paths = ResolvedPathspecs {
2276 specs: vec!["a.txt".into()],
2277 sources: vec!["a.txt".into()],
2278 scoped: true,
2279 };
2280 let err = commit_with_scope(
2281 &repo,
2282 &commit_req("partial"),
2283 CommitScope::Paths(paths),
2284 &mut NullProgress,
2285 )
2286 .unwrap_err();
2287 assert!(matches!(err, Error::PartialCommitDuringMerge));
2288 }
2289
2290 fn system_git(root: &Path, args: &[&str]) -> String {
2291 let out = Command::new("git")
2292 .env("GIT_CONFIG_NOSYSTEM", "1")
2293 .env("GIT_CONFIG_GLOBAL", "/dev/null")
2294 .env("GIT_AUTHOR_NAME", "Test")
2295 .env("GIT_AUTHOR_EMAIL", "test@example.com")
2296 .env("GIT_COMMITTER_NAME", "Test")
2297 .env("GIT_COMMITTER_EMAIL", "test@example.com")
2298 .current_dir(root)
2299 .args(args)
2300 .output()
2301 .unwrap();
2302 assert!(
2303 out.status.success(),
2304 "git {args:?}: {}",
2305 String::from_utf8_lossy(&out.stderr)
2306 );
2307 String::from_utf8(out.stdout).unwrap()
2308 }
2309
2310 fn git_repo_with_modified_file(root: &Path) -> Repository {
2312 system_git(root, &["init", "-q", "-b", "main"]);
2313 fs::write(root.join("f1.txt"), b"a\n").unwrap();
2314 system_git(root, &["add", "."]);
2315 system_git(root, &["commit", "-qm", "init"]);
2316 fs::write(root.join("f1.txt"), b"a\nb\n").unwrap();
2317 Repository::discover(Some(root), &RepositoryOptions::empty()).unwrap()
2318 }
2319
2320 fn f1_paths() -> ResolvedPathspecs {
2321 ResolvedPathspecs {
2322 specs: vec!["f1.txt".into()],
2323 sources: vec!["f1.txt".into()],
2324 scoped: true,
2325 }
2326 }
2327
2328 fn assert_commit_refused_by_foreign_lock(
2333 root: &Path,
2334 repo: &Repository,
2335 scope: CommitScope,
2336 objects_untouched: bool,
2337 ) {
2338 let lock_path = repo.git_dir().join("index.lock");
2339 fs::write(&lock_path, b"foreign\n").unwrap();
2340 let before = repo_snapshot(repo);
2341 let tip_before = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
2342
2343 let err = commit_with_scope(repo, &commit_req("x"), scope, &mut NullProgress).unwrap_err();
2344 match &err {
2345 Error::LockHeld {
2346 path: reported,
2347 holder,
2348 } => {
2349 assert_eq!(reported, &lock_path);
2350 assert_eq!(*holder, None);
2351 }
2352 other => panic!("expected Error::LockHeld, got {other:?}"),
2353 }
2354 assert!(err.to_string().contains("index.lock"), "{err}");
2355
2356 let (index, head, reflog, _editmsg, objects) = repo_snapshot(repo);
2357 assert_eq!(index, before.0, "index bytes must be unchanged");
2358 assert_eq!(head, before.1, "HEAD must be unchanged");
2359 assert_eq!(reflog, before.2, "no reflog entry may be written");
2360 if objects_untouched {
2361 assert_eq!(objects, before.4, "no objects may be written");
2362 }
2363 assert_eq!(
2364 resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
2365 tip_before
2366 );
2367 assert_eq!(
2368 fs::read(&lock_path).unwrap(),
2369 b"foreign\n",
2370 "someone else's lock must be left alone"
2371 );
2372 let leftovers: Vec<_> = fs::read_dir(repo.git_dir())
2373 .unwrap()
2374 .flatten()
2375 .filter(|e| e.file_name().to_string_lossy().starts_with("next-index-"))
2376 .collect();
2377 assert!(leftovers.is_empty(), "temporary hook index left behind");
2378
2379 fs::remove_file(&lock_path).unwrap();
2380 assert_eq!(system_git(root, &["status", "--porcelain"]), " M f1.txt\n");
2381 system_git(root, &["fsck", "--strict", "--no-progress"]);
2382 }
2383
2384 #[test]
2385 fn scope_paths_with_held_index_lock_creates_nothing() {
2386 let tmp = TempDir::new().unwrap();
2387 let root = tmp.path();
2388 let repo = git_repo_with_modified_file(root);
2389 assert_commit_refused_by_foreign_lock(root, &repo, CommitScope::Paths(f1_paths()), true);
2390
2391 let outcome = commit_with_scope(
2392 &repo,
2393 &commit_req("x"),
2394 CommitScope::Paths(f1_paths()),
2395 &mut NullProgress,
2396 )
2397 .unwrap();
2398 assert!(!repo.git_dir().join("index.lock").exists());
2399 assert_eq!(
2400 system_git(root, &["rev-parse", "HEAD"]).trim(),
2401 outcome.oid.to_hex()
2402 );
2403 assert_eq!(system_git(root, &["status", "--porcelain"]), "");
2404 system_git(root, &["fsck", "--strict", "--no-progress"]);
2405 }
2406
2407 #[test]
2408 fn scope_staged_and_auto_stage_with_held_index_lock_create_nothing() {
2409 let tmp = TempDir::new().unwrap();
2410 let root = tmp.path();
2411 let repo = git_repo_with_modified_file(root);
2412 assert_commit_refused_by_foreign_lock(root, &repo, CommitScope::AutoStage, false);
2413
2414 system_git(root, &["add", "f1.txt"]);
2415 let lock_path = repo.git_dir().join("index.lock");
2416 fs::write(&lock_path, b"foreign\n").unwrap();
2417 let tip_before = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
2418 let err = commit_with_scope(
2419 &repo,
2420 &commit_req("x"),
2421 CommitScope::Staged,
2422 &mut NullProgress,
2423 )
2424 .unwrap_err();
2425 assert!(
2426 matches!(&err, Error::LockHeld { path, .. } if *path == repo.git_dir().join("index.lock")),
2427 "{err:?}"
2428 );
2429 assert_eq!(
2430 resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
2431 tip_before
2432 );
2433 assert_eq!(fs::read(&lock_path).unwrap(), b"foreign\n");
2434 }
2435
2436 #[test]
2437 fn scope_patch_with_held_index_lock_creates_nothing() {
2438 use crate::diff::hunk_id::{hunk_ids_with_duplicate_peers, DiffSide};
2439 use crate::diff::{unstaged_file_diffs_with_peer_hunks, DiffOptions};
2440 use crate::porcelain::hunk_select::HunkSelection;
2441
2442 let tmp = TempDir::new().unwrap();
2443 let root = tmp.path();
2444 let repo = git_repo_with_modified_file(root);
2445 let (files, peers) =
2446 unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
2447 let ids = hunk_ids_with_duplicate_peers(
2448 DiffSide::Unstaged,
2449 "f1.txt",
2450 &files[0].hunks,
2451 peers.get("f1.txt").map(|v| v.as_slice()).unwrap_or(&[]),
2452 );
2453 let scope = CommitScope::Patch {
2454 input: CommitPatchInput::Hunks(HunkSelection {
2455 ids: vec![ids[0].clone()],
2456 ranges: Vec::new(),
2457 }),
2458 pathspecs: Vec::new(),
2459 };
2460 assert_commit_refused_by_foreign_lock(root, &repo, scope.clone(), true);
2461
2462 let outcome = commit_with_scope(&repo, &commit_req("x"), scope, &mut NullProgress).unwrap();
2463 assert!(!repo.git_dir().join("index.lock").exists());
2464 assert_eq!(
2465 system_git(root, &["rev-parse", "HEAD"]).trim(),
2466 outcome.oid.to_hex()
2467 );
2468 assert_eq!(system_git(root, &["status", "--porcelain"]), "");
2469 let index = repo.load_index().unwrap();
2470 verify_cache_tree(&index).expect("cache-tree valid");
2471 assert!(cache_tree_fully_valid(
2472 &repo.odb(),
2473 index.cache_tree.as_ref()
2474 ));
2475 let tree = parse_commit(&repo.odb().read(&outcome.oid).unwrap().data)
2476 .unwrap()
2477 .tree;
2478 assert_eq!(index.cache_tree_root, Some(tree));
2479 system_git(root, &["fsck", "--strict", "--no-progress"]);
2480 }
2481
2482 #[test]
2483 fn scope_paths_split_index_round_trips_with_git() {
2484 let tmp = TempDir::new().unwrap();
2485 let root = tmp.path();
2486 system_git(root, &["init", "-q", "-b", "main"]);
2487 system_git(root, &["config", "core.splitIndex", "true"]);
2488 system_git(root, &["config", "splitIndex.maxPercentChange", "100"]);
2489 for name in ["a.txt", "b.txt", "c.txt"] {
2490 fs::write(root.join(name), b"1\n").unwrap();
2491 }
2492 system_git(root, &["add", "."]);
2493 system_git(root, &["commit", "-qm", "init"]);
2494 fs::write(root.join("a.txt"), b"2\n").unwrap();
2495 fs::write(root.join("b.txt"), b"2\n").unwrap();
2496 system_git(root, &["add", "b.txt"]);
2497
2498 let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).unwrap();
2499 let specs = vec!["a.txt".to_owned()];
2500 commit_with_scope(
2501 &repo,
2502 &commit_req("partial"),
2503 CommitScope::Paths(ResolvedPathspecs {
2504 specs: specs.clone(),
2505 sources: specs,
2506 scoped: true,
2507 }),
2508 &mut NullProgress,
2509 )
2510 .unwrap();
2511
2512 assert!(!repo.git_dir().join("index.lock").exists());
2513 assert!(
2514 system_git(root, &["ls-files", "--debug"]).contains("a.txt"),
2515 "git must read the split index"
2516 );
2517 assert_eq!(system_git(root, &["status", "--porcelain"]), "M b.txt\n");
2518 assert_eq!(system_git(root, &["show", "HEAD:a.txt"]), "2\n");
2519 assert_eq!(system_git(root, &["show", "HEAD:b.txt"]), "1\n");
2520 system_git(root, &["fsck", "--strict", "--no-progress"]);
2521 let shared: Vec<_> = fs::read_dir(repo.git_dir())
2522 .unwrap()
2523 .flatten()
2524 .filter(|e| e.file_name().to_string_lossy().starts_with("sharedindex."))
2525 .collect();
2526 assert!(!shared.is_empty(), "index must stay split");
2527 }
2528
2529 #[test]
2530 fn commit_on_split_index_with_skip_hash_stays_readable_by_git() {
2531 for key in ["index.skipHash", "feature.manyFiles"] {
2532 let tmp = TempDir::new().unwrap();
2533 let root = tmp.path();
2534 system_git(root, &["init", "-q", "-b", "main"]);
2535 fs::write(root.join("a"), b"1\n").unwrap();
2536 system_git(root, &["add", "a"]);
2537 system_git(root, &["commit", "-qm", "c1"]);
2538 system_git(root, &["config", "core.splitIndex", "true"]);
2539 system_git(root, &["update-index", "--split-index"]);
2540 fs::write(root.join("a"), b"1\n2\n").unwrap();
2541 system_git(root, &["add", "a"]);
2542 system_git(root, &["commit", "-qm", "c2"]);
2543 system_git(root, &["config", key, "true"]);
2544 assert_eq!(system_git(root, &["status", "--short"]), "");
2545 fs::write(root.join("n"), b"n\n").unwrap();
2546
2547 let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).unwrap();
2548 let outcome = commit_with_scope(
2549 &repo,
2550 &commit_req("g"),
2551 CommitScope::AutoStage,
2552 &mut NullProgress,
2553 )
2554 .unwrap();
2555
2556 assert_eq!(system_git(root, &["ls-files"]), "a\nn\n", "{key}");
2557 system_git(root, &["config", "splitIndex.maxPercentChange", "100"]);
2560 assert_eq!(system_git(root, &["status", "--porcelain"]), "", "{key}");
2561 assert_eq!(
2562 system_git(root, &["rev-parse", "HEAD"]).trim(),
2563 outcome.oid.to_hex()
2564 );
2565 system_git(root, &["fsck", "--strict", "--no-progress"]);
2566 fs::write(root.join("m"), b"m\n").unwrap();
2567 system_git(root, &["add", "-A"]);
2568 system_git(root, &["commit", "-qm", "git after grit"]);
2569 assert_eq!(system_git(root, &["ls-files"]), "a\nm\nn\n", "{key}");
2570 assert_eq!(repo.load_index().unwrap().entries.len(), 3, "{key}");
2571 }
2572 }
2573
2574 fn author_info_from_commit(repo: &Repository, oid: ObjectId) -> CommitAuthorInfo {
2575 let data = repo.odb().read(&oid).unwrap().data;
2576 author_info_from_header_line(&parse_commit(&data).unwrap().author)
2577 }
2578
2579 fn git_head_commit_message(repo: &std::path::Path) -> String {
2580 let out = std::process::Command::new("git")
2581 .args(["cat-file", "commit", "HEAD"])
2582 .current_dir(repo)
2583 .output()
2584 .expect("git cat-file commit");
2585 assert!(out.status.success());
2586 let bytes = out.stdout;
2587 let mut i = 0usize;
2588 while i < bytes.len() {
2589 if i + 1 < bytes.len() && bytes[i] == b'\n' && bytes[i + 1] == b'\n' {
2590 i += 2;
2591 break;
2592 }
2593 i += 1;
2594 }
2595 String::from_utf8(bytes[i..].to_vec()).expect("utf-8 commit message")
2596 }
2597
2598 #[test]
2599 fn message_file_stripspace() {
2600 let raw = " subject \n\n body line \n\n";
2601 let msg = normalize_commit_message(raw).unwrap();
2602 let tmp = TempDir::new().unwrap();
2603 let msg_path = tmp.path().join("msg.txt");
2604 std::fs::write(&msg_path, raw).unwrap();
2605 std::process::Command::new("git")
2606 .args(["init", "-q"])
2607 .current_dir(tmp.path())
2608 .status()
2609 .unwrap();
2610 std::process::Command::new("git")
2611 .args(["config", "user.name", "T"])
2612 .current_dir(tmp.path())
2613 .status()
2614 .unwrap();
2615 std::process::Command::new("git")
2616 .args(["config", "user.email", "t@e.com"])
2617 .current_dir(tmp.path())
2618 .status()
2619 .unwrap();
2620 std::fs::write(tmp.path().join("f"), "x\n").unwrap();
2621 std::process::Command::new("git")
2622 .args(["add", "f"])
2623 .current_dir(tmp.path())
2624 .status()
2625 .unwrap();
2626 std::process::Command::new("git")
2627 .args(["commit", "-F"])
2628 .arg(&msg_path)
2629 .current_dir(tmp.path())
2630 .status()
2631 .unwrap();
2632 let git_body = git_head_commit_message(tmp.path());
2633 assert_eq!(msg, git_body);
2634 }
2635
2636 #[test]
2637 fn message_normalization_crlf_and_trailing_blanks_match_git_commit_f() {
2638 let raw = "\r\n title \r\n\r\n body \r\n\r\n\r\n";
2639 let grit_msg = normalize_commit_message(raw).unwrap();
2640 let tmp = tempfile::TempDir::new().unwrap();
2641 let msg_path = tmp.path().join("msg.txt");
2642 std::fs::write(&msg_path, raw).unwrap();
2643 let git_msg = std::process::Command::new("git")
2644 .args(["init", "-q"])
2645 .current_dir(tmp.path())
2646 .status()
2647 .unwrap();
2648 assert!(git_msg.success());
2649 std::process::Command::new("git")
2650 .args(["config", "user.name", "T"])
2651 .current_dir(tmp.path())
2652 .status()
2653 .unwrap();
2654 std::process::Command::new("git")
2655 .args(["config", "user.email", "t@e.com"])
2656 .current_dir(tmp.path())
2657 .status()
2658 .unwrap();
2659 std::fs::write(tmp.path().join("f"), "x\n").unwrap();
2660 std::process::Command::new("git")
2661 .args(["add", "f"])
2662 .current_dir(tmp.path())
2663 .status()
2664 .unwrap();
2665 std::process::Command::new("git")
2666 .args(["commit", "-F"])
2667 .arg(&msg_path)
2668 .current_dir(tmp.path())
2669 .status()
2670 .unwrap();
2671 let git_body = git_head_commit_message(tmp.path());
2672 assert_eq!(grit_msg, git_body);
2673 }
2674
2675 #[test]
2676 fn no_verify_skips_pre_commit_and_commit_msg() {
2677 let tmp = TempDir::new().unwrap();
2678 let root = tmp.path();
2679 let repo = init_repo(root);
2680 std::fs::write(
2681 repo.git_dir().join("config"),
2682 "[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",
2683 )
2684 .unwrap();
2685 fs::write(root.join("a.txt"), b"1\n").unwrap();
2686 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2687
2688 let mut req = commit_req("blocked");
2689 req.hooks = HookPolicy::Run;
2690 assert!(create_commit(&repo, &req, &mut NullProgress).is_err());
2691
2692 req.hooks = HookPolicy::SkipVerify;
2693 let outcome = create_commit(&repo, &req, &mut NullProgress).unwrap();
2694 assert!(outcome.hooks_run.is_empty());
2695 }
2696
2697 #[test]
2698 fn post_commit_hook_failure_does_not_fail_commit() {
2699 use crate::hooks::HookName;
2700
2701 let tmp = TempDir::new().unwrap();
2702 let root = tmp.path();
2703 let repo = init_repo(root);
2704 std::fs::write(
2705 repo.git_dir().join("config"),
2706 "[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tlogAllRefUpdates = true\n[hook \"hc\"]\n\tcommand = exit 9\n\tevent = post-commit\n",
2707 )
2708 .unwrap();
2709 fs::write(root.join("a.txt"), b"1\n").unwrap();
2710 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2711 let outcome = create_commit(&repo, &commit_req("after post"), &mut NullProgress).unwrap();
2712 assert_eq!(
2713 resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
2714 outcome.oid
2715 );
2716 assert!(outcome.hooks_run.contains(&HookName::PostCommit));
2717 }
2718
2719 #[test]
2720 fn hooks_run_lists_executed_hooks() {
2721 use crate::hooks::HookName;
2722
2723 let tmp = TempDir::new().unwrap();
2724 let root = tmp.path();
2725 let repo = init_repo(root);
2726 std::fs::write(
2727 repo.git_dir().join("config"),
2728 "[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",
2729 )
2730 .unwrap();
2731 fs::write(root.join("a.txt"), b"1\n").unwrap();
2732 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2733 let outcome = create_commit(&repo, &commit_req("hooks"), &mut NullProgress).unwrap();
2734 assert_eq!(
2735 outcome.hooks_run,
2736 vec![
2737 HookName::PreCommit,
2738 HookName::CommitMsg,
2739 HookName::PostCommit
2740 ]
2741 );
2742 }
2743
2744 #[test]
2745 fn author_date_override() {
2746 let tmp = TempDir::new().unwrap();
2747 let root = tmp.path();
2748 let repo = init_repo(root);
2749 fs::write(root.join("a.txt"), b"1\n").unwrap();
2750 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2751 let mut req = commit_req("dated");
2752 req.author_date = Some("946684800 +0000".to_owned());
2753 let outcome = create_commit(&repo, &req, &mut NullProgress).unwrap();
2754 assert_eq!(outcome.author.date, "946684800 +0000");
2755 }
2756
2757 #[test]
2758 fn trailers_appended_and_deduped() {
2759 let tmp = TempDir::new().unwrap();
2760 let root = tmp.path();
2761 let repo = init_repo(root);
2762 fs::write(root.join("a.txt"), b"1\n").unwrap();
2763 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2764 let mut req = commit_req("trailers");
2765 req.trailers = vec![
2766 ("Acked-by".to_owned(), "Alice".to_owned()),
2767 ("Acked-by".to_owned(), "Alice".to_owned()),
2768 ];
2769 let outcome = create_commit(&repo, &req, &mut NullProgress).unwrap();
2770 let msg = parse_commit(&repo.odb().read(&outcome.oid).unwrap().data)
2771 .unwrap()
2772 .message;
2773 assert_eq!(msg.matches("Acked-by: Alice").count(), 1);
2774 }
2775
2776 #[test]
2777 fn signoff_uses_committer() {
2778 let tmp = TempDir::new().unwrap();
2779 let root = tmp.path();
2780 let repo = init_repo(root);
2781 fs::write(root.join("a.txt"), b"1\n").unwrap();
2782 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2783 let mut req = commit_req("signoff");
2784 req.committer = Some(
2785 "Committer Name <committer@example.com> 2 +0000"
2786 .parse()
2787 .expect("signature"),
2788 );
2789 req.signoff = true;
2790 let outcome = create_commit(&repo, &req, &mut NullProgress).unwrap();
2791 let msg = parse_commit(&repo.odb().read(&outcome.oid).unwrap().data)
2792 .unwrap()
2793 .message;
2794 assert!(msg.contains("Signed-off-by: Committer Name <committer@example.com>"));
2795 }
2796
2797 #[test]
2798 fn signoff_before_explicit_trailers() {
2799 let tmp = TempDir::new().unwrap();
2800 let root = tmp.path();
2801 let repo = init_repo(root);
2802 fs::write(root.join("a.txt"), b"1\n").unwrap();
2803 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2804 let mut req = commit_req("subject\n");
2805 req.committer = Some(
2806 "Committer Name <committer@example.com> 2 +0000"
2807 .parse()
2808 .expect("signature"),
2809 );
2810 req.signoff = true;
2811 req.trailers = vec![("Acked-by".to_owned(), "Alice".to_owned())];
2812 let outcome = create_commit(&repo, &req, &mut NullProgress).unwrap();
2813 let msg = parse_commit(&repo.odb().read(&outcome.oid).unwrap().data)
2814 .unwrap()
2815 .message;
2816 let sob = msg.find("Signed-off-by:").expect("signoff trailer");
2817 let ack = msg.find("Acked-by:").expect("acked trailer");
2818 assert!(sob < ack, "signoff must precede explicit trailers:\n{msg}");
2819 }
2820
2821 #[test]
2822 fn amend_reuse_message_from_source() {
2823 let tmp = TempDir::new().unwrap();
2824 let root = tmp.path();
2825 let repo = init_repo(root);
2826 fs::write(root.join("a.txt"), b"1\n").unwrap();
2827 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2828 let mut source = commit_req("source subject\n");
2829 source.author = Some(
2830 "Source Author <author@example.com> 978307200 +0000"
2831 .parse()
2832 .expect("signature"),
2833 );
2834 source.committer = Some(ident().parse().expect("signature"));
2835 let source_out = create_commit(&repo, &source, &mut NullProgress).unwrap();
2836
2837 fs::write(root.join("a.txt"), b"2\n").unwrap();
2838 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2839 let mut tip = commit_req("tip subject\n");
2840 tip.author = Some(
2841 "Tip Author <tip@example.com> 1792591163 +0000"
2842 .parse()
2843 .expect("signature"),
2844 );
2845 create_commit(&repo, &tip, &mut NullProgress).unwrap();
2846
2847 fs::write(root.join("a.txt"), b"3\n").unwrap();
2848 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2849 let mut amend = commit_req("");
2850 amend.amend = true;
2851 amend.author = None;
2852 amend.reuse_message_from = Some(source_out.oid);
2853 let outcome = create_commit(&repo, &amend, &mut NullProgress).unwrap();
2854 let author = author_info_from_commit(&repo, outcome.oid);
2855 assert_eq!(author.name, "Source Author");
2856 assert_eq!(author.email, "author@example.com");
2857 assert_eq!(author.date, "978307200 +0000");
2858 let msg = parse_commit(&repo.odb().read(&outcome.oid).unwrap().data)
2859 .unwrap()
2860 .message;
2861 assert!(msg.starts_with("source subject"));
2862 }
2863
2864 #[test]
2865 fn amend_reuse_message_reset_author_uses_committer() {
2866 let tmp = TempDir::new().unwrap();
2867 let root = tmp.path();
2868 let repo = init_repo(root);
2869 fs::write(root.join("a.txt"), b"1\n").unwrap();
2870 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2871 let mut source = commit_req("source subject\n");
2872 source.author = Some(
2873 "Source Author <author@example.com> 978307200 +0000"
2874 .parse()
2875 .expect("signature"),
2876 );
2877 let source_out = create_commit(&repo, &source, &mut NullProgress).unwrap();
2878
2879 fs::write(root.join("a.txt"), b"2\n").unwrap();
2880 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2881 create_commit(&repo, &commit_req("tip"), &mut NullProgress).unwrap();
2882
2883 fs::write(root.join("a.txt"), b"3\n").unwrap();
2884 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2885 let mut amend = commit_req("");
2886 amend.amend = true;
2887 amend.author = None;
2888 amend.reset_author = true;
2889 amend.committer = Some(
2890 "Reset Author <reset@example.com> 1800000000 +0000"
2891 .parse()
2892 .expect("signature"),
2893 );
2894 amend.reuse_message_from = Some(source_out.oid);
2895 let outcome = create_commit(&repo, &amend, &mut NullProgress).unwrap();
2896 let author = author_info_from_commit(&repo, outcome.oid);
2897 assert_eq!(author.name, "Reset Author");
2898 assert_eq!(author.email, "reset@example.com");
2899 }
2900
2901 #[test]
2902 fn reuse_message_keeps_author() {
2903 let tmp = TempDir::new().unwrap();
2904 let root = tmp.path();
2905 let repo = init_repo(root);
2906 fs::write(root.join("a.txt"), b"1\n").unwrap();
2907 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2908 let mut first = commit_req("source");
2909 first.author = Some(
2910 "Source Author <source@example.com> 99 +0000"
2911 .parse()
2912 .expect("signature"),
2913 );
2914 first.committer = Some(ident().parse().expect("signature"));
2915 let first_out = create_commit(&repo, &first, &mut NullProgress).unwrap();
2916
2917 fs::write(root.join("a.txt"), b"2\n").unwrap();
2918 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2919 let mut req = commit_req("");
2920 req.author = None;
2921 req.reuse_message_from = Some(first_out.oid);
2922 let outcome = create_commit(&repo, &req, &mut NullProgress).unwrap();
2923 let author = author_info_from_commit(&repo, outcome.oid);
2924 assert_eq!(author.name, "Source Author");
2925 assert_eq!(author.email, "source@example.com");
2926 assert_eq!(author.date, "99 +0000");
2927 }
2928
2929 #[test]
2930 fn reset_author_with_reuse_and_amend() {
2931 let tmp = TempDir::new().unwrap();
2932 let root = tmp.path();
2933 let repo = init_repo(root);
2934 fs::write(root.join("a.txt"), b"1\n").unwrap();
2935 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2936 let mut first = commit_req("source");
2937 first.author = Some("Old <old@example.com> 1 +0000".parse().expect("signature"));
2938 create_commit(&repo, &first, &mut NullProgress).unwrap();
2939
2940 fs::write(root.join("a.txt"), b"2\n").unwrap();
2941 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2942 create_commit(&repo, &commit_req("second"), &mut NullProgress).unwrap();
2943
2944 let mut amend = commit_req("");
2945 amend.amend = true;
2946 amend.author = None;
2947 amend.reset_author = true;
2948 amend.committer = Some(
2949 "New Committer <new@example.com> 2000 +0000"
2950 .parse()
2951 .expect("signature"),
2952 );
2953 let outcome = create_commit(&repo, &amend, &mut NullProgress).unwrap();
2954 let author = author_info_from_commit(&repo, outcome.oid);
2955 assert_eq!(author.name, "New Committer");
2956 assert_eq!(author.email, "new@example.com");
2957 }
2958
2959 #[test]
2960 fn reuse_message_gets_new_change_id() {
2961 use crate::change_id::{change_id, ChangeIdError, ChangeIdGenerator};
2962 use std::sync::Arc;
2963
2964 struct FixedGenerator([u8; 16]);
2965 impl ChangeIdGenerator for FixedGenerator {
2966 fn random_bytes(&self) -> std::result::Result<[u8; 16], ChangeIdError> {
2967 Ok(self.0)
2968 }
2969 }
2970
2971 let tmp = TempDir::new().unwrap();
2972 let root = tmp.path();
2973 let gen1: Arc<dyn ChangeIdGenerator> = Arc::new(FixedGenerator([0x11; 16]));
2974 let git = root.join(".git");
2975 fs::create_dir_all(git.join("objects")).unwrap();
2976 fs::create_dir_all(git.join("refs/heads")).unwrap();
2977 fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
2978 fs::write(
2979 git.join("config"),
2980 "[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tlogAllRefUpdates = true\n",
2981 )
2982 .unwrap();
2983 let repo = Repository::open(
2984 &git,
2985 &RepositoryOptions::empty()
2986 .with_work_tree(root)
2987 .with_change_id_generator(gen1),
2988 )
2989 .unwrap();
2990 fs::write(root.join("a.txt"), b"1\n").unwrap();
2991 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
2992 let source = create_commit(&repo, &commit_req("source"), &mut NullProgress).unwrap();
2993 let source_data = parse_commit(&repo.odb().read(&source.oid).unwrap().data).unwrap();
2994 let source_id = change_id(&source_data).expect("source change id");
2995
2996 let gen2: Arc<dyn ChangeIdGenerator> = Arc::new(FixedGenerator([0x22; 16]));
2997 let repo = Repository::open(
2998 &git,
2999 &RepositoryOptions::empty()
3000 .with_work_tree(root)
3001 .with_change_id_generator(gen2),
3002 )
3003 .unwrap();
3004 fs::write(root.join("a.txt"), b"2\n").unwrap();
3005 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3006 let mut req = commit_req("");
3007 req.author = None;
3008 req.reuse_message_from = Some(source.oid);
3009 let copy = create_commit(&repo, &req, &mut NullProgress).unwrap();
3010 let copy_data = parse_commit(&repo.odb().read(©.oid).unwrap().data).unwrap();
3011 let copy_id = change_id(©_data).expect("copy change id");
3012 assert_ne!(copy_id, source_id);
3013 }
3014
3015 #[test]
3016 fn outcome_parents_merge_commit() {
3017 let tmp = TempDir::new().unwrap();
3018 let root = tmp.path();
3019 let repo = init_repo(root);
3020 fs::write(root.join("a.txt"), b"1\n").unwrap();
3021 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3022 let first = create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
3023
3024 fs::write(root.join("b.txt"), b"2\n").unwrap();
3025 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3026 let second = create_commit(&repo, &commit_req("second"), &mut NullProgress).unwrap();
3027
3028 fs::write(
3029 repo.git_dir().join("MERGE_HEAD"),
3030 format!("{}\n", first.oid.to_hex()),
3031 )
3032 .unwrap();
3033 fs::write(root.join("a.txt"), b"merged\n").unwrap();
3034 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3035 let merge = create_commit(&repo, &commit_req("merge"), &mut NullProgress).unwrap();
3036 assert_eq!(merge.parents.len(), 2);
3037 assert_eq!(merge.parents[0], second.oid);
3038 assert_eq!(merge.parents[1], first.oid);
3039 }
3040
3041 #[test]
3042 fn auto_stage_config_false_defaults_to_staged() {
3043 let tmp = TempDir::new().unwrap();
3044 let root = tmp.path();
3045 let git = root.join(".git");
3046 fs::create_dir_all(git.join("objects")).unwrap();
3047 fs::create_dir_all(git.join("refs/heads")).unwrap();
3048 fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
3049 fs::write(
3050 git.join("config"),
3051 "[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tlogAllRefUpdates = true\n[commit]\n\tautoStage = false\n",
3052 )
3053 .unwrap();
3054 let repo =
3055 Repository::open(&git, &RepositoryOptions::empty().with_work_tree(root)).unwrap();
3056 assert_eq!(
3057 default_commit_scope_from_config(&repo.config().unwrap()),
3058 CommitScope::Staged
3059 );
3060 }
3061
3062 #[test]
3063 fn dry_run_path_scope_includes_untracked_add() {
3064 let tmp = TempDir::new().unwrap();
3065 let root = tmp.path();
3066 let repo = init_repo(root);
3067 fs::write(root.join("tracked.txt"), b"1\n").unwrap();
3068 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3069 create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
3070 fs::write(root.join("new.txt"), b"new\n").unwrap();
3071 let paths = ResolvedPathspecs {
3072 specs: vec!["new.txt".into()],
3073 sources: vec!["new.txt".into()],
3074 scoped: true,
3075 };
3076 let before = repo_snapshot(&repo);
3077 let plan = plan_commit(
3078 &repo,
3079 &dry_run_req("add new"),
3080 CommitScope::Paths(paths),
3081 &mut NullProgress,
3082 )
3083 .unwrap();
3084 assert_eq!(plan.files.len(), 1);
3085 assert_eq!(plan.files[0].path, "new.txt");
3086 assert_eq!(repo_snapshot(&repo), before);
3087 }
3088
3089 #[test]
3090 fn dry_run_amend_honors_author_override() {
3091 let tmp = TempDir::new().unwrap();
3092 let root = tmp.path();
3093 let repo = init_repo(root);
3094 fs::write(root.join("a.txt"), b"1\n").unwrap();
3095 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3096 create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
3097 fs::write(root.join("a.txt"), b"2\n").unwrap();
3098 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3099 let mut req = dry_run_req("amend");
3100 req.amend = true;
3101 req.author = Some(
3102 "Override <o@example.com> 1700000000 +0000"
3103 .parse()
3104 .expect("signature"),
3105 );
3106 let outcome =
3107 commit_with_scope(&repo, &req, CommitScope::Staged, &mut NullProgress).unwrap();
3108 assert_eq!(outcome.author.email, "o@example.com");
3109 assert_eq!(outcome.author.name, "Override");
3110 }
3111
3112 fn git(dir: &std::path::Path, args: &[&str]) {
3113 let out = Command::new("git")
3114 .current_dir(dir)
3115 .args(args)
3116 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3117 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3118 .output()
3119 .expect("git");
3120 assert!(
3121 out.status.success(),
3122 "git {args:?}: {}",
3123 String::from_utf8_lossy(&out.stderr)
3124 );
3125 }
3126
3127 fn two_hunk_file() -> (String, String) {
3128 let mut base = String::new();
3129 for i in 0..24 {
3130 base.push_str(&format!("line{i}\n"));
3131 }
3132 let mut edited = base.clone();
3133 edited = edited.replacen("line5\n", "LINE5\n", 1);
3134 edited = edited.replacen("line18\n", "LINE18\n", 1);
3135 (base, edited)
3136 }
3137
3138 fn tree_blob(repo: &Repository, tree_oid: &ObjectId, path: &str) -> Vec<u8> {
3139 for (p, _mode, oid) in flatten_tree_entries(repo.odb(), tree_oid).unwrap() {
3140 if p == path {
3141 return repo.odb().read(&oid).unwrap().data;
3142 }
3143 }
3144 panic!("missing {path} in tree");
3145 }
3146
3147 #[test]
3148 fn commit_hunks_only_selected_in_tree() {
3149 use crate::diff::hunk_id::{hunk_ids_with_duplicate_peers, DiffSide};
3150 use crate::diff::{unstaged_file_diffs_with_peer_hunks, DiffOptions};
3151 use crate::porcelain::hunk_select::HunkSelection;
3152
3153 let tmp = TempDir::new().unwrap();
3154 let root = tmp.path();
3155 let repo = init_repo(root);
3156 let (base, edited) = two_hunk_file();
3157 fs::write(root.join("f.txt"), base).unwrap();
3158 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3159 create_commit(&repo, &commit_req("init"), &mut NullProgress).unwrap();
3160 fs::write(root.join("f.txt"), edited).unwrap();
3161
3162 let (files, peers) =
3163 unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
3164 let ids = hunk_ids_with_duplicate_peers(
3165 DiffSide::Unstaged,
3166 "f.txt",
3167 &files[0].hunks,
3168 peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
3169 );
3170 commit_with_patch(
3171 &repo,
3172 &commit_req("one hunk"),
3173 CommitPatchInput::Hunks(HunkSelection {
3174 ids: vec![ids[0].clone()],
3175 ranges: Vec::new(),
3176 }),
3177 &[],
3178 &mut NullProgress,
3179 )
3180 .unwrap();
3181
3182 let tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
3183 let tree = parse_commit(&repo.odb().read(&tip).unwrap().data)
3184 .unwrap()
3185 .tree;
3186 let blob = tree_blob(&repo, &tree, "f.txt");
3187 let text = String::from_utf8_lossy(&blob);
3188 assert!(text.contains("LINE5"), "first hunk in commit tree");
3189 assert!(
3190 !text.contains("LINE18"),
3191 "second hunk must not be in commit tree: {text}"
3192 );
3193 }
3194
3195 #[test]
3196 fn commit_hunks_leaves_other_unstaged() {
3197 use crate::diff::hunk_id::{hunk_ids_with_duplicate_peers, DiffSide};
3198 use crate::diff::{unstaged_file_diffs_with_peer_hunks, DiffOptions};
3199 use crate::porcelain::hunk_select::HunkSelection;
3200
3201 let tmp = TempDir::new().unwrap();
3202 let root = tmp.path();
3203 let repo = init_repo(root);
3204 let (base, edited) = two_hunk_file();
3205 fs::write(root.join("f.txt"), base).unwrap();
3206 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3207 create_commit(&repo, &commit_req("init"), &mut NullProgress).unwrap();
3208 fs::write(root.join("f.txt"), edited).unwrap();
3209
3210 let (files, peers) =
3211 unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
3212 let ids = hunk_ids_with_duplicate_peers(
3213 DiffSide::Unstaged,
3214 "f.txt",
3215 &files[0].hunks,
3216 peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
3217 );
3218 commit_with_patch(
3219 &repo,
3220 &commit_req("partial"),
3221 CommitPatchInput::Hunks(HunkSelection {
3222 ids: vec![ids[0].clone()],
3223 ranges: Vec::new(),
3224 }),
3225 &[],
3226 &mut NullProgress,
3227 )
3228 .unwrap();
3229
3230 let diff = run_grit_status_unstaged(root);
3231 assert!(
3232 diff.contains("line18") || diff.contains("LINE18"),
3233 "remaining hunk still unstaged: {diff}"
3234 );
3235 git(root, &["fsck", "--strict"]);
3236 }
3237
3238 fn run_grit_status_unstaged(root: &std::path::Path) -> String {
3239 let out = Command::new("git")
3240 .current_dir(root)
3241 .args(["diff"])
3242 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3243 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3244 .output()
3245 .unwrap();
3246 String::from_utf8_lossy(&out.stdout).into_owned()
3247 }
3248
3249 fn format_patch_for_git(patch: &FilePatch) -> String {
3250 use crate::apply::HunkLine;
3251 let mut out = String::new();
3252 let path = patch.effective_path().unwrap_or("f");
3253 out.push_str(&format!("diff --git a/{path} b/{path}\n"));
3254 if patch.is_new {
3255 out.push_str("new file mode 100644\n");
3256 }
3257 let old = if patch.is_new {
3258 "/dev/null".to_string()
3259 } else {
3260 format!("a/{}", patch.old_path.as_deref().unwrap_or(path))
3261 };
3262 let new = if patch.is_deleted {
3263 "/dev/null".to_string()
3264 } else {
3265 format!("b/{}", patch.new_path.as_deref().unwrap_or(path))
3266 };
3267 out.push_str(&format!("--- {old}\n"));
3268 out.push_str(&format!("+++ {new}\n"));
3269 for h in &patch.hunks {
3270 out.push_str(&format!(
3271 "@@ -{},{} +{},{} @@\n",
3272 h.old_start, h.old_count, h.new_start, h.new_count
3273 ));
3274 for line in &h.lines {
3275 match line {
3276 HunkLine::Context(s) => {
3277 out.push(' ');
3278 out.push_str(s);
3279 if !s.ends_with('\n') {
3280 out.push('\n');
3281 }
3282 }
3283 HunkLine::Add(s) => {
3284 out.push('+');
3285 out.push_str(s);
3286 if !s.ends_with('\n') {
3287 out.push('\n');
3288 }
3289 }
3290 HunkLine::Remove(s) => {
3291 out.push('-');
3292 out.push_str(s);
3293 if !s.ends_with('\n') {
3294 out.push('\n');
3295 }
3296 }
3297 HunkLine::NoNewline => out.push_str("\\ No newline at end of file\n"),
3298 }
3299 }
3300 }
3301 out
3302 }
3303
3304 #[test]
3305 fn commit_patch_file_matches_git_apply_cached_then_commit() {
3306 use crate::diff::hunk_id::{hunk_ids_with_duplicate_peers, DiffSide};
3307 use crate::diff::{unstaged_file_diffs_with_peer_hunks, DiffOptions};
3308 use crate::porcelain::hunk_select::{resolve_selection, HunkSelection};
3309
3310 let tmp = TempDir::new().unwrap();
3311 let root = tmp.path();
3312 let repo = init_repo(root);
3313 let (base, edited) = two_hunk_file();
3314 fs::write(root.join("f.txt"), base.clone()).unwrap();
3315 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3316 create_commit(&repo, &commit_req("init"), &mut NullProgress).unwrap();
3317 fs::write(root.join("f.txt"), edited.clone()).unwrap();
3318
3319 let (files, peers) =
3320 unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
3321 let ids = hunk_ids_with_duplicate_peers(
3322 DiffSide::Unstaged,
3323 "f.txt",
3324 &files[0].hunks,
3325 peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
3326 );
3327 let patch = resolve_selection(
3328 &repo,
3329 DiffSide::Unstaged,
3330 &HunkSelection {
3331 ids: vec![ids[0].clone()],
3332 ranges: Vec::new(),
3333 },
3334 &[],
3335 DiffOptions::default(),
3336 )
3337 .unwrap();
3338 let patch_text = patch.iter().map(format_patch_for_git).collect::<String>();
3339
3340 commit_with_patch(
3341 &repo,
3342 &commit_req("grit partial"),
3343 CommitPatchInput::Patches(patch),
3344 &[],
3345 &mut NullProgress,
3346 )
3347 .unwrap();
3348 let grit_tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
3349 let grit_tree = parse_commit(&repo.odb().read(&grit_tip).unwrap().data)
3350 .unwrap()
3351 .tree;
3352
3353 let git_dir = tempfile::tempdir().unwrap();
3354 git(git_dir.path(), &["init", "-q", "-b", "main"]);
3355 fs::write(git_dir.path().join("f.txt"), base).unwrap();
3356 git(git_dir.path(), &["add", "f.txt"]);
3357 git(git_dir.path(), &["commit", "-qm", "init"]);
3358 fs::write(git_dir.path().join("f.txt"), edited).unwrap();
3359 fs::write(git_dir.path().join("partial.patch"), &patch_text).unwrap();
3360 git(git_dir.path(), &["apply", "--cached", "partial.patch"]);
3361 git(git_dir.path(), &["commit", "-qm", "git partial"]);
3362 let git_tip = Command::new("git")
3363 .current_dir(git_dir.path())
3364 .args(["rev-parse", "HEAD^{tree}"])
3365 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3366 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3367 .output()
3368 .unwrap();
3369 let git_tree_hex = String::from_utf8_lossy(&git_tip.stdout);
3370 let git_tree = ObjectId::from_hex(git_tree_hex.trim()).unwrap();
3371 assert_eq!(grit_tree, git_tree);
3372 git(root, &["fsck", "--strict"]);
3373 }
3374
3375 fn loose_object_count(repo: &Repository) -> usize {
3376 use crate::objects::odb::store::loose::enumerate_loose_objects;
3377 enumerate_loose_objects(repo.odb().objects_dir(), repo.odb().hash_algo())
3378 .map(|v| v.len())
3379 .unwrap_or(0)
3380 }
3381
3382 #[test]
3383 fn commit_patch_dry_run_writes_no_loose_objects() {
3384 use crate::diff::hunk_id::{hunk_ids_with_duplicate_peers, DiffSide};
3385 use crate::diff::{unstaged_file_diffs_with_peer_hunks, DiffOptions};
3386 use crate::porcelain::hunk_select::{resolve_selection, HunkSelection};
3387
3388 let tmp = TempDir::new().unwrap();
3389 let root = tmp.path();
3390 let repo = init_repo(root);
3391 let (base, edited) = two_hunk_file();
3392 fs::write(root.join("f.txt"), base).unwrap();
3393 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3394 create_commit(&repo, &commit_req("init"), &mut NullProgress).unwrap();
3395 fs::write(root.join("f.txt"), edited).unwrap();
3396
3397 let before_loose = loose_object_count(&repo);
3398 let (files, peers) =
3399 unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
3400 let ids = hunk_ids_with_duplicate_peers(
3401 DiffSide::Unstaged,
3402 "f.txt",
3403 &files[0].hunks,
3404 peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
3405 );
3406 let patch = resolve_selection(
3407 &repo,
3408 DiffSide::Unstaged,
3409 &HunkSelection {
3410 ids: vec![ids[0].clone()],
3411 ranges: Vec::new(),
3412 },
3413 &[],
3414 DiffOptions::default(),
3415 )
3416 .unwrap();
3417 let before_tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
3418 let before_index = repo.load_index().unwrap().entries[0].oid;
3419
3420 let mut req = commit_req("dry preview");
3421 req.dry_run = true;
3422 commit_with_patch(
3423 &repo,
3424 &req,
3425 CommitPatchInput::Patches(patch),
3426 &[],
3427 &mut NullProgress,
3428 )
3429 .unwrap();
3430
3431 assert_eq!(loose_object_count(&repo), before_loose);
3432 assert_eq!(
3433 resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
3434 before_tip
3435 );
3436 assert_eq!(repo.load_index().unwrap().entries[0].oid, before_index);
3437 }
3438
3439 #[test]
3440 fn commit_hunks_stale_id_no_commit() {
3441 use crate::diff::hunk_id::HunkId;
3442 use crate::porcelain::hunk_select::HunkSelection;
3443
3444 let tmp = TempDir::new().unwrap();
3445 let root = tmp.path();
3446 let repo = init_repo(root);
3447 fs::write(root.join("f.txt"), b"one\n").unwrap();
3448 stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
3449 create_commit(&repo, &commit_req("init"), &mut NullProgress).unwrap();
3450 let before_tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
3451 let before_index_oid = repo.load_index().unwrap().entries[0].oid;
3452
3453 fs::write(root.join("f.txt"), b"two\n").unwrap();
3454 let err = commit_with_patch(
3455 &repo,
3456 &commit_req("nope"),
3457 CommitPatchInput::Hunks(HunkSelection {
3458 ids: vec![HunkId::from_str("000000000001").unwrap()],
3459 ranges: Vec::new(),
3460 }),
3461 &[],
3462 &mut NullProgress,
3463 )
3464 .unwrap_err();
3465 assert!(matches!(err, CommitPatchError::Select(_)));
3466 assert_eq!(
3467 resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
3468 before_tip
3469 );
3470 assert_eq!(repo.load_index().unwrap().entries[0].oid, before_index_oid);
3471 }
3472}