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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user