feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
275
crates/infrastructure/src/git/mod.rs
Normal file
275
crates/infrastructure/src/git/mod.rs
Normal file
@ -0,0 +1,275 @@
|
||||
//! [`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<dyn GitPort>` 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, GitError> {
|
||||
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<Vec<GitFileStatus>, 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<GitCommitInfo, GitError> {
|
||||
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<Vec<String>, 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<Option<String>, 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<Vec<GitCommitInfo>, 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<Vec<GraphCommit>, GitError> {
|
||||
let repo = open(root)?;
|
||||
|
||||
// Build a map from OID → ref short-names for label attachment.
|
||||
let mut ref_map: HashMap<git2::Oid, Vec<String>> = 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::<Vec<_>>();
|
||||
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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user