-
Notifications
You must be signed in to change notification settings - Fork 0
Closed
Labels
enhancementNew feature or requestNew feature or request
Description
Add possibility to run scripts (recipes), not only one-liner commands.
Problem: Sometimes what you need is to run a more complex set of commands instead of only one-liners for a repo.
Solution: A potential solution is to add an ability to define and run scripts (called recipes).
Goal
Allow running multi-step scripts (recipes) defined in config.yaml across repositories using: repos run --recipe "say hello"
Config Schema (Minimal Extension)
Add optional recipes list to existing config:
repositories:
- name: core-lib
url: git@github.com:org/core-lib.git
recipes:
- name: say hello
steps: |-
#!/usr/bin/env perl
print "Larry Wall says Hi!\n";
- name: update rust deps
steps: |-
#!/usr/bin/env bash
set -euo pipefail
cargo update
cargo audit || trueRules:
- name: unique (case-sensitive)
- steps: raw multiline string; may include shebang. If no shebang, default /bin/sh.
- No extra keys (keep minimal).
CLI Change
Extend existing run command:
- Add --recipe
- Mutually exclusive with the existing one-liner command argument.
- If --recipe is set, ignore other command input and execute recipe script per repo.
Execution Flow
- Parse config (recipes included).
- Locate recipe by name (O(n) linear search; n expected small).
- For each target repository (same filtering logic already used by run):
- Create ephemeral script file inside repo at: .repos/recipes/<sanitized_name>.script
- sanitized_name: lowercase, replace non [a-z0-9-_] with _
- Write steps verbatim.
- Ensure executable: chmod 750.
- If first line lacks shebang, prepend: #!/bin/sh
- Execute using existing command runner code path (reusing stdout/stderr capture, coloring, concurrency).
- Remove script file if you prefer (optional; minimal: leave it for debugging).
- Create ephemeral script file inside repo at: .repos/recipes/<sanitized_name>.script
- Success/failure semantics identical to current single command execution.
Minimal Code Additions
- config.rs (or existing config module): add Recipe struct and recipes: Option<Vec> field.
- run command args: add recipe: Option.
- Helper find_recipe(name).
- Small script materialization function.
Security Considerations
- Arbitrary code execution: treat recipes as trusted project config (no sandbox).
- Do not auto-fetch remote recipe definitions.
- Warn (in docs) against committing sensitive credentials in recipes.
Failure Handling
- Non-zero exit code from any repo: mark that repo failed (existing aggregation logic).
- Missing recipe name: error and abort early.
- Duplicate recipe names: first wins (or optionally error—minimal: error).
Tests (Integration Only)
Add:
- Parse config with one recipe.
- Run recipe with shebang (Perl example) verifying output contains “Larry Wall”.
- Run recipe without shebang (default shell).
- Error when recipe missing.
- Error when both command and recipe are provided (conflict).
Minimal Code Scaffolding
Config extension:
// ...existing code...
#[derive(Debug, Clone, serde::Deserialize)]
pub struct Recipe {
pub name: String,
pub steps: String,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct Config {
// ...existing fields...
#[serde(default)]
pub recipes: Vec<Recipe>,
}
// Helper (add near other impls)
impl Config {
pub fn find_recipe(&self, name: &str) -> Option<&Recipe> {
self.recipes.iter().find(|r| r.name == name)
}
}Run command argument addition (adjust to actual location):
// ...existing code...
#[derive(clap::Args, Debug)]
pub struct RunArgs {
/// One-liner command to execute across repositories
#[arg()]
pub command: Option<String>,
/// Name of a recipe defined in config.yaml
#[arg(long, conflicts_with="command")]
pub recipe: Option<String>,
// ...existing fields...
}
// ...existing code...
pub fn run(args: RunArgs, cfg: &Config) -> anyhow::Result<()> {
if let Some(recipe_name) = &args.recipe {
let recipe = cfg
.find_recipe(recipe_name)
.ok_or_else(|| anyhow::anyhow!("recipe '{}' not found", recipe_name))?;
return execute_recipe(args, cfg, recipe);
}
// existing single command path unchanged
// ...existing code...
}
// New minimal helper
fn execute_recipe(args: RunArgs, cfg: &Config, recipe: &Recipe) -> anyhow::Result<()> {
let script_label = sanitize(recipe.name.as_str());
let repos = select_repositories(cfg, &args); // reuse existing selection logic
for repo in repos {
let repo_path = std::path::Path::new(&repo.get_target_dir()?);
let recipes_dir = repo_path.join(".repos").join("recipes");
std::fs::create_dir_all(&recipes_dir)?;
let script_path = recipes_dir.join(format!("{script_label}.script"));
materialize_script(&script_path, &recipe.steps)?;
let status = std::process::Command::new(&script_path)
.current_dir(repo_path)
.status()
.with_context(|| format!("execute recipe '{}' in {}", recipe.name, repo.name))?;
if !status.success() {
eprintln!("recipe '{}' failed in {}", recipe.name, repo.name);
}
}
Ok(())
}
fn materialize_script(path: &std::path::Path, raw: &str) -> anyhow::Result<()> {
let mut content = raw.to_string();
if !content.starts_with("#!") {
content = format!("#!/bin/sh\n{content}");
}
std::fs::write(path, content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perm = std::fs::metadata(path)?.permissions();
perm.set_mode(0o750);
std::fs::set_permissions(path, perm)?;
}
Ok(())
}
fn sanitize(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for c in name.chars() {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
out.push(c.to_ascii_lowercase());
} else {
out.push('_');
}
}
out
}(helping functions like select_repositories assumed existing; adjust names minimally).
Integration test example:
use std::fs;
use tempfile::TempDir;
use repos::{Config};
#[test]
fn run_perl_recipe() {
let tmp = TempDir::new().unwrap();
let repo_dir = tmp.path().join("demo");
fs::create_dir_all(&repo_dir).unwrap();
// init git
assert!(std::process::Command::new("git")
.args(["init"])
.current_dir(&repo_dir)
.status()
.unwrap()
.success());
// build config
let cfg_yaml = format!(
r#"
repositories:
- name: demo
url: file://{}
path: "{}"
recipes:
- name: say hello
steps: |-
#!/usr/bin/env bash
echo "HELLO_FROM_RECIPE"
"#,
repo_dir.display(),
repo_dir.display()
);
let cfg_path = tmp.path().join("config.yaml");
fs::write(&cfg_path, cfg_yaml).unwrap();
let cfg: Config = serde_yaml::from_str(&fs::read_to_string(cfg_path).unwrap()).unwrap();
// invoke run --recipe
let status = std::process::Command::new("target/debug/repos")
.args(["run", "--recipe", "say hello"])
.current_dir(tmp.path())
.status()
.unwrap();
assert!(status.success());
// verify script materialized
let script = repo_dir.join(".repos/recipes/say_hello.script");
assert!(script.exists());
}Minimal Impact
- No refactor of existing run logic.
- Added fields + small guarded code path.
- Existing command usage unchanged.
Usage
repos run --recipe "say hello"
repos run "echo one-liner" # still works
Future features considerations (not in phase 1)
- dry-run
- Parameters: repos run --recipe update rust deps --var toolchain=stable
- Shared cache: only build heavy artifacts once.
- Dry-run: show script content without executing.
- JSON output mode.
- Create a secure temp file (use tempfile crate) with unique name in OS temp dir.
- Write recipe.steps to the temp file.
- Merge environment: inherit existing env, then apply recipe.env then CLI --env. Optionally strip sensitive vars.
- Stream stdout/stderr (use async or spawn threads to copy pipes to parent's stdout/stderr).
- Keep exit code.
- Apply timeout: use tokio::time::timeout or crossbeam/threads with wait + kill on expiry. If timeout triggers, kill process and return non-zero.
- Remove temp file unless --keep-script.
- Respect continue_on_error: if true, collect failures and continue; if false, stop on first failure.
- Avoid exposing secrets via logs; redact env values in logs unless --verbose.
- Run in specified working directory; do not change global state.
- Consider optional sandboxing integrations (e.g., bubblewrap, chroot, containers) as advanced feature
Metadata
Metadata
Assignees
Labels
enhancementNew feature or requestNew feature or request