use super::*;
use crate::server::api::control::{
list_selected_change_ids_in_worktree, start_single_project_run, CONTROL_CALLS,
};
use crate::server::api::ws::build_remote_project_snapshot_async;
pub async fn get_version() -> Response {
(
StatusCode::OK,
Json(serde_json::json!({
"version": format!("v{} ({})", env!("CARGO_PKG_VERSION"), env!("BUILD_NUMBER"))
})),
)
.into_response()
}
pub async fn list_projects(State(state): State<AppState>) -> Response {
let registry = state.registry.read().await;
let projects: Vec<ProjectResponse> = registry.list().into_iter().map(Into::into).collect();
(StatusCode::OK, Json(projects)).into_response()
}
pub async fn projects_state(State(state): State<AppState>) -> Response {
let (entries, data_dir, all_selections, all_errors) = {
let registry = state.registry.read().await;
let entries = registry.list();
let data_dir = registry.data_dir().to_path_buf();
let all_selections: std::collections::HashMap<
String,
std::collections::HashMap<String, bool>,
> = entries
.iter()
.filter_map(|e| {
registry
.change_selections_for_project(&e.id)
.map(|s| (e.id.clone(), s.clone()))
})
.collect();
let all_errors: std::collections::HashMap<
String,
std::collections::HashMap<String, String>,
> = entries
.iter()
.filter_map(|e| {
registry
.error_changes_for_project(&e.id)
.map(|s| (e.id.clone(), s.clone()))
})
.collect();
(entries, data_dir, all_selections, all_errors)
};
let mut projects = Vec::new();
for entry in &entries {
let selections = all_selections.get(&entry.id);
let errors = all_errors.get(&entry.id);
projects.push(
build_remote_project_snapshot_async(
&data_dir,
entry,
selections,
errors,
&state.shared_orchestrator_state,
)
.await,
);
}
let sync_available = state.resolve_command.is_some();
(
StatusCode::OK,
Json(ProjectsStateResponse {
projects,
sync_available,
}),
)
.into_response()
}
pub async fn add_project(
State(state): State<AppState>,
Json(req): Json<AddProjectRequest>,
) -> Response {
if req.remote_url.trim().is_empty() || req.branch.trim().is_empty() {
return error_response(
StatusCode::BAD_REQUEST,
"remote_url and branch are required",
);
}
let remote_url = req.remote_url.clone();
let branch = req.branch.clone();
let entry = {
let mut registry = state.registry.write().await;
match registry.add(remote_url.clone(), branch.clone()) {
Ok(e) => e,
Err(e) => {
let msg = e.to_string();
return if msg.contains("already exists") {
error_response(StatusCode::CONFLICT, msg)
} else {
error_response(StatusCode::INTERNAL_SERVER_ERROR, msg)
};
}
}
};
let project_id = entry.id.clone();
let rollback = |state: &AppState, project_id: String| {
let registry = state.registry.clone();
async move {
let mut reg = registry.write().await;
if let Err(e) = reg.remove(&project_id) {
error!("Rollback failed for project_id={}: {}", project_id, e);
} else {
info!("Rolled back registry entry for project_id={}", project_id);
}
}
};
let (lock, semaphore) = {
let registry = state.registry.read().await;
let lock = match registry.project_lock(&project_id) {
Some(l) => l,
None => {
rollback(&state, project_id).await;
return error_response(StatusCode::INTERNAL_SERVER_ERROR, "Missing project lock");
}
};
let semaphore = registry.global_semaphore();
(lock, semaphore)
};
let _sem_permit = match semaphore.acquire().await {
Ok(p) => p,
Err(_) => {
rollback(&state, project_id).await;
return error_response(
StatusCode::SERVICE_UNAVAILABLE,
"Server is at maximum concurrent capacity",
);
}
};
let _guard = lock.lock().await;
info!(
"add_project: project_id={} remote_url={} branch={}",
project_id, remote_url, branch
);
let server_branch = server_worktree_branch(&project_id, &branch);
let (data_dir, local_repo_path, worktree_path) = {
let registry = state.registry.read().await;
let data_dir = registry.data_dir().to_path_buf();
let repo = data_dir.join(&project_id);
let wt = data_dir.join("worktrees").join(&project_id).join(&branch);
(data_dir, repo, wt)
};
let ls_remote = tokio::process::Command::new("git")
.args(["ls-remote", "--heads", &remote_url, &branch])
.current_dir(&data_dir)
.output()
.await;
match ls_remote {
Ok(out) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout);
if stdout.trim().is_empty() {
rollback(&state, project_id).await;
return error_response(
StatusCode::UNPROCESSABLE_ENTITY,
format!("Branch '{}' not found on remote '{}'", branch, remote_url),
);
}
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
error!("git ls-remote failed: {}", stderr);
rollback(&state, project_id).await;
return error_response(
StatusCode::UNPROCESSABLE_ENTITY,
format!("git operation failed: {}", stderr),
);
}
Err(e) => {
error!("Failed to run git: {}", e);
rollback(&state, project_id).await;
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 clone (bare) succeeded: project_id={}", project_id);
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
error!(
"git clone failed: project_id={} stderr={}",
project_id, stderr
);
rollback(&state, project_id).await;
return error_response(
StatusCode::UNPROCESSABLE_ENTITY,
format!("git clone failed: {}", stderr),
);
}
Err(e) => {
rollback(&state, project_id).await;
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to run git clone: {}", e),
);
}
}
} else {
info!(
"Bare clone already exists, reusing: project_id={}",
project_id
);
}
if !worktree_path.exists() {
if let Err(e) = std::fs::create_dir_all(&worktree_path) {
error!("Failed to create worktree parent dirs: {}", e);
rollback(&state, project_id).await;
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create worktree directory: {}", e),
);
}
if let Err(e) = std::fs::remove_dir(&worktree_path) {
error!("Failed to remove pre-created worktree dir: {}", e);
rollback(&state, project_id).await;
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to prepare worktree directory: {}", e),
);
}
let worktree_output = tokio::process::Command::new("git")
.args([
"worktree",
"add",
"-b",
&server_branch,
worktree_path.to_str().unwrap_or(""),
&branch,
])
.current_dir(&local_repo_path)
.output()
.await;
match worktree_output {
Ok(out) if out.status.success() => {
info!(
"git worktree add succeeded: project_id={} path={:?} server_branch={}",
project_id, worktree_path, server_branch
);
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
error!(
"git worktree add failed: project_id={} stderr={}",
project_id, stderr
);
let _ = std::fs::remove_dir_all(&local_repo_path);
rollback(&state, project_id).await;
return error_response(
StatusCode::UNPROCESSABLE_ENTITY,
format!("git worktree add failed: {}", stderr),
);
}
Err(e) => {
let _ = std::fs::remove_dir_all(&local_repo_path);
rollback(&state, project_id).await;
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to run git worktree add: {}", e),
);
}
}
} else {
let head_output = tokio::process::Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(&local_repo_path)
.output()
.await;
if let Ok(out) = head_output {
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let wt_path_str = worktree_path.to_str().unwrap_or("");
let mut in_our_worktree = false;
let mut checked_out_branch: Option<String> = None;
for line in stdout.lines() {
if line.starts_with("worktree ") {
let wt_path = line.trim_start_matches("worktree ");
in_our_worktree = wt_path == wt_path_str;
if !in_our_worktree {
checked_out_branch = None;
}
} else if in_our_worktree && line.starts_with("branch refs/heads/") {
checked_out_branch =
Some(line.trim_start_matches("branch refs/heads/").to_string());
}
}
if checked_out_branch.as_deref() == Some(branch.as_str()) {
error!(
"Existing worktree checks out base branch '{}' directly: project_id={} path={:?}. \
This blocks git pull/push on the bare clone.",
branch, project_id, worktree_path
);
let err_msg = format!(
"Existing worktree at {:?} checks out the base branch '{}' directly. \
This prevents git pull/push on the bare clone. \
To fix: remove the project (DELETE /api/v1/projects/{}), then re-add it \
so the worktree is recreated on a server-specific branch ({}). \
Alternatively, run: git worktree remove {:?} && re-add via API.",
worktree_path, branch, project_id, server_branch, worktree_path
);
rollback(&state, project_id).await;
return error_response(StatusCode::CONFLICT, err_msg);
}
}
info!(
"Worktree already exists, reusing: project_id={} path={:?}",
project_id, worktree_path
);
}
if let Err(e) =
crate::vcs::git::commands::run_worktree_setup(&worktree_path, &worktree_path).await
{
error!(
"worktree setup failed: project_id={} worktree={:?} error={}",
project_id, worktree_path, e
);
if let Err(cleanup_err) = std::fs::remove_dir_all(&worktree_path) {
error!(
"Failed to cleanup worktree after setup failure: project_id={} path={:?} error={}",
project_id, worktree_path, cleanup_err
);
}
if let Err(cleanup_err) = std::fs::remove_dir_all(&local_repo_path) {
error!(
"Failed to cleanup bare clone after setup failure: project_id={} path={:?} error={}",
project_id, local_repo_path, cleanup_err
);
}
rollback(&state, project_id).await;
return error_response(
StatusCode::UNPROCESSABLE_ENTITY,
format!("worktree setup failed: {}", e),
);
}
info!("Project added with clone and worktree: id={}", project_id);
{
let orch_status = state.orchestration_status.read().await;
if *orch_status == OrchestrationStatus::Running {
let changes = list_selected_change_ids_in_worktree(
&worktree_path,
None,
&state.shared_orchestrator_state,
)
.await;
if !changes.is_empty() {
info!(
"Auto-enqueuing new project during Running state: project_id={} changes={:?}",
project_id, changes
);
if CONTROL_CALLS.get().is_none() {
if let Err(e) =
start_single_project_run(&state, &project_id, worktree_path, changes).await
{
error!(
"Failed to auto-enqueue new project: project_id={} err={}",
project_id, e
);
}
}
}
}
}
(StatusCode::CREATED, Json(ProjectResponse::from(entry))).into_response()
}
pub async fn delete_project(
State(state): State<AppState>,
Path(project_id): Path<String>,
) -> Response {
if let Some(db) = &state.db {
if let Err(e) = db.delete_change_states_for_project(&project_id) {
warn!(project_id = %project_id, error = %e, "Failed to clear persisted change states before deleting project");
}
}
let mut registry = state.registry.write().await;
match registry.remove(&project_id) {
Ok(_) => {
info!("Project deleted: id={}", project_id);
StatusCode::NO_CONTENT.into_response()
}
Err(e) => {
let msg = e.to_string();
if msg.contains("not found") {
error_response(StatusCode::NOT_FOUND, msg)
} else {
error_response(StatusCode::INTERNAL_SERVER_ERROR, msg)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use tempfile::TempDir;
use tower::ServiceExt;
use crate::server::api::test_support::{
create_local_git_repo, make_router, make_router_with_db, make_state,
run_sync_monitor_once_for_tests,
};
#[cfg(feature = "heavy-tests")]
use crate::server::api::test_support::create_local_git_repo_with_setup;
#[cfg(feature = "heavy-tests")]
#[tokio::test]
async fn test_add_project_without_repo_root_setup_succeeds_without_marker() {
let temp_dir = TempDir::new().unwrap();
let origin = create_local_git_repo(temp_dir.path());
let remote_url = format!("file://{}", origin.to_str().unwrap());
let router = make_router(&temp_dir, None);
let body = serde_json::json!({
"remote_url": remote_url,
"branch": "main"
});
let req = Request::builder()
.method(Method::POST)
.uri("/api/v1/projects")
.header("Content-Type", "application/json")
.body(Body::from(body.to_string()))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
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();
let project_id = json["id"].as_str().expect("Response must contain id");
let marker_path = temp_dir
.path()
.join("worktrees")
.join(project_id)
.join("main")
.join(".setup-root-path");
assert!(
!marker_path.exists(),
"No setup marker should exist when repo-root .wt/setup is absent"
);
}
#[cfg(feature = "heavy-tests")]
#[tokio::test]
async fn test_add_project_setup_failure_returns_422_and_rolls_back_registry() {
let temp_dir = TempDir::new().unwrap();
let origin =
create_local_git_repo_with_setup(temp_dir.path(), Some("#!/bin/sh\nexit 42\n"));
let remote_url = format!("file://{}", origin.to_str().unwrap());
let expected_project_id = crate::server::registry::generate_project_id(&remote_url, "main");
let state = make_state(&temp_dir, None);
let router = build_router(state.clone());
let body = serde_json::json!({
"remote_url": remote_url,
"branch": "main"
});
let req = Request::builder()
.method(Method::POST)
.uri("/api/v1/projects")
.header("Content-Type", "application/json")
.body(Body::from(body.to_string()))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
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();
let error_message = json["error"].as_str().unwrap_or_default();
assert!(
error_message.contains("worktree setup failed"),
"error should mention setup failure, got: {}",
json
);
let registry = state.registry.read().await;
assert!(
registry.list().is_empty(),
"Registry should be empty after setup failure rollback"
);
let local_repo_path = temp_dir.path().join(&expected_project_id);
let worktree_path = temp_dir
.path()
.join("worktrees")
.join(&expected_project_id)
.join("main");
assert!(
!local_repo_path.exists(),
"Bare clone should be cleaned up after setup failure"
);
assert!(
!worktree_path.exists(),
"Worktree should be cleaned up after setup failure"
);
}
#[tokio::test]
async fn test_global_control_run_records_call() {
let temp_dir = TempDir::new().unwrap();
let state = make_state(&temp_dir, None);
let _control_calls_guard = crate::server::api::control::lock_control_calls_for_test().await;
let entry = state
.registry
.write()
.await
.add("https://github.com/foo/bar".to_string(), "main".to_string())
.unwrap();
let worktree_path = temp_dir
.path()
.join("worktrees")
.join(&entry.id)
.join(&entry.branch)
.join("openspec/changes/fix-a");
std::fs::create_dir_all(&worktree_path).unwrap();
std::fs::write(worktree_path.join("proposal.md"), "# proposal\n").unwrap();
let router = build_router(state.clone());
let req = Request::builder()
.method(Method::POST)
.uri("/api/v1/control/run")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let calls = CONTROL_CALLS.get().unwrap().lock().unwrap();
assert!(calls
.iter()
.any(|(id, action)| id == "_global_" && action == "run"));
assert!(calls
.iter()
.any(|(id, action)| id == &entry.id && action == "run"));
}
#[tokio::test]
async fn test_projects_state_includes_sync_metadata_fields_after_monitor_refresh() {
let temp_dir = TempDir::new().unwrap();
let origin = create_local_git_repo(temp_dir.path());
let remote_url = format!("file://{}", origin.to_string_lossy());
let state = make_state(&temp_dir, None);
let router = build_router(state.clone());
let add_body = serde_json::json!({
"remote_url": remote_url,
"branch": "main"
});
let add_req = Request::builder()
.method(Method::POST)
.uri("/api/v1/projects")
.header("Content-Type", "application/json")
.body(Body::from(add_body.to_string()))
.unwrap();
let add_resp = router.clone().oneshot(add_req).await.unwrap();
assert_eq!(add_resp.status(), StatusCode::CREATED);
run_sync_monitor_once_for_tests(&state).await;
let req = Request::builder()
.method(Method::GET)
.uri("/api/v1/projects/state")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
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 project = &json["projects"][0];
assert_eq!(project["sync_state"], "up_to_date");
assert_eq!(project["ahead_count"], 0);
assert_eq!(project["behind_count"], 0);
assert_eq!(project["sync_required"], false);
assert!(project["local_sha"].as_str().is_some());
assert!(project["remote_sha"].as_str().is_some());
assert!(project["last_remote_check_at"].as_str().is_some());
assert!(project["remote_check_error"].is_null());
}
#[cfg(feature = "heavy-tests")]
#[test]
fn test_app_state_resolve_command_comes_from_top_level_config() {
let top_level_resolve_cmd = Some("echo top-level-resolve".to_string());
let app_state_resolve_command = top_level_resolve_cmd.clone();
assert_eq!(
app_state_resolve_command,
Some("echo top-level-resolve".to_string()),
"AppState resolve_command should come from top-level config resolve_command"
);
}
#[tokio::test]
async fn test_valid_auth_token_returns_200() {
let temp_dir = TempDir::new().unwrap();
let router = make_router(&temp_dir, Some("secret-token"));
let req = Request::builder()
.method(Method::GET)
.uri("/api/v1/projects")
.header("Authorization", "Bearer secret-token")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_ui_state_crud_endpoints() {
let temp_dir = TempDir::new().unwrap();
let router = make_router_with_db(&temp_dir, None);
let put_req = Request::builder()
.method(Method::PUT)
.uri("/api/v1/ui-state/selected_project_id")
.header("Content-Type", "application/json")
.body(Body::from(r#"{"value":"proj-1"}"#))
.unwrap();
let put_resp = router.clone().oneshot(put_req).await.unwrap();
assert_eq!(put_resp.status(), StatusCode::NO_CONTENT);
let get_req = Request::builder()
.method(Method::GET)
.uri("/api/v1/ui-state")
.body(Body::empty())
.unwrap();
let get_resp = router.clone().oneshot(get_req).await.unwrap();
assert_eq!(get_resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(get_resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["selected_project_id"], "proj-1");
let delete_req = Request::builder()
.method(Method::DELETE)
.uri("/api/v1/ui-state/selected_project_id")
.body(Body::empty())
.unwrap();
let delete_resp = router.clone().oneshot(delete_req).await.unwrap();
assert_eq!(delete_resp.status(), StatusCode::NO_CONTENT);
let get_req = Request::builder()
.method(Method::GET)
.uri("/api/v1/ui-state")
.body(Body::empty())
.unwrap();
let get_resp = router.oneshot(get_req).await.unwrap();
assert_eq!(get_resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(get_resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("selected_project_id").is_none());
}
#[tokio::test]
async fn test_get_version_returns_200() {
let temp_dir = TempDir::new().unwrap();
let router = make_router(&temp_dir, None);
let req = Request::builder()
.method(Method::GET)
.uri("/api/v1/version")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_get_version_no_auth_required() {
let temp_dir = TempDir::new().unwrap();
let router = make_router(&temp_dir, Some("secret-token"));
let req = Request::builder()
.method(Method::GET)
.uri("/api/v1/version")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"GET /api/v1/version must succeed without authentication even when auth is configured"
);
}
#[tokio::test]
async fn test_get_version_response_format() {
let temp_dir = TempDir::new().unwrap();
let router = make_router(&temp_dir, None);
let req = Request::builder()
.method(Method::GET)
.uri("/api/v1/version")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
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 version = json["version"]
.as_str()
.expect("Response must contain 'version' field as a string");
assert!(
!version.is_empty(),
"version field must not be empty, got: {:?}",
version
);
}
#[test]
fn test_server_auth_config_resolve_token_from_token_field() {
use crate::config::ServerAuthConfig;
let auth = ServerAuthConfig {
mode: crate::config::ServerAuthMode::BearerToken,
token: Some("direct-token".to_string()),
token_env: None,
};
assert_eq!(auth.resolve_token(), Some("direct-token".to_string()));
}
#[test]
fn test_server_auth_config_resolve_token_from_env_var() {
use crate::config::ServerAuthConfig;
let env_var_name = "CFLX_TEST_SERVER_TOKEN_UNIQUE_12345";
unsafe {
env::set_var(env_var_name, "env-token-value");
}
let auth = ServerAuthConfig {
mode: crate::config::ServerAuthMode::BearerToken,
token: Some("fallback-token".to_string()),
token_env: Some(env_var_name.to_string()),
};
assert_eq!(auth.resolve_token(), Some("env-token-value".to_string()));
unsafe {
env::remove_var(env_var_name);
}
}
#[test]
fn test_server_auth_config_resolve_token_falls_back_when_env_unset() {
use crate::config::ServerAuthConfig;
let env_var_name = "CFLX_TEST_SERVER_TOKEN_UNSET_UNIQUE_99999";
unsafe {
env::remove_var(env_var_name);
}
let auth = ServerAuthConfig {
mode: crate::config::ServerAuthMode::BearerToken,
token: Some("fallback-token".to_string()),
token_env: Some(env_var_name.to_string()),
};
assert_eq!(auth.resolve_token(), Some("fallback-token".to_string()));
}
#[tokio::test]
async fn test_add_project_creates_worktree_on_server_branch() {
let temp_dir = TempDir::new().unwrap();
let origin = create_local_git_repo(temp_dir.path());
let remote_url = format!("file://{}", origin.to_str().unwrap());
let router = make_router(&temp_dir, None);
let body = serde_json::json!({
"remote_url": remote_url,
"branch": "main"
});
let req = Request::builder()
.method(Method::POST)
.uri("/api/v1/projects")
.header("Content-Type", "application/json")
.body(Body::from(body.to_string()))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
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();
let project_id = json["id"].as_str().expect("Response must have id field");
let worktree_path = temp_dir
.path()
.join("worktrees")
.join(project_id)
.join("main");
assert!(
worktree_path.exists(),
"Worktree directory must exist at {:?}",
worktree_path
);
let head_output = std::process::Command::new("git")
.args(["symbolic-ref", "HEAD"])
.current_dir(&worktree_path)
.output();
if let Ok(out) = head_output {
if out.status.success() {
let head = String::from_utf8_lossy(&out.stdout).trim().to_string();
assert_ne!(
head, "refs/heads/main",
"Worktree HEAD must NOT reference refs/heads/main (base branch). It must use a server-specific branch. Got: {}",
head
);
let expected_prefix = format!("refs/heads/server-wt/{}/", project_id);
assert!(
head.starts_with(&expected_prefix),
"Worktree HEAD must start with '{}'. Got: {}",
expected_prefix,
head
);
}
}
}
#[test]
fn test_server_worktree_branch_function_produces_correct_format() {
use crate::server::registry::server_worktree_branch;
let project_id = "abc123def456789a";
let base_branch = "main";
let branch = server_worktree_branch(project_id, base_branch);
assert_eq!(
branch, "server-wt/abc123def456789a/main",
"server_worktree_branch must produce 'server-wt/<project_id>/<base_branch>'"
);
}
}