From 86bcc8f128d7766cc77721d731f9ad7fb98bd254 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Sat, 12 Sep 2026 14:18:34 -0600 Subject: [PATCH] Simplify CLI repository selectors and add workflow shorthand --- docs/public/core-concepts/workflows.mdx | 46 ++-- docs/public/reference/cli.mdx | 18 +- lib/apps/fabro-cli/src/args.rs | 28 ++- lib/apps/fabro-cli/src/commands/run/create.rs | 4 +- .../fabro-cli/src/commands/run/overrides.rs | 41 ++-- .../src/commands/run/remote_workflow.rs | 23 +- .../fabro-cli/src/commands/run/selection.rs | 212 +++++++++++++++--- lib/apps/fabro-cli/tests/it/cmd/create.rs | 121 ++++++---- lib/apps/fabro-cli/tests/it/cmd/run.rs | 55 +++-- 9 files changed, 391 insertions(+), 157 deletions(-) diff --git a/docs/public/core-concepts/workflows.mdx b/docs/public/core-concepts/workflows.mdx index 61bb09b5a..af2866ed2 100644 --- a/docs/public/core-concepts/workflows.mdx +++ b/docs/public/core-concepts/workflows.mdx @@ -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 diff --git a/docs/public/reference/cli.mdx b/docs/public/reference/cli.mdx index 8a4822416..659b31bf2 100644 --- a/docs/public/reference/cli.mdx +++ b/docs/public/reference/cli.mdx @@ -342,7 +342,7 @@ fabro create [OPTIONS] | 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] | `--provider ` | Override default LLM provider | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--target-branch ` | Target working branch (default: remote default branch), pinned to its observed commit | -| `--target-git ` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials | -| `--target-path ` | Observe this target directory instead of cwd; Folder targets require server filesystem access | +| `--target-from ` | Observe this target directory instead of cwd; Folder targets require server filesystem access | +| `--target-repo ` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials | +| `--target ` | Target GitHub repository and optional working branch | | `-I, --input ` | Override a workflow input value (repeatable, format: KEY=VALUE) | | `-v, --verbose` | Enable verbose output | -| `--workflow-git ` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials | | `--workflow-ref ` | Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names | +| `--workflow-repo ` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials | ### `fabro deny` @@ -1076,7 +1077,7 @@ fabro run [OPTIONS] | 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] | `--provider ` | Override default LLM provider | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--target-branch ` | Target working branch (default: remote default branch), pinned to its observed commit | -| `--target-git ` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials | -| `--target-path ` | Observe this target directory instead of cwd; Folder targets require server filesystem access | +| `--target-from ` | Observe this target directory instead of cwd; Folder targets require server filesystem access | +| `--target-repo ` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials | +| `--target ` | Target GitHub repository and optional working branch | | `-I, --input ` | Override a workflow input value (repeatable, format: KEY=VALUE) | | `-v, --verbose` | Enable verbose output | -| `--workflow-git ` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials | | `--workflow-ref ` | Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names | +| `--workflow-repo ` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials | ### `fabro sandbox` diff --git a/lib/apps/fabro-cli/src/args.rs b/lib/apps/fabro-cli/src/args.rs index fab071a5a..ac90101d9 100644 --- a/lib/apps/fabro-cli/src/args.rs +++ b/lib/apps/fabro-cli/src/args.rs @@ -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, /// Acquire workflow source locally from a GitHub OWNER/REPO using native /// Git credentials #[arg(long, value_name = "OWNER/REPO")] - pub(crate) workflow_git: Option, + pub(crate) workflow_repo: Option, /// 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, /// 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, + #[arg(long, conflicts_with_all = ["target_repo", "target_repo_selector"], value_name = "PATH")] + pub(crate) target_from: Option, + + /// 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, /// Target GitHub OWNER/REPO; the execution sandbox still needs its own /// clone credentials #[arg(long, value_name = "OWNER/REPO")] - pub(crate) target_git: Option, + pub(crate) target_repo: Option, /// 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, /// 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()); } diff --git a/lib/apps/fabro-cli/src/commands/run/create.rs b/lib/apps/fabro-cli/src/commands/run/create.rs index 9fe8d061b..6e07c0a65 100644 --- a/lib/apps/fabro-cli/src/commands/run/create.rs +++ b/lib/apps/fabro-cli/src/commands/run/create.rs @@ -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())? } diff --git a/lib/apps/fabro-cli/src/commands/run/overrides.rs b/lib/apps/fabro-cli/src/commands/run/overrides.rs index a925b9b01..9f1f49d70 100644 --- a/lib/apps/fabro-cli/src/commands/run/overrides.rs +++ b/lib/apps/fabro-cli/src/commands/run/overrides.rs @@ -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, } } diff --git a/lib/apps/fabro-cli/src/commands/run/remote_workflow.rs b/lib/apps/fabro-cli/src/commands/run/remote_workflow.rs index d1244b907..8bec413d7 100644 --- a/lib/apps/fabro-cli/src/commands/run/remote_workflow.rs +++ b/lib/apps/fabro-cli/src/commands/run/remote_workflow.rs @@ -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, diff --git a/lib/apps/fabro-cli/src/commands/run/selection.rs b/lib/apps/fabro-cli/src/commands/run/selection.rs index e560a6b4f..d413487e7 100644 --- a/lib/apps/fabro-cli/src/commands/run/selection.rs +++ b/lib/apps/fabro-cli/src/commands/run/selection.rs @@ -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 { + 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", diff --git a/lib/apps/fabro-cli/tests/it/cmd/create.rs b/lib/apps/fabro-cli/tests/it/cmd/create.rs index ab3e62578..4b73e98fe 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/create.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/create.rs @@ -74,33 +74,34 @@ fn help() { Usage: fabro create [OPTIONS] Arguments: - Workflow name or path (repository-relative with --workflow-git) + Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW Options: - --json Output as JSON [env: FABRO_JSON=] - --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 Override a workflow input value (repeatable, format: KEY=VALUE) - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --workflow-git Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --workflow-ref Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names - --target-path Observe this target directory instead of cwd; Folder targets require server filesystem access - --target-git Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials - --target-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 Override the workflow goal (available as {{ goal }} in prompts) - --goal-file Read a per-run goal value from a local file - --model Override default LLM model - --provider Override default LLM provider - -v, --verbose Enable verbose output - --environment Named environment for agent tools - --label Attach a label to this run (repeatable, format: KEY=VALUE) - --parent 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 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 Override a workflow input value (repeatable, format: KEY=VALUE) + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --workflow-repo Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --workflow-ref Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names + --target-from Observe this target directory instead of cwd; Folder targets require server filesystem access + --target Target GitHub repository and optional working branch + --target-repo Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials + --target-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 Override the workflow goal (available as {{ goal }} in prompts) + --goal-file Read a per-run goal value from a local file + --model Override default LLM model + --provider Override default LLM provider + -v, --verbose Enable verbose output + --environment Named environment for agent tools + --label Attach a label to this run (repeatable, format: KEY=VALUE) + --parent 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, diff --git a/lib/apps/fabro-cli/tests/it/cmd/run.rs b/lib/apps/fabro-cli/tests/it/cmd/run.rs index 19e389bfb..b5a00d326 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/run.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/run.rs @@ -124,33 +124,34 @@ fn help() { Usage: fabro run [OPTIONS] Arguments: - Workflow name or path (repository-relative with --workflow-git) + Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW Options: - --json Output as JSON [env: FABRO_JSON=] - --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 Override a workflow input value (repeatable, format: KEY=VALUE) - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --workflow-git Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --workflow-ref Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names - --target-path Observe this target directory instead of cwd; Folder targets require server filesystem access - --target-git Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials - --target-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 Override the workflow goal (available as {{ goal }} in prompts) - --goal-file Read a per-run goal value from a local file - --model Override default LLM model - --provider Override default LLM provider - -v, --verbose Enable verbose output - --environment Named environment for agent tools - --label Attach a label to this run (repeatable, format: KEY=VALUE) - --parent 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 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 Override a workflow input value (repeatable, format: KEY=VALUE) + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --workflow-repo Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --workflow-ref Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names + --target-from Observe this target directory instead of cwd; Folder targets require server filesystem access + --target Target GitHub repository and optional working branch + --target-repo Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials + --target-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 Override the workflow goal (available as {{ goal }} in prompts) + --goal-file Read a per-run goal value from a local file + --model Override default LLM model + --provider Override default LLM provider + -v, --verbose Enable verbose output + --environment Named environment for agent tools + --label Attach a label to this run (repeatable, format: KEY=VALUE) + --parent 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",