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,
}));