use super::*;
#[cfg(test)]
pub(super) fn build_resolve_command_argv(
resolve_command_template: &str,
prompt: &str,
) -> Result<Vec<String>, String> {
let parts = shlex::split(resolve_command_template)
.ok_or_else(|| "Failed to parse resolve_command (shlex split returned None)".to_string())?;
if parts.is_empty() {
return Err("resolve_command is empty".to_string());
}
Ok(parts
.into_iter()
.map(|p| p.replace("{prompt}", prompt))
.collect())
}
fn emit_log_entry(state: &AppState, entry: RemoteLogEntry) {
if let Some(db) = &state.db {
if let Err(e) = db.insert_log(
entry.project_id.as_deref(),
&entry.level,
&entry.message,
entry.change_id.as_deref(),
entry.operation.as_deref(),
entry.iteration.map(i64::from),
) {
error!(error = %e, "Failed to persist server log entry");
}
}
let _ = state.log_tx.send(entry);
}
fn build_resolve_log_entry(project_id: &str, level: &str, message: String) -> RemoteLogEntry {
RemoteLogEntry {
message,
level: level.to_string(),
change_id: None,
timestamp: chrono::Utc::now().to_rfc3339(),
project_id: Some(project_id.to_string()),
operation: Some("resolve".to_string()),
iteration: None,
}
}
fn emit_resolve_log(state: &AppState, project_id: &str, level: &str, message: String) {
emit_log_entry(state, build_resolve_log_entry(project_id, level, message));
}
pub(super) async fn run_resolve_command(
resolve_command_template: &str,
work_dir: &std::path::Path,
prompt: &str,
state: Option<&AppState>,
project_id: Option<&str>,
) -> (bool, Option<i32>) {
let command_str = crate::config::expand::expand_prompt(resolve_command_template, prompt);
info!(
"Running resolve_command via login shell: command='{}'",
command_str
);
if let (Some(state), Some(pid)) = (state, project_id) {
emit_resolve_log(
state,
pid,
"info",
format!("resolve_command started: {}", command_str),
);
}
let mut cmd = crate::shell_command::build_login_shell_command(&command_str);
cmd.current_dir(work_dir);
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let child = match cmd.spawn() {
Ok(child) => child,
Err(e) => {
error!(
"Failed to run resolve_command '{}': {}",
resolve_command_template, e
);
if let (Some(state), Some(pid)) = (state, project_id) {
emit_resolve_log(
state,
pid,
"error",
format!("resolve_command failed to start: {}", e),
);
}
return (true, Some(-1));
}
};
let output = match child.wait_with_output().await {
Ok(output) => output,
Err(e) => {
error!(
"Failed to wait for resolve_command '{}': {}",
resolve_command_template, e
);
return (true, Some(-1));
}
};
if let (Some(state), Some(pid)) = (state, project_id) {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if !line.is_empty() {
emit_resolve_log(state, pid, "info", line.to_string());
}
}
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr.lines() {
if !line.is_empty() {
emit_resolve_log(state, pid, "warn", line.to_string());
}
}
let exit_code = output.status.code();
let level = if output.status.success() {
"success"
} else {
"error"
};
emit_resolve_log(
state,
pid,
level,
format!("resolve_command finished: exit_code={:?}", exit_code),
);
}
(true, output.status.code())
}
fn build_auto_resolve_prompt(
operation: &str,
project_id: &str,
remote_url: &str,
branch: &str,
local_sha: &str,
remote_sha: &str,
work_dir: &std::path::Path,
) -> String {
format!(
"Conflux server auto_resolve\noperation={}\nproject_id={}\nremote_url={}\nbranch={}\nlocal_sha={}\nremote_sha={}\nwork_dir={}\n\nTask: reconcile local state so the {} can proceed. Exit 0 on success, non-zero on failure.",
operation,
project_id,
remote_url,
branch,
local_sha,
remote_sha,
work_dir.display(),
operation
)
}
#[derive(Debug, PartialEq, Eq)]
struct SyncPlan {
should_skip_resolve_and_push: bool,
}
fn plan_sync(local_sha_for_push: &str, remote_sha_for_push: &str) -> SyncPlan {
let should_skip_resolve_and_push =
!remote_sha_for_push.is_empty() && local_sha_for_push == remote_sha_for_push;
SyncPlan {
should_skip_resolve_and_push,
}
}
pub async fn git_pull(
state: State<AppState>,
path: Path<String>,
_payload: Option<Json<GitAutoResolveRequest>>,
) -> Response {
info!("git pull (delegating to git_sync): project_id={}", path.0);
git_sync(state, path).await
}
pub async fn git_push(
state: State<AppState>,
path: Path<String>,
_payload: Option<Json<GitAutoResolveRequest>>,
) -> Response {
info!("git push (delegating to git_sync): project_id={}", path.0);
git_sync(state, path).await
}
pub async fn git_sync(State(state): State<AppState>, Path(project_id): Path<String>) -> Response {
let resolve_command = match &state.resolve_command {
Some(cmd) => cmd.clone(),
None => {
return (
StatusCode::UNPROCESSABLE_ENTITY,
Json(serde_json::json!({
"error": "resolve_command_not_configured",
"reason": "git/sync requires resolve_command to be configured. Set resolve_command in your .cflx.jsonc configuration."
})),
)
.into_response();
}
};
let _active_guard = match try_acquire_active_command(
&state.active_commands,
&project_id,
RootKind::Base,
"sync",
)
.await
{
Ok(guard) => guard,
Err(resp) => return resp,
};
let (remote_url, branch, lock, semaphore) = {
let registry = state.registry.read().await;
let entry = match registry.get(&project_id) {
Some(e) => e.clone(),
None => {
return error_response(
StatusCode::NOT_FOUND,
format!("Project not found: {}", project_id),
)
}
};
let lock = match registry.project_lock(&project_id) {
Some(l) => l,
None => {
return error_response(StatusCode::INTERNAL_SERVER_ERROR, "Missing project lock")
}
};
let semaphore = registry.global_semaphore();
(entry.remote_url, entry.branch, lock, semaphore)
};
let _sem_permit = match semaphore.acquire().await {
Ok(p) => p,
Err(_) => {
return error_response(
StatusCode::SERVICE_UNAVAILABLE,
"Server is at maximum concurrent capacity",
)
}
};
let _guard = lock.lock().await;
info!(
"git sync: project_id={} remote_url={} branch={}",
project_id, remote_url, branch
);
let local_repo_path = {
let registry = state.registry.read().await;
registry.data_dir().join(&project_id)
};
let ls_remote = tokio::process::Command::new("git")
.args(["ls-remote", "--heads", &remote_url, &branch])
.output()
.await;
let remote_ref = match ls_remote {
Ok(out) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
if stdout.trim().is_empty() {
return error_response(
StatusCode::UNPROCESSABLE_ENTITY,
format!("Branch '{}' not found on remote '{}'", branch, remote_url),
);
}
stdout.trim().to_string()
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
return error_response(
StatusCode::UNPROCESSABLE_ENTITY,
format!("git operation failed: {}", stderr),
);
}
Err(e) => {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to run git: {}", e),
);
}
};
if !local_repo_path.exists() {
let clone_output = tokio::process::Command::new("git")
.args([
"clone",
"--bare",
"--branch",
&branch,
"--single-branch",
&remote_url,
local_repo_path.to_str().unwrap_or(""),
])
.output()
.await;
match clone_output {
Ok(out) if out.status.success() => {
info!("git sync clone (bare) succeeded: project_id={}", project_id);
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
return error_response(
StatusCode::UNPROCESSABLE_ENTITY,
format!("git clone failed: {}", stderr),
);
}
Err(e) => {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to run git clone: {}", e),
);
}
}
} else {
let fetch_remote_ref = format!("refs/heads/{}", branch);
let fetch_output = tokio::process::Command::new("git")
.args([
"fetch",
&remote_url,
&format!("{}:refs/remotes/origin/{}", fetch_remote_ref, branch),
])
.current_dir(&local_repo_path)
.output()
.await;
match fetch_output {
Ok(out) if out.status.success() => {
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
return error_response(
StatusCode::UNPROCESSABLE_ENTITY,
format!("git fetch failed: {}", stderr),
);
}
Err(e) => {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to run git fetch: {}", e),
);
}
}
let ff_output = tokio::process::Command::new("git")
.args([
"fetch",
&remote_url,
&format!("refs/heads/{}:refs/heads/{}", branch, branch),
])
.current_dir(&local_repo_path)
.output()
.await;
match ff_output {
Ok(out) if out.status.success() => {}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
return error_response(
StatusCode::UNPROCESSABLE_ENTITY,
format!("git fetch (fast-forward update) failed: {}", stderr),
);
}
Err(e) => {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to run git fetch: {}", e),
);
}
}
}
let pull_result = serde_json::json!({
"status": "pulled",
"ref": remote_ref
});
let local_rev = tokio::process::Command::new("git")
.args(["rev-parse", &format!("refs/heads/{}", branch)])
.current_dir(&local_repo_path)
.output()
.await;
let local_sha_for_push = match local_rev {
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_string(),
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
return error_response(
StatusCode::UNPROCESSABLE_ENTITY,
format!("Failed to get local branch ref: {}", stderr),
);
}
Err(e) => {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to run git rev-parse: {}", e),
);
}
};
let remote_rev = tokio::process::Command::new("git")
.args(["ls-remote", "--heads", &remote_url, &branch])
.output()
.await;
let remote_sha_for_push = match remote_rev {
Ok(out) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout);
if stdout.trim().is_empty() {
String::new()
} else {
stdout.split_whitespace().next().unwrap_or("").to_string()
}
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
return error_response(
StatusCode::UNPROCESSABLE_ENTITY,
format!("Failed to reach remote: {}", stderr),
);
}
Err(e) => {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to run git ls-remote: {}", e),
);
}
};
let sync_plan = plan_sync(&local_sha_for_push, &remote_sha_for_push);
if sync_plan.should_skip_resolve_and_push {
info!(
"git sync: already up-to-date, skipping resolve and push: project_id={} sha={}",
project_id, local_sha_for_push
);
return (
StatusCode::OK,
Json(serde_json::json!({
"status": "synced",
"pull": pull_result,
"push": {
"status": "already_up_to_date",
"branch": branch,
"local_sha": local_sha_for_push,
"remote_sha": remote_sha_for_push
},
"resolve_command_ran": false,
"resolve_exit_code": serde_json::Value::Null,
"skipped_reason": "local_and_remote_already_match"
})),
)
.into_response();
}
info!(
"git sync: running resolve_command before push: project_id={}",
project_id
);
let resolve_prompt = build_auto_resolve_prompt(
"git_sync",
&project_id,
&remote_url,
&branch,
&local_sha_for_push,
&remote_sha_for_push,
&local_repo_path,
);
let (resolve_command_ran, resolve_exit_code) = run_resolve_command(
&resolve_command,
&local_repo_path,
&resolve_prompt,
Some(&state),
Some(&project_id),
)
.await;
if resolve_exit_code != Some(0) {
error!(
"git sync: resolve_command failed: project_id={} exit_code={:?}",
project_id, resolve_exit_code
);
return (
StatusCode::UNPROCESSABLE_ENTITY,
Json(serde_json::json!({
"error": "resolve_command_failed",
"reason": "resolve_command failed during sync",
"local_sha": local_sha_for_push,
"remote_sha": remote_sha_for_push,
"resolve_command_ran": resolve_command_ran,
"resolve_exit_code": resolve_exit_code
})),
)
.into_response();
}
let push_output = tokio::process::Command::new("git")
.args([
"push",
&remote_url,
&format!("refs/heads/{}:refs/heads/{}", branch, branch),
])
.current_dir(&local_repo_path)
.output()
.await;
let push_result = match push_output {
Ok(out) if out.status.success() => {
info!("git sync push succeeded: project_id={}", project_id);
serde_json::json!({
"status": "pushed",
"remote_url": remote_url,
"branch": branch,
"local_sha": local_sha_for_push,
"resolve_command_ran": resolve_command_ran,
"resolve_exit_code": resolve_exit_code
})
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
error!(
"git sync push failed: project_id={} stderr={}",
project_id, stderr
);
if stderr.contains("non-fast-forward") || stderr.contains("rejected") {
return (
StatusCode::UNPROCESSABLE_ENTITY,
Json(serde_json::json!({
"error": "non_fast_forward",
"reason": "Push rejected by remote (non-fast-forward)",
"stderr": stderr
})),
)
.into_response();
}
return error_response(
StatusCode::UNPROCESSABLE_ENTITY,
format!("git push failed: {}", stderr),
);
}
Err(e) => {
error!("Failed to run git push: {}", e);
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to run git push: {}", e),
);
}
};
(
StatusCode::OK,
Json(serde_json::json!({
"status": "synced",
"pull": pull_result,
"push": push_result,
"resolve_command_ran": resolve_command_ran,
"resolve_exit_code": resolve_exit_code
})),
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use tempfile::TempDir;
use tokio::sync::broadcast::{error::TryRecvError, Receiver};
use tower::ServiceExt;
use crate::remote::types::RemoteLogEntry;
use crate::server::api::test_support::{
create_local_git_repo, init_bare_repo_with_commit, make_state,
};
use crate::server::api::{build_router, AppState, OrchestrationStatus, SERVER_LOG_BUFFER_SIZE};
use crate::server::registry::create_shared_registry;
fn drain_log_entries(log_rx: &mut Receiver<RemoteLogEntry>) -> Vec<RemoteLogEntry> {
let mut entries = Vec::new();
loop {
match log_rx.try_recv() {
Ok(entry) => entries.push(entry),
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Lagged(_)) => continue,
Err(TryRecvError::Closed) => break,
}
}
entries
}
#[test]
fn test_build_resolve_command_argv_replaces_prompt_placeholder_as_single_arg() {
let template = "opencode run --agent code '{prompt}'";
let prompt = "hello world";
let argv = build_resolve_command_argv(template, prompt).expect("argv should build");
assert_eq!(
argv,
vec![
"opencode".to_string(),
"run".to_string(),
"--agent".to_string(),
"code".to_string(),
"hello world".to_string(),
]
);
}
#[test]
fn test_build_resolve_command_argv_handles_quotes_and_braces_literal() {
let template = "echo '{prompt}' '{prompt}-suffix'";
let prompt = "a b c";
let argv = build_resolve_command_argv(template, prompt).expect("argv should build");
assert_eq!(
argv,
vec![
"echo".to_string(),
"a b c".to_string(),
"a b c-suffix".to_string(),
]
);
}
#[test]
fn test_plan_sync_skips_when_remote_sha_matches_local_sha() {
let plan = plan_sync("abc123", "abc123");
assert!(plan.should_skip_resolve_and_push);
}
#[test]
fn test_plan_sync_does_not_skip_when_remote_sha_differs() {
let plan = plan_sync("abc123", "def456");
assert!(!plan.should_skip_resolve_and_push);
}
#[test]
fn test_plan_sync_does_not_skip_when_remote_sha_is_empty() {
let plan = plan_sync("abc123", "");
assert!(!plan.should_skip_resolve_and_push);
}
#[tokio::test]
async fn test_run_resolve_command_uses_login_shell() {
let temp_dir = TempDir::new().unwrap();
let (ran, exit_code) =
run_resolve_command("echo hello", temp_dir.path(), "test prompt", None, None).await;
assert!(ran, "resolve_command should have been attempted");
assert_eq!(
exit_code,
Some(0),
"echo command should succeed via login shell"
);
}
#[tokio::test]
async fn test_run_resolve_command_substitutes_prompt() {
let temp_dir = TempDir::new().unwrap();
let (ran, exit_code) =
run_resolve_command("echo {prompt}", temp_dir.path(), "test_marker", None, None).await;
assert!(ran, "resolve_command should have been attempted");
assert_eq!(
exit_code,
Some(0),
"echo with prompt substitution should succeed"
);
}
#[tokio::test]
async fn test_run_resolve_command_quoted_template_does_not_double_quote() {
let temp_dir = TempDir::new().unwrap();
let marker_path = temp_dir.path().join("quoted_marker.txt");
let template = format!(
"printf '%s' '{{prompt}}' > '{}'",
marker_path.to_str().unwrap()
);
let (ran, exit_code) =
run_resolve_command(&template, temp_dir.path(), "hello world", None, None).await;
assert!(ran, "resolve_command should have been attempted");
assert_eq!(
exit_code,
Some(0),
"printf with quoted prompt template should succeed, not exit 127"
);
let content = std::fs::read_to_string(&marker_path).unwrap_or_default();
assert_eq!(
content, "hello world",
"Quoted template should pass prompt value without double-quoting"
);
}
#[tokio::test]
async fn test_run_resolve_command_unquoted_template_works() {
let temp_dir = TempDir::new().unwrap();
let marker_path = temp_dir.path().join("unquoted_marker.txt");
let template = format!(
"printf '%s' {{prompt}} > '{}'",
marker_path.to_str().unwrap()
);
let (ran, exit_code) =
run_resolve_command(&template, temp_dir.path(), "simple_word", None, None).await;
assert!(ran, "resolve_command should have been attempted");
assert_eq!(
exit_code,
Some(0),
"printf with unquoted prompt template should succeed"
);
let content = std::fs::read_to_string(&marker_path).unwrap_or_default();
assert_eq!(
content, "simple_word",
"Unquoted template should pass prompt value correctly"
);
}
#[tokio::test]
async fn test_run_resolve_command_multiline_prompt_does_not_break_shell() {
let temp_dir = TempDir::new().unwrap();
let marker_path = temp_dir.path().join("multiline_marker.txt");
let template = format!(
"printf '%s' '{{prompt}}' > '{}'",
marker_path.to_str().unwrap()
);
let multiline_prompt =
"Conflux server auto_resolve\noperation=git_sync\nproject_id=abc123\nTask: reconcile local state";
let (ran, exit_code) =
run_resolve_command(&template, temp_dir.path(), multiline_prompt, None, None).await;
assert!(ran, "resolve_command should have been attempted");
assert_eq!(
exit_code,
Some(0),
"Multi-line prompt with quoted template must not cause exit 127"
);
let content = std::fs::read_to_string(&marker_path).unwrap_or_default();
assert_eq!(
content, multiline_prompt,
"Multi-line prompt should be passed intact through the shell"
);
}
#[tokio::test]
async fn test_run_resolve_command_multiline_prompt_unquoted_template() {
let temp_dir = TempDir::new().unwrap();
let marker_path = temp_dir.path().join("multiline_unquoted.txt");
let template = format!(
"printf '%s' {{prompt}} > '{}'",
marker_path.to_str().unwrap()
);
let multiline_prompt = "Line 1\nLine 2\nLine 3";
let (ran, exit_code) =
run_resolve_command(&template, temp_dir.path(), multiline_prompt, None, None).await;
assert!(ran, "resolve_command should have been attempted");
assert_eq!(
exit_code,
Some(0),
"Multi-line prompt with unquoted template should succeed"
);
let content = std::fs::read_to_string(&marker_path).unwrap_or_default();
assert_eq!(
content, multiline_prompt,
"Multi-line prompt should be passed intact"
);
}
#[tokio::test]
async fn test_run_resolve_command_logs_start_stdout_stderr_finished_in_order() {
let temp_dir = TempDir::new().unwrap();
let mut state = make_state(&temp_dir, None);
let mut log_rx = state.log_tx.subscribe();
state.resolve_command = Some("echo unused".to_string());
let command = "printf 'stdout-line\\n'; printf 'stderr-line\\n' >&2; exit 0";
let (ran, exit_code) =
run_resolve_command(command, temp_dir.path(), "prompt", Some(&state), Some("p1")).await;
assert!(ran, "resolve_command should have been attempted");
assert_eq!(exit_code, Some(0));
let entries = drain_log_entries(&mut log_rx);
let resolve_entries: Vec<_> = entries
.into_iter()
.filter(|entry| entry.project_id.as_deref() == Some("p1"))
.collect();
assert_eq!(
resolve_entries.len(),
4,
"expected start/stdout/stderr/finished logs"
);
assert_eq!(resolve_entries[0].level, "info");
assert!(resolve_entries[0]
.message
.starts_with("resolve_command started:"));
assert_eq!(resolve_entries[1].level, "info");
assert_eq!(resolve_entries[1].message, "stdout-line");
assert_eq!(resolve_entries[2].level, "warn");
assert_eq!(resolve_entries[2].message, "stderr-line");
assert_eq!(resolve_entries[3].level, "success");
assert_eq!(
resolve_entries[3].message,
"resolve_command finished: exit_code=Some(0)"
);
assert!(resolve_entries
.iter()
.all(|entry| entry.operation.as_deref() == Some("resolve")));
}
#[tokio::test]
async fn test_run_resolve_command_logs_error_level_on_non_zero_exit() {
let temp_dir = TempDir::new().unwrap();
let state = make_state(&temp_dir, None);
let mut log_rx = state.log_tx.subscribe();
let (ran, exit_code) = run_resolve_command(
"printf 'ok\\n'; exit 7",
temp_dir.path(),
"prompt",
Some(&state),
Some("p2"),
)
.await;
assert!(ran);
assert_eq!(exit_code, Some(7));
let entries = drain_log_entries(&mut log_rx);
let resolve_entries: Vec<_> = entries
.into_iter()
.filter(|entry| entry.project_id.as_deref() == Some("p2"))
.collect();
assert_eq!(
resolve_entries.len(),
3,
"expected start/stdout/finished logs"
);
assert_eq!(resolve_entries[2].level, "error");
assert_eq!(
resolve_entries[2].message,
"resolve_command finished: exit_code=Some(7)"
);
}
#[tokio::test]
async fn test_git_pull_non_fast_forward_detection() {
let temp_dir = TempDir::new().unwrap();
let state = make_state(&temp_dir, None);
let branch = "main";
let remote_dir = TempDir::new().unwrap();
let remote_path = remote_dir.path();
let remote_sha = init_bare_repo_with_commit(remote_path, branch).await;
if remote_sha.is_none() {
return;
}
let remote_sha = remote_sha.unwrap();
let remote_url = format!("file://{}", remote_path.display());
let entry = state
.registry
.write()
.await
.add(remote_url.clone(), branch.to_string())
.unwrap();
let local_clone_path = temp_dir.path().join(&entry.id);
std::fs::create_dir_all(&local_clone_path).unwrap();
let init_local = tokio::process::Command::new("git")
.args(["init", "--bare"])
.current_dir(&local_clone_path)
.status()
.await;
if init_local.is_err() || !init_local.unwrap().success() {
return;
}
let work_dir = TempDir::new().unwrap();
let work_path = work_dir.path();
let clone_to_work = tokio::process::Command::new("git")
.args(["clone", &remote_url, work_path.to_str().unwrap()])
.status()
.await;
if clone_to_work.is_err() || !clone_to_work.unwrap().success() {
return;
}
tokio::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(work_path)
.status()
.await
.ok();
tokio::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(work_path)
.status()
.await
.ok();
std::fs::write(work_path.join("diverged.txt"), "diverged commit").unwrap();
tokio::process::Command::new("git")
.args(["add", "."])
.current_dir(work_path)
.status()
.await
.ok();
let diverged_commit = tokio::process::Command::new("git")
.args(["commit", "-m", "diverged commit"])
.current_dir(work_path)
.status()
.await;
if diverged_commit.is_err() || !diverged_commit.unwrap().success() {
return;
}
let push_to_local = tokio::process::Command::new("git")
.args([
"push",
local_clone_path.to_str().unwrap(),
&format!("{}:{}", branch, branch),
])
.current_dir(work_path)
.status()
.await;
if push_to_local.is_err() || !push_to_local.unwrap().success() {
return;
}
let work_dir2 = TempDir::new().unwrap();
let work_path2 = work_dir2.path();
let clone2 = tokio::process::Command::new("git")
.args(["clone", &remote_url, work_path2.to_str().unwrap()])
.status()
.await;
if clone2.is_err() || !clone2.unwrap().success() {
return;
}
tokio::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(work_path2)
.status()
.await
.ok();
tokio::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(work_path2)
.status()
.await
.ok();
std::fs::write(work_path2.join("remote_advance.txt"), "remote advance").unwrap();
tokio::process::Command::new("git")
.args(["add", "."])
.current_dir(work_path2)
.status()
.await
.ok();
let remote_commit = tokio::process::Command::new("git")
.args(["commit", "-m", "remote advance"])
.current_dir(work_path2)
.status()
.await;
if remote_commit.is_err() || !remote_commit.unwrap().success() {
return;
}
let push_remote = tokio::process::Command::new("git")
.args(["push", "origin", branch])
.current_dir(work_path2)
.status()
.await;
if push_remote.is_err() || !push_remote.unwrap().success() {
return;
}
let refs_dir = local_clone_path.join("refs/remotes/origin");
std::fs::create_dir_all(&refs_dir).unwrap();
std::fs::write(refs_dir.join(branch), format!("{}\n", remote_sha)).unwrap();
let router = build_router(state.clone());
let req = Request::builder()
.method(Method::POST)
.uri(format!("/api/v1/projects/{}/git/pull", entry.id))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
let status = resp.status();
let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap_or_default();
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(
json["error"].as_str().unwrap_or(""),
"resolve_command_not_configured"
);
}
async fn create_diverged_repo_setup(
temp_dir: &TempDir,
state: &AppState,
branch: &str,
) -> Option<(
tempfile::TempDir,
std::path::PathBuf,
crate::server::registry::ProjectEntry,
String,
)> {
let remote_dir = TempDir::new().ok()?;
let remote_path = remote_dir.path();
let remote_sha = init_bare_repo_with_commit(remote_path, branch).await?;
let remote_url = format!("file://{}", remote_path.display());
let entry = state
.registry
.write()
.await
.add(remote_url.clone(), branch.to_string())
.ok()?;
let local_clone_path = temp_dir.path().join(&entry.id);
std::fs::create_dir_all(&local_clone_path).ok()?;
let init_local = tokio::process::Command::new("git")
.args(["init", "--bare"])
.current_dir(&local_clone_path)
.status()
.await
.ok()?;
if !init_local.success() {
return None;
}
let work_dir = TempDir::new().ok()?;
let work_path = work_dir.path();
let clone_to_work = tokio::process::Command::new("git")
.args(["clone", &remote_url, work_path.to_str().unwrap()])
.status()
.await
.ok()?;
if !clone_to_work.success() {
return None;
}
tokio::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(work_path)
.status()
.await
.ok()?;
tokio::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(work_path)
.status()
.await
.ok()?;
std::fs::write(work_path.join("diverged.txt"), "diverged commit").ok()?;
tokio::process::Command::new("git")
.args(["add", "."])
.current_dir(work_path)
.status()
.await
.ok()?;
let diverged_commit = tokio::process::Command::new("git")
.args(["commit", "-m", "diverged commit"])
.current_dir(work_path)
.status()
.await
.ok()?;
if !diverged_commit.success() {
return None;
}
let push_to_local = tokio::process::Command::new("git")
.args([
"push",
local_clone_path.to_str().unwrap(),
&format!("{}:{}", branch, branch),
])
.current_dir(work_path)
.status()
.await
.ok()?;
if !push_to_local.success() {
return None;
}
let work_dir2 = TempDir::new().ok()?;
let work_path2 = work_dir2.path();
let clone2 = tokio::process::Command::new("git")
.args(["clone", &remote_url, work_path2.to_str().unwrap()])
.status()
.await
.ok()?;
if !clone2.success() {
return None;
}
tokio::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(work_path2)
.status()
.await
.ok()?;
tokio::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(work_path2)
.status()
.await
.ok()?;
std::fs::write(work_path2.join("remote_advance.txt"), "remote advance").ok()?;
tokio::process::Command::new("git")
.args(["add", "."])
.current_dir(work_path2)
.status()
.await
.ok()?;
let remote_commit = tokio::process::Command::new("git")
.args(["commit", "-m", "remote advance"])
.current_dir(work_path2)
.status()
.await
.ok()?;
if !remote_commit.success() {
return None;
}
let push_remote = tokio::process::Command::new("git")
.args(["push", "origin", branch])
.current_dir(work_path2)
.status()
.await
.ok()?;
if !push_remote.success() {
return None;
}
let refs_dir = local_clone_path.join("refs/remotes/origin");
std::fs::create_dir_all(&refs_dir).ok()?;
std::fs::write(refs_dir.join(branch), format!("{}\n", remote_sha)).ok()?;
Some((remote_dir, local_clone_path, entry, remote_url))
}
#[tokio::test]
async fn test_git_pull_delegates_to_git_sync_and_requires_resolve_command() {
let temp_dir = TempDir::new().unwrap();
let state = make_state(&temp_dir, None);
let branch = "main";
let result = create_diverged_repo_setup(&temp_dir, &state, branch).await;
let (_remote_dir, _local_clone_path, entry, _remote_url) = match result {
Some(r) => r,
None => return,
};
let router = build_router(state.clone());
let req = Request::builder()
.method(Method::POST)
.uri(format!("/api/v1/projects/{}/git/pull", entry.id))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
let status = resp.status();
let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap_or_default();
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(
json["error"].as_str().unwrap_or(""),
"resolve_command_not_configured"
);
}
#[tokio::test]
async fn test_git_pull_auto_resolve_runs_resolve_command() {
let temp_dir = TempDir::new().unwrap();
let registry = crate::server::registry::create_shared_registry(temp_dir.path(), 4).unwrap();
let (log_tx, _) = tokio::sync::broadcast::channel(SERVER_LOG_BUFFER_SIZE);
let state_update_tx = create_state_update_channel();
let state = AppState {
registry,
runners: crate::server::runner::create_shared_runners(),
db: None,
auth_token: None,
max_concurrent_total: 4,
resolve_command: Some("echo resolve".to_string()),
log_tx,
state_update_tx,
orchestration_status: Arc::new(
tokio::sync::RwLock::new(OrchestrationStatus::default()),
),
shared_orchestrator_state: Arc::new(tokio::sync::RwLock::new(
crate::orchestration::state::OrchestratorState::new(Vec::new(), 1),
)),
terminal_manager: crate::server::terminal::create_terminal_manager(),
active_commands: crate::server::active_commands::create_shared_active_commands(),
proposal_session_manager:
crate::server::proposal_session::create_proposal_session_manager(
crate::config::ProposalSessionConfig::default(),
None,
),
};
let branch = "main";
let result = create_diverged_repo_setup(&temp_dir, &state, branch).await;
let (_remote_dir, _local_clone_path, entry, _remote_url) = match result {
Some(r) => r,
None => return,
};
let router = build_router(state.clone());
let req = Request::builder()
.method(Method::POST)
.uri(format!("/api/v1/projects/{}/git/pull", entry.id))
.header("Content-Type", "application/json")
.body(Body::from(r#"{"auto_resolve": true}"#))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
let status = resp.status();
let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap_or_default();
if status == StatusCode::OK {
if let Some(ran) = json.get("resolve_command_ran") {
if ran.as_bool() == Some(true) {
assert_eq!(json["resolve_exit_code"].as_i64(), Some(0));
}
}
}
if status == StatusCode::UNPROCESSABLE_ENTITY {
let error_val = json["error"].as_str().unwrap_or("");
assert_ne!(error_val, "non_fast_forward");
}
assert!(status == StatusCode::OK || status == StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn test_git_pull_auto_resolve_without_resolve_command_configured_returns_error() {
let temp_dir = TempDir::new().unwrap();
let state = make_state(&temp_dir, None);
let branch = "main";
let result = create_diverged_repo_setup(&temp_dir, &state, branch).await;
let (_remote_dir, _local_clone_path, entry, _remote_url) = match result {
Some(r) => r,
None => return,
};
let router = build_router(state.clone());
let req = Request::builder()
.method(Method::POST)
.uri(format!("/api/v1/projects/{}/git/pull", entry.id))
.header("Content-Type", "application/json")
.body(Body::from(r#"{"auto_resolve": true}"#))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
let status = resp.status();
let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap_or_default();
if status == StatusCode::UNPROCESSABLE_ENTITY {
let error_val = json["error"].as_str().unwrap_or("");
assert!(
error_val == "resolve_command_not_configured" || error_val == "non_fast_forward"
);
}
}
#[tokio::test]
async fn test_git_pull_auto_resolve_uses_top_level_resolve_command() {
let temp_dir = TempDir::new().unwrap();
let registry = create_shared_registry(temp_dir.path(), 4).unwrap();
let (log_tx, _) = tokio::sync::broadcast::channel(SERVER_LOG_BUFFER_SIZE);
let state_update_tx = create_state_update_channel();
let state = AppState {
registry,
runners: crate::server::runner::create_shared_runners(),
db: None,
auth_token: None,
max_concurrent_total: 4,
resolve_command: Some("echo resolve".to_string()),
log_tx,
state_update_tx,
orchestration_status: Arc::new(
tokio::sync::RwLock::new(OrchestrationStatus::default()),
),
shared_orchestrator_state: Arc::new(tokio::sync::RwLock::new(
crate::orchestration::state::OrchestratorState::new(Vec::new(), 1),
)),
terminal_manager: crate::server::terminal::create_terminal_manager(),
active_commands: crate::server::active_commands::create_shared_active_commands(),
proposal_session_manager:
crate::server::proposal_session::create_proposal_session_manager(
crate::config::ProposalSessionConfig::default(),
None,
),
};
let branch = "main";
let result = create_diverged_repo_setup(&temp_dir, &state, branch).await;
let (_remote_dir, _local_clone_path, entry, _remote_url) = match result {
Some(r) => r,
None => return,
};
let router = build_router(state.clone());
let req = Request::builder()
.method(Method::POST)
.uri(format!("/api/v1/projects/{}/git/pull", entry.id))
.header("Content-Type", "application/json")
.body(Body::from(r#"{"auto_resolve": true}"#))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
let status = resp.status();
let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap_or_default();
if status == StatusCode::UNPROCESSABLE_ENTITY {
let error_val = json["error"].as_str().unwrap_or("");
assert_ne!(error_val, "resolve_command_not_configured");
}
if let Some(ran) = json.get("resolve_command_ran") {
if ran.as_bool() == Some(true) {
let exit_code = json["resolve_exit_code"].as_i64().unwrap_or(-1);
assert_eq!(exit_code, 0);
}
}
}
#[tokio::test]
async fn test_git_push_no_local_clone_returns_error() {
let temp_dir = TempDir::new().unwrap();
let state = make_state(&temp_dir, None);
let entry = state
.registry
.write()
.await
.add("https://github.com/foo/bar".to_string(), "main".to_string())
.unwrap();
let router = build_router(state.clone());
let req = Request::builder()
.method(Method::POST)
.uri(format!("/api/v1/projects/{}/git/push", entry.id))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let error_val = json["error"].as_str().unwrap_or("");
assert_eq!(error_val, "resolve_command_not_configured");
}
#[tokio::test]
async fn test_git_push_non_fast_forward_detection_with_bare_repos() {
let temp_dir = TempDir::new().unwrap();
let state = make_state(&temp_dir, None);
let entry = state
.registry
.write()
.await
.add("file:///not-a-real-remote".to_string(), "main".to_string())
.unwrap();
let local_repo_path = temp_dir.path().join(&entry.id);
std::fs::create_dir_all(&local_repo_path).unwrap();
let init_status = tokio::process::Command::new("git")
.args(["init", "--bare"])
.current_dir(&local_repo_path)
.status()
.await;
if init_status.is_err() || !init_status.unwrap().success() {
return;
}
let router = build_router(state.clone());
let req = Request::builder()
.method(Method::POST)
.uri(format!("/api/v1/projects/{}/git/push", entry.id))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert!(
resp.status() == StatusCode::UNPROCESSABLE_ENTITY
|| resp.status() == StatusCode::INTERNAL_SERVER_ERROR,
"Push with empty bare repo should fail, got: {}",
resp.status()
);
}
#[tokio::test]
async fn test_git_sync_project_not_found() {
let temp_dir = TempDir::new().unwrap();
let mut state = make_state(&temp_dir, None);
state.resolve_command = Some("echo resolve".to_string());
let router = build_router(state);
let req = Request::builder()
.method(Method::POST)
.uri("/api/v1/projects/nonexistent-project/git/sync")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["error"].is_string());
}
#[tokio::test]
async fn test_git_sync_fails_without_resolve_command() {
let temp_dir = TempDir::new().unwrap();
let state = make_state(&temp_dir, None);
let _router = build_router(state);
let registry = create_shared_registry(temp_dir.path(), 4).unwrap();
let project_id = {
let mut reg = registry.write().await;
let entry = reg
.add(
"https://github.com/example/repo.git".to_string(),
"main".to_string(),
)
.unwrap();
entry.id.clone()
};
let (log_tx, _) = tokio::sync::broadcast::channel(SERVER_LOG_BUFFER_SIZE);
let state_update_tx = create_state_update_channel();
let state_with_project = AppState {
registry,
runners: crate::server::runner::create_shared_runners(),
db: None,
auth_token: None,
max_concurrent_total: 4,
resolve_command: None,
log_tx,
state_update_tx,
orchestration_status: Arc::new(
tokio::sync::RwLock::new(OrchestrationStatus::default()),
),
shared_orchestrator_state: Arc::new(tokio::sync::RwLock::new(
crate::orchestration::state::OrchestratorState::new(Vec::new(), 1),
)),
terminal_manager: crate::server::terminal::create_terminal_manager(),
active_commands: crate::server::active_commands::create_shared_active_commands(),
proposal_session_manager:
crate::server::proposal_session::create_proposal_session_manager(
crate::config::ProposalSessionConfig::default(),
None,
),
};
let router = build_router(state_with_project);
let req = Request::builder()
.method(Method::POST)
.uri(format!("/api/v1/projects/{}/git/sync", project_id))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
json["error"].as_str().unwrap(),
"resolve_command_not_configured"
);
assert!(json["reason"].as_str().unwrap().contains("resolve_command"));
}
async fn setup_git_sync_fixture(
temp_dir: &TempDir,
resolve_command: Option<&str>,
) -> (String, AppState, axum::Router) {
let origin = create_local_git_repo(temp_dir.path());
let remote_url = format!("file://{}", origin.to_str().unwrap());
let registry = create_shared_registry(temp_dir.path(), 4).unwrap();
let project_id = {
let mut reg = registry.write().await;
let entry = reg.add(remote_url, "main".to_string()).unwrap();
entry.id.clone()
};
let (log_tx, _) = tokio::sync::broadcast::channel(SERVER_LOG_BUFFER_SIZE);
let state_update_tx = create_state_update_channel();
let state = AppState {
registry,
runners: crate::server::runner::create_shared_runners(),
db: None,
auth_token: None,
max_concurrent_total: 4,
resolve_command: resolve_command.map(str::to_string),
log_tx,
state_update_tx,
orchestration_status: Arc::new(
tokio::sync::RwLock::new(OrchestrationStatus::default()),
),
shared_orchestrator_state: Arc::new(tokio::sync::RwLock::new(
crate::orchestration::state::OrchestratorState::new(Vec::new(), 1),
)),
terminal_manager: crate::server::terminal::create_terminal_manager(),
active_commands: crate::server::active_commands::create_shared_active_commands(),
proposal_session_manager:
crate::server::proposal_session::create_proposal_session_manager(
crate::config::ProposalSessionConfig::default(),
None,
),
};
let router = build_router(state.clone());
(project_id, state, router)
}
async fn invoke_git_sync(
router: axum::Router,
project_id: &str,
) -> (StatusCode, serde_json::Value) {
let resp = router
.oneshot(
Request::builder()
.method(Method::POST)
.uri(format!("/api/v1/projects/{}/git/sync", project_id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
(status, json)
}
fn run_git(current_dir: &std::path::Path, args: &[&str]) {
let output = std::process::Command::new("git")
.args(args)
.current_dir(current_dir)
.output()
.unwrap();
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
}
fn run_git_stdout(current_dir: &std::path::Path, args: &[&str]) -> String {
let output = std::process::Command::new("git")
.args(args)
.current_dir(current_dir)
.output()
.unwrap();
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
#[tokio::test]
async fn test_git_sync_route_is_registered() {
let temp_dir = TempDir::new().unwrap();
let registry = create_shared_registry(temp_dir.path(), 4).unwrap();
let project_id = {
let mut reg = registry.write().await;
let entry = reg
.add(
"https://github.com/example/repo.git".to_string(),
"main".to_string(),
)
.unwrap();
entry.id.clone()
};
let (log_tx, _) = tokio::sync::broadcast::channel(SERVER_LOG_BUFFER_SIZE);
let state_update_tx = create_state_update_channel();
let state = AppState {
registry,
runners: crate::server::runner::create_shared_runners(),
db: None,
auth_token: None,
max_concurrent_total: 4,
resolve_command: None,
log_tx,
state_update_tx,
orchestration_status: Arc::new(
tokio::sync::RwLock::new(OrchestrationStatus::default()),
),
shared_orchestrator_state: Arc::new(tokio::sync::RwLock::new(
crate::orchestration::state::OrchestratorState::new(Vec::new(), 1),
)),
terminal_manager: crate::server::terminal::create_terminal_manager(),
active_commands: crate::server::active_commands::create_shared_active_commands(),
proposal_session_manager:
crate::server::proposal_session::create_proposal_session_manager(
crate::config::ProposalSessionConfig::default(),
None,
),
};
let router = build_router(state);
let req = Request::builder()
.method(Method::POST)
.uri(format!("/api/v1/projects/{}/git/sync", project_id))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_ne!(resp.status(), StatusCode::NOT_FOUND);
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn test_git_sync_success_response_contains_pull_and_push_sections() {
let temp_dir = TempDir::new().unwrap();
let (project_id, _state, router) = setup_git_sync_fixture(&temp_dir, Some("true")).await;
let (status, json) = invoke_git_sync(router, &project_id).await;
if status == StatusCode::OK {
assert!(json.get("pull").is_some());
assert!(json.get("push").is_some());
assert_eq!(json["status"].as_str(), Some("synced"));
}
assert!(status == StatusCode::OK || status == StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn test_git_sync_skips_resolve_when_already_up_to_date() {
let temp_dir = TempDir::new().unwrap();
let (project_id, _state, router) = setup_git_sync_fixture(&temp_dir, Some("false")).await;
let (status, json) = invoke_git_sync(router, &project_id).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["status"].as_str(), Some("synced"));
assert_eq!(json["resolve_command_ran"].as_bool(), Some(false));
assert!(json["resolve_exit_code"].is_null());
assert_eq!(json["push"]["status"].as_str(), Some("already_up_to_date"));
assert_eq!(
json["skipped_reason"].as_str(),
Some("local_and_remote_already_match")
);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn test_git_sync_runs_resolve_when_shas_differ() {
let _cwd_lock = crate::test_support::cwd_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let temp_dir = TempDir::new().unwrap();
let origin = create_local_git_repo(temp_dir.path());
let (project_id, state, router) = setup_git_sync_fixture(&temp_dir, Some("true")).await;
let (status1, json1) = invoke_git_sync(router.clone(), &project_id).await;
assert_eq!(status1, StatusCode::OK);
assert_eq!(json1["resolve_command_ran"].as_bool(), Some(false));
let local_bare = {
let reg = state.registry.read().await;
reg.data_dir().join(&project_id)
};
let tree_sha = String::from_utf8_lossy(
&std::process::Command::new("git")
.args(["rev-parse", "HEAD^{tree}"])
.current_dir(&local_bare)
.output()
.unwrap()
.stdout,
)
.trim()
.to_string();
let parent_sha = String::from_utf8_lossy(
&std::process::Command::new("git")
.args(["rev-parse", "refs/heads/main"])
.current_dir(&local_bare)
.output()
.unwrap()
.stdout,
)
.trim()
.to_string();
let commit_out = std::process::Command::new("git")
.args([
"commit-tree",
&tree_sha,
"-p",
&parent_sha,
"-m",
"local only commit",
])
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.current_dir(&local_bare)
.output()
.unwrap();
let new_sha = String::from_utf8_lossy(&commit_out.stdout)
.trim()
.to_string();
run_git(&local_bare, &["update-ref", "refs/heads/main", &new_sha]);
run_git(
&local_bare,
&["update-ref", "refs/remotes/origin/main", &new_sha],
);
run_git(&local_bare, &["update-ref", "refs/heads/main", &parent_sha]);
run_git(
&local_bare,
&["update-ref", "refs/remotes/origin/main", &parent_sha],
);
let scratch = temp_dir.path().join("scratch-work");
run_git(
temp_dir.path(),
&["clone", origin.to_str().unwrap(), scratch.to_str().unwrap()],
);
run_git(&scratch, &["config", "user.email", "test@example.com"]);
run_git(&scratch, &["config", "user.name", "Test"]);
std::fs::write(scratch.join("new-file.txt"), "origin-only").unwrap();
run_git(&scratch, &["add", "."]);
run_git(&scratch, &["commit", "-m", "origin divergence"]);
run_git(&scratch, &["push", "origin", "main"]);
let (status2, json2) = invoke_git_sync(router, &project_id).await;
assert_eq!(status2, StatusCode::OK, "{}", json2);
assert_eq!(json2["status"].as_str(), Some("synced"));
assert_eq!(json2["resolve_command_ran"].as_bool(), Some(false));
assert!(json2["resolve_exit_code"].is_null());
assert_eq!(json2["push"]["status"].as_str(), Some("already_up_to_date"));
assert_eq!(
json2["skipped_reason"].as_str(),
Some("local_and_remote_already_match")
);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn test_git_sync_runs_resolve_when_remote_ahead() {
let _cwd_lock = crate::test_support::cwd_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let temp_dir = TempDir::new().unwrap();
let origin = create_local_git_repo(temp_dir.path());
let (project_id, _state, router) =
setup_git_sync_fixture(&temp_dir, Some("git reset --hard origin/main")).await;
let (initial_status, _initial_json) = invoke_git_sync(router.clone(), &project_id).await;
assert_eq!(initial_status, StatusCode::OK);
let scratch = temp_dir.path().join("scratch-work-remote-ahead");
run_git(
temp_dir.path(),
&["clone", origin.to_str().unwrap(), scratch.to_str().unwrap()],
);
run_git(&scratch, &["config", "user.email", "test@example.com"]);
run_git(&scratch, &["config", "user.name", "Test"]);
std::fs::write(scratch.join("remote-change.txt"), "new remote commit").unwrap();
run_git(&scratch, &["add", "."]);
run_git(&scratch, &["commit", "-m", "remote change"]);
run_git(&scratch, &["push", "origin", "main"]);
let (status, json) = invoke_git_sync(router, &project_id).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["status"].as_str(), Some("synced"));
assert_eq!(json["resolve_command_ran"].as_bool(), Some(false));
assert!(json["resolve_exit_code"].is_null());
assert_eq!(json["push"]["status"].as_str(), Some("already_up_to_date"));
assert_eq!(
json["skipped_reason"].as_str(),
Some("local_and_remote_already_match")
);
}
}