933 lines
31 KiB
Rust
933 lines
31 KiB
Rust
//! 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::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<Option<gix::Repository>> {
|
|
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<gix::Repository> {
|
|
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<anyhow::Error> = None;
|
|
|
|
for url in clone_urls {
|
|
match clone(url, &path) {
|
|
Ok(repo) => 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`.
|
|
pub fn fetch_all(repo: &gix::Repository) -> Result<()> {
|
|
repo.find_remote("origin")?
|
|
.connect(gix::remote::Direction::Fetch)?
|
|
.prepare_fetch(Discard, Default::default())?
|
|
.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<gix::Repository> {
|
|
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,
|
|
/// 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<Vec<PathBuf>> {
|
|
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<Option<Vec<u8>>> {
|
|
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<Option<PathBuf>> {
|
|
let Some(workdir) = repo.workdir() else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let mut candidates: Vec<PathBuf> = 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<gix::Repository> {
|
|
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.
|
|
fn file_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
|
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(),
|
|
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 -- <rel>` 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<Option<FileCommit>> {
|
|
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 -- <rel>` 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<Vec<(PathBuf, FileCommit)>> {
|
|
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<Vec<(PathBuf, FileCommit)>> {
|
|
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<PathBuf> = 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)?));
|
|
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<FileCommit>,
|
|
}
|
|
|
|
/// 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<CommitList> {
|
|
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()?)?);
|
|
}
|
|
}
|
|
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<CommitList> {
|
|
all_commits(&open_with_cache(workdir)?)
|
|
}
|
|
|
|
/// 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<Option<FileCommit>> {
|
|
let Some(head) = repo.head_id().ok() else {
|
|
return Ok(None);
|
|
};
|
|
let commit = head.object()?.into_commit();
|
|
let author = commit.author()?;
|
|
let message = commit.message()?;
|
|
Ok(Some(FileCommit {
|
|
id: commit.id().shorten_or_id().to_string(),
|
|
summary: String::from_utf8_lossy(message.title).trim().to_string(),
|
|
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
|
time: author.time()?.seconds,
|
|
}))
|
|
}
|
|
|
|
/// Short names of local branches (`refs/heads/*`), sorted alphabetically.
|
|
pub fn worktree_branches(workdir: &Path) -> Result<Vec<String>> {
|
|
let repo = open_with_cache(workdir)?;
|
|
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/*`), sorted alphabetically.
|
|
pub fn worktree_tags(workdir: &Path) -> Result<Vec<String>> {
|
|
let repo = open_with_cache(workdir)?;
|
|
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 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<Option<String>> {
|
|
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<PathBuf>,
|
|
/// README path relative to the worktree, if any.
|
|
pub readme_path: Option<PathBuf>,
|
|
/// Contents of the README, if any.
|
|
pub readme: Option<Vec<u8>>,
|
|
/// Branch HEAD points to (`None` when detached, e.g. on a tag).
|
|
pub current_branch: Option<String>,
|
|
/// Commit HEAD points to, if any (see [`head_commit`]).
|
|
pub head_commit: Option<FileCommit>,
|
|
}
|
|
|
|
/// 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<WorktreeSnapshot> {
|
|
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/<name>`) 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<String> = entries
|
|
.iter()
|
|
.map(|p| p.to_string_lossy().into_owned())
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
entries,
|
|
vec![
|
|
"docs",
|
|
"src",
|
|
"README.md",
|
|
"docs/guide.md",
|
|
"src/lib.rs",
|
|
"src/main.rs"
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn worktree_read_returns_bytes_or_none() {
|
|
let (_dir, repo) = fixture(&[("a.txt", b"hello"), ("sub/b.bin", b"\x00\x01")]);
|
|
|
|
assert_eq!(
|
|
worktree_read(&repo, Path::new("a.txt")).expect("read"),
|
|
Some(b"hello".to_vec())
|
|
);
|
|
assert_eq!(
|
|
worktree_read(&repo, Path::new("sub/b.bin")).expect("read"),
|
|
Some(vec![0x00, 0x01])
|
|
);
|
|
assert_eq!(
|
|
worktree_read(&repo, Path::new("missing.txt")).expect("read"),
|
|
None
|
|
);
|
|
}
|
|
|
|
/// Stage everything and create a commit with the git CLI (like
|
|
/// [`apply_patch`], the crate already shells out to the CLI).
|
|
fn commit_all(repo: &gix::Repository, message: &str) {
|
|
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<String> = 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")
|
|
);
|
|
}
|
|
}
|