mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-08 22:21:45 +00:00
Deduplicate SSH types and utilities within fabro-sandbox
Extract shared SSH types (SshOutput, SshRunner, GitCloneParams) and utility functions (wrap_bash_command, resolve_clone_url, clone_repo) into a new ssh_common module, eliminating ~270 lines of duplication between the exe and ssh sandbox implementations. Also extract a shared resolve_path helper used by four sandbox implementations, and fix an O(n log n) metadata syscall issue in LocalSandbox::glob by switching to sort_by_cached_key. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9b1cc75b20
commit
1fc3495ba9
8 changed files with 228 additions and 326 deletions
|
|
@ -140,13 +140,8 @@ impl DaytonaSandbox {
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolve a path: relative paths are prepended with the working directory.
|
||||
fn resolve_path(&self, path: &str) -> String {
|
||||
if Path::new(path).is_absolute() {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("{WORKING_DIRECTORY}/{path}")
|
||||
}
|
||||
crate::sandbox::resolve_path(path, WORKING_DIRECTORY)
|
||||
}
|
||||
|
||||
/// Get the sandbox, returning an error if not yet initialized.
|
||||
|
|
|
|||
|
|
@ -5,16 +5,19 @@ use std::path::Path;
|
|||
use std::time::Instant;
|
||||
|
||||
use crate::shell_quote;
|
||||
use crate::ssh_common;
|
||||
use crate::{
|
||||
format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent,
|
||||
SandboxEventCallback,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub use crate::ssh_common::{GitCloneParams, SshOutput, SshRunner};
|
||||
pub use openssh_runner::OpensshRunner;
|
||||
|
||||
pub use fabro_config::sandbox::ExeConfig;
|
||||
|
||||
const WORKING_DIRECTORY: &str = "/home/exedev";
|
||||
const PROVIDER: &str = "exe";
|
||||
|
||||
|
|
@ -52,40 +55,6 @@ type DataSshFactory = Box<
|
|||
+ Sync,
|
||||
>;
|
||||
|
||||
/// Output from an SSH command execution.
|
||||
pub struct SshOutput {
|
||||
pub stdout: Vec<u8>,
|
||||
pub stderr: Vec<u8>,
|
||||
pub exit_code: i32,
|
||||
}
|
||||
|
||||
/// Trait abstracting SSH operations for testability.
|
||||
#[async_trait]
|
||||
pub trait SshRunner: Send + Sync {
|
||||
async fn run_command(&self, command: &str) -> Result<SshOutput, String>;
|
||||
|
||||
async fn run_command_with_timeout(
|
||||
&self,
|
||||
command: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<SshOutput, String>;
|
||||
|
||||
async fn upload_file(&self, path: &str, content: &[u8]) -> Result<(), String>;
|
||||
|
||||
async fn download_file(&self, path: &str) -> Result<Vec<u8>, String>;
|
||||
}
|
||||
|
||||
pub use fabro_config::sandbox::ExeConfig;
|
||||
|
||||
/// Parameters for cloning a git repo into the sandbox during initialization.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GitCloneParams {
|
||||
/// Clean HTTPS URL (no embedded credentials).
|
||||
pub url: String,
|
||||
/// Branch to clone. If None, uses the remote's default.
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
/// Sandbox that runs all operations inside an exe.dev VM via SSH.
|
||||
///
|
||||
/// Uses two SSH connections:
|
||||
|
|
@ -206,128 +175,22 @@ impl ExeSandbox {
|
|||
Ok(format!("ssh {host}"))
|
||||
}
|
||||
|
||||
/// Wrap a shell command in base64 encoding to avoid escaping issues.
|
||||
fn wrap_bash_command(command: &str) -> String {
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(command);
|
||||
format!("echo '{encoded}' | base64 -d | sh")
|
||||
}
|
||||
|
||||
/// Resolve an authenticated clone URL from the clean URL and github_app credentials.
|
||||
async fn resolve_clone_url(&self, url: &str) -> Result<String, String> {
|
||||
match &self.github_app {
|
||||
Some(creds) => fabro_github::resolve_authenticated_url(creds, url)
|
||||
.await
|
||||
.or_else(|_| Ok(url.to_string())),
|
||||
None => Ok(url.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone a git repo into the sandbox working directory.
|
||||
async fn clone_repo(&self, params: &GitCloneParams) -> Result<(), String> {
|
||||
let ssh = self.data_ssh()?;
|
||||
|
||||
self.emit(SandboxEvent::GitCloneStarted {
|
||||
url: params.url.clone(),
|
||||
branch: params.branch.clone(),
|
||||
});
|
||||
let clone_start = Instant::now();
|
||||
|
||||
let clone_url = self.resolve_clone_url(¶ms.url).await?;
|
||||
|
||||
let branch_flag = params
|
||||
.branch
|
||||
.as_deref()
|
||||
.map(|b| format!(" --branch {}", shell_quote(b)))
|
||||
.unwrap_or_default();
|
||||
|
||||
let clone_script = format!(
|
||||
"git clone{branch_flag} {} {WORKING_DIRECTORY}",
|
||||
shell_quote(&clone_url)
|
||||
);
|
||||
let clone_cmd = Self::wrap_bash_command(&clone_script);
|
||||
let clone_timeout = std::time::Duration::from_secs(300);
|
||||
let clone_output = ssh
|
||||
.run_command_with_timeout(&clone_cmd, clone_timeout)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let err = format!("git clone failed: {e}");
|
||||
self.emit(SandboxEvent::GitCloneFailed {
|
||||
url: params.url.clone(),
|
||||
error: err.clone(),
|
||||
});
|
||||
err
|
||||
})?;
|
||||
|
||||
if clone_output.exit_code != 0 {
|
||||
let stderr = String::from_utf8_lossy(&clone_output.stderr);
|
||||
|
||||
// Fall back to init + fetch + checkout if directory is not empty
|
||||
if stderr.contains("not an empty directory")
|
||||
|| stderr.contains("already exists and is not an empty")
|
||||
{
|
||||
let branch = params.branch.as_deref().unwrap_or("main");
|
||||
let fallback_script = format!(
|
||||
"cd {WORKING_DIRECTORY} && git init && git remote add origin {} && git fetch origin && git checkout {}",
|
||||
shell_quote(&clone_url),
|
||||
shell_quote(branch),
|
||||
);
|
||||
let fallback_cmd = Self::wrap_bash_command(&fallback_script);
|
||||
let fallback_output = ssh
|
||||
.run_command_with_timeout(&fallback_cmd, clone_timeout)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let err = format!("git fallback clone failed: {e}");
|
||||
self.emit(SandboxEvent::GitCloneFailed {
|
||||
url: params.url.clone(),
|
||||
error: err.clone(),
|
||||
});
|
||||
err
|
||||
})?;
|
||||
|
||||
if fallback_output.exit_code != 0 {
|
||||
let fallback_stderr = String::from_utf8_lossy(&fallback_output.stderr);
|
||||
let err = format!(
|
||||
"git fallback clone failed (exit {}): {fallback_stderr}",
|
||||
fallback_output.exit_code,
|
||||
);
|
||||
self.emit(SandboxEvent::GitCloneFailed {
|
||||
url: params.url.clone(),
|
||||
error: err.clone(),
|
||||
});
|
||||
return Err(err);
|
||||
}
|
||||
} else {
|
||||
let err = format!(
|
||||
"git clone failed (exit {}): {stderr}",
|
||||
clone_output.exit_code,
|
||||
);
|
||||
self.emit(SandboxEvent::GitCloneFailed {
|
||||
url: params.url.clone(),
|
||||
error: err.clone(),
|
||||
});
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Store the clean URL as origin_url for credential refresh
|
||||
let _ = self.origin_url.set(params.url.clone());
|
||||
|
||||
let duration_ms = u64::try_from(clone_start.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
self.emit(SandboxEvent::GitCloneCompleted {
|
||||
url: params.url.clone(),
|
||||
duration_ms,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
ssh_common::clone_repo(
|
||||
ssh,
|
||||
WORKING_DIRECTORY,
|
||||
params,
|
||||
self.github_app.as_ref(),
|
||||
&self.origin_url,
|
||||
&|event| self.emit(event),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resolve a path: relative paths are prepended with the working directory.
|
||||
fn resolve_path(&self, path: &str) -> String {
|
||||
if Path::new(path).is_absolute() {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("{WORKING_DIRECTORY}/{path}")
|
||||
}
|
||||
crate::sandbox::resolve_path(path, WORKING_DIRECTORY)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -488,7 +351,7 @@ impl Sandbox for ExeSandbox {
|
|||
};
|
||||
script.push_str(&format!("cd {} && {command}", shell_quote(&dir)));
|
||||
|
||||
let full_cmd = Self::wrap_bash_command(&script);
|
||||
let full_cmd = ssh_common::wrap_bash_command(&script);
|
||||
|
||||
let timeout = std::time::Duration::from_millis(timeout_ms);
|
||||
let token = cancel_token.unwrap_or_default();
|
||||
|
|
@ -841,6 +704,7 @@ impl Sandbox for ExeSandbox {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::Engine;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// A recorded command sent to the mock SSH runner.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ pub mod sandbox;
|
|||
|
||||
pub mod read_guard;
|
||||
|
||||
#[cfg(feature = "ssh")]
|
||||
pub(crate) mod ssh_common;
|
||||
|
||||
#[cfg(feature = "local")]
|
||||
pub mod local;
|
||||
|
||||
|
|
|
|||
|
|
@ -347,15 +347,13 @@ impl Sandbox for LocalSandbox {
|
|||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
|
||||
// Sort by mtime (newest first)
|
||||
results.sort_by(|a, b| {
|
||||
let mtime_a = std::fs::metadata(a)
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
||||
let mtime_b = std::fs::metadata(b)
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
||||
mtime_b.cmp(&mtime_a)
|
||||
// Sort by mtime (newest first), caching metadata to avoid O(n log n) syscalls
|
||||
results.sort_by_cached_key(|path| {
|
||||
std::cmp::Reverse(
|
||||
std::fs::metadata(path)
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH),
|
||||
)
|
||||
});
|
||||
|
||||
Ok(results)
|
||||
|
|
|
|||
|
|
@ -431,6 +431,22 @@ pub trait Sandbox: Send + Sync {
|
|||
fn mark_agent_read(&self, _path: &str) {}
|
||||
}
|
||||
|
||||
/// Resolve a path: relative paths are prepended with the working directory.
|
||||
/// Used by feature-gated sandbox implementations (exe, ssh, sprites, daytona).
|
||||
#[cfg(any(
|
||||
feature = "exe",
|
||||
feature = "ssh",
|
||||
feature = "sprites",
|
||||
feature = "daytona"
|
||||
))]
|
||||
pub(crate) fn resolve_path(path: &str, working_dir: &str) -> String {
|
||||
if std::path::Path::new(path).is_absolute() {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("{working_dir}/{path}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell-quote a string using `shlex::try_quote`, with a fallback for edge cases.
|
||||
pub fn shell_quote(s: &str) -> String {
|
||||
shlex::try_quote(s).map_or_else(
|
||||
|
|
|
|||
|
|
@ -80,11 +80,7 @@ impl SpritesSandbox {
|
|||
}
|
||||
|
||||
fn resolve_path(&self, path: &str) -> String {
|
||||
if Path::new(path).is_absolute() {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("{WORKING_DIRECTORY}/{path}")
|
||||
}
|
||||
crate::sandbox::resolve_path(path, WORKING_DIRECTORY)
|
||||
}
|
||||
|
||||
fn sprite_name(&self) -> Result<&str, String> {
|
||||
|
|
|
|||
|
|
@ -5,51 +5,20 @@ use std::path::Path;
|
|||
use std::time::Instant;
|
||||
|
||||
use crate::shell_quote;
|
||||
use crate::ssh_common;
|
||||
use crate::{
|
||||
format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent,
|
||||
SandboxEventCallback,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub use crate::ssh_common::{GitCloneParams, SshOutput, SshRunner};
|
||||
pub use openssh_runner::OpensshRunner;
|
||||
|
||||
const PROVIDER: &str = "ssh";
|
||||
|
||||
/// Output from an SSH command execution.
|
||||
pub struct SshOutput {
|
||||
pub stdout: Vec<u8>,
|
||||
pub stderr: Vec<u8>,
|
||||
pub exit_code: i32,
|
||||
}
|
||||
|
||||
/// Trait abstracting SSH operations for testability.
|
||||
#[async_trait]
|
||||
pub trait SshRunner: Send + Sync {
|
||||
async fn run_command(&self, command: &str) -> Result<SshOutput, String>;
|
||||
|
||||
async fn run_command_with_timeout(
|
||||
&self,
|
||||
command: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<SshOutput, String>;
|
||||
|
||||
async fn upload_file(&self, path: &str, content: &[u8]) -> Result<(), String>;
|
||||
|
||||
async fn download_file(&self, path: &str) -> Result<Vec<u8>, String>;
|
||||
}
|
||||
|
||||
pub use fabro_config::sandbox::SshConfig;
|
||||
|
||||
/// Parameters for cloning a git repo into the sandbox during initialization.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GitCloneParams {
|
||||
/// Clean HTTPS URL (no embedded credentials).
|
||||
pub url: String,
|
||||
/// Branch to clone. If None, uses the remote's default.
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
const PROVIDER: &str = "ssh";
|
||||
|
||||
/// Sandbox that runs all operations on a user-provided SSH host.
|
||||
///
|
||||
|
|
@ -127,131 +96,22 @@ impl SshSandbox {
|
|||
format!("ssh {}", self.config.destination)
|
||||
}
|
||||
|
||||
/// Wrap a shell command in base64 encoding to avoid escaping issues.
|
||||
fn wrap_bash_command(command: &str) -> String {
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(command);
|
||||
format!("echo '{encoded}' | base64 -d | sh")
|
||||
}
|
||||
|
||||
/// Resolve an authenticated clone URL from the clean URL and github_app credentials.
|
||||
async fn resolve_clone_url(&self, url: &str) -> Result<String, String> {
|
||||
match &self.github_app {
|
||||
Some(creds) => fabro_github::resolve_authenticated_url(creds, url)
|
||||
.await
|
||||
.or_else(|_| Ok(url.to_string())),
|
||||
None => Ok(url.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone a git repo into the sandbox working directory.
|
||||
async fn clone_repo(&self, params: &GitCloneParams) -> Result<(), String> {
|
||||
let ssh = self.ssh()?;
|
||||
let working_dir = &self.config.working_directory;
|
||||
|
||||
self.emit(SandboxEvent::GitCloneStarted {
|
||||
url: params.url.clone(),
|
||||
branch: params.branch.clone(),
|
||||
});
|
||||
let clone_start = Instant::now();
|
||||
|
||||
let clone_url = self.resolve_clone_url(¶ms.url).await?;
|
||||
|
||||
let branch_flag = params
|
||||
.branch
|
||||
.as_deref()
|
||||
.map(|b| format!(" --branch {}", shell_quote(b)))
|
||||
.unwrap_or_default();
|
||||
|
||||
let clone_script = format!(
|
||||
"git clone{branch_flag} {} {}",
|
||||
shell_quote(&clone_url),
|
||||
shell_quote(working_dir),
|
||||
);
|
||||
let clone_cmd = Self::wrap_bash_command(&clone_script);
|
||||
let clone_timeout = std::time::Duration::from_secs(300);
|
||||
let clone_output = ssh
|
||||
.run_command_with_timeout(&clone_cmd, clone_timeout)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let err = format!("git clone failed: {e}");
|
||||
self.emit(SandboxEvent::GitCloneFailed {
|
||||
url: params.url.clone(),
|
||||
error: err.clone(),
|
||||
});
|
||||
err
|
||||
})?;
|
||||
|
||||
if clone_output.exit_code != 0 {
|
||||
let stderr = String::from_utf8_lossy(&clone_output.stderr);
|
||||
|
||||
// Fall back to init + fetch + checkout if directory is not empty
|
||||
if stderr.contains("not an empty directory")
|
||||
|| stderr.contains("already exists and is not an empty")
|
||||
{
|
||||
let branch = params.branch.as_deref().unwrap_or("main");
|
||||
let fallback_script = format!(
|
||||
"cd {} && git init && git remote add origin {} && git fetch origin && git checkout {}",
|
||||
shell_quote(working_dir),
|
||||
shell_quote(&clone_url),
|
||||
shell_quote(branch),
|
||||
);
|
||||
let fallback_cmd = Self::wrap_bash_command(&fallback_script);
|
||||
let fallback_output = ssh
|
||||
.run_command_with_timeout(&fallback_cmd, clone_timeout)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let err = format!("git fallback clone failed: {e}");
|
||||
self.emit(SandboxEvent::GitCloneFailed {
|
||||
url: params.url.clone(),
|
||||
error: err.clone(),
|
||||
});
|
||||
err
|
||||
})?;
|
||||
|
||||
if fallback_output.exit_code != 0 {
|
||||
let fallback_stderr = String::from_utf8_lossy(&fallback_output.stderr);
|
||||
let err = format!(
|
||||
"git fallback clone failed (exit {}): {fallback_stderr}",
|
||||
fallback_output.exit_code,
|
||||
);
|
||||
self.emit(SandboxEvent::GitCloneFailed {
|
||||
url: params.url.clone(),
|
||||
error: err.clone(),
|
||||
});
|
||||
return Err(err);
|
||||
}
|
||||
} else {
|
||||
let err = format!(
|
||||
"git clone failed (exit {}): {stderr}",
|
||||
clone_output.exit_code,
|
||||
);
|
||||
self.emit(SandboxEvent::GitCloneFailed {
|
||||
url: params.url.clone(),
|
||||
error: err.clone(),
|
||||
});
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Store the clean URL as origin_url for credential refresh
|
||||
let _ = self.origin_url.set(params.url.clone());
|
||||
|
||||
let duration_ms = u64::try_from(clone_start.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
self.emit(SandboxEvent::GitCloneCompleted {
|
||||
url: params.url.clone(),
|
||||
duration_ms,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
ssh_common::clone_repo(
|
||||
ssh,
|
||||
&self.config.working_directory,
|
||||
params,
|
||||
self.github_app.as_ref(),
|
||||
&self.origin_url,
|
||||
&|event| self.emit(event),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resolve a path: relative paths are prepended with the working directory.
|
||||
fn resolve_path(&self, path: &str) -> String {
|
||||
if Path::new(path).is_absolute() {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("{}/{path}", self.config.working_directory)
|
||||
}
|
||||
crate::sandbox::resolve_path(path, &self.config.working_directory)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -362,7 +222,7 @@ impl Sandbox for SshSandbox {
|
|||
};
|
||||
script.push_str(&format!("cd {} && {command}", shell_quote(&dir)));
|
||||
|
||||
let full_cmd = Self::wrap_bash_command(&script);
|
||||
let full_cmd = ssh_common::wrap_bash_command(&script);
|
||||
|
||||
let timeout = std::time::Duration::from_millis(timeout_ms);
|
||||
let token = cancel_token.unwrap_or_default();
|
||||
|
|
@ -725,6 +585,7 @@ impl Sandbox for SshSandbox {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::Engine;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// A recorded command sent to the mock SSH runner.
|
||||
|
|
|
|||
169
lib/crates/fabro-sandbox/src/ssh_common.rs
Normal file
169
lib/crates/fabro-sandbox/src/ssh_common.rs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
//! Shared types and utilities for SSH-based sandbox implementations (exe, ssh).
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine;
|
||||
|
||||
use crate::{shell_quote, SandboxEvent};
|
||||
|
||||
/// Output from an SSH command execution.
|
||||
pub struct SshOutput {
|
||||
pub stdout: Vec<u8>,
|
||||
pub stderr: Vec<u8>,
|
||||
pub exit_code: i32,
|
||||
}
|
||||
|
||||
/// Trait abstracting SSH operations for testability.
|
||||
#[async_trait]
|
||||
pub trait SshRunner: Send + Sync {
|
||||
async fn run_command(&self, command: &str) -> Result<SshOutput, String>;
|
||||
|
||||
async fn run_command_with_timeout(
|
||||
&self,
|
||||
command: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<SshOutput, String>;
|
||||
|
||||
async fn upload_file(&self, path: &str, content: &[u8]) -> Result<(), String>;
|
||||
|
||||
async fn download_file(&self, path: &str) -> Result<Vec<u8>, String>;
|
||||
}
|
||||
|
||||
/// Parameters for cloning a git repo into the sandbox during initialization.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GitCloneParams {
|
||||
/// Clean HTTPS URL (no embedded credentials).
|
||||
pub url: String,
|
||||
/// Branch to clone. If None, uses the remote's default.
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
/// Wrap a shell command in base64 encoding to avoid escaping issues.
|
||||
pub(crate) fn wrap_bash_command(command: &str) -> String {
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(command);
|
||||
format!("echo '{encoded}' | base64 -d | sh")
|
||||
}
|
||||
|
||||
/// Resolve an authenticated clone URL using GitHub App credentials, falling back to the
|
||||
/// original URL if authentication fails or no credentials are provided.
|
||||
pub(crate) async fn resolve_clone_url(
|
||||
url: &str,
|
||||
github_app: Option<&fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<String, String> {
|
||||
match github_app {
|
||||
Some(creds) => fabro_github::resolve_authenticated_url(creds, url)
|
||||
.await
|
||||
.or_else(|_| Ok(url.to_string())),
|
||||
None => Ok(url.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone a git repo into a sandbox working directory over SSH.
|
||||
///
|
||||
/// Handles the common clone logic including fallback to init+fetch when the
|
||||
/// directory is not empty, event emission, and origin URL tracking.
|
||||
pub(crate) async fn clone_repo(
|
||||
ssh: &dyn SshRunner,
|
||||
working_dir: &str,
|
||||
params: &GitCloneParams,
|
||||
github_app: Option<&fabro_github::GitHubAppCredentials>,
|
||||
origin_url: &tokio::sync::OnceCell<String>,
|
||||
emit: &(dyn Fn(SandboxEvent) + Send + Sync),
|
||||
) -> Result<(), String> {
|
||||
emit(SandboxEvent::GitCloneStarted {
|
||||
url: params.url.clone(),
|
||||
branch: params.branch.clone(),
|
||||
});
|
||||
let clone_start = Instant::now();
|
||||
|
||||
let clone_url = resolve_clone_url(¶ms.url, github_app).await?;
|
||||
|
||||
let branch_flag = params
|
||||
.branch
|
||||
.as_deref()
|
||||
.map(|b| format!(" --branch {}", shell_quote(b)))
|
||||
.unwrap_or_default();
|
||||
|
||||
let clone_script = format!(
|
||||
"git clone{branch_flag} {} {}",
|
||||
shell_quote(&clone_url),
|
||||
shell_quote(working_dir),
|
||||
);
|
||||
let clone_cmd = wrap_bash_command(&clone_script);
|
||||
let clone_timeout = std::time::Duration::from_secs(300);
|
||||
let clone_output = ssh
|
||||
.run_command_with_timeout(&clone_cmd, clone_timeout)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let err = format!("git clone failed: {e}");
|
||||
emit(SandboxEvent::GitCloneFailed {
|
||||
url: params.url.clone(),
|
||||
error: err.clone(),
|
||||
});
|
||||
err
|
||||
})?;
|
||||
|
||||
if clone_output.exit_code != 0 {
|
||||
let stderr = String::from_utf8_lossy(&clone_output.stderr);
|
||||
|
||||
// Fall back to init + fetch + checkout if directory is not empty
|
||||
if stderr.contains("not an empty directory")
|
||||
|| stderr.contains("already exists and is not an empty")
|
||||
{
|
||||
let branch = params.branch.as_deref().unwrap_or("main");
|
||||
let fallback_script = format!(
|
||||
"cd {} && git init && git remote add origin {} && git fetch origin && git checkout {}",
|
||||
shell_quote(working_dir),
|
||||
shell_quote(&clone_url),
|
||||
shell_quote(branch),
|
||||
);
|
||||
let fallback_cmd = wrap_bash_command(&fallback_script);
|
||||
let fallback_output = ssh
|
||||
.run_command_with_timeout(&fallback_cmd, clone_timeout)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let err = format!("git fallback clone failed: {e}");
|
||||
emit(SandboxEvent::GitCloneFailed {
|
||||
url: params.url.clone(),
|
||||
error: err.clone(),
|
||||
});
|
||||
err
|
||||
})?;
|
||||
|
||||
if fallback_output.exit_code != 0 {
|
||||
let fallback_stderr = String::from_utf8_lossy(&fallback_output.stderr);
|
||||
let err = format!(
|
||||
"git fallback clone failed (exit {}): {fallback_stderr}",
|
||||
fallback_output.exit_code,
|
||||
);
|
||||
emit(SandboxEvent::GitCloneFailed {
|
||||
url: params.url.clone(),
|
||||
error: err.clone(),
|
||||
});
|
||||
return Err(err);
|
||||
}
|
||||
} else {
|
||||
let err = format!(
|
||||
"git clone failed (exit {}): {stderr}",
|
||||
clone_output.exit_code,
|
||||
);
|
||||
emit(SandboxEvent::GitCloneFailed {
|
||||
url: params.url.clone(),
|
||||
error: err.clone(),
|
||||
});
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Store the clean URL as origin_url for credential refresh
|
||||
let _ = origin_url.set(params.url.clone());
|
||||
|
||||
let duration_ms = u64::try_from(clone_start.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
emit(SandboxEvent::GitCloneCompleted {
|
||||
url: params.url.clone(),
|
||||
duration_ms,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue