a73x

9a13fc36

Let the tool enumerate its own refs

a73x   2026-08-13 14:52

Commit message
Let the tool enumerate its own refs

`git for-each-ref 'refs/collab/*'` prints nothing and exits 0. Its globs do
not cross `/`, so the pattern the README shows matches nothing below the first
level — and git2's `references_glob`, which git-collab uses internally, *does*
cross `/`, which is why the revision-ref design works at all. The same-looking
pattern means two different things on either side of the library boundary, and
the wrong guess is silent.

Two answers, one of them the real one.

`git-collab refs` lists every ref under `refs/collab/`, classified: issue,
patch events, patch revision, and the two superseded shapes — the bare `<id>`
ref that predates revision refs and the numbered `r/<n>` refs from the draft
the OID naming replaced. It ends with what that adds up to, so "does this
repository still hold a legacy layout?" is the last line rather than an
exercise in awk. `--json` carries full ids and full OIDs, plus `legacy` and
`total` for a script to branch on.

It is the one command excluded from the layout migration that `run` performs
on entry to every other one. That is the whole point: a command that migrates
before it looks answers its own question, and always with "none". The same
reasoning that keeps the server's read path from writing (5174338f) applies to
a command whose output *is* the state of the ref store.

`git-collab-server refs` is the roster-scale version, for the audit that
blocks e5096ffc: per-repository counts, the legacy refs named in full under
`--json`, no lock and no writability probe, and a non-zero exit if any
repository could not be read — an audit that silently skips one reports
"nothing legacy" for a roster it did not finish.

And the README's Storage section now says which for-each-ref patterns actually
work, since the layout was documented and the way to look at it was not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

