tests/refs_test.rs
Ref: Size: 18.1 KiB History
//! `git-collab refs`: enumerate this clone's collab refs, and say what each is.
//!
//! The layout nests — `refs/collab/patches/<id>/events`, `<id>/rev/<oid>` — and
//! `git for-each-ref 'refs/collab/*'` silently reports nothing, because
//! for-each-ref's globs do not cross `/` while git2's `references_glob`, which
//! this tool uses internally, does. An operator should not have to know which
//! side of that boundary they are on to look at their own repository.
//!
//! The load-bearing property, beyond listing: this is a read. A repository
//! holding a pre-migration shape must still hold it afterwards, or the one
//! question the command exists to answer — *does this repo hold legacy
//! shapes?* — answers itself by destroying the evidence.
mod common;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use common::{write_raw_event, TestRepo};
use serde_json::json;
use tempfile::TempDir;
/// Every ref under `refs/collab/`, as git itself sees it (no glob — that is the
/// whole point of the issue this command answers).
fn collab_refs(repo: &TestRepo) -> Vec<String> {
let mut refs: Vec<String> = repo
.git(&["for-each-ref", "--format=%(refname)", "refs/collab/"])
.lines()
.map(str::to_string)
.collect();
refs.sort();
refs
}
/// Expand a short patch id to the full 40-char id via the ref listing.
fn full_patch_id(repo: &TestRepo, short: &str) -> String {
for name in collab_refs(repo) {
if let Some(rest) = name.strip_prefix("refs/collab/patches/") {
let id = rest.split('/').next().unwrap_or_default();
if id.starts_with(short) {
return id.to_string();
}
}
}
panic!("no patch ref matching {}", short);
}
/// Create a patch on a fresh branch holding one commit. Returns (full id, tip).
fn patch_on_branch(repo: &TestRepo, branch: &str, file: &str) -> (String, String) {
repo.git(&["checkout", "-b", branch]);
let tip = repo.commit_file(file, "v1", &format!("add {}", file));
let out = repo.run_ok(&["patch", "create", "-t", branch, "-B", branch]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap_or_else(|| panic!("unexpected create output: {}", out))
.to_string();
(full_patch_id(repo, &short), tip)
}
/// Write a patch in the pre-migration layout: one bare `refs/collab/patches/<id>`
/// ref that *is* the event DAG. Returns the full id.
///
/// By hand, because no version of the tool that can still be built writes this
/// shape — and a repository somewhere holds it, which is the case under test.
fn legacy_bare_patch(repo: &TestRepo, title: &str) -> String {
let git_repo = git2::Repository::open(repo.dir.path()).unwrap();
let head = git_repo.head().unwrap().target().unwrap();
let tree = git_repo.find_commit(head).unwrap().tree().unwrap().id();
let root = write_raw_event(
&git_repo,
None,
json!({
"type": "patch.create",
"title": title,
"body": "",
"base_ref": "main",
"branch": "old",
"commit": head.to_string(),
"tree": tree.to_string(),
}),
1,
);
let id = root.to_string();
git_repo
.reference(
&format!("refs/collab/patches/{}", id),
root,
false,
"old layout",
)
.unwrap();
id
}
/// The one output line naming `needle`, or a panic naming what was there.
fn line_for<'a>(output: &'a str, needle: &str) -> &'a str {
output
.lines()
.find(|l| l.contains(needle))
.unwrap_or_else(|| panic!("no line naming {}, got:\n{}", needle, output))
}
/// A repository holding one of each shape that matters: a current patch with a
/// pinned revision, an issue, and a bare pre-migration patch ref plus an
/// interim numbered revision ref.
///
/// The superseded shapes are written after every CLI call, and the ordering is
/// still load-bearing — for the opposite reason it used to be. It was to stop
/// the layout migration converting them out from under the test; the migration
/// is gone (issue `e5096ffc`), and now every command except `refs` refuses to
/// run at all once one of these exists.
fn mixed_layout_repo() -> (TestRepo, String, String, String) {
let repo = TestRepo::new("Alice", "alice@example.com");
let (modern, tip) = patch_on_branch(&repo, "feat", "a.txt");
repo.run_ok(&["issue", "open", "-t", "An issue"]);
let legacy = legacy_bare_patch(&repo, "Written by an older version");
// The interim shape: a revision named by number rather than by the OID it
// pins. It can only exist beside an events ref, so it hangs off the current
// patch.
repo.git(&[
"update-ref",
&format!("refs/collab/patches/{}/r/1", modern),
&tip,
]);
(repo, modern, legacy, tip)
}
// ===========================================================================
// Enumeration
// ===========================================================================
#[test]
fn refs_reports_both_patch_layouts() {
let (repo, modern, legacy, tip) = mixed_layout_repo();
let out = repo.run_ok(&["refs"]);
let events = line_for(&out, &format!("refs/collab/patches/{}/events", modern));
assert!(
events.contains("patch events") && !events.contains("legacy"),
"the current layout must not be reported as legacy: {:?}",
events
);
let bare = line_for(&out, &format!("refs/collab/patches/{}", legacy));
assert!(
bare.contains("legacy"),
"a bare <id> ref is the pre-migration layout and must say so: {:?}",
bare
);
let rev = line_for(&out, &format!("refs/collab/patches/{}/rev/{}", modern, tip));
assert!(
rev.contains("revision") && !rev.contains("legacy"),
"a rev/<oid> ref pins a revision: {:?}",
rev
);
let numbered = line_for(&out, &format!("refs/collab/patches/{}/r/1", modern));
assert!(
numbered.contains("legacy"),
"a numbered r/<n> ref is the interim layout: {:?}",
numbered
);
let issue_ref = collab_refs(&repo)
.into_iter()
.find(|r| r.starts_with("refs/collab/issues/"))
.expect("the issue must have a ref");
assert!(
line_for(&out, &issue_ref).contains("issue"),
"issues are collab refs too"
);
}
#[test]
fn refs_summarises_the_legacy_shapes_it_found() {
let (repo, _modern, _legacy, _tip) = mixed_layout_repo();
let out = repo.run_ok(&["refs"]);
// The summary is the whole answer to "does this repo hold legacy shapes?",
// which is what blocks stripping the compatibility code.
let summary = line_for(&out, "legacy");
assert!(
out.lines().any(|l| l.contains("2") && l.contains("legacy")),
"expected a summary counting both legacy refs, got {:?}\nfull:\n{}",
summary,
out
);
}
#[test]
fn refs_on_a_clean_repo_says_so() {
let repo = TestRepo::new("Alice", "alice@example.com");
let out = repo.run_ok(&["refs"]);
assert!(
out.to_lowercase().contains("no collab refs"),
"an empty namespace must be stated, not printed as silence: {:?}",
out
);
}
// ===========================================================================
// JSON
// ===========================================================================
#[test]
fn refs_json_carries_full_ids() {
let (repo, modern, legacy, tip) = mixed_layout_repo();
let out = repo.run_ok(&["refs", "--json"]);
let v: serde_json::Value = serde_json::from_str(&out)
.unwrap_or_else(|e| panic!("--json must emit one JSON value ({}): {}", e, out));
let refs = v["refs"].as_array().expect("refs array");
let find = |name: &str| -> serde_json::Value {
refs.iter()
.find(|r| r["ref"] == name)
.unwrap_or_else(|| panic!("no entry for {} in {}", name, out))
.clone()
};
let events = find(&format!("refs/collab/patches/{}/events", modern));
assert_eq!(events["kind"], "patch-events");
assert_eq!(
events["id"], modern,
"--json carries full ids, never abbreviations"
);
assert_eq!(events["legacy"], false);
assert_eq!(events["archived"], false);
assert_eq!(
events["target"].as_str().unwrap().len(),
40,
"the target is a full OID"
);
let bare = find(&format!("refs/collab/patches/{}", legacy));
assert_eq!(bare["kind"], "patch-events-legacy");
assert_eq!(bare["id"], legacy);
assert_eq!(bare["legacy"], true);
let rev = find(&format!("refs/collab/patches/{}/rev/{}", modern, tip));
assert_eq!(rev["kind"], "patch-revision");
assert_eq!(rev["id"], modern, "a revision ref belongs to its patch");
assert_eq!(rev["target"], tip);
assert_eq!(rev["legacy"], false);
let numbered = find(&format!("refs/collab/patches/{}/r/1", modern));
assert_eq!(numbered["kind"], "patch-revision-numbered");
assert_eq!(numbered["id"], modern);
assert_eq!(numbered["legacy"], true);
assert_eq!(v["legacy"], 2, "two refs are in a superseded shape");
assert_eq!(v["total"], refs.len(), "total counts what was listed");
assert_eq!(v["counts"]["patch-events"], 1);
assert_eq!(v["counts"]["patch-events-legacy"], 1);
}
#[test]
fn refs_json_on_a_clean_repo_is_an_empty_listing() {
let repo = TestRepo::new("Alice", "alice@example.com");
let out = repo.run_ok(&["refs", "--json"]);
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["refs"].as_array().unwrap().len(), 0);
assert_eq!(v["total"], 0);
assert_eq!(v["legacy"], 0);
}
// ===========================================================================
// Reading must not write
// ===========================================================================
// ===========================================================================
// The server side: the same enumeration over a roster
// ===========================================================================
fn git_in(dir: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.expect("failed to run git");
assert!(
output.status.success(),
"git {:?} in {:?} failed: {}",
args,
dir,
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap()
}
/// A `repos_dir` with a `server.toml` beside it, and no server running: all
/// `git-collab-server refs` needs, since it only reads.
struct Roster {
root: TempDir,
repos_dir: PathBuf,
config: PathBuf,
}
impl Roster {
fn new() -> Self {
let root = TempDir::new().unwrap();
let repos_dir = root.path().join("repos");
std::fs::create_dir_all(&repos_dir).unwrap();
let authorized_keys = root.path().join("authorized_keys");
std::fs::write(&authorized_keys, "").unwrap();
let config = root.path().join("server.toml");
std::fs::write(
&config,
format!(
"repos_dir = {:?}\nauthorized_keys = {:?}\n",
repos_dir, authorized_keys
),
)
.unwrap();
Roster {
root,
repos_dir,
config,
}
}
/// A hosted bare repository holding one patch. Returns (path, full id).
fn seed(&self, name: &str) -> (PathBuf, String) {
let bare = self.repos_dir.join(format!("{name}.git"));
git_in(
self.root.path(),
&["init", "-q", "--bare", "-b", "main", bare.to_str().unwrap()],
);
let work = TestRepo::new("Alice", "alice@example.com");
work.patch_create("A patch");
work.git(&["push", "-q", bare.to_str().unwrap(), "main:main"]);
work.git(&[
"push",
"-q",
bare.to_str().unwrap(),
"refs/collab/*:refs/collab/*",
]);
let refs = git_in(
&bare,
&["for-each-ref", "--format=%(refname)", "refs/collab/"],
);
let id = refs
.lines()
.find_map(|n| n.strip_prefix("refs/collab/patches/"))
.and_then(|rest| rest.split('/').next())
.expect("a patch ref")
.to_string();
(bare, id)
}
/// Put a hosted repository's patch back into the pre-migration layout.
fn demote(&self, bare: &Path, id: &str) {
let events = format!("refs/collab/patches/{}/events", id);
let tip = git_in(bare, &["rev-parse", &events]).trim().to_string();
let names = git_in(
bare,
&[
"for-each-ref",
"--format=%(refname)",
&format!("refs/collab/patches/{}/", id),
],
);
for name in names.lines() {
git_in(bare, &["update-ref", "-d", name]);
}
git_in(
bare,
&["update-ref", &format!("refs/collab/patches/{}", id), &tip],
);
}
fn refs(&self, extra: &[&str]) -> Output {
let mut args = vec!["refs", "--config", self.config.to_str().unwrap()];
args.extend_from_slice(extra);
Command::new(env!("CARGO_BIN_EXE_git-collab-server"))
.args(&args)
.output()
.expect("failed to run git-collab-server refs")
}
}
/// Every collab ref in a bare repository, as sorted `<name> <oid>` lines.
fn snapshot(bare: &Path) -> String {
let mut lines: Vec<String> = git_in(
bare,
&[
"for-each-ref",
"--format=%(refname) %(objectname)",
"refs/collab/",
],
)
.lines()
.map(str::to_string)
.collect();
lines.sort();
lines.join("\n")
}
#[test]
fn server_refs_names_the_repositories_holding_a_legacy_layout() {
let roster = Roster::new();
let (old, old_id) = roster.seed("old");
roster.demote(&old, &old_id);
roster.seed("current");
let output = roster.refs(&[]);
assert!(output.status.success(), "a clean audit exits 0");
let stdout = String::from_utf8(output.stdout).unwrap();
// "older layout" rather than "superseded": the bare shape is read again
// (issue `05b18df6`), so this survey says which repositories are old, not
// which are broken. The operator uses it to plan a migration, and a word
// that implies breakage would make that look urgent when it is not.
assert!(
line_for(&stdout, "old:").contains("older layout"),
"the demoted repository must be called out: {}",
stdout
);
assert!(
!line_for(&stdout, "current:").contains("older layout"),
"the current repository must not be: {}",
stdout
);
assert!(
stdout.contains("2 repositories scanned"),
"expected a roster summary, got {}",
stdout
);
}
#[test]
fn server_refs_json_names_the_legacy_refs_in_full() {
let roster = Roster::new();
let (old, old_id) = roster.seed("old");
roster.demote(&old, &old_id);
roster.seed("current");
let output = roster.refs(&["--json"]);
let stdout = String::from_utf8(output.stdout).unwrap();
let v: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("--json must emit one JSON value ({}): {}", e, stdout));
assert_eq!(v["legacy"], 1, "one legacy ref across the roster");
assert_eq!(v["unreadable"], 0);
let repos = v["repositories"].as_array().unwrap();
let old_entry = repos
.iter()
.find(|r| r["repo"] == "old")
.expect("the demoted repository");
assert_eq!(old_entry["legacy"], 1);
assert_eq!(
old_entry["legacy_refs"][0]["ref"],
format!("refs/collab/patches/{}", old_id),
"the ref an operator has to act on, named in full"
);
assert_eq!(old_entry["legacy_refs"][0]["id"], old_id);
assert_eq!(old_entry["legacy_refs"][0]["kind"], "patch-events-legacy");
let current = repos
.iter()
.find(|r| r["repo"] == "current")
.expect("the current repository");
assert_eq!(current["legacy"], 0);
assert_eq!(current["legacy_refs"].as_array().unwrap().len(), 0);
}
#[test]
fn server_refs_reports_a_repository_it_cannot_read_rather_than_skipping_it() {
let roster = Roster::new();
roster.seed("fine");
// A directory the roster walk counts as a repository — it has a `HEAD` —
// and git2 cannot open, having nothing else. Silently skipping it would let
// the audit answer "nothing legacy" for a roster it never finished reading.
let broken = roster.repos_dir.join("broken.git");
std::fs::create_dir_all(&broken).unwrap();
std::fs::write(broken.join("HEAD"), "ref: refs/heads/main\n").unwrap();
let output = roster.refs(&[]);
assert!(
!output.status.success(),
"an audit that could not read everything must not exit 0"
);
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("broken") && stdout.contains("cannot be read"),
"the unreadable repository must be named: {}",
stdout
);
}
#[test]
fn server_refs_writes_nothing_to_the_repositories_it_reads() {
let roster = Roster::new();
let (old, old_id) = roster.seed("old");
roster.demote(&old, &old_id);
let before = snapshot(&old);
roster.refs(&[]);
roster.refs(&["--json"]);
assert_eq!(
snapshot(&old),
before,
"reading a hosted repository must leave its refs byte-identical — \
serving one that migrated on read was issue 5174338f"
);
}
#[test]
fn refs_does_not_migrate_what_it_reports() {
let (repo, modern, legacy, _tip) = mixed_layout_repo();
let before = collab_refs(&repo);
repo.run_ok(&["refs"]);
repo.run_ok(&["refs", "--json"]);
assert_eq!(
collab_refs(&repo),
before,
"listing refs must not move, convert or add a single one — every other \
command migrates on entry, and this is the one that must not"
);
assert!(
before.contains(&format!("refs/collab/patches/{}", legacy)),
"precondition: the bare ref was there to begin with"
);
assert!(
before.contains(&format!("refs/collab/patches/{}/r/1", modern)),
"precondition: the numbered ref was there to begin with"
);
}