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
This commit is contained in:
@@ -2,7 +2,11 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
|
import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
|
||||||
import { checkoutPrStatusQueryKey, checkoutStatusQueryKey } from "@/git/query-keys";
|
import {
|
||||||
|
checkoutCommitsQueryKey,
|
||||||
|
checkoutPrStatusQueryKey,
|
||||||
|
checkoutStatusQueryKey,
|
||||||
|
} from "@/git/query-keys";
|
||||||
import {
|
import {
|
||||||
prPanePipelineQueryKey,
|
prPanePipelineQueryKey,
|
||||||
prPaneTimelineQueryKey,
|
prPaneTimelineQueryKey,
|
||||||
@@ -132,6 +136,25 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
|
|||||||
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
|
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", () => {
|
it("writes the PR status cache when prStatus is present, and skips it otherwise", () => {
|
||||||
const queryClient = createQueryClient();
|
const queryClient = createQueryClient();
|
||||||
const pushedPr = prStatus({ requestId: "pr-1" });
|
const pushedPr = prStatus({ requestId: "pr-1" });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { QueryClient } from "@tanstack/react-query";
|
|||||||
import type { CheckoutStatusResponse, CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
|
import type { CheckoutStatusResponse, CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
|
||||||
import equal from "fast-deep-equal/es6";
|
import equal from "fast-deep-equal/es6";
|
||||||
import {
|
import {
|
||||||
|
checkoutCommitsQueryKey,
|
||||||
checkoutPrStatusQueryKey,
|
checkoutPrStatusQueryKey,
|
||||||
checkoutStatusQueryKey,
|
checkoutStatusQueryKey,
|
||||||
invalidatePrPaneTimelineForCheckout,
|
invalidatePrPaneTimelineForCheckout,
|
||||||
@@ -49,6 +50,9 @@ export function applyCheckoutStatusUpdateFromEvent({
|
|||||||
: undefined;
|
: undefined;
|
||||||
const cachePayload = prStatus ? { ...payload, prStatus } : payload;
|
const cachePayload = prStatus ? { ...payload, prStatus } : payload;
|
||||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload);
|
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload);
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: checkoutCommitsQueryKey(serverId, payload.cwd),
|
||||||
|
});
|
||||||
expireStaleDiffModeOverrides({
|
expireStaleDiffModeOverrides({
|
||||||
serverId,
|
serverId,
|
||||||
cwd: payload.cwd,
|
cwd: payload.cwd,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { QueryClient } from "@tanstack/react-query";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
checkoutDiffQueryKey,
|
checkoutDiffQueryKey,
|
||||||
|
checkoutCommitsQueryKey,
|
||||||
checkoutPrStatusQueryKey,
|
checkoutPrStatusQueryKey,
|
||||||
checkoutStatusQueryKey,
|
checkoutStatusQueryKey,
|
||||||
invalidateCheckoutGitQueriesForClient,
|
invalidateCheckoutGitQueriesForClient,
|
||||||
@@ -24,6 +25,8 @@ describe("checkout query keys", () => {
|
|||||||
files: [],
|
files: [],
|
||||||
});
|
});
|
||||||
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } });
|
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 }), {
|
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), {
|
||||||
items: [],
|
items: [],
|
||||||
});
|
});
|
||||||
@@ -62,6 +65,12 @@ describe("checkout query keys", () => {
|
|||||||
expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
queryClient.getQueryState(checkoutCommitsQueryKey(serverId, "/tmp/other"))?.isInvalidated,
|
||||||
|
).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }))
|
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }))
|
||||||
?.isInvalidated,
|
?.isInvalidated,
|
||||||
@@ -102,6 +111,8 @@ describe("checkout query keys", () => {
|
|||||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), { isGit: true });
|
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), { isGit: true });
|
||||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), { isGit: true });
|
queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), { isGit: true });
|
||||||
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } });
|
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 }), {
|
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), {
|
||||||
items: [],
|
items: [],
|
||||||
});
|
});
|
||||||
@@ -128,6 +139,12 @@ describe("checkout query keys", () => {
|
|||||||
expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
queryClient.getQueryState(checkoutCommitsQueryKey(otherServerId, cwd))?.isInvalidated,
|
||||||
|
).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }))
|
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }))
|
||||||
?.isInvalidated,
|
?.isInvalidated,
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export async function invalidateCheckoutGitQueriesForClient(
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
predicate: checkoutQueryPredicate("checkoutPrStatus", identity),
|
predicate: checkoutQueryPredicate("checkoutPrStatus", identity),
|
||||||
}),
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: checkoutCommitsQueryKey(identity.serverId, identity.cwd),
|
||||||
|
}),
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
|
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
|
||||||
}),
|
}),
|
||||||
@@ -81,6 +84,7 @@ export async function invalidateCheckoutGitQueriesForServer(
|
|||||||
const kinds = [
|
const kinds = [
|
||||||
"checkoutStatus",
|
"checkoutStatus",
|
||||||
"checkoutPrStatus",
|
"checkoutPrStatus",
|
||||||
|
"checkoutCommits",
|
||||||
prPaneTimelineQueryKind,
|
prPaneTimelineQueryKind,
|
||||||
prPanePipelineQueryKind,
|
prPanePipelineQueryKind,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -788,12 +788,12 @@ export const ar: TranslationResources = {
|
|||||||
deletedFile: "تم الحذف",
|
deletedFile: "تم الحذف",
|
||||||
commits: {
|
commits: {
|
||||||
title: "الإيداعات",
|
title: "الإيداعات",
|
||||||
countLabel: "{{count}} إيداعات قبل الأساس",
|
countLabel: "{{count}} من الإيداعات الأخيرة",
|
||||||
fileDiffEmpty: "لا توجد تغييرات لعرضها",
|
fileDiffEmpty: "لا توجد تغييرات لعرضها",
|
||||||
fileDiffError: "تعذّر تحميل فروق الملف",
|
fileDiffError: "تعذّر تحميل فروق الملف",
|
||||||
loading: "جارٍ تحميل الإيداعات…",
|
loading: "جارٍ تحميل الإيداعات…",
|
||||||
loadError: "تعذّر تحميل الإيداعات",
|
loadError: "تعذّر تحميل الإيداعات",
|
||||||
empty: "لا توجد إيداعات قبل الأساس",
|
empty: "لا توجد إيداعات بعد",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openInEditor: {
|
openInEditor: {
|
||||||
|
|||||||
@@ -798,12 +798,12 @@ export const en = {
|
|||||||
deletedFile: "Deleted",
|
deletedFile: "Deleted",
|
||||||
commits: {
|
commits: {
|
||||||
title: "Commits",
|
title: "Commits",
|
||||||
countLabel: "{{count}} commits ahead of base",
|
countLabel: "{{count}} recent commits",
|
||||||
fileDiffEmpty: "No changes to display",
|
fileDiffEmpty: "No changes to display",
|
||||||
fileDiffError: "Failed to load file diff",
|
fileDiffError: "Failed to load file diff",
|
||||||
loading: "Loading commits…",
|
loading: "Loading commits…",
|
||||||
loadError: "Failed to load commits",
|
loadError: "Failed to load commits",
|
||||||
empty: "No commits ahead of base",
|
empty: "No commits yet",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openInEditor: {
|
openInEditor: {
|
||||||
|
|||||||
@@ -819,12 +819,12 @@ export const es: TranslationResources = {
|
|||||||
deletedFile: "Eliminado",
|
deletedFile: "Eliminado",
|
||||||
commits: {
|
commits: {
|
||||||
title: "Commits",
|
title: "Commits",
|
||||||
countLabel: "{{count}} commits por delante de la base",
|
countLabel: "{{count}} commits recientes",
|
||||||
fileDiffEmpty: "No hay cambios para mostrar",
|
fileDiffEmpty: "No hay cambios para mostrar",
|
||||||
fileDiffError: "Error al cargar el diff del archivo",
|
fileDiffError: "Error al cargar el diff del archivo",
|
||||||
loading: "Cargando commits…",
|
loading: "Cargando commits…",
|
||||||
loadError: "Error al cargar los commits",
|
loadError: "Error al cargar los commits",
|
||||||
empty: "No hay commits por delante de la base",
|
empty: "Aún no hay commits",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openInEditor: {
|
openInEditor: {
|
||||||
|
|||||||
@@ -818,12 +818,12 @@ export const fr: TranslationResources = {
|
|||||||
deletedFile: "Supprimé",
|
deletedFile: "Supprimé",
|
||||||
commits: {
|
commits: {
|
||||||
title: "Commits",
|
title: "Commits",
|
||||||
countLabel: "{{count}} commits en avance sur la base",
|
countLabel: "{{count}} commits récents",
|
||||||
fileDiffEmpty: "Aucune modification à afficher",
|
fileDiffEmpty: "Aucune modification à afficher",
|
||||||
fileDiffError: "Échec du chargement du diff du fichier",
|
fileDiffError: "Échec du chargement du diff du fichier",
|
||||||
loading: "Chargement des commits…",
|
loading: "Chargement des commits…",
|
||||||
loadError: "Échec du chargement des commits",
|
loadError: "Échec du chargement des commits",
|
||||||
empty: "Aucun commit en avance sur la base",
|
empty: "Aucun commit pour le moment",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openInEditor: {
|
openInEditor: {
|
||||||
|
|||||||
@@ -799,12 +799,12 @@ export const ja: TranslationResources = {
|
|||||||
deletedFile: "削除済み",
|
deletedFile: "削除済み",
|
||||||
commits: {
|
commits: {
|
||||||
title: "コミット",
|
title: "コミット",
|
||||||
countLabel: "ベースより先のコミット数: {{count}}",
|
countLabel: "最近のコミット数: {{count}}",
|
||||||
fileDiffEmpty: "表示する変更はありません",
|
fileDiffEmpty: "表示する変更はありません",
|
||||||
fileDiffError: "ファイル差分の読み込みに失敗しました",
|
fileDiffError: "ファイル差分の読み込みに失敗しました",
|
||||||
loading: "コミットを読み込み中…",
|
loading: "コミットを読み込み中…",
|
||||||
loadError: "コミットの読み込みに失敗しました",
|
loadError: "コミットの読み込みに失敗しました",
|
||||||
empty: "ベースより先のコミットはありません",
|
empty: "コミットはまだありません",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openInEditor: {
|
openInEditor: {
|
||||||
|
|||||||
@@ -810,12 +810,12 @@ export const ptBR: TranslationResources = {
|
|||||||
deletedFile: "Excluído",
|
deletedFile: "Excluído",
|
||||||
commits: {
|
commits: {
|
||||||
title: "Commits",
|
title: "Commits",
|
||||||
countLabel: "{{count}} commits à frente da base",
|
countLabel: "{{count}} commits recentes",
|
||||||
fileDiffEmpty: "Nenhuma alteração para exibir",
|
fileDiffEmpty: "Nenhuma alteração para exibir",
|
||||||
fileDiffError: "Falha ao carregar diff do arquivo",
|
fileDiffError: "Falha ao carregar diff do arquivo",
|
||||||
loading: "Carregando commits…",
|
loading: "Carregando commits…",
|
||||||
loadError: "Falha ao carregar commits",
|
loadError: "Falha ao carregar commits",
|
||||||
empty: "Nenhum commit à frente da base",
|
empty: "Ainda não há commits",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openInEditor: {
|
openInEditor: {
|
||||||
|
|||||||
@@ -810,12 +810,12 @@ export const ru: TranslationResources = {
|
|||||||
deletedFile: "Удалено",
|
deletedFile: "Удалено",
|
||||||
commits: {
|
commits: {
|
||||||
title: "Коммиты",
|
title: "Коммиты",
|
||||||
countLabel: "{{count}} коммитов впереди базы",
|
countLabel: "{{count}} последних коммитов",
|
||||||
fileDiffEmpty: "Нет изменений для отображения",
|
fileDiffEmpty: "Нет изменений для отображения",
|
||||||
fileDiffError: "Не удалось загрузить различия файла",
|
fileDiffError: "Не удалось загрузить различия файла",
|
||||||
loading: "Загрузка коммитов…",
|
loading: "Загрузка коммитов…",
|
||||||
loadError: "Не удалось загрузить коммиты",
|
loadError: "Не удалось загрузить коммиты",
|
||||||
empty: "Нет коммитов впереди базы",
|
empty: "Коммитов пока нет",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openInEditor: {
|
openInEditor: {
|
||||||
|
|||||||
@@ -780,12 +780,12 @@ export const zhCN: TranslationResources = {
|
|||||||
deletedFile: "已删除",
|
deletedFile: "已删除",
|
||||||
commits: {
|
commits: {
|
||||||
title: "提交",
|
title: "提交",
|
||||||
countLabel: "领先基线 {{count}} 个提交",
|
countLabel: "最近 {{count}} 个提交",
|
||||||
fileDiffEmpty: "没有可显示的更改",
|
fileDiffEmpty: "没有可显示的更改",
|
||||||
fileDiffError: "加载文件差异失败",
|
fileDiffError: "加载文件差异失败",
|
||||||
loading: "正在加载提交…",
|
loading: "正在加载提交…",
|
||||||
loadError: "加载提交失败",
|
loadError: "加载提交失败",
|
||||||
empty: "没有领先基线的提交",
|
empty: "暂无提交",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openInEditor: {
|
openInEditor: {
|
||||||
|
|||||||
@@ -88,4 +88,22 @@ describe("getCommitFileDiff", () => {
|
|||||||
|
|
||||||
expect(file).toBeNull();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function addBareRemote(repoDir: string, tempDir: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("listCheckoutCommits", () => {
|
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();
|
const { repoDir, tempDir } = initRepoOnMain();
|
||||||
git(["checkout", "-b", "feature"], repoDir);
|
git(["checkout", "-b", "feature"], repoDir);
|
||||||
commitFile(repoDir, "foo.txt", "a\nb\nc\n", "Add foo");
|
commitFile(repoDir, "foo.txt", "a\nb\nc\n", "Add foo");
|
||||||
@@ -65,12 +65,14 @@ describe("listCheckoutCommits", () => {
|
|||||||
const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir });
|
const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir });
|
||||||
|
|
||||||
expect(baseRef).toBe("main");
|
expect(baseRef).toBe("main");
|
||||||
expect(commits).toHaveLength(2);
|
expect(commits).toHaveLength(3);
|
||||||
expect(commits[0]?.subject).toBe("Add bar");
|
expect(commits[0]?.subject).toBe("Add bar");
|
||||||
expect(commits[1]?.subject).toBe("Add foo");
|
expect(commits[1]?.subject).toBe("Add foo");
|
||||||
|
expect(commits[2]?.subject).toBe("initial");
|
||||||
|
|
||||||
expect(commits[0]?.isOnRemote).toBe(false);
|
expect(commits[0]?.isOnRemote).toBe(false);
|
||||||
expect(commits[1]?.isOnRemote).toBe(true);
|
expect(commits[1]?.isOnRemote).toBe(true);
|
||||||
|
expect(commits[2]?.isOnRemote).toBe(true);
|
||||||
|
|
||||||
expect(commits[0]?.files).toEqual([
|
expect(commits[0]?.files).toEqual([
|
||||||
{ path: "bar.txt", additions: 1, deletions: 0, status: "added" },
|
{ 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);
|
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 { repoDir } = initRepoOnMain();
|
||||||
const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir });
|
const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir });
|
||||||
expect(baseRef).toBeNull();
|
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 () => {
|
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 });
|
const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir });
|
||||||
|
|
||||||
expect(baseRef).toBe("main");
|
expect(baseRef).toBe("main");
|
||||||
expect(commits).toHaveLength(2);
|
expect(commits).toHaveLength(3);
|
||||||
expect(commits.every((c) => c.isOnRemote === false)).toBe(true);
|
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 () => {
|
it("classifies renamed files with status renamed and correct destination path", async () => {
|
||||||
const { repoDir } = initRepoOnMain();
|
const { repoDir } = initRepoOnMain();
|
||||||
git(["checkout", "-b", "feature"], repoDir);
|
git(["checkout", "-b", "feature"], repoDir);
|
||||||
|
|||||||
@@ -2001,9 +2001,8 @@ export async function getCheckoutStatus(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap on how many ahead-of-base commits we enumerate. Branches with more than
|
// The explorer is a recent-history view, not a full repository log.
|
||||||
// this many unmerged commits are truncated to the newest MAX_CHECKOUT_COMMITS.
|
const MAX_CHECKOUT_COMMITS = 20;
|
||||||
const MAX_CHECKOUT_COMMITS = 200;
|
|
||||||
// Bytes git emits between fields/records. We split parsed output on these.
|
// Bytes git emits between fields/records. We split parsed output on these.
|
||||||
const COMMIT_FIELD_SEPARATOR = "\x00";
|
const COMMIT_FIELD_SEPARATOR = "\x00";
|
||||||
const COMMIT_RECORD_SEPARATOR = "\x1e";
|
const COMMIT_RECORD_SEPARATOR = "\x1e";
|
||||||
@@ -2145,41 +2144,16 @@ function parseCheckoutCommitRecords(stdout: string): ParsedCheckoutCommit[] {
|
|||||||
return commits;
|
return commits;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveCheckoutCommitUpstreamRef(
|
// Returns commits reachable from HEAD that are not reachable from any remote ref.
|
||||||
cwd: string,
|
async function getUnpushedCommitShas(cwd: string, context?: CheckoutContext): Promise<Set<string>> {
|
||||||
currentBranch: string,
|
const { stdout } = await runGitCommand(
|
||||||
context?: CheckoutContext,
|
["rev-list", `--max-count=${MAX_CHECKOUT_COMMITS}`, "HEAD", "--not", "--remotes"],
|
||||||
): Promise<string | null> {
|
{
|
||||||
// Prefer the branch's configured `@{u}`. If it's configured but the
|
cwd,
|
||||||
// remote-tracking ref isn't present locally (e.g. configured upstream that was
|
envOverlay: READ_ONLY_GIT_ENV,
|
||||||
// never fetched), fall back to `origin/<branch>` when that ref does exist, so a
|
logger: context?.logger,
|
||||||
// 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<Set<string> | 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,
|
|
||||||
});
|
|
||||||
return new Set(
|
return new Set(
|
||||||
stdout
|
stdout
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -2189,13 +2163,8 @@ async function getLocalOnlyCommitShas(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists the current branch's commits that are ahead of its base branch, newest
|
* Lists the current branch's 20 most recent commits, newest first, each flagged
|
||||||
* first, each flagged local-only vs on-remote with per-commit file +/- stats.
|
* 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/<base> when present,
|
|
||||||
* else local <base>). Returns `[]` when the base cannot be resolved, the current
|
|
||||||
* ref is the base itself, or there are no commits ahead.
|
|
||||||
*/
|
*/
|
||||||
export interface CheckoutCommitsResult {
|
export interface CheckoutCommitsResult {
|
||||||
baseRef: string | null;
|
baseRef: string | null;
|
||||||
@@ -2213,21 +2182,14 @@ export async function listCheckoutCommits({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd);
|
const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd);
|
||||||
if (!resolvedBaseRef) {
|
const normalizedBaseRef = resolvedBaseRef ? normalizeLocalBranchRefName(resolvedBaseRef) : null;
|
||||||
return { baseRef: null, commits: [] };
|
let comparisonBaseRef: string | null = null;
|
||||||
}
|
if (resolvedBaseRef && normalizedBaseRef && normalizedBaseRef !== currentBranch) {
|
||||||
|
try {
|
||||||
const normalizedBaseRef = normalizeLocalBranchRefName(resolvedBaseRef);
|
comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef);
|
||||||
if (!normalizedBaseRef || normalizedBaseRef === currentBranch) {
|
} catch {
|
||||||
return { baseRef: null, commits: [] };
|
// History does not depend on the configured base being available.
|
||||||
}
|
}
|
||||||
|
|
||||||
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: [] };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single pass: `--raw` carries the status letter, `--numstat` the +/- counts.
|
// Single pass: `--raw` carries the status letter, `--numstat` the +/- counts.
|
||||||
@@ -2235,8 +2197,8 @@ export async function listCheckoutCommits({
|
|||||||
const logResult = await runGitCommand(
|
const logResult = await runGitCommand(
|
||||||
[
|
[
|
||||||
"log",
|
"log",
|
||||||
`${comparisonBaseRef}..HEAD`,
|
"HEAD",
|
||||||
"--no-merges",
|
"--diff-merges=first-parent",
|
||||||
`--max-count=${MAX_CHECKOUT_COMMITS}`,
|
`--max-count=${MAX_CHECKOUT_COMMITS}`,
|
||||||
`--format=${COMMIT_LOG_FORMAT}`,
|
`--format=${COMMIT_LOG_FORMAT}`,
|
||||||
"--raw",
|
"--raw",
|
||||||
@@ -2251,7 +2213,7 @@ export async function listCheckoutCommits({
|
|||||||
return { baseRef: comparisonBaseRef, commits: [] };
|
return { baseRef: comparisonBaseRef, commits: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const localOnlyShas = await getLocalOnlyCommitShas(cwd, currentBranch);
|
const unpushedShas = await getUnpushedCommitShas(cwd);
|
||||||
|
|
||||||
const commits = records.map((record) => ({
|
const commits = records.map((record) => ({
|
||||||
sha: record.sha,
|
sha: record.sha,
|
||||||
@@ -2259,7 +2221,7 @@ export async function listCheckoutCommits({
|
|||||||
subject: record.subject,
|
subject: record.subject,
|
||||||
authorName: record.authorName,
|
authorName: record.authorName,
|
||||||
authorDate: record.authorDate,
|
authorDate: record.authorDate,
|
||||||
isOnRemote: localOnlyShas === null ? false : !localOnlyShas.has(record.sha),
|
isOnRemote: !unpushedShas.has(record.sha),
|
||||||
files: record.files,
|
files: record.files,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -2271,13 +2233,12 @@ export async function listCheckoutCommits({
|
|||||||
* parses it into the same {@link ParsedDiffFile} shape the diff subscription
|
* parses it into the same {@link ParsedDiffFile} shape the diff subscription
|
||||||
* emits (so the client can reuse its existing renderer).
|
* emits (so the client can reuse its existing renderer).
|
||||||
*
|
*
|
||||||
* Runs `git show <sha> --format= -- <path>` with the sha and path passed as
|
* Compares merge commits to their first parent, matching the linear history shown
|
||||||
* separate process args (never interpolated into a shell string), and `--format=`
|
* in the explorer. The text is parsed and highlighted by
|
||||||
* suppresses the commit header so the output is a pure unified diff. The text is
|
* {@link parseAndHighlightDiff} — the exact parser the diff subscription uses.
|
||||||
* parsed and highlighted by {@link parseAndHighlightDiff} — the exact parser the
|
* Returns `null` when the file is absent from the commit or the change is
|
||||||
* diff subscription uses. Returns `null` when the file is absent from the commit
|
* binary-only (no textual hunks). Throws on git failure (e.g. an unknown sha),
|
||||||
* or the change is binary-only (no textual hunks). Throws on git failure (e.g. an
|
* which the caller maps to a typed checkout error.
|
||||||
* unknown sha), which the caller maps to a typed checkout error.
|
|
||||||
*/
|
*/
|
||||||
export async function getCommitFileDiff({
|
export async function getCommitFileDiff({
|
||||||
cwd,
|
cwd,
|
||||||
@@ -2288,10 +2249,13 @@ export async function getCommitFileDiff({
|
|||||||
sha: string;
|
sha: string;
|
||||||
path: string;
|
path: string;
|
||||||
}): Promise<ParsedDiffFile | null> {
|
}): Promise<ParsedDiffFile | null> {
|
||||||
const { stdout } = await runGitCommand(["show", sha, "--format=", "--", path], {
|
const { stdout } = await runGitCommand(
|
||||||
cwd,
|
["show", sha, "--format=", "--diff-merges=first-parent", "--", path],
|
||||||
envOverlay: READ_ONLY_GIT_ENV,
|
{
|
||||||
});
|
cwd,
|
||||||
|
envOverlay: READ_ONLY_GIT_ENV,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (stdout.trim().length === 0) {
|
if (stdout.trim().length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user