feat(runtime-api): expose git status metadata for agent view (#2862)
This commit is contained in:
+5
-4
@@ -136,10 +136,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
preview backed by recent runtime thread summaries, plus a read-only
|
||||
`GET /v1/snapshots` endpoint for GUI clients to inspect side-git restore
|
||||
points. The extension now renders those restore points read-only in its Agent
|
||||
View, and thread summaries include read-only workspace and branch metadata so
|
||||
the VS Code Agent View can show when a thread or agent lane is on another
|
||||
branch. Agent View and restore-point data now auto-refresh on a configurable
|
||||
read-only interval so branch/workspace changes become visible without a
|
||||
View, and thread summaries include read-only workspace, branch, current Git
|
||||
head, and dirty-state metadata so the VS Code Agent View can show when a
|
||||
thread or agent lane is on another branch or has changed worktree state. Agent
|
||||
View and restore-point data now auto-refresh on a configurable
|
||||
read-only interval so branch/workspace/status changes become visible without a
|
||||
manual refresh. Agent View refreshes keep thread branch/workspace rows
|
||||
independent from restore-point loading, so a snapshot-listing failure no
|
||||
longer clears already-available thread metadata. This answers the VS Code GUI
|
||||
|
||||
@@ -136,10 +136,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
preview backed by recent runtime thread summaries, plus a read-only
|
||||
`GET /v1/snapshots` endpoint for GUI clients to inspect side-git restore
|
||||
points. The extension now renders those restore points read-only in its Agent
|
||||
View, and thread summaries include read-only workspace and branch metadata so
|
||||
the VS Code Agent View can show when a thread or agent lane is on another
|
||||
branch. Agent View and restore-point data now auto-refresh on a configurable
|
||||
read-only interval so branch/workspace changes become visible without a
|
||||
View, and thread summaries include read-only workspace, branch, current Git
|
||||
head, and dirty-state metadata so the VS Code Agent View can show when a
|
||||
thread or agent lane is on another branch or has changed worktree state. Agent
|
||||
View and restore-point data now auto-refresh on a configurable
|
||||
read-only interval so branch/workspace/status changes become visible without a
|
||||
manual refresh. Agent View refreshes keep thread branch/workspace rows
|
||||
independent from restore-point loading, so a snapshot-listing failure no
|
||||
longer clears already-available thread metadata. This answers the VS Code GUI
|
||||
|
||||
@@ -266,6 +266,8 @@ struct ThreadSummary {
|
||||
mode: String,
|
||||
workspace: PathBuf,
|
||||
branch: Option<String>,
|
||||
head: Option<String>,
|
||||
dirty: bool,
|
||||
archived: bool,
|
||||
updated_at: chrono::DateTime<Utc>,
|
||||
latest_turn_id: Option<String>,
|
||||
@@ -277,6 +279,8 @@ struct WorkspaceStatusResponse {
|
||||
workspace: PathBuf,
|
||||
git_repo: bool,
|
||||
branch: Option<String>,
|
||||
head: Option<String>,
|
||||
dirty: bool,
|
||||
staged: usize,
|
||||
unstaged: usize,
|
||||
untracked: usize,
|
||||
@@ -284,6 +288,13 @@ struct WorkspaceStatusResponse {
|
||||
behind: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct WorkspaceGitMetadata {
|
||||
branch: Option<String>,
|
||||
head: Option<String>,
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SkillEntry {
|
||||
name: String,
|
||||
@@ -1241,13 +1252,16 @@ async fn list_threads_summary(
|
||||
}
|
||||
}
|
||||
|
||||
let workspace_git = collect_workspace_git_metadata(&thread.workspace);
|
||||
summaries.push(ThreadSummary {
|
||||
id: thread.id,
|
||||
title,
|
||||
preview,
|
||||
model: thread.model,
|
||||
mode: thread.mode,
|
||||
branch: current_git_branch(&thread.workspace),
|
||||
branch: workspace_git.branch,
|
||||
head: workspace_git.head,
|
||||
dirty: workspace_git.dirty,
|
||||
workspace: thread.workspace,
|
||||
archived: thread.archived,
|
||||
updated_at: thread.updated_at,
|
||||
@@ -2000,6 +2014,8 @@ fn collect_workspace_status(workspace: &std::path::Path) -> WorkspaceStatusRespo
|
||||
workspace: workspace.to_path_buf(),
|
||||
git_repo: false,
|
||||
branch: None,
|
||||
head: None,
|
||||
dirty: false,
|
||||
staged: 0,
|
||||
unstaged: 0,
|
||||
untracked: 0,
|
||||
@@ -2015,7 +2031,10 @@ fn collect_workspace_status(workspace: &std::path::Path) -> WorkspaceStatusRespo
|
||||
}
|
||||
|
||||
status.git_repo = true;
|
||||
status.branch = current_git_branch(workspace);
|
||||
let metadata = collect_workspace_git_metadata(workspace);
|
||||
status.branch = metadata.branch;
|
||||
status.head = metadata.head;
|
||||
status.dirty = metadata.dirty;
|
||||
|
||||
if let Some(porcelain) = run_git(workspace, &["status", "--porcelain=v1"]) {
|
||||
for line in porcelain.lines() {
|
||||
@@ -2049,6 +2068,22 @@ fn collect_workspace_status(workspace: &std::path::Path) -> WorkspaceStatusRespo
|
||||
status
|
||||
}
|
||||
|
||||
fn collect_workspace_git_metadata(workspace: &std::path::Path) -> WorkspaceGitMetadata {
|
||||
let Some(repo_check) = run_git(workspace, &["rev-parse", "--is-inside-work-tree"]) else {
|
||||
return WorkspaceGitMetadata::default();
|
||||
};
|
||||
if repo_check.trim() != "true" {
|
||||
return WorkspaceGitMetadata::default();
|
||||
}
|
||||
|
||||
WorkspaceGitMetadata {
|
||||
branch: current_git_branch(workspace),
|
||||
head: current_git_head(workspace),
|
||||
dirty: run_git(workspace, &["status", "--porcelain=v1"])
|
||||
.is_some_and(|porcelain| !porcelain.trim().is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_git(workspace: &std::path::Path, args: &[&str]) -> Option<String> {
|
||||
let output = crate::dependencies::Git::output(args, workspace).ok()?;
|
||||
if !output.status.success() {
|
||||
@@ -2075,6 +2110,12 @@ fn current_git_branch(workspace: &std::path::Path) -> Option<String> {
|
||||
(!short_hash.is_empty()).then(|| format!("detached@{short_hash}"))
|
||||
}
|
||||
|
||||
fn current_git_head(workspace: &std::path::Path) -> Option<String> {
|
||||
let head = run_git(workspace, &["rev-parse", "--short", "HEAD"])?;
|
||||
let head = head.trim();
|
||||
(!head.is_empty()).then(|| head.to_string())
|
||||
}
|
||||
|
||||
fn resolve_skills_dir(config: &Config, workspace: &std::path::Path) -> PathBuf {
|
||||
// Canonicalize the workspace once so the symlink-containment check below
|
||||
// compares like-for-like. If the workspace can't be canonicalized at all
|
||||
@@ -2459,6 +2500,43 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_status_reports_head_and_dirty_counts() -> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let repo = tmp.path().join("repo");
|
||||
fs::create_dir_all(&repo)?;
|
||||
run_test_git(&repo, &["init", "-b", "main"])?;
|
||||
fs::write(repo.join("tracked.txt"), "clean\n")?;
|
||||
run_test_git(&repo, &["add", "tracked.txt"])?;
|
||||
run_test_git(
|
||||
&repo,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=CodeWhale Test",
|
||||
"-c",
|
||||
"user.email=codewhale@example.invalid",
|
||||
"commit",
|
||||
"-m",
|
||||
"init",
|
||||
],
|
||||
)?;
|
||||
|
||||
let clean = collect_workspace_status(&repo);
|
||||
assert!(clean.git_repo);
|
||||
assert_eq!(clean.branch.as_deref(), Some("main"));
|
||||
assert!(clean.head.as_deref().is_some_and(|head| !head.is_empty()));
|
||||
assert!(!clean.dirty);
|
||||
|
||||
fs::write(repo.join("tracked.txt"), "dirty\n")?;
|
||||
fs::write(repo.join("untracked.txt"), "new\n")?;
|
||||
|
||||
let dirty = collect_workspace_status(&repo);
|
||||
assert!(dirty.dirty);
|
||||
assert_eq!(dirty.unstaged, 1);
|
||||
assert_eq!(dirty.untracked, 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_detail_tool_use_preserves_caller_metadata() {
|
||||
let detail = session_to_detail(saved_session_with_blocks(vec![
|
||||
@@ -2949,6 +3027,10 @@ mod tests {
|
||||
.as_str()
|
||||
.context("missing git thread id")?
|
||||
.to_string();
|
||||
fs::write(
|
||||
repo.join("dirty.txt"),
|
||||
"worktree changed after thread spawn\n",
|
||||
)?;
|
||||
|
||||
let plain_thread: serde_json::Value = client
|
||||
.post(format!("http://{addr}/v1/threads"))
|
||||
@@ -2979,6 +3061,12 @@ mod tests {
|
||||
.find(|item| item["id"] == git_thread_id)
|
||||
.context("missing git workspace summary")?;
|
||||
assert_eq!(git_summary["branch"], "feature/agent");
|
||||
assert!(
|
||||
git_summary["head"]
|
||||
.as_str()
|
||||
.is_some_and(|head| !head.is_empty())
|
||||
);
|
||||
assert_eq!(git_summary["dirty"], true);
|
||||
assert_eq!(git_summary["workspace"], repo.to_string_lossy().as_ref());
|
||||
|
||||
let plain_summary = summaries
|
||||
@@ -2986,6 +3074,8 @@ mod tests {
|
||||
.find(|item| item["id"] == plain_thread_id)
|
||||
.context("missing plain workspace summary")?;
|
||||
assert_eq!(plain_summary["branch"], serde_json::Value::Null);
|
||||
assert_eq!(plain_summary["head"], serde_json::Value::Null);
|
||||
assert_eq!(plain_summary["dirty"], false);
|
||||
assert_eq!(
|
||||
plain_summary["workspace"],
|
||||
non_git.to_string_lossy().as_ref()
|
||||
|
||||
@@ -191,6 +191,8 @@ workspace metadata:
|
||||
"model": "deepseek-v4-pro",
|
||||
"mode": "agent",
|
||||
"branch": "feature/runtime-api",
|
||||
"head": "abc1234",
|
||||
"dirty": false,
|
||||
"workspace": "/Users/you/projects/codewhale",
|
||||
"archived": false,
|
||||
"updated_at": "2026-06-06T05:43:00Z",
|
||||
@@ -201,6 +203,8 @@ workspace metadata:
|
||||
|
||||
`branch` is resolved from the thread workspace at request time and may be
|
||||
`null` when the workspace is not a Git repository or the branch cannot be read.
|
||||
`head` is the current short Git commit for that workspace when available.
|
||||
`dirty` is true when the workspace has staged, unstaged, or untracked changes.
|
||||
`workspace` is included so editor clients can show when an agent lane is working
|
||||
outside the current VS Code folder.
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ config source, result, and follow-up issue or PR.
|
||||
| Transcript tool-collapse smoke or explicit defer | UX steward | ship | #2776 (`c76ec4752`) landed dense successful tool-run collapse with guardrails for failed/running/shell/patch/review/diff cells; focused widget coverage includes `chat_widget_collapses_dense_tool_runs_by_default`, `chat_widget_expands_dense_tool_runs_on_demand`, and `chat_widget_expanded_mode_leaves_dense_tool_runs_visible`. |
|
||||
| Sidebar detail popovers smoke or explicit defer | UX steward | ship | #2778 (`3cb49233e`) added row-level hover metadata and wrapping detail popovers for truncated Work/Tasks/Agents rows; #2806 (`19f5c7aa6`) preserved current sub-agent progress in the sidebar hover text. Focused coverage includes `sidebar_hover_rows_mark_source_text_diff_as_truncated` and `subagent_hover_text_preserves_full_agent_id_and_progress`. |
|
||||
| Plan review/handoff artifact smoke | Plan steward | ship | #2770 (`7ac8063b6`) added rich PlanArtifact sections through the transcript/Plan prompt path; focused coverage includes `plan_update_cell_renders_rich_artifact_metadata` and `plan_prompt_renders_rich_plan_artifact_sections`. |
|
||||
| VS Code Agent View branch/workspace visibility smoke | GUI steward | ship | #2825 (`1bacaf763`) added `workspace` / `branch` metadata to `/v1/threads/summary`; #2832 (`50b773f1d`) added read-only auto-refresh so branch/workspace changes can appear without manual refresh. |
|
||||
| VS Code Agent View branch/workspace visibility smoke | GUI steward | ship | #2825 (`1bacaf763`) added `workspace` / `branch` metadata to `/v1/threads/summary`; #2832 (`50b773f1d`) added read-only auto-refresh so branch/workspace changes can appear without manual refresh. The current stewardship slice extends the same read-only metadata with current Git `head` and `dirty` worktree state for editor/agent-lane visibility. |
|
||||
|
||||
## v0.9.0 Feature Gates
|
||||
|
||||
|
||||
Reference in New Issue
Block a user