1use 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#[derive(Debug, thiserror::Error)]
42pub enum GitError {
43 #[error("capability denied: git {0} not permitted")]
45 Denied(&'static str),
46 #[error("git: {0}")]
48 Engine(#[from] grit_lib::error::Error),
49 #[error("io: {0}")]
51 Io(#[from] std::io::Error),
52 #[error("unsupported: {0}")]
55 Unsupported(&'static str),
56 #[error("{0}")]
60 Refused(String),
61 #[error("rebase conflict at {0} — aborted, branch unchanged")]
64 Conflict(String),
65 #[error("bad rebase plan: {0}")]
67 BadPlan(String),
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum RebaseAction {
73 Pick,
75 Reword,
77 Squash,
79 Fixup,
81 Drop,
83}
84
85#[derive(Debug, Clone)]
87pub struct RebaseStep {
88 pub commit: String,
90 pub action: RebaseAction,
91 pub message: Option<String>,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct RebaseReport {
98 pub new_head: String,
100 pub produced: usize,
102 pub dropped: usize,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct Author {
109 pub name: String,
110 pub email: String,
111}
112
113impl Author {
114 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct FileChange {
127 pub status: char,
128 pub path: String,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct StatusReport {
134 pub branch: Option<String>,
136 pub head: Option<String>,
138 pub staged: Vec<FileChange>,
140 pub unstaged: Vec<FileChange>,
142 pub untracked: Vec<String>,
144 pub clean: bool,
146}
147
148#[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 pub timestamp: i64,
157 pub summary: String,
158 pub parents: Vec<String>,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct DiffReport {
164 pub files: Vec<FileChange>,
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum DiffSpec {
170 Worktree,
172 Staged,
174}
175
176pub struct GitEngine {
178 repo: Repository,
179}
180
181impl GitEngine {
182 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 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 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 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 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 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 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 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 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 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 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 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 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 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; }
556 RebaseAction::Drop => unreachable!("filtered above"),
557 }
558 }
559 if open {
561 tip = self.write_commit_on(cur_parent, cur_tree, &cur_msgs.join("\n\n"), author)?;
562 produced += 1;
563 }
564 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 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 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 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 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 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 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 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 checkout_between_trees(&self.repo, Some(&w_tree), &head_tree)?;
772 Ok(format!("Saved working directory and index state {wip_msg}"))
773 }
774
775 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 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 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 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 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)?; let git_dir = &self.repo.git_dir;
854 delete_reflog_entries(git_dir, "refs/stash", &[k])?;
855 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
892fn 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
909pub struct LocalGitTool {
920 pub root: std::path::PathBuf,
921 pub author: Author,
922 pub coauthor: Option<String>,
930}
931
932fn 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 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 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 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
1083fn 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
1141fn stash_index(args: &serde_json::Value) -> usize {
1143 args.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize
1144}
1145
1146fn 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 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 #[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 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(); 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 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 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 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 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 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 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 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 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 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 let out = sign_message("docs: tweak", Some(tr));
1633 assert_eq!(out, format!("docs: tweak\n\n{tr}"));
1634 let already = "feat: x\n\nCo-authored-by: someone <a@b.c>";
1636 assert_eq!(sign_message(already, Some(tr)), already);
1637 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 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 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 assert_eq!(commit_count(dir.path()), count_before);
1747 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 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 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 assert!(t
1819 .dispatch("status", &serde_json::json!({}), &GitCaveats::read_only())
1820 .is_ok());
1821 }
1822
1823 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 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 }
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 assert_eq!(commit_count(dir.path()), 2);
1915 let body = head_message(dir.path());
1917 assert!(
1918 body.contains("c2") && body.contains("folded note"),
1919 "got: {body}"
1920 );
1921 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 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 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 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 let dir = repo_with_commit();
2070 let p = dir.path();
2071 std::fs::write(p.join("a.txt"), "changed\n").unwrap(); 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 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 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}