Skip to content

Commit 2b5e1de

Browse files
committed
fix: paginate git history on panel scroll
1 parent aac4af4 commit 2b5e1de

9 files changed

Lines changed: 1235 additions & 57 deletions

File tree

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

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ describe("Git Commands", () => {
304304
expect(result.ok).toBe(true);
305305
expect(result.data).toEqual(
306306
expect.objectContaining({
307+
hasMore: false,
307308
entries: expect.arrayContaining([
308309
expect.objectContaining({
309310
subject: "Refresh command surface",
@@ -314,6 +315,71 @@ describe("Git Commands", () => {
314315
);
315316
});
316317

318+
it("returns the next git.log page after a cursor", async () => {
319+
for (const subject of [
320+
"Second command commit",
321+
"Third command commit",
322+
"Fourth command commit",
323+
]) {
324+
await writeFile(join(testDir, "sample.ts"), `${subject}\n`);
325+
await execFileAsync("git", ["add", "."], { cwd: testDir });
326+
await execFileAsync("git", ["commit", "-m", subject], { cwd: testDir });
327+
}
328+
329+
const firstPage = await dispatch(
330+
{
331+
kind: "command",
332+
id: "git-log-page-1",
333+
op: "git.log",
334+
args: {
335+
workspaceId,
336+
limit: 2,
337+
},
338+
},
339+
ctx
340+
);
341+
342+
expect(firstPage.ok).toBe(true);
343+
expect(firstPage.data).toEqual(
344+
expect.objectContaining({
345+
hasMore: true,
346+
entries: [
347+
expect.objectContaining({ subject: "Fourth command commit" }),
348+
expect.objectContaining({ subject: "Third command commit" }),
349+
],
350+
})
351+
);
352+
353+
const cursor = (firstPage.data as { entries: Array<{ sha: string }> }).entries[1]!.sha;
354+
const secondPage = await dispatch(
355+
{
356+
kind: "command",
357+
id: "git-log-page-2",
358+
op: "git.log",
359+
args: {
360+
workspaceId,
361+
limit: 2,
362+
afterSha: cursor,
363+
},
364+
},
365+
ctx
366+
);
367+
368+
expect(secondPage.ok).toBe(true);
369+
expect(secondPage.data).toEqual(
370+
expect.objectContaining({
371+
hasMore: false,
372+
entries: [
373+
expect.objectContaining({ subject: "Second command commit" }),
374+
expect.objectContaining({ subject: "Initial commit" }),
375+
],
376+
})
377+
);
378+
expect(
379+
(secondPage.data as { entries: Array<{ sha: string }> }).entries.map((entry) => entry.sha)
380+
).not.toContain(cursor);
381+
});
382+
317383
it("returns a commit patch for git.show", async () => {
318384
await execFileAsync("git", ["add", "."], { cwd: testDir });
319385
await execFileAsync("git", ["commit", "-m", "Refresh command surface"], { cwd: testDir });

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

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -770,18 +770,19 @@ describe("getGitHistory", () => {
770770

771771
const history = await getGitHistory(testDir, 5);
772772

773-
expect(history).toHaveLength(2);
774-
expect(history[0]).toEqual(
773+
expect(history.hasMore).toBe(false);
774+
expect(history.entries).toHaveLength(2);
775+
expect(history.entries[0]).toEqual(
775776
expect.objectContaining({
776777
subject: "second commit",
777778
authorName: "Test",
778779
})
779780
);
780-
expect(history[0]?.sha).toHaveLength(40);
781-
expect(history[0]?.shortSha).toHaveLength(7);
782-
expect(history[0]?.authoredAt).toBeTypeOf("number");
783-
expect(history[0]!.authoredAt).toBeGreaterThanOrEqual(history[1]!.authoredAt);
784-
expect(history[1]).toEqual(
781+
expect(history.entries[0]?.sha).toHaveLength(40);
782+
expect(history.entries[0]?.shortSha).toHaveLength(7);
783+
expect(history.entries[0]?.authoredAt).toBeTypeOf("number");
784+
expect(history.entries[0]!.authoredAt).toBeGreaterThanOrEqual(history.entries[1]!.authoredAt);
785+
expect(history.entries[1]).toEqual(
785786
expect.objectContaining({
786787
subject: "first commit",
787788
authorName: "Test",
@@ -795,7 +796,45 @@ describe("getGitHistory", () => {
795796
const testDir = await mkdtemp(join(tmpdir(), "git-history-empty-"));
796797
await execFileAsync("git", ["init"], { cwd: testDir });
797798

798-
await expect(getGitHistory(testDir, 5)).resolves.toEqual([]);
799+
await expect(getGitHistory(testDir, 5)).resolves.toEqual({
800+
entries: [],
801+
hasMore: false,
802+
});
803+
804+
await rm(testDir, { recursive: true, force: true });
805+
});
806+
807+
it("returns paged commits after a cursor without duplicating the cursor", async () => {
808+
const testDir = await mkdtemp(join(tmpdir(), "git-history-paged-"));
809+
await execFileAsync("git", ["init"], { cwd: testDir });
810+
await execFileAsync("git", ["config", "user.name", "Test"], { cwd: testDir });
811+
await execFileAsync("git", ["config", "user.email", "test@example.com"], { cwd: testDir });
812+
813+
for (const subject of ["first commit", "second commit", "third commit", "fourth commit"]) {
814+
await writeFile(join(testDir, "file.txt"), `${subject}\n`);
815+
await execFileAsync("git", ["add", "."], { cwd: testDir });
816+
await execFileAsync("git", ["commit", "-m", subject], { cwd: testDir });
817+
}
818+
819+
const firstPage = await getGitHistory(testDir, { limit: 2 });
820+
821+
expect(firstPage.hasMore).toBe(true);
822+
expect(firstPage.entries.map((entry) => entry.subject)).toEqual([
823+
"fourth commit",
824+
"third commit",
825+
]);
826+
827+
const secondPage = await getGitHistory(testDir, {
828+
limit: 2,
829+
afterSha: firstPage.entries[1]!.sha,
830+
});
831+
832+
expect(secondPage.hasMore).toBe(false);
833+
expect(secondPage.entries.map((entry) => entry.subject)).toEqual([
834+
"second commit",
835+
"first commit",
836+
]);
837+
expect(secondPage.entries.map((entry) => entry.sha)).not.toContain(firstPage.entries[1]!.sha);
799838

800839
await rm(testDir, { recursive: true, force: true });
801840
});

packages/server/src/commands/git.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ const gitCommitRevisionSchema = z
3636

3737
const GIT_BACKGROUND_FETCH_TIMEOUT_MS = 30 * 1000;
3838

39+
function debugGitHistoryCommand(message: string, details?: Record<string, unknown>) {
40+
if (process.env.CODER_STUDIO_DEBUG_GIT_HISTORY !== "1") {
41+
return;
42+
}
43+
44+
console.log("[git-history]", message, details);
45+
}
46+
3947
async function runGitNetworkOperation<T>(
4048
ctx: CommandContext,
4149
workspaceId: string,
@@ -131,16 +139,35 @@ registerCommand(
131139
z.object({
132140
workspaceId: z.string(),
133141
limit: z.number().int().min(1).max(50).optional(),
142+
afterSha: gitCommitRevisionSchema.optional(),
134143
}),
135144
async (args, ctx) => {
136145
const workspace = ctx.workspaceMgr.get(args.workspaceId);
137146
if (!workspace) {
138147
throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` };
139148
}
140149

141-
return {
142-
entries: await getGitHistory(workspace.path, args.limit ?? 5),
143-
};
150+
debugGitHistoryCommand("command git.log request", {
151+
workspaceId: args.workspaceId,
152+
workspacePath: workspace.path,
153+
limit: args.limit ?? 5,
154+
afterSha: args.afterSha,
155+
});
156+
157+
const history = await getGitHistory(workspace.path, {
158+
limit: args.limit ?? 5,
159+
afterSha: args.afterSha,
160+
});
161+
162+
debugGitHistoryCommand("command git.log response", {
163+
workspaceId: args.workspaceId,
164+
entryCount: history.entries.length,
165+
hasMore: history.hasMore,
166+
firstSha: history.entries[0]?.sha,
167+
lastSha: history.entries.at(-1)?.sha,
168+
});
169+
170+
return history;
144171
}
145172
);
146173

packages/server/src/git/cli.ts

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -181,18 +181,53 @@ export async function getGitStatus(cwd: string): Promise<GitStatus> {
181181
};
182182
}
183183

184+
export interface GitHistoryOptions {
185+
limit?: number;
186+
afterSha?: string;
187+
}
188+
189+
export interface GitHistoryResult {
190+
entries: GitCommitSummary[];
191+
hasMore: boolean;
192+
}
193+
194+
function debugGitHistory(message: string, details?: Record<string, unknown>) {
195+
if (process.env.CODER_STUDIO_DEBUG_GIT_HISTORY !== "1") {
196+
return;
197+
}
198+
199+
console.log("[git-history]", message, details);
200+
}
201+
184202
/**
185203
* Get recent commit history for the current HEAD.
186204
*/
187-
export async function getGitHistory(cwd: string, limit = 5): Promise<GitCommitSummary[]> {
205+
export async function getGitHistory(
206+
cwd: string,
207+
options: number | GitHistoryOptions = 5
208+
): Promise<GitHistoryResult> {
209+
const limit = Math.max(1, typeof options === "number" ? options : (options.limit ?? 5));
210+
const afterSha = typeof options === "number" ? undefined : options.afterSha;
211+
const pageSize = limit + 1;
212+
const revisionArgs = afterSha ? ["--skip=1", "--end-of-options", afterSha] : [];
213+
188214
try {
215+
debugGitHistory("server get history request", {
216+
cwd,
217+
limit,
218+
pageSize,
219+
afterSha,
220+
revisionArgs,
221+
});
222+
189223
const { stdout } = await runGit(cwd, [
190224
"log",
191-
`--max-count=${Math.max(1, limit)}`,
225+
`--max-count=${pageSize}`,
192226
"--format=%H%x1f%h%x1f%s%x1f%an%x1f%at%x1e",
227+
...revisionArgs,
193228
]);
194229

195-
return stdout
230+
const entries = stdout
196231
.split("\x1e")
197232
.map((record) => record.trim())
198233
.filter((record) => record.length > 0)
@@ -208,9 +243,30 @@ export async function getGitHistory(cwd: string, limit = 5): Promise<GitCommitSu
208243
};
209244
})
210245
.filter((entry) => entry.sha && entry.subject);
246+
247+
debugGitHistory("server get history response", {
248+
cwd,
249+
limit,
250+
afterSha,
251+
rawRecordCount: stdout.split("\x1e").filter((record) => record.trim().length > 0).length,
252+
parsedCount: entries.length,
253+
returnedCount: entries.slice(0, limit).length,
254+
hasMore: entries.length > limit,
255+
firstSha: entries[0]?.sha,
256+
lastReturnedSha: entries.slice(0, limit).at(-1)?.sha,
257+
extraSha: entries[limit]?.sha,
258+
});
259+
260+
return {
261+
entries: entries.slice(0, limit),
262+
hasMore: entries.length > limit,
263+
};
211264
} catch (error) {
212265
if (error instanceof GitError && /does not have any commits yet/i.test(error.stderr)) {
213-
return [];
266+
return {
267+
entries: [],
268+
hasMore: false,
269+
};
214270
}
215271

216272
throw error;

0 commit comments

Comments
 (0)