Files
gitks/commit/create_commit.rs

434 lines
15 KiB
Rust

//! 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<CreateCommitResponse> {
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<String> = 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<String> {
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<String> {
// 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::<Vec<_>>();
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<String> {
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<String> {
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(),
})
}
}