use super::{read_json_object, string_field};
use crate::server::app::AppState;
use crate::server::errors::error;
use axum::body::Bytes;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{post, put};
use axum::{Json, Router};
use nomoreide_core::config::{Config, GitRepoDef};
use nomoreide_core::git_manager::{GitManager, GitWorktree};
use nomoreide_daemon_client::protocol::ServiceRuntimeState;
use serde::Serialize;
use serde_json::Value;
use std::path::{Component, Path, PathBuf};
pub(super) fn routes() -> Router<AppState> {
Router::new()
.route("/api/git/worktrees", post(create).delete(remove))
.route("/api/git/worktrees/active", put(select))
.route("/api/git/worktrees/prune", post(prune))
}
async fn selected_repository(state: &AppState) -> Result<(Config, GitRepoDef), Response> {
let config = state
.config_store
.load()
.await
.map_err(|reason| error(StatusCode::INTERNAL_SERVER_ERROR, &reason.to_string()))?;
let repository = config
.selected_git_repository
.as_ref()
.and_then(|selected| {
config
.git_repositories
.iter()
.find(|repo| &repo.name == selected)
})
.or_else(|| config.git_repositories.first())
.cloned()
.ok_or_else(|| error(StatusCode::NOT_FOUND, "No Git project is selected."))?;
Ok((config, repository))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WorktreeEnvelope {
ok: bool,
worktree: GitWorktree,
}
async fn create(State(state): State<AppState>, body: Bytes) -> Response {
let body = read_json_object(&body);
let (_config, repository) = match selected_repository(&state).await {
Ok(resolved) => resolved,
Err(response) => return response,
};
let branch = string_field(&body, "branch").unwrap_or_default();
let create_branch = body.get("createBranch") == Some(&Value::Bool(true));
let base_ref = string_field(&body, "baseRef");
let created = GitManager::create_worktree(
&repository.path,
Some(&repository.name),
branch,
create_branch,
base_ref,
)
.await;
let worktree = match created {
Ok(worktree) => worktree,
Err(reason) => return error(StatusCode::UNPROCESSABLE_ENTITY, &reason.to_string()),
};
if let Err(reason) = state
.config_store
.select_git_worktree(&repository.name, &worktree.path)
.await
{
return error(StatusCode::UNPROCESSABLE_ENTITY, &reason.to_string());
}
(
StatusCode::CREATED,
Json(WorktreeEnvelope { ok: true, worktree }),
)
.into_response()
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ActiveEnvelope {
ok: bool,
active_path: String,
}
async fn select(State(state): State<AppState>, body: Bytes) -> Response {
let body = read_json_object(&body);
let path = string_field(&body, "path").unwrap_or_default();
let (_config, repository) = match selected_repository(&state).await {
Ok(resolved) => resolved,
Err(response) => return response,
};
match state
.config_store
.select_git_worktree(&repository.name, path)
.await
{
Ok(_) => Json(ActiveEnvelope {
ok: true,
active_path: path.to_string(),
})
.into_response(),
Err(reason) => error(StatusCode::UNPROCESSABLE_ENTITY, &reason.to_string()),
}
}
#[derive(Serialize)]
struct OkEnvelope {
ok: bool,
}
async fn remove(State(state): State<AppState>, body: Bytes) -> Response {
let body = read_json_object(&body);
let path = string_field(&body, "path").unwrap_or_default();
let (config, repository) = match selected_repository(&state).await {
Ok(resolved) => resolved,
Err(response) => return response,
};
let active_path = repository
.active_worktree_path
.clone()
.unwrap_or_else(|| repository.path.clone());
if lexically_resolve(&active_path) == lexically_resolve(path) {
return error(
StatusCode::CONFLICT,
"Switch to another worktree before removing this one.",
);
}
if state
.terminal
.list_sessions()
.iter()
.any(|session| path_contains(path, &session.cwd))
{
return error(
StatusCode::CONFLICT,
"Close terminals using this worktree before removing it.",
);
}
let running = state.runtime.status();
let active_service = config.services.iter().find(|service| {
service.cwd.as_deref().is_some_and(|cwd| {
path_contains(path, cwd)
&& running.iter().any(|status| {
status.name == service.name && status.state == ServiceRuntimeState::Running
})
})
});
if let Some(service) = active_service {
return error(
StatusCode::CONFLICT,
&format!(
"Stop service \"{}\" before removing this worktree.",
service.name
),
);
}
match GitManager::remove_worktree(&repository.path, path).await {
Ok(()) => Json(OkEnvelope { ok: true }).into_response(),
Err(reason) => error(StatusCode::UNPROCESSABLE_ENTITY, &reason.to_string()),
}
}
async fn prune(State(state): State<AppState>) -> Response {
let (_config, repository) = match selected_repository(&state).await {
Ok(resolved) => resolved,
Err(response) => return response,
};
match GitManager::prune_worktrees(&repository.path).await {
Ok(()) => Json(OkEnvelope { ok: true }).into_response(),
Err(reason) => error(StatusCode::INTERNAL_SERVER_ERROR, &reason.to_string()),
}
}
fn path_contains(root: &str, candidate: &str) -> bool {
let root = lexically_resolve(root);
let candidate = lexically_resolve(candidate);
candidate == root || candidate.starts_with(&root)
}
fn lexically_resolve(path: &str) -> PathBuf {
let candidate = Path::new(path);
let absolute = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
std::env::current_dir().unwrap_or_default().join(candidate)
};
let mut out = PathBuf::new();
for component in absolute.components() {
match component {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}