From 8cf70d10bf438c5f1fb032b7028bba5949ec07a7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Jul 2026 12:17:48 +0200 Subject: [PATCH] Show workspace commits clearly in Changes (#2350) * feat(commits): distinguish workspace history from base commits Keep every workspace commit visible while bounding base history to ten context commits, and make push and base state readable in the commit rail. * fix(commits): preserve history when base refs disappear Fall back to recent HEAD history for stale saved bases, and reject truncated git output instead of presenting an incomplete commit list. * fix(commits): classify history against the current base Use the furthest-ahead local or remote base for classification, and re-infer base history when saved worktree metadata points to a deleted branch. --- .../git/commits-section/commit-graph-node.tsx | 79 +++++++++++ .../src/git/commits-section/commit-row.tsx | 55 ++++++-- .../git/commits-section/commits-section.tsx | 16 ++- .../app/src/git/commits-section/shared.ts | 37 ----- packages/app/src/git/use-commits-query.ts | 21 ++- packages/app/src/i18n/resources/ar.ts | 2 +- packages/app/src/i18n/resources/en.ts | 2 +- packages/app/src/i18n/resources/es.ts | 2 +- packages/app/src/i18n/resources/fr.ts | 2 +- packages/app/src/i18n/resources/ja.ts | 2 +- packages/app/src/i18n/resources/pt-BR.ts | 2 +- packages/app/src/i18n/resources/ru.ts | 2 +- packages/app/src/i18n/resources/zh-CN.ts | 2 +- .../src/messages.checkout-commits.test.ts | 34 ++++- packages/protocol/src/messages.ts | 4 + .../src/server/daemon-client.e2e.test.ts | 1 + .../server/src/server/websocket-server.ts | 2 + .../src/utils/checkout-git.commits.test.ts | 90 ++++++++++++- packages/server/src/utils/checkout-git.ts | 127 ++++++++++++------ 19 files changed, 372 insertions(+), 110 deletions(-) create mode 100644 packages/app/src/git/commits-section/commit-graph-node.tsx delete mode 100644 packages/app/src/git/commits-section/shared.ts diff --git a/packages/app/src/git/commits-section/commit-graph-node.tsx b/packages/app/src/git/commits-section/commit-graph-node.tsx new file mode 100644 index 000000000..f6ac03d16 --- /dev/null +++ b/packages/app/src/git/commits-section/commit-graph-node.tsx @@ -0,0 +1,79 @@ +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import type { ClassifiedCheckoutCommit } from "@/git/use-commits-query"; + +interface CommitGraphNodeProps { + commit: ClassifiedCheckoutCommit; + isFirst: boolean; + isLast: boolean; +} + +export function CommitGraphNode({ commit, isFirst, isLast }: CommitGraphNodeProps) { + const isOnBase = commit.isOnBase; + const railColor = isOnBase ? styles.railBase : styles.railWorkspace; + const markerColor = isOnBase ? styles.markerBase : styles.markerWorkspace; + + return ( + + {isFirst && isLast ? null : ( + + )} + + + ); +} + +const MARKER_SIZE = 8; +const RAIL_WIDTH = 2; + +const styles = StyleSheet.create((theme) => ({ + container: { + width: MARKER_SIZE, + alignSelf: "stretch", + alignItems: "center", + justifyContent: "center", + position: "relative", + flexShrink: 0, + }, + rail: { + position: "absolute", + top: -theme.spacing[1] - 1, + bottom: -theme.spacing[1] - 1, + width: RAIL_WIDTH, + }, + railFirst: { + top: "50%", + }, + railLast: { + bottom: "50%", + }, + railBase: { + backgroundColor: theme.colors.foregroundMuted, + }, + railWorkspace: { + backgroundColor: theme.colors.accent, + }, + marker: { + width: MARKER_SIZE, + height: MARKER_SIZE, + borderRadius: theme.borderRadius.full, + borderWidth: theme.borderWidth[2], + zIndex: 1, + }, + markerBase: { + backgroundColor: theme.colors.foregroundMuted, + borderColor: theme.colors.foregroundMuted, + }, + markerWorkspace: { + backgroundColor: theme.colors.accent, + borderColor: theme.colors.accent, + }, + markerRing: { + backgroundColor: theme.colors.surface0, + }, +})); diff --git a/packages/app/src/git/commits-section/commit-row.tsx b/packages/app/src/git/commits-section/commit-row.tsx index b5cdb9213..4da725c91 100644 --- a/packages/app/src/git/commits-section/commit-row.tsx +++ b/packages/app/src/git/commits-section/commit-row.tsx @@ -1,18 +1,33 @@ import { memo, useCallback } from "react"; -import { Pressable, Text, View } from "react-native"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import type { CheckoutCommit } from "@getpaseo/protocol/messages"; import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron"; +import type { ClassifiedCheckoutCommit } from "@/git/use-commits-query"; import { formatTimeAgo } from "@/utils/time"; -import { dotStyles } from "./shared"; +import { CommitGraphNode } from "./commit-graph-node"; interface CommitRowProps { - commit: CheckoutCommit; + commit: ClassifiedCheckoutCommit; + isFirst: boolean; + isLast: boolean; now: Date; onCommitPress: (sha: string) => void; } -export const CommitRow = memo(function CommitRow({ commit, now, onCommitPress }: CommitRowProps) { +function commitRowPressableStyle({ + hovered, + pressed, +}: PressableStateCallbackType & { hovered?: boolean }) { + return [styles.row, (Boolean(hovered) || pressed) && styles.rowActive]; +} + +export const CommitRow = memo(function CommitRow({ + commit, + isFirst, + isLast, + now, + onCommitPress, +}: CommitRowProps) { const handlePress = useCallback(() => { onCommitPress(commit.sha); }, [commit.sha, onCommitPress]); @@ -22,16 +37,17 @@ export const CommitRow = memo(function CommitRow({ commit, now, onCommitPress }: accessibilityRole="button" testID={`commit-row-${commit.shortSha}`} onPress={handlePress} - style={styles.row} + style={commitRowPressableStyle} > - - {commit.shortSha} - - {commit.subject} - + + + + {commit.shortSha} + + + {commit.subject} + + {formatTimeAgo(new Date(commit.authorDate), now)} @@ -49,10 +65,21 @@ const styles = StyleSheet.create((theme) => ({ paddingRight: theme.spacing[2], paddingVertical: theme.spacing[1], }, + rowActive: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, + commitDetails: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, shortSha: { fontSize: theme.fontSize.xs, fontFamily: theme.fontFamily.mono, color: theme.colors.foregroundMuted, + width: theme.spacing[16], flexShrink: 0, }, subject: { diff --git a/packages/app/src/git/commits-section/commits-section.tsx b/packages/app/src/git/commits-section/commits-section.tsx index 97107cfb8..931d95e29 100644 --- a/packages/app/src/git/commits-section/commits-section.tsx +++ b/packages/app/src/git/commits-section/commits-section.tsx @@ -67,8 +67,15 @@ function CommitsSectionContent({ } return ( - {query.data.commits.map((commit) => ( - + {query.data.commits.map((commit, index) => ( + ))} ); @@ -110,7 +117,10 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP if (query.status === "unsupported") { return null; } - const commitCount = query.status === "loaded" ? query.data.commits.length : null; + const commitCount = + query.status === "loaded" + ? query.data.commits.filter((commit) => !commit.isOnBase).length + : null; return ( diff --git a/packages/app/src/git/commits-section/shared.ts b/packages/app/src/git/commits-section/shared.ts deleted file mode 100644 index e193cefbc..000000000 --- a/packages/app/src/git/commits-section/shared.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { StyleSheet } from "react-native-unistyles"; -import type { CheckoutCommit } from "@getpaseo/protocol/messages"; - -export type CheckoutCommitFile = CheckoutCommit["files"][number]; - -export type FilePressHandler = (commit: CheckoutCommit, file: CheckoutCommitFile) => void; - -export const DOT_SIZE = 8; - -// The "on remote" dot is intentionally understated: the local-only ring is the -// state worth noticing (you still have work to push), so the remote fill is -// dimmed toward the background rather than rendered at full-strength green. -const REMOTE_DOT_OPACITY = 0.55; - -/** - * A local-only commit is a hollow ring; a commit that has reached the remote - * is a subtle (dimmed) filled green dot. - */ -export const dotStyles = StyleSheet.create((theme) => ({ - dotLocal: { - width: DOT_SIZE, - height: DOT_SIZE, - borderRadius: theme.borderRadius.full, - backgroundColor: "transparent", - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.foregroundMuted, - flexShrink: 0, - }, - dotRemote: { - width: DOT_SIZE, - height: DOT_SIZE, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.statusSuccess, - opacity: REMOTE_DOT_OPACITY, - flexShrink: 0, - }, -})); diff --git a/packages/app/src/git/use-commits-query.ts b/packages/app/src/git/use-commits-query.ts index 19b24ba80..a1c5edbff 100644 --- a/packages/app/src/git/use-commits-query.ts +++ b/packages/app/src/git/use-commits-query.ts @@ -1,4 +1,5 @@ import type { CheckoutCommit } from "@getpaseo/protocol/messages"; +import invariant from "tiny-invariant"; import { useFetchQuery } from "@/data/query"; import { checkoutCommitsQueryKey } from "@/git/query-keys"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; @@ -16,7 +17,11 @@ interface UseCheckoutCommitsQueryOptions { export interface CheckoutCommitsData { baseRef: string | null; - commits: CheckoutCommit[]; + commits: ClassifiedCheckoutCommit[]; +} + +export interface ClassifiedCheckoutCommit extends CheckoutCommit { + isOnBase: boolean; } export type CheckoutCommitsQueryResult = @@ -69,10 +74,13 @@ export function useCheckoutCommitsQuery({ }: UseCheckoutCommitsQueryOptions): CheckoutCommitsQueryResult { const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); - // COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16. + // COMPAT(commitsList): added in v0.1.110, remove after 2027-01-16. + // COMPAT(commitBaseClassification): added in v0.2.0, remove after 2027-01-23. // Single capability-detection site; downstream reads a clean load-state union. const capabilityPresent = useSessionStore( - (state) => state.sessions[serverId]?.serverInfo?.features?.commitsList === true, + (state) => + state.sessions[serverId]?.serverInfo?.features?.commitsList === true && + state.sessions[serverId]?.serverInfo?.features?.commitBaseClassification === true, ); const canFetch = Boolean(cwd) && Boolean(client) && isConnected; @@ -84,7 +92,12 @@ export function useCheckoutCommitsQuery({ if (!client) { throw new Error("Host disconnected"); } - return client.listCheckoutCommits(cwd); + const data = await client.listCheckoutCommits(cwd); + const commits = data.commits.map((commit) => { + invariant(commit.isOnBase !== undefined, "Host omitted commit base classification"); + return { ...commit, isOnBase: commit.isOnBase }; + }); + return { baseRef: data.baseRef, commits }; }, enabled: queryEnabled, staleTimeMs: CHECKOUT_COMMITS_STALE_TIME, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 6f6518733..442092530 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -795,7 +795,7 @@ export const ar: TranslationResources = { deletedFile: "تم الحذف", commits: { title: "الإيداعات", - countLabel: "{{count}} من الإيداعات الأخيرة", + countLabel: "{{count}} من إيداعات مساحة العمل", fileDiffEmpty: "لا توجد تغييرات لعرضها", fileDiffError: "تعذّر تحميل فروق الملف", loading: "جارٍ تحميل الإيداعات…", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index bc8bba546..36440715d 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -805,7 +805,7 @@ export const en = { deletedFile: "Deleted", commits: { title: "Commits", - countLabel: "{{count}} recent commits", + countLabel: "{{count}} workspace commits", fileDiffEmpty: "No changes to display", fileDiffError: "Failed to load file diff", loading: "Loading commits…", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 204273c96..9558f1d65 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -826,7 +826,7 @@ export const es: TranslationResources = { deletedFile: "Eliminado", commits: { title: "Commits", - countLabel: "{{count}} commits recientes", + countLabel: "{{count}} commits del espacio de trabajo", fileDiffEmpty: "No hay cambios para mostrar", fileDiffError: "Error al cargar el diff del archivo", loading: "Cargando commits…", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 07dd95a2a..e46900e01 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -825,7 +825,7 @@ export const fr: TranslationResources = { deletedFile: "Supprimé", commits: { title: "Commits", - countLabel: "{{count}} commits récents", + countLabel: "{{count}} commits de l’espace de travail", fileDiffEmpty: "Aucune modification à afficher", fileDiffError: "Échec du chargement du diff du fichier", loading: "Chargement des commits…", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 5a27447dc..e0e1771f3 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -806,7 +806,7 @@ export const ja: TranslationResources = { deletedFile: "削除済み", commits: { title: "コミット", - countLabel: "最近のコミット数: {{count}}", + countLabel: "ワークスペースのコミット数: {{count}}", fileDiffEmpty: "表示する変更はありません", fileDiffError: "ファイル差分の読み込みに失敗しました", loading: "コミットを読み込み中…", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 2fce56ddd..7c1b2d923 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -817,7 +817,7 @@ export const ptBR: TranslationResources = { deletedFile: "Excluído", commits: { title: "Commits", - countLabel: "{{count}} commits recentes", + countLabel: "{{count}} commits do espaço de trabalho", fileDiffEmpty: "Nenhuma alteração para exibir", fileDiffError: "Falha ao carregar diff do arquivo", loading: "Carregando commits…", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 51e83e28d..aa1e391f1 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -817,7 +817,7 @@ export const ru: TranslationResources = { deletedFile: "Удалено", commits: { title: "Коммиты", - countLabel: "{{count}} последних коммитов", + countLabel: "{{count}} коммитов рабочего пространства", fileDiffEmpty: "Нет изменений для отображения", fileDiffError: "Не удалось загрузить различия файла", loading: "Загрузка коммитов…", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index b9ddd3cc7..27e4b046d 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -787,7 +787,7 @@ export const zhCN: TranslationResources = { deletedFile: "已删除", commits: { title: "提交", - countLabel: "最近 {{count}} 个提交", + countLabel: "{{count}} 个工作区提交", fileDiffEmpty: "没有可显示的更改", fileDiffError: "加载文件差异失败", loading: "正在加载提交…", diff --git a/packages/protocol/src/messages.checkout-commits.test.ts b/packages/protocol/src/messages.checkout-commits.test.ts index 290f39450..8d1c528ae 100644 --- a/packages/protocol/src/messages.checkout-commits.test.ts +++ b/packages/protocol/src/messages.checkout-commits.test.ts @@ -35,6 +35,7 @@ describe("checkout.commits.list schemas", () => { authorName: "Ada", authorDate: "2026-06-13T10:00:00.000Z", isOnRemote: true, + isOnBase: false, files: [ { path: "src/a.ts", additions: 10, deletions: 2, status: "modified" }, { path: "src/b.ts", additions: 5, deletions: 0, status: "added" }, @@ -47,6 +48,7 @@ describe("checkout.commits.list schemas", () => { authorName: "Ada", authorDate: "2026-06-13T11:00:00.000Z", isOnRemote: false, + isOnBase: true, files: [{ path: "src/c.ts", additions: 1, deletions: 1 }], }, ], @@ -61,10 +63,37 @@ describe("checkout.commits.list schemas", () => { expect(parsed.payload).toEqual(payload); expect(parsed.payload.commits[0]?.isOnRemote).toBe(true); + expect(parsed.payload.commits[0]?.isOnBase).toBe(false); expect(parsed.payload.commits[1]?.isOnRemote).toBe(false); + expect(parsed.payload.commits[1]?.isOnBase).toBe(true); expect(parsed.payload.commits[1]?.files[0]?.status).toBeUndefined(); }); + test("still parses commits from hosts without base classification", () => { + const parsed = CheckoutCommitsListResponseSchema.parse({ + type: "checkout.commits.list.response", + payload: { + cwd: "/tmp/repo", + baseRef: "main", + commits: [ + { + sha: "1111111111111111111111111111111111111111", + shortSha: "1111111", + subject: "Legacy commit", + authorName: "Ada", + authorDate: "2026-06-13T10:00:00.000Z", + isOnRemote: true, + files: [], + }, + ], + error: null, + requestId: "request-commits", + }, + }); + + expect(parsed.payload.commits[0]?.isOnBase).toBeUndefined(); + }); + test("accepts a null baseRef and an error payload", () => { const payload = { cwd: "/tmp/repo", @@ -107,16 +136,17 @@ describe("checkout.commits.list schemas", () => { ).toMatchObject({ type: "checkout.commits.list.response" }); }); - test("accepts the commitsList server_info feature flag", () => { + test("accepts the commit history server_info feature flags", () => { expect( ServerInfoStatusPayloadSchema.parse({ status: "server_info", serverId: "srv_test", features: { commitsList: true, + commitBaseClassification: true, }, }).features, - ).toEqual({ commitsList: true }); + ).toEqual({ commitsList: true, commitBaseClassification: true }); }); test("still parses server_info without the commitsList feature flag", () => { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index f6be9128d..f2e490766 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1732,6 +1732,8 @@ const CheckoutCommitSchema = z.object({ authorName: z.string(), authorDate: z.string(), // ISO 8601 isOnRemote: z.boolean(), // false = local-only (unpushed) + // COMPAT(commitBaseClassification): added in v0.2.0, remove optional after 2027-01-23. + isOnBase: z.boolean().optional(), files: z.array(CheckoutCommitFileSchema), }); @@ -2777,6 +2779,8 @@ export const ServerInfoStatusPayloadSchema = z projectCreateDirectory: z.boolean().optional(), // COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16. commitsList: z.boolean().optional(), + // COMPAT(commitBaseClassification): added in v0.2.0, remove gate after 2027-01-23. + commitBaseClassification: z.boolean().optional(), // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. providerRemoval: z.boolean().optional(), // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 44c4b51be..bdc1b269d 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -1008,6 +1008,7 @@ test("receives server_info on websocket connect", async () => { expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true); expect(serverInfo?.features?.hubRelationship).toBe(true); expect(serverInfo?.features?.commitsList).toBe(true); + expect(serverInfo?.features?.commitBaseClassification).toBe(true); expect(serverInfo?.desktopManaged).toBe(false); expect(serverInfo?.features?.daemonSelfUpdate).toBe(true); expect(serverInfo?.features?.worktreeRestore).toBe(true); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index cec656af2..35929bb84 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1408,6 +1408,8 @@ export class VoiceAssistantWebSocketServer { projectCreateDirectory: true, // COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16. commitsList: true, + // COMPAT(commitBaseClassification): added in v0.2.0, remove gate after 2027-01-23. + commitBaseClassification: true, // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. providerRemoval: true, // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. diff --git a/packages/server/src/utils/checkout-git.commits.test.ts b/packages/server/src/utils/checkout-git.commits.test.ts index 83bb1e745..c2d48346b 100644 --- a/packages/server/src/utils/checkout-git.commits.test.ts +++ b/packages/server/src/utils/checkout-git.commits.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "os"; import { join } from "path"; import { afterEach, describe, expect, it } from "vitest"; import { listCheckoutCommits } from "./checkout-git.js"; +import { writePaseoWorktreeMetadata } from "./worktree-metadata.js"; const tempDirs: string[] = []; @@ -73,6 +74,9 @@ describe("listCheckoutCommits", () => { expect(commits[0]?.isOnRemote).toBe(false); expect(commits[1]?.isOnRemote).toBe(true); expect(commits[2]?.isOnRemote).toBe(true); + expect(commits[0]?.isOnBase).toBe(false); + expect(commits[1]?.isOnBase).toBe(false); + expect(commits[2]?.isOnBase).toBe(true); expect(commits[0]?.files).toEqual([ { path: "bar.txt", additions: 1, deletions: 0, status: "added" }, @@ -92,6 +96,26 @@ describe("listCheckoutCommits", () => { const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir }); expect(baseRef).toBeNull(); expect(commits.map((entry) => entry.subject)).toEqual(["initial"]); + expect(commits[0]?.isOnBase).toBe(true); + }); + + it("keeps recent history when the saved base branch no longer exists", async () => { + const { repoDir, tempDir } = initRepoOnMain(); + const worktreesRoot = join(tempDir, "worktrees"); + const worktreeDir = join(worktreesRoot, "repo-hash", "feature"); + mkdirSync(join(worktreesRoot, "repo-hash"), { recursive: true }); + git(["worktree", "add", "-b", "feature", worktreeDir], repoDir); + commitFile(worktreeDir, "feature.txt", "feature\n", "Feature work"); + writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" }); + + const { baseRef, commits } = await listCheckoutCommits({ + cwd: worktreeDir, + context: { worktreesRoot }, + }); + + expect(baseRef).toBe("main"); + expect(commits.map((entry) => entry.subject)).toEqual(["Feature work", "initial"]); + expect(commits.map((entry) => entry.isOnBase)).toEqual([false, true]); }); it("marks all commits local-only when there is no remote", async () => { @@ -122,19 +146,77 @@ describe("listCheckoutCommits", () => { ]); }); - it("limits history and unpushed classification to the 20 most recent commits", async () => { + it("keeps local base commits out of workspace history when the local base is ahead", async () => { + const { repoDir, tempDir } = initRepoOnMain(); + addBareRemote(repoDir, tempDir); + git(["push", "-u", "origin", "main"], repoDir); + commitFile(repoDir, "local-base.txt", "base\n", "Local base work"); + git(["checkout", "-b", "feature"], repoDir); + commitFile(repoDir, "feature.txt", "feature\n", "Feature work"); + + const { commits } = await listCheckoutCommits({ cwd: repoDir }); + + expect(commits.map(({ subject, isOnBase }) => ({ subject, isOnBase }))).toEqual([ + { subject: "Feature work", isOnBase: false }, + { subject: "Local base work", isOnBase: true }, + { subject: "initial", isOnBase: true }, + ]); + }); + + it("shows every workspace commit followed by at most 10 base commits", async () => { const { repoDir } = initRepoOnMain(); + for (let index = 1; index <= 14; index += 1) { + commitFile(repoDir, "base-history.txt", `${index}\n`, `Base ${index}`); + } + git(["checkout", "-b", "feature"], repoDir); for (let index = 1; index <= 24; index += 1) { + commitFile(repoDir, "workspace-history.txt", `${index}\n`, `Workspace ${index}`); + } + + const { commits } = await listCheckoutCommits({ cwd: repoDir }); + + expect(commits).toHaveLength(34); + expect(commits.every((entry) => entry.isOnRemote === false)).toBe(true); + expect(commits.slice(0, 24).map((entry) => entry.subject)).toEqual( + Array.from({ length: 24 }, (_, index) => `Workspace ${24 - index}`), + ); + expect(commits.slice(0, 24).every((entry) => entry.isOnBase === false)).toBe(true); + expect(commits.slice(24).map((entry) => entry.subject)).toEqual( + Array.from({ length: 10 }, (_, index) => `Base ${14 - index}`), + ); + expect(commits.slice(24).every((entry) => entry.isOnBase === true)).toBe(true); + }); + + it("starts base context at the fork point when the base branch has advanced", async () => { + const { repoDir } = initRepoOnMain(); + commitFile(repoDir, "shared.txt", "shared\n", "Shared base"); + git(["checkout", "-b", "feature"], repoDir); + commitFile(repoDir, "feature.txt", "feature\n", "Feature work"); + git(["checkout", "main"], repoDir); + commitFile(repoDir, "newer-base.txt", "newer\n", "Newer base"); + git(["checkout", "feature"], repoDir); + + const { commits } = await listCheckoutCommits({ cwd: repoDir }); + + expect(commits.map(({ subject, isOnBase }) => ({ subject, isOnBase }))).toEqual([ + { subject: "Feature work", isOnBase: false }, + { subject: "Shared base", isOnBase: true }, + { subject: "initial", isOnBase: true }, + ]); + }); + + it("limits base-branch history to 10 commits", async () => { + const { repoDir } = initRepoOnMain(); + for (let index = 1; index <= 14; index += 1) { commitFile(repoDir, "history.txt", `${index}\n`, `Commit ${index}`); } const { commits } = await listCheckoutCommits({ cwd: repoDir }); - expect(commits).toHaveLength(20); - expect(commits.every((entry) => entry.isOnRemote === false)).toBe(true); expect(commits.map((entry) => entry.subject)).toEqual( - Array.from({ length: 20 }, (_, index) => `Commit ${24 - index}`), + Array.from({ length: 10 }, (_, index) => `Commit ${14 - index}`), ); + expect(commits.every((entry) => entry.isOnBase === true)).toBe(true); }); it("shows merged branch commits and compares the merge against its first parent", async () => { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index ffac0e5f2..f518adfed 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1988,8 +1988,9 @@ export async function getCheckoutStatus( }; } -// The explorer is a recent-history view, not a full repository log. -const MAX_CHECKOUT_COMMITS = 20; +// Workspace history stays complete; base history is bounded context until the +// commits list supports paging older base commits. +const CHECKOUT_BASE_COMMIT_LIMIT = 10; // Bytes git emits between fields/records. We split parsed output on these. const COMMIT_FIELD_SEPARATOR = "\x00"; const COMMIT_RECORD_SEPARATOR = "\x1e"; @@ -2009,6 +2010,12 @@ interface ParsedCheckoutCommit { files: CheckoutCommitFile[]; } +interface CheckoutCommitLogInput { + cwd: string; + revision: string; + maxCount?: number; +} + function mapNameStatusLetter(letter: string): CheckoutCommitFileStatus | undefined { switch (letter) { case "A": @@ -2133,14 +2140,11 @@ function parseCheckoutCommitRecords(stdout: string): ParsedCheckoutCommit[] { // Returns commits reachable from HEAD that are not reachable from any remote ref. async function getUnpushedCommitShas(cwd: string, context?: CheckoutContext): Promise> { - const { stdout } = await runGitCommand( - ["rev-list", `--max-count=${MAX_CHECKOUT_COMMITS}`, "HEAD", "--not", "--remotes"], - { - cwd, - envOverlay: READ_ONLY_GIT_ENV, - logger: context?.logger, - }, - ); + const { stdout } = await runGitCommand(["rev-list", "HEAD", "--not", "--remotes"], { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + logger: context?.logger, + }); return new Set( stdout .split("\n") @@ -2149,58 +2153,104 @@ async function getUnpushedCommitShas(cwd: string, context?: CheckoutContext): Pr ); } -/** - * Lists the current branch's 20 most recent commits, newest first, each flagged - * local-only vs on-remote with per-commit file +/- stats. - */ +async function getCheckoutCommitRecords({ + cwd, + revision, + maxCount, +}: CheckoutCommitLogInput): Promise { + const args = [ + "log", + revision, + "--diff-merges=first-parent", + `--format=${COMMIT_LOG_FORMAT}`, + "--raw", + "--numstat", + "-M", + ]; + if (maxCount !== undefined) { + args.splice(2, 0, `--max-count=${maxCount}`); + } + + const result = await runGitCommand(args, { cwd, envOverlay: READ_ONLY_GIT_ENV }); + if (result.truncated) { + throw new Error("Commit history exceeded the git output limit"); + } + return parseCheckoutCommitRecords(result.stdout); +} + export interface CheckoutCommitsResult { baseRef: string | null; commits: CheckoutCommit[]; } +async function tryResolveCheckoutCommitsBaseRef( + cwd: string, + baseRef: string | null, + currentBranch: string, +): Promise { + if (!baseRef) { + return null; + } + const normalizedBaseRef = normalizeLocalBranchRefName(baseRef); + if (!normalizedBaseRef || normalizedBaseRef === currentBranch) { + return null; + } + return resolveMostAheadBaseRef(cwd, normalizedBaseRef).catch(() => null); +} + export async function listCheckoutCommits({ cwd, + context, }: { cwd: string; + context?: CheckoutContext; }): Promise { const currentBranch = await getCurrentBranch(cwd); if (!currentBranch) { return { baseRef: null, commits: [] }; } - const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd); + const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context); const normalizedBaseRef = resolvedBaseRef ? normalizeLocalBranchRefName(resolvedBaseRef) : null; - let comparisonBaseRef: string | null = null; - if (resolvedBaseRef && normalizedBaseRef && normalizedBaseRef !== currentBranch) { - try { - comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef); - } catch { - // History does not depend on the configured base being available. - } + let comparisonBaseRef = await tryResolveCheckoutCommitsBaseRef( + cwd, + resolvedBaseRef, + currentBranch, + ); + if (!comparisonBaseRef && normalizedBaseRef && normalizedBaseRef !== currentBranch) { + // Saved worktree metadata can outlive a renamed or deleted base branch. + comparisonBaseRef = await tryResolveCheckoutCommitsBaseRef( + cwd, + await resolveBaseRef(cwd), + currentBranch, + ); } - // Single pass: `--raw` carries the status letter, `--numstat` the +/- counts. - // (`--name-status` cannot be combined with `--numstat` — git emits only one.) - const logResult = await runGitCommand( - [ - "log", - "HEAD", - "--diff-merges=first-parent", - `--max-count=${MAX_CHECKOUT_COMMITS}`, - `--format=${COMMIT_LOG_FORMAT}`, - "--raw", - "--numstat", - "-M", - ], - { cwd, envOverlay: READ_ONLY_GIT_ENV }, - ); + let workspaceRecords: ParsedCheckoutCommit[] = []; + let baseRevision = "HEAD"; + if (comparisonBaseRef) { + const [records, mergeBase] = await Promise.all([ + getCheckoutCommitRecords({ cwd, revision: `${comparisonBaseRef}..HEAD` }), + tryResolveMergeBase(cwd, comparisonBaseRef), + ]); + workspaceRecords = records; + baseRevision = mergeBase ?? ""; + } - const records = parseCheckoutCommitRecords(logResult.stdout); + const baseRecords = baseRevision + ? await getCheckoutCommitRecords({ + cwd, + revision: baseRevision, + maxCount: CHECKOUT_BASE_COMMIT_LIMIT, + }) + : []; + const records = [...workspaceRecords, ...baseRecords]; if (records.length === 0) { return { baseRef: comparisonBaseRef, commits: [] }; } const unpushedShas = await getUnpushedCommitShas(cwd); + const workspaceShas = new Set(workspaceRecords.map((record) => record.sha)); const commits = records.map((record) => ({ sha: record.sha, @@ -2209,6 +2259,7 @@ export async function listCheckoutCommits({ authorName: record.authorName, authorDate: record.authorDate, isOnRemote: !unpushedShas.has(record.sha), + isOnBase: !workspaceShas.has(record.sha), files: record.files, }));