Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 81 additions & 20 deletions bin/core/src/api/write/stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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)
Expand Down
111 changes: 105 additions & 6 deletions bin/core/src/stack/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>,
) -> anyhow::Result<Vec<Log>> {
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)
}
2 changes: 2 additions & 0 deletions lib/git/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ use komodo_client::entities::{
mod clone;
mod commit;
mod init;
mod ls_remote;
mod pull;
mod pull_or_clone;

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,
};
Expand Down
72 changes: 72 additions & 0 deletions lib/git/src/ls_remote.rs
Original file line number Diff line number Diff line change
@@ -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<String> },
/// 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<RemoteState> {
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, "<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 })
}
}
2 changes: 1 addition & 1 deletion ui/src/resources/stack/info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export default function StackInfo({
{error.path}
</Group>

{canEdit && (
{canEdit && !error.path.startsWith("Failed at:") && (
<ConfirmButton
loading={isPending}
icon={<FilePlus size="1rem" />}
Expand Down