use crate::fleet::config::gitmodules::validate_gitmodules_structure;
use crate::prelude::*;
use anyhow::{bail, Context, Result};
use gix::bstr::BString;
use gix::remote;
use gix::status;
use gix::status::index_worktree;
use gix::status::index_worktree::iter::Summary as IndexWorktreeSummary;
use ignore::WalkBuilder;
use std::collections::BTreeSet;
use std::fs;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BranchKind {
Local,
Remote,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FileStatusKind {
Staged,
Modified,
Untracked,
Ignored,
Deleted,
Renamed,
Conflict,
}
#[derive(Debug, Clone)]
pub struct StatusEntry {
pub path: String,
pub status: FileStatusKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PushOutcome {
Updated,
UpToDate,
}
pub fn discover_repo(path: &Path) -> Result<gix::Repository> {
gix::discover(path)
.with_context(|| format!("Failed to discover git repository from {}", path.display()))
}
pub fn is_git_repository(path: &Path) -> bool {
gix::discover(path).is_ok()
}
pub fn repo_root(path: &Path) -> Result<PathBuf> {
let repo = discover_repo(path)?;
repo.workdir()
.map(Path::to_path_buf)
.or_else(|| repo.git_dir().parent().map(Path::to_path_buf))
.context("Discovered repository does not expose a usable root path")
}
pub fn init_repository(path: &Path) -> Result<gix::Repository> {
let output = std::process::Command::new("git")
.args(["init"])
.current_dir(path)
.output()
.context("Failed to execute git init")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git init failed: {}", stderr.trim());
}
open_repo(path)
}
pub fn configure_local_user(repo: &gix::Repository, name: &str, email: &str) -> Result<()> {
let config_path = repo.git_dir().join("config");
let existing = fs::read_to_string(&config_path).with_context(|| {
format!(
"Failed to read repository config at {}",
config_path.display()
)
})?;
let user_block = format!("[user]\n\tname = {name}\n\temail = {email}\n");
let updated = replace_or_append_config_section(&existing, "user", &user_block);
fs::write(&config_path, updated).with_context(|| {
format!(
"Failed to write repository config at {}",
config_path.display()
)
})
}
pub fn open_repo(path: &Path) -> Result<gix::Repository> {
gix::open(path).with_context(|| format!("Failed to open git repository at {}", path.display()))
}
pub fn current_branch(repo: &gix::Repository) -> Result<String> {
let head = repo.head().context("Failed to read HEAD")?;
if head.is_detached() {
bail!("Detached HEAD");
}
let name = head
.referent_name()
.context("HEAD is not attached to a branch")?
.shorten()
.to_string();
Ok(name)
}
pub fn is_detached_head(repo: &gix::Repository) -> Result<bool> {
Ok(repo.head().context("Failed to read HEAD")?.is_detached())
}
pub fn remote_url(repo: &gix::Repository, remote_name: &str) -> Result<String> {
let remote = repo
.find_remote(remote_name)
.with_context(|| format!("Remote '{remote_name}' not found"))?;
let url = remote
.url(remote::Direction::Fetch)
.context("Remote does not have a fetch URL configured")?;
Ok(url.to_bstring().to_string())
}
pub fn branch_exists(repo: &gix::Repository, branch_name: &str, kind: BranchKind) -> Result<bool> {
let full_name = match kind {
BranchKind::Local => format!("refs/heads/{branch_name}"),
BranchKind::Remote => format!("refs/remotes/{branch_name}"),
};
Ok(repo
.try_find_reference(full_name.as_str())
.context("Failed to query branch reference")?
.is_some())
}
pub fn local_branches(repo: &gix::Repository) -> Result<Vec<String>> {
let mut branches: Vec<String> = repo
.references()
.context("Failed to list references")?
.local_branches()
.context("Failed to list local branches")?
.flatten()
.map(|branch| branch.name().shorten().to_string())
.collect();
branches.sort();
Ok(branches)
}
pub fn remote_branches(repo: &gix::Repository) -> Result<Vec<String>> {
let mut branches: Vec<String> = repo
.references()
.context("Failed to list references")?
.remote_branches()
.context("Failed to list remote branches")?
.flatten()
.map(|branch| branch.name().shorten().to_string())
.collect();
branches.sort();
Ok(branches)
}
pub fn is_clean(repo: &gix::Repository) -> Result<bool> {
let entries = status_entries(repo)?;
Ok(entries
.iter()
.all(|entry| entry.status == FileStatusKind::Ignored))
}
pub fn has_uncommitted_changes(entries: &[StatusEntry]) -> bool {
entries
.iter()
.any(|entry| entry.status != FileStatusKind::Ignored)
}
pub fn diff_file(repo: &gix::Repository, rel_path: &str) -> Result<String> {
let workdir = repo
.workdir()
.context("bare repositories are not supported for diff")?;
let unstaged = std::process::Command::new("git")
.args(["diff", "HEAD", "--", rel_path])
.current_dir(workdir)
.output()
.context("failed to execute git diff")?;
if !unstaged.status.success() {
let stderr = String::from_utf8_lossy(&unstaged.stderr);
bail!("git diff failed: {}", stderr.trim());
}
let unstaged_text =
String::from_utf8(unstaged.stdout).context("git diff output is not valid UTF-8")?;
if !unstaged_text.is_empty() {
return Ok(unstaged_text);
}
let staged = std::process::Command::new("git")
.args(["diff", "--cached", "HEAD", "--", rel_path])
.current_dir(workdir)
.output()
.context("failed to execute git diff --cached")?;
if !staged.status.success() {
let stderr = String::from_utf8_lossy(&staged.stderr);
bail!("git diff --cached failed: {}", stderr.trim());
}
String::from_utf8(staged.stdout).context("git diff --cached output is not valid UTF-8")
}
fn dir_has_non_ignored_files(dir: &Path) -> bool {
if !dir.is_dir() {
return true; }
let walker = WalkBuilder::new(dir)
.hidden(false) .git_ignore(true) .git_global(true) .git_exclude(true) .build();
for entry in walker {
let Ok(entry) = entry else { continue };
if entry.file_type().map_or(false, |ft| ft.is_file()) {
return true;
}
}
false
}
pub fn status_entries(repo: &gix::Repository) -> Result<Vec<StatusEntry>> {
if let Some(workdir) = repo.workdir() {
let gitmodules = workdir.join(".gitmodules");
if gitmodules.exists() {
let bytes = std::fs::read(&gitmodules)
.with_context(|| format!("failed to read {}", gitmodules.display()))?;
validate_gitmodules_structure(&bytes, &gitmodules)?;
}
}
let workdir = repo
.workdir()
.context("bare repository has no working directory")?
.to_path_buf();
let mut staged: BTreeSet<String> = BTreeSet::new();
let mut worktree: std::collections::BTreeMap<String, FileStatusKind> =
std::collections::BTreeMap::new();
let mut platform = repo
.status(gix::progress::Discard)
.context("Failed to initialise status")?;
platform.dirwalk_options_mut(|opts| {
opts.set_emit_ignored(Some(gix::dir::walk::EmissionMode::CollapseDirectory));
opts.set_emit_untracked(gix::dir::walk::EmissionMode::CollapseDirectory);
});
let iter = platform
.into_iter(Vec::<BString>::new())
.context("Failed to compute repository status")?;
for item in iter {
match item.context("Failed to inspect status entry")? {
status::Item::TreeIndex(change) => match change {
gix::diff::index::Change::Addition { location, .. }
| gix::diff::index::Change::Modification { location, .. } => {
staged.insert(location.to_string());
}
gix::diff::index::Change::Deletion { location, .. } => {
worktree.insert(location.to_string(), FileStatusKind::Deleted);
}
gix::diff::index::Change::Rewrite {
source_location,
location,
..
} => {
worktree.insert(source_location.to_string(), FileStatusKind::Deleted);
worktree.insert(location.to_string(), FileStatusKind::Renamed);
}
},
status::Item::IndexWorktree(entry) => match entry {
index_worktree::Item::Modification { ref rela_path, .. } => match entry.summary() {
Some(IndexWorktreeSummary::Conflict) => {
worktree.insert(rela_path.to_string(), FileStatusKind::Conflict);
}
Some(IndexWorktreeSummary::Removed) => {
worktree.insert(rela_path.to_string(), FileStatusKind::Deleted);
}
Some(IndexWorktreeSummary::TypeChange)
| Some(IndexWorktreeSummary::Modified)
| Some(IndexWorktreeSummary::Copied) => {
worktree.insert(rela_path.to_string(), FileStatusKind::Modified);
}
Some(IndexWorktreeSummary::IntentToAdd) => {
staged.insert(rela_path.to_string());
}
Some(IndexWorktreeSummary::Added) => {
worktree.insert(rela_path.to_string(), FileStatusKind::Untracked);
}
Some(IndexWorktreeSummary::Renamed) => {
worktree.insert(rela_path.to_string(), FileStatusKind::Renamed);
}
None => {}
},
index_worktree::Item::DirectoryContents {
entry,
collapsed_directory_status: _,
} => {
let status = match entry.status {
gix::dir::entry::Status::Untracked => {
let is_dir = entry.disk_kind.map_or(false, |k| k.is_dir());
if is_dir {
let abs = workdir.join(entry.rela_path.to_string());
if dir_has_non_ignored_files(&abs) {
Some(FileStatusKind::Untracked)
} else {
None
}
} else {
Some(FileStatusKind::Untracked)
}
}
gix::dir::entry::Status::Ignored(_) => Some(FileStatusKind::Ignored),
gix::dir::entry::Status::Tracked | gix::dir::entry::Status::Pruned => None,
};
if let Some(status) = status {
worktree.insert(entry.rela_path.to_string(), status);
}
}
index_worktree::Item::Rewrite {
dirwalk_entry,
source,
..
} => {
worktree.insert(dirwalk_entry.rela_path.to_string(), FileStatusKind::Renamed);
worktree.insert(source.rela_path().to_string(), FileStatusKind::Deleted);
}
},
}
}
let mut result: std::collections::BTreeMap<String, FileStatusKind> =
std::collections::BTreeMap::new();
for path in &staged {
if let Some(wt_status) = worktree.get(path) {
result.insert(path.clone(), *wt_status);
} else {
result.insert(path.clone(), FileStatusKind::Staged);
}
}
for (path, kind) in &worktree {
result.entry(path.clone()).or_insert(*kind);
}
Ok(result
.into_iter()
.map(|(path, status)| StatusEntry { path, status })
.collect())
}
pub fn get_default_branch(repo: &gix::Repository) -> Result<String> {
for branch in ["main", "master"] {
if branch_exists(repo, branch, BranchKind::Local)? {
return Ok(branch.to_string());
}
}
local_branches(repo)?
.into_iter()
.next()
.context("No branches found")
}
pub fn fetch(repo: &gix::Repository, remote_name: Option<&str>) -> Result<()> {
let workdir = repo
.workdir()
.context("Bare repositories are not supported for fetch")?;
let remote = remote_name.unwrap_or("origin");
let output = std::process::Command::new("git")
.args(["fetch", remote])
.current_dir(workdir)
.output()
.context("Failed to execute git fetch")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git fetch failed: {}", stderr.trim());
}
Ok(())
}
pub fn fetch_all(repo: &gix::Repository) -> Result<()> {
let workdir = repo
.workdir()
.context("Bare repositories are not supported for fetch")?;
let output = std::process::Command::new("git")
.args(["fetch", "--all"])
.current_dir(workdir)
.output()
.context("Failed to execute git fetch --all")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git fetch --all failed: {}", stderr.trim());
}
Ok(())
}
pub fn pull(repo: &gix::Repository, rebase: bool) -> Result<()> {
if rebase {
bail!("Rebase pulls are not supported");
}
let head = repo.head().context("Failed to read HEAD")?;
let branch = head
.referent_name()
.context("Detached HEAD is not supported for pull")?;
let remote_name = match repo
.branch_remote(branch.shorten(), gix::remote::Direction::Fetch)
.transpose()
.context("Failed to determine branch remote")?
{
Some(remote) => remote
.name()
.cloned()
.context("Remote name must be available")?,
None => repo
.find_default_remote(gix::remote::Direction::Fetch)
.context("No fetch remote configured for current branch")??
.name()
.cloned()
.context("Remote name must be available")?,
};
let remote_name_str = remote_name
.as_symbol()
.context("Remote name must be symbolic")?
.to_string();
fetch(repo, Some(&remote_name_str))?;
let tracking = repo
.branch_remote_tracking_ref_name(branch, gix::remote::Direction::Fetch)
.transpose()
.context("Failed to resolve tracking branch")?
.context("No tracking branch configured for current branch")?;
merge_ff(repo, &tracking.to_string())
}
pub fn nearest_tag(repo: &gix::Repository, revspec: Option<&str>) -> Result<Option<String>> {
let mut repo = repo.clone();
repo.object_cache_size_if_unset(4 * 1024 * 1024);
let commit = match revspec {
Some(spec) => repo
.rev_parse_single(spec)
.with_context(|| format!("Failed to resolve revision '{spec}'"))?
.object()
.context("Failed to open revision object")?
.try_into_commit()
.context("Revision does not resolve to a commit")?,
None => match repo.head_commit() {
Ok(commit) => commit,
Err(_) => return Ok(None),
},
};
let resolution = match commit.describe().try_resolve() {
Ok(Some(resolution)) => resolution,
Ok(None) => return Ok(None),
Err(_) => return Ok(None),
};
let describe = resolution.format()?.to_string();
Ok(Some(
describe
.split_once('-')
.map(|(tag, _)| tag.to_string())
.unwrap_or(describe),
))
}
pub fn list_tags(repo: &gix::Repository) -> Result<Vec<String>> {
let mut tags: Vec<String> = repo
.references()
.context("Failed to list references")?
.tags()
.context("Failed to list tags")?
.flatten()
.map(|tag| tag.name().shorten().to_string())
.collect();
tags.sort_by(|a, b| {
version_sort_key(b)
.cmp(&version_sort_key(a))
.then_with(|| b.cmp(a))
});
Ok(tags)
}
pub fn total_commit_count(repo: &gix::Repository, revspec: Option<&str>) -> Result<u32> {
let commit = match revspec {
Some(spec) => resolve_commit_id(repo, spec)?,
None => match repo.head_id() {
Ok(id) => id.detach(),
Err(_) => return Ok(0),
},
};
let commit = repo.find_commit(commit).context("Failed to open commit")?;
let mut count = 0u32;
for ancestor in commit
.ancestors()
.all()
.context("Failed to walk commit history")?
{
let _ = ancestor.context("Failed to inspect ancestor commit")?;
count = count.saturating_add(1);
}
Ok(count)
}
pub fn commit_count_since(repo: &gix::Repository, revspec: &str) -> Result<u32> {
if revspec == "v0" {
return total_commit_count(repo, None);
}
let stop = resolve_commit_id(repo, revspec)?;
let head = match repo.head_id() {
Ok(id) => id.detach(),
Err(_) => return Ok(0),
};
count_commits_until(repo, head, Some(stop))
}
pub fn change_count_since(repo: &gix::Repository, revspec: Option<&str>) -> Result<u32> {
let stop = match revspec {
Some(spec) => Some(resolve_commit_id(repo, spec)?),
None => None,
};
let head = match repo.head_id() {
Ok(id) => id.detach(),
Err(_) => return Ok(0),
};
count_tree_changes_until(repo, head, stop)
}
pub fn count_commits_since_midnight(repo: &gix::Repository) -> Result<usize> {
let now = chrono::Local::now();
let midnight = now
.date_naive()
.and_hms_opt(0, 0, 0)
.context("Failed to compute local midnight")?
.and_local_timezone(*now.offset())
.single()
.context("Failed to resolve local midnight timezone")?
.timestamp();
let head = match repo.head_id() {
Ok(id) => id.detach(),
Err(_) => return Ok(0),
};
let commit = repo
.find_commit(head)
.context("Failed to open HEAD commit")?;
let mut count = 0usize;
for ancestor in commit
.ancestors()
.all()
.context("Failed to walk commit history")?
{
let ancestor = ancestor.context("Failed to inspect ancestor commit")?;
if ancestor.commit_time.unwrap_or(i64::MIN) < midnight {
break;
}
count += 1;
}
Ok(count)
}
pub fn has_changes(repo: &gix::Repository) -> Result<bool> {
Ok(!status_entries(repo)?.is_empty())
}
pub fn stage_paths(repo: &gix::Repository, paths: &[String]) -> Result<Vec<String>> {
if paths.is_empty() {
return Ok(Vec::new());
}
let workdir = repo
.workdir()
.context("Bare repositories are not supported")?;
let mut args = vec!["add".to_string()];
for path in paths {
args.push(path.clone());
}
let output = std::process::Command::new("git")
.args(&args)
.current_dir(workdir)
.output()
.context("Failed to execute git add")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git add failed: {}", stderr.trim());
}
Ok(paths.to_vec())
}
pub fn checkout_branch(repo: &gix::Repository, branch_name: &str) -> Result<()> {
let workdir = repo
.workdir()
.context("Bare repositories are not supported")?;
let output = std::process::Command::new("git")
.args(["checkout", branch_name])
.current_dir(workdir)
.output()
.context("Failed to execute git checkout")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git checkout failed: {}", stderr.trim());
}
Ok(())
}
pub fn create_branch(
repo: &gix::Repository,
branch_name: &str,
from_ref: Option<&str>,
) -> Result<()> {
let workdir = repo
.workdir()
.context("Bare repositories are not supported")?;
let mut args = vec![
"checkout".to_string(),
"-b".to_string(),
branch_name.to_string(),
];
if let Some(from) = from_ref {
args.push(from.to_string());
}
let output = std::process::Command::new("git")
.args(&args)
.current_dir(workdir)
.output()
.context("Failed to execute git checkout -b")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git checkout -b failed: {}", stderr.trim());
}
Ok(())
}
pub fn delete_local_branch(repo: &gix::Repository, branch_name: &str, force: bool) -> Result<()> {
let current = current_branch(repo).ok();
if current.as_deref() == Some(branch_name) {
bail!("Cannot delete the currently checked out branch '{branch_name}'");
}
let workdir = repo
.workdir()
.context("Bare repositories are not supported for branch deletion")?;
let delete_flag = if force { "-D" } else { "-d" };
let output = std::process::Command::new("git")
.args(["branch", delete_flag, branch_name])
.current_dir(workdir)
.output()
.context("Failed to execute git branch deletion")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git branch {delete_flag} failed: {}", stderr.trim());
}
Ok(())
}
pub fn stage_all(repo: &gix::Repository) -> Result<()> {
let workdir = repo
.workdir()
.context("Bare repositories are not supported")?;
let output = std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(workdir)
.output()
.context("Failed to execute git add -A")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git add -A failed: {}", stderr.trim());
}
Ok(())
}
pub fn commit_all(repo: &gix::Repository, message: &str) -> Result<Option<String>> {
let workdir = repo
.workdir()
.context("Bare repositories are not supported")?;
let output = std::process::Command::new("git")
.args(["commit", "-m", message])
.current_dir(workdir)
.output()
.context("Failed to execute git commit")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined = format!("{} {}", stderr.trim(), stdout.trim());
if combined.contains("nothing to commit") || combined.contains("nothing added to commit") {
return Ok(None);
}
let msg = if stderr.trim().is_empty() {
stdout.trim().to_string()
} else {
stderr.trim().to_string()
};
bail!("git commit failed: {}", msg);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let commit_hash = stdout
.lines()
.find(|line| line.starts_with('['))
.and_then(|line| line.split_whitespace().nth(1))
.map(|s| s.to_string());
Ok(commit_hash)
}
pub fn create_tag(repo: &gix::Repository, name: &str, message: &str) -> Result<()> {
let workdir = repo
.workdir()
.context("Bare repositories are not supported")?;
let output = std::process::Command::new("git")
.args(["tag", "-a", name, "-m", message])
.current_dir(workdir)
.output()
.context("Failed to execute git tag")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git tag failed: {}", stderr.trim());
}
Ok(())
}
pub fn merge_ff(repo: &gix::Repository, branch: &str) -> Result<()> {
let workdir = repo
.workdir()
.context("Bare repositories are not supported")?;
let current_commit = repo
.head_id()
.context("Failed to resolve current HEAD")?
.detach();
let target_commit = resolve_commit_id(repo, branch)?;
let merge_base = repo
.merge_base(current_commit, target_commit)
.context("Failed to compute merge base")?;
if merge_base != current_commit {
bail!("Fast-forward merge is not possible");
}
let output = std::process::Command::new("git")
.args(["merge", "--ff-only", branch])
.current_dir(workdir)
.output()
.context("Failed to execute git merge --ff-only")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git merge --ff-only failed: {}", stderr.trim());
}
Ok(())
}
pub fn checkout_commit(repo: &gix::Repository, commit: gix::ObjectId) -> Result<()> {
let workdir = repo
.workdir()
.context("Bare repositories are not supported")?;
let output = std::process::Command::new("git")
.args(["checkout", &commit.to_string()])
.current_dir(workdir)
.output()
.context("Failed to execute git checkout")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git checkout failed: {}", stderr.trim());
}
Ok(())
}
pub fn clone_repository(url: &str, destination: &Path) -> Result<gix::Repository> {
let output = std::process::Command::new("git")
.args(["clone", url, destination.to_str().unwrap_or_default()])
.output()
.context("Failed to execute git clone")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git clone failed: {}", stderr.trim());
}
open_repo(destination)
}
pub fn push_current_branch(
repo: &gix::Repository,
remote_name: Option<&str>,
set_upstream: bool,
force_with_lease: bool,
) -> Result<PushOutcome> {
let workdir = repo
.workdir()
.context("Bare repositories are not supported for push")?;
let remote = remote_name.unwrap_or("origin");
let mut args = vec!["push", remote];
if set_upstream {
args.push("--set-upstream");
}
if force_with_lease {
args.push("--force-with-lease");
}
let output = std::process::Command::new("git")
.args(&args)
.current_dir(workdir)
.output()
.context("Failed to execute git push — is git installed?")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git push failed: {}", stderr.trim());
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("Everything up-to-date") {
Ok(PushOutcome::UpToDate)
} else {
Ok(PushOutcome::Updated)
}
}
pub fn delete_remote_branch(
repo: &gix::Repository,
remote_name: Option<&str>,
branch_name: &str,
) -> Result<()> {
let workdir = repo
.workdir()
.context("Bare repositories are not supported for remote branch deletion")?;
let remote = remote_name.unwrap_or("origin");
let refspec = format!(":{branch_name}");
let output = std::process::Command::new("git")
.args(["push", remote, &refspec])
.current_dir(workdir)
.output()
.context("Failed to execute git push — is git installed?")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git push --delete failed: {}", stderr.trim());
}
Ok(())
}
fn resolve_commit_id(repo: &gix::Repository, spec: &str) -> Result<gix::ObjectId> {
repo.rev_parse_single(spec)
.with_context(|| format!("Failed to resolve revision '{spec}'"))?
.object()
.context("Failed to open resolved revision")?
.peel_to_commit()
.context("Revision does not resolve to a commit")
.map(|commit| commit.id)
}
fn count_commits_until(
repo: &gix::Repository,
from: gix::ObjectId,
stop: Option<gix::ObjectId>,
) -> Result<u32> {
let commit = repo.find_commit(from).context("Failed to open commit")?;
let mut count = 0u32;
for ancestor in commit
.ancestors()
.all()
.context("Failed to walk commit history")?
{
let ancestor = ancestor.context("Failed to inspect ancestor commit")?;
if stop.is_some_and(|stop_id| ancestor.id == stop_id) {
break;
}
count = count.saturating_add(1);
}
Ok(count)
}
fn count_tree_changes_until(
repo: &gix::Repository,
from: gix::ObjectId,
stop: Option<gix::ObjectId>,
) -> Result<u32> {
let commit = repo.find_commit(from).context("Failed to open commit")?;
let mut total = 0u32;
let mut current = Some(commit);
while let Some(commit) = current {
if stop.is_some_and(|stop_id| commit.id == stop_id) {
break;
}
let tree_id = commit.tree_id().context("Failed to read commit tree id")?;
let tree = repo.find_object(tree_id)?.peel_to_tree()?;
let parent_tree = if let Some(parent_id) = commit.parent_ids().next() {
let parent = repo
.find_commit(parent_id)
.context("Failed to open parent commit")?;
Some(repo.find_object(parent.tree_id()?)?.peel_to_tree()?)
} else {
None
};
let changes = repo
.diff_tree_to_tree(parent_tree.as_ref(), &tree, None)
.context("Failed to diff commit trees")?;
total = total.saturating_add(changes.len() as u32);
let next_parent = commit.parent_ids().next();
current = next_parent
.map(|parent_id| {
repo.find_commit(parent_id)
.context("Failed to open parent commit")
})
.transpose()?;
}
Ok(total)
}
fn version_sort_key(tag: &str) -> Vec<u64> {
tag.trim_start_matches('v')
.split(|ch: char| !ch.is_ascii_digit())
.filter(|part| !part.is_empty())
.map(|part| part.parse::<u64>().unwrap_or(0))
.collect()
}
fn replace_or_append_config_section(config: &str, section: &str, replacement: &str) -> String {
let mut output = String::new();
let mut lines = config.lines().peekable();
let header = format!("[{section}]");
let mut replaced = false;
while let Some(line) = lines.next() {
let trimmed = line.trim();
if trimmed == header {
replaced = true;
output.push_str(replacement);
if !replacement.ends_with('\n') {
output.push('\n');
}
while let Some(next) = lines.peek() {
let next_trimmed = next.trim();
if next_trimmed.starts_with('[') && next_trimmed.ends_with(']') {
break;
}
lines.next();
}
continue;
}
output.push_str(line);
output.push('\n');
}
if !replaced {
if !output.is_empty() && !output.ends_with("\n\n") {
output.push('\n');
}
output.push_str(replacement);
if !replacement.ends_with('\n') {
output.push('\n');
}
}
output
}