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.
This commit is contained in:
@@ -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 (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{isFirst && isLast ? null : (
|
||||||
|
<View
|
||||||
|
style={[styles.rail, railColor, isFirst && styles.railFirst, isLast && styles.railLast]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
testID={commit.isOnRemote ? "commit-dot-remote" : "commit-dot-local"}
|
||||||
|
style={[styles.marker, markerColor, !commit.isOnRemote && styles.markerRing]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,18 +1,33 @@
|
|||||||
import { memo, useCallback } from "react";
|
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 { StyleSheet } from "react-native-unistyles";
|
||||||
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
|
|
||||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||||
|
import type { ClassifiedCheckoutCommit } from "@/git/use-commits-query";
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { dotStyles } from "./shared";
|
import { CommitGraphNode } from "./commit-graph-node";
|
||||||
|
|
||||||
interface CommitRowProps {
|
interface CommitRowProps {
|
||||||
commit: CheckoutCommit;
|
commit: ClassifiedCheckoutCommit;
|
||||||
|
isFirst: boolean;
|
||||||
|
isLast: boolean;
|
||||||
now: Date;
|
now: Date;
|
||||||
onCommitPress: (sha: string) => void;
|
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(() => {
|
const handlePress = useCallback(() => {
|
||||||
onCommitPress(commit.sha);
|
onCommitPress(commit.sha);
|
||||||
}, [commit.sha, onCommitPress]);
|
}, [commit.sha, onCommitPress]);
|
||||||
@@ -22,16 +37,17 @@ export const CommitRow = memo(function CommitRow({ commit, now, onCommitPress }:
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
testID={`commit-row-${commit.shortSha}`}
|
testID={`commit-row-${commit.shortSha}`}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
style={styles.row}
|
style={commitRowPressableStyle}
|
||||||
>
|
>
|
||||||
<View
|
<CommitGraphNode commit={commit} isFirst={isFirst} isLast={isLast} />
|
||||||
testID={commit.isOnRemote ? "commit-dot-remote" : "commit-dot-local"}
|
<View style={styles.commitDetails}>
|
||||||
style={commit.isOnRemote ? dotStyles.dotRemote : dotStyles.dotLocal}
|
<Text style={styles.shortSha} numberOfLines={1}>
|
||||||
/>
|
{commit.shortSha}
|
||||||
<Text style={styles.shortSha}>{commit.shortSha}</Text>
|
</Text>
|
||||||
<Text style={styles.subject} numberOfLines={1}>
|
<Text style={styles.subject} numberOfLines={1}>
|
||||||
{commit.subject}
|
{commit.subject}
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
<Text style={styles.timestamp}>{formatTimeAgo(new Date(commit.authorDate), now)}</Text>
|
<Text style={styles.timestamp}>{formatTimeAgo(new Date(commit.authorDate), now)}</Text>
|
||||||
<View style={styles.caret}>
|
<View style={styles.caret}>
|
||||||
<ThemedChevron size={14} uniProps={chevronColorMapping} />
|
<ThemedChevron size={14} uniProps={chevronColorMapping} />
|
||||||
@@ -49,10 +65,21 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingRight: theme.spacing[2],
|
paddingRight: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
},
|
},
|
||||||
|
rowActive: {
|
||||||
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
|
},
|
||||||
|
commitDetails: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
shortSha: {
|
shortSha: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontFamily: theme.fontFamily.mono,
|
fontFamily: theme.fontFamily.mono,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
|
width: theme.spacing[16],
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
subject: {
|
subject: {
|
||||||
|
|||||||
@@ -67,8 +67,15 @@ function CommitsSectionContent({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<View style={styles.list}>
|
<View style={styles.list}>
|
||||||
{query.data.commits.map((commit) => (
|
{query.data.commits.map((commit, index) => (
|
||||||
<CommitRow key={commit.sha} commit={commit} now={now} onCommitPress={onCommitPress} />
|
<CommitRow
|
||||||
|
key={commit.sha}
|
||||||
|
commit={commit}
|
||||||
|
isFirst={index === 0}
|
||||||
|
isLast={index === query.data.commits.length - 1}
|
||||||
|
now={now}
|
||||||
|
onCommitPress={onCommitPress}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -110,7 +117,10 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP
|
|||||||
if (query.status === "unsupported") {
|
if (query.status === "unsupported") {
|
||||||
return null;
|
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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
|
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
|
||||||
|
import invariant from "tiny-invariant";
|
||||||
import { useFetchQuery } from "@/data/query";
|
import { useFetchQuery } from "@/data/query";
|
||||||
import { checkoutCommitsQueryKey } from "@/git/query-keys";
|
import { checkoutCommitsQueryKey } from "@/git/query-keys";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
@@ -16,7 +17,11 @@ interface UseCheckoutCommitsQueryOptions {
|
|||||||
|
|
||||||
export interface CheckoutCommitsData {
|
export interface CheckoutCommitsData {
|
||||||
baseRef: string | null;
|
baseRef: string | null;
|
||||||
commits: CheckoutCommit[];
|
commits: ClassifiedCheckoutCommit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClassifiedCheckoutCommit extends CheckoutCommit {
|
||||||
|
isOnBase: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CheckoutCommitsQueryResult =
|
export type CheckoutCommitsQueryResult =
|
||||||
@@ -69,10 +74,13 @@ export function useCheckoutCommitsQuery({
|
|||||||
}: UseCheckoutCommitsQueryOptions): CheckoutCommitsQueryResult {
|
}: UseCheckoutCommitsQueryOptions): CheckoutCommitsQueryResult {
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(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.
|
// Single capability-detection site; downstream reads a clean load-state union.
|
||||||
const capabilityPresent = useSessionStore(
|
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;
|
const canFetch = Boolean(cwd) && Boolean(client) && isConnected;
|
||||||
@@ -84,7 +92,12 @@ export function useCheckoutCommitsQuery({
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Host disconnected");
|
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,
|
enabled: queryEnabled,
|
||||||
staleTimeMs: CHECKOUT_COMMITS_STALE_TIME,
|
staleTimeMs: CHECKOUT_COMMITS_STALE_TIME,
|
||||||
|
|||||||
@@ -795,7 +795,7 @@ export const ar: TranslationResources = {
|
|||||||
deletedFile: "تم الحذف",
|
deletedFile: "تم الحذف",
|
||||||
commits: {
|
commits: {
|
||||||
title: "الإيداعات",
|
title: "الإيداعات",
|
||||||
countLabel: "{{count}} من الإيداعات الأخيرة",
|
countLabel: "{{count}} من إيداعات مساحة العمل",
|
||||||
fileDiffEmpty: "لا توجد تغييرات لعرضها",
|
fileDiffEmpty: "لا توجد تغييرات لعرضها",
|
||||||
fileDiffError: "تعذّر تحميل فروق الملف",
|
fileDiffError: "تعذّر تحميل فروق الملف",
|
||||||
loading: "جارٍ تحميل الإيداعات…",
|
loading: "جارٍ تحميل الإيداعات…",
|
||||||
|
|||||||
@@ -805,7 +805,7 @@ export const en = {
|
|||||||
deletedFile: "Deleted",
|
deletedFile: "Deleted",
|
||||||
commits: {
|
commits: {
|
||||||
title: "Commits",
|
title: "Commits",
|
||||||
countLabel: "{{count}} recent commits",
|
countLabel: "{{count}} workspace 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…",
|
||||||
|
|||||||
@@ -826,7 +826,7 @@ export const es: TranslationResources = {
|
|||||||
deletedFile: "Eliminado",
|
deletedFile: "Eliminado",
|
||||||
commits: {
|
commits: {
|
||||||
title: "Commits",
|
title: "Commits",
|
||||||
countLabel: "{{count}} commits recientes",
|
countLabel: "{{count}} commits del espacio de trabajo",
|
||||||
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…",
|
||||||
|
|||||||
@@ -825,7 +825,7 @@ export const fr: TranslationResources = {
|
|||||||
deletedFile: "Supprimé",
|
deletedFile: "Supprimé",
|
||||||
commits: {
|
commits: {
|
||||||
title: "Commits",
|
title: "Commits",
|
||||||
countLabel: "{{count}} commits récents",
|
countLabel: "{{count}} commits de l’espace de travail",
|
||||||
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…",
|
||||||
|
|||||||
@@ -806,7 +806,7 @@ export const ja: TranslationResources = {
|
|||||||
deletedFile: "削除済み",
|
deletedFile: "削除済み",
|
||||||
commits: {
|
commits: {
|
||||||
title: "コミット",
|
title: "コミット",
|
||||||
countLabel: "最近のコミット数: {{count}}",
|
countLabel: "ワークスペースのコミット数: {{count}}",
|
||||||
fileDiffEmpty: "表示する変更はありません",
|
fileDiffEmpty: "表示する変更はありません",
|
||||||
fileDiffError: "ファイル差分の読み込みに失敗しました",
|
fileDiffError: "ファイル差分の読み込みに失敗しました",
|
||||||
loading: "コミットを読み込み中…",
|
loading: "コミットを読み込み中…",
|
||||||
|
|||||||
@@ -817,7 +817,7 @@ export const ptBR: TranslationResources = {
|
|||||||
deletedFile: "Excluído",
|
deletedFile: "Excluído",
|
||||||
commits: {
|
commits: {
|
||||||
title: "Commits",
|
title: "Commits",
|
||||||
countLabel: "{{count}} commits recentes",
|
countLabel: "{{count}} commits do espaço de trabalho",
|
||||||
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…",
|
||||||
|
|||||||
@@ -817,7 +817,7 @@ export const ru: TranslationResources = {
|
|||||||
deletedFile: "Удалено",
|
deletedFile: "Удалено",
|
||||||
commits: {
|
commits: {
|
||||||
title: "Коммиты",
|
title: "Коммиты",
|
||||||
countLabel: "{{count}} последних коммитов",
|
countLabel: "{{count}} коммитов рабочего пространства",
|
||||||
fileDiffEmpty: "Нет изменений для отображения",
|
fileDiffEmpty: "Нет изменений для отображения",
|
||||||
fileDiffError: "Не удалось загрузить различия файла",
|
fileDiffError: "Не удалось загрузить различия файла",
|
||||||
loading: "Загрузка коммитов…",
|
loading: "Загрузка коммитов…",
|
||||||
|
|||||||
@@ -787,7 +787,7 @@ export const zhCN: TranslationResources = {
|
|||||||
deletedFile: "已删除",
|
deletedFile: "已删除",
|
||||||
commits: {
|
commits: {
|
||||||
title: "提交",
|
title: "提交",
|
||||||
countLabel: "最近 {{count}} 个提交",
|
countLabel: "{{count}} 个工作区提交",
|
||||||
fileDiffEmpty: "没有可显示的更改",
|
fileDiffEmpty: "没有可显示的更改",
|
||||||
fileDiffError: "加载文件差异失败",
|
fileDiffError: "加载文件差异失败",
|
||||||
loading: "正在加载提交…",
|
loading: "正在加载提交…",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ describe("checkout.commits.list schemas", () => {
|
|||||||
authorName: "Ada",
|
authorName: "Ada",
|
||||||
authorDate: "2026-06-13T10:00:00.000Z",
|
authorDate: "2026-06-13T10:00:00.000Z",
|
||||||
isOnRemote: true,
|
isOnRemote: true,
|
||||||
|
isOnBase: false,
|
||||||
files: [
|
files: [
|
||||||
{ path: "src/a.ts", additions: 10, deletions: 2, status: "modified" },
|
{ path: "src/a.ts", additions: 10, deletions: 2, status: "modified" },
|
||||||
{ path: "src/b.ts", additions: 5, deletions: 0, status: "added" },
|
{ path: "src/b.ts", additions: 5, deletions: 0, status: "added" },
|
||||||
@@ -47,6 +48,7 @@ describe("checkout.commits.list schemas", () => {
|
|||||||
authorName: "Ada",
|
authorName: "Ada",
|
||||||
authorDate: "2026-06-13T11:00:00.000Z",
|
authorDate: "2026-06-13T11:00:00.000Z",
|
||||||
isOnRemote: false,
|
isOnRemote: false,
|
||||||
|
isOnBase: true,
|
||||||
files: [{ path: "src/c.ts", additions: 1, deletions: 1 }],
|
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).toEqual(payload);
|
||||||
expect(parsed.payload.commits[0]?.isOnRemote).toBe(true);
|
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]?.isOnRemote).toBe(false);
|
||||||
|
expect(parsed.payload.commits[1]?.isOnBase).toBe(true);
|
||||||
expect(parsed.payload.commits[1]?.files[0]?.status).toBeUndefined();
|
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", () => {
|
test("accepts a null baseRef and an error payload", () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
cwd: "/tmp/repo",
|
cwd: "/tmp/repo",
|
||||||
@@ -107,16 +136,17 @@ describe("checkout.commits.list schemas", () => {
|
|||||||
).toMatchObject({ type: "checkout.commits.list.response" });
|
).toMatchObject({ type: "checkout.commits.list.response" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("accepts the commitsList server_info feature flag", () => {
|
test("accepts the commit history server_info feature flags", () => {
|
||||||
expect(
|
expect(
|
||||||
ServerInfoStatusPayloadSchema.parse({
|
ServerInfoStatusPayloadSchema.parse({
|
||||||
status: "server_info",
|
status: "server_info",
|
||||||
serverId: "srv_test",
|
serverId: "srv_test",
|
||||||
features: {
|
features: {
|
||||||
commitsList: true,
|
commitsList: true,
|
||||||
|
commitBaseClassification: true,
|
||||||
},
|
},
|
||||||
}).features,
|
}).features,
|
||||||
).toEqual({ commitsList: true });
|
).toEqual({ commitsList: true, commitBaseClassification: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("still parses server_info without the commitsList feature flag", () => {
|
test("still parses server_info without the commitsList feature flag", () => {
|
||||||
|
|||||||
@@ -1732,6 +1732,8 @@ const CheckoutCommitSchema = z.object({
|
|||||||
authorName: z.string(),
|
authorName: z.string(),
|
||||||
authorDate: z.string(), // ISO 8601
|
authorDate: z.string(), // ISO 8601
|
||||||
isOnRemote: z.boolean(), // false = local-only (unpushed)
|
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),
|
files: z.array(CheckoutCommitFileSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2777,6 +2779,8 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
projectCreateDirectory: z.boolean().optional(),
|
projectCreateDirectory: z.boolean().optional(),
|
||||||
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
|
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
|
||||||
commitsList: z.boolean().optional(),
|
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.
|
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
|
||||||
providerRemoval: z.boolean().optional(),
|
providerRemoval: z.boolean().optional(),
|
||||||
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
|
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
|
||||||
|
|||||||
@@ -1008,6 +1008,7 @@ test("receives server_info on websocket connect", async () => {
|
|||||||
expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true);
|
expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true);
|
||||||
expect(serverInfo?.features?.hubRelationship).toBe(true);
|
expect(serverInfo?.features?.hubRelationship).toBe(true);
|
||||||
expect(serverInfo?.features?.commitsList).toBe(true);
|
expect(serverInfo?.features?.commitsList).toBe(true);
|
||||||
|
expect(serverInfo?.features?.commitBaseClassification).toBe(true);
|
||||||
expect(serverInfo?.desktopManaged).toBe(false);
|
expect(serverInfo?.desktopManaged).toBe(false);
|
||||||
expect(serverInfo?.features?.daemonSelfUpdate).toBe(true);
|
expect(serverInfo?.features?.daemonSelfUpdate).toBe(true);
|
||||||
expect(serverInfo?.features?.worktreeRestore).toBe(true);
|
expect(serverInfo?.features?.worktreeRestore).toBe(true);
|
||||||
|
|||||||
@@ -1408,6 +1408,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
projectCreateDirectory: true,
|
projectCreateDirectory: true,
|
||||||
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
|
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
|
||||||
commitsList: true,
|
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.
|
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
|
||||||
providerRemoval: true,
|
providerRemoval: true,
|
||||||
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
|
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { tmpdir } from "os";
|
|||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { listCheckoutCommits } from "./checkout-git.js";
|
import { listCheckoutCommits } from "./checkout-git.js";
|
||||||
|
import { writePaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
@@ -73,6 +74,9 @@ describe("listCheckoutCommits", () => {
|
|||||||
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[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([
|
expect(commits[0]?.files).toEqual([
|
||||||
{ path: "bar.txt", additions: 1, deletions: 0, status: "added" },
|
{ path: "bar.txt", additions: 1, deletions: 0, status: "added" },
|
||||||
@@ -92,6 +96,26 @@ describe("listCheckoutCommits", () => {
|
|||||||
const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir });
|
const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir });
|
||||||
expect(baseRef).toBeNull();
|
expect(baseRef).toBeNull();
|
||||||
expect(commits.map((entry) => entry.subject)).toEqual(["initial"]);
|
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 () => {
|
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();
|
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) {
|
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}`);
|
commitFile(repoDir, "history.txt", `${index}\n`, `Commit ${index}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { commits } = await listCheckoutCommits({ cwd: repoDir });
|
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(
|
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 () => {
|
it("shows merged branch commits and compares the merge against its first parent", async () => {
|
||||||
|
|||||||
@@ -1988,8 +1988,9 @@ export async function getCheckoutStatus(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// The explorer is a recent-history view, not a full repository log.
|
// Workspace history stays complete; base history is bounded context until the
|
||||||
const MAX_CHECKOUT_COMMITS = 20;
|
// 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.
|
// 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";
|
||||||
@@ -2009,6 +2010,12 @@ interface ParsedCheckoutCommit {
|
|||||||
files: CheckoutCommitFile[];
|
files: CheckoutCommitFile[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CheckoutCommitLogInput {
|
||||||
|
cwd: string;
|
||||||
|
revision: string;
|
||||||
|
maxCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
function mapNameStatusLetter(letter: string): CheckoutCommitFileStatus | undefined {
|
function mapNameStatusLetter(letter: string): CheckoutCommitFileStatus | undefined {
|
||||||
switch (letter) {
|
switch (letter) {
|
||||||
case "A":
|
case "A":
|
||||||
@@ -2133,14 +2140,11 @@ function parseCheckoutCommitRecords(stdout: string): ParsedCheckoutCommit[] {
|
|||||||
|
|
||||||
// Returns commits reachable from HEAD that are not reachable from any remote ref.
|
// Returns commits reachable from HEAD that are not reachable from any remote ref.
|
||||||
async function getUnpushedCommitShas(cwd: string, context?: CheckoutContext): Promise<Set<string>> {
|
async function getUnpushedCommitShas(cwd: string, context?: CheckoutContext): Promise<Set<string>> {
|
||||||
const { stdout } = await runGitCommand(
|
const { stdout } = await runGitCommand(["rev-list", "HEAD", "--not", "--remotes"], {
|
||||||
["rev-list", `--max-count=${MAX_CHECKOUT_COMMITS}`, "HEAD", "--not", "--remotes"],
|
cwd,
|
||||||
{
|
envOverlay: READ_ONLY_GIT_ENV,
|
||||||
cwd,
|
logger: context?.logger,
|
||||||
envOverlay: READ_ONLY_GIT_ENV,
|
});
|
||||||
logger: context?.logger,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return new Set(
|
return new Set(
|
||||||
stdout
|
stdout
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -2149,58 +2153,104 @@ async function getUnpushedCommitShas(cwd: string, context?: CheckoutContext): Pr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function getCheckoutCommitRecords({
|
||||||
* Lists the current branch's 20 most recent commits, newest first, each flagged
|
cwd,
|
||||||
* local-only vs on-remote with per-commit file +/- stats.
|
revision,
|
||||||
*/
|
maxCount,
|
||||||
|
}: CheckoutCommitLogInput): Promise<ParsedCheckoutCommit[]> {
|
||||||
|
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 {
|
export interface CheckoutCommitsResult {
|
||||||
baseRef: string | null;
|
baseRef: string | null;
|
||||||
commits: CheckoutCommit[];
|
commits: CheckoutCommit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function tryResolveCheckoutCommitsBaseRef(
|
||||||
|
cwd: string,
|
||||||
|
baseRef: string | null,
|
||||||
|
currentBranch: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!baseRef) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
|
||||||
|
if (!normalizedBaseRef || normalizedBaseRef === currentBranch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolveMostAheadBaseRef(cwd, normalizedBaseRef).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
export async function listCheckoutCommits({
|
export async function listCheckoutCommits({
|
||||||
cwd,
|
cwd,
|
||||||
|
context,
|
||||||
}: {
|
}: {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
context?: CheckoutContext;
|
||||||
}): Promise<CheckoutCommitsResult> {
|
}): Promise<CheckoutCommitsResult> {
|
||||||
const currentBranch = await getCurrentBranch(cwd);
|
const currentBranch = await getCurrentBranch(cwd);
|
||||||
if (!currentBranch) {
|
if (!currentBranch) {
|
||||||
return { baseRef: null, commits: [] };
|
return { baseRef: null, commits: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd);
|
const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
||||||
const normalizedBaseRef = resolvedBaseRef ? normalizeLocalBranchRefName(resolvedBaseRef) : null;
|
const normalizedBaseRef = resolvedBaseRef ? normalizeLocalBranchRefName(resolvedBaseRef) : null;
|
||||||
let comparisonBaseRef: string | null = null;
|
let comparisonBaseRef = await tryResolveCheckoutCommitsBaseRef(
|
||||||
if (resolvedBaseRef && normalizedBaseRef && normalizedBaseRef !== currentBranch) {
|
cwd,
|
||||||
try {
|
resolvedBaseRef,
|
||||||
comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef);
|
currentBranch,
|
||||||
} catch {
|
);
|
||||||
// History does not depend on the configured base being available.
|
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.
|
let workspaceRecords: ParsedCheckoutCommit[] = [];
|
||||||
// (`--name-status` cannot be combined with `--numstat` — git emits only one.)
|
let baseRevision = "HEAD";
|
||||||
const logResult = await runGitCommand(
|
if (comparisonBaseRef) {
|
||||||
[
|
const [records, mergeBase] = await Promise.all([
|
||||||
"log",
|
getCheckoutCommitRecords({ cwd, revision: `${comparisonBaseRef}..HEAD` }),
|
||||||
"HEAD",
|
tryResolveMergeBase(cwd, comparisonBaseRef),
|
||||||
"--diff-merges=first-parent",
|
]);
|
||||||
`--max-count=${MAX_CHECKOUT_COMMITS}`,
|
workspaceRecords = records;
|
||||||
`--format=${COMMIT_LOG_FORMAT}`,
|
baseRevision = mergeBase ?? "";
|
||||||
"--raw",
|
}
|
||||||
"--numstat",
|
|
||||||
"-M",
|
|
||||||
],
|
|
||||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
|
|
||||||
);
|
|
||||||
|
|
||||||
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) {
|
if (records.length === 0) {
|
||||||
return { baseRef: comparisonBaseRef, commits: [] };
|
return { baseRef: comparisonBaseRef, commits: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const unpushedShas = await getUnpushedCommitShas(cwd);
|
const unpushedShas = await getUnpushedCommitShas(cwd);
|
||||||
|
const workspaceShas = new Set(workspaceRecords.map((record) => record.sha));
|
||||||
|
|
||||||
const commits = records.map((record) => ({
|
const commits = records.map((record) => ({
|
||||||
sha: record.sha,
|
sha: record.sha,
|
||||||
@@ -2209,6 +2259,7 @@ export async function listCheckoutCommits({
|
|||||||
authorName: record.authorName,
|
authorName: record.authorName,
|
||||||
authorDate: record.authorDate,
|
authorDate: record.authorDate,
|
||||||
isOnRemote: !unpushedShas.has(record.sha),
|
isOnRemote: !unpushedShas.has(record.sha),
|
||||||
|
isOnBase: !workspaceShas.has(record.sha),
|
||||||
files: record.files,
|
files: record.files,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user