use chrono::{DateTime, Utc};
use git2::build::TreeUpdateBuilder;
use git2::{
Delta, DiffFindOptions, DiffFormat, DiffOptions, FileMode, Oid, Repository, Signature, Sort,
};
use serde::Serialize;
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use crate::error::AppError;
use crate::seek::SeekFilter;
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TreeNode {
File {
name: String,
},
Directory {
name: String,
children: Vec<TreeNode>,
},
}
#[derive(Debug, Default)]
pub struct FileCounts {
pub files: usize,
pub directories: usize,
}
#[derive(Debug, Serialize)]
pub struct CommitAuthor {
pub name: String,
pub email: String,
}
#[derive(Debug, Serialize)]
pub struct CommitSummary {
pub sha: String,
pub message: String,
pub author: CommitAuthor,
pub committed_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub statistics: Option<CommitStatistics>,
}
#[derive(Debug, Serialize)]
pub struct CommitStatistics {
pub insertions: usize,
pub deletions: usize,
pub files_changed: usize,
}
#[derive(Debug, Serialize)]
pub struct CommitDetail {
pub sha: String,
pub message: String,
pub author: CommitAuthor,
pub committed_at: DateTime<Utc>,
pub files: Vec<CommitFileDetail>,
pub statistics: CommitStatistics,
}
#[derive(Debug, Serialize)]
pub struct CommitFileDetail {
pub path: String,
pub change: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub from_path: Option<String>,
pub content: String,
pub diff: String,
}
#[derive(Debug, Clone)]
pub enum FileChange {
Created {
path: String,
content: String,
},
Updated {
path: String,
content: String,
},
Deleted {
path: String,
},
Moved {
from_path: String,
to_path: String,
content: String,
},
}
struct DeltaRecord {
status: Delta,
old_oid: Oid,
new_oid: Oid,
old_path: Option<PathBuf>,
new_path: Option<PathBuf>,
}
struct GitUtils;
impl GitUtils {
fn git_signature<'a>(
author_name: &'a str,
author_email: &'a str,
) -> Result<Signature<'a>, AppError> {
if author_name.trim().is_empty() {
return Err(AppError::InvalidOperation {
reason: "author.name must not be empty".to_string(),
});
}
if author_email.trim().is_empty() {
return Err(AppError::InvalidOperation {
reason: "author.email must not be empty".to_string(),
});
}
tracing::trace!(author_name = %author_name, author_email = %author_email, "creating git signature");
Signature::now(author_name, author_email).map_err(AppError::Git)
}
fn timestamp_from_git_time(git_time: git2::Time) -> DateTime<Utc> {
DateTime::from_timestamp(git_time.seconds(), 0).unwrap_or(DateTime::UNIX_EPOCH)
}
fn open_tenant_repo(repo_path: &Path, tenant_id: &str) -> Result<Repository, AppError> {
if !repo_path.exists() {
tracing::debug!(tenant_id = %tenant_id, "tenant repository not found");
return Err(AppError::TenantNotFound {
tenant_id: tenant_id.to_string(),
});
}
tracing::trace!(tenant_id = %tenant_id, path = %repo_path.display(), "opening tenant repository");
Repository::open(repo_path).map_err(AppError::Git)
}
fn open_or_init_repo(
repo_path: &Path,
author_name: &str,
author_email: &str,
) -> Result<Repository, AppError> {
if repo_path.join(".git").exists() {
tracing::trace!(path = %repo_path.display(), "opening existing repository");
return Repository::open(repo_path).map_err(AppError::Git);
}
tracing::info!(path = %repo_path.display(), "initialising new tenant repository");
std::fs::create_dir_all(repo_path)?;
let repo = Repository::init(repo_path)?;
let signature = Self::git_signature(author_name, author_email)?;
tracing::trace!(path = %repo_path.display(), "writing empty tree for root commit");
let empty_tree_id = repo.treebuilder(None)?.write()?;
let empty_tree = repo.find_tree(empty_tree_id)?;
let root_oid = repo.commit(
Some("HEAD"),
&signature,
&signature,
"chore: initialize",
&empty_tree,
&[],
)?;
tracing::debug!(path = %repo_path.display(), sha = %root_oid, "root commit created");
drop(empty_tree);
Ok(repo)
}
fn blob_content_from_tree(
repo: &Repository,
tree: &git2::Tree<'_>,
file_path: &str,
) -> Result<String, AppError> {
tracing::trace!(path = %file_path, "reading blob from tree");
let tree_entry =
tree.get_path(Path::new(file_path))
.map_err(|_err| AppError::FileNotFound {
path: file_path.to_string(),
})?;
let blob = repo.find_blob(tree_entry.id())?;
tracing::trace!(path = %file_path, blob_id = %tree_entry.id(), size = blob.size(), "blob found");
std::str::from_utf8(blob.content())
.map(|text| text.to_string())
.map_err(|_err| AppError::InvalidUtf8 {
path: file_path.to_string(),
})
}
fn blob_oid_in_tree(tree: &git2::Tree<'_>, file_path: &str) -> Option<Oid> {
let tree_entry = tree.get_path(Path::new(file_path)).ok()?;
if tree_entry.kind() != Some(git2::ObjectType::Blob) {
return None;
}
Some(tree_entry.id())
}
fn windowed_blob_content(
repo: &Repository,
oid: Oid,
file_path: &str,
seek: &SeekFilter,
) -> Result<String, AppError> {
if seek.is_noop() {
let blob = repo.find_blob(oid)?;
return std::str::from_utf8(blob.content())
.map(|text| text.to_string())
.map_err(|_err| AppError::InvalidUtf8 {
path: file_path.to_string(),
});
}
let odb = repo.odb()?;
let window = match odb.reader(oid) {
Ok((reader, _size, _object_type)) => {
tracing::trace!(path = %file_path, blob_id = %oid, "seek-reading blob via odb stream");
seek.apply_reader(std::io::BufReader::new(reader), file_path)
}
Err(_stream_unsupported) => {
tracing::trace!(path = %file_path, blob_id = %oid, "seek-reading blob in memory (streaming unsupported)");
let blob = repo.find_blob(oid)?;
seek.apply_reader(std::io::Cursor::new(blob.content()), file_path)
}
};
window
}
fn path_string(path: Option<&Path>) -> String {
path.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default()
}
fn build_tree(
flat: Vec<String>,
stubs: Vec<String>,
max_depth: Option<usize>,
) -> Vec<TreeNode> {
enum NodeBuilder {
File,
Dir(BTreeMap<String, NodeBuilder>),
}
fn insert(
dir: &mut BTreeMap<String, NodeBuilder>,
components: &[&str],
max_depth: Option<usize>,
current_depth: usize,
) {
match components {
[] => {}
[name] => {
dir.insert(name.to_string(), NodeBuilder::File);
}
[name, rest @ ..] => {
if let Some(max) = max_depth {
if current_depth >= max {
dir.entry(name.to_string())
.or_insert_with(|| NodeBuilder::Dir(BTreeMap::new()));
return;
}
}
let child = dir
.entry(name.to_string())
.or_insert_with(|| NodeBuilder::Dir(BTreeMap::new()));
if let NodeBuilder::Dir(children) = child {
insert(children, rest, max_depth, current_depth + 1);
}
}
}
}
fn insert_stub(dir: &mut BTreeMap<String, NodeBuilder>, components: &[&str]) {
match components {
[] => {}
[name] => {
dir.entry(name.to_string())
.or_insert_with(|| NodeBuilder::Dir(BTreeMap::new()));
}
[name, rest @ ..] => {
let child = dir
.entry(name.to_string())
.or_insert_with(|| NodeBuilder::Dir(BTreeMap::new()));
if let NodeBuilder::Dir(children) = child {
insert_stub(children, rest);
}
}
}
}
fn convert(name: String, node: NodeBuilder) -> TreeNode {
match node {
NodeBuilder::File => TreeNode::File { name },
NodeBuilder::Dir(children) => {
let mut dirs: Vec<TreeNode> = Vec::new();
let mut files: Vec<TreeNode> = Vec::new();
for (child_name, child_node) in children {
match child_node {
NodeBuilder::Dir(_) => dirs.push(convert(child_name, child_node)),
NodeBuilder::File => files.push(convert(child_name, child_node)),
}
}
TreeNode::Directory {
name,
children: dirs.into_iter().chain(files).collect(),
}
}
}
}
let mut root: BTreeMap<String, NodeBuilder> = BTreeMap::new();
for path in flat {
let components: Vec<&str> = path.split('/').collect();
insert(&mut root, &components, max_depth, 1);
}
for stub_path in stubs {
let components: Vec<&str> = stub_path.split('/').collect();
insert_stub(&mut root, &components);
}
let mut dirs: Vec<TreeNode> = Vec::new();
let mut files: Vec<TreeNode> = Vec::new();
for (name, node) in root {
match node {
NodeBuilder::Dir(_) => dirs.push(convert(name, node)),
NodeBuilder::File => files.push(convert(name, node)),
}
}
dirs.into_iter().chain(files).collect()
}
}
pub struct GitLocks;
impl GitLocks {
pub fn cleanup_stale_index_lock(repo_path: &Path) -> Result<(), AppError> {
const STALE_LOCK_THRESHOLD_SECS: u64 = 30;
let lock_path = repo_path.join(".git").join("index.lock");
let metadata = match std::fs::metadata(&lock_path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(AppError::Io(err)),
};
let modified_time = metadata.modified()?;
let lock_age = std::time::SystemTime::now()
.duration_since(modified_time)
.unwrap_or_default();
if lock_age.as_secs() > STALE_LOCK_THRESHOLD_SECS {
tracing::warn!(
"Removing stale git lock file at {:?} (age: {}s)",
lock_path,
lock_age.as_secs()
);
if let Err(err) = std::fs::remove_file(&lock_path) {
if err.kind() != std::io::ErrorKind::NotFound {
return Err(AppError::Io(err));
}
}
}
Ok(())
}
pub fn cleanup_all_stale_locks(repos_root: &Path) {
let collections_dir = match std::fs::read_dir(repos_root) {
Ok(d) => d,
Err(_) => return,
};
for collection_entry_result in collections_dir {
let Ok(collection_entry) = collection_entry_result else {
continue;
};
let collection_path = collection_entry.path();
if !collection_path.is_dir() {
continue;
}
let tenants_dir = match std::fs::read_dir(&collection_path) {
Ok(d) => d,
Err(_) => continue,
};
for tenant_entry_result in tenants_dir {
let Ok(tenant_entry) = tenant_entry_result else {
continue;
};
let lock_path = tenant_entry.path().join(".git").join("index.lock");
if lock_path.exists() {
tracing::warn!(
"Removing stale git lock file found on startup: {:?}",
lock_path
);
if let Err(remove_err) = std::fs::remove_file(&lock_path) {
tracing::error!(
"Failed to remove stale lock {:?}: {}",
lock_path,
remove_err
);
}
}
}
}
}
}
#[derive(Debug, Default)]
pub struct MaintenanceReport {
pub packed_objects: usize,
pub loose_objects_removed: usize,
pub old_packs_removed: usize,
}
pub struct GitMaintenance;
impl GitMaintenance {
pub fn run(repo_path: &Path, destructive_prune: bool) -> Result<MaintenanceReport, AppError> {
if !repo_path.join(".git").exists() {
tracing::debug!(path = %repo_path.display(), "repository gone, skipping maintenance");
return Ok(MaintenanceReport::default());
}
let repo = Repository::open(repo_path)?;
Self::expire_reflogs(&repo);
let loose_objects = Self::enumerate_loose_objects(repo_path)?;
let packs_before = Self::enumerate_pack_stems(repo_path)?;
tracing::debug!(
path = %repo_path.display(),
loose_objects = loose_objects.len(),
packs = packs_before.len(),
destructive_prune = destructive_prune,
"running repository maintenance"
);
let report = if loose_objects.is_empty() && packs_before.len() <= 1 {
MaintenanceReport::default()
} else {
Self::repack(
&repo,
repo_path,
destructive_prune,
&loose_objects,
&packs_before,
)?
};
GitLocks::cleanup_stale_index_lock(repo_path)?;
let head_tree = repo.head()?.peel_to_commit()?.tree()?;
let mut index = repo.index()?;
index.read_tree(&head_tree)?;
index.write()?;
Ok(report)
}
fn repack(
repo: &Repository,
repo_path: &Path,
destructive_prune: bool,
loose_objects: &[(Oid, PathBuf)],
packs_before: &HashSet<PathBuf>,
) -> Result<MaintenanceReport, AppError> {
let odb = repo.odb()?;
let mut pack_builder = repo.packbuilder()?;
if destructive_prune {
Self::insert_reachable_objects(repo, &mut pack_builder)?;
} else {
odb.foreach(|oid| pack_builder.insert_object(*oid, None).is_ok())?;
}
let mut pack_writer = odb.packwriter()?;
pack_builder.foreach(|chunk| {
use std::io::Write;
pack_writer.write_all(chunk).is_ok()
})?;
pack_writer.commit()?;
let mut report = MaintenanceReport {
packed_objects: pack_builder.object_count(),
..Default::default()
};
for (_, loose_path) in loose_objects {
let _ = std::fs::remove_file(loose_path);
}
report.loose_objects_removed = loose_objects.len();
let fanout_dirs: HashSet<PathBuf> = loose_objects
.iter()
.filter_map(|(_, loose_path)| loose_path.parent().map(PathBuf::from))
.collect();
for fanout_dir in fanout_dirs {
let _ = std::fs::remove_dir(fanout_dir);
}
let packs_after = Self::enumerate_pack_stems(repo_path)?;
let new_pack_appeared = packs_after.difference(packs_before).next().is_some();
if new_pack_appeared {
for stem in packs_before {
for extension in ["pack", "idx", "rev", "mtimes", "keep", "bitmap"] {
let _ = std::fs::remove_file(stem.with_extension(extension));
}
report.old_packs_removed += 1;
}
}
Ok(report)
}
fn insert_reachable_objects(
repo: &Repository,
pack_builder: &mut git2::PackBuilder<'_>,
) -> Result<(), AppError> {
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
for reference in repo.references()?.flatten() {
if let Ok(name) = reference.name() {
let _ = revwalk.push_ref(name);
}
if let Some(oid) = reference.target() {
if let Ok(object) = repo.find_object(oid, None) {
if object.kind() == Some(git2::ObjectType::Tag) {
let _ = pack_builder.insert_object(oid, None);
}
}
}
}
pack_builder.insert_walk(&mut revwalk)?;
Ok(())
}
fn expire_reflogs(repo: &Repository) {
let branch_ref_name = repo
.head()
.ok()
.and_then(|head_ref| head_ref.name().ok().map(str::to_owned));
let _ = repo.reflog_delete("HEAD");
if let Some(name) = branch_ref_name {
tracing::trace!(reference = %name, "expiring reflog");
let _ = repo.reflog_delete(&name);
}
}
fn enumerate_pack_stems(repo_path: &Path) -> Result<HashSet<PathBuf>, AppError> {
let pack_dir = repo_path.join(".git").join("objects").join("pack");
let mut stems: HashSet<PathBuf> = HashSet::new();
let entries = match std::fs::read_dir(&pack_dir) {
Ok(entries) => entries,
Err(_) => return Ok(stems),
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|extension| extension.to_str()) == Some("pack") {
stems.insert(path.with_extension(""));
}
}
Ok(stems)
}
fn enumerate_loose_objects(repo_path: &Path) -> Result<Vec<(Oid, PathBuf)>, AppError> {
let objects_dir = repo_path.join(".git").join("objects");
let mut loose_objects: Vec<(Oid, PathBuf)> = Vec::new();
let fanout_entries = match std::fs::read_dir(&objects_dir) {
Ok(entries) => entries,
Err(_) => return Ok(loose_objects),
};
for fanout_entry in fanout_entries.flatten() {
let fanout_name = fanout_entry.file_name();
let Some(prefix) = fanout_name.to_str() else {
continue;
};
if prefix.len() != 2 || !prefix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
continue;
}
let Ok(object_entries) = std::fs::read_dir(fanout_entry.path()) else {
continue;
};
for object_entry in object_entries.flatten() {
let object_name = object_entry.file_name();
let Some(suffix) = object_name.to_str() else {
continue;
};
if let Ok(oid) = Oid::from_str(&format!("{}{}", prefix, suffix)) {
loose_objects.push((oid, object_entry.path()));
}
}
}
Ok(loose_objects)
}
}
pub struct GitFiles;
impl GitFiles {
pub fn list_files(
repo_path: &Path,
tenant_id: &str,
path_prefix: Option<&str>,
maximum_depth: Option<usize>,
include_hidden_files: bool,
file_name_starts_with: Option<&[String]>,
page: usize,
per_page: usize,
) -> Result<(Vec<TreeNode>, bool), AppError> {
tracing::debug!(tenant_id = %tenant_id, path_prefix = ?path_prefix, maximum_depth = ?maximum_depth, include_hidden_files = include_hidden_files, file_name_starts_with = ?file_name_starts_with, page = page, per_page = per_page, "listing files");
let repo = GitUtils::open_tenant_repo(repo_path, tenant_id)?;
let head_commit = repo.head()?.peel_to_commit()?;
tracing::trace!(tenant_id = %tenant_id, head_sha = %head_commit.id(), "resolved HEAD for file listing");
let head_tree = head_commit.tree()?;
let walk_tree: git2::Tree<'_> = match path_prefix.filter(|p| !p.is_empty()) {
Some(prefix) => match head_tree.get_path(Path::new(prefix)) {
Ok(entry) => match repo.find_tree(entry.id()) {
Ok(tree) => tree,
Err(_) => return Ok((vec![], false)),
},
Err(_) => return Ok((vec![], false)),
},
None => head_tree,
};
if let Some(needles) = file_name_starts_with {
return Self::search_by_file_name(
&walk_tree,
needles,
maximum_depth,
include_hidden_files,
page,
per_page,
);
}
let mut root_dirs: Vec<(String, Oid)> = Vec::new();
let mut root_files: Vec<String> = Vec::new();
for entry in walk_tree.iter() {
let Ok(name) = entry.name() else {
continue;
};
if !include_hidden_files && name.starts_with('.') {
continue;
}
match entry.kind() {
Some(git2::ObjectType::Tree) => root_dirs.push((name.to_string(), entry.id())),
Some(git2::ObjectType::Blob) => root_files.push(name.to_string()),
_ => {}
}
}
root_dirs.sort_by(|left, right| left.0.cmp(&right.0));
root_files.sort();
let total = root_dirs.len() + root_files.len();
let offset = ((page - 1) * per_page).min(total);
let has_more = total > offset + per_page;
enum RootEntry {
Directory(String, Oid),
File(String),
}
let page_entries: Vec<RootEntry> = root_dirs
.into_iter()
.map(|(name, oid)| RootEntry::Directory(name, oid))
.chain(root_files.into_iter().map(RootEntry::File))
.skip(offset)
.take(per_page)
.collect();
let mut nodes: Vec<TreeNode> = Vec::with_capacity(page_entries.len());
for root_entry in page_entries {
match root_entry {
RootEntry::File(name) => nodes.push(TreeNode::File { name }),
RootEntry::Directory(name, oid) => {
if maximum_depth == Some(1) {
nodes.push(TreeNode::Directory {
name,
children: Vec::new(),
});
continue;
}
let subtree = repo.find_tree(oid)?;
let subtree_max_depth = maximum_depth.map(|max| max - 1);
let children =
Self::collect_subtree(&subtree, subtree_max_depth, include_hidden_files)?;
nodes.push(TreeNode::Directory { name, children });
}
}
}
tracing::debug!(tenant_id = %tenant_id, page = page, returned = nodes.len(), has_more = has_more, "file listing complete");
Ok((nodes, has_more))
}
fn collect_subtree(
subtree: &git2::Tree<'_>,
max_depth: Option<usize>,
include_hidden_files: bool,
) -> Result<Vec<TreeNode>, AppError> {
let mut flat: Vec<String> = Vec::new();
let mut dir_stubs: Vec<String> = Vec::new();
subtree.walk(git2::TreeWalkMode::PreOrder, |root, entry| {
if !include_hidden_files && entry.name().is_ok_and(|name| name.starts_with('.')) {
return if entry.kind() == Some(git2::ObjectType::Tree) {
git2::TreeWalkResult::Skip
} else {
git2::TreeWalkResult::Ok
};
}
let entry_depth = root.chars().filter(|c| *c == '/').count() + 1;
if entry.kind() == Some(git2::ObjectType::Tree) {
if let Some(max) = max_depth {
if entry_depth >= max {
let name = entry.name().unwrap_or("");
dir_stubs.push(format!("{}{}", root, name));
return git2::TreeWalkResult::Skip;
}
}
return git2::TreeWalkResult::Ok;
}
if entry.kind() != Some(git2::ObjectType::Blob) {
return git2::TreeWalkResult::Ok;
}
let name = entry.name().unwrap_or("");
flat.push(format!("{}{}", root, name));
git2::TreeWalkResult::Ok
})?;
Ok(GitUtils::build_tree(flat, dir_stubs, max_depth))
}
fn search_by_file_name(
walk_tree: &git2::Tree<'_>,
needles: &[String],
maximum_depth: Option<usize>,
include_hidden_files: bool,
page: usize,
per_page: usize,
) -> Result<(Vec<TreeNode>, bool), AppError> {
let needles: Vec<String> = needles.iter().map(|needle| needle.to_lowercase()).collect();
let mut flat: Vec<String> = Vec::new();
let mut dir_stubs: Vec<String> = Vec::new();
let mut inside_matched: Option<String> = None;
walk_tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| {
if let Some(prefix) = &inside_matched {
if !root.starts_with(prefix.as_str()) {
inside_matched = None;
}
}
if !include_hidden_files && entry.name().is_ok_and(|name| name.starts_with('.')) {
return if entry.kind() == Some(git2::ObjectType::Tree) {
git2::TreeWalkResult::Skip
} else {
git2::TreeWalkResult::Ok
};
}
let Ok(name) = entry.name() else {
return git2::TreeWalkResult::Ok;
};
let full_path = format!("{}{}", root, name);
let in_matched = inside_matched.is_some();
let lower_name = name.to_lowercase();
let self_matches = needles.iter().any(|needle| lower_name.starts_with(needle));
match entry.kind() {
Some(git2::ObjectType::Tree) => {
let matched = in_matched || self_matches;
if let Some(max) = maximum_depth {
let entry_depth = root.chars().filter(|c| *c == '/').count() + 1;
if entry_depth >= max {
if matched {
dir_stubs.push(full_path);
}
return git2::TreeWalkResult::Skip;
}
}
if self_matches && !in_matched {
inside_matched = Some(format!("{}/", full_path));
dir_stubs.push(full_path);
}
}
Some(git2::ObjectType::Blob) => {
if in_matched || self_matches {
flat.push(full_path);
}
}
_ => {}
}
git2::TreeWalkResult::Ok
})?;
let tree = GitUtils::build_tree(flat, dir_stubs, None);
let total = tree.len();
let offset = ((page - 1) * per_page).min(total);
let has_more = total > offset + per_page;
let nodes: Vec<TreeNode> = tree.into_iter().skip(offset).take(per_page).collect();
Ok((nodes, has_more))
}
pub fn count_files(
repo_path: &Path,
tenant_id: &str,
path_prefix: Option<&str>,
maximum_depth: Option<usize>,
include_hidden_files: bool,
restrict_file_extensions: Option<&[String]>,
) -> Result<FileCounts, AppError> {
tracing::debug!(tenant_id = %tenant_id, path_prefix = ?path_prefix, maximum_depth = ?maximum_depth, include_hidden_files = include_hidden_files, restrict_file_extensions = ?restrict_file_extensions, "counting files");
let repo = GitUtils::open_tenant_repo(repo_path, tenant_id)?;
let head_commit = repo.head()?.peel_to_commit()?;
tracing::trace!(tenant_id = %tenant_id, head_sha = %head_commit.id(), "resolved HEAD for file counting");
let head_tree = head_commit.tree()?;
let walk_tree: git2::Tree<'_> = match path_prefix.filter(|p| !p.is_empty()) {
Some(prefix) => match head_tree.get_path(Path::new(prefix)) {
Ok(entry) => match repo.find_tree(entry.id()) {
Ok(tree) => tree,
Err(_) => return Ok(FileCounts::default()),
},
Err(_) => return Ok(FileCounts::default()),
},
None => head_tree,
};
let mut counts = FileCounts::default();
walk_tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| {
if !include_hidden_files && entry.name().is_ok_and(|name| name.starts_with('.')) {
return if entry.kind() == Some(git2::ObjectType::Tree) {
git2::TreeWalkResult::Skip
} else {
git2::TreeWalkResult::Ok
};
}
match entry.kind() {
Some(git2::ObjectType::Tree) => {
counts.directories += 1;
if let Some(max) = maximum_depth {
let entry_depth = root.chars().filter(|c| *c == '/').count() + 1;
if entry_depth >= max {
return git2::TreeWalkResult::Skip;
}
}
}
Some(git2::ObjectType::Blob) => {
let counted = match restrict_file_extensions {
None => true,
Some(allowed) => entry
.name()
.ok()
.and_then(|name| Path::new(name).extension())
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
allowed
.iter()
.any(|entry| entry.eq_ignore_ascii_case(extension))
}),
};
if counted {
counts.files += 1;
}
}
_ => {}
}
git2::TreeWalkResult::Ok
})?;
tracing::debug!(tenant_id = %tenant_id, files = counts.files, directories = counts.directories, "file counting complete");
Ok(counts)
}
pub fn read_file(
repo_path: &Path,
tenant_id: &str,
file_path: &str,
seek: &SeekFilter,
) -> Result<String, AppError> {
tracing::debug!(tenant_id = %tenant_id, path = %file_path, "reading file");
let repo = GitUtils::open_tenant_repo(repo_path, tenant_id)?;
let head_commit = repo.head()?.peel_to_commit()?;
tracing::trace!(tenant_id = %tenant_id, path = %file_path, head_sha = %head_commit.id(), "resolved HEAD for read");
let head_tree = head_commit.tree()?;
let blob_oid = GitUtils::blob_oid_in_tree(&head_tree, file_path).ok_or_else(|| {
AppError::FileNotFound {
path: file_path.to_string(),
}
})?;
GitUtils::windowed_blob_content(&repo, blob_oid, file_path, seek)
}
pub fn batch_read_files(
repo_path: &Path,
tenant_id: &str,
file_reads: &[(String, SeekFilter)],
) -> Result<Vec<Option<String>>, AppError> {
tracing::debug!(tenant_id = %tenant_id, count = file_reads.len(), "batch reading files");
let repo = GitUtils::open_tenant_repo(repo_path, tenant_id)?;
let head_commit = repo.head()?.peel_to_commit()?;
tracing::trace!(tenant_id = %tenant_id, head_sha = %head_commit.id(), "resolved HEAD for batch read");
let head_tree = head_commit.tree()?;
file_reads
.iter()
.map(
|(file_path, seek)| match GitUtils::blob_oid_in_tree(&head_tree, file_path) {
None => Ok(None),
Some(blob_oid) => {
GitUtils::windowed_blob_content(&repo, blob_oid, file_path, seek).map(Some)
}
},
)
.collect()
}
pub fn file_exists(repo_path: &Path, tenant_id: &str, file_path: &str) -> Result<(), AppError> {
tracing::debug!(tenant_id = %tenant_id, path = %file_path, "checking file existence");
let repo = GitUtils::open_tenant_repo(repo_path, tenant_id)?;
let head_commit = repo.head()?.peel_to_commit()?;
tracing::trace!(tenant_id = %tenant_id, path = %file_path, head_sha = %head_commit.id(), "resolved HEAD for existence check");
let head_tree = head_commit.tree()?;
let tree_entry =
head_tree
.get_path(Path::new(file_path))
.map_err(|_err| AppError::FileNotFound {
path: file_path.to_string(),
})?;
if tree_entry.kind() != Some(git2::ObjectType::Blob) {
return Err(AppError::FileNotFound {
path: file_path.to_string(),
});
}
Ok(())
}
pub fn write_file(
repo_path: &Path,
file_path: &str,
content: &str,
commit_message: Option<&str>,
author_name: &str,
author_email: &str,
) -> Result<(String, Option<FileChange>), AppError> {
tracing::debug!(path = %file_path, author_name = %author_name, author_email = %author_email, "writing file");
let repo = GitUtils::open_or_init_repo(repo_path, author_name, author_email)?;
let parent_commit = repo.head()?.peel_to_commit()?;
let head_tree = parent_commit.tree()?;
let is_new_file = match head_tree.get_path(Path::new(file_path)) {
Ok(entry) if entry.kind() == Some(git2::ObjectType::Blob) => {
let incoming_oid =
git2::Oid::hash_object(git2::ObjectType::Blob, content.as_bytes())?;
if entry.id() == incoming_oid {
tracing::debug!(path = %file_path, "content unchanged, skipping commit");
return Ok((parent_commit.id().to_string(), None));
}
false
}
Ok(_) => {
return Err(AppError::InvalidOperation {
reason: format!("path is a folder: {}", file_path),
})
}
Err(_) => true,
};
tracing::debug!(path = %file_path, is_new_file = is_new_file, "staging file write");
let absolute_path = repo_path.join(file_path);
if let Some(parent_dir) = absolute_path.parent() {
std::fs::create_dir_all(parent_dir)?;
}
std::fs::write(&absolute_path, content)?;
tracing::trace!(path = %file_path, "building updated tree");
let blob_oid = repo.blob(content.as_bytes())?;
let tree_id = TreeUpdateBuilder::new()
.upsert(file_path, blob_oid, FileMode::Blob)
.create_updated(&repo, &head_tree)?;
let tree = repo.find_tree(tree_id)?;
let signature = GitUtils::git_signature(author_name, author_email)?;
let auto_message = if is_new_file {
format!("create: {}", file_path)
} else {
format!("update: {}", file_path)
};
let message = commit_message.unwrap_or(&auto_message);
tracing::trace!(path = %file_path, message = %message, "committing file write");
let commit_oid = repo.commit(
Some("HEAD"),
&signature,
&signature,
message,
&tree,
&[&parent_commit],
)?;
tracing::debug!(path = %file_path, sha = %commit_oid, is_new_file = is_new_file, "file write committed");
let change = if is_new_file {
FileChange::Created {
path: file_path.to_string(),
content: content.to_string(),
}
} else {
FileChange::Updated {
path: file_path.to_string(),
content: content.to_string(),
}
};
Ok((commit_oid.to_string(), Some(change)))
}
pub fn delete_file(
repo_path: &Path,
tenant_id: &str,
file_path: &str,
commit_message: Option<&str>,
author_name: &str,
author_email: &str,
) -> Result<(String, FileChange), AppError> {
tracing::debug!(tenant_id = %tenant_id, path = %file_path, author_name = %author_name, author_email = %author_email, "deleting file");
let repo = GitUtils::open_tenant_repo(repo_path, tenant_id)?;
let parent_commit = repo.head()?.peel_to_commit()?;
let head_tree = parent_commit.tree()?;
match head_tree.get_path(Path::new(file_path)) {
Ok(entry) if entry.kind() == Some(git2::ObjectType::Blob) => {}
_ => {
tracing::debug!(tenant_id = %tenant_id, path = %file_path, "file not found for deletion");
return Err(AppError::FileNotFound {
path: file_path.to_string(),
});
}
}
tracing::trace!(tenant_id = %tenant_id, path = %file_path, "building updated tree without path");
let absolute_path = repo_path.join(file_path);
match std::fs::remove_file(&absolute_path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(AppError::Io(err)),
}
let tree_id = TreeUpdateBuilder::new()
.remove(file_path)
.create_updated(&repo, &head_tree)?;
let tree = repo.find_tree(tree_id)?;
let signature = GitUtils::git_signature(author_name, author_email)?;
let auto_message = format!("delete: {}", file_path);
let message = commit_message.unwrap_or(&auto_message);
tracing::trace!(tenant_id = %tenant_id, path = %file_path, message = %message, "committing file deletion");
let commit_oid = repo.commit(
Some("HEAD"),
&signature,
&signature,
message,
&tree,
&[&parent_commit],
)?;
tracing::debug!(tenant_id = %tenant_id, path = %file_path, sha = %commit_oid, "file deletion committed");
Ok((
commit_oid.to_string(),
FileChange::Deleted {
path: file_path.to_string(),
},
))
}
pub fn move_file(
repo_path: &Path,
tenant_id: &str,
from_path: &str,
to_path: &str,
commit_message: Option<&str>,
author_name: &str,
author_email: &str,
) -> Result<(String, FileChange), AppError> {
tracing::debug!(
tenant_id = %tenant_id,
from_path = %from_path,
to_path = %to_path,
author_email = %author_email,
"moving file"
);
let repo = GitUtils::open_tenant_repo(repo_path, tenant_id)?;
if from_path == to_path {
tracing::debug!(tenant_id = %tenant_id, path = %from_path, "move rejected: source and destination are identical");
return Err(AppError::InvalidOperation {
reason: "destination must differ from source path".to_string(),
});
}
let parent_commit = repo.head()?.peel_to_commit()?;
let head_tree = parent_commit.tree()?;
let source_blob_oid = match head_tree.get_path(Path::new(from_path)) {
Ok(entry) if entry.kind() == Some(git2::ObjectType::Blob) => entry.id(),
_ => {
tracing::debug!(tenant_id = %tenant_id, from_path = %from_path, "source file not found for move");
return Err(AppError::FileNotFound {
path: from_path.to_string(),
});
}
};
if head_tree.get_path(Path::new(to_path)).is_ok() {
tracing::debug!(tenant_id = %tenant_id, to_path = %to_path, "move rejected: destination already exists");
return Err(AppError::InvalidOperation {
reason: format!("destination already exists: {}", to_path),
});
}
let content = GitUtils::blob_content_from_tree(&repo, &head_tree, from_path)?;
let absolute_from = repo_path.join(from_path);
let absolute_to = repo_path.join(to_path);
match std::fs::remove_file(&absolute_from) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(AppError::Io(err)),
}
if let Some(parent_dir) = absolute_to.parent() {
std::fs::create_dir_all(parent_dir)?;
}
std::fs::write(&absolute_to, &content)?;
tracing::trace!(
tenant_id = %tenant_id,
from_path = %from_path,
to_path = %to_path,
"building updated tree for move"
);
let tree_id = TreeUpdateBuilder::new()
.remove(from_path)
.upsert(to_path, source_blob_oid, FileMode::Blob)
.create_updated(&repo, &head_tree)?;
let tree = repo.find_tree(tree_id)?;
let signature = GitUtils::git_signature(author_name, author_email)?;
let auto_message = format!("move: {} -> {}", from_path, to_path);
let message = commit_message.unwrap_or(&auto_message);
tracing::trace!(
tenant_id = %tenant_id,
from_path = %from_path,
to_path = %to_path,
message = %message,
"committing file move"
);
let commit_oid = repo.commit(
Some("HEAD"),
&signature,
&signature,
message,
&tree,
&[&parent_commit],
)?;
tracing::debug!(
tenant_id = %tenant_id,
from_path = %from_path,
to_path = %to_path,
sha = %commit_oid,
"file move committed"
);
Ok((
commit_oid.to_string(),
FileChange::Moved {
from_path: from_path.to_string(),
to_path: to_path.to_string(),
content,
},
))
}
}
pub struct GitCommits;
impl GitCommits {
pub fn list_commits(
repo_path: &Path,
tenant_id: &str,
page: usize,
per_page: usize,
file_path: Option<&str>,
include_statistics: bool,
) -> Result<(Vec<CommitSummary>, bool), AppError> {
if let Some(path) = file_path {
return Self::list_commits_by_file(
repo_path,
tenant_id,
page,
per_page,
path,
include_statistics,
);
}
tracing::debug!(tenant_id = %tenant_id, page = page, per_page = per_page, include_statistics = include_statistics, "listing commits");
let repo = GitUtils::open_tenant_repo(repo_path, tenant_id)?;
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
revwalk.set_sorting(Sort::TIME | Sort::TOPOLOGICAL)?;
let skip_count = page.saturating_sub(1).saturating_mul(per_page);
tracing::trace!(tenant_id = %tenant_id, skip_count = skip_count, per_page = per_page, "walking commit graph");
let mut commits: Vec<CommitSummary> = revwalk
.skip(skip_count)
.take(per_page + 1)
.filter_map(|oid_result| oid_result.ok())
.filter_map(|oid| repo.find_commit(oid).ok())
.map(|commit| {
let statistics = if include_statistics {
Some(Self::statistics_for_commit(&repo, &commit)?)
} else {
None
};
Ok(CommitSummary {
sha: commit.id().to_string(),
message: commit.message().unwrap_or("").to_string(),
author: CommitAuthor {
name: commit.author().name().unwrap_or("").to_string(),
email: commit.author().email().unwrap_or("").to_string(),
},
committed_at: GitUtils::timestamp_from_git_time(commit.time()),
statistics,
})
})
.collect::<Result<Vec<CommitSummary>, AppError>>()?;
let has_more = commits.len() > per_page;
commits.truncate(per_page);
tracing::debug!(tenant_id = %tenant_id, page = page, returned = commits.len(), has_more = has_more, "commit listing complete");
Ok((commits, has_more))
}
fn list_commits_by_file(
repo_path: &Path,
tenant_id: &str,
page: usize,
per_page: usize,
file_path: &str,
include_statistics: bool,
) -> Result<(Vec<CommitSummary>, bool), AppError> {
tracing::debug!(
tenant_id = %tenant_id,
page = page,
per_page = per_page,
file_path = %file_path,
include_statistics = include_statistics,
"listing commits by file path"
);
let repo = GitUtils::open_tenant_repo(repo_path, tenant_id)?;
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
revwalk.set_sorting(Sort::TIME | Sort::TOPOLOGICAL)?;
let skip_count = page.saturating_sub(1).saturating_mul(per_page);
let need = skip_count + per_page + 1;
let mut current_path = file_path.to_string();
let mut matching: Vec<CommitSummary> = Vec::new();
for oid_result in revwalk {
if matching.len() >= need {
break;
}
let oid = match oid_result {
Ok(id) => id,
Err(_) => continue,
};
let commit = match repo.find_commit(oid) {
Ok(c) => c,
Err(_) => continue,
};
let commit_tree = match commit.tree() {
Ok(t) => t,
Err(_) => continue,
};
let (is_match, rename_from) = if commit.parent_count() == 0 {
let exists = commit_tree.get_path(Path::new(¤t_path)).is_ok();
tracing::trace!(
tenant_id = %tenant_id,
sha = %commit.id(),
path = %current_path,
exists = exists,
"checking root commit for file"
);
(exists, None)
} else {
let parent_tree = match commit.parent(0).and_then(|p| p.tree()) {
Ok(t) => t,
Err(_) => continue,
};
let commit_entry = commit_tree.get_path(Path::new(¤t_path)).ok();
let parent_entry = parent_tree.get_path(Path::new(¤t_path)).ok();
match (commit_entry, parent_entry) {
(Some(in_commit), Some(in_parent))
if in_commit.id() == in_parent.id()
&& in_commit.filemode() == in_parent.filemode() =>
{
(false, None)
}
(Some(_), Some(_)) => (true, None),
(None, Some(_)) => (true, None),
(None, None) => (false, None),
(Some(_), None) => (
true,
Self::rename_source(&repo, &parent_tree, &commit_tree, ¤t_path),
),
}
};
if is_match {
tracing::trace!(
tenant_id = %tenant_id,
sha = %commit.id(),
path = %current_path,
"commit matched file path filter"
);
matching.push(CommitSummary {
sha: commit.id().to_string(),
message: commit.message().unwrap_or("").to_string(),
author: CommitAuthor {
name: commit.author().name().unwrap_or("").to_string(),
email: commit.author().email().unwrap_or("").to_string(),
},
committed_at: GitUtils::timestamp_from_git_time(commit.time()),
statistics: None,
});
if let Some(old_name) = rename_from {
current_path = old_name;
}
}
}
let has_more = matching.len() > skip_count + per_page;
let commits: Vec<CommitSummary> = matching
.into_iter()
.skip(skip_count)
.take(per_page)
.map(|mut summary| {
if include_statistics {
let oid = Oid::from_str(&summary.sha)?;
let commit = repo.find_commit(oid)?;
summary.statistics = Some(Self::statistics_for_commit(&repo, &commit)?);
}
Ok(summary)
})
.collect::<Result<Vec<CommitSummary>, AppError>>()?;
tracing::debug!(
tenant_id = %tenant_id,
page = page,
returned = per_page,
has_more = has_more,
"commit listing by file complete"
);
Ok((commits, has_more))
}
fn rename_source(
repo: &Repository,
parent_tree: &git2::Tree<'_>,
commit_tree: &git2::Tree<'_>,
current_path: &str,
) -> Option<String> {
let mut diff_opts = DiffOptions::new();
diff_opts.include_untracked(false);
let mut diff = repo
.diff_tree_to_tree(Some(parent_tree), Some(commit_tree), Some(&mut diff_opts))
.ok()?;
let mut find_opts = DiffFindOptions::new();
find_opts.renames(true);
diff.find_similar(Some(&mut find_opts)).ok()?;
for index in 0..diff.deltas().count() {
let Some(delta) = diff.get_delta(index) else {
continue;
};
if delta.status() != Delta::Renamed {
continue;
}
let new = delta
.new_file()
.path()
.map(|path| path.to_string_lossy().into_owned());
if new.as_deref() == Some(current_path) {
let old = delta
.old_file()
.path()
.map(|path| path.to_string_lossy().into_owned());
tracing::trace!(
from = ?old,
to = %current_path,
"rename detected, following path backward"
);
return old;
}
}
None
}
fn statistics_for_commit(
repo: &Repository,
commit: &git2::Commit,
) -> Result<CommitStatistics, AppError> {
let commit_tree = commit.tree()?;
let parent_tree = if commit.parent_count() > 0 {
Some(commit.parent(0)?.tree()?)
} else {
None
};
let mut diff_options = DiffOptions::new();
diff_options.include_untracked(false);
let mut diff = repo.diff_tree_to_tree(
parent_tree.as_ref(),
Some(&commit_tree),
Some(&mut diff_options),
)?;
let mut find_options = DiffFindOptions::new();
find_options.renames(true);
diff.find_similar(Some(&mut find_options))?;
let stats = diff.stats()?;
Ok(CommitStatistics {
insertions: stats.insertions(),
deletions: stats.deletions(),
files_changed: stats.files_changed(),
})
}
pub fn get_commit(
repo_path: &Path,
tenant_id: &str,
sha: &str,
) -> Result<CommitDetail, AppError> {
tracing::debug!(tenant_id = %tenant_id, sha = %sha, "fetching commit detail");
let repo = GitUtils::open_tenant_repo(repo_path, tenant_id)?;
let object = repo
.revparse_single(sha)
.map_err(|_err| AppError::CommitNotFound {
sha: sha.to_string(),
})?;
let commit = object
.peel_to_commit()
.map_err(|_err| AppError::CommitNotFound {
sha: sha.to_string(),
})?;
let commit_tree = commit.tree()?;
let parent_tree = if commit.parent_count() > 0 {
Some(commit.parent(0)?.tree()?)
} else {
None
};
tracing::trace!(
tenant_id = %tenant_id,
sha = %sha,
has_parent = parent_tree.is_some(),
"diffing commit against parent"
);
let mut diff_options = DiffOptions::new();
diff_options.include_untracked(false);
let mut diff = repo.diff_tree_to_tree(
parent_tree.as_ref(),
Some(&commit_tree),
Some(&mut diff_options),
)?;
let mut find_options = DiffFindOptions::new();
find_options.renames(true);
diff.find_similar(Some(&mut find_options))?;
let diff_stats = diff.stats()?;
let statistics = CommitStatistics {
insertions: diff_stats.insertions(),
deletions: diff_stats.deletions(),
files_changed: diff_stats.files_changed(),
};
let records: Vec<DeltaRecord> = (0..diff.deltas().count())
.filter_map(|index| diff.get_delta(index))
.map(|delta| {
tracing::trace!(
tenant_id = %tenant_id,
sha = %sha,
status = ?delta.status(),
old_path = ?delta.old_file().path(),
new_path = ?delta.new_file().path(),
"processing diff delta"
);
DeltaRecord {
status: delta.status(),
old_oid: delta.old_file().id(),
new_oid: delta.new_file().id(),
old_path: delta.old_file().path().map(PathBuf::from),
new_path: delta.new_file().path().map(PathBuf::from),
}
})
.collect();
tracing::trace!(tenant_id = %tenant_id, sha = %sha, delta_count = records.len(), "building per-file diffs");
let mut per_file_diffs: Vec<String> = vec![String::new(); records.len()];
diff.print(DiffFormat::Patch, |delta, _hunk, line| {
let key = (delta.old_file().id(), delta.new_file().id());
if let Some(idx) = records
.iter()
.position(|record| (record.old_oid, record.new_oid) == key)
{
let bucket = &mut per_file_diffs[idx];
match line.origin() {
'+' | '-' | ' ' | '\\' => bucket.push(line.origin()),
_ => {}
}
bucket.push_str(std::str::from_utf8(line.content()).unwrap_or(""));
}
true
})?;
let mut file_details: Vec<CommitFileDetail> = Vec::with_capacity(records.len());
for (index, record) in records.iter().enumerate() {
let (change_label, file_path, from_path) = match record.status {
Delta::Added => (
"created",
GitUtils::path_string(record.new_path.as_deref()),
None,
),
Delta::Deleted => (
"deleted",
GitUtils::path_string(record.old_path.as_deref()),
None,
),
Delta::Renamed => (
"moved",
GitUtils::path_string(record.new_path.as_deref()),
record
.old_path
.as_deref()
.map(|path| path.to_string_lossy().into_owned()),
),
_ => (
"updated",
GitUtils::path_string(record.new_path.as_deref()),
None,
),
};
tracing::trace!(
tenant_id = %tenant_id,
sha = %sha,
path = %file_path,
change = %change_label,
"assembling commit file detail"
);
let content = if record.status == Delta::Deleted {
String::new()
} else {
GitUtils::blob_content_from_tree(&repo, &commit_tree, &file_path)?
};
file_details.push(CommitFileDetail {
path: file_path,
change: change_label.to_string(),
from_path,
content,
diff: std::mem::take(&mut per_file_diffs[index]),
});
}
let sha = commit.id().to_string();
let message = commit.message().unwrap_or("").to_string();
let author = CommitAuthor {
name: commit.author().name().unwrap_or("").to_string(),
email: commit.author().email().unwrap_or("").to_string(),
};
let committed_at = GitUtils::timestamp_from_git_time(commit.time());
tracing::debug!(tenant_id = %tenant_id, sha = %sha, file_count = file_details.len(), "commit detail ready");
Ok(CommitDetail {
sha,
message,
author,
committed_at,
files: file_details,
statistics,
})
}
pub fn revert_commit(
repo_path: &Path,
tenant_id: &str,
sha: &str,
commit_message: Option<&str>,
author_name: &str,
author_email: &str,
) -> Result<(String, Vec<FileChange>), AppError> {
tracing::debug!(tenant_id = %tenant_id, sha = %sha, author_name = %author_name, author_email = %author_email, "reverting commit");
let repo = GitUtils::open_tenant_repo(repo_path, tenant_id)?;
let object = repo
.revparse_single(sha)
.map_err(|_err| AppError::CommitNotFound {
sha: sha.to_string(),
})?;
let target_commit = object
.peel_to_commit()
.map_err(|_err| AppError::CommitNotFound {
sha: sha.to_string(),
})?;
if target_commit.parent_count() == 0 {
tracing::warn!(tenant_id = %tenant_id, sha = %sha, "cannot revert root commit");
return Err(AppError::InvalidOperation {
reason: "cannot revert the initial commit".to_string(),
});
}
let parent_commit = target_commit.parent(0)?;
let commit_tree = target_commit.tree()?;
let parent_tree = parent_commit.tree()?;
tracing::trace!(tenant_id = %tenant_id, sha = %sha, "computing diff for revert");
let mut diff = repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)?;
let mut find_options = DiffFindOptions::new();
find_options.renames(true);
diff.find_similar(Some(&mut find_options))?;
let raw_deltas: Vec<DeltaRecord> = (0..diff.deltas().count())
.filter_map(|index| diff.get_delta(index))
.map(|delta| DeltaRecord {
status: delta.status(),
old_oid: delta.old_file().id(),
new_oid: delta.new_file().id(),
old_path: delta.old_file().path().map(PathBuf::from),
new_path: delta.new_file().path().map(PathBuf::from),
})
.collect();
tracing::trace!(tenant_id = %tenant_id, sha = %sha, delta_count = raw_deltas.len(), "applying revert deltas");
let head_commit = repo.head()?.peel_to_commit()?;
let head_tree = head_commit.tree()?;
let mut tree_update = TreeUpdateBuilder::new();
let mut file_changes: Vec<FileChange> = Vec::new();
for raw_delta in &raw_deltas {
match raw_delta.status {
Delta::Added => {
if let Some(new_path) = &raw_delta.new_path {
tracing::trace!(
tenant_id = %tenant_id,
sha = %sha,
path = %new_path.display(),
"revert: removing added file"
);
let absolute_path = repo_path.join(new_path);
if absolute_path.exists() {
std::fs::remove_file(&absolute_path)?;
}
tree_update.remove(new_path);
file_changes.push(FileChange::Deleted {
path: new_path.to_string_lossy().into_owned(),
});
}
}
Delta::Deleted => {
if let Some(old_path) = &raw_delta.old_path {
tracing::trace!(
tenant_id = %tenant_id,
sha = %sha,
path = %old_path.display(),
"revert: restoring deleted file"
);
let content = GitUtils::blob_content_from_tree(
&repo,
&parent_tree,
&old_path.to_string_lossy(),
)?;
let absolute_path = repo_path.join(old_path);
if let Some(parent_dir) = absolute_path.parent() {
std::fs::create_dir_all(parent_dir)?;
}
std::fs::write(&absolute_path, &content)?;
tree_update.upsert(old_path, raw_delta.old_oid, FileMode::Blob);
file_changes.push(FileChange::Created {
path: old_path.to_string_lossy().into_owned(),
content,
});
}
}
Delta::Modified => {
if let Some(old_path) = &raw_delta.old_path {
tracing::trace!(
tenant_id = %tenant_id,
sha = %sha,
path = %old_path.display(),
"revert: restoring modified file to previous version"
);
let content = GitUtils::blob_content_from_tree(
&repo,
&parent_tree,
&old_path.to_string_lossy(),
)?;
let absolute_path = repo_path.join(old_path);
std::fs::write(&absolute_path, &content)?;
tree_update.upsert(old_path, raw_delta.old_oid, FileMode::Blob);
file_changes.push(FileChange::Updated {
path: old_path.to_string_lossy().into_owned(),
content,
});
}
}
Delta::Renamed => {
if let (Some(old_path), Some(new_path)) =
(&raw_delta.old_path, &raw_delta.new_path)
{
tracing::trace!(
tenant_id = %tenant_id,
sha = %sha,
from_path = %new_path.display(),
to_path = %old_path.display(),
"revert: reversing rename"
);
let content = GitUtils::blob_content_from_tree(
&repo,
&parent_tree,
&old_path.to_string_lossy(),
)?;
let absolute_old = repo_path.join(old_path);
let absolute_new = repo_path.join(new_path);
if absolute_new.exists() {
std::fs::remove_file(&absolute_new)?;
}
if let Some(parent_dir) = absolute_old.parent() {
std::fs::create_dir_all(parent_dir)?;
}
std::fs::write(&absolute_old, &content)?;
tree_update.remove(new_path);
tree_update.upsert(old_path, raw_delta.old_oid, FileMode::Blob);
file_changes.push(FileChange::Moved {
from_path: new_path.to_string_lossy().into_owned(),
to_path: old_path.to_string_lossy().into_owned(),
content,
});
}
}
_ => {}
}
}
tracing::trace!(tenant_id = %tenant_id, sha = %sha, "building revert tree and committing");
let tree_id = tree_update.create_updated(&repo, &head_tree)?;
let tree = repo.find_tree(tree_id)?;
let signature = GitUtils::git_signature(author_name, author_email)?;
let auto_message = format!("revert: {}", target_commit.message().unwrap_or("unknown"));
let revert_message = commit_message.unwrap_or(&auto_message);
let new_commit_oid = repo.commit(
Some("HEAD"),
&signature,
&signature,
revert_message,
&tree,
&[&head_commit],
)?;
tracing::debug!(
tenant_id = %tenant_id,
reverted_sha = %sha,
new_sha = %new_commit_oid,
file_change_count = file_changes.len(),
"revert committed"
);
Ok((new_commit_oid.to_string(), file_changes))
}
}
pub struct GitTenant;
impl GitTenant {
pub fn delete_repo(repo_path: &Path, tenant_id: &str) -> Result<(), AppError> {
tracing::debug!(tenant_id = %tenant_id, "deleting tenant repository");
if !repo_path.exists() {
tracing::debug!(tenant_id = %tenant_id, "tenant repository not found for deletion");
return Err(AppError::TenantNotFound {
tenant_id: tenant_id.to_string(),
});
}
std::fs::remove_dir_all(repo_path).map_err(|err| {
tracing::error!(
tenant_id = %tenant_id,
path = %repo_path.display(),
err = %err,
"failed to remove tenant repository directory"
);
AppError::Io(err)
})?;
tracing::info!(tenant_id = %tenant_id, "tenant repository deleted");
Ok(())
}
}