Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ clap = { version = "=4.5.60", features = ["derive"] }
fs2 = "=0.4.3"
dialoguer = "=0.12.0"
schemars = { version = "=1.2.1", optional = true }
semver = "=1.0.26"
serde = { version = "=1.0.228", features = ["derive"] }
serde-saphyr = "=0.0.21"
serde_json = "=1.0.149"
Expand Down
4 changes: 4 additions & 0 deletions docs/src/content/docs/cli/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ Evaluate a command against your rules and report the decision — without execut

Evaluate a command against your rules and, if allowed, execute it — [optionally within a sandbox](/sandbox/overview/).

### [`runok update-presets`](/cli/update-presets/)

Force-update all remote presets referenced via `extends`, bypassing the TTL-based cache. Shows a diff for each preset that changed.

## Related

- [Denial feedback](/configuration/denial-feedback/) -- Configure `message` and `fix_suggestion` for denied commands.
75 changes: 75 additions & 0 deletions docs/src/content/docs/cli/update-presets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
title: runok update-presets
description: Update all remote presets referenced via extends.
sidebar:
order: 5
---

`runok update-presets` updates all remote presets referenced in your configuration's `extends` field. For branch references, it forces a re-fetch bypassing the TTL cache. For version-tagged references, it finds the latest compatible version and updates your config file.

## Usage

```sh
runok update-presets
```

## Behavior

1. **Collect references** -- Scans all configuration layers (global, global local override, project, project local override) for remote `extends` references (GitHub shorthand and git URLs). Local file references are ignored.
2. **Skip immutable references** -- Presets pinned to a commit SHA (40-character hex) are permanently cached and skipped.
3. **Version tag upgrade** -- For references with a version tag, queries the remote repository for all available tags and finds the latest compatible version. The upgrade scope depends on the tag precision (see [Version upgrade rules](#version-upgrade-rules) below). If a newer compatible version exists, fetches it and updates the `extends` entry in your config file.
4. **Branch/Latest re-fetch** -- For non-version references (e.g., `@main`, no version), forces a re-fetch regardless of cache TTL.
5. **Show diff** -- Displays a colored unified diff for any preset whose content changed.
6. **Summary** -- Prints a summary of how many presets were updated, upgraded, already up to date, skipped, or errored.

## Examples

Update all remote presets:

```sh
runok update-presets
```

Example output when a version tag is upgraded:

```
Upgraded: github:org/[email protected] → github:org/[email protected]
--- a/runok.yml
+++ b/runok.yml
@@ -1,3 +1,3 @@
extends:
- - github:org/[email protected]
+ - github:org/[email protected]

Summary: 0 updated, 1 upgraded, 0 already up to date, 0 skipped, 0 errors
```

Example output when a branch reference has new content:

```
Updated: github:org/shared-rules@main (abc1234 → def5678)

Summary: 1 updated, 0 upgraded, 0 already up to date, 0 skipped, 0 errors
```

## Version upgrade rules

- **Major-only tags** (`v1`, `2`): Upgraded to the latest major version (e.g., `@v1` → `@v2`). Crosses major version boundaries.
- **Major.minor tags** (`v1.0`, `1.2`): Upgraded to the latest minor version within the same major (e.g., `@v1.0` → `@v1.3`). Does not cross major version boundaries.
- **Full semver tags** (`v1.0.0`, `1.2.3`): Upgraded to the latest stable version within the same major version (e.g., `@v1.0.0` → `@v1.2.0`). Does not cross major version boundaries.
- **Non-version tags** (`main`, `stable`): Treated as branch references and force-re-fetched.
- **Commit SHA** (40-character hex): Skipped entirely (immutable).
- **Pre-release exclusion**: Pre-release versions (e.g., `v2.0.0-beta.1`) are never selected as upgrade candidates.
- **v-prefix matching**: If your current tag uses a `v` prefix (e.g., `v1`), only tags with a `v` prefix are considered as upgrade candidates, and vice versa.

## Exit codes

| Code | Meaning |
| ---- | ------------------------------------------------------------------- |
| 0 | All presets updated successfully (or already up to date / skipped). |
| 1 | One or more presets failed to update. |

## Related

- [Extending configuration](/configuration/schema/#extends) -- How to use `extends` to share presets.
- [CLI Overview](/cli/overview/) -- All available commands.
6 changes: 6 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ pub enum Commands {
Check(CheckArgs),
/// Initialize runok configuration
Init(InitArgs),
/// Force-update all remote presets referenced via extends
UpdatePresets,
/// Print the JSON Schema for runok.yml to stdout
#[cfg(feature = "config-schema")]
ConfigSchema,
Expand Down Expand Up @@ -188,6 +190,10 @@ mod tests {
&["runok", "init", "--scope", "user", "-y"],
Commands::Init(InitArgs { scope: Some(InitScope::User), yes: true }),
)]
#[case::update_presets(
&["runok", "update-presets"],
Commands::UpdatePresets,
)]
fn cli_parsing(#[case] argv: &[&str], #[case] expected: Commands) {
let cli = Cli::parse_from(argv);
assert_eq!(cli.command, expected);
Expand Down
79 changes: 78 additions & 1 deletion src/config/git_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@ use std::process::Command;

use super::PresetError;

/// Whether a remote ref is a tag or a branch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefKind {
Tag,
Branch,
}

/// A remote ref returned by `ls-remote`, with its name and kind.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteRef {
pub name: String,
pub kind: RefKind,
}

