diff --git a/Cargo.lock b/Cargo.lock index 0582a4a..271f8bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7971,6 +7971,7 @@ dependencies = [ "gix", "gix-worktree", "gix-worktree-state", + "ignore", "nostr", "signed_core", "tempfile", diff --git a/crates/signed_git/Cargo.toml b/crates/signed_git/Cargo.toml index d52f8eb..a4cd750 100644 --- a/crates/signed_git/Cargo.toml +++ b/crates/signed_git/Cargo.toml @@ -13,6 +13,7 @@ gix-worktree = "0.56" gix-worktree-state = "0.34" anyhow.workspace = true diffy = "0.5" +ignore = "0.4" [dev-dependencies] tempfile = "3" diff --git a/crates/signed_git/src/cache.rs b/crates/signed_git/src/cache.rs new file mode 100644 index 0000000..bdfdde1 --- /dev/null +++ b/crates/signed_git/src/cache.rs @@ -0,0 +1,96 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use signed_core::{Announcement, RepoAddr}; + +use crate::remote::{clone_repo, fetch_all}; + +/// On-disk cache of cloned repositories, keyed by owner pubkey / repo id. +#[derive(Debug, Clone)] +pub struct GitCache { + root: PathBuf, +} + +impl GitCache { + pub fn new(root: PathBuf) -> Self { + 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> { + let path = self.repo_path(addr); + match gix::open(&path) { + Ok(repo) => Ok(Some(repo)), + Err(gix::open::Error::NotARepository { .. }) => Ok(None), + Err(gix::open::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// Open the existing clone, fetching it first. + pub fn ensure_clone>( + &self, + addr: &RepoAddr, + clone_urls: &[U], + ) -> Result { + let path = self.repo_path(addr); + + if let Some(repo) = self.open(addr)? { + fetch_all(&repo).ok(); + return Ok(repo); + } + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + + clone_repo(clone_urls, &path)?; + self.open(addr)? + .ok_or_else(|| anyhow::anyhow!("clone finished but the repository cannot be opened")) + } +} + +/// 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() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' + } + }) + .collect(); + + if sanitized == "." || sanitized == ".." { + return "_".to_owned(); + } + + sanitized +} + +/// The refs namespace of a fork's import in the target mirror. +pub fn fork_namespace(announcement: &Announcement) -> String { + format!( + "{}/{}", + announcement.owner.to_hex(), + sanitize_path_component(&announcement.id) + ) +} diff --git a/crates/signed_git/src/diff.rs b/crates/signed_git/src/diff.rs new file mode 100644 index 0000000..cffc382 --- /dev/null +++ b/crates/signed_git/src/diff.rs @@ -0,0 +1,304 @@ +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, + /// 1-based line number in the old version, if the line exists there. + pub old: Option, + /// 1-based line number in the new version, if the line exists there. + pub new: Option, + /// Line content without the trailing newline. + pub text: String, +} + +/// A hunk of a file diff, like `@@ -a,b +c,d @@`. +#[derive(Debug, Clone)] +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, +} + +/// How a file changed in a commit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiffStatus { + Added, + Modified, + Deleted, + Renamed, + Copied, +} + +/// The diff of one file in a commit. +#[derive(Debug, Clone)] +pub struct FileDiff { + /// Path of the file relative to the repo root. + /// + /// For renames and copies, this is the destination path. + pub path: String, + /// Previous path, for renames and copies. + pub old_path: Option, + pub status: DiffStatus, + /// Number of added lines, 0 for binary files. + pub insertions: usize, + /// Number of removed lines, 0 for binary files. + pub deletions: usize, + /// True if either version is binary, then `hunks` is empty. + pub binary: bool, + pub hunks: Vec, +} + +/// The changes of one commit. +#[derive(Debug, Clone)] +pub struct CommitDiff { + pub files: Vec, +} + +/// The changes of the commit `id`, short or full, in the repository at `workdir`. +/// +/// Compared against its first parent, the empty tree for the root commit. +pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result { + commit_diff(&gix::open(workdir)?, id) +} + +fn commit_diff(repo: &gix::Repository, id: &str) -> Result { + let commit_id = repo.rev_parse_single(id.as_bytes())?; + let commit = commit_id.object()?.into_commit(); + let new_tree = commit.tree()?; + let old_tree = match commit.parent_ids().next() { + Some(parent) => Some(parent.object()?.into_commit().tree()?), + None => None, + }; + tree_diff(repo, old_tree.as_ref(), &new_tree) +} + +/// The changes between two commits, `base`..`tip`, like `git diff base tip`. +/// +/// Directories and submodules are skipped, files are sorted by path. +pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result { + let repo = gix::open(workdir)?; + let base_tree = repo + .rev_parse_single(base.as_bytes())? + .object()? + .into_commit() + .tree()?; + let tip_tree = repo + .rev_parse_single(tip.as_bytes())? + .object()? + .into_commit() + .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<'_>>, + new_tree: &gix::Tree<'_>, +) -> Result { + use gix::diff::blob::platform::prepare_diff::Operation; + use gix::object::tree::diff::Change; + use gix::objs::tree::EntryKind; + + let changes = repo.diff_tree_to_tree(old_tree, Some(new_tree), None)?; + + let mut cache = repo.diff_resource_cache_for_tree_diff()?; + let mut files = Vec::new(); + + for change in changes { + let attached = Change::from_change_ref(change.to_ref(), repo, repo); + + // Skip directory trees and submodule gitlinks, only files are listed. + let (path, old_path, status) = match attached { + Change::Addition { + location, + entry_mode, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { + (location.to_owned(), None, DiffStatus::Added) + } + Change::Deletion { + location, + entry_mode, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { + (location.to_owned(), None, DiffStatus::Deleted) + } + Change::Modification { + location, + previous_entry_mode, + entry_mode, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) + && !matches!( + previous_entry_mode.kind(), + EntryKind::Tree | EntryKind::Commit + ) => + { + (location.to_owned(), None, DiffStatus::Modified) + } + Change::Rewrite { + location, + source_location, + source_entry_mode, + entry_mode, + copy, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) + && !matches!( + source_entry_mode.kind(), + EntryKind::Tree | EntryKind::Commit + ) => + { + let status = if copy { + DiffStatus::Copied + } else { + DiffStatus::Renamed + }; + ( + location.to_owned(), + Some(source_location.to_owned()), + status, + ) + } + _ => continue, + }; + + // Always diff with the built-in algorithm. + // External diff drivers would shell out, out of scope for a read-only viewer. + let platform = attached.diff(&mut cache)?; + platform + .resource_cache + .options + .skip_internal_diff_if_external_is_configured = true; + let outcome = platform.resource_cache.prepare_diff()?; + + let (binary, hunks, insertions, deletions) = match outcome.operation { + Operation::InternalDiff { algorithm } => { + let input = outcome.interned_input(); + let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input); + + let mut hunks = Vec::new(); + let mut insertions = 0usize; + let mut deletions = 0usize; + let collector = HunkCollector { + hunks: &mut hunks, + insertions: &mut insertions, + deletions: &mut deletions, + }; + gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default()) + .consume()?; + (false, hunks, insertions, deletions) + } + Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0), + Operation::ExternalCommand { .. } => unreachable!("external diff drivers are disabled"), + }; + + files.push(FileDiff { + path: String::from_utf8_lossy(&path).into_owned(), + old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()), + status, + insertions, + deletions, + binary, + hunks, + }); + } + + files.sort_by(|a, b| a.path.cmp(&b.path)); + + Ok(CommitDiff { files }) +} + +/// Collects the hunks of one blob diff while tracking per-line numbers. +struct HunkCollector<'a> { + hunks: &'a mut Vec, + insertions: &'a mut usize, + deletions: &'a mut usize, +} + +impl ConsumeHunk for HunkCollector<'_> { + type Out = (); + + fn consume_hunk( + &mut self, + header: HunkHeader, + lines: &[(GixLineKind, &[u8])], + ) -> std::io::Result<()> { + let mut old_ln = header.before_hunk_start; + let mut new_ln = header.after_hunk_start; + let mut out = Vec::with_capacity(lines.len()); + + for (kind, content) in lines { + let text = String::from_utf8_lossy(content).into_owned(); + let line = match kind { + GixLineKind::Context => { + let line = DiffLine { + kind: DiffLineKind::Context, + old: Some(old_ln), + new: Some(new_ln), + text, + }; + old_ln += 1; + new_ln += 1; + line + } + GixLineKind::Remove => { + *self.deletions += 1; + let line = DiffLine { + kind: DiffLineKind::Deletion, + old: Some(old_ln), + new: None, + text, + }; + old_ln += 1; + line + } + GixLineKind::Add => { + *self.insertions += 1; + let line = DiffLine { + kind: DiffLineKind::Addition, + old: None, + new: Some(new_ln), + text, + }; + new_ln += 1; + line + } + }; + out.push(line); + } + + self.hunks.push(DiffHunk { + old_start: header.before_hunk_start, + old_lines: header.before_hunk_len, + new_start: header.after_hunk_start, + new_lines: header.after_hunk_len, + lines: out, + }); + + Ok(()) + } + + fn finish(self) {} +} diff --git a/crates/signed_git/src/history.rs b/crates/signed_git/src/history.rs new file mode 100644 index 0000000..731ce32 --- /dev/null +++ b/crates/signed_git/src/history.rs @@ -0,0 +1,267 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +/// In-memory object cache for history walks, see [`open_with_cache`]. +/// +/// Without one, a walk re-decodes the same commit objects from the object database. +/// Sized generously: a walk can cover a large portion of the repository's history. +const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024; + +/// Metadata of a commit, as shown in the repository browser's file header. +#[derive(Debug, Clone)] +pub struct FileCommit { + /// Shortened commit id, 7+ hex chars, disambiguated if needed. + pub id: String, + /// First line of the commit message. + pub summary: String, + /// Rest of the commit message after the title. + /// + /// `None` for single-line commit messages. + pub description: Option, + /// Author name. + pub author: String, + /// Author time, seconds since the Unix epoch. + pub time: i64, +} + +/// Open the repository at `workdir` with an in-memory object cache. +/// +/// Only history walks use it, they re-decode the same commit objects repeatedly. +/// Single-object reads open the repository plain. +pub(crate) fn open_with_cache(workdir: &Path) -> Result { + let mut repo = gix::open(workdir)?; + repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES); + Ok(repo) +} + +/// A [`FileCommit`] from a commit, with author, message title, body and shortened id. +/// +/// The diff panel fetches the full commit on demand. +fn file_commit(commit: &gix::Commit<'_>) -> Result { + file_commit_with_description(commit, true) +} + +/// A [`FileCommit`] without the message body, for history lists that never display it. +/// +/// Skipping the body saves an allocation per listed commit. +fn file_commit_summary(commit: &gix::Commit<'_>) -> Result { + 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, +) -> Result { + let author = commit.author()?; + let message = commit.message()?; + + Ok(FileCommit { + id: commit.id().shorten_or_id().to_string(), + summary: String::from_utf8_lossy(message.title).trim().to_string(), + description: if include_description { + message + .body + .map(|body| String::from_utf8_lossy(body).trim().to_string()) + .filter(|body| !body.is_empty()) + } else { + None + }, + author: String::from_utf8_lossy(author.name).trim().to_string(), + time: author.time()?.seconds, + }) +} + +/// Find the most recent commit that changed `rel`, a path relative to the worktree. +/// +/// `Ok(None)` when no commit touched the file, e.g. an untracked file. +pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result> { + let rel = rel.to_path_buf(); + Ok(last_commits(repo, std::slice::from_ref(&rel))? + .into_iter() + .next() + .map(|(_, commit)| commit)) +} + +/// Newest commit touching each of `rels`, like `git log -1 -- ` per path. +/// `rels` are paths relative to the worktree. +/// +/// Paths without any commit, like untracked files, are absent from the result. +pub fn worktree_last_commits( + workdir: &Path, + rels: &[PathBuf], +) -> Result> { + 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> { + use gix::traverse::commit::simple::CommitTimeOrder; + + let Some(head) = repo.head_id().ok() else { + return Ok(Vec::new()); + }; + + // De-duplicate while preserving order. + let mut pending: Vec = Vec::with_capacity(rels.len()); + let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len()); + + for rel in rels { + if seen.insert(rel.as_path()) { + pending.push(rel.clone()); + } + } + + let walk = repo + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + CommitTimeOrder::NewestFirst, + )); + + let mut found = Vec::new(); + for info in walk.all()? { + if pending.is_empty() { + break; + } + let info = info?; + let commit = info.object()?; + let tree = commit.tree()?; + let parent_tree = match info.parent_ids().next() { + Some(parent) => Some(parent.object()?.into_commit().tree()?), + None => None, + }; + + // 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]; + let blob = tree.lookup_entry_by_path(rel)?; + let parent_blob = match &parent_tree { + Some(tree) => tree.lookup_entry_by_path(rel)?, + None => None, + }; + + if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach()) + { + found.push((rel.clone(), file_commit(&commit)?)); + pending.swap_remove(ix); + } else { + ix += 1; + } + } + } + + Ok(found) +} + +/// Cap on [`CommitList::commits`]. The virtual list renders a window at a time, +/// the tab badge shows the real count. +/// +/// A huge history is never fully materialized in memory. +pub const MAX_LISTED_COMMITS: usize = 20_000; + +/// Commits reachable from `HEAD`, newest first, possibly capped. +pub struct CommitList { + /// Number of commits reachable from HEAD. + pub total: usize, + /// Newest commits, capped at [`MAX_LISTED_COMMITS`]. + pub commits: Vec, +} + +/// All commits reachable from `HEAD`, newest first, with author and summary. +/// +/// Returns an empty list for a repository without any commits yet. +pub fn all_commits(repo: &gix::Repository) -> Result { + use gix::traverse::commit::simple::CommitTimeOrder; + + let Some(head) = repo.head_id().ok() else { + return Ok(CommitList { + total: 0, + commits: Vec::new(), + }); + }; + + let walk = repo + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + CommitTimeOrder::NewestFirst, + )); + + let mut commits = Vec::new(); + let mut total = 0; + + for info in walk.all()? { + let info = info?; + total += 1; + if commits.len() < MAX_LISTED_COMMITS { + commits.push(file_commit_summary(&info.object()?)?); + } + } + + 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 { + all_commits(&open_with_cache(workdir)?) +} + +/// Commits in the range `base`..`tip`, newest first, like `git log base..tip`. +pub fn worktree_commit_range_commits( + workdir: &Path, + base: &str, + tip: &str, +) -> Result> { + use gix::traverse::commit::simple::CommitTimeOrder; + + let repo = open_with_cache(workdir)?; + let base_id = repo.rev_parse_single(base.as_bytes())?; + let tip_id = repo.rev_parse_single(tip.as_bytes())?; + let walk = repo + .rev_walk([tip_id]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + CommitTimeOrder::NewestFirst, + )) + .with_hidden([base_id]); + + let mut commits = Vec::new(); + + for info in walk.all()? { + let info = info?; + commits.push(file_commit_summary(&info.object()?)?); + } + + Ok(commits) +} +/// The commit HEAD points to, like `git log -1`. +/// +/// `Ok(None)` for a repository without commits yet, an unborn HEAD. +pub fn head_commit(repo: &gix::Repository) -> Result> { + let Some(head) = repo.head_id().ok() else { + return Ok(None); + }; + let commit = head.object()?.into_commit(); + Ok(Some(file_commit(&commit)?)) +} + +/// Full metadata of the commit `id`, short or full, in the repository at `workdir`. +/// Like [`head_commit`] for an arbitrary commit. +/// +/// `Ok(None)` when the id cannot be resolved. +pub fn worktree_commit(workdir: &Path, id: &str) -> Result> { + let repo = gix::open(workdir)?; + match repo.rev_parse_single(id.as_bytes()) { + Ok(commit_id) => { + let commit = commit_id.object()?.into_commit(); + Ok(Some(file_commit(&commit)?)) + } + Err(_) => Ok(None), + } +} diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index fc81b8d..bb92b84 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -1,1036 +1,52 @@ -use std::collections::{HashMap, HashSet}; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; - -use anyhow::{Context, Result, bail}; -use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet}; -use diffy::{Hunk, Line}; -use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader}; -use gix::interrupt::IS_INTERRUPTED; -use gix::progress::Discard; -use signed_core::{Announcement, RepoAddr}; - -/// On-disk cache of cloned repositories, keyed by owner pubkey / repo id. -#[derive(Debug, Clone)] -pub struct GitCache { - root: PathBuf, -} - -impl GitCache { - pub fn new(root: PathBuf) -> Self { - 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> { - let path = self.repo_path(addr); - match gix::open(&path) { - Ok(repo) => Ok(Some(repo)), - Err(gix::open::Error::NotARepository { .. }) => Ok(None), - Err(gix::open::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(e.into()), - } - } - - /// Open the existing clone, fetching it first. - pub fn ensure_clone>( - &self, - addr: &RepoAddr, - clone_urls: &[U], - ) -> Result { - let path = self.repo_path(addr); - - if let Some(repo) = self.open(addr)? { - fetch_all(&repo).ok(); - return Ok(repo); - } - - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - } - - clone_repo(clone_urls, &path)?; - self.open(addr)? - .ok_or_else(|| anyhow::anyhow!("clone finished but the repository cannot be opened")) - } -} - -/// Maximum directory nesting depth when scanning for local repositories. -/// -/// Pathological trees can't stall the scan. -const SCAN_MAX_DEPTH: usize = 12; - -/// Directories never descended into during a scan. -/// -/// Dependency caches can be enormous without ever containing user repositories. -const SCAN_SKIPPED_DIR: &str = "node_modules"; - -/// Walk `root` recursively and collect the paths of git repositories below it. -pub fn find_git_repos(root: &Path) -> Vec { - let mut repos = Vec::new(); - if !root.is_dir() { - return repos; - } - - let mut stack = vec![(root.to_path_buf(), 0usize)]; - while let Some((dir, depth)) = stack.pop() { - if depth > SCAN_MAX_DEPTH { - continue; - } - // A directory containing a `.git` entry is a repository. - // A linked worktree has a `.git` file instead of a directory. - // Don't descend into repositories. - if dir.join(".git").exists() { - if let Ok(path) = dir.canonicalize() { - repos.push(path); - } - continue; - } - - let Ok(entries) = std::fs::read_dir(&dir) else { - continue; - }; - for entry in entries.flatten() { - let Ok(file_type) = entry.file_type() else { - continue; - }; - if !file_type.is_dir() || file_type.is_symlink() { - continue; - } - let file_name = entry.file_name(); - let name = file_name.to_string_lossy(); - if name.starts_with('.') || name == SCAN_SKIPPED_DIR { - continue; - } - stack.push((entry.path(), depth + 1)); - } - } - - repos.sort(); - repos.dedup(); - repos -} - -/// Clone into `path` from the first working URL in `clone_urls`. -/// -/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache. -pub fn clone_repo>(clone_urls: &[U], path: &Path) -> Result<()> { - if path.exists() { - bail!("destination {} already exists", path.display()); - } - - try_each_url(clone_urls, "clone", |url| { - let repo = clone(url, path)?; - // The initial clone uses the default refspecs. - // Also fetch the `refs/nostr/*` PR refs. - fetch_all(&repo).ok(); - Ok(()) - }) -} - -/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace. -pub fn fetch_all(repo: &gix::Repository) -> Result<()> { - let options = gix::remote::ref_map::Options { - extra_refspecs: vec![ - gix::refspec::parse( - gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"), - gix::refspec::parse::Operation::Fetch, - )? - .to_owned(), - ], - ..Default::default() - }; - repo.find_remote("origin")? - .connect(gix::remote::Direction::Fetch)? - .prepare_fetch(Discard, options)? - .receive(Discard, &IS_INTERRUPTED)?; - Ok(()) -} - -/// Apply a `git format-patch` patch or series with `git am`, -/// uses the git CLI because it handles the mbox format natively. -/// -/// TODO: Replaced 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") - .current_dir(repo_path) - .stdin(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .context("failed to spawn `git am`")?; - - child - .stdin - .as_mut() - .expect("stdin piped") - .write_all(patch.as_bytes())?; - - let output = child.wait_with_output()?; - if !output.status.success() { - bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr)); - } - Ok(()) -} - -/// The merge base of two revisions in the repository at `repo_path`, -/// revisions may be branch names, remote-tracking refs or commit ids. -/// -/// `Ok(None)` when the revisions share no common ancestor. -/// -/// Unresolvable revisions are errors. -pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result> { - let repo = open_with_cache(repo_path)?; - let a = repo.rev_parse_single(a.as_bytes())?; - let b = repo.rev_parse_single(b.as_bytes())?; - match repo.merge_base(a, b) { - Ok(id) => Ok(Some(id.to_string())), - // No common ancestor, a valid outcome for a proposal. - Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None), - Err(e) => Err(e.into()), - } -} - -/// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`. -/// Fails when the range has no commits. -/// -/// The mbox is returned untrimmed. Trailing newlines are part of the format. -pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result { - let output = Command::new("git") - .arg("-C") - .arg(repo_path) - .args(["format-patch", "--stdout", &format!("{base}..{tip}")]) - .env("GIT_TERMINAL_PROMPT", "0") - .stderr(Stdio::piped()) - .output() - .context("failed to spawn `git format-patch`")?; - - if !output.status.success() { - bail!( - "git format-patch failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - let patch = String::from_utf8_lossy(&output.stdout).into_owned(); - if patch.trim().is_empty() { - bail!("no commits between {base} and {tip}"); - } - Ok(patch) -} - -/// 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") - .arg(repo_path) - .args(["push"]) - .arg(url) - .arg(format!("{commit}:{reference}")) - .env("GIT_TERMINAL_PROMPT", "0") - .stderr(Stdio::piped()) - .output() - .context("failed to spawn `git push`")?; - - if !output.status.success() { - bail!( - "git push failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - Ok(()) -} - -/// Split a `git format-patch` series into its individual patches, mbox messages. -/// -/// A single patch yields one element. -/// A malformed input yields one element covering it. -pub fn split_patch_series(patch: &str) -> Vec<&str> { - let mut starts = vec![0usize]; - let mut search_from = 1; - while let Some(rel) = patch[search_from..].find("\nFrom ") { - let ix = search_from + rel + 1; - let hex = patch[ix + 5..] - .split(|c: char| !c.is_ascii_hexdigit()) - .next() - .unwrap_or(""); - if hex.len() == 40 { - starts.push(ix); - } - search_from = ix + 1; - } - - starts - .iter() - .enumerate() - .map(|(i, &start)| { - let end = starts.get(i + 1).copied().unwrap_or(patch.len()); - &patch[start..end] - }) - .collect() -} - -/// The commit HEAD points to in the repository at `repo_path`. -/// -/// `None` when the repository has no commits yet, an unborn HEAD. -pub fn head_commit_id(repo_path: &Path) -> Result> { - let Ok(repo) = gix::open(repo_path) else { - return Ok(None); - }; - - match repo.head_id() { - Ok(id) => Ok(Some(id.to_string())), - Err(_) => Ok(None), - } -} - -/// The commits in `base..HEAD` of the repository at `repo_path`, oldest first. -/// This is the order `git am` creates them. -/// -/// `HEAD` alone when `base` is `None`. -pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result> { - let repo = match gix::open(repo_path) { - Ok(repo) => repo, - Err(_) if base.is_none() => return Ok(Vec::new()), - Err(e) => return Err(e.into()), - }; - - let head = match repo.head_id() { - Ok(head) => head, - Err(_) if base.is_none() => return Ok(Vec::new()), - Err(e) => return Err(e).context("repository has no commits"), - }; - - let Some(base) = base else { - // `HEAD` alone when no base is given. - return Ok(vec![head.to_string()]); - }; - - let base = repo.rev_parse_single(base.as_bytes())?; - let mut commits = Vec::new(); - - for info in repo - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, - )) - .with_hidden([base]) - .all()? - { - commits.push(info?.id().to_string()); - } - - // Oldest first, like `git rev-list --reverse`, the order `git am` creates them. - commits.reverse(); - - Ok(commits) -} - -/// Rewrite a grasp server URL to the https URL the git transport actually uses. -/// -/// GRASP servers announce `grasp:////` clone URLs. -/// The transport is git smart HTTP, so the scheme is rewritten for gix. -fn transport_url(url: &str) -> String { - url.strip_prefix("grasp://") - .map(|rest| format!("https://{rest}")) - .unwrap_or_else(|| url.to_owned()) -} - -/// Run `attempt` against each URL in `urls` until one succeeds. -/// -/// Returns the last error wrapped in `failed to {verb} from any mirror`, -/// or `no clone URLs provided` when the list is empty. -fn try_each_url, F>(urls: &[U], verb: &str, mut attempt: F) -> Result<()> -where - F: FnMut(&str) -> Result<()>, -{ - let mut last_err: Option = None; - - for url in urls { - match attempt(url.as_ref()) { - Ok(()) => return Ok(()), - Err(e) => last_err = Some(e), - } - } - - match last_err { - Some(e) => Err(e).context(format!("failed to {verb} from any mirror")), - None => bail!("no clone URLs provided"), - } -} - -fn clone(url: &str, path: &Path) -> Result { - let url = transport_url(url); - let url = gix::url::parse(url).context("invalid clone URL")?; - - let mut prepare = gix::prepare_clone(url, path)?; - let (mut checkout, _fetch) = prepare.fetch_then_checkout(Discard, &IS_INTERRUPTED)?; - let (repo, _checkout) = checkout.main_worktree(Discard, &IS_INTERRUPTED)?; - - Ok(repo) -} - -/// The identity written to reflogs and commits created by this crate itself. -/// -/// Like `git -c user.name=… -c user.email=…` per invocation: the repository works -/// without a global git identity, and `gix` runs no hooks and never signs. -fn repository_signature() -> (gix::actor::Signature, gix::date::parse::TimeBuf) { - let seconds = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_secs() as i64) - .unwrap_or_default(); - let signature = gix::actor::Signature { - name: gix::bstr::BString::from("Signed"), - email: gix::bstr::BString::from("signed@localhost"), - time: gix::date::Time { seconds, offset: 0 }, - }; - (signature, gix::date::parse::TimeBuf::default()) -} - -/// Create a repository at `path` with an initial `main` branch. -/// Write a `README.md` from `name` and `description`, then create the initial commit. -/// -/// Returns the initial commit id. -pub fn init_repository(path: &Path, name: &str, description: &str) -> Result { - use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; - - std::fs::create_dir_all(path) - .with_context(|| format!("failed to create {}", path.display()))?; - - let repo = gix::init(path)?; - - let (signature, mut time_buf) = repository_signature(); - let signature = signature.to_ref(&mut time_buf); - - // The initial branch is `main`, regardless of `init.defaultBranch` in - // the user's git configuration: point the unborn HEAD there. - let head = gix::refs::FullName::try_from("HEAD") - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - - repo.edit_references_as( - [RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: "checkout: moving to main".into(), - }, - expected: PreviousValue::Any, - new: gix::refs::Target::Symbolic( - gix::refs::FullName::try_from("refs/heads/main") - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?, - ), - }, - name: head, - deref: false, - }], - Some(signature), - )?; - - let readme = if description.trim().is_empty() { - format!("# {name}\n") - } else { - format!("# {name}\n\n{description}\n") - }; - - std::fs::write(path.join("README.md"), &readme).context("failed to write README.md")?; - - let blob = repo.write_object(gix::objs::Blob { - data: readme.into_bytes(), - })?; - - let tree = repo.write_object(gix::objs::Tree { - entries: vec![gix::objs::tree::Entry { - mode: gix::objs::tree::EntryKind::Blob.into(), - filename: gix::bstr::BString::from("README.md"), - oid: blob.into(), - }], - })?; - - let commit = repo.commit_as( - signature, - signature, - "HEAD", - "Initial commit", - tree, - Vec::::new(), - )?; - - // Populate the index so the fresh repository is clean, - // as `git add` and`git commit` would leave it. - let mut index = repo.index_from_tree(&tree)?; - index.write(gix::index::write::Options::default())?; - - let commit = commit.to_string(); - if commit.len() != 40 { - bail!("unexpected initial commit id: {commit}"); - } - - Ok(commit) -} - -/// 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, - base_url, - owner, - repo_id, - &["refs/heads/main:refs/heads/main"], - ) -} - -/// Push every local branch and tag of the repository at `repo_path` to a grasp server. -/// -/// This mirrors an initialized repository's whole history. -pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { - push_refspecs( - repo_path, - base_url, - owner, - repo_id, - &["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], - ) -} - -/// Push `refspecs` to the grasp server URL derived from `base_url`, `owner` and `repo_id`. -fn push_refspecs( - repo_path: &Path, - base_url: &str, - owner: &str, - repo_id: &str, - refspecs: &[&str], -) -> Result<()> { - let url = format!("{base_url}/{owner}/{repo_id}.git"); - - let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2); - args.push("push"); - args.push(&url); - args.extend_from_slice(refspecs); - - let output = git_output(repo_path, &args, "git push")?; - - if !output.status.success() { - bail!( - "git push to {base_url} failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - Ok(()) -} - -/// Whether `url` advertises every ref in `expected` at the given commit. -/// -/// Extra advertised refs are ignored: the question is whether the data this -/// push wanted to land is already there, not whether the remote is an exact mirror. -/// This is the convergence probe for a push that lost the compare-and-swap race -/// to the grasp server's own background ref alignment. -pub fn remote_has_refs(repo_path: &Path, url: &str, expected: &[(String, String)]) -> Result { - if expected.is_empty() { - return Ok(true); - } - - let repo = gix::open(repo_path)?; - let url = transport_url(url); - - // A URL-created remote has no configured fetch refspecs, and `ref_map` only - // keeps refs that match one. Match each expected ref by its exact name, like - // `git ls-remote ` would; ref maps never write to the repository. - let refspecs = expected - .iter() - .map(|(name, _)| { - gix::refspec::parse( - gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()), - gix::refspec::parse::Operation::Fetch, - ) - .map(|spec| spec.to_owned()) - }) - .collect::, _>>() - .context("invalid refspec")?; - - let options = gix::remote::ref_map::Options { - extra_refspecs: refspecs, - ..Default::default() - }; - - let (refs, _) = repo - .remote_at(url.as_str()) - .with_context(|| format!("cannot use remote {url}"))? - .connect(gix::remote::Direction::Fetch) - .with_context(|| format!("cannot connect to {url}"))? - .ref_map(Discard, options) - .with_context(|| format!("listing refs of {url} failed"))?; - - // Peeled tag entries carry the tag object in their direct oid, so mapping - // each advertised ref to its direct oid matches `git ls-remote` while - // skipping the duplicated `^{}` lines. - let advertised: HashMap = refs - .remote_refs - .iter() - .filter_map(|reference| { - let (name, object, _peeled) = reference.unpack(); - object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string())) - }) - .collect(); - - Ok(expected - .iter() - .all(|(name, oid)| advertised.get(name.as_str()) == Some(oid))) -} - -/// The earliest unique commit of the repository at `repo_path`. -/// Used as the NIP-34 announcement's `euc` marker. -/// -/// `None` for a repository without commits. -pub fn root_commit(repo_path: &Path) -> Result> { - let Ok(repo) = gix::open(repo_path) else { - return Ok(None); - }; - - let Ok(head) = repo.head_id() else { - // An unborn HEAD with no commits yet has no root commit. - return Ok(None); - }; - - for info in repo - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, - )) - .all()? - { - let info = info?; - if info.parent_ids().next().is_none() { - let id = info.id().to_string(); - return Ok((id.len() == 40).then_some(id)); - } - } - - Ok(None) -} - -/// Add `origin` pointing at `url` when the repository has no remote yet. -/// -/// No-op if `origin` already exists. -pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> { - let repo = gix::open(repo_path)?; - if repo.find_remote("origin").is_ok() { - return Ok(()); - } - - // `git remote add` also configures the default fetch refspec. - edit_local_config(&repo, |config| { - config.set_raw_value("remote.origin.url", url)?; - config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; - Ok(()) - }) -} - -/// Point `origin` at `url`, replacing an existing remote, -/// used after a clone whose `origin` points at the cloned-from path. -/// -/// A working copy cloned from a local mirror is re-targeted at the grasp server. -pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> { - let repo = gix::open(repo_path)?; - let had_origin = repo.find_remote("origin").is_ok(); - - edit_local_config(&repo, |config| { - // Replaces the existing url, like `git remote set-url origin `. - // A pre-existing fetch refspec is left untouched. - config.set_raw_value("remote.origin.url", url)?; - - if !had_origin { - config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; - } - - Ok(()) - }) -} - -/// Apply `edit` to the repository-local configuration and persist it. -/// -/// The config file is locked while it is read, edited and written back, -/// like git would when running `git config` or `git remote`. -fn edit_local_config( - repo: &gix::Repository, - edit: impl FnOnce(&mut gix::config::File) -> Result<()>, -) -> Result<()> { - let config_path = repo.common_dir().join("config"); - - let mut lock = gix::lock::File::acquire_to_update_resource( - &config_path, - gix::lock::acquire::Fail::Immediately, - None, - ) - .context("failed to lock repository config")?; - - let mut config = - match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local) { - Ok(config) => config, - // A repository without a config file yet starts from scratch. - Err(gix::config::file::init::from_paths::Error::Io { source, .. }) - if source.kind() == std::io::ErrorKind::NotFound => - { - gix::config::File::default() - } - Err(error) => return Err(error).context("failed to read repository config"), - }; - - edit(&mut config)?; - - config - .write_to(&mut lock) - .context("failed to write repository config")?; - - lock.commit().context("failed to save repository config")?; - - Ok(()) -} - -/// Fetch `refspec` into `repo_path` from the first working URL in `urls`. -/// When no URL works, the last error is returned. -/// -/// Never touches the checked-out refs or the worktree. -pub fn fetch_repo_refs>(repo_path: &Path, urls: &[U], refspec: &str) -> Result<()> { - let repo = gix::open(repo_path)?; - let refspec = gix::refspec::parse( - gix::bstr::BStr::new(refspec), - gix::refspec::parse::Operation::Fetch, - ) - .context("invalid fetch refspec")? - .to_owned(); - - try_each_url(urls, "fetch", |url| { - let url = transport_url(url); - let options = gix::remote::ref_map::Options { - extra_refspecs: vec![refspec.clone()], - ..Default::default() - }; - repo.remote_at(url.as_str()) - .with_context(|| format!("fetch from {url} failed"))? - .connect(gix::remote::Direction::Fetch) - .with_context(|| format!("fetch from {url} failed"))? - .prepare_fetch(Discard, options) - .with_context(|| format!("fetch from {url} failed"))? - .receive(Discard, &IS_INTERRUPTED) - .with_context(|| format!("fetch from {url} failed"))?; - Ok(()) - }) -} - -/// Full ref names under `prefix`, sorted lexicographically, like `git for-each-ref`. -/// `prefix` is a ref namespace like `refs/fork//`. -/// -/// Returns an empty list when nothing matches. -pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result> { - let pattern = prefix.trim_end_matches('/'); - let repo = gix::open(repo_path)?; - let mut names = Vec::new(); - - for reference in repo.references()?.all()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - let name = String::from_utf8_lossy(reference.name().as_bstr()).into_owned(); - - // Match the pattern itself and everything beneath it, like `git for-each-ref`. - let under_pattern = name - .strip_prefix(pattern) - .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')); - - if under_pattern { - names.push(name); - } - } - - // Sort lexicographically, like `git for-each-ref`. - names.sort(); - - Ok(names) -} - -/// Delete every ref under `prefix` of the repository at `repo_path`. -/// `prefix` is a ref namespace like `refs/fork//`. -pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> { - use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog}; - - let refs = refs_with_prefix(repo_path, prefix)?; - if refs.is_empty() { - return Ok(()); - } - - let repo = gix::open(repo_path)?; - let edits: Vec = refs - .iter() - .map(|name| { - let full = gix::refs::FullName::try_from(name.as_str()) - .map_err(|e| anyhow::anyhow!("invalid ref name {name}: {e}"))?; - Ok(RefEdit { - change: Change::Delete { - expected: PreviousValue::Any, - log: RefLog::AndReference, - }, - name: full, - deref: false, - }) - }) - .collect::>>()?; - - // Delete all refs with the given prefix. - repo.edit_references(edits)?; - - Ok(()) -} - -/// The URL of the `origin` remote of the repository at `workdir`. -/// -/// `None` when it has no `origin` yet. -pub fn origin_url(workdir: &Path) -> Result> { - let Ok(repo) = gix::open(workdir) else { - return Ok(None); - }; - - let Ok(remote) = repo.find_remote("origin") else { - return Ok(None); - }; - - Ok(remote - .url(gix::remote::Direction::Fetch) - .map(|url| url.to_string())) -} - -/// Whether the worktree of `workdir` has uncommitted changes. -/// -/// Best-effort: any read failure is reported as clean. -pub fn worktree_dirty(workdir: &Path) -> bool { - let Ok(repo) = gix::open(workdir) else { - return false; - }; - - // Changes to tracked files, staged or not; untracked files are excluded. - match repo.is_dirty() { - Ok(true) => return true, - Ok(false) => {} - Err(_) => return false, - } - - // Untracked files surface as `DirectoryContents` items of the index-vs-worktree walk, - // tracked files only appear there when modified. - let Ok(platform) = repo.status(Discard) else { - return false; - }; - - let Ok(mut changes) = platform.into_index_worktree_iter(Vec::::new()) - else { - return false; - }; - - for change in changes.by_ref() { - match change { - Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true, - Ok(_) => {} - Err(_) => return false, - } - } - - false -} - -/// Commits in `base..branch` of the checkout at `workdir`. -/// -/// Best-effort: 0 when the range cannot be computed. -pub fn worktree_commits_ahead(workdir: &Path, base: &str, branch: &str) -> u32 { - let Ok(repo) = gix::open(workdir) else { - return 0; - }; - - let (Some(base), Some(branch)) = (resolve_commit(&repo, base), resolve_commit(&repo, branch)) - else { - return 0; - }; - - let Ok(walk) = repo.rev_walk([branch]).with_hidden([base]).all() else { - return 0; - }; - - walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32 -} - -/// Resolve `rev` to a commit id, accepting full refs, -/// symbolic refs and the bare branch names callers pass, like git's DWIM. -fn resolve_commit<'a>(repo: &'a gix::Repository, rev: &str) -> Option> { - if let Ok(id) = repo.rev_parse_single(rev.as_bytes()) { - return Some(id); - } - - // Branch names arrive bare, like git resolving `main`. - if rev.contains('/') { - return None; - } - - repo.rev_parse_single(format!("refs/heads/{rev}").as_bytes()) - .ok() -} - -/// Short name of the branch HEAD points to at `workdir`, -/// `None` when detached or unreadable, like `git branch --show-current`. -pub fn worktree_current_branch(workdir: &Path) -> Option { - let repo = gix::open(workdir).ok()?; - let head = repo.head().ok()?; - let name = head.referent_name()?; - 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; - }; - repo.find_reference(name).is_ok() -} - -/// Fast-forward local branches that trail their remote-tracking counterpart. -/// -/// Returns whether any branch moved. -pub fn fast_forward_branches(workdir: &Path) -> Result { - let repo = gix::open(workdir)?; - let current = worktree_current_branch(workdir); - let heads = refs_with_prefix(workdir, "refs/heads")?; - - let (signature, mut time_buf) = repository_signature(); - let signature = signature.to_ref(&mut time_buf); - - let mut moved = false; - - for head in heads { - let Some(branch) = head.strip_prefix("refs/heads/") else { - continue; - }; - - let remote = format!("refs/remotes/origin/{branch}"); - // No remote-tracking counterpart means the remote lacks this branch. - let Ok(mut remote_reference) = repo.find_reference(&remote) else { - continue; - }; - - let Ok(mut local_reference) = repo.find_reference(&head) else { - continue; - }; - - let Ok(remote_oid) = remote_reference.peel_to_id() else { - continue; - }; - - let Ok(local_oid) = local_reference.peel_to_id() else { - continue; - }; - - let remote_oid = remote_oid.detach(); - let local_oid = local_oid.detach(); - - if local_oid == remote_oid { - continue; - } - - // Only fast-forward. - // Local-only commits or diverged history must never be rewritten by a refresh. - let Ok(base) = repo.merge_base(local_oid, remote_oid) else { - continue; - }; - - if base != local_oid { - continue; - } - - let full = gix::refs::FullName::try_from(head.as_str()) - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - - let edit = |new: gix::refs::Target| { - use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; - RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: format!("merge {remote}: Fast-forward").into(), - }, - expected: PreviousValue::ExistingMustMatch(gix::refs::Target::Object( - local_oid, - )), - new, - }, - name: full.clone(), - deref: false, - } - }; - - if current.as_deref() == Some(branch) { - // Merge so the checked-out worktree follows the branch. - // Only proceed on a clean worktree, like `git merge --ff-only`. - if worktree_dirty(workdir) { - continue; - } - - 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), - )?; - - 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), - )?; - - moved = true; - } - } - - Ok(moved) -} - -/// Run `git -C dir args`, disabling the terminal prompt and capturing stderr. -/// -/// `what` names the command in the spawn error. -fn git_output(dir: &Path, args: &[&str], what: &str) -> Result { - Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .env("GIT_TERMINAL_PROMPT", "0") - .stderr(Stdio::piped()) - .output() - .with_context(|| format!("failed to spawn `{what}`")) -} +mod cache; +mod diff; +mod history; +mod patch; +mod remote; +mod repo; +mod scan; +mod worktree; + +#[cfg(test)] +mod tests; + +pub use cache::{GitCache, fork_namespace, sanitize_path_component}; +pub use diff::{ + CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff, worktree_commit_diff, + worktree_commit_range_diff, +}; +pub use history::{ + CommitList, FileCommit, MAX_LISTED_COMMITS, all_commits, head_commit, last_commit, + worktree_all_commits, worktree_commit, worktree_commit_range_commits, worktree_last_commits, +}; +pub use patch::{ + apply_patch, format_patch_between, patch_commits, patch_diffs, split_patch_series, +}; +pub use remote::{ + clone_repo, ensure_origin, fetch_all, fetch_repo_refs, origin_url, push_all, push_commit_ref, + push_main, remote_has_refs, set_origin, +}; +pub use repo::{ + RepoRefState, commits_since, current_branch, delete_refs_with_prefix, fast_forward_branches, + head_commit_id, init_repository, merge_base, refs_with_prefix, repo_branches, repo_ref_state, + repo_tags, root_commit, worktree_branches, worktree_current_branch, worktree_ref_exists, + worktree_ref_state, +}; +pub use scan::find_git_repos; +pub use worktree::{ + WorktreeSnapshot, find_readme, worktree_checkout_branch, worktree_checkout_tag, + 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: &Path, args: &[&str]) -> Result { - let output = git_output(dir, args, "git")?; +fn git_in(dir: &std::path::Path, args: &[&str]) -> anyhow::Result { + let output = remote::git_output(dir, args, "git")?; if !output.status.success() { - bail!( + anyhow::bail!( "git {} failed: {}", args.join(" "), String::from_utf8_lossy(&output.stderr).trim() @@ -1039,2962 +55,3 @@ fn git_in(dir: &Path, args: &[&str]) -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) } - -/// 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() - .map(|c| { - if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { - c - } else { - '_' - } - }) - .collect(); - - if sanitized == "." || sanitized == ".." { - return "_".to_owned(); - } - - sanitized -} - -/// The refs namespace of a fork's import in the target mirror. -pub fn fork_namespace(announcement: &Announcement) -> String { - format!( - "{}/{}", - announcement.owner.to_hex(), - sanitize_path_component(&announcement.id) - ) -} - -/// In-memory object cache for history walks, see [`open_with_cache`]. -/// -/// Without one, a walk re-decodes the same commit objects from the object database. -/// Sized generously: a walk can cover a large portion of the repository's history. -const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024; - -/// Metadata of a commit, as shown in the repository browser's file header. -#[derive(Debug, Clone)] -pub struct FileCommit { - /// Shortened commit id, 7+ hex chars, disambiguated if needed. - pub id: String, - /// First line of the commit message. - pub summary: String, - /// Rest of the commit message after the title. - /// `None` for single-line commit messages. - pub description: Option, - /// Author name. - pub author: String, - /// Author time, seconds since the Unix epoch. - pub time: i64, -} - -/// Relative paths of all entries in the worktree, files and directories. -/// -/// The `.git` directory is skipped. -pub fn worktree_entries(repo: &gix::Repository) -> Result> { - let workdir = repo.workdir().context("repository has no worktree")?; - - let mut entries: Vec<(PathBuf, bool)> = Vec::new(); - collect_entries(workdir, workdir, &mut entries)?; - - entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| { - b_is_dir - .cmp(a_is_dir) - .then_with(|| a.as_os_str().cmp(b.as_os_str())) - }); - Ok(entries.into_iter().map(|(path, _)| path).collect()) -} - -/// Read a file from the worktree. -/// -/// Returns `Ok(None)` if the path is missing or not a regular file. -pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result>> { - let workdir = repo.workdir().context("repository has no worktree")?; - let path = workdir.join(rel); - - match std::fs::read(&path) { - Ok(bytes) => Ok(Some(bytes)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None), - Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), - } -} - -/// Find the README file in the repository root. -/// -/// Falls back to any other file whose name starts with `readme`. -pub fn find_readme(repo: &gix::Repository) -> Result> { - let Some(workdir) = repo.workdir() else { - return Ok(None); - }; - - let mut candidates: Vec = Vec::new(); - for entry in std::fs::read_dir(workdir)? { - let entry = entry?; - let name = entry.file_name(); - let Some(name) = name.to_str() else { continue }; - if name.to_ascii_lowercase().starts_with("readme") { - candidates.push(entry.path()); - } - } - - candidates.sort_by_key(|path| { - let ext = path - .extension() - .map(|e| e.to_string_lossy().to_ascii_lowercase()); - match ext.as_deref() { - Some("md") => 0, - Some("markdown") => 1, - Some("mdown") => 2, - Some("mkdn") => 3, - Some(_) => 5, - None => 4, - } - }); - - Ok(candidates - .into_iter() - .next() - .and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf))) -} - -/// Open the repository at `workdir` with an in-memory object cache. -/// -/// Only history walks use it, they re-decode the same commit objects repeatedly. -/// Single-object reads open the repository plain. -fn open_with_cache(workdir: &Path) -> Result { - let mut repo = gix::open(workdir)?; - repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES); - Ok(repo) -} - -/// A [`FileCommit`] from a commit, with author, message title, body and shortened id. -/// -/// The diff panel fetches the full commit on demand. -fn file_commit(commit: &gix::Commit<'_>) -> Result { - file_commit_with_description(commit, true) -} - -/// A [`FileCommit`] without the message body, for history lists that never display it. -/// -/// Skipping the body saves an allocation per listed commit. -fn file_commit_summary(commit: &gix::Commit<'_>) -> Result { - 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, -) -> Result { - let author = commit.author()?; - let message = commit.message()?; - Ok(FileCommit { - id: commit.id().shorten_or_id().to_string(), - summary: String::from_utf8_lossy(message.title).trim().to_string(), - description: if include_description { - message - .body - .map(|body| String::from_utf8_lossy(body).trim().to_string()) - .filter(|body| !body.is_empty()) - } else { - None - }, - author: String::from_utf8_lossy(author.name).trim().to_string(), - time: author.time()?.seconds, - }) -} - -/// Find the most recent commit that changed `rel`, a path relative to the worktree. -/// -/// `Ok(None)` when no commit touched the file, e.g. an untracked file. -pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result> { - let rel = rel.to_path_buf(); - Ok(last_commits(repo, std::slice::from_ref(&rel))? - .into_iter() - .next() - .map(|(_, commit)| commit)) -} - -/// Newest commit touching each of `rels`, like `git log -1 -- ` per path. -/// `rels` are paths relative to the worktree. -/// -/// Paths without any commit, like untracked files, are absent from the result. -pub fn worktree_last_commits( - workdir: &Path, - rels: &[PathBuf], -) -> Result> { - 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> { - use gix::traverse::commit::simple::CommitTimeOrder; - - let Some(head) = repo.head_id().ok() else { - return Ok(Vec::new()); - }; - - // De-duplicate while preserving order. - let mut pending: Vec = Vec::with_capacity(rels.len()); - let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len()); - for rel in rels { - if seen.insert(rel.as_path()) { - pending.push(rel.clone()); - } - } - - let walk = repo - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - CommitTimeOrder::NewestFirst, - )); - - let mut found = Vec::new(); - for info in walk.all()? { - if pending.is_empty() { - break; - } - let info = info?; - let commit = info.object()?; - let tree = commit.tree()?; - let parent_tree = match info.parent_ids().next() { - Some(parent) => Some(parent.object()?.into_commit().tree()?), - None => None, - }; - - // 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]; - let blob = tree.lookup_entry_by_path(rel)?; - let parent_blob = match &parent_tree { - Some(tree) => tree.lookup_entry_by_path(rel)?, - None => None, - }; - - if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach()) - { - found.push((rel.clone(), file_commit(&commit)?)); - pending.swap_remove(ix); - } else { - ix += 1; - } - } - } - - Ok(found) -} - -/// Cap on [`CommitList::commits`]. The virtual list renders a window at a time, -/// the tab badge shows the real count. -/// -/// A huge history is never fully materialized in memory. -pub const MAX_LISTED_COMMITS: usize = 20_000; - -/// Commits reachable from `HEAD`, newest first, possibly capped. -pub struct CommitList { - /// Number of commits reachable from HEAD. - pub total: usize, - /// Newest commits, capped at [`MAX_LISTED_COMMITS`]. - pub commits: Vec, -} - -/// All commits reachable from `HEAD`, newest first, with author and summary. -/// -/// Returns an empty list for a repository without any commits yet. -pub fn all_commits(repo: &gix::Repository) -> Result { - use gix::traverse::commit::simple::CommitTimeOrder; - - let Some(head) = repo.head_id().ok() else { - return Ok(CommitList { - total: 0, - commits: Vec::new(), - }); - }; - let walk = repo - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - CommitTimeOrder::NewestFirst, - )); - - let mut commits = Vec::new(); - let mut total = 0; - for info in walk.all()? { - let info = info?; - total += 1; - if commits.len() < MAX_LISTED_COMMITS { - commits.push(file_commit_summary(&info.object()?)?); - } - } - 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 { - all_commits(&open_with_cache(workdir)?) -} - -/// 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, - /// 1-based line number in the old version, if the line exists there. - pub old: Option, - /// 1-based line number in the new version, if the line exists there. - pub new: Option, - /// Line content without the trailing newline. - pub text: String, -} - -/// A hunk of a file diff, like `@@ -a,b +c,d @@`. -#[derive(Debug, Clone)] -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, -} - -/// How a file changed in a commit. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DiffStatus { - Added, - Modified, - Deleted, - Renamed, - Copied, -} - -/// The diff of one file in a commit. -#[derive(Debug, Clone)] -pub struct FileDiff { - /// Path of the file relative to the repo root. - /// For renames and copies, this is the destination path. - pub path: String, - /// Previous path, for renames and copies. - pub old_path: Option, - pub status: DiffStatus, - /// Number of added lines, 0 for binary files. - pub insertions: usize, - /// Number of removed lines, 0 for binary files. - pub deletions: usize, - /// True if either version is binary, then `hunks` is empty. - pub binary: bool, - pub hunks: Vec, -} - -/// The changes of one commit. -#[derive(Debug, Clone)] -pub struct CommitDiff { - pub files: Vec, -} - -/// The changes of the commit `id`, short or full, in the repository at `workdir`. -/// -/// Compared against its first parent, the empty tree for the root commit. -pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result { - commit_diff(&gix::open(workdir)?, id) -} - -fn commit_diff(repo: &gix::Repository, id: &str) -> Result { - let commit_id = repo.rev_parse_single(id.as_bytes())?; - let commit = commit_id.object()?.into_commit(); - let new_tree = commit.tree()?; - let old_tree = match commit.parent_ids().next() { - Some(parent) => Some(parent.object()?.into_commit().tree()?), - None => None, - }; - tree_diff(repo, old_tree.as_ref(), &new_tree) -} - -/// The changes between two commits, `base`..`tip`, like `git diff base tip`. -/// -/// Directories and submodules are skipped, files are sorted by path. -pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result { - let repo = gix::open(workdir)?; - let base_tree = repo - .rev_parse_single(base.as_bytes())? - .object()? - .into_commit() - .tree()?; - let tip_tree = repo - .rev_parse_single(tip.as_bytes())? - .object()? - .into_commit() - .tree()?; - tree_diff(&repo, Some(&base_tree), &tip_tree) -} - -/// Commits in the range `base`..`tip`, newest first, like `git log base..tip`. -pub fn worktree_commit_range_commits( - workdir: &Path, - base: &str, - tip: &str, -) -> Result> { - use gix::traverse::commit::simple::CommitTimeOrder; - - let repo = open_with_cache(workdir)?; - let base_id = repo.rev_parse_single(base.as_bytes())?; - let tip_id = repo.rev_parse_single(tip.as_bytes())?; - let walk = repo - .rev_walk([tip_id]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - CommitTimeOrder::NewestFirst, - )) - .with_hidden([base_id]); - - let mut commits = Vec::new(); - for info in walk.all()? { - let info = info?; - commits.push(file_commit_summary(&info.object()?)?); - } - Ok(commits) -} - -/// 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<'_>>, - new_tree: &gix::Tree<'_>, -) -> Result { - use gix::diff::blob::platform::prepare_diff::Operation; - use gix::object::tree::diff::Change; - use gix::objs::tree::EntryKind; - - let changes = repo.diff_tree_to_tree(old_tree, Some(new_tree), None)?; - let mut cache = repo.diff_resource_cache_for_tree_diff()?; - - let mut files = Vec::new(); - for change in changes { - let attached = Change::from_change_ref(change.to_ref(), repo, repo); - - // Skip directory trees and submodule gitlinks, only files are listed. - let (path, old_path, status) = match attached { - Change::Addition { - location, - entry_mode, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { - (location.to_owned(), None, DiffStatus::Added) - } - Change::Deletion { - location, - entry_mode, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { - (location.to_owned(), None, DiffStatus::Deleted) - } - Change::Modification { - location, - previous_entry_mode, - entry_mode, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) - && !matches!( - previous_entry_mode.kind(), - EntryKind::Tree | EntryKind::Commit - ) => - { - (location.to_owned(), None, DiffStatus::Modified) - } - Change::Rewrite { - location, - source_location, - source_entry_mode, - entry_mode, - copy, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) - && !matches!( - source_entry_mode.kind(), - EntryKind::Tree | EntryKind::Commit - ) => - { - let status = if copy { - DiffStatus::Copied - } else { - DiffStatus::Renamed - }; - ( - location.to_owned(), - Some(source_location.to_owned()), - status, - ) - } - _ => continue, - }; - - // Always diff with the built-in algorithm. - // External diff drivers would shell out, out of scope for a read-only viewer. - let platform = attached.diff(&mut cache)?; - platform - .resource_cache - .options - .skip_internal_diff_if_external_is_configured = true; - let outcome = platform.resource_cache.prepare_diff()?; - - let (binary, hunks, insertions, deletions) = match outcome.operation { - Operation::InternalDiff { algorithm } => { - let input = outcome.interned_input(); - let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input); - - let mut hunks = Vec::new(); - let mut insertions = 0usize; - let mut deletions = 0usize; - let collector = HunkCollector { - hunks: &mut hunks, - insertions: &mut insertions, - deletions: &mut deletions, - }; - gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default()) - .consume()?; - (false, hunks, insertions, deletions) - } - Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0), - Operation::ExternalCommand { .. } => unreachable!("external diff drivers are disabled"), - }; - - files.push(FileDiff { - path: String::from_utf8_lossy(&path).into_owned(), - old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()), - status, - insertions, - deletions, - binary, - hunks, - }); - } - - files.sort_by(|a, b| a.path.cmp(&b.path)); - Ok(CommitDiff { files }) -} - -/// Parse `git format-patch` output, a single patch or a series. -/// -/// Backed by [`diffy::patch_set`], which implements git's extended diff format: -/// `diff --git` headers, rename and copy detection, binary detection, and -/// C-style quoted or octal-escaped paths. -pub fn patch_diffs(patch: &str) -> Result { - // `PatchSet` reports an error when the input holds no patch at all, - // while a patch without git diff sections is simply empty here. - if !patch.lines().any(|line| line.starts_with("diff --git ")) { - return Ok(CommitDiff { files: Vec::new() }); - } - - let mut files = Vec::new(); - - for file in PatchSet::parse(patch, ParseOptions::gitdiff()) { - files.push(file_diff(file?)?); - } - - Ok(CommitDiff { files }) -} - -/// The [`FileDiff`] of one parsed file patch. -fn file_diff(file: FilePatch<'_, str>) -> Result { - // The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path - // component is dropped, the same way `git apply -p1` does. - // Rename and copy paths come from their own headers, unprefixed. - let stripped; - let operation = match file.operation() { - operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation, - operation => { - stripped = operation.strip_prefix(1); - &stripped - } - }; - - let (path, old_path, status) = match operation { - FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added), - FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted), - FileOperation::Modify { modified, .. } => (modified.as_ref(), None, DiffStatus::Modified), - FileOperation::Rename { from, to } => { - (to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed) - } - FileOperation::Copy { from, to } => (to.as_ref(), Some(from.as_ref()), DiffStatus::Copied), - }; - - let mut insertions = 0usize; - let mut deletions = 0usize; - let mut hunks = Vec::new(); - let patch = file.patch(); - if let Some(text) = patch.as_text() { - for hunk in text.hunks() { - let hunk = hunk_diff(hunk); - insertions += hunk - .lines - .iter() - .filter(|line| line.kind == DiffLineKind::Addition) - .count(); - deletions += hunk - .lines - .iter() - .filter(|line| line.kind == DiffLineKind::Deletion) - .count(); - hunks.push(hunk); - } - } - - Ok(FileDiff { - path: path.to_owned(), - old_path: old_path.map(str::to_owned), - status, - insertions, - deletions, - binary: patch.is_binary(), - hunks, - }) -} - -/// The [`DiffHunk`] of one parsed hunk, including the line number of every line. -/// -/// `diffy` reports only the hunk header ranges. The per-line numbers are -/// counted from them the way the header encodes them: context lines advance -/// both sides, deletions only the old, insertions only the new. -fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk { - let old_range = hunk.old_range(); - let new_range = hunk.new_range(); - let mut old = old_range.start() as u32; - let mut new = new_range.start() as u32; - - let mut lines = Vec::with_capacity(hunk.lines().len()); - for line in hunk.lines() { - let (kind, text) = match line { - Line::Context(text) => (DiffLineKind::Context, *text), - Line::Delete(text) => (DiffLineKind::Deletion, *text), - Line::Insert(text) => (DiffLineKind::Addition, *text), - }; - - let (old_no, new_no) = match kind { - DiffLineKind::Context => { - let numbers = (Some(old), Some(new)); - old += 1; - new += 1; - numbers - } - DiffLineKind::Addition => { - let number = Some(new); - new += 1; - (None, number) - } - DiffLineKind::Deletion => { - let number = Some(old); - old += 1; - (number, None) - } - }; - - lines.push(DiffLine { - kind, - old: old_no, - new: new_no, - text: line_text(text), - }); - } - - DiffHunk { - old_start: old_range.start() as u32, - old_lines: old_range.len() as u32, - new_start: new_range.start() as u32, - new_lines: new_range.len() as u32, - lines, - } -} - -/// The content of a parsed line without its line ending. -/// -/// `diffy` keeps the trailing `\n`, the way `str::lines` splits it off. -fn line_text(text: &str) -> String { - let text = text.strip_suffix('\n').unwrap_or(text); - text.strip_suffix('\r').unwrap_or(text).to_owned() -} - -/// Commits of a `git format-patch` output, a single patch or a series. -/// -/// Entries appear in patch order, oldest first as `git format-patch` produces them. -pub fn patch_commits(patch: &str) -> Vec { - let lines: Vec<&str> = patch.lines().collect(); - let mut commits = Vec::new(); - let mut i = 0; - - while i < lines.len() { - // A patch starts with its `From ` envelope line. - let Some(rest) = lines[i].strip_prefix("From ") else { - i += 1; - continue; - }; - let Some(id) = rest.split_whitespace().next() else { - i += 1; - continue; - }; - if id.len() != 40 { - i += 1; - continue; - } - - let mut author = String::new(); - let mut summary = String::new(); - let mut time = 0i64; - - // Envelope headers run up to the blank line before the commit message. - i += 1; - while i < lines.len() && !lines[i].is_empty() { - let header = lines[i]; - if let Some(value) = header.strip_prefix("From: ") { - author = name_from_address(value); - } else if let Some(value) = header.strip_prefix("Subject: ") { - summary = strip_patch_prefix(value); - } else if let Some(value) = header.strip_prefix("Date: ") { - time = gix::date::parse(value.trim(), None) - .map(|t| t.seconds) - .unwrap_or(0); - } - i += 1; - } - - commits.push(FileCommit { - id: id.to_string(), - summary, - description: None, - author, - time, - }); - } - - commits -} - -/// The name part of a `From: Name ` header value. -fn name_from_address(from: &str) -> String { - match from.trim().find('<') { - Some(ix) => from[..ix].trim().to_string(), - None => from.trim().to_string(), - } -} - -/// Strip the patch prefix from a `Subject:` header. -/// -/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`. -fn strip_patch_prefix(subject: &str) -> String { - let trimmed = subject.trim(); - let Some(rest) = trimmed.strip_prefix('[') else { - return trimmed.to_string(); - }; - let Some(end) = rest.find(']') else { - return trimmed.to_string(); - }; - if rest[..end].to_ascii_lowercase().contains("patch") { - rest[end + 1..].trim().to_string() - } else { - trimmed.to_string() - } -} - -/// Collects the hunks of one blob diff while tracking per-line numbers. -struct HunkCollector<'a> { - hunks: &'a mut Vec, - insertions: &'a mut usize, - deletions: &'a mut usize, -} - -impl ConsumeHunk for HunkCollector<'_> { - type Out = (); - - fn consume_hunk( - &mut self, - header: HunkHeader, - lines: &[(GixLineKind, &[u8])], - ) -> std::io::Result<()> { - let mut old_ln = header.before_hunk_start; - let mut new_ln = header.after_hunk_start; - let mut out = Vec::with_capacity(lines.len()); - - for (kind, content) in lines { - let text = String::from_utf8_lossy(content).into_owned(); - let line = match kind { - GixLineKind::Context => { - let line = DiffLine { - kind: DiffLineKind::Context, - old: Some(old_ln), - new: Some(new_ln), - text, - }; - old_ln += 1; - new_ln += 1; - line - } - GixLineKind::Remove => { - *self.deletions += 1; - let line = DiffLine { - kind: DiffLineKind::Deletion, - old: Some(old_ln), - new: None, - text, - }; - old_ln += 1; - line - } - GixLineKind::Add => { - *self.insertions += 1; - let line = DiffLine { - kind: DiffLineKind::Addition, - old: None, - new: Some(new_ln), - text, - }; - new_ln += 1; - line - } - }; - out.push(line); - } - - self.hunks.push(DiffHunk { - old_start: header.before_hunk_start, - old_lines: header.before_hunk_len, - new_start: header.after_hunk_start, - new_lines: header.after_hunk_len, - lines: out, - }); - - Ok(()) - } - - fn finish(self) {} -} - -/// The commit HEAD points to, like `git log -1`. -/// -/// `Ok(None)` for a repository without commits yet, an unborn HEAD. -pub fn head_commit(repo: &gix::Repository) -> Result> { - let Some(head) = repo.head_id().ok() else { - return Ok(None); - }; - let commit = head.object()?.into_commit(); - Ok(Some(file_commit(&commit)?)) -} - -/// Full metadata of the commit `id`, short or full, in the repository at `workdir`. -/// Like [`head_commit`] for an arbitrary commit. -/// -/// `Ok(None)` when the id cannot be resolved. -pub fn worktree_commit(workdir: &Path, id: &str) -> Result> { - let repo = gix::open(workdir)?; - match repo.rev_parse_single(id.as_bytes()) { - Ok(commit_id) => { - let commit = commit_id.object()?.into_commit(); - Ok(Some(file_commit(&commit)?)) - } - Err(_) => Ok(None), - } -} - -/// Short names of local branches, `refs/heads/*`, of `repo`, sorted alphabetically. -pub fn repo_branches(repo: &gix::Repository) -> Result> { - let mut names = Vec::new(); - for reference in repo.references()?.local_branches()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); - } - names.sort(); - Ok(names) -} - -/// Short names of tags, `refs/tags/*`, of `repo`, sorted alphabetically. -pub fn repo_tags(repo: &gix::Repository) -> Result> { - let mut names = Vec::new(); - for reference in repo.references()?.tags()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); - } - names.sort(); - Ok(names) -} - -/// Short names of local branches, `refs/heads/*`, sorted alphabetically. -pub fn worktree_branches(workdir: &Path) -> Result> { - repo_branches(&gix::open(workdir)?) -} - -/// Short name of the branch HEAD points to, or `None` when detached. -/// -/// Detached after checking out a tag or a commit directly. -pub fn current_branch(repo: &gix::Repository) -> Result> { - let head = repo.head()?; - let Some(name) = head.referent_name() else { - return Ok(None); - }; - Ok(Some(String::from_utf8_lossy(name.shorten()).into_owned())) -} - -/// Branch, tag and HEAD refs of a repository. -/// -/// Ready for a NIP-34 kind-30618 repository state announcement. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RepoRefState { - /// `(full refname, commit id)` pairs for heads and tags, sorted. - pub refs: Vec<(String, String)>, - /// Short branch name HEAD points to, or `None` when detached. - pub head: Option, -} - -/// 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 { - let mut refs = Vec::new(); - - for reference in repo.references()?.local_branches()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - refs.push(( - String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), - reference.id().to_string(), - )); - } - for reference in repo.references()?.tags()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - refs.push(( - String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), - reference.id().to_string(), - )); - } - refs.sort(); - - let head = match repo.head() { - Ok(head) => head - .referent_name() - .filter(|name| name.as_bstr().starts_with(b"refs/heads/")) - .map(|name| String::from_utf8_lossy(name.shorten()).into_owned()), - Err(_) => None, - }; - - Ok(RepoRefState { refs, head }) -} - -/// [`repo_ref_state`] for the repository at `workdir`. -pub fn worktree_ref_state(workdir: &Path) -> Result { - repo_ref_state(&gix::open(workdir)?) -} - -/// Everything the browser needs to refresh after a branch or tag switch. -pub struct WorktreeSnapshot { - /// Relative paths of all worktree entries, directories first. - pub entries: Vec, - /// README path relative to the worktree, if any. - pub readme_path: Option, - /// Contents of the README, if any. - pub readme: Option>, - /// Branch HEAD points to, `None` when detached, for example on a tag. - pub current_branch: Option, - /// Commit HEAD points to, if any, see [`head_commit`]. - pub head_commit: Option, -} - -/// 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 { - let repo = gix::open(workdir)?; - let readme_path = find_readme(&repo)?; - let readme = match &readme_path { - Some(path) => worktree_read(&repo, path)?, - None => None, - }; - Ok(WorktreeSnapshot { - entries: worktree_entries(&repo)?, - readme_path, - readme, - current_branch: current_branch(&repo)?, - head_commit: head_commit(&repo)?, - }) -} - -/// Check out `tree` into the worktree of `repo` -fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> Result<()> { - let workdir = repo - .workdir() - .context("repository has no worktree")? - .to_path_buf(); - - let mut index = repo.index_from_tree(tree)?; - - // Files the previous index tracked but `tree` no longer contains are removed, - // like git deleting files that vanish between branches. - if let Ok(previous) = repo.index_or_empty() { - let keep: HashSet = index - .entries() - .iter() - .map(|entry| PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned())) - .collect(); - for entry in previous.entries() { - let rel = entry.path(&previous); - let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned()); - - if keep.contains(&rel) { - continue; - } - - let path = workdir.join(&rel); - - match std::fs::remove_file(&path) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(error) - .with_context(|| format!("failed to remove {}", path.display())); - } - } - } - } - - let mut options = - repo.checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?; - options.overwrite_existing = true; - - let objects = repo.objects.clone().into_arc()?; - let files = gix::progress::Discard; - let bytes = gix::progress::Discard; - - // Check out the index into the worktree. - gix_worktree_state::checkout( - &mut index, - workdir, - objects, - &files, - &bytes, - &gix::interrupt::IS_INTERRUPTED, - options, - )?; - - // Write the index to disk. - index.write(gix::index::write::Options::default())?; - - Ok(()) -} - -/// Point `HEAD` at `target` and record the switch in the reflog. -fn move_head( - repo: &gix::Repository, - signature: gix::actor::SignatureRef<'_>, - target: gix::refs::Target, - message: &str, -) -> Result<()> { - use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; - - 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 { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: message.into(), - }, - expected: PreviousValue::Any, - new: target, - }, - name: head, - deref: false, - }], - Some(signature), - )?; - - Ok(()) -} - -/// Check out the local branch `name`, HEAD stays attached to it. -pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> { - let repo = gix::open(workdir)?; - let full = format!("refs/heads/{name}"); - - let branch = gix::refs::FullName::try_from(full.as_str()) - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - - let mut reference = repo.find_reference(&full)?; - let tree = reference.peel_to_tree()?.id; - - 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, - gix::refs::Target::Symbolic(branch), - &format!("checkout: moving to {name}"), - )?; - - // Check out the branch's tree, replacing index + worktree. - force_checkout(&repo, &tree)?; - - Ok(()) -} - -/// Check out the tag `name`, HEAD becomes detached at the tagged commit. -pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> { - let repo = gix::open(workdir)?; - let full = format!("refs/tags/{name}"); - - let mut reference = repo.find_reference(&full)?; - - let commit = reference.peel_to_id()?; - let tree = reference.peel_to_tree()?.id; - - 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, - gix::refs::Target::Object(commit.detach()), - &format!("checkout: moving to {name}"), - )?; - - // Check out the tag's tree, replacing index + worktree. - force_checkout(&repo, &tree)?; - - Ok(()) -} - -fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - if entry.file_name() == ".git" { - continue; - } - - let is_dir = entry.file_type()?.is_dir(); - let path = entry.path(); - let rel = path.strip_prefix(root)?.to_path_buf(); - out.push((rel, is_dir)); - - if is_dir { - collect_entries(root, &path, out)?; - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use nostr::prelude::*; - use signed_core::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(".."), "_"); - assert_eq!(sanitize_path_component("."), "_"); - // Separators are neutralized before the check, so these stay safe. - assert_eq!(sanitize_path_component("../.."), ".._.."); - 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(); - let root = temp.path(); - - // Repositories are found at any depth. - // A linked worktree, with a `.git` file instead of a directory, counts too. - let nested = root.join("a/b/project"); - std::fs::create_dir_all(nested.join(".git")).unwrap(); - let worktree = root.join("wt"); - std::fs::create_dir_all(&worktree).unwrap(); - std::fs::write( - worktree.join(".git"), - "gitdir: ../a/b/project/.git/worktrees/wt", - ) - .unwrap(); - - // Plain directories are not repositories. - std::fs::create_dir_all(root.join("plain")).unwrap(); - - // Hidden entries and dependency caches are skipped. - std::fs::create_dir_all(root.join(".hidden/repo/.git")).unwrap(); - std::fs::create_dir_all(root.join("node_modules/pkg/.git")).unwrap(); - - // A repository is not descended into. - // Repositories inside it, like submodule worktrees, are 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(); - - let mut found = find_git_repos(root); - found.sort(); - - let mut expected = vec![ - nested.canonicalize().unwrap(), - worktree.canonicalize().unwrap(), - outer.canonicalize().unwrap(), - ]; - expected.sort(); - assert_eq!(found, expected); - } - - #[test] - fn root_commit_reports_the_first_ancestor() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - - let dir = dir.path(); - let root = root_commit(dir).expect("root").expect("commit"); - assert_eq!(root.len(), 40); - - // The root commit does not change when history grows. - std::fs::write(dir.join("b.txt"), b"two").expect("write"); - commit_all(&repo, "second"); - assert_eq!( - root_commit(dir).expect("root").as_deref(), - Some(root.as_str()) - ); - } - - #[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. - // 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(); - - // 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"); - git_run(dir, &["checkout", "-"]); - git_run(dir, &["tag", "v1.0"]); - - 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/heads/main")); - assert!(refs.contains("refs/heads/feature")); - 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(); - 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 main = git_in(dir, &["rev-parse", "refs/heads/main"]).expect("main oid"); - 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( - dir, - &format!("file://{}", server.path().display()), - "npub1test", - "my-repo", - ) - .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. - let stale = vec![("refs/heads/main".to_owned(), "0".repeat(40))]; - assert!(!remote_has_refs(dir, &url, &stale).expect("probe")); - - // Extra remote refs (e.g. a tag pushed later) do not invalidate the - // refs this push wanted to land. - git_run(dir, &["tag", "v1.0"]); - push_all( - dir, - &format!("file://{}", server.path().display()), - "npub1test", - "my-repo", - ) - .expect("push"); - assert!(remote_has_refs(dir, &url, &expected).expect("probe")); - } - - #[test] - fn repo_ref_state_lists_branches_tags_and_head() { - let (_dir, repo) = fixture(&[("a.txt", b"hello")]); - commit_all(&repo, "initial"); - let workdir = repo.workdir().expect("workdir").to_path_buf(); - - let state = repo_ref_state(&repo).expect("refs"); - - let branch = current_branch(&repo).expect("branch").expect("on a branch"); - assert_eq!(state.head.as_deref(), Some(branch.as_str())); - assert_eq!(state.refs.len(), 1); - 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"]); - - let state = repo_ref_state(&repo).expect("refs"); - let mut expected: Vec = vec![ - format!("refs/heads/{branch}"), - "refs/heads/feature".to_owned(), - "refs/tags/v1.0".to_owned(), - ]; - expected.sort(); - assert_eq!( - state - .refs - .iter() - .map(|(name, _)| name.clone()) - .collect::>(), - 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()); - assert_eq!(state.refs.len(), 3); - } - - /// Build a throwaway non-bare repository from `(rel, bytes)` file pairs. - fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) { - let dir = tempfile::tempdir().expect("tempdir"); - let repo = gix::init(&dir).expect("init"); - - for (rel, bytes) in files { - let path = dir.path().join(rel); - std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); - std::fs::write(&path, bytes).expect("write"); - } - - (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 = 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) { - git_run(repo.workdir().expect("workdir"), &["add", "-A"]); - git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]); - } - - #[test] - fn merge_base_finds_the_fork_point_and_reports_unrelated_history() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("repo"); - let initial = init_repository(&path, "My Repo", "desc").expect("init"); - - // A feature branch and a mainline commit diverge from the initial commit. - // The initial commit is their merge base. - 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"); - git_run(&path, &["checkout", "main"]); - std::fs::write(path.join("main.txt"), "main\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "mainline commit"); - - assert_eq!( - merge_base(&path, "feature", "main") - .expect("merge base") - .as_deref(), - Some(initial.as_str()) - ); - - // An orphan branch shares no history with main, so `Ok(None)`. - git_run(&path, &["checkout", "--orphan", "orphan"]); - std::fs::write(path.join("orphan.txt"), "orphan\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "orphan commit"); - assert_eq!(merge_base(&path, "orphan", "main").expect("ok"), None); - - // An unresolvable revision is an error, not a missing ancestor. - 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"); - 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("one.txt"), "one\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "first commit"); - std::fs::write(path.join("two.txt"), "two\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "second commit"); - - let series = format_patch_between(&path, &initial, "feature").expect("series"); - let parts = split_patch_series(&series); - - assert_eq!(parts.len(), 2); - assert!(parts[0].contains("Subject: [PATCH 1/2] first commit")); - assert!(parts[1].contains("Subject: [PATCH 2/2] second commit")); - // Each part starts its own mbox message with its own commit id. - let first = parts[0].lines().next().expect("first header"); - let second = parts[1].lines().next().expect("second header"); - assert!(first.starts_with("From ") && first.len() >= 45); - 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 \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"); - let path = dir.path().join("repo"); - let initial = init_repository(&path, "My Repo", "desc").expect("init"); - - assert_eq!( - head_commit_id(&path).expect("head").as_deref(), - Some(initial.as_str()) - ); - // No commits yet, `HEAD` alone. - assert_eq!( - commits_since(&path, None).expect("commits"), - vec![initial.clone()] - ); - - std::fs::write(path.join("one.txt"), "one\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "first commit"); - let first = head_commit_id(&path).expect("head").expect("on a branch"); - - std::fs::write(path.join("two.txt"), "two\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "second commit"); - let second = head_commit_id(&path).expect("head").expect("on a branch"); - - // Oldest first, like the order `git am` creates them. - assert_eq!( - commits_since(&path, Some(&initial)).expect("commits"), - vec![first.clone(), second.clone()] - ); - assert_eq!( - commits_since(&path, Some(&first)).expect("commits"), - vec![second] - ); - } - - #[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::::new() - ); - } - - #[test] - fn init_repository_creates_main_branch_and_readme() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("my-repo"); - - let commit = init_repository(&path, "My Repo", "Does things.\n\nCool.").expect("init"); - assert_eq!(commit.len(), 40); - - 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\nDoes things.\n\nCool.\n" - ); - - let branch = current_branch(&repo).expect("branch").expect("on a branch"); - assert_eq!(branch, "main"); - // [`FileCommit`] carries the short id, the full id is 40 chars. - assert_eq!( - head_commit(&repo).expect("head").expect("commit").id, - &commit[..7] - ); - - let state = repo_ref_state(&repo).expect("refs"); - assert_eq!(state.head.as_deref(), Some("main")); - assert_eq!(state.refs, vec![("refs/heads/main".to_owned(), commit)]); - - // The index matches the committed tree, so the fresh repo is clean. - 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(), - Some("https://gitnostr.com/npub1test/repo.git") - ); - - // An existing origin is replaced, not duplicated. - // A clone's origin points at the cloned-from path. - // It is re-targeted at the grasp server. - set_origin(&path, "https://grasp.example/npub1test/repo.git").expect("replace"); - assert_eq!( - origin_url(&path).expect("url").as_deref(), - Some("https://grasp.example/npub1test/repo.git") - ); - } - - #[test] - fn working_copy_cloned_from_the_mirror_matches_head_and_origin() { - // The mirror is a freshly initialized repository, standing in for - // the grasp server. Its `origin` points at the (fake) grasp server. - // `GitCache::ensure_clone` lazily clones from a URL shaped like this - // the first time a repository is opened. - let dir = tempfile::tempdir().expect("tempdir"); - let mirror = dir.path().join("mirror"); - let commit = init_repository(&mirror, "My Repo", "Does things.").expect("init"); - ensure_origin(&mirror, "https://gitnostr.com/npub1test/my-repo.git").expect("origin"); - - // The working copy is cloned from the mirror. - // It then shares the announced history exactly. - // `origin` is re-pointed at the grasp server instead of the mirror path. - let destination = dir.path().join("folder").join("My_Repo"); - std::fs::create_dir_all(destination.parent().unwrap()).expect("parent"); - clone_repo(&[format!("file://{}", mirror.display())], &destination).expect("clone"); - set_origin(&destination, "https://gitnostr.com/npub1test/my-repo.git").expect("set origin"); - - assert_eq!( - origin_url(&destination).expect("url").as_deref(), - Some("https://gitnostr.com/npub1test/my-repo.git") - ); - assert_eq!( - head_commit_id(&destination).expect("head").as_deref(), - Some(commit.as_str()) - ); - assert!(destination.join("README.md").is_file()); - } - - #[test] - fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { - // A bare server, like a grasp server's `{base}/{owner}/{repo}.git` layout. - let dir = tempfile::tempdir().expect("tempdir"); - let base_server = dir.path().join("npub1test").join("repo.git"); - std::fs::create_dir_all(base_server.parent().unwrap()).unwrap(); - let init_status = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&base_server) - .status() - .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(); - let base_url = format!("file://{}", dir.path().display()); - push_all(work, &base_url, "npub1test", "repo").expect("push"); - - // A mirror clone, like the app's GitCache clones. - let mirror = dir.path().join("mirror"); - git_run( - dir.path(), - &[ - "clone", - "-q", - &format!("{base_url}/npub1test/repo.git"), - mirror.to_str().unwrap(), - ], - ); - let initial = git_in(&mirror, &["rev-parse", "HEAD"]).expect("initial"); - - // The owner pushes a new commit. - // The mirror fetches it, but its local `main` and worktree stay behind. - std::fs::write(work.join("new.txt"), b"new\n").expect("write"); - commit_all(&gix::open(work).expect("open"), "new commit"); - push_all(work, &base_url, "npub1test", "repo").expect("push"); - git_run(&mirror, &["fetch", "origin"]); - let remote = git_in(&mirror, &["rev-parse", "refs/remotes/origin/main"]).expect("remote"); - assert_eq!( - git_in(&mirror, &["rev-parse", "HEAD"]).expect("local"), - initial - ); - assert_ne!(remote, initial); - - // Fast-forwarding catches the branch and its worktree up. - // The second call has nothing left to move. - assert!(fast_forward_branches(&mirror).expect("ff")); - assert_eq!( - git_in(&mirror, &["rev-parse", "HEAD"]).expect("local"), - remote - ); - assert!(mirror.join("new.txt").is_file()); - assert!(!fast_forward_branches(&mirror).expect("idle")); - - // A branch with local commits of its own is never touched. - git_run(&mirror, &["checkout", "-b", "wip"]); - std::fs::write(mirror.join("wip.txt"), b"wip\n").expect("write"); - commit_all(&gix::open(&mirror).expect("open"), "local wip"); - let wip = git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip"); - assert!(!fast_forward_branches(&mirror).expect("wip skipped")); - assert_eq!( - git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip kept"), - wip - ); - } - - #[test] - fn fetch_repo_refs_imports_heads_under_a_prefix() { - let dir = tempfile::tempdir().expect("tempdir"); - - // A bare base server holding the initial commit. - // Like a grasp server's `{base}/{owner}/{repo-id}.git` layout. - let base_server = dir.path().join("npub1base").join("base.git"); - std::fs::create_dir_all(base_server.parent().unwrap()).unwrap(); - let init_status = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&base_server) - .status() - .expect("spawn git init --bare"); - assert!(init_status.success()); - - let (upstream_dir, upstream_repo) = fixture(&[("a.txt", b"one")]); - commit_all(&upstream_repo, "initial"); - let upstream_path = upstream_dir.path(); - let initial = git_in(upstream_path, &["rev-parse", "HEAD"]).expect("initial"); - push_all( - upstream_path, - &format!("file://{}", dir.path().display()), - "npub1base", - "base", - ) - .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( - dir.path(), - &["clone", "-q", &base_url, mirror.to_str().unwrap()], - ); - - // The fork server has the same initial commit. - // It also carries a feature commit on its own `feature` branch. - let fork_work = dir.path().join("fork-work"); - git_run( - dir.path(), - &["clone", "-q", &base_url, fork_work.to_str().unwrap()], - ); - git_run(&fork_work, &["checkout", "-b", "feature"]); - std::fs::write(fork_work.join("feature.txt"), "feature\n").expect("write"); - commit_all(&gix::open(&fork_work).expect("open"), "feature commit"); - let tip = git_in(&fork_work, &["rev-parse", "HEAD"]).expect("tip"); - - let fork_server = dir.path().join("npub1fork").join("fork.git"); - std::fs::create_dir_all(fork_server.parent().unwrap()).unwrap(); - let init_status = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&fork_server) - .status() - .expect("spawn git init --bare"); - assert!(init_status.success()); - push_commit_ref( - &fork_work, - &format!("file://{}", fork_server.display()), - &tip, - "refs/heads/feature", - ) - .expect("push"); - - // Import the fork's heads into the mirror under a private prefix. - // The first dead URL is skipped, the second works. - let dead = format!("file://{}/missing.git", dir.path().display()); - fetch_repo_refs( - &mirror, - &[dead, format!("file://{}", fork_server.display())], - "+refs/heads/*:refs/fork/npub1fork/fork/*", - ) - .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"] - ); - // Nothing leaked into the normal ref namespaces. - assert_eq!( - refs_with_prefix(&mirror, "refs/heads/fork").expect("refs"), - Vec::::new() - ); - - // The mirror can now range across both histories. - // The fork point is the shared initial commit, the proposal covers the fork commit. - assert_eq!( - merge_base( - &mirror, - "refs/remotes/origin/main", - "refs/fork/npub1fork/fork/feature", - ) - .expect("merge base") - .as_deref(), - Some(initial.as_str()) - ); - let patch = format_patch_between(&mirror, &initial, "refs/fork/npub1fork/fork/feature") - .expect("patch"); - 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"), - Vec::::new() - ); - } - - #[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") - .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(args) - .status() - .expect("spawn git"); - assert!(status.success(), "git {args:?} failed"); - } - - #[test] - fn last_commit_returns_most_recent_change() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - - std::fs::write(dir.path().join("a.txt"), b"two").expect("write"); - commit_all(&repo, "change a"); - - // A commit touching another file must not be reported for a.txt. - std::fs::write(dir.path().join("b.txt"), b"other").expect("write"); - commit_all(&repo, "add b"); - - let commit = last_commit(&repo, Path::new("a.txt")) - .expect("lookup") - .expect("found"); - assert_eq!(commit.summary, "change a"); - assert_eq!(commit.author, "Test Author"); - assert!(!commit.id.is_empty()); - assert!(commit.time > 0); - } - - #[test] - fn all_commits_lists_every_commit() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - - std::fs::write(dir.path().join("a.txt"), b"two").expect("write"); - commit_all(&repo, "second"); - std::fs::write(dir.path().join("b.txt"), b"b").expect("write"); - commit_all(&repo, "third"); - - let list = all_commits(&repo).expect("commits"); - assert_eq!(list.total, 3); - let mut summaries: Vec<&str> = list.commits.iter().map(|c| c.summary.as_str()).collect(); - summaries.sort(); - assert_eq!(summaries, vec!["initial", "second", "third"]); - assert!( - list.commits - .iter() - .all(|c| c.author == "Test Author" && !c.id.is_empty() && c.time > 0) - ); - } - - #[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")]); - commit_all(&repo, "initial"); - - let run = |args: &[&str]| { - let status = Command::new("git") - .current_dir(dir.path()) - .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(args) - .status() - .expect("spawn git"); - assert!(status.success(), "git {args:?} failed"); - }; - run(&["checkout", "-b", "feature"]); - std::fs::write(dir.path().join("a.txt"), b"feature").expect("write"); - commit_all(&repo, "feature change"); - run(&["checkout", "-"]); - // `--no-ff` forces a merge commit, it is the latest commit changing a.txt. - run(&["merge", "--no-ff", "--no-edit", "feature"]); - - let commit = last_commit(&repo, Path::new("a.txt")) - .expect("lookup") - .expect("found"); - assert_eq!( - commit.id, - repo.head_id().expect("head").shorten_or_id().to_string() - ); - 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")]); - - let readme = find_readme(&repo).expect("find"); - assert_eq!( - readme.map(|p| p.to_string_lossy().into_owned()), - Some("README.md".into()) - ); - } - - #[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")]); - commit_all(&repo, "initial"); - let dir = dir.path(); - - let default = worktree_branches(dir) - .expect("branches") - .into_iter() - .next() - .expect("default branch"); - assert_eq!( - current_branch(&repo).expect("branch").as_deref(), - Some(default.as_str()) - ); - - git_run(dir, &["checkout", "-b", "feature"]); - assert_eq!( - current_branch(&repo).expect("branch").as_deref(), - 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(), - Some(default.as_str()) - ); - } - - #[test] - fn worktree_snapshot_reflects_checked_out_ref() { - let (dir, repo) = fixture(&[("README.md", b"# main"), ("a.txt", b"one")]); - commit_all(&repo, "initial"); - let dir = dir.path(); - - git_run(dir, &["checkout", "-b", "feature"]); - std::fs::write(dir.join("README.md"), b"# feature").expect("write"); - std::fs::write(dir.join("b.txt"), b"b").expect("write"); - commit_all(&repo, "feature work"); - - let snapshot = worktree_snapshot(dir).expect("snapshot"); - assert_eq!(snapshot.current_branch.as_deref(), Some("feature")); - assert_eq!( - snapshot.head_commit.as_ref().expect("head commit").summary, - "feature work" - ); - assert_eq!( - String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"), - "# feature" - ); - let entries: Vec = snapshot - .entries - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - assert!(entries.contains(&"b.txt".to_string())); - - let default = worktree_branches(dir) - .expect("branches") - .into_iter() - .find(|name| name != "feature") - .expect("default branch"); - worktree_checkout_branch(dir, &default).expect("checkout"); - - let snapshot = worktree_snapshot(dir).expect("snapshot"); - assert_eq!(snapshot.current_branch.as_deref(), Some(default.as_str())); - assert_eq!( - snapshot.head_commit.as_ref().expect("head commit").summary, - "initial" - ); - assert_eq!( - String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"), - "# main" - ); - assert!( - !snapshot - .entries - .iter() - .any(|p| p.to_string_lossy() == "b.txt") - ); - } - - #[test] - fn commit_diff_lists_added_modified_and_deleted_files() { - let (dir, repo) = fixture(&[("keep.txt", b"keep"), ("mod.txt", b"one\ntwo\nthree\n")]); - commit_all(&repo, "initial"); - - std::fs::write(dir.path().join("mod.txt"), b"one\ntwo!\nthree\n").expect("write"); - std::fs::write(dir.path().join("new.txt"), b"hello\n").expect("write"); - std::fs::remove_file(dir.path().join("keep.txt")).expect("remove"); - commit_all(&repo, "changes"); - - let head = repo.head_id().expect("head").shorten_or_id().to_string(); - let diff = worktree_commit_diff(dir.path(), &head).expect("diff"); - - let by_path: HashMap<&str, &FileDiff> = diff - .files - .iter() - .map(|file| (file.path.as_str(), file)) - .collect(); - assert_eq!(by_path.len(), 3); - - let added = by_path["new.txt"]; - assert_eq!(added.status, DiffStatus::Added); - assert_eq!(added.insertions, 1); - assert_eq!(added.deletions, 0); - assert_eq!(added.hunks.len(), 1); - assert_eq!(added.hunks[0].lines.len(), 1); - assert_eq!(added.hunks[0].lines[0].kind, DiffLineKind::Addition); - assert_eq!(added.hunks[0].lines[0].old, None); - assert_eq!(added.hunks[0].lines[0].new, Some(1)); - assert_eq!(added.hunks[0].lines[0].text, "hello"); - - let modified = by_path["mod.txt"]; - assert_eq!(modified.status, DiffStatus::Modified); - assert_eq!(modified.insertions, 1); - assert_eq!(modified.deletions, 1); - assert!(!modified.binary); - let lines = &modified.hunks[0].lines; - // One hunk with context around the single-line change. - // The removed line is old 2, the added line is new 2. - assert!(lines.iter().any(|line| { - line.kind == DiffLineKind::Deletion - && line.old == Some(2) - && line.new.is_none() - && line.text == "two" - })); - assert!(lines.iter().any(|line| { - line.kind == DiffLineKind::Addition - && line.old.is_none() - && line.new == Some(2) - && line.text == "two!" - })); - assert!(lines.iter().any(|line| { - line.kind == DiffLineKind::Context && line.old == Some(1) && line.new == Some(1) - })); - - let deleted = by_path["keep.txt"]; - assert_eq!(deleted.status, DiffStatus::Deleted); - assert_eq!(deleted.deletions, 1); - assert_eq!(deleted.hunks[0].lines[0].kind, DiffLineKind::Deletion); - assert_eq!(deleted.hunks[0].lines[0].old, Some(1)); - assert_eq!(deleted.hunks[0].lines[0].new, None); - } - - #[test] - fn commit_range_diff_lists_changes_between_two_commits() { - let (dir, repo) = fixture(&[("a.txt", b"a\n"), ("b.txt", b"b\n")]); - commit_all(&repo, "first"); - let base = repo.head_id().expect("head").to_string(); - - std::fs::write(dir.path().join("a.txt"), b"changed\n").expect("write"); - std::fs::write(dir.path().join("c.txt"), b"new\n").expect("write"); - commit_all(&repo, "second"); - let tip = repo.head_id().expect("head").to_string(); - - let diff = worktree_commit_range_diff(dir.path(), &base, &tip).expect("diff"); - - let by_path: HashMap<&str, &FileDiff> = diff - .files - .iter() - .map(|file| (file.path.as_str(), file)) - .collect(); - assert_eq!(by_path.len(), 2); - assert_eq!(by_path["a.txt"].status, DiffStatus::Modified); - assert_eq!(by_path["a.txt"].insertions, 1); - assert_eq!(by_path["a.txt"].deletions, 1); - assert_eq!(by_path["c.txt"].status, DiffStatus::Added); - // b.txt is unchanged between the 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")]); - commit_all(&repo, "initial"); - - std::fs::write(_dir.path().join("blob.bin"), b"\x00\x03").expect("write"); - commit_all(&repo, "binary change"); - - let head = repo.head_id().expect("head").shorten_or_id().to_string(); - let diff = worktree_commit_diff(_dir.path(), &head).expect("diff"); - let file = diff - .files - .iter() - .find(|f| f.path == "blob.bin") - .expect("file"); - assert!(file.binary); - assert!(file.hunks.is_empty()); - assert_eq!(file.insertions, 0); - 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")]); - commit_all(&repo, "initial"); - - std::fs::rename(_dir.path().join("old.txt"), _dir.path().join("new.txt")).expect("rename"); - commit_all(&repo, "rename"); - - let head = repo.head_id().expect("head").shorten_or_id().to_string(); - let diff = worktree_commit_diff(_dir.path(), &head).expect("diff"); - let file = diff - .files - .iter() - .find(|f| f.path == "new.txt") - .expect("file"); - assert_eq!(file.status, DiffStatus::Renamed); - assert_eq!(file.old_path.as_deref(), Some("old.txt")); - // A pure rename has no content change, the file is still listed. - assert!(file.hunks.is_empty()); - assert_eq!(file.insertions, 0); - 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 -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 -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 -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 -From: Alice -Date: Tue, 1 Aug 2023 10:00:00 +0200 -Subject: [PATCH 1/2] first - -body one ---- - a.txt | 1 + - 1 file changed, 1 insertion(+) - -diff --git a/a.txt b/a.txt -@@ -1 +1,2 @@ - a -+b - -From 2222222222222222222222222222222222222222 Mon Sep 17 00:00:00 2001 -From: Bob -Date: Wed, 2 Aug 2023 11:30:00 +0000 -Subject: [PATCH 2/2] second - -body two ---- - b.txt | 1 + - 1 file changed, 1 insertion(+) - -diff --git a/b.txt b/b.txt -@@ -1 +1,2 @@ - x -+y -"#; - - let commits = patch_commits(patch); - assert_eq!(commits.len(), 2); - - assert_eq!(commits[0].id, "1111111111111111111111111111111111111111"); - assert_eq!(commits[0].summary, "first"); - assert_eq!(commits[0].author, "Alice"); - assert_eq!(commits[0].time, 1690876800); - - assert_eq!(commits[1].id, "2222222222222222222222222222222222222222"); - assert_eq!(commits[1].summary, "second"); - assert_eq!(commits[1].author, "Bob"); - 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 -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. - // Feed genuine `git format-patch` output through the parser. - // It covers quoted and octal-escaped paths. - // There are also a rename-free modification, an addition and a binary deletion. - let (dir, repo) = fixture(&[ - ("src/main.rs", b"fn main() {\n println!(\"one\");\n}\n"), - ("my file.txt", b"hello\n"), - ("\u{8bf4}\u{660e}.md", "# \u{8bf4}\u{660e}\n".as_bytes()), - ("img.png", b"\x89PNG\r\n\x1a\n\x00binary"), - ]); - commit_all(&repo, "initial"); - - std::fs::write( - dir.path().join("src/main.rs"), - b"fn main() {\n println!(\"two\");\n println!(\"three\");\n}\n", - ) - .expect("write"); - std::fs::write(dir.path().join("my file.txt"), b"hello world\n").expect("write"); - std::fs::write( - dir.path().join("\u{8bf4}\u{660e}.md"), - "# \u{8bf4}\u{660e}\nupdated\n", - ) - .expect("write"); - std::fs::remove_file(dir.path().join("img.png")).expect("remove"); - std::fs::write(dir.path().join("new file.md"), b"# new\n").expect("write"); - commit_all(&repo, "changes"); - - let output = Command::new("git") - .current_dir(dir.path()) - .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") - .args(["format-patch", "-1", "--stdout"]) - .output() - .expect("spawn git format-patch"); - assert!(output.status.success(), "git format-patch failed"); - let patch = String::from_utf8(output.stdout).expect("patch is utf-8"); - - let diff = patch_diffs(&patch).expect("parse real format-patch output"); - - let by_path = |path: &str| { - diff.files - .iter() - .find(|file| file.path == path) - .unwrap_or_else(|| panic!("missing file {path:?}")) - }; - - // Space in the name makes git quote the path in the header. - let file = by_path("my file.txt"); - assert_eq!(file.status, DiffStatus::Modified); - assert_eq!(file.insertions, 1); - - // UTF-8 names are emitted as octal escapes. - let file = by_path("\u{8bf4}\u{660e}.md"); - assert_eq!(file.status, DiffStatus::Modified); - assert_eq!(file.insertions, 1); - - let file = by_path("src/main.rs"); - assert_eq!(file.status, DiffStatus::Modified); - assert_eq!(file.insertions, 2); - assert_eq!(file.deletions, 1); - assert!(!file.hunks.is_empty()); - - let file = by_path("new file.md"); - assert_eq!(file.status, DiffStatus::Added); - assert_eq!(file.insertions, 1); - - // A binary deletion emits no `---` or `+++` lines. - // Only the mode line and the `Binary files` marker remain. - let file = by_path("img.png"); - assert_eq!(file.status, DiffStatus::Deleted); - assert!(file.binary); - assert!(file.hunks.is_empty()); - } - - #[test] - fn worktree_dirty_tracks_changes_and_untracked_files() { - let (dir, repo) = fixture(&[("tracked.txt", b"one")]); - commit_all(&repo, "initial"); - let workdir = dir.path(); - - 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)); - - // After restoring, an untracked file alone is dirty as well. - git_run(workdir, &["checkout", "--", "tracked.txt"]); - assert!(!worktree_dirty(workdir)); - 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)); - - // A missing directory is clean, not an error. - 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")]); - commit_all(&repo, "initial"); - let path = dir.path(); - - git_run(path, &["checkout", "-q", "-b", "feature"]); - std::fs::write(path.join("f.txt"), b"f\n").expect("write"); - commit_all(&gix::open(path).expect("open"), "feature work"); - - assert_eq!(worktree_commits_ahead(path, "main", "feature"), 1); - assert_eq!(worktree_commits_ahead(path, "feature", "main"), 0); - - git_run(path, &["checkout", "-q", "main"]); - assert_eq!(worktree_current_branch(path).as_deref(), Some("main")); - assert!(worktree_ref_exists(path, "refs/heads/feature")); - assert!(!worktree_ref_exists(path, "refs/heads/nope")); - assert_eq!(worktree_commits_ahead(path, "main", "feature"), 1); - } -} diff --git a/crates/signed_git/src/patch.rs b/crates/signed_git/src/patch.rs new file mode 100644 index 0000000..873c23a --- /dev/null +++ b/crates/signed_git/src/patch.rs @@ -0,0 +1,323 @@ +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result, bail}; +use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet}; +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. +/// +/// TODO: Replaced 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") + .current_dir(repo_path) + .stdin(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("failed to spawn `git am`")?; + + child + .stdin + .as_mut() + .expect("stdin piped") + .write_all(patch.as_bytes())?; + + let output = child.wait_with_output()?; + + if !output.status.success() { + bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr)); + } + + Ok(()) +} + +/// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`. +/// Fails when the range has no commits. +/// +/// The mbox is returned untrimmed. Trailing newlines are part of the format. +pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(repo_path) + .args(["format-patch", "--stdout", &format!("{base}..{tip}")]) + .env("GIT_TERMINAL_PROMPT", "0") + .stderr(Stdio::piped()) + .output() + .context("failed to spawn `git format-patch`")?; + + if !output.status.success() { + bail!( + "git format-patch failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let patch = String::from_utf8_lossy(&output.stdout).into_owned(); + + if patch.trim().is_empty() { + bail!("no commits between {base} and {tip}"); + } + + Ok(patch) +} + +/// Split a `git format-patch` series into its individual patches, mbox messages. +/// +/// A single patch yields one element. +/// A malformed input yields one element covering it. +pub fn split_patch_series(patch: &str) -> Vec<&str> { + let mut starts = vec![0usize]; + let mut search_from = 1; + + while let Some(rel) = patch[search_from..].find("\nFrom ") { + let ix = search_from + rel + 1; + let hex = patch[ix + 5..] + .split(|c: char| !c.is_ascii_hexdigit()) + .next() + .unwrap_or(""); + if hex.len() == 40 { + starts.push(ix); + } + search_from = ix + 1; + } + + starts + .iter() + .enumerate() + .map(|(i, &start)| { + let end = starts.get(i + 1).copied().unwrap_or(patch.len()); + &patch[start..end] + }) + .collect() +} + +/// Parse `git format-patch` output, a single patch or a series. +/// +/// Backed by [`diffy::patch_set`], which implements git's extended diff format: +/// `diff --git` headers, rename and copy detection, binary detection, and +/// C-style quoted or octal-escaped paths. +pub fn patch_diffs(patch: &str) -> Result { + if !patch.lines().any(|line| line.starts_with("diff --git ")) { + return Ok(CommitDiff { files: Vec::new() }); + } + + let mut files = Vec::new(); + + for file in PatchSet::parse(patch, ParseOptions::gitdiff()) { + files.push(file_diff(file?)?); + } + + Ok(CommitDiff { files }) +} + +/// The [`FileDiff`] of one parsed file patch. +fn file_diff(file: FilePatch<'_, str>) -> Result { + // The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path + // component is dropped, the same way `git apply -p1` does. + // Rename and copy paths come from their own headers, unprefixed. + let stripped; + let operation = match file.operation() { + operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation, + operation => { + stripped = operation.strip_prefix(1); + &stripped + } + }; + + let (path, old_path, status) = match operation { + FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added), + FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted), + FileOperation::Modify { modified, .. } => (modified.as_ref(), None, DiffStatus::Modified), + FileOperation::Rename { from, to } => { + (to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed) + } + FileOperation::Copy { from, to } => (to.as_ref(), Some(from.as_ref()), DiffStatus::Copied), + }; + + let mut insertions = 0usize; + let mut deletions = 0usize; + let mut hunks = Vec::new(); + + let patch = file.patch(); + + if let Some(text) = patch.as_text() { + for hunk in text.hunks() { + let hunk = hunk_diff(hunk); + insertions += hunk + .lines + .iter() + .filter(|line| line.kind == DiffLineKind::Addition) + .count(); + deletions += hunk + .lines + .iter() + .filter(|line| line.kind == DiffLineKind::Deletion) + .count(); + hunks.push(hunk); + } + } + + Ok(FileDiff { + path: path.to_owned(), + old_path: old_path.map(str::to_owned), + status, + insertions, + deletions, + binary: patch.is_binary(), + hunks, + }) +} + +/// The [`DiffHunk`] of one parsed hunk, including the line number of every line. +/// +/// `diffy` reports only the hunk header ranges. The per-line numbers are +/// counted from them the way the header encodes them: context lines advance +/// both sides, deletions only the old, insertions only the new. +fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk { + let old_range = hunk.old_range(); + let new_range = hunk.new_range(); + + let mut old = old_range.start() as u32; + let mut new = new_range.start() as u32; + let mut lines = Vec::with_capacity(hunk.lines().len()); + + for line in hunk.lines() { + let (kind, text) = match line { + Line::Context(text) => (DiffLineKind::Context, *text), + Line::Delete(text) => (DiffLineKind::Deletion, *text), + Line::Insert(text) => (DiffLineKind::Addition, *text), + }; + + let (old_no, new_no) = match kind { + DiffLineKind::Context => { + let numbers = (Some(old), Some(new)); + old += 1; + new += 1; + numbers + } + DiffLineKind::Addition => { + let number = Some(new); + new += 1; + (None, number) + } + DiffLineKind::Deletion => { + let number = Some(old); + old += 1; + (number, None) + } + }; + + lines.push(DiffLine { + kind, + old: old_no, + new: new_no, + text: line_text(text), + }); + } + + DiffHunk { + old_start: old_range.start() as u32, + old_lines: old_range.len() as u32, + new_start: new_range.start() as u32, + new_lines: new_range.len() as u32, + lines, + } +} + +/// The content of a parsed line without its line ending. +/// +/// `diffy` keeps the trailing `\n`, the way `str::lines` splits it off. +fn line_text(text: &str) -> String { + let text = text.strip_suffix('\n').unwrap_or(text); + text.strip_suffix('\r').unwrap_or(text).to_owned() +} + +/// Commits of a `git format-patch` output, a single patch or a series. +/// +/// Entries appear in patch order, oldest first as `git format-patch` produces them. +pub fn patch_commits(patch: &str) -> Vec { + let lines: Vec<&str> = patch.lines().collect(); + + let mut commits = Vec::new(); + let mut i = 0; + + while i < lines.len() { + // A patch starts with its `From ` envelope line. + let Some(rest) = lines[i].strip_prefix("From ") else { + i += 1; + continue; + }; + + let Some(id) = rest.split_whitespace().next() else { + i += 1; + continue; + }; + + if id.len() != 40 { + i += 1; + continue; + } + + let mut author = String::new(); + let mut summary = String::new(); + let mut time = 0i64; + + // Envelope headers run up to the blank line before the commit message. + i += 1; + while i < lines.len() && !lines[i].is_empty() { + let header = lines[i]; + if let Some(value) = header.strip_prefix("From: ") { + author = name_from_address(value); + } else if let Some(value) = header.strip_prefix("Subject: ") { + summary = strip_patch_prefix(value); + } else if let Some(value) = header.strip_prefix("Date: ") { + time = gix::date::parse(value.trim(), None) + .map(|t| t.seconds) + .unwrap_or(0); + } + i += 1; + } + + commits.push(FileCommit { + id: id.to_string(), + summary, + description: None, + author, + time, + }); + } + + commits +} + +/// The name part of a `From: Name ` header value. +fn name_from_address(from: &str) -> String { + match from.trim().find('<') { + Some(ix) => from[..ix].trim().to_string(), + None => from.trim().to_string(), + } +} + +/// Strip the patch prefix from a `Subject:` header. +/// +/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`. +fn strip_patch_prefix(subject: &str) -> String { + let trimmed = subject.trim(); + let Some(rest) = trimmed.strip_prefix('[') else { + return trimmed.to_string(); + }; + let Some(end) = rest.find(']') else { + return trimmed.to_string(); + }; + if rest[..end].to_ascii_lowercase().contains("patch") { + rest[end + 1..].trim().to_string() + } else { + trimmed.to_string() + } +} diff --git a/crates/signed_git/src/remote.rs b/crates/signed_git/src/remote.rs new file mode 100644 index 0000000..432eef0 --- /dev/null +++ b/crates/signed_git/src/remote.rs @@ -0,0 +1,357 @@ +use std::collections::HashMap; +use std::path::Path; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result, bail}; +use gix::interrupt::IS_INTERRUPTED; +use gix::progress::Discard; + +/// Clone into `path` from the first working URL in `clone_urls`. +/// +/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache. +pub fn clone_repo>(clone_urls: &[U], path: &Path) -> Result<()> { + if path.exists() { + bail!("destination {} already exists", path.display()); + } + + try_each_url(clone_urls, "clone", |url| { + let repo = clone(url, path)?; + // The initial clone uses the default refspecs. Also fetch the `refs/nostr/*` PR refs. + fetch_all(&repo).ok(); + Ok(()) + }) +} + +/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace. +pub fn fetch_all(repo: &gix::Repository) -> Result<()> { + let options = gix::remote::ref_map::Options { + extra_refspecs: vec![ + gix::refspec::parse( + gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"), + gix::refspec::parse::Operation::Fetch, + )? + .to_owned(), + ], + ..Default::default() + }; + repo.find_remote("origin")? + .connect(gix::remote::Direction::Fetch)? + .prepare_fetch(Discard, options)? + .receive(Discard, &IS_INTERRUPTED)?; + 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") + .arg(repo_path) + .args(["push"]) + .arg(url) + .arg(format!("{commit}:{reference}")) + .env("GIT_TERMINAL_PROMPT", "0") + .stderr(Stdio::piped()) + .output() + .context("failed to spawn `git push`")?; + + if !output.status.success() { + bail!( + "git push failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +/// Rewrite a grasp server URL to the https URL the git transport actually uses. +/// +/// GRASP servers announce `grasp:////` clone URLs. +/// The transport is git smart HTTP, so the scheme is rewritten for gix. +fn transport_url(url: &str) -> String { + url.strip_prefix("grasp://") + .map(|rest| format!("https://{rest}")) + .unwrap_or_else(|| url.to_owned()) +} + +/// Run `attempt` against each URL in `urls` until one succeeds. +/// +/// Returns the last error wrapped in `failed to {verb} from any mirror`, +/// or `no clone URLs provided` when the list is empty. +fn try_each_url, F>(urls: &[U], verb: &str, mut attempt: F) -> Result<()> +where + F: FnMut(&str) -> Result<()>, +{ + let mut last_err: Option = None; + + for url in urls { + match attempt(url.as_ref()) { + Ok(()) => return Ok(()), + Err(e) => last_err = Some(e), + } + } + + match last_err { + Some(e) => Err(e).context(format!("failed to {verb} from any mirror")), + None => bail!("no clone URLs provided"), + } +} + +fn clone(url: &str, path: &Path) -> Result { + let url = transport_url(url); + let url = gix::url::parse(url).context("invalid clone URL")?; + + let mut prepare = gix::prepare_clone(url, path)?; + let (mut checkout, _fetch) = prepare.fetch_then_checkout(Discard, &IS_INTERRUPTED)?; + let (repo, _checkout) = checkout.main_worktree(Discard, &IS_INTERRUPTED)?; + + 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, + base_url, + owner, + repo_id, + &["refs/heads/main:refs/heads/main"], + ) +} + +/// Push every local branch and tag of the repository at `repo_path` to a grasp server. +/// +/// This mirrors an initialized repository's whole history. +pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { + push_refspecs( + repo_path, + base_url, + owner, + repo_id, + &["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], + ) +} + +/// Push `refspecs` to the grasp server URL derived from `base_url`, `owner` and `repo_id`. +fn push_refspecs( + repo_path: &Path, + base_url: &str, + owner: &str, + repo_id: &str, + refspecs: &[&str], +) -> Result<()> { + let url = format!("{base_url}/{owner}/{repo_id}.git"); + + let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2); + args.push("push"); + args.push(&url); + args.extend_from_slice(refspecs); + + let output = git_output(repo_path, &args, "git push")?; + + if !output.status.success() { + bail!( + "git push to {base_url} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +/// Whether `url` advertises every ref in `expected` at the given commit. +/// +/// Extra advertised refs are ignored: the question is whether the data this +/// push wanted to land is already there, not whether the remote is an exact mirror. +/// This is the convergence probe for a push that lost the compare-and-swap race +/// to the grasp server's own background ref alignment. +pub fn remote_has_refs(repo_path: &Path, url: &str, expected: &[(String, String)]) -> Result { + if expected.is_empty() { + return Ok(true); + } + + let repo = gix::open(repo_path)?; + let url = transport_url(url); + + // A URL-created remote has no configured fetch refspecs, and `ref_map` only + // keeps refs that match one. Match each expected ref by its exact name, + // like `git ls-remote ` would; ref maps never write to the repository. + let refspecs = expected + .iter() + .map(|(name, _)| { + gix::refspec::parse( + gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()), + gix::refspec::parse::Operation::Fetch, + ) + .map(|spec| spec.to_owned()) + }) + .collect::, _>>() + .context("invalid refspec")?; + + let options = gix::remote::ref_map::Options { + extra_refspecs: refspecs, + ..Default::default() + }; + + let (refs, _) = repo + .remote_at(url.as_str()) + .with_context(|| format!("cannot use remote {url}"))? + .connect(gix::remote::Direction::Fetch) + .with_context(|| format!("cannot connect to {url}"))? + .ref_map(Discard, options) + .with_context(|| format!("listing refs of {url} failed"))?; + + // Peeled tag entries carry the tag object in their direct oid, so mapping + // each advertised ref to its direct oid matches `git ls-remote` while + // skipping the duplicated `^{}` lines. + let advertised: HashMap = refs + .remote_refs + .iter() + .filter_map(|reference| { + let (name, object, _peeled) = reference.unpack(); + object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string())) + }) + .collect(); + + Ok(expected + .iter() + .all(|(name, oid)| advertised.get(name.as_str()) == Some(oid))) +} + +/// Add `origin` pointing at `url` when the repository has no remote yet. +/// +/// No-op if `origin` already exists. +pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> { + let repo = gix::open(repo_path)?; + if repo.find_remote("origin").is_ok() { + return Ok(()); + } + + // `git remote add` also configures the default fetch refspec. + edit_local_config(&repo, |config| { + config.set_raw_value("remote.origin.url", url)?; + config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; + Ok(()) + }) +} + +/// Point `origin` at `url`, replacing an existing remote, +/// used after a clone whose `origin` points at the cloned-from path. +/// +/// A working copy cloned from a local mirror is re-targeted at the grasp server. +pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> { + let repo = gix::open(repo_path)?; + let had_origin = repo.find_remote("origin").is_ok(); + + edit_local_config(&repo, |config| { + // Replaces the existing url, like `git remote set-url origin `. + // A pre-existing fetch refspec is left untouched. + config.set_raw_value("remote.origin.url", url)?; + + if !had_origin { + config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; + } + + Ok(()) + }) +} + +/// Apply `edit` to the repository-local configuration and persist it. +/// +/// The config file is locked while it is read, edited and written back, +/// like git would when running `git config` or `git remote`. +fn edit_local_config( + repo: &gix::Repository, + edit: impl FnOnce(&mut gix::config::File) -> Result<()>, +) -> Result<()> { + let config_path = repo.common_dir().join("config"); + + let mut lock = gix::lock::File::acquire_to_update_resource( + &config_path, + gix::lock::acquire::Fail::Immediately, + None, + ) + .context("failed to lock repository config")?; + + let mut config = + match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local) { + Ok(config) => config, + // A repository without a config file yet starts from scratch. + Err(gix::config::file::init::from_paths::Error::Io { source, .. }) + if source.kind() == std::io::ErrorKind::NotFound => + { + gix::config::File::default() + } + Err(error) => return Err(error).context("failed to read repository config"), + }; + + edit(&mut config)?; + + config + .write_to(&mut lock) + .context("failed to write repository config")?; + + lock.commit().context("failed to save repository config")?; + + Ok(()) +} + +/// Fetch `refspec` into `repo_path` from the first working URL in `urls`. +/// When no URL works, the last error is returned. +/// +/// Never touches the checked-out refs or the worktree. +pub fn fetch_repo_refs>(repo_path: &Path, urls: &[U], refspec: &str) -> Result<()> { + let repo = gix::open(repo_path)?; + let refspec = gix::refspec::parse( + gix::bstr::BStr::new(refspec), + gix::refspec::parse::Operation::Fetch, + ) + .context("invalid fetch refspec")? + .to_owned(); + + try_each_url(urls, "fetch", |url| { + let url = transport_url(url); + let options = gix::remote::ref_map::Options { + extra_refspecs: vec![refspec.clone()], + ..Default::default() + }; + repo.remote_at(url.as_str()) + .with_context(|| format!("fetch from {url} failed"))? + .connect(gix::remote::Direction::Fetch) + .with_context(|| format!("fetch from {url} failed"))? + .prepare_fetch(Discard, options) + .with_context(|| format!("fetch from {url} failed"))? + .receive(Discard, &IS_INTERRUPTED) + .with_context(|| format!("fetch from {url} failed"))?; + Ok(()) + }) +} + +/// The URL of the `origin` remote of the repository at `workdir`. +/// +/// `None` when it has no `origin` yet. +pub fn origin_url(workdir: &Path) -> Result> { + let Ok(repo) = gix::open(workdir) else { + return Ok(None); + }; + + let Ok(remote) = repo.find_remote("origin") else { + return Ok(None); + }; + + Ok(remote + .url(gix::remote::Direction::Fetch) + .map(|url| url.to_string())) +} + +/// Run `git -C dir args`, disabling the terminal prompt and capturing stderr. +/// +/// `what` names the command in the spawn error. +pub(crate) fn git_output(dir: &Path, args: &[&str], what: &str) -> Result { + Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .env("GIT_TERMINAL_PROMPT", "0") + .stderr(Stdio::piped()) + .output() + .with_context(|| format!("failed to spawn `{what}`")) +} diff --git a/crates/signed_git/src/repo.rs b/crates/signed_git/src/repo.rs new file mode 100644 index 0000000..4b86afb --- /dev/null +++ b/crates/signed_git/src/repo.rs @@ -0,0 +1,488 @@ +use std::path::Path; + +use anyhow::{Context, Result, bail}; + +use crate::history::open_with_cache; +use crate::worktree::{force_checkout, worktree_dirty}; + +/// The merge base of two revisions in the repository at `repo_path`, +/// revisions may be branch names, remote-tracking refs or commit ids. +/// +/// `Ok(None)` when the revisions share no common ancestor. +/// +/// Unresolvable revisions are errors. +pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result> { + let repo = open_with_cache(repo_path)?; + let a = repo.rev_parse_single(a.as_bytes())?; + let b = repo.rev_parse_single(b.as_bytes())?; + match repo.merge_base(a, b) { + Ok(id) => Ok(Some(id.to_string())), + // No common ancestor, a valid outcome for a proposal. + Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None), + Err(e) => Err(e.into()), + } +} + +/// The commit HEAD points to in the repository at `repo_path`. +/// +/// `None` when the repository has no commits yet, an unborn HEAD. +pub fn head_commit_id(repo_path: &Path) -> Result> { + let Ok(repo) = gix::open(repo_path) else { + return Ok(None); + }; + + match repo.head_id() { + Ok(id) => Ok(Some(id.to_string())), + Err(_) => Ok(None), + } +} + +/// The commits in `base..HEAD` of the repository at `repo_path`, oldest first. +/// This is the order `git am` creates them. +/// +/// `HEAD` alone when `base` is `None`. +pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result> { + let repo = match gix::open(repo_path) { + Ok(repo) => repo, + Err(_) if base.is_none() => return Ok(Vec::new()), + Err(e) => return Err(e.into()), + }; + + let head = match repo.head_id() { + Ok(head) => head, + Err(_) if base.is_none() => return Ok(Vec::new()), + Err(e) => return Err(e).context("repository has no commits"), + }; + + let Some(base) = base else { + // `HEAD` alone when no base is given. + return Ok(vec![head.to_string()]); + }; + + let base = repo.rev_parse_single(base.as_bytes())?; + let mut commits = Vec::new(); + + for info in repo + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, + )) + .with_hidden([base]) + .all()? + { + commits.push(info?.id().to_string()); + } + + // Oldest first, like `git rev-list --reverse`, the order `git am` creates them. + commits.reverse(); + + Ok(commits) +} + +/// The identity written to reflogs and commits created by this crate itself. +/// +/// Like `git -c user.name=… -c user.email=…` per invocation: the repository works +/// without a global git identity, and `gix` runs no hooks and never signs. +pub(crate) fn repository_signature() -> (gix::actor::Signature, gix::date::parse::TimeBuf) { + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or_default(); + + let signature = gix::actor::Signature { + name: gix::bstr::BString::from("Signed"), + email: gix::bstr::BString::from("signed@localhost"), + time: gix::date::Time { seconds, offset: 0 }, + }; + + (signature, gix::date::parse::TimeBuf::default()) +} + +/// Create a repository at `path` with an initial `main` branch. +/// Write a `README.md` from `name` and `description`, then create the initial commit. +/// +/// Returns the initial commit id. +pub fn init_repository(path: &Path, name: &str, description: &str) -> Result { + use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; + + std::fs::create_dir_all(path) + .with_context(|| format!("failed to create {}", path.display()))?; + + let repo = gix::init(path)?; + + let (signature, mut time_buf) = repository_signature(); + let signature = signature.to_ref(&mut time_buf); + + // The initial branch is `main`, regardless of `init.defaultBranch` in + // the user's git configuration: point the unborn HEAD there. + let head = gix::refs::FullName::try_from("HEAD") + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; + + repo.edit_references_as( + [RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: "checkout: moving to main".into(), + }, + expected: PreviousValue::Any, + new: gix::refs::Target::Symbolic( + gix::refs::FullName::try_from("refs/heads/main") + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?, + ), + }, + name: head, + deref: false, + }], + Some(signature), + )?; + + let readme = if description.trim().is_empty() { + format!("# {name}\n") + } else { + format!("# {name}\n\n{description}\n") + }; + + std::fs::write(path.join("README.md"), &readme).context("failed to write README.md")?; + + let blob = repo.write_object(gix::objs::Blob { + data: readme.into_bytes(), + })?; + + let tree = repo.write_object(gix::objs::Tree { + entries: vec![gix::objs::tree::Entry { + mode: gix::objs::tree::EntryKind::Blob.into(), + filename: gix::bstr::BString::from("README.md"), + oid: blob.into(), + }], + })?; + + let commit = repo.commit_as( + signature, + signature, + "HEAD", + "Initial commit", + tree, + Vec::::new(), + )?; + + // Populate the index so the fresh repository is clean, + // as `git add` and`git commit` would leave it. + let mut index = repo.index_from_tree(&tree)?; + index.write(gix::index::write::Options::default())?; + + let commit = commit.to_string(); + if commit.len() != 40 { + bail!("unexpected initial commit id: {commit}"); + } + + Ok(commit) +} + +/// The earliest unique commit of the repository at `repo_path`. +/// Used as the NIP-34 announcement's `euc` marker. +/// +/// `None` for a repository without commits. +pub fn root_commit(repo_path: &Path) -> Result> { + let Ok(repo) = gix::open(repo_path) else { + return Ok(None); + }; + + let Ok(head) = repo.head_id() else { + // An unborn HEAD with no commits yet has no root commit. + return Ok(None); + }; + + for info in repo + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, + )) + .all()? + { + let info = info?; + if info.parent_ids().next().is_none() { + let id = info.id().to_string(); + return Ok((id.len() == 40).then_some(id)); + } + } + + Ok(None) +} + +/// Full ref names under `prefix`, sorted lexicographically, like `git for-each-ref`. +/// `prefix` is a ref namespace like `refs/fork//`. +/// +/// Returns an empty list when nothing matches. +pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result> { + let pattern = prefix.trim_end_matches('/'); + let repo = gix::open(repo_path)?; + let mut names = Vec::new(); + + for reference in repo.references()?.all()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + let name = String::from_utf8_lossy(reference.name().as_bstr()).into_owned(); + + // Match the pattern itself and everything beneath it, like `git for-each-ref`. + let under_pattern = name + .strip_prefix(pattern) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')); + + if under_pattern { + names.push(name); + } + } + + // Sort lexicographically, like `git for-each-ref`. + names.sort(); + + Ok(names) +} + +/// Delete every ref under `prefix` of the repository at `repo_path`. +/// `prefix` is a ref namespace like `refs/fork//`. +pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> { + use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog}; + + let refs = refs_with_prefix(repo_path, prefix)?; + if refs.is_empty() { + return Ok(()); + } + + let repo = gix::open(repo_path)?; + let edits: Vec = refs + .iter() + .map(|name| { + let full = gix::refs::FullName::try_from(name.as_str()) + .map_err(|e| anyhow::anyhow!("invalid ref name {name}: {e}"))?; + Ok(RefEdit { + change: Change::Delete { + expected: PreviousValue::Any, + log: RefLog::AndReference, + }, + name: full, + deref: false, + }) + }) + .collect::>>()?; + + // Delete all refs with the given prefix. + repo.edit_references(edits)?; + + Ok(()) +} + +/// Short name of the branch HEAD points to at `workdir`, +/// `None` when detached or unreadable, like `git branch --show-current`. +pub fn worktree_current_branch(workdir: &Path) -> Option { + let repo = gix::open(workdir).ok()?; + let head = repo.head().ok()?; + let name = head.referent_name()?; + 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; + }; + repo.find_reference(name).is_ok() +} + +/// Fast-forward local branches that trail their remote-tracking counterpart. +/// +/// Returns whether any branch moved. +pub fn fast_forward_branches(workdir: &Path) -> Result { + let repo = gix::open(workdir)?; + let current = worktree_current_branch(workdir); + let heads = refs_with_prefix(workdir, "refs/heads")?; + + let (signature, mut time_buf) = repository_signature(); + let signature = signature.to_ref(&mut time_buf); + + let mut moved = false; + + for head in heads { + let Some(branch) = head.strip_prefix("refs/heads/") else { + continue; + }; + + let remote = format!("refs/remotes/origin/{branch}"); + // No remote-tracking counterpart means the remote lacks this branch. + let Ok(mut remote_reference) = repo.find_reference(&remote) else { + continue; + }; + + let Ok(mut local_reference) = repo.find_reference(&head) else { + continue; + }; + + let Ok(remote_oid) = remote_reference.peel_to_id() else { + continue; + }; + + let Ok(local_oid) = local_reference.peel_to_id() else { + continue; + }; + + let remote_oid = remote_oid.detach(); + let local_oid = local_oid.detach(); + + if local_oid == remote_oid { + continue; + } + + // Only fast-forward. + // Local-only commits or diverged history must never be rewritten by a refresh. + let Ok(base) = repo.merge_base(local_oid, remote_oid) else { + continue; + }; + + if base != local_oid { + continue; + } + + let full = gix::refs::FullName::try_from(head.as_str()) + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; + + let edit = |new: gix::refs::Target| { + use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; + RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: format!("merge {remote}: Fast-forward").into(), + }, + expected: PreviousValue::ExistingMustMatch(gix::refs::Target::Object( + local_oid, + )), + new, + }, + name: full.clone(), + deref: false, + } + }; + + if current.as_deref() == Some(branch) { + // Merge so the checked-out worktree follows the branch. + // Only proceed on a clean worktree, like `git merge --ff-only`. + if worktree_dirty(workdir) { + continue; + } + + 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), + )?; + + 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), + )?; + + moved = true; + } + } + + Ok(moved) +} + +/// Short names of local branches, `refs/heads/*`, of `repo`, sorted alphabetically. +pub fn repo_branches(repo: &gix::Repository) -> Result> { + let mut names = Vec::new(); + for reference in repo.references()?.local_branches()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); + } + names.sort(); + Ok(names) +} + +/// Short names of tags, `refs/tags/*`, of `repo`, sorted alphabetically. +pub fn repo_tags(repo: &gix::Repository) -> Result> { + let mut names = Vec::new(); + for reference in repo.references()?.tags()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); + } + names.sort(); + Ok(names) +} + +/// Short names of local branches, `refs/heads/*`, sorted alphabetically. +pub fn worktree_branches(workdir: &Path) -> Result> { + repo_branches(&gix::open(workdir)?) +} + +/// Short name of the branch HEAD points to, or `None` when detached. +/// +/// Detached after checking out a tag or a commit directly. +pub fn current_branch(repo: &gix::Repository) -> Result> { + let head = repo.head()?; + let Some(name) = head.referent_name() else { + return Ok(None); + }; + Ok(Some(String::from_utf8_lossy(name.shorten()).into_owned())) +} + +/// Branch, tag and HEAD refs of a repository. +/// +/// Ready for a NIP-34 kind-30618 repository state announcement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RepoRefState { + /// `(full refname, commit id)` pairs for heads and tags, sorted. + pub refs: Vec<(String, String)>, + /// Short branch name HEAD points to, or `None` when detached. + pub head: Option, +} + +/// 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 { + let mut refs = Vec::new(); + + for reference in repo.references()?.local_branches()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + refs.push(( + String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), + reference.id().to_string(), + )); + } + + for reference in repo.references()?.tags()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + refs.push(( + String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), + reference.id().to_string(), + )); + } + refs.sort(); + + let head = match repo.head() { + Ok(head) => head + .referent_name() + .filter(|name| name.as_bstr().starts_with(b"refs/heads/")) + .map(|name| String::from_utf8_lossy(name.shorten()).into_owned()), + Err(_) => None, + }; + + Ok(RepoRefState { refs, head }) +} + +/// [`repo_ref_state`] for the repository at `workdir`. +pub fn worktree_ref_state(workdir: &Path) -> Result { + repo_ref_state(&gix::open(workdir)?) +} diff --git a/crates/signed_git/src/scan.rs b/crates/signed_git/src/scan.rs new file mode 100644 index 0000000..718cb3e --- /dev/null +++ b/crates/signed_git/src/scan.rs @@ -0,0 +1,43 @@ +use std::path::{Path, PathBuf}; + +use ignore::WalkBuilder; + +/// Maximum directory nesting depth when scanning for local repositories. +/// +/// 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. +pub fn find_git_repos(root: &Path) -> Vec { + if !root.is_dir() { + return Vec::new(); + } + + let walker = WalkBuilder::new(root) + .max_depth(Some(SCAN_MAX_DEPTH)) + // Honour `.gitignore` even when the scan root is not itself a repository. + .require_git(false) + .build(); + + let mut repos: Vec = walker + .flatten() + .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_dir())) + .map(ignore::DirEntry::into_path) + .filter(|dir| dir.join(".git").exists()) + .filter_map(|dir| dir.canonicalize().ok()) + .collect(); + + repos.sort(); + repos.dedup(); + + // A repository nested inside another, like a submodule worktree, is not reported. + let mut roots: Vec = Vec::with_capacity(repos.len()); + for repo in repos { + if !roots.iter().any(|kept| repo.starts_with(kept)) { + roots.push(repo); + } + } + + roots +} diff --git a/crates/signed_git/src/tests.rs b/crates/signed_git/src/tests.rs new file mode 100644 index 0000000..c73b066 --- /dev/null +++ b/crates/signed_git/src/tests.rs @@ -0,0 +1,1792 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +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(".."), "_"); + assert_eq!(sanitize_path_component("."), "_"); + // Separators are neutralized before the check, so these stay safe. + assert_eq!(sanitize_path_component("../.."), ".._.."); + 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(); + let root = temp.path(); + + // Repositories are found at any depth. + // A linked worktree, with a `.git` file instead of a directory, counts too. + let nested = root.join("a/b/project"); + std::fs::create_dir_all(nested.join(".git")).unwrap(); + let worktree = root.join("wt"); + std::fs::create_dir_all(&worktree).unwrap(); + std::fs::write( + worktree.join(".git"), + "gitdir: ../a/b/project/.git/worktrees/wt", + ) + .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. + 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(); + + let mut found = find_git_repos(root); + found.sort(); + + let mut expected = vec![ + nested.canonicalize().unwrap(), + worktree.canonicalize().unwrap(), + outer.canonicalize().unwrap(), + ]; + expected.sort(); + assert_eq!(found, expected); +} + +#[test] +fn root_commit_reports_the_first_ancestor() { + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + + let dir = dir.path(); + let root = root_commit(dir).expect("root").expect("commit"); + assert_eq!(root.len(), 40); + + // The root commit does not change when history grows. + std::fs::write(dir.join("b.txt"), b"two").expect("write"); + commit_all(&repo, "second"); + assert_eq!( + root_commit(dir).expect("root").as_deref(), + Some(root.as_str()) + ); +} + +#[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. + // 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(); + + // 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"); + git_run(dir, &["checkout", "-"]); + git_run(dir, &["tag", "v1.0"]); + + 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/heads/main")); + assert!(refs.contains("refs/heads/feature")); + 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(); + 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 main = git_in(dir, &["rev-parse", "refs/heads/main"]).expect("main oid"); + 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( + dir, + &format!("file://{}", server.path().display()), + "npub1test", + "my-repo", + ) + .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. + let stale = vec![("refs/heads/main".to_owned(), "0".repeat(40))]; + assert!(!remote_has_refs(dir, &url, &stale).expect("probe")); + + // Extra remote refs (e.g. a tag pushed later) do not invalidate the + // refs this push wanted to land. + git_run(dir, &["tag", "v1.0"]); + push_all( + dir, + &format!("file://{}", server.path().display()), + "npub1test", + "my-repo", + ) + .expect("push"); + assert!(remote_has_refs(dir, &url, &expected).expect("probe")); +} + +#[test] +fn repo_ref_state_lists_branches_tags_and_head() { + let (_dir, repo) = fixture(&[("a.txt", b"hello")]); + commit_all(&repo, "initial"); + let workdir = repo.workdir().expect("workdir").to_path_buf(); + + let state = repo_ref_state(&repo).expect("refs"); + + let branch = current_branch(&repo).expect("branch").expect("on a branch"); + assert_eq!(state.head.as_deref(), Some(branch.as_str())); + assert_eq!(state.refs.len(), 1); + 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"]); + + let state = repo_ref_state(&repo).expect("refs"); + let mut expected: Vec = vec![ + format!("refs/heads/{branch}"), + "refs/heads/feature".to_owned(), + "refs/tags/v1.0".to_owned(), + ]; + expected.sort(); + assert_eq!( + state + .refs + .iter() + .map(|(name, _)| name.clone()) + .collect::>(), + 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()); + assert_eq!(state.refs.len(), 3); +} + +/// Build a throwaway non-bare repository from `(rel, bytes)` file pairs. +fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) { + let dir = tempfile::tempdir().expect("tempdir"); + let repo = gix::init(&dir).expect("init"); + + for (rel, bytes) in files { + let path = dir.path().join(rel); + std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + std::fs::write(&path, bytes).expect("write"); + } + + (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 = 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) { + git_run(repo.workdir().expect("workdir"), &["add", "-A"]); + git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]); +} + +#[test] +fn merge_base_finds_the_fork_point_and_reports_unrelated_history() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("repo"); + let initial = init_repository(&path, "My Repo", "desc").expect("init"); + + // A feature branch and a mainline commit diverge from the initial commit. + // The initial commit is their merge base. + 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"); + git_run(&path, &["checkout", "main"]); + std::fs::write(path.join("main.txt"), "main\n").expect("write"); + commit_all(&gix::open(&path).expect("open"), "mainline commit"); + + assert_eq!( + merge_base(&path, "feature", "main") + .expect("merge base") + .as_deref(), + Some(initial.as_str()) + ); + + // An orphan branch shares no history with main, so `Ok(None)`. + git_run(&path, &["checkout", "--orphan", "orphan"]); + std::fs::write(path.join("orphan.txt"), "orphan\n").expect("write"); + commit_all(&gix::open(&path).expect("open"), "orphan commit"); + assert_eq!(merge_base(&path, "orphan", "main").expect("ok"), None); + + // An unresolvable revision is an error, not a missing ancestor. + 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"); + 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("one.txt"), "one\n").expect("write"); + commit_all(&gix::open(&path).expect("open"), "first commit"); + std::fs::write(path.join("two.txt"), "two\n").expect("write"); + commit_all(&gix::open(&path).expect("open"), "second commit"); + + let series = format_patch_between(&path, &initial, "feature").expect("series"); + let parts = split_patch_series(&series); + + assert_eq!(parts.len(), 2); + assert!(parts[0].contains("Subject: [PATCH 1/2] first commit")); + assert!(parts[1].contains("Subject: [PATCH 2/2] second commit")); + // Each part starts its own mbox message with its own commit id. + let first = parts[0].lines().next().expect("first header"); + let second = parts[1].lines().next().expect("second header"); + assert!(first.starts_with("From ") && first.len() >= 45); + 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 \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"); + let path = dir.path().join("repo"); + let initial = init_repository(&path, "My Repo", "desc").expect("init"); + + assert_eq!( + head_commit_id(&path).expect("head").as_deref(), + Some(initial.as_str()) + ); + // No commits yet, `HEAD` alone. + assert_eq!( + commits_since(&path, None).expect("commits"), + vec![initial.clone()] + ); + + std::fs::write(path.join("one.txt"), "one\n").expect("write"); + commit_all(&gix::open(&path).expect("open"), "first commit"); + let first = head_commit_id(&path).expect("head").expect("on a branch"); + + std::fs::write(path.join("two.txt"), "two\n").expect("write"); + commit_all(&gix::open(&path).expect("open"), "second commit"); + let second = head_commit_id(&path).expect("head").expect("on a branch"); + + // Oldest first, like the order `git am` creates them. + assert_eq!( + commits_since(&path, Some(&initial)).expect("commits"), + vec![first.clone(), second.clone()] + ); + assert_eq!( + commits_since(&path, Some(&first)).expect("commits"), + vec![second] + ); +} + +#[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::::new() + ); +} + +#[test] +fn init_repository_creates_main_branch_and_readme() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("my-repo"); + + let commit = init_repository(&path, "My Repo", "Does things.\n\nCool.").expect("init"); + assert_eq!(commit.len(), 40); + + 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\nDoes things.\n\nCool.\n" + ); + + let branch = current_branch(&repo).expect("branch").expect("on a branch"); + assert_eq!(branch, "main"); + // [`FileCommit`] carries the short id, the full id is 40 chars. + assert_eq!( + head_commit(&repo).expect("head").expect("commit").id, + &commit[..7] + ); + + let state = repo_ref_state(&repo).expect("refs"); + assert_eq!(state.head.as_deref(), Some("main")); + assert_eq!(state.refs, vec![("refs/heads/main".to_owned(), commit)]); + + // The index matches the committed tree, so the fresh repo is clean. + 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(), + Some("https://gitnostr.com/npub1test/repo.git") + ); + + // An existing origin is replaced, not duplicated. + // A clone's origin points at the cloned-from path. + // It is re-targeted at the grasp server. + set_origin(&path, "https://grasp.example/npub1test/repo.git").expect("replace"); + assert_eq!( + origin_url(&path).expect("url").as_deref(), + Some("https://grasp.example/npub1test/repo.git") + ); +} + +#[test] +fn working_copy_cloned_from_the_mirror_matches_head_and_origin() { + // The mirror is a freshly initialized repository, standing in for + // the grasp server. Its `origin` points at the (fake) grasp server. + // `GitCache::ensure_clone` lazily clones from a URL shaped like this + // the first time a repository is opened. + let dir = tempfile::tempdir().expect("tempdir"); + let mirror = dir.path().join("mirror"); + let commit = init_repository(&mirror, "My Repo", "Does things.").expect("init"); + ensure_origin(&mirror, "https://gitnostr.com/npub1test/my-repo.git").expect("origin"); + + // The working copy is cloned from the mirror. + // It then shares the announced history exactly. + // `origin` is re-pointed at the grasp server instead of the mirror path. + let destination = dir.path().join("folder").join("My_Repo"); + std::fs::create_dir_all(destination.parent().unwrap()).expect("parent"); + clone_repo(&[format!("file://{}", mirror.display())], &destination).expect("clone"); + set_origin(&destination, "https://gitnostr.com/npub1test/my-repo.git").expect("set origin"); + + assert_eq!( + origin_url(&destination).expect("url").as_deref(), + Some("https://gitnostr.com/npub1test/my-repo.git") + ); + assert_eq!( + head_commit_id(&destination).expect("head").as_deref(), + Some(commit.as_str()) + ); + assert!(destination.join("README.md").is_file()); +} + +#[test] +fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { + // A bare server, like a grasp server's `{base}/{owner}/{repo}.git` layout. + let dir = tempfile::tempdir().expect("tempdir"); + let base_server = dir.path().join("npub1test").join("repo.git"); + std::fs::create_dir_all(base_server.parent().unwrap()).unwrap(); + let init_status = Command::new("git") + .args(["init", "--bare", "-q"]) + .arg(&base_server) + .status() + .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(); + let base_url = format!("file://{}", dir.path().display()); + push_all(work, &base_url, "npub1test", "repo").expect("push"); + + // A mirror clone, like the app's GitCache clones. + let mirror = dir.path().join("mirror"); + git_run( + dir.path(), + &[ + "clone", + "-q", + &format!("{base_url}/npub1test/repo.git"), + mirror.to_str().unwrap(), + ], + ); + let initial = git_in(&mirror, &["rev-parse", "HEAD"]).expect("initial"); + + // The owner pushes a new commit. + // The mirror fetches it, but its local `main` and worktree stay behind. + std::fs::write(work.join("new.txt"), b"new\n").expect("write"); + commit_all(&gix::open(work).expect("open"), "new commit"); + push_all(work, &base_url, "npub1test", "repo").expect("push"); + git_run(&mirror, &["fetch", "origin"]); + let remote = git_in(&mirror, &["rev-parse", "refs/remotes/origin/main"]).expect("remote"); + assert_eq!( + git_in(&mirror, &["rev-parse", "HEAD"]).expect("local"), + initial + ); + assert_ne!(remote, initial); + + // Fast-forwarding catches the branch and its worktree up. + // The second call has nothing left to move. + assert!(fast_forward_branches(&mirror).expect("ff")); + assert_eq!( + git_in(&mirror, &["rev-parse", "HEAD"]).expect("local"), + remote + ); + assert!(mirror.join("new.txt").is_file()); + assert!(!fast_forward_branches(&mirror).expect("idle")); + + // A branch with local commits of its own is never touched. + git_run(&mirror, &["checkout", "-b", "wip"]); + std::fs::write(mirror.join("wip.txt"), b"wip\n").expect("write"); + commit_all(&gix::open(&mirror).expect("open"), "local wip"); + let wip = git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip"); + assert!(!fast_forward_branches(&mirror).expect("wip skipped")); + assert_eq!( + git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip kept"), + wip + ); +} + +#[test] +fn fetch_repo_refs_imports_heads_under_a_prefix() { + let dir = tempfile::tempdir().expect("tempdir"); + + // A bare base server holding the initial commit. + // Like a grasp server's `{base}/{owner}/{repo-id}.git` layout. + let base_server = dir.path().join("npub1base").join("base.git"); + std::fs::create_dir_all(base_server.parent().unwrap()).unwrap(); + let init_status = Command::new("git") + .args(["init", "--bare", "-q"]) + .arg(&base_server) + .status() + .expect("spawn git init --bare"); + assert!(init_status.success()); + + let (upstream_dir, upstream_repo) = fixture(&[("a.txt", b"one")]); + commit_all(&upstream_repo, "initial"); + let upstream_path = upstream_dir.path(); + let initial = git_in(upstream_path, &["rev-parse", "HEAD"]).expect("initial"); + push_all( + upstream_path, + &format!("file://{}", dir.path().display()), + "npub1base", + "base", + ) + .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( + dir.path(), + &["clone", "-q", &base_url, mirror.to_str().unwrap()], + ); + + // The fork server has the same initial commit. + // It also carries a feature commit on its own `feature` branch. + let fork_work = dir.path().join("fork-work"); + git_run( + dir.path(), + &["clone", "-q", &base_url, fork_work.to_str().unwrap()], + ); + git_run(&fork_work, &["checkout", "-b", "feature"]); + std::fs::write(fork_work.join("feature.txt"), "feature\n").expect("write"); + commit_all(&gix::open(&fork_work).expect("open"), "feature commit"); + let tip = git_in(&fork_work, &["rev-parse", "HEAD"]).expect("tip"); + + let fork_server = dir.path().join("npub1fork").join("fork.git"); + std::fs::create_dir_all(fork_server.parent().unwrap()).unwrap(); + let init_status = Command::new("git") + .args(["init", "--bare", "-q"]) + .arg(&fork_server) + .status() + .expect("spawn git init --bare"); + assert!(init_status.success()); + push_commit_ref( + &fork_work, + &format!("file://{}", fork_server.display()), + &tip, + "refs/heads/feature", + ) + .expect("push"); + + // Import the fork's heads into the mirror under a private prefix. + // The first dead URL is skipped, the second works. + let dead = format!("file://{}/missing.git", dir.path().display()); + fetch_repo_refs( + &mirror, + &[dead, format!("file://{}", fork_server.display())], + "+refs/heads/*:refs/fork/npub1fork/fork/*", + ) + .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"] + ); + // Nothing leaked into the normal ref namespaces. + assert_eq!( + refs_with_prefix(&mirror, "refs/heads/fork").expect("refs"), + Vec::::new() + ); + + // The mirror can now range across both histories. + // The fork point is the shared initial commit, the proposal covers the fork commit. + assert_eq!( + merge_base( + &mirror, + "refs/remotes/origin/main", + "refs/fork/npub1fork/fork/feature", + ) + .expect("merge base") + .as_deref(), + Some(initial.as_str()) + ); + let patch = + format_patch_between(&mirror, &initial, "refs/fork/npub1fork/fork/feature").expect("patch"); + 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"), + Vec::::new() + ); +} + +#[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") + .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(args) + .status() + .expect("spawn git"); + assert!(status.success(), "git {args:?} failed"); +} + +#[test] +fn last_commit_returns_most_recent_change() { + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + + std::fs::write(dir.path().join("a.txt"), b"two").expect("write"); + commit_all(&repo, "change a"); + + // A commit touching another file must not be reported for a.txt. + std::fs::write(dir.path().join("b.txt"), b"other").expect("write"); + commit_all(&repo, "add b"); + + let commit = last_commit(&repo, Path::new("a.txt")) + .expect("lookup") + .expect("found"); + assert_eq!(commit.summary, "change a"); + assert_eq!(commit.author, "Test Author"); + assert!(!commit.id.is_empty()); + assert!(commit.time > 0); +} + +#[test] +fn all_commits_lists_every_commit() { + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + + std::fs::write(dir.path().join("a.txt"), b"two").expect("write"); + commit_all(&repo, "second"); + std::fs::write(dir.path().join("b.txt"), b"b").expect("write"); + commit_all(&repo, "third"); + + let list = all_commits(&repo).expect("commits"); + assert_eq!(list.total, 3); + let mut summaries: Vec<&str> = list.commits.iter().map(|c| c.summary.as_str()).collect(); + summaries.sort(); + assert_eq!(summaries, vec!["initial", "second", "third"]); + assert!( + list.commits + .iter() + .all(|c| c.author == "Test Author" && !c.id.is_empty() && c.time > 0) + ); +} + +#[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")]); + commit_all(&repo, "initial"); + + let run = |args: &[&str]| { + let status = Command::new("git") + .current_dir(dir.path()) + .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(args) + .status() + .expect("spawn git"); + assert!(status.success(), "git {args:?} failed"); + }; + run(&["checkout", "-b", "feature"]); + std::fs::write(dir.path().join("a.txt"), b"feature").expect("write"); + commit_all(&repo, "feature change"); + run(&["checkout", "-"]); + // `--no-ff` forces a merge commit, it is the latest commit changing a.txt. + run(&["merge", "--no-ff", "--no-edit", "feature"]); + + let commit = last_commit(&repo, Path::new("a.txt")) + .expect("lookup") + .expect("found"); + assert_eq!( + commit.id, + repo.head_id().expect("head").shorten_or_id().to_string() + ); + 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")]); + + let readme = find_readme(&repo).expect("find"); + assert_eq!( + readme.map(|p| p.to_string_lossy().into_owned()), + Some("README.md".into()) + ); +} + +#[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")]); + commit_all(&repo, "initial"); + let dir = dir.path(); + + let default = worktree_branches(dir) + .expect("branches") + .into_iter() + .next() + .expect("default branch"); + assert_eq!( + current_branch(&repo).expect("branch").as_deref(), + Some(default.as_str()) + ); + + git_run(dir, &["checkout", "-b", "feature"]); + assert_eq!( + current_branch(&repo).expect("branch").as_deref(), + 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(), + Some(default.as_str()) + ); +} + +#[test] +fn worktree_snapshot_reflects_checked_out_ref() { + let (dir, repo) = fixture(&[("README.md", b"# main"), ("a.txt", b"one")]); + commit_all(&repo, "initial"); + let dir = dir.path(); + + git_run(dir, &["checkout", "-b", "feature"]); + std::fs::write(dir.join("README.md"), b"# feature").expect("write"); + std::fs::write(dir.join("b.txt"), b"b").expect("write"); + commit_all(&repo, "feature work"); + + let snapshot = worktree_snapshot(dir).expect("snapshot"); + assert_eq!(snapshot.current_branch.as_deref(), Some("feature")); + assert_eq!( + snapshot.head_commit.as_ref().expect("head commit").summary, + "feature work" + ); + assert_eq!( + String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"), + "# feature" + ); + let entries: Vec = snapshot + .entries + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + assert!(entries.contains(&"b.txt".to_string())); + + let default = worktree_branches(dir) + .expect("branches") + .into_iter() + .find(|name| name != "feature") + .expect("default branch"); + worktree_checkout_branch(dir, &default).expect("checkout"); + + let snapshot = worktree_snapshot(dir).expect("snapshot"); + assert_eq!(snapshot.current_branch.as_deref(), Some(default.as_str())); + assert_eq!( + snapshot.head_commit.as_ref().expect("head commit").summary, + "initial" + ); + assert_eq!( + String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"), + "# main" + ); + assert!( + !snapshot + .entries + .iter() + .any(|p| p.to_string_lossy() == "b.txt") + ); +} + +#[test] +fn commit_diff_lists_added_modified_and_deleted_files() { + let (dir, repo) = fixture(&[("keep.txt", b"keep"), ("mod.txt", b"one\ntwo\nthree\n")]); + commit_all(&repo, "initial"); + + std::fs::write(dir.path().join("mod.txt"), b"one\ntwo!\nthree\n").expect("write"); + std::fs::write(dir.path().join("new.txt"), b"hello\n").expect("write"); + std::fs::remove_file(dir.path().join("keep.txt")).expect("remove"); + commit_all(&repo, "changes"); + + let head = repo.head_id().expect("head").shorten_or_id().to_string(); + let diff = worktree_commit_diff(dir.path(), &head).expect("diff"); + + let by_path: HashMap<&str, &FileDiff> = diff + .files + .iter() + .map(|file| (file.path.as_str(), file)) + .collect(); + assert_eq!(by_path.len(), 3); + + let added = by_path["new.txt"]; + assert_eq!(added.status, DiffStatus::Added); + assert_eq!(added.insertions, 1); + assert_eq!(added.deletions, 0); + assert_eq!(added.hunks.len(), 1); + assert_eq!(added.hunks[0].lines.len(), 1); + assert_eq!(added.hunks[0].lines[0].kind, DiffLineKind::Addition); + assert_eq!(added.hunks[0].lines[0].old, None); + assert_eq!(added.hunks[0].lines[0].new, Some(1)); + assert_eq!(added.hunks[0].lines[0].text, "hello"); + + let modified = by_path["mod.txt"]; + assert_eq!(modified.status, DiffStatus::Modified); + assert_eq!(modified.insertions, 1); + assert_eq!(modified.deletions, 1); + assert!(!modified.binary); + let lines = &modified.hunks[0].lines; + // One hunk with context around the single-line change. + // The removed line is old 2, the added line is new 2. + assert!(lines.iter().any(|line| { + line.kind == DiffLineKind::Deletion + && line.old == Some(2) + && line.new.is_none() + && line.text == "two" + })); + assert!(lines.iter().any(|line| { + line.kind == DiffLineKind::Addition + && line.old.is_none() + && line.new == Some(2) + && line.text == "two!" + })); + assert!(lines.iter().any(|line| { + line.kind == DiffLineKind::Context && line.old == Some(1) && line.new == Some(1) + })); + + let deleted = by_path["keep.txt"]; + assert_eq!(deleted.status, DiffStatus::Deleted); + assert_eq!(deleted.deletions, 1); + assert_eq!(deleted.hunks[0].lines[0].kind, DiffLineKind::Deletion); + assert_eq!(deleted.hunks[0].lines[0].old, Some(1)); + assert_eq!(deleted.hunks[0].lines[0].new, None); +} + +#[test] +fn commit_range_diff_lists_changes_between_two_commits() { + let (dir, repo) = fixture(&[("a.txt", b"a\n"), ("b.txt", b"b\n")]); + commit_all(&repo, "first"); + let base = repo.head_id().expect("head").to_string(); + + std::fs::write(dir.path().join("a.txt"), b"changed\n").expect("write"); + std::fs::write(dir.path().join("c.txt"), b"new\n").expect("write"); + commit_all(&repo, "second"); + let tip = repo.head_id().expect("head").to_string(); + + let diff = worktree_commit_range_diff(dir.path(), &base, &tip).expect("diff"); + + let by_path: HashMap<&str, &FileDiff> = diff + .files + .iter() + .map(|file| (file.path.as_str(), file)) + .collect(); + assert_eq!(by_path.len(), 2); + assert_eq!(by_path["a.txt"].status, DiffStatus::Modified); + assert_eq!(by_path["a.txt"].insertions, 1); + assert_eq!(by_path["a.txt"].deletions, 1); + assert_eq!(by_path["c.txt"].status, DiffStatus::Added); + // b.txt is unchanged between the 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")]); + commit_all(&repo, "initial"); + + std::fs::write(_dir.path().join("blob.bin"), b"\x00\x03").expect("write"); + commit_all(&repo, "binary change"); + + let head = repo.head_id().expect("head").shorten_or_id().to_string(); + let diff = worktree_commit_diff(_dir.path(), &head).expect("diff"); + let file = diff + .files + .iter() + .find(|f| f.path == "blob.bin") + .expect("file"); + assert!(file.binary); + assert!(file.hunks.is_empty()); + assert_eq!(file.insertions, 0); + 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")]); + commit_all(&repo, "initial"); + + std::fs::rename(_dir.path().join("old.txt"), _dir.path().join("new.txt")).expect("rename"); + commit_all(&repo, "rename"); + + let head = repo.head_id().expect("head").shorten_or_id().to_string(); + let diff = worktree_commit_diff(_dir.path(), &head).expect("diff"); + let file = diff + .files + .iter() + .find(|f| f.path == "new.txt") + .expect("file"); + assert_eq!(file.status, DiffStatus::Renamed); + assert_eq!(file.old_path.as_deref(), Some("old.txt")); + // A pure rename has no content change, the file is still listed. + assert!(file.hunks.is_empty()); + assert_eq!(file.insertions, 0); + 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 +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 +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 +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 +From: Alice +Date: Tue, 1 Aug 2023 10:00:00 +0200 +Subject: [PATCH 1/2] first + +body one +--- + a.txt | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/a.txt b/a.txt +@@ -1 +1,2 @@ + a ++b + +From 2222222222222222222222222222222222222222 Mon Sep 17 00:00:00 2001 +From: Bob +Date: Wed, 2 Aug 2023 11:30:00 +0000 +Subject: [PATCH 2/2] second + +body two +--- + b.txt | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/b.txt b/b.txt +@@ -1 +1,2 @@ + x ++y +"#; + + let commits = patch_commits(patch); + assert_eq!(commits.len(), 2); + + assert_eq!(commits[0].id, "1111111111111111111111111111111111111111"); + assert_eq!(commits[0].summary, "first"); + assert_eq!(commits[0].author, "Alice"); + assert_eq!(commits[0].time, 1690876800); + + assert_eq!(commits[1].id, "2222222222222222222222222222222222222222"); + assert_eq!(commits[1].summary, "second"); + assert_eq!(commits[1].author, "Bob"); + 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 +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. + // Feed genuine `git format-patch` output through the parser. + // It covers quoted and octal-escaped paths. + // There are also a rename-free modification, an addition and a binary deletion. + let (dir, repo) = fixture(&[ + ("src/main.rs", b"fn main() {\n println!(\"one\");\n}\n"), + ("my file.txt", b"hello\n"), + ("\u{8bf4}\u{660e}.md", "# \u{8bf4}\u{660e}\n".as_bytes()), + ("img.png", b"\x89PNG\r\n\x1a\n\x00binary"), + ]); + commit_all(&repo, "initial"); + + std::fs::write( + dir.path().join("src/main.rs"), + b"fn main() {\n println!(\"two\");\n println!(\"three\");\n}\n", + ) + .expect("write"); + std::fs::write(dir.path().join("my file.txt"), b"hello world\n").expect("write"); + std::fs::write( + dir.path().join("\u{8bf4}\u{660e}.md"), + "# \u{8bf4}\u{660e}\nupdated\n", + ) + .expect("write"); + std::fs::remove_file(dir.path().join("img.png")).expect("remove"); + std::fs::write(dir.path().join("new file.md"), b"# new\n").expect("write"); + commit_all(&repo, "changes"); + + let output = Command::new("git") + .current_dir(dir.path()) + .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") + .args(["format-patch", "-1", "--stdout"]) + .output() + .expect("spawn git format-patch"); + assert!(output.status.success(), "git format-patch failed"); + let patch = String::from_utf8(output.stdout).expect("patch is utf-8"); + + let diff = patch_diffs(&patch).expect("parse real format-patch output"); + + let by_path = |path: &str| { + diff.files + .iter() + .find(|file| file.path == path) + .unwrap_or_else(|| panic!("missing file {path:?}")) + }; + + // Space in the name makes git quote the path in the header. + let file = by_path("my file.txt"); + assert_eq!(file.status, DiffStatus::Modified); + assert_eq!(file.insertions, 1); + + // UTF-8 names are emitted as octal escapes. + let file = by_path("\u{8bf4}\u{660e}.md"); + assert_eq!(file.status, DiffStatus::Modified); + assert_eq!(file.insertions, 1); + + let file = by_path("src/main.rs"); + assert_eq!(file.status, DiffStatus::Modified); + assert_eq!(file.insertions, 2); + assert_eq!(file.deletions, 1); + assert!(!file.hunks.is_empty()); + + let file = by_path("new file.md"); + assert_eq!(file.status, DiffStatus::Added); + assert_eq!(file.insertions, 1); + + // A binary deletion emits no `---` or `+++` lines. + // Only the mode line and the `Binary files` marker remain. + let file = by_path("img.png"); + assert_eq!(file.status, DiffStatus::Deleted); + assert!(file.binary); + assert!(file.hunks.is_empty()); +} + +#[test] +fn worktree_dirty_tracks_changes_and_untracked_files() { + let (dir, repo) = fixture(&[("tracked.txt", b"one")]); + commit_all(&repo, "initial"); + let workdir = dir.path(); + + 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)); + + // After restoring, an untracked file alone is dirty as well. + git_run(workdir, &["checkout", "--", "tracked.txt"]); + assert!(!worktree_dirty(workdir)); + 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)); + + // A missing directory is clean, not an error. + 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")]); + commit_all(&repo, "initial"); + let path = dir.path(); + + git_run(path, &["checkout", "-q", "-b", "feature"]); + std::fs::write(path.join("f.txt"), b"f\n").expect("write"); + commit_all(&gix::open(path).expect("open"), "feature work"); + + assert_eq!(worktree_commits_ahead(path, "main", "feature"), 1); + assert_eq!(worktree_commits_ahead(path, "feature", "main"), 0); + + git_run(path, &["checkout", "-q", "main"]); + assert_eq!(worktree_current_branch(path).as_deref(), Some("main")); + assert!(worktree_ref_exists(path, "refs/heads/feature")); + assert!(!worktree_ref_exists(path, "refs/heads/nope")); + assert_eq!(worktree_commits_ahead(path, "main", "feature"), 1); +} diff --git a/crates/signed_git/src/worktree.rs b/crates/signed_git/src/worktree.rs new file mode 100644 index 0000000..d89a3f0 --- /dev/null +++ b/crates/signed_git/src/worktree.rs @@ -0,0 +1,354 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use gix::progress::Discard; + +use crate::history::{FileCommit, head_commit}; +use crate::repo::{current_branch, repository_signature}; + +/// Whether the worktree of `workdir` has uncommitted changes. +/// +/// Best-effort: any read failure is reported as clean. +pub fn worktree_dirty(workdir: &Path) -> bool { + let Ok(repo) = gix::open(workdir) else { + return false; + }; + + // Changes to tracked files, staged or not; untracked files are excluded. + match repo.is_dirty() { + Ok(true) => return true, + Ok(false) => {} + Err(_) => return false, + } + + // Untracked files surface as `DirectoryContents` items of the index-vs-worktree walk, + // tracked files only appear there when modified. + let Ok(platform) = repo.status(Discard) else { + return false; + }; + + let Ok(mut changes) = platform.into_index_worktree_iter(Vec::::new()) + else { + return false; + }; + + for change in changes.by_ref() { + match change { + Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true, + Ok(_) => {} + Err(_) => return false, + } + } + + false +} + +/// Commits in `base..branch` of the checkout at `workdir`. +/// +/// Best-effort: 0 when the range cannot be computed. +pub fn worktree_commits_ahead(workdir: &Path, base: &str, branch: &str) -> u32 { + let Ok(repo) = gix::open(workdir) else { + return 0; + }; + + let (Some(base), Some(branch)) = (resolve_commit(&repo, base), resolve_commit(&repo, branch)) + else { + return 0; + }; + + let Ok(walk) = repo.rev_walk([branch]).with_hidden([base]).all() else { + return 0; + }; + + walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32 +} + +/// Resolve `rev` to a commit id, accepting full refs, +/// symbolic refs and the bare branch names callers pass, like git's DWIM. +fn resolve_commit<'a>(repo: &'a gix::Repository, rev: &str) -> Option> { + if let Ok(id) = repo.rev_parse_single(rev.as_bytes()) { + return Some(id); + } + + // Branch names arrive bare, like git resolving `main`. + if rev.contains('/') { + return None; + } + + repo.rev_parse_single(format!("refs/heads/{rev}").as_bytes()) + .ok() +} + +/// Relative paths of all entries in the worktree, files and directories. +/// +/// The `.git` directory is skipped. +pub fn worktree_entries(repo: &gix::Repository) -> Result> { + let workdir = repo.workdir().context("repository has no worktree")?; + + let mut entries: Vec<(PathBuf, bool)> = Vec::new(); + collect_entries(workdir, workdir, &mut entries)?; + + entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| { + b_is_dir + .cmp(a_is_dir) + .then_with(|| a.as_os_str().cmp(b.as_os_str())) + }); + Ok(entries.into_iter().map(|(path, _)| path).collect()) +} + +/// Read a file from the worktree. +/// +/// Returns `Ok(None)` if the path is missing or not a regular file. +pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result>> { + let workdir = repo.workdir().context("repository has no worktree")?; + let path = workdir.join(rel); + + match std::fs::read(&path) { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None), + Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), + } +} + +/// Find the README file in the repository root. +/// +/// Falls back to any other file whose name starts with `readme`. +pub fn find_readme(repo: &gix::Repository) -> Result> { + let Some(workdir) = repo.workdir() else { + return Ok(None); + }; + + let mut candidates: Vec = Vec::new(); + for entry in std::fs::read_dir(workdir)? { + let entry = entry?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if name.to_ascii_lowercase().starts_with("readme") { + candidates.push(entry.path()); + } + } + + candidates.sort_by_key(|path| { + let ext = path + .extension() + .map(|e| e.to_string_lossy().to_ascii_lowercase()); + match ext.as_deref() { + Some("md") => 0, + Some("markdown") => 1, + Some("mdown") => 2, + Some("mkdn") => 3, + Some(_) => 5, + None => 4, + } + }); + + Ok(candidates + .into_iter() + .next() + .and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf))) +} + +/// Everything the browser needs to refresh after a branch or tag switch. +pub struct WorktreeSnapshot { + /// Relative paths of all worktree entries, directories first. + pub entries: Vec, + /// README path relative to the worktree, if any. + pub readme_path: Option, + /// Contents of the README, if any. + pub readme: Option>, + /// Branch HEAD points to, `None` when detached, for example on a tag. + pub current_branch: Option, + /// Commit HEAD points to, if any, see [`head_commit`]. + pub head_commit: Option, +} + +/// 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 { + let repo = gix::open(workdir)?; + let readme_path = find_readme(&repo)?; + let readme = match &readme_path { + Some(path) => worktree_read(&repo, path)?, + None => None, + }; + Ok(WorktreeSnapshot { + entries: worktree_entries(&repo)?, + readme_path, + readme, + current_branch: current_branch(&repo)?, + head_commit: head_commit(&repo)?, + }) +} + +/// 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() + .context("repository has no worktree")? + .to_path_buf(); + + let mut index = repo.index_from_tree(tree)?; + + // Files the previous index tracked but `tree` no longer contains are removed, + // like git deleting files that vanish between branches. + if let Ok(previous) = repo.index_or_empty() { + let keep: HashSet = index + .entries() + .iter() + .map(|entry| PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned())) + .collect(); + for entry in previous.entries() { + let rel = entry.path(&previous); + let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned()); + + if keep.contains(&rel) { + continue; + } + + let path = workdir.join(&rel); + + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("failed to remove {}", path.display())); + } + } + } + } + + let mut options = + repo.checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?; + options.overwrite_existing = true; + + let objects = repo.objects.clone().into_arc()?; + let files = gix::progress::Discard; + let bytes = gix::progress::Discard; + + // Check out the index into the worktree. + gix_worktree_state::checkout( + &mut index, + workdir, + objects, + &files, + &bytes, + &gix::interrupt::IS_INTERRUPTED, + options, + )?; + + // Write the index to disk. + index.write(gix::index::write::Options::default())?; + + Ok(()) +} + +/// Point `HEAD` at `target` and record the switch in the reflog. +fn move_head( + repo: &gix::Repository, + signature: gix::actor::SignatureRef<'_>, + target: gix::refs::Target, + message: &str, +) -> Result<()> { + use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; + + 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 { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: message.into(), + }, + expected: PreviousValue::Any, + new: target, + }, + name: head, + deref: false, + }], + Some(signature), + )?; + + Ok(()) +} + +/// Check out the local branch `name`, HEAD stays attached to it. +pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> { + let repo = gix::open(workdir)?; + let full = format!("refs/heads/{name}"); + + let branch = gix::refs::FullName::try_from(full.as_str()) + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; + + let mut reference = repo.find_reference(&full)?; + let tree = reference.peel_to_tree()?.id; + + 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, + gix::refs::Target::Symbolic(branch), + &format!("checkout: moving to {name}"), + )?; + + // Check out the branch's tree, replacing index + worktree. + force_checkout(&repo, &tree)?; + + Ok(()) +} + +/// Check out the tag `name`, HEAD becomes detached at the tagged commit. +pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> { + let repo = gix::open(workdir)?; + let full = format!("refs/tags/{name}"); + + let mut reference = repo.find_reference(&full)?; + + let commit = reference.peel_to_id()?; + let tree = reference.peel_to_tree()?.id; + + 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, + gix::refs::Target::Object(commit.detach()), + &format!("checkout: moving to {name}"), + )?; + + // Check out the tag's tree, replacing index + worktree. + force_checkout(&repo, &tree)?; + + Ok(()) +} + +fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + if entry.file_name() == ".git" { + continue; + } + + let is_dir = entry.file_type()?.is_dir(); + let path = entry.path(); + let rel = path.strip_prefix(root)?.to_path_buf(); + out.push((rel, is_dir)); + + if is_dir { + collect_entries(root, &path, out)?; + } + } + Ok(()) +}