Skip to main content

newt_git/
lib.rs

1//! `newt-git` — the embedded git engine for newt agents.
2//!
3//! Wraps [`grit-lib`](https://crates.io/crates/grit-lib) (a pure-Rust, from-scratch
4//! git reimplementation, MIT) behind the [`GitCaveats`](newt_core::git_caveats::GitCaveats)
5//! OCAP surface. Every operation takes the already-composed `&GitCaveats` and **fails
6//! closed**; results are this crate's **own** structured serde models, converted at the
7//! grit-lib boundary — so grit-lib's pre-1.0 API churn is contained to this one crate
8//! and never leaks into the rest of the workspace.
9//!
10//! **Scope: LOCAL ops** — reads (`open`/`status`/`log`/`diff`) and writes
11//! (`add`/`commit`/`branch`), each gated by the matching `GitCaveats` axis and
12//! fail-closed without it. Network ops (`clone`/`fetch`/`push`) are deferred (PR5) —
13//! fail-closed under the OCAP deviation ratchet, riding the SSH transport. We depend
14//! ONLY on the MIT `grit-lib`, never the GPL-2.0 `grit-legacy`.
15
16use newt_core::git_caveats::GitCaveats;
17use serde::{Deserialize, Serialize};
18use std::path::Path;
19
20use grit_lib::diff::{diff_index_to_tree, diff_index_to_worktree, DiffEntry};
21use grit_lib::index::{IndexEntry, MODE_REGULAR};
22use grit_lib::merge_base::resolve_commit_specs;
23use grit_lib::merge_file::MergeFavor;
24use grit_lib::merge_trees::{
25    merge_trees_three_way, TreeMergeConflictPresentation, WhitespaceMergeOptions,
26};
27use grit_lib::objects::{parse_commit, serialize_commit, CommitData, ObjectId, ObjectKind};
28use grit_lib::porcelain::status::{collect_untracked_and_ignored, IgnoredMode};
29use grit_lib::refs::{delete_ref, read_head, resolve_ref, write_ref, write_symbolic_ref};
30use grit_lib::repo::Repository;
31use grit_lib::state::{resolve_head, HeadState};
32use grit_lib::write_tree::write_tree_from_index;
33
34/// Errors from the embedded git engine.
35#[derive(Debug, thiserror::Error)]
36pub enum GitError {
37    /// The [`GitCaveats`] surface denied this operation class (fail-closed).
38    #[error("capability denied: git {0} not permitted")]
39    Denied(&'static str),
40    /// The underlying grit-lib engine failed.
41    #[error("git: {0}")]
42    Engine(#[from] grit_lib::error::Error),
43    /// A filesystem read of a worktree file failed (during staging).
44    #[error("io: {0}")]
45    Io(#[from] std::io::Error),
46    /// The operation isn't supported in this repository state (e.g. bare repo,
47    /// detached/unborn HEAD).
48    #[error("unsupported: {0}")]
49    Unsupported(&'static str),
50    /// The operation was refused for a runtime reason that carries a dynamic
51    /// message — e.g. deleting the current branch, or switching to a branch at a
52    /// different commit (no working-tree updater here). No side effects.
53    #[error("{0}")]
54    Refused(String),
55    /// A rebase step produced a merge conflict; the rebase was aborted and the
56    /// branch ref was left untouched (no side effects).
57    #[error("rebase conflict at {0} — aborted, branch unchanged")]
58    Conflict(String),
59    /// The rebase plan was malformed (e.g. a squash before any pick).
60    #[error("bad rebase plan: {0}")]
61    BadPlan(String),
62}
63
64/// What a [rebase](GitEngine::rebase) step does with its commit.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum RebaseAction {
67    /// Replay the commit as-is.
68    Pick,
69    /// Replay, but with a new message.
70    Reword,
71    /// Fold into the previous commit, KEEPING both messages.
72    Squash,
73    /// Fold into the previous commit, DISCARDING this message.
74    Fixup,
75    /// Skip the commit entirely.
76    Drop,
77}
78
79/// One entry in a structured rebase plan.
80#[derive(Debug, Clone)]
81pub struct RebaseStep {
82    /// The commit to act on (id / ref / short oid — resolved at run time).
83    pub commit: String,
84    pub action: RebaseAction,
85    /// New / extra message for `Reword` and `Squash`.
86    pub message: Option<String>,
87}
88
89/// Outcome of a [rebase](GitEngine::rebase).
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct RebaseReport {
92    /// Short oid of the new branch tip.
93    pub new_head: String,
94    /// Commits produced on the rebased segment.
95    pub produced: usize,
96    /// Steps dropped.
97    pub dropped: usize,
98}
99
100/// Commit authorship — supplied by the caller (e.g. from the agent identity).
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct Author {
103    pub name: String,
104    pub email: String,
105}
106
107impl Author {
108    /// A git ident line stamped at the current time, UTC: `"Name <email> <secs> +0000"`.
109    fn ident_now(&self) -> String {
110        let secs = std::time::SystemTime::now()
111            .duration_since(std::time::UNIX_EPOCH)
112            .map(|d| d.as_secs())
113            .unwrap_or(0);
114        format!("{} <{}> {} +0000", self.name, self.email, secs)
115    }
116}
117
118/// A single changed path. `status` is git's status letter (`M`/`A`/`D`/`R`/`C`/…).
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct FileChange {
121    pub status: char,
122    pub path: String,
123}
124
125/// Working-tree status — the porcelain facts, no presentation.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct StatusReport {
128    /// Current branch short name, or `None` when detached/unborn.
129    pub branch: Option<String>,
130    /// Short HEAD oid, or `None` on an unborn HEAD.
131    pub head: Option<String>,
132    /// Staged changes (index vs HEAD tree).
133    pub staged: Vec<FileChange>,
134    /// Unstaged changes (worktree vs index).
135    pub unstaged: Vec<FileChange>,
136    /// Untracked paths.
137    pub untracked: Vec<String>,
138    /// True iff nothing is staged, unstaged, or untracked.
139    pub clean: bool,
140}
141
142/// One commit's metadata.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct CommitInfo {
145    pub id: String,
146    pub short_id: String,
147    pub author_name: String,
148    pub author_email: String,
149    /// Author time, unix seconds.
150    pub timestamp: i64,
151    pub summary: String,
152    pub parents: Vec<String>,
153}
154
155/// The set of files a diff touched.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct DiffReport {
158    pub files: Vec<FileChange>,
159}
160
161/// Which diff to compute.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum DiffSpec {
164    /// Unstaged: worktree vs index.
165    Worktree,
166    /// Staged: index vs HEAD tree.
167    Staged,
168}
169
170/// An embedded git engine bound to one repository.
171pub struct GitEngine {
172    repo: Repository,
173}
174
175impl GitEngine {
176    /// Discover and open the repository containing `root` (walks up for `.git`).
177    pub fn open(root: &Path) -> Result<Self, GitError> {
178        let repo = Repository::discover(Some(root))?;
179        Ok(Self { repo })
180    }
181
182    fn head_oid(&self) -> Result<Option<ObjectId>, GitError> {
183        Ok(match resolve_head(&self.repo.git_dir)? {
184            HeadState::Branch { oid, .. } => oid,
185            HeadState::Detached { oid } => Some(oid),
186            HeadState::Invalid => None,
187        })
188    }
189
190    fn head_tree(&self) -> Result<Option<ObjectId>, GitError> {
191        match self.head_oid()? {
192            Some(oid) => {
193                let obj = self.repo.odb.read(&oid)?;
194                Ok(Some(parse_commit(&obj.data)?.tree))
195            }
196            None => Ok(None),
197        }
198    }
199
200    /// `git status` — requires the `read` capability.
201    pub fn status(&self, caps: &GitCaveats) -> Result<StatusReport, GitError> {
202        if !caps.permits_read() {
203            return Err(GitError::Denied("read"));
204        }
205        let index = self.repo.load_index()?;
206        let (branch, head) = match resolve_head(&self.repo.git_dir)? {
207            HeadState::Branch {
208                short_name, oid, ..
209            } => (Some(short_name), oid.as_ref().map(short_oid)),
210            HeadState::Detached { oid } => (None, Some(short_oid(&oid))),
211            HeadState::Invalid => (None, None),
212        };
213        let tree = self.head_tree()?;
214        let staged = diff_index_to_tree(&self.repo.odb, &index, tree.as_ref(), false)?;
215        let (unstaged, untracked) = match self.repo.work_tree.clone() {
216            Some(wt) => {
217                let unstaged = diff_index_to_worktree(&self.repo.odb, &index, &wt, false, false)?;
218                let untracked = collect_untracked_and_ignored(
219                    &self.repo,
220                    &index,
221                    &wt,
222                    IgnoredMode::No,
223                    false,
224                    &[],
225                )?
226                .0;
227                (unstaged, untracked)
228            }
229            None => (Vec::new(), Vec::new()),
230        };
231        let staged: Vec<FileChange> = staged.iter().map(file_change).collect();
232        let unstaged: Vec<FileChange> = unstaged.iter().map(file_change).collect();
233        let clean = staged.is_empty() && unstaged.is_empty() && untracked.is_empty();
234        Ok(StatusReport {
235            branch,
236            head,
237            staged,
238            unstaged,
239            untracked,
240            clean,
241        })
242    }
243
244    /// `git log` — a first-parent walk from HEAD, up to `limit` commits. Requires `read`.
245    pub fn log(&self, caps: &GitCaveats, limit: usize) -> Result<Vec<CommitInfo>, GitError> {
246        if !caps.permits_read() {
247            return Err(GitError::Denied("read"));
248        }
249        let mut out = Vec::new();
250        let mut next = self.head_oid()?;
251        while let Some(oid) = next {
252            if out.len() >= limit {
253                break;
254            }
255            let obj = self.repo.odb.read(&oid)?;
256            let commit = parse_commit(&obj.data)?;
257            out.push(commit_info(&oid, &commit));
258            next = commit.parents.first().cloned();
259        }
260        Ok(out)
261    }
262
263    /// `git diff` for the given `spec`. Requires `read`.
264    pub fn diff(&self, caps: &GitCaveats, spec: DiffSpec) -> Result<DiffReport, GitError> {
265        if !caps.permits_read() {
266            return Err(GitError::Denied("read"));
267        }
268        let index = self.repo.load_index()?;
269        let entries = match spec {
270            DiffSpec::Worktree => match self.repo.work_tree.clone() {
271                Some(wt) => diff_index_to_worktree(&self.repo.odb, &index, &wt, false, false)?,
272                None => Vec::new(),
273            },
274            DiffSpec::Staged => {
275                let tree = self.head_tree()?;
276                diff_index_to_tree(&self.repo.odb, &index, tree.as_ref(), false)?
277            }
278        };
279        Ok(DiffReport {
280            files: entries.iter().map(file_change).collect(),
281        })
282    }
283
284    /// `git add` — stage worktree files into the index. Requires `stage`.
285    /// Each `path` is repository-relative. Returns the paths actually staged.
286    pub fn add(&self, caps: &GitCaveats, paths: &[String]) -> Result<Vec<String>, GitError> {
287        if !caps.permits_stage() {
288            return Err(GitError::Denied("stage"));
289        }
290        let wt = self
291            .repo
292            .work_tree
293            .clone()
294            .ok_or(GitError::Unsupported("cannot stage in a bare repository"))?;
295        let mut index = self.repo.load_index()?;
296        let mut staged = Vec::with_capacity(paths.len());
297        for rel in paths {
298            let abs = wt.join(rel);
299            let bytes = std::fs::read(&abs)?;
300            let oid = self.repo.odb.write(ObjectKind::Blob, &bytes)?;
301            let size = bytes.len() as u32;
302            let path = rel.as_bytes().to_vec();
303            // The stat fields are left zero (a benign "needs refresh" to git); the
304            // blob oid is authoritative for diffs. `flags` carries the name length.
305            let flags = path.len().min(0x0FFF) as u16;
306            index.add_or_replace(IndexEntry {
307                ctime_sec: 0,
308                ctime_nsec: 0,
309                mtime_sec: 0,
310                mtime_nsec: 0,
311                dev: 0,
312                ino: 0,
313                mode: MODE_REGULAR,
314                uid: 0,
315                gid: 0,
316                size,
317                oid,
318                flags,
319                flags_extended: None,
320                path,
321                base_index_pos: 0,
322            });
323            staged.push(rel.clone());
324        }
325        self.repo.write_index(&mut index)?;
326        Ok(staged)
327    }
328
329    /// `git commit` — build a tree from the index, write the commit, and advance the
330    /// current branch. Requires `commit_local`. Errors on detached HEAD.
331    pub fn commit(
332        &self,
333        caps: &GitCaveats,
334        message: &str,
335        author: &Author,
336    ) -> Result<CommitInfo, GitError> {
337        if !caps.permits_commit() {
338            return Err(GitError::Denied("commit"));
339        }
340        let index = self.repo.load_index()?;
341        let tree = write_tree_from_index(&self.repo.odb, &index, "")?;
342        let parents: Vec<ObjectId> = self.head_oid()?.into_iter().collect();
343        let ident = author.ident_now();
344        let commit = CommitData {
345            tree,
346            parents,
347            author: ident.clone(),
348            committer: ident,
349            author_raw: Vec::new(),
350            committer_raw: Vec::new(),
351            encoding: None,
352            message: message.to_string(),
353            raw_message: None,
354        };
355        let oid = self
356            .repo
357            .odb
358            .write(ObjectKind::Commit, &serialize_commit(&commit))?;
359        match read_head(&self.repo.git_dir)? {
360            Some(branch_ref) => write_ref(&self.repo.git_dir, &branch_ref, &oid)?,
361            None => return Err(GitError::Unsupported("cannot commit on a detached HEAD")),
362        }
363        Ok(commit_info(&oid, &commit))
364    }
365
366    /// `git commit --amend` — replace HEAD with a new commit carrying the
367    /// current index tree, keeping HEAD's PARENTS (not HEAD itself). `message`
368    /// `None` reuses HEAD's existing message (amend-to-add-files); `Some` rewords
369    /// it. Requires `commit_local`. Errors on an unborn / detached HEAD.
370    pub fn amend(
371        &self,
372        caps: &GitCaveats,
373        message: Option<&str>,
374        author: &Author,
375    ) -> Result<CommitInfo, GitError> {
376        if !caps.permits_commit() {
377            return Err(GitError::Denied("commit"));
378        }
379        let head = self
380            .head_oid()?
381            .ok_or(GitError::Unsupported("nothing to amend (unborn HEAD)"))?;
382        let head_commit = parse_commit(&self.repo.odb.read(&head)?.data)?;
383        let index = self.repo.load_index()?;
384        let tree = write_tree_from_index(&self.repo.odb, &index, "")?;
385        let ident = author.ident_now();
386        let commit = CommitData {
387            tree,
388            // The defining difference from `commit`: keep HEAD's parents so the
389            // amended commit replaces HEAD rather than stacking on top of it.
390            parents: head_commit.parents.clone(),
391            author: ident.clone(),
392            committer: ident,
393            author_raw: Vec::new(),
394            committer_raw: Vec::new(),
395            encoding: None,
396            message: message.map(str::to_string).unwrap_or(head_commit.message),
397            raw_message: None,
398        };
399        let oid = self
400            .repo
401            .odb
402            .write(ObjectKind::Commit, &serialize_commit(&commit))?;
403        match read_head(&self.repo.git_dir)? {
404            Some(branch_ref) => write_ref(&self.repo.git_dir, &branch_ref, &oid)?,
405            None => return Err(GitError::Unsupported("cannot amend on a detached HEAD")),
406        }
407        Ok(commit_info(&oid, &commit))
408    }
409
410    /// Resolve a commit spec (short oid / ref / id) to an `ObjectId`.
411    fn resolve_one(&self, spec: &str) -> Result<ObjectId, GitError> {
412        resolve_commit_specs(&self.repo, &[spec.to_string()])?
413            .into_iter()
414            .next()
415            .ok_or(GitError::Unsupported("could not resolve commit"))
416    }
417
418    fn commit_tree(&self, oid: &ObjectId) -> Result<ObjectId, GitError> {
419        Ok(parse_commit(&self.repo.odb.read(oid)?.data)?.tree)
420    }
421
422    /// Write a single-parent commit with the agent's identity; returns its oid.
423    fn write_commit_on(
424        &self,
425        parent: ObjectId,
426        tree: ObjectId,
427        message: &str,
428        author: &Author,
429    ) -> Result<ObjectId, GitError> {
430        let ident = author.ident_now();
431        let commit = CommitData {
432            tree,
433            parents: vec![parent],
434            author: ident.clone(),
435            committer: ident,
436            author_raw: Vec::new(),
437            committer_raw: Vec::new(),
438            encoding: None,
439            message: message.to_string(),
440            raw_message: None,
441        };
442        Ok(self
443            .repo
444            .odb
445            .write(ObjectKind::Commit, &serialize_commit(&commit))?)
446    }
447
448    /// Structured-plan rebase: replay `steps` (in order) onto `onto`, applying
449    /// pick / reword / squash / fixup / drop. All new trees and commits are
450    /// written to the ODB; the branch ref is advanced **only at the very end**,
451    /// so a conflict (or any error) aborts with the branch unchanged — no
452    /// working-tree, index, or ref side effects. Requires `commit_local`.
453    ///
454    /// Cherry-pick per step is a 3-way tree merge with `MergeFavor::None` so
455    /// real conflicts are reported (not silently resolved). Authorship on the
456    /// produced commits is the agent's (the typical case: rewriting its own
457    /// recent history). Root commits (no parent) cannot be replayed.
458    pub fn rebase(
459        &self,
460        caps: &GitCaveats,
461        onto: &str,
462        steps: &[RebaseStep],
463        author: &Author,
464    ) -> Result<RebaseReport, GitError> {
465        if !caps.permits_commit() {
466            return Err(GitError::Denied("commit"));
467        }
468        let head_ref = read_head(&self.repo.git_dir)?
469            .ok_or(GitError::Unsupported("cannot rebase on a detached HEAD"))?;
470        let onto_oid = self.resolve_one(onto)?;
471
472        // The commit currently being assembled (a `pick`/`reword` opens it;
473        // `squash`/`fixup` extend it; the next pick or the end closes it).
474        let mut tip = onto_oid;
475        let mut tip_tree = self.commit_tree(&onto_oid)?;
476        let mut open = false;
477        let mut cur_parent = onto_oid;
478        let mut cur_tree = tip_tree;
479        let mut cur_msgs: Vec<String> = Vec::new();
480        let mut produced = 0usize;
481        let mut dropped = 0usize;
482
483        for step in steps {
484            if step.action == RebaseAction::Drop {
485                dropped += 1;
486                continue;
487            }
488            let c = self.resolve_one(&step.commit)?;
489            let cc = parse_commit(&self.repo.odb.read(&c)?.data)?;
490            let parent = cc
491                .parents
492                .first()
493                .copied()
494                .ok_or(GitError::Unsupported("cannot rebase a root commit"))?;
495            let base_tree = self.commit_tree(&parent)?;
496            let ours = if open { cur_tree } else { tip_tree };
497            let merged = merge_trees_three_way(
498                &self.repo,
499                base_tree,
500                ours,
501                cc.tree,
502                MergeFavor::None,
503                WhitespaceMergeOptions::default(),
504                None,
505                TreeMergeConflictPresentation::default(),
506            )?;
507            if !merged.conflict_content.is_empty() {
508                let subj = cc.message.lines().next().unwrap_or("").trim();
509                return Err(GitError::Conflict(format!("{} ({subj})", short_oid(&c))));
510            }
511            let new_tree = write_tree_from_index(&self.repo.odb, &merged.index, "")?;
512
513            match step.action {
514                RebaseAction::Pick | RebaseAction::Reword => {
515                    // Close any open commit first.
516                    if open {
517                        tip = self.write_commit_on(
518                            cur_parent,
519                            cur_tree,
520                            &cur_msgs.join("\n\n"),
521                            author,
522                        )?;
523                        tip_tree = cur_tree;
524                        produced += 1;
525                    }
526                    cur_parent = tip;
527                    cur_tree = new_tree;
528                    cur_msgs = vec![match step.action {
529                        RebaseAction::Reword => step
530                            .message
531                            .clone()
532                            .ok_or(GitError::BadPlan("reword needs a message".into()))?,
533                        _ => cc.message.clone(),
534                    }];
535                    open = true;
536                }
537                RebaseAction::Squash => {
538                    if !open {
539                        return Err(GitError::BadPlan("squash before any pick".into()));
540                    }
541                    cur_tree = new_tree;
542                    cur_msgs.push(step.message.clone().unwrap_or_else(|| cc.message.clone()));
543                }
544                RebaseAction::Fixup => {
545                    if !open {
546                        return Err(GitError::BadPlan("fixup before any pick".into()));
547                    }
548                    cur_tree = new_tree; // message discarded
549                }
550                RebaseAction::Drop => unreachable!("filtered above"),
551            }
552        }
553        // Close the final open commit.
554        if open {
555            tip = self.write_commit_on(cur_parent, cur_tree, &cur_msgs.join("\n\n"), author)?;
556            produced += 1;
557        }
558        // The single mutating step: advance the branch ref to the new tip.
559        write_ref(&self.repo.git_dir, &head_ref, &tip)?;
560        Ok(RebaseReport {
561            new_head: short_oid(&tip),
562            produced,
563            dropped,
564        })
565    }
566
567    /// `git branch <name>` — create `refs/heads/<name>` at the current HEAD commit.
568    /// Requires `refs` to permit that ref name. Returns the full ref name.
569    pub fn branch(&self, caps: &GitCaveats, name: &str) -> Result<String, GitError> {
570        let refname = format!("refs/heads/{name}");
571        if !caps.permits_ref(&refname) {
572            return Err(GitError::Denied("refs"));
573        }
574        let oid = self
575            .head_oid()?
576            .ok_or(GitError::Unsupported("cannot branch from an unborn HEAD"))?;
577        write_ref(&self.repo.git_dir, &refname, &oid)?;
578        Ok(refname)
579    }
580
581    /// `git checkout [-b] <name>` — point HEAD at branch `<name>`, creating it at
582    /// the current commit first when `create` is set (the `-b` case the model
583    /// reaches for). Requires `refs` to permit the branch ref.
584    ///
585    /// newt is local-only and has no working-tree updater, so this only moves
586    /// HEAD when the target branch is at the SAME commit as the current HEAD
587    /// (always true for a freshly-created branch). Switching to a branch at a
588    /// different commit is *refused* rather than silently leaving the worktree
589    /// stale — no side effects on refusal.
590    pub fn checkout(
591        &self,
592        caps: &GitCaveats,
593        name: &str,
594        create: bool,
595    ) -> Result<String, GitError> {
596        let refname = format!("refs/heads/{name}");
597        if !caps.permits_ref(&refname) {
598            return Err(GitError::Denied("refs"));
599        }
600        let head = self.head_oid()?;
601        let existing = resolve_ref(&self.repo.git_dir, &refname).ok();
602        let (target, created) = match (existing, create) {
603            (Some(oid), _) => (Some(oid), false),
604            (None, true) => {
605                let oid = head.ok_or(GitError::Unsupported(
606                    "cannot create a branch from an unborn HEAD",
607                ))?;
608                write_ref(&self.repo.git_dir, &refname, &oid)?;
609                (Some(oid), true)
610            }
611            (None, false) => {
612                return Err(GitError::Refused(format!(
613                    "branch '{name}' does not exist (pass create=true to make it)"
614                )));
615            }
616        };
617        if target != head {
618            return Err(GitError::Refused(format!(
619                "refusing to switch to '{name}': it points at a different commit \
620                 than HEAD and newt cannot update the working tree (local-only). \
621                 Commit or stash first, or create a new branch at HEAD."
622            )));
623        }
624        write_symbolic_ref(&self.repo.git_dir, "HEAD", &refname)?;
625        Ok(if created {
626            format!("created and switched to branch '{name}'")
627        } else {
628            format!("switched to branch '{name}'")
629        })
630    }
631
632    /// `git branch -d <name>` — delete `refs/heads/<name>`. Requires `refs` to
633    /// permit the ref. Refuses to delete the branch HEAD is currently on, or a
634    /// branch that does not exist (no side effects on refusal).
635    pub fn branch_delete(&self, caps: &GitCaveats, name: &str) -> Result<String, GitError> {
636        let refname = format!("refs/heads/{name}");
637        if !caps.permits_ref(&refname) {
638            return Err(GitError::Denied("refs"));
639        }
640        if let HeadState::Branch { short_name, .. } = resolve_head(&self.repo.git_dir)? {
641            if short_name == name {
642                return Err(GitError::Refused(format!(
643                    "cannot delete branch '{name}': it is the current branch"
644                )));
645            }
646        }
647        if resolve_ref(&self.repo.git_dir, &refname).is_err() {
648            return Err(GitError::Refused(format!("branch '{name}' does not exist")));
649        }
650        delete_ref(&self.repo.git_dir, &refname)?;
651        Ok(format!("deleted branch '{name}'"))
652    }
653}
654
655fn short_oid(oid: &ObjectId) -> String {
656    oid.to_hex().chars().take(7).collect()
657}
658
659fn file_change(e: &DiffEntry) -> FileChange {
660    FileChange {
661        status: e.status.letter(),
662        path: e.path().to_string(),
663    }
664}
665
666fn commit_info(oid: &ObjectId, c: &CommitData) -> CommitInfo {
667    let (author_name, author_email, timestamp) = parse_ident(&c.author);
668    let summary = c.message.lines().next().unwrap_or("").to_string();
669    CommitInfo {
670        id: oid.to_hex(),
671        short_id: short_oid(oid),
672        author_name,
673        author_email,
674        timestamp,
675        summary,
676        parents: c.parents.iter().map(|p| p.to_hex()).collect(),
677    }
678}
679
680/// Parse a git ident line `"Name <email> 1700000000 +0000"` → (name, email, unix secs).
681fn parse_ident(s: &str) -> (String, String, i64) {
682    let name = s.split(" <").next().unwrap_or("").trim().to_string();
683    let email = s
684        .split_once('<')
685        .and_then(|(_, rest)| rest.split_once('>'))
686        .map(|(e, _)| e.to_string())
687        .unwrap_or_default();
688    let timestamp = s
689        .rsplit('>')
690        .next()
691        .and_then(|tail| tail.split_whitespace().next())
692        .and_then(|n| n.parse::<i64>().ok())
693        .unwrap_or(0);
694    (name, email, timestamp)
695}
696
697// ---------------------------------------------------------------------------
698// LocalGitTool — the injected `GitTool` impl (PR4, #461)
699// ---------------------------------------------------------------------------
700
701/// The on-disk [`GitEngine`] adapted to newt-core's
702/// [`GitTool`](newt_core::agentic::GitTool) seam. The binary constructs one per
703/// session (root = workspace, author = the resolved agent identity) and injects
704/// it into the agent loop; `execute_tool`'s `git` arm calls
705/// [`dispatch`](GitTool::dispatch). A fresh `GitEngine::open` per call keeps it
706/// stateless and cheap (no long-lived handle across turns).
707pub struct LocalGitTool {
708    pub root: std::path::PathBuf,
709    pub author: Author,
710    /// A `Co-authored-by:` trailer auto-appended to every commit message — the
711    /// AI credit, e.g. `Co-authored-by: qwen3:30b (newt-agent v0.6.8)
712    /// <noreply@newt-agent.com>`. The tool owns this (deterministic, always
713    /// correct) rather than trusting the model to add it; the system prompt
714    /// tells the model it's automatic so it does not add a second copy.
715    /// `None` disables auto-signing. Skipped when the message already carries a
716    /// co-authored-by line (the model signed anyway → don't duplicate).
717    pub coauthor: Option<String>,
718}
719
720/// Append the `coauthor` trailer to a commit message, unless the message
721/// already carries any `Co-authored-by:` line (case-insensitive) — the user's
722/// "skip if one already present" rule. Pure, for testing.
723fn sign_message(message: &str, coauthor: Option<&str>) -> String {
724    match coauthor {
725        Some(trailer) if !message.to_lowercase().contains("co-authored-by:") => {
726            format!("{}\n\n{trailer}", message.trim_end())
727        }
728        _ => message.to_string(),
729    }
730}
731
732impl newt_core::agentic::GitTool for LocalGitTool {
733    fn dispatch(
734        &self,
735        op: &str,
736        args: &serde_json::Value,
737        caps: &GitCaveats,
738    ) -> Result<String, String> {
739        let eng = GitEngine::open(&self.root).map_err(|e| e.to_string())?;
740        let s = |e: GitError| e.to_string();
741        match op {
742            "status" => Ok(render_status(&eng.status(caps).map_err(s)?)),
743            "log" => {
744                let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
745                Ok(render_log(&eng.log(caps, limit).map_err(s)?))
746            }
747            "diff" => {
748                let spec = match args.get("spec").and_then(|v| v.as_str()) {
749                    Some("staged") => DiffSpec::Staged,
750                    _ => DiffSpec::Worktree,
751                };
752                Ok(render_diff(&eng.diff(caps, spec).map_err(s)?))
753            }
754            "add" => {
755                let paths = str_array(args, "paths");
756                if paths.is_empty() {
757                    return Err("add: 'paths' (array of repo-relative paths) is required".into());
758                }
759                let staged = eng.add(caps, &paths).map_err(s)?;
760                Ok(format!(
761                    "staged {} path(s): {}",
762                    staged.len(),
763                    staged.join(", ")
764                ))
765            }
766            "commit" => {
767                let msg = args
768                    .get("message")
769                    .and_then(|v| v.as_str())
770                    .filter(|m| !m.trim().is_empty())
771                    .ok_or("commit: 'message' is required")?;
772                let signed = sign_message(msg, self.coauthor.as_deref());
773                let c = eng.commit(caps, &signed, &self.author).map_err(s)?;
774                Ok(format!("committed {}: {}", c.short_id, c.summary))
775            }
776            "amend" => {
777                // Optional message: present → reword (signed); absent → keep
778                // HEAD's existing message (which already carries its trailer, so
779                // no re-sign needed).
780                let msg = args
781                    .get("message")
782                    .and_then(|v| v.as_str())
783                    .filter(|m| !m.trim().is_empty());
784                let signed = msg.map(|m| sign_message(m, self.coauthor.as_deref()));
785                let c = eng
786                    .amend(caps, signed.as_deref(), &self.author)
787                    .map_err(s)?;
788                Ok(format!("amended {}: {}", c.short_id, c.summary))
789            }
790            "rebase" => {
791                let onto = args
792                    .get("onto")
793                    .and_then(|v| v.as_str())
794                    .filter(|o| !o.trim().is_empty())
795                    .ok_or("rebase: 'onto' (the base commit/ref to replay onto) is required")?;
796                let steps = parse_rebase_plan(args, self.coauthor.as_deref())?;
797                if steps.is_empty() {
798                    return Err("rebase: 'plan' must list at least one step".to_string());
799                }
800                let r = eng.rebase(caps, onto, &steps, &self.author).map_err(s)?;
801                Ok(format!(
802                    "rebased onto {onto} → {} ({} commit(s), {} dropped)",
803                    r.new_head, r.produced, r.dropped
804                ))
805            }
806            "branch" => {
807                let name = args
808                    .get("name")
809                    .and_then(|v| v.as_str())
810                    .filter(|n| !n.trim().is_empty())
811                    .ok_or("branch: 'name' is required")?;
812                let r = eng.branch(caps, name).map_err(s)?;
813                Ok(format!("created {r}"))
814            }
815            "checkout" => {
816                let name = args
817                    .get("name")
818                    .and_then(|v| v.as_str())
819                    .filter(|n| !n.trim().is_empty())
820                    .ok_or("checkout: 'name' (the branch to switch to) is required")?;
821                // Default to creating the branch when absent — the `checkout -b`
822                // the model reaches for to start work. Pass create=false for a
823                // plain switch to an existing branch.
824                let create = args.get("create").and_then(|v| v.as_bool()).unwrap_or(true);
825                eng.checkout(caps, name, create).map_err(s)
826            }
827            "branch-delete" => {
828                let name = args
829                    .get("name")
830                    .and_then(|v| v.as_str())
831                    .filter(|n| !n.trim().is_empty())
832                    .ok_or("branch-delete: 'name' is required")?;
833                eng.branch_delete(caps, name).map_err(s)
834            }
835            other => Err(format!(
836                "unknown git op '{other}' \
837                 (use status|log|diff|add|commit|amend|rebase|branch|checkout|branch-delete)"
838            )),
839        }
840    }
841}
842
843/// Parse the `plan` array (`[{commit, action, message?}]`) into `RebaseStep`s.
844/// `reword`/`squash` messages are signed with the co-author trailer (the tool
845/// owns signing), so rebased commits keep the AI credit too.
846fn parse_rebase_plan(
847    args: &serde_json::Value,
848    coauthor: Option<&str>,
849) -> Result<Vec<RebaseStep>, String> {
850    let plan = args
851        .get("plan")
852        .and_then(|v| v.as_array())
853        .ok_or("rebase: 'plan' (array of {commit, action, message?}) is required")?;
854    let mut steps = Vec::with_capacity(plan.len());
855    for (i, e) in plan.iter().enumerate() {
856        let commit = e
857            .get("commit")
858            .and_then(|v| v.as_str())
859            .ok_or_else(|| format!("rebase plan[{i}]: 'commit' is required"))?
860            .to_string();
861        let action = match e.get("action").and_then(|v| v.as_str()).unwrap_or("pick") {
862            "pick" => RebaseAction::Pick,
863            "reword" => RebaseAction::Reword,
864            "squash" => RebaseAction::Squash,
865            "fixup" => RebaseAction::Fixup,
866            "drop" => RebaseAction::Drop,
867            other => {
868                return Err(format!(
869                    "rebase plan[{i}]: unknown action '{other}' (pick|reword|squash|fixup|drop)"
870                ))
871            }
872        };
873        let message = e
874            .get("message")
875            .and_then(|v| v.as_str())
876            .filter(|m| !m.trim().is_empty())
877            .map(|m| match action {
878                RebaseAction::Reword | RebaseAction::Squash => sign_message(m, coauthor),
879                _ => m.to_string(),
880            });
881        steps.push(RebaseStep {
882            commit,
883            action,
884            message,
885        });
886    }
887    Ok(steps)
888}
889
890fn str_array(args: &serde_json::Value, key: &str) -> Vec<String> {
891    args.get(key)
892        .and_then(|v| v.as_array())
893        .map(|a| {
894            a.iter()
895                .filter_map(|v| v.as_str().map(String::from))
896                .collect()
897        })
898        .unwrap_or_default()
899}
900
901/// Compact, model-readable status (not raw JSON — the model reads prose better).
902fn render_status(s: &StatusReport) -> String {
903    let branch = s.branch.as_deref().unwrap_or("(detached)");
904    let head = s.head.as_deref().unwrap_or("(unborn)");
905    let mut out = format!("on branch {branch} (HEAD {head})\n");
906    if s.clean {
907        out.push_str("working tree clean");
908        return out;
909    }
910    let mut group = |label: &str, files: &[FileChange]| {
911        if !files.is_empty() {
912            out.push_str(label);
913            out.push_str(":\n");
914            for f in files {
915                out.push_str(&format!("  {} {}\n", f.status, f.path));
916            }
917        }
918    };
919    group("staged", &s.staged);
920    group("unstaged", &s.unstaged);
921    if !s.untracked.is_empty() {
922        out.push_str("untracked:\n");
923        for p in &s.untracked {
924            out.push_str(&format!("  ? {p}\n"));
925        }
926    }
927    out.trim_end().to_string()
928}
929
930fn render_log(commits: &[CommitInfo]) -> String {
931    if commits.is_empty() {
932        return "no commits".to_string();
933    }
934    commits
935        .iter()
936        .map(|c| format!("{}  {}  ({})", c.short_id, c.summary, c.author_name))
937        .collect::<Vec<_>>()
938        .join("\n")
939}
940
941fn render_diff(d: &DiffReport) -> String {
942    if d.files.is_empty() {
943        return "no changes".to_string();
944    }
945    d.files
946        .iter()
947        .map(|f| format!("{} {}", f.status, f.path))
948        .collect::<Vec<_>>()
949        .join("\n")
950}
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955    use std::path::Path;
956    use std::process::Command;
957
958    fn git(dir: &Path, args: &[&str]) {
959        let ok = Command::new("git")
960            .current_dir(dir)
961            .args(args)
962            .status()
963            .expect("git runs")
964            .success();
965        assert!(ok, "git {args:?} failed");
966    }
967
968    /// A temp repo with one commit on `a.txt`.
969    fn repo_with_commit() -> tempfile::TempDir {
970        let dir = tempfile::tempdir().unwrap();
971        let p = dir.path();
972        git(p, &["init", "-q", "-b", "main"]);
973        std::fs::write(p.join("a.txt"), "hello\n").unwrap();
974        git(p, &["add", "a.txt"]);
975        git(
976            p,
977            &[
978                "-c",
979                "user.name=Tester",
980                "-c",
981                "user.email=t@example.com",
982                "commit",
983                "-q",
984                "-m",
985                "first commit",
986            ],
987        );
988        dir
989    }
990
991    #[test]
992    fn open_and_status_on_clean_repo() {
993        let dir = repo_with_commit();
994        let eng = GitEngine::open(dir.path()).unwrap();
995        let s = eng.status(&GitCaveats::top()).unwrap();
996        assert!(s.clean, "fresh commit -> clean: {s:?}");
997        assert_eq!(s.branch.as_deref(), Some("main"));
998        assert!(s.head.is_some());
999    }
1000
1001    #[test]
1002    fn log_returns_the_commit() {
1003        let dir = repo_with_commit();
1004        let eng = GitEngine::open(dir.path()).unwrap();
1005        let log = eng.log(&GitCaveats::top(), 10).unwrap();
1006        assert_eq!(log.len(), 1);
1007        assert_eq!(log[0].summary, "first commit");
1008        assert_eq!(log[0].author_name, "Tester");
1009        assert_eq!(log[0].author_email, "t@example.com");
1010        assert!(log[0].parents.is_empty(), "root commit has no parents");
1011    }
1012
1013    #[test]
1014    fn status_sees_unstaged_modification() {
1015        let dir = repo_with_commit();
1016        std::fs::write(dir.path().join("a.txt"), "changed\n").unwrap();
1017        let eng = GitEngine::open(dir.path()).unwrap();
1018        let s = eng.status(&GitCaveats::top()).unwrap();
1019        assert!(!s.clean);
1020        assert!(s.unstaged.iter().any(|f| f.path == "a.txt"));
1021    }
1022
1023    #[test]
1024    fn status_sees_untracked_file() {
1025        let dir = repo_with_commit();
1026        std::fs::write(dir.path().join("new.txt"), "x\n").unwrap();
1027        let eng = GitEngine::open(dir.path()).unwrap();
1028        let s = eng.status(&GitCaveats::top()).unwrap();
1029        assert!(s.untracked.iter().any(|p| p == "new.txt"), "{s:?}");
1030    }
1031
1032    #[test]
1033    fn diff_worktree_lists_the_change() {
1034        let dir = repo_with_commit();
1035        std::fs::write(dir.path().join("a.txt"), "changed\n").unwrap();
1036        let eng = GitEngine::open(dir.path()).unwrap();
1037        let d = eng.diff(&GitCaveats::top(), DiffSpec::Worktree).unwrap();
1038        assert!(d.files.iter().any(|f| f.path == "a.txt"));
1039    }
1040
1041    // -- Step 27.2: checkout (create+switch) + branch-delete ----------------
1042
1043    #[test]
1044    fn checkout_creates_and_switches_to_a_new_branch() {
1045        let dir = repo_with_commit();
1046        let eng = GitEngine::open(dir.path()).unwrap();
1047        let msg = eng.checkout(&GitCaveats::top(), "feat/y", true).unwrap();
1048        assert!(msg.contains("created and switched"), "{msg}");
1049        // The system git agrees HEAD now points at the new branch.
1050        let out = Command::new("git")
1051            .current_dir(dir.path())
1052            .args(["symbolic-ref", "--short", "HEAD"])
1053            .output()
1054            .unwrap();
1055        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "feat/y");
1056    }
1057
1058    #[test]
1059    fn checkout_switches_to_existing_branch_at_same_commit() {
1060        let dir = repo_with_commit();
1061        let eng = GitEngine::open(dir.path()).unwrap();
1062        eng.branch(&GitCaveats::top(), "feat/z").unwrap(); // ref at HEAD, HEAD stays main
1063        let msg = eng.checkout(&GitCaveats::top(), "feat/z", false).unwrap();
1064        assert_eq!(msg, "switched to branch 'feat/z'");
1065    }
1066
1067    #[test]
1068    fn checkout_refuses_existing_branch_at_a_different_commit() {
1069        let dir = repo_with_commit();
1070        let p = dir.path();
1071        // 'ahead' is one commit past main; switching there would need a worktree
1072        // update, which newt does not do — it must refuse with no side effects.
1073        git(p, &["checkout", "-q", "-b", "ahead"]);
1074        std::fs::write(p.join("a.txt"), "v2\n").unwrap();
1075        git(p, &["add", "a.txt"]);
1076        git(
1077            p,
1078            &[
1079                "-c",
1080                "user.name=T",
1081                "-c",
1082                "user.email=t@e.c",
1083                "commit",
1084                "-q",
1085                "-m",
1086                "c2",
1087            ],
1088        );
1089        git(p, &["checkout", "-q", "main"]);
1090        let eng = GitEngine::open(p).unwrap();
1091        let err = eng
1092            .checkout(&GitCaveats::top(), "ahead", false)
1093            .unwrap_err();
1094        assert!(matches!(err, GitError::Refused(_)), "{err}");
1095        let out = Command::new("git")
1096            .current_dir(p)
1097            .args(["symbolic-ref", "--short", "HEAD"])
1098            .output()
1099            .unwrap();
1100        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "main");
1101    }
1102
1103    #[test]
1104    fn checkout_missing_branch_without_create_is_refused() {
1105        let dir = repo_with_commit();
1106        let eng = GitEngine::open(dir.path()).unwrap();
1107        let err = eng.checkout(&GitCaveats::top(), "nope", false).unwrap_err();
1108        assert!(matches!(err, GitError::Refused(_)), "{err}");
1109    }
1110
1111    #[test]
1112    fn branch_delete_removes_a_non_current_branch() {
1113        let dir = repo_with_commit();
1114        let eng = GitEngine::open(dir.path()).unwrap();
1115        eng.branch(&GitCaveats::top(), "scratch").unwrap();
1116        let msg = eng.branch_delete(&GitCaveats::top(), "scratch").unwrap();
1117        assert_eq!(msg, "deleted branch 'scratch'");
1118        let exists = Command::new("git")
1119            .current_dir(dir.path())
1120            .args(["rev-parse", "--verify", "--quiet", "refs/heads/scratch"])
1121            .status()
1122            .unwrap()
1123            .success();
1124        assert!(!exists, "ref must be gone after branch-delete");
1125    }
1126
1127    #[test]
1128    fn branch_delete_refuses_current_branch_and_missing() {
1129        let dir = repo_with_commit();
1130        let eng = GitEngine::open(dir.path()).unwrap();
1131        let cur = eng.branch_delete(&GitCaveats::top(), "main").unwrap_err();
1132        assert!(matches!(cur, GitError::Refused(_)), "{cur}");
1133        let missing = eng.branch_delete(&GitCaveats::top(), "ghost").unwrap_err();
1134        assert!(matches!(missing, GitError::Refused(_)), "{missing}");
1135    }
1136
1137    #[test]
1138    fn checkout_and_branch_delete_fail_closed_without_refs() {
1139        let dir = repo_with_commit();
1140        let eng = GitEngine::open(dir.path()).unwrap();
1141        let ro = GitCaveats::read_only();
1142        assert!(matches!(
1143            eng.checkout(&ro, "x", true),
1144            Err(GitError::Denied("refs"))
1145        ));
1146        assert!(matches!(
1147            eng.branch_delete(&ro, "x"),
1148            Err(GitError::Denied("refs"))
1149        ));
1150    }
1151
1152    #[test]
1153    fn read_ops_fail_closed_without_read_capability() {
1154        let dir = repo_with_commit();
1155        let eng = GitEngine::open(dir.path()).unwrap();
1156        let no = GitCaveats::none();
1157        assert!(matches!(eng.status(&no), Err(GitError::Denied("read"))));
1158        assert!(matches!(eng.log(&no, 1), Err(GitError::Denied("read"))));
1159        assert!(matches!(
1160            eng.diff(&no, DiffSpec::Worktree),
1161            Err(GitError::Denied("read"))
1162        ));
1163    }
1164
1165    #[test]
1166    fn status_report_serde_roundtrip() {
1167        let dir = repo_with_commit();
1168        let eng = GitEngine::open(dir.path()).unwrap();
1169        let s = eng.status(&GitCaveats::top()).unwrap();
1170        let json = serde_json::to_string(&s).unwrap();
1171        let back: StatusReport = serde_json::from_str(&json).unwrap();
1172        assert_eq!(s, back);
1173    }
1174
1175    #[test]
1176    fn add_then_commit_advances_history() {
1177        let dir = repo_with_commit();
1178        std::fs::write(dir.path().join("new.txt"), "data\n").unwrap();
1179        let eng = GitEngine::open(dir.path()).unwrap();
1180        let caps = GitCaveats::top();
1181
1182        let staged = eng.add(&caps, &["new.txt".to_string()]).unwrap();
1183        assert_eq!(staged, vec!["new.txt".to_string()]);
1184        assert!(eng
1185            .status(&caps)
1186            .unwrap()
1187            .staged
1188            .iter()
1189            .any(|f| f.path == "new.txt"));
1190
1191        let author = Author {
1192            name: "Bot".into(),
1193            email: "bot@newt.dev".into(),
1194        };
1195        let c = eng.commit(&caps, "add new file", &author).unwrap();
1196        assert_eq!(c.summary, "add new file");
1197        assert_eq!(c.author_name, "Bot");
1198
1199        let log = eng.log(&caps, 10).unwrap();
1200        assert_eq!(log.len(), 2);
1201        assert_eq!(log[0].summary, "add new file");
1202        assert!(eng.status(&caps).unwrap().clean, "clean after commit");
1203
1204        // The system `git` agrees grit wrote a real, readable history.
1205        let out = std::process::Command::new("git")
1206            .current_dir(dir.path())
1207            .args(["log", "--oneline"])
1208            .output()
1209            .unwrap();
1210        assert_eq!(String::from_utf8_lossy(&out.stdout).lines().count(), 2);
1211    }
1212
1213    #[test]
1214    fn branch_creates_a_ref_at_head() {
1215        let dir = repo_with_commit();
1216        let eng = GitEngine::open(dir.path()).unwrap();
1217        let refname = eng.branch(&GitCaveats::top(), "feat/x").unwrap();
1218        assert_eq!(refname, "refs/heads/feat/x");
1219        let ok = Command::new("git")
1220            .current_dir(dir.path())
1221            .args(["rev-parse", "--verify", "refs/heads/feat/x"])
1222            .status()
1223            .unwrap()
1224            .success();
1225        assert!(ok, "branch ref must resolve under the system git too");
1226    }
1227
1228    #[test]
1229    fn writes_fail_closed_without_capability() {
1230        let dir = repo_with_commit();
1231        std::fs::write(dir.path().join("new.txt"), "x\n").unwrap();
1232        let eng = GitEngine::open(dir.path()).unwrap();
1233        let author = Author {
1234            name: "B".into(),
1235            email: "b@b".into(),
1236        };
1237
1238        let ro = GitCaveats::read_only();
1239        assert!(matches!(
1240            eng.add(&ro, &["new.txt".to_string()]),
1241            Err(GitError::Denied("stage"))
1242        ));
1243        assert!(matches!(
1244            eng.commit(&ro, "m", &author),
1245            Err(GitError::Denied("commit"))
1246        ));
1247        assert!(matches!(
1248            eng.branch(&ro, "x"),
1249            Err(GitError::Denied("refs"))
1250        ));
1251
1252        // Stage-but-not-commit: add is allowed, commit is refused.
1253        let stage_only = GitCaveats {
1254            commit_local: false,
1255            ..GitCaveats::top()
1256        };
1257        assert!(eng.add(&stage_only, &["new.txt".to_string()]).is_ok());
1258        assert!(matches!(
1259            eng.commit(&stage_only, "m", &author),
1260            Err(GitError::Denied("commit"))
1261        ));
1262    }
1263
1264    // --- LocalGitTool (the injected GitTool seam) ---------------------------
1265
1266    use newt_core::agentic::GitTool as _;
1267
1268    fn tool(dir: &Path) -> LocalGitTool {
1269        LocalGitTool {
1270            root: dir.to_path_buf(),
1271            author: Author {
1272                name: "newt-agent[bot]".into(),
1273                email: "bot@example.com".into(),
1274            },
1275            coauthor: Some(
1276                "Co-authored-by: qwen3:30b (newt-agent v0.6.8) <noreply@newt-agent.com>".into(),
1277            ),
1278        }
1279    }
1280
1281    #[test]
1282    fn dispatch_checkout_creates_branch_and_branch_delete_removes_it() {
1283        let dir = repo_with_commit();
1284        let t = tool(dir.path());
1285        // checkout defaults create=true → `checkout -b`.
1286        let out = t
1287            .dispatch(
1288                "checkout",
1289                &serde_json::json!({"name": "feat/dispatch"}),
1290                &GitCaveats::top(),
1291            )
1292            .unwrap();
1293        assert!(out.contains("created and switched"), "{out}");
1294        // Switch back to main (same commit), then delete the scratch branch.
1295        t.dispatch(
1296            "checkout",
1297            &serde_json::json!({"name": "main", "create": false}),
1298            &GitCaveats::top(),
1299        )
1300        .unwrap();
1301        let del = t
1302            .dispatch(
1303                "branch-delete",
1304                &serde_json::json!({"name": "feat/dispatch"}),
1305                &GitCaveats::top(),
1306            )
1307            .unwrap();
1308        assert_eq!(del, "deleted branch 'feat/dispatch'");
1309    }
1310
1311    #[test]
1312    fn dispatch_unknown_op_lists_the_supported_ops() {
1313        let dir = repo_with_commit();
1314        let t = tool(dir.path());
1315        // 'pull' is no longer advertised or implemented (local-only).
1316        let err = t
1317            .dispatch("pull", &serde_json::json!({}), &GitCaveats::top())
1318            .unwrap_err();
1319        assert!(err.contains("unknown git op 'pull'"), "{err}");
1320        assert!(err.contains("checkout"), "{err}");
1321        assert!(err.contains("branch-delete"), "{err}");
1322    }
1323
1324    #[test]
1325    fn sign_message_appends_trailer_then_dedups() {
1326        let tr = "Co-authored-by: qwen3:30b (newt-agent v0.6.8) <noreply@newt-agent.com>";
1327        // Plain message → trailer appended after a blank line.
1328        let out = sign_message("docs: tweak", Some(tr));
1329        assert_eq!(out, format!("docs: tweak\n\n{tr}"));
1330        // Message already carrying a co-authored-by → left untouched (no dup).
1331        let already = "feat: x\n\nCo-authored-by: someone <a@b.c>";
1332        assert_eq!(sign_message(already, Some(tr)), already);
1333        // No trailer configured → message unchanged.
1334        assert_eq!(sign_message("m", None), "m");
1335    }
1336
1337    #[test]
1338    fn commit_carries_the_coauthor_trailer_in_the_message() {
1339        let dir = repo_with_commit();
1340        std::fs::write(dir.path().join("c.txt"), "x\n").unwrap();
1341        let t = tool(dir.path());
1342        t.dispatch(
1343            "add",
1344            &serde_json::json!({"paths": ["c.txt"]}),
1345            &GitCaveats::top(),
1346        )
1347        .unwrap();
1348        t.dispatch(
1349            "commit",
1350            &serde_json::json!({"message": "add c"}),
1351            &GitCaveats::top(),
1352        )
1353        .unwrap();
1354        // Inspect the real commit message via system git.
1355        let log = Command::new("git")
1356            .current_dir(dir.path())
1357            .args(["log", "-1", "--pretty=%B"])
1358            .output()
1359            .unwrap();
1360        let body = String::from_utf8_lossy(&log.stdout);
1361        assert!(body.contains("add c"), "subject present: {body}");
1362        assert!(
1363            body.contains("Co-authored-by: qwen3:30b (newt-agent v0.6.8)"),
1364            "trailer present: {body}"
1365        );
1366    }
1367
1368    #[test]
1369    fn local_git_tool_status_renders_readable_text() {
1370        let dir = repo_with_commit();
1371        let t = tool(dir.path());
1372        let out = t
1373            .dispatch("status", &serde_json::json!({}), &GitCaveats::top())
1374            .unwrap();
1375        assert!(out.contains("on branch main"), "got: {out}");
1376        assert!(out.contains("working tree clean"), "got: {out}");
1377    }
1378
1379    #[test]
1380    fn local_git_tool_log_lists_commits() {
1381        let dir = repo_with_commit();
1382        let t = tool(dir.path());
1383        let out = t
1384            .dispatch("log", &serde_json::json!({"limit": 5}), &GitCaveats::top())
1385            .unwrap();
1386        assert!(out.contains("first commit"), "got: {out}");
1387    }
1388
1389    #[test]
1390    fn local_git_tool_add_then_commit_succeeds_when_permitted() {
1391        let dir = repo_with_commit();
1392        std::fs::write(dir.path().join("b.txt"), "two\n").unwrap();
1393        let t = tool(dir.path());
1394        let staged = t
1395            .dispatch(
1396                "add",
1397                &serde_json::json!({"paths": ["b.txt"]}),
1398                &GitCaveats::top(),
1399            )
1400            .unwrap();
1401        assert!(staged.contains("b.txt"), "got: {staged}");
1402        let committed = t
1403            .dispatch(
1404                "commit",
1405                &serde_json::json!({"message": "add b"}),
1406                &GitCaveats::top(),
1407            )
1408            .unwrap();
1409        assert!(committed.starts_with("committed "), "got: {committed}");
1410        assert!(committed.contains("add b"), "got: {committed}");
1411    }
1412
1413    #[test]
1414    fn local_git_tool_amend_rewords_head_without_adding_a_commit() {
1415        let dir = repo_with_commit();
1416        std::fs::write(dir.path().join("d.txt"), "d\n").unwrap();
1417        let t = tool(dir.path());
1418        t.dispatch(
1419            "add",
1420            &serde_json::json!({"paths": ["d.txt"]}),
1421            &GitCaveats::top(),
1422        )
1423        .unwrap();
1424        t.dispatch(
1425            "commit",
1426            &serde_json::json!({"message": "add d"}),
1427            &GitCaveats::top(),
1428        )
1429        .unwrap();
1430        let count_before = commit_count(dir.path());
1431
1432        // Reword the last commit.
1433        let out = t
1434            .dispatch(
1435                "amend",
1436                &serde_json::json!({"message": "add d (reworded)"}),
1437                &GitCaveats::top(),
1438            )
1439            .unwrap();
1440        assert!(out.starts_with("amended "), "got: {out}");
1441        // Same number of commits (HEAD replaced, not stacked).
1442        assert_eq!(commit_count(dir.path()), count_before);
1443        // The new subject is in HEAD.
1444        let body = head_message(dir.path());
1445        assert!(body.contains("add d (reworded)"), "got: {body}");
1446        assert!(
1447            body.contains("Co-authored-by: qwen3:30b"),
1448            "amend re-signs the new message: {body}"
1449        );
1450    }
1451
1452    #[test]
1453    fn local_git_tool_amend_keeps_message_when_omitted() {
1454        let dir = repo_with_commit();
1455        let t = tool(dir.path());
1456        // Amend with no message → keep "first commit".
1457        t.dispatch("amend", &serde_json::json!({}), &GitCaveats::top())
1458            .unwrap();
1459        assert!(head_message(dir.path()).contains("first commit"));
1460    }
1461
1462    #[test]
1463    fn local_git_tool_amend_denied_on_read_only() {
1464        let dir = repo_with_commit();
1465        let t = tool(dir.path());
1466        let err = t
1467            .dispatch(
1468                "amend",
1469                &serde_json::json!({"message": "x"}),
1470                &GitCaveats::read_only(),
1471            )
1472            .unwrap_err();
1473        assert!(
1474            err.contains("denied") && err.contains("commit"),
1475            "got: {err}"
1476        );
1477    }
1478
1479    fn commit_count(dir: &Path) -> usize {
1480        let out = Command::new("git")
1481            .current_dir(dir)
1482            .args(["rev-list", "--count", "HEAD"])
1483            .output()
1484            .unwrap();
1485        String::from_utf8_lossy(&out.stdout).trim().parse().unwrap()
1486    }
1487
1488    fn head_message(dir: &Path) -> String {
1489        let out = Command::new("git")
1490            .current_dir(dir)
1491            .args(["log", "-1", "--pretty=%B"])
1492            .output()
1493            .unwrap();
1494        String::from_utf8_lossy(&out.stdout).to_string()
1495    }
1496
1497    #[test]
1498    fn local_git_tool_commit_denied_on_read_only_caveats() {
1499        let dir = repo_with_commit();
1500        let t = tool(dir.path());
1501        // read_only permits status/log/diff but never a commit.
1502        let err = t
1503            .dispatch(
1504                "commit",
1505                &serde_json::json!({"message": "nope"}),
1506                &GitCaveats::read_only(),
1507            )
1508            .unwrap_err();
1509        assert!(
1510            err.contains("denied") && err.contains("commit"),
1511            "got: {err}"
1512        );
1513        // …but a read op is allowed under the same caveats.
1514        assert!(t
1515            .dispatch("status", &serde_json::json!({}), &GitCaveats::read_only())
1516            .is_ok());
1517    }
1518
1519    // --- rebase (structured plan) ------------------------------------------
1520
1521    /// A repo with three linear commits c1→c2→c3 (a/b/c.txt). Returns the dir
1522    /// and the full oids [c1, c2, c3].
1523    fn repo_with_three() -> (tempfile::TempDir, Vec<String>) {
1524        let dir = tempfile::tempdir().unwrap();
1525        let p = dir.path();
1526        git(p, &["init", "-q", "-b", "main"]);
1527        let mk = |name: &str, content: &str, msg: &str| {
1528            std::fs::write(p.join(name), content).unwrap();
1529            git(p, &["add", name]);
1530            git(
1531                p,
1532                &[
1533                    "-c",
1534                    "user.name=T",
1535                    "-c",
1536                    "user.email=t@e.c",
1537                    "commit",
1538                    "-q",
1539                    "-m",
1540                    msg,
1541                ],
1542            );
1543        };
1544        mk("a.txt", "v1\n", "c1");
1545        mk("b.txt", "b\n", "c2");
1546        mk("c.txt", "c\n", "c3");
1547        let out = Command::new("git")
1548            .current_dir(p)
1549            .args(["log", "--format=%H", "--reverse"])
1550            .output()
1551            .unwrap();
1552        let oids = String::from_utf8_lossy(&out.stdout)
1553            .lines()
1554            .map(String::from)
1555            .collect();
1556        (dir, oids)
1557    }
1558
1559    #[test]
1560    fn rebase_rewords_a_middle_commit() {
1561        let (dir, oids) = repo_with_three();
1562        let t = tool(dir.path());
1563        let out = t
1564            .dispatch(
1565                "rebase",
1566                &serde_json::json!({
1567                    "onto": oids[0],
1568                    "plan": [
1569                        {"commit": oids[1], "action": "reword", "message": "b reworded"},
1570                        {"commit": oids[2], "action": "pick"},
1571                    ]
1572                }),
1573                &GitCaveats::top(),
1574            )
1575            .unwrap();
1576        assert!(out.starts_with("rebased onto"), "got: {out}");
1577        assert_eq!(commit_count(dir.path()), 3, "same number of commits");
1578        // History: c1, b reworded, c3.
1579        let log = Command::new("git")
1580            .current_dir(dir.path())
1581            .args(["log", "--format=%s", "--reverse"])
1582            .output()
1583            .unwrap();
1584        let subjects = String::from_utf8_lossy(&log.stdout);
1585        assert!(subjects.contains("b reworded"), "got: {subjects}");
1586        assert!(
1587            !subjects.contains("\nc2\n"),
1588            "old c2 subject gone: {subjects}"
1589        );
1590        // b.txt and c.txt still present (changes preserved).
1591    }
1592
1593    #[test]
1594    fn rebase_squashes_two_commits_into_one() {
1595        let (dir, oids) = repo_with_three();
1596        let t = tool(dir.path());
1597        t.dispatch(
1598            "rebase",
1599            &serde_json::json!({
1600                "onto": oids[0],
1601                "plan": [
1602                    {"commit": oids[1], "action": "pick"},
1603                    {"commit": oids[2], "action": "squash", "message": "folded note"},
1604                ]
1605            }),
1606            &GitCaveats::top(),
1607        )
1608        .unwrap();
1609        // c1 + one squashed commit = 2.
1610        assert_eq!(commit_count(dir.path()), 2);
1611        // The squashed commit carries both messages.
1612        let body = head_message(dir.path());
1613        assert!(
1614            body.contains("c2") && body.contains("folded note"),
1615            "got: {body}"
1616        );
1617        // Both files landed in the squashed tree.
1618        let files = Command::new("git")
1619            .current_dir(dir.path())
1620            .args(["ls-tree", "--name-only", "-r", "HEAD"])
1621            .output()
1622            .unwrap();
1623        let names = String::from_utf8_lossy(&files.stdout);
1624        assert!(
1625            names.contains("b.txt") && names.contains("c.txt"),
1626            "got: {names}"
1627        );
1628    }
1629
1630    #[test]
1631    fn rebase_drops_a_commit() {
1632        let (dir, oids) = repo_with_three();
1633        let t = tool(dir.path());
1634        t.dispatch(
1635            "rebase",
1636            &serde_json::json!({
1637                "onto": oids[0],
1638                "plan": [
1639                    {"commit": oids[1], "action": "pick"},
1640                    {"commit": oids[2], "action": "drop"},
1641                ]
1642            }),
1643            &GitCaveats::top(),
1644        )
1645        .unwrap();
1646        assert_eq!(commit_count(dir.path()), 2);
1647        let names = Command::new("git")
1648            .current_dir(dir.path())
1649            .args(["ls-tree", "--name-only", "-r", "HEAD"])
1650            .output()
1651            .unwrap();
1652        let names = String::from_utf8_lossy(&names.stdout);
1653        assert!(
1654            !names.contains("c.txt"),
1655            "dropped commit's file gone: {names}"
1656        );
1657    }
1658
1659    #[test]
1660    fn rebase_aborts_on_conflict_leaving_the_branch_unchanged() {
1661        // c1: a=v1; c2: a=v2; c3: a=v3. Cherry-picking c3 onto c1 conflicts
1662        // (both c1 and c3 changed a.txt from c2's base).
1663        let dir = tempfile::tempdir().unwrap();
1664        let p = dir.path();
1665        git(p, &["init", "-q", "-b", "main"]);
1666        let mk = |content: &str, msg: &str| {
1667            std::fs::write(p.join("a.txt"), content).unwrap();
1668            git(p, &["add", "a.txt"]);
1669            git(
1670                p,
1671                &[
1672                    "-c",
1673                    "user.name=T",
1674                    "-c",
1675                    "user.email=t@e.c",
1676                    "commit",
1677                    "-q",
1678                    "-m",
1679                    msg,
1680                ],
1681            );
1682        };
1683        mk("v1\n", "c1");
1684        mk("v2\n", "c2");
1685        mk("v3\n", "c3");
1686        let head_before = Command::new("git")
1687            .current_dir(p)
1688            .args(["rev-parse", "HEAD"])
1689            .output()
1690            .unwrap();
1691        let oids: Vec<String> = String::from_utf8_lossy(
1692            &Command::new("git")
1693                .current_dir(p)
1694                .args(["log", "--format=%H", "--reverse"])
1695                .output()
1696                .unwrap()
1697                .stdout,
1698        )
1699        .lines()
1700        .map(String::from)
1701        .collect();
1702        let t = tool(p);
1703        let err = t
1704            .dispatch(
1705                "rebase",
1706                &serde_json::json!({
1707                    "onto": oids[0],
1708                    "plan": [{"commit": oids[2], "action": "pick"}]
1709                }),
1710                &GitCaveats::top(),
1711            )
1712            .unwrap_err();
1713        assert!(
1714            err.contains("conflict") && err.contains("aborted"),
1715            "got: {err}"
1716        );
1717        // The branch ref did NOT move.
1718        let head_after = Command::new("git")
1719            .current_dir(p)
1720            .args(["rev-parse", "HEAD"])
1721            .output()
1722            .unwrap();
1723        assert_eq!(
1724            head_before.stdout, head_after.stdout,
1725            "branch must be unchanged"
1726        );
1727    }
1728
1729    #[test]
1730    fn rebase_denied_on_read_only() {
1731        let (dir, oids) = repo_with_three();
1732        let t = tool(dir.path());
1733        let err = t
1734            .dispatch(
1735                "rebase",
1736                &serde_json::json!({"onto": oids[0], "plan": [{"commit": oids[1], "action": "pick"}]}),
1737                &GitCaveats::read_only(),
1738            )
1739            .unwrap_err();
1740        assert!(
1741            err.contains("denied") && err.contains("commit"),
1742            "got: {err}"
1743        );
1744    }
1745
1746    #[test]
1747    fn local_git_tool_unknown_op_and_missing_args_error() {
1748        let dir = repo_with_commit();
1749        let t = tool(dir.path());
1750        let err = t
1751            .dispatch("frobnicate", &serde_json::json!({}), &GitCaveats::top())
1752            .unwrap_err();
1753        assert!(err.contains("unknown git op"), "got: {err}");
1754        // commit without a message is a clear arg error, not a panic.
1755        let err = t
1756            .dispatch("commit", &serde_json::json!({}), &GitCaveats::top())
1757            .unwrap_err();
1758        assert!(err.contains("message"), "got: {err}");
1759    }
1760}