/// Abstraction for git command execution.
///
/// Enables testing with `MockGitClient` while `ProcessGitClient` runs real git processes.
Expand All @@ -23,6 +37,10 @@ pub trait GitClient {

/// Run `git rev-parse HEAD` in `repo_dir` and return the commit SHA.
fn rev_parse_head(&self, repo_dir: &Path) -> Result<String, PresetError>;

/// Run `git ls-remote --tags --heads --refs <url>` and return remote refs
/// with their kind (tag or branch).
fn ls_remote_refs(&self, url: &str) -> Result<Vec<RemoteRef>, PresetError>;
}

/// Strip credentials from a URL for safe use in error messages.
Expand Down Expand Up @@ -149,6 +167,49 @@ impl GitClient for ProcessGitClient {
})
}
}

fn ls_remote_refs(&self, url: &str) -> Result<Vec<RemoteRef>, PresetError> {
let mut cmd = Command::new("git");
cmd.env_remove("GIT_DIR");
cmd.env_remove("GIT_INDEX_FILE");
// --tags --heads: list both tags and branches; --refs excludes peeled refs (^{})
cmd.args(["ls-remote", "--tags", "--heads", "--refs", "--", url]);

let output = cmd.output().map_err(|e| PresetError::GitClone {
reference: sanitize_url(url),
message: format!("failed to execute git ls-remote: {e}"),
})?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PresetError::GitClone {
reference: sanitize_url(url),
message: format!("git ls-remote failed: {}", stderr.trim()),
});
}

// Output format: "<sha>\trefs/tags/<name>" or "<sha>\trefs/heads/<name>"
let stdout = String::from_utf8_lossy(&output.stdout);
let refs = stdout
.lines()
.filter_map(|line| {
let (_sha, refname) = line.split_once('\t')?;
if let Some(name) = refname.strip_prefix("refs/tags/") {
Some(RemoteRef {
name: name.to_string(),
kind: RefKind::Tag,
})
} else {
refname.strip_prefix("refs/heads/").map(|name| RemoteRef {
name: name.to_string(),
kind: RefKind::Branch,
})
}
})
.collect();

Ok(refs)
}
}

#[cfg(test)]
Expand All @@ -157,7 +218,7 @@ pub mod mock {
use std::collections::VecDeque;
use std::path::Path;

use super::{GitClient, PresetError};
use super::{GitClient, PresetError, RemoteRef};

/// Records of calls made to MockGitClient methods.
#[derive(Debug, Clone)]
Expand All @@ -166,6 +227,7 @@ pub mod mock {
Fetch,
Checkout { git_ref: String },
RevParseHead,
LsRemoteRefs { url: String },
}

/// Test double for `GitClient` that returns pre-configured results.
Expand All @@ -174,6 +236,7 @@ pub mod mock {
fetch_results: RefCell<VecDeque<Result<(), PresetError>>>,
checkout_results: RefCell<VecDeque<Result<(), PresetError>>>,
rev_parse_results: RefCell<VecDeque<Result<String, PresetError>>>,
ls_remote_refs_results: RefCell<VecDeque<Result<Vec<RemoteRef>, PresetError>>>,
pub calls: RefCell<Vec<GitCall>>,
}

Expand All @@ -190,6 +253,7 @@ pub mod mock {
fetch_results: RefCell::new(VecDeque::new()),
checkout_results: RefCell::new(VecDeque::new()),
rev_parse_results: RefCell::new(VecDeque::new()),
ls_remote_refs_results: RefCell::new(VecDeque::new()),
calls: RefCell::new(Vec::new()),
}
}
Expand Down Expand Up @@ -218,6 +282,12 @@ pub mod mock {
self
}

