mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
0608ffeb63
commit
a07ca77b69
23 files changed
+531
-117
No files matched your search
@@ -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("");
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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("已暂存的部分");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user