From 309672c8e54f0e6af48463b3737b74a1c1352a7a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 21 Jul 2026 22:12:33 +0200 Subject: [PATCH] Show recent commit history in the explorer (#2312) * feat(app): show recent commit history in explorer * fix(app): keep recent commit history accurate * fix(app): include merged commits in recent history * perf(app): bound recent commit classification * fix(app): refresh recent commits on Git updates --- .../app/src/git/checkout-status-cache.test.ts | 25 +++- packages/app/src/git/checkout-status-cache.ts | 4 + packages/app/src/git/query-keys.test.ts | 17 +++ packages/app/src/git/query-keys.ts | 4 + packages/app/src/i18n/resources/ar.ts | 4 +- packages/app/src/i18n/resources/en.ts | 4 +- packages/app/src/i18n/resources/es.ts | 4 +- packages/app/src/i18n/resources/fr.ts | 4 +- packages/app/src/i18n/resources/ja.ts | 4 +- packages/app/src/i18n/resources/pt-BR.ts | 4 +- packages/app/src/i18n/resources/ru.ts | 4 +- packages/app/src/i18n/resources/zh-CN.ts | 4 +- .../checkout-git.commit-file-diff.test.ts | 18 +++ .../src/utils/checkout-git.commits.test.ts | 63 +++++++++- packages/server/src/utils/checkout-git.ts | 114 ++++++------------ 15 files changed, 180 insertions(+), 97 deletions(-) diff --git a/packages/app/src/git/checkout-status-cache.test.ts b/packages/app/src/git/checkout-status-cache.test.ts index 5792a17fa..4bd435e4e 100644 --- a/packages/app/src/git/checkout-status-cache.test.ts +++ b/packages/app/src/git/checkout-status-cache.test.ts @@ -2,7 +2,11 @@ import { QueryClient } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages"; -import { checkoutPrStatusQueryKey, checkoutStatusQueryKey } from "@/git/query-keys"; +import { + checkoutCommitsQueryKey, + checkoutPrStatusQueryKey, + checkoutStatusQueryKey, +} from "@/git/query-keys"; import { prPanePipelineQueryKey, prPaneTimelineQueryKey, @@ -132,6 +136,25 @@ describe("applyCheckoutStatusUpdateFromEvent", () => { expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed); }); + it("invalidates recent commits when checkout status is pushed", () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] }); + queryClient.setQueryData(checkoutCommitsQueryKey(serverId, "/repo2"), { commits: [] }); + + applyCheckoutStatusUpdateFromEvent({ + queryClient, + serverId, + message: checkoutStatusUpdate(checkoutStatus()), + }); + + expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe( + true, + ); + expect( + queryClient.getQueryState(checkoutCommitsQueryKey(serverId, "/repo2"))?.isInvalidated, + ).toBe(false); + }); + it("writes the PR status cache when prStatus is present, and skips it otherwise", () => { const queryClient = createQueryClient(); const pushedPr = prStatus({ requestId: "pr-1" }); diff --git a/packages/app/src/git/checkout-status-cache.ts b/packages/app/src/git/checkout-status-cache.ts index 73ef9bf1a..79ba03a41 100644 --- a/packages/app/src/git/checkout-status-cache.ts +++ b/packages/app/src/git/checkout-status-cache.ts @@ -2,6 +2,7 @@ import type { QueryClient } from "@tanstack/react-query"; import type { CheckoutStatusResponse, CheckoutStatusUpdate } from "@getpaseo/protocol/messages"; import equal from "fast-deep-equal/es6"; import { + checkoutCommitsQueryKey, checkoutPrStatusQueryKey, checkoutStatusQueryKey, invalidatePrPaneTimelineForCheckout, @@ -49,6 +50,9 @@ export function applyCheckoutStatusUpdateFromEvent({ : undefined; const cachePayload = prStatus ? { ...payload, prStatus } : payload; queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload); + void queryClient.invalidateQueries({ + queryKey: checkoutCommitsQueryKey(serverId, payload.cwd), + }); expireStaleDiffModeOverrides({ serverId, cwd: payload.cwd, diff --git a/packages/app/src/git/query-keys.test.ts b/packages/app/src/git/query-keys.test.ts index f61adcd0c..e6caa3cc3 100644 --- a/packages/app/src/git/query-keys.test.ts +++ b/packages/app/src/git/query-keys.test.ts @@ -2,6 +2,7 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import { checkoutDiffQueryKey, + checkoutCommitsQueryKey, checkoutPrStatusQueryKey, checkoutStatusQueryKey, invalidateCheckoutGitQueriesForClient, @@ -24,6 +25,8 @@ describe("checkout query keys", () => { files: [], }); queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } }); + queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] }); + queryClient.setQueryData(checkoutCommitsQueryKey(serverId, "/tmp/other"), { commits: [] }); queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), { items: [], }); @@ -62,6 +65,12 @@ describe("checkout query keys", () => { expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe( true, ); + expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe( + true, + ); + expect( + queryClient.getQueryState(checkoutCommitsQueryKey(serverId, "/tmp/other"))?.isInvalidated, + ).toBe(false); expect( queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 })) ?.isInvalidated, @@ -102,6 +111,8 @@ describe("checkout query keys", () => { queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), { isGit: true }); queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), { isGit: true }); queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } }); + queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] }); + queryClient.setQueryData(checkoutCommitsQueryKey(otherServerId, cwd), { commits: [] }); queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), { items: [], }); @@ -128,6 +139,12 @@ describe("checkout query keys", () => { expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe( true, ); + expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe( + true, + ); + expect( + queryClient.getQueryState(checkoutCommitsQueryKey(otherServerId, cwd))?.isInvalidated, + ).toBe(false); expect( queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 })) ?.isInvalidated, diff --git a/packages/app/src/git/query-keys.ts b/packages/app/src/git/query-keys.ts index a255c10e8..cfd804e45 100644 --- a/packages/app/src/git/query-keys.ts +++ b/packages/app/src/git/query-keys.ts @@ -62,6 +62,9 @@ export async function invalidateCheckoutGitQueriesForClient( queryClient.invalidateQueries({ predicate: checkoutQueryPredicate("checkoutPrStatus", identity), }), + queryClient.invalidateQueries({ + queryKey: checkoutCommitsQueryKey(identity.serverId, identity.cwd), + }), queryClient.invalidateQueries({ predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity), }), @@ -81,6 +84,7 @@ export async function invalidateCheckoutGitQueriesForServer( const kinds = [ "checkoutStatus", "checkoutPrStatus", + "checkoutCommits", prPaneTimelineQueryKind, prPanePipelineQueryKind, ]; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index bcb3436bf..76ab14396 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -788,12 +788,12 @@ export const ar: TranslationResources = { deletedFile: "تم الحذف", commits: { title: "الإيداعات", - countLabel: "{{count}} إيداعات قبل الأساس", + countLabel: "{{count}} من الإيداعات الأخيرة", fileDiffEmpty: "لا توجد تغييرات لعرضها", fileDiffError: "تعذّر تحميل فروق الملف", loading: "جارٍ تحميل الإيداعات…", loadError: "تعذّر تحميل الإيداعات", - empty: "لا توجد إيداعات قبل الأساس", + empty: "لا توجد إيداعات بعد", }, }, openInEditor: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index f52ab3b73..eed9a9eeb 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -798,12 +798,12 @@ export const en = { deletedFile: "Deleted", commits: { title: "Commits", - countLabel: "{{count}} commits ahead of base", + countLabel: "{{count}} recent commits", fileDiffEmpty: "No changes to display", fileDiffError: "Failed to load file diff", loading: "Loading commits…", loadError: "Failed to load commits", - empty: "No commits ahead of base", + empty: "No commits yet", }, }, openInEditor: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index dcb0bc4d1..08ad8d064 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -819,12 +819,12 @@ export const es: TranslationResources = { deletedFile: "Eliminado", commits: { title: "Commits", - countLabel: "{{count}} commits por delante de la base", + countLabel: "{{count}} commits recientes", fileDiffEmpty: "No hay cambios para mostrar", fileDiffError: "Error al cargar el diff del archivo", loading: "Cargando commits…", loadError: "Error al cargar los commits", - empty: "No hay commits por delante de la base", + empty: "Aún no hay commits", }, }, openInEditor: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 0ece48dbe..cbad99696 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -818,12 +818,12 @@ export const fr: TranslationResources = { deletedFile: "Supprimé", commits: { title: "Commits", - countLabel: "{{count}} commits en avance sur la base", + countLabel: "{{count}} commits récents", fileDiffEmpty: "Aucune modification à afficher", fileDiffError: "Échec du chargement du diff du fichier", loading: "Chargement des commits…", loadError: "Échec du chargement des commits", - empty: "Aucun commit en avance sur la base", + empty: "Aucun commit pour le moment", }, }, openInEditor: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 48721fdea..44135239b 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -799,12 +799,12 @@ export const ja: TranslationResources = { deletedFile: "削除済み", commits: { title: "コミット", - countLabel: "ベースより先のコミット数: {{count}}", + countLabel: "最近のコミット数: {{count}}", fileDiffEmpty: "表示する変更はありません", fileDiffError: "ファイル差分の読み込みに失敗しました", loading: "コミットを読み込み中…", loadError: "コミットの読み込みに失敗しました", - empty: "ベースより先のコミットはありません", + empty: "コミットはまだありません", }, }, openInEditor: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index e7e9b34dd..8189823f6 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -810,12 +810,12 @@ export const ptBR: TranslationResources = { deletedFile: "Excluído", commits: { title: "Commits", - countLabel: "{{count}} commits à frente da base", + countLabel: "{{count}} commits recentes", fileDiffEmpty: "Nenhuma alteração para exibir", fileDiffError: "Falha ao carregar diff do arquivo", loading: "Carregando commits…", loadError: "Falha ao carregar commits", - empty: "Nenhum commit à frente da base", + empty: "Ainda não há commits", }, }, openInEditor: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 7cb510dc6..1bf835988 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -810,12 +810,12 @@ export const ru: TranslationResources = { deletedFile: "Удалено", commits: { title: "Коммиты", - countLabel: "{{count}} коммитов впереди базы", + countLabel: "{{count}} последних коммитов", fileDiffEmpty: "Нет изменений для отображения", fileDiffError: "Не удалось загрузить различия файла", loading: "Загрузка коммитов…", loadError: "Не удалось загрузить коммиты", - empty: "Нет коммитов впереди базы", + empty: "Коммитов пока нет", }, }, openInEditor: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 44811021f..3d19b56c6 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -780,12 +780,12 @@ export const zhCN: TranslationResources = { deletedFile: "已删除", commits: { title: "提交", - countLabel: "领先基线 {{count}} 个提交", + countLabel: "最近 {{count}} 个提交", fileDiffEmpty: "没有可显示的更改", fileDiffError: "加载文件差异失败", loading: "正在加载提交…", loadError: "加载提交失败", - empty: "没有领先基线的提交", + empty: "暂无提交", }, }, openInEditor: { diff --git a/packages/server/src/utils/checkout-git.commit-file-diff.test.ts b/packages/server/src/utils/checkout-git.commit-file-diff.test.ts index bcf5b095e..fbb22fe3b 100644 --- a/packages/server/src/utils/checkout-git.commit-file-diff.test.ts +++ b/packages/server/src/utils/checkout-git.commit-file-diff.test.ts @@ -88,4 +88,22 @@ describe("getCommitFileDiff", () => { expect(file).toBeNull(); }); + + it("returns a merge commit diff against its first parent", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "README.md", "base\n", "initial"); + git(["checkout", "-b", "feature"], repoDir); + commitFile(repoDir, "feature.txt", "feature\n", "add feature"); + git(["checkout", "main"], repoDir); + commitFile(repoDir, "main.txt", "main\n", "advance main"); + git(["merge", "--no-ff", "feature", "-m", "merge feature"], repoDir); + const sha = headSha(repoDir); + + const file = await getCommitFileDiff({ cwd: repoDir, sha, path: "feature.txt" }); + + expect(file?.path).toBe("feature.txt"); + expect(file?.isNew).toBe(true); + expect(file?.additions).toBe(1); + expect(file?.deletions).toBe(0); + }); }); diff --git a/packages/server/src/utils/checkout-git.commits.test.ts b/packages/server/src/utils/checkout-git.commits.test.ts index b1a5a242b..83bb1e745 100644 --- a/packages/server/src/utils/checkout-git.commits.test.ts +++ b/packages/server/src/utils/checkout-git.commits.test.ts @@ -52,7 +52,7 @@ function addBareRemote(repoDir: string, tempDir: string): string { } describe("listCheckoutCommits", () => { - it("lists commits ahead of base newest-first with on-remote flags and file stats", async () => { + it("lists recent commits newest-first with on-remote flags and file stats", async () => { const { repoDir, tempDir } = initRepoOnMain(); git(["checkout", "-b", "feature"], repoDir); commitFile(repoDir, "foo.txt", "a\nb\nc\n", "Add foo"); @@ -65,12 +65,14 @@ describe("listCheckoutCommits", () => { const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir }); expect(baseRef).toBe("main"); - expect(commits).toHaveLength(2); + expect(commits).toHaveLength(3); expect(commits[0]?.subject).toBe("Add bar"); expect(commits[1]?.subject).toBe("Add foo"); + expect(commits[2]?.subject).toBe("initial"); expect(commits[0]?.isOnRemote).toBe(false); expect(commits[1]?.isOnRemote).toBe(true); + expect(commits[2]?.isOnRemote).toBe(true); expect(commits[0]?.files).toEqual([ { path: "bar.txt", additions: 1, deletions: 0, status: "added" }, @@ -85,11 +87,11 @@ describe("listCheckoutCommits", () => { expect(Number.isNaN(new Date(commits[0]?.authorDate ?? "").getTime())).toBe(false); }); - it("returns [] when there are no commits ahead of base", async () => { + it("shows recent history on the base branch", async () => { const { repoDir } = initRepoOnMain(); const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir }); expect(baseRef).toBeNull(); - expect(commits).toEqual([]); + expect(commits.map((entry) => entry.subject)).toEqual(["initial"]); }); it("marks all commits local-only when there is no remote", async () => { @@ -101,10 +103,61 @@ describe("listCheckoutCommits", () => { const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir }); expect(baseRef).toBe("main"); - expect(commits).toHaveLength(2); + expect(commits).toHaveLength(3); expect(commits.every((c) => c.isOnRemote === false)).toBe(true); }); + it("recognizes base history on a remote before the feature branch is pushed", async () => { + const { repoDir, tempDir } = initRepoOnMain(); + addBareRemote(repoDir, tempDir); + git(["push", "-u", "origin", "main"], repoDir); + git(["checkout", "-b", "feature"], repoDir); + commitFile(repoDir, "feature.txt", "local\n", "Local feature"); + + const { commits } = await listCheckoutCommits({ cwd: repoDir }); + + expect(commits.map(({ subject, isOnRemote }) => ({ subject, isOnRemote }))).toEqual([ + { subject: "Local feature", isOnRemote: false }, + { subject: "initial", isOnRemote: true }, + ]); + }); + + it("limits history and unpushed classification to the 20 most recent commits", async () => { + const { repoDir } = initRepoOnMain(); + for (let index = 1; index <= 24; 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}`), + ); + }); + + it("shows merged branch commits and compares the merge against its first parent", async () => { + const { repoDir } = initRepoOnMain(); + git(["checkout", "-b", "feature"], repoDir); + commitFile(repoDir, "feature.txt", "feature\n", "Add feature"); + git(["checkout", "main"], repoDir); + commitFile(repoDir, "main.txt", "main\n", "Advance main"); + git(["merge", "--no-ff", "feature", "-m", "Merge feature"], repoDir); + + const { commits } = await listCheckoutCommits({ cwd: repoDir }); + + expect(commits.map((entry) => entry.subject)).toEqual([ + "Merge feature", + "Advance main", + "Add feature", + "initial", + ]); + expect(commits[0]?.files).toEqual([ + { path: "feature.txt", additions: 1, deletions: 0, status: "added" }, + ]); + }); + it("classifies renamed files with status renamed and correct destination path", async () => { const { repoDir } = initRepoOnMain(); git(["checkout", "-b", "feature"], repoDir); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index ed026e70c..e0f507259 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -2001,9 +2001,8 @@ export async function getCheckoutStatus( }; } -// Cap on how many ahead-of-base commits we enumerate. Branches with more than -// this many unmerged commits are truncated to the newest MAX_CHECKOUT_COMMITS. -const MAX_CHECKOUT_COMMITS = 200; +// The explorer is a recent-history view, not a full repository log. +const MAX_CHECKOUT_COMMITS = 20; // Bytes git emits between fields/records. We split parsed output on these. const COMMIT_FIELD_SEPARATOR = "\x00"; const COMMIT_RECORD_SEPARATOR = "\x1e"; @@ -2145,41 +2144,16 @@ function parseCheckoutCommitRecords(stdout: string): ParsedCheckoutCommit[] { return commits; } -async function resolveCheckoutCommitUpstreamRef( - cwd: string, - currentBranch: string, - context?: CheckoutContext, -): Promise { - // Prefer the branch's configured `@{u}`. If it's configured but the - // remote-tracking ref isn't present locally (e.g. configured upstream that was - // never fetched), fall back to `origin/` when that ref does exist, so a - // standard `origin` push is still recognized. Both missing => no remote. - const configured = await getConfiguredUpstreamRef(cwd, currentBranch, context); - if (configured && (await doesGitRefExist(cwd, `refs/remotes/${configured}`, context))) { - return configured; - } - if (await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`, context)) { - return `origin/${currentBranch}`; - } - return null; -} - -// Returns the set of SHAs on the current branch that are NOT on the upstream -// (local-only/unpushed), or `null` when no upstream exists (no remote at all). -async function getLocalOnlyCommitShas( - cwd: string, - currentBranch: string, - context?: CheckoutContext, -): Promise | null> { - const upstreamRef = await resolveCheckoutCommitUpstreamRef(cwd, currentBranch, context); - if (!upstreamRef) { - return null; - } - const { stdout } = await runGitCommand(["rev-list", `${upstreamRef}..HEAD`], { - cwd, - envOverlay: READ_ONLY_GIT_ENV, - logger: context?.logger, - }); +// 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, + }, + ); return new Set( stdout .split("\n") @@ -2189,13 +2163,8 @@ async function getLocalOnlyCommitShas( } /** - * Lists the current branch's commits that are ahead of its base branch, newest - * first, each flagged local-only vs on-remote with per-commit file +/- stats. - * - * Base ref is resolved exactly like {@link getCheckoutStatus}: the stored/default - * base branch, mapped to the best comparison ref (origin/ when present, - * else local ). Returns `[]` when the base cannot be resolved, the current - * ref is the base itself, or there are no commits ahead. + * Lists the current branch's 20 most recent commits, newest first, each flagged + * local-only vs on-remote with per-commit file +/- stats. */ export interface CheckoutCommitsResult { baseRef: string | null; @@ -2213,21 +2182,14 @@ export async function listCheckoutCommits({ } const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd); - if (!resolvedBaseRef) { - return { baseRef: null, commits: [] }; - } - - const normalizedBaseRef = normalizeLocalBranchRefName(resolvedBaseRef); - if (!normalizedBaseRef || normalizedBaseRef === currentBranch) { - return { baseRef: null, commits: [] }; - } - - let comparisonBaseRef: string; - try { - comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef); - } catch { - // Base branch is not present locally or on origin — nothing to compare against. - return { baseRef: null, commits: [] }; + 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. + } } // Single pass: `--raw` carries the status letter, `--numstat` the +/- counts. @@ -2235,8 +2197,8 @@ export async function listCheckoutCommits({ const logResult = await runGitCommand( [ "log", - `${comparisonBaseRef}..HEAD`, - "--no-merges", + "HEAD", + "--diff-merges=first-parent", `--max-count=${MAX_CHECKOUT_COMMITS}`, `--format=${COMMIT_LOG_FORMAT}`, "--raw", @@ -2251,7 +2213,7 @@ export async function listCheckoutCommits({ return { baseRef: comparisonBaseRef, commits: [] }; } - const localOnlyShas = await getLocalOnlyCommitShas(cwd, currentBranch); + const unpushedShas = await getUnpushedCommitShas(cwd); const commits = records.map((record) => ({ sha: record.sha, @@ -2259,7 +2221,7 @@ export async function listCheckoutCommits({ subject: record.subject, authorName: record.authorName, authorDate: record.authorDate, - isOnRemote: localOnlyShas === null ? false : !localOnlyShas.has(record.sha), + isOnRemote: !unpushedShas.has(record.sha), files: record.files, })); @@ -2271,13 +2233,12 @@ export async function listCheckoutCommits({ * parses it into the same {@link ParsedDiffFile} shape the diff subscription * emits (so the client can reuse its existing renderer). * - * Runs `git show --format= -- ` with the sha and path passed as - * separate process args (never interpolated into a shell string), and `--format=` - * suppresses the commit header so the output is a pure unified diff. The text is - * parsed and highlighted by {@link parseAndHighlightDiff} — the exact parser the - * diff subscription uses. Returns `null` when the file is absent from the commit - * or the change is binary-only (no textual hunks). Throws on git failure (e.g. an - * unknown sha), which the caller maps to a typed checkout error. + * Compares merge commits to their first parent, matching the linear history shown + * in the explorer. The text is parsed and highlighted by + * {@link parseAndHighlightDiff} — the exact parser the diff subscription uses. + * Returns `null` when the file is absent from the commit or the change is + * binary-only (no textual hunks). Throws on git failure (e.g. an unknown sha), + * which the caller maps to a typed checkout error. */ export async function getCommitFileDiff({ cwd, @@ -2288,10 +2249,13 @@ export async function getCommitFileDiff({ sha: string; path: string; }): Promise { - const { stdout } = await runGitCommand(["show", sha, "--format=", "--", path], { - cwd, - envOverlay: READ_ONLY_GIT_ENV, - }); + const { stdout } = await runGitCommand( + ["show", sha, "--format=", "--diff-merges=first-parent", "--", path], + { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + }, + ); if (stdout.trim().length === 0) { return null;