diff --git a/bin/core/src/api/write/stack.rs b/bin/core/src/api/write/stack.rs index e16b359a0..eb14838f8 100644 --- a/bin/core/src/api/write/stack.rs +++ b/bin/core/src/api/write/stack.rs @@ -357,6 +357,40 @@ async fn write_stack_file_contents_git( })?; } + // Probe the remote first so an empty remote (or missing branch) can be + // bootstrapped locally instead of failing on `git pull`, and an + // unreachable remote surfaces an actionable error before any local work. + let remote_state = + match git::check_remote_state(&repo_args, git_token.as_deref()) + .await + .context("Failed to check remote state") + { + Ok(state) => state, + Err(e) => { + update + .push_error_log("Check Remote", format_serror(&e.into())); + update.finalize(); + update.id = add_update(update.clone()).await?; + return Ok(update); + } + }; + + if let git::RemoteState::Unreachable { stderr } = &remote_state { + update.push_error_log( + "Check Remote", + format!( + "Remote not reachable: {}/{}\n\n{}\n\n\ + Create the repo or fix credentials, then refresh this stack.", + repo_args.provider, + repo_args.repo.as_deref().unwrap_or(""), + stderr.trim(), + ), + ); + update.finalize(); + update.id = add_update(update.clone()).await?; + return Ok(update); + } + // Ensure the folder is initialized as git repo. // This allows a new file to be committed on a branch that may not exist. if !root.join(".git").exists() { @@ -377,27 +411,54 @@ async fn write_stack_file_contents_git( // Save this for later -- repo_args moved next. let branch = repo_args.branch.clone(); - // Pull latest changes to repo to ensure linear commit history - match git::pull_or_clone( - repo_args, - &core_config().repo_directory, - git_token, - ) - .await - .context("Failed to pull latest changes before commit") - { - Ok((res, _)) => update.logs.extend(res.logs), - Err(e) => { - update.push_error_log("Pull Repo", format_serror(&e.into())); - update.finalize(); - return Ok(update); - } - }; - if !all_logs_success(&update.logs) { - update.finalize(); - update.id = add_update(update.clone()).await?; - return Ok(update); + match remote_state { + git::RemoteState::BranchExists => { + // Pull latest changes to repo to ensure linear commit history + match git::pull_or_clone( + repo_args, + &core_config().repo_directory, + git_token, + ) + .await + .context("Failed to pull latest changes before commit") + { + Ok((res, _)) => update.logs.extend(res.logs), + Err(e) => { + update + .push_error_log("Pull Repo", format_serror(&e.into())); + update.finalize(); + return Ok(update); + } + }; + + if !all_logs_success(&update.logs) { + update.finalize(); + update.id = add_update(update.clone()).await?; + return Ok(update); + } + } + git::RemoteState::Empty => { + update.push_simple_log( + "Check Remote", + format!( + "Remote is empty. Bootstrapping branch '{branch}' from \ + this initial commit." + ), + ); + } + git::RemoteState::BranchMissing { available_branches } => { + update.push_simple_log( + "Check Remote", + format!( + "Branch '{branch}' does not exist on remote. Available \ + branches: [{}]. Bootstrapping branch '{branch}' from \ + this initial commit.", + available_branches.join(", "), + ), + ); + } + git::RemoteState::Unreachable { .. } => unreachable!(), } if let Err(e) = tokio::fs::write(&full_path, &contents) diff --git a/bin/core/src/stack/remote.rs b/bin/core/src/stack/remote.rs index 41cf6b6e9..3ee9a0ab7 100644 --- a/bin/core/src/stack/remote.rs +++ b/bin/core/src/stack/remote.rs @@ -2,8 +2,10 @@ use std::{fs, path::PathBuf}; use anyhow::Context; use formatting::format_serror; +use git::RemoteState; use komodo_client::entities::{ FileContents, RepoExecutionArgs, + all_logs_success, repo::Repo, stack::{Stack, StackRemoteFileContents}, update::Log, @@ -108,10 +110,107 @@ pub async fn ensure_remote_repo( clone_args.unique_path(&core_config().repo_directory)?; clone_args.destination = Some(repo_path.display().to_string()); - git::pull_or_clone(clone_args, &config.repo_directory, access_token) - .await - .context("Failed to clone stack repo") - .map(|(res, _)| { - (repo_path, res.logs, res.commit_hash, res.commit_message) - }) + // Probe the remote first so we can distinguish "repo unreachable" + // from "repo reachable but empty / branch missing" and bootstrap + // empty remotes locally instead of failing on `git pull`. + let remote_state = git::check_remote_state( + &clone_args, + access_token.as_deref(), + ) + .await + .context("Failed to check remote state before clone")?; + + match remote_state { + RemoteState::Unreachable { stderr } => { + let log = Log::error( + "Check Remote", + format!( + "Remote not reachable: {}/{}\n\n{}\n\n\ + Create the repo or fix credentials, then refresh this stack.", + clone_args.provider, + clone_args.repo.as_deref().unwrap_or(""), + stderr.trim(), + ), + ); + Ok((repo_path, vec![log], None, None)) + } + RemoteState::Empty => { + let mut logs = + bootstrap_local_clone(&repo_path, &clone_args, &access_token) + .await?; + if !all_logs_success(&logs) { + return Ok((repo_path, logs, None, None)); + } + logs.push(Log::simple( + "Check Remote", + format!( + "Remote repository is reachable but empty. \ + Initialized local clone on branch {}. \ + Use Initialize File to write the first commit.", + clone_args.branch, + ), + )); + Ok((repo_path, logs, None, None)) + } + RemoteState::BranchMissing { available_branches } => { + let mut logs = + bootstrap_local_clone(&repo_path, &clone_args, &access_token) + .await?; + if !all_logs_success(&logs) { + return Ok((repo_path, logs, None, None)); + } + logs.push(Log::simple( + "Check Remote", + format!( + "Remote repository is reachable but branch '{}' does not \ + exist. Available branches: [{}]. Use Initialize File to \ + create the branch from the first commit.", + clone_args.branch, + available_branches.join(", "), + ), + )); + Ok((repo_path, logs, None, None)) + } + RemoteState::BranchExists => { + git::pull_or_clone( + clone_args, + &config.repo_directory, + access_token, + ) + .await + .context("Failed to clone stack repo") + .map(|(res, _)| { + (repo_path, res.logs, res.commit_hash, res.commit_message) + }) + } + } +} + +/// Ensure the local clone directory exists and is initialized as a git repo +/// pointing at origin + the configured branch. Used when the remote is +/// reachable but has nothing to pull (empty repo or branch missing). +async fn bootstrap_local_clone( + repo_path: &std::path::Path, + clone_args: &RepoExecutionArgs, + access_token: &Option, +) -> anyhow::Result> { + let mut logs = Vec::new(); + if let Some(parent) = repo_path.parent() { + tokio::fs::create_dir_all(parent).await.with_context(|| { + format!("Failed to create repo parent directory {parent:?}") + })?; + } + tokio::fs::create_dir_all(repo_path).await.with_context( + || format!("Failed to create repo directory {repo_path:?}"), + )?; + if !repo_path.join(".git").exists() { + git::init_folder_as_repo( + repo_path, + clone_args, + access_token.as_deref(), + &mut logs, + ) + .await; + } + Ok(logs) } diff --git a/lib/git/src/lib.rs b/lib/git/src/lib.rs index 6a209ad0b..12b60c737 100644 --- a/lib/git/src/lib.rs +++ b/lib/git/src/lib.rs @@ -10,6 +10,7 @@ use komodo_client::entities::{ mod clone; mod commit; mod init; +mod ls_remote; mod pull; mod pull_or_clone; @@ -17,6 +18,7 @@ pub use crate::{ clone::clone, commit::{commit_all, commit_file, write_commit_file}, init::init_folder_as_repo, + ls_remote::{check_remote_state, RemoteState}, pull::pull, pull_or_clone::pull_or_clone, }; diff --git a/lib/git/src/ls_remote.rs b/lib/git/src/ls_remote.rs new file mode 100644 index 000000000..f0079a3c6 --- /dev/null +++ b/lib/git/src/ls_remote.rs @@ -0,0 +1,72 @@ +use anyhow::Context; +use command::run_standard_command; +use komodo_client::entities::RepoExecutionArgs; + +/// Structured view of the remote repository's state, +/// used to decide whether a pull/clone can succeed or +/// whether we can bootstrap from an empty remote. +#[derive(Debug, Clone)] +pub enum RemoteState { + /// Remote is reachable and the configured branch ref exists. + BranchExists, + /// Remote is reachable and has refs, but not the configured branch. + BranchMissing { available_branches: Vec }, + /// Remote is reachable but has zero refs (freshly created empty repo). + Empty, + /// Remote is not reachable. Could be: repo not found, auth failure, + /// network error. `stderr` is scrubbed of the access token. + Unreachable { stderr: String }, +} + +/// Cheap read-only probe of the remote via `git ls-remote`. +/// Uses the raw `run_standard_command` so the token-embedded URL +/// never reaches a log record. `stderr` returned in the Unreachable +/// variant is scrubbed. +pub async fn check_remote_state( + args: &RepoExecutionArgs, + access_token: Option<&str>, +) -> anyhow::Result { + let repo_url = args + .remote_url(access_token) + .context("Failed to build remote URL for ls-remote probe")?; + + let output = run_standard_command( + &format!("git ls-remote {repo_url}"), + None, + ) + .await; + + if !output.success() { + let mut stderr = output.stderr; + if let Some(token) = access_token { + stderr = stderr.replace(token, ""); + } + return Ok(RemoteState::Unreachable { stderr }); + } + + let stdout = output.stdout.trim(); + if stdout.is_empty() { + return Ok(RemoteState::Empty); + } + + let branch_ref = format!("refs/heads/{}", args.branch); + let mut has_branch = false; + let mut available_branches = Vec::new(); + for line in stdout.lines() { + let Some(refname) = line.split_whitespace().nth(1) else { + continue; + }; + if refname == branch_ref { + has_branch = true; + } + if let Some(branch) = refname.strip_prefix("refs/heads/") { + available_branches.push(branch.to_string()); + } + } + + if has_branch { + Ok(RemoteState::BranchExists) + } else { + Ok(RemoteState::BranchMissing { available_branches }) + } +} diff --git a/ui/src/resources/stack/info.tsx b/ui/src/resources/stack/info.tsx index 6ca59bb10..82082cb3d 100644 --- a/ui/src/resources/stack/info.tsx +++ b/ui/src/resources/stack/info.tsx @@ -68,7 +68,7 @@ export default function StackInfo({ {error.path} - {canEdit && ( + {canEdit && !error.path.startsWith("Failed at:") && ( }