9a13fc36
Let the tool enumerate its own refs
a73x 2026-08-13 14:52
Commit message
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 | } | ||