feat(studio): stage and unstage changes from the changes list

The commit button only records what is staged, and Studio had no way to stage:
an empty index sent people to git add. The changes list now shows each file as
staged, partly staged or not, ticks and unticks them, and stages everything at
once; the kernel accepts only paths git itself lists.
This commit is contained in:
esengine committed 2026-10-13 02:38:38 -07:00
1 parent a41ce9f980
commit 40e097879d
23 files changed
+695 -46

No files matched your search

+1
View File
@@ -18,6 +18,7 @@ import "../src/styles/phonecomposer.css";
import "../src/styles/stophint.css";
import "../src/styles/dialogcenter.css";
import "../src/styles/treerows.css";
import "../src/styles/workbench-changes.css";
import "../src/styles/vthumb.css";
import "../src/styles/tips.css";
import "../src/styles/waitnote.css";
+2
View File
@@ -137,6 +137,8 @@ export const ACTIONS: UIAction[] = [
// Drafting reads the index and asks the model; only confirm records a commit,
// and only locally.
{ id: "commit.draft", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "workbench.stage", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "workbench.stage-all", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "commit.edit", kind: "view", target: "none", proof: "interaction" },
{ id: "commit.acknowledge", kind: "view", target: "none", proof: "interaction" },
{ id: "commit.confirm", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
+8 -2
View File
@@ -1408,8 +1408,14 @@ export const EN: Record<string, string> = {
"回合运行中,等它结束后再分支": "A turn is running; branch after it ends",
"这个位置无法分支(重启后恢复的会话,或已被压缩的历史)": "This point cannot be branched (a session restored after a restart, or history that was compacted)",
"分支失败,请重试": "Branching failed; try again",
"暂存区是空的,先用 git add 暂存要提交的文件":
"Nothing is staged; stage the files to commit with git add first",
"暂存区是空的:先在改动列表里勾选要提交的文件,或点「暂存全部」":
"Nothing is staged: tick the files to commit in the changes list, or press Stage all",
"这个文件已经不在改动列表里了,请刷新后再试": "That file is no longer in the changes list; refresh and try again",
"暂存请求无法识别": "The stage request was not understood",
"暂存 {path}": "Stage {path}",
"暂存全部": "Stage all",
"取消全部暂存": "Unstage all",
"暂存失败,请重试": "Staging failed; try again",
"暂存区在你确认之后又变了,请重新起草":
"The staged changes changed after you reviewed them; draft again",
"暂存的内容里有疑似密钥的文件,需要你确认后才能提交":
+4 -1
View File
@@ -256,7 +256,10 @@ const SAID: Record<string, string> = {
"conversation_branch.failed": "分支失败,请重试",
"commit.no_repository": "这个工作区不是 git 仓库,无法提交",
"commit.nothing_staged": "暂存区是空的,先用 git add 暂存要提交的文件",
"commit.nothing_staged": "暂存区是空的:先在改动列表里勾选要提交的文件,或点「暂存全部」",
"stage.unknown_path": "这个文件已经不在改动列表里了,请刷新后再试",
"stage.bad_request": "暂存请求无法识别",
"stage.failed": "暂存失败,请重试",
"commit.staged_changed": "暂存区在你确认之后又变了,请重新起草",
"commit.secrets_staged": "暂存的内容里有疑似密钥的文件,需要你确认后才能提交",
"commit.empty_message": "提交说明是空的",
+1
View File
@@ -30,6 +30,7 @@ import "./styles/sidebardrop.css";
import "./styles/sessionquick.css";
import "./styles/sessionpin.css";
import "./styles/treerows.css";
import "./styles/workbench-changes.css";
import { App } from "./ui/App";
import { SseHub } from "./port/hub";
import { HttpError } from "./port/http_error";
@@ -1,5 +1,5 @@
import { HttpError } from "./http_error";
import type { ChangeDiff, WorkspaceChanges, WorkspaceGit, WorkspaceBranches } from "./workspace";
import type { ChangeDiff, StageRequest, WorkspaceChange, WorkspaceChanges, WorkspaceGit, WorkspaceBranches } from "./workspace";
import { MockFeedback } from "./mock_feedback";
export class MockWorkspace extends MockFeedback {
@@ -28,15 +28,25 @@ export class MockWorkspace extends MockFeedback {
}
// The scripted transcript's tree facts, without a repository behind the fixture.
// Held in a field so staging is visible to the next read, as the index is.
changeList: WorkspaceChange[] = [
{ path: "internal/provider/retry.go", status: "M", unstaged: true },
{ path: "internal/config/credentials.go", status: "M", staged: true },
{ path: "internal/provider/openai/streaming/chunk_decoder.go", status: "A", staged: true, unstaged: true },
{ path: "docs/notes.md", status: "??", unstaged: true },
];
async changes(): Promise<WorkspaceChanges> {
return {
repo: true,
changes: [
{ path: "internal/provider/retry.go", status: "M" },
{ path: "internal/config/credentials.go", status: "M" },
{ path: "internal/provider/openai/streaming/chunk_decoder.go", status: "A" },
],
};
return { repo: true, changes: this.changeList.map((c) => ({ ...c })) };
}
async stageChanges(req: StageRequest): Promise<void> {
const named = new Set(req.paths ?? []);
this.changeList = this.changeList.map((c) =>
req.all || named.has(c.path)
? { ...c, staged: !req.unstage, unstaged: !!req.unstage }
: c,
);
}
// Scripted, because there is no tree to read: enough of a diff for the
+5 -2
View File
@@ -23,7 +23,7 @@ import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, Br
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
import type { SkillCatalog, SkillEntry } from "./skill";
import type { StudioCrash, StudioLogs, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo } from "./workspace";
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo } from "./workspace";
// The port is one contract; its subjects each keep their own file, the way the
// wire and the layers below already do. This is where a reader still finds
@@ -35,7 +35,7 @@ export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, Approval
MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
RewindResult, RewindUndo, RewindScope, RoleAssignments, RoleOverride, ScopeLayer, SessionEntry, SessionStatus,
ShellOption, ShellSettings, SkillCatalog, SkillEntry, UpdateProgress, VersionEntry,
StudioCrash, StudioLogs, VersionHub, VersionNotes, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo };
StudioCrash, StudioLogs, VersionHub, VersionNotes, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo };
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
@@ -500,6 +500,9 @@ export interface AgentPort {
// the text, and commitStaged records it locally once they confirm.
proposeCommit(signal?: AbortSignal): Promise<CommitProposal>;
commitStaged(req: CommitRequest): Promise<CommitResult>;
// Moves changes the workspace lists into or out of the index, so a commit has
// something to record without a terminal.
stageChanges(req: StageRequest): Promise<void>;
// Dot entries are left out unless hidden is set; VCS stores never appear.
workspaceFiles(path?: string, query?: string, hidden?: boolean): Promise<WorkspaceFiles>;
workspaceFile(path: string): Promise<WorkspaceFile>;
@@ -1,4 +1,4 @@
import type { ChangeDiff, WorkspaceBranches, WorkspaceChanges, WorkspaceGit } from "./port";
import type { ChangeDiff, StageRequest, WorkspaceBranches, WorkspaceChanges, WorkspaceGit } from "./port";
import { SseFeedback } from "./sse_feedback";
// The workspace's tree facts: what it differs by and which branch git says it
@@ -9,6 +9,10 @@ export class SseWorkspace extends SseFeedback {
return this.get<WorkspaceChanges>("/changes");
}
stageChanges(req: StageRequest) {
return this.post("/changes/stage", req);
}
workspaceGit() {
return this.get<WorkspaceGit>("/workspace/git");
}
@@ -4,6 +4,10 @@ export interface WorkspaceChange {
oldPath?: string;
// git porcelain XY, trimmed: "M", "A", "D", "R", "??".
status: string;
// The index differs from HEAD for this path / the work tree differs from the
// index (or the path is untracked). One path can be both. Absent is false.
staged?: boolean;
unstaged?: boolean;
// How much the file differs by. Absent is "git did not say" — a binary file,
// or one nothing counted — which is not the same as changed by nothing.
insertions?: number;
@@ -112,6 +116,14 @@ export interface CommitRequest {
acknowledgeSecrets: boolean;
}
// POST /changes/stage. Paths are changes the workspace lists; all means every
// listed change. Unstage moves them out of the index and keeps their content.
export interface StageRequest {
paths?: string[];
all?: boolean;
unstage?: boolean;
}
export interface CommitResult {
hash: string;
subject: string;
@@ -0,0 +1,13 @@
.workbench-changes > .workbench-change { flex: none; display: flex; align-items: center; }
.workbench-change .workbench-change-row { flex: 1; min-width: 0; width: auto; }
.workbench-stage { flex: none; width: 16px; height: 16px; margin-inline: 8px 0; padding: 0; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 4px; background: none; color: transparent; cursor: pointer; transition: background var(--m-line), border-color var(--m-line); }
.workbench-stage svg { width: 11px; height: 11px; stroke-width: 3; }
.workbench-stage:hover:not(:disabled) { border-color: var(--text); }
.workbench-stage[aria-checked="true"] { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
.workbench-stage[aria-checked="mixed"] { border-color: var(--accent); color: var(--accent); }
.workbench-stage[aria-checked="mixed"] svg { display: none; }
.workbench-stage[aria-checked="mixed"]::after { content: ""; width: 8px; height: 2px; border-radius: 1px; background: currentColor; }
.workbench-stage:disabled { opacity: .5; cursor: default; }
.workbench-stage:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.workbench-explorer-head .workbench-stage-all { height: 20px; margin-inline: 8px; padding: 0 8px; font-size: 11px; }
.workbench-stage-error { margin: 4px 10px; }
@@ -0,0 +1,84 @@
import { useState, type ReactNode } from "react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { AgentPort, StageRequest, WorkspaceChange } from "../port/port";
import { StudioIcon } from "./StudioIcon";
interface Props {
port: AgentPort;
changes: WorkspaceChange[];
// The path whose diff is on screen, "" when none is.
current: string;
onOpen: (path: string) => void;
// Asked for when staging changed the index. Absent means this surface cannot
// refresh the list, so it offers no staging either.
onChanged?: () => void;
commit: { button: ReactNode; card: ReactNode };
}
const mark = (c: WorkspaceChange): boolean | "mixed" => (c.staged && c.unstaged ? "mixed" : !!c.staged);
// What differs, without opening anything, and which of it the next commit will
// record: the index is what a commit reads, so it is shown and changed here.
export function WorkbenchChanges({ port, changes, current, onOpen, onChanged, commit }: Props) {
const [busy, setBusy] = useState(false);
const [failure, setFailure] = useState("");
const settled = changes.length > 0 && changes.every((c) => c.staged && !c.unstaged);
const run = async (req: StageRequest) => {
setBusy(true);
setFailure("");
try {
await port.stageChanges(req);
onChanged?.();
} catch (e) {
setFailure(reason(e));
} finally {
setBusy(false);
}
};
return (
<div className="workbench-changes">
<div className="workbench-explorer-head">
<span>{t("改动")}</span>
<small>{changes.length}</small>
{onChanged && (
<button type="button" className="btn sm workbench-stage-all" data-action="workbench.stage-all" disabled={busy}
onClick={() => void run({ all: true, unstage: settled })}>
{t(settled ? "取消全部暂存" : "暂存全部")}
</button>
)}
{onChanged && commit.button}
</div>
{commit.card}
{failure && <p className="refine-error workbench-stage-error" role="alert">{failure}</p>}
{changes.map((item) => (
<div className="workbench-change" key={`c:${item.path}`} data-staged={item.staged ? "" : undefined}>
{onChanged && (
<button type="button" role="checkbox" className="workbench-stage" data-action="workbench.stage" data-target={item.path}
aria-checked={mark(item)} aria-label={t("暂存 {path}", { path: item.path })} disabled={busy}
onClick={() => void run({ paths: [item.path], unstage: item.staged === true && !item.unstaged })}>
<StudioIcon name="check" aria-hidden="true" />
</button>
)}
<button
className="workbench-change-row"
data-action="workbench.diff"
data-target={item.path}
title={item.path}
aria-current={current === item.path ? "page" : undefined}
onClick={() => onOpen(item.path)}
>
<em data-status={item.status}>{item.status || "M"}</em>
<span>{item.path.slice(item.path.lastIndexOf("/") + 1)}</span>
{/* Absent counts print nothing: a binary file did not change
by zero lines, git simply did not count it. */}
{item.insertions !== undefined && <i data-io="up">+{item.insertions}</i>}
{item.deletions !== undefined && <i data-io="down">-{item.deletions}</i>}
</button>
</div>
))}
</div>
);
}
+12 -29
View File
@@ -24,6 +24,7 @@ import { keyOf, labelOf, type Surface } from "./workbench_tabs";
import { useTabClose } from "./useTabClose";
import { BrowserTabMenu } from "./BrowserTabMenu";
import { WorkbenchFileMenu, type FileMenu } from "./WorkbenchFileMenu";
import { WorkbenchChanges } from "./WorkbenchChanges";
import { WorkbenchExplorerHead } from "./WorkbenchExplorerHead";
import { WorkbenchFileBar, type FileMode } from "./WorkbenchFileBar";
import { useHtmlPreview } from "./useHtmlPreview";
@@ -630,35 +631,17 @@ export function WorkbenchPanel({
file only once its folder is expanded, so a change three levels
down was invisible until someone went looking for it. */}
{changes.length > 0 && (
<div className="workbench-changes">
<div className="workbench-explorer-head">
<span>{t("改动")}</span>
<small>{changes.length}</small>
{onTreeChanged && commit.button}
</div>
{commit.card}
{changes.map((item) => (
<button
className="workbench-change-row"
data-action="workbench.diff"
data-target={item.path}
key={`c:${item.path}`}
title={item.path}
aria-current={active?.kind === "file" && active.path === item.path && mode === "diff" ? "page" : undefined}
onClick={() => {
openFile(item.path);
setMode("diff");
}}
>
<em data-status={item.status}>{item.status || "M"}</em>
<span>{item.path.slice(item.path.lastIndexOf("/") + 1)}</span>
{/* Absent counts print nothing: a binary file did not change
by zero lines, git simply did not count it. */}
{item.insertions !== undefined && <i data-io="up">+{item.insertions}</i>}
{item.deletions !== undefined && <i data-io="down">-{item.deletions}</i>}
</button>
))}
</div>
<WorkbenchChanges
port={port}
changes={changes}
current={active?.kind === "file" && mode === "diff" ? active.path : ""}
onOpen={(path) => {
openFile(path);
setMode("diff");
}}
onChanged={onTreeChanged}
commit={commit}
/>
)}
<div className="workbench-filelist">
{rows.map((row) =>
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { MockPort } from "../port/mock";
import { HttpError, type AgentPort, type WorkspaceChange } from "../port/port";
import { WorkbenchChanges } from "./WorkbenchChanges";
afterEach(cleanup);
const changes: WorkspaceChange[] = [
{ path: "src/edited.go", status: "M", unstaged: true },
{ path: "src/ready.go", status: "M", staged: true },
{ path: "src/both.go", status: "A", staged: true, unstaged: true },
{ path: "notes.md", status: "??", unstaged: true },
];
function draw(list: WorkspaceChange[] = changes, port?: AgentPort) {
const p = port ?? (new MockPort() as unknown as AgentPort);
p.stageChanges = vi.fn(async () => {});
const onChanged = vi.fn();
const onOpen = vi.fn();
render(
<WorkbenchChanges port={p} changes={list} current="" onOpen={onOpen} onChanged={onChanged}
commit={{ button: <button data-action="commit.draft">提交…</button>, card: null }} />,
);
return { port: p, onChanged, onOpen };
}
const box = (path: string) => screen.getByRole("checkbox", { name: `暂存 ${path}` });
describe("the changes list stages and unstages what a commit will record", () => {
it("shows each file as staged, partly staged or not staged", () => {
draw();
expect(box("src/edited.go").getAttribute("aria-checked")).toBe("false");
expect(box("src/ready.go").getAttribute("aria-checked")).toBe("true");
expect(box("src/both.go").getAttribute("aria-checked")).toBe("mixed");
expect(box("notes.md").getAttribute("aria-checked")).toBe("false");
});
it("stages one file by ticking it, and unstages a staged one, then asks for the list again", async () => {
const { port, onChanged } = draw();
await userEvent.click(box("notes.md"));
expect(port.stageChanges).toHaveBeenLastCalledWith({ paths: ["notes.md"], unstage: false });
await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
await userEvent.click(box("src/ready.go"));
expect(port.stageChanges).toHaveBeenLastCalledWith({ paths: ["src/ready.go"], unstage: true });
});
it("finishes a partly staged file by staging it, rather than undoing what was staged", async () => {
const { port } = draw();
await userEvent.click(box("src/both.go"));
expect(port.stageChanges).toHaveBeenLastCalledWith({ paths: ["src/both.go"], unstage: false });
});
it("stages everything at once while anything is unstaged, and unstages everything when nothing is left", async () => {
const { port } = draw();
await userEvent.click(screen.getByRole("button", { name: "暂存全部" }));
expect(port.stageChanges).toHaveBeenLastCalledWith({ all: true, unstage: false });
cleanup();
const done = draw([{ path: "a.go", status: "M", staged: true }]);
await userEvent.click(screen.getByRole("button", { name: "取消全部暂存" }));
expect(done.port.stageChanges).toHaveBeenLastCalledWith({ all: true, unstage: true });
});
it("opening a file's diff still works from its name", async () => {
const { onOpen } = draw();
await userEvent.click(screen.getByRole("button", { name: /edited\.go/ }));
expect(onOpen).toHaveBeenCalledWith("src/edited.go");
});
it("says why staging failed and keeps the list as it was", async () => {
const port = new MockPort() as unknown as AgentPort;
const { onChanged } = draw(changes, port);
port.stageChanges = vi.fn(async () => { throw new HttpError(400, "x", { code: "stage.unknown_path" }); });
await userEvent.click(box("notes.md"));
expect((await screen.findByRole("alert")).textContent).toContain("不在改动列表");
expect(onChanged).not.toHaveBeenCalled();
});
it("holds every control while a request is in flight, so two clicks are one", async () => {
let release: () => void = () => {};
const port = new MockPort() as unknown as AgentPort;
draw(changes, port);
port.stageChanges = vi.fn(() => new Promise<void>((r) => { release = r; }));
await userEvent.click(box("notes.md"));
expect(box("src/edited.go").hasAttribute("disabled")).toBe(true);
expect((screen.getByRole("button", { name: "暂存全部" }) as HTMLButtonElement).disabled).toBe(true);
release();
await waitFor(() => expect(box("src/edited.go").hasAttribute("disabled")).toBe(false));
});
it("offers no staging where the list cannot be refreshed", () => {
const port = new MockPort() as unknown as AgentPort;
render(<WorkbenchChanges port={port} changes={changes} current="" onOpen={() => {}} commit={{ button: null, card: null }} />);
expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
expect(screen.queryByRole("button", { name: "暂存全部" })).toBeNull();
});
});
+25
View File
@@ -20,6 +20,7 @@ func (s *Server) registerGitRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /workspace/branch/switch", s.workspaceSwitchBranch)
mux.HandleFunc("POST /commit/propose", s.commitPropose)
mux.HandleFunc("POST /commit", s.commitStaged)
mux.HandleFunc("POST /changes/stage", s.stageChanges)
}
// commitPropose answers the staged changes and a model-proposed message. It
@@ -48,6 +49,30 @@ func (s *Server) commitStaged(w http.ResponseWriter, r *http.Request) {
writeJSON(w, res)
}
// stageChanges moves the named changes into the index, or out of it. Only paths
// the workspace lists as changed are accepted.
func (s *Server) stageChanges(w http.ResponseWriter, r *http.Request) {
var req control.StageRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
refuse(w, http.StatusBadRequest, "stage.bad_request", "the request body is not a JSON stage request", nil)
return
}
if err := s.ctl().StageChanges(r.Context(), req); err != nil {
switch {
case r.Context().Err() != nil:
// The person cancelled; nobody is left to read an answer.
case errors.Is(err, gitcommit.ErrNoRepository):
refuse(w, http.StatusConflict, "commit.no_repository", err.Error(), nil)
case errors.Is(err, gitcommit.ErrUnknownPath):
refuse(w, http.StatusBadRequest, "stage.unknown_path", err.Error(), nil)
default:
refuse(w, http.StatusInternalServerError, "stage.failed", err.Error(), nil)
}
return
}
w.WriteHeader(http.StatusNoContent)
}
func refuseCommit(w http.ResponseWriter, r *http.Request, err error) {
var sensitive *gitcommit.SensitiveError
switch {
+82
View File
@@ -153,3 +153,85 @@ func TestEachCommitRefusalHasItsOwnCode(t *testing.T) {
t.Errorf("secrets refusal params = %v", out["params"])
}
}
// The report behind this: a new file in the list and a commit button that
// answered "stage it with git add" to a person with no terminal open. Staging
// from the list is what makes the commit possible.
func TestAUntrackedFileCanBeStagedFromTheListThenCommitted(t *testing.T) {
dir := repoWith(t, nil)
if err := os.WriteFile(filepath.Join(dir, "pelican.svg"), []byte("<svg/>\n"), 0o600); err != nil {
t.Fatal(err)
}
srv := commitServer(t, dir, messenger("feat: add pelican"))
flags := func() (bool, bool) {
res, err := http.Get(srv.URL + "/changes")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var got struct {
Changes []struct {
Path string `json:"path"`
Staged bool `json:"staged"`
Unstaged bool `json:"unstaged"`
} `json:"changes"`
}
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatal(err)
}
for _, c := range got.Changes {
if c.Path == "pelican.svg" {
return c.Staged, c.Unstaged
}
}
t.Fatalf("pelican.svg is not listed: %+v", got)
return false, false
}
if s, u := flags(); s || !u {
t.Fatalf("before staging: staged=%v unstaged=%v", s, u)
}
if status, body := commitPost(t, srv, "/commit/propose", `{}`); status != http.StatusConflict || !strings.Contains(asString(body), "commit.nothing_staged") {
t.Fatalf("propose with an empty index = %d %v", status, body)
}
if status, body := commitPost(t, srv, "/changes/stage", `{"paths":["pelican.svg"]}`); status != http.StatusNoContent {
t.Fatalf("stage = %d %v", status, body)
}
if s, u := flags(); !s || u {
t.Fatalf("after staging: staged=%v unstaged=%v", s, u)
}
if status, prop := commitPost(t, srv, "/commit/propose", `{}`); status != http.StatusOK || prop["message"] != "feat: add pelican" {
t.Fatalf("propose after staging = %d %v", status, prop)
}
if status, _ := commitPost(t, srv, "/changes/stage", `{"paths":["pelican.svg"],"unstage":true}`); status != http.StatusNoContent {
t.Fatalf("unstage = %d", status)
}
if s, u := flags(); s || !u {
t.Fatalf("after unstaging: staged=%v unstaged=%v", s, u)
}
}
func TestStagingRefusesWhatIsNotAChangeInTheWorkspace(t *testing.T) {
dir := repoWith(t, nil)
srv := commitServer(t, dir, messenger("m"))
for name, tc := range map[string]struct{ body, code string }{
"a path git does not list": {`{"paths":["nope.go"]}`, "stage.unknown_path"},
"a traversal": {`{"paths":["../x"]}`, "stage.unknown_path"},
"nothing named": {`{}`, "stage.unknown_path"},
"a body that is not JSON": {`not json`, "stage.bad_request"},
} {
status, body := commitPost(t, srv, "/changes/stage", tc.body)
if status != http.StatusBadRequest || body["code"] != tc.code {
t.Errorf("%s: %d %v, want 400 %s", name, status, body, tc.code)
}
}
noRepo := commitServer(t, "", messenger("m"))
if status, body := commitPost(t, noRepo, "/changes/stage", `{"all":true}`); status != http.StatusConflict || body["code"] != "commit.no_repository" {
t.Errorf("no repository: %d %v", status, body)
}
}
func asString(m map[string]any) string {
raw, _ := json.Marshal(m)
return string(raw)
}
@@ -371,3 +371,12 @@ func TestCredentialStraddlingAChunkEdgeIsNotMissed(t *testing.T) {
t.Fatalf("snap = %+v err = %v", snap, err)
}
}
func reopen(t *testing.T, dir string) gitcmd.Repo {
t.Helper()
repo, err := gitcmd.Open(context.Background(), dir)
if err != nil {
return gitcmd.Repo{Dir: dir}
}
return repo
}
+116
View File
@@ -0,0 +1,116 @@
package gitcommit
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/platform/gitcmd"
"reasonix/internal/platform/gitstatus"
)
// ErrUnknownPath is a path that git does not report as changed in the
// workspace. Only what git itself lists can be staged, so a traversal, an
// option spelling or a name that is not a change never reaches a command.
var ErrUnknownPath = errors.New("gitcommit: not a changed file in the workspace")
// Stage records the named changes in the index, or every listed change when all
// is set. Deletions are staged too.
func Stage(ctx context.Context, repo gitcmd.Repo, paths []string, all bool) error {
return alter(ctx, repo, paths, all, func(c gitstatus.Change) []string {
if all && !c.Unstaged {
return nil
}
return []string{c.Path}
}, func(ctx context.Context, repo gitcmd.Repo) []string {
return []string{"add", "-A", "--pathspec-from-file=-", "--pathspec-file-nul"}
})
}
// Unstage returns the named changes to the work tree, which keeps their
// content, or every staged change when all is set. A rename gives back both of
// its halves.
func Unstage(ctx context.Context, repo gitcmd.Repo, paths []string, all bool) error {
return alter(ctx, repo, paths, all, func(c gitstatus.Change) []string {
if (all && !c.Staged) || (!all && !c.Staged) {
return nil
}
if c.OldPath != "" {
return []string{c.Path, c.OldPath}
}
return []string{c.Path}
}, func(ctx context.Context, repo gitcmd.Repo) []string {
if repo.Command(ctx, "rev-parse", "--verify", "--quiet", "HEAD").Run() == nil {
return []string{"restore", "--staged", "--pathspec-from-file=-", "--pathspec-file-nul"}
}
// Before the first commit there is no HEAD to restore from: the index
// is simply emptied of the path.
return []string{"rm", "--cached", "-r", "-q", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"}
})
}
func alter(ctx context.Context, repo gitcmd.Repo, paths []string, all bool,
specs func(gitstatus.Change) []string, command func(context.Context, gitcmd.Repo) []string) error {
changes, ok, err := gitstatus.Status(ctx, repo)
if err != nil {
return fmt.Errorf("git status: %w", err)
}
if !ok {
return ErrNoRepository
}
listed := make(map[string]gitstatus.Change, len(changes))
for _, c := range changes {
listed[c.Path] = c
}
var chosen []gitstatus.Change
if all {
for _, c := range changes {
if !isDirectory(repo.Dir, c.Path) {
chosen = append(chosen, c)
}
}
} else {
if len(paths) == 0 {
return ErrUnknownPath
}
for _, p := range paths {
c, ok := listed[p]
if !ok || isDirectory(repo.Dir, p) {
return fmt.Errorf("%w: %q", ErrUnknownPath, p)
}
chosen = append(chosen, c)
}
}
var input bytes.Buffer
n := 0
for _, c := range chosen {
for _, p := range specs(c) {
input.WriteString(":(literal)" + p + "\x00")
n++
}
}
if n == 0 {
return nil
}
args := command(ctx, repo)
cmd := repo.Command(ctx, args...)
cmd.Stdin = &input
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("git %s: %w: %s", args[0], err, strings.TrimSpace(stderr.String()))
}
return nil
}
// isDirectory is true for a nested repository or a submodule, the only
// directories status lists. git would run inside one, under its own
// configuration, to stage it, so such a path is never handed to a command.
func isDirectory(root, rel string) bool {
st, err := os.Lstat(filepath.Join(root, filepath.FromSlash(rel)))
return err == nil && st.IsDir()
}
+145
View File
@@ -0,0 +1,145 @@
package gitcommit
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/platform/gitstatus"
)
func statusOf(t *testing.T, dir string) map[string]gitstatus.Change {
t.Helper()
repo := reopen(t, dir)
changes, ok, err := gitstatus.Status(context.Background(), repo)
if err != nil || !ok {
t.Fatalf("status: ok=%v err=%v", ok, err)
}
out := map[string]gitstatus.Change{}
for _, c := range changes {
out[c.Path] = c
}
return out
}
func TestStagingATrackedEditAnUntrackedFileAndADeletion(t *testing.T) {
dir, repo := fixture(t)
write(t, dir, "a.go", "package a\n// edited\n")
write(t, dir, "new.go", "package n\n")
write(t, dir, "gone.go", "package g\n")
run(t, dir, "add", "gone.go")
run(t, dir, "commit", "-qm", "gone")
if err := os.Remove(filepath.Join(dir, "gone.go")); err != nil {
t.Fatal(err)
}
if err := Stage(context.Background(), repo, []string{"a.go", "new.go", "gone.go"}, false); err != nil {
t.Fatal(err)
}
for _, path := range []string{"a.go", "new.go", "gone.go"} {
if c := statusOf(t, dir)[path]; !c.Staged || c.Unstaged {
t.Errorf("%s after staging: %+v", path, c)
}
}
}
func TestStageAllStagesEveryListedChangeAndLeavesNestedRepositoriesAlone(t *testing.T) {
dir, repo := fixture(t)
write(t, dir, "a.go", "package a\n// edited\n")
write(t, dir, "b.go", "package b\n")
nested := filepath.Join(dir, "vendor", "lib")
if err := os.MkdirAll(nested, 0o700); err != nil {
t.Fatal(err)
}
run(t, nested, "init", "-q")
write(t, nested, "x.go", "package x\n")
if err := Stage(context.Background(), repo, nil, true); err != nil {
t.Fatal(err)
}
st := statusOf(t, dir)
if !st["a.go"].Staged || !st["b.go"].Staged {
t.Fatalf("the files were not staged: %+v", st)
}
staged := run(t, dir, "diff", "--cached", "--name-only")
if strings.Contains(staged, "vendor") {
t.Fatalf("a nested repository was staged: %s", staged)
}
}
func TestUnstagingRestoresTheIndexAndKeepsTheWork(t *testing.T) {
dir, repo := fixture(t)
write(t, dir, "a.go", "package a\n// edited\n")
write(t, dir, "b.go", "package b\n")
run(t, dir, "add", "a.go", "b.go")
if err := Unstage(context.Background(), repo, []string{"a.go", "b.go"}, false); err != nil {
t.Fatal(err)
}
st := statusOf(t, dir)
if st["a.go"].Staged || !st["a.go"].Unstaged || st["b.go"].Staged || st["b.go"].Status != "??" {
t.Fatalf("after unstaging: %+v", st)
}
if body, _ := os.ReadFile(filepath.Join(dir, "a.go")); !strings.Contains(string(body), "edited") {
t.Fatal("unstaging touched the work tree")
}
}
func TestUnstagingInARepositoryWithNoCommitYet(t *testing.T) {
isolate(t)
dir := t.TempDir()
run(t, dir, "init", "-q")
write(t, dir, "first.go", "package f\n")
run(t, dir, "add", "first.go")
if err := Unstage(context.Background(), reopen(t, dir), []string{"first.go"}, false); err != nil {
t.Fatal(err)
}
if c := statusOf(t, dir)["first.go"]; c.Staged || c.Status != "??" {
t.Fatalf("first.go: %+v", c)
}
}
func TestUnstagingARenameReturnsBothHalves(t *testing.T) {
dir, repo := fixture(t)
run(t, dir, "mv", "a.go", "renamed.go")
if err := Unstage(context.Background(), repo, []string{"renamed.go"}, false); err != nil {
t.Fatal(err)
}
if staged := run(t, dir, "diff", "--cached", "--name-only"); strings.TrimSpace(staged) != "" {
t.Fatalf("part of the rename is still staged: %s", staged)
}
}
// A path is a literal name. A glob, a pathspec magic prefix or an option
// spelling must neither match other files nor reach git as anything but a path.
func TestOnlyChangedPathsAreAcceptedAndTheyAreLiteral(t *testing.T) {
dir, repo := fixture(t)
write(t, dir, "one.txt", "1\n")
write(t, dir, "two.txt", "2\n")
write(t, dir, "*.txt", "star\n")
if err := Stage(context.Background(), repo, []string{"*.txt"}, false); err != nil {
t.Fatal(err)
}
st := statusOf(t, dir)
if !st["*.txt"].Staged || st["one.txt"].Staged || st["two.txt"].Staged {
t.Fatalf("a glob matched files it did not name: %+v", st)
}
for _, bad := range []string{"../outside.txt", "/etc/passwd", "--all", ":(top)a.go", "missing.txt", ""} {
if err := Stage(context.Background(), repo, []string{bad}, false); !errors.Is(err, ErrUnknownPath) {
t.Errorf("%q: %v, want ErrUnknownPath", bad, err)
}
}
}
func TestStagingWithoutARepositoryOrAnythingToDo(t *testing.T) {
if err := Stage(context.Background(), reopen(t, t.TempDir()), nil, true); !errors.Is(err, ErrNoRepository) {
t.Fatalf("no repo: %v", err)
}
_, repo := fixture(t)
if err := Stage(context.Background(), repo, nil, false); !errors.Is(err, ErrUnknownPath) {
t.Fatalf("no paths and not all: %v", err)
}
if err := Stage(context.Background(), repo, nil, true); err != nil {
t.Fatalf("stage all of a clean tree: %v", err)
}
}
+6
View File
@@ -22,6 +22,10 @@ type Change struct {
Path string `json:"path"`
OldPath string `json:"oldPath,omitempty"`
Status string `json:"status"`
// Staged: the index differs from HEAD for this path. Unstaged: the work tree
// differs from the index, or the path is untracked. One path can be both.
Staged bool `json:"staged,omitempty"`
Unstaged bool `json:"unstaged,omitempty"`
// How much the file differs by. Nil is "git did not say" — a binary file,
// or one nothing counted — and it stays nil rather than becoming a zero,
// which reads as "changed by nothing" and is a different fact.
@@ -151,6 +155,8 @@ func ParsePorcelainZ(raw []byte) []Change {
}
status := string(part[:2])
entry := Change{Path: string(part[3:]), Status: strings.TrimSpace(status)}
entry.Staged = status[0] != ' ' && status[0] != '?'
entry.Unstaged = status[1] != ' '
if strings.ContainsAny(status, "RC") && i+1 < len(parts) {
i++
entry.OldPath = string(parts[i])
@@ -176,3 +176,26 @@ func TestDiffCapsWhatItReturns(t *testing.T) {
t.Fatalf("want a truncated %d-byte answer, got %d truncated=%v err=%v", MaxDiffBytes, len(text), truncated, err)
}
}
// A change is staged when the index differs from HEAD for it and unstaged when
// the work tree differs from the index; one path can be both.
func TestChangesSayWhetherTheyAreStagedOrNot(t *testing.T) {
raw := "M staged.txt\x00 M edited.txt\x00MM both.txt\x00A new.txt\x00?? loose.txt\x00 D gone.txt\x00D removed.txt\x00R new-name.txt\x00old-name.txt\x00"
got := map[string]Change{}
for _, c := range ParsePorcelainZ([]byte(raw)) {
got[c.Path] = c
}
for path, want := range map[string][2]bool{
"staged.txt": {true, false}, "edited.txt": {false, true}, "both.txt": {true, true},
"new.txt": {true, false}, "loose.txt": {false, true}, "gone.txt": {false, true},
"removed.txt": {true, false}, "new-name.txt": {true, false},
} {
c, ok := got[path]
if !ok {
t.Fatalf("%s is missing from %v", path, got)
}
if c.Staged != want[0] || c.Unstaged != want[1] {
t.Errorf("%s: staged=%v unstaged=%v, want %v", path, c.Staged, c.Unstaged, want)
}
}
}
+19
View File
@@ -37,6 +37,25 @@ type CommitResult struct {
Subject string `json:"subject"`
}
// StageRequest names the changes to move into the index, or out of it when
// Unstage is set. All means every change the workspace lists.
type StageRequest struct {
Paths []string `json:"paths,omitempty"`
All bool `json:"all,omitempty"`
Unstage bool `json:"unstage,omitempty"`
}
// StageChanges stages or unstages the named changes. It shares the commit lock,
// so the index never moves under a commit in flight.
func (c *Controller) StageChanges(ctx context.Context, req StageRequest) error {
c.commitMu.Lock()
defer c.commitMu.Unlock()
if req.Unstage {
return gitcommit.Unstage(ctx, c.workspaceRepo, req.Paths, req.All)
}
return gitcommit.Stage(ctx, c.workspaceRepo, req.Paths, req.All)
}
// ProposeCommit reads the staged changes and has the session's model propose a
// message. Nothing is staged or committed: the person edits the text first.
func (c *Controller) ProposeCommit(ctx context.Context) (CommitProposal, error) {
+3 -2
View File
@@ -422,12 +422,13 @@ type LocalShell interface {
}
// Commits proposes a commit message for the staged changes and records a local
// commit once the person has confirmed the text. It never stages, pushes or
// rewrites history. It also moves the workspace between local git branches —
// commit once the person has confirmed the text. It stages or unstages only what
// the person names, and never pushes or rewrites history. It also moves the workspace between local git branches —
// a workspace write like a commit, refused typed while a turn runs.
type Commits interface {
ProposeCommit(ctx context.Context) (CommitProposal, error)
CommitStaged(ctx context.Context, req CommitRequest) (CommitResult, error)
StageChanges(ctx context.Context, req StageRequest) error
SwitchWorkspaceBranch(ctx context.Context, name string) (gitstatus.Info, bool, error)
}
+1
View File
@@ -197,6 +197,7 @@ var mirroredWireTypes = []wireMirror{
{"internal/session/control/commit.go", "CommitProposal", tsWorkspaceFile, "CommitProposal"},
{"internal/session/control/commit.go", "CommitRequest", tsWorkspaceFile, "CommitRequest"},
{"internal/session/control/commit.go", "CommitResult", tsWorkspaceFile, "CommitResult"},
{"internal/session/control/commit.go", "StageRequest", tsWorkspaceFile, "StageRequest"},
{"internal/platform/gitcommit/gitcommit.go", "File", tsWorkspaceFile, "CommitFile"},
// The scale range the slider is drawn from: a bound the page cannot read
// would leave it a range of its own.