1use super::file_repository::FileRepository;
17use super::repository::{BacklogItemRepository, SprintRepository};
18use crate::backlog::{BacklogItem, ItemId};
19use crate::error::{Error, Result};
20use crate::sprint::{Sprint, SprintId};
21use std::collections::HashSet;
22use std::path::{Path, PathBuf};
23use std::process::Output;
24use std::sync::atomic::{AtomicU64, Ordering};
25use std::time::{SystemTime, UNIX_EPOCH};
26use tokio::process::Command;
27
28#[derive(Debug, Clone)]
30pub struct GitRepository {
31 file: FileRepository,
33 root: PathBuf,
35 workdir: PathBuf,
37 baseline_paths: Vec<String>,
43}
44
45impl GitRepository {
46 pub fn new(root: impl Into<PathBuf>) -> Self {
49 let root = root.into();
50 let workdir = root
52 .parent()
53 .map(Path::to_path_buf)
54 .unwrap_or_else(|| root.clone());
55 let file = FileRepository::new(root.clone());
56 Self {
57 file,
58 root,
59 workdir,
60 baseline_paths: Vec::new(),
61 }
62 }
63
64 pub(crate) async fn prepare(mut self) -> Result<Self> {
71 self.baseline_paths = match self.status_paths().await {
72 Ok(paths) => paths,
73 Err(Error::Git(_)) => Vec::new(),
76 Err(error) => return Err(error),
77 };
78 Ok(self)
79 }
80
81 fn board_pathspec(&self) -> &str {
85 self.root
86 .file_name()
87 .and_then(|s| s.to_str())
88 .unwrap_or(".pinto")
89 }
90
91 fn lock_pathspec(&self) -> String {
93 format!("{}/.lock", self.board_pathspec())
94 }
95
96 async fn run_git(&self, args: &[&str]) -> Result<Output> {
99 Command::new("git")
100 .args(args)
101 .current_dir(&self.workdir)
102 .output()
103 .await
104 .map_err(|e| {
105 if e.kind() == std::io::ErrorKind::NotFound {
106 Error::Git(
107 "`git` command not found; install git or set `[storage] backend = \"file\"`"
108 .to_string(),
109 )
110 } else {
111 Error::Git(format!("failed to run git {}: {e}", args.join(" ")))
112 }
113 })
114 }
115
116 async fn git_checked(&self, args: &[&str]) -> Result<Output> {
118 let out = self.run_git(args).await?;
119 if out.status.success() {
120 Ok(out)
121 } else {
122 let stderr = String::from_utf8_lossy(&out.stderr);
123 Err(Error::Git(format!(
124 "git {} failed: {}",
125 args.join(" "),
126 stderr.trim()
127 )))
128 }
129 }
130
131 async fn run_git_with_index(&self, args: &[&str], index: &Path) -> Result<Output> {
133 Command::new("git")
134 .args(args)
135 .current_dir(&self.workdir)
136 .env("GIT_INDEX_FILE", index)
137 .output()
138 .await
139 .map_err(|e| {
140 if e.kind() == std::io::ErrorKind::NotFound {
141 Error::Git(
142 "`git` command not found; install git or set `[storage] backend = \"file\"`"
143 .to_string(),
144 )
145 } else {
146 Error::Git(format!("failed to run git {}: {e}", args.join(" ")))
147 }
148 })
149 }
150
151 async fn git_checked_with_index(&self, args: &[&str], index: &Path) -> Result<Output> {
153 let out = self.run_git_with_index(args, index).await?;
154 if out.status.success() {
155 Ok(out)
156 } else {
157 let stderr = String::from_utf8_lossy(&out.stderr);
158 Err(Error::Git(format!(
159 "git {} failed: {}",
160 args.join(" "),
161 stderr.trim()
162 )))
163 }
164 }
165
166 async fn ensure_repo(&self) -> Result<()> {
168 let inside = self
169 .run_git(&["rev-parse", "--is-inside-work-tree"])
170 .await?;
171 if !inside.status.success() {
172 eprintln!(
173 "warning: initializing a Git repository for the selected git backend in {}",
174 self.workdir.display()
175 );
176 self.git_checked(&["init"]).await?;
177 }
178 Ok(())
179 }
180
181 pub(crate) async fn commit(&self, message: &str) -> Result<()> {
196 self.ensure_repo().await?;
197 let current = self.status_paths().await?;
198 let baseline: HashSet<&str> = self.baseline_paths.iter().map(String::as_str).collect();
199 let mut operation_paths: Vec<String> = current
200 .into_iter()
201 .filter(|path| !baseline.contains(path.as_str()))
202 .collect();
203 operation_paths.sort();
204 operation_paths.dedup();
205
206 let lock_path = self.lock_pathspec();
207 let lock_tracked = self.index_has_path(&lock_path).await?;
208 if operation_paths.is_empty() && !lock_tracked {
209 return Ok(());
210 }
211
212 let staged = self.capture_staged_index().await?;
213 if staged.entries.iter().any(|entry| entry.stage != 0) {
214 return Err(Error::Git(
215 "cannot commit while the Git index has unresolved conflicts; resolve them and retry"
216 .to_string(),
217 ));
218 }
219
220 self.commit_with_isolated_index(message, &operation_paths, &lock_path, &staged)
221 .await
222 }
223
224 async fn status_paths(&self) -> Result<Vec<String>> {
226 let spec = self.board_pathspec();
227 let lock_spec = format!(":(exclude){spec}/.lock");
228 let args = [
229 "status",
230 "--porcelain=v1",
231 "-z",
232 "--untracked-files=all",
233 "--",
234 spec,
235 lock_spec.as_str(),
236 ];
237 let output = self.git_checked(&args).await?;
238 Ok(parse_status_paths(&output.stdout))
239 }
240
241 async fn index_has_path(&self, path: &str) -> Result<bool> {
243 let output = self
244 .run_git(&["ls-files", "--error-unmatch", "--", path])
245 .await?;
246 Ok(output.status.success())
247 }
248
249 async fn capture_staged_index(&self) -> Result<StagedIndex> {
251 let staged_output = self
252 .run_git(&["diff", "--cached", "--name-only", "-z"])
253 .await?;
254 let staged_paths = if staged_output.status.success() {
255 parse_nul_paths(&staged_output.stdout)
256 .into_iter()
257 .collect::<HashSet<_>>()
258 } else {
259 HashSet::new()
260 };
261
262 let index_output = self.git_checked(&["ls-files", "--stage", "-z"]).await?;
263 let entries = parse_index_entries(&index_output.stdout)
264 .into_iter()
265 .filter(|entry| staged_paths.contains(&entry.path))
266 .collect();
267 Ok(StagedIndex {
268 paths: staged_paths,
269 entries,
270 })
271 }
272
273 async fn commit_with_isolated_index(
275 &self,
276 message: &str,
277 operation_paths: &[String],
278 lock_path: &str,
279 staged: &StagedIndex,
280 ) -> Result<()> {
281 let index = temporary_index_path();
282 let result = async {
283 let head = self.head().await?;
284 if let Some(head) = head.as_deref() {
285 self.git_checked_with_index(&["read-tree", head], &index)
286 .await?;
287 } else {
288 self.git_checked_with_index(&["read-tree", "--empty"], &index)
289 .await?;
290 }
291
292 if !operation_paths.is_empty() {
293 let mut add_args = vec!["add", "-A", "--"];
294 add_args.extend(operation_paths.iter().map(String::as_str));
295 self.git_checked_with_index(&add_args, &index).await?;
296 }
297
298 if self
301 .run_git_with_index(&["ls-files", "--error-unmatch", "--", lock_path], &index)
302 .await?
303 .status
304 .success()
305 {
306 self.git_checked_with_index(
307 &["update-index", "--force-remove", "--", lock_path],
308 &index,
309 )
310 .await?;
311 }
312
313 let identity = self.fallback_identity_args().await?;
316 let mut commit_args: Vec<String> = identity;
317 commit_args.extend(["commit".to_string(), "-m".to_string(), message.to_string()]);
318 let commit_args: Vec<&str> = commit_args.iter().map(String::as_str).collect();
319 self.git_checked_with_index(&commit_args, &index).await?;
320
321 self.restore_real_index(staged).await
324 }
325 .await;
326
327 let cleanup = tokio::fs::remove_file(&index).await;
328 match (result, cleanup) {
329 (Err(error), _) => Err(error),
330 (Ok(()), Ok(())) => Ok(()),
331 (Ok(()), Err(error)) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
332 (Ok(()), Err(error)) => Err(Error::io(&index, &error)),
333 }
334 }
335
336 pub(crate) async fn undo_last(&self) -> Result<String> {
347 self.ensure_repo().await?;
348 let head = self.head().await?.ok_or_else(|| {
349 Error::UndoUnavailable(
350 "the board has no commits yet, so there is nothing to undo".to_string(),
351 )
352 })?;
353 let subject = self.commit_subject("HEAD").await?;
354 if !subject.starts_with("pinto: ") {
355 let short = &head[..head.len().min(7)];
356 return Err(Error::UndoUnavailable(format!(
357 "the most recent commit ({short} {subject}) is not a pinto board mutation; revert it yourself with `git revert {short}` or inspect `git log -- .pinto`"
358 )));
359 }
360
361 let identity = self.fallback_identity_args().await?;
363 let mut args: Vec<String> = identity;
364 args.extend([
365 "revert".to_string(),
366 "--no-edit".to_string(),
367 "HEAD".to_string(),
368 ]);
369 let args: Vec<&str> = args.iter().map(String::as_str).collect();
370 self.git_checked(&args).await?;
371 Ok(subject)
372 }
373
374 async fn commit_subject(&self, revision: &str) -> Result<String> {
376 let output = self
377 .git_checked(&["show", "-s", "--format=%s", revision])
378 .await?;
379 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
380 }
381
382 async fn head(&self) -> Result<Option<String>> {
384 let output = self.run_git(&["rev-parse", "--verify", "HEAD"]).await?;
385 if !output.status.success() {
386 return Ok(None);
387 }
388 Ok(Some(
389 String::from_utf8_lossy(&output.stdout).trim().to_string(),
390 ))
391 }
392
393 async fn restore_real_index(&self, staged: &StagedIndex) -> Result<()> {
395 self.git_checked(&["read-tree", "HEAD"]).await?;
396 for path in &staged.paths {
397 let entries: Vec<&IndexEntry> = staged
398 .entries
399 .iter()
400 .filter(|entry| &entry.path == path)
401 .collect();
402 if entries.is_empty() {
403 let output = self
405 .run_git(&["update-index", "--force-remove", "--", path])
406 .await?;
407 if !output.status.success()
408 && !String::from_utf8_lossy(&output.stderr).contains("does not exist")
409 {
410 let stderr = String::from_utf8_lossy(&output.stderr);
411 return Err(Error::Git(format!(
412 "git update-index --force-remove -- {path} failed: {}",
413 stderr.trim()
414 )));
415 }
416 continue;
417 }
418 for entry in entries {
419 let cacheinfo = format!("{},{},{}", entry.mode, entry.object, entry.path);
420 self.git_checked(&["update-index", "--add", "--cacheinfo", &cacheinfo])
421 .await?;
422 }
423 }
424 Ok(())
425 }
426
427 async fn fallback_identity_args(&self) -> Result<Vec<String>> {
431 let email = self.run_git(&["config", "user.email"]).await?;
432 let has_identity =
433 email.status.success() && !String::from_utf8_lossy(&email.stdout).trim().is_empty();
434 if has_identity {
435 Ok(Vec::new())
436 } else {
437 Ok(vec![
438 "-c".to_string(),
439 "user.name=pinto".to_string(),
440 "-c".to_string(),
441 "user.email=pinto@localhost".to_string(),
442 ])
443 }
444 }
445}
446
447#[derive(Debug)]
449struct IndexEntry {
450 path: String,
451 mode: String,
452 object: String,
453 stage: u8,
454}
455
456#[derive(Debug, Default)]
458struct StagedIndex {
459 paths: HashSet<String>,
460 entries: Vec<IndexEntry>,
461}
462
463fn parse_status_paths(bytes: &[u8]) -> Vec<String> {
465 let mut paths = Vec::new();
466 let mut records = bytes.split(|byte| *byte == 0);
467 while let Some(record) = records.next() {
468 if record.len() < 4 {
469 continue;
470 }
471 paths.push(String::from_utf8_lossy(&record[3..]).into_owned());
474 if (record[0] == b'R' || record[0] == b'C' || record[1] == b'R' || record[1] == b'C')
475 && let Some(new_path) = records.next()
476 && !new_path.is_empty()
477 {
478 paths.push(String::from_utf8_lossy(new_path).into_owned());
479 }
480 }
481 paths
482}
483
484fn parse_nul_paths(bytes: &[u8]) -> Vec<String> {
486 bytes
487 .split(|byte| *byte == 0)
488 .filter(|record| !record.is_empty())
489 .map(|record| String::from_utf8_lossy(record).into_owned())
490 .collect()
491}
492
493fn parse_index_entries(bytes: &[u8]) -> Vec<IndexEntry> {
495 bytes
496 .split(|byte| *byte == 0)
497 .filter_map(|record| {
498 let tab = record.iter().position(|byte| *byte == b'\t')?;
499 let metadata = String::from_utf8_lossy(&record[..tab]);
500 let mut fields = metadata.split_whitespace();
501 let mode = fields.next()?.to_string();
502 let object = fields.next()?.to_string();
503 let stage = fields.next()?.parse().ok()?;
504 let path = String::from_utf8_lossy(&record[tab + 1..]).into_owned();
505 Some(IndexEntry {
506 path,
507 mode,
508 object,
509 stage,
510 })
511 })
512 .collect()
513}
514
515fn temporary_index_path() -> PathBuf {
517 static NEXT_INDEX: AtomicU64 = AtomicU64::new(0);
518 let nanos = SystemTime::now()
519 .duration_since(UNIX_EPOCH)
520 .map_or(0, |duration| duration.as_nanos());
521 let sequence = NEXT_INDEX.fetch_add(1, Ordering::Relaxed);
522 std::env::temp_dir().join(format!(
523 "pinto-index-{}-{nanos}-{sequence}",
524 std::process::id()
525 ))
526}
527
528impl BacklogItemRepository for GitRepository {
529 async fn save(&self, item: &BacklogItem) -> Result<()> {
530 BacklogItemRepository::save(&self.file, item).await
534 }
535
536 async fn load(&self, id: &ItemId) -> Result<BacklogItem> {
537 BacklogItemRepository::load(&self.file, id).await
538 }
539
540 async fn list(&self) -> Result<Vec<BacklogItem>> {
541 BacklogItemRepository::list(&self.file).await
542 }
543
544 async fn list_archived(&self) -> Result<Vec<BacklogItem>> {
545 BacklogItemRepository::list_archived(&self.file).await
546 }
547
548 async fn load_archived(&self, id: &ItemId) -> Result<BacklogItem> {
549 BacklogItemRepository::load_archived(&self.file, id).await
550 }
551
552 async fn delete(&self, id: &ItemId) -> Result<()> {
553 BacklogItemRepository::delete(&self.file, id).await
554 }
555
556 async fn archive(&self, id: &ItemId) -> Result<PathBuf> {
557 self.file.archive(id).await
558 }
559
560 async fn restore(&self, id: &ItemId) -> Result<()> {
561 BacklogItemRepository::restore(&self.file, id).await
562 }
563
564 async fn next_id(&self, prefix: &str) -> Result<ItemId> {
565 self.file.next_id(prefix).await
566 }
567}
568
569impl SprintRepository for GitRepository {
570 async fn save(&self, sprint: &Sprint) -> Result<()> {
571 SprintRepository::save(&self.file, sprint).await
572 }
573
574 async fn load(&self, id: &SprintId) -> Result<Sprint> {
575 SprintRepository::load(&self.file, id).await
576 }
577
578 async fn list(&self) -> Result<Vec<Sprint>> {
579 SprintRepository::list(&self.file).await
580 }
581
582 async fn delete(&self, id: &SprintId) -> Result<()> {
583 SprintRepository::delete(&self.file, id).await
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
594 use crate::backlog::Status;
595 use crate::rank::Rank;
596 use crate::sprint::SprintId;
597 use chrono::{TimeZone, Utc};
598 use tempfile::TempDir;
599
600 fn repo() -> (TempDir, GitRepository) {
602 let dir = TempDir::new().expect("temp dir");
603 let git = GitRepository::new(dir.path().join(".pinto"));
604 (dir, git)
605 }
606
607 fn item(n: u32, title: &str) -> BacklogItem {
608 BacklogItem::new(
609 ItemId::new("T", n),
610 title,
611 Status::new("todo"),
612 Rank::after(None),
613 Utc.timestamp_opt(1_000, 0).single().unwrap(),
614 )
615 .expect("valid item")
616 }
617
618 async fn commit_subjects(workdir: &Path) -> Vec<String> {
620 let out = Command::new("git")
621 .args(["log", "--format=%s"])
622 .current_dir(workdir)
623 .output()
624 .await
625 .expect("git log");
626 String::from_utf8_lossy(&out.stdout)
627 .lines()
628 .map(str::to_string)
629 .collect()
630 }
631
632 #[tokio::test]
633 async fn commit_after_save_auto_inits_repo_and_commits_add() {
634 let (dir, git) = repo();
635 assert!(!dir.path().join(".git").exists());
637
638 BacklogItemRepository::save(&git, &item(1, "First"))
639 .await
640 .expect("save");
641 git.commit("pinto: add T-1").await.expect("commit");
642
643 assert!(
645 dir.path().join(".git").exists(),
646 "auto-initialized git repo"
647 );
648 assert_eq!(commit_subjects(dir.path()).await, ["pinto: add T-1"]);
649 let loaded = BacklogItemRepository::load(&git, &ItemId::new("T", 1))
651 .await
652 .expect("load");
653 assert_eq!(loaded.title, "First");
654 }
655
656 #[tokio::test]
657 async fn separate_service_boundaries_commit_add_then_update() {
658 let (dir, git) = repo();
659 BacklogItemRepository::save(&git, &item(1, "First"))
660 .await
661 .expect("add");
662 git.commit("pinto: add T-1").await.expect("add commit");
663 let mut edited = item(1, "First edited");
664 edited.updated = Utc.timestamp_opt(2_000, 0).single().unwrap();
665 BacklogItemRepository::save(&git, &edited)
666 .await
667 .expect("update");
668 git.commit("pinto: update T-1")
669 .await
670 .expect("update commit");
671
672 assert_eq!(
673 commit_subjects(dir.path()).await,
674 ["pinto: update T-1", "pinto: add T-1"]
675 );
676 }
677
678 #[tokio::test]
679 async fn delete_commits_remove() {
680 let (dir, git) = repo();
681 BacklogItemRepository::save(&git, &item(1, "First"))
682 .await
683 .expect("add");
684 git.commit("pinto: add T-1").await.expect("add commit");
685 BacklogItemRepository::delete(&git, &ItemId::new("T", 1))
686 .await
687 .expect("delete");
688 git.commit("pinto: remove T-1")
689 .await
690 .expect("remove commit");
691
692 assert_eq!(
693 commit_subjects(dir.path()).await,
694 ["pinto: remove T-1", "pinto: add T-1"]
695 );
696 }
697
698 #[tokio::test]
699 async fn archive_commits_archive() {
700 let (dir, git) = repo();
701 BacklogItemRepository::save(&git, &item(1, "First"))
702 .await
703 .expect("add");
704 git.commit("pinto: add T-1").await.expect("add commit");
705 let dest = git.archive(&ItemId::new("T", 1)).await.expect("archive");
706 assert!(dest.ends_with("archive/T-1.md"));
707 git.commit("pinto: archive T-1")
708 .await
709 .expect("archive commit");
710
711 assert_eq!(
712 commit_subjects(dir.path()).await,
713 ["pinto: archive T-1", "pinto: add T-1"]
714 );
715 }
716
717 #[tokio::test]
718 async fn delete_missing_item_does_not_commit() {
719 let (dir, git) = repo();
720 BacklogItemRepository::save(&git, &item(1, "First"))
721 .await
722 .expect("add");
723 git.commit("pinto: add T-1").await.expect("add commit");
724 let err = BacklogItemRepository::delete(&git, &ItemId::new("T", 99))
725 .await
726 .expect_err("missing delete errors");
727 assert_eq!(err, Error::NotFound(ItemId::new("T", 99)));
728 assert_eq!(commit_subjects(dir.path()).await, ["pinto: add T-1"]);
730 }
731
732 #[tokio::test]
733 async fn sprint_save_commits_add_then_update() {
734 let (dir, git) = repo();
735 let sprint = Sprint::new(
736 SprintId::new("S-1").unwrap(),
737 "Sprint 1",
738 Utc.timestamp_opt(1_000, 0).single().unwrap(),
739 )
740 .unwrap();
741 SprintRepository::save(&git, &sprint).await.expect("add");
742 git.commit("pinto: add S-1").await.expect("add commit");
743
744 let mut updated = sprint.clone();
745 updated.goal = "goal".to_string();
746 SprintRepository::save(&git, &updated)
747 .await
748 .expect("update");
749 git.commit("pinto: update S-1")
750 .await
751 .expect("update commit");
752
753 assert_eq!(
754 commit_subjects(dir.path()).await,
755 ["pinto: update S-1", "pinto: add S-1"]
756 );
757 }
758
759 #[tokio::test]
760 async fn sprint_delete_commits_remove() {
761 let (dir, git) = repo();
762 let sprint = Sprint::new(
763 SprintId::new("S-1").unwrap(),
764 "Sprint 1",
765 Utc.timestamp_opt(1_000, 0).single().unwrap(),
766 )
767 .unwrap();
768 SprintRepository::save(&git, &sprint).await.expect("add");
769 git.commit("pinto: add S-1").await.expect("add commit");
770 SprintRepository::delete(&git, &sprint.id)
771 .await
772 .expect("delete");
773 git.commit("pinto: remove S-1")
774 .await
775 .expect("remove commit");
776 assert_eq!(
777 commit_subjects(dir.path()).await,
778 ["pinto: remove S-1", "pinto: add S-1"]
779 );
780 }
781
782 #[tokio::test]
783 async fn sprint_delete_missing_does_not_commit() {
784 let (dir, git) = repo();
785 BacklogItemRepository::save(&git, &item(1, "First"))
786 .await
787 .expect("add");
788 git.commit("pinto: add T-1").await.expect("add commit");
789 let err = SprintRepository::delete(&git, &SprintId::new("S-9").unwrap())
790 .await
791 .expect_err("missing sprint delete errors");
792 assert_eq!(err, Error::SprintNotFound(SprintId::new("S-9").unwrap()));
793 assert_eq!(commit_subjects(dir.path()).await, ["pinto: add T-1"]);
794 }
795
796 #[tokio::test]
797 async fn undo_last_reverts_the_most_recent_pinto_commit() {
798 let (dir, git) = repo();
799 BacklogItemRepository::save(&git, &item(1, "First"))
800 .await
801 .expect("add");
802 git.commit("pinto: add T-1").await.expect("add commit");
803 BacklogItemRepository::save(&git, &item(2, "Second"))
804 .await
805 .expect("add");
806 git.commit("pinto: add T-2").await.expect("add commit");
807
808 let subject = git.undo_last().await.expect("undo");
809 assert_eq!(subject, "pinto: add T-2");
810
811 assert_eq!(
813 commit_subjects(dir.path()).await,
814 [
815 "Revert \"pinto: add T-2\"",
816 "pinto: add T-2",
817 "pinto: add T-1"
818 ]
819 );
820 let missing = BacklogItemRepository::load(&git, &ItemId::new("T", 2))
822 .await
823 .expect_err("T-2 should be reverted");
824 assert_eq!(missing, Error::NotFound(ItemId::new("T", 2)));
825 let kept = BacklogItemRepository::load(&git, &ItemId::new("T", 1))
826 .await
827 .expect("T-1 remains");
828 assert_eq!(kept.title, "First");
829 }
830
831 #[tokio::test]
832 async fn undo_last_refuses_when_head_is_not_a_pinto_commit() {
833 let (dir, git) = repo();
834 BacklogItemRepository::save(&git, &item(1, "First"))
835 .await
836 .expect("add");
837 git.commit("pinto: add T-1").await.expect("add commit");
838 tokio::fs::write(dir.path().join("NOTES.md"), "notes")
840 .await
841 .expect("write file");
842 for args in [
843 vec!["add", "NOTES.md"],
844 vec![
845 "-c",
846 "user.name=tester",
847 "-c",
848 "user.email=tester@localhost",
849 "commit",
850 "-m",
851 "chore: notes",
852 ],
853 ] {
854 let out = Command::new("git")
855 .args(&args)
856 .current_dir(dir.path())
857 .output()
858 .await
859 .expect("git");
860 assert!(out.status.success(), "git {args:?} failed: {out:?}");
861 }
862
863 let err = git.undo_last().await.expect_err("non-pinto head refused");
864 assert!(
865 matches!(err, Error::UndoUnavailable(_)),
866 "expected UndoUnavailable, got {err:?}"
867 );
868 }
869
870 #[tokio::test]
871 async fn undo_last_refuses_when_there_are_no_commits() {
872 let dir = TempDir::new().expect("temp dir");
873 let out = Command::new("git")
874 .args(["init"])
875 .current_dir(dir.path())
876 .output()
877 .await
878 .expect("git init");
879 assert!(out.status.success());
880 let git = GitRepository::new(dir.path().join(".pinto"));
881
882 let err = git.undo_last().await.expect_err("empty repo refused");
883 assert!(
884 matches!(err, Error::UndoUnavailable(_)),
885 "expected UndoUnavailable, got {err:?}"
886 );
887 }
888
889 #[tokio::test]
890 async fn commits_land_in_existing_ancestor_repo_without_reinit() {
891 let dir = TempDir::new().expect("temp dir");
893 let out = Command::new("git")
894 .args(["init"])
895 .current_dir(dir.path())
896 .output()
897 .await
898 .expect("git init");
899 assert!(out.status.success());
900
901 let git = GitRepository::new(dir.path().join(".pinto"));
902 BacklogItemRepository::save(&git, &item(1, "First"))
903 .await
904 .expect("save");
905 git.commit("pinto: add T-1").await.expect("commit");
906 assert_eq!(commit_subjects(dir.path()).await, ["pinto: add T-1"]);
907 }
908}