feat: add VCS status, diff, and revert APIs

This commit is contained in:
Nathan Flurry 2026-02-04 14:32:26 -08:00
parent 7378abee46
commit 4bf3a6f0ee
9 changed files with 601 additions and 13 deletions

1
.turbo Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/.turbo

1
dist Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/dist

1
node_modules Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/node_modules

View file

@ -7,3 +7,4 @@ pub mod router;
pub mod server_logs;
pub mod telemetry;
pub mod ui;
pub mod vcs;

View file

@ -31,6 +31,7 @@ use sandbox_agent_universal_agent_schema::{
UniversalEventType, UniversalItem, PermissionEventData, PermissionStatus, QuestionEventData,
QuestionStatus, FileAction, ItemStatus,
};
use crate::vcs::VcsFileStatus;
static SESSION_COUNTER: AtomicU64 = AtomicU64::new(1);
static MESSAGE_COUNTER: AtomicU64 = AtomicU64::new(1);
@ -466,6 +467,13 @@ struct DirectoryQuery {
directory: Option<String>,
}
#[derive(Debug, Deserialize, IntoParams)]
struct DiffQuery {
directory: Option<String>,
#[serde(rename = "messageID")]
message_id: Option<String>,
}
#[derive(Debug, Deserialize, IntoParams)]
struct ToolQuery {
directory: Option<String>,
@ -497,6 +505,15 @@ struct FileContentQuery {
path: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
struct SessionRevertRequest {
#[serde(rename = "messageID")]
message_id: Option<String>,
#[serde(rename = "partID")]
part_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
struct SessionMessageRequest {
@ -783,6 +800,14 @@ fn bool_ok(value: bool) -> (StatusCode, Json<Value>) {
(StatusCode::OK, Json(json!(value)))
}
fn vcs_status_label(status: &VcsFileStatus) -> &'static str {
match status {
VcsFileStatus::Added => "added",
VcsFileStatus::Deleted => "deleted",
VcsFileStatus::Modified => "modified",
}
}
fn build_user_message(
session_id: &str,
message_id: &str,
@ -2528,13 +2553,21 @@ async fn oc_path(State(state): State<Arc<OpenCodeAppState>>, headers: HeaderMap)
responses((status = 200)),
tag = "opencode"
)]
async fn oc_vcs(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoResponse {
(
StatusCode::OK,
Json(json!({
"branch": state.opencode.branch_name(),
})),
)
async fn oc_vcs(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
Query(query): Query<DirectoryQuery>,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
let vcs = state.inner.session_manager().vcs();
let status = tokio::task::spawn_blocking(move || vcs.status(&directory))
.await
.ok()
.flatten();
let branch = status
.map(|status| status.branch)
.unwrap_or_else(|| state.opencode.branch_name());
(StatusCode::OK, Json(json!({ "branch": branch })))
}
#[utoipa::path(
@ -2829,8 +2862,33 @@ async fn oc_session_fork(
responses((status = 200)),
tag = "opencode"
)]
async fn oc_session_diff() -> impl IntoResponse {
(StatusCode::OK, Json(json!([])))
async fn oc_session_diff(
State(state): State<Arc<OpenCodeAppState>>,
Path(_session_id): Path<String>,
headers: HeaderMap,
Query(query): Query<DiffQuery>,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
let vcs = state.inner.session_manager().vcs();
let diffs = tokio::task::spawn_blocking(move || vcs.diff(&directory))
.await
.ok()
.flatten()
.unwrap_or_default();
let response: Vec<Value> = diffs
.into_iter()
.map(|diff| {
json!({
"file": diff.file,
"before": diff.before,
"after": diff.after,
"additions": diff.additions,
"deletions": diff.deletions,
"status": vcs_status_label(&diff.status),
})
})
.collect();
(StatusCode::OK, Json(json!(response)))
}
#[utoipa::path(
@ -3236,7 +3294,7 @@ async fn oc_session_shell(
post,
path = "/session/{sessionID}/revert",
params(("sessionID" = String, Path, description = "Session ID")),
request_body = String,
request_body = SessionRevertRequest,
responses((status = 200)),
tag = "opencode"
)]
@ -3245,7 +3303,19 @@ async fn oc_session_revert(
Path(session_id): Path<String>,
headers: HeaderMap,
Query(query): Query<DirectoryQuery>,
_body: Option<Json<SessionRevertRequest>>,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
let vcs = state.inner.session_manager().vcs();
let session_id_clone = session_id.clone();
let revert_result = tokio::task::spawn_blocking(move || vcs.revert(&directory, &session_id_clone))
.await
.map_err(|err| SandboxError::InvalidRequest {
message: err.to_string(),
});
if let Ok(Err(err)) | Err(err) = revert_result {
return internal_error(&err.to_string()).into_response();
}
oc_session_get(State(state), Path(session_id), headers, Query(query)).await
}
@ -3253,7 +3323,6 @@ async fn oc_session_revert(
post,
path = "/session/{sessionID}/unrevert",
params(("sessionID" = String, Path, description = "Session ID")),
request_body = String,
responses((status = 200)),
tag = "opencode"
)]
@ -3263,6 +3332,18 @@ async fn oc_session_unrevert(
headers: HeaderMap,
Query(query): Query<DirectoryQuery>,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
let vcs = state.inner.session_manager().vcs();
let session_id_clone = session_id.clone();
let unrevert_result =
tokio::task::spawn_blocking(move || vcs.unrevert(&directory, &session_id_clone))
.await
.map_err(|err| SandboxError::InvalidRequest {
message: err.to_string(),
});
if let Ok(Err(err)) | Err(err) = unrevert_result {
return internal_error(&err.to_string()).into_response();
}
oc_session_get(State(state), Path(session_id), headers, Query(query)).await
}
@ -3784,8 +3865,30 @@ async fn oc_file_content(Query(query): Query<FileContentQuery>) -> impl IntoResp
responses((status = 200)),
tag = "opencode"
)]
async fn oc_file_status() -> impl IntoResponse {
(StatusCode::OK, Json(json!([]))).into_response()
async fn oc_file_status(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
Query(query): Query<DirectoryQuery>,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
let vcs = state.inner.session_manager().vcs();
let summaries = tokio::task::spawn_blocking(move || vcs.file_status(&directory))
.await
.ok()
.flatten()
.unwrap_or_default();
let response: Vec<Value> = summaries
.into_iter()
.map(|summary| {
json!({
"path": summary.path,
"added": summary.added,
"removed": summary.removed,
"status": vcs_status_label(&summary.status),
})
})
.collect();
(StatusCode::OK, Json(json!(response))).into_response()
}
#[utoipa::path(
@ -4264,6 +4367,7 @@ async fn oc_tui_select_session() -> impl IntoResponse {
SessionCommandRequest,
SessionShellRequest,
SessionSummarizeRequest,
SessionRevertRequest,
PermissionReplyRequest,
PermissionGlobalReplyRequest,
QuestionReplyBody,

View file

@ -40,6 +40,7 @@ use utoipa::{Modify, OpenApi, ToSchema};
use crate::agent_server_logs::AgentServerLogs;
use crate::opencode_compat::{build_opencode_router, OpenCodeAppState};
use crate::vcs::VcsService;
use crate::ui;
use sandbox_agent_agent_management::agents::{
AgentError as ManagerError, AgentId, AgentManager, InstallOptions, SpawnOptions, StreamingSpawn,
@ -818,6 +819,7 @@ pub(crate) struct SessionManager {
sessions: Mutex<Vec<SessionState>>,
server_manager: Arc<AgentServerManager>,
http_client: Client,
vcs: Arc<VcsService>,
}
/// Shared Codex app-server process that handles multiple sessions via JSON-RPC.
@ -1538,9 +1540,14 @@ impl SessionManager {
sessions: Mutex::new(Vec::new()),
server_manager,
http_client: Client::new(),
vcs: Arc::new(VcsService::new()),
}
}
pub(crate) fn vcs(&self) -> Arc<VcsService> {
self.vcs.clone()
}
fn session_ref<'a>(sessions: &'a [SessionState], session_id: &str) -> Option<&'a SessionState> {
sessions
.iter()

View file

@ -0,0 +1,365 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Mutex;
use sandbox_agent_error::SandboxError;
#[derive(Debug, Clone)]
pub struct VcsStatus {
pub branch: String,
pub ahead: u32,
pub behind: u32,
pub dirty_files: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum VcsFileStatus {
Added,
Deleted,
Modified,
}
#[derive(Debug, Clone)]
pub struct VcsFileDiff {
pub file: String,
pub before: String,
pub after: String,
pub additions: u32,
pub deletions: u32,
pub status: VcsFileStatus,
}
#[derive(Debug, Clone)]
pub struct VcsFileSummary {
pub path: String,
pub added: u32,
pub removed: u32,
pub status: VcsFileStatus,
}
#[derive(Debug, Clone)]
struct StatusEntry {
status: String,
path: String,
}
#[derive(Debug, Clone)]
struct BranchInfo {
branch: String,
ahead: u32,
behind: u32,
}
#[derive(Debug, Clone)]
struct VcsStashInfo {
repo_root: PathBuf,
stash_ref: String,
}
#[derive(Debug, Default)]
pub struct VcsService {
stashes: Mutex<HashMap<String, VcsStashInfo>>,
}
impl VcsService {
pub fn new() -> Self {
Self {
stashes: Mutex::new(HashMap::new()),
}
}
pub fn discover_repo(&self, directory: &str) -> Option<PathBuf> {
let root = run_git(Path::new(directory), &["rev-parse", "--show-toplevel"]).ok()?;
let root = root.trim();
if root.is_empty() {
return None;
}
Some(PathBuf::from(root))
}
pub fn status(&self, directory: &str) -> Option<VcsStatus> {
let repo_root = self.discover_repo(directory)?;
let (branch_info, entries) = status_entries(&repo_root)?;
let branch_info = branch_info.unwrap_or_else(|| BranchInfo {
branch: "HEAD".to_string(),
ahead: 0,
behind: 0,
});
let dirty_files = entries.into_iter().map(|entry| entry.path).collect();
Some(VcsStatus {
branch: branch_info.branch,
ahead: branch_info.ahead,
behind: branch_info.behind,
dirty_files,
})
}
pub fn diff(&self, directory: &str) -> Option<Vec<VcsFileDiff>> {
let repo_root = self.discover_repo(directory)?;
let (_branch, entries) = status_entries(&repo_root)?;
let mut diffs = Vec::new();
for entry in entries {
let status = status_from_codes(&entry.status);
let before = match status {
VcsFileStatus::Added => String::new(),
VcsFileStatus::Deleted | VcsFileStatus::Modified => {
git_show_file(&repo_root, &entry.path).unwrap_or_default()
}
};
let after = match status {
VcsFileStatus::Deleted => String::new(),
VcsFileStatus::Added | VcsFileStatus::Modified => {
read_file(&repo_root, &entry.path).unwrap_or_default()
}
};
let (additions, deletions) = match numstat_for_file(&repo_root, &entry.path) {
Some((adds, dels)) => (adds, dels),
None => match status {
VcsFileStatus::Added => (count_lines(&after), 0),
VcsFileStatus::Deleted => (0, count_lines(&before)),
VcsFileStatus::Modified => (count_lines(&after), count_lines(&before)),
},
};
diffs.push(VcsFileDiff {
file: entry.path,
before,
after,
additions,
deletions,
status,
});
}
Some(diffs)
}
pub fn diff_text(&self, directory: &str) -> Option<String> {
let repo_root = self.discover_repo(directory)?;
run_git(&repo_root, &["diff", "HEAD"]).ok()
}
pub fn file_status(&self, directory: &str) -> Option<Vec<VcsFileSummary>> {
let repo_root = self.discover_repo(directory)?;
let (_branch, entries) = status_entries(&repo_root)?;
let mut summaries = Vec::new();
for entry in entries {
let status = status_from_codes(&entry.status);
let (added, removed) = match numstat_for_file(&repo_root, &entry.path) {
Some((adds, dels)) => (adds, dels),
None => match status {
VcsFileStatus::Added => {
let after = read_file(&repo_root, &entry.path).unwrap_or_default();
(count_lines(&after), 0)
}
VcsFileStatus::Deleted => {
let before = git_show_file(&repo_root, &entry.path).unwrap_or_default();
(0, count_lines(&before))
}
VcsFileStatus::Modified => (0, 0),
},
};
summaries.push(VcsFileSummary {
path: entry.path,
added,
removed,
status,
});
}
Some(summaries)
}
pub fn revert(&self, directory: &str, session_id: &str) -> Result<bool, SandboxError> {
let repo_root = match self.discover_repo(directory) {
Some(root) => root,
None => return Ok(false),
};
let key = stash_key(&repo_root, session_id);
{
let stashes = self.stashes.lock().unwrap();
if stashes.contains_key(&key) {
return Ok(true);
}
}
let message = format!("sandbox-agent:session:{session_id}:revert");
let output = run_git(&repo_root, &["stash", "push", "-u", "-m", &message])?;
if output.contains("No local changes to save") {
return Ok(false);
}
let stash_ref = find_stash_ref(&repo_root, &message)?;
let mut stashes = self.stashes.lock().unwrap();
stashes.insert(
key,
VcsStashInfo {
repo_root,
stash_ref,
},
);
Ok(true)
}
pub fn unrevert(&self, directory: &str, session_id: &str) -> Result<bool, SandboxError> {
let repo_root = match self.discover_repo(directory) {
Some(root) => root,
None => return Ok(false),
};
let key = stash_key(&repo_root, session_id);
let stash = {
let mut stashes = self.stashes.lock().unwrap();
stashes.remove(&key)
};
let Some(stash) = stash else {
return Ok(false);
};
run_git(&stash.repo_root, &["stash", "apply", &stash.stash_ref])?;
let _ = run_git(&stash.repo_root, &["stash", "drop", &stash.stash_ref]);
Ok(true)
}
}
fn stash_key(repo_root: &Path, session_id: &str) -> String {
format!("{}::{}", repo_root.display(), session_id)
}
fn run_git(directory: &Path, args: &[&str]) -> Result<String, SandboxError> {
let output = Command::new("git")
.arg("-C")
.arg(directory)
.args(args)
.output()
.map_err(|err| SandboxError::InvalidRequest {
message: format!("git execution failed: {err}"),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SandboxError::InvalidRequest {
message: format!("git command failed: {stderr}"),
});
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn status_entries(repo_root: &Path) -> Option<(Option<BranchInfo>, Vec<StatusEntry>)> {
let output = run_git(repo_root, &["status", "--porcelain=v1", "-b", "-z"]).ok()?;
let mut entries = Vec::new();
let mut branch_info = None;
let mut iter = output.split_terminator('\0');
while let Some(entry) = iter.next() {
if entry.is_empty() {
continue;
}
if let Some(info) = parse_branch_line(entry) {
branch_info = Some(info);
continue;
}
if entry.len() < 3 {
continue;
}
let status = entry[..2].to_string();
let mut path = entry[3..].to_string();
if status.starts_with('R') || status.starts_with('C') {
if let Some(new_path) = iter.next() {
path = new_path.to_string();
}
}
entries.push(StatusEntry { status, path });
}
Some((branch_info, entries))
}
fn parse_branch_line(line: &str) -> Option<BranchInfo> {
if !line.starts_with("## ") {
return None;
}
let line = line.trim_start_matches("## ").trim();
let mut branch = line;
let mut ahead = 0;
let mut behind = 0;
if let Some((name, rest)) = line.split_once("...") {
branch = name;
if let Some((start, end)) = rest.find('[').zip(rest.find(']')) {
let stats = &rest[start + 1..end];
for part in stats.split(',') {
let part = part.trim();
if let Some(count) = part.strip_prefix("ahead ") {
ahead = count.parse::<u32>().unwrap_or(0);
} else if let Some(count) = part.strip_prefix("behind ") {
behind = count.parse::<u32>().unwrap_or(0);
}
}
}
}
if branch.starts_with("HEAD") {
branch = "HEAD";
}
Some(BranchInfo {
branch: branch.to_string(),
ahead,
behind,
})
}
fn status_from_codes(status: &str) -> VcsFileStatus {
let bytes = status.as_bytes();
if bytes.len() < 2 {
return VcsFileStatus::Modified;
}
let index = bytes[0] as char;
let worktree = bytes[1] as char;
if index == 'A' || worktree == 'A' || index == '?' || worktree == '?' {
VcsFileStatus::Added
} else if index == 'D' || worktree == 'D' {
VcsFileStatus::Deleted
} else {
VcsFileStatus::Modified
}
}
fn git_show_file(repo_root: &Path, path: &str) -> Option<String> {
let output = run_git(repo_root, &["show", &format!("HEAD:{path}")]).ok()?;
Some(output)
}
fn read_file(repo_root: &Path, path: &str) -> Option<String> {
let full_path = repo_root.join(path);
let bytes = std::fs::read(full_path).ok()?;
Some(String::from_utf8_lossy(&bytes).to_string())
}
fn numstat_for_file(repo_root: &Path, path: &str) -> Option<(u32, u32)> {
let output = run_git(repo_root, &["diff", "--numstat", "HEAD", "--", path]).ok()?;
let line = output.lines().next()?;
let mut parts = line.split('\t');
let adds = parts.next()?;
let dels = parts.next()?;
let parse_field = |value: &str| -> Option<u32> {
if value == "-" {
Some(0)
} else {
value.parse::<u32>().ok()
}
};
Some((parse_field(adds)?, parse_field(dels)?))
}
fn count_lines(text: &str) -> u32 {
if text.is_empty() {
0
} else {
text.lines().count() as u32
}
}
fn find_stash_ref(repo_root: &Path, message: &str) -> Result<String, SandboxError> {
let output = run_git(repo_root, &["stash", "list", "--format=%gd:%s"])?;
for line in output.lines() {
if let Some((stash_ref, subject)) = line.split_once(':') {
if subject.trim() == message {
return Ok(stash_ref.to_string());
}
}
}
Err(SandboxError::InvalidRequest {
message: "unable to locate stash reference".to_string(),
})
}

View file

@ -0,0 +1,107 @@
/**
* Tests for OpenCode-compatible VCS endpoints.
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
async function createFixtureRepo(): Promise<{ dir: string; filePath: string }> {
const dir = await mkdtemp(join(tmpdir(), "opencode-vcs-"));
execFileSync("git", ["init", "-b", "main"], { cwd: dir });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir });
execFileSync("git", ["config", "user.name", "Test User"], { cwd: dir });
const filePath = join(dir, "README.md");
await writeFile(filePath, "hello\n", "utf8");
execFileSync("git", ["add", "."], { cwd: dir });
execFileSync("git", ["commit", "-m", "init"], { cwd: dir });
return { dir, filePath };
}
describe("OpenCode-compatible VCS API", () => {
let handle: SandboxAgentHandle;
let client: OpencodeClient;
let repoDir: string;
let filePath: string;
beforeAll(async () => {
await buildSandboxAgent();
});
beforeEach(async () => {
const repo = await createFixtureRepo();
repoDir = repo.dir;
filePath = repo.filePath;
handle = await spawnSandboxAgent({
opencodeCompat: true,
env: {
OPENCODE_COMPAT_DIRECTORY: repoDir,
OPENCODE_COMPAT_WORKTREE: repoDir,
OPENCODE_COMPAT_BRANCH: "main",
},
});
client = createOpencodeClient({
baseUrl: `${handle.baseUrl}/opencode`,
headers: { Authorization: `Bearer ${handle.token}` },
});
});
afterEach(async () => {
await handle?.dispose();
if (repoDir) {
await rm(repoDir, { recursive: true, force: true });
}
});
it("returns branch and diff entries", async () => {
const vcs = await client.vcs.get({ query: { directory: repoDir } });
expect(vcs.data?.branch).toBe("main");
const session = await client.session.create();
const sessionId = session.data?.id!;
await writeFile(filePath, "hello\nworld\n", "utf8");
const diff = await client.session.diff({
path: { sessionID: sessionId },
query: { directory: repoDir },
});
expect(diff.data?.length).toBe(1);
const entry = diff.data?.[0]!;
expect(entry.file).toBe("README.md");
expect(entry.before).toContain("hello");
expect(entry.after).toContain("world");
expect(entry.additions).toBeGreaterThan(0);
});
it("reverts and unreverts working tree changes", async () => {
const session = await client.session.create();
const sessionId = session.data?.id!;
await writeFile(filePath, "hello\nchanged\n", "utf8");
await client.session.revert({
path: { sessionID: sessionId },
query: { directory: repoDir },
body: { messageID: "msg_test" },
});
const reverted = await readFile(filePath, "utf8");
expect(reverted).toBe("hello\n");
await client.session.unrevert({
path: { sessionID: sessionId },
query: { directory: repoDir },
});
const restored = await readFile(filePath, "utf8");
expect(restored).toBe("hello\nchanged\n");
});
});

1
target Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/target