Skip to content

Run recipes #35

@codcod

Description

@codcod

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 || true

Rules:

  • 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

  1. Parse config (recipes included).
  2. Locate recipe by name (O(n) linear search; n expected small).
  3. 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).
  4. Success/failure semantics identical to current single command execution.

Minimal Code Additions

  1. config.rs (or existing config module): add Recipe struct and recipes: Option<Vec> field.
  2. run command args: add recipe: Option.
  3. Helper find_recipe(name).
  4. 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 request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions