Simplify CLI repository selectors and add workflow shorthand

This commit is contained in:
Scott Werner 2026-09-12 14:18:34 -06:00
parent 94c7159ef8
commit 86bcc8f128
9 changed files with 391 additions and 157 deletions

View file

@ -147,15 +147,17 @@ See the [Quick Start](/getting-started/quick-start) to try it out, or browse the
registers the workflow and leaves a submitted run for you to start with
`fabro start RUN`. `run` also starts it, then attaches unless you pass `--detach`.
The required positional argument selects the workflow. With no source flags,
names are found in the current checkout, then a marked project, then installed
user workflows. Explicit local paths retain their usual package roots.
The required positional argument selects the workflow. Local names are found in
the current checkout, then a marked project, then installed user workflows.
Explicit local paths retain their usual package roots.
```sh
fabro run review
fabro create ./review.toml --target-path ../app
fabro run review --workflow-git acme/workflows --workflow-ref v1.2 \
--target-git acme/app --target-branch release
fabro create ./review.toml --target-from ../app
fabro run acme/workflows@v1.2:review --target acme/app@release
# Equivalent explicit flags:
fabro run review --workflow-repo acme/workflows --workflow-ref v1.2 \
--target-repo acme/app --target-branch release
```
In the last example, workflow instructions come from `acme/workflows`, and the
@ -164,13 +166,21 @@ run works on `acme/app`. Neither selection changes the other. Local workflow pat
Without target flags, Fabro keeps its existing cwd/environment-based target
inference.
`--workflow-git OWNER/REPO` acquires source using native Git on your machine. A
Remote workflow shorthand is `OWNER/REPO[@REF]:WORKFLOW`. The workflow selector
is required: `acme/workflows:review` selects a named workflow, and
`acme/workflows@v1.2:./reviews/security.toml` selects a file. Repository default
workflows are not supported; without `:WORKFLOW`, the positional argument retains
local lookup behavior. Prefix local paths containing a colon with `./`, `../`,
or `/` to avoid shorthand parsing. Shorthand cannot be combined with
`--workflow-repo` or `--workflow-ref`. Repository slugs currently imply GitHub.com.
`--workflow-repo OWNER/REPO` acquires source using native Git on your machine. A
workflow name selects `.fabro/workflows/NAME/workflow.toml` in that repository;
you can also supply an explicit repository-relative `.toml` or `.fabro` file.
Absolute paths, traversal, and directory selectors are rejected. A missing remote
workflow never falls back to a local or installed workflow.
`--workflow-ref` requires `--workflow-git`. Omit it, or use `HEAD`, to select the
`--workflow-ref` requires `--workflow-repo`. Omit it, or use `HEAD`, to select the
remote default branch. You can select a branch, tag, or full 40-hex commit SHA.
If a branch and tag share a name, qualify it with `refs/heads/` or `refs/tags/`.
Fabro resolves the revision once, fetches that exact commit into a temporary
@ -182,15 +192,19 @@ stops owned Git processes before cleanup; collection already in progress must
finish before its files can be removed.
For local workflows without target flags, the existing `run.scm` repository
configuration still participates in target inference. Explicit `--target-path`
and `--target-git` selections take precedence over that inferred repository.
configuration in `workflow.toml` or `.fabro/project.toml` still participates in
target inference, with workflow values overriding project values field by field.
For clone-based environments, the configured repository must match the checkout's
origin. Without that configuration, omission is equivalent to `--target-from .`.
Explicit `--target-from`, `--target-repo`, and `--target` selections take precedence
over the configured repository.
Target selection depends on the environment:
| Selection | Local environment | Clone-based environment (Docker, Daytona, or plugin) |
| --- | --- | --- |
| Default cwd or `--target-path PATH` | Uses the live directory, including uncommitted files | Uses the enclosing Git repository and exact available commit; a non-Git directory selects an empty workspace |
| `--target-git OWNER/REPO` | Rejected | Uses the selected repository and exact observed branch commit; cloning must be enabled |
| Default cwd or `--target-from PATH` | Uses the live directory, including uncommitted files | Uses the enclosing Git repository and exact available commit; a non-Git directory selects an empty workspace |
| `--target-repo OWNER/REPO` or `--target OWNER/REPO[@BRANCH]` | Rejected | Uses the selected repository and exact observed branch commit; cloning must be enabled |
For clone-based execution, a target path selects a repository, not a subdirectory
working-directory override. Local target files are not uploaded. Existing target
@ -200,10 +214,14 @@ commits fail. Folder targets require the directory to be accessible to the
server and its Local execution environment; passing a caller-local path does not
transfer it to a remote server.
`--target-branch` requires `--target-git` and accepts a working branch name, not a
`--target-branch` requires `--target-repo` and accepts a working branch name, not a
tag or SHA. Without it, Fabro resolves the repository's default branch. The CLI
only looks up target metadata; the execution sandbox clones the target.
`--target-path` and `--target-git` conflict.
The shorthand `--target acme/app@release/v2` selects the working branch
`release/v2`; omit `@BRANCH` to use the remote default branch. Target suffixes
accept working branches, while workflow suffixes accept branches, tags, or SHAs.
`--target`, `--target-from`, and `--target-repo` are mutually exclusive.
`--target-branch` cannot be combined with `--target`.
Local Git credential helpers, SSH-agent access through configured URL rewrites,
and user network configuration govern source acquisition and remote target

