diff --git a/crates/opencode-proto/src/lib.rs b/crates/opencode-proto/src/lib.rs index 1ae4bd069bee..2a6084f26b7c 100644 --- a/crates/opencode-proto/src/lib.rs +++ b/crates/opencode-proto/src/lib.rs @@ -206,6 +206,17 @@ pub struct FileNode { pub ignored: bool, } +/// Version-control info for a directory (`vcs.get`): `{ branch?, default_branch? }`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema, PartialEq, Eq)] +pub struct VcsInfo { + /// Current branch (omitted when detached or not a repo). + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, + /// The repository's default branch, if known. + #[serde(skip_serializing_if = "Option::is_none")] + pub default_branch: Option, +} + /// The tool call a [`PermissionRequest`] is gating (`{ messageID, callID }`). #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] pub struct PermissionRequestTool { diff --git a/crates/opencode-server/src/lib.rs b/crates/opencode-server/src/lib.rs index 19ecad424552..11c8476dda50 100644 --- a/crates/opencode-server/src/lib.rs +++ b/crates/opencode-server/src/lib.rs @@ -2125,6 +2125,7 @@ async fn v2_provider_get( file_list, permission_list, question_list, + vcs_get, project_list, project_current, v2_event_subscribe, @@ -2218,7 +2219,8 @@ async fn v2_provider_get( opencode_proto::QuestionRequest, opencode_proto::QuestionInfo, opencode_proto::QuestionOption, - opencode_proto::QuestionTool + opencode_proto::QuestionTool, + opencode_proto::VcsInfo )), tags( (name = "control", description = "Control-plane routes"), @@ -2334,6 +2336,64 @@ async fn file_list( Ok(Json(data)) } +/// Run `git -C ` and return trimmed stdout, or `None` on any failure (not a repo, git +/// missing, empty output). Best-effort so `vcs.get` degrades gracefully outside a repository. +async fn git_field(dir: &str, args: &[&str]) -> Option { + let output = tokio::process::Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .await + .ok()?; + if !output.status.success() { + return None; + } + let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (!s.is_empty()).then_some(s) +} + +/// `GET /vcs` — version-control info for a directory (group `instance`). Matches the golden `vcs.get`: +/// 200 `VcsInfo`, 400 `BadRequestError`. Best-effort `git` queries; fields are omitted outside a repo. +#[utoipa::path( + get, + path = "/vcs", + operation_id = "vcs.get", + params( + ("directory" = Option, Query, description = "Directory (defaults to cwd)"), + ("workspace" = Option, Query, description = "Workspace id") + ), + responses( + (status = 200, description = "VCS info", body = opencode_proto::VcsInfo), + (status = 400, description = "Bad request", body = opencode_proto::BadRequestError) + ), + tag = "instance" +)] +async fn vcs_get( + State(_state): State, + Query(params): Query>, +) -> Json { + let dir = params + .get("directory") + .filter(|s| !s.is_empty()) + .cloned() + .unwrap_or_else(|| { + std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_default() + }); + let branch = git_field(&dir, &["rev-parse", "--abbrev-ref", "HEAD"]) + .await + .filter(|b| b != "HEAD"); // detached HEAD → no branch + let default_branch = git_field(&dir, &["rev-parse", "--abbrev-ref", "origin/HEAD"]) + .await + .map(|s| s.strip_prefix("origin/").unwrap_or(&s).to_string()); + Json(opencode_proto::VcsInfo { + branch, + default_branch, + }) +} + /// `GET /permission` — pending permission requests (group `permission`). Matches the golden /// `permission.list`: 200 `[PermissionRequest]`, 400 `BadRequestError`. Pending requests are ephemeral /// execution state; until the native runner produces them this is empty (no in-flight approvals). @@ -2435,6 +2495,7 @@ pub fn build_router(state: ServerState) -> Router { router = router.route("/path", get(path_get)); router = router.route("/session/{sessionID}/abort", post(session_abort)); router = router.route("/instance/dispose", post(instance_dispose)); + router = router.route("/vcs", get(vcs_get)); } if state.routes.handles("file") { router = router.route("/find/file", get(find_files)); @@ -3229,6 +3290,37 @@ mod tests { assert_eq!(v["data"]["message"], "Session not found: ses_missing"); } + #[tokio::test] + async fn vcs_get_outside_repo_returns_empty_object() { + use tower::ServiceExt; + let dir = tempfile::tempdir().unwrap(); // a fresh temp dir is not a git repo + let state = ServerState { + ctx: AppContext::in_memory(), + routes: RouteTable::parse("instance"), + proxy: Arc::new(proxy::Upstream::new("http://127.0.0.1:1")), + runner: RunnerServices::default(), + coordinator: SessionCoordinator::default(), + }; + let uri = format!("/vcs?directory={}", dir.path().display()); + let resp = build_router(state) + .oneshot( + axum::extract::Request::builder() + .uri(&uri) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + // No repo → both fields omitted (object present, branch absent). + assert!(v.is_object()); + assert!(v.get("branch").is_none()); + } + #[tokio::test] async fn permission_and_question_lists_are_empty_when_idle() { use tower::ServiceExt; diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index 2df315c58067..30c874a5cc9a 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -21,6 +21,7 @@ const CUTOVER_PATHS: &[&str] = &[ "/log", "/permission", "/question", + "/vcs", "/api/session", "/api/session/{sessionID}", "/api/session/{sessionID}/message", diff --git a/docs/ROADMAP-rust-only.md b/docs/ROADMAP-rust-only.md index 78f014bfb2e2..7aa10dba20e6 100644 --- a/docs/ROADMAP-rust-only.md +++ b/docs/ROADMAP-rust-only.md @@ -38,6 +38,8 @@ - ✅ 1l — `session.todo` (read store `todo` + rota `GET /session/{id}/todo`) — **#73** - ✅ 1o — `global.dispose` + `instance.dispose` (lifecycle ack, 200 `true`) — **#74** - ✅ 1r — `file.list` (`GET /file`, listagem de diretório com flag gitignore) — **#76** +- ✅ 1s — `permission.list` + `question.list` (vazios até a engine) — **#77** +- ✅ 1t — `vcs.get` (`GET /vcs`, branch/default_branch via git best-effort) — **#78** - ⏳ 1p — `session.status` / `session.diff` — **dependem da engine de execução** (estado live / snapshots), não enxutas - ⏳ 1q — `session.update` / `revert` / `share` / `command` / `summarize` — **dependem do write-path** (PENDENCIAS #6) - 🔄 1m — `permission.list` ✅ (#77, vazio até a engine produzir) · `permission.respond` ⏳ (precisa engine)