diff --git a/.config/nextest.toml b/.config/nextest.toml index acb0654..f35fcd2 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,2 +1,6 @@ +# Required for NEXTEST_BIN_EXE_ with hyphens in binary names replaced by +# underscores. +nextest-version = "0.9.113" + [profile.ci] fail-fast = false diff --git a/Cargo.lock b/Cargo.lock index 30957b4..e1b42bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1000,6 +1000,7 @@ version = "0.1.0" dependencies = [ "anyhow", "assert_matches", + "atomicwrites", "camino", "camino-tempfile", "camino-tempfile-ext", diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..287900e --- /dev/null +++ b/clippy.toml @@ -0,0 +1,11 @@ +# Clippy configuration for dropshot-api-manager. + +# Ban non-atomic file writes. Use atomicwrites::AtomicFile instead to prevent +# corruption on interruption. +[[disallowed-methods]] +path = "std::fs::write" +reason = "use atomicwrites instead to prevent corruption on interruption" + +[[disallowed-methods]] +path = "fs_err::write" +reason = "use atomicwrites instead to prevent corruption on interruption" diff --git a/crates/dropshot-api-manager-types/src/validation.rs b/crates/dropshot-api-manager-types/src/validation.rs index 18dbb05..2ef7fe4 100644 --- a/crates/dropshot-api-manager-types/src/validation.rs +++ b/crates/dropshot-api-manager-types/src/validation.rs @@ -130,42 +130,75 @@ impl ApiSpecFileName { } /// Returns the path of this file relative to the root of the OpenAPI - /// documents + /// documents. pub fn path(&self) -> Utf8PathBuf { match &self.kind { ApiSpecFileNameKind::Lockstep => { Utf8PathBuf::from_iter([self.basename()]) } - ApiSpecFileNameKind::Versioned { .. } => Utf8PathBuf::from_iter([ - self.ident.deref().clone(), - self.basename(), - ]), + ApiSpecFileNameKind::Versioned { .. } + | ApiSpecFileNameKind::VersionedGitRef { .. } => { + Utf8PathBuf::from_iter([ + self.ident.deref().clone(), + self.basename(), + ]) + } } } - /// Returns the base name of this file path + /// Returns the base name of this file path. pub fn basename(&self) -> String { match &self.kind { ApiSpecFileNameKind::Lockstep => format!("{}.json", self.ident), ApiSpecFileNameKind::Versioned { version, hash } => { format!("{}-{}-{}.json", self.ident, version, hash) } + ApiSpecFileNameKind::VersionedGitRef { version, hash } => { + format!("{}-{}-{}.json.gitref", self.ident, version, hash) + } } } - /// For versioned APIs, returns the version part of the filename + /// For versioned APIs, returns the version part of the filename. pub fn version(&self) -> Option<&semver::Version> { match &self.kind { ApiSpecFileNameKind::Lockstep => None, - ApiSpecFileNameKind::Versioned { version, .. } => Some(version), + ApiSpecFileNameKind::Versioned { version, .. } + | ApiSpecFileNameKind::VersionedGitRef { version, .. } => { + Some(version) + } } } - /// For versioned APIs, returns the hash part of the filename + /// For versioned APIs, returns the hash part of the filename. pub fn hash(&self) -> Option<&str> { match &self.kind { ApiSpecFileNameKind::Lockstep => None, - ApiSpecFileNameKind::Versioned { hash, .. } => Some(hash), + ApiSpecFileNameKind::Versioned { hash, .. } + | ApiSpecFileNameKind::VersionedGitRef { hash, .. } => Some(hash), + } + } + + /// Returns true if this is a git ref file. + pub fn is_git_ref(&self) -> bool { + matches!(self.kind, ApiSpecFileNameKind::VersionedGitRef { .. }) + } + + /// Converts a `VersionedGitRef` to its `Versioned` equivalent. + /// + /// For non-git ref files, returns a clone of self. + pub fn to_json_filename(&self) -> ApiSpecFileName { + match &self.kind { + ApiSpecFileNameKind::VersionedGitRef { version, hash } => { + ApiSpecFileName::new( + self.ident.clone(), + ApiSpecFileNameKind::Versioned { + version: version.clone(), + hash: hash.clone(), + }, + ) + } + _ => self.clone(), } } } @@ -182,6 +215,17 @@ pub enum ApiSpecFileNameKind { /// The hash of the file contents. hash: String, }, + /// The file's path implies a versioned API stored as a git ref. + /// + /// Instead of storing the full JSON content, a `.gitref` file contains a + /// reference in the format `commit:path` that can be used to retrieve the + /// content via `git show`. + VersionedGitRef { + /// The version of the API this document describes. + version: semver::Version, + /// The hash of the file contents (from the original file). + hash: String, + }, } /// Newtype for API identifiers diff --git a/crates/dropshot-api-manager/README.md b/crates/dropshot-api-manager/README.md index ac3fb3d..47b68b2 100644 --- a/crates/dropshot-api-manager/README.md +++ b/crates/dropshot-api-manager/README.md @@ -75,6 +75,7 @@ In addition, if you have versioned APIs (see below): * The OpenAPI manager must be run within a Git repository. * You must also ensure that `git` is available on the command line, or that the `GIT` environment variable is set to the location of the Git binary. +* Shallow clones don't work; full Git history is required. See [_CI and shallow clones_](#ci-and-shallow-clones) below. #### API crates @@ -227,6 +228,30 @@ Again, we assume you're starting from a fresh branch from "main". As of this writing, every API has exactly one Rust client package and it's always generated from the latest version of the API. Per RFD 532, this is sufficient for APIs that are server-side-only versioned. For APIs that will be client-side versioned, you may need to create additional Rust packages that use Progenitor to generate clients based on older OpenAPI documents. This has not been done before but is believed to be straightforward. +## Git ref storage + +For versioned APIs, the Dropshot API manager can optionally store older API versions as *Git ref files* instead of full JSON files. A Git ref file is a small text file (with a `.gitref` extension) that points to the JSON content at a specific Git commit. + +### Benefits + +- **Meaningful diffs on GitHub.** New API versions appear as renames of the previous version, so GitHub shows what actually changed rather than thousands of added lines. +- **Blame works across versions.** `git blame` on the latest version traces history back through previous versions. +- **Smaller repository checkout on disk.** Git ref files are ~100 bytes each, replacing large JSON files in the working copy. + +For more background, see [RFD 634](https://rfd.shared.oxide.computer/rfd/0634). + +### Tradeoffs + +- **Requires Git history.** Shallow clones won't work, so CI must use `fetch-depth: 0`. See [_CI and shallow clones_](#ci-and-shallow-clones) below. +- **Rename-rename conflicts.** Parallel branches adding versions to the same API produce merge conflicts. See [_Git ref merge conflicts_](#git-ref-merge-conflicts) below. +- **Tools must dereference Git ref files.** Tools that need older API versions must know how to read Git ref files, though most workflows only use the `-latest.json` symlink. + +### Enabling Git ref storage + +To enable Git ref storage for an API, use the `.with_git_ref_storage()` builder method when configuring the API in your integration point. You can also call `.with_git_ref_storage()` on a combined `ManagedApi` to turn Git ref storage on by default. Use `.disable_git_ref_storage()` to opt an API out of default Git ref storage. + +For details on the file format and conversion rules, see [_Git ref storage details_](#git-ref-storage-details) below. + ## More about versioned APIs The idea behind versioned APIs is: @@ -293,6 +318,18 @@ You generally don't need to think about any of this to use the tool. Like with 2. You defined a new version, but forgot to annotate the API endpoints with what version they were added or removed in. Again, you'll get an error about having changed a blessed version and you'll need to follow the steps above to fix it. 3. You merge with an upstream that adds new versions. +### CI and shallow clones + +Versioned APIs require access to Git history: the tool loads blessed versions from the merge-base between `HEAD` and `main`, which requires that history to be available. Shallow clones (e.g., `git clone --depth 1`) typically lack the necessary history. + +For GitHub Actions, use a full clone: + +```yaml +- uses: actions/checkout@v6 + with: + fetch-depth: 0 +``` + ### Merging with upstream changes to versioned APIs When you merge with commits that added one or more versions to the same API that you also changed locally: @@ -347,6 +384,82 @@ That should be it! Now, when iterating on the API, you'll need to follow the pr In principle, this process could be reversed to convert an API from versioned to lockstep, but this almost certainly has runtime implications that would need to be considered. +### Git ref storage details + +#### What changes on disk + +With Git ref storage enabled, the directory structure changes from: + +``` +openapi/sled-agent/ +├── sled-agent-1.0.0-2da304.json +├── sled-agent-2.0.0-a3e161.json +├── sled-agent-3.0.0-f44f77.json +└── sled-agent-latest.json -> sled-agent-3.0.0-f44f77.json +``` + +To: + +``` +openapi/sled-agent/ +├── sled-agent-1.0.0-2da304.json.gitref +├── sled-agent-2.0.0-a3e161.json.gitref +├── sled-agent-3.0.0-f44f77.json +└── sled-agent-latest.json -> sled-agent-3.0.0-f44f77.json +``` + +The latest version remains a full JSON file, and the `-latest.json` symlink continues to work. Older blessed versions become `.gitref` files. + +#### Git ref file format + +A `.gitref` file contains a single line: + +``` +99c3f3ef97f80d1401c54ce0c625af125d4faef3:openapi/sled-agent/sled-agent-2.0.0-a3e161.json +``` + +The format is `:`, where the commit hash is when that version was introduced. + +#### Reading Git ref file contents + +To view the contents of a Git ref file: + +```sh +git show $(cat sled-agent-2.0.0-a3e161.json.gitref) +``` + +For Jujutsu: + +```sh +IFS=: read -r commit path < sled-agent-2.0.0-a3e161.json.gitref +jj file show -r "$commit" "root:$path" +``` + +#### When versions are converted + +The API manager automatically converts versions between JSON and Git ref formats. A version is stored as a Git ref when all of the following are true: + +- Git ref storage is enabled for the API. +- The version is blessed (present in the upstream branch). +- The version is not the latest. +- The version was not introduced in the same commit as the latest version. + +When you add a new version locally, the previous latest version is converted to a Git ref. If you remove that new version, the conversion is reversed. + +#### Git ref merge conflicts + +With Git ref storage, Git detects new API versions as renames of the previous version. If parallel branches both add new versions, Git produces a rename-rename conflict. + +To resolve, run `cargo openapi generate` (or your equivalent alias). The tool regenerates the correct files from your resolved `api_versions!` macro. + +If you use Jujutsu, the `-latest.json` symlink becomes a regular file during conflicts. The API manager detects this and corrects it when you run `generate`. + +#### Progenitor and client generation + +Progenitor-generated clients should continue to reference the `-latest.json` symlink, which always points to a real JSON file. No changes are needed for typical client generation. + +If you need to generate a client for an older version stored as a Git ref, you will currently need to disable Git ref storage for that API. + ## Contributing Bugfixes and other minor fixes are welcome! Before working on a major feature, please [open an issue](https://github.com/oxidecomputer/dropshot-api-manager/issues/new) to discuss it. diff --git a/crates/dropshot-api-manager/src/apis.rs b/crates/dropshot-api-manager/src/apis.rs index 8513864..b4f2f0b 100644 --- a/crates/dropshot-api-manager/src/apis.rs +++ b/crates/dropshot-api-manager/src/apis.rs @@ -83,6 +83,13 @@ pub struct ManagedApi { /// /// Default: false (bytewise check is performed for latest version). allow_trivial_changes_for_latest: bool, + + /// Per-API override for git ref storage. + /// + /// - `None`: use the global setting from `ManagedApis`. + /// - `Some(true)`: enable git ref storage for this API. + /// - `Some(false)`: disable git ref storage for this API. + use_git_ref_storage: Option, } impl fmt::Debug for ManagedApi { @@ -95,6 +102,7 @@ impl fmt::Debug for ManagedApi { api_description: _, extra_validation, allow_trivial_changes_for_latest, + use_git_ref_storage, } = self; f.debug_struct("ManagedApi") @@ -111,6 +119,7 @@ impl fmt::Debug for ManagedApi { "allow_trivial_changes_for_latest", allow_trivial_changes_for_latest, ) + .field("use_git_ref_storage", use_git_ref_storage) .finish() } } @@ -132,6 +141,7 @@ impl From for ManagedApi { api_description, extra_validation: None, allow_trivial_changes_for_latest: false, + use_git_ref_storage: None, } } } @@ -184,6 +194,30 @@ impl ManagedApi { self.allow_trivial_changes_for_latest } + /// Enables git ref storage for this API, overriding the global setting. + /// + /// When enabled, non-latest blessed API versions are stored as `.gitref` + /// files containing a git reference instead of full JSON files. + pub fn with_git_ref_storage(mut self) -> Self { + self.use_git_ref_storage = Some(true); + self + } + + /// Disables git ref storage for this API, overriding the global setting. + pub fn disable_git_ref_storage(mut self) -> Self { + self.use_git_ref_storage = Some(false); + self + } + + /// Returns the git ref storage setting for this API. + /// + /// - `None`: use the global setting. + /// - `Some(true)`: git ref storage is enabled for this API. + /// - `Some(false)`: git ref storage is disabled for this API. + pub fn uses_git_ref_storage(&self) -> Option { + self.use_git_ref_storage + } + /// Sets extra validation to perform on the OpenAPI document. /// /// For versioned APIs, extra validation is performed on *all* versions, @@ -269,16 +303,24 @@ pub struct ManagedApis { apis: BTreeMap, unknown_apis: BTreeSet, validation: Option>, + + /// If true, store non-latest blessed API versions as git ref files instead + /// of full JSON files. This saves disk space but requires git access to + /// read the contents. + /// + /// The default is false. + use_git_ref_storage: bool, } impl fmt::Debug for ManagedApis { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let Self { apis, unknown_apis, validation } = self; + let Self { apis, unknown_apis, validation, use_git_ref_storage } = self; f.debug_struct("ManagedApis") .field("apis", apis) .field("unknown_apis", unknown_apis) .field("validation", &validation.as_ref().map(|_| "...")) + .field("use_git_ref_storage", use_git_ref_storage) .finish() } } @@ -307,6 +349,7 @@ impl ManagedApis { apis, unknown_apis: BTreeSet::new(), validation: None, + use_git_ref_storage: false, }) } @@ -343,6 +386,30 @@ impl ManagedApis { self.validation.as_deref() } + /// Enables git ref storage for older blessed API versions. + /// + /// When enabled, non-latest blessed API versions are stored as `.gitref` + /// files containing a Git reference instead of full JSON files. This allows + /// for Git (including the GitHub web UI) to detect changed OpenAPI + /// documents as renames, but Git history is required to be present to read + /// older versions. + /// + /// Individual APIs can override this setting using + /// [`ManagedApi::use_git_ref_storage`] or + /// [`ManagedApi::disable_git_ref_storage`]. + pub fn with_git_ref_storage(mut self) -> Self { + self.use_git_ref_storage = true; + self + } + + /// Returns true if git ref storage is enabled for the given API. + /// + /// This checks the per-API setting first, falling back to the global + /// setting if not specified. + pub(crate) fn uses_git_ref_storage(&self, api: &ManagedApi) -> bool { + api.uses_git_ref_storage().unwrap_or(self.use_git_ref_storage) + } + /// Returns the number of APIs managed by this instance. pub fn len(&self) -> usize { self.apis.len() diff --git a/crates/dropshot-api-manager/src/cmd/check.rs b/crates/dropshot-api-manager/src/cmd/check.rs index af9e812..61ef87c 100644 --- a/crates/dropshot-api-manager/src/cmd/check.rs +++ b/crates/dropshot-api-manager/src/cmd/check.rs @@ -24,10 +24,12 @@ pub(crate) fn check_impl( eprintln!("{:>HEADER_WIDTH$}", SEPARATOR); - let (generated, errors) = generated_source.load(apis, &styles)?; + let (generated, errors) = + generated_source.load(apis, &styles, &env.repo_root)?; display_load_problems(&errors, &styles)?; - let (local_files, errors) = env.local_source.load(apis, &styles)?; + let (local_files, errors) = + env.local_source.load(apis, &styles, &env.repo_root)?; display_load_problems(&errors, &styles)?; let (blessed, errors) = diff --git a/crates/dropshot-api-manager/src/cmd/debug.rs b/crates/dropshot-api-manager/src/cmd/debug.rs index d899116..1270f45 100644 --- a/crates/dropshot-api-manager/src/cmd/debug.rs +++ b/crates/dropshot-api-manager/src/cmd/debug.rs @@ -26,7 +26,8 @@ pub(crate) fn debug_impl( // Print information about local files. - let (local_files, errors) = env.local_source.load(apis, &styles)?; + let (local_files, errors) = + env.local_source.load(apis, &styles, &env.repo_root)?; dump_structure(&local_files, &errors); // Print information about what we found in Git. @@ -35,7 +36,8 @@ pub(crate) fn debug_impl( dump_structure(&blessed, &errors); // Print information about generated files. - let (generated, errors) = generated_source.load(apis, &styles)?; + let (generated, errors) = + generated_source.load(apis, &styles, &env.repo_root)?; dump_structure(&generated, &errors); // Print result of resolving the differences. diff --git a/crates/dropshot-api-manager/src/cmd/generate.rs b/crates/dropshot-api-manager/src/cmd/generate.rs index c47ec78..8dbc533 100644 --- a/crates/dropshot-api-manager/src/cmd/generate.rs +++ b/crates/dropshot-api-manager/src/cmd/generate.rs @@ -43,10 +43,12 @@ pub(crate) fn generate_impl( styles.colorize(); } - let (generated, errors) = generated_source.load(apis, &styles)?; + let (generated, errors) = + generated_source.load(apis, &styles, &env.repo_root)?; display_load_problems(&errors, &styles)?; - let (local_files, errors) = env.local_source.load(apis, &styles)?; + let (local_files, errors) = + env.local_source.load(apis, &styles, &env.repo_root)?; display_load_problems(&errors, &styles)?; let (blessed, errors) = @@ -159,10 +161,11 @@ pub(crate) fn generate_impl( return Ok(GenerateResult::Failures); } - // Finally, check again for any problems. Since we expect this should have + // Finally, check again for any problems. Since we expect this should have // fixed everything, be quiet unless we find something amiss. let mut nproblems = 0; - let (local_files, errors) = env.local_source.load(apis, &styles)?; + let (local_files, errors) = + env.local_source.load(apis, &styles, &env.repo_root)?; eprintln!( "{:>HEADER_WIDTH$} all local files", "Rechecking".style(styles.success_header), diff --git a/crates/dropshot-api-manager/src/environment.rs b/crates/dropshot-api-manager/src/environment.rs index ccc72bb..83ec293 100644 --- a/crates/dropshot-api-manager/src/environment.rs +++ b/crates/dropshot-api-manager/src/environment.rs @@ -1,11 +1,11 @@ -// Copyright 2025 Oxide Computer Company +// Copyright 2026 Oxide Computer Company //! Describes the environment the command is running in, and particularly where //! different sets of specifications are loaded from use crate::{ apis::ManagedApis, - git::GitRevision, + git::{GitRevision, is_shallow_clone}, output::{ Styles, headers::{GENERATING, HEADER_WIDTH}, @@ -199,7 +199,7 @@ pub enum BlessedSource { } impl BlessedSource { - /// Load the blessed OpenAPI documents + /// Load the blessed OpenAPI documents. pub fn load( &self, repo_root: &Utf8Path, @@ -215,7 +215,12 @@ impl BlessedSource { local_directory, ); let api_files: ApiSpecFilesBuilder<'_, BlessedApiSpecFile> = - walk_local_directory(local_directory, apis, &mut errors)?; + walk_local_directory( + local_directory, + apis, + &mut errors, + repo_root, + )?; Ok((BlessedFiles::from(api_files), errors)) } BlessedSource::GitRevisionMergeBase { revision, directory } => { @@ -254,11 +259,12 @@ pub enum GeneratedSource { } impl GeneratedSource { - /// Load the generated OpenAPI documents (i.e., generating them as needed) + /// Load the generated OpenAPI documents (i.e., generating them as needed). pub fn load( &self, apis: &ManagedApis, styles: &Styles, + repo_root: &Utf8Path, ) -> anyhow::Result<(GeneratedFiles, ErrorAccumulator)> { let mut errors = ErrorAccumulator::new(); match self { @@ -277,8 +283,12 @@ impl GeneratedSource { "Loading".style(styles.success_header), local_directory, ); - let api_files = - walk_local_directory(local_directory, apis, &mut errors)?; + let api_files = walk_local_directory( + local_directory, + apis, + &mut errors, + repo_root, + )?; Ok((GeneratedFiles::from(api_files), errors)) } } @@ -299,13 +309,31 @@ pub enum LocalSource { } impl LocalSource { - /// Load the local OpenAPI documents + /// Load the local OpenAPI documents. + /// + /// The `repo_root` parameter is needed to resolve `.gitref` files. pub fn load( &self, apis: &ManagedApis, styles: &Styles, + repo_root: &Utf8Path, ) -> anyhow::Result<(LocalFiles, ErrorAccumulator)> { let mut errors = ErrorAccumulator::new(); + + // Shallow clones and git ref storage are incompatible. + let any_uses_git_ref = + apis.iter_apis().any(|a| apis.uses_git_ref_storage(a)); + if any_uses_git_ref && is_shallow_clone(repo_root) { + errors.error(anyhow::anyhow!( + "this repository is a shallow clone, but git ref storage is \ + enabled for some APIs. Git refs cannot be resolved in a \ + shallow clone because the referenced commits may not be \ + available. To fix this, run `git fetch --unshallow` to \ + fetch complete history, or make a fresh clone without --depth." + )); + return Ok((LocalFiles::default(), errors)); + } + match self { LocalSource::Directory { abs_dir, .. } => { eprintln!( @@ -319,6 +347,7 @@ impl LocalSource { abs_dir, apis, &mut errors, + repo_root, )?, errors, )) diff --git a/crates/dropshot-api-manager/src/git.rs b/crates/dropshot-api-manager/src/git.rs index dc53a91..0912ea9 100644 --- a/crates/dropshot-api-manager/src/git.rs +++ b/crates/dropshot-api-manager/src/git.rs @@ -4,7 +4,8 @@ use anyhow::{Context, bail}; use camino::{Utf8Path, Utf8PathBuf}; -use std::process::Command; +use std::{fmt, process::Command, str::FromStr}; +use thiserror::Error; /// Newtype String wrapper identifying a Git revision /// @@ -18,6 +19,77 @@ NewtypeDerefMut! { () pub struct GitRevision(String); } NewtypeDisplay! { () pub struct GitRevision(String); } NewtypeFrom! { () pub struct GitRevision(String); } +/// A Git commit hash. +/// +/// This type guarantees the contained string is either: +/// +/// - 40 lowercase hex digits (SHA-1) +/// - 64 lowercase hex digits (SHA-256) +/// +/// See [`GitRevision`] for a more general type that can represent a commit +/// hash; or a branch name, tag, or other symbolic reference. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum GitCommitHash { + /// A SHA-1 hash: the one traditionally used in Git. + Sha1([u8; 20]), + /// A SHA-256 hash, supported by newer versions of Git. + Sha256([u8; 32]), +} + +impl FromStr for GitCommitHash { + type Err = CommitHashParseError; + + fn from_str(s: &str) -> Result { + let len = s.len(); + match len { + 40 => { + let mut bytes = [0; 20]; + hex::decode_to_slice(s, &mut bytes) + .map_err(CommitHashParseError::InvalidHex)?; + Ok(GitCommitHash::Sha1(bytes)) + } + 64 => { + let mut bytes = [0; 32]; + hex::decode_to_slice(s, &mut bytes) + .map_err(CommitHashParseError::InvalidHex)?; + Ok(GitCommitHash::Sha256(bytes)) + } + _ => Err(CommitHashParseError::InvalidLength(len)), + } + } +} + +impl fmt::Display for GitCommitHash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + GitCommitHash::Sha1(bytes) => hex::encode(bytes).fmt(f), + GitCommitHash::Sha256(bytes) => hex::encode(bytes).fmt(f), + } + } +} + +impl From for GitRevision { + fn from(hash: GitCommitHash) -> Self { + GitRevision::from(hash.to_string()) + } +} + +/// An error that occurs while parsing a [`GitCommitHash`]. +#[derive(Clone, Debug, Error, PartialEq)] +#[non_exhaustive] +pub enum CommitHashParseError { + /// The commit hash has an invalid length. + #[error( + "invalid length: expected 40 (SHA-1) or 64 (SHA-256) hex characters, \ + got {0}" + )] + InvalidLength(usize), + + /// The commit hash is not valid hexadecimal. + #[error("invalid hexadecimal")] + InvalidHex(hex::FromHexError), +} + /// Given a revision, return its merge base with HEAD pub fn git_merge_base_head( repo_root: &Utf8Path, @@ -87,6 +159,75 @@ pub fn git_show_file( Ok(stdout.into_bytes()) } +/// Returns the first commit where a file was introduced, searching up to and +/// including the given revision. +/// +/// This is used to find a stable, canonical commit for git ref storage. Using +/// the first commit (as opposed to something more readily available like the +/// merge base) ensures that if two different developers make changes to the +/// same API starting from different merge bases, this tool will convert the +/// previous blessed version into having the same contents for both developers. +/// This avoids an unnecessary merge conflict in the contents of the `.gitref` +/// file. +pub fn git_first_commit_for_file( + repo_root: &Utf8Path, + revision: &GitRevision, + path: &Utf8Path, +) -> anyhow::Result { + // Use --diff-filter=A to find the commit that *added* the file, limiting + // search to the given revision. + // + // We intentionally don't use --follow because Git's rename detection can + // incorrectly match unrelated files with similar content, causing it to + // return the wrong commit. + let mut cmd = git_start(repo_root); + cmd.arg("log") + .arg("--diff-filter=A") + .arg("--format=%H") + .arg(revision.as_str()) + .arg("--") + .arg(path); + let stdout = do_run(&mut cmd)?; + let commit = stdout.trim(); + + // If a file was removed and re-added, git log will show multiple commits + // with --diff-filter=A. Take the first line (i.e. the most recent commit) + // since that's the commit where the current version of the file was + // introduced. The choice here is somewhat arbitrary, but it is consistent + // across clones (which is important to minimize merge conflicts). + let first_commit = commit.lines().next().with_context(|| { + format!( + "no commit found that added file {:?} \ + (searched backwards from {})", + path, revision, + ) + })?; + + // Git's --format=%H always returns full SHA-1 or SHA-256 hashes. + first_commit.parse().with_context(|| { + format!( + "git returned invalid commit hash {:?} for {:?}", + first_commit, path + ) + }) +} + +/// Returns true if the repository is a shallow clone. +/// +/// Shallow clones have truncated history, which can cause `git log` to return +/// incorrect results when searching for the commit that added a file. In a +/// shallow clone, files present at the shallow boundary appear to have been +/// "added" in the boundary commit, even if they were actually added earlier. +pub fn is_shallow_clone(repo_root: &Utf8Path) -> bool { + let mut cmd = git_start(repo_root); + cmd.arg("rev-parse").arg("--is-shallow-repository"); + match do_run(&mut cmd) { + Ok(output) => output.trim() == "true", + // If this failed, don't print a warning. + Err(_) => false, + } +} + /// Begin assembling an invocation of git(1) fn git_start(repo_root: &Utf8Path) -> Command { let git = std::env::var("GIT").ok().unwrap_or_else(|| String::from("git")); @@ -135,3 +276,173 @@ fn cmd_label(cmd: &Command) -> String { .join(" ") ) } + +/// Represents a Git reference to a file at a specific commit. +/// +/// A git ref is stored as a string in the format `commit:path`, and can be +/// used to retrieve file contents via `git show`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GitRef { + /// The commit hash (validated SHA-1 or SHA-256). + pub commit: GitCommitHash, + /// The path within the repository. + pub path: Utf8PathBuf, +} + +impl GitRef { + /// Read the contents of the file at this git ref. + pub fn read_contents( + &self, + repo_root: &Utf8Path, + ) -> anyhow::Result> { + git_show_file(repo_root, &GitRevision::from(self.commit), &self.path) + } +} + +impl fmt::Display for GitRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.commit, self.path) + } +} + +impl FromStr for GitRef { + type Err = GitRefParseError; + + fn from_str(s: &str) -> Result { + let s = s.trim(); + let (commit, path) = s + .split_once(':') + .ok_or_else(|| GitRefParseError::InvalidFormat(s.to_owned()))?; + let commit: GitCommitHash = commit.parse()?; + Ok(GitRef { commit, path: Utf8PathBuf::from(path) }) + } +} + +/// An error that occurs while parsing a git ref. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum GitRefParseError { + /// The git ref string did not contain the expected 'commit:path' format. + #[error("invalid git ref format: expected 'commit:path', got {0}")] + InvalidFormat(String), + + /// The commit hash in the git ref was invalid. + #[error("invalid commit hash")] + InvalidCommitHash(#[from] CommitHashParseError), +} + +#[cfg(test)] +mod tests { + use super::*; + + const VALID_SHA1: &str = "0123456789abcdef0123456789abcdef01234567"; + const VALID_SHA256: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + #[test] + fn test_commit_hash_valid() { + let hash: GitCommitHash = VALID_SHA1.parse().unwrap(); + assert_eq!(hash.to_string(), VALID_SHA1); + + let hash: GitCommitHash = VALID_SHA256.parse().unwrap(); + assert_eq!(hash.to_string(), VALID_SHA256); + + let hash: GitCommitHash = VALID_SHA1.parse().unwrap(); + let revision: GitRevision = hash.into(); + assert_eq!(revision.as_str(), VALID_SHA1); + } + + #[test] + fn test_commit_hash_invalid() { + assert_eq!( + "abc123".parse::(), + Err(CommitHashParseError::InvalidLength(6)), + "too short" + ); + + assert_eq!( + VALID_SHA1[..39].parse::(), + Err(CommitHashParseError::InvalidLength(39)), + "39 chars (one short of SHA-1)" + ); + + let input = format!("{}0", VALID_SHA1); + assert_eq!( + input.parse::(), + Err(CommitHashParseError::InvalidLength(41)), + "41 chars (one over SHA-1)" + ); + + assert!( + matches!( + "0123456789abcdefg123456789abcdef01234567" + .parse::(), + Err(CommitHashParseError::InvalidHex(_)) + ), + "non-hex character 'g'" + ); + + let input = format!(" {}", VALID_SHA1); + assert_eq!( + input.parse::(), + Err(CommitHashParseError::InvalidLength(41)), + "leading whitespace (the CommitHash parser doesn't do trimming)" + ); + } + + #[test] + fn test_git_ref_parse() { + let input = format!("{}:openapi/api/api-1.0.0-def456.json", VALID_SHA1); + let git_ref = input.parse::().unwrap(); + assert_eq!(git_ref.commit.to_string(), VALID_SHA1); + assert_eq!(git_ref.path.as_str(), "openapi/api/api-1.0.0-def456.json"); + } + + #[test] + fn test_git_ref_parse_with_whitespace() { + let input = format!(" {}:path/file.json\n", VALID_SHA1); + let git_ref = input.parse::().unwrap(); + assert_eq!(git_ref.commit.to_string(), VALID_SHA1); + assert_eq!(git_ref.path.as_str(), "path/file.json"); + } + + #[test] + fn test_git_ref_parse_invalid_no_colon() { + let result = "no-colon".parse::(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + GitRefParseError::InvalidFormat(_) + )); + } + + #[test] + fn test_git_ref_parse_invalid_empty() { + let result = "".parse::(); + assert!(result.is_err()); + } + + #[test] + fn test_git_ref_parse_invalid_commit_hash() { + // Valid format but invalid commit hash (too short). + let result = "abc123:path/file.json".parse::(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + GitRefParseError::InvalidCommitHash(_) + )); + } + + #[test] + fn test_git_ref_roundtrip() { + let git_ref = GitRef { + commit: VALID_SHA1.parse().unwrap(), + path: Utf8PathBuf::from("path/to/file.json"), + }; + let s = git_ref.to_string(); + let expected = format!("{}:path/to/file.json", VALID_SHA1); + assert_eq!(s, expected); + let parsed = s.parse::().unwrap(); + assert_eq!(git_ref, parsed); + } +} diff --git a/crates/dropshot-api-manager/src/lib.rs b/crates/dropshot-api-manager/src/lib.rs index 10cec36..6d8564b 100644 --- a/crates/dropshot-api-manager/src/lib.rs +++ b/crates/dropshot-api-manager/src/lib.rs @@ -29,3 +29,4 @@ extern crate newtype_derive; pub use apis::*; pub use cmd::dispatch::{App, FAILURE_EXIT_CODE, NEEDS_UPDATE_EXIT_CODE}; pub use environment::Environment; +pub use git::{GitRef, GitRefParseError}; diff --git a/crates/dropshot-api-manager/src/resolved.rs b/crates/dropshot-api-manager/src/resolved.rs index 57121be..71b59a5 100644 --- a/crates/dropshot-api-manager/src/resolved.rs +++ b/crates/dropshot-api-manager/src/resolved.rs @@ -1,4 +1,4 @@ -// Copyright 2025 Oxide Computer Company +// Copyright 2026 Oxide Computer Company //! Resolve different sources of API information (blessed, local, upstream) @@ -6,9 +6,10 @@ use crate::{ apis::{ManagedApi, ManagedApis}, compatibility::{ApiCompatIssue, api_compatible}, environment::ResolvedEnv, + git::{GitCommitHash, GitRef}, iter_only::iter_only, output::{InlineErrorChain, plural}, - spec_files_blessed::{BlessedApiSpecFile, BlessedFiles}, + spec_files_blessed::{BlessedApiSpecFile, BlessedFiles, BlessedGitRef}, spec_files_generated::{GeneratedApiSpecFile, GeneratedFiles}, spec_files_generic::ApiFiles, spec_files_local::{LocalApiSpecFile, LocalFiles}, @@ -46,14 +47,14 @@ where } } -/// A non-error note that's worth highlighting to the user +/// A non-error note that's worth highlighting to the user. // These are not technically errors, but it is useful to treat them the same // way in terms of having an associated message, etc. #[derive(Debug, Error)] pub enum Note { /// A previously-supported API version has been removed locally. /// - /// This is not an error because we do expect to EOL old API specs. There's + /// This is not an error because we do expect to EOL old API specs. There's /// not currently a way for this tool to know if the EOL'ing is correct or /// not, so we at least highlight it to the user. #[error( @@ -90,6 +91,11 @@ impl<'a> Resolution<'a> { !self.problems.is_empty() } + /// Add a problem to this resolution. + pub fn add_problem(&mut self, problem: Problem<'a>) { + self.problems.push(problem); + } + pub fn has_errors(&self) -> bool { self.problems().any(|p| !p.is_fixable()) } @@ -272,6 +278,41 @@ pub enum Problem<'a> { found: &'a ApiSpecFileName, link: &'a ApiSpecFileName, }, + + #[error( + "Blessed non-latest version is stored as a full JSON file. This can \ + be converted to a git ref. This tool can perform the conversion for \ + you." + )] + BlessedVersionShouldBeGitRef { + local_file: &'a LocalApiSpecFile, + git_ref: GitRef, + }, + + #[error( + "Blessed version is stored as a git ref file, but should be stored as \ + JSON. This tool can perform the conversion for you." + )] + GitRefShouldBeJson { local_file: &'a LocalApiSpecFile }, + + #[error( + "Duplicate local file found: both JSON and git ref versions exist for \ + this API version. This tool can remove the redundant file for you." + )] + DuplicateLocalFile { local_file: &'a LocalApiSpecFile }, + + #[error( + "The first commit for this blessed version could not be determined. This \ + may indicate a corrupted git repository or other git-related issue. Git \ + ref storage requires complete git history access" + // Note: omitting a trailing period after "access" because we show ": + // ". + )] + GitRefFirstCommitUnknown { + spec_file_name: ApiSpecFileName, + #[source] + source: anyhow::Error, + }, } impl<'a> Problem<'a> { @@ -324,6 +365,20 @@ impl<'a> Problem<'a> { | Problem::LatestLinkMissing { api_ident, link } => { Some(Fix::UpdateSymlink { api_ident, link }) } + Problem::BlessedVersionShouldBeGitRef { local_file, git_ref } => { + Some(Fix::ConvertToGitRef { local_file, git_ref }) + } + Problem::GitRefShouldBeJson { local_file } => { + Some(Fix::ConvertToJson { local_file }) + } + Problem::DuplicateLocalFile { local_file } => { + Some(Fix::DeleteFiles { + files: DisplayableVec(vec![ + local_file.spec_file_name().clone(), + ]), + }) + } + Problem::GitRefFirstCommitUnknown { .. } => None, } } } @@ -347,6 +402,15 @@ pub enum Fix<'a> { api_ident: &'a ApiIdent, link: &'a ApiSpecFileName, }, + /// Convert a full JSON file to a git ref file. + ConvertToGitRef { + local_file: &'a LocalApiSpecFile, + git_ref: &'a GitRef, + }, + /// Convert a git ref file back to a full JSON file. + ConvertToJson { + local_file: &'a LocalApiSpecFile, + }, } impl Display for Fix<'_> { @@ -388,7 +452,25 @@ impl Display for Fix<'_> { writeln!(f, "{label} file {path} from generated")?; } Fix::UpdateSymlink { link, .. } => { - writeln!(f, "update symlink to point to {}", link.basename())?; + writeln!( + f, + "update symlink to point to {}", + link.to_json_filename().basename() + )?; + } + Fix::ConvertToGitRef { local_file, .. } => { + writeln!( + f, + "convert {} to git ref", + local_file.spec_file_name().path() + )?; + } + Fix::ConvertToJson { local_file } => { + writeln!( + f, + "convert {} from git ref to JSON", + local_file.spec_file_name().path() + )?; } }; Ok(()) @@ -452,8 +534,9 @@ impl Fix<'_> { .join(api_ident.versioned_api_latest_symlink()); // We want the link to contain a relative path to a file in the // same directory so that it's correct no matter where it's - // resolved from. - let target = link.basename(); + // resolved from. If the link target is a gitref, convert it to + // the JSON filename (the symlink should always point to JSON). + let target = link.to_json_filename().basename(); match fs_err::remove_file(&path) { Ok(_) => (), Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} @@ -464,6 +547,68 @@ impl Fix<'_> { symlink_file(&target, &path)?; Ok(vec![format!("wrote link {} -> {}", path, target)]) } + Fix::ConvertToGitRef { local_file, git_ref } => { + let json_path = root.join(local_file.spec_file_name().path()); + + let git_ref_basename = format!( + "{}.gitref", + local_file.spec_file_name().basename() + ); + let git_ref_path = json_path + .parent() + .ok_or_else(|| anyhow!("cannot get parent directory"))? + .join(&git_ref_basename); + + // Write the git ref file. Add a trailing newline so diffs don't + // have the "\ No newline at end of file" message. Otherwise, + // the extra newline has no impact on usability or correctness. + let overwrite_status = overwrite_file( + &git_ref_path, + format!("{}\n", git_ref).as_bytes(), + )?; + + // Remove the original JSON file. + fs_err::remove_file(&json_path)?; + + Ok(vec![ + format!("converted {} to git ref", json_path), + format!("created {}: {:?}", git_ref_path, overwrite_status), + ]) + } + Fix::ConvertToJson { local_file } => { + let git_ref_path = + root.join(local_file.spec_file_name().path()); + + // The local_file already has the contents loaded from git (git + // ref files are dereferenced when loaded). We just need to + // write those contents to a new JSON file. + let contents = local_file.contents(); + + // Compute the JSON file path by removing the .gitref suffix. + let git_ref_basename = local_file.spec_file_name().basename(); + let json_basename = git_ref_basename + .strip_suffix(".gitref") + .ok_or_else(|| { + anyhow!( + "expected git ref file to end with .gitref: {}", + git_ref_basename + ) + })?; + + let json_path = git_ref_path + .parent() + .ok_or_else(|| anyhow!("cannot get parent directory"))? + .join(json_basename); + + let overwrite_status = overwrite_file(&json_path, contents)?; + + fs_err::remove_file(&git_ref_path)?; + + Ok(vec![ + format!("converted {} from git ref to JSON", git_ref_path), + format!("created {}: {:?}", json_path, overwrite_status), + ]) + } } } } @@ -516,7 +661,7 @@ impl<'a> Resolved<'a> { // Get one easy case out of the way: if there are any blessed API // versions that aren't supported any more, note that. - let notes = resolve_removed_blessed_versions( + let notes: Vec = resolve_removed_blessed_versions( &supported_versions_by_api, blessed, ) @@ -551,6 +696,8 @@ impl<'a> Resolved<'a> { env, api, apis.validation(), + apis.uses_git_ref_storage(api), + blessed, api_blessed, api_generated, api_local, @@ -647,10 +794,13 @@ fn resolve_orphaned_local_specs<'a>( }) } +#[expect(clippy::too_many_arguments)] fn resolve_api<'a>( env: &'a ResolvedEnv, api: &'a ManagedApi, validation: Option<&DynValidationFn>, + use_git_ref_storage: bool, + all_blessed: &'a BlessedFiles, api_blessed: Option<&'a ApiFiles>, api_generated: &'a ApiFiles, api_local: Option<&'a ApiFiles>>, @@ -667,7 +817,44 @@ fn resolve_api<'a>( None, ) } else { - let by_version: BTreeMap<_, _> = api + let latest_version = api + .iter_versions_semver() + .next_back() + .expect("versioned API has at least one version"); + + // Compute the first commit for the latest version, capturing any errors. + let (latest_first_commit, latest_first_commit_error) = { + let latest_is_blessed = api_blessed + .is_some_and(|b| b.versions().contains_key(latest_version)); + + if !latest_is_blessed { + (LatestFirstCommit::NotBlessed, None) + } else { + // The latest version is blessed. Try to find its first commit. + match all_blessed.git_ref(api.ident(), latest_version) { + Some(gr) => match gr.to_git_ref(&env.repo_root) { + Ok(git_ref) => { + (LatestFirstCommit::Blessed(git_ref.commit), None) + } + Err(error) => { + // Capture the error to report it for the latest + // version. + let blessed_file = api_blessed + .and_then(|b| b.versions().get(latest_version)); + let spec_file_name = blessed_file + .map(|f| f.spec_file_name().clone()); + ( + LatestFirstCommit::BlessedError, + Some((spec_file_name, error)), + ) + } + }, + None => (LatestFirstCommit::BlessedError, None), + } + } + }; + + let mut by_version: BTreeMap<_, _> = api .iter_versions_semver() // Reverse the order of versions: they are stored in sorted order, // so the last version (first one from the back) is the latest. @@ -685,20 +872,39 @@ fn resolve_api<'a>( .map(|v| v.as_slice()) .unwrap_or(&[]); + // Look up the git ref for this version. + let git_ref = all_blessed.git_ref(api.ident(), &version); + let resolution = resolve_api_version( env, api, validation, + use_git_ref_storage, ApiVersion { version: &version, is_latest, is_blessed }, blessed, + git_ref, generated, local, + latest_first_commit, ); (version, resolution) }) .collect(); + // If there was an error computing the first commit for the latest + // version, add the error to the latest version's resolution. + if let Some((spec_file_name, error)) = latest_first_commit_error { + if let Some(resolution) = by_version.get_mut(latest_version) { + if let Some(spec_file_name) = spec_file_name { + resolution.add_problem(Problem::GitRefFirstCommitUnknown { + spec_file_name, + source: error, + }); + } + } + } + // Check the "latest" symlink. let latest_generated = api_generated.latest_link().expect( "\"generated\" source should always have a \"latest\" link", @@ -930,18 +1136,31 @@ struct ApiVersion<'a> { is_blessed: Option, } +#[expect(clippy::too_many_arguments)] fn resolve_api_version<'a>( env: &'_ ResolvedEnv, api: &'_ ManagedApi, validation: Option<&DynValidationFn>, + use_git_ref_storage: bool, version: ApiVersion<'_>, blessed: Option<&'a BlessedApiSpecFile>, + git_ref: Option<&'a BlessedGitRef>, generated: &'a GeneratedApiSpecFile, local: &'a [LocalApiSpecFile], + latest_first_commit: LatestFirstCommit, ) -> Resolution<'a> { match blessed { Some(blessed) => resolve_api_version_blessed( - env, api, validation, version, blessed, generated, local, + env, + api, + validation, + use_git_ref_storage, + version, + blessed, + git_ref, + generated, + local, + latest_first_commit, ), None => resolve_api_version_local( env, api, validation, version, generated, local, @@ -949,14 +1168,18 @@ fn resolve_api_version<'a>( } } +#[expect(clippy::too_many_arguments)] fn resolve_api_version_blessed<'a>( env: &'_ ResolvedEnv, api: &'_ ManagedApi, validation: Option<&DynValidationFn>, + use_git_ref_storage: bool, version: ApiVersion<'_>, blessed: &'a BlessedApiSpecFile, + git_ref: Option<&'a BlessedGitRef>, generated: &'a GeneratedApiSpecFile, local: &'a [LocalApiSpecFile], + latest_first_commit: LatestFirstCommit, ) -> Resolution<'a> { let mut problems = Vec::new(); let is_latest = version.is_latest; @@ -1025,25 +1248,106 @@ fn resolve_api_version_blessed<'a>( assert_eq!(hashes_match, contents_match); hashes_match }); + if matching.is_empty() { problems.push(Problem::BlessedVersionMissingLocal { spec_file_name: blessed.spec_file_name().clone(), - }) + }); + } else if !use_git_ref_storage || is_latest { + // Fast path: git ref storage disabled or this is the latest version. + // Computing first commits is slow, and we know we always want JSON in + // this case, so we can avoid computing them here. + + if matching.len() > 1 { + // We might have both api.json and api.json.gitref for the same + // version. Mark the redundant file (always the gitref file in this + // case) for deletion. + for local_file in matching { + if local_file.spec_file_name().is_git_ref() { + problems.push(Problem::DuplicateLocalFile { local_file }); + } + } + } else { + let local_file = matching[0]; + if local_file.spec_file_name().is_git_ref() { + problems.push(Problem::GitRefShouldBeJson { local_file }); + } + } + + problems.extend(non_matching.into_iter().map(|s| { + Problem::BlessedVersionExtraLocalSpec { + spec_file_name: s.spec_file_name().clone(), + } + })); } else { - // The specs are identified by, among other things, their hash. Thus, - // to have two matching specs (i.e., having the same contents), we'd - // have to have a hash collision. This is conceivable but unlikely - // enough that this is more likely a logic bug. - assert_eq!(matching.len(), 1); - } + // Slow path: git ref storage enabled and not latest; need to check the + // respective first commits to determine if this version should be a git + // ref. + // + // A version should be stored as a git ref if it was introduced in a + // different commit from the latest (see RFD 634). If we can't determine + // the first commit, report an error. + let should_be_git_ref = match git_ref { + Some(r) => match r.to_git_ref(&env.repo_root) { + Ok(current) => should_convert_to_git_ref( + latest_first_commit, + current.commit, + ) + .then_some(current), + Err(error) => { + problems.push(Problem::GitRefFirstCommitUnknown { + spec_file_name: blessed.spec_file_name().clone(), + source: error, + }); + None + } + }, + None => None, + }; - // There shouldn't be any local specs that match the same version but don't - // match the same contents. - problems.extend(non_matching.into_iter().map(|s| { - Problem::BlessedVersionExtraLocalSpec { - spec_file_name: s.spec_file_name().clone(), + if matching.len() > 1 { + // We might have both api.json and api.json.gitref for the same + // version. Mark the redundant file for deletion. + for local_file in matching { + let redundant = match ( + should_be_git_ref.is_some(), + local_file.spec_file_name().is_git_ref(), + ) { + (true, false) | (false, true) => true, + (true, true) | (false, false) => false, + }; + if redundant { + problems.push(Problem::DuplicateLocalFile { local_file }); + } + } + } else { + let local_file = matching[0]; + + match (should_be_git_ref, local_file.spec_file_name().is_git_ref()) + { + (Some(git_ref), false) => { + // Should be git ref but is JSON: convert to git ref. + problems.push(Problem::BlessedVersionShouldBeGitRef { + local_file, + git_ref: git_ref.clone(), + }); + } + (None, true) => { + // Should be JSON but is git ref: convert to JSON. + problems.push(Problem::GitRefShouldBeJson { local_file }); + } + (Some(_), true) | (None, false) => { + // Format matches preference: no conversion needed. + } + } } - })); + + problems.extend(non_matching.into_iter().map(|s| { + Problem::BlessedVersionExtraLocalSpec { + spec_file_name: s.spec_file_name().clone(), + } + })); + } Resolution::new_blessed(problems) } @@ -1129,9 +1433,56 @@ fn validate_generated( } } +/// Describes the first commit for the latest version. +/// +/// Used to decide whether to suggest git ref conversion for older versions. +#[derive(Clone, Copy, Debug)] +enum LatestFirstCommit { + NotBlessed, + Blessed(GitCommitHash), + BlessedError, +} + +/// Returns true if this tool should convert a blessed version to a git ref, +/// assuming that git ref storage is enabled. +fn should_convert_to_git_ref( + latest: LatestFirstCommit, + first_commit: GitCommitHash, +) -> bool { + // This match statement captures the decision table: + // + // status | suggest conversion? + // | + // NotBlessed | yes (always) + // Blessed(same) | no + // Blessed(different) | yes + // BlessedError | no + match latest { + LatestFirstCommit::NotBlessed => { + // The latest version is not blessed. This means that a new version + // is being added, so we should always convert blessed versions to + // git refs. + true + } + + LatestFirstCommit::Blessed(latest_first_commit) => { + // The latest version is blessed. Only suggest conversions if the + // version's first commit is different from the latest version's + // first commit. + first_commit != latest_first_commit + } + + LatestFirstCommit::BlessedError => { + // The latest version is blessed, but an error occurred while + // determining its first commit. + false + } + } +} + #[cfg(test)] -mod test { - use super::DisplayableVec; +mod tests { + use super::*; #[test] fn test_displayable_vec() { @@ -1144,4 +1495,42 @@ mod test { let v = DisplayableVec(vec![8, 12, 14]); assert_eq!(v.to_string(), "8, 12, 14"); } + + #[test] + fn test_should_suggest_git_ref_conversion() { + let current = commit(COMMIT_A); + + assert!( + should_convert_to_git_ref(LatestFirstCommit::NotBlessed, current), + "latest NotBlessed => always suggest conversion" + ); + + let latest = LatestFirstCommit::Blessed(commit(COMMIT_A)); + assert!( + !should_convert_to_git_ref(latest, current), + "latest Blessed with same commit => do not suggest conversion" + ); + + let latest = LatestFirstCommit::Blessed(commit(COMMIT_B)); + assert!( + should_convert_to_git_ref(latest, current), + "latest Blessed with different commit => suggest conversion" + ); + + assert!( + !should_convert_to_git_ref( + LatestFirstCommit::BlessedError, + current + ), + "latest BlessedUnknown => do not suggest conversion" + ); + } + + // Test commit hashes. + const COMMIT_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const COMMIT_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + fn commit(s: &str) -> GitCommitHash { + s.parse().unwrap() + } } diff --git a/crates/dropshot-api-manager/src/spec_files_blessed.rs b/crates/dropshot-api-manager/src/spec_files_blessed.rs index 9fc3e95..d6ac0c5 100644 --- a/crates/dropshot-api-manager/src/spec_files_blessed.rs +++ b/crates/dropshot-api-manager/src/spec_files_blessed.rs @@ -6,13 +6,16 @@ use crate::{ apis::ManagedApis, environment::ErrorAccumulator, - git::{GitRevision, git_ls_tree, git_merge_base_head, git_show_file}, + git::{ + GitCommitHash, GitRef, GitRevision, git_first_commit_for_file, + git_ls_tree, git_merge_base_head, git_show_file, + }, spec_files_generic::{ ApiFiles, ApiLoad, ApiSpecFile, ApiSpecFilesBuilder, AsRawFiles, }, }; use anyhow::{anyhow, bail}; -use camino::Utf8Path; +use camino::{Utf8Path, Utf8PathBuf}; use dropshot_api_manager_types::ApiIdent; use std::{collections::BTreeMap, ops::Deref}; @@ -62,7 +65,98 @@ impl AsRawFiles for BlessedApiSpecFile { } } -/// Container for OpenAPI documents from the "blessed" source (usually Git) +/// Git reference information for a blessed file. +/// +/// This tracks where a blessed file came from in git, so we can create git ref +/// files that point back to the original content. +/// +/// For `.gitref` files, the commit is already known from parsing the file. For +/// JSON files, the commit is computed lazily to avoid slow `git log` calls when +/// git ref storage is disabled. +#[derive(Clone, Debug)] +pub enum BlessedGitRef { + /// The Git reference is already known. Obtained by from parsing a `.gitref` + /// file. + Known { + /// The git commit hash where this file was blessed. + commit: GitCommitHash, + /// The path within the repository, relative to the repo root. + path: Utf8PathBuf, + }, + /// The Git reference needs to be computed. Obtained through JSON files, and + /// only resolved if conversions are required. + Lazy { + /// The git revision to search within (typically the merge-base). + revision: GitRevision, + /// The path within the repository, relative to the repo root. + path: Utf8PathBuf, + }, +} + +impl BlessedGitRef { + /// Convert to a `GitRef` for reading content. + /// + /// For `Known` variants, this is a simple conversion. For `Lazy` variants, + /// this calls `git log` to find the first commit that introduced the file. + pub fn to_git_ref(&self, repo_root: &Utf8Path) -> anyhow::Result { + match self { + BlessedGitRef::Known { commit, path } => { + Ok(GitRef { commit: *commit, path: path.clone() }) + } + BlessedGitRef::Lazy { revision, path } => { + let commit = + git_first_commit_for_file(repo_root, revision, path)?; + Ok(GitRef { commit, path: path.clone() }) + } + } + } +} + +/// Represents the structure of a path found during blessed file enumeration. +/// +/// This enum captures what we can determine from path structure alone, before +/// any API-level validation. +enum BlessedPathKind<'a> { + /// Single-component path (e.g., "api.json"). Potential lockstep file. + Lockstep { basename: &'a str }, + + /// Two-component path with `.json.gitref` extension. Potential versioned + /// git ref file. + GitRefFile { api_dir: &'a str, basename: &'a str }, + + /// Two-component path (e.g., "api/api-1.2.3-hash.json"). Could be a + /// versioned file or latest symlink - requires API validation. + VersionedFile { api_dir: &'a str, basename: &'a str }, +} + +/// Path structure we don't understand (empty, >2 components, etc.). +struct UnrecognizedPath; + +impl<'a> BlessedPathKind<'a> { + /// Parse a path from git ls-tree output into its structural kind. + fn parse(path: &'a Utf8Path) -> Result { + let parts: Vec<_> = path.iter().collect(); + match parts.as_slice() { + [basename] => Ok(BlessedPathKind::Lockstep { basename }), + [api_dir, basename] if basename.ends_with(".json.gitref") => { + Ok(BlessedPathKind::GitRefFile { api_dir, basename }) + } + [api_dir, basename] => { + Ok(BlessedPathKind::VersionedFile { api_dir, basename }) + } + _ => Err(UnrecognizedPath), + } + } +} + +/// Key for looking up git refs by API and version. +#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq)] +struct GitRefKey { + ident: ApiIdent, + version: semver::Version, +} + +/// Container for OpenAPI documents from the "blessed" source (usually Git). /// /// **Be sure to check for load errors and warnings before using this /// structure.** @@ -70,12 +164,34 @@ impl AsRawFiles for BlessedApiSpecFile { /// For more on what's been validated at this point, see /// [`ApiSpecFilesBuilder`]. #[derive(Debug)] -pub struct BlessedFiles(BTreeMap>); +pub struct BlessedFiles { + /// The loaded blessed files. + files: BTreeMap>, + /// Git refs for each blessed file, keyed by (ident, version). + git_refs: BTreeMap, +} -NewtypeDeref! { - () pub struct BlessedFiles( - BTreeMap> - ); +impl Deref for BlessedFiles { + type Target = BTreeMap>; + + fn deref(&self) -> &Self::Target { + &self.files + } +} + +impl BlessedFiles { + /// Returns the git ref for the given API and version, if available. + /// + /// This is used to create git ref files that point back to the original + /// blessed content in git. + pub fn git_ref( + &self, + ident: &ApiIdent, + version: &semver::Version, + ) -> Option<&BlessedGitRef> { + self.git_refs + .get(&GitRefKey { ident: ident.clone(), version: version.clone() }) + } } impl BlessedFiles { @@ -121,53 +237,138 @@ impl BlessedFiles { ) -> anyhow::Result { let mut api_files: ApiSpecFilesBuilder = ApiSpecFilesBuilder::new(apis, error_accumulator); + let mut git_refs: BTreeMap = BTreeMap::new(); + let files_found = git_ls_tree(repo_root, commit, directory)?; for f in files_found { - // We should be looking at either a single-component path - // ("api.json") or a file inside one level of directory hierarchy - // ("api/api-1.2.3-hash.json"). Figure out which case we're in. - let parts: Vec<_> = f.iter().collect(); - if parts.is_empty() || parts.len() > 2 { - api_files.load_warning(anyhow!( - "path {:?}: can't understand this path name", - f - )); - continue; - } + let kind = match BlessedPathKind::parse(&f) { + Ok(kind) => kind, + Err(UnrecognizedPath) => { + api_files.load_warning(anyhow!( + "path {:?}: can't understand this path name", + f + )); + continue; + } + }; // Read the contents. Use "/" rather than "\" on Windows. - let file_name = format!("{directory}/{f}"); - let contents = - git_show_file(repo_root, commit, file_name.as_ref())?; - if parts.len() == 1 { - if let Some(file_name) = api_files.lockstep_file_name(parts[0]) - { - api_files.load_contents(file_name, contents); + let git_path = format!("{directory}/{f}"); + let contents = git_show_file(repo_root, commit, git_path.as_ref())?; + + match kind { + BlessedPathKind::Lockstep { basename } => { + if let Some(spec_file_name) = + api_files.lockstep_file_name(basename) + { + api_files.load_contents(spec_file_name, contents); + // Lockstep files don't need git refs since they're + // always regenerated. + } } - } else if parts.len() == 2 { - if let Some(ident) = api_files.versioned_directory(parts[0]) { - if ident.versioned_api_is_latest_symlink(parts[1]) { - // This is the "latest" symlink. We could dereference - // it and report it here, but it's not relevant for - // anything this tool does, so we don't bother. + + BlessedPathKind::VersionedFile { api_dir, basename } => { + let Some(ident) = api_files.versioned_directory(api_dir) + else { + continue; + }; + + // This is the "latest" symlink. We could dereference it and + // report it here, but it's not relevant for anything this + // tool does, so we don't bother. + if ident.versioned_api_is_latest_symlink(basename) { continue; } - if let Some(file_name) = - api_files.versioned_file_name(&ident, parts[1]) - { - api_files.load_contents(file_name, contents); + let Some(spec_file_name) = + api_files.versioned_file_name(&ident, basename) + else { + continue; + }; + + // Track the git ref for this versioned file. Use Lazy so + // the first commit is only computed when needed (i.e., when + // git ref storage is enabled). + if let Some(version) = spec_file_name.version() { + git_refs.insert( + GitRefKey { + ident: ident.clone(), + version: version.clone(), + }, + BlessedGitRef::Lazy { + revision: commit.clone(), + path: Utf8PathBuf::from(&git_path), + }, + ); + } + + api_files.load_contents(spec_file_name, contents); + } + + BlessedPathKind::GitRefFile { api_dir, basename } => { + let Some(ident) = api_files.versioned_directory(api_dir) + else { + continue; + }; + let Some(spec_file_name) = + api_files.versioned_git_ref_file_name(&ident, basename) + else { + continue; + }; + + // Parse the git ref content to get the referenced commit + // and path. + let git_ref_str = + String::from_utf8_lossy(&contents).to_string(); + let git_ref: GitRef = match git_ref_str.parse() { + Ok(g) => g, + Err(err) => { + api_files.load_error(anyhow!(err).context( + format!("parsing git ref file {:?}", git_path), + )); + continue; + } + }; + + // Load the actual JSON content from the git ref. + let json_contents = match git_ref.read_contents(repo_root) { + Ok(c) => c, + Err(err) => { + api_files.load_error(err.context(format!( + "reading content for git ref {:?}", + git_path + ))); + continue; + } + }; + + // Track the git ref for this versioned file. The git ref + // already contains the first commit, so we use it directly. + if let Some(version) = spec_file_name.version() { + git_refs.insert( + GitRefKey { + ident: ident.clone(), + version: version.clone(), + }, + BlessedGitRef::Known { + commit: git_ref.commit, + path: git_ref.path.clone(), + }, + ); } + + api_files.load_contents(spec_file_name, json_contents); } } } - Ok(BlessedFiles::from(api_files)) + Ok(BlessedFiles { files: api_files.into_map(), git_refs }) } } impl<'a> From> for BlessedFiles { fn from(api_files: ApiSpecFilesBuilder<'a, BlessedApiSpecFile>) -> Self { - BlessedFiles(api_files.into_map()) + // When loading from a directory, we don't have git refs. + BlessedFiles { files: api_files.into_map(), git_refs: BTreeMap::new() } } } diff --git a/crates/dropshot-api-manager/src/spec_files_generic.rs b/crates/dropshot-api-manager/src/spec_files_generic.rs index de8e026..d6a518e 100644 --- a/crates/dropshot-api-manager/src/spec_files_generic.rs +++ b/crates/dropshot-api-manager/src/spec_files_generic.rs @@ -99,6 +99,42 @@ fn parse_versioned_file_name( )) } +/// Attempts to parse the given file basename as an ApiSpecFileName of kind +/// `VersionedGitRef`. +/// +/// These look like: `ident-SEMVER-HASH.json.gitref`. +fn parse_versioned_git_ref_file_name( + apis: &ManagedApis, + ident: &str, + basename: &str, +) -> Result { + // The file name must end with .json.gitref. + let json_basename = basename.strip_suffix(".gitref").ok_or_else(|| { + BadVersionedFileName::UnexpectedName { + ident: ApiIdent::from(ident.to_string()), + source: anyhow!("expected .json.gitref suffix"), + } + })?; + + // Parse the underlying versioned name to get the version and hash. + let versioned = parse_versioned_file_name(apis, ident, json_basename)?; + + match versioned.kind() { + ApiSpecFileNameKind::Versioned { version, hash } => { + Ok(ApiSpecFileName::new( + versioned.ident().clone(), + ApiSpecFileNameKind::VersionedGitRef { + version: version.clone(), + hash: hash.clone(), + }, + )) + } + other => unreachable!( + "parse_versioned_file_name always returns Versioned, found {other:?}" + ), + } +} + /// Attempts to parse the given file basename as an ApiSpecFileName of kind /// `Lockstep` fn parse_lockstep_file_name( @@ -195,28 +231,42 @@ impl ApiSpecFile { ) })?; - if let ApiSpecFileNameKind::Versioned { version, hash } = - spec_file_name.kind() - { - if *version != parsed_version { - bail!( - "file {:?}: version in the file ({}) differs from \ - the one in the filename", - spec_file_name.path(), - parsed_version - ); - } + match spec_file_name.kind() { + ApiSpecFileNameKind::Versioned { version, hash } => { + if *version != parsed_version { + bail!( + "file {:?}: version in the file ({}) differs from \ + the one in the filename", + spec_file_name.path(), + parsed_version + ); + } - let expected_hash = hash_contents(&contents_buf); - if expected_hash != *hash { - bail!( - "file {:?}: computed hash {:?}, but file name has \ - different hash {:?}", - spec_file_name.path(), - expected_hash, - hash - ); + let expected_hash = hash_contents(&contents_buf); + if expected_hash != *hash { + bail!( + "file {:?}: computed hash {:?}, but file name has \ + different hash {:?}", + spec_file_name.path(), + expected_hash, + hash + ); + } + } + ApiSpecFileNameKind::VersionedGitRef { version, .. } => { + // Git ref files: validate that the version matches, but skip + // hash check. The content came from git, so the git ref itself + // is the source of truth. + if *version != parsed_version { + bail!( + "file {:?}: version in the file ({}) differs from \ + the one in the filename", + spec_file_name.path(), + parsed_version + ); + } } + ApiSpecFileNameKind::Lockstep => {} } Ok(ApiSpecFile { @@ -477,6 +527,45 @@ impl<'a, T: ApiLoad + AsRawFiles> ApiSpecFilesBuilder<'a, T> { } } + /// Returns an `ApiSpecFileName` for the given versioned git ref file. + /// + /// On success, this does not load anything into `self`. Callers generally + /// invoke `load_contents()` with the returned value after dereferencing the + /// git ref. On failure, warnings or errors will be recorded. + pub fn versioned_git_ref_file_name( + &mut self, + ident: &ApiIdent, + basename: &str, + ) -> Option { + match parse_versioned_git_ref_file_name(self.apis, ident, basename) { + Ok(file_name) => Some(file_name), + Err( + warning @ (BadVersionedFileName::NoSuchApi + | BadVersionedFileName::NotVersioned), + ) if T::MISCONFIGURATIONS_ALLOWED => { + self.load_warning( + anyhow!(warning) + .context(format!("skipping git ref file {}", basename)), + ); + None + } + Err(warning @ BadVersionedFileName::UnexpectedName { .. }) => { + self.load_warning( + anyhow!(warning) + .context(format!("skipping git ref file {}", basename)), + ); + None + } + Err(error) => { + self.load_error( + anyhow!(error) + .context(format!("git ref file {}", basename)), + ); + None + } + } + } + /// Like `versioned_file_name()`, but the error message for a bogus path /// better communicates that the problem is with the symlink pub fn symlink_contents( @@ -822,6 +911,68 @@ mod test { assert_matches!(error, BadVersionedFileName::UnexpectedName { .. }); } + #[test] + fn test_parse_name_versioned_git_ref_valid() { + let apis = all_apis().unwrap(); + let name = parse_versioned_git_ref_file_name( + &apis, + "versioned", + "versioned-1.2.3-feedface.json.gitref", + ) + .unwrap(); + assert_eq!( + name, + ApiSpecFileName::new( + ApiIdent::from("versioned".to_owned()), + ApiSpecFileNameKind::VersionedGitRef { + version: Version::new(1, 2, 3), + hash: "feedface".to_owned(), + }, + ) + ); + } + + #[test] + fn test_parse_name_versioned_git_ref_invalid() { + let apis = all_apis().unwrap(); + + // Wrong suffix - missing .gitref. + let error = parse_versioned_git_ref_file_name( + &apis, + "versioned", + "versioned-1.2.3-feedface.json", + ) + .unwrap_err(); + assert_matches!(error, BadVersionedFileName::UnexpectedName { .. }); + + // Unknown API. + let error = parse_versioned_git_ref_file_name( + &apis, + "unknown", + "unknown-1.2.3-feedface.json.gitref", + ) + .unwrap_err(); + assert_matches!(error, BadVersionedFileName::NoSuchApi); + + // Lockstep API (not versioned). + let error = parse_versioned_git_ref_file_name( + &apis, + "lockstep", + "lockstep-1.2.3-feedface.json.gitref", + ) + .unwrap_err(); + assert_matches!(error, BadVersionedFileName::NotVersioned); + + // Bad version in the name. + let error = parse_versioned_git_ref_file_name( + &apis, + "versioned", + "versioned-badversion-feedface.json.gitref", + ) + .unwrap_err(); + assert_matches!(error, BadVersionedFileName::UnexpectedName { .. }); + } + fn all_apis() -> anyhow::Result { let apis = vec![ ManagedApiConfig { diff --git a/crates/dropshot-api-manager/src/spec_files_local.rs b/crates/dropshot-api-manager/src/spec_files_local.rs index caf96fc..4e3a473 100644 --- a/crates/dropshot-api-manager/src/spec_files_local.rs +++ b/crates/dropshot-api-manager/src/spec_files_local.rs @@ -6,6 +6,7 @@ use crate::{ apis::ManagedApis, environment::ErrorAccumulator, + git::GitRef, spec_files_generic::{ ApiFiles, ApiLoad, ApiSpecFile, ApiSpecFilesBuilder, AsRawFiles, }, @@ -59,7 +60,7 @@ impl AsRawFiles for Vec { /// /// For more on what's been validated at this point, see /// [`ApiSpecFilesBuilder`]. -#[derive(Debug)] +#[derive(Debug, Default)] pub struct LocalFiles(BTreeMap>>); NewtypeDeref! { @@ -69,17 +70,23 @@ NewtypeDeref! { } impl LocalFiles { - /// Load OpenAPI documents from a given directory tree + /// Load OpenAPI documents from a given directory tree. /// /// If it's at all possible to load any documents, this will return an `Ok` /// value, but you should still check the `errors` field on the returned /// [`LocalFiles`]. + /// + /// The `repo_root` parameter is needed to resolve `.gitref` files, which + /// store a reference to an OpenAPI document rather than the document + /// itself. pub fn load_from_directory( dir: &Utf8Path, apis: &ManagedApis, error_accumulator: &mut ErrorAccumulator, + repo_root: &Utf8Path, ) -> anyhow::Result { - let api_files = walk_local_directory(dir, apis, error_accumulator)?; + let api_files = + walk_local_directory(dir, apis, error_accumulator, repo_root)?; Ok(Self::from(api_files)) } } @@ -90,33 +97,38 @@ impl From>> for LocalFiles { } } -/// Load OpenAPI documents for the local directory tree +/// Load OpenAPI documents for the local directory tree. /// /// Under `dir`, we expect to find either: /// -/// * for each lockstep API, a file called `api-ident.json` (e.g., `wicketd.json`) +/// * for each lockstep API, a file called `api-ident.json` (e.g., +/// `wicketd.json`) /// * for each versioned API, a directory called `api-ident` that contains: /// * any number of files called `api-ident-SEMVER-HASH.json` /// (e.g., dns-server-1.0.0-eb52aeeb.json) +/// * any number of git ref files called `api-ident-SEMVER-HASH.json.gitref` +/// that contain a `commit:path` reference to the actual content /// * one symlink called `api-ident-latest.json` that points to a file in /// the same directory /// /// Here's an example: /// /// ```text -/// wicketd.json # file for lockstep API -/// dns-server/ # directory for versioned API -/// dns-server/dns-server-1.0.0-eb2aeeb.json # file for versioned API -/// dns-server/dns-server-2.0.0-298ea47.json # file for versioned API -/// dns-server/dns-server-latest.json # symlink +/// wicketd.json # file for lockstep API +/// dns-server/ # directory for versioned API +/// dns-server/dns-server-1.0.0-eb2aeeb.json # file for versioned API +/// dns-server/dns-server-2.0.0-fba287a.json.gitref # git ref for versioned API +/// dns-server/dns-server-3.0.0-298ea47.json # file for versioned API +/// dns-server/dns-server-latest.json # symlink /// ``` -// This function is always used for the "local" files. It can sometimes be +// This function is always used for the "local" files. It can sometimes be // used for both generated and blessed files, if the user asks to load those // from the local filesystem instead of their usual sources. pub fn walk_local_directory<'a, T: ApiLoad + AsRawFiles>( dir: &'_ Utf8Path, apis: &'a ManagedApis, error_accumulator: &'a mut ErrorAccumulator, + repo_root: &Utf8Path, ) -> anyhow::Result> { let mut api_files = ApiSpecFilesBuilder::new(apis, error_accumulator); let entry_iter = @@ -126,7 +138,7 @@ pub fn walk_local_directory<'a, T: ApiLoad + AsRawFiles>( maybe_entry.with_context(|| format!("readdir {:?} entry", dir))?; // If this entry is a file, then we'd expect it to be the JSON file - // for one of our lockstep APIs. Check and see. + // for one of our lockstep APIs. Check and see. let path = entry.path(); let file_name = entry.file_name(); let file_type = entry @@ -146,7 +158,12 @@ pub fn walk_local_directory<'a, T: ApiLoad + AsRawFiles>( } }; } else if file_type.is_dir() { - load_versioned_directory(&mut api_files, path, file_name); + load_versioned_directory( + &mut api_files, + path, + file_name, + repo_root, + ); } else { // This is not something the tool cares about, but it's not // obviously a problem, either. @@ -167,6 +184,7 @@ fn load_versioned_directory( api_files: &mut ApiSpecFilesBuilder<'_, T>, path: &Utf8Path, basename: &str, + repo_root: &Utf8Path, ) { let Some(ident) = api_files.versioned_directory(basename) else { return; @@ -211,6 +229,53 @@ fn load_versioned_directory( continue; } + // Handle .gitref files: these contain a `commit:path` reference to the + // actual content in git. + if file_name.ends_with(".json.gitref") { + let Some(spec_file_name) = + api_files.versioned_git_ref_file_name(&ident, file_name) + else { + continue; + }; + + let git_ref_contents = match fs_err::read_to_string(entry.path()) { + Ok(content) => content, + Err(error) => { + api_files.load_error(anyhow!(error).context(format!( + "failed to read git ref file {:?}", + entry.path() + ))); + continue; + } + }; + + let git_ref = match git_ref_contents.parse::() { + Ok(git_ref) => git_ref, + Err(error) => { + api_files.load_error(anyhow!(error).context(format!( + "failed to parse git ref file {:?}", + entry.path() + ))); + continue; + } + }; + + let contents = match git_ref.read_contents(repo_root) { + Ok(contents) => contents, + Err(error) => { + api_files.load_error(error.context(format!( + "failed to read content for git ref {:?}", + entry.path() + ))); + continue; + } + }; + + api_files.load_contents(spec_file_name, contents); + continue; + } + + // Handle regular .json files. let Some(file_name) = api_files.versioned_file_name(&ident, file_name) else { continue; diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index 25b249c..cd543ef 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -9,6 +9,7 @@ publish = false [dependencies] anyhow.workspace = true +atomicwrites.workspace = true camino.workspace = true camino-tempfile.workspace = true camino-tempfile-ext.workspace = true diff --git a/crates/integration-tests/src/bin/fake-git.rs b/crates/integration-tests/src/bin/fake-git.rs new file mode 100644 index 0000000..a54f8ec --- /dev/null +++ b/crates/integration-tests/src/bin/fake-git.rs @@ -0,0 +1,36 @@ +// Copyright 2025 Oxide Computer Company + +//! A fake git binary for testing error injection. +//! +//! This binary passes most commands through to the real git, but fails on +//! specific commands to test error handling. + +use std::{ + env, + process::{Command, exit}, +}; + +fn main() { + let args: Vec = env::args().skip(1).collect(); + + // Fail on `git log --diff-filter=A` to test GitRefFirstCommitUnknown. + if args.iter().any(|arg| arg == "--diff-filter=A") { + eprintln!("fatal: simulated git failure for testing"); + exit(128); + } + + // Otherwise, pass through to the real git. + let git = env::var("REAL_GIT").unwrap_or_else(|_| "git".to_string()); + let status = Command::new(git) + .args(&args) + .status() + .expect("failed to execute real git"); + + match status.code() { + Some(code) => exit(code), + None => { + // No exit code was available (maybe a signal?) + exit(101); + } + } +} diff --git a/crates/integration-tests/src/environment.rs b/crates/integration-tests/src/environment.rs index b5cd76d..4a57b94 100644 --- a/crates/integration-tests/src/environment.rs +++ b/crates/integration-tests/src/environment.rs @@ -1,15 +1,17 @@ -// Copyright 2025 Oxide Computer Company +// Copyright 2026 Oxide Computer Company //! Test environment infrastructure for integration tests. use anyhow::{Context, Result, anyhow}; +use atomicwrites::AtomicFile; use camino::{Utf8Path, Utf8PathBuf}; use camino_tempfile::Utf8TempDir; use camino_tempfile_ext::{fixture::ChildPath, prelude::*}; use clap::Parser; -use dropshot_api_manager::{Environment, ManagedApis}; +use dropshot_api_manager::{Environment, GitRef, ManagedApis}; use std::{ fs, + io::Write, process::{Command, ExitCode}, }; @@ -172,7 +174,8 @@ impl TestEnvironment { } /// Find the path of a versioned API document for a specific version, - /// relative to the workspace root. + /// relative to the workspace root. Only matches full JSON files, not git + /// ref files. pub fn find_versioned_document_path( &self, api_ident: &str, @@ -187,7 +190,9 @@ impl TestEnvironment { let path = files.iter().find_map(|f| { let rel_path = rel_path_forward_slashes(f.as_ref()); - rel_path.starts_with(&pattern).then(|| Utf8PathBuf::from(rel_path)) + // Only match .json files, not .json.gitref files. + (rel_path.starts_with(&pattern) && rel_path.ends_with(".json")) + .then(|| Utf8PathBuf::from(rel_path)) }); Ok(path) } @@ -260,6 +265,86 @@ impl TestEnvironment { self.read_file(&file_name) } + /// Check if a git ref file exists for a versioned API at a specific + /// version. + pub fn versioned_git_ref_exists( + &self, + api_ident: &str, + version: &str, + ) -> Result { + let path = self.find_versioned_git_ref_path(api_ident, version)?; + Ok(path.is_some()) + } + + /// Find the path of a git ref file for a versioned API at a specific + /// version, relative to the workspace root. + pub fn find_versioned_git_ref_path( + &self, + api_ident: &str, + version: &str, + ) -> Result> { + let files = self.list_document_files()?; + + // Git ref files are stored like: + // documents/api/api-version-hash.json.gitref. + let pattern = + format!("documents/{}/{}-{}-", api_ident, api_ident, version); + + let path = files.iter().find_map(|f| { + let rel_path = rel_path_forward_slashes(f.as_ref()); + (rel_path.starts_with(&pattern) + && rel_path.ends_with(".json.gitref")) + .then(|| Utf8PathBuf::from(rel_path)) + }); + Ok(path) + } + + /// Read the content of a git ref file for a versioned API. + pub fn read_versioned_git_ref( + &self, + api_ident: &str, + version: &str, + ) -> Result { + let path = self + .find_versioned_git_ref_path(api_ident, version)? + .with_context(|| { + format!( + "did not find git ref file for {} v{}", + api_ident, version + ) + })?; + self.read_file(&path) + } + + /// Check if a git ref file exists for a lockstep API. + /// (This should never happen - lockstep APIs don't use git refs.) + pub fn lockstep_git_ref_exists(&self, api_ident: &str) -> bool { + self.file_exists(format!("documents/{}.json.gitref", api_ident)) + } + + /// Read the actual content referenced by a git ref file. + /// + /// This reads the git ref file, parses it to get the commit and path, then + /// uses git to retrieve the referenced content. + pub fn read_git_ref_content( + &self, + api_ident: &str, + version: &str, + ) -> Result { + let git_ref_content = + self.read_versioned_git_ref(api_ident, version)?; + let git_ref: GitRef = git_ref_content.parse().with_context(|| { + format!("failed to parse git ref for {} v{}", api_ident, version) + })?; + let content = git_ref.read_contents(&self.workspace_root)?; + String::from_utf8(content).with_context(|| { + format!( + "git ref content for {} v{} is not valid UTF-8", + api_ident, version + ) + }) + } + /// Add files to git staging area. pub fn git_add(&self, paths: &[&Utf8Path]) -> Result<()> { let mut args = vec!["add"]; @@ -320,15 +405,112 @@ impl TestEnvironment { Ok(output.trim().to_string()) } - /// Helper to run git commands in the workspace root. - fn run_git_command( - workspace_root: &Utf8Path, - args: &[&str], - ) -> Result { + /// Get the current git commit hash (full form). + pub fn get_current_commit_hash_full(&self) -> Result { + let output = Self::run_git_command( + &self.workspace_root, + &["rev-parse", "HEAD"], + )?; + Ok(output.trim().to_string()) + } + + /// Check if any file matching the given prefix pattern is committed in the + /// documents directory. + pub fn is_file_committed(&self, prefix: &str) -> Result { + let rel_docs_dir = self + .documents_dir + .strip_prefix(&self.workspace_root) + .context("documents_dir should be under workspace_root")?; + let pattern = + rel_path_forward_slashes(&format!("{}/{}", rel_docs_dir, prefix)); + let output = Self::run_git_command( + &self.workspace_root, + &["ls-tree", "-r", "--name-only", "HEAD"], + )?; + Ok(output.lines().any(|line| line.starts_with(&pattern))) + } + + /// Make an unrelated commit (useful for advancing HEAD without changing + /// API documents). + pub fn make_unrelated_commit(&self, message: &str) -> Result<()> { + // Create or update a dummy file. + let dummy_path = self.workspace_root.join("dummy.txt"); + let content = format!("{}\n{}\n", message, chrono::Utc::now()); + AtomicFile::new( + &dummy_path, + atomicwrites::OverwriteBehavior::AllowOverwrite, + ) + .write(|f| f.write_all(content.as_bytes()))?; + Self::run_git_command(&self.workspace_root, &["add", "dummy.txt"])?; + Self::run_git_command( + &self.workspace_root, + &["commit", "-m", message], + )?; + Ok(()) + } + + /// Create a shallow clone of this repository in a new directory. + /// + /// This creates an actual shallow clone using `git clone --depth `, + /// which means objects from commits before the shallow boundary won't exist + /// in the clone. This is different from just writing `.git/shallow` (which + /// only affects `git log` but leaves objects accessible). + /// + /// Returns a new `TestEnvironment` pointing to the shallow clone. + pub fn shallow_clone(&self, depth: u32) -> Result { + let temp_dir = + Utf8TempDir::with_prefix("dropshot-api-manager-shallow-") + .context("failed to create temp dir for shallow clone")?; + + let clone_root = temp_dir.path().join("workspace"); + let depth_str = depth.to_string(); + + // --no-local forces git to copy objects rather than using hardlinks, + // which is necessary for a true shallow clone. + Self::run_git_command( + temp_dir.path(), + &[ + "clone", + "--no-local", + "--depth", + &depth_str, + self.workspace_root.as_str(), + clone_root.as_str(), + ], + )?; + + Self::run_git_command( + &clone_root, + &["config", "user.name", "Test User"], + )?; + Self::run_git_command( + &clone_root, + &["config", "user.email", "test@example.com"], + )?; + + let workspace_root = temp_dir.child("workspace"); + + let environment = Environment::new( + "test-openapi-manager", + workspace_root.as_path(), + "documents", + )? + .with_default_git_branch("main"); + + Ok(TestEnvironment { + temp_dir, + workspace_root: workspace_root.clone(), + documents_dir: workspace_root.child("documents"), + environment, + }) + } + + /// Helper to run git commands in a directory. + fn run_git_command(cwd: &Utf8Path, args: &[&str]) -> Result { let git = std::env::var("GIT").ok().unwrap_or_else(|| String::from("git")); let output = Command::new(git) - .current_dir(workspace_root) + .current_dir(cwd) .args(args) .output() .context("failed to execute git command")?; diff --git a/crates/integration-tests/src/fixtures.rs b/crates/integration-tests/src/fixtures.rs index 46b73b0..c9e09f5 100644 --- a/crates/integration-tests/src/fixtures.rs +++ b/crates/integration-tests/src/fixtures.rs @@ -172,7 +172,7 @@ pub mod versioned_health { api_versions!([(3, WITH_METRICS), (2, WITH_DETAILED_STATUS), (1, INITIAL)]); - #[dropshot::api_description] + #[dropshot::api_description { module = "api_mod" }] pub trait VersionedHealthApi { type Context; @@ -255,7 +255,7 @@ pub mod versioned_user { (1, INITIAL), ]); - #[dropshot::api_description] + #[dropshot::api_description { module = "api_mod" }] pub trait VersionedUserApi { type Context; @@ -497,6 +497,36 @@ pub mod versioned_health_reduced { }; } +/// Versioned health API fixture without v1 (only v2 and v3). +/// +/// Used to simulate removal of the first version. +pub mod versioned_health_no_v1 { + use dropshot_api_manager_types::api_versions; + + api_versions!([(3, WITH_METRICS), (2, WITH_DETAILED_STATUS)]); + + // Reuse the same API and response types from the main versioned_health module. + pub use super::versioned_health::{ + DependencyStatus, DetailedHealthStatus, HealthStatusV1, ServiceMetrics, + VersionedHealthApi, api_mod, + }; +} + +/// Versioned health API fixture with only v1. +/// +/// Used to test git ref conversion when multiple versions share the same +/// first commit as the new latest. +pub mod versioned_health_v1_only { + use dropshot_api_manager_types::api_versions; + + api_versions!([(1, INITIAL)]); + + // Reuse the same API and response types from the main versioned_health module. + pub use super::versioned_health::{ + HealthStatusV1, VersionedHealthApi, api_mod, + }; +} + /// Versioned health API fixture that skips the middle version (2.0.0). /// This has versions 3.0.0 and 1.0.0 only, simulating retirement of an older /// blessed version. @@ -696,8 +726,7 @@ pub fn versioned_health_api() -> ManagedApiConfig { ), ..Default::default() }, - api_description: - versioned_health::versioned_health_api_mod::stub_api_description, + api_description: versioned_health::api_mod::stub_api_description, } } @@ -714,8 +743,7 @@ pub fn versioned_user_api() -> ManagedApiConfig { ), ..Default::default() }, - api_description: - versioned_user::versioned_user_api_mod::stub_api_description, + api_description: versioned_user::api_mod::stub_api_description, } } @@ -846,8 +874,7 @@ pub fn versioned_health_trivial_change_allowed_apis() -> Result { description: Some("A versioned health API with trivial changes"), ..Default::default() }, - api_description: - versioned_health::versioned_health_api_mod::stub_api_description, + api_description: versioned_health::api_mod::stub_api_description, }; ManagedApis::new(vec![ @@ -938,6 +965,54 @@ pub fn versioned_health_skip_middle_apis() -> Result { .context("failed to create skip middle versioned health ManagedApis") } +/// Create versioned health API without v1 (only v2 and v3). +/// +/// Used to simulate removal of the first version. +pub fn versioned_health_no_v1_apis() -> Result { + let config = ManagedApiConfig { + ident: "versioned-health", + versions: Versions::Versioned { + supported_versions: versioned_health_no_v1::supported_versions(), + }, + title: "Versioned Health API", + metadata: ManagedApiMetadata { + description: Some( + "A versioned health API for testing version evolution", + ), + ..Default::default() + }, + api_description: versioned_health_no_v1::api_mod::stub_api_description, + }; + + ManagedApis::new(vec![config]) + .context("failed to create no-v1 versioned health ManagedApis") +} + +/// Create versioned health API with only v1. +/// +/// Used to test git ref conversion when multiple versions share the same +/// first commit as the new latest. +pub fn versioned_health_v1_only_apis() -> Result { + let config = ManagedApiConfig { + ident: "versioned-health", + versions: Versions::Versioned { + supported_versions: versioned_health_v1_only::supported_versions(), + }, + title: "Versioned Health API", + metadata: ManagedApiMetadata { + description: Some( + "A versioned health API for testing version evolution", + ), + ..Default::default() + }, + api_description: + versioned_health_v1_only::api_mod::stub_api_description, + }; + + ManagedApis::new(vec![config]) + .context("failed to create v1-only versioned health ManagedApis") +} + /// Create a versioned health API with incompatible changes that break backward /// compatibility. pub fn versioned_health_incompat_apis() -> Result { @@ -1039,8 +1114,7 @@ pub fn versioned_health_with_validation_api() -> ManagedApi { ), ..Default::default() }, - api_description: - versioned_health::versioned_health_api_mod::stub_api_description, + api_description: versioned_health::api_mod::stub_api_description, }) .with_extra_validation(validate) } @@ -1058,8 +1132,7 @@ pub fn versioned_health_with_extra_file_api() -> ManagedApi { ), ..Default::default() }, - api_description: - versioned_health::versioned_health_api_mod::stub_api_description, + api_description: versioned_health::api_mod::stub_api_description, }) .with_extra_validation(validate_with_extra_file) } @@ -1075,3 +1148,106 @@ pub fn versioned_health_with_extra_file_apis() -> Result { "failed to create versioned health with conditional files ManagedApis", ) } + +/// Create a versioned health API with git ref storage enabled. +pub fn versioned_health_git_ref_api() -> ManagedApi { + ManagedApi::from(versioned_health_api()).with_git_ref_storage() +} + +/// Create versioned health APIs with git ref storage enabled. +pub fn versioned_health_git_ref_apis() -> Result { + ManagedApis::new(vec![versioned_health_git_ref_api()]) + .context("failed to create versioned health git ref ManagedApis") +} + +/// Create a versioned health API with v4 and git ref storage enabled. +pub fn versioned_health_with_v4_git_ref_api() -> ManagedApi { + ManagedApi::from(ManagedApiConfig { + ident: "versioned-health", + versions: Versions::Versioned { + supported_versions: versioned_health_with_v4::supported_versions(), + }, + title: "Versioned Health API", + metadata: ManagedApiMetadata { + description: Some( + "A versioned health API for testing version evolution", + ), + ..Default::default() + }, + api_description: + versioned_health_with_v4::api_mod::stub_api_description, + }) + .with_git_ref_storage() +} + +/// Create versioned health APIs with v4 and git ref storage enabled. +pub fn versioned_health_with_v4_git_ref_apis() -> Result { + ManagedApis::new(vec![versioned_health_with_v4_git_ref_api()]).context( + "failed to create versioned health with v4 git ref ManagedApis", + ) +} + +/// Create a versioned health API with v4 but without git ref storage. +/// +/// This is used to test conversion from git ref files back to JSON files when +/// git ref storage is disabled. +pub fn versioned_health_with_v4_api() -> ManagedApi { + ManagedApi::from(ManagedApiConfig { + ident: "versioned-health", + versions: Versions::Versioned { + supported_versions: versioned_health_with_v4::supported_versions(), + }, + title: "Versioned Health API", + metadata: ManagedApiMetadata { + description: Some( + "A versioned health API for testing version evolution", + ), + ..Default::default() + }, + api_description: + versioned_health_with_v4::api_mod::stub_api_description, + }) +} + +/// Create versioned health APIs with v4 but without git ref storage. +/// +/// This is used to test conversion from git ref files back to JSON files when +/// git ref storage is disabled. +pub fn versioned_health_with_v4_apis() -> Result { + ManagedApis::new(vec![versioned_health_with_v4_api()]) + .context("failed to create versioned health with v4 ManagedApis") +} + +/// Create lockstep APIs (for testing that lockstep never uses git ref storage). +pub fn lockstep_apis() -> Result { + ManagedApis::new(vec![lockstep_health_api()]) + .context("failed to create lockstep ManagedApis") +} + +/// Create a versioned health API with reduced versions (v1, v2 only) and git +/// ref storage enabled. +pub fn versioned_health_reduced_git_ref_apis() -> Result { + let config = ManagedApiConfig { + ident: "versioned-health", + versions: Versions::Versioned { + // Use a subset of versions (only 1.0.0 and 2.0.0, not 3.0.0). + supported_versions: versioned_health_reduced::supported_versions(), + }, + title: "Versioned Health API", + metadata: ManagedApiMetadata { + // Use the same description as the original to ensure bytewise + // equality for unchanged versions. + description: Some( + "A versioned health API for testing version evolution", + ), + ..Default::default() + }, + api_description: + versioned_health_reduced::api_mod::stub_api_description, + }; + + ManagedApis::new(vec![ManagedApi::from(config).with_git_ref_storage()]) + .context( + "failed to create reduced versioned health git ref ManagedApis", + ) +} diff --git a/crates/integration-tests/tests/integration/git_ref.rs b/crates/integration-tests/tests/integration/git_ref.rs new file mode 100644 index 0000000..9325a30 --- /dev/null +++ b/crates/integration-tests/tests/integration/git_ref.rs @@ -0,0 +1,917 @@ +// Copyright 2026 Oxide Computer Company + +//! Tests for git ref storage of blessed API versions. +//! +//! When git ref storage is enabled, older (non-latest) blessed API versions are +//! stored as `.gitref` files containing a git reference (`commit:path`) instead +//! of full JSON files. The content is retrieved via `git cat-file blob` at +//! runtime. + +use anyhow::Result; +use dropshot_api_manager::{ + GitRef, + test_util::{CheckResult, check_apis_up_to_date}, +}; +use integration_tests::*; + +/// Test that git ref conversion happens when adding a new version, and that +/// the content is preserved correctly. +/// +/// When a new version is added to an API with git ref storage enabled, the +/// older blessed versions should be converted from full JSON files to git ref +/// files. The git refs should point to the first commit where each version was +/// introduced. +#[test] +fn test_conversion() -> Result<()> { + let env = TestEnvironment::new()?; + let apis = versioned_health_git_ref_apis()?; + + env.generate_documents(&apis)?; + env.commit_documents()?; + let first_commit = env.get_current_commit_hash_full()?; + let original_v1 = + env.read_versioned_document("versioned-health", "1.0.0")?; + + assert!( + env.versioned_local_document_exists("versioned-health", "1.0.0")?, + "v1 should exist as JSON" + ); + assert!( + env.versioned_local_document_exists("versioned-health", "2.0.0")?, + "v2 should exist as JSON" + ); + assert!( + env.versioned_local_document_exists("versioned-health", "3.0.0")?, + "v3 should exist as JSON" + ); + assert!( + !env.versioned_git_ref_exists("versioned-health", "1.0.0")?, + "v1 should not yet be a git ref" + ); + assert!( + !env.versioned_git_ref_exists("versioned-health", "2.0.0")?, + "v2 should not yet be a git ref" + ); + assert!( + !env.versioned_git_ref_exists("versioned-health", "3.0.0")?, + "v3 should not yet be a git ref" + ); + + env.make_unrelated_commit("unrelated change 1")?; + env.make_unrelated_commit("unrelated change 2")?; + let current_commit = env.get_current_commit_hash_full()?; + assert_ne!( + first_commit, current_commit, + "current commit should have advanced past the first commit" + ); + + // v3 (the previous latest) should be converted to a git ref in the same + // operation that creates v4. + let extended_apis = versioned_health_with_v4_git_ref_apis()?; + env.generate_documents(&extended_apis)?; + + assert!( + !env.is_file_committed("versioned-health/versioned-health-4.0.0-")?, + "v4 should not be committed yet" + ); + assert!( + env.versioned_git_ref_exists("versioned-health", "1.0.0")?, + "v1 should now be a git ref" + ); + assert!( + env.versioned_git_ref_exists("versioned-health", "2.0.0")?, + "v2 should now be a git ref" + ); + assert!( + env.versioned_git_ref_exists("versioned-health", "3.0.0")?, + "v3 should now be a git ref" + ); + assert!( + !env.versioned_local_document_exists("versioned-health", "1.0.0")?, + "v1 JSON should be removed" + ); + assert!( + !env.versioned_local_document_exists("versioned-health", "2.0.0")?, + "v2 JSON should be removed" + ); + assert!( + !env.versioned_local_document_exists("versioned-health", "3.0.0")?, + "v3 JSON should be removed" + ); + assert!( + env.versioned_local_document_exists("versioned-health", "4.0.0")?, + "v4 should exist as JSON" + ); + assert!( + !env.versioned_git_ref_exists("versioned-health", "4.0.0")?, + "v4 should not be a git ref" + ); + + for version in ["1.0.0", "2.0.0", "3.0.0"] { + let git_ref_content = + env.read_versioned_git_ref("versioned-health", version)?; + let commit = git_ref_content.trim().split(':').next().unwrap(); + assert_eq!( + commit, first_commit, + "git ref for v{} should point to the first commit ({}) \ + (current commit: {})", + version, first_commit, current_commit + ); + } + + let git_ref_content = + env.read_versioned_git_ref("versioned-health", "1.0.0")?; + assert!( + git_ref_content.contains(':'), + "git ref should contain a colon separator" + ); + let git_ref = git_ref_content + .parse::() + .expect("git ref should parse correctly"); + assert!( + git_ref + .path + .as_str() + .starts_with("documents/versioned-health/versioned-health-1.0.0-"), + "path {} should start with `documents/versioned-health/versioned-health-1.0.0-`", + git_ref.path.as_str(), + ); + assert_eq!( + git_ref.path.extension(), + Some("json"), + "path {} should have extension `json`", + git_ref.path.as_str(), + ); + + let git_ref_v1_content = + env.read_git_ref_content("versioned-health", "1.0.0")?; + assert_eq!( + original_v1, git_ref_v1_content, + "git ref content should match original" + ); + + let result = check_apis_up_to_date(env.environment(), &extended_apis)?; + assert_eq!(result, CheckResult::Success); + + Ok(()) +} + +/// Test that the latest version and versions sharing its first commit are not +/// converted to git refs. +/// +/// When multiple versions share the same first commit as the latest, no +/// conversion should happen. We don't want check to fail immediately after +/// multiple versions were added in a single commit -- that is a poor user +/// experience. +#[test] +fn test_same_first_commit_no_conversion() -> Result<()> { + let env = TestEnvironment::new()?; + let apis = versioned_health_git_ref_apis()?; + + env.generate_documents(&apis)?; + env.commit_documents()?; + + let result = check_apis_up_to_date(env.environment(), &apis)?; + assert_eq!( + result, + CheckResult::Success, + "check should pass when all versions share the same first commit" + ); + + env.generate_documents(&apis)?; + + assert!( + env.versioned_local_document_exists("versioned-health", "3.0.0")?, + "v3 should still exist as JSON" + ); + assert!( + !env.versioned_git_ref_exists("versioned-health", "3.0.0")?, + "v3 should not be a git ref" + ); + assert!( + env.versioned_local_document_exists("versioned-health", "1.0.0")?, + "v1 should remain as JSON (same first commit as latest)" + ); + assert!( + env.versioned_local_document_exists("versioned-health", "2.0.0")?, + "v2 should remain as JSON (same first commit as latest)" + ); + assert!( + !env.versioned_git_ref_exists("versioned-health", "1.0.0")?, + "v1 should not be a git ref" + ); + assert!( + !env.versioned_git_ref_exists("versioned-health", "2.0.0")?, + "v2 should not be a git ref" + ); + + Ok(()) +} + +/// Test that lockstep APIs are never converted to git ref files. +#[test] +fn test_lockstep_never_converted_to_git_ref() -> Result<()> { + let env = TestEnvironment::new()?; + let apis = lockstep_apis()?; + + env.generate_documents(&apis)?; + env.commit_documents()?; + env.generate_documents(&apis)?; + + assert!( + env.lockstep_document_exists("health"), + "lockstep document should exist" + ); + assert!( + !env.lockstep_git_ref_exists("health"), + "lockstep should never be a git ref" + ); + + Ok(()) +} + +/// Test that only versions with different first commits are converted. +/// +/// When the latest version is blessed, only versions from earlier commits +/// should be converted. Versions sharing the same first commit as latest should +/// remain as JSON. +#[test] +fn test_mixed_first_commits_selective_conversion() -> Result<()> { + let env = TestEnvironment::new()?; + + let v1_v2_no_git_ref = versioned_health_reduced_apis()?; + env.generate_documents(&v1_v2_no_git_ref)?; + env.commit_documents()?; + let first_commit = env.get_current_commit_hash_full()?; + + let v1_v2_v3_no_git_ref = versioned_health_apis()?; + env.generate_documents(&v1_v2_v3_no_git_ref)?; + env.commit_documents()?; + let second_commit = env.get_current_commit_hash_full()?; + assert_ne!(first_commit, second_commit); + + assert!(env.versioned_local_document_exists("versioned-health", "1.0.0")?); + assert!(env.versioned_local_document_exists("versioned-health", "2.0.0")?); + assert!(env.versioned_local_document_exists("versioned-health", "3.0.0")?); + + // v3 is latest (from second_commit) while v1, v2 are from first_commit. + let v1_v2_v3_git_ref = versioned_health_git_ref_apis()?; + let result = check_apis_up_to_date(env.environment(), &v1_v2_v3_git_ref)?; + assert_eq!( + result, + CheckResult::NeedsUpdate, + "check should suggest converting v1, v2 (different first commit)" + ); + + env.generate_documents(&v1_v2_v3_git_ref)?; + + assert!(env.versioned_git_ref_exists("versioned-health", "1.0.0")?); + assert!(env.versioned_git_ref_exists("versioned-health", "2.0.0")?); + + let v1_ref = env.read_versioned_git_ref("versioned-health", "1.0.0")?; + let v1_commit = v1_ref.trim().split(':').next().unwrap(); + assert_eq!(v1_commit, first_commit); + + assert!(env.versioned_local_document_exists("versioned-health", "3.0.0")?); + assert!(!env.versioned_git_ref_exists("versioned-health", "3.0.0")?); + + let result = check_apis_up_to_date(env.environment(), &v1_v2_v3_git_ref)?; + assert_eq!(result, CheckResult::Success); + + Ok(()) +} + +/// Test that git refs work correctly after reloading from a different state. +/// +/// This also tests that versions introduced in different commits have git refs +/// pointing to their respective first commits. +#[test] +fn test_git_ref_check_after_conversion() -> Result<()> { + let env = TestEnvironment::new()?; + + let v1_v2_apis = versioned_health_reduced_git_ref_apis()?; + env.generate_documents(&v1_v2_apis)?; + env.commit_documents()?; + let v1_v2_commit = env.get_current_commit_hash_full()?; + + env.make_unrelated_commit("between v2 and v3")?; + + let v1_v2_v3_apis = versioned_health_git_ref_apis()?; + env.generate_documents(&v1_v2_v3_apis)?; + env.commit_documents()?; + let v3_commit = env.get_current_commit_hash_full()?; + assert_ne!(v1_v2_commit, v3_commit, "v1/v2 and v3 in different commits"); + + env.make_unrelated_commit("after v3")?; + + let extended_apis = versioned_health_with_v4_git_ref_apis()?; + env.generate_documents(&extended_apis)?; + env.commit_documents()?; + + let result = check_apis_up_to_date(env.environment(), &extended_apis)?; + assert_eq!(result, CheckResult::Success); + + assert!( + env.versioned_git_ref_exists("versioned-health", "1.0.0")?, + "v1 should be a git ref" + ); + assert!( + env.versioned_git_ref_exists("versioned-health", "2.0.0")?, + "v2 should be a git ref" + ); + assert!( + env.versioned_git_ref_exists("versioned-health", "3.0.0")?, + "v3 should be a git ref" + ); + assert!( + env.versioned_local_document_exists("versioned-health", "4.0.0")?, + "v4 should be JSON" + ); + + let v1_git_ref = env.read_versioned_git_ref("versioned-health", "1.0.0")?; + let v1_commit = v1_git_ref.trim().split(':').next().unwrap(); + assert_eq!( + v1_commit, v1_v2_commit, + "v1 git ref should point to the commit where v1 was first introduced" + ); + + let v2_git_ref = env.read_versioned_git_ref("versioned-health", "2.0.0")?; + let v2_commit = v2_git_ref.trim().split(':').next().unwrap(); + assert_eq!( + v2_commit, v1_v2_commit, + "v2 git ref should point to the commit where v2 was first introduced" + ); + + let v3_git_ref = env.read_versioned_git_ref("versioned-health", "3.0.0")?; + let v3_commit_from_git_ref = v3_git_ref.trim().split(':').next().unwrap(); + assert_eq!( + v3_commit_from_git_ref, v3_commit, + "v3 git ref should point to the commit where v3 was first introduced" + ); + + assert_ne!( + v1_commit, v3_commit_from_git_ref, + "v1 and v3 should point to different commits" + ); + + Ok(()) +} + +/// Test that without git ref storage enabled, no conversion happens. +#[test] +fn test_no_conversion_without_git_ref_enabled() -> Result<()> { + let env = TestEnvironment::new()?; + let apis = versioned_health_apis()?; + + env.generate_documents(&apis)?; + env.commit_documents()?; + env.generate_documents(&apis)?; + + assert!( + env.versioned_local_document_exists("versioned-health", "1.0.0")?, + "v1 should be JSON" + ); + assert!( + env.versioned_local_document_exists("versioned-health", "2.0.0")?, + "v2 should be JSON" + ); + assert!( + env.versioned_local_document_exists("versioned-health", "3.0.0")?, + "v3 should be JSON" + ); + assert!( + !env.versioned_git_ref_exists("versioned-health", "1.0.0")?, + "v1 should not be a git ref" + ); + assert!( + !env.versioned_git_ref_exists("versioned-health", "2.0.0")?, + "v2 should not be a git ref" + ); + assert!( + !env.versioned_git_ref_exists("versioned-health", "3.0.0")?, + "v3 should not be a git ref" + ); + + Ok(()) +} + +/// Test that git ref files are converted back to JSON when git ref storage is +/// disabled, with content preservation. +/// +/// This is the reverse of `test_conversion`. When a user disables git ref +/// storage, existing git ref files should be converted back to full JSON files. +#[test] +fn test_convert_to_json_when_disabled() -> Result<()> { + let env = TestEnvironment::new()?; + + let apis_with_git_ref = versioned_health_git_ref_apis()?; + env.generate_documents(&apis_with_git_ref)?; + env.commit_documents()?; + + let original_v1 = + env.read_versioned_document("versioned-health", "1.0.0")?; + let original_v2 = + env.read_versioned_document("versioned-health", "2.0.0")?; + + let extended_with_git_ref = versioned_health_with_v4_git_ref_apis()?; + env.generate_documents(&extended_with_git_ref)?; + + assert!( + env.versioned_git_ref_exists("versioned-health", "1.0.0")?, + "v1 should be a git ref" + ); + assert!( + env.versioned_git_ref_exists("versioned-health", "2.0.0")?, + "v2 should be a git ref" + ); + assert!( + env.versioned_git_ref_exists("versioned-health", "3.0.0")?, + "v3 should be a git ref" + ); + + let extended_without_git_ref = versioned_health_with_v4_apis()?; + let result = + check_apis_up_to_date(env.environment(), &extended_without_git_ref)?; + assert_eq!( + result, + CheckResult::NeedsUpdate, + "check should report needs update when git refs exist but git ref \ + storage is disabled" + ); + + env.generate_documents(&extended_without_git_ref)?; + + assert!( + !env.versioned_git_ref_exists("versioned-health", "1.0.0")?, + "v1 git ref should be removed" + ); + assert!( + !env.versioned_git_ref_exists("versioned-health", "2.0.0")?, + "v2 git ref should be removed" + ); + assert!( + !env.versioned_git_ref_exists("versioned-health", "3.0.0")?, + "v3 git ref should be removed" + ); + assert!( + env.versioned_local_document_exists("versioned-health", "1.0.0")?, + "v1 should be JSON" + ); + assert!( + env.versioned_local_document_exists("versioned-health", "2.0.0")?, + "v2 should be JSON" + ); + assert!( + env.versioned_local_document_exists("versioned-health", "3.0.0")?, + "v3 should be JSON" + ); + assert!( + env.versioned_local_document_exists("versioned-health", "4.0.0")?, + "v4 should be JSON" + ); + + let restored_v1 = + env.read_versioned_document("versioned-health", "1.0.0")?; + let restored_v2 = + env.read_versioned_document("versioned-health", "2.0.0")?; + assert_eq!( + original_v1, restored_v1, + "v1 content should match original after git ref-to-JSON conversion" + ); + assert_eq!( + original_v2, restored_v2, + "v2 content should match original after git ref-to-JSON conversion" + ); + + Ok(()) +} + +/// Test that duplicate git ref and JSON files are handled correctly. +/// +/// When both git ref and JSON exist for the same version, the system should: +/// +/// - With git ref enabled: delete the JSON (git ref preferred for non-latest) +/// - With git ref disabled: delete the git ref (JSON preferred) +/// +/// This can happen from interrupted conversions, manual file manipulation, +/// or merge conflicts. +#[test] +fn test_duplicates() -> Result<()> { + let env = TestEnvironment::new()?; + let apis = versioned_health_git_ref_apis()?; + + env.generate_documents(&apis)?; + env.commit_documents()?; + + let extended = versioned_health_with_v4_git_ref_apis()?; + env.generate_documents(&extended)?; + + assert!( + env.versioned_git_ref_exists("versioned-health", "1.0.0")?, + "v1 should be a git ref" + ); + assert!( + !env.versioned_local_document_exists("versioned-health", "1.0.0")?, + "v1 should not have a JSON file" + ); + + // Manually create a duplicate JSON file for v1. + let json_content = env.read_git_ref_content("versioned-health", "1.0.0")?; + let git_ref_path = env + .find_versioned_git_ref_path("versioned-health", "1.0.0")? + .expect("git ref should exist"); + let json_path = git_ref_path.with_extension(""); + env.create_file(&json_path, &json_content)?; + + assert!( + env.versioned_git_ref_exists("versioned-health", "1.0.0")?, + "git ref should still exist" + ); + assert!( + env.versioned_local_document_exists("versioned-health", "1.0.0")?, + "duplicate JSON should exist" + ); + + let result = check_apis_up_to_date(env.environment(), &extended)?; + assert_eq!( + result, + CheckResult::NeedsUpdate, + "check should report needs update when duplicate files exist" + ); + + env.generate_documents(&extended)?; + + assert!( + env.versioned_git_ref_exists("versioned-health", "1.0.0")?, + "git ref should still exist after generate" + ); + assert!( + !env.versioned_local_document_exists("versioned-health", "1.0.0")?, + "duplicate JSON should be deleted" + ); + + // Now test with git refs disabled. + env.create_file(&json_path, &json_content)?; + assert!( + env.versioned_git_ref_exists("versioned-health", "1.0.0")?, + "git ref should exist" + ); + assert!( + env.versioned_local_document_exists("versioned-health", "1.0.0")?, + "JSON should exist" + ); + + let extended_no_git_ref = versioned_health_with_v4_apis()?; + env.generate_documents(&extended_no_git_ref)?; + + assert!( + !env.versioned_git_ref_exists("versioned-health", "1.0.0")?, + ".gitref should be deleted" + ); + assert!( + env.versioned_local_document_exists("versioned-health", "1.0.0")?, + "JSON should remain" + ); + + Ok(()) +} + +/// Test that git ref points to the most recent addition when a version is +/// removed and re-added. +/// +/// Consider the situation where: +/// +/// - commit 1 adds API v1 +/// - commit 2 removes API v1 (by dropping it from the list of supported versions) +/// - commit 3 re-adds API v1 +/// +/// When git ref storage is enabled and v1 is converted to a git ref, the ref +/// should point to commit 3 (the most recent addition), not commit 1 (the +/// original addition). This ensures the git ref points to the current version +/// of the file. +#[test] +fn test_remove_readd() -> Result<()> { + let env = TestEnvironment::new()?; + + let v1_v2_v3 = versioned_health_apis()?; + env.generate_documents(&v1_v2_v3)?; + env.commit_documents()?; + let commit_1 = env.get_current_commit_hash_full()?; + + let v1_path_before = env + .find_versioned_document_path("versioned-health", "1.0.0")? + .expect("v1 should exist after commit 1"); + + let v2_v3_only = versioned_health_no_v1_apis()?; + env.generate_documents(&v2_v3_only)?; + env.commit_documents()?; + let commit_2 = env.get_current_commit_hash_full()?; + + assert!( + !env.versioned_local_document_exists("versioned-health", "1.0.0")?, + "v1 should be removed after commit 2" + ); + + let v1_v2_v3_again = versioned_health_apis()?; + env.generate_documents(&v1_v2_v3_again)?; + env.commit_documents()?; + let commit_3 = env.get_current_commit_hash_full()?; + + let v1_path_after = env + .find_versioned_document_path("versioned-health", "1.0.0")? + .expect("v1 should exist after commit 3"); + assert_eq!( + v1_path_before, v1_path_after, + "v1 path should be the same (same content hash)" + ); + + assert_ne!(commit_1, commit_2, "commits 1 and 2 should differ"); + assert_ne!(commit_2, commit_3, "commits 2 and 3 should differ"); + assert_ne!(commit_1, commit_3, "commits 1 and 3 should differ"); + + let v4_with_git_ref = versioned_health_with_v4_git_ref_apis()?; + env.generate_documents(&v4_with_git_ref)?; + + assert!( + env.versioned_git_ref_exists("versioned-health", "1.0.0")?, + "v1 should be converted to a git ref" + ); + + // The git ref for v1 should point to commit 3 (the re-addition), not + // commit 1 (the original addition). + let v1_git_ref = env.read_versioned_git_ref("versioned-health", "1.0.0")?; + let v1_commit = v1_git_ref.trim().split(':').next().unwrap(); + + assert_eq!( + v1_commit, commit_3, + "v1 git ref should point to the re-addition commit (commit 3: {}), \ + not the original addition (commit 1: {})", + commit_3, commit_1 + ); + + let v2_git_ref = env.read_versioned_git_ref("versioned-health", "2.0.0")?; + let v2_commit = v2_git_ref.trim().split(':').next().unwrap(); + assert_eq!( + v2_commit, commit_1, + "v2 git ref should point to commit 1 (never removed)" + ); + + let v3_git_ref = env.read_versioned_git_ref("versioned-health", "3.0.0")?; + let v3_commit = v3_git_ref.trim().split(':').next().unwrap(); + assert_eq!( + v3_commit, commit_1, + "v3 git ref should point to commit 1 (never removed)" + ); + + Ok(()) +} + +/// Test that when the latest version is removed, the previous-latest version +/// is converted from a git ref back to a JSON file. +#[test] +fn test_latest_removed() -> Result<()> { + let env = TestEnvironment::new()?; + + let v1_v2 = versioned_health_reduced_git_ref_apis()?; + env.generate_documents(&v1_v2)?; + env.commit_documents()?; + let v1_v2_commit = env.get_current_commit_hash_full()?; + + env.make_unrelated_commit("between v2 and v3")?; + + let v1_v2_v3 = versioned_health_git_ref_apis()?; + env.generate_documents(&v1_v2_v3)?; + + assert!(env.versioned_git_ref_exists("versioned-health", "1.0.0")?); + assert!(env.versioned_git_ref_exists("versioned-health", "2.0.0")?); + assert!(env.versioned_local_document_exists("versioned-health", "3.0.0")?); + + env.commit_documents()?; + let v3_commit = env.get_current_commit_hash_full()?; + assert_ne!(v1_v2_commit, v3_commit); + + env.make_unrelated_commit("between v3 and v4")?; + + let v1_v2_v3_v4 = versioned_health_with_v4_git_ref_apis()?; + env.generate_documents(&v1_v2_v3_v4)?; + + assert!(env.versioned_git_ref_exists("versioned-health", "3.0.0")?); + assert!(env.versioned_local_document_exists("versioned-health", "4.0.0")?); + + env.commit_documents()?; + + // Remove v4 by going back to v1-v3. + env.generate_documents(&v1_v2_v3)?; + + // v3 should be converted back to JSON because it's now the latest. + assert!( + env.versioned_local_document_exists("versioned-health", "3.0.0")?, + "v3 should be JSON (new latest after v4 removal)" + ); + assert!( + !env.versioned_git_ref_exists("versioned-health", "3.0.0")?, + "v3 should not be a git ref anymore" + ); + + // v1, v2 should remain as git refs (different first commit from v3). + assert!( + env.versioned_git_ref_exists("versioned-health", "1.0.0")?, + "v1 should remain as a git ref" + ); + assert!( + env.versioned_git_ref_exists("versioned-health", "2.0.0")?, + "v2 should remain as a git ref" + ); + + let v1_git_ref = env.read_versioned_git_ref("versioned-health", "1.0.0")?; + let v1_commit_from_ref = v1_git_ref.trim().split(':').next().unwrap(); + assert_eq!(v1_commit_from_ref, v1_v2_commit); + + let v2_git_ref = env.read_versioned_git_ref("versioned-health", "2.0.0")?; + let v2_commit_from_ref = v2_git_ref.trim().split(':').next().unwrap(); + assert_eq!(v2_commit_from_ref, v1_v2_commit); + + let result = check_apis_up_to_date(env.environment(), &v1_v2_v3)?; + assert_eq!(result, CheckResult::Success); + + Ok(()) +} + +/// Test that when the latest version is removed, versions that share the same +/// first commit as the new latest are also converted from git refs to JSON. +#[test] +fn test_latest_removed_same_commit() -> Result<()> { + let env = TestEnvironment::new()?; + + let v1_only = versioned_health_v1_only_apis()?; + env.generate_documents(&v1_only)?; + env.commit_documents()?; + let v1_commit = env.get_current_commit_hash_full()?; + + env.make_unrelated_commit("between v1 and v2/v3")?; + + // Add v2, v3 in the same generate call (they share the same first commit). + let v1_v2_v3 = versioned_health_git_ref_apis()?; + env.generate_documents(&v1_v2_v3)?; + + assert!(env.versioned_git_ref_exists("versioned-health", "1.0.0")?); + assert!(env.versioned_local_document_exists("versioned-health", "2.0.0")?); + assert!(env.versioned_local_document_exists("versioned-health", "3.0.0")?); + + env.commit_documents()?; + let v2_v3_commit = env.get_current_commit_hash_full()?; + assert_ne!(v1_commit, v2_v3_commit); + + env.make_unrelated_commit("between v3 and v4")?; + + let v1_v2_v3_v4 = versioned_health_with_v4_git_ref_apis()?; + env.generate_documents(&v1_v2_v3_v4)?; + + assert!(env.versioned_git_ref_exists("versioned-health", "1.0.0")?); + assert!(env.versioned_git_ref_exists("versioned-health", "2.0.0")?); + assert!(env.versioned_git_ref_exists("versioned-health", "3.0.0")?); + assert!(env.versioned_local_document_exists("versioned-health", "4.0.0")?); + + env.commit_documents()?; + + // Remove v4 by going back to v1-v3. + env.generate_documents(&v1_v2_v3)?; + + // v3 should be converted back to JSON because it's now the latest. + assert!( + env.versioned_local_document_exists("versioned-health", "3.0.0")?, + "v3 should be JSON (new latest after v4 removal)" + ); + assert!( + !env.versioned_git_ref_exists("versioned-health", "3.0.0")?, + "v3 should not be a git ref anymore" + ); + + // v2 was introduced in the same commit as v3 (the new latest), so it should + // also be converted back to JSON. + assert!( + env.versioned_local_document_exists("versioned-health", "2.0.0")?, + "v2 should be JSON (same first commit as new latest v3)" + ); + assert!( + !env.versioned_git_ref_exists("versioned-health", "2.0.0")?, + "v2 should not be a git ref anymore" + ); + + // v1 should remain as a git ref (different first commit from v3). + assert!( + env.versioned_git_ref_exists("versioned-health", "1.0.0")?, + "v1 should remain as a git ref" + ); + + let v1_git_ref = env.read_versioned_git_ref("versioned-health", "1.0.0")?; + let v1_commit_from_ref = v1_git_ref.trim().split(':').next().unwrap(); + assert_eq!(v1_commit_from_ref, v1_commit); + + let result = check_apis_up_to_date(env.environment(), &v1_v2_v3)?; + assert_eq!(result, CheckResult::Success); + + Ok(()) +} + +/// Test that git errors during first commit lookup are reported as problems. +/// +/// This test uses a fake git binary that fails on `--diff-filter=A` commands +/// to simulate a git failure during first commit lookup. +#[test] +fn test_git_error_reports_problem() -> Result<()> { + let env = TestEnvironment::new()?; + + let apis = versioned_health_git_ref_apis()?; + env.generate_documents(&apis)?; + env.commit_documents()?; + + let fake_git = std::env::var("NEXTEST_BIN_EXE_fake_git") + .expect("NEXTEST_BIN_EXE_fake_git should be set by nextest"); + let original_git = std::env::var("GIT").ok(); + + // SAFETY: + // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests + unsafe { + std::env::set_var("GIT", &fake_git); + // Tell fake_git where the real git is. + std::env::set_var("REAL_GIT", original_git.as_deref().unwrap_or("git")); + } + + let v4_apis = versioned_health_with_v4_git_ref_apis()?; + let result = check_apis_up_to_date(env.environment(), &v4_apis)?; + + // Should report a failure due to the unfixable GitRefFirstCommitUnknown + // problem. + assert_eq!(result, CheckResult::Failures); + + Ok(()) +} + +/// Test behavior when running in a shallow clone where git refs point to +/// commits whose objects are not available. +/// +/// This simulates the scenario where: +/// +/// 1. Git ref storage is set up and committed to main. +/// 2. CI does a shallow clone (`git clone --depth 1`). +/// 3. CI runs `check` and the git refs can't be resolved because the commits +/// they reference are outside the shallow boundary. +#[test] +fn test_shallow_clone_with_git_refs() -> Result<()> { + let env = TestEnvironment::new()?; + + let v1_v2_v3 = versioned_health_git_ref_apis()?; + env.generate_documents(&v1_v2_v3)?; + env.commit_documents()?; + + env.make_unrelated_commit("intermediate")?; + + let v4 = versioned_health_with_v4_git_ref_apis()?; + env.generate_documents(&v4)?; + env.commit_documents()?; + + assert!(env.versioned_git_ref_exists("versioned-health", "1.0.0")?); + assert!(env.versioned_git_ref_exists("versioned-health", "2.0.0")?); + assert!(env.versioned_git_ref_exists("versioned-health", "3.0.0")?); + + let shallow_env = env.shallow_clone(1)?; + + assert!( + shallow_env.versioned_git_ref_exists("versioned-health", "1.0.0")?, + "git ref file should exist in shallow clone" + ); + + // Check should fail early. + let result = check_apis_up_to_date(shallow_env.environment(), &v4); + result.expect_err("check should fail in shallow clone with git refs"); + + Ok(()) +} + +/// Test that shallow clones work fine when git ref storage is not enabled. +#[test] +fn test_shallow_clone_without_git_refs() -> Result<()> { + let env = TestEnvironment::new()?; + + // Use APIs without git ref storage. + let v1_v2_v3 = versioned_health_apis()?; + env.generate_documents(&v1_v2_v3)?; + env.commit_documents()?; + + env.make_unrelated_commit("intermediate")?; + + let shallow_env = env.shallow_clone(1)?; + + assert!( + shallow_env + .versioned_local_document_exists("versioned-health", "1.0.0")?, + "v1 document should exist in shallow clone" + ); + + // Check should succeed since we're not using git ref storage. + let result = check_apis_up_to_date(shallow_env.environment(), &v1_v2_v3)?; + assert_eq!(result, CheckResult::Success); + + Ok(()) +} diff --git a/crates/integration-tests/tests/integration/main.rs b/crates/integration-tests/tests/integration/main.rs index c87796c..1ac7962 100644 --- a/crates/integration-tests/tests/integration/main.rs +++ b/crates/integration-tests/tests/integration/main.rs @@ -1,4 +1,5 @@ // Copyright 2025 Oxide Computer Company +mod git_ref; mod lockstep; mod versioned; diff --git a/e2e-example/apis/src/lib.rs b/e2e-example/apis/src/lib.rs index eb7d763..cc25fd4 100644 --- a/e2e-example/apis/src/lib.rs +++ b/e2e-example/apis/src/lib.rs @@ -25,6 +25,9 @@ pub mod versioned { use serde::Serialize; api_versions!([ + // Exercise: try uncommenting version 4 below. This will cause + // the Dropshot API manager to generate a new OpenAPI document. + // (4, FOUR_DOT_OH), // Version 3.0.0 was added to capture bytewise changes to the schema // serialization (e.g., the Number wrapper type being serialized as a // separate schema instead of inlined). diff --git a/e2e-example/bin/src/main.rs b/e2e-example/bin/src/main.rs index 36571d8..2ade468 100644 --- a/e2e-example/bin/src/main.rs +++ b/e2e-example/bin/src/main.rs @@ -78,6 +78,11 @@ pub fn all_apis() -> anyhow::Result { let apis = ManagedApis::new(apis) .context("error creating ManagedApis")? + // Exercise: try uncommenting with_git_ref_storage below. This will cause + // the Dropshot API manager to convert older JSON versions to git refs. + // + // .with_git_ref_storage() + // // A global validation function can be provided to the OpenAPI manager. // This function will be called for each API under consideration. .with_validation(validate);