diff --git a/README_ADVANCED.md b/README_ADVANCED.md index 58375405..72f06f8f 100644 --- a/README_ADVANCED.md +++ b/README_ADVANCED.md @@ -25,11 +25,16 @@ foc-devnet init [OPTIONS] - `--rand` - Use random mnemonic instead of deterministic one. Use this for unique test scenarios. **Source Format:** -- `gittag:v1.0.0` - Specific git tag (uses default repo) -- `gittag:https://github.com/user/repo.git:v1.0.0` - Tag from custom repo -- `gitcommit:abc123` - Specific git commit -- `gitbranch:main` - Specific git branch -- `local:/path/to/repo` - Local directory +- `latesttag` - Newest git tag in the default repo (resolved once at `init`). +- `latesttag:` - Newest git tag matching a glob selector, e.g. `latesttag:v*` or `latesttag:pdp/v*`. +- `latesttag::` - Newest matching tag from a custom repo, e.g. `latesttag:https://github.com/org/repo.git:v*`. +- `gittag:` - Specific git tag (uses default repo) +- `gittag::` - Tag from custom repo, e.g. `gittag:https://github.com/org/repo.git:v1.0.0` +- `gitcommit:` - Specific commit (uses default repo) +- `gitcommit::` - Commit from custom repo +- `gitbranch:` - Specific branch (uses default repo) +- `gitbranch::` - Branch from custom repo +- `local:` - Local directory **Example:** ```bash @@ -812,6 +817,7 @@ port_range_count = 100 Default versions for these repositories are defined in code (see [`src/config.rs`](src/config.rs) `Config::default()`). **Version specification methods:** +- **Latest tag** (`latesttag`, `latesttag:`, `latesttag::`): Resolved once at `init` time via `git ls-remote` and pinned as a concrete `GitTag` in `config.toml`. Use a glob selector to scope which tags are considered, e.g. `latesttag:v*` or `latesttag:pdp/v*`. Bare `latesttag` matches all tags. - **Git tags** (`GitTag`): Used for stable releases. Tags provide version pinning and stability. - **Git commits** (`GitCommit`): Used for repositories where specific commits are required and there isn't a corresponding tag yet. (Generally tags should be preferred over commits.) - **Git branches** (`GitBranch`): Used for development or when tracking latest changes. diff --git a/src/cli.rs b/src/cli.rs index 993301dc..500f04ac 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -23,13 +23,28 @@ pub enum Commands { Stop, /// Initialize foc-devnet by building and caching Docker images Init { - /// Curio source location (e.g., 'gittag:tag', 'gittag:url:tag', 'gitcommit:commit', 'gitcommit:url:commit', 'gitbranch:branch', 'gitbranch:url:branch', 'local:/path/to/curio') + /// Curio source location. + /// Latest tag: 'latesttag' (newest), 'latesttag:' (e.g. 'latesttag:pdp/v*'), + /// 'latesttag::' (custom repo). Resolved once at init. + /// Explicit: 'gittag:', 'gittag::', 'gitcommit:', + /// 'gitcommit::', 'gitbranch:', 'gitbranch::', + /// 'local:/path/to/curio'. #[arg(long)] curio: Option, - /// Lotus source location (e.g., 'gittag:v1.0.0', 'gittag:url:tag', 'gitcommit:abc123', 'gitcommit:url:commit', 'gitbranch:main', 'gitbranch:url:main', 'local:/path/to/lotus') + /// Lotus source location. + /// Latest tag: 'latesttag' (newest), 'latesttag:' (e.g. 'latesttag:v*'), + /// 'latesttag::' (custom repo). Resolved once at init. + /// Explicit: 'gittag:', 'gittag::', 'gitcommit:', + /// 'gitcommit::', 'gitbranch:', 'gitbranch::', + /// 'local:/path/to/lotus'. #[arg(long)] lotus: Option, - /// Filecoin Services source location (e.g., 'gittag:v1.0.0', 'gittag:url:tag', 'gitcommit:abc123', 'gitcommit:url:commit', 'gitbranch:main', 'gitbranch:url:main', 'local:/path/to/filecoin-services') + /// Filecoin Services source location. + /// Latest tag: 'latesttag' (newest), 'latesttag:' (e.g. 'latesttag:v*'), + /// 'latesttag::' (custom repo). Resolved once at init. + /// Explicit: 'gittag:', 'gittag::', 'gitcommit:', + /// 'gitcommit::', 'gitbranch:', 'gitbranch::', + /// 'local:/path/to/filecoin-services'. #[arg(long)] filecoin_services: Option, /// Yugabyte download URL @@ -82,12 +97,12 @@ pub enum BuildCommands { pub enum ConfigCommands { /// Configure Lotus source location Lotus { - /// Lotus source location (e.g., 'gittag:v1.0.0', 'gitcommit:abc123', 'local:/path/to/lotus') + /// Lotus source location (e.g., 'latesttag', 'latesttag:v*', 'latesttag::v*', 'gittag:v1.0.0', 'gitcommit:abc123', 'gitbranch:main', 'local:/path/to/lotus') source: String, }, /// Configure Curio source location Curio { - /// Curio source location (e.g., 'gittag:v1.0.0', 'gitcommit:abc123', 'local:/path/to/curio') + /// Curio source location (e.g., 'latesttag', 'latesttag:pdp/v*', 'latesttag::pdp/v*', 'gittag:v1.0.0', 'gitcommit:abc123', 'gitbranch:main', 'local:/path/to/curio') source: String, }, } diff --git a/src/commands/config.rs b/src/commands/config.rs index 4a370d17..d8deceea 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -59,7 +59,7 @@ fn update_config_location( .map_err(|e| format!("Failed to parse config file: {}", e))?; // Parse the source location - let location = Location::parse_with_default(&source, default_url) + let location = Location::resolve_with_default(&source, default_url) .map_err(|e| format!("Invalid {} source format: {}", field, e))?; // Update the appropriate field diff --git a/src/commands/init/config.rs b/src/commands/init/config.rs index 82966f3f..75cdcde8 100644 --- a/src/commands/init/config.rs +++ b/src/commands/init/config.rs @@ -105,7 +105,7 @@ pub fn apply_location_override( Location::GitBranch { ref url, .. } => url.clone(), Location::LocalSource { .. } => default_url.to_string(), }; - *location = Location::parse_with_default(&loc_str, &url) + *location = Location::resolve_with_default(&loc_str, &url) .map_err(|e| format!("Invalid location: {}", e))?; } Ok(()) diff --git a/src/config.rs b/src/config.rs index 85b3e822..27591abd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -39,76 +39,149 @@ pub enum Location { } impl Location { - /// Parse a location string in the format "type:value" or "type:url:value" + /// Given a url and a selector, finds the latest tag given that selector. /// - /// Supported formats: - /// - "gittag:tag" (uses default URL) - /// - "gitcommit:commit" (uses default URL) - /// - "gitbranch:branch" (uses default URL) - /// - "local:dir" - /// - "gittag:url:tag" - /// - "gitcommit:url:commit" - /// - "gitbranch:url:branch" + /// Runs `git ls-remote --tags --sort=-version:refname ` and + /// returns the first tag name from the output (i.e. the lexicographically newest + /// version-sorted tag that matches `selector`). /// - /// Where url can contain colons (e.g., https://github.com/repo.git) - pub fn parse_with_default(s: &str, default_url: &str) -> Result { - let parts: Vec<&str> = s.split(':').collect(); - if parts.len() < 2 { - return Err(format!( - "Invalid location format: {}. Expected 'type:value' or 'type:url:value'", - s - )); + /// Example: `resolveLatestTag("https://github.com/foo/bar.git", "v*")` might + /// return `"v1.2.3"`. + fn resolve_latest_tag(url: &str, selector: &str) -> Result { + let stdout = { + #[cfg(not(test))] + { + let output = std::process::Command::new("git") + .args([ + "ls-remote", + "--tags", + "--sort=-version:refname", + url, + selector, + ]) + .output() + .map_err(|e| format!("Failed to run git ls-remote: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git ls-remote failed: {}", stderr.trim())); + } + + String::from_utf8_lossy(&output.stdout).into_owned() + } + + #[cfg(test)] + { + String::from("0000000000000 refs/tags/v1.0.0") + } + }; + + let first_line = stdout + .lines() + .next() + .ok_or_else(|| format!("No tags found for selector '{}' at '{}'", selector, url))?; + + // Output format: "\trefs/tags/" + let tag_ref = first_line + .split_whitespace() + .nth(1) + .ok_or_else(|| format!("Unexpected git ls-remote output: '{}'", first_line))?; + + tag_ref + .strip_prefix("refs/tags/") + .map(|t| t.to_string()) + .ok_or_else(|| format!("Unexpected tag ref format: '{}'", tag_ref)) + } + + /// Canonicalizes the location from a set of following variants: + /// + /// - `latesttag` — newest tag matching `*` (uses default URL) + /// - `latesttag:` — newest tag matching selector (e.g. `latesttag:pdp/v*`) + /// - `latesttag::` — newest tag matching selector at a custom URL + /// - `gittag:` — (uses default URL) + /// - `gitcommit:` — (uses default URL) + /// - `gitbranch:` — (uses default URL) + /// - `local:` + /// - `gittag::` + /// - `gitcommit::` + /// - `gitbranch::` + /// + /// to a smaller subset: + /// + /// - `local:` -> `("local", "", "dir")` + /// - `gittag::` -> `("gittag", "url", "tag")` + /// - `gitcommit::` -> `("gitcommit", "url", "commit")` + /// - `gitbranch::` -> `("gitbranch", "url", "branch")` + fn canonicalize_location( + location: &str, + default_url: &str, + ) -> Result<(String, String, String), String> { + // Special case: bare "latesttag" with no selector — implicitly matches all tags. + if location == "latesttag" { + let tag = Self::resolve_latest_tag(default_url, "*")?; + return Ok(("gittag".into(), default_url.into(), tag)); } - let location_type = parts[0]; - let remaining = &parts[1..].join(":"); + // We need to do this setup in two steps since otherwise + // "gittag:https://github.com/orgs/repo:v1.2.0" would not be parseable + // and split the string itself in two parts + let (location_type, remaining) = location.split_once(':').ok_or_else(|| { + format!( + "Invalid location format: '{}'. Expected \ + 'latesttag:', or 'gittag/gitcommit/gitbranch/local:...'", + location + ) + })?; + + // If the remaining part contains another ':', the portion before the last ':' + // is the URL and everything after is the value (handles HTTPS URLs with colons). + let (url, selector) = if let Some(colon_pos) = remaining.rfind(':') { + (&remaining[..colon_pos], &remaining[colon_pos + 1..]) + } else { + (default_url, remaining) + }; + + // Special case: latesttag resolution into GitTag + if location_type == "latesttag" { + let tag = Self::resolve_latest_tag(url, selector)?; + return Ok(("gittag".into(), url.into(), tag)); + } - match location_type { - "local" => Ok(Location::LocalSource { - dir: remaining.to_string(), + Ok((location_type.into(), url.into(), selector.into())) + } + + /// Parse a location string in the format "type:value" or "type:url:value". + /// May attempt to resolve `latesttag` if provided by reaching over the internet. + /// + /// Supported formats: + /// - `latesttag` — newest tag (uses default URL, matches `*`) + /// - `latesttag:` — newest tag matching selector (e.g. `latesttag:pdp/v*`) + /// - `latesttag::` — newest tag matching selector at a custom URL + /// - `gittag:` — (uses default URL) + /// - `gitcommit:` — (uses default URL) + /// - `gitbranch:` — (uses default URL) + /// - `local:` + /// - `gittag::` + /// - `gitcommit::` + /// - `gitbranch::` + pub fn resolve_with_default(location: &str, default_url: &str) -> Result { + let canonical_location = Self::canonicalize_location(location, default_url)?; + let (loc_type, url, selector) = canonical_location; + + match loc_type.as_ref() { + "local" => Ok(Location::LocalSource { dir: selector }), + "gittag" => Ok(Location::GitTag { url, tag: selector }), + "gitcommit" => Ok(Location::GitCommit { + url, + commit: selector, + }), + "gitbranch" => Ok(Location::GitBranch { + url, + branch: selector, }), - "gittag" | "gitcommit" | "gitbranch" => { - // Check if remaining contains ':' (indicating url:value format) - if let Some(colon_pos) = remaining.rfind(':') { - let url = &remaining[..colon_pos]; - let value = &remaining[colon_pos + 1..]; - match location_type { - "gittag" => Ok(Location::GitTag { - url: url.to_string(), - tag: value.to_string(), - }), - "gitcommit" => Ok(Location::GitCommit { - url: url.to_string(), - commit: value.to_string(), - }), - "gitbranch" => Ok(Location::GitBranch { - url: url.to_string(), - branch: value.to_string(), - }), - _ => unreachable!(), - } - } else { - // No colon, so remaining is just the value, use default URL - match location_type { - "gittag" => Ok(Location::GitTag { - url: default_url.to_string(), - tag: remaining.to_string(), - }), - "gitcommit" => Ok(Location::GitCommit { - url: default_url.to_string(), - commit: remaining.to_string(), - }), - "gitbranch" => Ok(Location::GitBranch { - url: default_url.to_string(), - branch: remaining.to_string(), - }), - _ => unreachable!(), - } - } - } _ => Err(format!( "Unknown location type: {}. Supported types: local, gittag, gitcommit, gitbranch", - location_type + loc_type )), } } @@ -287,3 +360,132 @@ impl Config { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::Location; + + const DEFAULT_URL: &str = "https://github.com/default/repo.git"; + + fn canonicalize(s: &str) -> Result<(String, String, String), String> { + Location::canonicalize_location(s, DEFAULT_URL) + } + + // --- happy-path tests --- + + #[test] + fn gittag_short_uses_default_url() { + assert_eq!( + canonicalize("gittag:v1.2.3").unwrap(), + ("gittag".into(), DEFAULT_URL.into(), "v1.2.3".into()) + ); + } + + #[test] + fn gittag_explicit_https_url() { + assert_eq!( + canonicalize("gittag:https://github.com/foo/bar.git:v1.2.3").unwrap(), + ( + "gittag".into(), + "https://github.com/foo/bar.git".into(), + "v1.2.3".into() + ) + ); + } + + #[test] + fn gitcommit_short_uses_default_url() { + assert_eq!( + canonicalize("gitcommit:abc123def456").unwrap(), + ( + "gitcommit".into(), + DEFAULT_URL.into(), + "abc123def456".into() + ) + ); + } + + #[test] + fn gitcommit_explicit_https_url() { + assert_eq!( + canonicalize("gitcommit:https://github.com/foo/bar.git:abc123def456").unwrap(), + ( + "gitcommit".into(), + "https://github.com/foo/bar.git".into(), + "abc123def456".into() + ) + ); + } + + #[test] + fn gitbranch_short_uses_default_url() { + assert_eq!( + canonicalize("gitbranch:main").unwrap(), + ("gitbranch".into(), DEFAULT_URL.into(), "main".into()) + ); + } + + #[test] + fn gitbranch_explicit_https_url() { + assert_eq!( + canonicalize("gitbranch:https://github.com/foo/bar.git:feature/my-branch").unwrap(), + ( + "gitbranch".into(), + "https://github.com/foo/bar.git".into(), + "feature/my-branch".into() + ) + ); + } + + #[test] + fn local_dir() { + assert_eq!( + canonicalize("local:/home/user/my-project").unwrap(), + ( + "local".into(), + DEFAULT_URL.into(), + "/home/user/my-project".into() + ) + ); + } + + #[test] + fn latesttag_with_url() { + assert_eq!( + canonicalize("latesttag:https://github.com/randomorg/randomrepo:v*").unwrap(), + ( + "gittag".into(), + "https://github.com/randomorg/randomrepo".into(), + "v1.0.0".into() + ) + ); + } + + #[test] + fn latesttag_without_url() { + assert_eq!( + canonicalize("latesttag:v*").unwrap(), + ("gittag".into(), DEFAULT_URL.into(), "v1.0.0".into()) + ); + } + + #[test] + fn latesttag_without_url_without_selector() { + assert_eq!( + canonicalize("latesttag").unwrap(), + ("gittag".into(), DEFAULT_URL.into(), "v1.0.0".into()) + ); + } + + // --- error-path tests --- + + #[test] + fn missing_colon_returns_error() { + assert!(canonicalize("gittag").is_err()); + } + + #[test] + fn empty_string_returns_error() { + assert!(canonicalize("").is_err()); + } +}