View file

@ -342,7 +342,7 @@ fabro create [OPTIONS] <WORKFLOW>
| Name | Description |
| --- | --- |
| `WORKFLOW` | Workflow name or path (repository-relative with --workflow-git) |
| `WORKFLOW` | Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW |
#### Options
@ -361,12 +361,13 @@ fabro create [OPTIONS] <WORKFLOW>
| `--provider <provider>` | Override default LLM provider |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `--target-branch <branch>` | Target working branch (default: remote default branch), pinned to its observed commit |
| `--target-git <owner/repo>` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials |
| `--target-path <path>` | Observe this target directory instead of cwd; Folder targets require server filesystem access |
| `--target-from <path>` | Observe this target directory instead of cwd; Folder targets require server filesystem access |
| `--target-repo <owner/repo>` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials |
| `--target <owner/repo[@branch]>` | Target GitHub repository and optional working branch |
| `-I, --input <key=value>` | Override a workflow input value (repeatable, format: KEY=VALUE) |
| `-v, --verbose` | Enable verbose output |
| `--workflow-git <owner/repo>` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials |
| `--workflow-ref <ref>` | Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names |
| `--workflow-repo <owner/repo>` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials |
### `fabro deny`
@ -1076,7 +1077,7 @@ fabro run [OPTIONS] <WORKFLOW>
| Name | Description |
| --- | --- |
| `WORKFLOW` | Workflow name or path (repository-relative with --workflow-git) |
| `WORKFLOW` | Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW |
#### Options
@ -1095,12 +1096,13 @@ fabro run [OPTIONS] <WORKFLOW>
| `--provider <provider>` | Override default LLM provider |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `--target-branch <branch>` | Target working branch (default: remote default branch), pinned to its observed commit |
| `--target-git <owner/repo>` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials |
| `--target-path <path>` | Observe this target directory instead of cwd; Folder targets require server filesystem access |
| `--target-from <path>` | Observe this target directory instead of cwd; Folder targets require server filesystem access |
| `--target-repo <owner/repo>` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials |
| `--target <owner/repo[@branch]>` | Target GitHub repository and optional working branch |
| `-I, --input <key=value>` | Override a workflow input value (repeatable, format: KEY=VALUE) |
| `-v, --verbose` | Enable verbose output |
| `--workflow-git <owner/repo>` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials |
| `--workflow-ref <ref>` | Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names |
| `--workflow-repo <owner/repo>` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials |
### `fabro sandbox`

View file

