use std::path::{
Path,
PathBuf,
};
use anyhow::{
Context,
Result,
};
use bstr::ByteSlice;
use smallvec::SmallVec;
use super::diff;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileType {
CargoLock,
Readme,
Other,
}
#[derive(Debug)]
pub struct AdditionalFile {
pub path: PathBuf,
pub working_content: String,
pub head_content: Option<String>,
pub file_type: FileType,
}
pub fn commit_version_changes(
manifest_path: &Path,
crate_name: &str,
old_version: &str,
new_version: &str,
) -> Result<()> {
commit_version_changes_with_files(manifest_path, crate_name, old_version, new_version, &[])
}
pub fn commit_version_changes_with_files(
manifest_path: &Path,
crate_name: &str,
old_version: &str,
new_version: &str,
additional_files: &[AdditionalFile],
) -> Result<()> {
let repo = gix::discover(manifest_path.parent().unwrap_or_else(|| Path::new(".")))
.context("Not in a git repository")?;
let repo_path = repo.path().parent().context("Invalid repository path")?;
let relative_path = manifest_path
.strip_prefix(repo_path)
.or_else(|_| manifest_path.strip_prefix("."))
.unwrap_or(manifest_path);
let current_content = std::fs::read_to_string(manifest_path)
.with_context(|| format!("Failed to read {}", manifest_path.display()))?;
let head = repo.head().context("Failed to read HEAD")?;
let head_commit_id = head.id().context("HEAD does not point to a commit")?;
let head_commit = repo
.find_object(head_commit_id)
.context("Failed to find HEAD commit")?
.try_into_commit()
.context("HEAD is not a commit")?;
let head_tree = head_commit.tree().context("Failed to get HEAD tree")?;
verify_version_changes(
&head_tree,
relative_path,
¤t_content,
old_version,
new_version,
)?;
let head_content = get_head_content(&head_tree, relative_path)?;
let has_other_changes =
diff::has_non_version_changes(&head_content, ¤t_content, old_version, new_version);
let staged_content = if has_other_changes {
eprintln!("⚠️ Using hunk-level staging: only version lines will be committed.");
diff::apply_version_hunks(&head_content, ¤t_content, old_version, new_version)?
} else {
current_content.clone()
};
let cargo_toml_blob_id = write_blob(&repo, &staged_content)?;
let mut file_updates: Vec<(std::path::PathBuf, gix::ObjectId)> = Vec::new();
file_updates.push((relative_path.to_path_buf(), cargo_toml_blob_id));
for file in additional_files {
let file_relative_path = file
.path
.strip_prefix(repo_path)
.or_else(|_| file.path.strip_prefix("."))
.unwrap_or(&file.path);
let content_to_commit = match (&file.head_content, file.file_type) {
(Some(head_content), FileType::Readme) => {
if diff::has_non_readme_version_changes(
head_content,
&file.working_content,
crate_name,
old_version,
new_version,
) {
eprintln!(
"⚠️ Using hunk-level staging for README.md: only version lines will be committed."
);
diff::apply_readme_version_hunks(
head_content,
&file.working_content,
crate_name,
old_version,
new_version,
)?
} else {
file.working_content.clone()
}
}
(Some(head_content), FileType::CargoLock) => {
if diff::has_non_cargo_lock_version_changes(
head_content,
&file.working_content,
crate_name,
old_version,
new_version,
) {
eprintln!(
"⚠️ Using hunk-level staging for Cargo.lock: only our crate's version will be committed."
);
diff::apply_cargo_lock_version_hunks(
head_content,
&file.working_content,
crate_name,
old_version,
new_version,
)?
} else {
file.working_content.clone()
}
}
_ => {
file.working_content.clone()
}
};
let blob_id = write_blob(&repo, &content_to_commit)?;
file_updates.push((file_relative_path.to_path_buf(), blob_id));
}
let tree_id = update_tree_with_files(&repo, &head_tree, &file_updates)?;
let commit_id = create_commit(&repo, &tree_id, head_commit_id, old_version, new_version)?;
update_head(&repo, commit_id)?;
reset_index_to_head(&repo)?;
Ok(())
}
fn get_head_content(head_tree: &gix::Tree, relative_path: &Path) -> Result<String> {
let entry = head_tree
.lookup_entry_by_path(relative_path)
.context("Failed to lookup file in HEAD tree")?
.context("File does not exist in HEAD")?;
let blob = entry
.object()
.context("Failed to get blob from tree entry")?
.try_into_blob()
.context("Tree entry is not a blob")?;
Ok(blob.data.to_str_lossy().into_owned())
}
fn verify_version_changes(
head_tree: &gix::Tree,
relative_path: &Path,
current_content: &str,
old_version: &str,
new_version: &str,
) -> Result<()> {
let has_version_changes = if let Ok(Some(entry)) = head_tree.lookup_entry_by_path(relative_path)
{
let head_blob = entry
.object()
.context("Failed to get blob from tree entry")?
.try_into_blob()
.context("Tree entry is not a blob")?;
let head_content = head_blob.data.to_str_lossy();
head_content.contains(old_version) && current_content.contains(new_version)
} else {
current_content.contains("version") && current_content.contains(new_version)
};
if !has_version_changes {
anyhow::bail!("No version-related changes found");
}
Ok(())
}
fn write_blob(repo: &gix::Repository, content: &str) -> Result<gix::ObjectId> {
let blob_id = repo
.write_object(gix::objs::Blob {
data: content.as_bytes().into(),
})
.context("Failed to write blob")?
.detach();
Ok(blob_id)
}
fn update_tree_with_files(
repo: &gix::Repository,
head_tree: &gix::Tree,
file_updates: &[(std::path::PathBuf, gix::ObjectId)],
) -> Result<gix::ObjectId> {
use std::collections::HashMap;
use gix::objs::{
Tree,
tree,
};
let mut grouped: HashMap<Vec<u8>, Vec<(Option<std::path::PathBuf>, gix::ObjectId)>> =
HashMap::new();
for (path, blob_id) in file_updates {
let mut components = path.components();
if let Some(first) = components.next() {
let first_bytes = first.as_os_str().as_encoded_bytes().to_vec();
let remaining: std::path::PathBuf = components.collect();
let remaining = if remaining.as_os_str().is_empty() {
None
} else {
Some(remaining)
};
grouped
.entry(first_bytes)
.or_default()
.push((remaining, *blob_id));
}
}
let mut tree_entries: Vec<tree::Entry> = Vec::new();
for entry in head_tree.iter() {
let entry = entry.context("Failed to iterate tree entry")?;
let entry_name = entry.filename();
if let Some(updates) = grouped.remove(entry_name.as_bytes()) {
let direct_updates: Vec<_> = updates
.iter()
.filter(|(remaining, _)| remaining.is_none())
.collect();
let nested_updates: Vec<_> = updates
.iter()
.filter_map(|(remaining, blob_id)| {
remaining.as_ref().map(|path| (path.clone(), *blob_id))
})
.collect();
if !direct_updates.is_empty() {
let (_, blob_id) = direct_updates.last().unwrap();
tree_entries.push(tree::Entry {
mode: entry.mode(),
filename: entry_name.into(),
oid: *blob_id,
});
} else if !nested_updates.is_empty() {
let subtree = entry
.object()
.context("Failed to get subtree object")?
.try_into_tree()
.context("Entry is not a tree")?;
let new_subtree_id = update_tree_with_files(repo, &subtree, &nested_updates)?;
tree_entries.push(tree::Entry {
mode: entry.mode(),
filename: entry_name.into(),
oid: new_subtree_id,
});
}
} else {
tree_entries.push(tree::Entry {
mode: entry.mode(),
filename: entry_name.into(),
oid: entry.oid().to_owned(),
});
}
}
tree_entries.sort_by(|entry_a, entry_b| {
use gix::objs::tree::EntryKind;
let a_name = if matches!(entry_a.mode.kind(), EntryKind::Tree) {
let mut name = entry_a.filename.to_vec();
name.push(b'/');
name
} else {
entry_a.filename.to_vec()
};
let b_name = if matches!(entry_b.mode.kind(), EntryKind::Tree) {
let mut name = entry_b.filename.to_vec();
name.push(b'/');
name
} else {
entry_b.filename.to_vec()
};
a_name.cmp(&b_name)
});
let tree = Tree {
entries: tree_entries,
};
let tree_id = repo
.write_object(&tree)
.context("Failed to write updated tree")?
.detach();
Ok(tree_id)
}
fn create_commit(
repo: &gix::Repository,
tree_id: &gix::ObjectId,
parent_id: gix::Id,
old_version: &str,
new_version: &str,
) -> Result<gix::ObjectId> {
use super::signing;
let commit_message = format!("chore(version): bump {} -> {}", old_version, new_version);
let author = get_signature_from_config(repo)?;
let committer = author.clone();
let parents: SmallVec<[gix::ObjectId; 1]> = SmallVec::from_iter([parent_id.detach()]);
let signing_config = signing::read_signing_config(repo);
let extra_headers = if signing_config.enabled {
let payload =
signing::build_commit_payload(tree_id, parent_id, &author, &committer, &commit_message);
match signing::sign_commit_payload(&signing_config, &payload) {
Ok(Some(signature)) => {
vec![("gpgsig".into(), signature.into())]
}
Ok(None) => {
vec![]
}
Err(err) => {
return Err(err.context("Failed to sign commit"));
}
}
} else {
vec![]
};
let commit_id = repo
.write_object(gix::objs::Commit {
tree: *tree_id,
parents,
author,
committer,
message: commit_message.into(),
encoding: None,
extra_headers,
})
.context("Failed to write commit object")?
.detach();
Ok(commit_id)
}
fn update_head(repo: &gix::Repository, commit_id: gix::ObjectId) -> Result<()> {
let mut head_ref = repo
.head()
.context("Failed to read HEAD")?
.try_into_referent()
.context("HEAD is not a reference (detached HEAD state)")?;
head_ref
.set_target_id(commit_id, "bump version")
.context("Failed to update HEAD reference")?;
Ok(())
}
fn reset_index_to_head(repo: &gix::Repository) -> Result<()> {
let mut head = repo.head().context("Failed to read HEAD")?;
let head_commit = head
.peel_to_commit()
.context("Failed to peel HEAD to commit")?;
let head_tree = head_commit.tree().context("Failed to get HEAD tree")?;
let validate_opts = gix::validate::path::component::Options::default();
let state = gix::index::State::from_tree(&head_tree.id, &repo.objects, validate_opts)
.context("Failed to create index state from tree")?;
let mut index = gix::index::File::from_state(state, repo.index_path());
index
.write(gix::index::write::Options::default())
.context("Failed to write index")?;
Ok(())
}
fn get_signature_from_config(repo: &gix::Repository) -> Result<gix::actor::Signature> {
let config = repo.config_snapshot();
let name = config
.string("user.name")
.map(|s| s.to_string())
.ok_or_else(|| {
anyhow::anyhow!(
"Git config 'user.name' is not set.\n\
Please configure it with:\n \
git config user.name \"Your Name\""
)
})?;
let email = config
.string("user.email")
.map(|s| s.to_string())
.ok_or_else(|| {
anyhow::anyhow!(
"Git config 'user.email' is not set.\n\
Please configure it with:\n \
git config user.email \"your.email@example.com\""
)
})?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.context("Failed to get current time")?;
let time = gix::date::Time {
seconds: now.as_secs() as i64,
offset: 0, };
Ok(gix::actor::Signature {
name: name.into(),
email: email.into(),
time,
})
}