Skip to content

Commit 45850f4

Browse files
committed
Refine workspace git panel interactions
1 parent 98db173 commit 45850f4

30 files changed

Lines changed: 3656 additions & 500 deletions

packages/core/src/domain/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,20 @@ export interface GitStatus {
103103
deleted: GitFileChange[];
104104
}
105105

106+
export type GitChangeStatus = "added" | "modified" | "deleted" | "renamed" | "untracked";
107+
106108
export interface GitFileChange {
107109
path: string;
108110
oldPath?: string; // for renames
111+
status?: GitChangeStatus;
112+
}
113+
114+
export interface GitCommitSummary {
115+
sha: string;
116+
shortSha: string;
117+
subject: string;
118+
authorName: string;
119+
authoredAt: number;
109120
}
110121

111122
export interface GitBranch {

packages/server/src/__tests__/git-commands.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,64 @@ describe("Git Commands", () => {
131131
expect((result.data as { diff: string }).diff).toContain("+notes");
132132
});
133133

134+
it("returns recent commit history for git.log", async () => {
135+
await execFileAsync("git", ["add", "."], { cwd: testDir });
136+
await execFileAsync("git", ["commit", "-m", "Refresh command surface"], { cwd: testDir });
137+
138+
const result = await dispatch(
139+
{
140+
kind: "command",
141+
id: "git-log-1",
142+
op: "git.log",
143+
args: {
144+
workspaceId,
145+
limit: 5,
146+
},
147+
},
148+
ctx
149+
);
150+
151+
expect(result.ok).toBe(true);
152+
expect(result.data).toEqual(
153+
expect.objectContaining({
154+
entries: expect.arrayContaining([
155+
expect.objectContaining({
156+
subject: "Refresh command surface",
157+
authorName: "Test",
158+
}),
159+
]),
160+
})
161+
);
162+
});
163+
164+
it("returns a commit patch for git.show", async () => {
165+
await execFileAsync("git", ["add", "."], { cwd: testDir });
166+
await execFileAsync("git", ["commit", "-m", "Refresh command surface"], { cwd: testDir });
167+
const { stdout: headSha } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: testDir });
168+
169+
const result = await dispatch(
170+
{
171+
kind: "command",
172+
id: "git-show-1",
173+
op: "git.show",
174+
args: {
175+
workspaceId,
176+
sha: headSha.trim(),
177+
},
178+
},
179+
ctx
180+
);
181+
182+
expect(result.ok).toBe(true);
183+
expect(result.data).toEqual(
184+
expect.objectContaining({
185+
diff: expect.stringContaining("Refresh command surface"),
186+
})
187+
);
188+
expect((result.data as { diff: string }).diff).toContain("+export const value = 2;");
189+
expect((result.data as { diff: string }).diff).toContain("-export const value = 1;");
190+
});
191+
134192
it("discards modified tracked files", async () => {
135193
const result = await dispatch(
136194
{

packages/server/src/__tests__/git/cli.test.ts

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1111
import {
1212
classifyGitAuthFailure,
1313
GitError,
14+
getGitHistory,
1415
getGitStatus,
1516
runGit,
1617
runGitFetch,
@@ -71,7 +72,7 @@ describe("runGit", () => {
7172

7273
const status = await getGitStatus(testDir);
7374

74-
expect(status.deleted).toEqual([{ path: "docs/验收报告/phase 1/a b.txt" }]);
75+
expect(status.deleted).toEqual([{ path: "docs/验收报告/phase 1/a b.txt", status: "deleted" }]);
7576
await expect(
7677
execFileAsync("git", ["add", "--", status.deleted[0]?.path ?? ""], { cwd: testDir })
7778
).resolves.toBeDefined();
@@ -638,8 +639,8 @@ describe("getGitStatus", () => {
638639
const status = await getGitStatus(testDir);
639640

640641
expect(status.untracked).toEqual([
641-
{ path: "docs/help/README.md" },
642-
{ path: "docs/help/assets/logo.png" },
642+
{ path: "docs/help/README.md", status: "untracked" },
643+
{ path: "docs/help/assets/logo.png", status: "untracked" },
643644
]);
644645

645646
await rm(testDir, { recursive: true, force: true });
@@ -681,6 +682,82 @@ describe("getGitStatus", () => {
681682
});
682683
});
683684

685+
describe("getGitHistory", () => {
686+
it("returns recent commits in reverse chronological order with author and timestamp", async () => {
687+
const testDir = await mkdtemp(join(tmpdir(), "git-history-"));
688+
await execFileAsync("git", ["init"], { cwd: testDir });
689+
await execFileAsync("git", ["config", "user.name", "Test"], { cwd: testDir });
690+
await execFileAsync("git", ["config", "user.email", "test@example.com"], { cwd: testDir });
691+
692+
await writeFile(join(testDir, "file.txt"), "one\n");
693+
await execFileAsync("git", ["add", "."], { cwd: testDir });
694+
await execFileAsync("git", ["commit", "-m", "first commit"], { cwd: testDir });
695+
696+
await writeFile(join(testDir, "file.txt"), "two\n");
697+
await execFileAsync("git", ["add", "."], { cwd: testDir });
698+
await execFileAsync("git", ["commit", "-m", "second commit"], { cwd: testDir });
699+
700+
const history = await getGitHistory(testDir, 5);
701+
702+
expect(history).toHaveLength(2);
703+
expect(history[0]).toEqual(
704+
expect.objectContaining({
705+
subject: "second commit",
706+
authorName: "Test",
707+
})
708+
);
709+
expect(history[0]?.sha).toHaveLength(40);
710+
expect(history[0]?.shortSha).toHaveLength(7);
711+
expect(history[0]?.authoredAt).toBeTypeOf("number");
712+
expect(history[0]!.authoredAt).toBeGreaterThanOrEqual(history[1]!.authoredAt);
713+
expect(history[1]).toEqual(
714+
expect.objectContaining({
715+
subject: "first commit",
716+
authorName: "Test",
717+
})
718+
);
719+
720+
await rm(testDir, { recursive: true, force: true });
721+
});
722+
723+
it("returns an empty list for repositories without commits", async () => {
724+
const testDir = await mkdtemp(join(tmpdir(), "git-history-empty-"));
725+
await execFileAsync("git", ["init"], { cwd: testDir });
726+
727+
await expect(getGitHistory(testDir, 5)).resolves.toEqual([]);
728+
729+
await rm(testDir, { recursive: true, force: true });
730+
});
731+
});
732+
733+
describe("getGitCommitDiff", () => {
734+
it("returns the requested commit patch with metadata", async () => {
735+
const testDir = await mkdtemp(join(tmpdir(), "git-show-"));
736+
await execFileAsync("git", ["init"], { cwd: testDir });
737+
await execFileAsync("git", ["config", "user.name", "Test"], { cwd: testDir });
738+
await execFileAsync("git", ["config", "user.email", "test@example.com"], { cwd: testDir });
739+
740+
await writeFile(join(testDir, "file.txt"), "one\n");
741+
await execFileAsync("git", ["add", "."], { cwd: testDir });
742+
await execFileAsync("git", ["commit", "-m", "first commit"], { cwd: testDir });
743+
744+
await writeFile(join(testDir, "file.txt"), "two\n");
745+
await execFileAsync("git", ["add", "."], { cwd: testDir });
746+
await execFileAsync("git", ["commit", "-m", "second commit"], { cwd: testDir });
747+
748+
const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: testDir });
749+
const { getGitCommitDiff } = await import("../../git/cli.js");
750+
const diff = await getGitCommitDiff(testDir, stdout.trim());
751+
752+
expect(diff).toContain("second commit");
753+
expect(diff).toContain("diff --git a/file.txt b/file.txt");
754+
expect(diff).toContain("-one");
755+
expect(diff).toContain("+two");
756+
757+
await rm(testDir, { recursive: true, force: true });
758+
});
759+
});
760+
684761
describe("runGitCheckout", () => {
685762
let testDir: string;
686763
let remoteDir: string;

packages/server/src/__tests__/git/status-parser.test.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ describe("parseStatus", () => {
4242
const status = parseStatus(porcelain);
4343
expect(status.untracked).toHaveLength(2);
4444
expect(status.untracked[0].path).toBe("file1.txt");
45+
expect(status.untracked[0].status).toBe("untracked");
4546
expect(status.untracked[1].path).toBe("file2.txt");
47+
expect(status.untracked[1].status).toBe("untracked");
4648
});
4749

4850
it("should parse modified files", () => {
@@ -51,6 +53,7 @@ describe("parseStatus", () => {
5153
const status = parseStatus(porcelain);
5254
expect(status.modified).toHaveLength(1);
5355
expect(status.modified[0].path).toBe("file.txt");
56+
expect(status.modified[0].status).toBe("modified");
5457
});
5558

5659
it("should parse staged files", () => {
@@ -59,6 +62,7 @@ describe("parseStatus", () => {
5962
const status = parseStatus(porcelain);
6063
expect(status.staged).toHaveLength(1);
6164
expect(status.staged[0].path).toBe("file.txt");
65+
expect(status.staged[0].status).toBe("modified");
6266
});
6367

6468
it("should parse unstaged deleted files", () => {
@@ -67,6 +71,7 @@ describe("parseStatus", () => {
6771
const status = parseStatus(porcelain);
6872
expect(status.deleted).toHaveLength(1);
6973
expect(status.deleted[0].path).toBe("file.txt");
74+
expect(status.deleted[0].status).toBe("deleted");
7075
});
7176

7277
it("should treat staged deletions as staged changes", () => {
@@ -75,6 +80,7 @@ describe("parseStatus", () => {
7580
const status = parseStatus(porcelain);
7681
expect(status.staged).toHaveLength(1);
7782
expect(status.staged[0].path).toBe("file.txt");
83+
expect(status.staged[0].status).toBe("deleted");
7884
expect(status.deleted).toHaveLength(0);
7985
});
8086

@@ -85,6 +91,7 @@ describe("parseStatus", () => {
8591
expect(status.staged).toHaveLength(1);
8692
expect(status.staged[0].path).toBe("new.txt");
8793
expect(status.staged[0].oldPath).toBe("old.txt");
94+
expect(status.staged[0].status).toBe("renamed");
8895
});
8996

9097
it("should parse NUL-delimited records with raw paths", () => {
@@ -100,8 +107,10 @@ describe("parseStatus", () => {
100107
expect(status.branch).toBe("main");
101108
expect(status.ahead).toBe(1);
102109
expect(status.behind).toBe(2);
103-
expect(status.deleted).toEqual([{ path: "docs/验收报告/phase 1/a b.txt" }]);
104-
expect(status.untracked).toEqual([{ path: "docs/验收报告/phase 1/c d.txt" }]);
110+
expect(status.deleted).toEqual([{ path: "docs/验收报告/phase 1/a b.txt", status: "deleted" }]);
111+
expect(status.untracked).toEqual([
112+
{ path: "docs/验收报告/phase 1/c d.txt", status: "untracked" },
113+
]);
105114
});
106115

107116
it("should parse NUL-delimited renamed files with spaces and non-ascii paths", () => {
@@ -117,6 +126,20 @@ describe("parseStatus", () => {
117126
{
118127
path: "docs/验收报告/phase 1/c d.txt",
119128
oldPath: "docs/验收报告/phase 1/a b.txt",
129+
status: "renamed",
130+
},
131+
]);
132+
});
133+
134+
it("parses staged additions with an added status marker", () => {
135+
const porcelain = `# branch.head main
136+
1 A. N... 000000 100644 100644 000000 abc123 new-file.ts`;
137+
const status = parseStatus(porcelain);
138+
139+
expect(status.staged).toEqual([
140+
{
141+
path: "new-file.ts",
142+
status: "added",
120143
},
121144
]);
122145
});

packages/server/src/commands/git.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
commitChanges,
88
discardChanges,
99
GitAuthError,
10+
getGitCommitDiff,
11+
getGitHistory,
1012
getGitStatus,
1113
runGitCheckout,
1214
runGitCreateBranch,
@@ -96,6 +98,44 @@ registerCommand(
9698
}
9799
);
98100

101+
// git.log
102+
registerCommand(
103+
"git.log",
104+
z.object({
105+
workspaceId: z.string(),
106+
limit: z.number().int().min(1).max(50).optional(),
107+
}),
108+
async (args, ctx) => {
109+
const workspace = ctx.workspaceMgr.get(args.workspaceId);
110+
if (!workspace) {
111+
throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` };
112+
}
113+
114+
return {
115+
entries: await getGitHistory(workspace.path, args.limit ?? 5),
116+
};
117+
}
118+
);
119+
120+
// git.show
121+
registerCommand(
122+
"git.show",
123+
z.object({
124+
workspaceId: z.string(),
125+
sha: z.string().min(1),
126+
}),
127+
async (args, ctx) => {
128+
const workspace = ctx.workspaceMgr.get(args.workspaceId);
129+
if (!workspace) {
130+
throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` };
131+
}
132+
133+
return {
134+
diff: await getGitCommitDiff(workspace.path, args.sha),
135+
};
136+
}
137+
);
138+
99139
// git.unstage
100140
registerCommand(
101141
"git.unstage",

packages/server/src/git/cli.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Git CLI operations - Wrapper around git commands.
33
*/
44

5-
import type { GitBranch, GitStatus } from "@coder-studio/core";
5+
import type { GitBranch, GitCommitSummary, GitStatus } from "@coder-studio/core";
66
import { execFile } from "child_process";
77
import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
88
import os from "os";
@@ -173,6 +173,50 @@ export async function getGitStatus(cwd: string): Promise<GitStatus> {
173173
};
174174
}
175175

176+
/**
177+
* Get recent commit history for the current HEAD.
178+
*/
179+
export async function getGitHistory(cwd: string, limit = 5): Promise<GitCommitSummary[]> {
180+
try {
181+
const { stdout } = await runGit(cwd, [
182+
"log",
183+
`--max-count=${Math.max(1, limit)}`,
184+
"--format=%H%x1f%h%x1f%s%x1f%an%x1f%at%x1e",
185+
]);
186+
187+
return stdout
188+
.split("\x1e")
189+
.map((record) => record.trim())
190+
.filter((record) => record.length > 0)
191+
.map((record) => {
192+
const [sha = "", shortSha = "", subject = "", authorName = "", authoredAt = "0"] =
193+
record.split("\x1f");
194+
return {
195+
sha,
196+
shortSha,
197+
subject,
198+
authorName,
199+
authoredAt: Number.parseInt(authoredAt, 10) * 1000,
200+
};
201+
})
202+
.filter((entry) => entry.sha && entry.subject);
203+
} catch (error) {
204+
if (error instanceof GitError && /does not have any commits yet/i.test(error.stderr)) {
205+
return [];
206+
}
207+
208+
throw error;
209+
}
210+
}
211+
212+
/**
213+
* Get the full patch for a specific commit.
214+
*/
215+
export async function getGitCommitDiff(cwd: string, sha: string): Promise<string> {
216+
const { stdout } = await runGit(cwd, ["show", "--format=medium", "--no-color", sha]);
217+
return stdout;
218+
}
219+
176220
/**
177221
* Get compact git status text for supervisor evaluation prompts.
178222
*/

0 commit comments

Comments
 (0)