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