//! Copyright (c) 2022-2026 GitDataAi All rights reserved. use crate::bare::GitBare; use crate::error::{GitError, GitResult}; use crate::oid::ZERO_OID; use crate::pb::{ CreateCommitRequest, CreateCommitResponse, GetCommitRequest, ObjectName, ObjectSelector, create_commit_action, object_selector, }; impl GitBare { pub fn create_commit(&self, request: CreateCommitRequest) -> GitResult { crate::sanitize::validate_ref_name(&request.branch)?; if let Some(rev) = request.start_revision.as_ref() { match rev.selector.as_ref() { Some(object_selector::Selector::Revision(name)) => { crate::sanitize::validate_revision(&name.revision)?; } Some(object_selector::Selector::Oid(oid)) => { crate::sanitize::validate_oid_hex(&oid.hex)?; } None => {} // will use branch name, already validated } } if request.actions.len() > crate::config::MAX_ACTIONS_PER_COMMIT { return Err(GitError::InvalidArgument(format!( "too many commit actions ({} > max {})", request.actions.len(), crate::config::MAX_ACTIONS_PER_COMMIT, ))); } let repo = self.gix_repo()?; let branch = request.branch.clone(); tracing::debug!( repo = %self.bare_dir.display(), branch = %branch, actions = request.actions.len(), "creating commit" ); let start_rev = match request.start_revision.clone().and_then(|s| s.selector) { Some(object_selector::Selector::Oid(oid)) => { crate::sanitize::validate_oid_hex(&oid.hex)?; oid.hex } Some(object_selector::Selector::Revision(name)) => name.revision, None => request.branch.clone(), }; let parent_id = match repo.rev_parse_single(start_rev.as_str()) { Ok(id) => Some(id.to_string()), Err(_) => None, // branch/revision does not exist yet — will create initial commit }; let current_branch_tip = match repo.find_reference(format!("refs/heads/{}", request.branch).as_str()) { Ok(mut r) => r.peel_to_id().ok().map(|id| id.to_string()), Err(_) => None, // branch does not exist yet }; let tree_id = if request.actions.is_empty() { let Some(parent) = parent_id.as_ref() else { return Err(GitError::InvalidArgument( "cannot create an empty root commit without file actions".into(), )); }; self.rev_parse_tree(parent)? } else { self.tree_from_actions(parent_id.as_deref(), &request.actions)? }; let message = commit_message_with_trailers(&request); let parents: Vec = parent_id.iter().cloned().collect(); let commit_id = self.commit_tree( &tree_id, &parents, &message, request.author.as_ref(), request.committer.as_ref(), )?; self.update_branch_after_commit(&request, &commit_id, current_branch_tip.as_deref())?; let revision = Some(ObjectSelector { selector: Some(object_selector::Selector::Revision(ObjectName { revision: commit_id, })), }); Ok(CreateCommitResponse { commit: Some(self.get_commit(GetCommitRequest { repository: request.repository, revision, include_stats: false, include_raw: false, })?), branch: request.branch, }) } fn rev_parse_tree(&self, revision: &str) -> GitResult { let result = duct::cmd( "git", [ "--git-dir", self.bare_dir.to_string_lossy().as_ref(), "rev-parse", &format!("{revision}^{{tree}}"), ], ) .stdout_capture() .stderr_capture() .unchecked() .run()?; command_ok(result).map(|stdout| stdout.trim().to_string()) } fn tree_from_actions( &self, parent_id: Option<&str>, actions: &[crate::pb::CreateCommitAction], ) -> GitResult { // Use system temp directory instead of bare_dir to avoid clutter // and ensure proper cleanup even if process crashes let tmp_index = tempfile::Builder::new() .prefix("gitks-index-") .tempfile() .map_err(GitError::Io)?; let tmp_index_path = tmp_index.path().to_string_lossy().into_owned(); if let Some(parent) = parent_id { let read_tree = duct::cmd( "git", [ "--git-dir", self.bare_dir.to_string_lossy().as_ref(), "read-tree", parent, ], ) .env("GIT_INDEX_FILE", &tmp_index_path) .stdout_capture() .stderr_capture() .unchecked() .run()?; command_ok(read_tree)?; } for action in actions { self.apply_commit_action(&tmp_index_path, action)?; } let write_tree = duct::cmd( "git", [ "--git-dir", self.bare_dir.to_string_lossy().as_ref(), "write-tree", ], ) .env("GIT_INDEX_FILE", &tmp_index_path) .stdout_capture() .stderr_capture() .unchecked() .run()?; command_ok(write_tree).map(|stdout| stdout.trim().to_string()) } fn apply_commit_action( &self, index_path: &str, action: &crate::pb::CreateCommitAction, ) -> GitResult<()> { if action.content.len() > crate::config::MAX_ACTION_CONTENT_BYTES { return Err(GitError::InvalidArgument(format!( "action content too large ({} bytes, max {})", action.content.len(), crate::config::MAX_ACTION_CONTENT_BYTES, ))); } if !action.file_path.is_empty() { crate::sanitize::validate_file_path(&action.file_path)?; } if !action.previous_path.is_empty() { crate::sanitize::validate_file_path(&action.previous_path)?; } let action_type = create_commit_action::Action::try_from(action.action) .unwrap_or(create_commit_action::Action::CreateCommitActionUnspecified); match action_type { create_commit_action::Action::CreateCommitActionCreate | create_commit_action::Action::CreateCommitActionUpdate => { let hash = duct::cmd( "git", [ "--git-dir", self.bare_dir.to_string_lossy().as_ref(), "hash-object", "-w", "--stdin", ], ) .stdin_bytes(action.content.clone()) .stdout_capture() .stderr_capture() .unchecked() .run()?; let blob = command_ok(hash)?.trim().to_string(); let mode = if action.executable { "100755" } else { "100644" }; self.update_index_cacheinfo(index_path, mode, &blob, &action.file_path) } create_commit_action::Action::CreateCommitActionDelete => { let result = duct::cmd( "git", [ "--git-dir", self.bare_dir.to_string_lossy().as_ref(), "update-index", "--force-remove", &action.file_path, ], ) .env("GIT_INDEX_FILE", index_path) .env("GIT_WORK_TREE", self.bare_dir.to_string_lossy().as_ref()) .stdout_capture() .stderr_capture() .unchecked() .run()?; command_ok(result).map(|_| ()) } create_commit_action::Action::CreateCommitActionMove => { if action.previous_path.is_empty() { return Err(GitError::InvalidArgument( "MOVE action requires previous_path".into(), )); } let (mode, oid) = self.index_entry(index_path, &action.previous_path)?; self.update_index_cacheinfo(index_path, &mode, &oid, &action.file_path)?; let remove = duct::cmd( "git", [ "--git-dir", self.bare_dir.to_string_lossy().as_ref(), "update-index", "--force-remove", &action.previous_path, ], ) .env("GIT_INDEX_FILE", index_path) .env("GIT_WORK_TREE", self.bare_dir.to_string_lossy().as_ref()) .stdout_capture() .stderr_capture() .unchecked() .run()?; command_ok(remove).map(|_| ()) } create_commit_action::Action::CreateCommitActionChmod => { let (_old_mode, oid) = self.index_entry(index_path, &action.file_path)?; let mode = if action.executable { "100755" } else { "100644" }; self.update_index_cacheinfo(index_path, mode, &oid, &action.file_path) } create_commit_action::Action::CreateCommitActionUnspecified => Err( GitError::InvalidArgument("unspecified commit action".into()), ), } } fn index_entry(&self, index_path: &str, path: &str) -> GitResult<(String, String)> { let result = duct::cmd( "git", [ "--git-dir", self.bare_dir.to_string_lossy().as_ref(), "ls-files", "-s", "--", path, ], ) .env("GIT_INDEX_FILE", index_path) .env("GIT_WORK_TREE", self.bare_dir.to_string_lossy().as_ref()) .stdout_capture() .stderr_capture() .unchecked() .run()?; let stdout = command_ok(result)?; let line = stdout .lines() .next() .ok_or_else(|| GitError::NotFound(path.to_string()))?; let parts = line.split_whitespace().collect::>(); if parts.len() < 2 { return Err(GitError::ParseError(format!( "invalid index entry for {path}: {line}" ))); } Ok((parts[0].to_string(), parts[1].to_string())) } fn update_index_cacheinfo( &self, index_path: &str, mode: &str, oid: &str, path: &str, ) -> GitResult<()> { let result = duct::cmd( "git", [ "--git-dir", self.bare_dir.to_string_lossy().as_ref(), "update-index", "--add", "--cacheinfo", mode, oid, path, ], ) .env("GIT_INDEX_FILE", index_path) .env("GIT_WORK_TREE", self.bare_dir.to_string_lossy().as_ref()) .stdout_capture() .stderr_capture() .unchecked() .run()?; command_ok(result).map(|_| ()) } pub(crate) fn commit_tree( &self, tree_id: &str, parent_ids: &[String], message: &str, author: Option<&crate::pb::Signature>, committer: Option<&crate::pb::Signature>, ) -> GitResult { if message.len() > crate::config::MAX_COMMIT_MESSAGE_BYTES { return Err(GitError::InvalidArgument(format!( "commit message too large ({} bytes, max {})", message.len(), crate::config::MAX_COMMIT_MESSAGE_BYTES, ))); } let mut args = vec![ "--git-dir".to_string(), self.bare_dir.to_string_lossy().into_owned(), "commit-tree".into(), tree_id.to_string(), ]; for parent in parent_ids { args.push("-p".into()); args.push(parent.clone()); } args.push("-m".into()); args.push(message.to_string()); let mut cmd = duct::cmd("git", &args).stdout_capture().stderr_capture(); if let Some(author) = author.and_then(|s| s.identity.as_ref()) { cmd = cmd .env("GIT_AUTHOR_NAME", &author.name) .env("GIT_AUTHOR_EMAIL", &author.email); } if let Some(committer) = committer.and_then(|s| s.identity.as_ref()) { cmd = cmd .env("GIT_COMMITTER_NAME", &committer.name) .env("GIT_COMMITTER_EMAIL", &committer.email); } let commit = cmd.unchecked().run()?; command_ok(commit).map(|stdout| stdout.trim().to_string()) } fn update_branch_after_commit( &self, request: &CreateCommitRequest, commit_id: &str, current_branch_tip: Option<&str>, ) -> GitResult<()> { let refname = format!("refs/heads/{}", request.branch); let mut args = vec![ "--git-dir".to_string(), self.bare_dir.to_string_lossy().into_owned(), "update-ref".into(), refname, commit_id.to_string(), ]; if !request.force { args.push(current_branch_tip.unwrap_or(ZERO_OID).to_string()); } let update = duct::cmd("git", &args) .stdout_capture() .stderr_capture() .unchecked() .run()?; command_ok(update).map(|_| ()) } } fn commit_message_with_trailers(request: &CreateCommitRequest) -> String { if request.trailers.is_empty() { return request.message.clone(); } let mut message = request.message.trim_end().to_string(); message.push_str("\n\n"); for trailer in &request.trailers { let separator = if trailer.separator_present { ": " } else { " " }; message.push_str(&trailer.key); message.push_str(separator); message.push_str(&trailer.value); message.push('\n'); } message } pub(crate) fn command_ok(output: std::process::Output) -> GitResult { if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } else { Err(GitError::CommandFailed { status_code: output.status.code(), stderr: String::from_utf8_lossy(&output.stderr).into_owned(), }) } }