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