Skip to main content

pinto/storage/
git_repository.rs

1//! Git backend.
2//!
3//! Add Git commits to backlog-item and sprint mutations without changing the plain-text storage
4//! provided by [`FileRepository`]. Git supplies the history and undo operations.
5//!
6//! **Policy**: invoke the `git` CLI through [`tokio::process`] to avoid another dependency and keep
7//! the backend lightweight. Waiting for the subprocess is asynchronous (see `docs/DESIGN.md` ยง3.4).
8//!
9//! **Behavior when Git is uninitialized or absent**:
10//! - If the directory containing `.pinto` is not a Git repository, `git init` runs automatically.
11//! - If `git` is not on `PATH`, return [`Error::Git`] and suggest installing Git or setting
12//!   `[storage] backend = "file"`.
13//!
14//! **Commit message convention**: `pinto: <verb> <id>` (`add` / `update` / `remove` / `archive`).
15
16use 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/// File persistence with a thin Git-commit layer.
29#[derive(Debug, Clone)]
30pub struct GitRepository {
31    /// File implementation responsible for actual reading and writing.
32    file: FileRepository,
33    /// The root of the board (`.pinto/`).
34    root: PathBuf,
35    /// Working directory to run git (parent of `.pinto` = project root).
36    workdir: PathBuf,
37    /// Paths that were already dirty when a write operation opened the backend.
38    ///
39    /// They remain in the user's worktree/index and are deliberately excluded from the pinto
40    /// commit. `GitRepository::new` leaves this empty for the low-level repository API; the service
41    /// layer uses [`Self::prepare`] before it starts a board mutation.
42    baseline_paths: Vec<String>,
43}
44
45impl GitRepository {
46    /// Build a repository for `.pinto/` without performing I/O. Git preparation is delayed until
47    /// the first commit; read-only operations do not invoke Git.
48    pub fn new(root: impl Into<PathBuf>) -> Self {
49        let root = root.into();
50        // Git runs from the parent of `.pinto`; use the root itself when no parent exists.
51        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    /// Capture the worktree state before a board mutation starts.
65    ///
66    /// Read-only backend construction still uses [`Self::new`], so it does not invoke Git. The
67    /// service write path calls this method after acquiring the board lock. A repository that has
68    /// not been initialized yet has no baseline; its first migration/write is treated as the
69    /// initial board commit.
70    pub(crate) async fn prepare(mut self) -> Result<Self> {
71        self.baseline_paths = match self.status_paths().await {
72            Ok(paths) => paths,
73            // `git status` is expected to fail before the Git backend's lazy `git init`. Keep the
74            // error deferred until the actual commit, where it can explain how to fix Git.
75            Err(Error::Git(_)) => Vec::new(),
76            Err(error) => return Err(error),
77        };
78        Ok(self)
79    }
80
81    /// A pathspec (relative to `workdir`) that limits git commits to the `.pinto` subtree.
82    ///
83    /// This avoids including unrelated changes that the user has staged outside of `.pinto`.
84    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    /// Pathspec for the transient lock marker.
92    fn lock_pathspec(&self) -> String {
93        format!("{}/.lock", self.board_pathspec())
94    }
95
96    /// Run `git <args>` in `workdir` and return its raw output. Map a missing executable to
97    /// [`Error::Git`].
98    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    /// Run `git <args>` and map a non-zero exit status to [`Error::Git`], including stderr.
117    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    /// Run `git <args>` with an alternate index file.
132    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    /// Run a Git command with an alternate index and include stderr on failure.
152    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    /// Ensure `workdir` is a Git repository, initializing it when necessary.
167    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    /// Record one board mutation as one commit; do nothing when there are no changes.
182    ///
183    /// Build and commit an isolated temporary index rooted at `HEAD`. Only paths that became dirty
184    /// after [`Self::prepare`] are copied into it, so pre-existing staged, unstaged, or untracked
185    /// changes remain outside the pinto commit. The real index is rebuilt from the new `HEAD` and
186    /// the caller's pre-existing staged entries are restored afterwards.
187    ///
188    /// The transient `.pinto/.lock` is never copied into the temporary index. If an old checkout
189    /// tracked that path, it is removed from the new tree while the live lock file remains open;
190    /// this avoids racing another writer by unlinking the lock before the guard is dropped.
191    ///
192    /// If Git fails, durable board files remain in the worktree and the user's real index is not
193    /// changed. The caller can inspect/fix Git and retry the same operation or commit the durable
194    /// files manually.
195    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    /// Return changed paths relative to the current `HEAD`, excluding the transient lock marker.
225    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    /// Check whether `path` is present in the current real index.
242    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    /// Capture the user's staged index entries before the isolated commit changes `HEAD`.
250    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    /// Commit an operation through a temporary index, leaving the user's index isolated.
274    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            // Do not stage the live lock contents. Remove the path from the temporary index while
299            // the OS lock stays held; BoardLock removes the live marker when its guard is dropped.
300            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 users commit with their configured identity; use pinto's fallback only when Git
314            // cannot resolve one.
315            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            // HEAD now includes only the operation tree. Refresh the real index, then put back
322            // exactly the entries that were staged before pinto started.
323            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    /// Revert the most recent completed board mutation by committing its inverse.
337    ///
338    /// pinto records each board mutation as a `pinto: <verb> <id>` commit, so the most recent
339    /// mutation is the current `HEAD`. `git revert --no-edit HEAD` writes a new commit that reverses
340    /// it. Revert (rather than reset) keeps history intact, and the undo itself stays reviewable with
341    /// `git diff` and reversible. The reverted commit's subject is returned for the caller's report.
342    ///
343    /// Refuses without touching the repository when there is nothing pinto can undo: an empty
344    /// repository, or a `HEAD` whose subject was not authored by pinto. This prevents undo from
345    /// silently reversing an unrelated user commit stacked on top of the board.
346    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        // A revert creates a commit, so it needs an identity just like `commit`.
362        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    /// Return the subject line of `revision` (the first line of its commit message).
375    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    /// Return the current commit ID, or `None` for an initialized repository without a `HEAD`.
383    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    /// Refresh the real index from `HEAD` and restore the user's staged paths.
394    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                // A staged deletion has no index entry; force-remove the HEAD entry.
404                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    /// Return pinto's default identity arguments when `user.email` is not configured; otherwise
428    /// return an empty list. The supplied `-c` values override Git configuration, so only add
429    /// them when no user identity is available.
430    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/// One staged entry captured from `git ls-files --stage`.
448#[derive(Debug)]
449struct IndexEntry {
450    path: String,
451    mode: String,
452    object: String,
453    stage: u8,
454}
455
456/// The user's staged state that must survive a pinto commit.
457#[derive(Debug, Default)]
458struct StagedIndex {
459    paths: HashSet<String>,
460    entries: Vec<IndexEntry>,
461}
462
463/// Parse NUL-delimited porcelain status records into relative paths.
464fn 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        // Porcelain v1 uses two status bytes followed by a space. Rename/copy records have a
472        // second NUL-delimited path; retain both so the next `git add -A` sees the full operation.
473        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
484/// Parse NUL-delimited path records.
485fn 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
493/// Parse the `mode object stage<TAB>path` records emitted by `git ls-files --stage -z`.
494fn 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
515/// Pick a process-local temporary index path without touching the user's index.
516fn 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        // Git commit boundaries belong to the service operation, not to this single-file
531        // persistence primitive. A user operation may update the item plus issued-ID history (or
532        // several items), and the service commits that complete durable set once.
533        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    //! Integration testing in a temporary git repository (tempfile quarantine).
590    //!
591    //! Since it uses the actual `git` CLI, git is required in the execution environment (prerequisite for CI/development environment).
592
593    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    /// Returns the GitRepository for `.pinto` and its parent (working directory).
601    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    /// Returns the most recent commit subject in chronological order.
619    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        // Not a git repository beforehand.
636        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        // Automatically initialized.
644        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        // Files are actually tracked and stored.
650        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        // The number of commits has not increased (only one add).
729        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        // A revert commit is recorded on top; the original commits are preserved.
812        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        // The reverted mutation's file is gone; the earlier item remains.
821        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        // A user commit lands on top of the board mutation.
839        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        // If it is already a git repository, add commits to its history without re-init.
892        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}