README.md
Old New
@@ -239,6 +239,7 @@ reader folds it.
239 | `dashboard` | interactive TUI | 239 | `dashboard` | interactive TUI |
240 | `search` | full-text across issues and patches | 240 | `search` | full-text across issues and patches |
241 | `log` | raw event stream, chronological | 241 | `log` | raw event stream, chronological |
242 | `refs` | list this repository's collab refs and what each one is |
242 | `key` | manage trusted signing keys | 243 | `key` | manage trusted signing keys |
243 | `whoami`, `identity` | your identity and its aliases | 244 | `whoami`, `identity` | your identity and its aliases |
244 | `release` | publish, list and delete artifacts on a server | 245 | `release` | publish, list and delete artifacts on a server |
@@ -493,6 +494,34 @@ Each event commit's tree holds the event as canonical JSON alongside its
493 detached signature and public key. Nothing is stored outside the object 494 detached signature and public key. Nothing is stored outside the object
494 database, so `git gc`, `git fsck` and every other git tool work unchanged. 495 database, so `git gc`, `git fsck` and every other git tool work unchanged.
495 496
497 ### Looking at them
498
499 The patch refs nest, and that trips up the obvious way to inspect them:
500 `git for-each-ref` globs do **not** cross `/`, so `refs/collab/*` matches
501 nothing below the first level and reports it as an empty result rather than an
502 error. Use a prefix with no glob, or `**`:
503
504 ```console
505 $ git for-each-ref 'refs/collab/*' # nothing, and no error
506 $ git for-each-ref refs/collab # everything
507 $ git for-each-ref 'refs/collab/**' # everything
508 ```
509
510 Better, ask the tool, which knows what each ref is:
511
512 ```console
513 $ git-collab refs
514 patch events a1b2c3d4e5f6 refs/collab/patches/<id>/events
515 patch revision 9f8e7d6c5b4a refs/collab/patches/<id>/rev/9f8e7d6c5b4a...
516
517 2 collab refs: 1 patch, 1 revision.
518 ```
519
520 It takes `--json`, and it is the one command that never migrates the repository
521 on the way past — so it can be trusted to report a layout an older version
522 wrote rather than quietly converting it. `git-collab-server refs --config
523 server.toml` answers the same question across a server's repositories.
524
496 ## Status 525 ## Status
497 526
498 Early. The format has changed before and may change again; there is no 527 Early. The format has changed before and may change again; there is no
src/cli.rs
Old New
@@ -190,6 +190,7 @@ impl Cli {
190 Commands::Identity(cmd) => cmd.wants_json(), 190 Commands::Identity(cmd) => cmd.wants_json(),
191 Commands::Hooks(HookCmd::Install { json }) => *json, 191 Commands::Hooks(HookCmd::Install { json }) => *json,
192 Commands::InitKey { json, .. } => *json, 192 Commands::InitKey { json, .. } => *json,
193 Commands::Refs { json } => *json,
193 _ => false, 194 _ => false,
194 } 195 }
195 } 196 }
@@ -278,6 +279,21 @@ pub enum Commands {
278 #[command(subcommand, alias = "identities")] 279 #[command(subcommand, alias = "identities")]
279 Identity(IdentityCmd), 280 Identity(IdentityCmd),
280 281
282 /// List this repository's collab refs and say what each one is
283 ///
284 /// The layout nests — `refs/collab/patches/<id>/events` and
285 /// `<id>/rev/<oid>` — and `git for-each-ref` globs do not cross `/`, so
286 /// `for-each-ref 'refs/collab/*'` reports nothing and looks like an empty
287 /// answer. This enumerates the whole namespace, including the shapes an
288 /// older version wrote, and never converts one: it is the one command that
289 /// does not migrate the repository on the way past.
290 #[command(alias = "ref-list")]
291 Refs {
292 /// Output as JSON
293 #[arg(long)]
294 json: bool,
295 },
296
281 /// Full-text search across all issues and patches 297 /// Full-text search across all issues and patches
282 #[command(alias = "find", alias = "grep")] 298 #[command(alias = "find", alias = "grep")]
283 Search { 299 Search {
src/lib.rs
Old New
@@ -14,6 +14,7 @@ pub mod log;
14 pub mod merge_scan; 14 pub mod merge_scan;
15 pub mod output; 15 pub mod output;
16 pub mod patch; 16 pub mod patch;
17 pub mod refs;
17 pub mod release; 18 pub mod release;
18 pub mod signing; 19 pub mod signing;
19 pub mod state; 20 pub mod state;
@@ -218,7 +219,15 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
218 // Excluded: the `commit-msg` hook, which runs inside `git commit` and has 219 // Excluded: the `commit-msg` hook, which runs inside `git commit` and has
219 // one job — not to give git a reason to abort, and not to spray migration 220 // one job — not to give git a reason to abort, and not to spray migration
220 // warnings into the middle of a commit. 221 // warnings into the middle of a commit.
221 if !matches!(cli.command, Commands::Hooks(HookCmd::RunCommitMsg { .. })) { 222 //
223 // Excluded too, and for a different reason: `refs`, whose entire purpose is
224 // to report what shapes this repository holds. Migrating first would make
225 // it answer its own question — a repository with a legacy ref would be
226 // converted by the act of asking, and the answer would always be "none".
227 if !matches!(
228 cli.command,
229 Commands::Hooks(HookCmd::RunCommitMsg { .. }) | Commands::Refs { .. }
230 ) {
222 state::migrate_patch_layout(repo); 231 state::migrate_patch_layout(repo);
223 } 232 }
224 233
@@ -1575,6 +1584,15 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
1575 ) 1584 )
1576 } 1585 }
1577 }, 1586 },
1587 Commands::Refs { json } => {
1588 let refs = refs::scan(repo)?;
1589 if json {
1590 println!("{}", serde_json::to_string_pretty(&refs::to_json(&refs))?);
1591 } else {
1592 refs::render(&refs, &mut std::io::stdout())?;
1593 }
1594 Ok(())
1595 }
1578 Commands::Search { query } => search(repo, &query), 1596 Commands::Search { query } => search(repo, &query),
1579 }?; 1597 }?;
1580 1598
src/refs.rs
Old New
@@ -0,0 +1,504 @@
1 //! What collab refs a repository holds, and what each of them is.
2 //!
3 //! The layout nests: a patch is `refs/collab/patches/<id>/events` with a
4 //! `<id>/rev/<oid>` beside it for every revision. That is fine for the tool,
5 //! which walks it with git2's `references_glob` — whose `*` crosses `/` — and a
6 //! trap for everyone else, because `git for-each-ref`'s globs do **not** cross
7 //! `/`. So the pattern printed in the README, `refs/collab/*`, matches nothing
8 //! below the first level and reports it as silence rather than as an error.
9 //!
10 //! Two different tools, two different meanings for one pattern, and no
11 //! diagnostic on the wrong guess. This module exists so nobody has to know
12 //! which side of that boundary they are standing on: the tool already knows how
13 //! to enumerate its own refs, so it can be asked.
14 //!
15 //! ## This is a read
16 //!
17 //! Nothing here writes, and the CLI excludes `refs` from the layout migration
18 //! it runs on entry to every other command. That is not tidiness. The question
19 //! this command exists to answer — *does this repository still hold a
20 //! pre-migration shape?* — is unanswerable by a command that migrates before it
21 //! looks, and a server-side read that wrote to the repository it was reading was
22 //! already one real bug (issue 5174338f).
23
24 use std::collections::BTreeMap;
25 use std::io::Write;
26
27 use git2::Repository;
28
29 /// The whole namespace. Deliberately without a glob: `references_glob` would
30 /// cross `/` here and match everything anyway, but naming the prefix says what
31 /// is meant.
32 const COLLAB_PREFIX: &str = "refs/collab/";
33
34 /// What a collab ref is, as far as its name can say.
35 ///
36 /// Classification is by name alone: no object is read, so a ref pointing at a
37 /// missing or corrupt object is still reported rather than swallowed, which is
38 /// the case an operator debugging a half-finished sync most needs to see.
39 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
40 pub enum RefKind {
41 /// `issues/<id>` — an issue's event DAG.
42 Issue,
43 /// `patches/<id>/events` — a patch's event DAG, current layout.
44 PatchEvents,
45 /// `patches/<id>` — a patch's event DAG in the pre-migration layout, where
46 /// the bare id ref *was* the DAG.
47 PatchEventsLegacy,
48 /// `patches/<id>/rev/<oid>` — a revision's commit, pinned by its OID.
49 PatchRevision,
50 /// `patches/<id>/r/<n>` — a revision pinned by number, from the draft the
51 /// OID naming replaced.
52 PatchRevisionNumbered,
53 /// `local/…` — this clone's own bookkeeping (read markers, migration
54 /// parks). Never pushed.
55 Local,
56 /// `sync/…` — scratch space a fetch writes and a sync clears.
57 Sync,
58 /// Under `refs/collab/` and matching nothing this version knows.
59 Other,
60 }
61
62 impl RefKind {
63 /// The stable machine name, for `--json`.
64 pub fn slug(self) -> &'static str {
65 match self {
66 RefKind::Issue => "issue",
67 RefKind::PatchEvents => "patch-events",
68 RefKind::PatchEventsLegacy => "patch-events-legacy",
69 RefKind::PatchRevision => "patch-revision",
70 RefKind::PatchRevisionNumbered => "patch-revision-numbered",
71 RefKind::Local => "local",
72 RefKind::Sync => "sync",
73 RefKind::Other => "other",
74 }
75 }
76
77 /// What it is, in words, for the column a person reads.
78 pub fn label(self) -> &'static str {
79 match self {
80 RefKind::Issue => "issue",
81 RefKind::PatchEvents => "patch events",
82 RefKind::PatchEventsLegacy => "patch events (legacy layout)",
83 RefKind::PatchRevision => "patch revision",
84 RefKind::PatchRevisionNumbered => "patch revision (legacy numbering)",
85 RefKind::Local => "local bookkeeping",
86 RefKind::Sync => "sync scratch",
87 RefKind::Other => "unrecognised",
88 }
89 }
90
91 /// Whether this shape is one a migration converts.
92 ///
93 /// The single question blocking the removal of the compatibility code
94 /// (issue e5096ffc) is whether any repository anywhere still answers yes.
95 pub fn is_legacy(self) -> bool {
96 matches!(
97 self,
98 RefKind::PatchEventsLegacy | RefKind::PatchRevisionNumbered
99 )
100 }
101
102 /// The plural noun this kind counts as in a summary, coarser than the kind
103 /// itself: both patch layouts are patches, both revision namings are
104 /// revisions.
105 fn group(self) -> &'static str {
106 match self {
107 RefKind::Issue => "issues",
108 RefKind::PatchEvents | RefKind::PatchEventsLegacy => "patches",
109 RefKind::PatchRevision | RefKind::PatchRevisionNumbered => "revisions",
110 RefKind::Local => "local refs",
111 RefKind::Sync => "sync refs",
112 RefKind::Other => "unrecognised refs",
113 }
114 }
115 }
116
117 /// One collab ref, named in full.
118 ///
119 /// Full names and full OIDs throughout: this is the output an operator pastes
120 /// into `git update-ref`, and an abbreviation that has to be expanded again is
121 /// not a diagnostic.
122 #[derive(Debug, Clone, PartialEq, Eq)]
123 pub struct CollabRef {
124 /// The full ref name.
125 pub name: String,
126 /// The full OID the ref points at, or empty if it could not be resolved.
127 pub target: String,
128 pub kind: RefKind,
129 /// The full id of the issue or patch this ref belongs to, where it belongs
130 /// to one. A revision ref carries its *patch's* id — the pinned commit is
131 /// the target.
132 pub id: Option<String>,
133 /// Whether it lives under `refs/collab/archive/`.
134 pub archived: bool,
135 }
136
137 impl CollabRef {
138 pub fn is_legacy(&self) -> bool {
139 self.kind.is_legacy()
140 }
141
142 /// The `label()` of the kind, with archived said out loud.
143 fn display_label(&self) -> String {
144 if self.archived {
145 format!("{}, archived", self.kind.label())
146 } else {
147 self.kind.label().to_string()
148 }
149 }
150 }
151
152 /// Every ref under `refs/collab/`, classified, sorted by name.
153 ///
154 /// `references_glob` is what makes this correct where a hand-written
155 /// `for-each-ref` pattern is not: its `*` crosses `/`, so one pattern reaches
156 /// the whole subtree.
157 pub fn scan(repo: &Repository) -> Result<Vec<CollabRef>, crate::error::Error> {
158 let mut out = Vec::new();
159 for r in repo.references_glob(&format!("{}*", COLLAB_PREFIX))? {
160 let r = r?;
161 let Some(name) = r.name() else { continue };
162 // A symbolic ref has no direct target; resolve it rather than reporting
163 // a blank, and leave the blank for the genuinely broken.
164 let target = r
165 .target()
166 .or_else(|| r.resolve().ok().and_then(|r| r.target()))
167 .map(|oid| oid.to_string())
168 .unwrap_or_default();
169 let (kind, id, archived) = classify(name);
170 out.push(CollabRef {
171 name: name.to_string(),
172 target,
173 kind,
174 id,
175 archived,
176 });
177 }
178 out.sort_by(|a, b| a.name.cmp(&b.name));
179 Ok(out)
180 }
181
182 /// Work out what a ref name is, by name alone.
183 fn classify(name: &str) -> (RefKind, Option<String>, bool) {
184 let Some(rest) = name.strip_prefix(COLLAB_PREFIX) else {
185 return (RefKind::Other, None, false);
186 };
187 let (rest, archived) = match rest.strip_prefix("archive/") {
188 Some(inner) => (inner, true),
189 None => (rest, false),
190 };
191
192 if let Some(id) = rest.strip_prefix("issues/") {
193 // An issue is one ref, flat. Anything deeper is not an issue.
194 if id.is_empty() || id.contains('/') {
195 return (RefKind::Other, None, archived);
196 }
197 return (RefKind::Issue, Some(id.to_string()), archived);
198 }
199
200 if let Some(patch) = rest.strip_prefix("patches/") {
201 return classify_patch(patch, archived);
202 }
203
204 // `local/` and `sync/` are namespaces, not items: neither carries an id at
205 // a fixed depth, and both are this clone's own business.
206 if rest.starts_with("local/") {
207 return (RefKind::Local, None, archived);
208 }
209 if rest.starts_with("sync/") {
210 return (RefKind::Sync, None, archived);
211 }
212
213 (RefKind::Other, None, archived)
214 }
215
216 /// The part after `patches/`, which is where both layouts differ: the current
217 /// one always has a suffix, the pre-migration one never does.
218 fn classify_patch(rest: &str, archived: bool) -> (RefKind, Option<String>, bool) {
219 let Some((id, suffix)) = rest.split_once('/') else {
220 if rest.is_empty() {
221 return (RefKind::Other, None, archived);
222 }
223 return (RefKind::PatchEventsLegacy, Some(rest.to_string()), archived);
224 };
225 let id = Some(id.to_string());
226 let kind = match suffix {
227 "events" => RefKind::PatchEvents,
228 s if s.starts_with("rev/") => RefKind::PatchRevision,
229 s if s.starts_with("r/") => RefKind::PatchRevisionNumbered,
230 _ => return (RefKind::Other, None, archived),
231 };
232 (kind, id, archived)
233 }
234
235 /// How many refs of each kind, keyed by slug, zeroes omitted.
236 pub fn counts(refs: &[CollabRef]) -> BTreeMap<&'static str, usize> {
237 let mut counts = BTreeMap::new();
238 for r in refs {
239 *counts.entry(r.kind.slug()).or_insert(0) += 1;
240 }
241 counts
242 }
243
244 /// How many refs are in a shape a migration would convert.
245 pub fn legacy_count(refs: &[CollabRef]) -> usize {
246 refs.iter().filter(|r| r.is_legacy()).count()
247 }
248
249 /// The listing as one JSON object: the refs, counts by kind, and the two
250 /// numbers a script actually branches on.
251 pub fn to_json(refs: &[CollabRef]) -> serde_json::Value {
252 let entries: Vec<serde_json::Value> = refs
253 .iter()
254 .map(|r| {
255 serde_json::json!({
256 "ref": r.name,
257 "target": r.target,
258 "kind": r.kind.slug(),
259 "id": r.id,
260 "archived": r.archived,
261 "legacy": r.is_legacy(),
262 })
263 })
264 .collect();
265 let counts: serde_json::Map<String, serde_json::Value> = counts(refs)
266 .into_iter()
267 .map(|(k, v)| (k.to_string(), serde_json::Value::from(v)))
268 .collect();
269 serde_json::json!({
270 "refs": entries,
271 "counts": counts,
272 "total": refs.len(),
273 "legacy": legacy_count(refs),
274 })
275 }
276
277 /// How much of an OID to show a person. Full OIDs are in `--json`; a listing is
278 /// read down a column.
279 const TARGET_WIDTH: usize = 12;
280
281 /// Render the listing for a person: one aligned line per ref, then what it adds
282 /// up to.
283 pub fn render(refs: &[CollabRef], out: &mut impl Write) -> std::io::Result<()> {
284 if refs.is_empty() {
285 writeln!(out, "No collab refs in this repository.")?;
286 return Ok(());
287 }
288
289 let labels: Vec<String> = refs.iter().map(CollabRef::display_label).collect();
290 let width = labels.iter().map(String::len).max().unwrap_or(0);
291 for (r, label) in refs.iter().zip(&labels) {
292 let target: String = r.target.chars().take(TARGET_WIDTH).collect();
293 writeln!(
294 out,
295 "{:<width$} {:<target_width$} {}",
296 label,
297 target,
298 r.name,
299 width = width,
300 target_width = TARGET_WIDTH
301 )?;
302 }
303
304 writeln!(out)?;
305 writeln!(out, "{} collab refs: {}.", refs.len(), groups(refs))?;
306
307 let legacy = legacy_count(refs);
308 if legacy > 0 {
309 writeln!(
310 out,
311 "{} in a superseded layout ({}) — any other git-collab command in this \
312 repository converts them.",
313 legacy,
314 legacy_breakdown(refs)
315 )?;
316 }
317 Ok(())
318 }
319
320 /// `2 issues, 1 patch, 3 revisions`, in a fixed order rather than an alphabetical
321 /// one, so the shape of the listing does not change with its contents.
322 pub fn groups(refs: &[CollabRef]) -> String {
323 const ORDER: [RefKind; 6] = [
324 RefKind::Issue,
325 RefKind::PatchEvents,
326 RefKind::PatchRevision,
327 RefKind::Local,
328 RefKind::Sync,
329 RefKind::Other,
330 ];
331 let mut parts = Vec::new();
332 for kind in ORDER {
333 let n = refs
334 .iter()
335 .filter(|r| r.kind.group() == kind.group())
336 .count();
337 if n > 0 {
338 parts.push(format!("{} {}", n, singularise(kind.group(), n)));
339 }
340 }
341 parts.join(", ")
342 }
343
344 /// `1 bare patch ref, 2 numbered revision refs` — which superseded shapes, and
345 /// how many of each, so an operator knows what a migration would touch.
346 pub fn legacy_breakdown(refs: &[CollabRef]) -> String {
347 let mut parts = Vec::new();
348 for (kind, noun) in [
349 (RefKind::PatchEventsLegacy, "bare patch ref"),
350 (RefKind::PatchRevisionNumbered, "numbered revision ref"),
351 ] {
352 let n = refs.iter().filter(|r| r.kind == kind).count();
353 if n > 0 {
354 parts.push(format!("{} {}", n, singularise_noun(noun, n)));
355 }
356 }
357 parts.join(", ")
358 }
359
360 /// The group nouns are written plural; one of anything is singular.
361 fn singularise(plural: &str, n: usize) -> String {
362 if n != 1 {
363 return plural.to_string();
364 }
365 match plural {
366 "issues" => "issue".to_string(),
367 "patches" => "patch".to_string(),
368 "revisions" => "revision".to_string(),
369 "local refs" => "local ref".to_string(),
370 "sync refs" => "sync ref".to_string(),
371 other => other.to_string(),
372 }
373 }
374
375 /// The legacy nouns are written singular; more than one takes an `s`.
376 fn singularise_noun(singular: &str, n: usize) -> String {
377 if n == 1 {
378 singular.to_string()
379 } else {
380 format!("{}s", singular)
381 }
382 }
383
384 #[cfg(test)]
385 mod tests {
386 use super::*;
387
388 fn kind_of(name: &str) -> RefKind {
389 classify(name).0
390 }
391
392 #[test]
393 fn both_patch_layouts_are_told_apart() {
394 assert_eq!(
395 kind_of("refs/collab/patches/abc"),
396 RefKind::PatchEventsLegacy
397 );
398 assert_eq!(
399 kind_of("refs/collab/patches/abc/events"),
400 RefKind::PatchEvents
401 );
402 assert_eq!(
403 kind_of("refs/collab/patches/abc/rev/def"),
404 RefKind::PatchRevision
405 );
406 assert_eq!(
407 kind_of("refs/collab/patches/abc/r/1"),
408 RefKind::PatchRevisionNumbered
409 );
410 }
411
412 #[test]
413 fn a_revision_ref_carries_its_patchs_id() {
414 let (_, id, _) = classify("refs/collab/patches/abc/rev/def");
415 assert_eq!(id.as_deref(), Some("abc"));
416 }
417
418 #[test]
419 fn the_archive_namespace_keeps_its_shapes() {
420 let (kind, id, archived) = classify("refs/collab/archive/patches/abc/events");
421 assert_eq!(kind, RefKind::PatchEvents);
422 assert_eq!(id.as_deref(), Some("abc"));
423 assert!(archived);
424
425 let (kind, _, archived) = classify("refs/collab/archive/issues/abc");
426 assert_eq!(kind, RefKind::Issue);
427 assert!(archived);
428 }
429
430 #[test]
431 fn clone_private_namespaces_are_not_items() {
432 assert_eq!(kind_of("refs/collab/local/seen/issues/abc"), RefKind::Local);
433 assert_eq!(
434 kind_of("refs/collab/local/migrating/patches/abc"),
435 RefKind::Local
436 );
437 assert_eq!(
438 kind_of("refs/collab/sync/origin/patches/abc"),
439 RefKind::Sync
440 );
441 }
442
443 #[test]
444 fn an_unknown_shape_is_reported_not_guessed() {
445 assert_eq!(kind_of("refs/collab/futures/abc"), RefKind::Other);
446 assert_eq!(kind_of("refs/collab/issues/abc/extra"), RefKind::Other);
447 assert_eq!(kind_of("refs/heads/main"), RefKind::Other);
448 }
449
450 #[test]
451 fn only_the_superseded_shapes_count_as_legacy() {
452 assert!(RefKind::PatchEventsLegacy.is_legacy());
453 assert!(RefKind::PatchRevisionNumbered.is_legacy());
454 for kind in [
455 RefKind::Issue,
456 RefKind::PatchEvents,
457 RefKind::PatchRevision,
458 RefKind::Local,
459 RefKind::Sync,
460 RefKind::Other,
461 ] {
462 assert!(!kind.is_legacy(), "{:?} is current", kind);
463 }
464 }
465
466 fn sample() -> Vec<CollabRef> {
467 ["refs/collab/patches/a/events", "refs/collab/patches/b"]
468 .into_iter()
469 .map(|name| {
470 let (kind, id, archived) = classify(name);
471 CollabRef {
472 name: name.to_string(),
473 target: "0".repeat(40),
474 kind,
475 id,
476 archived,
477 }
478 })
479 .collect()
480 }
481
482 #[test]
483 fn the_summary_names_the_legacy_refs_it_found() {
484 let mut out = Vec::new();
485 render(&sample(), &mut out).unwrap();
486 let text = String::from_utf8(out).unwrap();
487 assert!(text.contains("2 collab refs: 2 patches."), "got {}", text);
488 assert!(
489 text.contains("1 in a superseded layout (1 bare patch ref)"),
490 "got {}",
491 text
492 );
493 }
494
495 #[test]
496 fn an_empty_namespace_is_stated() {
497 let mut out = Vec::new();
498 render(&[], &mut out).unwrap();
499 assert_eq!(
500 String::from_utf8(out).unwrap(),
501 "No collab refs in this repository.\n"
502 );
503 }
504 }
src/server/main.rs
Old New
@@ -7,6 +7,7 @@ mod config;
7 mod governance; 7 mod governance;
8 mod http; 8 mod http;
9 mod migrate; 9 mod migrate;
10 mod refs;
10 mod releases; 11 mod releases;
11 mod repos; 12 mod repos;
12 mod ssh; 13 mod ssh;
@@ -59,6 +60,22 @@ enum Command {
59 #[arg(long)] 60 #[arg(long)]
60 dry_run: bool, 61 dry_run: bool,
61 }, 62 },
63
64 /// Report what collab refs the hosted repositories hold.
65 ///
66 /// A read, and only a read: no lock, no writes, and a repository that
67 /// cannot be opened is reported and counted rather than skipped, because an
68 /// audit that quietly omits a repository answers "nothing legacy here" for
69 /// a roster it did not finish reading.
70 Refs {
71 /// The same config the server runs with; only `repos_dir` is read.
72 #[arg(short, long)]
73 config: PathBuf,
74
75 /// Output as JSON
76 #[arg(long)]
77 json: bool,
78 },
62 } 79 }
63 80
64 #[tokio::main] 81 #[tokio::main]
@@ -79,15 +96,25 @@ async fn main() {
79 return; 96 return;
80 } 97 }
81 98
82 if let Some(Command::Migrate { config, dry_run }) = args.command { 99 // The subcommands are one-shot roster tools: they read the config for
83 let config = match config::ServerConfig::from_file(&config) { 100 // `repos_dir`, do their work, and exit. Only the no-subcommand case goes on
101 // to serve.
102 if let Some(command) = args.command {
103 let (path, dry_run, json) = match &command {
104 Command::Migrate { config, dry_run } => (config, *dry_run, false),
105 Command::Refs { config, json } => (config, false, *json),
106 };
107 let config = match config::ServerConfig::from_file(path) {
84 Ok(c) => c, 108 Ok(c) => c,
85 Err(e) => { 109 Err(e) => {
86 eprintln!("Failed to load config from {:?}: {}", config, e); 110 eprintln!("Failed to load config from {:?}: {}", path, e);
87 std::process::exit(1); 111 std::process::exit(1);
88 } 112 }
89 }; 113 };
90 std::process::exit(migrate::run(&config.repos_dir, dry_run)); 114 std::process::exit(match command {
115 Command::Migrate { .. } => migrate::run(&config.repos_dir, dry_run),
116 Command::Refs { .. } => refs::run(&config.repos_dir, json),
117 });
91 } 118 }
92 119
93 let Some(config_path) = args.config else { 120 let Some(config_path) = args.config else {
src/server/refs.rs
Old New
@@ -0,0 +1,206 @@
1 //! `git-collab-server refs`: what collab refs the hosted repositories hold.
2 //!
3 //! `migrate --dry-run` already answers "what would change here", but only for
4 //! the repositories it can write, and only in the vocabulary of a migration. An
5 //! operator sequencing the removal of the legacy compatibility code (issue
6 //! e5096ffc) has a narrower question — *does any repository on this server still
7 //! hold a superseded shape?* — and needs it answered without the tool being
8 //! allowed to change the answer.
9 //!
10 //! So this is the read half of `migrate`: the same enumeration, no lock, no
11 //! writes, not even the writability probe. It reports a repository it cannot
12 //! open rather than skipping it, and exits non-zero when there was one, because
13 //! an audit that silently omits a repository reports "nothing legacy" for a
14 //! roster it did not finish reading.
15 //!
16 //! It summarises rather than dumping every ref. A roster's worth of full ref
17 //! names is not something anybody reads, and the actionable subset is the
18 //! legacy ones — those are named in full, under `--json`, so the answer can be
19 //! acted on directly. For the whole listing of one repository, run
20 //! `git-collab refs` in a clone of it.
21
22 use std::path::Path;
23
24 use git_collab::refs::{self, CollabRef};
25
26 use crate::repos;
27
28 /// What one repository had to say.
29 enum Outcome {
30 Scanned(Vec<CollabRef>),
31 /// Could not be read, and why. Never silently skipped.
32 Unreadable(String),
33 }
34
35 pub fn run(repos_dir: &Path, json: bool) -> i32 {
36 let entries = match repos::discover(repos_dir) {
37 Ok(entries) => entries,
38 Err(e) => {
39 eprintln!("error: cannot read {}: {}", repos_dir.display(), e);
40 return 1;
41 }
42 };
43
44 let scanned: Vec<(String, Outcome)> = entries
45 .iter()
46 .map(|entry| (entry.name.clone(), scan_one(entry)))
47 .collect();
48
49 let unreadable = scanned
50 .iter()
51 .filter(|(_, o)| matches!(o, Outcome::Unreadable(_)))
52 .count();
53
54 if json {
55 println!(
56 "{}",
57 serde_json::to_string_pretty(&as_json(&scanned)).unwrap()
58 );
59 } else {
60 render(repos_dir, &scanned);
61 }
62
63 if unreadable > 0 {
64 1
65 } else {
66 0
67 }
68 }
69
70 fn scan_one(entry: &repos::RepoEntry) -> Outcome {
71 match repos::open(entry).map_err(|e| e.to_string()) {
72 Err(e) => Outcome::Unreadable(format!("cannot open the repository: {}", e)),
73 Ok(repo) => match refs::scan(&repo) {
74 Ok(found) => Outcome::Scanned(found),
75 Err(e) => Outcome::Unreadable(format!("cannot read its refs: {}", e)),
76 },
77 }
78 }
79
80 fn render(repos_dir: &Path, scanned: &[(String, Outcome)]) {
81 if scanned.is_empty() {
82 println!("No repositories found under {}.", repos_dir.display());
83 return;
84 }
85
86 let mut legacy_repos = 0usize;
87 let mut unreadable = 0usize;
88 for (name, outcome) in scanned {
89 match outcome {
90 Outcome::Unreadable(reason) => {
91 unreadable += 1;
92 println!("{}: cannot be read — {}", name, reason);
93 }
94 Outcome::Scanned(found) if found.is_empty() => {
95 println!("{}: no collab refs", name);
96 }
97 Outcome::Scanned(found) => {
98 let legacy = refs::legacy_count(found);
99 if legacy > 0 {
100 legacy_repos += 1;
101 println!(
102 "{}: {} collab refs — {}; {} in a superseded layout ({})",
103 name,
104 found.len(),
105 refs::groups(found),
106 legacy,
107 refs::legacy_breakdown(found)
108 );
109 } else {
110 println!(
111 "{}: {} collab refs — {}",
112 name,
113 found.len(),
114 refs::groups(found)
115 );
116 }
117 }
118 }
119 }
120
121 let mut tail = Vec::new();
122 if legacy_repos > 0 {
123 tail.push(format!(
124 "{} hold{} a superseded layout",
125 legacy_repos,
126 if legacy_repos == 1 { "s" } else { "" }
127 ));
128 }
129 if unreadable > 0 {
130 tail.push(format!("{} could not be read", unreadable));
131 }
132 if tail.is_empty() {
133 tail.push("all current".to_string());
134 }
135 println!(
136 "\n{} repositor{} scanned: {}.",
137 scanned.len(),
138 if scanned.len() == 1 { "y" } else { "ies" },
139 tail.join(", ")
140 );
141 }
142
143 fn as_json(scanned: &[(String, Outcome)]) -> serde_json::Value {
144 let repositories: Vec<serde_json::Value> = scanned
145 .iter()
146 .map(|(name, outcome)| match outcome {
147 Outcome::Unreadable(reason) => serde_json::json!({
148 "repo": name,
149 "error": reason,
150 }),
151 Outcome::Scanned(found) => {
152 // Full ref names and full ids: this is the list an operator
153 // acts on, and it has to be usable without a second lookup.
154 let legacy_refs: Vec<serde_json::Value> = found
155 .iter()
156 .filter(|r| r.is_legacy())
157 .map(|r| {
158 serde_json::json!({
159 "ref": r.name,
160 "target": r.target,
161 "kind": r.kind.slug(),
162 "id": r.id,
163 })
164 })
165 .collect();
166 let counts: serde_json::Map<String, serde_json::Value> = refs::counts(found)
167 .into_iter()
168 .map(|(k, v)| (k.to_string(), serde_json::Value::from(v)))
169 .collect();
170 serde_json::json!({
171 "repo": name,
172 "total": found.len(),
173 "legacy": legacy_refs.len(),
174 "counts": counts,
175 "legacy_refs": legacy_refs,
176 })
177 }
178 })
179 .collect();
180
181 let total: usize = scanned
182 .iter()
183 .filter_map(|(_, o)| match o {
184 Outcome::Scanned(found) => Some(found.len()),
185 Outcome::Unreadable(_) => None,
186 })
187 .sum();
188 let legacy: usize = scanned
189 .iter()
190 .filter_map(|(_, o)| match o {
191 Outcome::Scanned(found) => Some(refs::legacy_count(found)),
192 Outcome::Unreadable(_) => None,
193 })
194 .sum();
195 let unreadable = scanned
196 .iter()
197 .filter(|(_, o)| matches!(o, Outcome::Unreadable(_)))
198 .count();
199
200 serde_json::json!({
201 "repositories": repositories,
202 "total": total,
203 "legacy": legacy,
204 "unreadable": unreadable,
205 })
206 }
tests/refs_test.rs
Old New
@@ -0,0 +1,529 @@
1 //! `git-collab refs`: enumerate this clone's collab refs, and say what each is.
2 //!
3 //! The layout nests — `refs/collab/patches/<id>/events`, `<id>/rev/<oid>` — and
4 //! `git for-each-ref 'refs/collab/*'` silently reports nothing, because
5 //! for-each-ref's globs do not cross `/` while git2's `references_glob`, which
6 //! this tool uses internally, does. An operator should not have to know which
7 //! side of that boundary they are on to look at their own repository.
8 //!
9 //! The load-bearing property, beyond listing: this is a read. A repository
10 //! holding a pre-migration shape must still hold it afterwards, or the one
11 //! question the command exists to answer — *does this repo hold legacy
12 //! shapes?* — answers itself by destroying the evidence.
13
14 mod common;
15
16 use std::path::{Path, PathBuf};
17 use std::process::{Command, Output};
18
19 use common::{write_raw_event, TestRepo};
20 use serde_json::json;
21 use tempfile::TempDir;
22
23 /// Every ref under `refs/collab/`, as git itself sees it (no glob — that is the
24 /// whole point of the issue this command answers).
25 fn collab_refs(repo: &TestRepo) -> Vec<String> {
26 let mut refs: Vec<String> = repo
27 .git(&["for-each-ref", "--format=%(refname)", "refs/collab/"])
28 .lines()
29 .map(str::to_string)
30 .collect();
31 refs.sort();
32 refs
33 }
34
35 /// Expand a short patch id to the full 40-char id via the ref listing.
36 fn full_patch_id(repo: &TestRepo, short: &str) -> String {
37 for name in collab_refs(repo) {
38 if let Some(rest) = name.strip_prefix("refs/collab/patches/") {
39 let id = rest.split('/').next().unwrap_or_default();
40 if id.starts_with(short) {
41 return id.to_string();
42 }
43 }
44 }
45 panic!("no patch ref matching {}", short);
46 }
47
48 /// Create a patch on a fresh branch holding one commit. Returns (full id, tip).
49 fn patch_on_branch(repo: &TestRepo, branch: &str, file: &str) -> (String, String) {
50 repo.git(&["checkout", "-b", branch]);
51 let tip = repo.commit_file(file, "v1", &format!("add {}", file));
52 let out = repo.run_ok(&["patch", "create", "-t", branch, "-B", branch]);
53 let short = out
54 .trim()
55 .strip_prefix("Created patch ")
56 .unwrap_or_else(|| panic!("unexpected create output: {}", out))
57 .to_string();
58 (full_patch_id(repo, &short), tip)
59 }
60
61 /// Write a patch in the pre-migration layout: one bare `refs/collab/patches/<id>`
62 /// ref that *is* the event DAG. Returns the full id.
63 ///
64 /// By hand, because no version of the tool that can still be built writes this
65 /// shape — and a repository somewhere holds it, which is the case under test.
66 fn legacy_bare_patch(repo: &TestRepo, title: &str) -> String {
67 let git_repo = git2::Repository::open(repo.dir.path()).unwrap();
68 let head = git_repo.head().unwrap().target().unwrap();
69 let tree = git_repo.find_commit(head).unwrap().tree().unwrap().id();
70 let root = write_raw_event(
71 &git_repo,
72 None,
73 json!({
74 "type": "patch.create",
75 "title": title,
76 "body": "",
77 "base_ref": "main",
78 "branch": "old",
79 "commit": head.to_string(),
80 "tree": tree.to_string(),
81 }),
82 1,
83 );
84 let id = root.to_string();
85 git_repo
86 .reference(
87 &format!("refs/collab/patches/{}", id),
88 root,
89 false,
90 "old layout",
91 )
92 .unwrap();
93 id
94 }
95
96 /// The one output line naming `needle`, or a panic naming what was there.
97 fn line_for<'a>(output: &'a str, needle: &str) -> &'a str {
98 output
99 .lines()
100 .find(|l| l.contains(needle))
101 .unwrap_or_else(|| panic!("no line naming {}, got:\n{}", needle, output))
102 }
103
104 /// A repository holding one of each shape that matters: a current patch with a
105 /// pinned revision, an issue, and — written after every CLI call, so nothing
106 /// migrates it out from under the test — a bare pre-migration patch ref and an
107 /// interim numbered revision ref.
108 fn mixed_layout_repo() -> (TestRepo, String, String, String) {
109 let repo = TestRepo::new("Alice", "alice@example.com");
110 let (modern, tip) = patch_on_branch(&repo, "feat", "a.txt");
111 repo.run_ok(&["issue", "open", "-t", "An issue"]);
112
113 let legacy = legacy_bare_patch(&repo, "Written by an older version");
114
115 // The interim shape: a revision named by number rather than by the OID it
116 // pins. It can only exist beside an events ref, so it hangs off the current
117 // patch.
118 repo.git(&[
119 "update-ref",
120 &format!("refs/collab/patches/{}/r/1", modern),
121 &tip,
122 ]);
123
124 (repo, modern, legacy, tip)
125 }
126
127 // ===========================================================================
128 // Enumeration
129 // ===========================================================================
130
131 #[test]
132 fn refs_reports_both_patch_layouts() {
133 let (repo, modern, legacy, tip) = mixed_layout_repo();
134 let out = repo.run_ok(&["refs"]);
135
136 let events = line_for(&out, &format!("refs/collab/patches/{}/events", modern));
137 assert!(
138 events.contains("patch events") && !events.contains("legacy"),
139 "the current layout must not be reported as legacy: {:?}",
140 events
141 );
142
143 let bare = line_for(&out, &format!("refs/collab/patches/{}", legacy));
144 assert!(
145 bare.contains("legacy"),
146 "a bare <id> ref is the pre-migration layout and must say so: {:?}",
147 bare
148 );
149
150 let rev = line_for(&out, &format!("refs/collab/patches/{}/rev/{}", modern, tip));
151 assert!(
152 rev.contains("revision") && !rev.contains("legacy"),
153 "a rev/<oid> ref pins a revision: {:?}",
154 rev
155 );
156
157 let numbered = line_for(&out, &format!("refs/collab/patches/{}/r/1", modern));
158 assert!(
159 numbered.contains("legacy"),
160 "a numbered r/<n> ref is the interim layout: {:?}",
161 numbered
162 );
163
164 let issue_ref = collab_refs(&repo)
165 .into_iter()
166 .find(|r| r.starts_with("refs/collab/issues/"))
167 .expect("the issue must have a ref");
168 assert!(
169 line_for(&out, &issue_ref).contains("issue"),
170 "issues are collab refs too"
171 );
172 }
173
174 #[test]
175 fn refs_summarises_the_legacy_shapes_it_found() {
176 let (repo, _modern, _legacy, _tip) = mixed_layout_repo();
177 let out = repo.run_ok(&["refs"]);
178
179 // The summary is the whole answer to "does this repo hold legacy shapes?",
180 // which is what blocks stripping the compatibility code.
181 let summary = line_for(&out, "legacy");
182 assert!(
183 out.lines().any(|l| l.contains("2") && l.contains("legacy")),
184 "expected a summary counting both legacy refs, got {:?}\nfull:\n{}",
185 summary,
186 out
187 );
188 }
189
190 #[test]
191 fn refs_on_a_clean_repo_says_so() {
192 let repo = TestRepo::new("Alice", "alice@example.com");
193 let out = repo.run_ok(&["refs"]);
194 assert!(
195 out.to_lowercase().contains("no collab refs"),
196 "an empty namespace must be stated, not printed as silence: {:?}",
197 out
198 );
199 }
200
201 // ===========================================================================
202 // JSON
203 // ===========================================================================
204
205 #[test]
206 fn refs_json_carries_full_ids() {
207 let (repo, modern, legacy, tip) = mixed_layout_repo();
208 let out = repo.run_ok(&["refs", "--json"]);
209 let v: serde_json::Value = serde_json::from_str(&out)
210 .unwrap_or_else(|e| panic!("--json must emit one JSON value ({}): {}", e, out));
211
212 let refs = v["refs"].as_array().expect("refs array");
213 let find = |name: &str| -> serde_json::Value {
214 refs.iter()
215 .find(|r| r["ref"] == name)
216 .unwrap_or_else(|| panic!("no entry for {} in {}", name, out))
217 .clone()
218 };
219
220 let events = find(&format!("refs/collab/patches/{}/events", modern));
221 assert_eq!(events["kind"], "patch-events");
222 assert_eq!(
223 events["id"], modern,
224 "--json carries full ids, never abbreviations"
225 );
226 assert_eq!(events["legacy"], false);
227 assert_eq!(events["archived"], false);
228 assert_eq!(
229 events["target"].as_str().unwrap().len(),
230 40,
231 "the target is a full OID"
232 );
233
234 let bare = find(&format!("refs/collab/patches/{}", legacy));
235 assert_eq!(bare["kind"], "patch-events-legacy");
236 assert_eq!(bare["id"], legacy);
237 assert_eq!(bare["legacy"], true);
238
239 let rev = find(&format!("refs/collab/patches/{}/rev/{}", modern, tip));
240 assert_eq!(rev["kind"], "patch-revision");
241 assert_eq!(rev["id"], modern, "a revision ref belongs to its patch");
242 assert_eq!(rev["target"], tip);
243 assert_eq!(rev["legacy"], false);
244
245 let numbered = find(&format!("refs/collab/patches/{}/r/1", modern));
246 assert_eq!(numbered["kind"], "patch-revision-numbered");
247 assert_eq!(numbered["id"], modern);
248 assert_eq!(numbered["legacy"], true);
249
250 assert_eq!(v["legacy"], 2, "two refs are in a superseded shape");
251 assert_eq!(v["total"], refs.len(), "total counts what was listed");
252 assert_eq!(v["counts"]["patch-events"], 1);
253 assert_eq!(v["counts"]["patch-events-legacy"], 1);
254 }
255
256 #[test]
257 fn refs_json_on_a_clean_repo_is_an_empty_listing() {
258 let repo = TestRepo::new("Alice", "alice@example.com");
259 let out = repo.run_ok(&["refs", "--json"]);
260 let v: serde_json::Value = serde_json::from_str(&out).unwrap();
261 assert_eq!(v["refs"].as_array().unwrap().len(), 0);
262 assert_eq!(v["total"], 0);
263 assert_eq!(v["legacy"], 0);
264 }
265
266 // ===========================================================================
267 // Reading must not write
268 // ===========================================================================
269
270 // ===========================================================================
271 // The server side: the same enumeration over a roster
272 // ===========================================================================
273
274 fn git_in(dir: &Path, args: &[&str]) -> String {
275 let output = Command::new("git")
276 .args(args)
277 .current_dir(dir)
278 .output()
279 .expect("failed to run git");
280 assert!(
281 output.status.success(),
282 "git {:?} in {:?} failed: {}",
283 args,
284 dir,
285 String::from_utf8_lossy(&output.stderr)
286 );
287 String::from_utf8(output.stdout).unwrap()
288 }
289
290 /// A `repos_dir` with a `server.toml` beside it, and no server running: all
291 /// `git-collab-server refs` needs, since it only reads.
292 struct Roster {
293 root: TempDir,
294 repos_dir: PathBuf,
295 config: PathBuf,
296 }
297
298 impl Roster {
299 fn new() -> Self {
300 let root = TempDir::new().unwrap();
301 let repos_dir = root.path().join("repos");
302 std::fs::create_dir_all(&repos_dir).unwrap();
303 let authorized_keys = root.path().join("authorized_keys");
304 std::fs::write(&authorized_keys, "").unwrap();
305 let config = root.path().join("server.toml");
306 std::fs::write(
307 &config,
308 format!(
309 "repos_dir = {:?}\nauthorized_keys = {:?}\n",
310 repos_dir, authorized_keys
311 ),
312 )
313 .unwrap();
314 Roster {
315 root,
316 repos_dir,
317 config,
318 }
319 }
320
321 /// A hosted bare repository holding one patch. Returns (path, full id).
322 fn seed(&self, name: &str) -> (PathBuf, String) {
323 let bare = self.repos_dir.join(format!("{name}.git"));
324 git_in(
325 self.root.path(),
326 &["init", "-q", "--bare", "-b", "main", bare.to_str().unwrap()],
327 );
328
329 let work = TestRepo::new("Alice", "alice@example.com");
330 work.patch_create("A patch");
331 work.git(&["push", "-q", bare.to_str().unwrap(), "main:main"]);
332 work.git(&[
333 "push",
334 "-q",
335 bare.to_str().unwrap(),
336 "refs/collab/*:refs/collab/*",
337 ]);
338
339 let refs = git_in(
340 &bare,
341 &["for-each-ref", "--format=%(refname)", "refs/collab/"],
342 );
343 let id = refs
344 .lines()
345 .find_map(|n| n.strip_prefix("refs/collab/patches/"))
346 .and_then(|rest| rest.split('/').next())
347 .expect("a patch ref")
348 .to_string();
349 (bare, id)
350 }
351
352 /// Put a hosted repository's patch back into the pre-migration layout.
353 fn demote(&self, bare: &Path, id: &str) {
354 let events = format!("refs/collab/patches/{}/events", id);
355 let tip = git_in(bare, &["rev-parse", &events]).trim().to_string();
356 let names = git_in(
357 bare,
358 &[
359 "for-each-ref",
360 "--format=%(refname)",
361 &format!("refs/collab/patches/{}/", id),
362 ],
363 );
364 for name in names.lines() {
365 git_in(bare, &["update-ref", "-d", name]);
366 }
367 git_in(
368 bare,
369 &["update-ref", &format!("refs/collab/patches/{}", id), &tip],
370 );
371 }
372
373 fn refs(&self, extra: &[&str]) -> Output {
374 let mut args = vec!["refs", "--config", self.config.to_str().unwrap()];
375 args.extend_from_slice(extra);
376 Command::new(env!("CARGO_BIN_EXE_git-collab-server"))
377 .args(&args)
378 .output()
379 .expect("failed to run git-collab-server refs")
380 }
381 }
382
383 /// Every collab ref in a bare repository, as sorted `<name> <oid>` lines.
384 fn snapshot(bare: &Path) -> String {
385 let mut lines: Vec<String> = git_in(
386 bare,
387 &[
388 "for-each-ref",
389 "--format=%(refname) %(objectname)",
390 "refs/collab/",
391 ],
392 )
393 .lines()
394 .map(str::to_string)
395 .collect();
396 lines.sort();
397 lines.join("\n")
398 }
399
400 #[test]
401 fn server_refs_names_the_repositories_holding_a_legacy_layout() {
402 let roster = Roster::new();
403 let (old, old_id) = roster.seed("old");
404 roster.demote(&old, &old_id);
405 roster.seed("current");
406
407 let output = roster.refs(&[]);
408 assert!(output.status.success(), "a clean audit exits 0");
409 let stdout = String::from_utf8(output.stdout).unwrap();
410
411 assert!(
412 line_for(&stdout, "old:").contains("superseded"),
413 "the demoted repository must be called out: {}",
414 stdout
415 );
416 assert!(
417 !line_for(&stdout, "current:").contains("superseded"),
418 "the current repository must not be: {}",
419 stdout
420 );
421 assert!(
422 stdout.contains("2 repositories scanned"),
423 "expected a roster summary, got {}",
424 stdout
425 );
426 }
427
428 #[test]
429 fn server_refs_json_names_the_legacy_refs_in_full() {
430 let roster = Roster::new();
431 let (old, old_id) = roster.seed("old");
432 roster.demote(&old, &old_id);
433 roster.seed("current");
434
435 let output = roster.refs(&["--json"]);
436 let stdout = String::from_utf8(output.stdout).unwrap();
437 let v: serde_json::Value = serde_json::from_str(&stdout)
438 .unwrap_or_else(|e| panic!("--json must emit one JSON value ({}): {}", e, stdout));
439
440 assert_eq!(v["legacy"], 1, "one legacy ref across the roster");
441 assert_eq!(v["unreadable"], 0);
442
443 let repos = v["repositories"].as_array().unwrap();
444 let old_entry = repos
445 .iter()
446 .find(|r| r["repo"] == "old")
447 .expect("the demoted repository");
448 assert_eq!(old_entry["legacy"], 1);
449 assert_eq!(
450 old_entry["legacy_refs"][0]["ref"],
451 format!("refs/collab/patches/{}", old_id),
452 "the ref an operator has to act on, named in full"
453 );
454 assert_eq!(old_entry["legacy_refs"][0]["id"], old_id);
455 assert_eq!(old_entry["legacy_refs"][0]["kind"], "patch-events-legacy");
456
457 let current = repos
458 .iter()
459 .find(|r| r["repo"] == "current")
460 .expect("the current repository");
461 assert_eq!(current["legacy"], 0);
462 assert_eq!(current["legacy_refs"].as_array().unwrap().len(), 0);
463 }
464
465 #[test]
466 fn server_refs_reports_a_repository_it_cannot_read_rather_than_skipping_it() {
467 let roster = Roster::new();
468 roster.seed("fine");
469 // A directory the roster walk counts as a repository — it has a `HEAD` —
470 // and git2 cannot open, having nothing else. Silently skipping it would let
471 // the audit answer "nothing legacy" for a roster it never finished reading.
472 let broken = roster.repos_dir.join("broken.git");
473 std::fs::create_dir_all(&broken).unwrap();
474 std::fs::write(broken.join("HEAD"), "ref: refs/heads/main\n").unwrap();
475
476 let output = roster.refs(&[]);
477 assert!(
478 !output.status.success(),
479 "an audit that could not read everything must not exit 0"
480 );
481 let stdout = String::from_utf8(output.stdout).unwrap();
482 assert!(
483 stdout.contains("broken") && stdout.contains("cannot be read"),
484 "the unreadable repository must be named: {}",
485 stdout
486 );
487 }
488
489 #[test]
490 fn server_refs_writes_nothing_to_the_repositories_it_reads() {
491 let roster = Roster::new();
492 let (old, old_id) = roster.seed("old");
493 roster.demote(&old, &old_id);
494 let before = snapshot(&old);
495
496 roster.refs(&[]);
497 roster.refs(&["--json"]);
498
499 assert_eq!(
500 snapshot(&old),
501 before,
502 "reading a hosted repository must leave its refs byte-identical — \
503 serving one that migrated on read was issue 5174338f"
504 );
505 }
506
507 #[test]
508 fn refs_does_not_migrate_what_it_reports() {
509 let (repo, modern, legacy, _tip) = mixed_layout_repo();
510 let before = collab_refs(&repo);
511
512 repo.run_ok(&["refs"]);
513 repo.run_ok(&["refs", "--json"]);
514
515 assert_eq!(
516 collab_refs(&repo),
517 before,
518 "listing refs must not move, convert or add a single one — every other \
519 command migrates on entry, and this is the one that must not"
520 );
521 assert!(
522 before.contains(&format!("refs/collab/patches/{}", legacy)),
523 "precondition: the bare ref was there to begin with"
524 );
525 assert!(
526 before.contains(&format!("refs/collab/patches/{}/r/1", modern)),
527 "precondition: the numbered ref was there to begin with"
528 );
529 }