chore: refactor git module (#27)
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run

Reviewed-on: #27
This commit was merged in pull request #27.
This commit is contained in:
2026-10-07 02:40:12 +00:00
parent a706fadafa
commit 5887a14c50
13 changed files with 1760 additions and 1569 deletions
+3 -293
View File
@@ -1,18 +1,11 @@
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use anyhow::{Context, Result};
use gix::progress::Discard;
use crate::GixResultExt as _;
use crate::history::FileCommit;
use crate::repo::Repo;
pub struct WorktreeSnapshot {
pub entries: Vec<PathBuf>,
pub readme_path: Option<PathBuf>,
pub readme: Option<Vec<u8>>,
// `None` when detached, for example on a tag.
pub current_branch: Option<String>,
pub head_commit: Option<FileCommit>,
pub branches: Vec<String>,
@@ -20,7 +13,8 @@ pub struct WorktreeSnapshot {
}
impl WorktreeSnapshot {
fn new(
/// Creates a snapshot of the worktree contents and ref state.
pub(crate) fn new(
entries: Vec<PathBuf>,
readme_path: Option<PathBuf>,
readme: Option<Vec<u8>>,
@@ -40,287 +34,3 @@ impl WorktreeSnapshot {
}
}
}
impl Repo {
pub fn is_dirty(&self) -> bool {
match self.inner.is_dirty() {
Ok(true) => return true,
Ok(false) => {}
Err(_) => return false,
}
let Ok(platform) = self.inner.status(Discard) else {
return false;
};
let Ok(mut changes) = platform.into_index_worktree_iter(Vec::<gix::bstr::BString>::new())
else {
return false;
};
for change in changes.by_ref() {
match change {
Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true,
Ok(_) => {}
Err(_) => return false,
}
}
false
}
pub fn commits_ahead(&self, base: &str, branch: &str) -> u32 {
let (Some(base), Some(branch)) = (self.resolve_commit(base), self.resolve_commit(branch))
else {
return 0;
};
let Ok(walk) = self.inner.rev_walk([branch]).with_hidden([base]).all() else {
return 0;
};
walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32
}
fn resolve_commit<'a>(&'a self, rev: &str) -> Option<gix::Id<'a>> {
self.inner.rev_parse_single(rev.as_bytes()).ok()
}
pub fn entries(&self) -> Result<Vec<PathBuf>> {
let workdir = self.inner.workdir().context("repository has no worktree")?;
let mut entries: Vec<(PathBuf, bool)> = Vec::new();
Self::collect_entries(workdir, workdir, &mut entries)?;
entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| {
b_is_dir
.cmp(a_is_dir)
.then_with(|| a.as_os_str().cmp(b.as_os_str()))
});
Ok(entries.into_iter().map(|(path, _)| path).collect())
}
pub fn read(&self, rel: &Path) -> Result<Option<Vec<u8>>> {
let workdir = self.inner.workdir().context("repository has no worktree")?;
let path = workdir.join(rel);
match std::fs::read(&path) {
Ok(bytes) => Ok(Some(bytes)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None),
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
}
}
pub fn find_readme(&self) -> Result<Option<PathBuf>> {
let Some(workdir) = self.inner.workdir() else {
return Ok(None);
};
let mut candidates: Vec<PathBuf> = Vec::new();
for entry in std::fs::read_dir(workdir)? {
let entry = entry?;
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if name.to_ascii_lowercase().starts_with("readme") {
candidates.push(entry.path());
}
}
candidates.sort_by_key(|path| {
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_ascii_lowercase());
match ext.as_deref() {
Some("md") => 0,
Some("markdown") => 1,
Some("mdown") => 2,
Some("mkdn") => 3,
Some(_) => 5,
None => 4,
}
});
Ok(candidates
.into_iter()
.next()
.and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf)))
}
pub fn snapshot(&self) -> Result<WorktreeSnapshot> {
let readme_path = self.find_readme()?;
let readme = match &readme_path {
Some(path) => self.read(path)?,
None => None,
};
Ok(WorktreeSnapshot::new(
self.entries()?,
readme_path,
readme,
self.current_branch(),
self.head_commit().unwrap_or(None),
self.branches().unwrap_or_default(),
self.tags().unwrap_or_default(),
))
}
pub fn checkout_branch(&self, name: &str) -> Result<()> {
let full = format!("refs/heads/{name}");
let branch = gix::refs::FullName::try_from(full.as_str())
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
let mut reference = self.inner.find_reference(&full)?;
let tree = reference.peel_to_tree()?.id;
let (signature, mut time_buf) = Self::repository_signature();
let signature = signature.to_ref(&mut time_buf);
self.move_head(
signature,
gix::refs::Target::Symbolic(branch),
&format!("checkout: moving to {name}"),
)?;
self.force_checkout(&tree)?;
Ok(())
}
pub fn checkout_tag(&self, name: &str) -> Result<()> {
let full = format!("refs/tags/{name}");
let mut reference = self.inner.find_reference(&full)?;
let commit = reference.peel_to_id()?;
let tree = reference.peel_to_tree()?.id;
let (signature, mut time_buf) = Self::repository_signature();
let signature = signature.to_ref(&mut time_buf);
self.move_head(
signature,
gix::refs::Target::Object(commit.detach()),
&format!("checkout: moving to {name}"),
)?;
self.force_checkout(&tree)?;
Ok(())
}
pub(crate) fn force_checkout(&self, tree: &gix::hash::oid) -> Result<()> {
let workdir = self
.inner
.workdir()
.context("repository has no worktree")?
.to_path_buf();
let mut index = self.inner.index_from_tree(tree)?;
if let Ok(previous) = self.inner.index_or_empty() {
let keep: HashSet<PathBuf> = index
.entries()
.iter()
.map(|entry| {
PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned())
})
.collect();
for entry in previous.entries() {
let rel = entry.path(&previous);
let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned());
if keep.contains(&rel) {
continue;
}
let path = workdir.join(&rel);
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(error)
.with_context(|| format!("failed to remove {}", path.display()));
}
}
}
}
let mut options = self
.inner
.checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?;
options.overwrite_existing = true;
let objects = self.inner.objects.clone().into_arc()?;
let files = gix::progress::Discard;
let bytes = gix::progress::Discard;
gix_worktree_state::checkout(
&mut index,
workdir,
objects,
&files,
&bytes,
&gix::interrupt::IS_INTERRUPTED,
options,
)
.into_anyhow()?;
index
.write(gix::index::write::Options::default())
.into_anyhow()?;
Ok(())
}
fn move_head(
&self,
signature: gix::actor::SignatureRef<'_>,
target: gix::refs::Target,
message: &str,
) -> Result<()> {
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
let head = gix::refs::FullName::try_from("HEAD")
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
self.inner.edit_references_as(
[RefEdit {
change: Change::Update {
log: LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: message.into(),
},
expected: PreviousValue::Any,
new: target,
},
name: head,
deref: false,
}],
Some(signature),
)?;
Ok(())
}
fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
if entry.file_name() == ".git" {
continue;
}
let is_dir = entry.file_type()?.is_dir();
let path = entry.path();
let rel = path.strip_prefix(root)?.to_path_buf();
out.push((rel, is_dir));
if is_dir {
Self::collect_entries(root, &path, out)?;
}
}
Ok(())
}
}