feat(git): switch with uncommitted changes (carry / shelve), Create & switch, and shelves with Restore/Delete

- switch takes changes: carry (plain git switch, 409 ECARRYCONFLICT with files) or shelve (shelf first, re-applied if the switch fails)
- branch-create switch: true runs git switch -c, the changes come along
- shelve / unshelve / shelf-drop + GET /git/shelves: only agenthub-shelf: stash entries, addressed by sha, repo root only, never mid-operation; a clean restore drops the shelf, a conflicted one keeps it
- 409 EAGENTRUNNING until confirm: true while the workspace's agent is working
- git bar: Bring my changes / Shelve and switch choice, Create & switch, restore offer after a switch; Changes panel: Shelve (n) and the Shelves list
- fix: after a switch the Changes panel showed the old branch — the post-write re-read joined an in-flight GET started before the write (request() dedupe); writes now close in-flight GETs, and every git write fires agenthub:git-changed so every view of the workspace re-reads
This commit is contained in:
2026-11-02 11:43:51 +07:00
parent 2b26fe1efa
commit f6eb39eb73
22 changed files with 2005 additions and 77 deletions
@@ -0,0 +1,90 @@
/**
* @file Pins the cause of "after Switch branch the Changes panel still shows
* the old branch": `request()` shares one in-flight GET between callers, and
* the panel's re-read after a switch joined a poll that had STARTED before
* the switch finished — so it got the old branch's answer. A write now
* closes every in-flight GET to new joiners (before it is sent and once it
* settles), and `gitAction` announces itself (`agenthub:git-changed`) so the
* views that never hear the bar's callback read again too.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { api } from "../api";
import { GIT_CHANGED_EVENT } from "../gitChanged";
afterEach(() => {
vi.unstubAllGlobals();
});
function json(body: unknown) {
return {
ok: true,
status: 200,
headers: { get: () => "application/json" },
json: async () => body,
};
}
describe("a git write and the reads around it", () => {
it("a read asked after a switch never joins a read started before it", async () => {
let releaseOld: (v: unknown) => void = () => {};
const spy = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method === "POST") return Promise.resolve(json({ ok: true, action: "switch" }));
// The first GET (a poll) hangs until released, answering the OLD branch.
if (spy.mock.calls.filter((c) => !c[1]?.method).length === 1) {
return new Promise((r) => {
releaseOld = () => r(json({ branch: "old" }));
});
}
return Promise.resolve(json({ branch: "new" }));
});
vi.stubGlobal("fetch", spy);
const poll = api.lanes.git(3);
await api.lanes.gitAction(3, "switch", { branch: "new" });
const after = api.lanes.git(3);
releaseOld(undefined);
expect(await poll).toEqual({ branch: "old" });
expect(await after).toEqual({ branch: "new" });
expect(spy.mock.calls.filter((c) => !c[1]?.method)).toHaveLength(2);
});
it("still shares a read between callers when no write came between them", async () => {
const spy = vi.fn(() => Promise.resolve(json({ branch: "x" })));
vi.stubGlobal("fetch", spy);
await Promise.all([api.lanes.git(4), api.lanes.git(4)]);
expect(spy).toHaveBeenCalledTimes(1);
});
it("announces every git write for its workspace, a failed one included", async () => {
const seen: unknown[] = [];
const on = (e: Event) => seen.push((e as CustomEvent).detail);
window.addEventListener(GIT_CHANGED_EVENT, on);
try {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(json({ ok: true })))
);
await api.lanes.gitAction({ lane: 5, session: "s" }, "shelve", {});
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
ok: false,
status: 409,
headers: { get: () => "application/json" },
json: async () => ({ error: { code: "ESHELVED", message: "x" } }),
})
)
);
await expect(api.lanes.gitAction(5, "switch", { branch: "b" })).rejects.toThrow();
} finally {
window.removeEventListener(GIT_CHANGED_EVENT, on);
}
expect(seen).toEqual([
{ laneId: 5, action: "shelve" },
{ laneId: 5, action: "switch" },
]);
});
});
+76 -6
View File
@@ -438,6 +438,7 @@ import type {
import { activeSourcesParam } from "./dataScope";
import i18n from "i18next";
import { applyRange, type DateRange } from "./dateRange";
import { emitGitChanged } from "./gitChanged";
// Root path all endpoint paths are appended to. Kept relative (no host) so the
// same client bundle works behind the Vite dev proxy and in same-origin prod.
@@ -995,7 +996,11 @@ export type UserPrefs = { prefs: Record<string, string>; onboardedAt: string | n
/** GETs in flight, by path: concurrent callers of the same read (several
* components mounting, a WS event fanning out to many listeners) share one
* fetch and one parsed result. Deleted as soon as it settles, so this is
* dedupe, never a cache. Callers must treat the result as read-only. */
* dedupe, never a cache. Callers must treat the result as read-only.
* A WRITE closes every one of them to new joiners, before it is sent and
* again once it settles: a read asked after a write must see the write, and
* joining a poll that started before it handed back the old state (after a
* branch switch, the Changes panel kept showing the branch just left). */
const inflight = new Map<string, Promise<unknown>>();
function request<T>(path: string, options?: RequestInit): Promise<T> {
@@ -1004,7 +1009,10 @@ function request<T>(path: string, options?: RequestInit): Promise<T> {
!options ||
(Object.keys(options).every((k) => k === "method") &&
(options.method ?? "GET").toUpperCase() === "GET");
if (!plainGet) return send<T>(path, options);
if (!plainGet) {
inflight.clear();
return send<T>(path, options).finally(() => inflight.clear());
}
const hit = inflight.get(path);
if (hit) return hit as Promise<T>;
const p = send<T>(path, options).finally(() => inflight.delete(path));
@@ -1048,6 +1056,8 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
auth: body?.error?.auth as GitAuthNeeded | undefined,
// ESESSIONLIVE from a workspace start: the pane already holding it.
runId: body?.error?.run_id as string | undefined,
// ECARRYCONFLICT from a lane git switch: the files it would overwrite.
files: body?.error?.files as string[] | undefined,
// EEXISTS from the File Explorer: the names already taken.
names: body?.error?.names as string[] | undefined,
// ETOOBIG from a prompt drop: the server's own limit.
@@ -1100,6 +1110,20 @@ export type GitTarget = number | { lane: number; session?: string; source?: numb
export type GitSource = number | string;
/** `/lanes/<id>/git<sub>` plus `q` and the target's `session=` / `source=`. */
/**
* A git write, then `agenthub:git-changed` for its workspace — success or
* failure, since a refused shelve or a conflicted restore may still have
* moved files — so every view of that workspace reads again (gitChanged.ts).
*/
function requestAndAnnounce<T>(
path: string,
options: RequestInit,
laneId: number,
action: string
): Promise<T> {
return request<T>(path, options).finally(() => emitGitChanged(laneId, action));
}
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
const params = new URLSearchParams(q);
const lane = typeof target === "number" ? target : target.lane;
@@ -1228,6 +1252,21 @@ export function gitTarget(
return session ? { lane: laneId, session } : laneId;
}
/** A shelf: changes set aside by AgentHub (a git stash entry it made). */
export interface GitShelf {
/** `stash@{n}` right now — informational; writes address `sha`. */
ref: string;
/** The stash commit; what unshelve and shelf-drop take. */
sha: string;
/** The branch it was shelved on. */
branch: string;
note: string;
/** ISO time it was shelved. */
createdAt: string | null;
/** Its files; `??` = an untracked file. */
files: { path: string; status: string }[];
}
/** A file the dashboard machine's file manager holds, by its own path. */
export interface FileManagerFile {
path: string;
@@ -4397,6 +4436,12 @@ export const api = {
current: string | null;
worktrees: LaneWorktree[];
}>(gitUrl(id, "/branches")),
/**
* GET /api/lanes/:id/git/shelves — the stash entries AgentHub made
* (shelves), newest first. Other stashes are never listed.
*/
gitShelves: (id: GitTarget) =>
request<{ available: boolean; shelves: GitShelf[] }>(gitUrl(id, "/shelves")),
/**
* POST /api/lanes/:id/git/commit-message — a message for what is staged,
* written by the user's `claude` CLI (no tools, nothing persisted).
@@ -4411,7 +4456,7 @@ export const api = {
* @param id The lane id.
* @param action `fetch`, `pull` (always --ff-only), `push`, `stage`,
* `unstage`, `discard`, `commit`, `switch`, `branch-create`,
* `branch-delete` or `publish`.
* `branch-delete`, `publish`, `shelve`, `unshelve` or `shelf-drop`.
* @param body `{message, only}` for commit; `{branch, create}` for switch;
* `{branch, base}` for branch-create; `{paths}` for stage/unstage;
* `{paths, confirm: true}` for discard — which is irreversible, so the
@@ -4443,8 +4488,17 @@ export const api = {
| "revert"
| "undo-commit"
| "amend"
| "ignore",
| "ignore"
| "shelve"
| "unshelve"
| "shelf-drop",
body: {
/** switch: what happens to uncommitted changes (absent = refuse a dirty tree). */
changes?: "carry" | "shelve";
/** branch-create: also switch to the new branch, carrying the changes. */
switch?: boolean;
/** shelve, switch with changes: "shelve": a one-line note on the shelf. */
note?: string;
/** cherry-pick / revert: the commit. */
sha?: string;
url?: string;
@@ -4467,9 +4521,25 @@ export const api = {
credentials?: GitCredentialInput & { remember: boolean };
} = {}
) =>
request<{ action: string; ok: true; output: string; branch?: string; conflicts?: string[] }>(
requestAndAnnounce<{
action: string;
ok: true;
output: string;
branch?: string;
conflicts?: string[];
/** switch: the TARGET branch's shelves, to offer back. */
shelves?: GitShelf[];
/** switch with changes: "shelve": the shelf it made. */
shelved?: GitShelf;
/** shelve: the shelf it made. */
shelf?: GitShelf;
/** unshelve: true when conflicts kept the shelf. */
kept?: boolean;
}>(
gitUrl(id, `/${action}`),
{ method: "POST", body: JSON.stringify(body) }
{ method: "POST", body: JSON.stringify(body) },
typeof id === "number" ? id : id.lane,
action
),
/**
* GET /api/lanes/:id/runtime — the lane's own application stack: slot,
+48
View File
@@ -0,0 +1,48 @@
/**
* @file gitChanged.ts
* @description One window event, `agenthub:git-changed` `{laneId, action}`,
* fired after EVERY git write the dashboard sends for a workspace
* (`api.lanes.gitAction` fires it, success or failure — a failed shelve or
* a conflicted restore still moved files). Every view that shows that
* workspace's git state — the Changes panel of each repo, the header's facts
* (`useLaneGitFacts`, the rail counts included), the workspace home's branch —
* listens and reads again, so a switch made in one of them is never left
* stale in another for a poll interval. The views do not share a callback;
* this is the bus between them.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef } from "react";
export const GIT_CHANGED_EVENT = "agenthub:git-changed";
export interface GitChangedDetail {
laneId: number;
/** The git action that ran (`switch`, `commit`, `shelve`, …). */
action: string;
}
/** Tell every view of `laneId` that its git state may have moved. */
export function emitGitChanged(laneId: number, action: string): void {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent<GitChangedDetail>(GIT_CHANGED_EVENT, { detail: { laneId, action } })
);
}
/**
* Call `fn` after every git write on workspace `laneId`. The latest `fn` is
* used, so a caller may pass an inline closure without resubscribing.
*/
export function useGitChanged(laneId: number, fn: (action: string) => void): void {
const latest = useRef(fn);
latest.current = fn;
useEffect(() => {
const on = (e: Event) => {
const d = (e as CustomEvent<GitChangedDetail>).detail;
if (d && d.laneId === laneId) latest.current(d.action);
};
window.addEventListener(GIT_CHANGED_EVENT, on);
return () => window.removeEventListener(GIT_CHANGED_EVENT, on);
}, [laneId]);
}