/// Queue a result for the next `ls_remote_refs` call.
pub fn on_ls_remote_refs(&self, result: Result<Vec<RemoteRef>, PresetError>) -> &Self {
self.ls_remote_refs_results.borrow_mut().push_back(result);
self
}

fn pop_result<T>(
results: &RefCell<VecDeque<Result<T, PresetError>>>,
) -> Result<T, PresetError> {
Expand Down Expand Up @@ -259,5 +329,12 @@ pub mod mock {
self.calls.borrow_mut().push(GitCall::RevParseHead);
Self::pop_result(&self.rev_parse_results)
}

fn ls_remote_refs(&self, url: &str) -> Result<Vec<RemoteRef>, PresetError> {
self.calls.borrow_mut().push(GitCall::LsRemoteRefs {
url: url.to_string(),
});
Self::pop_result(&self.ls_remote_refs_results)
}
}
}
6 changes: 3 additions & 3 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
pub(crate) mod cache;
pub mod cache;
pub mod dirs;
mod error;
pub(crate) mod git_client;
pub mod git_client;
mod loader;
mod model;
pub mod path_resolver;
mod preset;
pub(crate) mod preset_remote;
pub mod preset_remote;

pub use cache::PresetCache;
pub use error::*;
Expand Down
42 changes: 26 additions & 16 deletions src/config/preset_remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,13 +216,13 @@ fn parse_git_url(reference: &str) -> Result<PresetReference, PresetError> {
}

/// Resolve git parameters from a `PresetReference`.
struct GitParams {
url: String,
git_ref: Option<String>,
is_immutable: bool,
pub struct GitParams {
pub url: String,
pub git_ref: Option<String>,
pub is_immutable: bool,
}

fn resolve_git_params(reference: &PresetReference) -> GitParams {
pub fn resolve_git_params(reference: &PresetReference) -> GitParams {
match reference {
PresetReference::GitHub {
owner,
Expand Down Expand Up @@ -258,11 +258,14 @@ fn resolve_git_params(reference: &PresetReference) -> GitParams {
}
}

/// Read a preset config file from a directory.
/// Resolve the preset file path within a directory.
///
/// When `preset_path` is `None`, reads `runok.yml` (or `runok.yaml`) from the root.
/// When `preset_path` is `Some("foo/bar")`, reads `foo/bar.yml` (or `foo/bar.yaml`).
fn read_preset_from_dir(dir: &Path, preset_path: Option<&str>) -> Result<Config, ConfigError> {
/// When `preset_path` is `None`, looks for `runok.yml` (or `runok.yaml`) from the root.
/// When `preset_path` is `Some("foo/bar")`, looks for `foo/bar.yml` (or `foo/bar.yaml`).
pub fn resolve_preset_file_path(
dir: &Path,
preset_path: Option<&str>,
) -> Result<PathBuf, ConfigError> {
let (yml, yaml, not_found_msg) = match preset_path {
Some(p) => (
dir.join(format!("{p}.yml")),
Expand All @@ -276,18 +279,25 @@ fn read_preset_from_dir(dir: &Path, preset_path: Option<&str>) -> Result<Config,
),
};

let path = if yml.exists() {
yml
if yml.exists() {
Ok(yml)
} else if yaml.exists() {
yaml
Ok(yaml)
} else {
return Err(PresetError::GitClone {
Err(PresetError::GitClone {
reference: dir.display().to_string(),
message: not_found_msg,
}
.into());
};
.into())
}
}

/// Read a preset config file from a directory.
///
/// When `preset_path` is `None`, reads `runok.yml` (or `runok.yaml`) from the root.
/// When `preset_path` is `Some("foo/bar")`, reads `foo/bar.yml` (or `foo/bar.yaml`).
pub fn read_preset_from_dir(dir: &Path, preset_path: Option<&str>) -> Result<Config, ConfigError> {
let path = resolve_preset_file_path(dir, preset_path)?;
let content = std::fs::read_to_string(&path)?;
let config = parse_config(&content)?;
Ok(config)
Expand All @@ -314,7 +324,7 @@ fn current_timestamp() -> u64 {
}

/// Extract the preset path from a reference (only GitHub shorthand supports this).
fn preset_path_from_reference(reference: &PresetReference) -> Option<&str> {
pub fn preset_path_from_reference(reference: &PresetReference) -> Option<&str> {
match reference {
PresetReference::GitHub { path, .. } => path.as_deref(),
_ => None,
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ pub mod config;
pub mod exec;
pub mod init;
pub mod rules;
pub mod update_presets;
Loading
Loading