From f8304243f1fcee042b936c52b66f2d6d1522164e Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 00:21:58 -0700 Subject: [PATCH 01/11] feat(run): detect pipeline_not_found from cloud submit --- crates/hm/src/commands/run/mod.rs | 50 +++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/hm/src/commands/run/mod.rs b/crates/hm/src/commands/run/mod.rs index 8c92e320..8bc0a69d 100644 --- a/crates/hm/src/commands/run/mod.rs +++ b/crates/hm/src/commands/run/mod.rs @@ -306,6 +306,24 @@ fn backend_anyhow(err: &hm_exec::BackendError) -> anyhow::Error { HmError::Backend(explain(err), exit_category(err)).into() } +/// The server's structured error code for "no pipeline matches this +/// `(repo_name, source_slug)`" — the signal that `hm run --cloud` is the +/// repo's first cloud build and the pipeline must be created. +const PIPELINE_NOT_FOUND_CODE: &str = "pipeline_not_found"; + +/// Whether a backend error means "the pipeline doesn't exist yet". The cloud +/// submit path surfaces this as a structured `Rejected { code }` (current SDK); +/// we also accept an opaque `NotFound` body carrying the code, for robustness +/// against older servers that took the un-structured 404 path. +fn is_missing_pipeline(err: &hm_exec::BackendError) -> bool { + use hm_exec::BackendError as E; + match err { + E::Rejected { code, .. } => code == PIPELINE_NOT_FOUND_CODE, + E::NotFound(body) => body.contains(PIPELINE_NOT_FOUND_CODE), + _ => false, + } +} + /// Map a [`hm_exec::BackendError`] to the process exit-code category. /// /// Note: the old taxonomy distinguished a downed Docker daemon @@ -414,6 +432,38 @@ error[backend]: {other} mod tests { use super::*; + #[test] + fn missing_pipeline_detected_from_structured_reject() { + let err = hm_exec::BackendError::Rejected { + code: "pipeline_not_found".into(), + message: "No pipeline with that slug exists in this organization.".into(), + }; + assert!(is_missing_pipeline(&err)); + } + + #[test] + fn other_reject_is_not_missing_pipeline() { + let err = hm_exec::BackendError::Rejected { + code: "build_rejected".into(), + message: "pipeline_ir invalid".into(), + }; + assert!(!is_missing_pipeline(&err)); + } + + #[test] + fn missing_pipeline_detected_from_opaque_not_found() { + let err = hm_exec::BackendError::NotFound( + r#"{"error":{"code":"pipeline_not_found"}}"#.into(), + ); + assert!(is_missing_pipeline(&err)); + } + + #[test] + fn transport_error_is_not_missing_pipeline() { + let err = hm_exec::BackendError::Transport("connection refused".into()); + assert!(!is_missing_pipeline(&err)); + } + #[test] fn parse_repo_name_handles_https_ssh_and_scp() { assert_eq!( From 1a7347882493e4b664338d029d4f71f16f721704 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 00:25:58 -0700 Subject: [PATCH 02/11] feat(run): add git remote-url and default-branch helpers --- crates/hm/src/commands/run/mod.rs | 52 +++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/hm/src/commands/run/mod.rs b/crates/hm/src/commands/run/mod.rs index 8bc0a69d..2af1b12c 100644 --- a/crates/hm/src/commands/run/mod.rs +++ b/crates/hm/src/commands/run/mod.rs @@ -229,8 +229,16 @@ fn parse_repo_name(url: &str) -> Option { Some(segs[segs.len() - 2..].join("/")) } -/// Best-effort `owner/repo` from the worktree's `origin` remote. -fn git_remote_repo_name(root: &std::path::Path) -> Option { +/// Extract the default branch name from a `git symbolic-ref +/// refs/remotes/origin/HEAD` result (e.g. `refs/remotes/origin/main` → `main`). +/// `None` when the line is empty or lacks the expected prefix. +fn parse_default_branch(symbolic_ref: &str) -> Option { + let branch = symbolic_ref.trim().strip_prefix("refs/remotes/origin/")?; + (!branch.is_empty()).then(|| branch.to_string()) +} + +/// The worktree's raw `origin` remote URL (the pipeline's `repository`). +fn git_remote_url(root: &std::path::Path) -> Option { let out = std::process::Command::new("git") .arg("-C") .arg(root) @@ -238,7 +246,26 @@ fn git_remote_repo_name(root: &std::path::Path) -> Option { .output() .ok() .filter(|o| o.status.success())?; - parse_repo_name(&String::from_utf8_lossy(&out.stdout)) + let url = String::from_utf8_lossy(&out.stdout).trim().to_string(); + (!url.is_empty()).then_some(url) +} + +/// The repo's default branch, from `origin/HEAD`. `None` when `origin/HEAD` +/// isn't set (common on fresh clones without `git remote set-head`). +fn git_default_branch(root: &std::path::Path) -> Option { + let out = std::process::Command::new("git") + .arg("-C") + .arg(root) + .args(["symbolic-ref", "refs/remotes/origin/HEAD"]) + .output() + .ok() + .filter(|o| o.status.success())?; + parse_default_branch(&String::from_utf8_lossy(&out.stdout)) +} + +/// Best-effort `owner/repo` from the worktree's `origin` remote. +fn git_remote_repo_name(root: &std::path::Path) -> Option { + parse_repo_name(&git_remote_url(root)?) } /// Resolve repo root, detect the DSL, select the pipeline slug, and render @@ -490,6 +517,25 @@ mod tests { assert_eq!(parse_repo_name("not-a-url"), None); } + #[test] + fn parses_default_branch_from_symbolic_ref() { + assert_eq!( + parse_default_branch("refs/remotes/origin/main\n").as_deref(), + Some("main") + ); + assert_eq!( + parse_default_branch("refs/remotes/origin/master").as_deref(), + Some("master") + ); + } + + #[test] + fn default_branch_none_when_unexpected_or_empty() { + assert_eq!(parse_default_branch(""), None); + assert_eq!(parse_default_branch("refs/heads/main"), None); + assert_eq!(parse_default_branch("refs/remotes/origin/"), None); + } + #[test] fn parse_env_splits_pairs() { let m = parse_env(&["A=1".into(), "B=x=y".into(), "bad".into()]); From f3ee4663f0b37d8dc144259d4db930b4e9bbe848 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 00:33:43 -0700 Subject: [PATCH 03/11] feat(run): build CreatePipelineRequest from repo identity --- crates/hm/src/commands/run/mod.rs | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/hm/src/commands/run/mod.rs b/crates/hm/src/commands/run/mod.rs index 2af1b12c..90e9e542 100644 --- a/crates/hm/src/commands/run/mod.rs +++ b/crates/hm/src/commands/run/mod.rs @@ -351,6 +351,25 @@ fn is_missing_pipeline(err: &hm_exec::BackendError) -> bool { } } +/// Build the create-pipeline request body. `name` is the in-repo source slug +/// (so the server's derived global slug matches what the retry submits); +/// `repo_name` (`owner/repo`) is passed explicitly so the server need not +/// re-parse the clone URL. +fn build_create_pipeline_request( + name: &str, + default_branch: &str, + repository: &str, + repo_name: &str, +) -> harmont_cloud::types::CreatePipelineRequest { + harmont_cloud::types::CreatePipelineRequest { + default_branch: default_branch.to_string(), + description: None, + name: name.to_string(), + repo_name: Some(repo_name.to_string()), + repository: repository.to_string(), + } +} + /// Map a [`hm_exec::BackendError`] to the process exit-code category. /// /// Note: the old taxonomy distinguished a downed Docker daemon @@ -610,4 +629,19 @@ mod tests { ErrorCategory::Usage ); } + + #[test] + fn create_request_maps_fields_and_sets_repo_name() { + let body = build_create_pipeline_request( + "web", + "main", + "git@github.com:acme/my-app.git", + "acme/my-app", + ); + assert_eq!(body.name, "web"); + assert_eq!(body.default_branch, "main"); + assert_eq!(body.repository, "git@github.com:acme/my-app.git"); + assert_eq!(body.repo_name.as_deref(), Some("acme/my-app")); + assert!(body.description.is_none()); + } } From bb705caa503f5fbab94355f166b0f70c03b7d819 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 00:41:52 -0700 Subject: [PATCH 04/11] feat(run): auto-create cloud pipeline on first run --- crates/hm/src/commands/run/mod.rs | 98 ++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/crates/hm/src/commands/run/mod.rs b/crates/hm/src/commands/run/mod.rs index 90e9e542..91521922 100644 --- a/crates/hm/src/commands/run/mod.rs +++ b/crates/hm/src/commands/run/mod.rs @@ -30,6 +30,7 @@ use crate::error::{ErrorCategory, HmError}; /// Returns a doctrine-shaped error (carrying the right process exit code) when /// the backend rejects the build, authentication fails, the network is /// unreachable, the local daemon is down, or the pipeline fails to render. +#[allow(clippy::too_many_lines)] // thin top-level driver: linear, no good split point pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { // 1. Resolve the backend name: explicit --backend > legacy --cloud alias > // config.backend (figment-layered default "docker"). @@ -82,9 +83,13 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { let renderer = hm_render::renderer_for(&args.format, ctx.output.color_enabled(), use_logs)?; // 5. Build the backend. For local runs this is where we connect to Docker. + // For cloud runs, keep a cloned client + org so a `pipeline_not_found` on + // the first submit can create the pipeline and retry. `None` for local. + let mut autocreate_client: Option<(harmont_cloud::HarmontClient, String)> = None; let backend: Box = if let Some((api_url, token, org)) = cloud_creds { let client = harmont_cloud::HarmontClient::with_base_url(token, &api_url); + autocreate_client = Some((client.clone(), org.clone())); // The watch link must point at the dashboard (app.) host, not the // API host — a link built from `api_url` lands on raw JSON. let app_url = hm_config::app_url(&api_url, std::env::var("HM_APP_URL").ok().as_deref()); @@ -143,8 +148,34 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { }, }; + // Cloud-only auto-create context. Borrow `req` here (before it's moved into + // `start`): the repository URL and default branch come from the worktree's + // git remote; the pipeline name is the in-repo source slug. + let autocreate = autocreate_client.map(|(client, org)| AutoCreate { + client, + org, + repo_name: req.source.repo_name.clone(), + repository: git_remote_url(&req.repo_root), + name: req.pipeline_slug.clone(), + default_branch: git_default_branch(&req.repo_root) + .unwrap_or_else(|| req.source.branch.clone()), + }); + // 8. Start, drive events, own Ctrl-C, await the outcome. - let handle = backend.start(req).await.map_err(|e| backend_anyhow(&e))?; + // Submit. If the pipeline doesn't exist yet, optionally create it and retry + // once. Clone the request up front so the retry has its own copy (the first + // `start` consumes it). + let req_retry = req.clone(); + let handle = match backend.start(req).await { + Ok(handle) => handle, + Err(err) => { + if maybe_autocreate(&err, autocreate.as_ref()).await? { + backend.start(req_retry).await.map_err(|e| backend_anyhow(&e))? + } else { + return Err(backend_anyhow(&err)); + } + } + }; let (events, control) = handle.into_parts(); let _ctrlc = crate::signal::install_ctrlc(control.cancel_token()); let render = tokio::spawn(hm_render::drive_stream(renderer, events)); @@ -370,6 +401,71 @@ fn build_create_pipeline_request( } } +/// Everything the run driver needs to create a missing cloud pipeline and +/// retry the build. Built only for cloud runs; `repo_name`/`repository` are +/// `Option` because a remoteless worktree can't be auto-created (the cloud +/// backend already rejects those earlier with a clear "need a git remote" +/// message, so in practice both are `Some` whenever we reach the create path). +struct AutoCreate { + client: harmont_cloud::HarmontClient, + org: String, + repo_name: Option, + repository: Option, + /// The in-repo source slug — becomes the new pipeline's `name`. + name: String, + default_branch: String, +} + +/// On a `pipeline_not_found`, create the pipeline (confirming on a TTY, auto in +/// CI) and report whether the caller should retry the submit. +/// +/// Returns `Ok(true)` when a pipeline was created and the build should be +/// resubmitted; `Ok(false)` when the error isn't a missing pipeline, there's +/// no auto-create context, the repo can't be identified, or the user declined +/// (the caller then surfaces the original error). Returns `Err` only when the +/// create request itself failed (e.g. a slug collision) — worth surfacing over +/// the original `pipeline_not_found`. +async fn maybe_autocreate(err: &hm_exec::BackendError, ac: Option<&AutoCreate>) -> Result { + use std::io::IsTerminal; + + if !is_missing_pipeline(err) { + return Ok(false); + } + let Some(ac) = ac else { return Ok(false) }; + let (Some(repo_name), Some(repository)) = (&ac.repo_name, &ac.repository) else { + return Ok(false); + }; + + if std::io::stdin().is_terminal() { + let ok = dialoguer::Confirm::new() + .with_prompt(format!( + "No pipeline for {repo_name} in org {}. Create it?", + ac.org + )) + .default(true) + .interact() + .unwrap_or(false); + if !ok { + return Ok(false); + } + } else { + tracing::info!( + "no pipeline for {repo_name} yet — creating it in org {}", + ac.org + ); + } + + let body = build_create_pipeline_request(&ac.name, &ac.default_branch, repository, repo_name); + ac.client + .raw() + .create_pipeline(&ac.org, &body) + .await + .map_err(hm_plugin_cloud::settings::map_raw) + .with_context(|| format!("creating pipeline '{}' in org {}", ac.name, ac.org))?; + tracing::info!("created pipeline '{}' — submitting build", ac.name); + Ok(true) +} + /// Map a [`hm_exec::BackendError`] to the process exit-code category. /// /// Note: the old taxonomy distinguished a downed Docker daemon From 730f64504ca2fdc5cde8ec0a5bb229d95ddb9677 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 00:54:34 -0700 Subject: [PATCH 05/11] feat(exec): cloud backend can submit by resolved pipeline slug --- crates/hm-exec/src/cloud/backend.rs | 75 +++++++++++++++--------- crates/hm-exec/src/request.rs | 7 +++ crates/hm-exec/tests/backend_contract.rs | 1 + crates/hm/src/commands/run/mod.rs | 1 + 4 files changed, 57 insertions(+), 27 deletions(-) diff --git a/crates/hm-exec/src/cloud/backend.rs b/crates/hm-exec/src/cloud/backend.rs index c8b30148..008f89f4 100644 --- a/crates/hm-exec/src/cloud/backend.rs +++ b/crates/hm-exec/src/cloud/backend.rs @@ -2,7 +2,7 @@ //! to Harmont Cloud, and watch it to completion. The server schedules and runs; //! this backend is an *observer* (see [`Capabilities::cloud`]). -use harmont_cloud::{HarmontClient, HarmontError, builds::NewRepoBuild}; +use harmont_cloud::{HarmontClient, HarmontError, builds::{NewBuild, NewRepoBuild}}; use hm_plugin_protocol::events::{BuildEvent, BuildRef}; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; @@ -74,6 +74,10 @@ impl ExecutionBackend for CloudBackend { Capabilities::cloud() } + // The two submit branches (by-slug vs. repo-identity) plus the + // watch/no-watch handling push this just over the 100-line lint; splitting + // it would only scatter the linear submit-then-watch flow. + #[allow(clippy::too_many_lines)] async fn start(&self, req: RunRequest) -> Result { // Archive the worktree (fail fast as a setup error). let source_tgz = crate::local::build_archive_bytes(&req.repo_root) @@ -84,32 +88,49 @@ impl ExecutionBackend for CloudBackend { // the size so the upload isn't a silent gulf of evaluation. guard_archive_size(source_tgz.len(), &req.repo_root)?; - // Resolve the repo's `owner/repo`; cloud runs address the pipeline by - // (repo_name, source_slug) — the bare DSL slug is not the server's - // global slug. A worktree with no remote can't be matched to a pipeline. - let repo_name = req.source.repo_name.clone().ok_or_else(|| { - BackendError::Local( - "cloud runs need a git remote to identify the pipeline — add an \ - `origin` remote, or run from a cloned repo" - .into(), - ) - })?; - - let build = self - .client - .submit_repo_build(NewRepoBuild { - org: self.org.clone(), - repo_name, - source_slug: req.pipeline_slug.clone(), - branch: req.source.branch.clone(), - commit: req.source.commit.clone(), - message: req.source.message.clone(), - pipeline_ir: req.plan.ir_json.clone(), // verbatim - source_tgz, - env: req.env.clone().into_iter().collect(), - }) - .await - .map_err(map_harmont_err)?; + // Submit the build. Normally we address the pipeline by repo identity + // (`submit_repo_build` resolves `(repo_name, source_slug)` to the + // server's global slug). But when the driver has already resolved or + // created the pipeline — for a repo the server hasn't discovered — it + // passes the global slug directly, and we submit by slug + // (`submit_build`), bypassing repo-identity resolution. + let build = if let Some(slug) = req.cloud_pipeline_slug.clone() { + self.client + .submit_build(NewBuild { + org: self.org.clone(), + pipeline: slug, + branch: req.source.branch.clone(), + commit: req.source.commit.clone(), + message: req.source.message.clone(), + pipeline_ir: req.plan.ir_json.clone(), // verbatim + source_tgz, + env: req.env.clone().into_iter().collect(), + }) + .await + .map_err(map_harmont_err)? + } else { + let repo_name = req.source.repo_name.clone().ok_or_else(|| { + BackendError::Local( + "cloud runs need a git remote to identify the pipeline — add an \ + `origin` remote, or run from a cloned repo" + .into(), + ) + })?; + self.client + .submit_repo_build(NewRepoBuild { + org: self.org.clone(), + repo_name, + source_slug: req.pipeline_slug.clone(), + branch: req.source.branch.clone(), + commit: req.source.commit.clone(), + message: req.source.message.clone(), + pipeline_ir: req.plan.ir_json.clone(), // verbatim + source_tgz, + env: req.env.clone().into_iter().collect(), + }) + .await + .map_err(map_harmont_err)? + }; // The server resolved (and returns) the global slug; watch/cancel/log // endpoints are addressed by it, NOT by the source slug we submitted. diff --git a/crates/hm-exec/src/request.rs b/crates/hm-exec/src/request.rs index 30b4edc8..e8a74a50 100644 --- a/crates/hm-exec/src/request.rs +++ b/crates/hm-exec/src/request.rs @@ -91,6 +91,13 @@ pub struct RunRequest { pub env: BTreeMap, pub source: SourceMeta, pub options: RunOptions, + /// When `Some`, the cloud backend submits the build directly to this + /// already-resolved org-global pipeline slug (via `submit_build`) instead + /// of resolving by repo identity (`submit_repo_build`). Set by the `hm run` + /// driver after it has created or looked up the pipeline for a repo the + /// server hasn't "discovered" (connected/pushed). `None` for the normal + /// repo-identity path. Ignored by non-cloud backends. + pub cloud_pipeline_slug: Option, } #[cfg(test)] diff --git a/crates/hm-exec/tests/backend_contract.rs b/crates/hm-exec/tests/backend_contract.rs index dae86507..797eccdf 100644 --- a/crates/hm-exec/tests/backend_contract.rs +++ b/crates/hm-exec/tests/backend_contract.rs @@ -136,5 +136,6 @@ fn fake_request() -> RunRequest { watch: true, ..Default::default() }, + cloud_pipeline_slug: None, } } diff --git a/crates/hm/src/commands/run/mod.rs b/crates/hm/src/commands/run/mod.rs index 91521922..5e0101de 100644 --- a/crates/hm/src/commands/run/mod.rs +++ b/crates/hm/src/commands/run/mod.rs @@ -146,6 +146,7 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { watch: !args.no_watch, keep_going: args.keep_going, }, + cloud_pipeline_slug: None, }; // Cloud-only auto-create context. Borrow `req` here (before it's moved into From 8f8d8f3071cbccdfebaf060d991e3736d2bce4af Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 00:59:42 -0700 Subject: [PATCH 06/11] feat(run): resolve-or-create pipeline, submit by global slug --- crates/hm/src/commands/run/mod.rs | 82 ++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 29 deletions(-) diff --git a/crates/hm/src/commands/run/mod.rs b/crates/hm/src/commands/run/mod.rs index 5e0101de..c7ca610a 100644 --- a/crates/hm/src/commands/run/mod.rs +++ b/crates/hm/src/commands/run/mod.rs @@ -163,19 +163,21 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { }); // 8. Start, drive events, own Ctrl-C, await the outcome. - // Submit. If the pipeline doesn't exist yet, optionally create it and retry - // once. Clone the request up front so the retry has its own copy (the first - // `start` consumes it). + // Submit. If the pipeline doesn't exist yet, resolve-or-create it and retry + // by submitting directly to its global slug (the repo-identity path can't + // see API-created pipelines). Clone the request up front so the retry has + // its own copy (the first `start` consumes it). let req_retry = req.clone(); let handle = match backend.start(req).await { Ok(handle) => handle, - Err(err) => { - if maybe_autocreate(&err, autocreate.as_ref()).await? { - backend.start(req_retry).await.map_err(|e| backend_anyhow(&e))? - } else { - return Err(backend_anyhow(&err)); + Err(err) => match resolve_or_create_cloud_pipeline(&err, autocreate.as_ref()).await? { + Some(slug) => { + let mut retry = req_retry; + retry.cloud_pipeline_slug = Some(slug); + backend.start(retry).await.map_err(|e| backend_anyhow(&e))? } - } + None => return Err(backend_anyhow(&err)), + }, }; let (events, control) = handle.into_parts(); let _ctrlc = crate::signal::install_ctrlc(control.cancel_token()); @@ -417,26 +419,49 @@ struct AutoCreate { default_branch: String, } -/// On a `pipeline_not_found`, create the pipeline (confirming on a TTY, auto in -/// CI) and report whether the caller should retry the submit. +/// On a `pipeline_not_found`, resolve the build's target pipeline and return +/// its org-global slug so the caller can retry by submitting directly to it +/// (`RunRequest::cloud_pipeline_slug`), bypassing the repo-identity resolution +/// that can't see API-created pipelines. +/// +/// The pipeline may already exist from a prior `hm run` (the repo-identity path +/// can't find it, so a `pipeline_not_found` does NOT mean it's absent). So we +/// look it up by slug first and only create — after confirming on a TTY, or +/// automatically when non-interactive — when it's truly missing. /// -/// Returns `Ok(true)` when a pipeline was created and the build should be -/// resubmitted; `Ok(false)` when the error isn't a missing pipeline, there's -/// no auto-create context, the repo can't be identified, or the user declined -/// (the caller then surfaces the original error). Returns `Err` only when the -/// create request itself failed (e.g. a slug collision) — worth surfacing over -/// the original `pipeline_not_found`. -async fn maybe_autocreate(err: &hm_exec::BackendError, ac: Option<&AutoCreate>) -> Result { +/// Returns `Ok(Some(slug))` to retry by that slug; `Ok(None)` when the error +/// isn't a missing pipeline, there's no auto-create context, the repo can't be +/// identified, or the user declined (caller then surfaces the original error). +/// Returns `Err` only when a lookup or create request itself failed. +async fn resolve_or_create_cloud_pipeline( + err: &hm_exec::BackendError, + ac: Option<&AutoCreate>, +) -> Result> { use std::io::IsTerminal; if !is_missing_pipeline(err) { - return Ok(false); + return Ok(None); } - let Some(ac) = ac else { return Ok(false) }; + let Some(ac) = ac else { return Ok(None) }; let (Some(repo_name), Some(repository)) = (&ac.repo_name, &ac.repository) else { - return Ok(false); + return Ok(None); }; + // Already created on a prior run? Look it up by slug; the repo-identity + // submit can't see it, but `get_pipeline` (by global slug) can. This + // assumes the org-global slug equals `ac.name` (the source slug we create + // the pipeline under) — true for a slug-shaped name; a server-normalized + // mismatch falls through to a clear create-collision error below. + match ac.client.raw().get_pipeline(&ac.org, &ac.name).await { + Ok(p) => return Ok(Some(p.into_inner().slug)), + Err(e) if e.status().is_some_and(|s| s.as_u16() == 404) => {} // truly absent → create + Err(e) => { + return Err(hm_plugin_cloud::settings::map_raw(e)) + .with_context(|| format!("looking up pipeline '{}' in org {}", ac.name, ac.org)); + } + } + + // Truly missing — confirm on a TTY, auto-create when non-interactive. if std::io::stdin().is_terminal() { let ok = dialoguer::Confirm::new() .with_prompt(format!( @@ -447,24 +472,23 @@ async fn maybe_autocreate(err: &hm_exec::BackendError, ac: Option<&AutoCreate>) .interact() .unwrap_or(false); if !ok { - return Ok(false); + return Ok(None); } } else { - tracing::info!( - "no pipeline for {repo_name} yet — creating it in org {}", - ac.org - ); + tracing::info!("no pipeline for {repo_name} yet — creating it in org {}", ac.org); } let body = build_create_pipeline_request(&ac.name, &ac.default_branch, repository, repo_name); - ac.client + let created = ac + .client .raw() .create_pipeline(&ac.org, &body) .await .map_err(hm_plugin_cloud::settings::map_raw) .with_context(|| format!("creating pipeline '{}' in org {}", ac.name, ac.org))?; - tracing::info!("created pipeline '{}' — submitting build", ac.name); - Ok(true) + let slug = created.into_inner().slug; + tracing::info!("created pipeline '{slug}' — submitting build"); + Ok(Some(slug)) } /// Map a [`hm_exec::BackendError`] to the process exit-code category. From dc077b298f910a057f72edb874cdf8d2c5c65766 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 10:06:37 -0700 Subject: [PATCH 07/11] feat(config): add optional [cloud] pipeline slug --- crates/hm-config/src/lib.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/hm-config/src/lib.rs b/crates/hm-config/src/lib.rs index 571958ba..7bd5fd2f 100644 --- a/crates/hm-config/src/lib.rs +++ b/crates/hm-config/src/lib.rs @@ -69,6 +69,10 @@ pub fn app_url(api: &str, override_url: Option<&str>) -> String { pub struct CloudConfig { pub org: Option, pub api_url: String, + /// Org-global pipeline slug to submit builds to directly (set by `hm run` + /// after registering a remoteless directory). When present, cloud runs + /// submit by this slug instead of resolving by git-repo identity. + pub pipeline: Option, } impl Default for CloudConfig { @@ -76,6 +80,7 @@ impl Default for CloudConfig { Self { org: None, api_url: DEFAULT_API_URL.to_owned(), + pipeline: None, } } } @@ -270,6 +275,7 @@ mod tests { assert_eq!(cfg.backend, Backend::Docker); assert_eq!(cfg.cloud.api_url, DEFAULT_API_URL); assert!(cfg.cloud.org.is_none()); + assert!(cfg.cloud.pipeline.is_none()); assert_eq!(cfg.preferences.format, "human"); assert!(!cfg.preferences.auto_watch); } @@ -280,6 +286,7 @@ mod tests { [cloud] org = "acme" api_url = "https://custom.api" +pipeline = "acme/web" [preferences] format = "json" @@ -288,6 +295,7 @@ auto_watch = true let cfg: Config = toml::from_str(toml_str).unwrap(); assert_eq!(cfg.cloud.org.as_deref(), Some("acme")); assert_eq!(cfg.cloud.api_url, "https://custom.api"); + assert_eq!(cfg.cloud.pipeline.as_deref(), Some("acme/web")); assert_eq!(cfg.preferences.format, "json"); assert!(cfg.preferences.auto_watch); } @@ -388,6 +396,7 @@ org = "project-org" let cfg = Config { cloud: CloudConfig { org: Some("saved-org".into()), + pipeline: Some("saved-org/web".into()), ..CloudConfig::default() }, ..Config::default() @@ -396,6 +405,7 @@ org = "project-org" let loaded = Config::load_from_paths(Some(&path), None).unwrap(); assert_eq!(loaded.cloud.org.as_deref(), Some("saved-org")); + assert_eq!(loaded.cloud.pipeline.as_deref(), Some("saved-org/web")); assert_eq!(loaded.cloud.api_url, DEFAULT_API_URL); assert_eq!(loaded.preferences.format, "human"); } From dc9716e972cbee5e460f86ead0254e5b4565cb79 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 10:09:20 -0700 Subject: [PATCH 08/11] fix(run): drop dead error-doc URLs and misleading Docker hint --- crates/hm/src/commands/run/mod.rs | 40 +++++++++++-------------------- 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/crates/hm/src/commands/run/mod.rs b/crates/hm/src/commands/run/mod.rs index c7ca610a..85213d7b 100644 --- a/crates/hm/src/commands/run/mod.rs +++ b/crates/hm/src/commands/run/mod.rs @@ -527,39 +527,29 @@ fn explain(err: &hm_exec::BackendError) -> String { match err { E::Unauthorized => "\ error[auth_required]: not authenticated - fix run `hm cloud login` (or set HM_API_TOKEN) - docs https://harmont.dev/docs/errors/auth_required" + fix run `hm cloud login` (or set HM_API_TOKEN)" .to_string(), E::Rejected { code, message } => format!( "\ error[{code}]: {message} - fix fix the pipeline and re-run `hm run` - docs https://harmont.dev/docs/errors/{code}" + fix fix the pipeline and re-run `hm run`" ), E::NotFound(what) => format!( "\ error[not_found]: {what} - fix check the org, pipeline, and build number are correct - docs https://harmont.dev/docs/errors/not_found" + fix check the org, pipeline, and build number are correct" ), E::Transport(m) => format!( "\ error[network]: {m} - fix check your connection and the API URL (HM_API_URL) - docs https://harmont.dev/docs/errors/network" + fix check your connection and the API URL (HM_API_URL)" ), E::LogStream(m) => format!( "\ error[log_stream]: live logs interrupted — {m} - fix the build continues; re-attach with `hm cloud build show` - docs https://harmont.dev/docs/errors/log_stream" - ), - E::Local(m) => format!( - "\ -error[local]: {m} - fix check that the Docker daemon is running (`docker version`) - docs https://harmont.dev/docs/errors/local" + fix the build continues; re-attach with `hm cloud build show`" ), + E::Local(m) => format!("error[local]: {m}"), E::SourceTooLarge { observed_bytes, cap_bytes, @@ -580,17 +570,12 @@ error[local]: {m} "\ error[source_too_large]: worktree archive is {observed} (cap {cap}) biggest\n{biggest} - fix add the offending paths to .gitignore (build output, caches, vendored deps), then re-run `hm run` - docs https://harmont.dev/docs/errors/source_too_large", + fix add the offending paths to .gitignore (build output, caches, vendored deps), then re-run `hm run`", observed = mb(*observed_bytes), cap = mb(*cap_bytes), ) } - other => format!( - "\ -error[backend]: {other} - docs https://harmont.dev/docs/errors/backend" - ), + other => format!("error[backend]: {other}"), } } @@ -692,7 +677,7 @@ mod tests { } #[test] - fn explain_carries_stable_codes_and_docs() { + fn explain_carries_stable_codes() { use hm_exec::BackendError as E; assert!(explain(&E::Unauthorized).contains("error[auth_required]")); assert!(explain(&E::NotFound("x".into())).contains("error[not_found]")); @@ -713,15 +698,18 @@ mod tests { // Points precisely (observed + cap), names the offender, states the fix. assert!(big.contains("7.0 MB") && big.contains("6.0 MB")); assert!(big.contains("node_modules") && big.contains(".gitignore")); - assert!(big.contains("docs https://harmont.dev/docs/errors/source_too_large")); + // Doc URLs were removed (the pages 404); no error should link to them. for s in [ explain(&E::Unauthorized), explain(&E::NotFound("x".into())), explain(&E::Transport("x".into())), explain(&E::Local("x".into())), ] { - assert!(s.contains("docs https://harmont.dev/docs/errors/")); + assert!(!s.contains("docs https://harmont.dev/docs/errors/")); } + // The Local arm no longer gives misleading Docker advice. + assert!(!explain(&E::Local("archiving worktree: boom".into())).contains("Docker")); + assert!(explain(&E::Local("archiving worktree: boom".into())).contains("error[local]")); } #[test] From 91d245ae7593e9f578235dd10d64160976109b20 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 10:14:04 -0700 Subject: [PATCH 09/11] feat(run): register a pipeline interactively when there's no git remote --- crates/hm/src/commands/run/mod.rs | 155 +++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 5 deletions(-) diff --git a/crates/hm/src/commands/run/mod.rs b/crates/hm/src/commands/run/mod.rs index 85213d7b..4bf00e24 100644 --- a/crates/hm/src/commands/run/mod.rs +++ b/crates/hm/src/commands/run/mod.rs @@ -129,7 +129,7 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { // 7. Assemble the run request. let (branch, commit) = git_metadata(&repo_root, args.branch.clone()); let repo_name = git_remote_repo_name(&repo_root); - let req = hm_exec::RunRequest { + let mut req = hm_exec::RunRequest { plan, repo_root, pipeline_slug: slug, @@ -149,6 +149,22 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { cloud_pipeline_slug: None, }; + // Cloud target resolution (before the first submit): a persisted pipeline + // slug wins; otherwise a remoteless worktree (no git remote) is registered + // interactively now and its slug persisted. A worktree WITH a remote falls + // through to the repo-identity submit + get-or-create fallback below. + if let Some((client, org)) = autocreate_client.as_ref() { + if let Some(slug) = ctx.config.cloud.pipeline.clone() { + req.cloud_pipeline_slug = Some(slug); + } else if req.source.repo_name.is_none() { + let default_branch = + git_default_branch(&req.repo_root).unwrap_or_else(|| req.source.branch.clone()); + let slug = + register_remoteless_pipeline(client, org, &req.repo_root, &default_branch).await?; + req.cloud_pipeline_slug = Some(slug); + } + } + // Cloud-only auto-create context. Borrow `req` here (before it's moved into // `start`): the repository URL and default branch come from the worktree's // git remote; the pipeline name is the in-repo source slug. @@ -393,17 +409,103 @@ fn build_create_pipeline_request( name: &str, default_branch: &str, repository: &str, - repo_name: &str, + repo_name: Option<&str>, ) -> harmont_cloud::types::CreatePipelineRequest { harmont_cloud::types::CreatePipelineRequest { default_branch: default_branch.to_string(), description: None, name: name.to_string(), - repo_name: Some(repo_name.to_string()), + repo_name: repo_name.map(str::to_string), repository: repository.to_string(), } } +/// Merge `backend = "cloud"` and `[cloud] org/pipeline = …` into the project's +/// `.hm/config.toml`, preserving any other keys already in the file. Creates +/// the file (and `.hm/`) when absent. Used after registering a remoteless +/// directory so later runs submit by the persisted slug without prompting. +fn persist_project_pipeline(dir: &std::path::Path, org: &str, slug: &str) -> Result<()> { + let path = dir.join(".hm/config.toml"); + let mut doc: toml::Table = std::fs::read_to_string(&path) + .ok() + .and_then(|s| toml::from_str(&s).ok()) + .unwrap_or_default(); + doc.insert("backend".into(), toml::Value::String("cloud".into())); + let cloud = doc + .entry("cloud".to_string()) + .or_insert_with(|| toml::Value::Table(toml::Table::new())); + if let Some(t) = cloud.as_table_mut() { + t.insert("org".into(), toml::Value::String(org.to_string())); + t.insert("pipeline".into(), toml::Value::String(slug.to_string())); + } + let serialized = toml::to_string_pretty(&doc).context("serializing .hm/config.toml")?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating {}", parent.display()))?; + } + std::fs::write(&path, serialized).with_context(|| format!("writing {}", path.display()))?; + Ok(()) +} + +/// Register the current directory as a Harmont pipeline when there's no git +/// remote to identify one. Prompts for a name (default: the directory name) on +/// a TTY; auto-uses the directory name when non-interactive. Reuses an existing +/// pipeline of that name, else creates it (`repository` = the name, no +/// `repo_name`). Persists the resolved slug to `.hm/config.toml`. Returns the +/// org-global slug to submit by. +async fn register_remoteless_pipeline( + client: &harmont_cloud::HarmontClient, + org: &str, + dir: &std::path::Path, + default_branch: &str, +) -> Result { + use std::io::IsTerminal; + + let dirname = dir + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("pipeline") + .to_string(); + + let name = if std::io::stdin().is_terminal() { + // Propagate a genuine prompt I/O error rather than silently registering + // under the default name — creating a cloud pipeline is a side effect we + // shouldn't perform on an interrupted/failed read. (Hitting Enter accepts + // the default and returns `Ok`, so this only fires on a real failure.) + dialoguer::Input::::new() + .with_prompt("No pipeline linked here — register this directory. Pipeline name") + .default(dirname) + .interact_text() + .context("reading the pipeline name")? + } else { + tracing::info!("no pipeline linked — registering '{dirname}' in org {org}"); + dirname + }; + + // Reuse an existing pipeline of that name, else create one. + let slug = match client.raw().get_pipeline(org, &name).await { + Ok(p) => p.into_inner().slug, + Err(e) if e.status().is_some_and(|s| s.as_u16() == 404) => { + let body = build_create_pipeline_request(&name, default_branch, &name, None); + let created = client + .raw() + .create_pipeline(org, &body) + .await + .map_err(hm_plugin_cloud::settings::map_raw) + .with_context(|| format!("registering pipeline '{name}' in org {org}"))?; + created.into_inner().slug + } + Err(e) => { + return Err(hm_plugin_cloud::settings::map_raw(e)) + .with_context(|| format!("looking up pipeline '{name}' in org {org}")); + } + }; + + persist_project_pipeline(dir, org, &slug).context("saving .hm/config.toml")?; + tracing::info!("registered pipeline '{slug}' — submitting build"); + Ok(slug) +} + /// Everything the run driver needs to create a missing cloud pipeline and /// retry the build. Built only for cloud runs; `repo_name`/`repository` are /// `Option` because a remoteless worktree can't be auto-created (the cloud @@ -478,7 +580,8 @@ async fn resolve_or_create_cloud_pipeline( tracing::info!("no pipeline for {repo_name} yet — creating it in org {}", ac.org); } - let body = build_create_pipeline_request(&ac.name, &ac.default_branch, repository, repo_name); + let body = + build_create_pipeline_request(&ac.name, &ac.default_branch, repository, Some(repo_name)); let created = ac .client .raw() @@ -745,12 +848,54 @@ mod tests { "web", "main", "git@github.com:acme/my-app.git", - "acme/my-app", + Some("acme/my-app"), ); assert_eq!(body.name, "web"); assert_eq!(body.default_branch, "main"); assert_eq!(body.repository, "git@github.com:acme/my-app.git"); assert_eq!(body.repo_name.as_deref(), Some("acme/my-app")); assert!(body.description.is_none()); + + // The remoteless path passes `None`: no `repo_name`, and `repository` + // falls back to the pipeline name itself. + let body = build_create_pipeline_request("my-app-2", "main", "my-app-2", None); + assert!(body.repo_name.is_none()); + assert_eq!(body.repository, "my-app-2"); + assert_eq!(body.name, "my-app-2"); + } + + #[test] + fn persist_creates_config_when_absent() { + let dir = tempfile::tempdir().unwrap(); + persist_project_pipeline(dir.path(), "acme", "my-app-2").unwrap(); + + let raw = std::fs::read_to_string(dir.path().join(".hm/config.toml")).unwrap(); + let doc: toml::Table = toml::from_str(&raw).unwrap(); + assert_eq!(doc["backend"].as_str(), Some("cloud")); + let cloud = doc["cloud"].as_table().unwrap(); + assert_eq!(cloud["org"].as_str(), Some("acme")); + assert_eq!(cloud["pipeline"].as_str(), Some("my-app-2")); + } + + #[test] + fn persist_preserves_existing_keys() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".hm")).unwrap(); + std::fs::write( + dir.path().join(".hm/config.toml"), + "backend = \"docker\"\n[cloud]\norg = \"old\"\napi_url = \"https://example.test\"\n", + ) + .unwrap(); + + persist_project_pipeline(dir.path(), "acme", "web").unwrap(); + + let raw = std::fs::read_to_string(dir.path().join(".hm/config.toml")).unwrap(); + let doc: toml::Table = toml::from_str(&raw).unwrap(); + assert_eq!(doc["backend"].as_str(), Some("cloud")); + let cloud = doc["cloud"].as_table().unwrap(); + assert_eq!(cloud["pipeline"].as_str(), Some("web")); + assert_eq!(cloud["org"].as_str(), Some("acme")); + // The unrelated key is preserved across the merge. + assert_eq!(cloud["api_url"].as_str(), Some("https://example.test")); } } From 0d2ec4765059bbb03137bb49a10ece93e100f134 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 10:28:17 -0700 Subject: [PATCH 10/11] fix(run): pipeline name from .py/.ts; prompt for repo name on remoteless register --- crates/hm/src/commands/run/mod.rs | 88 +++++++++++++++++++------------ 1 file changed, 53 insertions(+), 35 deletions(-) diff --git a/crates/hm/src/commands/run/mod.rs b/crates/hm/src/commands/run/mod.rs index 4bf00e24..2d7d17e0 100644 --- a/crates/hm/src/commands/run/mod.rs +++ b/crates/hm/src/commands/run/mod.rs @@ -159,8 +159,14 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { } else if req.source.repo_name.is_none() { let default_branch = git_default_branch(&req.repo_root).unwrap_or_else(|| req.source.branch.clone()); - let slug = - register_remoteless_pipeline(client, org, &req.repo_root, &default_branch).await?; + let slug = register_remoteless_pipeline( + client, + org, + &req.pipeline_slug, + &req.repo_root, + &default_branch, + ) + .await?; req.cloud_pipeline_slug = Some(slug); } } @@ -447,57 +453,69 @@ fn persist_project_pipeline(dir: &std::path::Path, org: &str, slug: &str) -> Res Ok(()) } -/// Register the current directory as a Harmont pipeline when there's no git -/// remote to identify one. Prompts for a name (default: the directory name) on -/// a TTY; auto-uses the directory name when non-interactive. Reuses an existing -/// pipeline of that name, else creates it (`repository` = the name, no -/// `repo_name`). Persists the resolved slug to `.hm/config.toml`. Returns the -/// org-global slug to submit by. +/// Register the in-repo pipeline (`pipeline_name`, the `@hm.pipeline("…")` slug +/// from the `.py`/`.ts`) with Harmont when there's no git remote to identify +/// its repository. Reuses an existing pipeline of that name; otherwise prompts +/// the user for the repository name (`owner/repo`, default: the directory name) +/// and creates it. Persists the resolved slug to `.hm/config.toml` so later +/// runs submit by slug without prompting. Returns the org-global slug. async fn register_remoteless_pipeline( client: &harmont_cloud::HarmontClient, org: &str, + pipeline_name: &str, dir: &std::path::Path, default_branch: &str, ) -> Result { use std::io::IsTerminal; - let dirname = dir - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("pipeline") - .to_string(); - - let name = if std::io::stdin().is_terminal() { - // Propagate a genuine prompt I/O error rather than silently registering - // under the default name — creating a cloud pipeline is a side effect we - // shouldn't perform on an interrupted/failed read. (Hitting Enter accepts - // the default and returns `Ok`, so this only fires on a real failure.) - dialoguer::Input::::new() - .with_prompt("No pipeline linked here — register this directory. Pipeline name") - .default(dirname) - .interact_text() - .context("reading the pipeline name")? - } else { - tracing::info!("no pipeline linked — registering '{dirname}' in org {org}"); - dirname - }; - - // Reuse an existing pipeline of that name, else create one. - let slug = match client.raw().get_pipeline(org, &name).await { + // Reuse the pipeline if it already exists (resolved by its slug, which is + // derived from the in-repo name); otherwise create it, asking for the repo + // identity we can't read from a (missing) git remote. + let slug = match client.raw().get_pipeline(org, pipeline_name).await { Ok(p) => p.into_inner().slug, Err(e) if e.status().is_some_and(|s| s.as_u16() == 404) => { - let body = build_create_pipeline_request(&name, default_branch, &name, None); + let dirname = dir + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("repo") + .to_string(); + let repo_name = if std::io::stdin().is_terminal() { + // Propagate a genuine prompt I/O error rather than silently + // creating a pipeline under the default name — a create is a + // side effect we shouldn't perform on an interrupted read. + // (Hitting Enter accepts the default and returns `Ok`, so this + // only fires on a real failure.) + dialoguer::Input::::new() + .with_prompt(format!( + "No git remote — register pipeline '{pipeline_name}'. Repository name (owner/repo)" + )) + .default(dirname) + .interact_text() + .context("reading the repository name")? + } else { + tracing::info!( + "no git remote — registering pipeline '{pipeline_name}' for repo '{dirname}' in org {org}" + ); + dirname + }; + let body = build_create_pipeline_request( + pipeline_name, + default_branch, + &repo_name, + Some(&repo_name), + ); let created = client .raw() .create_pipeline(org, &body) .await .map_err(hm_plugin_cloud::settings::map_raw) - .with_context(|| format!("registering pipeline '{name}' in org {org}"))?; + .with_context(|| format!("registering pipeline '{pipeline_name}' in org {org}"))?; created.into_inner().slug } Err(e) => { - return Err(hm_plugin_cloud::settings::map_raw(e)) - .with_context(|| format!("looking up pipeline '{name}' in org {org}")); + return Err(hm_plugin_cloud::settings::map_raw(e)).with_context(|| { + format!("looking up pipeline '{pipeline_name}' in org {org}") + }); } }; From 0b47619c02d613baa24c852f8e52f57847723318 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 10:40:20 -0700 Subject: [PATCH 11/11] style: cargo fmt --- crates/hm-exec/src/cloud/backend.rs | 5 ++++- crates/hm/src/commands/run/mod.rs | 15 ++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/hm-exec/src/cloud/backend.rs b/crates/hm-exec/src/cloud/backend.rs index 008f89f4..0ec75ccd 100644 --- a/crates/hm-exec/src/cloud/backend.rs +++ b/crates/hm-exec/src/cloud/backend.rs @@ -2,7 +2,10 @@ //! to Harmont Cloud, and watch it to completion. The server schedules and runs; //! this backend is an *observer* (see [`Capabilities::cloud`]). -use harmont_cloud::{HarmontClient, HarmontError, builds::{NewBuild, NewRepoBuild}}; +use harmont_cloud::{ + HarmontClient, HarmontError, + builds::{NewBuild, NewRepoBuild}, +}; use hm_plugin_protocol::events::{BuildEvent, BuildRef}; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; diff --git a/crates/hm/src/commands/run/mod.rs b/crates/hm/src/commands/run/mod.rs index 2d7d17e0..747ed4a3 100644 --- a/crates/hm/src/commands/run/mod.rs +++ b/crates/hm/src/commands/run/mod.rs @@ -513,9 +513,8 @@ async fn register_remoteless_pipeline( created.into_inner().slug } Err(e) => { - return Err(hm_plugin_cloud::settings::map_raw(e)).with_context(|| { - format!("looking up pipeline '{pipeline_name}' in org {org}") - }); + return Err(hm_plugin_cloud::settings::map_raw(e)) + .with_context(|| format!("looking up pipeline '{pipeline_name}' in org {org}")); } }; @@ -595,7 +594,10 @@ async fn resolve_or_create_cloud_pipeline( return Ok(None); } } else { - tracing::info!("no pipeline for {repo_name} yet — creating it in org {}", ac.org); + tracing::info!( + "no pipeline for {repo_name} yet — creating it in org {}", + ac.org + ); } let body = @@ -725,9 +727,8 @@ mod tests { #[test] fn missing_pipeline_detected_from_opaque_not_found() { - let err = hm_exec::BackendError::NotFound( - r#"{"error":{"code":"pipeline_not_found"}}"#.into(), - ); + let err = + hm_exec::BackendError::NotFound(r#"{"error":{"code":"pipeline_not_found"}}"#.into()); assert!(is_missing_pipeline(&err)); }