chore: clean up codebase (#19)
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run

Reviewed-on: #19
This commit was merged in pull request #19.
This commit is contained in:
2026-09-13 09:42:08 +00:00
parent 40deb9db66
commit f6b8a5e133
82 changed files with 3559 additions and 7862 deletions
-6
View File
@@ -16,19 +16,16 @@ impl GitCache {
Self { root }
}
/// The root directory holding the mirror clones.
pub fn root(&self) -> &Path {
&self.root
}
/// Local path of the clone for a repository.
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
self.root
.join(addr.public_key.to_hex())
.join(sanitize_path_component(&addr.identifier))
}
/// Open an existing clone.
pub fn open(&self, addr: &RepoAddr) -> Result<Option<gix::Repository>> {
let path = self.repo_path(addr);
match gix::open(&path) {
@@ -64,9 +61,6 @@ impl GitCache {
}
/// Map an untrusted repository id or display name to a safe single path component.
///
/// Everything outside `[A-Za-z0-9._-]` becomes `_`.
/// An id that maps to exactly `.` or `..` becomes `_`.
pub fn sanitize_path_component(id: &str) -> String {
let sanitized: String = id
.chars()
+1 -10
View File
@@ -3,18 +3,14 @@ use std::path::Path;
use anyhow::Result;
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
/// The kind of a [`DiffLine`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffLineKind {
/// An unchanged context line, present on both sides.
Context,
/// A line added by the commit.
Addition,
/// A line removed by the commit.
Deletion,
}
/// One line of a file diff.
#[derive(Debug, Clone)]
pub struct DiffLine {
pub kind: DiffLineKind,
@@ -31,16 +27,13 @@ pub struct DiffLine {
pub struct DiffHunk {
/// 1-based start line in the old version.
pub old_start: u32,
/// Number of old lines covered by the hunk.
pub old_lines: u32,
/// 1-based start line in the new version.
pub new_start: u32,
/// Number of new lines covered by the hunk.
pub new_lines: u32,
pub lines: Vec<DiffLine>,
}
/// How a file changed in a commit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffStatus {
Added,
@@ -50,7 +43,6 @@ pub enum DiffStatus {
Copied,
}
/// The diff of one file in a commit.
#[derive(Debug, Clone)]
pub struct FileDiff {
/// Path of the file relative to the repo root.
@@ -69,7 +61,6 @@ pub struct FileDiff {
pub hunks: Vec<DiffHunk>,
}
/// The changes of one commit.
#[derive(Debug, Clone)]
pub struct CommitDiff {
pub files: Vec<FileDiff>,
@@ -110,7 +101,7 @@ pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Resu
.tree()?;
tree_diff(&repo, Some(&base_tree), &tip_tree)
}
/// The changes between two trees. Used by both [`commit_diff`] and [`worktree_commit_range_diff`].
fn tree_diff(
repo: &gix::Repository,
old_tree: Option<&gix::Tree<'_>>,
+1 -9
View File
@@ -20,7 +20,6 @@ pub struct FileCommit {
///
/// `None` for single-line commit messages.
pub description: Option<String>,
/// Author name.
pub author: String,
/// Author time, seconds since the Unix epoch.
pub time: i64,
@@ -36,7 +35,7 @@ pub(crate) fn open_with_cache(workdir: &Path) -> Result<gix::Repository> {
Ok(repo)
}
/// A [`FileCommit`] from a commit, with author, message title, body and shortened id.
/// A [`FileCommit`] with author, message title, body and shortened id.
///
/// The diff panel fetches the full commit on demand.
fn file_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
@@ -50,7 +49,6 @@ fn file_commit_summary(commit: &gix::Commit<'_>) -> Result<FileCommit> {
file_commit_with_description(commit, false)
}
/// [`file_commit`] and [`file_commit_summary`], `include_description` picks the body.
fn file_commit_with_description(
commit: &gix::Commit<'_>,
include_description: bool,
@@ -96,8 +94,6 @@ pub fn worktree_last_commits(
last_commits(&open_with_cache(workdir)?, rels)
}
/// The walk behind [`last_commit`] and [`worktree_last_commits`].
///
/// Stops as soon as every pending path has its commit.
fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf, FileCommit)>> {
use gix::traverse::commit::simple::CommitTimeOrder;
@@ -106,7 +102,6 @@ fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf
return Ok(Vec::new());
};
// De-duplicate while preserving order.
let mut pending: Vec<PathBuf> = Vec::with_capacity(rels.len());
let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len());
@@ -136,7 +131,6 @@ fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf
};
// Compare each unresolved path against this commit and its first parent.
// Resolved paths leave the pending set.
let mut ix = 0;
while ix < pending.len() {
let rel = &pending[ix];
@@ -206,8 +200,6 @@ pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> {
Ok(CommitList { total, commits })
}
/// Like [`all_commits`], but opens the repository at `workdir` first.
///
/// For non-bare clones the clone root is the worktree.
pub fn worktree_all_commits(workdir: &Path) -> Result<CommitList> {
all_commits(&open_with_cache(workdir)?)
-2
View File
@@ -38,8 +38,6 @@ pub use worktree::{
worktree_commits_ahead, worktree_dirty, worktree_entries, worktree_read, worktree_snapshot,
};
/// Run a git command in `dir`, returning trimmed stdout.
///
/// The terminal prompt is disabled so a credential request fails instead of hanging.
#[cfg(test)]
fn git_in(dir: &std::path::Path, args: &[&str]) -> anyhow::Result<String> {
+4 -5
View File
@@ -9,10 +9,11 @@ use diffy::{Hunk, Line};
use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff};
use crate::history::FileCommit;
/// Apply a `git format-patch` patch or series with `git am`,
/// uses the git CLI because it handles the mbox format natively.
/// Apply a `git format-patch` patch or series with `git am`.
///
/// TODO: Replaced with a pure-Rust implementation later without changing callers.
/// Uses the git CLI because it handles the mbox format natively.
///
/// TODO: replace with a pure-Rust implementation later without changing callers.
pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
let mut child = Command::new("git")
.arg("am")
@@ -116,7 +117,6 @@ pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
Ok(CommitDiff { files })
}
/// The [`FileDiff`] of one parsed file patch.
fn file_diff(file: FilePatch<'_, str>) -> Result<FileDiff> {
// The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path
// component is dropped, the same way `git apply -p1` does.
@@ -296,7 +296,6 @@ pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
commits
}
/// The name part of a `From: Name <email>` header value.
fn name_from_address(from: &str) -> String {
match from.trim().find('<') {
Some(ix) => from[..ix].trim().to_string(),
-3
View File
@@ -41,7 +41,6 @@ pub fn fetch_all(repo: &gix::Repository) -> Result<()> {
Ok(())
}
/// Push `commit` to `reference` on the server at `url`, from `repo_path`.
pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> {
let output = Command::new("git")
.arg("-C")
@@ -107,7 +106,6 @@ fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
Ok(repo)
}
/// Push the `main` branch of the repository at `repo_path` to a grasp server.
pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
push_refspecs(
repo_path,
@@ -131,7 +129,6 @@ pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) ->
)
}
/// Push `refspecs` to the grasp server URL derived from `base_url`, `owner` and `repo_id`.
fn push_refspecs(
repo_path: &Path,
base_url: &str,
+1 -14
View File
@@ -55,7 +55,6 @@ pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result<Vec<String>
};
let Some(base) = base else {
// `HEAD` alone when no base is given.
return Ok(vec![head.to_string()]);
};
@@ -168,7 +167,7 @@ pub fn init_repository(path: &Path, name: &str, description: &str) -> Result<Str
)?;
// Populate the index so the fresh repository is clean,
// as `git add` and`git commit` would leave it.
// as `git add` and `git commit` would leave it.
let mut index = repo.index_from_tree(&tree)?;
index.write(gix::index::write::Options::default())?;
@@ -190,7 +189,6 @@ pub fn root_commit(repo_path: &Path) -> Result<Option<String>> {
};
let Ok(head) = repo.head_id() else {
// An unborn HEAD with no commits yet has no root commit.
return Ok(None);
};
@@ -234,7 +232,6 @@ pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>> {
}
}
// Sort lexicographically, like `git for-each-ref`.
names.sort();
Ok(names)
@@ -267,7 +264,6 @@ pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> {
})
.collect::<Result<Vec<_>>>()?;
// Delete all refs with the given prefix.
repo.edit_references(edits)?;
Ok(())
@@ -282,7 +278,6 @@ pub fn worktree_current_branch(workdir: &Path) -> Option<String> {
Some(String::from_utf8_lossy(name.shorten()).into_owned())
}
/// Whether the reference `name` exists in the repository at `workdir`.
pub fn worktree_ref_exists(workdir: &Path, name: &str) -> bool {
let Ok(repo) = gix::open(workdir) else {
return false;
@@ -374,10 +369,8 @@ pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
let tree = repo.find_object(remote_oid)?.peel_to_tree()?.id;
// Check out the remote tree, discarding local changes.
force_checkout(&repo, &tree)?;
// Update the branch reference to point to the remote tree.
repo.edit_references_as(
[edit(gix::refs::Target::Object(remote_oid))],
Some(signature),
@@ -385,7 +378,6 @@ pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
moved = true;
} else {
// Update the branch reference to point to the remote tree.
repo.edit_references_as(
[edit(gix::refs::Target::Object(remote_oid))],
Some(signature),
@@ -447,10 +439,6 @@ pub struct RepoRefState {
pub head: Option<String>,
}
/// Collect the refs of `repo`.
///
/// Local branches and tags become `(refname, commit-id)` pairs.
/// Also reports the branch HEAD points to.
pub fn repo_ref_state(repo: &gix::Repository) -> Result<RepoRefState> {
let mut refs = Vec::new();
@@ -482,7 +470,6 @@ pub fn repo_ref_state(repo: &gix::Repository) -> Result<RepoRefState> {
Ok(RepoRefState { refs, head })
}
/// [`repo_ref_state`] for the repository at `workdir`.
pub fn worktree_ref_state(workdir: &Path) -> Result<RepoRefState> {
repo_ref_state(&gix::open(workdir)?)
}
+3 -5
View File
@@ -2,13 +2,11 @@ use std::path::{Path, PathBuf};
use ignore::WalkBuilder;
/// Maximum directory nesting depth when scanning for local repositories.
///
/// Pathological trees can't stall the scan.
/// Caps nesting so pathological trees can't stall the scan.
const SCAN_MAX_DEPTH: usize = 12;
/// Walk `root` recursively and collect the paths of git repositories below it,
/// honouring `.gitignore` (and `.ignore`) files.
/// Walk `root` recursively and collect the paths of git repositories below it.
/// `.gitignore` and `.ignore` files are honoured.
pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
if !root.is_dir() {
return Vec::new();
+3 -739
View File
@@ -1,25 +1,9 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::process::Command;
use nostr::prelude::*;
use signed_core::{Announcement, repo_addr};
use super::*;
#[test]
fn keeps_plain_ids() {
assert_eq!(sanitize_path_component("my-repo"), "my-repo");
assert_eq!(sanitize_path_component("repo.v2"), "repo.v2");
assert_eq!(sanitize_path_component("a_b-c"), "a_b-c");
}
#[test]
fn replaces_unsafe_characters() {
assert_eq!(sanitize_path_component("a/b\\c:d"), "a_b_c_d");
assert_eq!(sanitize_path_component(""), "");
}
#[test]
fn blocks_parent_components() {
assert_eq!(sanitize_path_component(".."), "_");
@@ -29,34 +13,6 @@ fn blocks_parent_components() {
assert_eq!(sanitize_path_component("a/../b"), "a_.._b");
}
#[test]
fn fork_namespace_combines_owner_and_sanitized_id() {
let keys = Keys::generate();
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags([Tag::parse(["d", "my/repo"]).expect("valid tag")])
.finalize(&keys)
.expect("signed event");
let announcement = Announcement::from_event(&event).expect("parses");
assert_eq!(
fork_namespace(&announcement),
format!("{}/my_repo", keys.public_key().to_hex())
);
}
#[test]
fn repo_path_stays_inside_root() {
let cache = GitCache::new("/cache".into());
let owner = Keys::generate().public_key();
let path = cache.repo_path(&repo_addr(owner, ".."));
assert!(path.starts_with("/cache"));
assert_eq!(
path.file_name().map(|n| n.to_string_lossy().into_owned()),
Some("_".into())
);
}
#[test]
fn find_git_repos_discovers_repositories_recursively() {
let temp = tempfile::tempdir().unwrap();
@@ -74,18 +30,14 @@ fn find_git_repos_discovers_repositories_recursively() {
)
.unwrap();
// Plain directories are not repositories.
std::fs::create_dir_all(root.join("plain")).unwrap();
// A `.gitignore` at the root excludes dependency caches.
std::fs::write(root.join(".gitignore"), "node_modules/\n").unwrap();
std::fs::create_dir_all(root.join("node_modules/pkg/.git")).unwrap();
// Hidden entries are skipped.
std::fs::create_dir_all(root.join(".hidden/repo/.git")).unwrap();
// A repository is not descended into.
// Repositories inside it, like submodule worktrees, are not reported.
// A repository inside another, like a submodule worktree, is not reported.
let outer = root.join("outer");
std::fs::create_dir_all(outer.join(".git")).unwrap();
std::fs::create_dir_all(outer.join("sub/other/.git")).unwrap();
@@ -120,13 +72,6 @@ fn root_commit_reports_the_first_ancestor() {
);
}
#[test]
fn root_commit_is_none_without_commits() {
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
let workdir = repo.workdir().expect("workdir");
assert_eq!(root_commit(workdir).expect("root"), None);
}
#[test]
fn push_all_mirrors_branches_and_tags() {
// A bare server repository reachable via a `file://` URL.
@@ -145,7 +90,6 @@ fn push_all_mirrors_branches_and_tags() {
commit_all(&repo, "initial");
let dir = dir.path();
// Two branches plus a tag are all mirrored.
git_run(dir, &["checkout", "-b", "feature"]);
std::fs::write(dir.join("b.txt"), b"two").expect("write");
commit_all(&repo, "feature work");
@@ -161,34 +105,6 @@ fn push_all_mirrors_branches_and_tags() {
assert!(refs.contains("refs/tags/v1.0"));
}
#[test]
fn push_all_tolerates_a_missing_ref_kind() {
// A repository with only tags and no branches still pushes.
// Wildcard refspecs without a local match are ignored.
let server = tempfile::tempdir().unwrap();
let server_repo = server.path().join("npub1test").join("my-repo.git");
std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap();
let init_status = Command::new("git")
.args(["init", "--bare", "-q"])
.arg(&server_repo)
.status()
.expect("spawn git init --bare");
assert!(init_status.success());
let (dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
let dir = dir.path();
git_run(dir, &["tag", "v1.0"]);
git_run(dir, &["update-ref", "-d", "refs/heads/main"]);
let base_url = format!("file://{}", server.path().display());
push_all(dir, &base_url, "npub1test", "my-repo").expect("push");
let refs = git_in(&server_repo, &["show-ref"]).expect("server refs");
assert!(refs.contains("refs/tags/v1.0"));
assert!(!refs.contains("refs/heads/"));
}
#[test]
fn remote_has_refs_reports_whether_pushed_refs_landed() {
let server = tempfile::tempdir().unwrap();
@@ -208,7 +124,6 @@ fn remote_has_refs_reports_whether_pushed_refs_landed() {
let url = format!("file://{}/npub1test/my-repo.git", server.path().display());
let expected = vec![("refs/heads/main".to_owned(), main.clone())];
// Nothing pushed yet: the ref is absent.
assert!(!remote_has_refs(dir, &url, &expected).expect("probe"));
push_all(
@@ -219,7 +134,6 @@ fn remote_has_refs_reports_whether_pushed_refs_landed() {
)
.expect("push");
// The pushed ref is advertised at the expected commit.
assert!(remote_has_refs(dir, &url, &expected).expect("probe"));
// A stale expectation - the exact race a retry resolves - is false.
@@ -253,7 +167,6 @@ fn repo_ref_state_lists_branches_tags_and_head() {
assert_eq!(state.refs[0].0, format!("refs/heads/{branch}"));
assert_eq!(state.refs[0].1.len(), 40);
// Additional branches and tags are listed alongside.
git_run(&workdir, &["branch", "feature"]);
git_run(&workdir, &["tag", "v1.0"]);
@@ -273,7 +186,6 @@ fn repo_ref_state_lists_branches_tags_and_head() {
expected
);
// A detached HEAD yields no head branch.
git_run(&workdir, &["checkout", "--detach"]);
let state = repo_ref_state(&repo).expect("refs");
assert!(state.head.is_none());
@@ -294,52 +206,6 @@ fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) {
(dir, repo)
}
#[test]
fn worktree_entries_lists_all_files_and_dirs() {
let (_dir, repo) = fixture(&[
("README.md", b"# Hi"),
("src/main.rs", b"fn main() {}"),
("src/lib.rs", b""),
("docs/guide.md", b"guide"),
]);
let entries = worktree_entries(&repo).expect("entries");
let entries: Vec<String> = entries
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert_eq!(
entries,
vec![
"docs",
"src",
"README.md",
"docs/guide.md",
"src/lib.rs",
"src/main.rs"
]
);
}
#[test]
fn worktree_read_returns_bytes_or_none() {
let (_dir, repo) = fixture(&[("a.txt", b"hello"), ("sub/b.bin", b"\x00\x01")]);
assert_eq!(
worktree_read(&repo, Path::new("a.txt")).expect("read"),
Some(b"hello".to_vec())
);
assert_eq!(
worktree_read(&repo, Path::new("sub/b.bin")).expect("read"),
Some(vec![0x00, 0x01])
);
assert_eq!(
worktree_read(&repo, Path::new("missing.txt")).expect("read"),
None
);
}
/// Stage everything and create a commit with the git CLI.
/// Like [`apply_patch`], the crate already shells out to the CLI.
fn commit_all(repo: &gix::Repository, message: &str) {
@@ -379,50 +245,6 @@ fn merge_base_finds_the_fork_point_and_reports_unrelated_history() {
assert!(merge_base(&path, "orphan", "no-such-ref").is_err());
}
#[test]
fn format_patch_between_produces_the_series_and_rejects_empty_ranges() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("repo");
let initial = init_repository(&path, "My Repo", "desc").expect("init");
git_run(&path, &["checkout", "-b", "feature"]);
std::fs::write(path.join("feature.txt"), "feature\n").expect("write");
commit_all(&gix::open(&path).expect("open"), "feature commit");
let patch = format_patch_between(&path, &initial, "feature").expect("patch");
assert!(patch.contains("Subject: [PATCH] feature commit"));
assert!(patch.contains("feature.txt"));
// An empty range has no commits to send.
assert!(format_patch_between(&path, "feature", "feature").is_err());
}
#[test]
fn push_commit_ref_pushes_to_the_event_namespace() {
// A bare server repository reachable via a `file://` URL.
// Mirrors a grasp server's `{base}/{owner}/{repo-id}.git` layout.
let server = tempfile::tempdir().unwrap();
let server_repo = server.path().join("npub1test").join("my-repo.git");
std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap();
let init_status = Command::new("git")
.args(["init", "--bare", "-q"])
.arg(&server_repo)
.status()
.expect("spawn git init --bare");
assert!(init_status.success());
let (dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
let dir = dir.path();
let tip = git_in(dir, &["rev-parse", "HEAD"]).expect("tip");
let url = format!("file://{}/npub1test/my-repo.git", server.path().display());
push_commit_ref(dir, &url, &tip, "refs/nostr/abcd1234").expect("push");
let refs = git_in(&server_repo, &["show-ref"]).expect("server refs");
assert!(refs.contains("refs/nostr/abcd1234"));
}
#[test]
fn split_patch_series_splits_real_multi_commit_mboxes() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -448,14 +270,6 @@ fn split_patch_series_splits_real_multi_commit_mboxes() {
assert_ne!(first, second);
}
#[test]
fn split_patch_series_keeps_single_patches_whole() {
let patch = "From abcdefabcdefabcdefabcdefabcdefabcdefab Mon Sep 17 00:00:00 2001\nFrom: A <a@b>\nSubject: [PATCH] fix\n\n---\n";
let parts = split_patch_series(patch);
assert_eq!(parts.len(), 1);
assert_eq!(parts[0], patch);
}
#[test]
fn head_commit_and_commits_since_track_applied_commits() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -466,7 +280,7 @@ fn head_commit_and_commits_since_track_applied_commits() {
head_commit_id(&path).expect("head").as_deref(),
Some(initial.as_str())
);
// No commits yet, `HEAD` alone.
// No base given, `HEAD` alone.
assert_eq!(
commits_since(&path, None).expect("commits"),
vec![initial.clone()]
@@ -491,24 +305,6 @@ fn head_commit_and_commits_since_track_applied_commits() {
);
}
#[test]
fn head_commit_reports_unborn_repositories() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("repo");
let status = Command::new("git")
.args(["init", "-q"])
.arg(&path)
.status()
.expect("spawn git init");
assert!(status.success());
assert_eq!(head_commit_id(&path).expect("head"), None);
assert_eq!(
commits_since(&path, None).expect("commits"),
Vec::<String>::new()
);
}
#[test]
fn init_repository_creates_main_branch_and_readme() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -541,70 +337,12 @@ fn init_repository_creates_main_branch_and_readme() {
assert!(!worktree_dirty(workdir));
}
#[test]
fn init_repository_omits_description_when_empty() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("my-repo");
init_repository(&path, "My Repo", " ").expect("init");
let repo = gix::open(&path).expect("open");
let workdir = repo.workdir().expect("workdir");
assert_eq!(
std::fs::read_to_string(workdir.join("README.md")).expect("read"),
"# My Repo\n"
);
}
#[test]
fn ensure_origin_adds_remote_only_once() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("my-repo");
init_repository(&path, "My Repo", "").expect("init");
ensure_origin(&path, "https://gitnostr.com/npub1test/repo.git").expect("add");
assert_eq!(
git_in(&path, &["remote", "get-url", "origin"]).expect("url"),
"https://gitnostr.com/npub1test/repo.git"
);
// The standard fetch mapping is configured with the remote.
// Later `git fetch origin` updates `refs/remotes/origin/*`.
assert_eq!(
git_in(&path, &["config", "remote.origin.fetch"]).expect("refspec"),
"+refs/heads/*:refs/remotes/origin/*"
);
// A second call must not override the existing remote.
ensure_origin(&path, "https://other.example/repo.git").expect("keep");
assert_eq!(
git_in(&path, &["remote", "get-url", "origin"]).expect("url"),
"https://gitnostr.com/npub1test/repo.git"
);
}
#[test]
fn origin_url_reads_the_remote_or_reports_none() {
let (dir, _repo) = fixture(&[("a.txt", b"one")]);
commit_all(&_repo, "initial");
let dir = dir.path();
// No remote configured yet.
assert_eq!(origin_url(dir).expect("read"), None);
ensure_origin(dir, "https://gitnostr.com/npub1test/repo.git").expect("add");
assert_eq!(
origin_url(dir).expect("read").as_deref(),
Some("https://gitnostr.com/npub1test/repo.git")
);
}
#[test]
fn set_origin_creates_or_replaces_the_remote() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("my-repo");
init_repository(&path, "My Repo", "").expect("init");
// No origin yet, so one is added.
set_origin(&path, "https://gitnostr.com/npub1test/repo.git").expect("add");
assert_eq!(
origin_url(&path).expect("url").as_deref(),
@@ -664,7 +402,6 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() {
.expect("spawn git init --bare");
assert!(init_status.success());
// The owner's working repo pushes the initial commit.
let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]);
commit_all(&work_repo, "initial");
let work = work_dir.path();
@@ -746,7 +483,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
)
.expect("push");
// The base mirror is a plain clone of the base server.
let base_url = format!("file://{}", base_server.display());
let mirror = dir.path().join("mirror");
git_run(
@@ -792,7 +528,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
)
.expect("fetch");
// The imported refs are listed under the prefix only.
assert_eq!(
refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"),
vec!["refs/fork/npub1fork/fork/feature"]
@@ -820,7 +555,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
assert!(patch.contains("Subject: [PATCH] feature commit"));
assert!(patch.contains("feature.txt"));
// Pruning the prefix removes the import again.
delete_refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("delete");
assert_eq!(
refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"),
@@ -828,30 +562,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
);
}
#[test]
fn fetch_repo_refs_fails_when_every_url_fails() {
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
let dir = _dir.path();
let dead = format!("file://{}/missing.git", dir.display());
let err =
fetch_repo_refs(dir, &[dead], "+refs/heads/*:refs/fork/x/*").expect_err("all URLs fail");
assert!(err.to_string().contains("failed to fetch"));
// Without any URL there is nothing to try.
let err =
fetch_repo_refs(dir, &[] as &[String], "+refs/heads/*:refs/fork/x/*").expect_err("no URLs");
assert!(err.to_string().contains("no clone URLs"));
}
#[test]
fn delete_refs_with_prefix_is_a_noop_without_matches() {
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
delete_refs_with_prefix(_dir.path(), "refs/fork/nothing").expect("noop");
}
/// Run a git command in `dir`, asserting success.
fn git_run(dir: &Path, args: &[&str]) {
let status = Command::new("git")
@@ -910,25 +620,6 @@ fn all_commits_lists_every_commit() {
);
}
#[test]
fn all_commits_returns_empty_without_head() {
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
let list = all_commits(&repo).expect("commits");
assert!(list.commits.is_empty());
assert_eq!(list.total, 0);
}
#[test]
fn last_commit_returns_none_for_untracked_files() {
let (dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
std::fs::write(dir.path().join("untracked.txt"), b"x").expect("write");
let commit = last_commit(&repo, Path::new("untracked.txt")).expect("lookup");
assert!(commit.is_none());
}
#[test]
fn last_commit_reports_merge_commits() {
let (dir, repo) = fixture(&[("a.txt", b"base")]);
@@ -964,35 +655,6 @@ fn last_commit_reports_merge_commits() {
assert!(commit.summary.starts_with("Merge branch"));
}
#[test]
fn last_commits_batches_multiple_paths() {
let (dir, repo) = fixture(&[("a.txt", b"one"), ("b.txt", b"b")]);
commit_all(&repo, "initial");
std::fs::write(dir.path().join("a.txt"), b"two").expect("write");
commit_all(&repo, "change a");
std::fs::write(dir.path().join("b.txt"), b"bb").expect("write");
commit_all(&repo, "change b");
let found = worktree_last_commits(
dir.path(),
&[
PathBuf::from("a.txt"),
PathBuf::from("b.txt"),
// Untracked paths are absent from the result.
PathBuf::from("missing.txt"),
],
)
.expect("commits");
let by_path: HashMap<&Path, &FileCommit> = found
.iter()
.map(|(path, commit)| (path.as_path(), commit))
.collect();
assert_eq!(by_path.len(), 2);
assert_eq!(by_path[Path::new("a.txt")].summary, "change a");
assert_eq!(by_path[Path::new("b.txt")].summary, "change b");
}
#[test]
fn find_readme_prefers_markdown() {
let (_dir, repo) = fixture(&[("readme.txt", b"txt"), ("README.md", b"md")]);
@@ -1004,63 +666,6 @@ fn find_readme_prefers_markdown() {
);
}
#[test]
fn find_readme_falls_back_to_any_readme() {
let (_dir, repo) = fixture(&[("README.rst", b"rst")]);
let readme = find_readme(&repo).expect("find");
assert_eq!(
readme.map(|p| p.to_string_lossy().into_owned()),
Some("README.rst".into())
);
}
#[test]
fn find_readme_returns_none_without_one() {
let (_dir, repo) = fixture(&[("main.rs", b"")]);
assert!(find_readme(&repo).expect("find").is_none());
}
#[test]
fn head_commit_reports_head() {
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
// Unborn HEAD means no commit yet.
assert!(head_commit(&repo).expect("head").is_none());
commit_all(&repo, "initial");
let head = head_commit(&repo).expect("head").expect("commit");
assert_eq!(
head.id,
repo.head_id().expect("head id").shorten_or_id().to_string()
);
assert_eq!(head.summary, "initial");
assert_eq!(head.author, "Test Author");
}
#[test]
fn worktree_branches_and_tags_list_short_names() {
let (dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
let dir = dir.path();
git_run(dir, &["checkout", "-b", "feature"]);
git_run(dir, &["tag", "v0.9"]);
git_run(dir, &["tag", "v1.0"]);
// The initial branch name depends on git configuration.
// Only the branch we created is fixed.
let branches = worktree_branches(dir).expect("branches");
assert_eq!(branches.len(), 2);
assert!(branches.contains(&"feature".to_string()));
assert!(branches.windows(2).all(|pair| pair[0] <= pair[1]), "sorted");
assert_eq!(
repo_tags(&repo).expect("tags"),
vec!["v0.9".to_string(), "v1.0".to_string()]
);
}
#[test]
fn current_branch_tracks_checkout() {
let (dir, repo) = fixture(&[("a.txt", b"one")]);
@@ -1083,12 +688,10 @@ fn current_branch_tracks_checkout() {
Some("feature")
);
// Tags detach HEAD.
git_run(dir, &["tag", "v1.0"]);
worktree_checkout_tag(dir, "v1.0").expect("checkout tag");
assert_eq!(current_branch(&repo).expect("branch"), None);
// Branches re-attach HEAD.
worktree_checkout_branch(dir, &default).expect("checkout branch");
assert_eq!(
current_branch(&repo).expect("branch").as_deref(),
@@ -1239,25 +842,6 @@ fn commit_range_diff_lists_changes_between_two_commits() {
assert!(diff.files.iter().all(|file| file.path != "b.txt"));
}
#[test]
fn commit_range_commits_lists_only_new_commits_newest_first() {
let (dir, repo) = fixture(&[("a.txt", b"one\n")]);
commit_all(&repo, "one");
let base = repo.head_id().expect("head").to_string();
std::fs::write(dir.path().join("a.txt"), b"two\n").expect("write");
commit_all(&repo, "two");
std::fs::write(dir.path().join("a.txt"), b"three\n").expect("write");
commit_all(&repo, "three");
let tip = repo.head_id().expect("head").to_string();
let commits = worktree_commit_range_commits(dir.path(), &base, &tip).expect("commits");
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].summary, "three");
assert_eq!(commits[1].summary, "two");
}
#[test]
fn commit_diff_reports_binary_files_without_hunks() {
let (_dir, repo) = fixture(&[("blob.bin", b"\x00\x01\x02")]);
@@ -1279,56 +863,6 @@ fn commit_diff_reports_binary_files_without_hunks() {
assert_eq!(file.deletions, 0);
}
#[test]
fn commit_diff_resolves_short_ids_and_root_commit() {
let (dir, repo) = fixture(&[("a.txt", b"one\n")]);
commit_all(&repo, "initial");
// The root commit diffs against the empty tree, everything is added.
let head = repo.head_id().expect("head").shorten_or_id().to_string();
let diff = worktree_commit_diff(dir.path(), &head).expect("diff");
assert_eq!(diff.files.len(), 1);
assert_eq!(diff.files[0].path, "a.txt");
assert_eq!(diff.files[0].status, DiffStatus::Added);
assert_eq!(diff.files[0].insertions, 1);
}
#[test]
fn file_commit_includes_message_body() {
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "title");
// A single-line message has no body.
let head = head_commit(&repo).expect("head").expect("commit");
assert_eq!(head.summary, "title");
assert_eq!(head.description, None);
// A message with a body exposes it, trimmed.
let dir = _dir.path();
let status = Command::new("git")
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "Test Author")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test Author")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.env("GIT_EDITOR", "true")
.args([
"commit",
"--allow-empty",
"-m",
"title two",
"-m",
"line one\n\nline two",
])
.status()
.expect("spawn git");
assert!(status.success(), "git commit failed");
let head = head_commit(&repo).expect("head").expect("commit");
assert_eq!(head.summary, "title two");
assert_eq!(head.description.as_deref(), Some("line one\n\nline two"));
}
#[test]
fn commit_diff_reports_renames() {
let (_dir, repo) = fixture(&[("old.txt", b"same content\n")]);
@@ -1352,143 +886,6 @@ fn commit_diff_reports_renames() {
assert_eq!(file.deletions, 0);
}
#[test]
fn parses_format_patch_output() {
let patch = r#"From 1f6c0c5f3f1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a Mon Sep 17 00:00:00 2001
From: A <a@b.c>
Subject: [PATCH] fix
fix the thing
---
src/lib.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/lib.rs b/src/lib.rs
index 1234567..89abcde 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,3 +1,3 @@
fn main() {
- println!("old");
+ println!("new");
}
"#;
let diff = patch_diffs(patch).expect("parse");
assert_eq!(diff.files.len(), 1);
let file = &diff.files[0];
assert_eq!(file.path, "src/lib.rs");
assert_eq!(file.old_path, None);
assert_eq!(file.status, DiffStatus::Modified);
assert_eq!(file.insertions, 1);
assert_eq!(file.deletions, 1);
let hunk = &file.hunks[0];
assert_eq!(hunk.old_start, 1);
assert_eq!(hunk.old_lines, 3);
assert_eq!(hunk.new_start, 1);
assert_eq!(hunk.new_lines, 3);
assert_eq!(hunk.lines.len(), 4);
assert_eq!(hunk.lines[0].kind, DiffLineKind::Context);
assert_eq!(hunk.lines[0].old, Some(1));
assert_eq!(hunk.lines[0].new, Some(1));
assert_eq!(hunk.lines[1].kind, DiffLineKind::Deletion);
assert_eq!(hunk.lines[1].old, Some(2));
assert_eq!(hunk.lines[1].new, None);
assert_eq!(hunk.lines[2].kind, DiffLineKind::Addition);
assert_eq!(hunk.lines[2].old, None);
assert_eq!(hunk.lines[2].new, Some(2));
assert_eq!(hunk.lines[3].kind, DiffLineKind::Context);
assert_eq!(hunk.lines[3].old, Some(3));
assert_eq!(hunk.lines[3].new, Some(3));
}
#[test]
fn parses_new_file_as_added() {
let patch = r#"diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1234567
--- /dev/null
+++ b/README.md
@@ -0,0 +1 @@
+# hello
"#;
let diff = patch_diffs(patch).expect("parse");
let file = &diff.files[0];
assert_eq!(file.path, "README.md");
assert_eq!(file.status, DiffStatus::Added);
assert_eq!(file.old_path, None);
assert_eq!(file.insertions, 1);
assert_eq!(file.deletions, 0);
assert_eq!(file.hunks[0].old_start, 0);
assert_eq!(file.hunks[0].old_lines, 0);
assert_eq!(file.hunks[0].new_start, 1);
}
#[test]
fn parses_renames_with_old_path() {
let patch = r#"diff --git a/old.rs b/new.rs
similarity index 85%
rename from old.rs
rename to new.rs
index 123..456 100644
--- a/old.rs
+++ b/new.rs
@@ -1 +1 @@
-fn main() {}
+fn main() { println!("hi"); }
"#;
let diff = patch_diffs(patch).expect("parse");
let file = &diff.files[0];
assert_eq!(file.path, "new.rs");
assert_eq!(file.old_path.as_deref(), Some("old.rs"));
assert_eq!(file.status, DiffStatus::Renamed);
assert_eq!(file.insertions, 1);
assert_eq!(file.deletions, 1);
}
#[test]
fn parses_patch_series_and_skips_envelope() {
let patch = r#"From aaaa Mon Sep 17 00:00:00 2001
From: A <a@b.c>
Subject: [PATCH 1/2] one
---
a.txt | 1 +
1 file changed, 1 insertion(+)
diff --git a/a.txt b/a.txt
index 1..2 100644
--- a/a.txt
+++ b/a.txt
@@ -1 +1,2 @@
a
+b
From bbbb Mon Sep 17 00:00:00 2001
From: A <a@b.c>
Subject: [PATCH 2/2] two
diff --git a/b.txt b/b.txt
index 3..4 100644
--- a/b.txt
+++ b/b.txt
@@ -1 +1 @@
-x
+y
"#;
let diff = patch_diffs(patch).expect("parse");
assert_eq!(diff.files.len(), 2);
assert_eq!(diff.files[0].path, "a.txt");
assert_eq!(diff.files[0].insertions, 1);
assert_eq!(diff.files[1].path, "b.txt");
assert_eq!(diff.files[1].deletions, 1);
}
#[test]
fn patch_commits_lists_every_patch_in_order() {
let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001
@@ -1536,119 +933,6 @@ diff --git a/b.txt b/b.txt
assert_eq!(commits[1].time, 1690975800);
}
#[test]
fn patch_commits_strips_patch_subject_prefixes() {
let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001
From: A <a@b.c>
Subject: [RFC PATCH v3 4/7] the real title
---
"#;
let commits = patch_commits(patch);
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].summary, "the real title");
}
#[test]
fn patch_commits_handles_missing_headers() {
// A hand-written patch without author or date headers still lists a commit.
// Time stays 0 and the author stays empty.
let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001
Subject: [PATCH] plain
---
"#;
let commits = patch_commits(patch);
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].summary, "plain");
assert_eq!(commits[0].author, "");
assert_eq!(commits[0].time, 0);
}
#[test]
fn patch_commits_ignores_non_patch_lines() {
assert!(patch_commits("").is_empty());
assert!(patch_commits("just some text\nFrom 123\n").is_empty());
// A diff-only body without an mbox envelope has no commits.
let patch = "diff --git a/x b/x\n--- a/x\n+++ b/x\n";
assert!(patch_commits(patch).is_empty());
}
#[test]
fn marks_binary_sections() {
let patch = r#"diff --git a/img.png b/img.png
index 123..456 100644
Binary files a/img.png and b/img.png differ
"#;
let diff = patch_diffs(patch).expect("parse");
assert!(diff.files[0].binary);
assert!(diff.files[0].hunks.is_empty());
}
#[test]
fn unquotes_quoted_paths() {
let patch = r#"diff --git "a/weird file.rs" "b/weird file.rs"
index 123..456 100644
--- "a/weird file.rs"
+++ "b/weird file.rs"
@@ -1 +1 @@
-x
+y
"#;
let diff = patch_diffs(patch).expect("parse");
assert_eq!(diff.files[0].path, "weird file.rs");
assert_eq!(diff.files[0].status, DiffStatus::Modified);
}
#[test]
fn unquotes_non_ascii_quoted_paths() {
let patch = r#"diff --git "a/说明.md" "b/说明.md"
index 123..456 100644
--- "a/说明.md"
+++ "b/说明.md"
@@ -1 +1 @@
-x
+y
"#;
let diff = patch_diffs(patch).expect("parse");
assert_eq!(diff.files[0].path, "说明.md");
assert_eq!(diff.files[0].status, DiffStatus::Modified);
}
#[test]
fn unquotes_octal_escaped_paths() {
let patch = r#"diff --git "a/\345\270\226.md" "b/\345\270\226.md"
index 123..456 100644
--- "a/\345\270\226.md"
+++ "b/\345\270\226.md"
@@ -1 +1 @@
-x
+y
"#;
let diff = patch_diffs(patch).expect("parse");
assert_eq!(diff.files[0].path, "帖.md");
assert_eq!(diff.files[0].status, DiffStatus::Modified);
}
#[test]
fn empty_or_unparseable_patch_yields_no_files() {
assert_eq!(patch_diffs("").expect("parse").files.len(), 0);
assert_eq!(patch_diffs("just some text").expect("parse").files.len(), 0);
assert_eq!(
patch_diffs("---\nnot a patch\n")
.expect("parse")
.files
.len(),
0
);
}
#[test]
fn parses_real_format_patch_output() {
// Build a commit touching a mix of file kinds.
@@ -1735,7 +1019,6 @@ fn worktree_dirty_tracks_changes_and_untracked_files() {
assert!(!worktree_dirty(workdir));
// A modified tracked file is dirty.
std::fs::write(workdir.join("tracked.txt"), b"two").expect("write");
assert!(worktree_dirty(workdir));
@@ -1745,7 +1028,6 @@ fn worktree_dirty_tracks_changes_and_untracked_files() {
std::fs::write(workdir.join("untracked.txt"), b"new").expect("write");
assert!(worktree_dirty(workdir));
// A staged change counts too.
git_run(workdir, &["rm", "--cached", "tracked.txt"]);
assert!(worktree_dirty(workdir));
@@ -1753,24 +1035,6 @@ fn worktree_dirty_tracks_changes_and_untracked_files() {
assert!(!worktree_dirty(&dir.path().join("missing")));
}
#[test]
fn worktree_dirty_reports_unborn_worktrees_with_files() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("repo");
let status = Command::new("git")
.args(["init", "-q"])
.arg(&path)
.status()
.expect("spawn git init");
assert!(status.success());
// No commits and no files: porcelain is empty.
assert!(!worktree_dirty(&path));
// An unborn repository holding files is dirty.
std::fs::write(path.join("README.md"), "# hello\n").expect("write");
assert!(worktree_dirty(&path));
}
#[test]
fn worktree_commits_ahead_counts_branch_only_commits() {
let (dir, repo) = fixture(&[("a.txt", b"one")]);
-10
View File
@@ -164,8 +164,6 @@ pub struct WorktreeSnapshot {
pub head_commit: Option<FileCommit>,
}
/// Snapshot the worktree after a branch or tag switch.
///
/// Collects entries, the README, the branch HEAD points to and its commit.
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
let repo = gix::open(workdir)?;
@@ -183,7 +181,6 @@ pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
})
}
/// Check out `tree` into the worktree of `repo`
pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> Result<()> {
let workdir = repo
.workdir()
@@ -229,7 +226,6 @@ pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> R
let files = gix::progress::Discard;
let bytes = gix::progress::Discard;
// Check out the index into the worktree.
gix_worktree_state::checkout(
&mut index,
workdir,
@@ -240,7 +236,6 @@ pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> R
options,
)?;
// Write the index to disk.
index.write(gix::index::write::Options::default())?;
Ok(())
@@ -258,7 +253,6 @@ fn move_head(
let head = gix::refs::FullName::try_from("HEAD")
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
// Update the reference, creating a reflog entry.
repo.edit_references_as(
[RefEdit {
change: Change::Update {
@@ -293,7 +287,6 @@ pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> {
let (signature, mut time_buf) = repository_signature();
let signature = signature.to_ref(&mut time_buf);
// Move HEAD to the branch, creating a reflog entry.
move_head(
&repo,
signature,
@@ -301,7 +294,6 @@ pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> {
&format!("checkout: moving to {name}"),
)?;
// Check out the branch's tree, replacing index + worktree.
force_checkout(&repo, &tree)?;
Ok(())
@@ -320,7 +312,6 @@ pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> {
let (signature, mut time_buf) = repository_signature();
let signature = signature.to_ref(&mut time_buf);
// Move HEAD to the tag, creating a reflog entry.
move_head(
&repo,
signature,
@@ -328,7 +319,6 @@ pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> {
&format!("checkout: moving to {name}"),
)?;
// Check out the tag's tree, replacing index + worktree.
force_checkout(&repo, &tree)?;
Ok(())