mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
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:
1 parent
a41ce9f980
commit
40e097879d
23 files changed
+695
-46
No files matched your search
@@ -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";
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
"暂存的内容里有疑似密钥的文件,需要你确认后才能提交":
|
||||
|
||||
@@ -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": "提交说明是空的",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user