b2db9cf4
Stop the server rewriting the repositories it serves
a73x 2026-08-13 09:23
Commit message
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -190,6 +190,21 @@ fn report( | |||
| 190 | } | 190 | } |
| 191 | 191 | ||
| 192 | pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | 192 | pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { |
| 193 | // The one place the CLI brings a legacy patch layout up to date, and the | ||
| 194 | // reason it is here rather than inside `list_patches`: the CLI owns the | ||
| 195 | // repository it was invoked in, so a write is its to make, whereas the | ||
| 196 | // server does not own the repositories it serves and must never write | ||
| 197 | // while rendering. Reads tolerate both layouts either way; writes cannot, | ||
| 198 | // because `refs/collab/patches/<id>/rev/<oid>` and a bare | ||
| 199 | // `refs/collab/patches/<id>` are a directory/file conflict git refuses. | ||
| 200 | // | ||
| 201 | // Excluded: the `commit-msg` hook, which runs inside `git commit` and has | ||
| 202 | // one job — not to give git a reason to abort, and not to spray migration | ||
| 203 | // warnings into the middle of a commit. | ||
| 204 | if !matches!(cli.command, Commands::Hooks(HookCmd::RunCommitMsg { .. })) { | ||
| 205 | state::migrate_patch_layout(repo); | ||
| 206 | } | ||
| 207 | |||
| 193 | let is_write = cli.command.is_write(); | 208 | let is_write = cli.command.is_write(); |
| 194 | match cli.command { | 209 | match cli.command { |
| 195 | Commands::Init => sync::init(repo), | 210 | Commands::Init => sync::init(repo), |
src/server/main.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,11 +1,12 @@ | |||
| 1 | use std::path::PathBuf; | 1 | use std::path::PathBuf; |
| 2 | 2 | ||
| 3 | use clap::Parser; | 3 | use clap::{CommandFactory, Parser, Subcommand}; |
| 4 | use tracing::info; | 4 | use tracing::info; |
| 5 | 5 | ||
| 6 | mod config; | 6 | mod config; |
| 7 | mod governance; | 7 | mod governance; |
| 8 | mod http; | 8 | mod http; |
| 9 | mod migrate; | ||
| 9 | mod releases; | 10 | mod releases; |
| 10 | mod repos; | 11 | mod repos; |
| 11 | mod ssh; | 12 | mod ssh; |
| @@ -17,7 +18,11 @@ mod ssh; | |||
| 17 | about = "Minimal git hosting server" | 18 | about = "Minimal git hosting server" |
| 18 | )] | 19 | )] |
| 19 | struct Args { | 20 | struct Args { |
| 20 | #[arg(short, long, required_unless_present = "governance_hook")] | 21 | // Not `required_unless_present`: a subcommand is not an arg, so clap |
| 22 | // cannot express "unless one was given" here. Checked by hand at the one | ||
| 23 | // point that reaches for it, with the same clap error it would have | ||
| 24 | // produced. | ||
| 25 | #[arg(short, long)] | ||
| 21 | config: Option<PathBuf>, | 26 | config: Option<PathBuf>, |
| 22 | 27 | ||
| 23 | /// Internal: run as the `update` hook this server installs in the | 28 | /// Internal: run as the `update` hook this server installs in the |
| @@ -32,6 +37,28 @@ struct Args { | |||
| 32 | conflicts_with = "config" | 37 | conflicts_with = "config" |
| 33 | )] | 38 | )] |
| 34 | governance_hook: Option<Vec<String>>, | 39 | governance_hook: Option<Vec<String>>, |
| 40 | |||
| 41 | #[command(subcommand)] | ||
| 42 | command: Option<Command>, | ||
| 43 | } | ||
| 44 | |||
| 45 | #[derive(Subcommand)] | ||
| 46 | enum Command { | ||
| 47 | /// Bring hosted repositories' collab refs up to the current layout. | ||
| 48 | /// | ||
| 49 | /// Serving a repository never migrates it — rendering a page must not | ||
| 50 | /// write to the repository being rendered — so this is where an operator | ||
| 51 | /// converts a roster, deliberately, before an upgrade that drops support | ||
| 52 | /// for the old layout. | ||
| 53 | Migrate { | ||
| 54 | /// The same config the server runs with; only `repos_dir` is read. | ||
| 55 | #[arg(short, long)] | ||
| 56 | config: PathBuf, | ||
| 57 | |||
| 58 | /// Report what would change, and change nothing. | ||
| 59 | #[arg(long)] | ||
| 60 | dry_run: bool, | ||
| 61 | }, | ||
| 35 | } | 62 | } |
| 36 | 63 | ||
| 37 | #[tokio::main] | 64 | #[tokio::main] |
| @@ -49,10 +76,25 @@ async fn main() { | |||
| 49 | return; | 76 | return; |
| 50 | } | 77 | } |
| 51 | 78 | ||
| 52 | // `required_unless_present` above leaves exactly one way to get here. | 79 | if let Some(Command::Migrate { config, dry_run }) = args.command { |
| 53 | let config_path = args | 80 | let config = match config::ServerConfig::from_file(&config) { |
| 54 | .config | 81 | Ok(c) => c, |
| 55 | .expect("--config is required without --governance-hook"); | 82 | Err(e) => { |
| 83 | eprintln!("Failed to load config from {:?}: {}", config, e); | ||
| 84 | std::process::exit(1); | ||
| 85 | } | ||
| 86 | }; | ||
| 87 | std::process::exit(migrate::run(&config.repos_dir, dry_run)); | ||
| 88 | } | ||
| 89 | |||
| 90 | let Some(config_path) = args.config else { | ||
| 91 | Args::command() | ||
| 92 | .error( | ||
| 93 | clap::error::ErrorKind::MissingRequiredArgument, | ||
| 94 | "the following required arguments were not provided:\n --config <CONFIG>", | ||
| 95 | ) | ||
| 96 | .exit(); | ||
| 97 | }; | ||
| 56 | 98 | ||
| 57 | let config = match config::ServerConfig::from_file(&config_path) { | 99 | let config = match config::ServerConfig::from_file(&config_path) { |
| 58 | Ok(c) => c, | 100 | Ok(c) => c, |
src/server/migrate.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,209 @@ | |||
| 1 | //! `git-collab-server migrate`: bring hosted repositories' collab refs up to | ||
| 2 | //! the current layout, deliberately. | ||
| 3 | //! | ||
| 4 | //! Migration used to happen as a side effect of somebody loading a page, which | ||
| 5 | //! meant an anonymous HTTP request rewrote the repository being served (issue | ||
| 6 | //! 5174338f). Reading now tolerates the pre-migration layout without touching | ||
| 7 | //! it, which leaves migration needing an owner — and the owner is the operator, | ||
| 8 | //! here, at a moment of their choosing. | ||
| 9 | //! | ||
| 10 | //! Sequencing is the point. Once legacy support is stripped, an unmigrated | ||
| 11 | //! repository will not render at all; an operator needs to convert the roster | ||
| 12 | //! *before* upgrading rather than discovering the problem from a 500. | ||
| 13 | //! | ||
| 14 | //! ## Locking | ||
| 15 | //! | ||
| 16 | //! Each repository is migrated under its own `SyncLock` — `.git/collab/ | ||
| 17 | //! sync.lock`, the advisory lock `git-collab sync` already takes. That is a | ||
| 18 | //! real guarantee against a concurrent `git-collab sync` in the same | ||
| 19 | //! repository, and it makes the tool refuse rather than race. | ||
| 20 | //! | ||
| 21 | //! It is *not* a guarantee against a concurrent `git receive-pack`: git takes | ||
| 22 | //! no such lock, and nothing this process does could make it. What protects | ||
| 23 | //! that case is narrower and comes from git itself — every ref this migration | ||
| 24 | //! writes goes through git2, which takes the per-ref lockfile, so an individual | ||
| 25 | //! update cannot interleave with a push's update of the same ref. A push | ||
| 26 | //! landing mid-run can still leave the run's *report* stale. An operator who | ||
| 27 | //! wants the stronger property should stop the service first, and the dry run | ||
| 28 | //! is there to tell them whether it is worth the downtime. | ||
| 29 | |||
| 30 | use std::path::Path; | ||
| 31 | |||
| 32 | use git_collab::state::{self, MigrationReport}; | ||
| 33 | use git_collab::sync_lock::SyncLock; | ||
| 34 | |||
| 35 | use crate::repos; | ||
| 36 | |||
| 37 | /// What happened to one repository. | ||
| 38 | enum Outcome { | ||
| 39 | /// Nothing to do. | ||
| 40 | Current, | ||
| 41 | /// Changed, or — under `--dry-run` — would be. | ||
| 42 | Changed(MigrationReport), | ||
| 43 | /// Not attempted, or attempted and stopped before it could start, and why. | ||
| 44 | Blocked(String), | ||
| 45 | } | ||
| 46 | |||
| 47 | /// Migrate every repository under `repos_dir`. Returns the process exit code: | ||
| 48 | /// non-zero if anything at all could not be migrated, because a sweep run | ||
| 49 | /// before an upgrade is only useful if a partial result is loud. | ||
| 50 | pub fn run(repos_dir: &Path, dry_run: bool) -> i32 { | ||
| 51 | let entries = match repos::discover(repos_dir) { | ||
| 52 | Ok(entries) => entries, | ||
| 53 | Err(e) => { | ||
| 54 | eprintln!("error: cannot read {}: {}", repos_dir.display(), e); | ||
| 55 | return 1; | ||
| 56 | } | ||
| 57 | }; | ||
| 58 | |||
| 59 | if entries.is_empty() { | ||
| 60 | println!("No repositories found under {}.", repos_dir.display()); | ||
| 61 | return 0; | ||
| 62 | } | ||
| 63 | |||
| 64 | if dry_run { | ||
| 65 | println!("dry run: nothing will be written."); | ||
| 66 | } | ||
| 67 | |||
| 68 | let mut current = 0usize; | ||
| 69 | let mut changed = 0usize; | ||
| 70 | let mut blocked = 0usize; | ||
| 71 | |||
| 72 | for entry in &entries { | ||
| 73 | let outcome = migrate_one(entry, dry_run); | ||
| 74 | match &outcome { | ||
| 75 | Outcome::Current => current += 1, | ||
| 76 | Outcome::Changed(report) if report.failed.is_empty() => changed += 1, | ||
| 77 | // A report carrying failures counts as both: something moved, and | ||
| 78 | // something did not. It is the second half the exit code is about. | ||
| 79 | Outcome::Changed(_) => { | ||
| 80 | changed += 1; | ||
| 81 | blocked += 1; | ||
| 82 | } | ||
| 83 | Outcome::Blocked(_) => blocked += 1, | ||
| 84 | } | ||
| 85 | report(&entry.name, &outcome, dry_run); | ||
| 86 | } | ||
| 87 | |||
| 88 | println!( | ||
| 89 | "\n{} repositor{} scanned: {} {}, {} already current, {} could not be migrated.", | ||
| 90 | entries.len(), | ||
| 91 | if entries.len() == 1 { "y" } else { "ies" }, | ||
| 92 | changed, | ||
| 93 | if dry_run { "to migrate" } else { "migrated" }, | ||
| 94 | current, | ||
| 95 | blocked, | ||
| 96 | ); | ||
| 97 | |||
| 98 | // A dry run reports; it does not judge. Failing it on work still to do | ||
| 99 | // would make "is there anything to migrate?" indistinguishable from "did | ||
| 100 | // the migration break?", and the answer to the first is routinely yes. | ||
| 101 | if blocked > 0 && !dry_run { | ||
| 102 | 1 | ||
| 103 | } else { | ||
| 104 | 0 | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | fn migrate_one(entry: &repos::RepoEntry, dry_run: bool) -> Outcome { | ||
| 109 | let repo = match repos::open(entry) { | ||
| 110 | Ok(repo) => repo, | ||
| 111 | Err(e) => return Outcome::Blocked(format!("cannot open the repository: {}", e)), | ||
| 112 | }; | ||
| 113 | |||
| 114 | // Before anything else, and in the dry run too: whether the repository can | ||
| 115 | // be written at all. This is the diagnostic the old path never produced — | ||
| 116 | // on a read-only mount it failed once per patch on stderr and told the | ||
| 117 | // caller nothing — and an operator planning an upgrade needs it *before* | ||
| 118 | // the migration, not from its wreckage. | ||
| 119 | if let Err(reason) = probe_writable(&repo) { | ||
| 120 | return Outcome::Blocked(format!("the repository is not writable: {}", reason)); | ||
| 121 | } | ||
| 122 | |||
| 123 | if dry_run { | ||
| 124 | let plan = state::plan_patch_layout_migration(&repo); | ||
| 125 | return if plan.is_current() { | ||
| 126 | Outcome::Current | ||
| 127 | } else { | ||
| 128 | Outcome::Changed(plan) | ||
| 129 | }; | ||
| 130 | } | ||
| 131 | |||
| 132 | // See the module note on locking: this stops a concurrent `git-collab | ||
| 133 | // sync`, and says so rather than racing it. | ||
| 134 | let _lock = match SyncLock::acquire(&repo) { | ||
| 135 | Ok(lock) => lock, | ||
| 136 | Err(e) => return Outcome::Blocked(format!("{}", e)), | ||
| 137 | }; | ||
| 138 | |||
| 139 | let report = state::migrate_patch_layout_reporting(&repo); | ||
| 140 | if report.is_current() { | ||
| 141 | Outcome::Current | ||
| 142 | } else { | ||
| 143 | Outcome::Changed(report) | ||
| 144 | } | ||
| 145 | } | ||
| 146 | |||
| 147 | /// Whether the repository can be written, answered by writing. | ||
| 148 | /// | ||
| 149 | /// Mode bits are not the question: a read-only bind mount leaves them saying | ||
| 150 | /// `0755` and still refuses every write, which is exactly the deployment this | ||
| 151 | /// check exists for. So the probe creates a file in the git directory — where | ||
| 152 | /// git's own ref lockfiles go — and deletes it on the spot. It leaves nothing | ||
| 153 | /// behind, which is what lets the dry run use it too. | ||
| 154 | fn probe_writable(repo: &git2::Repository) -> Result<(), String> { | ||
| 155 | tempfile::Builder::new() | ||
| 156 | .prefix(".git-collab-migrate-probe") | ||
| 157 | .tempfile_in(repo.path()) | ||
| 158 | .map(|_| ()) | ||
| 159 | .map_err(|e| e.to_string()) | ||
| 160 | } | ||
| 161 | |||
| 162 | fn report(name: &str, outcome: &Outcome, dry_run: bool) { | ||
| 163 | match outcome { | ||
| 164 | Outcome::Current => println!("{}: already current", name), | ||
| 165 | Outcome::Blocked(reason) => println!("{}: cannot migrate — {}", name, reason), | ||
| 166 | Outcome::Changed(report) => { | ||
| 167 | let mut parts = Vec::new(); | ||
| 168 | if !report.migrated.is_empty() { | ||
| 169 | parts.push(format!( | ||
| 170 | "{} {}", | ||
| 171 | if dry_run { "would migrate" } else { "migrated" }, | ||
| 172 | plural(report.migrated.len(), "patch", "patches") | ||
| 173 | )); | ||
| 174 | } | ||
| 175 | if !report.repinned.is_empty() { | ||
| 176 | parts.push(format!( | ||
| 177 | "{} numbered revision refs on {}", | ||
| 178 | if dry_run { "would re-pin" } else { "re-pinned" }, | ||
| 179 | plural(report.repinned.len(), "patch", "patches") | ||
| 180 | )); | ||
| 181 | } | ||
| 182 | if !report.resumed.is_empty() { | ||
| 183 | parts.push(format!( | ||
| 184 | "{} {} left by an interrupted migration", | ||
| 185 | if dry_run { "would finish" } else { "finished" }, | ||
| 186 | plural(report.resumed.len(), "patch", "patches") | ||
| 187 | )); | ||
| 188 | } | ||
| 189 | if !report.failed.is_empty() { | ||
| 190 | parts.push(format!( | ||
| 191 | "{} could not be migrated", | ||
| 192 | plural(report.failed.len(), "patch", "patches") | ||
| 193 | )); | ||
| 194 | } | ||
| 195 | println!("{}: {}", name, parts.join(", ")); | ||
| 196 | // Per-patch reasons, indented under the repository they belong to. | ||
| 197 | // The old path put these on stderr with no repository name at all, | ||
| 198 | // which on a multi-repo sweep named nothing an operator could act | ||
| 199 | // on. | ||
| 200 | for (_, message) in &report.failed { | ||
| 201 | println!(" {}", message); | ||
| 202 | } | ||
| 203 | } | ||
| 204 | } | ||
| 205 | } | ||
| 206 | |||
| 207 | fn plural(n: usize, singular: &str, plural: &str) -> String { | ||
| 208 | format!("{} {}", n, if n == 1 { singular } else { plural }) | ||
| 209 | } | ||
src/state.rs
| Old | New | ||
|---|---|---|---|
| @@ -1637,21 +1637,37 @@ fn refs_under( | |||
| 1637 | } | 1637 | } |
| 1638 | 1638 | ||
| 1639 | /// Enumerate the event DAG ref of every patch under a prefix, returning | 1639 | /// Enumerate the event DAG ref of every patch under a prefix, returning |
| 1640 | /// (ref_name, id) pairs. Only `<id>/events` counts as a patch; the revision | 1640 | /// (ref_name, id) pairs. |
| 1641 | /// refs beside it are commits, not DAGs. | 1641 | /// |
| 1642 | /// Both layouts, and reading only. In the current one the DAG is at | ||
| 1643 | /// `<id>/events` and the refs beside it — `rev/<oid>`, or `r/<n>` from the | ||
| 1644 | /// draft that preceded it — are commits, not DAGs. In the pre-migration one | ||
| 1645 | /// the bare `<id>` ref *is* the DAG, and it is read where it lies. | ||
| 1646 | /// | ||
| 1647 | /// Tolerating the old shape here rather than migrating on the way past is what | ||
| 1648 | /// lets the server serve an unmigrated repository without writing to it. The | ||
| 1649 | /// two cannot coexist for one id — git will not have `<id>` be both a ref and a | ||
| 1650 | /// directory — so there is nothing to disambiguate, and everything downstream | ||
| 1651 | /// takes the ref name it is handed. | ||
| 1642 | fn patch_refs_under( | 1652 | fn patch_refs_under( |
| 1643 | repo: &Repository, | 1653 | repo: &Repository, |
| 1644 | prefix: &str, | 1654 | prefix: &str, |
| 1645 | ) -> Result<Vec<(String, String)>, crate::error::Error> { | 1655 | ) -> Result<Vec<(String, String)>, crate::error::Error> { |
| 1646 | let refs = repo.references_glob(&format!("{}*/events", prefix))?; | 1656 | let refs = repo.references_glob(&format!("{}*", prefix))?; |
| 1647 | let mut result = Vec::new(); | 1657 | let mut result = Vec::new(); |
| 1648 | for r in refs { | 1658 | for r in refs { |
| 1649 | let r = r?; | 1659 | let r = r?; |
| 1650 | let ref_name = r.name().unwrap_or_default().to_string(); | 1660 | let ref_name = r.name().unwrap_or_default().to_string(); |
| 1651 | if let Some((id, "events")) = split_patch_ref(&ref_name, prefix) { | 1661 | let id = match split_patch_ref(&ref_name, prefix) { |
| 1652 | let id = id.to_string(); | 1662 | Some((id, "events")) => id.to_string(), |
| 1653 | result.push((ref_name, id)); | 1663 | // A revision ref, not a patch. |
| 1664 | Some(_) => continue, | ||
| 1665 | None => ref_name.strip_prefix(prefix).unwrap_or_default().to_string(), | ||
| 1666 | }; | ||
| 1667 | if id.is_empty() { | ||
| 1668 | continue; | ||
| 1654 | } | 1669 | } |
| 1670 | result.push((ref_name, id)); | ||
| 1655 | } | 1671 | } |
| 1656 | Ok(result) | 1672 | Ok(result) |
| 1657 | } | 1673 | } |
| @@ -1766,7 +1782,6 @@ pub fn list_issues(repo: &Repository) -> Result<Vec<IssueState>, crate::error::E | |||
| 1766 | 1782 | ||
| 1767 | /// List active patch refs, excluding any that also have an archived ref. | 1783 | /// List active patch refs, excluding any that also have an archived ref. |
| 1768 | pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> { | 1784 | pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> { |
| 1769 | migrate_patch_layout(repo); | ||
| 1770 | let archived_ids: std::collections::HashSet<String> = collab_archive_refs(repo, "patches")? | 1785 | let archived_ids: std::collections::HashSet<String> = collab_archive_refs(repo, "patches")? |
| 1771 | .into_iter() | 1786 | .into_iter() |
| 1772 | .map(|(_, id)| id) | 1787 | .map(|(_, id)| id) |
| @@ -1816,7 +1831,6 @@ pub fn list_issues_with_archived( | |||
| 1816 | pub fn list_patches_with_archived( | 1831 | pub fn list_patches_with_archived( |
| 1817 | repo: &Repository, | 1832 | repo: &Repository, |
| 1818 | ) -> Result<Vec<PatchState>, crate::error::Error> { | 1833 | ) -> Result<Vec<PatchState>, crate::error::Error> { |
| 1819 | migrate_patch_layout(repo); | ||
| 1820 | let mut seen = std::collections::HashSet::new(); | 1834 | let mut seen = std::collections::HashSet::new(); |
| 1821 | let mut items = Vec::new(); | 1835 | let mut items = Vec::new(); |
| 1822 | 1836 | ||
| @@ -2003,7 +2017,7 @@ const MIGRATION_PARK_PREFIX: &str = "refs/collab/local/migrating/patches/"; | |||
| 2003 | /// was dropped — leaves the park behind as litter, and possibly a patch whose | 2017 | /// was dropped — leaves the park behind as litter, and possibly a patch whose |
| 2004 | /// revisions were never pinned. Both come out of one loop, and the park is only | 2018 | /// revisions were never pinned. Both come out of one loop, and the park is only |
| 2005 | /// dropped once the patch is whole. | 2019 | /// dropped once the patch is whole. |
| 2006 | fn resume_interrupted_migrations(repo: &Repository) { | 2020 | fn resume_interrupted_migrations(repo: &Repository, report: &mut MigrationReport) { |
| 2007 | let Ok(refs) = repo.references_glob(&format!("{}*", MIGRATION_PARK_PREFIX)) else { | 2021 | let Ok(refs) = repo.references_glob(&format!("{}*", MIGRATION_PARK_PREFIX)) else { |
| 2008 | return; | 2022 | return; |
| 2009 | }; | 2023 | }; |
| @@ -2032,30 +2046,123 @@ fn resume_interrupted_migrations(repo: &Repository) { | |||
| 2032 | None => finish_migration(repo, PATCH_PREFIX, &id, &park_ref), | 2046 | None => finish_migration(repo, PATCH_PREFIX, &id, &park_ref), |
| 2033 | }; | 2047 | }; |
| 2034 | if let Err(e) = outcome { | 2048 | if let Err(e) = outcome { |
| 2035 | eprintln!( | 2049 | report.failed.push(( |
| 2036 | "warning: could not resume interrupted migration of patch {:.8}: {}", | 2050 | id.clone(), |
| 2037 | id, e | 2051 | format!( |
| 2038 | ); | 2052 | "could not resume interrupted migration of patch {:.8}: {}", |
| 2053 | id, e | ||
| 2054 | ), | ||
| 2055 | )); | ||
| 2039 | continue; | 2056 | continue; |
| 2040 | } | 2057 | } |
| 2041 | // Only now is the patch whole; the park has nothing left to protect. | 2058 | // Only now is the patch whole; the park has nothing left to protect. |
| 2042 | if let Ok(mut r) = repo.find_reference(&park_ref) { | 2059 | if let Ok(mut r) = repo.find_reference(&park_ref) { |
| 2043 | let _ = r.delete(); | 2060 | let _ = r.delete(); |
| 2044 | } | 2061 | } |
| 2062 | report.resumed.push(id); | ||
| 2045 | } | 2063 | } |
| 2046 | } | 2064 | } |
| 2047 | 2065 | ||
| 2066 | /// What a patch-layout migration found in a repository, and what it managed to | ||
| 2067 | /// do about it. | ||
| 2068 | /// | ||
| 2069 | /// The old path printed warnings and returned nothing, which is fine for a CLI | ||
| 2070 | /// speaking to the person who ran it and useless to an operator sweeping a | ||
| 2071 | /// server: a read-only mount produced a line per patch on stderr and no way for | ||
| 2072 | /// the caller to know the repository had been left behind. This carries the | ||
| 2073 | /// same facts back as data. | ||
| 2074 | #[derive(Debug, Default, Clone, PartialEq, Eq)] | ||
| 2075 | pub struct MigrationReport { | ||
| 2076 | /// Patch ids brought from the bare `<id>` layout to `<id>/events`. | ||
| 2077 | pub migrated: Vec<String>, | ||
| 2078 | /// Patch ids whose numbered `r/<n>` refs were re-pinned by OID. | ||
| 2079 | pub repinned: Vec<String>, | ||
| 2080 | /// Patch ids whose interrupted migration was resumed or swept. | ||
| 2081 | pub resumed: Vec<String>, | ||
| 2082 | /// `(id, message)` for everything that could not be done, and why. | ||
| 2083 | pub failed: Vec<(String, String)>, | ||
| 2084 | } | ||
| 2085 | |||
| 2086 | impl MigrationReport { | ||
| 2087 | /// Nothing found and nothing to do: the repository is already current. | ||
| 2088 | pub fn is_current(&self) -> bool { | ||
| 2089 | self.migrated.is_empty() | ||
| 2090 | && self.repinned.is_empty() | ||
| 2091 | && self.resumed.is_empty() | ||
| 2092 | && self.failed.is_empty() | ||
| 2093 | } | ||
| 2094 | } | ||
| 2095 | |||
| 2096 | /// What [`migrate_patch_layout`] would do to this repository, without doing any | ||
| 2097 | /// of it. | ||
| 2098 | /// | ||
| 2099 | /// A scan of ref *names* only — the same three scans the migration itself runs, | ||
| 2100 | /// with the writes left out — so it is safe against a repository nobody wants | ||
| 2101 | /// touched yet. It cannot predict a failure, because a failure is something | ||
| 2102 | /// only the attempt discovers; `failed` is always empty here. | ||
| 2103 | pub fn plan_patch_layout_migration(repo: &Repository) -> MigrationReport { | ||
| 2104 | let mut report = MigrationReport::default(); | ||
| 2105 | |||
| 2106 | if let Ok(refs) = repo.references_glob(&format!("{}*", MIGRATION_PARK_PREFIX)) { | ||
| 2107 | for r in refs.flatten() { | ||
| 2108 | if let Some(id) = r.name().and_then(|n| n.strip_prefix(MIGRATION_PARK_PREFIX)) { | ||
| 2109 | report.resumed.push(id.to_string()); | ||
| 2110 | } | ||
| 2111 | } | ||
| 2112 | } | ||
| 2113 | |||
| 2114 | for prefix in [PATCH_PREFIX, ARCHIVE_PATCH_PREFIX] { | ||
| 2115 | let Ok(refs) = repo.references_glob(&format!("{}*", prefix)) else { | ||
| 2116 | continue; | ||
| 2117 | }; | ||
| 2118 | for r in refs.flatten() { | ||
| 2119 | let Some(name) = r.name() else { continue }; | ||
| 2120 | match split_patch_ref(name, prefix) { | ||
| 2121 | // The new layout always has a suffix; the old one never does. | ||
| 2122 | None => report | ||
| 2123 | .migrated | ||
| 2124 | .push(name.strip_prefix(prefix).unwrap_or_default().to_string()), | ||
| 2125 | Some((id, suffix)) if suffix.starts_with("r/") => { | ||
| 2126 | report.repinned.push(id.to_string()) | ||
| 2127 | } | ||
| 2128 | Some(_) => {} | ||
| 2129 | } | ||
| 2130 | } | ||
| 2131 | } | ||
| 2132 | |||
| 2133 | report.migrated.sort(); | ||
| 2134 | report.migrated.dedup(); | ||
| 2135 | report.repinned.sort(); | ||
| 2136 | report.repinned.dedup(); | ||
| 2137 | report.resumed.sort(); | ||
| 2138 | report.resumed.dedup(); | ||
| 2139 | report | ||
| 2140 | } | ||
| 2141 | |||
| 2048 | /// Bring patches written in the pre-revision-refs layout — a single ref at | 2142 | /// Bring patches written in the pre-revision-refs layout — a single ref at |
| 2049 | /// `refs/collab/patches/<id>` — up to the current one. Called from every entry | 2143 | /// `refs/collab/patches/<id>` — up to the current one, printing a warning per |
| 2050 | /// point that enumerates or resolves a patch, so an old repository migrates on | 2144 | /// patch it could not convert. |
| 2051 | /// first use rather than breaking. | 2145 | /// |
| 2146 | /// This writes, so only a caller that owns the repository may run it: the CLI, | ||
| 2147 | /// at its entry point, and `sync`. **Not the server.** Reading tolerates both | ||
| 2148 | /// layouts (see [`patch_refs_under`]) precisely so that rendering a page never | ||
| 2149 | /// has to reach for this; an operator asks for it by hand with | ||
| 2150 | /// `git-collab-server migrate`. | ||
| 2052 | /// | 2151 | /// |
| 2053 | /// Failures are reported and skipped rather than propagated: one unmigratable | 2152 | /// Failures are reported and skipped rather than propagated: one unmigratable |
| 2054 | /// patch must not make the whole list unreadable. | 2153 | /// patch must not make the whole list unreadable. |
| 2055 | pub fn migrate_patch_layout(repo: &Repository) { | 2154 | pub fn migrate_patch_layout(repo: &Repository) { |
| 2155 | for (_, message) in migrate_patch_layout_reporting(repo).failed { | ||
| 2156 | eprintln!("warning: {}", message); | ||
| 2157 | } | ||
| 2158 | } | ||
| 2159 | |||
| 2160 | /// [`migrate_patch_layout`], with what it did handed back instead of printed. | ||
| 2161 | pub fn migrate_patch_layout_reporting(repo: &Repository) -> MigrationReport { | ||
| 2162 | let mut report = MigrationReport::default(); | ||
| 2056 | // Before anything else: a patch stranded by an earlier interrupted run is | 2163 | // Before anything else: a patch stranded by an earlier interrupted run is |
| 2057 | // invisible to the scan below, because its old ref is already gone. | 2164 | // invisible to the scan below, because its old ref is already gone. |
| 2058 | resume_interrupted_migrations(repo); | 2165 | resume_interrupted_migrations(repo, &mut report); |
| 2059 | 2166 | ||
| 2060 | for prefix in [PATCH_PREFIX, ARCHIVE_PATCH_PREFIX] { | 2167 | for prefix in [PATCH_PREFIX, ARCHIVE_PATCH_PREFIX] { |
| 2061 | let Ok(refs) = repo.references_glob(&format!("{}*", prefix)) else { | 2168 | let Ok(refs) = repo.references_glob(&format!("{}*", prefix)) else { |
| @@ -2075,8 +2182,12 @@ pub fn migrate_patch_layout(repo: &Repository) { | |||
| 2075 | .collect(); | 2182 | .collect(); |
| 2076 | for old_ref in old_layout { | 2183 | for old_ref in old_layout { |
| 2077 | let id = old_ref.strip_prefix(prefix).unwrap_or_default().to_string(); | 2184 | let id = old_ref.strip_prefix(prefix).unwrap_or_default().to_string(); |
| 2078 | if let Err(e) = migrate_one_patch(repo, prefix, &id, &old_ref) { | 2185 | match migrate_one_patch(repo, prefix, &id, &old_ref) { |
| 2079 | eprintln!("warning: could not migrate patch {:.8}: {}", id, e); | 2186 | Ok(()) => report.migrated.push(id), |
| 2187 | Err(e) => report.failed.push(( | ||
| 2188 | id.clone(), | ||
| 2189 | format!("could not migrate patch {:.8}: {}", id, e), | ||
| 2190 | )), | ||
| 2080 | } | 2191 | } |
| 2081 | } | 2192 | } |
| 2082 | 2193 | ||
| @@ -2096,14 +2207,19 @@ pub fn migrate_patch_layout(repo: &Repository) { | |||
| 2096 | ids.sort(); | 2207 | ids.sort(); |
| 2097 | ids.dedup(); | 2208 | ids.dedup(); |
| 2098 | for id in ids { | 2209 | for id in ids { |
| 2099 | if let Err(e) = retire_numbered_revision_refs(repo, prefix, &id) { | 2210 | match retire_numbered_revision_refs(repo, prefix, &id) { |
| 2100 | eprintln!( | 2211 | Ok(()) => report.repinned.push(id), |
| 2101 | "warning: could not convert numbered revision refs for patch {:.8}: {}", | 2212 | Err(e) => report.failed.push(( |
| 2102 | id, e | 2213 | id.clone(), |
| 2103 | ); | 2214 | format!( |
| 2215 | "could not convert numbered revision refs for patch {:.8}: {}", | ||
| 2216 | id, e | ||
| 2217 | ), | ||
| 2218 | )), | ||
| 2104 | } | 2219 | } |
| 2105 | } | 2220 | } |
| 2106 | } | 2221 | } |
| 2222 | report | ||
| 2107 | } | 2223 | } |
| 2108 | 2224 | ||
| 2109 | /// Replace a patch's `r/<n>` refs with OID-named ones. | 2225 | /// Replace a patch's `r/<n>` refs with OID-named ones. |
| @@ -2244,7 +2360,6 @@ pub fn resolve_patch_ref( | |||
| 2244 | repo: &Repository, | 2360 | repo: &Repository, |
| 2245 | prefix: &str, | 2361 | prefix: &str, |
| 2246 | ) -> Result<(String, String), crate::error::Error> { | 2362 | ) -> Result<(String, String), crate::error::Error> { |
| 2247 | migrate_patch_layout(repo); | ||
| 2248 | resolve_ref(repo, "patches", "patch", prefix) | 2363 | resolve_ref(repo, "patches", "patch", prefix) |
| 2249 | } | 2364 | } |
| 2250 | 2365 | ||
src/timeline.rs
| Old | New | ||
|---|---|---|---|
| @@ -41,12 +41,11 @@ | |||
| 41 | //! # Reads do not write | 41 | //! # Reads do not write |
| 42 | //! | 42 | //! |
| 43 | //! Building a timeline appends no event and moves no ref. It resolves the | 43 | //! Building a timeline appends no event and moves no ref. It resolves the |
| 44 | //! patch's ref, walks it, and folds it. (`resolve_patch_ref` runs the one-time | 44 | //! patch's ref, walks it, and folds it — including a patch still in the |
| 45 | //! `migrate_patch_layout`, exactly as `patch show` and `patch log` already do; | 45 | //! pre-revision-refs layout, which resolves and walks where it lies rather |
| 46 | //! that is a pre-existing ref-layout migration, not a collab event, and this | 46 | //! than being migrated on the way past. (Note `patch show` additionally moves |
| 47 | //! view adds nothing to it. Note `patch show` additionally moves a | 47 | //! a `refs/collab/local/seen/` read-marker — the timeline deliberately does |
| 48 | //! `refs/collab/local/seen/` read-marker — the timeline deliberately does *not* | 48 | //! *not* adopt that pattern and marks nothing read.) |
| 49 | //! adopt that pattern and marks nothing read.) | ||
| 50 | 49 | ||
| 51 | use git2::Repository; | 50 | use git2::Repository; |
| 52 | use serde::Serialize; | 51 | use serde::Serialize; |
tests/server_migrate_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,458 @@ | |||
| 1 | //! Serving a repository is a read, and migrating one is a decision. | ||
| 2 | //! | ||
| 3 | //! Two halves of the same defect (5174338f). `state::list_patches` used to call | ||
| 4 | //! `migrate_patch_layout`, and the server calls `list_patches` on every page — | ||
| 5 | //! so an unauthenticated GET rewrote the repository being served, with no lock, | ||
| 6 | //! able to race a concurrent request or an in-flight `receive-pack`. | ||
| 7 | //! | ||
| 8 | //! The fix has to hold both ends up at once: the read path must tolerate the | ||
| 9 | //! pre-migration layout *without writing*, or "stop migrating on read" just | ||
| 10 | //! means "stop showing old patches". Hence a pair of tests over one live | ||
| 11 | //! server: the refs are byte-identical afterwards, and the patch is on the page. | ||
| 12 | //! | ||
| 13 | //! The other half is `git-collab-server migrate`, the command an operator runs | ||
| 14 | //! deliberately. It matters for sequencing: once legacy support is stripped an | ||
| 15 | //! unmigrated repository will simply fail to read, and the operator needs to | ||
| 16 | //! migrate *before* upgrading rather than discovering it afterwards. | ||
| 17 | |||
| 18 | mod common; | ||
| 19 | |||
| 20 | use common::{ServerHarness, TestRepo}; | ||
| 21 | use std::path::{Path, PathBuf}; | ||
| 22 | use std::process::{Command, Output}; | ||
| 23 | use tempfile::TempDir; | ||
| 24 | |||
| 25 | // --------------------------------------------------------------------------- | ||
| 26 | // Fixtures | ||
| 27 | // --------------------------------------------------------------------------- | ||
| 28 | |||
| 29 | fn git_in(dir: &Path, args: &[&str]) -> String { | ||
| 30 | let output = Command::new("git") | ||
| 31 | .args(args) | ||
| 32 | .current_dir(dir) | ||
| 33 | .output() | ||
| 34 | .expect("failed to run git"); | ||
| 35 | assert!( | ||
| 36 | output.status.success(), | ||
| 37 | "git {:?} in {:?} failed: {}", | ||
| 38 | args, | ||
| 39 | dir, | ||
| 40 | String::from_utf8_lossy(&output.stderr) | ||
| 41 | ); | ||
| 42 | String::from_utf8(output.stdout).unwrap() | ||
| 43 | } | ||
| 44 | |||
| 45 | /// Every collab ref in `bare`, as sorted `<name> <oid>` lines. | ||
| 46 | /// | ||
| 47 | /// All of `refs/collab/**`, not just the patch refs: a migration parks tips | ||
| 48 | /// under `refs/collab/local/migrating/` and pins revisions under `<id>/rev/`, | ||
| 49 | /// and a snapshot narrower than the namespace would miss both. | ||
| 50 | fn collab_refs(bare: &Path) -> String { | ||
| 51 | let mut lines: Vec<String> = git_in( | ||
| 52 | bare, | ||
| 53 | &[ | ||
| 54 | "for-each-ref", | ||
| 55 | "--format=%(refname) %(objectname)", | ||
| 56 | "refs/collab/", | ||
| 57 | ], | ||
| 58 | ) | ||
| 59 | .lines() | ||
| 60 | .map(str::to_string) | ||
| 61 | .collect(); | ||
| 62 | lines.sort(); | ||
| 63 | lines.join("\n") | ||
| 64 | } | ||
| 65 | |||
| 66 | /// The full id of the one patch in `bare`, in either layout. | ||
| 67 | fn only_patch_id(bare: &Path) -> String { | ||
| 68 | let refs = git_in(bare, &["for-each-ref", "--format=%(refname)", "refs/collab/patches/"]); | ||
| 69 | let ids: Vec<String> = refs | ||
| 70 | .lines() | ||
| 71 | .filter_map(|name| { | ||
| 72 | let rest = name.strip_prefix("refs/collab/patches/")?; | ||
| 73 | Some(rest.split('/').next()?.to_string()) | ||
| 74 | }) | ||
| 75 | .collect(); | ||
| 76 | let mut ids = ids; | ||
| 77 | ids.sort(); | ||
| 78 | ids.dedup(); | ||
| 79 | assert_eq!(ids.len(), 1, "expected exactly one patch, got {:?}", ids); | ||
| 80 | ids.into_iter().next().unwrap() | ||
| 81 | } | ||
| 82 | |||
| 83 | /// Put the patch back into the layout git-collab wrote before revision refs | ||
| 84 | /// existed: one bare ref at `refs/collab/patches/<id>` holding the event DAG, | ||
| 85 | /// and nothing beside it. | ||
| 86 | fn demote_to_old_layout(bare: &Path, id: &str) { | ||
| 87 | let events = format!("refs/collab/patches/{}/events", id); | ||
| 88 | let tip = git_in(bare, &["rev-parse", &events]).trim().to_string(); | ||
| 89 | let names = git_in( | ||
| 90 | bare, | ||
| 91 | &[ | ||
| 92 | "for-each-ref", | ||
| 93 | "--format=%(refname)", | ||
| 94 | &format!("refs/collab/patches/{}/", id), | ||
| 95 | ], | ||
| 96 | ); | ||
| 97 | for name in names.lines() { | ||
| 98 | git_in(bare, &["update-ref", "-d", name]); | ||
| 99 | } | ||
| 100 | git_in(bare, &["update-ref", &format!("refs/collab/patches/{}", id), &tip]); | ||
| 101 | } | ||
| 102 | |||
| 103 | /// A `repos_dir` with a `server.toml` beside it, and no server running: enough | ||
| 104 | /// for `git-collab-server migrate`, which needs the config only to find the | ||
| 105 | /// repositories. | ||
| 106 | struct Fixture { | ||
| 107 | root: TempDir, | ||
| 108 | repos_dir: PathBuf, | ||
| 109 | config: PathBuf, | ||
| 110 | } | ||
| 111 | |||
| 112 | impl Fixture { | ||
| 113 | fn new() -> Self { | ||
| 114 | let root = TempDir::new().unwrap(); | ||
| 115 | let repos_dir = root.path().join("repos"); | ||
| 116 | std::fs::create_dir_all(&repos_dir).unwrap(); | ||
| 117 | let authorized_keys = root.path().join("authorized_keys"); | ||
| 118 | std::fs::write(&authorized_keys, "").unwrap(); | ||
| 119 | let config = root.path().join("server.toml"); | ||
| 120 | std::fs::write( | ||
| 121 | &config, | ||
| 122 | format!( | ||
| 123 | "repos_dir = {:?}\nauthorized_keys = {:?}\n", | ||
| 124 | repos_dir, authorized_keys | ||
| 125 | ), | ||
| 126 | ) | ||
| 127 | .unwrap(); | ||
| 128 | Fixture { | ||
| 129 | root, | ||
| 130 | repos_dir, | ||
| 131 | config, | ||
| 132 | } | ||
| 133 | } | ||
| 134 | |||
| 135 | /// A hosted bare repository at `<repos_dir>/<relative>.git` holding one | ||
| 136 | /// patch in the pre-migration layout. Returns (bare path, patch id). | ||
| 137 | fn seed_old_layout(&self, relative: &str, title: &str) -> (PathBuf, String) { | ||
| 138 | let (bare, id) = self.seed_current_layout(relative, title); | ||
| 139 | demote_to_old_layout(&bare, &id); | ||
| 140 | (bare, id) | ||
| 141 | } | ||
| 142 | |||
| 143 | /// The same, left in the current layout: a repository `migrate` has | ||
| 144 | /// nothing to do to. | ||
| 145 | fn seed_current_layout(&self, relative: &str, title: &str) -> (PathBuf, String) { | ||
| 146 | let bare = self.repos_dir.join(format!("{relative}.git")); | ||
| 147 | std::fs::create_dir_all(bare.parent().unwrap()).unwrap(); | ||
| 148 | git_in( | ||
| 149 | self.root.path(), | ||
| 150 | &["init", "-q", "--bare", "-b", "main", bare.to_str().unwrap()], | ||
| 151 | ); | ||
| 152 | |||
| 153 | let work = TestRepo::new("Alice", "alice@example.com"); | ||
| 154 | work.patch_create(title); | ||
| 155 | work.git(&["push", "-q", bare.to_str().unwrap(), "main:main"]); | ||
| 156 | work.git(&[ | ||
| 157 | "push", | ||
| 158 | "-q", | ||
| 159 | bare.to_str().unwrap(), | ||
| 160 | "refs/collab/*:refs/collab/*", | ||
| 161 | ]); | ||
| 162 | |||
| 163 | let id = only_patch_id(&bare); | ||
| 164 | (bare, id) | ||
| 165 | } | ||
| 166 | |||
| 167 | fn migrate(&self, extra: &[&str]) -> Output { | ||
| 168 | let mut args = vec!["migrate", "--config", self.config.to_str().unwrap()]; | ||
| 169 | args.extend_from_slice(extra); | ||
| 170 | Command::new(env!("CARGO_BIN_EXE_git-collab-server")) | ||
| 171 | .args(&args) | ||
| 172 | .output() | ||
| 173 | .expect("failed to run git-collab-server migrate") | ||
| 174 | } | ||
| 175 | } | ||
| 176 | |||
| 177 | fn combined(output: &Output) -> String { | ||
| 178 | format!( | ||
| 179 | "{}{}", | ||
| 180 | String::from_utf8_lossy(&output.stdout), | ||
| 181 | String::from_utf8_lossy(&output.stderr) | ||
| 182 | ) | ||
| 183 | } | ||
| 184 | |||
| 185 | // --------------------------------------------------------------------------- | ||
| 186 | // The server must not migrate | ||
| 187 | // --------------------------------------------------------------------------- | ||
| 188 | |||
| 189 | /// The regression. One old-layout patch, every page that renders patches, and | ||
| 190 | /// the ref store byte-identical afterwards. | ||
| 191 | #[test] | ||
| 192 | fn serving_an_old_layout_repository_writes_nothing() { | ||
| 193 | let harness = ServerHarness::new("readonly-render"); | ||
| 194 | harness | ||
| 195 | .work_repo() | ||
| 196 | .patch_create("A patch from before revision refs"); | ||
| 197 | harness.push_head(); | ||
| 198 | harness.push_collab_refs(); | ||
| 199 | |||
| 200 | let bare = harness.repos_dir().join("readonly-render.git"); | ||
| 201 | let id = only_patch_id(&bare); | ||
| 202 | demote_to_old_layout(&bare, &id); | ||
| 203 | |||
| 204 | let before = collab_refs(&bare); | ||
| 205 | let name = harness.repo_name(); | ||
| 206 | for path in [ | ||
| 207 | "/".to_string(), | ||
| 208 | format!("/{name}"), | ||
| 209 | format!("/{name}/patches"), | ||
| 210 | format!("/{name}/patches?filter=all"), | ||
| 211 | format!("/{name}/patches?filter=closed"), | ||
| 212 | format!("/{name}/patches?filter=merged"), | ||
| 213 | format!("/{name}/patches/{id}"), | ||
| 214 | format!("/{name}/issues"), | ||
| 215 | format!("/{name}/issues?filter=all"), | ||
| 216 | format!("/{name}/commits"), | ||
| 217 | ] { | ||
| 218 | harness.get_ok(&path); | ||
| 219 | } | ||
| 220 | let after = collab_refs(&bare); | ||
| 221 | |||
| 222 | assert_eq!( | ||
| 223 | before, after, | ||
| 224 | "serving pages rewrote the hosted repository's collab refs" | ||
| 225 | ); | ||
| 226 | } | ||
| 227 | |||
| 228 | /// Not writing is worthless if it means not reading: the unmigrated patch has | ||
| 229 | /// to be on the page, in the list and on its own detail page. | ||
| 230 | #[test] | ||
| 231 | fn an_old_layout_patch_renders_without_being_migrated() { | ||
| 232 | let title = "A patch from before revision refs"; | ||
| 233 | let harness = ServerHarness::new("readonly-visible"); | ||
| 234 | harness.work_repo().patch_create(title); | ||
| 235 | harness.push_head(); | ||
| 236 | harness.push_collab_refs(); | ||
| 237 | |||
| 238 | let bare = harness.repos_dir().join("readonly-visible.git"); | ||
| 239 | let id = only_patch_id(&bare); | ||
| 240 | demote_to_old_layout(&bare, &id); | ||
| 241 | |||
| 242 | let name = harness.repo_name(); | ||
| 243 | let list = harness.get_ok(&format!("/{name}/patches?filter=all")).body; | ||
| 244 | assert!( | ||
| 245 | list.contains(title), | ||
| 246 | "the unmigrated patch is missing from the patch list:\n{list}" | ||
| 247 | ); | ||
| 248 | |||
| 249 | let overview = harness.get_ok(&format!("/{name}")).body; | ||
| 250 | assert!( | ||
| 251 | overview.contains(title), | ||
| 252 | "the unmigrated patch is missing from the overview:\n{overview}" | ||
| 253 | ); | ||
| 254 | |||
| 255 | let detail = harness.get_ok(&format!("/{name}/patches/{id}")).body; | ||
| 256 | assert!( | ||
| 257 | detail.contains(title), | ||
| 258 | "the unmigrated patch's detail page does not show it:\n{detail}" | ||
| 259 | ); | ||
| 260 | |||
| 261 | // And still nothing was written to reach it. | ||
| 262 | assert!( | ||
| 263 | git_in(&bare, &["for-each-ref", "--format=%(refname)", "refs/collab/patches/"]) | ||
| 264 | .lines() | ||
| 265 | .all(|name| name == format!("refs/collab/patches/{id}")), | ||
| 266 | "rendering the patch migrated it after all" | ||
| 267 | ); | ||
| 268 | } | ||
| 269 | |||
| 270 | // --------------------------------------------------------------------------- | ||
| 271 | // git-collab-server migrate | ||
| 272 | // --------------------------------------------------------------------------- | ||
| 273 | |||
| 274 | #[test] | ||
| 275 | fn migrate_brings_an_old_layout_repository_up_to_date() { | ||
| 276 | let fixture = Fixture::new(); | ||
| 277 | let (bare, id) = fixture.seed_old_layout("waystty", "An old patch"); | ||
| 278 | |||
| 279 | let out = fixture.migrate(&[]); | ||
| 280 | let text = combined(&out); | ||
| 281 | assert!( | ||
| 282 | out.status.success(), | ||
| 283 | "migrate should succeed:\n{text}" | ||
| 284 | ); | ||
| 285 | assert!( | ||
| 286 | text.contains("waystty") && text.contains("migrated 1 patch"), | ||
| 287 | "migrate did not report what it did to waystty:\n{text}" | ||
| 288 | ); | ||
| 289 | |||
| 290 | let refs = collab_refs(&bare); | ||
| 291 | assert!( | ||
| 292 | refs.contains(&format!("refs/collab/patches/{id}/events")), | ||
| 293 | "the events ref was not created:\n{refs}" | ||
| 294 | ); | ||
| 295 | assert!( | ||
| 296 | !refs.lines().any(|l| l.starts_with(&format!("refs/collab/patches/{id} "))), | ||
| 297 | "the old bare ref survived the migration:\n{refs}" | ||
| 298 | ); | ||
| 299 | } | ||
| 300 | |||
| 301 | /// Nesting and dot-prefixed skips are the server's discovery rules, and | ||
| 302 | /// `migrate` has to walk by exactly the same ones or it will quietly leave a | ||
| 303 | /// repository behind for the upgrade to break. | ||
| 304 | #[test] | ||
| 305 | fn migrate_walks_nested_repositories_and_skips_dot_prefixed_ones() { | ||
| 306 | let fixture = Fixture::new(); | ||
| 307 | let (nested, nested_id) = fixture.seed_old_layout("agents/claude-a", "A nested old patch"); | ||
| 308 | let (hidden, hidden_id) = fixture.seed_old_layout(".private/secret", "A hidden old patch"); | ||
| 309 | |||
| 310 | let out = fixture.migrate(&[]); | ||
| 311 | let text = combined(&out); | ||
| 312 | assert!(out.status.success(), "{text}"); | ||
| 313 | assert!( | ||
| 314 | text.contains("agents/claude-a"), | ||
| 315 | "the nested repository was not reported by its full name:\n{text}" | ||
| 316 | ); | ||
| 317 | assert!( | ||
| 318 | collab_refs(&nested).contains(&format!("refs/collab/patches/{nested_id}/events")), | ||
| 319 | "the nested repository was not migrated" | ||
| 320 | ); | ||
| 321 | assert!( | ||
| 322 | !collab_refs(&hidden).contains(&format!("refs/collab/patches/{hidden_id}/events")), | ||
| 323 | "a dot-prefixed path is not a hosted repository and must not be touched" | ||
| 324 | ); | ||
| 325 | } | ||
| 326 | |||
| 327 | #[test] | ||
| 328 | fn migrate_is_idempotent() { | ||
| 329 | let fixture = Fixture::new(); | ||
| 330 | let (bare, _) = fixture.seed_old_layout("waystty", "An old patch"); | ||
| 331 | |||
| 332 | assert!(fixture.migrate(&[]).status.success()); | ||
| 333 | let after_first = collab_refs(&bare); | ||
| 334 | |||
| 335 | let out = fixture.migrate(&[]); | ||
| 336 | let text = combined(&out); | ||
| 337 | assert!(out.status.success(), "a second migrate should succeed:\n{text}"); | ||
| 338 | assert!( | ||
| 339 | text.contains("already current"), | ||
| 340 | "a repository with nothing to do should say so:\n{text}" | ||
| 341 | ); | ||
| 342 | assert_eq!( | ||
| 343 | after_first, | ||
| 344 | collab_refs(&bare), | ||
| 345 | "a second migrate changed the refs" | ||
| 346 | ); | ||
| 347 | } | ||
| 348 | |||
| 349 | #[test] | ||
| 350 | fn migrate_reports_a_repository_it_cannot_write() { | ||
| 351 | let fixture = Fixture::new(); | ||
| 352 | let (writable, writable_id) = fixture.seed_old_layout("writable", "A migratable patch"); | ||
| 353 | let (blocked, blocked_id) = fixture.seed_old_layout("blocked", "An unmigratable patch"); | ||
| 354 | |||
| 355 | make_read_only(&blocked); | ||
| 356 | if is_still_writable(&blocked) { | ||
| 357 | // Running as root, where mode bits mean nothing. Nothing to assert. | ||
| 358 | restore_write(&blocked); | ||
| 359 | eprintln!("skipping: this user can write a mode-0555 directory"); | ||
| 360 | return; | ||
| 361 | } | ||
| 362 | |||
| 363 | let out = fixture.migrate(&[]); | ||
| 364 | let text = combined(&out); | ||
| 365 | restore_write(&blocked); | ||
| 366 | |||
| 367 | assert!( | ||
| 368 | !out.status.success(), | ||
| 369 | "migrate must exit non-zero when a repository could not be migrated:\n{text}" | ||
| 370 | ); | ||
| 371 | assert!( | ||
| 372 | text.contains("blocked"), | ||
| 373 | "the unmigratable repository was not named:\n{text}" | ||
| 374 | ); | ||
| 375 | assert!( | ||
| 376 | text.contains("not writable"), | ||
| 377 | "migrate did not say why the repository could not be migrated:\n{text}" | ||
| 378 | ); | ||
| 379 | // A repository it cannot write must not stop the ones it can. | ||
| 380 | assert!( | ||
| 381 | collab_refs(&writable).contains(&format!("refs/collab/patches/{writable_id}/events")), | ||
| 382 | "one unwritable repository stopped the rest of the run" | ||
| 383 | ); | ||
| 384 | let untouched = collab_refs(&blocked); | ||
| 385 | assert!( | ||
| 386 | !untouched.contains(&format!("refs/collab/patches/{blocked_id}/")), | ||
| 387 | "the unwritable repository was changed anyway:\n{untouched}" | ||
| 388 | ); | ||
| 389 | } | ||
| 390 | |||
| 391 | #[test] | ||
| 392 | fn migrate_dry_run_changes_nothing() { | ||
| 393 | let fixture = Fixture::new(); | ||
| 394 | let (bare, _) = fixture.seed_old_layout("waystty", "An old patch"); | ||
| 395 | let before = collab_refs(&bare); | ||
| 396 | |||
| 397 | let out = fixture.migrate(&["--dry-run"]); | ||
| 398 | let text = combined(&out); | ||
| 399 | assert!(out.status.success(), "{text}"); | ||
| 400 | assert!( | ||
| 401 | text.contains("would migrate 1 patch"), | ||
| 402 | "dry run did not say what it would change:\n{text}" | ||
| 403 | ); | ||
| 404 | assert!( | ||
| 405 | text.contains("dry run"), | ||
| 406 | "dry run did not say it was a dry run:\n{text}" | ||
| 407 | ); | ||
| 408 | assert_eq!( | ||
| 409 | before, | ||
| 410 | collab_refs(&bare), | ||
| 411 | "a dry run changed the repository" | ||
| 412 | ); | ||
| 413 | } | ||
| 414 | |||
| 415 | /// A repository already in the current layout is reported, not skipped in | ||
| 416 | /// silence: an operator running this before an upgrade wants the whole roster | ||
| 417 | /// accounted for. | ||
| 418 | #[test] | ||
| 419 | fn migrate_reports_repositories_with_nothing_to_do() { | ||
| 420 | let fixture = Fixture::new(); | ||
| 421 | fixture.seed_current_layout("current", "A current patch"); | ||
| 422 | |||
| 423 | let out = fixture.migrate(&[]); | ||
| 424 | let text = combined(&out); | ||
| 425 | assert!(out.status.success(), "{text}"); | ||
| 426 | assert!( | ||
| 427 | text.contains("current: already current"), | ||
| 428 | "a repository with nothing to do was not reported:\n{text}" | ||
| 429 | ); | ||
| 430 | } | ||
| 431 | |||
| 432 | // --------------------------------------------------------------------------- | ||
| 433 | // Read-only mount simulation | ||
| 434 | // --------------------------------------------------------------------------- | ||
| 435 | |||
| 436 | fn make_read_only(dir: &Path) { | ||
| 437 | set_mode(dir, 0o555); | ||
| 438 | } | ||
| 439 | |||
| 440 | fn restore_write(dir: &Path) { | ||
| 441 | set_mode(dir, 0o755); | ||
| 442 | } | ||
| 443 | |||
| 444 | fn set_mode(dir: &Path, mode: u32) { | ||
| 445 | use std::os::unix::fs::PermissionsExt; | ||
| 446 | std::fs::set_permissions(dir, std::fs::Permissions::from_mode(mode)).unwrap(); | ||
| 447 | } | ||
| 448 | |||
| 449 | fn is_still_writable(dir: &Path) -> bool { | ||
| 450 | let probe = dir.join(".write-probe"); | ||
| 451 | match std::fs::write(&probe, b"x") { | ||
| 452 | Ok(()) => { | ||
| 453 | let _ = std::fs::remove_file(&probe); | ||
| 454 | true | ||
| 455 | } | ||
| 456 | Err(_) => false, | ||
| 457 | } | ||
| 458 | } | ||