use anyhow::{bail, Context, Result};
use crate::fleet::config::gitmodules::validate_gitmodules_structure;
use gix::bstr::{BString, ByteSlice, ByteVec};
use gix::object::tree::Editor;
use gix::prelude::Write;
use gix::remote;
use gix::status;
use gix::status::index_worktree;
use gix::status::index_worktree::iter::Summary as IndexWorktreeSummary;
use gix::ThreadSafeRepository;
use ignore::WalkBuilder;
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
#[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> {
gix::init(path).with_context(|| format!("Failed to initialize git repository in {}", path.display()))
}
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 remote = match remote_name {
Some(name) => repo
.find_remote(name)
.with_context(|| format!("Failed to find remote '{}'", name))?,
None => repo
.find_fetch_remote(None)
.context("Failed to find default fetch remote")?,
};
remote
.connect(gix::remote::Direction::Fetch)
.context("Failed to connect to remote")?
.prepare_fetch(gix::progress::Discard, Default::default())
.context("Failed to prepare fetch")?
.receive(gix::progress::Discard, &std::sync::atomic::AtomicBool::new(false))
.context("Failed to fetch from remote")?;
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 index_path: PathBuf = std::env::var_os("GIT_INDEX_FILE")
.map(PathBuf::from)
.unwrap_or_else(|| repo.index_path());
let mut index = if index_path.exists() {
gix::index::File::at(
&index_path,
repo.object_hash(),
false,
Default::default(),
)
.with_context(|| format!("Failed to load index from {}", index_path.display()))?
} else {
let state = match repo.head_commit() {
Ok(commit) => {
let tree = commit.tree().context("Failed to get HEAD tree")?;
gix::index::State::from_tree(&tree.id, repo, Default::default())
.context("Failed to build index state from HEAD tree")?
}
Err(_) => gix::index::State::new(repo.object_hash()),
};
gix::index::File::from_state(state, index_path.clone())
};
let mut staged = Vec::new();
for file in paths {
let relative = Path::new(file);
if relative.is_absolute() {
bail!("Only relative paths can be staged: {}", relative.display());
}
let full_path = workdir.join(relative);
let rela_path = gix::path::to_unix_separators_on_windows(gix::path::into_bstr(relative.to_path_buf()));
remove_index_entries_for_path(&mut index, rela_path.as_ref());
if full_path.exists() {
let metadata = fs::symlink_metadata(&full_path)
.with_context(|| format!("Failed to read metadata for {}", full_path.display()))?;
if metadata.file_type().is_symlink() {
let target = gix::path::into_bstr(
fs::read_link(&full_path)
.with_context(|| format!("Failed to read symlink {}", full_path.display()))?,
)
.into_owned();
let id = repo.write_blob(target.as_slice()).context("Failed to write symlink blob")?;
let stat = gix::index::entry::Stat::from_fs(
&gix::index::fs::Metadata::from_path_no_follow(&full_path)
.with_context(|| format!("Failed to read index metadata for {}", full_path.display()))?,
)
.context("Failed to build symlink stat information")?;
index.dangerously_push_entry(
stat,
id.detach(),
gix::index::entry::Flags::empty(),
gix::index::entry::Mode::SYMLINK,
rela_path.as_ref(),
);
} else {
let bytes =
fs::read(&full_path).with_context(|| format!("Failed to read {}", full_path.display()))?;
let id = repo.write_blob(bytes).context("Failed to write blob")?;
let stat = gix::index::entry::Stat::from_fs(
&gix::index::fs::Metadata::from_path_no_follow(&full_path)
.with_context(|| format!("Failed to read index metadata for {}", full_path.display()))?,
)
.context("Failed to build file stat information")?;
let mode = if is_executable(&metadata) {
gix::index::entry::Mode::FILE_EXECUTABLE
} else {
gix::index::entry::Mode::FILE
};
index.dangerously_push_entry(
stat,
id.detach(),
gix::index::entry::Flags::empty(),
mode,
rela_path.as_ref(),
);
}
staged.push(file.clone());
} else {
staged.push(file.clone());
}
}
index.sort_entries();
index.write(Default::default()).context("Failed to write index")?;
Ok(staged)
}
pub fn checkout_branch(repo: &gix::Repository, branch_name: &str) -> Result<()> {
let branch_ref = format!("refs/heads/{branch_name}");
let mut branch = repo
.find_reference(branch_ref.as_str())
.with_context(|| format!("Branch '{branch_name}' not found"))?;
let target_commit = branch.peel_to_id().context("Branch does not point to an object")?;
checkout_target(repo, &branch_ref, target_commit.into())
}
pub fn create_branch(repo: &gix::Repository, branch_name: &str, from_ref: Option<&str>) -> Result<()> {
let target = resolve_commit_id(repo, from_ref.unwrap_or("HEAD"))?;
let branch_ref = format!("refs/heads/{branch_name}");
repo.reference(
branch_ref.as_str(),
target,
gix::refs::transaction::PreviousValue::MustNotExist,
format!("branch: Created from {}", from_ref.unwrap_or("HEAD")),
)
.with_context(|| format!("Failed to create branch '{branch_name}'"))?;
checkout_target(repo, &branch_ref, target)
}
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 tree_id = snapshot_worktree_tree(repo)?;
let mut index = repo
.index_from_tree(tree_id.as_ref())
.context("Failed to build index from worktree snapshot")?;
index.write(Default::default()).context("Failed to write index")
}
pub fn commit_all(repo: &gix::Repository, message: &str) -> Result<Option<String>> {
stage_all(repo)?;
let tree_id = snapshot_worktree_tree(repo)?;
let parent = repo.head_id().ok().map(|id| id.detach());
let tree_changed = match parent {
Some(parent_id) => {
let parent_tree = repo
.find_commit(parent_id)
.context("Failed to read HEAD commit")?
.tree_id()
.context("Failed to read HEAD tree id")?;
parent_tree != tree_id
}
None => true,
};
if !tree_changed {
return Ok(None);
}
let parents: Vec<_> = parent.into_iter().collect();
let commit = repo
.commit("HEAD", message, tree_id, parents)
.context("Failed to create commit")?;
Ok(Some(commit.to_string()))
}
pub fn create_tag(repo: &gix::Repository, name: &str, message: &str) -> Result<()> {
let head = repo.head_id().context("Failed to resolve HEAD for tag creation")?;
repo.tag(
name,
head.detach(),
gix::object::Kind::Commit,
None,
message,
gix::refs::transaction::PreviousValue::MustNotExist,
)
.with_context(|| format!("Failed to create tag '{name}'"))?;
Ok(())
}
pub fn merge_ff(repo: &gix::Repository, branch: &str) -> Result<()> {
let current_ref = repo
.head()
.context("Failed to read HEAD")?
.referent_name()
.context("Detached HEAD is not supported for fast-forward merge")?
.to_owned();
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 mut current_ref = repo
.find_reference(current_ref.as_ref())
.context("Failed to open current branch reference")?;
current_ref
.set_target_id(target_commit, format!("merge: Fast-forward {branch}"))
.context("Failed to move current branch")?;
checkout_target(repo, ¤t_ref.name().to_string(), target_commit)
}
pub fn checkout_commit(repo: &gix::Repository, commit: gix::ObjectId) -> Result<()> {
checkout_target_detached(repo, commit)
}
pub fn clone_repository(url: &str, destination: &Path) -> Result<gix::Repository> {
let mut prepare = gix::prepare_clone(url, destination)
.with_context(|| format!("Failed to prepare clone from '{url}'"))?;
let (mut checkout, _outcome) = prepare
.fetch_then_checkout(gix::progress::Discard, &AtomicBool::new(false))
.context("Failed to fetch clone data")?;
let (repo, checkout_outcome) = checkout
.main_worktree(gix::progress::Discard, &AtomicBool::new(false))
.context("Failed to checkout cloned repository")?;
if !checkout_outcome.collisions.is_empty() || !checkout_outcome.errors.is_empty() {
bail!("Clone checkout produced worktree collisions or errors");
}
Ok(repo)
}
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 checkout_target(
repo: &gix::Repository,
branch_ref: &str,
commit_id: gix::ObjectId,
) -> Result<()> {
let root_tree = repo
.find_commit(commit_id)
.context("Failed to read checkout target commit")?
.tree_id()
.context("Failed to read checkout target tree")?;
let mut index = repo
.index_from_tree(root_tree.as_ref())
.context("Failed to build index for checkout")?;
let workdir = repo.workdir().context("Bare repositories cannot be checked out")?;
let mut opts = repo.checkout_options(gix::worktree::stack::state::attributes::Source::IdMapping)?;
opts.destination_is_initially_empty = false;
let outcome = gix_worktree_state::checkout(
&mut index,
workdir,
repo.objects.clone().into_arc()?,
&gix::progress::Discard,
&gix::progress::Discard,
&AtomicBool::new(false),
opts,
)
.context("Failed to update worktree during checkout")?;
if !outcome.collisions.is_empty() || !outcome.errors.is_empty() {
bail!("Checkout produced worktree collisions or errors");
}
index.write(Default::default()).context("Failed to persist checked-out index")?;
let repo = ThreadSafeRepository::from(repo.clone()).to_thread_local();
let mut repo = repo;
let prev_write_reflog = repo.refs.write_reflog;
repo.refs.write_reflog = gix::refs::store::WriteReflog::Normal;
repo.edit_reference(gix::refs::transaction::RefEdit {
change: gix::refs::transaction::Change::Update {
log: Default::default(),
expected: gix::refs::transaction::PreviousValue::Any,
new: gix::refs::Target::Symbolic(branch_ref.try_into()?),
},
name: "HEAD".try_into().expect("valid HEAD"),
deref: false,
})
.context("Failed to move HEAD")?;
repo.refs.write_reflog = prev_write_reflog;
Ok(())
}
fn checkout_target_detached(repo: &gix::Repository, commit_id: gix::ObjectId) -> Result<()> {
let root_tree = repo
.find_commit(commit_id)
.context("Failed to read checkout target commit")?
.tree_id()
.context("Failed to read checkout target tree")?;
let mut index = repo
.index_from_tree(root_tree.as_ref())
.context("Failed to build index for detached checkout")?;
let workdir = repo.workdir().context("Bare repositories cannot be checked out")?;
let mut opts = repo.checkout_options(gix::worktree::stack::state::attributes::Source::IdMapping)?;
opts.destination_is_initially_empty = false;
let outcome = gix_worktree_state::checkout(
&mut index,
workdir,
repo.objects.clone().into_arc()?,
&gix::progress::Discard,
&gix::progress::Discard,
&AtomicBool::new(false),
opts,
)
.context("Failed to update worktree during detached checkout")?;
if !outcome.collisions.is_empty() || !outcome.errors.is_empty() {
bail!("Detached checkout produced worktree collisions or errors");
}
index
.write(Default::default())
.context("Failed to persist detached checkout index")?;
let repo = ThreadSafeRepository::from(repo.clone()).to_thread_local();
let mut repo = repo;
let prev_write_reflog = repo.refs.write_reflog;
repo.refs.write_reflog = gix::refs::store::WriteReflog::Normal;
repo.edit_reference(gix::refs::transaction::RefEdit {
change: gix::refs::transaction::Change::Update {
log: Default::default(),
expected: gix::refs::transaction::PreviousValue::Any,
new: gix::refs::Target::Object(commit_id),
},
name: "HEAD".try_into().expect("valid HEAD"),
deref: false,
})
.context("Failed to detach HEAD")?;
repo.refs.write_reflog = prev_write_reflog;
Ok(())
}
fn snapshot_worktree_tree(repo: &gix::Repository) -> Result<gix::ObjectId> {
let workdir = repo.workdir().context("Bare repositories are not supported")?;
let mut editor = Editor::new(&repo.empty_tree()).context("Failed to create tree editor")?;
let mut walker = WalkBuilder::new(workdir);
walker.hidden(false);
walker.git_ignore(true);
walker.git_global(true);
walker.git_exclude(true);
walker.require_git(false);
let root = workdir.to_path_buf();
walker.filter_entry(move |entry| {
if entry.path() == root {
return true;
}
!(entry.file_type().is_some_and(|kind| kind.is_dir()) && looks_like_nested_repository(entry.path()))
});
for entry in walker.build() {
let entry = entry.context("Failed to walk worktree")?;
let path = entry.path();
if path == workdir || path.starts_with(repo.git_dir()) {
continue;
}
let rela_path = path
.strip_prefix(workdir)
.with_context(|| format!("Path '{}' escaped the worktree root", path.display()))?;
let rela_path = gix::path::into_bstr(rela_path.to_path_buf());
let rela_path = gix::path::to_unix_separators_on_windows(rela_path);
if rela_path.as_ref() == b".git".as_bstr() {
continue;
}
if entry.file_type().is_some_and(|kind| kind.is_dir()) && looks_like_nested_repository(path) {
let subrepo = open_repo(path)?;
let subhead = subrepo.head_id().with_context(|| {
format!(
"Submodule-like repository '{}' does not have a checked out commit",
path.display()
)
})?;
editor
.upsert(
rela_path.as_ref(),
gix::object::tree::EntryKind::Commit,
subhead.detach(),
)
.context("Failed to insert nested repository entry into tree")?;
continue;
}
if entry.file_type().is_none() || entry.file_type().is_some_and(|kind| kind.is_dir()) {
continue;
}
let metadata = fs::symlink_metadata(path)
.with_context(|| format!("Failed to read metadata for {}", path.display()))?;
if metadata.file_type().is_symlink() {
let target = fs::read_link(path)
.with_context(|| format!("Failed to read symlink {}", path.display()))?;
let target = gix::path::into_bstr(target).into_owned();
let id = repo
.write_blob(target.as_slice())
.context("Failed to write symlink blob")?;
editor
.upsert(rela_path.as_ref(), gix::object::tree::EntryKind::Link, id.detach())
.context("Failed to insert symlink into tree")?;
continue;
}
if entry.file_type().is_some_and(|kind| kind.is_dir()) {
continue;
}
let bytes = fs::read(path).with_context(|| format!("Failed to read {}", path.display()))?;
let id = repo.write_blob(bytes).context("Failed to write blob")?;
let kind = if is_executable(&metadata) {
gix::object::tree::EntryKind::BlobExecutable
} else {
gix::object::tree::EntryKind::Blob
};
editor
.upsert(rela_path.as_ref(), kind, id.detach())
.context("Failed to insert blob into tree")?;
}
Ok(editor.write().context("Failed to write worktree snapshot tree")?.detach())
}
#[cfg(unix)]
fn is_executable(metadata: &fs::Metadata) -> bool {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
fn is_executable(_metadata: &fs::Metadata) -> bool {
false
}
fn remove_index_entries_for_path(index: &mut gix::index::File, rela_path: &gix::bstr::BStr) {
let prefix = {
let mut prefix = rela_path.to_owned();
prefix.push_str("/");
prefix
};
index.remove_entries(|_, path, _| path == rela_path || path.starts_with(prefix.as_ref()));
}
fn looks_like_nested_repository(path: &Path) -> bool {
path.join(".git").exists()
}
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
}