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