mirror of
https://github.com/cline/cline.git
synced 2026-10-25 10:26:36 +08:00
desktop: leave commit, push, and pull to the agent
Remove the commit message box and the Commit, Push, and Pull buttons from the Git view, and the matching sidecar actions, since people usually ask the agent to do these. The branch line (with ahead/behind counts), staging, discard, history, and the background fetch stay. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
co-authored by
Saoud Rizwan
parent
f2c5ec6b93
commit
492be9026b
@@ -165,16 +165,10 @@ describe("parseSourceControlAction", () => {
|
||||
type: "stage",
|
||||
paths: ["a"],
|
||||
});
|
||||
expect(
|
||||
parseSourceControlAction({ type: "commit", message: "m", push: true }),
|
||||
).toEqual({
|
||||
type: "commit",
|
||||
message: "m",
|
||||
push: true,
|
||||
});
|
||||
expect(parseSourceControlAction({ type: "pull" })).toEqual({
|
||||
type: "pull",
|
||||
});
|
||||
// Committing, pushing, and pulling are left to the agent.
|
||||
for (const type of ["commit", "push", "pull"]) {
|
||||
expect(() => parseSourceControlAction({ type })).toThrow(/Unknown/);
|
||||
}
|
||||
expect(parseSourceControlAction({ type: "fetch" })).toEqual({
|
||||
type: "fetch",
|
||||
});
|
||||
@@ -383,7 +377,7 @@ describe("against a real repository", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("pushes to and pulls from an upstream", async () => {
|
||||
it("fetch surfaces commits pushed elsewhere without touching the tree", async () => {
|
||||
const remote = mkdtempSync(join(tmpdir(), "cline-remote-"));
|
||||
const other = mkdtempSync(join(tmpdir(), "cline-clone-"));
|
||||
const run = (cwd: string) => async (args: string[]) =>
|
||||
@@ -398,13 +392,12 @@ describe("against a real repository", () => {
|
||||
remote,
|
||||
]);
|
||||
await git(["remote", "add", "origin", remote]);
|
||||
// First push publishes the branch (no upstream yet).
|
||||
await runSourceControlAction(git, { type: "push" });
|
||||
await git(["push", "-q", "-u", "origin", "HEAD"]);
|
||||
let state = await getSourceControlState(git, "local");
|
||||
expect(state.hasUpstream).toBe(true);
|
||||
expect(state.commits[0]?.pushed).toBe(true);
|
||||
|
||||
// Someone else lands a commit; pull brings it in.
|
||||
// Someone else lands a commit; only a fetch can reveal it.
|
||||
await execFileAsync("git", ["clone", "-q", remote, other]);
|
||||
const otherGit = run(other);
|
||||
await otherGit(["config", "user.email", "o@example.com"]);
|
||||
@@ -417,12 +410,8 @@ describe("against a real repository", () => {
|
||||
await runSourceControlAction(git, { type: "fetch" });
|
||||
state = await getSourceControlState(git, "local");
|
||||
expect(state.behind).toBe(1);
|
||||
// Park the local edits so the pull is a clean fast-forward.
|
||||
await git(["stash", "-q", "--include-untracked"]);
|
||||
await runSourceControlAction(git, { type: "pull" });
|
||||
state = await getSourceControlState(git, "local");
|
||||
expect(state.behind).toBe(0);
|
||||
expect(state.commits[0]?.subject).toBe("remote work");
|
||||
// Fetching never touches the working tree.
|
||||
expect(existsSync(join(root, "remote.txt"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(remote, { recursive: true, force: true });
|
||||
rmSync(other, { recursive: true, force: true });
|
||||
@@ -455,7 +444,7 @@ describe("against a real repository", () => {
|
||||
expect(added.newText).toBe("one\ntwo\n");
|
||||
});
|
||||
|
||||
it("stages, unstages, discards, and commits", async () => {
|
||||
it("stages, unstages, and discards", async () => {
|
||||
await runSourceControlAction(git, {
|
||||
type: "stage",
|
||||
paths: ["a.ts", "new.txt"],
|
||||
@@ -478,21 +467,5 @@ describe("against a real repository", () => {
|
||||
untrackedPaths: [],
|
||||
});
|
||||
expect(readFileSync(join(root, "a.ts"), "utf8")).toBe("one\ntwo\n");
|
||||
|
||||
await runSourceControlAction(git, {
|
||||
type: "commit",
|
||||
message: "add staged work",
|
||||
push: false,
|
||||
});
|
||||
state = await getSourceControlState(git, "local");
|
||||
expect(state.staged).toEqual([]);
|
||||
expect(state.commits[0]?.subject).toBe("add staged work");
|
||||
await expect(
|
||||
runSourceControlAction(git, {
|
||||
type: "commit",
|
||||
message: " ",
|
||||
push: false,
|
||||
}),
|
||||
).rejects.toThrow(/message/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Source control backing for the desktop app's workspace panel: one call
|
||||
// returns everything the Source Control column renders, plus the small set
|
||||
// of mutations it offers (stage, unstage, discard, commit, push).
|
||||
// of mutations it offers (stage, unstage, discard) plus a background fetch.
|
||||
// Committing, pushing, and pulling are left to the agent.
|
||||
|
||||
/** Runs git in the repository and resolves stdout; rejects on non-zero exit. */
|
||||
export type GitRunner = (args: string[]) => Promise<string>;
|
||||
@@ -485,9 +486,6 @@ export type SourceControlAction =
|
||||
| { type: "stage"; paths: string[] }
|
||||
| { type: "unstage"; paths: string[] }
|
||||
| { type: "discard"; paths: string[]; untrackedPaths: string[] }
|
||||
| { type: "commit"; message: string; push: boolean }
|
||||
| { type: "push" }
|
||||
| { type: "pull" }
|
||||
/** Background refresh of remote-tracking refs; never touches the work tree. */
|
||||
| { type: "fetch" };
|
||||
|
||||
@@ -515,33 +513,13 @@ export function parseSourceControlAction(value: unknown): SourceControlAction {
|
||||
paths: stringList(record.paths),
|
||||
untrackedPaths: stringList(record.untrackedPaths),
|
||||
};
|
||||
case "commit":
|
||||
return {
|
||||
type: "commit",
|
||||
message: typeof record.message === "string" ? record.message : "",
|
||||
push: record.push === true,
|
||||
};
|
||||
case "push":
|
||||
case "pull":
|
||||
case "fetch":
|
||||
return { type: record.type };
|
||||
return { type: "fetch" };
|
||||
default:
|
||||
throw new Error("Unknown source control action");
|
||||
}
|
||||
}
|
||||
|
||||
async function hasUpstream(git: GitRunner): Promise<boolean> {
|
||||
return git([
|
||||
"rev-parse",
|
||||
"--abbrev-ref",
|
||||
"--symbolic-full-name",
|
||||
"@{u}",
|
||||
]).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
async function hasHead(git: GitRunner): Promise<boolean> {
|
||||
return git(["rev-parse", "--verify", "--quiet", "HEAD"]).then(
|
||||
() => true,
|
||||
@@ -549,12 +527,6 @@ async function hasHead(git: GitRunner): Promise<boolean> {
|
||||
);
|
||||
}
|
||||
|
||||
async function push(git: GitRunner): Promise<void> {
|
||||
// A branch without an upstream is published rather than failing.
|
||||
if (await hasUpstream(git)) await git(["push"]);
|
||||
else await git(["push", "-u", "origin", "HEAD"]);
|
||||
}
|
||||
|
||||
// Filenames are data, never patterns: without this, `draft*.txt` would also
|
||||
// match (and discard) every other file the glob covers.
|
||||
const LITERAL = "--literal-pathspecs";
|
||||
@@ -595,21 +567,6 @@ export async function runSourceControlAction(
|
||||
await git([LITERAL, "clean", "-f", "--", ...action.untrackedPaths]);
|
||||
}
|
||||
return;
|
||||
case "commit": {
|
||||
const message = action.message.trim();
|
||||
if (!message) throw new Error("A commit message is required.");
|
||||
await git(["commit", "-m", message]);
|
||||
if (action.push) await push(git);
|
||||
return;
|
||||
}
|
||||
case "push":
|
||||
await push(git);
|
||||
return;
|
||||
case "pull":
|
||||
// Non-interactive: git skips the merge editor without a TTY, and
|
||||
// conflicts surface as the error message in the UI.
|
||||
await git(["pull"]);
|
||||
return;
|
||||
case "fetch":
|
||||
await git(["fetch", "--quiet"]);
|
||||
return;
|
||||
|
||||
+24
-89
@@ -209,16 +209,18 @@ describe("ProjectFilesPanel source control view", () => {
|
||||
expect(text).toContain("README.md");
|
||||
expect(text).toContain("notes.txt");
|
||||
expect(text).toContain("Latest work");
|
||||
// One commit ahead: Push is enabled with a count, Pull is available.
|
||||
const push = byLabel("Push", container) as HTMLButtonElement;
|
||||
expect(push.disabled).toBe(false);
|
||||
expect(push.textContent).toContain("1");
|
||||
expect((byLabel("Pull", container) as HTMLButtonElement).disabled).toBe(
|
||||
false,
|
||||
);
|
||||
await click(byLabel("Pull", container));
|
||||
expect(actionCalls()).toContainEqual({ type: "pull" });
|
||||
expect(container.textContent).toContain("main");
|
||||
// Committing, pushing, and pulling are left to the agent; the branch
|
||||
// line still reports how far the branch is ahead.
|
||||
expect(container.querySelector('[aria-label="Commit message"]')).toBeNull();
|
||||
for (const label of ["Push", "Pull", "Publish branch"]) {
|
||||
expect(container.querySelector(`[aria-label="${label}"]`)).toBeNull();
|
||||
}
|
||||
expect(
|
||||
Array.from(column?.querySelectorAll("button") ?? []).some((el) =>
|
||||
/^Commit$/.test(el.textContent ?? ""),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(text).toMatch(/main\s*1/);
|
||||
// Session-touched rows carry the marker.
|
||||
expect(
|
||||
column?.querySelector('[title="Changed in this session"]'),
|
||||
@@ -249,7 +251,7 @@ describe("ProjectFilesPanel source control view", () => {
|
||||
).toContain("/repo/README.md");
|
||||
});
|
||||
|
||||
it("stages, unstages, and commits through the sidecar", async () => {
|
||||
it("stages and unstages through the sidecar", async () => {
|
||||
await render();
|
||||
await click(byLabel("Stage README.md"));
|
||||
expect(actionCalls()).toContainEqual({
|
||||
@@ -261,26 +263,6 @@ describe("ProjectFilesPanel source control view", () => {
|
||||
type: "unstage",
|
||||
paths: ["src/app.ts"],
|
||||
});
|
||||
|
||||
const message = byLabel("Commit message") as HTMLTextAreaElement;
|
||||
const commit = button(/^Commit$/);
|
||||
expect(commit.disabled).toBe(true);
|
||||
await act(async () => {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
setter?.call(message, "feat: panel");
|
||||
message.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
expect(button(/^Commit$/).disabled).toBe(false);
|
||||
await click(button(/^Commit$/));
|
||||
expect(actionCalls()).toContainEqual({
|
||||
type: "commit",
|
||||
message: "feat: panel",
|
||||
push: false,
|
||||
});
|
||||
expect((byLabel("Commit message") as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("opens the first change when the panel opens, once", async () => {
|
||||
@@ -344,63 +326,26 @@ describe("ProjectFilesPanel source control view", () => {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the commit message when the commit fails", async () => {
|
||||
await render();
|
||||
const message = byLabel("Commit message") as HTMLTextAreaElement;
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set?.call(message, "wip");
|
||||
message.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
invokeMock.mockImplementationOnce(async (command: string) => {
|
||||
if (command === "run_source_control_action") {
|
||||
throw new Error("pre-commit hook failed");
|
||||
}
|
||||
return null;
|
||||
});
|
||||
await click(button(/^Commit$/));
|
||||
expect((byLabel("Commit message") as HTMLTextAreaElement).value).toBe(
|
||||
"wip",
|
||||
);
|
||||
});
|
||||
|
||||
it("publishes a branch that has no upstream and disables Pull", async () => {
|
||||
const previous = { ...sourceControlState };
|
||||
Object.assign(sourceControlState, { hasUpstream: false, ahead: 0 });
|
||||
try {
|
||||
await render();
|
||||
expect((byLabel("Pull", container) as HTMLButtonElement).disabled).toBe(
|
||||
true,
|
||||
);
|
||||
await click(byLabel("Publish branch", container));
|
||||
expect(actionCalls()).toContainEqual({ type: "push" });
|
||||
} finally {
|
||||
Object.assign(sourceControlState, previous);
|
||||
}
|
||||
});
|
||||
|
||||
it("reloads the folders expanded by the time a pull finishes", async () => {
|
||||
it("reloads the folders expanded by the time an action finishes", async () => {
|
||||
await render();
|
||||
const listings = () =>
|
||||
invokeMock.mock.calls.filter(
|
||||
([command]) => command === "list_project_entries",
|
||||
).length;
|
||||
// Hold the pull open and expand a folder while it runs.
|
||||
let finishPull: (value: unknown) => void = () => {};
|
||||
// Hold a stage open and expand a folder while it runs.
|
||||
let finishAction: (value: unknown) => void = () => {};
|
||||
invokeMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishPull = resolve;
|
||||
finishAction = resolve;
|
||||
}),
|
||||
);
|
||||
await click(byLabel("Pull", container));
|
||||
await click(byLabel("Stage README.md", container));
|
||||
await click(button(/^Files$/));
|
||||
await click(button(/^src/));
|
||||
const before = listings();
|
||||
await act(async () => {
|
||||
finishPull({ environmentId: "local" });
|
||||
finishAction({ environmentId: "local" });
|
||||
await Promise.resolve();
|
||||
});
|
||||
// Root and the newly expanded src both list again, and src keeps
|
||||
@@ -449,13 +394,16 @@ describe("ProjectFilesPanel source control view", () => {
|
||||
try {
|
||||
await render();
|
||||
expect(fetchCount()).toBe(1);
|
||||
await click(byLabel("Pull", container));
|
||||
await click(byLabel("Stage README.md", container));
|
||||
expect(actionCalls()).toEqual([]);
|
||||
await act(async () => {
|
||||
finishFetch({ environmentId: "local" });
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(actionCalls()).toContainEqual({ type: "pull" });
|
||||
expect(actionCalls()).toContainEqual({
|
||||
type: "stage",
|
||||
paths: ["README.md"],
|
||||
});
|
||||
} finally {
|
||||
invokeMock.mockImplementation(fallback as never);
|
||||
}
|
||||
@@ -472,19 +420,6 @@ describe("ProjectFilesPanel source control view", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("disables Push when nothing is ahead", async () => {
|
||||
const previous = { ...sourceControlState };
|
||||
Object.assign(sourceControlState, { ahead: 0 });
|
||||
try {
|
||||
await render();
|
||||
expect((byLabel("Push", container) as HTMLButtonElement).disabled).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
Object.assign(sourceControlState, previous);
|
||||
}
|
||||
});
|
||||
|
||||
it("restarts a diff read after closing the tab mid-flight", async () => {
|
||||
await render();
|
||||
invokeMock.mockImplementationOnce(() => new Promise(() => {}));
|
||||
|
||||
@@ -652,12 +652,7 @@ export function ProjectFilesPanel({
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title:
|
||||
action.type === "commit"
|
||||
? "Commit failed"
|
||||
: action.type === "push"
|
||||
? "Push failed"
|
||||
: "Git command failed",
|
||||
title: "Git command failed",
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return false;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
GitBranch,
|
||||
@@ -13,8 +12,6 @@ import {
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useCallback, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
type CommitFile,
|
||||
type SourceControlAction,
|
||||
@@ -84,7 +81,6 @@ export function SourceControlColumn({
|
||||
onOpenCommitFile,
|
||||
selectedCommitFile,
|
||||
}: SourceControlColumnProps) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [expandedCommits, setExpandedCommits] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
@@ -124,26 +120,6 @@ export function SourceControlColumn({
|
||||
const [commitsOpen, setCommitsOpen] = useState(true);
|
||||
const staged = state?.staged ?? [];
|
||||
const changes = [...(state?.unstaged ?? []), ...(state?.untracked ?? [])];
|
||||
const canCommit = staged.length > 0 && message.trim().length > 0 && !busy;
|
||||
// Pushing publishes a branch without an upstream; otherwise it needs
|
||||
// something ahead. Pulling always needs an upstream.
|
||||
const canPush =
|
||||
!busy &&
|
||||
Boolean(state?.root) &&
|
||||
(state?.hasUpstream === false
|
||||
? state.commits.length > 0
|
||||
: (state?.ahead ?? 0) > 0);
|
||||
const canPull = !busy && Boolean(state?.root && state.hasUpstream);
|
||||
|
||||
const commit = useCallback(async () => {
|
||||
if (!canCommit) return;
|
||||
// A rejected hook or missing identity keeps the message for retry.
|
||||
if (
|
||||
await onAction({ type: "commit", message: message.trim(), push: false })
|
||||
) {
|
||||
setMessage("");
|
||||
}
|
||||
}, [canCommit, message, onAction]);
|
||||
|
||||
if (state && !state.root) {
|
||||
return (
|
||||
@@ -159,89 +135,11 @@ export function SourceControlColumn({
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" id="source-control-column">
|
||||
<div className="flex flex-col gap-2 border-b border-border p-2">
|
||||
{state?.root ? (
|
||||
<div className="flex h-5 min-w-0 items-center px-1">
|
||||
<BranchIndicator state={state} />
|
||||
</div>
|
||||
) : null}
|
||||
<Textarea
|
||||
aria-label="Commit message"
|
||||
className="min-h-16 resize-none bg-background text-xs"
|
||||
disabled={busy}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void commit();
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
staged.length > 0
|
||||
? "Commit message (⌘↵ to commit)"
|
||||
: "Stage changes to commit"
|
||||
}
|
||||
value={message}
|
||||
/>
|
||||
<Button
|
||||
className="h-7 w-full text-xs"
|
||||
disabled={!canCommit}
|
||||
onClick={() => void commit()}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-3.5" />
|
||||
)}
|
||||
Commit
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<Button
|
||||
aria-label={
|
||||
state?.hasUpstream === false ? "Publish branch" : "Push"
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
disabled={!canPush}
|
||||
onClick={() => void onAction({ type: "push" })}
|
||||
size="sm"
|
||||
title={
|
||||
state?.hasUpstream === false
|
||||
? "Publish this branch to origin"
|
||||
: state?.ahead
|
||||
? `Push ${state.ahead} commit${state.ahead === 1 ? "" : "s"}`
|
||||
: "Nothing to push"
|
||||
}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
Push
|
||||
{state?.ahead ? <CountBadge count={state.ahead} /> : null}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Pull"
|
||||
className="h-7 text-xs"
|
||||
disabled={!canPull}
|
||||
onClick={() => void onAction({ type: "pull" })}
|
||||
size="sm"
|
||||
title={
|
||||
state?.hasUpstream
|
||||
? state.behind
|
||||
? `Pull ${state.behind} commit${state.behind === 1 ? "" : "s"}`
|
||||
: "Pull from upstream"
|
||||
: "No upstream branch to pull from"
|
||||
}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowDown className="size-3.5" />
|
||||
Pull
|
||||
{state?.behind ? <CountBadge count={state.behind} /> : null}
|
||||
</Button>
|
||||
{state?.root ? (
|
||||
<div className="flex h-8 shrink-0 min-w-0 items-center border-b border-border px-3">
|
||||
<BranchIndicator state={state} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 overflow-auto px-1 py-1">
|
||||
{error ? (
|
||||
<div className="px-2 py-1 text-[11px] text-destructive">{error}</div>
|
||||
@@ -588,14 +486,6 @@ function CommitDetailRow({
|
||||
);
|
||||
}
|
||||
|
||||
function CountBadge({ count }: { count: number }) {
|
||||
return (
|
||||
<span className="rounded bg-foreground/10 px-1 font-mono text-[10px] leading-4">
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Group({
|
||||
label,
|
||||
count,
|
||||
|
||||
@@ -50,9 +50,7 @@ export type SourceControlAction =
|
||||
| { type: "stage"; paths: string[] }
|
||||
| { type: "unstage"; paths: string[] }
|
||||
| { type: "discard"; paths: string[]; untrackedPaths: string[] }
|
||||
| { type: "commit"; message: string; push: boolean }
|
||||
| { type: "push" }
|
||||
| { type: "pull" };
|
||||
| { type: "fetch" };
|
||||
|
||||
// Over SSH every poll is a remote git invocation, so it runs less often.
|
||||
const LOCAL_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
Reference in New Issue
Block a user