- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés, environnement local détecté, stratégies compilées). UI EmbedderSettings. - LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la mémoire dépasse le budget de recall sans embedder configuré (event EmbedderSuggested, anti-spam 1×/session, « ne plus demander »). - Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les agents/profils au lancement, avant la persona. UI ProjectContextPanel. Tests : backend workspace vert (0 échec) ; frontend 306/306. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
272 lines
9.8 KiB
Rust
272 lines
9.8 KiB
Rust
//! [`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(),
|
|
))
|
|
}
|
|
}
|