//! Blocking local git operations against GRASP servers. //! //! All functions may block; call them inside `cx.background_spawn`. use std::collections::HashSet; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader}; use gix::interrupt::IS_INTERRUPTED; use gix::progress::Discard; use signed_core::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 } } /// 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 local clone if it exists (fetching first), otherwise clone /// from the first working URL in `clone_urls` (the announcement's `clone` tag). pub fn ensure_clone(&self, addr: &RepoAddr, clone_urls: &[String]) -> 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()))?; } let mut last_err: Option = None; for url in clone_urls { match clone(url, &path) { Ok(repo) => { // The initial clone uses the default refspecs; also // fetch the `refs/nostr/*` PR refs. fetch_all(&repo).ok(); return Ok(repo); } Err(e) => last_err = Some(e), } } match last_err { Some(e) => Err(e).context("failed to clone from any mirror"), None => bail!("no clone URLs provided"), } } } /// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` /// namespace where GRASP mirrors serve pull request branches (one ref per /// PR event id, as used by ngit). 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; can be /// 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(()) } fn clone(url: &str, path: &Path) -> Result { 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) } /// Map an untrusted repository id to a safe single path component. /// /// Replaces everything outside `[A-Za-z0-9._-]` with `_`, and rejects the /// special components `.` and `..` so the id can't escape the cache root /// when joined onto the owner directory. 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 } /// In-memory object cache for history walks (see [`open_with_cache`]). /// Without one, every walk re-decodes the same commit objects from the /// object database. 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` when there is no /// body (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), /// directories first, then alphabetically within each group. 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 (returned as a path relative /// to the worktree). Case-insensitive; prefers `README.md`, then `.markdown`, /// `.mdown`, `.mkdn`, then 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 sized for /// history walks. 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 walk commit: author, message title and shortened /// id. `include_description` controls whether the message body is copied; /// history lists never display it, so skipping it saves a string allocation /// per listed commit (the diff panel fetches the full commit on demand). fn file_commit(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), like `git log -1 -- ` does for non-merge commits. /// /// Walks history from `HEAD` newest-first and returns the first commit whose /// tree entry for `rel` differs from its first parent's; a merge that only /// changed the file through its second parent is therefore not reported. /// Returns `Ok(None)` if no commit touched the file (e.g. untracked files). 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` (relative to the worktree), like /// `git log -1 -- ` per path, found in a single history walk: every /// commit is decoded once and shared across all paths. Paths without any /// commit (e.g. 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`], stopping 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 still-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, true)?)); pending.swap_remove(ix); } else { ix += 1; } } } Ok(found) } /// Cap on [`CommitList::commits`]: the virtual list renders a window at a /// time and the tab badge shows the real count, so 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: `commits` /// holds at most [`MAX_LISTED_COMMITS`] entries and `total` is the real /// count (for the tab badge). 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(&info.object()?, false)?); } } Ok(CommitList { total, commits }) } /// Like [`all_commits`], but opens the repository located at `workdir` /// (for non-bare clones the clone root is the worktree) first. 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 @@`, with the lines between the /// two headers (context around the change, then removals and additions). #[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 (the destination path for /// renames and copies). 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: every file it added, modified, deleted or /// renamed, with line-level hunks for text files. #[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), like `git show`. Directory entries and submodules are skipped; /// their contents are reported as individual file changes. Files are sorted /// by path. pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result { commit_diff(&open_with_cache(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`. /// Same file handling as [`worktree_commit_diff`] (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 = open_with_cache(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(&info.object()?, false)?); } 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); // The tree diff also reports directory entries; only their contents // are listed, so skip trees and submodule gitlinks. 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, which is 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 a `git format-patch` output (a single patch or a patch series) /// into the same [`CommitDiff`] structure used for commit diffs. /// /// The mbox envelope (From/Subject/… headers, commit body and diffstat) /// is skipped; every `diff --git` section becomes one [`FileDiff`]. Paths /// are taken from the section headers, with git's C-style quoting undone. /// Sections without hunks (pure renames, mode changes, binary files) are /// reported without lines. pub fn patch_diffs(patch: &str) -> Result { let lines: Vec<&str> = patch.lines().collect(); let mut files = Vec::new(); let mut i = 0; while i < lines.len() { let Some(header) = lines[i].strip_prefix("diff --git ") else { i += 1; continue; }; let (file, next) = parse_diff_section(header, &lines, i + 1)?; files.push(file); i = next; } Ok(CommitDiff { files }) } /// Commits of a `git format-patch` output (a single patch or a patch /// series), parsed from the mbox envelope headers of each patch: commit id, /// author, summary and author time. Entries appear in patch order (oldest /// first, as produced by `git format-patch`). 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 of this patch, up to the blank line separating // them from 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]`, `[PATCH 1/2]`, `[RFC PATCH]` … prefix from a patch /// `Subject:` header. 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() } } /// Parse one file's diff section: everything after its `diff --git` header /// up to the next section (or the end of the patch). Returns the section /// and the index of the first unconsumed line. fn parse_diff_section(header: &str, lines: &[&str], start: usize) -> Result<(FileDiff, usize)> { let (header_old, header_new) = header_paths(header)?; // The `---`/`+++` lines name the two sides unambiguously (the header // can't distinguish spaces); fall back to the header for sections // without them (pure renames, mode changes). let mut old_path = header_old; let mut new_path = header_new; let mut status = DiffStatus::Modified; let mut binary = false; let mut hunks = Vec::new(); let mut insertions = 0usize; let mut deletions = 0usize; let mut i = start; while i < lines.len() { let line = lines[i]; // The next file's section starts at this line. if line.starts_with("diff --git ") { break; } i += 1; if line.starts_with("@@ -") { let (hunk, next) = parse_hunk(lines, i - 1)?; i = next; 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); } else if let Some(rest) = line.strip_prefix("--- ") { if rest == "/dev/null" { status = DiffStatus::Added; } else { old_path = diff_line_path(rest, "a/")?; } } else if let Some(rest) = line.strip_prefix("+++ ") { if rest == "/dev/null" { status = DiffStatus::Deleted; } else { new_path = diff_line_path(rest, "b/")?; } } else if line.starts_with("new file mode ") { status = DiffStatus::Added; } else if line.starts_with("deleted file mode ") { status = DiffStatus::Deleted; } else if line.starts_with("copy from ") { status = DiffStatus::Copied; } else if line.starts_with("rename from ") { status = DiffStatus::Renamed; } else if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") { binary = true; // A literal binary patch may follow; skip it without consuming // the next section's header. while i < lines.len() && !lines[i].starts_with("diff --git ") { i += 1; } break; } // Everything else (index/mode/similarity lines) is ignored. } Ok(( FileDiff { path: new_path, old_path: matches!(status, DiffStatus::Renamed | DiffStatus::Copied) .then_some(old_path), status, insertions, deletions, binary, hunks, }, i, )) } /// Parse one hunk: the `@@ -a,b +c,d @@` header plus every body line up to /// the next hunk header, the next `diff --git` section or the end of the /// patch. Returns the hunk and the index of the first unconsumed line. fn parse_hunk(lines: &[&str], start: usize) -> Result<(DiffHunk, usize)> { let (old_start, old_lines, new_start, new_lines) = hunk_header(lines[start])?; let mut diff_lines = Vec::new(); let mut old = old_start; let mut new = new_start; let mut i = start + 1; while i < lines.len() { let line = lines[i]; let Some(kind) = line_prefix_kind(line) else { break; }; i += 1; // Context lines advance both counters, deletions only the old one // and additions only the new one, so every line ends up with its // real line number in both versions. 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) } }; diff_lines.push(DiffLine { kind, old: old_no, new: new_no, text: line[1..].to_owned(), }); } Ok(( DiffHunk { old_start, old_lines, new_start, new_lines, lines: diff_lines, }, i, )) } /// The kind of a hunk body line, from its first character; lines that don't /// belong to the hunk (headers, `\ No newline…`, the next section) yield /// `None`. fn line_prefix_kind(line: &str) -> Option { match line.as_bytes().first()? { b' ' => Some(DiffLineKind::Context), b'+' => Some(DiffLineKind::Addition), b'-' => Some(DiffLineKind::Deletion), _ => None, } } /// Parse a unified-diff hunk header `@@ -a,b +c,d @@`; omitted line counts /// default to 1. fn hunk_header(header: &str) -> Result<(u32, u32, u32, u32)> { let rest = header .strip_prefix("@@ ") .context("malformed hunk header")?; let (old_spec, rest) = rest.split_once(' ').context("malformed hunk header")?; let new_spec = rest.split_once(' ').map(|(new, _)| new).unwrap_or(rest); fn parse(spec: &str) -> Result<(u32, u32)> { let spec = spec.strip_prefix(['-', '+']).unwrap_or(spec); let (start, count) = match spec.split_once(',') { Some((start, count)) => (start, count.parse::()?), None => (spec, 1), }; Ok((start.parse::()?, count)) } let (old_start, old_lines) = parse(old_spec)?; let (new_start, new_lines) = parse(new_spec)?; Ok((old_start, old_lines, new_start, new_lines)) } /// The old and new paths of a `diff --git a/X b/Y` header, with git's /// C-style quoting undone. /// /// Git only quotes paths containing characters that need escaping (non-ASCII /// bytes, `"`, `\`); plain spaces are left unquoted, so the two sides of an /// unquoted header are split at the last ` b/`. fn header_paths(header: &str) -> Result<(String, String)> { if header.starts_with('"') { // Quoted paths include the `a/` / `b/` prefix inside the quotes. let (old, rest) = take_quoted(header).context("unterminated quoted path")?; let rest = rest.trim_start(); let new = if rest.starts_with('"') { take_quoted(rest).context("unterminated quoted path")?.0 } else { rest.split_whitespace().next().unwrap_or(rest) }; let old = old .strip_prefix("a/") .context("old path without `a/` prefix")?; let new = new .strip_prefix("b/") .context("new path without `b/` prefix")?; Ok((unquote_path(old)?, unquote_path(new)?)) } else { let (old, rest) = header .rsplit_once(" b/") .context("malformed diff --git header")?; let old = old .strip_prefix("a/") .context("old path without `a/` prefix")?; Ok((old.to_owned(), rest.to_owned())) } } /// The path of a `--- a/X` / `+++ b/Y` line: the prefix stripped, git's /// trailing padding tab (for paths containing spaces) removed and C-style /// quoting undone. These lines name the two sides unambiguously, unlike the /// `diff --git` header. fn diff_line_path(line: &str, prefix: &str) -> Result { let line = line.trim_end_matches('\t'); if line.starts_with('"') { let (path, _) = take_quoted(line).context("unterminated quoted path")?; let path = path .strip_prefix(prefix) .context("diff line path without `a/` or `b/` prefix")?; unquote_path(path) } else { Ok(line .strip_prefix(prefix) .context("diff line path without `a/` or `b/` prefix")? .to_owned()) } } /// The content of a git C-style quoted path (opening `"`, escaped content, /// closing `"`) and the rest of the input; `None` if unterminated. /// /// Iterates by character so the returned slices always land on UTF-8 /// boundaries, even for non-ASCII paths. fn take_quoted(input: &str) -> Option<(&str, &str)> { let mut end = 1; // byte after the opening quote let mut rest = &input[1..]; while let Some(ch) = rest.chars().next() { let len = ch.len_utf8(); match ch { '\\' => { // Consume the escaped character too (it may be multi-byte). let escaped = rest[len..].chars().next()?; let consumed = len + escaped.len_utf8(); end += consumed; rest = &rest[consumed..]; } '"' => return Some((&input[1..end], &input[end + len..])), _ => { end += len; rest = &rest[len..]; } } } None } /// Undo git's C-style path quoting (`\NNN` octal escapes, `\"`, `\\`). fn unquote_path(path: &str) -> Result { if !path.contains('\\') { return Ok(path.to_owned()); } let mut out = Vec::with_capacity(path.len()); let mut bytes = path.as_bytes(); while let Some((&b, rest)) = bytes.split_first() { bytes = rest; if b == b'\\' { match bytes.split_first() { Some((&b'"', rest)) | Some((&b'\\', rest)) => { out.push(b); bytes = rest; } Some((&b'n', rest)) => { out.push(b'\n'); bytes = rest; } Some((&b't', rest)) => { out.push(b'\t'); bytes = rest; } Some((&d1, rest)) if (b'0'..=b'7').contains(&d1) => { let Some((&d2, rest)) = rest.split_first() else { bail!("malformed octal escape in quoted path"); }; let Some((&d3, rest)) = rest.split_first() else { bail!("malformed octal escape in quoted path"); }; if !(b'0'..=b'7').contains(&d2) || !(b'0'..=b'7').contains(&d3) { bail!("malformed octal escape in quoted path"); } let code = (d1 - b'0') as u16 * 64 + (d2 - b'0') as u16 * 8 + (d3 - b'0') as u16; if code > u8::MAX as u16 { bail!("octal escape out of range in quoted path"); } out.push(code as u8); bytes = rest; } _ => bail!("malformed escape in quoted path"), } } else { out.push(b); } } String::from_utf8(out).context("invalid UTF-8 in quoted path") } /// Collects the hunks of one blob diff while tracking per-line numbers. /// /// The unified-diff headers give the 1-based start line of the hunk in each /// file; context lines advance both counters, removals only the old one and /// additions only the new one, so each line ends up with its real line /// numbers in both versions. 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`. Returns `Ok(None)` for a /// repository without commits yet (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, true)?)) } /// Full metadata of the commit `id` (short or full) in the repository at /// `workdir`, like [`head_commit`] for an arbitrary commit. Returns /// `Ok(None)` when the id cannot be resolved. /// /// The commit list ([`all_commits`]) omits message bodies to keep the walk /// cheap; the diff panel uses this to fetch the full commit on demand. pub fn worktree_commit(workdir: &Path, id: &str) -> Result> { let repo = open_with_cache(workdir)?; match repo.rev_parse_single(id.as_bytes()) { Ok(commit_id) => { let commit = commit_id.object()?.into_commit(); Ok(Some(file_commit(&commit, true)?)) } 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(&open_with_cache(workdir)?) } /// Short names of tags (`refs/tags/*`), sorted alphabetically. pub fn worktree_tags(workdir: &Path) -> Result> { repo_tags(&open_with_cache(workdir)?) } /// Short name of the branch HEAD points to, or `None` when detached (e.g. /// 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())) } /// 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, e.g. 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/tag switch: entries, README, the /// branch HEAD points to and its commit, opening the repository once. pub fn worktree_snapshot(workdir: &Path) -> Result { let repo = open_with_cache(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)?, }) } /// Switch the checked-out ref and update the worktree to match, like /// `git checkout --force`. Local modifications are discarded since these /// clones are read-only browser copies. fn checkout(workdir: &Path, args: &[&str]) -> Result<()> { let output = Command::new("git") .arg("checkout") .arg("--force") .args(args) .current_dir(workdir) .output() .context("failed to spawn `git checkout`")?; if !output.status.success() { bail!( "git checkout {} failed: {}", args.join(" "), String::from_utf8_lossy(&output.stderr) ); } Ok(()) } /// Check out the local branch `name`; HEAD stays attached to it. pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> { // The short name (not `refs/heads/`) keeps HEAD attached; the // full ref name would be treated as a commit-ish and detach it. checkout(workdir, &[name]) } /// Check out the tag `name`; HEAD becomes detached at the tagged commit, /// which [`current_branch`] reports as `None`. pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> { // `--detach` pins the full tag ref so HEAD always ends up detached. checkout(workdir, &["--detach", &format!("refs/tags/{name}")]) } 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 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()) ); } /// Build a throwaway non-bare repository with the given files (rel → bytes). 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]); } /// 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 simply 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: 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!( worktree_tags(dir).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/date headers still lists a // commit; time stays 0 and the author falls back to the raw value. 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 (no 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, then feed genuine // `git format-patch` output through the parser: quoted paths (space // in the name), octal-escaped paths (UTF-8 name), 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: git quotes 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 name: git emits the path 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); // Binary deletion: git emits no ---/+++ lines, only the mode and // the "Binary files" marker. let file = by_path("img.png"); assert_eq!(file.status, DiffStatus::Deleted); assert!(file.binary); assert!(file.hunks.is_empty()); } }