use crate::change_id::{change_id, set_change_id, ChangeId, ChangeIdPolicy};
use crate::config::ConfigSet;
use crate::diff::refresh_index_stat_content_verified_with_rules;
use crate::diff::{diff_tree_path_change_count, zero_oid};
use crate::error::{Error, Result};
use crate::hooks::{run_commit_hook_checked, CommitHookEnv};
use crate::index::Index;
use crate::objects::{parse_commit, serialize_commit, CommitData, ObjectId, ObjectKind};
use crate::pathspec::{pathspec_is_exclude, ResolvedPathspecs};
use crate::porcelain::add::{stage_on_index, StageMode, StageOptions};
use crate::porcelain::index_from_tree::index_from_tree_with_worktree_stats;
use crate::porcelain::pathspec_match::ensure_pathspec_matches_known_in_repository;
use crate::progress::ProgressSink;
use crate::refs::{update_branch_for_commit_with_config, BranchCommitRefUpdate};
use crate::repo::Repository;
use crate::signing::{
extra_headers_without_commit_signatures, should_sign_commit, sign_serialized_commit, GpgConfig,
};
use crate::state::{finish_merge_state, read_merge_heads, resolve_head, HeadState};
use crate::worktree_rules::WorktreeRules;
use crate::write_tree::{
cache_tree_extension_valid, is_empty_tree_oid, write_tree_update_index, WriteTreeFlags,
};
use std::fs;
use std::path::{Path, PathBuf};
use std::process;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone)]
pub struct CommitRequest {
pub message: String,
pub author: String,
pub committer: String,
pub allow_empty: bool,
pub sign_override: Option<bool>,
pub amend: bool,
pub assume_worktree_synced: bool,
#[doc(hidden)]
pub skip_pre_commit_tree_match: bool,
#[doc(hidden)]
pub staged_change_count: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommitScope {
AutoStage,
Staged,
Paths(ResolvedPathspecs),
}
pub fn default_commit_scope(repo: &Repository) -> Result<CommitScope> {
let config = repo.config()?;
Ok(default_commit_scope_from_config(config.as_ref()))
}
#[must_use]
pub fn default_commit_scope_from_config(config: &ConfigSet) -> CommitScope {
let auto_stage = config
.get_bool("commit.autoStage")
.and_then(|r| r.ok())
.unwrap_or(true);
if auto_stage {
CommitScope::AutoStage
} else {
CommitScope::Staged
}
}
pub fn commit_with_scope(
repo: &Repository,
req: &CommitRequest,
scope: CommitScope,
progress: &mut dyn ProgressSink,
) -> Result<CommitOutcome> {
match scope {
CommitScope::AutoStage => commit_worktree(repo, req, progress),
CommitScope::Staged => create_commit(repo, req, progress),
CommitScope::Paths(paths) => commit_paths_scope(repo, req, &paths, progress),
}
}
fn commit_paths_scope(
repo: &Repository,
req: &CommitRequest,
paths: &ResolvedPathspecs,
progress: &mut dyn ProgressSink,
) -> Result<CommitOutcome> {
if paths.specs.is_empty() {
return Err(Error::Message("you must specify path(s)".into()));
}
if !read_merge_heads(repo.git_dir())?.is_empty() {
return Err(Error::PartialCommitDuringMerge);
}
validate_commit_pathspecs(repo, paths)?;
let head = resolve_head(repo.git_dir())?;
let parent_tree = match head {
HeadState::Branch { oid: Some(p), .. } | HeadState::Detached { oid: p } => {
Some(commit_tree_oid(repo, &p)?)
}
HeadState::Branch { oid: None, .. } => None,
HeadState::Invalid => {
return Err(Error::Message("HEAD is in an unknown state".into()));
}
};
let mut real_index = repo.load_index()?;
if real_index.has_unmerged_entries() {
return Err(Error::IndexUnmerged);
}
let mut commit_index = match parent_tree.as_ref() {
Some(tree) => index_from_tree_with_worktree_stats(repo, tree)?,
None => Index::new(),
};
let stage_opts = StageOptions {
pathspecs: paths.specs.clone(),
pathspec_sources: if paths.sources.is_empty() {
paths.specs.clone()
} else {
paths.sources.clone()
},
mode: StageMode::All,
..StageOptions::default()
};
let staged_commit = stage_on_index(repo, &mut commit_index, &stage_opts, progress)?;
let _staged_real = stage_on_index(repo, &mut real_index, &stage_opts, progress)?;
let mut req = req.clone();
req.assume_worktree_synced = true;
req.skip_pre_commit_tree_match = false;
req.staged_change_count = Some(staged_commit.total().max(1));
create_commit_with_index(
repo,
&mut commit_index,
&req,
progress,
Some(&mut real_index),
)
}
fn validate_commit_pathspecs(repo: &Repository, paths: &ResolvedPathspecs) -> Result<()> {
let pairs: Vec<(&str, &str)> =
if paths.sources.len() == paths.specs.len() && !paths.sources.is_empty() {
paths
.sources
.iter()
.zip(paths.specs.iter())
.map(|(a, b)| (a.as_str(), b.as_str()))
.collect()
} else {
paths
.specs
.iter()
.map(|s| (s.as_str(), s.as_str()))
.collect()
};
for (source, resolved) in pairs {
if pathspec_is_exclude(source) {
continue;
}
ensure_pathspec_matches_known_in_repository(repo, source, resolved)?;
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommitOutcome {
pub oid: ObjectId,
pub branch: String,
pub parent: Option<ObjectId>,
pub changes: usize,
pub change_id: Option<ChangeId>,
}
pub fn create_commit(
repo: &Repository,
req: &CommitRequest,
progress: &mut dyn ProgressSink,
) -> Result<CommitOutcome> {
let mut index = repo.load_index()?;
create_commit_with_index(repo, &mut index, req, progress, None)
}
pub fn commit_worktree(
repo: &Repository,
req: &CommitRequest,
progress: &mut dyn ProgressSink,
) -> Result<CommitOutcome> {
let mut index = repo.load_index()?;
let staged = crate::porcelain::add::stage_for_commit_on_index(repo, &mut index, progress)?;
if index.has_unmerged_entries() {
return Err(Error::IndexUnmerged);
}
let mut req = req.clone();
if req.assume_worktree_synced && req.skip_pre_commit_tree_match {
req.staged_change_count = Some(staged.total());
}
create_commit_with_index(repo, &mut index, &req, progress, None)
}
struct TempHookIndexFile(PathBuf);
impl Drop for TempHookIndexFile {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
fn create_commit_with_index(
repo: &Repository,
index: &mut Index,
req: &CommitRequest,
_progress: &mut dyn ProgressSink,
persist_index: Option<&mut Index>,
) -> Result<CommitOutcome> {
let head = resolve_head(repo.git_dir())?;
let (refname, short_name, mut parent) = match head {
HeadState::Branch {
refname,
short_name,
oid,
} => (refname, short_name, oid),
HeadState::Detached { .. } => return Err(Error::DetachedHead),
HeadState::Invalid => {
return Err(Error::Message("HEAD is in an unknown state".into()));
}
};
let mut amend_parents = Vec::new();
let mut amend_author = None;
let mut amend_author_raw = Vec::new();
let mut amend_extra_headers = Vec::new();
let mut amend_default_message = None;
let mut replaced_tip = None;
if req.amend {
let Some(tip) = parent.take() else {
return Err(Error::AmendUnborn);
};
let obj = repo.odb().read(&tip)?;
if obj.kind != ObjectKind::Commit {
return Err(Error::CorruptObject(format!(
"expected commit, got {}",
obj.kind.as_str()
)));
}
let old = parse_commit(&obj.data)?;
amend_parents = old.parents.clone();
parent = old.parents.first().copied();
amend_author = Some(old.author);
amend_author_raw = old.author_raw;
amend_extra_headers = extra_headers_without_commit_signatures(&old.extra_headers);
amend_default_message = Some(old.message);
replaced_tip = Some(tip);
}
if index.has_unmerged_entries() {
return Err(Error::IndexUnmerged);
}
let merge_heads = read_merge_heads(repo.git_dir())?;
let concluding_merge = !merge_heads.is_empty();
let index_path = repo.git_dir().join("index");
let parent_tree = parent
.as_ref()
.map(|parent_oid| commit_tree_oid(repo, parent_oid))
.transpose()?;
if !req.allow_empty && !req.amend && !concluding_merge {
let skip_tree_match = req.assume_worktree_synced && req.skip_pre_commit_tree_match;
if !skip_tree_match {
if let Some(ref old) = parent_tree {
if index_staged_tree_matches(repo.odb(), index, old) {
return Err(Error::NothingToCommit);
}
} else if index.entries.is_empty() {
return Err(Error::NothingToCommit);
}
}
}
let partial_commit = persist_index.is_some();
let _temp_hook_index_guard = if partial_commit {
let path = repo.git_dir().join(format!("next-index-{}", process::id()));
repo.write_index_at(&path, index)?;
Some(TempHookIndexFile(path))
} else {
repo.write_index(index)?;
None
};
let temp_hook_index_path = _temp_hook_index_guard.as_ref().map(|g| g.0.clone());
let hook_index_path: PathBuf = temp_hook_index_path
.clone()
.unwrap_or_else(|| index_path.clone());
let commit_env = CommitHookEnv {
index_file: Some(hook_index_path.as_path()),
git_editor: Some(":"),
git_prefix: None,
extra_env: &[],
};
run_commit_hook_checked(repo, "pre-commit", &[], None, &commit_env)?;
let tree = write_tree_update_index(repo.odb(), index, "", WriteTreeFlags::silent())?;
if !req.allow_empty && !req.amend {
if parent_tree.is_none() && is_empty_tree_oid(repo.odb(), &tree) {
return Err(Error::NothingToCommit);
}
if !concluding_merge {
if let Some(ref old) = parent_tree {
if old == &tree {
return Err(Error::NothingToCommit);
}
}
}
}
let mut message = req.message.trim().to_owned();
if req.amend && message.is_empty() {
message = amend_default_message.unwrap_or_default();
}
if !message.is_empty() && !message.ends_with('\n') {
message.push('\n');
}
let editmsg_path = repo.git_dir().join("COMMIT_EDITMSG");
fs::write(&editmsg_path, &message).map_err(Error::Io)?;
let editmsg_arg = commit_editmsg_hook_argument(repo, &editmsg_path);
run_commit_hook_checked(
repo,
"commit-msg",
&[editmsg_arg.as_str()],
None,
&commit_env,
)?;
message = fs::read_to_string(&editmsg_path).map_err(Error::Io)?;
if message.is_empty() {
return Err(Error::Message("empty commit message".into()));
}
if !message.ends_with('\n') {
message.push('\n');
}
let (author, author_raw) = if let Some(a) = amend_author {
(a, amend_author_raw)
} else {
(req.author.clone(), Vec::new())
};
let mut commit_parents = if req.amend {
amend_parents
} else {
parent.into_iter().collect()
};
if !req.amend {
for merge_head in merge_heads {
if !commit_parents.iter().any(|p| p == &merge_head) {
commit_parents.push(merge_head);
}
}
}
let mut commit_data = CommitData {
tree,
parents: commit_parents,
author,
committer: req.committer.clone(),
author_raw,
committer_raw: Vec::new(),
encoding: None,
message,
raw_message: None,
extra_headers: amend_extra_headers,
};
let config = repo.config()?;
let policy = ChangeIdPolicy::from_config(config.as_ref());
if policy.enabled() {
if req.amend {
if change_id(&commit_data).is_none() {
if let Some(tip) = replaced_tip {
set_change_id(&mut commit_data, &ChangeId::derived_from_commit_id(&tip));
}
}
} else {
stamp_change_id_on_new_commit(repo, &policy, &mut commit_data)?;
}
}
let bytes = prepare_commit_bytes(repo, config.as_ref(), &commit_data, req.sign_override)?;
let oid = repo.odb().write(ObjectKind::Commit, &bytes)?;
let previous_tip = replaced_tip.or(parent);
let reflog_old = previous_tip.unwrap_or_else(zero_oid);
let subject = commit_subject(&commit_data.message);
let reflog_msg = if req.amend {
format!("commit (amend): {subject}")
} else if parent.is_some() {
format!("commit: {subject}")
} else {
format!("commit (initial): {subject}")
};
let expected_old = previous_tip.or_else(|| {
if crate::refs::resolve_ref(repo.git_dir(), &refname).is_ok() {
Some(reflog_old)
} else {
None
}
});
if !partial_commit {
if !req.assume_worktree_synced {
if let Some(work_tree) = repo.work_tree() {
let rules = WorktreeRules::from_repository(repo, index).ok();
let rules_arc = rules.map(|r| Arc::new(Mutex::new(r)));
let _ = refresh_index_stat_content_verified_with_rules(
repo.odb(),
repo.git_dir(),
index,
work_tree,
None,
Some(config.as_ref()),
None,
rules_arc.as_ref(),
)?;
}
}
repo.write_index(index)?;
}
let changes = if parent_tree.as_ref() == Some(&tree) {
0
} else if req.assume_worktree_synced && req.skip_pre_commit_tree_match {
const LARGE_INDEX_CHANGE_COUNT_FAST_PATH: usize = 4096;
let large_index = index.entries.len() >= LARGE_INDEX_CHANGE_COUNT_FAST_PATH;
if large_index {
if let Some(n) = req.staged_change_count.filter(|&n| n > 0) {
n
} else {
diff_tree_path_change_count(repo.odb(), parent_tree.as_ref(), Some(&tree), "")?
}
} else {
diff_tree_path_change_count(repo.odb(), parent_tree.as_ref(), Some(&tree), "")?
}
} else {
diff_tree_path_change_count(repo.odb(), parent_tree.as_ref(), Some(&tree), "")?
};
update_branch_for_commit_with_config(
repo.git_dir(),
&BranchCommitRefUpdate {
branch_ref: &refname,
expected_old,
new_oid: oid,
identity: &req.committer,
reflog_message: &reflog_msg,
},
config.as_ref(),
)?;
if concluding_merge {
let _ = finish_merge_state(repo)?;
}
if let Some(persist) = persist_index {
if !req.assume_worktree_synced {
if let Some(work_tree) = repo.work_tree() {
let rules = WorktreeRules::from_repository(repo, persist).ok();
let rules_arc = rules.map(|r| Arc::new(Mutex::new(r)));
let _ = refresh_index_stat_content_verified_with_rules(
repo.odb(),
repo.git_dir(),
persist,
work_tree,
None,
Some(config.as_ref()),
None,
rules_arc.as_ref(),
)?;
}
}
repo.write_index(persist)?;
}
let post_commit_env = CommitHookEnv {
index_file: Some(index_path.as_path()),
git_editor: Some(":"),
git_prefix: None,
extra_env: &[],
};
let _ = run_commit_hook_checked(repo, "post-commit", &[], None, &post_commit_env);
Ok(CommitOutcome {
oid,
branch: short_name,
parent,
changes,
change_id: change_id(&commit_data),
})
}
pub(crate) fn stamp_change_id_on_new_commit(
repo: &Repository,
policy: &ChangeIdPolicy,
data: &mut CommitData,
) -> Result<()> {
if let Some(id) = policy
.id_for_new_commit(
data,
repo.odb().hash_algo(),
repo.change_id_generator().as_ref(),
)
.map_err(|e| Error::Message(format!("change id: {e}")))?
{
set_change_id(data, &id);
}
Ok(())
}
pub fn prepare_commit_bytes(
repo: &Repository,
config: &ConfigSet,
data: &CommitData,
sign_override: Option<bool>,
) -> Result<Vec<u8>> {
let unsigned = serialize_commit(data);
if !should_sign_commit(config, sign_override)? {
return Ok(unsigned);
}
let gpg = GpgConfig::from_config(config)?.with_command_runner(repo.command_runner());
sign_serialized_commit(
&gpg,
&unsigned,
&data.committer,
None,
repo.odb().hash_algo(),
)
}
pub fn write_commit_object(
repo: &Repository,
data: &CommitData,
sign_override: Option<bool>,
) -> Result<ObjectId> {
let config = repo.config()?;
let bytes = prepare_commit_bytes(repo, config.as_ref(), data, sign_override)?;
repo.odb().write(ObjectKind::Commit, &bytes)
}
fn index_staged_tree_matches(
odb: &crate::odb::Odb,
index: &crate::index::Index,
tree_oid: &ObjectId,
) -> bool {
if index.cache_tree_root == Some(*tree_oid)
&& cache_tree_extension_valid(index.cache_tree.as_ref())
{
return true;
}
crate::diff::diff_index_to_tree(odb, index, Some(tree_oid), false)
.map(|d| d.is_empty())
.unwrap_or(false)
}
fn commit_tree_oid(repo: &Repository, commit_oid: &ObjectId) -> Result<ObjectId> {
let obj = repo.odb().read(commit_oid)?;
if obj.kind != ObjectKind::Commit {
return Err(Error::CorruptObject(format!(
"expected commit, got {}",
obj.kind.as_str()
)));
}
Ok(parse_commit(&obj.data)?.tree)
}
fn commit_subject(message: &str) -> &str {
message.split('\n').next().unwrap_or("").trim_end()
}
fn commit_editmsg_hook_argument(repo: &Repository, editmsg_path: &Path) -> String {
if let Some(wt) = repo.work_tree() {
if let Ok(rel) = editmsg_path.strip_prefix(wt) {
let s = rel.to_string_lossy();
if !s.is_empty() {
return s.replace('\\', "/");
}
}
}
editmsg_path.to_string_lossy().into_owned()
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use crate::environment::RepositoryOptions;
use super::*;
use crate::index::{entry_from_stat, Index, MODE_REGULAR};
use crate::objects::ObjectKind;
use crate::pathspec::ResolvedPathspecs;
use crate::porcelain::add::{stage, StageOptions};
use crate::progress::NullProgress;
use crate::reflog::read_reflog;
use crate::refs::{resolve_ref, set_test_inject_reflog_fail};
use crate::repo::set_test_inject_index_write_fail;
use crate::write_tree::{cache_tree_fully_valid, verify_cache_tree};
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn init_repo(root: &Path) -> Repository {
let git = root.join(".git");
fs::create_dir_all(git.join("objects")).unwrap();
fs::create_dir_all(git.join("refs/heads")).unwrap();
fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
fs::write(
git.join("config"),
"[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tlogAllRefUpdates = true\n",
)
.unwrap();
Repository::open(&git, &RepositoryOptions::empty().with_work_tree(root)).unwrap()
}
fn ident() -> String {
"Test User <t@example.com> 1 +0000".to_owned()
}
fn stage_file_in_index(repo: &Repository, index: &mut Index, rel: &str, contents: &[u8]) {
let wt = repo.work_tree().unwrap();
let abs = wt.join(rel);
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&abs, contents).unwrap();
let oid = repo.odb().write(ObjectKind::Blob, contents).unwrap();
let entry = entry_from_stat(&abs, rel.as_bytes(), oid, MODE_REGULAR).unwrap();
index.add_or_replace(entry);
}
fn write_index(repo: &Repository, index: &mut Index) {
index.sort();
repo.write_index(index).unwrap();
}
fn commit_req(message: &str) -> CommitRequest {
let ident = ident();
CommitRequest {
message: message.to_owned(),
author: ident.clone(),
committer: ident,
allow_empty: false,
sign_override: None,
amend: false,
assume_worktree_synced: false,
skip_pre_commit_tree_match: false,
staged_change_count: None,
}
}
#[test]
fn create_commit_initial() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let repo = init_repo(root);
fs::write(root.join("README"), b"hello\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
let outcome = create_commit(&repo, &commit_req("initial"), &mut NullProgress).unwrap();
assert!(outcome.parent.is_none());
assert_eq!(outcome.branch, "main");
assert_eq!(outcome.changes, 1);
assert_eq!(
resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap(),
outcome.oid
);
}
#[test]
fn create_commit_rejects_empty_initial_index() {
let tmp = TempDir::new().unwrap();
let repo = init_repo(tmp.path());
let err = create_commit(&repo, &commit_req("empty"), &mut NullProgress).unwrap_err();
assert!(matches!(err, Error::NothingToCommit));
assert!(resolve_ref(&repo.git_dir(), "refs/heads/main").is_err());
}
#[test]
fn create_commit_normal() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let repo = init_repo(root);
fs::write(root.join("a.txt"), b"1\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
fs::write(root.join("a.txt"), b"2\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
let outcome = create_commit(&repo, &commit_req("second"), &mut NullProgress).unwrap();
assert_eq!(outcome.changes, 1);
assert!(outcome.parent.is_some());
}
#[test]
fn create_commit_nothing_to_commit() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let repo = init_repo(root);
fs::write(root.join("a.txt"), b"1\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
let err = create_commit(&repo, &commit_req("noop"), &mut NullProgress).unwrap_err();
assert!(matches!(err, Error::NothingToCommit));
}
#[test]
fn create_commit_detached_head() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let repo = init_repo(root);
fs::write(root.join("a.txt"), b"1\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
let first = create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
fs::write(repo.git_dir().join("HEAD"), first.oid.to_hex()).unwrap();
let err = create_commit(&repo, &commit_req("detached"), &mut NullProgress).unwrap_err();
assert!(matches!(err, Error::DetachedHead));
}
#[test]
fn create_commit_updates_reflogs() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let repo = init_repo(root);
fs::write(root.join("a.txt"), b"1\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
let outcome = create_commit(&repo, &commit_req("subject line"), &mut NullProgress).unwrap();
let branch_log = read_reflog(&repo.git_dir(), "refs/heads/main").unwrap();
assert_eq!(branch_log.len(), 1);
assert_eq!(branch_log[0].new_oid, outcome.oid);
assert_eq!(branch_log[0].message, "commit (initial): subject line");
let head_log = read_reflog(&repo.git_dir(), "HEAD").unwrap();
assert_eq!(head_log.len(), 1);
assert_eq!(head_log[0].new_oid, outcome.oid);
}
#[test]
fn create_commit_index_write_failure_does_not_advance_branch() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let repo = init_repo(root);
fs::write(root.join("a.txt"), b"1\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
set_test_inject_index_write_fail(true);
let err = create_commit(&repo, &commit_req("fail"), &mut NullProgress).unwrap_err();
set_test_inject_index_write_fail(false);
assert!(!matches!(err, Error::NothingToCommit));
assert!(
resolve_ref(&repo.git_dir(), "refs/heads/main").is_err(),
"branch must not advance when index write fails"
);
}
#[test]
fn create_commit_rolls_back_branch_when_head_reflog_fails() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let repo = init_repo(root);
fs::write(root.join("a.txt"), b"1\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
set_test_inject_reflog_fail(Some("HEAD"));
let err = create_commit(&repo, &commit_req("fail"), &mut NullProgress).unwrap_err();
set_test_inject_reflog_fail(None);
assert!(!matches!(err, Error::NothingToCommit));
assert!(
resolve_ref(&repo.git_dir(), "refs/heads/main").is_err(),
"branch must not advance when HEAD reflog fails"
);
assert!(read_reflog(&repo.git_dir(), "refs/heads/main")
.unwrap()
.is_empty());
assert!(read_reflog(&repo.git_dir(), "HEAD").unwrap().is_empty());
}
#[test]
fn create_commit_leaves_valid_cache_tree() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let repo = init_repo(root);
let mut index = Index::new();
stage_file_in_index(&repo, &mut index, "dir/one.txt", b"1\n");
stage_file_in_index(&repo, &mut index, "dir/two.txt", b"2\n");
write_index(&repo, &mut index);
let outcome = create_commit(&repo, &commit_req("tree"), &mut NullProgress).unwrap();
let index = repo.load_index().unwrap();
verify_cache_tree(&index).expect("cache-tree valid");
assert!(cache_tree_fully_valid(
&repo.odb(),
index.cache_tree.as_ref()
));
let commit_obj = repo.odb().read(&outcome.oid).unwrap();
let tree = parse_commit(&commit_obj.data).unwrap().tree;
assert_eq!(index.cache_tree_root, Some(tree));
}
#[test]
fn create_commit_amend_same_tree() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let repo = init_repo(root);
fs::write(root.join("a.txt"), b"1\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
fs::write(root.join("a.txt"), b"2\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
create_commit(&repo, &commit_req("second"), &mut NullProgress).unwrap();
let mut req = commit_req("");
req.amend = true;
let tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
let outcome = create_commit(&repo, &req, &mut NullProgress).unwrap();
assert_eq!(
outcome.oid, tip,
"same tree and message with fixed committer yields the same object id"
);
assert!(outcome.parent.is_some());
let branch_log = read_reflog(&repo.git_dir(), "refs/heads/main").unwrap();
assert_eq!(branch_log.len(), 3);
assert_eq!(branch_log[2].message, "commit (amend): second");
}
#[test]
fn create_commit_amend_preserves_message_bytes() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let repo = init_repo(root);
fs::write(root.join("a.txt"), b"a\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
create_commit(&repo, &commit_req("oneline subject"), &mut NullProgress).unwrap();
let baseline = {
let oid = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
parse_commit(&repo.odb().read(&oid).unwrap().data)
.unwrap()
.message
};
for extra in 1..=2 {
fs::write(root.join("a.txt"), format!("a\n{extra}\n")).unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
let mut req = commit_req("");
req.amend = true;
create_commit(&repo, &req, &mut NullProgress).unwrap();
let oid = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
let message = parse_commit(&repo.odb().read(&oid).unwrap().data)
.unwrap()
.message;
assert_eq!(message, baseline, "after amend {extra}");
}
}
#[test]
fn create_commit_amend_unborn() {
let tmp = TempDir::new().unwrap();
let repo = init_repo(tmp.path());
fs::write(tmp.path().join("a.txt"), b"1\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
let mut req = commit_req("nope");
req.amend = true;
let err = create_commit(&repo, &req, &mut NullProgress).unwrap_err();
assert!(matches!(err, Error::AmendUnborn));
}
#[test]
fn create_commit_allow_empty() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let repo = init_repo(root);
fs::write(root.join("a.txt"), b"1\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
let mut req = commit_req("empty");
req.allow_empty = true;
let parent = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
let outcome = create_commit(&repo, &req, &mut NullProgress).unwrap();
assert_eq!(outcome.changes, 0);
assert_eq!(outcome.parent, Some(parent));
assert_ne!(outcome.oid, parent);
}
#[test]
fn scope_staged_commits_index_only() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let repo = init_repo(root);
fs::write(root.join("a.txt"), b"1\n").unwrap();
fs::write(root.join("b.txt"), b"1\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
fs::write(root.join("a.txt"), b"2\n").unwrap();
fs::write(root.join("b.txt"), b"2\n").unwrap();
let mut index = repo.load_index().unwrap();
stage_file_in_index(&repo, &mut index, "a.txt", b"2\n");
write_index(&repo, &mut index);
let outcome = commit_with_scope(
&repo,
&commit_req("only a"),
CommitScope::Staged,
&mut NullProgress,
)
.unwrap();
assert_eq!(outcome.changes, 1);
let index = repo.load_index().unwrap();
let b_oid = index.get(b"b.txt", 0).unwrap().oid;
let b_blob = repo.odb().read(&b_oid).unwrap();
assert_eq!(b_blob.data, b"1\n", "unstaged paths stay in the index");
}
#[test]
fn scope_paths_leaves_other_staged_entries() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let repo = init_repo(root);
fs::write(root.join("file"), b"1\n").unwrap();
fs::write(root.join("baz"), b"1\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
create_commit(&repo, &commit_req("initial"), &mut NullProgress).unwrap();
fs::write(root.join("file"), b"2\n").unwrap();
fs::write(root.join("baz"), b"2\n").unwrap();
stage(
&repo,
&StageOptions {
pathspecs: vec!["baz".into()],
..StageOptions::default()
},
&mut NullProgress,
)
.unwrap();
let paths = ResolvedPathspecs {
specs: vec!["file".into()],
sources: vec!["file".into()],
scoped: true,
};
commit_with_scope(
&repo,
&commit_req("file only"),
CommitScope::Paths(paths),
&mut NullProgress,
)
.unwrap();
let index = repo.load_index().unwrap();
let baz_oid = index.get(b"baz", 0).unwrap().oid;
assert_eq!(repo.odb().read(&baz_oid).unwrap().data, b"2\n");
}
#[test]
fn scope_paths_deleted_and_untracked() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let repo = init_repo(root);
fs::write(root.join("tracked.txt"), b"1\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
fs::write(root.join("new.txt"), b"new\n").unwrap();
fs::remove_file(root.join("tracked.txt")).unwrap();
let paths = ResolvedPathspecs {
specs: vec!["new.txt".into(), "tracked.txt".into()],
sources: vec!["new.txt".into(), "tracked.txt".into()],
scoped: true,
};
commit_with_scope(
&repo,
&commit_req("add and remove"),
CommitScope::Paths(paths),
&mut NullProgress,
)
.unwrap();
let index = repo.load_index().unwrap();
assert!(index.get(b"tracked.txt", 0).is_none());
assert!(index.get(b"new.txt", 0).is_some());
}
#[test]
fn scope_paths_no_match_errors() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let repo = init_repo(root);
fs::write(root.join("a.txt"), b"1\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
let paths = ResolvedPathspecs {
specs: vec!["missing.txt".into()],
sources: vec!["missing.txt".into()],
scoped: true,
};
let err = commit_with_scope(
&repo,
&commit_req("nope"),
CommitScope::Paths(paths),
&mut NullProgress,
)
.unwrap_err();
assert!(matches!(err, Error::PathspecNoMatch { .. }));
}
#[test]
fn scope_paths_refused_during_merge() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let repo = init_repo(root);
fs::write(root.join("a.txt"), b"1\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).unwrap();
create_commit(&repo, &commit_req("first"), &mut NullProgress).unwrap();
let tip = resolve_ref(&repo.git_dir(), "refs/heads/main").unwrap();
fs::write(repo.git_dir().join("MERGE_HEAD"), format!("{tip}\n")).unwrap();
let paths = ResolvedPathspecs {
specs: vec!["a.txt".into()],
sources: vec!["a.txt".into()],
scoped: true,
};
let err = commit_with_scope(
&repo,
&commit_req("partial"),
CommitScope::Paths(paths),
&mut NullProgress,
)
.unwrap_err();
assert!(matches!(err, Error::PartialCommitDuringMerge));
}
#[test]
fn auto_stage_config_false_defaults_to_staged() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let git = root.join(".git");
fs::create_dir_all(git.join("objects")).unwrap();
fs::create_dir_all(git.join("refs/heads")).unwrap();
fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
fs::write(
git.join("config"),
"[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tlogAllRefUpdates = true\n[commit]\n\tautoStage = false\n",
)
.unwrap();
let repo =
Repository::open(&git, &RepositoryOptions::empty().with_work_tree(root)).unwrap();
assert_eq!(
default_commit_scope_from_config(&repo.config().unwrap()),
CommitScope::Staged
);
}
}