@ -233,33 +233,37 @@ pub(crate) struct RunArgs {
#[command(flatten)]
pub(crate) inputs: InputOverrideArgs,
/// Workflow name or path (repository-relative with --workflow-git)
/// Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW
#[arg(required = true)]
pub(crate) workflow: Option<PathBuf>,
/// Acquire workflow source locally from a GitHub OWNER/REPO using native
/// Git credentials
#[arg(long, value_name = "OWNER/REPO")]
pub(crate) workflow_git: Option<GitHubRepositorySlug>,
pub(crate) workflow_repo: Option<GitHubRepositorySlug>,
/// Workflow branch, tag, HEAD (default), or full commit SHA; qualify
/// ambiguous names
#[arg(long, requires = "workflow_git", value_name = "REF")]
#[arg(long, requires = "workflow_repo", value_name = "REF")]
pub(crate) workflow_ref: Option<String>,
/// Observe this target directory instead of cwd; Folder targets require
/// server filesystem access
#[arg(long, conflicts_with = "target_git", value_name = "PATH")]
pub(crate) target_path: Option<PathBuf>,
#[arg(long, conflicts_with_all = ["target_repo", "target_repo_selector"], value_name = "PATH")]
pub(crate) target_from: Option<PathBuf>,
/// Target GitHub repository and optional working branch
#[arg(long = "target", conflicts_with_all = ["target_repo", "target_branch"], value_name = "OWNER/REPO[@BRANCH]")]
pub(crate) target_repo_selector: Option<String>,
/// Target GitHub OWNER/REPO; the execution sandbox still needs its own
/// clone credentials
#[arg(long, value_name = "OWNER/REPO")]
pub(crate) target_git: Option<GitHubRepositorySlug>,
pub(crate) target_repo: Option<GitHubRepositorySlug>,
/// Target working branch (default: remote default branch), pinned to its
/// observed commit
#[arg(long, requires = "target_git", value_name = "BRANCH")]
#[arg(long, requires = "target_repo", value_name = "BRANCH")]
pub(crate) target_branch: Option<String>,
/// Simulate execution; workflow source may still be fetched and uploaded
@ -2011,16 +2015,16 @@ mod run_selection_grammar_tests {
for flags in [
vec![
"review",
"--workflow-git",
"--workflow-repo",
"acme/workflows",
"--workflow-ref",
"refs/tags/v1",
"--target-git",
"--target-repo",
"acme/app",
"--target-branch",
"release/topic",
],
vec!["./review.toml", "--target-path", "../app"],
vec!["./review.toml", "--target-from", "../app"],
] {
assert!(parse_run_args(flags).is_ok());
}
@ -2031,8 +2035,8 @@ mod run_selection_grammar_tests {
for flags in [
vec!["review", "--workflow-ref", "v1"],
vec!["review", "--target-branch", "release"],
vec!["review", "--target-path", ".", "--target-git", "acme/app"],
vec!["--workflow-git", "acme/workflows"],
vec!["review", "--target-from", ".", "--target-repo", "acme/app"],
vec!["--workflow-repo", "acme/workflows"],
] {
assert!(parse_run_args(flags).is_err());
}

View file

@ -100,7 +100,9 @@ pub(crate) async fn create_run(
// path. Explicit targets select their own repository independently.
let configured_repo_origin_url = match &package {
ResolvedWorkflow::Local(package)
if args.target_path.is_none() && args.target_git.is_none() =>
if args.target_from.is_none()
&& args.target_repo.is_none()
&& args.target_repo_selector.is_none() =>
{
fabro_manifest::configured_repo_origin_url_for_location(package.workflow_location())?
}

View file

@ -165,26 +165,27 @@ mod tests {
fn run_args() -> RunArgs {
RunArgs {
target: ServerTargetArgs::default(),
inputs: InputOverrideArgs::default(),
workflow: Some(PathBuf::from("workflow.fabro")),
workflow_git: None,
workflow_ref: None,
target_path: None,
target_git: None,
target_branch: None,
dry_run: false,
auto_approve: false,
goal: None,
goal_file: None,
model: None,
provider: None,
verbose: false,
environment: None,
label: Vec::new(),
parent: None,
preserve_sandbox: false,
detach: false,
target: ServerTargetArgs::default(),
inputs: InputOverrideArgs::default(),
workflow: Some(PathBuf::from("workflow.fabro")),
workflow_repo: None,
workflow_ref: None,
target_from: None,
target_repo_selector: None,
target_repo: None,
target_branch: None,
dry_run: false,
auto_approve: false,
goal: None,
goal_file: None,
model: None,
provider: None,
verbose: false,
environment: None,
label: Vec::new(),
parent: None,
preserve_sandbox: false,
detach: false,
}
}

View file

@ -461,7 +461,15 @@ impl Interruption {
}
pub(crate) fn for_run_args(args: &RunArgs) -> Self {
Self::new(args.workflow_git.is_some() || args.target_git.is_some())
Self::new(
args.workflow_repo.is_some()
|| args.target_repo.is_some()
|| args.target_repo_selector.is_some()
|| args
.workflow
.as_deref()
.is_some_and(|path| super::selection::workflow_shorthand(path).is_some()),
)
}
/// Run `work` to completion, or until Ctrl-C cancels it and every owned
@ -522,6 +530,19 @@ mod tests {
use super::super::test_support::{commit_all, write_workflow};
use super::*;
#[test]
fn shorthand_acquisition_owns_interruption_but_local_paths_do_not() {
for (flags, listens) in [
(vec!["acme/workflows:review"], true),
(vec!["review", "--target", "acme/app@main"], true),
(vec!["./acme/workflows:review"], false),
(vec!["review", "--target-from", "."], false),
] {
let args = super::super::test_support::parse_run_args(flags).unwrap();
assert_eq!(Interruption::for_run_args(&args).listens, listens);
}
}
struct Fixture {
root: tempfile::TempDir,
repo: git2::Repository,

View file

@ -88,12 +88,50 @@ pub(super) fn validate_remote_selector(path: &Path) -> anyhow::Result<()> {
}
}
/// Explicit local paths escape the shorthand grammar, including colons in
/// file names. A repository without `:WORKFLOW` retains local lookup behavior.
pub(super) fn workflow_shorthand(path: &Path) -> Option<(&str, &str)> {
let value = path.to_str()?;
if path.is_absolute() || value.starts_with("./") || value.starts_with("../") {
return None;
}
value.split_once(':')
}
fn repository_revision(value: &str) -> anyhow::Result<(GitHubRepositorySlug, Option<&str>)> {
let (repository, revision) = value
.split_once('@')
.map_or((value, None), |(repository, revision)| {
(repository, Some(revision))
});
let repository = repository
.parse()
.context("repository must be a GitHub OWNER/REPO")?;
if revision == Some("") {
bail!("a revision or branch is required after '@'");
}
Ok((repository, revision))
}
pub(super) fn parse(args: &RunArgs) -> anyhow::Result<(WorkflowSelection, TargetSelection)> {
// Flag co-occurrence rules (`requires`/`conflicts_with`) are enforced by clap.
let workflow = args.workflow.as_ref().context("workflow is required")?;
let workflow = match &args.workflow_git {
None => WorkflowSelection::Local(workflow.clone()),
Some(repository) => {
let workflow = match (&args.workflow_repo, workflow_shorthand(workflow)) {
(_, Some(_)) if args.workflow_repo.is_some() || args.workflow_ref.is_some() => {
bail!("workflow shorthand cannot be combined with --workflow-repo or --workflow-ref");
}
(None, Some((source, selector))) => {
let (repository, revision) = repository_revision(source)?;
let selector = PathBuf::from(selector);
validate_remote_selector(&selector)?;
WorkflowSelection::Git {
repository,
selector,
revision: RemoteWorkflowRevision::parse(revision)?,
}
}
(None, None) => WorkflowSelection::Local(workflow.clone()),
(Some(repository), _) => {
validate_remote_selector(workflow)?;
WorkflowSelection::Git {
repository: repository.clone(),
@ -102,28 +140,32 @@ pub(super) fn parse(args: &RunArgs) -> anyhow::Result<(WorkflowSelection, Target
}
}
};
let target = match (&args.target_path, &args.target_git) {
(Some(path), _) => TargetSelection::Path(path.clone()),
(_, Some(repository)) => {
if args
.target_branch
.as_deref()
.is_some_and(|branch| !repository::is_valid_git_branch_name(branch))
{
bail!(
"target branch must be a working branch name, not a tag, SHA, or qualified ref"
);
}
TargetSelection::Git {
repository: repository.clone(),
branch: args.target_branch.clone(),
}
let target = if let Some(value) = &args.target_repo_selector {
let (repository, branch) = repository_revision(value)?;
git_target(repository, branch)?
} else {
match (&args.target_from, &args.target_repo) {
(Some(path), _) => TargetSelection::Path(path.clone()),
(_, Some(repository)) => git_target(repository.clone(), args.target_branch.as_deref())?,
_ => TargetSelection::Path(PathBuf::from(".")),
}
_ => TargetSelection::Path(PathBuf::from(".")),
};
Ok((workflow, target))
}
fn git_target(
repository: GitHubRepositorySlug,
branch: Option<&str>,
) -> anyhow::Result<TargetSelection> {
if branch.is_some_and(|branch| !repository::is_valid_git_branch_name(branch)) {
bail!("target branch must be a working branch name, not a tag, SHA, or qualified ref");
}
Ok(TargetSelection::Git {
repository,
branch: branch.map(str::to_owned),
})
}
#[cfg(test)]
mod tests {
use super::*;
@ -202,6 +244,118 @@ mod adapter_tests {
use super::*;
use crate::args::{Cli, Commands, RunCommands};
#[test]
fn shorthand_matches_explicit_selections_for_both_commands() {
for command in ["run", "create"] {
for (suffix, reference) in [
("", None),
("@v1.2", Some("v1.2")),
("@release/v2", Some("release/v2")),
("@refs/tags/v1", Some("refs/tags/v1")),
(
"@abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd",
Some("abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd"),
),
] {
for selector in ["review", "./reviews/security.toml"] {
for branch in [None, Some("release/v2")] {
let workflow = format!("acme/workflows{suffix}:{selector}");
let target = branch.map_or_else(
|| "acme/app".to_owned(),
|branch| format!("acme/app@{branch}"),
);
let short = ["fabro", command, &workflow, "--target", &target];
let mut explicit = vec![
"fabro",
command,
selector,
"--workflow-repo",
"acme/workflows",
"--target-repo",
"acme/app",
];
if let Some(reference) = reference {
explicit.extend(["--workflow-ref", reference]);
}
if let Some(branch) = branch {
explicit.extend(["--target-branch", branch]);
}
let selections = |argv: &[&str]| {
let cli = Cli::try_parse_from(argv).unwrap();
let Commands::RunCmd(
RunCommands::Run(args) | RunCommands::Create(args),
) = *cli.command.unwrap()
else {
panic!("expected run args")
};
parse(&args).unwrap()
};
assert_eq!(selections(&short), selections(&explicit));
}
}
}
}
}
#[test]
fn shorthand_preserves_local_paths_and_requires_remote_workflow_selector() {
for value in [
"review",
"dir/review.toml",
"acme/workflows",
"acme/workflows@v1",
"./acme/workflows:review",
"../acme/workflows:review",
"/tmp/workflows:review",
] {
let args = parse_run_args([value]).unwrap();
assert_eq!(
parse(&args).unwrap(),
(
WorkflowSelection::Local(value.into()),
TargetSelection::Path(".".into())
)
);
}
}
#[test]
fn shorthand_rejects_malformed_or_conflicting_selections_before_acquisition() {
for flags in [
vec!["acme/workflows:"],
vec!["acme/workflows@:review"],
vec!["acme/workflows@HEAD~1:review"],
vec!["acme/workflows:../review.toml"],
vec!["acme/workflows:/review.toml"],
vec!["acme/workflows/extra:review"],
vec!["https://github.com/acme/workflows:review"],
vec!["acme/workflows:review", "--workflow-repo", "acme/other"],
vec!["acme/workflows:review", "--workflow-ref", "v1"],
vec!["review", "--target", "acme/app@"],
vec!["review", "--target", "acme/app@refs/tags/v1"],
vec![
"review",
"--target",
"acme/app@abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd",
],
vec!["review", "--target", "acme/app@main..next"],
vec!["review", "--target", "acme/app/extra"],
vec!["review", "--target", "acme/app", "--target-from", "."],
vec![
"review",
"--target",
"acme/app",
"--target-repo",
"acme/app",
],
vec!["review", "--target", "acme/app", "--target-branch", "main"],
] {
if let Ok(args) = parse_run_args(flags.iter().copied()) {
assert!(parse(&args).is_err(), "{flags:?}");
}
}
}
#[test]
fn run_selection_both_commands_share_the_adapter() {
for command in ["run", "create"] {
@ -209,11 +363,11 @@ mod adapter_tests {
"fabro",
command,
"review",
"--workflow-git",
"--workflow-repo",
"acme/workflows",
"--workflow-ref",
"v1",
"--target-git",
"--target-repo",
"acme/app",
"--target-branch",
"release",
@ -251,33 +405,33 @@ mod adapter_tests {
for flags in [
[
"review",
"--workflow-git",
"--workflow-repo",
"https://github.com/acme/workflows",
],
["review", "--target-git", "acme/app/extra"],
["review", "--target-repo", "acme/app/extra"],
] {
assert!(parse_run_args(flags).is_err());
}
for flags in [
vec!["../review.toml", "--workflow-git", "acme/workflows"],
vec!["/tmp/review.toml", "--workflow-git", "acme/workflows"],
vec!["../review.toml", "--workflow-repo", "acme/workflows"],
vec!["/tmp/review.toml", "--workflow-repo", "acme/workflows"],
vec![
"review",
"--workflow-git",
"--workflow-repo",
"acme/workflows",
"--workflow-ref",
"HEAD~1",
],
vec![
"review",
"--target-git",
"--target-repo",
"acme/app",
"--target-branch",
"refs/tags/v1",
],
vec![
"review",
"--target-git",
"--target-repo",
"acme/app",
"--target-branch",
"1234567890123456789012345678901234567890",

View file

@ -74,33 +74,34 @@ fn help() {
Usage: fabro create [OPTIONS] <WORKFLOW>
Arguments:
<WORKFLOW> Workflow name or path (repository-relative with --workflow-git)
<WORKFLOW> Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW
Options:
--json Output as JSON [env: FABRO_JSON=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
-I, --input <KEY=VALUE> Override a workflow input value (repeatable, format: KEY=VALUE)
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--workflow-git <OWNER/REPO> Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--workflow-ref <REF> Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names
--target-path <PATH> Observe this target directory instead of cwd; Folder targets require server filesystem access
--target-git <OWNER/REPO> Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials
--target-branch <BRANCH> Target working branch (default: remote default branch), pinned to its observed commit
--dry-run Simulate execution; workflow source may still be fetched and uploaded
--auto-approve Auto-approve all human gates
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
--goal-file <GOAL_FILE> Read a per-run goal value from a local file
--model <MODEL> Override default LLM model
--provider <PROVIDER> Override default LLM provider
-v, --verbose Enable verbose output
--environment <ENVIRONMENT> Named environment for agent tools
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
--parent <RUN> Link this run to an existing orchestration parent run
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)
-d, --detach Run the workflow in the background and print the run ID
-h, --help Print help
--json Output as JSON [env: FABRO_JSON=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
-I, --input <KEY=VALUE> Override a workflow input value (repeatable, format: KEY=VALUE)
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--workflow-repo <OWNER/REPO> Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--workflow-ref <REF> Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names
--target-from <PATH> Observe this target directory instead of cwd; Folder targets require server filesystem access
--target <OWNER/REPO[@BRANCH]> Target GitHub repository and optional working branch
--target-repo <OWNER/REPO> Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials
--target-branch <BRANCH> Target working branch (default: remote default branch), pinned to its observed commit
--dry-run Simulate execution; workflow source may still be fetched and uploaded
--auto-approve Auto-approve all human gates
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
--goal-file <GOAL_FILE> Read a per-run goal value from a local file
--model <MODEL> Override default LLM model
--provider <PROVIDER> Override default LLM provider
-v, --verbose Enable verbose output
--environment <ENVIRONMENT> Named environment for agent tools
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
--parent <RUN> Link this run to an existing orchestration parent run
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)
-d, --detach Run the workflow in the background and print the run ID
-h, --help Print help
----- stderr -----
");
}
@ -729,7 +730,7 @@ fn create_preserves_configured_repository_inference_but_explicit_target_path_win
"--server",
&server_url,
workflow.to_str().unwrap(),
"--target-path",
"--target-from",
".",
])
.output()
@ -749,9 +750,36 @@ fn create_preserves_configured_repository_inference_but_explicit_target_path_win
.to_string(),
sha
);
environment.assert_calls(2);
versions.assert_calls(1);
create.assert_calls(1);
let config = root.path().join("gitconfig");
std::fs::write(
&config,
format!(
"[url \"file://{}\"]\n insteadOf = https://github.com/acme/actual\n",
origin.display()
),
)
.unwrap();
let shorthand = context
.create_cmd()
.current_dir(&checkout)
.env("GIT_CONFIG_GLOBAL", &config)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_COUNT", "0")
.args([
"--server",
&server_url,
workflow.to_str().unwrap(),
"--target",
"acme/actual@topic",
])
.output()
.unwrap();
assert!(shorthand.status.success(), "{}", output_stderr(&shorthand));
let requests = requests.lock().unwrap();
assert_eq!(requests[0]["target"], requests[1]["target"]);
environment.assert_calls(3);
versions.assert_calls(2);
create.assert_calls(2);
}
#[test]
@ -1744,8 +1772,8 @@ fn run_selection_source_target_cross_product_keeps_workflow_goal_and_target_inde
.closure()
.root_id();
assert_ne!(local_id, remote_id);
for source_kind in ["name", "file", "git"] {
for target_kind in ["inferred", "path", "git", "git-plugin"] {
for source_kind in ["name", "file", "git", "shorthand"] {
for target_kind in ["inferred", "path", "git", "git-plugin", "shorthand"] {
let mut command = context.create_cmd();
command
.current_dir(&caller)
@ -1758,26 +1786,31 @@ fn run_selection_source_target_cross_product_keeps_workflow_goal_and_target_inde
"--goal-file",
"goal.txt",
]);
command.arg(if source_kind == "file" {
command.arg(if source_kind == "shorthand" {
"acme/workflows@trunk:review"
} else if source_kind == "file" {
".fabro/workflows/review/workflow.toml"
} else {
"review"
});
if source_kind == "git" {
command.args([
"--workflow-git",
"--workflow-repo",
"acme/workflows",
"--workflow-ref",
"trunk",
]);
}
match target_kind {
"shorthand" => {
command.args(["--target", "acme/app@release", "--environment", "docker"]);
}
"path" => {
command.args(["--target-path", "../target", "--environment", "local"]);
command.args(["--target-from", "../target", "--environment", "local"]);
}
"git" | "git-plugin" => {
command.args([
"--target-git",
"--target-repo",
"acme/app",
"--target-branch",
"release",
@ -1804,7 +1837,7 @@ fn run_selection_source_target_cross_product_keeps_workflow_goal_and_target_inde
assert_eq!(
intent["workflow_version_id"],
match source_kind {
"git" => remote_id,
"git" | "shorthand" => remote_id,
"file" => file_id,
_ => local_id,
}
@ -1813,17 +1846,17 @@ fn run_selection_source_target_cross_product_keeps_workflow_goal_and_target_inde
assert_eq!(intent["goal"], "Caller goal");
assert_eq!(intent["target"], match target_kind {
"path" => json!({"kind":"folder","path":target.canonicalize().unwrap()}),
"git" | "git-plugin" =>
"git" | "git-plugin" | "shorthand" =>
json!({"kind":"git","repo":"acme/app","branch":"release","sha":target_sha}),
_ => json!({"kind":"none"}),
});
}
}
local_env.assert_calls(3);
docker_env.assert_calls(6);
plugin_env.assert_calls(3);
versions.assert_calls(12);
create.assert_calls(12);
local_env.assert_calls(4);
docker_env.assert_calls(12);
plugin_env.assert_calls(4);
versions.assert_calls(20);
create.assert_calls(20);
}
#[test]
@ -1866,7 +1899,7 @@ fn create_leaves_remote_workflow_run_submitted_without_starting() {
.env("GIT_TRACE", &trace)
.args([
"review",
"--workflow-git",
"--workflow-repo",
"acme/workflows",
"--server",
&format!("{}/api/v1", server.base_url()),
@ -1945,7 +1978,7 @@ fn remote_workflow_explicit_acquisition_failure_has_no_fallback_or_server_mutati
.env("GIT_CONFIG_COUNT", "0")
.args([
"review",
"--workflow-git",
"--workflow-repo",
"acme/workflows",
"--workflow-ref",
reference,

View file

@ -124,33 +124,34 @@ fn help() {
Usage: fabro run [OPTIONS] <WORKFLOW>
Arguments:
<WORKFLOW> Workflow name or path (repository-relative with --workflow-git)
<WORKFLOW> Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW
Options:
--json Output as JSON [env: FABRO_JSON=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
-I, --input <KEY=VALUE> Override a workflow input value (repeatable, format: KEY=VALUE)
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--workflow-git <OWNER/REPO> Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--workflow-ref <REF> Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names
--target-path <PATH> Observe this target directory instead of cwd; Folder targets require server filesystem access
--target-git <OWNER/REPO> Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials
--target-branch <BRANCH> Target working branch (default: remote default branch), pinned to its observed commit
--dry-run Simulate execution; workflow source may still be fetched and uploaded
--auto-approve Auto-approve all human gates
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
--goal-file <GOAL_FILE> Read a per-run goal value from a local file
--model <MODEL> Override default LLM model
--provider <PROVIDER> Override default LLM provider
-v, --verbose Enable verbose output
--environment <ENVIRONMENT> Named environment for agent tools
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
--parent <RUN> Link this run to an existing orchestration parent run
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)
-d, --detach Run the workflow in the background and print the run ID
-h, --help Print help
--json Output as JSON [env: FABRO_JSON=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
-I, --input <KEY=VALUE> Override a workflow input value (repeatable, format: KEY=VALUE)
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--workflow-repo <OWNER/REPO> Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--workflow-ref <REF> Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names
--target-from <PATH> Observe this target directory instead of cwd; Folder targets require server filesystem access
--target <OWNER/REPO[@BRANCH]> Target GitHub repository and optional working branch
--target-repo <OWNER/REPO> Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials
--target-branch <BRANCH> Target working branch (default: remote default branch), pinned to its observed commit
--dry-run Simulate execution; workflow source may still be fetched and uploaded
--auto-approve Auto-approve all human gates
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
--goal-file <GOAL_FILE> Read a per-run goal value from a local file
--model <MODEL> Override default LLM model
--provider <PROVIDER> Override default LLM provider
-v, --verbose Enable verbose output
--environment <ENVIRONMENT> Named environment for agent tools
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
--parent <RUN> Link this run to an existing orchestration parent run
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)
-d, --detach Run the workflow in the background and print the run ID
-h, --help Print help
----- stderr -----
");
}
@ -1225,9 +1226,7 @@ fn run_starts_remote_workflow_once_and_failures_do_not_refetch() {
.env("GIT_CONFIG_COUNT", "0")
.env("GIT_TRACE", &trace)
.args([
"review",
"--workflow-git",
"acme/workflows",
"acme/workflows:review",
"--server",
&format!("{}/api/v1", server.base_url()),
"--dry-run",