mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 14:03:52 +00:00
feat: add VCS status, diff, and revert APIs
This commit is contained in:
parent
7378abee46
commit
4bf3a6f0ee
9 changed files with 601 additions and 13 deletions
1
.turbo
Symbolic link
1
.turbo
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/nathan/sandbox-agent/.turbo
|
||||
1
dist
Symbolic link
1
dist
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/nathan/sandbox-agent/dist
|
||||
1
node_modules
Symbolic link
1
node_modules
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/nathan/sandbox-agent/node_modules
|
||||
|
|
@ -7,3 +7,4 @@ pub mod router;
|
|||
pub mod server_logs;
|
||||
pub mod telemetry;
|
||||
pub mod ui;
|
||||
pub mod vcs;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
365
server/packages/sandbox-agent/src/vcs.rs
Normal file
365
server/packages/sandbox-agent/src/vcs.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
107
server/packages/sandbox-agent/tests/opencode-compat/vcs.test.ts
Normal file
107
server/packages/sandbox-agent/tests/opencode-compat/vcs.test.ts
Normal 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
1
target
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/nathan/sandbox-agent/target
|
||||
Loading…
Add table
Add a link
Reference in a new issue