fix(git): repositories are listed once and kept until the structure changes, reads are settled and not repeated, a kernel without the route keeps the old behaviour

This commit is contained in:
esengine committed 2026-10-18 08:50:37 -07:00
1 parent 0608ffeb63
commit a07ca77b69
23 files changed
+531 -117

No files matched your search

+3 -1
View File
@@ -29,7 +29,9 @@ try {
if (!shotDir) return;
await page.mouse.move(width - 3, 995);
await page.waitForTimeout(250);
await page.locator(".workbench-explorer").first().screenshot({ path: `${shotDir}/${scheme}-${width}-${name}.png` });
const box = await page.locator(".workbench-explorer").first().boundingBox();
// At phone width the composer's toolbar is drawn over the foot of the column; the panel's own picture stops above it.
await page.screenshot({ path: `${shotDir}/${scheme}-${width}-${name}.png`, clip: { x: box.x, y: box.y, width: box.width, height: box.height - (width <= 700 ? 150 : 0) } });
};
const one = await open("");
+3 -3
View File
@@ -101,9 +101,9 @@ class BenchPort extends MockPort {
// ?git=multi is a folder of projects: three repositories, each with changes of its own.
if (query.get("git") === "multi") {
this.repoRows = [
{ dir: "api", name: "api", branch: "main", detached: false, added: 3, removed: 1, untracked: 1 },
{ dir: "web", name: "web", branch: "feature/login", detached: false, added: 5, removed: 2, untracked: 0 },
{ dir: "tools/release", name: "release", branch: "main", detached: false, added: 0, removed: 0, untracked: 0 },
{ dir: "api", name: "api", branch: "main", detached: false },
{ dir: "web", name: "web", branch: "feature/login", detached: false },
{ dir: "tools/release", name: "release", branch: "main", detached: false },
];
this.repoChanges = {
api: [
+1
View File
@@ -691,6 +691,7 @@ export const ACTIONS: UIAction[] = [
{ id: "workbench.discard-confirm", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "workbench.discard-cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.repo", kind: "view", target: "entity", proof: "interaction" },
{ id: "workbench.repos-reload", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.commit", kind: "view", target: "entity", proof: "interaction" },
{ id: "workbench.commit-file", kind: "view", target: "entity", proof: "interaction" },
{ id: "push.preview", kind: "view", target: "none", proof: "interaction" },
+1
View File
@@ -146,6 +146,7 @@ export const EN: Record<string, string> = {
"确认丢弃": "Confirm discard",
"丢弃 {path} 的改动?这个文件会回到上次提交的样子,改动无法恢复。": "Discard the change to {path}? The file goes back to how the last commit has it, and the change cannot be recovered.",
"丢弃": "Discard",
"它已暂存的部分和还没暂存的部分都会丢弃。": "Both its staged part and its unstaged part are discarded.",
"无法读取这个仓库的提交历史": "Could not read this repository's commit history",
"这不是一个提交编号": "That is not a commit id",
"这个仓库里没有这个提交": "This repository has no such commit",
+1 -1
View File
@@ -10,7 +10,7 @@ const refuse = (status: number, code: string, error: string) => new HttpError(st
// write is seen by the next read the way the kernel's would be.
export class MockGit extends MockFeedback {
// One entry is the workspace being a repository; several are a folder of projects.
repoRows: GitRepoRow[] = [{ dir: "", name: "reasonix", branch: "main", detached: false, added: 2, removed: 1, untracked: 3 }];
repoRows: GitRepoRow[] = [{ dir: "", name: "reasonix", branch: "main", detached: false }];
// The changes of each repository below the workspace; the workspace's own are MockWorkspace.changeList.
repoChanges: Record<string, WorkspaceChange[]> = {};
@@ -198,9 +198,6 @@ export interface GitRepoRow {
name: string;
branch: string;
detached: boolean;
added: number;
removed: number;
untracked: number;
}
// The workspace's repositories; truncated says a search bound ended the look early.
@@ -43,7 +43,7 @@ describe("the changes tree", () => {
});
describe("which repository a path belongs to", () => {
const row = (dir: string) => ({ dir, name: dir || "ws", branch: "main", detached: false, added: 0, removed: 0, untracked: 0 });
const row = (dir: string) => ({ dir, name: dir || "ws", branch: "main", detached: false });
it("gives a folder of projects' path to the deepest listed repository", () => {
const repos = [row("api"), row("group/tools"), row("group")];
expect(locate(repos, "api/main.go")).toEqual({ repo: "api", rel: "main.go" });
@@ -117,6 +117,7 @@ export function WorkbenchChanges({ port, changes, repo, current, onOpen, onChang
{asking === item.path && (
<div className="workbench-discard-ask" role="group" aria-label={t("确认丢弃")}>
<p>{t("丢弃 {path} 的改动?这个文件会回到上次提交的样子,改动无法恢复。", { path: item.path })}</p>
{item.staged && item.unstaged && <p>{t("它已暂存的部分和还没暂存的部分都会丢弃。")}</p>}
<div>
<button type="button" className="btn sm" data-danger="" data-action="workbench.discard-confirm" data-target={item.path} disabled={busy} onClick={() => void discard(item.path)}>{t("丢弃")}</button>
<button type="button" className="btn sm" data-weak="" data-action="workbench.discard-cancel" disabled={busy} onClick={() => setAsking("")}>{t("取消")}</button>
+21 -10
View File
@@ -1,8 +1,10 @@
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import { t } from "../i18n";
import type { AgentPort, WorkspaceChange } from "../port/port";
import type { GitRepoRow } from "../port/workspace";
import { useCommitCard } from "./CommitCard";
import { usePushCard } from "./GitPushCard";
import { useRepoChanges } from "./useRepoChanges";
import { StudioIcon } from "./StudioIcon";
import { WorkbenchChanges } from "./WorkbenchChanges";
import { WorkbenchHistory } from "./WorkbenchHistory";
@@ -13,6 +15,9 @@ interface Props {
// of projects has none of its own: each repository below it reads its own.
changes: WorkspaceChange[];
repos: GitRepoRow[];
// Why the repositories could not be listed, in words, and the way to ask again.
failure?: string;
onRetry?: () => void;
// Moves when the agent has written, so a repository the pane does not watch is read again.
wrote: number;
// The workspace-relative path whose diff is on screen, "" when none is.
@@ -28,7 +33,20 @@ const join = (repo: string, path: string) => (repo ? `${repo}/${path}` : path);
// stage, discard and commit, the push of the branch, and its history. A workspace
// that is one repository shows just that; a folder of projects shows one section per
// repository, each acting on its own.
export function WorkbenchGit({ port, changes, repos, wrote, current, onOpen, onChanged, remote }: Props) {
export function WorkbenchGit({ port, changes, repos, failure = "", onRetry, wrote, current, onOpen, onChanged, remote }: Props) {
// A list that could not be read says so, and the workspace is shown as the one
// repository it was before there was a list: the changes the pane holds are real.
if (failure) {
return (
<>
<p className="refine-error workbench-stage-error" role="alert">
{failure}
{onRetry && <button type="button" className="btn sm" data-action="workbench.repos-reload" onClick={onRetry}>{t("重新读取")}</button>}
</p>
<RepoSection port={port} repo="" changes={changes} current={current} onOpen={onOpen} onChanged={onChanged} remote={remote} />
</>
);
}
if (repos.length === 0) return null;
const several = repos.length > 1 || repos[0].dir !== "";
if (!several) {
@@ -45,14 +63,7 @@ export function WorkbenchGit({ port, changes, repos, wrote, current, onOpen, onC
function Folder({ port, row, wrote, current, onOpen, remote }: { port: AgentPort; row: GitRepoRow; wrote: number; current: string; onOpen: (path: string) => void; remote: boolean }) {
const [open, setOpen] = useState(true);
const [changes, setChanges] = useState<WorkspaceChange[]>([]);
const [again, setAgain] = useState(0);
useEffect(() => {
let live = true;
port.changes(row.dir).then((r) => live && setChanges(r.changes), () => live && setChanges([]));
return () => { live = false; };
}, [port, row.dir, wrote, again]);
const reload = useCallback(() => setAgain((n) => n + 1), []);
const { changes, reload } = useRepoChanges(port, row.dir, open, wrote);
return (
<section className="workbench-repo" data-repo={row.dir}>
<button type="button" className="workbench-repo-head" data-action="workbench.repo" data-target={row.dir} aria-expanded={open} onClick={() => setOpen(!open)}>
@@ -170,7 +170,6 @@ export function WorkbenchPanel({
const newTab = useRef<HTMLButtonElement>(null);
const toggleLauncher = () => { supersede(); setLauncher((open) => !open); };
const chooseFiles = () => { supersede(); setLauncher(false); setShowFiles(true); };
const repos = useGitRepos(port, wrote);
const toggleExplorer = () => { supersede(); setLauncher(false); setShowFiles((on) => launcher || !on); };
useOnBump(revealChanges, chooseFiles);
useOnBump(toggleFiles, toggleExplorer);
@@ -232,6 +231,7 @@ export function WorkbenchPanel({
// only as stale as the last time it asked. A delete in another application
// tells nobody here; looking back at the window is when it can have happened.
const glance = useGlance();
const gitRepos = useGitRepos(port, glance);
useEffect(() => {
if (!shown) return;
folderRequests.clear();
@@ -359,7 +359,7 @@ export function WorkbenchPanel({
const previewTab = previewable ? preview.tabFor(filePath) : undefined;
const { open: openPreview, sync: syncPreview } = preview;
const { file, draft, diff, busy, error: fileFailed, missing, setDraft, save } = useWorkbenchFiles({
port, workspaceRoot, repos, path: isWorkspaceImage(filePath) ? "" : filePath, openPaths: openFiles, shown, refresh: JSON.stringify([changeKey, glance, running, wrote]),
port, workspaceRoot, repos: gitRepos.repos, path: isWorkspaceImage(filePath) ? "" : filePath, openPaths: openFiles, shown, refresh: JSON.stringify([changeKey, glance, running, wrote]),
poll: (readable && mode === "read") || (previewable && mode === "preview"),
});
const revision = file?.revision;
@@ -709,7 +709,9 @@ export function WorkbenchPanel({
<WorkbenchGit
port={port}
changes={changes}
repos={repos}
repos={gitRepos.repos}
failure={gitRepos.failure}
onRetry={gitRepos.retry}
wrote={wrote}
current={active?.kind === "file" && mode === "diff" ? active.path : ""}
onOpen={(path) => {
@@ -12,7 +12,7 @@ import { WorkbenchGit } from "./WorkbenchGit";
afterEach(cleanup);
const mock = () => new MockPort();
const row = (dir: string, name = dir || "ws", branch = "main"): GitRepoRow => ({ dir, name, branch, detached: false, added: 0, removed: 0, untracked: 0 });
const row = (dir: string, name = dir || "ws", branch = "main"): GitRepoRow => ({ dir, name, branch, detached: false });
const NO_COMMIT = { button: null, card: null };
describe("the changes are a folder tree", () => {
@@ -0,0 +1,143 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, renderHook, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { MockPort } from "../port/mock";
import { HttpError } from "../port/http_error";
import type { AgentPort } from "../port/port";
import type { GitRepoRow } from "../port/workspace";
import { WorkbenchChanges } from "./WorkbenchChanges";
import { WorkbenchGit } from "./WorkbenchGit";
import { useGitRepos } from "./useGitRepos";
import { SETTLE_MS } from "./useRepoChanges";
afterEach(() => { cleanup(); vi.useRealTimers(); });
const row = (dir: string): GitRepoRow => ({ dir, name: dir || "ws", branch: "main", detached: false });
const flush = () => act(async () => { await Promise.resolve(); await Promise.resolve(); });
describe("the repositories are listed once, not after every tool call", () => {
it("reads when the pane opens and again only when the window is looked back at", async () => {
const port = new MockPort();
const read = vi.spyOn(port, "gitRepos");
const { rerender } = renderHook(({ again }) => useGitRepos(port as unknown as AgentPort, again), { initialProps: { again: 0 } });
await flush();
expect(read).toHaveBeenCalledTimes(1);
rerender({ again: 0 });
await flush();
expect(read).toHaveBeenCalledTimes(1);
rerender({ again: 1 });
await flush();
expect(read).toHaveBeenCalledTimes(2);
});
it("makes one request of requests that overlap", async () => {
const port = new MockPort();
let release!: () => void;
const gate = new Promise<void>((r) => { release = r; });
const read = vi.spyOn(port, "gitRepos").mockImplementation(async () => { await gate; return { repos: [row("")], truncated: false }; });
const { rerender } = renderHook(({ again }) => useGitRepos(port as unknown as AgentPort, again), { initialProps: { again: 0 } });
rerender({ again: 1 });
rerender({ again: 2 });
expect(read).toHaveBeenCalledTimes(1);
release();
await flush();
});
it("treats a kernel with no such route as the one repository it always was", async () => {
const port = new MockPort();
vi.spyOn(port, "gitRepos").mockRejectedValue(new HttpError(404, "/git/repos: 404"));
const { result } = renderHook(() => useGitRepos(port as unknown as AgentPort));
await flush();
expect(result.current.failure).toBe("");
expect(result.current.repos).toHaveLength(1);
expect(result.current.repos[0].dir).toBe("");
render(<WorkbenchGit port={port as unknown as AgentPort} changes={[{ path: "a.go", status: "M", unstaged: true }]} repos={result.current.repos} wrote={0} current="" onOpen={() => {}} onChanged={() => {}} remote={false} />);
expect(screen.getByRole("button", { name: /^M\s?a\.go/ })).toBeTruthy();
});
it("does not take a refusal for a missing route: it says why, keeps the changes shown, and can try again", async () => {
const port = new MockPort();
const gitRepos = vi.spyOn(port, "gitRepos").mockRejectedValueOnce(new HttpError(500, "x", { code: "git.repos_failed" }));
const { result } = renderHook(() => useGitRepos(port as unknown as AgentPort));
await flush();
expect(result.current.failure).toContain("无法列出");
render(<WorkbenchGit port={port as unknown as AgentPort} changes={[{ path: "a.go", status: "M", unstaged: true }]} repos={result.current.repos} failure={result.current.failure} onRetry={result.current.retry} wrote={0} current="" onOpen={() => {}} onChanged={() => {}} remote={false} />);
expect(screen.getByRole("alert").textContent).toContain("无法列出");
expect(screen.getByRole("button", { name: /^M\s?a\.go/ })).toBeTruthy();
gitRepos.mockResolvedValue({ repos: [row("")], truncated: false });
await userEvent.click(screen.getByRole("button", { name: "重新读取" }));
await flush();
expect(result.current.failure).toBe("");
});
});
describe("a repository is read once for a burst of writes, and only while it is open", () => {
beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: false }));
const draw = (port: MockPort, wrote: number) =>
render(<WorkbenchGit port={port as unknown as AgentPort} changes={[]} repos={[row("api"), row("web")]} wrote={wrote} current="" onOpen={() => {}} onChanged={() => {}} remote={false} />);
it("reads each open repository when the pane opens, then once after the writes settle", async () => {
const port = new MockPort();
port.repoChanges = { api: [], web: [] };
const read = vi.spyOn(port, "changes");
const { rerender } = draw(port, 0);
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
expect(read.mock.calls.map((c) => c[0]).sort()).toEqual(["api", "web"]);
read.mockClear();
for (const n of [1, 2, 3, 4, 5]) {
rerender(<WorkbenchGit port={port as unknown as AgentPort} changes={[]} repos={[row("api"), row("web")]} wrote={n} current="" onOpen={() => {}} onChanged={() => {}} remote={false} />);
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
}
expect(read).not.toHaveBeenCalled();
await act(async () => { await vi.advanceTimersByTimeAsync(SETTLE_MS); });
expect(read.mock.calls.map((c) => c[0]).sort()).toEqual(["api", "web"]);
});
it("does not read a collapsed repository, and reads it when it is opened", async () => {
const port = new MockPort();
port.repoChanges = { api: [], web: [] };
const read = vi.spyOn(port, "changes");
const { rerender } = draw(port, 0);
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
read.mockClear();
await act(async () => { screen.getByRole("button", { name: /^api/ }).click(); });
rerender(<WorkbenchGit port={port as unknown as AgentPort} changes={[]} repos={[row("api"), row("web")]} wrote={1} current="" onOpen={() => {}} onChanged={() => {}} remote={false} />);
await act(async () => { await vi.advanceTimersByTimeAsync(SETTLE_MS + 50); });
expect(read.mock.calls.map((c) => c[0])).toEqual(["web"]);
read.mockClear();
await act(async () => { screen.getByRole("button", { name: /^api/ }).click(); });
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
expect(read.mock.calls.map((c) => c[0])).toEqual(["api"]);
});
it("does not run two reads of one repository at once, and reads once more when the first ends", async () => {
const port = new MockPort();
let release!: () => void;
const gate = new Promise<void>((r) => { release = r; });
const read = vi.spyOn(port, "changes").mockImplementation(async () => { await gate; return { repo: true, changes: [] }; });
const { rerender } = render(<WorkbenchGit port={port as unknown as AgentPort} changes={[]} repos={[row("api")]} wrote={0} current="" onOpen={() => {}} onChanged={() => {}} remote={false} />);
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
expect(read).toHaveBeenCalledTimes(1);
rerender(<WorkbenchGit port={port as unknown as AgentPort} changes={[]} repos={[row("api")]} wrote={1} current="" onOpen={() => {}} onChanged={() => {}} remote={false} />);
await act(async () => { await vi.advanceTimersByTimeAsync(SETTLE_MS + 50); });
expect(read).toHaveBeenCalledTimes(1);
release();
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
expect(read).toHaveBeenCalledTimes(2);
});
});
describe("discarding a partly staged file says both parts go", () => {
it("adds the line only when the file is both staged and unstaged", async () => {
const port = new MockPort() as unknown as AgentPort;
render(<WorkbenchChanges port={port} changes={[{ path: "both.go", status: "M", staged: true, unstaged: true }, { path: "one.go", status: "M", unstaged: true }]} current="" onOpen={() => {}} onChanged={() => {}} commit={{ button: null, card: null }} />);
await userEvent.click(screen.getByRole("button", { name: "丢弃 both.go 的改动" }));
expect(screen.getByRole("group", { name: "确认丢弃" }).textContent).toContain("已暂存的部分和还没暂存的部分都会丢弃");
await userEvent.click(screen.getByRole("button", { name: "取消" }));
await userEvent.click(screen.getByRole("button", { name: "丢弃 one.go 的改动" }));
expect(screen.getByRole("group", { name: "确认丢弃" }).textContent).not.toContain("已暂存的部分");
});
});
+37 -11
View File
@@ -1,16 +1,42 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { reason } from "../i18n/kernel";
import { HttpError } from "../port/http_error";
import type { AgentPort } from "../port/port";
import type { GitRepoRow } from "../port/workspace";
export interface GitRepoList {
// Empty until answered, and empty for a workspace git knows nothing about,
// which is not a failure.
repos: GitRepoRow[];
// Why the list could not be read, in words; "" when it could.
failure: string;
retry: () => void;
}
// A kernel that predates /git/repos has no such route: a plain 404 or 501 with no
// refusal code. That is a version, not a fault, and it keeps every older answer
// working as it did: the workspace is one repository.
const OLD_KERNEL: GitRepoRow[] = [{ dir: "", name: "", branch: "", detached: false }];
const noRoute = (e: unknown) => e instanceof HttpError && (e.status === 404 || e.status === 501) && !e.reason?.code;
// The workspace's repositories as the kernel lists them: itself when it is one,
// otherwise those found below it. Empty until answered, and empty for a workspace
// git knows nothing about, which is not a failure. Read again when `again` moves.
export function useGitRepos(port: AgentPort, again = 0): GitRepoRow[] {
const [repos, setRepos] = useState<GitRepoRow[]>([]);
useEffect(() => {
let live = true;
port.gitRepos().then((r) => live && setRepos(r.repos), () => live && setRepos([]));
return () => { live = false; };
}, [port, again]);
return repos;
// otherwise those found below it. Read when the pane opens and whenever `again`
// moves (the window looked back at), not after every tool call: which
// repositories exist almost never changes, and the kernel answers from what it
// kept while it has not. Requests that overlap are one request.
export function useGitRepos(port: AgentPort, again = 0): GitRepoList {
const [state, setState] = useState<{ repos: GitRepoRow[]; failure: string }>({ repos: [], failure: "" });
const flying = useRef(false);
const live = useRef(true);
useEffect(() => () => { live.current = false; }, []);
const read = useCallback(() => {
if (flying.current) return;
flying.current = true;
port.gitRepos().then(
(r) => live.current && setState({ repos: r.repos, failure: "" }),
(e) => live.current && setState(noRoute(e) ? { repos: OLD_KERNEL, failure: "" } : { repos: [], failure: reason(e) }),
).finally(() => { flying.current = false; });
}, [port]);
useEffect(() => { read(); }, [read, again]);
return { ...state, retry: read };
}
@@ -0,0 +1,54 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { AgentPort, WorkspaceChange } from "../port/port";
// How long writes are let to pile up before a repository is read: one agent turn
// is dozens of tool calls, and the repository is read once for all of them.
export const SETTLE_MS = 700;
// The changes of one repository below the workspace. A repository that is
// collapsed is not read at all, and is read the moment it is opened; the first
// read and a read the person asked for (reload) are immediate; reads that the
// agent's writes ask for wait until the writes settle. A read asked for while one
// is running is made once more when that one ends, never alongside it.
export function useRepoChanges(port: AgentPort, repo: string, open: boolean, wrote: number) {
const [changes, setChanges] = useState<WorkspaceChange[]>([]);
const [asked, setAsked] = useState(0);
const flying = useRef(false);
const again = useRef(false);
const live = useRef(true);
const immediate = useRef(true);
useEffect(() => () => { live.current = false; }, []);
const read = useCallback(() => {
if (flying.current) {
again.current = true;
return;
}
flying.current = true;
port.changes(repo).then(
(r) => live.current && setChanges(r.changes),
() => live.current && setChanges([]),
).finally(() => {
flying.current = false;
if (again.current && live.current) {
again.current = false;
read();
}
});
}, [port, repo]);
useEffect(() => { if (open) immediate.current = true; }, [open]);
useEffect(() => {
if (!open) return;
const wait = immediate.current ? 0 : SETTLE_MS;
immediate.current = false;
const timer = setTimeout(read, wait);
return () => clearTimeout(timer);
}, [open, wrote, asked, read]);
const reload = useCallback(() => {
immediate.current = true;
setAsked((n) => n + 1);
}, []);
return { changes, reload };
}
+11 -10
View File
@@ -6,6 +6,7 @@ import (
"errors"
"net/http"
"strconv"
"sync"
"reasonix/internal/platform/gitcmd"
"reasonix/internal/platform/gitrepos"
@@ -181,10 +182,6 @@ type gitRepoRow struct {
Name string `json:"name"`
Branch string `json:"branch"`
Detached bool `json:"detached"`
Added int `json:"added"`
Removed int `json:"removed"`
// Untracked counts files git does not know yet.
Untracked int `json:"untracked"`
}
// gitReposView is the repositories of the workspace.
@@ -193,8 +190,8 @@ type gitReposView struct {
Truncated bool `json:"truncated"`
}
// gitRepos lists the repositories of the workspace with the one-line identity of
// each: the workspace itself when it is a repository, otherwise the repositories
// gitRepos lists the repositories of the workspace with the identity of each,
// which branch and no counts (a heading is all that is drawn from it): the workspace itself when it is a repository, otherwise the repositories
// found below it. Truncated says a search bound ended the look early.
func (s *Server) gitRepos(w http.ResponseWriter, r *http.Request) {
ctl, ok := s.ctl().(interface {
@@ -209,10 +206,14 @@ func (s *Server) gitRepos(w http.ResponseWriter, r *http.Request) {
refuse(w, http.StatusInternalServerError, "git.repos_failed", err.Error(), nil)
return
}
rows := make([]gitRepoRow, 0, len(found.Repos))
for _, f := range found.Repos {
info, _ := gitstatus.Summary(r.Context(), f.Repo)
rows = append(rows, gitRepoRow{Dir: f.Dir, Name: info.Name, Branch: info.Branch, Detached: info.Detached, Added: info.Added, Removed: info.Removed, Untracked: info.Untracked})
rows := make([]gitRepoRow, len(found.Repos))
var reads sync.WaitGroup
for i, f := range found.Repos {
reads.Go(func() {
info, _ := gitstatus.Identity(r.Context(), f.Repo)
rows[i] = gitRepoRow{Dir: f.Dir, Name: info.Name, Branch: info.Branch, Detached: info.Detached}
})
}
reads.Wait()
writeJSON(w, gitReposView{Repos: rows, Truncated: found.Truncated})
}
+89
View File
@@ -0,0 +1,89 @@
package gitrepos
import (
"context"
"os"
"sync"
"time"
)
// maxWatched bounds how many directories a cached answer is guarded by; a search
// that met more than this is not cached and is done again each time.
const maxWatched = 2000
type watch struct {
exists bool
mod time.Time
}
// Finder answers Discover for one workspace and keeps the answer until the
// workspace's structure changes. The structure is the directories the search
// met: a repository appearing or going away changes the modification time of a
// directory the search read or listed, and one stat of each is far cheaper than
// asking git about every candidate again.
type Finder struct {
mu sync.Mutex
root string
result Result
watched map[string]watch
present map[string]bool
valid bool
runs int // how many times the search itself ran, for the tests
}
// Find is Discover through the cache. It returns the cached Result while every
// watched directory is as it was.
func (f *Finder) Find(ctx context.Context, root string) (Result, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.valid && f.root == root && f.unchanged() {
return f.result, nil
}
f.runs++
res, err := Discover(ctx, root)
if err != nil {
f.valid = false
return Result{}, err
}
f.root, f.result, f.valid = root, res, false
if len(res.watch)+len(res.present) > 0 && len(res.watch)+len(res.present) <= maxWatched {
f.watched = snapshot(res.watch)
f.present = map[string]bool{}
for _, p := range res.present {
f.present[p] = stamp(p).exists
}
f.valid = true
}
return res, nil
}
func (f *Finder) unchanged() bool {
for p, was := range f.present {
if stamp(p).exists != was {
return false
}
}
for p, was := range f.watched {
now := stamp(p)
if now.exists != was.exists || !now.mod.Equal(was.mod) {
return false
}
}
return true
}
func stamp(p string) watch {
info, err := os.Lstat(p)
if err != nil {
return watch{}
}
return watch{exists: true, mod: info.ModTime()}
}
func snapshot(paths []string) map[string]watch {
out := make(map[string]watch, len(paths))
for _, p := range paths {
out[p] = stamp(p)
}
return out
}
+24 -59
View File
@@ -6,7 +6,6 @@ import (
"os"
"path/filepath"
"sort"
"strings"
"reasonix/internal/platform/gitcmd"
)
@@ -33,6 +32,23 @@ type Found struct {
type Result struct {
Repos []Found
Truncated bool
// What a Finder compares to know the answer still holds: the directories whose
// entries decided it (by modification time), and each repository's .git (by
// being there). Work inside a repository changes neither.
watch []string
present []string
}
// Find is the repository listed under dir, the folder below the workspace as
// Repos names it, or ErrNotListed. Nothing is built from dir: it is matched
// against what the search found.
func (r Result) Find(dir string) (gitcmd.Repo, error) {
for _, f := range r.Repos {
if f.Dir == dir {
return f.Repo, nil
}
}
return gitcmd.Repo{}, ErrNotListed
}
// Discover lists the repositories of the workspace at root. A workspace that is
@@ -46,7 +62,7 @@ func Discover(ctx context.Context, root string) (Result, error) {
return Result{}, err
}
if repo, err := gitcmd.Open(ctx, abs); err == nil {
return Result{Repos: []Found{{Repo: repo}}}, nil
return Result{Repos: []Found{{Repo: repo}}, watch: []string{abs}, present: []string{filepath.Join(abs, ".git")}}, nil
} else if ctx.Err() != nil {
return Result{}, ctx.Err()
}
@@ -63,6 +79,7 @@ func Discover(ctx context.Context, root string) (Result, error) {
if cur.depth >= MaxDepth {
continue
}
out.watch = append(out.watch, cur.dir)
entries, err := os.ReadDir(cur.dir)
if err != nil {
continue
@@ -81,6 +98,7 @@ func Discover(ctx context.Context, root string) (Result, error) {
}
child := filepath.Join(cur.dir, e.Name())
if repo, ok := topOfItsOwnTree(ctx, child); ok {
out.present = append(out.present, filepath.Join(child, ".git"))
if len(out.Repos) >= MaxRepos {
out.Truncated = true
continue
@@ -89,6 +107,7 @@ func Discover(ctx context.Context, root string) (Result, error) {
out.Repos = append(out.Repos, Found{Dir: filepath.ToSlash(rel), Repo: repo})
continue
}
out.watch = append(out.watch, child)
queue = append(queue, level{child, cur.depth + 1})
}
}
@@ -114,61 +133,7 @@ func topOfItsOwnTree(ctx context.Context, dir string) (gitcmd.Repo, bool) {
return repo, true
}
// ErrNotListed is returned for a repository name Discover would not have found:
// outside the workspace, deeper than MaxDepth, behind a link, not the top of
// its own work tree, or named when the workspace is itself a repository.
// ErrNotListed is returned for a repository name the search did not find: outside
// the workspace, deeper than MaxDepth, behind a link, not the top of its own work
// tree, or named when the workspace is itself a repository.
var ErrNotListed = errors.New("gitrepos: not a repository of this workspace")
// Resolve answers the repository a request named, by the same test Discover
// applies and never by trusting the text: dir is a slash-separated path below
// root, at most MaxDepth deep, with no link along it. An empty dir is the
// workspace's own repository, if it is one.
func Resolve(ctx context.Context, root, dir string) (gitcmd.Repo, error) {
abs, err := filepath.Abs(root)
if err != nil {
return gitcmd.Repo{}, err
}
if dir == "" {
repo, err := gitcmd.Open(ctx, abs)
if err != nil {
return gitcmd.Repo{}, errors.Join(ErrNotListed, err)
}
return repo, nil
}
if _, err := gitcmd.Open(ctx, abs); err == nil {
return gitcmd.Repo{}, ErrNotListed
}
parts := strings.Split(dir, "/")
if len(parts) > MaxDepth || !filepath.IsLocal(filepath.FromSlash(dir)) {
return gitcmd.Repo{}, ErrNotListed
}
at := abs
for _, p := range parts {
if p == "" || p == "." || p == ".." {
return gitcmd.Repo{}, ErrNotListed
}
// The name is taken from the directory's own listing, never built from the
// request: only an entry that is there, is a directory and is not a link
// can be stepped into.
entries, err := os.ReadDir(at)
if err != nil {
return gitcmd.Repo{}, ErrNotListed
}
next := ""
for _, e := range entries {
if e.Name() == p && e.IsDir() && e.Type()&os.ModeSymlink == 0 {
next = e.Name()
break
}
}
if next == "" {
return gitcmd.Repo{}, ErrNotListed
}
at = filepath.Join(at, next)
}
repo, ok := topOfItsOwnTree(ctx, at)
if !ok {
return gitcmd.Repo{}, ErrNotListed
}
return repo, nil
}
+71 -11
View File
@@ -8,6 +8,7 @@ import (
"os/exec"
"path/filepath"
"testing"
"time"
"reasonix/internal/base/testenv"
)
@@ -123,7 +124,7 @@ func TestTheNumberOfRepositoriesKeptIsBounded(t *testing.T) {
}
}
func TestResolveAnswersOnlyWhatDiscoverWouldHaveFound(t *testing.T) {
func TestFindAnswersOnlyWhatTheSearchFound(t *testing.T) {
root, elsewhere := testenv.TempDir(t), testenv.TempDir(t)
initRepo(t, filepath.Join(root, "api"))
initRepo(t, filepath.Join(root, "group", "tools"))
@@ -132,28 +133,87 @@ func TestResolveAnswersOnlyWhatDiscoverWouldHaveFound(t *testing.T) {
if err := os.Symlink(filepath.Join(elsewhere, "outside"), filepath.Join(root, "link")); err != nil {
t.Skip("symlinks unavailable:", err)
}
ctx := context.Background()
found, err := Discover(context.Background(), root)
if err != nil {
t.Fatal(err)
}
for _, ok := range []string{"api", "group/tools"} {
if repo, err := Resolve(ctx, root, ok); err != nil || !repo.Valid() {
t.Errorf("Resolve(%q) = %v, %v; want its repository", ok, repo.WorkTree, err)
if repo, err := found.Find(ok); err != nil || !repo.Valid() {
t.Errorf("Find(%q) = %v, %v; want its repository", ok, repo.WorkTree, err)
}
}
for _, bad := range []string{"", "..", "../" + filepath.Base(elsewhere) + "/outside", "/etc", "a/b/deep", "link", "api/..", "api//x", "group", "missing", "./api", "api/sub/x"} {
if _, err := Resolve(ctx, root, bad); !errors.Is(err, ErrNotListed) {
t.Errorf("Resolve(%q) err = %v, want ErrNotListed", bad, err)
for _, bad := range []string{"", "..", "../" + filepath.Base(elsewhere) + "/outside", "/etc", "a/b/deep", "link", "api/..", "api//x", "group", "missing", "./api", "api/sub/x", `api\x`, `..\api`} {
if _, err := found.Find(bad); !errors.Is(err, ErrNotListed) {
t.Errorf("Find(%q) err = %v, want ErrNotListed", bad, err)
}
}
}
func TestResolveNamesNoChildWhenTheWorkspaceIsTheRepository(t *testing.T) {
func TestFindNamesNoChildWhenTheWorkspaceIsTheRepository(t *testing.T) {
root := testenv.TempDir(t)
initRepo(t, root)
initRepo(t, filepath.Join(root, "inner"))
ctx := context.Background()
if _, err := Resolve(ctx, root, ""); err != nil {
found, err := Discover(context.Background(), root)
if err != nil {
t.Fatal(err)
}
if _, err := found.Find(""); err != nil {
t.Fatalf("the workspace's own repository: %v", err)
}
if _, err := Resolve(ctx, root, "inner"); !errors.Is(err, ErrNotListed) {
if _, err := found.Find("inner"); !errors.Is(err, ErrNotListed) {
t.Fatalf("a child of a repository workspace = %v, want ErrNotListed", err)
}
}
func TestTheSearchIsKeptUntilTheWorkspaceStructureChanges(t *testing.T) {
root := testenv.TempDir(t)
initRepo(t, filepath.Join(root, "api"))
var f Finder
ctx := context.Background()
first, err := f.Find(ctx, root)
if err != nil || len(first.Repos) != 1 {
t.Fatalf("first = %v, %v", dirsOf(first), err)
}
if _, err := f.Find(ctx, root); err != nil || f.runs != 1 {
t.Fatalf("an unchanged workspace searched again: runs = %d (%v)", f.runs, err)
}
// Work inside a repository is not a change of the structure.
if err := os.WriteFile(filepath.Join(root, "api", "main.go"), []byte("package a\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := f.Find(ctx, root); err != nil || f.runs != 1 {
t.Fatalf("a file written in a repository made the search run again: runs = %d", f.runs)
}
time.Sleep(20 * time.Millisecond)
initRepo(t, filepath.Join(root, "web"))
again, err := f.Find(ctx, root)
if err != nil || f.runs != 2 || fmt.Sprint(dirsOf(again)) != "[api web]" {
t.Fatalf("a new repository was not found: runs = %d, repos = %v, %v", f.runs, dirsOf(again), err)
}
time.Sleep(20 * time.Millisecond)
initRepo(t, filepath.Join(root, "group", "tools"))
deep, err := f.Find(ctx, root)
if err != nil || f.runs != 3 || fmt.Sprint(dirsOf(deep)) != "[api group/tools web]" {
t.Fatalf("a new repository two levels down was not found: runs = %d, repos = %v, %v", f.runs, dirsOf(deep), err)
}
time.Sleep(20 * time.Millisecond)
if err := os.RemoveAll(filepath.Join(root, "web")); err != nil {
t.Fatal(err)
}
gone, err := f.Find(ctx, root)
if err != nil || f.runs != 4 || fmt.Sprint(dirsOf(gone)) != "[api group/tools]" {
t.Fatalf("a removed repository is still listed: runs = %d, repos = %v, %v", f.runs, dirsOf(gone), err)
}
}
func TestACachedSearchIsOnlyForTheWorkspaceItWasMadeFor(t *testing.T) {
a, b := testenv.TempDir(t), testenv.TempDir(t)
initRepo(t, filepath.Join(a, "one"))
initRepo(t, filepath.Join(b, "two"))
var f Finder
ra, _ := f.Find(context.Background(), a)
rb, _ := f.Find(context.Background(), b)
if fmt.Sprint(dirsOf(ra)) != "[one]" || fmt.Sprint(dirsOf(rb)) != "[two]" || f.runs != 2 {
t.Fatalf("a = %v, b = %v, runs = %d", dirsOf(ra), dirsOf(rb), f.runs)
}
}
+10 -1
View File
@@ -4,7 +4,9 @@ import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
@@ -116,7 +118,14 @@ func commitBase(ctx context.Context, repo gitcmd.Repo, id string) (string, error
}
top := repo.Top()
if err := top.Command(ctx, "rev-parse", "--verify", "--quiet", id+"^{commit}").Run(); err != nil {
return "", ErrNoSuchCommit
// With --quiet git answers an id that names no commit with exit status 1 and
// nothing else; any other failure is git not working, which says nothing
// about the commit.
var exit *exec.ExitError
if errors.As(err, &exit) && exit.ExitCode() == 1 {
return "", ErrNoSuchCommit
}
return "", fmt.Errorf("gitstatus: resolve commit: %w", err)
}
if out, err := top.Command(ctx, "rev-parse", "--verify", "--quiet", id+"^1").Output(); err == nil {
return strings.TrimSpace(string(out)), nil
@@ -147,3 +147,18 @@ func TestACommitOfAFolderThatIsNotARepositoryAnswersNotOK(t *testing.T) {
t.Fatalf("ok=%v err=%v, want a quiet not-a-repository", ok, err)
}
}
func TestAGitThatCannotAnswerIsNotAMissingCommit(t *testing.T) {
dir := testenv.TempDir(t)
git(t, dir, "init", "-q")
write(t, dir, "a.go", "package a\n")
git(t, dir, "add", ".")
git(t, dir, "commit", "-qm", "base")
repo := opened(t, dir)
id := head(t, dir)
write(t, filepath.Join(dir, ".git"), "HEAD", "garbage that is not a ref\n")
_, _, _, err := CommitFiles(context.Background(), repo, id)
if err == nil || errors.Is(err, ErrNoSuchCommit) {
t.Fatalf("err = %v, want git's own failure and not ErrNoSuchCommit", err)
}
}
+17
View File
@@ -59,6 +59,23 @@ func Summary(ctx context.Context, repo gitcmd.Repo) (Info, bool) {
return info, true
}
// Identity is the part of Info that costs one process: where the work tree is
// and which branch it is on, with no counts. A caller that only names a
// repository (a heading, a list of them) reads this and leaves the diff to the
// repository that is open.
func Identity(ctx context.Context, repo gitcmd.Repo) (Info, bool) {
if !repo.Valid() {
return Info{}, false
}
top := repo.Top()
info := Info{Name: filepath.Base(top.WorkTree)}
info.Branch, info.Detached = branchOf(ctx, top)
if ctx.Err() != nil {
return Info{}, false
}
return info, true
}
func branchOf(ctx context.Context, top gitcmd.Repo) (name string, detached bool) {
if out, err := top.Command(ctx, "symbolic-ref", "--quiet", "HEAD").Output(); err == nil {
if name = strings.TrimSpace(string(out)); name != "" {
@@ -13,6 +13,7 @@ import (
"reasonix/internal/ext/plugin"
"reasonix/internal/ext/skill"
"reasonix/internal/model/billing"
"reasonix/internal/platform/gitrepos"
"reasonix/internal/runtime/agent"
"reasonix/internal/runtime/goaleval"
"reasonix/internal/runtime/guardian"
@@ -49,6 +50,8 @@ type controllerDeps struct {
committer CommitDrafter
// commitMu serialises commits so two confirmations cannot interleave.
commitMu sync.Mutex
// repos keeps the workspace's repositories until its structure changes.
repos gitrepos.Finder
// goalUsageTee accounts billable usage events into the active goal turn's
// observational token total. It wraps the public sink when the caller didn't provide one.
goalUsageTee *goalUsageTee
+19 -2
View File
@@ -10,7 +10,7 @@ import (
// GitRepos lists the repositories of the workspace: the workspace itself when it
// is one, otherwise the repositories found below it within the search bounds.
func (c *Controller) GitRepos(ctx context.Context) (gitrepos.Result, error) {
return gitrepos.Discover(ctx, c.WorkspaceRoot())
return c.repos.Find(ctx, c.WorkspaceRoot())
}
// RepoFor is the repository a request named by its directory below the workspace,
@@ -21,5 +21,22 @@ func (c *Controller) RepoFor(ctx context.Context, dir string) (gitcmd.Repo, erro
if dir == "" {
return c.WorkspaceRepo(), nil
}
return gitrepos.Resolve(ctx, c.WorkspaceRoot(), dir)
found, err := c.repos.Find(ctx, c.WorkspaceRoot())
if err != nil {
return gitcmd.Repo{}, err
}
repo, err := found.Find(dir)
if err != nil {
return gitcmd.Repo{}, err
}
// A repository that was there when the answer was kept can have been replaced
// in place; its identity is checked against the files before it is used.
if repo.Verify() != nil {
fresh, err := gitrepos.Discover(ctx, c.WorkspaceRoot())
if err != nil {
return gitcmd.Repo{}, err
}
return fresh.Find(dir)
}
return repo, nil
}