434 lines
15 KiB
Rust
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(),
|
|
})
|
|
}
|
|
}
|