//! [`Git2Repository`] — local Git adapter implementing the [`GitPort`] port via //! libgit2 (`git2`), ARCHITECTURE §5, L8. //! //! Scope is **local** operations (status, stage/unstage, commit, branches, //! checkout, log, init). Network operations (`pull`/`push`) need remote + //! credential handling and are deferred to L9 (`RemoteGitRepository` over SSH/WSL //! and credential callbacks); here they return a clear [`GitError::Operation`]. //! //! # Async & `Send` //! //! [`GitPort`] is `#[async_trait]`, but libgit2 is synchronous and its handles //! ([`git2::Repository`]) are `!Send`. Each method opens the repository, does all //! its work, and drops it **within a single poll** — there is no `.await` while a //! repo/handle is alive — so the returned futures are `Send` and the adapter is //! safe behind `Arc` on the multi-threaded runtime. use async_trait::async_trait; use git2::{BranchType, ErrorCode, Repository, Status, StatusOptions}; use std::collections::HashMap; use domain::ports::{GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit}; use domain::project::ProjectPath; /// Local Git adapter backed by libgit2. #[derive(Clone, Default)] pub struct Git2Repository; impl Git2Repository { /// Builds the adapter (stateless; a repository is opened per call from the /// project root, so one instance serves every project). #[must_use] pub fn new() -> Self { Self } } /// Maps a libgit2 error to a domain [`GitError`] (its message, not the raw type). fn op(e: git2::Error) -> GitError { GitError::Operation(e.message().to_owned()) } /// Opens the repository at `root`, distinguishing "not a repo" from other errors. fn open(root: &ProjectPath) -> Result { Repository::open(root.as_str()).map_err(|e| { if e.code() == ErrorCode::NotFound { GitError::NotFound } else { op(e) } }) } /// Index-side status flags (a path with any of these has staged changes). const STAGED: Status = Status::INDEX_NEW .union(Status::INDEX_MODIFIED) .union(Status::INDEX_DELETED) .union(Status::INDEX_RENAMED) .union(Status::INDEX_TYPECHANGE); #[async_trait] impl GitPort for Git2Repository { async fn init(&self, root: &ProjectPath) -> Result<(), GitError> { Repository::init(root.as_str()).map_err(op)?; Ok(()) } async fn status(&self, root: &ProjectPath) -> Result, GitError> { let repo = open(root)?; let mut opts = StatusOptions::new(); opts.include_untracked(true).recurse_untracked_dirs(true); let statuses = repo.statuses(Some(&mut opts)).map_err(op)?; let mut out = Vec::new(); for entry in statuses.iter() { let s = entry.status(); if s.is_ignored() { continue; } if let Some(path) = entry.path() { out.push(GitFileStatus { path: path.to_owned(), staged: s.intersects(STAGED), }); } } Ok(out) } async fn stage(&self, root: &ProjectPath, path: &str) -> Result<(), GitError> { let repo = open(root)?; let mut index = repo.index().map_err(op)?; index.add_path(std::path::Path::new(path)).map_err(op)?; index.write().map_err(op)?; Ok(()) } async fn unstage(&self, root: &ProjectPath, path: &str) -> Result<(), GitError> { let repo = open(root)?; match repo.head() { // Reset the path in the index back to its HEAD state. Ok(head) => { let obj = head.peel(git2::ObjectType::Commit).map_err(op)?; repo.reset_default(Some(&obj), [path]).map_err(op)?; } // Unborn HEAD (no commit yet): "unstage" means drop it from the index. Err(_) => { let mut index = repo.index().map_err(op)?; index.remove_path(std::path::Path::new(path)).map_err(op)?; index.write().map_err(op)?; } } Ok(()) } async fn commit(&self, root: &ProjectPath, message: &str) -> Result { let repo = open(root)?; let mut index = repo.index().map_err(op)?; let tree_oid = index.write_tree().map_err(op)?; let tree = repo.find_tree(tree_oid).map_err(op)?; // Prefer the configured identity; fall back to a stable local one so a // fresh repo with no user.name/email can still commit. let sig = repo .signature() .or_else(|_| git2::Signature::now("IdeA", "idea@localhost")) .map_err(op)?; let parent = match repo.head() { Ok(head) => Some(head.peel_to_commit().map_err(op)?), Err(_) => None, }; let parents: Vec<&git2::Commit> = parent.iter().collect(); let oid = repo .commit(Some("HEAD"), &sig, &sig, message, &tree, &parents) .map_err(op)?; Ok(GitCommitInfo { hash: oid.to_string(), summary: message.lines().next().unwrap_or("").to_owned(), }) } async fn branches(&self, root: &ProjectPath) -> Result, GitError> { let repo = open(root)?; let mut names = Vec::new(); for branch in repo.branches(Some(BranchType::Local)).map_err(op)? { let (branch, _) = branch.map_err(op)?; if let Some(name) = branch.name().map_err(op)? { names.push(name.to_owned()); } } Ok(names) } async fn current_branch(&self, root: &ProjectPath) -> Result, GitError> { let repo = open(root)?; let head = match repo.head() { Ok(head) => head, // Unborn branch (no commits yet): no current branch to report. Err(e) if e.code() == ErrorCode::UnbornBranch => return Ok(None), Err(e) => return Err(op(e)), }; // A detached HEAD (or a non-branch ref) has no branch name. Ok(if head.is_branch() { head.shorthand().map(ToOwned::to_owned) } else { None }) } async fn checkout(&self, root: &ProjectPath, branch: &str) -> Result<(), GitError> { let repo = open(root)?; let obj = repo.revparse_single(branch).map_err(op)?; repo.checkout_tree(&obj, None).map_err(op)?; repo.set_head(&format!("refs/heads/{branch}")).map_err(op)?; Ok(()) } async fn log(&self, root: &ProjectPath, limit: usize) -> Result, GitError> { let repo = open(root)?; let mut revwalk = repo.revwalk().map_err(op)?; // No commits yet ⇒ nothing to walk. if revwalk.push_head().is_err() { return Ok(Vec::new()); } let mut out = Vec::new(); for oid in revwalk.take(limit) { let oid = oid.map_err(op)?; let commit = repo.find_commit(oid).map_err(op)?; out.push(GitCommitInfo { hash: oid.to_string(), summary: commit.summary().unwrap_or("").to_owned(), }); } Ok(out) } async fn log_graph( &self, root: &ProjectPath, limit: usize, ) -> Result, GitError> { let repo = open(root)?; // Build a map from OID → ref short-names for label attachment. let mut ref_map: HashMap> = HashMap::new(); if let Ok(references) = repo.references() { for reference in references.flatten() { // Only handle symbolic and direct refs; skip errors. let target_oid = reference.resolve().ok().and_then(|r| r.target()); if let Some(oid) = target_oid { let label = match reference.shorthand() { Some(name) => { // Distinguish tags from branches by prefix. if reference.is_tag() { format!("tag: {name}") } else { name.to_owned() } } None => continue, }; ref_map.entry(oid).or_default().push(label); } } } let mut revwalk = repo.revwalk().map_err(op)?; revwalk .set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME) .map_err(op)?; // Push all local branches; if the repo is empty this produces no OIDs. let _ = revwalk.push_glob("refs/heads/*"); let mut out = Vec::new(); for oid_result in revwalk.take(limit) { let oid = oid_result.map_err(op)?; let commit = repo.find_commit(oid).map_err(op)?; let parents = commit .parent_ids() .map(|p| p.to_string()) .collect::>(); let author = commit.author().name().unwrap_or("").to_owned(); let timestamp = commit.time().seconds(); let refs = ref_map.get(&oid).cloned().unwrap_or_default(); out.push(GraphCommit { hash: oid.to_string(), summary: commit.summary().unwrap_or("").to_owned(), parents, refs, author, timestamp, }); } Ok(out) } async fn pull(&self, _root: &ProjectPath) -> Result<(), GitError> { Err(GitError::Operation( "pull requires remote/credential configuration (L9)".to_owned(), )) } async fn push(&self, _root: &ProjectPath) -> Result<(), GitError> { Err(GitError::Operation( "push requires remote/credential configuration (L9)".to_owned(), )) } }