use newt_core::git_caveats::GitCaveats;
use serde::{Deserialize, Serialize};
use std::path::Path;
use grit_lib::diff::{diff_index_to_tree, diff_index_to_worktree, DiffEntry};
use grit_lib::index::{IndexEntry, MODE_REGULAR};
use grit_lib::merge_base::resolve_commit_specs;
use grit_lib::merge_file::MergeFavor;
use grit_lib::merge_trees::{
merge_trees_three_way, TreeMergeConflictPresentation, WhitespaceMergeOptions,
};
use grit_lib::objects::{parse_commit, serialize_commit, CommitData, ObjectId, ObjectKind};
use grit_lib::porcelain::status::{collect_untracked_and_ignored, IgnoredMode};
use grit_lib::refs::{delete_ref, read_head, resolve_ref, write_ref, write_symbolic_ref};
use grit_lib::repo::Repository;
use grit_lib::state::{resolve_head, HeadState};
use grit_lib::write_tree::write_tree_from_index;
#[derive(Debug, thiserror::Error)]
pub enum GitError {
#[error("capability denied: git {0} not permitted")]
Denied(&'static str),
#[error("git: {0}")]
Engine(#[from] grit_lib::error::Error),
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("unsupported: {0}")]
Unsupported(&'static str),
#[error("{0}")]
Refused(String),
#[error("rebase conflict at {0} — aborted, branch unchanged")]
Conflict(String),
#[error("bad rebase plan: {0}")]
BadPlan(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RebaseAction {
Pick,
Reword,
Squash,
Fixup,
Drop,
}
#[derive(Debug, Clone)]
pub struct RebaseStep {
pub commit: String,
pub action: RebaseAction,
pub message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RebaseReport {
pub new_head: String,
pub produced: usize,
pub dropped: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Author {
pub name: String,
pub email: String,
}
impl Author {
fn ident_now(&self) -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("{} <{}> {} +0000", self.name, self.email, secs)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileChange {
pub status: char,
pub path: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatusReport {
pub branch: Option<String>,
pub head: Option<String>,
pub staged: Vec<FileChange>,
pub unstaged: Vec<FileChange>,
pub untracked: Vec<String>,
pub clean: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommitInfo {
pub id: String,
pub short_id: String,
pub author_name: String,
pub author_email: String,
pub timestamp: i64,
pub summary: String,
pub parents: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiffReport {
pub files: Vec<FileChange>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffSpec {
Worktree,
Staged,
}
pub struct GitEngine {
repo: Repository,
}
impl GitEngine {
pub fn open(root: &Path) -> Result<Self, GitError> {
let repo = Repository::discover(Some(root))?;
Ok(Self { repo })
}
fn head_oid(&self) -> Result<Option<ObjectId>, GitError> {
Ok(match resolve_head(&self.repo.git_dir)? {
HeadState::Branch { oid, .. } => oid,
HeadState::Detached { oid } => Some(oid),
HeadState::Invalid => None,
})
}
fn head_tree(&self) -> Result<Option<ObjectId>, GitError> {
match self.head_oid()? {
Some(oid) => {
let obj = self.repo.odb.read(&oid)?;
Ok(Some(parse_commit(&obj.data)?.tree))
}
None => Ok(None),
}
}
pub fn status(&self, caps: &GitCaveats) -> Result<StatusReport, GitError> {
if !caps.permits_read() {
return Err(GitError::Denied("read"));
}
let index = self.repo.load_index()?;
let (branch, head) = match resolve_head(&self.repo.git_dir)? {
HeadState::Branch {
short_name, oid, ..
} => (Some(short_name), oid.as_ref().map(short_oid)),
HeadState::Detached { oid } => (None, Some(short_oid(&oid))),
HeadState::Invalid => (None, None),
};
let tree = self.head_tree()?;
let staged = diff_index_to_tree(&self.repo.odb, &index, tree.as_ref(), false)?;
let (unstaged, untracked) = match self.repo.work_tree.clone() {
Some(wt) => {
let unstaged = diff_index_to_worktree(&self.repo.odb, &index, &wt, false, false)?;
let untracked = collect_untracked_and_ignored(
&self.repo,
&index,
&wt,
IgnoredMode::No,
false,
&[],
)?
.0;
(unstaged, untracked)
}
None => (Vec::new(), Vec::new()),
};
let staged: Vec<FileChange> = staged.iter().map(file_change).collect();
let unstaged: Vec<FileChange> = unstaged.iter().map(file_change).collect();
let clean = staged.is_empty() && unstaged.is_empty() && untracked.is_empty();
Ok(StatusReport {
branch,
head,
staged,
unstaged,
untracked,
clean,
})
}
pub fn log(&self, caps: &GitCaveats, limit: usize) -> Result<Vec<CommitInfo>, GitError> {
if !caps.permits_read() {
return Err(GitError::Denied("read"));
}
let mut out = Vec::new();
let mut next = self.head_oid()?;
while let Some(oid) = next {
if out.len() >= limit {
break;
}
let obj = self.repo.odb.read(&oid)?;
let commit = parse_commit(&obj.data)?;
out.push(commit_info(&oid, &commit));
next = commit.parents.first().cloned();
}
Ok(out)
}
pub fn diff(&self, caps: &GitCaveats, spec: DiffSpec) -> Result<DiffReport, GitError> {
if !caps.permits_read() {
return Err(GitError::Denied("read"));
}
let index = self.repo.load_index()?;
let entries = match spec {
DiffSpec::Worktree => match self.repo.work_tree.clone() {
Some(wt) => diff_index_to_worktree(&self.repo.odb, &index, &wt, false, false)?,
None => Vec::new(),
},
DiffSpec::Staged => {
let tree = self.head_tree()?;
diff_index_to_tree(&self.repo.odb, &index, tree.as_ref(), false)?
}
};
Ok(DiffReport {
files: entries.iter().map(file_change).collect(),
})
}
pub fn add(&self, caps: &GitCaveats, paths: &[String]) -> Result<Vec<String>, GitError> {
if !caps.permits_stage() {
return Err(GitError::Denied("stage"));
}
let wt = self
.repo
.work_tree
.clone()
.ok_or(GitError::Unsupported("cannot stage in a bare repository"))?;
let mut index = self.repo.load_index()?;
let mut staged = Vec::with_capacity(paths.len());
for rel in paths {
let abs = wt.join(rel);
let bytes = std::fs::read(&abs)?;
let oid = self.repo.odb.write(ObjectKind::Blob, &bytes)?;
let size = bytes.len() as u32;
let path = rel.as_bytes().to_vec();
let flags = path.len().min(0x0FFF) as u16;
index.add_or_replace(IndexEntry {
ctime_sec: 0,
ctime_nsec: 0,
mtime_sec: 0,
mtime_nsec: 0,
dev: 0,
ino: 0,
mode: MODE_REGULAR,
uid: 0,
gid: 0,
size,
oid,
flags,
flags_extended: None,
path,
base_index_pos: 0,
});
staged.push(rel.clone());
}
self.repo.write_index(&mut index)?;
Ok(staged)
}
pub fn commit(
&self,
caps: &GitCaveats,
message: &str,
author: &Author,
) -> Result<CommitInfo, GitError> {
if !caps.permits_commit() {
return Err(GitError::Denied("commit"));
}
let index = self.repo.load_index()?;
let tree = write_tree_from_index(&self.repo.odb, &index, "")?;
let parents: Vec<ObjectId> = self.head_oid()?.into_iter().collect();
let ident = author.ident_now();
let commit = CommitData {
tree,
parents,
author: ident.clone(),
committer: ident,
author_raw: Vec::new(),
committer_raw: Vec::new(),
encoding: None,
message: message.to_string(),
raw_message: None,
};
let oid = self
.repo
.odb
.write(ObjectKind::Commit, &serialize_commit(&commit))?;
match read_head(&self.repo.git_dir)? {
Some(branch_ref) => write_ref(&self.repo.git_dir, &branch_ref, &oid)?,
None => return Err(GitError::Unsupported("cannot commit on a detached HEAD")),
}
Ok(commit_info(&oid, &commit))
}
pub fn amend(
&self,
caps: &GitCaveats,
message: Option<&str>,
author: &Author,
) -> Result<CommitInfo, GitError> {
if !caps.permits_commit() {
return Err(GitError::Denied("commit"));
}
let head = self
.head_oid()?
.ok_or(GitError::Unsupported("nothing to amend (unborn HEAD)"))?;
let head_commit = parse_commit(&self.repo.odb.read(&head)?.data)?;
let index = self.repo.load_index()?;
let tree = write_tree_from_index(&self.repo.odb, &index, "")?;
let ident = author.ident_now();
let commit = CommitData {
tree,
parents: head_commit.parents.clone(),
author: ident.clone(),
committer: ident,
author_raw: Vec::new(),
committer_raw: Vec::new(),
encoding: None,
message: message.map(str::to_string).unwrap_or(head_commit.message),
raw_message: None,
};
let oid = self
.repo
.odb
.write(ObjectKind::Commit, &serialize_commit(&commit))?;
match read_head(&self.repo.git_dir)? {
Some(branch_ref) => write_ref(&self.repo.git_dir, &branch_ref, &oid)?,
None => return Err(GitError::Unsupported("cannot amend on a detached HEAD")),
}
Ok(commit_info(&oid, &commit))
}
fn resolve_one(&self, spec: &str) -> Result<ObjectId, GitError> {
resolve_commit_specs(&self.repo, &[spec.to_string()])?
.into_iter()
.next()
.ok_or(GitError::Unsupported("could not resolve commit"))
}
fn commit_tree(&self, oid: &ObjectId) -> Result<ObjectId, GitError> {
Ok(parse_commit(&self.repo.odb.read(oid)?.data)?.tree)
}
fn write_commit_on(
&self,
parent: ObjectId,
tree: ObjectId,
message: &str,
author: &Author,
) -> Result<ObjectId, GitError> {
let ident = author.ident_now();
let commit = CommitData {
tree,
parents: vec![parent],
author: ident.clone(),
committer: ident,
author_raw: Vec::new(),
committer_raw: Vec::new(),
encoding: None,
message: message.to_string(),
raw_message: None,
};
Ok(self
.repo
.odb
.write(ObjectKind::Commit, &serialize_commit(&commit))?)
}
pub fn rebase(
&self,
caps: &GitCaveats,
onto: &str,
steps: &[RebaseStep],
author: &Author,
) -> Result<RebaseReport, GitError> {
if !caps.permits_commit() {
return Err(GitError::Denied("commit"));
}
let head_ref = read_head(&self.repo.git_dir)?
.ok_or(GitError::Unsupported("cannot rebase on a detached HEAD"))?;
let onto_oid = self.resolve_one(onto)?;
let mut tip = onto_oid;
let mut tip_tree = self.commit_tree(&onto_oid)?;
let mut open = false;
let mut cur_parent = onto_oid;
let mut cur_tree = tip_tree;
let mut cur_msgs: Vec<String> = Vec::new();
let mut produced = 0usize;
let mut dropped = 0usize;
for step in steps {
if step.action == RebaseAction::Drop {
dropped += 1;
continue;
}
let c = self.resolve_one(&step.commit)?;
let cc = parse_commit(&self.repo.odb.read(&c)?.data)?;
let parent = cc
.parents
.first()
.copied()
.ok_or(GitError::Unsupported("cannot rebase a root commit"))?;
let base_tree = self.commit_tree(&parent)?;
let ours = if open { cur_tree } else { tip_tree };
let merged = merge_trees_three_way(
&self.repo,
base_tree,
ours,
cc.tree,
MergeFavor::None,
WhitespaceMergeOptions::default(),
None,
TreeMergeConflictPresentation::default(),
)?;
if !merged.conflict_content.is_empty() {
let subj = cc.message.lines().next().unwrap_or("").trim();
return Err(GitError::Conflict(format!("{} ({subj})", short_oid(&c))));
}
let new_tree = write_tree_from_index(&self.repo.odb, &merged.index, "")?;
match step.action {
RebaseAction::Pick | RebaseAction::Reword => {
if open {
tip = self.write_commit_on(
cur_parent,
cur_tree,
&cur_msgs.join("\n\n"),
author,
)?;
tip_tree = cur_tree;
produced += 1;
}
cur_parent = tip;
cur_tree = new_tree;
cur_msgs = vec![match step.action {
RebaseAction::Reword => step
.message
.clone()
.ok_or(GitError::BadPlan("reword needs a message".into()))?,
_ => cc.message.clone(),
}];
open = true;
}
RebaseAction::Squash => {
if !open {
return Err(GitError::BadPlan("squash before any pick".into()));
}
cur_tree = new_tree;
cur_msgs.push(step.message.clone().unwrap_or_else(|| cc.message.clone()));
}
RebaseAction::Fixup => {
if !open {
return Err(GitError::BadPlan("fixup before any pick".into()));
}
cur_tree = new_tree; }
RebaseAction::Drop => unreachable!("filtered above"),
}
}
if open {
tip = self.write_commit_on(cur_parent, cur_tree, &cur_msgs.join("\n\n"), author)?;
produced += 1;
}
write_ref(&self.repo.git_dir, &head_ref, &tip)?;
Ok(RebaseReport {
new_head: short_oid(&tip),
produced,
dropped,
})
}
pub fn branch(&self, caps: &GitCaveats, name: &str) -> Result<String, GitError> {
let refname = format!("refs/heads/{name}");
if !caps.permits_ref(&refname) {
return Err(GitError::Denied("refs"));
}
let oid = self
.head_oid()?
.ok_or(GitError::Unsupported("cannot branch from an unborn HEAD"))?;
write_ref(&self.repo.git_dir, &refname, &oid)?;
Ok(refname)
}
pub fn checkout(
&self,
caps: &GitCaveats,
name: &str,
create: bool,
) -> Result<String, GitError> {
let refname = format!("refs/heads/{name}");
if !caps.permits_ref(&refname) {
return Err(GitError::Denied("refs"));
}
let head = self.head_oid()?;
let existing = resolve_ref(&self.repo.git_dir, &refname).ok();
let (target, created) = match (existing, create) {
(Some(oid), _) => (Some(oid), false),
(None, true) => {
let oid = head.ok_or(GitError::Unsupported(
"cannot create a branch from an unborn HEAD",
))?;
write_ref(&self.repo.git_dir, &refname, &oid)?;
(Some(oid), true)
}
(None, false) => {
return Err(GitError::Refused(format!(
"branch '{name}' does not exist (pass create=true to make it)"
)));
}
};
if target != head {
return Err(GitError::Refused(format!(
"refusing to switch to '{name}': it points at a different commit \
than HEAD and newt cannot update the working tree (local-only). \
Commit or stash first, or create a new branch at HEAD."
)));
}
write_symbolic_ref(&self.repo.git_dir, "HEAD", &refname)?;
Ok(if created {
format!("created and switched to branch '{name}'")
} else {
format!("switched to branch '{name}'")
})
}
pub fn branch_delete(&self, caps: &GitCaveats, name: &str) -> Result<String, GitError> {
let refname = format!("refs/heads/{name}");
if !caps.permits_ref(&refname) {
return Err(GitError::Denied("refs"));
}
if let HeadState::Branch { short_name, .. } = resolve_head(&self.repo.git_dir)? {
if short_name == name {
return Err(GitError::Refused(format!(
"cannot delete branch '{name}': it is the current branch"
)));
}
}
if resolve_ref(&self.repo.git_dir, &refname).is_err() {
return Err(GitError::Refused(format!("branch '{name}' does not exist")));
}
delete_ref(&self.repo.git_dir, &refname)?;
Ok(format!("deleted branch '{name}'"))
}
}
fn short_oid(oid: &ObjectId) -> String {
oid.to_hex().chars().take(7).collect()
}
fn file_change(e: &DiffEntry) -> FileChange {
FileChange {
status: e.status.letter(),
path: e.path().to_string(),
}
}
fn commit_info(oid: &ObjectId, c: &CommitData) -> CommitInfo {
let (author_name, author_email, timestamp) = parse_ident(&c.author);
let summary = c.message.lines().next().unwrap_or("").to_string();
CommitInfo {
id: oid.to_hex(),
short_id: short_oid(oid),
author_name,
author_email,
timestamp,
summary,
parents: c.parents.iter().map(|p| p.to_hex()).collect(),
}
}
fn parse_ident(s: &str) -> (String, String, i64) {
let name = s.split(" <").next().unwrap_or("").trim().to_string();
let email = s
.split_once('<')
.and_then(|(_, rest)| rest.split_once('>'))
.map(|(e, _)| e.to_string())
.unwrap_or_default();
let timestamp = s
.rsplit('>')
.next()
.and_then(|tail| tail.split_whitespace().next())
.and_then(|n| n.parse::<i64>().ok())
.unwrap_or(0);
(name, email, timestamp)
}
pub struct LocalGitTool {
pub root: std::path::PathBuf,
pub author: Author,
pub coauthor: Option<String>,
}
fn sign_message(message: &str, coauthor: Option<&str>) -> String {
match coauthor {
Some(trailer) if !message.to_lowercase().contains("co-authored-by:") => {
format!("{}\n\n{trailer}", message.trim_end())
}
_ => message.to_string(),
}
}
impl newt_core::agentic::GitTool for LocalGitTool {
fn dispatch(
&self,
op: &str,
args: &serde_json::Value,
caps: &GitCaveats,
) -> Result<String, String> {
let eng = GitEngine::open(&self.root).map_err(|e| e.to_string())?;
let s = |e: GitError| e.to_string();
match op {
"status" => Ok(render_status(&eng.status(caps).map_err(s)?)),
"log" => {
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
Ok(render_log(&eng.log(caps, limit).map_err(s)?))
}
"diff" => {
let spec = match args.get("spec").and_then(|v| v.as_str()) {
Some("staged") => DiffSpec::Staged,
_ => DiffSpec::Worktree,
};
Ok(render_diff(&eng.diff(caps, spec).map_err(s)?))
}
"add" => {
let paths = str_array(args, "paths");
if paths.is_empty() {
return Err("add: 'paths' (array of repo-relative paths) is required".into());
}
let staged = eng.add(caps, &paths).map_err(s)?;
Ok(format!(
"staged {} path(s): {}",
staged.len(),
staged.join(", ")
))
}
"commit" => {
let msg = args
.get("message")
.and_then(|v| v.as_str())
.filter(|m| !m.trim().is_empty())
.ok_or("commit: 'message' is required")?;
let signed = sign_message(msg, self.coauthor.as_deref());
let c = eng.commit(caps, &signed, &self.author).map_err(s)?;
Ok(format!("committed {}: {}", c.short_id, c.summary))
}
"amend" => {
let msg = args
.get("message")
.and_then(|v| v.as_str())
.filter(|m| !m.trim().is_empty());
let signed = msg.map(|m| sign_message(m, self.coauthor.as_deref()));
let c = eng
.amend(caps, signed.as_deref(), &self.author)
.map_err(s)?;
Ok(format!("amended {}: {}", c.short_id, c.summary))
}
"rebase" => {
let onto = args
.get("onto")
.and_then(|v| v.as_str())
.filter(|o| !o.trim().is_empty())
.ok_or("rebase: 'onto' (the base commit/ref to replay onto) is required")?;
let steps = parse_rebase_plan(args, self.coauthor.as_deref())?;
if steps.is_empty() {
return Err("rebase: 'plan' must list at least one step".to_string());
}
let r = eng.rebase(caps, onto, &steps, &self.author).map_err(s)?;
Ok(format!(
"rebased onto {onto} → {} ({} commit(s), {} dropped)",
r.new_head, r.produced, r.dropped
))
}
"branch" => {
let name = args
.get("name")
.and_then(|v| v.as_str())
.filter(|n| !n.trim().is_empty())
.ok_or("branch: 'name' is required")?;
let r = eng.branch(caps, name).map_err(s)?;
Ok(format!("created {r}"))
}
"checkout" => {
let name = args
.get("name")
.and_then(|v| v.as_str())
.filter(|n| !n.trim().is_empty())
.ok_or("checkout: 'name' (the branch to switch to) is required")?;
let create = args.get("create").and_then(|v| v.as_bool()).unwrap_or(true);
eng.checkout(caps, name, create).map_err(s)
}
"branch-delete" => {
let name = args
.get("name")
.and_then(|v| v.as_str())
.filter(|n| !n.trim().is_empty())
.ok_or("branch-delete: 'name' is required")?;
eng.branch_delete(caps, name).map_err(s)
}
other => Err(format!(
"unknown git op '{other}' \
(use status|log|diff|add|commit|amend|rebase|branch|checkout|branch-delete)"
)),
}
}
}
fn parse_rebase_plan(
args: &serde_json::Value,
coauthor: Option<&str>,
) -> Result<Vec<RebaseStep>, String> {
let plan = args
.get("plan")
.and_then(|v| v.as_array())
.ok_or("rebase: 'plan' (array of {commit, action, message?}) is required")?;
let mut steps = Vec::with_capacity(plan.len());
for (i, e) in plan.iter().enumerate() {
let commit = e
.get("commit")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("rebase plan[{i}]: 'commit' is required"))?
.to_string();
let action = match e.get("action").and_then(|v| v.as_str()).unwrap_or("pick") {
"pick" => RebaseAction::Pick,
"reword" => RebaseAction::Reword,
"squash" => RebaseAction::Squash,
"fixup" => RebaseAction::Fixup,
"drop" => RebaseAction::Drop,
other => {
return Err(format!(
"rebase plan[{i}]: unknown action '{other}' (pick|reword|squash|fixup|drop)"
))
}
};
let message = e
.get("message")
.and_then(|v| v.as_str())
.filter(|m| !m.trim().is_empty())
.map(|m| match action {
RebaseAction::Reword | RebaseAction::Squash => sign_message(m, coauthor),
_ => m.to_string(),
});
steps.push(RebaseStep {
commit,
action,
message,
});
}
Ok(steps)
}
fn str_array(args: &serde_json::Value, key: &str) -> Vec<String> {
args.get(key)
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
fn render_status(s: &StatusReport) -> String {
let branch = s.branch.as_deref().unwrap_or("(detached)");
let head = s.head.as_deref().unwrap_or("(unborn)");
let mut out = format!("on branch {branch} (HEAD {head})\n");
if s.clean {
out.push_str("working tree clean");
return out;
}
let mut group = |label: &str, files: &[FileChange]| {
if !files.is_empty() {
out.push_str(label);
out.push_str(":\n");
for f in files {
out.push_str(&format!(" {} {}\n", f.status, f.path));
}
}
};
group("staged", &s.staged);
group("unstaged", &s.unstaged);
if !s.untracked.is_empty() {
out.push_str("untracked:\n");
for p in &s.untracked {
out.push_str(&format!(" ? {p}\n"));
}
}
out.trim_end().to_string()
}
fn render_log(commits: &[CommitInfo]) -> String {
if commits.is_empty() {
return "no commits".to_string();
}
commits
.iter()
.map(|c| format!("{} {} ({})", c.short_id, c.summary, c.author_name))
.collect::<Vec<_>>()
.join("\n")
}
fn render_diff(d: &DiffReport) -> String {
if d.files.is_empty() {
return "no changes".to_string();
}
d.files
.iter()
.map(|f| format!("{} {}", f.status, f.path))
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use std::process::Command;
fn git(dir: &Path, args: &[&str]) {
let ok = Command::new("git")
.current_dir(dir)
.args(args)
.status()
.expect("git runs")
.success();
assert!(ok, "git {args:?} failed");
}
fn repo_with_commit() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
std::fs::write(p.join("a.txt"), "hello\n").unwrap();
git(p, &["add", "a.txt"]);
git(
p,
&[
"-c",
"user.name=Tester",
"-c",
"user.email=t@example.com",
"commit",
"-q",
"-m",
"first commit",
],
);
dir
}
#[test]
fn open_and_status_on_clean_repo() {
let dir = repo_with_commit();
let eng = GitEngine::open(dir.path()).unwrap();
let s = eng.status(&GitCaveats::top()).unwrap();
assert!(s.clean, "fresh commit -> clean: {s:?}");
assert_eq!(s.branch.as_deref(), Some("main"));
assert!(s.head.is_some());
}
#[test]
fn log_returns_the_commit() {
let dir = repo_with_commit();
let eng = GitEngine::open(dir.path()).unwrap();
let log = eng.log(&GitCaveats::top(), 10).unwrap();
assert_eq!(log.len(), 1);
assert_eq!(log[0].summary, "first commit");
assert_eq!(log[0].author_name, "Tester");
assert_eq!(log[0].author_email, "t@example.com");
assert!(log[0].parents.is_empty(), "root commit has no parents");
}
#[test]
fn status_sees_unstaged_modification() {
let dir = repo_with_commit();
std::fs::write(dir.path().join("a.txt"), "changed\n").unwrap();
let eng = GitEngine::open(dir.path()).unwrap();
let s = eng.status(&GitCaveats::top()).unwrap();
assert!(!s.clean);
assert!(s.unstaged.iter().any(|f| f.path == "a.txt"));
}
#[test]
fn status_sees_untracked_file() {
let dir = repo_with_commit();
std::fs::write(dir.path().join("new.txt"), "x\n").unwrap();
let eng = GitEngine::open(dir.path()).unwrap();
let s = eng.status(&GitCaveats::top()).unwrap();
assert!(s.untracked.iter().any(|p| p == "new.txt"), "{s:?}");
}
#[test]
fn diff_worktree_lists_the_change() {
let dir = repo_with_commit();
std::fs::write(dir.path().join("a.txt"), "changed\n").unwrap();
let eng = GitEngine::open(dir.path()).unwrap();
let d = eng.diff(&GitCaveats::top(), DiffSpec::Worktree).unwrap();
assert!(d.files.iter().any(|f| f.path == "a.txt"));
}
#[test]
fn checkout_creates_and_switches_to_a_new_branch() {
let dir = repo_with_commit();
let eng = GitEngine::open(dir.path()).unwrap();
let msg = eng.checkout(&GitCaveats::top(), "feat/y", true).unwrap();
assert!(msg.contains("created and switched"), "{msg}");
let out = Command::new("git")
.current_dir(dir.path())
.args(["symbolic-ref", "--short", "HEAD"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "feat/y");
}
#[test]
fn checkout_switches_to_existing_branch_at_same_commit() {
let dir = repo_with_commit();
let eng = GitEngine::open(dir.path()).unwrap();
eng.branch(&GitCaveats::top(), "feat/z").unwrap(); let msg = eng.checkout(&GitCaveats::top(), "feat/z", false).unwrap();
assert_eq!(msg, "switched to branch 'feat/z'");
}
#[test]
fn checkout_refuses_existing_branch_at_a_different_commit() {
let dir = repo_with_commit();
let p = dir.path();
git(p, &["checkout", "-q", "-b", "ahead"]);
std::fs::write(p.join("a.txt"), "v2\n").unwrap();
git(p, &["add", "a.txt"]);
git(
p,
&[
"-c",
"user.name=T",
"-c",
"user.email=t@e.c",
"commit",
"-q",
"-m",
"c2",
],
);
git(p, &["checkout", "-q", "main"]);
let eng = GitEngine::open(p).unwrap();
let err = eng
.checkout(&GitCaveats::top(), "ahead", false)
.unwrap_err();
assert!(matches!(err, GitError::Refused(_)), "{err}");
let out = Command::new("git")
.current_dir(p)
.args(["symbolic-ref", "--short", "HEAD"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "main");
}
#[test]
fn checkout_missing_branch_without_create_is_refused() {
let dir = repo_with_commit();
let eng = GitEngine::open(dir.path()).unwrap();
let err = eng.checkout(&GitCaveats::top(), "nope", false).unwrap_err();
assert!(matches!(err, GitError::Refused(_)), "{err}");
}
#[test]
fn branch_delete_removes_a_non_current_branch() {
let dir = repo_with_commit();
let eng = GitEngine::open(dir.path()).unwrap();
eng.branch(&GitCaveats::top(), "scratch").unwrap();
let msg = eng.branch_delete(&GitCaveats::top(), "scratch").unwrap();
assert_eq!(msg, "deleted branch 'scratch'");
let exists = Command::new("git")
.current_dir(dir.path())
.args(["rev-parse", "--verify", "--quiet", "refs/heads/scratch"])
.status()
.unwrap()
.success();
assert!(!exists, "ref must be gone after branch-delete");
}
#[test]
fn branch_delete_refuses_current_branch_and_missing() {
let dir = repo_with_commit();
let eng = GitEngine::open(dir.path()).unwrap();
let cur = eng.branch_delete(&GitCaveats::top(), "main").unwrap_err();
assert!(matches!(cur, GitError::Refused(_)), "{cur}");
let missing = eng.branch_delete(&GitCaveats::top(), "ghost").unwrap_err();
assert!(matches!(missing, GitError::Refused(_)), "{missing}");
}
#[test]
fn checkout_and_branch_delete_fail_closed_without_refs() {
let dir = repo_with_commit();
let eng = GitEngine::open(dir.path()).unwrap();
let ro = GitCaveats::read_only();
assert!(matches!(
eng.checkout(&ro, "x", true),
Err(GitError::Denied("refs"))
));
assert!(matches!(
eng.branch_delete(&ro, "x"),
Err(GitError::Denied("refs"))
));
}
#[test]
fn read_ops_fail_closed_without_read_capability() {
let dir = repo_with_commit();
let eng = GitEngine::open(dir.path()).unwrap();
let no = GitCaveats::none();
assert!(matches!(eng.status(&no), Err(GitError::Denied("read"))));
assert!(matches!(eng.log(&no, 1), Err(GitError::Denied("read"))));
assert!(matches!(
eng.diff(&no, DiffSpec::Worktree),
Err(GitError::Denied("read"))
));
}
#[test]
fn status_report_serde_roundtrip() {
let dir = repo_with_commit();
let eng = GitEngine::open(dir.path()).unwrap();
let s = eng.status(&GitCaveats::top()).unwrap();
let json = serde_json::to_string(&s).unwrap();
let back: StatusReport = serde_json::from_str(&json).unwrap();
assert_eq!(s, back);
}
#[test]
fn add_then_commit_advances_history() {
let dir = repo_with_commit();
std::fs::write(dir.path().join("new.txt"), "data\n").unwrap();
let eng = GitEngine::open(dir.path()).unwrap();
let caps = GitCaveats::top();
let staged = eng.add(&caps, &["new.txt".to_string()]).unwrap();
assert_eq!(staged, vec!["new.txt".to_string()]);
assert!(eng
.status(&caps)
.unwrap()
.staged
.iter()
.any(|f| f.path == "new.txt"));
let author = Author {
name: "Bot".into(),
email: "bot@newt.dev".into(),
};
let c = eng.commit(&caps, "add new file", &author).unwrap();
assert_eq!(c.summary, "add new file");
assert_eq!(c.author_name, "Bot");
let log = eng.log(&caps, 10).unwrap();
assert_eq!(log.len(), 2);
assert_eq!(log[0].summary, "add new file");
assert!(eng.status(&caps).unwrap().clean, "clean after commit");
let out = std::process::Command::new("git")
.current_dir(dir.path())
.args(["log", "--oneline"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&out.stdout).lines().count(), 2);
}
#[test]
fn branch_creates_a_ref_at_head() {
let dir = repo_with_commit();
let eng = GitEngine::open(dir.path()).unwrap();
let refname = eng.branch(&GitCaveats::top(), "feat/x").unwrap();
assert_eq!(refname, "refs/heads/feat/x");
let ok = Command::new("git")
.current_dir(dir.path())
.args(["rev-parse", "--verify", "refs/heads/feat/x"])
.status()
.unwrap()
.success();
assert!(ok, "branch ref must resolve under the system git too");
}
#[test]
fn writes_fail_closed_without_capability() {
let dir = repo_with_commit();
std::fs::write(dir.path().join("new.txt"), "x\n").unwrap();
let eng = GitEngine::open(dir.path()).unwrap();
let author = Author {
name: "B".into(),
email: "b@b".into(),
};
let ro = GitCaveats::read_only();
assert!(matches!(
eng.add(&ro, &["new.txt".to_string()]),
Err(GitError::Denied("stage"))
));
assert!(matches!(
eng.commit(&ro, "m", &author),
Err(GitError::Denied("commit"))
));
assert!(matches!(
eng.branch(&ro, "x"),
Err(GitError::Denied("refs"))
));
let stage_only = GitCaveats {
commit_local: false,
..GitCaveats::top()
};
assert!(eng.add(&stage_only, &["new.txt".to_string()]).is_ok());
assert!(matches!(
eng.commit(&stage_only, "m", &author),
Err(GitError::Denied("commit"))
));
}
use newt_core::agentic::GitTool as _;
fn tool(dir: &Path) -> LocalGitTool {
LocalGitTool {
root: dir.to_path_buf(),
author: Author {
name: "newt-agent[bot]".into(),
email: "bot@example.com".into(),
},
coauthor: Some(
"Co-authored-by: qwen3:30b (newt-agent v0.6.8) <noreply@newt-agent.com>".into(),
),
}
}
#[test]
fn dispatch_checkout_creates_branch_and_branch_delete_removes_it() {
let dir = repo_with_commit();
let t = tool(dir.path());
let out = t
.dispatch(
"checkout",
&serde_json::json!({"name": "feat/dispatch"}),
&GitCaveats::top(),
)
.unwrap();
assert!(out.contains("created and switched"), "{out}");
t.dispatch(
"checkout",
&serde_json::json!({"name": "main", "create": false}),
&GitCaveats::top(),
)
.unwrap();
let del = t
.dispatch(
"branch-delete",
&serde_json::json!({"name": "feat/dispatch"}),
&GitCaveats::top(),
)
.unwrap();
assert_eq!(del, "deleted branch 'feat/dispatch'");
}
#[test]
fn dispatch_unknown_op_lists_the_supported_ops() {
let dir = repo_with_commit();
let t = tool(dir.path());
let err = t
.dispatch("pull", &serde_json::json!({}), &GitCaveats::top())
.unwrap_err();
assert!(err.contains("unknown git op 'pull'"), "{err}");
assert!(err.contains("checkout"), "{err}");
assert!(err.contains("branch-delete"), "{err}");
}
#[test]
fn sign_message_appends_trailer_then_dedups() {
let tr = "Co-authored-by: qwen3:30b (newt-agent v0.6.8) <noreply@newt-agent.com>";
let out = sign_message("docs: tweak", Some(tr));
assert_eq!(out, format!("docs: tweak\n\n{tr}"));
let already = "feat: x\n\nCo-authored-by: someone <a@b.c>";
assert_eq!(sign_message(already, Some(tr)), already);
assert_eq!(sign_message("m", None), "m");
}
#[test]
fn commit_carries_the_coauthor_trailer_in_the_message() {
let dir = repo_with_commit();
std::fs::write(dir.path().join("c.txt"), "x\n").unwrap();
let t = tool(dir.path());
t.dispatch(
"add",
&serde_json::json!({"paths": ["c.txt"]}),
&GitCaveats::top(),
)
.unwrap();
t.dispatch(
"commit",
&serde_json::json!({"message": "add c"}),
&GitCaveats::top(),
)
.unwrap();
let log = Command::new("git")
.current_dir(dir.path())
.args(["log", "-1", "--pretty=%B"])
.output()
.unwrap();
let body = String::from_utf8_lossy(&log.stdout);
assert!(body.contains("add c"), "subject present: {body}");
assert!(
body.contains("Co-authored-by: qwen3:30b (newt-agent v0.6.8)"),
"trailer present: {body}"
);
}
#[test]
fn local_git_tool_status_renders_readable_text() {
let dir = repo_with_commit();
let t = tool(dir.path());
let out = t
.dispatch("status", &serde_json::json!({}), &GitCaveats::top())
.unwrap();
assert!(out.contains("on branch main"), "got: {out}");
assert!(out.contains("working tree clean"), "got: {out}");
}
#[test]
fn local_git_tool_log_lists_commits() {
let dir = repo_with_commit();
let t = tool(dir.path());
let out = t
.dispatch("log", &serde_json::json!({"limit": 5}), &GitCaveats::top())
.unwrap();
assert!(out.contains("first commit"), "got: {out}");
}
#[test]
fn local_git_tool_add_then_commit_succeeds_when_permitted() {
let dir = repo_with_commit();
std::fs::write(dir.path().join("b.txt"), "two\n").unwrap();
let t = tool(dir.path());
let staged = t
.dispatch(
"add",
&serde_json::json!({"paths": ["b.txt"]}),
&GitCaveats::top(),
)
.unwrap();
assert!(staged.contains("b.txt"), "got: {staged}");
let committed = t
.dispatch(
"commit",
&serde_json::json!({"message": "add b"}),
&GitCaveats::top(),
)
.unwrap();
assert!(committed.starts_with("committed "), "got: {committed}");
assert!(committed.contains("add b"), "got: {committed}");
}
#[test]
fn local_git_tool_amend_rewords_head_without_adding_a_commit() {
let dir = repo_with_commit();
std::fs::write(dir.path().join("d.txt"), "d\n").unwrap();
let t = tool(dir.path());
t.dispatch(
"add",
&serde_json::json!({"paths": ["d.txt"]}),
&GitCaveats::top(),
)
.unwrap();
t.dispatch(
"commit",
&serde_json::json!({"message": "add d"}),
&GitCaveats::top(),
)
.unwrap();
let count_before = commit_count(dir.path());
let out = t
.dispatch(
"amend",
&serde_json::json!({"message": "add d (reworded)"}),
&GitCaveats::top(),
)
.unwrap();
assert!(out.starts_with("amended "), "got: {out}");
assert_eq!(commit_count(dir.path()), count_before);
let body = head_message(dir.path());
assert!(body.contains("add d (reworded)"), "got: {body}");
assert!(
body.contains("Co-authored-by: qwen3:30b"),
"amend re-signs the new message: {body}"
);
}
#[test]
fn local_git_tool_amend_keeps_message_when_omitted() {
let dir = repo_with_commit();
let t = tool(dir.path());
t.dispatch("amend", &serde_json::json!({}), &GitCaveats::top())
.unwrap();
assert!(head_message(dir.path()).contains("first commit"));
}
#[test]
fn local_git_tool_amend_denied_on_read_only() {
let dir = repo_with_commit();
let t = tool(dir.path());
let err = t
.dispatch(
"amend",
&serde_json::json!({"message": "x"}),
&GitCaveats::read_only(),
)
.unwrap_err();
assert!(
err.contains("denied") && err.contains("commit"),
"got: {err}"
);
}
fn commit_count(dir: &Path) -> usize {
let out = Command::new("git")
.current_dir(dir)
.args(["rev-list", "--count", "HEAD"])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().parse().unwrap()
}
fn head_message(dir: &Path) -> String {
let out = Command::new("git")
.current_dir(dir)
.args(["log", "-1", "--pretty=%B"])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).to_string()
}
#[test]
fn local_git_tool_commit_denied_on_read_only_caveats() {
let dir = repo_with_commit();
let t = tool(dir.path());
let err = t
.dispatch(
"commit",
&serde_json::json!({"message": "nope"}),
&GitCaveats::read_only(),
)
.unwrap_err();
assert!(
err.contains("denied") && err.contains("commit"),
"got: {err}"
);
assert!(t
.dispatch("status", &serde_json::json!({}), &GitCaveats::read_only())
.is_ok());
}
fn repo_with_three() -> (tempfile::TempDir, Vec<String>) {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
let mk = |name: &str, content: &str, msg: &str| {
std::fs::write(p.join(name), content).unwrap();
git(p, &["add", name]);
git(
p,
&[
"-c",
"user.name=T",
"-c",
"user.email=t@e.c",
"commit",
"-q",
"-m",
msg,
],
);
};
mk("a.txt", "v1\n", "c1");
mk("b.txt", "b\n", "c2");
mk("c.txt", "c\n", "c3");
let out = Command::new("git")
.current_dir(p)
.args(["log", "--format=%H", "--reverse"])
.output()
.unwrap();
let oids = String::from_utf8_lossy(&out.stdout)
.lines()
.map(String::from)
.collect();
(dir, oids)
}
#[test]
fn rebase_rewords_a_middle_commit() {
let (dir, oids) = repo_with_three();
let t = tool(dir.path());
let out = t
.dispatch(
"rebase",
&serde_json::json!({
"onto": oids[0],
"plan": [
{"commit": oids[1], "action": "reword", "message": "b reworded"},
{"commit": oids[2], "action": "pick"},
]
}),
&GitCaveats::top(),
)
.unwrap();
assert!(out.starts_with("rebased onto"), "got: {out}");
assert_eq!(commit_count(dir.path()), 3, "same number of commits");
let log = Command::new("git")
.current_dir(dir.path())
.args(["log", "--format=%s", "--reverse"])
.output()
.unwrap();
let subjects = String::from_utf8_lossy(&log.stdout);
assert!(subjects.contains("b reworded"), "got: {subjects}");
assert!(
!subjects.contains("\nc2\n"),
"old c2 subject gone: {subjects}"
);
}
#[test]
fn rebase_squashes_two_commits_into_one() {
let (dir, oids) = repo_with_three();
let t = tool(dir.path());
t.dispatch(
"rebase",
&serde_json::json!({
"onto": oids[0],
"plan": [
{"commit": oids[1], "action": "pick"},
{"commit": oids[2], "action": "squash", "message": "folded note"},
]
}),
&GitCaveats::top(),
)
.unwrap();
assert_eq!(commit_count(dir.path()), 2);
let body = head_message(dir.path());
assert!(
body.contains("c2") && body.contains("folded note"),
"got: {body}"
);
let files = Command::new("git")
.current_dir(dir.path())
.args(["ls-tree", "--name-only", "-r", "HEAD"])
.output()
.unwrap();
let names = String::from_utf8_lossy(&files.stdout);
assert!(
names.contains("b.txt") && names.contains("c.txt"),
"got: {names}"
);
}
#[test]
fn rebase_drops_a_commit() {
let (dir, oids) = repo_with_three();
let t = tool(dir.path());
t.dispatch(
"rebase",
&serde_json::json!({
"onto": oids[0],
"plan": [
{"commit": oids[1], "action": "pick"},
{"commit": oids[2], "action": "drop"},
]
}),
&GitCaveats::top(),
)
.unwrap();
assert_eq!(commit_count(dir.path()), 2);
let names = Command::new("git")
.current_dir(dir.path())
.args(["ls-tree", "--name-only", "-r", "HEAD"])
.output()
.unwrap();
let names = String::from_utf8_lossy(&names.stdout);
assert!(
!names.contains("c.txt"),
"dropped commit's file gone: {names}"
);
}
#[test]
fn rebase_aborts_on_conflict_leaving_the_branch_unchanged() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
let mk = |content: &str, msg: &str| {
std::fs::write(p.join("a.txt"), content).unwrap();
git(p, &["add", "a.txt"]);
git(
p,
&[
"-c",
"user.name=T",
"-c",
"user.email=t@e.c",
"commit",
"-q",
"-m",
msg,
],
);
};
mk("v1\n", "c1");
mk("v2\n", "c2");
mk("v3\n", "c3");
let head_before = Command::new("git")
.current_dir(p)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
let oids: Vec<String> = String::from_utf8_lossy(
&Command::new("git")
.current_dir(p)
.args(["log", "--format=%H", "--reverse"])
.output()
.unwrap()
.stdout,
)
.lines()
.map(String::from)
.collect();
let t = tool(p);
let err = t
.dispatch(
"rebase",
&serde_json::json!({
"onto": oids[0],
"plan": [{"commit": oids[2], "action": "pick"}]
}),
&GitCaveats::top(),
)
.unwrap_err();
assert!(
err.contains("conflict") && err.contains("aborted"),
"got: {err}"
);
let head_after = Command::new("git")
.current_dir(p)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
assert_eq!(
head_before.stdout, head_after.stdout,
"branch must be unchanged"
);
}
#[test]
fn rebase_denied_on_read_only() {
let (dir, oids) = repo_with_three();
let t = tool(dir.path());
let err = t
.dispatch(
"rebase",
&serde_json::json!({"onto": oids[0], "plan": [{"commit": oids[1], "action": "pick"}]}),
&GitCaveats::read_only(),
)
.unwrap_err();
assert!(
err.contains("denied") && err.contains("commit"),
"got: {err}"
);
}
#[test]
fn local_git_tool_unknown_op_and_missing_args_error() {
let dir = repo_with_commit();
let t = tool(dir.path());
let err = t
.dispatch("frobnicate", &serde_json::json!({}), &GitCaveats::top())
.unwrap_err();
assert!(err.contains("unknown git op"), "got: {err}");
let err = t
.dispatch("commit", &serde_json::json!({}), &GitCaveats::top())
.unwrap_err();
assert!(err.contains("message"), "got: {err}");
}
}