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