feat(git): inline file diffs in commit history rows (#1291)

* chore: add .worktrees/ to gitignore for worktree workflow

* feat(git): add getCommitFileDiff service function

* docs(git): document getCommitFileDiff in module docs

* feat(git): add GET /api/git/commit-file-diff route

* feat(git): add CommitFileDiffResponse type and GitAPI method signature

* feat(git): add getCommitFileDiff HTTP client function

* feat(git): add getCommitFileDiff API facade

* feat(git): add getCommitFileDiff stub to VS Code bridge

* feat(git): add getCommitFileDiff to VS Code gitService and bridge handler

* feat(git): add inline file diff to history commit rows

* fix(git): consolidate CommitFileDiffResponse import to gitApi facade

* fix(git): pass directory through history, validate hash, propagate git errors

* fix(git): use exit code check for VS Code getCommitFileDiff error detection

* fix(git): VS Code rename detection, hash validation parity, retry on error

* fix(git): register scroll container as virtualizer root to fix empty space in history diffs

* fix(git): address greptile review — rename key extraction, directory guard, language detection, isBinary cleanup

* fix(git): harden history inline diffs

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Erman HAVUÇ
2026-05-17 20:08:17 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent fa8fac2590
commit 631905764e
19 changed files with 488 additions and 44 deletions
@@ -2325,6 +2325,7 @@ export const GitView: React.FC = () => {
commitFilesMap={commitFilesMap}
loadingCommitHashes={loadingCommitHashes}
onCopyHash={handleCopyCommitHash}
directory={currentDirectory ?? undefined}
showHeader={false}
contentMaxHeightClassName="h-full max-h-none"
branchDivider={historyBranchDivider}
@@ -5,6 +5,52 @@ import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import type { GitLogEntry, CommitFileEntry } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
import { getCommitFileDiff, type CommitFileDiffResponse } from '@/lib/gitApi';
import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
import { getLanguageFromExtension } from '@/lib/toolHelpers';
const HISTORY_DIFF_REQUEST_TIMEOUT_MS = 15000;
const HISTORY_DIFF_LARGE_CHANGED_LINES = 500;
const HISTORY_DIFF_CACHE_MAX_ENTRIES = 12;
const HISTORY_DIFF_CACHE_MAX_TOTAL_SIZE_BYTES = 8 * 1024 * 1024;
type HistoryDiffCacheValue = CommitFileDiffResponse | 'loading' | 'error';
const getHistoryDiffCacheSize = (value: HistoryDiffCacheValue): number => {
if (typeof value === 'string') {
return 0;
}
return (value.original?.length ?? 0) + (value.modified?.length ?? 0);
};
const trimHistoryDiffCache = (cache: Map<string, HistoryDiffCacheValue>): Map<string, HistoryDiffCacheValue> => {
if (cache.size <= HISTORY_DIFF_CACHE_MAX_ENTRIES) {
let totalSize = 0;
for (const value of cache.values()) {
totalSize += getHistoryDiffCacheSize(value);
}
if (totalSize <= HISTORY_DIFF_CACHE_MAX_TOTAL_SIZE_BYTES) {
return cache;
}
}
const entries = Array.from(cache.entries()).reverse();
const next = new Map<string, HistoryDiffCacheValue>();
let totalSize = 0;
for (const [key, value] of entries) {
if (next.size >= HISTORY_DIFF_CACHE_MAX_ENTRIES) {
continue;
}
const entrySize = getHistoryDiffCacheSize(value);
if (totalSize + entrySize > HISTORY_DIFF_CACHE_MAX_TOTAL_SIZE_BYTES && next.size > 0) {
continue;
}
next.set(key, value);
totalSize += entrySize;
}
return new Map(Array.from(next.entries()).reverse());
};
interface HistoryCommitRowProps {
entry: GitLogEntry;
@@ -13,6 +59,7 @@ interface HistoryCommitRowProps {
files: CommitFileEntry[];
isLoadingFiles: boolean;
onCopyHash: (hash: string) => void;
directory: string | undefined;
}
function formatCommitDate(date: string) {
@@ -53,8 +100,68 @@ export const HistoryCommitRow = React.memo(({
files,
isLoadingFiles,
onCopyHash,
directory,
}: HistoryCommitRowProps) => {
const { t } = useI18n();
const [openDiffPaths, setOpenDiffPaths] = React.useState<Set<string>>(new Set());
const [diffCache, setDiffCache] = React.useState<Map<string, HistoryDiffCacheValue>>(new Map());
const [forceRenderLargePaths, setForceRenderLargePaths] = React.useState<Set<string>>(new Set());
const loadFileDiff = React.useCallback(async (file: CommitFileEntry) => {
const key = file.path;
if (!directory) {
setDiffCache(prev => new Map(prev).set(key, 'error'));
return;
}
setDiffCache(prev => trimHistoryDiffCache(new Map(prev).set(key, 'loading')));
try {
const fetchPromise = getCommitFileDiff(directory, entry.hash, file.path, false);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`Timed out after ${HISTORY_DIFF_REQUEST_TIMEOUT_MS}ms`)), HISTORY_DIFF_REQUEST_TIMEOUT_MS);
});
const result = await Promise.race([fetchPromise, timeoutPromise]);
setDiffCache(prev => trimHistoryDiffCache(new Map(prev).set(key, result)));
} catch {
setDiffCache(prev => new Map(prev).set(key, 'error'));
}
}, [directory, entry.hash]);
const toggleFileDiff = React.useCallback(async (file: CommitFileEntry) => {
const key = file.path;
if (file.changeType === 'R' || file.isBinary) {
setOpenDiffPaths(prev => {
const next = new Set(prev);
if (next.has(key)) { next.delete(key); } else { next.add(key); }
return next;
});
return;
}
const cached = diffCache.get(key);
const isOpen = openDiffPaths.has(key);
if (isOpen && cached && cached !== 'error') {
// Close it
setOpenDiffPaths(prev => { const next = new Set(prev); next.delete(key); return next; });
return;
}
// Open it (or re-fetch on error)
setOpenDiffPaths(prev => { const next = new Set(prev); next.add(key); return next; });
if (cached && cached !== 'error') return; // Already loaded
const changedLines = file.insertions + file.deletions;
if (changedLines > HISTORY_DIFF_LARGE_CHANGED_LINES && !forceRenderLargePaths.has(key)) {
return;
}
await loadFileDiff(file);
}, [diffCache, forceRenderLargePaths, loadFileDiff, openDiffPaths]);
return (
<li>
<button
@@ -120,36 +227,108 @@ export const HistoryCommitRow = React.memo(({
) : (
<ul className="space-y-0.5 py-2">
{files.map((file) => (
<li
key={file.path}
className="flex items-center gap-2 typography-micro"
>
<span
<li key={file.path}>
<button
type="button"
onClick={() => toggleFileDiff(file)}
className={cn(
'font-semibold w-3 text-center',
getChangeTypeColor(file.changeType)
'w-full flex items-center gap-2 typography-micro text-left cursor-pointer transition-colors rounded px-1',
openDiffPaths.has(file.path) ? 'bg-sidebar/90' : 'hover:bg-sidebar/40'
)}
>
{file.changeType}
</span>
<span className="truncate text-foreground min-w-0" title={file.path}>
{file.path}
</span>
{!file.isBinary && (
<span className="shrink-0">
<span style={{ color: 'var(--status-success)' }}>
+{file.insertions}
</span>
<span className="text-muted-foreground mx-0.5">/</span>
<span style={{ color: 'var(--status-error)' }}>
-{file.deletions}
</span>
<span
className={cn(
'font-semibold w-3 text-center shrink-0',
getChangeTypeColor(file.changeType)
)}
>
{file.changeType}
</span>
)}
{file.isBinary && (
<span className="typography-micro text-muted-foreground shrink-0">
{t('gitView.history.binary')}
<span className="truncate text-foreground min-w-0" title={file.path}>
{file.path}
</span>
{!file.isBinary && (
<span className="shrink-0">
<span style={{ color: 'var(--status-success)' }}>
+{file.insertions}
</span>
<span className="text-muted-foreground mx-0.5">/</span>
<span style={{ color: 'var(--status-error)' }}>
-{file.deletions}
</span>
</span>
)}
{file.isBinary && (
<span className="typography-micro text-muted-foreground shrink-0">
{t('gitView.history.binary')}
</span>
)}
<Icon
name={openDiffPaths.has(file.path) ? 'arrow-down-s' : 'arrow-right-s'}
className="size-3 shrink-0 text-muted-foreground"
/>
</button>
{openDiffPaths.has(file.path) && (
<div className="max-h-[400px] overflow-y-auto rounded border border-border/40 mx-2 mb-1" data-diff-virtual-root data-diff-virtual-content>
{file.changeType === 'R' ? (
<div className="px-3 py-2 text-sm text-muted-foreground">{t('gitView.history.renamedNoDiff')}</div>
) : file.isBinary ? (
<div className="px-3 py-2 text-sm text-muted-foreground">{t('gitView.history.binaryNoDiff')}</div>
) : (() => {
const changedLines = file.insertions + file.deletions;
if (!forceRenderLargePaths.has(file.path) && changedLines > HISTORY_DIFF_LARGE_CHANGED_LINES) {
return (
<div className="flex flex-col items-start gap-1 px-3 py-2 text-sm text-muted-foreground">
<div className="typography-ui-label font-semibold text-foreground">
{t('gitView.history.largeDiffTitle', { count: changedLines })}
</div>
<div className="typography-meta text-muted-foreground">
{t('gitView.history.largeDiffDescription')}
</div>
<Button
type="button"
variant="ghost"
size="xs"
className="h-6 px-0 text-primary hover:bg-transparent hover:underline"
onClick={() => {
setForceRenderLargePaths(prev => new Set(prev).add(file.path));
void loadFileDiff(file);
}}
>
{t('gitView.history.renderDiffAnyway')}
</Button>
</div>
);
}
const cached = diffCache.get(file.path);
if (cached === 'loading' || cached === undefined) {
return <div className="px-3 py-2 text-sm text-muted-foreground">{t('gitView.history.loadingDiff')}</div>;
}
if (cached === 'error') {
return (
<button
type="button"
onClick={() => toggleFileDiff(file)}
className="w-full text-left px-3 py-2 text-sm text-muted-foreground hover:bg-[var(--interactive-hover)] transition-colors"
>
{t('gitView.history.diffError')}
</button>
);
}
return (
<PierreDiffViewer
original={cached.original}
modified={cached.modified}
language={getLanguageFromExtension(file.path) || ''}
fileName={file.path}
renderSideBySide={false}
layout="inline"
/>
);
})()}
</div>
)}
</li>
))}
@@ -33,6 +33,7 @@ interface HistorySectionProps {
commitFilesMap: Map<string, CommitFileEntry[]>;
loadingCommitHashes: Set<string>;
onCopyHash: (hash: string) => void;
directory: string | undefined;
showHeader?: boolean;
contentMaxHeightClassName?: string;
branchDivider?: {
@@ -52,6 +53,7 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
commitFilesMap,
loadingCommitHashes,
onCopyHash,
directory,
showHeader = true,
contentMaxHeightClassName = 'max-h-[50vh]',
branchDivider = null,
@@ -92,6 +94,7 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
files={commitFilesMap.get(entry.hash) ?? []}
isLoadingFiles={loadingCommitHashes.has(entry.hash)}
onCopyHash={onCopyHash}
directory={directory}
/>
))}
</ul>
+7
View File
@@ -303,6 +303,12 @@ export interface GitCommitFilesResponse {
files: CommitFileEntry[];
}
export interface CommitFileDiffResponse {
original: string;
modified: string;
isBinary: boolean;
}
export interface GitWorktreeInfo {
head: string;
name: string;
@@ -447,6 +453,7 @@ export interface GitAPI {
renameBranch(directory: string, oldName: string, newName: string): Promise<{ success: boolean; branch: string }>;
getGitLog(directory: string, options?: GitLogOptions): Promise<GitLogResponse>;
getCommitFiles(directory: string, hash: string): Promise<GitCommitFilesResponse>;
getCommitFileDiff?(directory: string, hash: string, filePath: string, isBinary: boolean): Promise<CommitFileDiffResponse>;
getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null>;
hasLocalIdentity?(directory: string): Promise<boolean>;
setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }>;
+12
View File
@@ -36,6 +36,7 @@ export type {
GitMergeResult,
GitRebaseResult,
MergeConflictDetails,
CommitFileDiffResponse,
} from './api/types';
declare global {
@@ -642,6 +643,17 @@ export async function getCommitFiles(
return gitHttp.getCommitFiles(directory, hash);
}
export async function getCommitFileDiff(
directory: string,
hash: string,
filePath: string,
isBinary: boolean
): Promise<import('./api/types').CommitFileDiffResponse> {
const runtime = getRuntimeGit();
if (runtime?.getCommitFileDiff) return runtime.getCommitFileDiff(directory, hash, filePath, isBinary);
return gitHttp.getCommitFileDiff(directory, hash, filePath, isBinary);
}
export async function getGitIdentities(): Promise<import('./api/types').GitIdentityProfile[]> {
const runtime = getRuntimeGit();
if (runtime) return runtime.getGitIdentities();
+20
View File
@@ -25,6 +25,7 @@ import type {
GitLogOptions,
GitLogResponse,
GitCommitFilesResponse,
CommitFileDiffResponse,
GitIdentityProfile,
GitIdentitySummary,
DiscoveredGitCredential,
@@ -681,6 +682,25 @@ export async function getCommitFiles(
return response.json();
}
export async function getCommitFileDiff(
directory: string,
hash: string,
filePath: string,
isBinary: boolean
): Promise<CommitFileDiffResponse> {
const response = await fetch(
buildUrl(`${API_BASE}/commit-file-diff`, directory, {
hash,
path: filePath,
binary: isBinary ? 'true' : undefined,
})
);
if (!response.ok) {
throw new Error(`Failed to get commit file diff: ${response.statusText}`);
}
return response.json();
}
export async function getGitIdentities(): Promise<GitIdentityProfile[]> {
const response = await fetch(buildUrl(`${API_BASE}/identities`, undefined));
if (!response.ok) {
+7
View File
@@ -483,14 +483,21 @@ export const dict = {
'gitView.header.removeRemoteAria': 'Remove Remote aria label',
'gitView.header.removeRemoteTitle': 'Remove Remote Title',
'gitView.history.binary': 'Binary',
'gitView.history.binaryNoDiff': 'Binary file — no diff available',
'gitView.history.commitsPlaceholder': 'Commits Placeholder',
'gitView.history.copySha': 'Copy SHA',
'gitView.history.diffError': 'Failed to load diff. Click to retry.',
'gitView.history.largeDiffDescription': 'Rendering may be slow. You can still view the diff by clicking below.',
'gitView.history.largeDiffTitle': 'Large diff ({count} changed lines)',
'gitView.history.loadingDiff': 'Loading diff...',
'gitView.history.loadingFiles': 'Loading files...',
'gitView.history.logSize100': 'Log Size100',
'gitView.history.logSize25': 'Log Size25',
'gitView.history.logSize50': 'Log Size50',
'gitView.history.noCommits': 'No commits found',
'gitView.history.noFiles': 'No files',
'gitView.history.renamedNoDiff': 'Renamed file — diff not supported',
'gitView.history.renderDiffAnyway': 'Render anyway',
'gitView.history.title': 'History',
'gitView.integrate.checking': 'Checking…',
'gitView.integrate.cherryPickAbortedToast': 'Cherry Pick Aborted Toast',
+7
View File
@@ -484,14 +484,21 @@ export const dict: Record<I18nKey, string> = {
"gitView.header.removeRemoteAria": "Eliminar remoto",
"gitView.header.removeRemoteTitle": "Eliminar remoto",
"gitView.history.binary": "Binario",
"gitView.history.binaryNoDiff": "Archivo binario — no hay diff disponible",
"gitView.history.commitsPlaceholder": "Buscar commits...",
"gitView.history.copySha": "Copiar SHA",
"gitView.history.diffError": "Error al cargar el diff. Haz clic para reintentar.",
"gitView.history.largeDiffDescription": "El renderizado puede ser lento. Puedes ver el diff igualmente haciendo clic abajo.",
"gitView.history.largeDiffTitle": "Diff grande ({count} líneas cambiadas)",
"gitView.history.loadingDiff": "Cargando diff...",
"gitView.history.loadingFiles": "Cargando archivos...",
"gitView.history.logSize100": "100 commits",
"gitView.history.logSize25": "25 commits",
"gitView.history.logSize50": "50 commits",
"gitView.history.noCommits": "No se encontraron commits",
"gitView.history.noFiles": "No hay archivos",
"gitView.history.renamedNoDiff": "Archivo renombrado — diff no soportado",
"gitView.history.renderDiffAnyway": "Renderizar igualmente",
"gitView.history.title": "Historial",
"gitView.integrate.checking": "Verificando…",
"gitView.integrate.cherryPickAbortedToast": "Cherry-pick abortado",
+7
View File
@@ -484,14 +484,21 @@ export const dict: Record<I18nKey, string> = {
'gitView.header.removeRemoteAria': '리모트 제거',
'gitView.header.removeRemoteTitle': '리모트 제거',
'gitView.history.binary': '바이너리',
'gitView.history.binaryNoDiff': '바이너리 파일 — diff 없음',
'gitView.history.commitsPlaceholder': '커밋 검색',
'gitView.history.copySha': 'SHA 복사',
'gitView.history.diffError': 'diff 로드 실패. 클릭하여 재시도.',
'gitView.history.largeDiffDescription': '렌더링이 느릴 수 있습니다. 아래를 클릭해 diff를 계속 볼 수 있습니다.',
'gitView.history.largeDiffTitle': '큰 diff({count}개 변경된 줄)',
'gitView.history.loadingDiff': 'diff 로드 중…',
'gitView.history.loadingFiles': '파일 로드 중…',
'gitView.history.logSize100': '최근 100개',
'gitView.history.logSize25': '최근 25개',
'gitView.history.logSize50': '최근 50개',
'gitView.history.noCommits': '커밋 없음',
'gitView.history.noFiles': '파일 없음',
'gitView.history.renamedNoDiff': '이름 변경된 파일 — diff 미지원',
'gitView.history.renderDiffAnyway': '그래도 렌더링',
'gitView.history.title': '히스토리',
'gitView.integrate.checking': '확인 중…',
'gitView.integrate.cherryPickAbortedToast': 'Cherry-pick이 중단되었습니다',
+7
View File
@@ -1450,15 +1450,22 @@ export const dict: Record<I18nKey, string> = {
'gitView.header.removeRemoteAria': 'Remove Remote aria label',
'gitView.header.removeRemoteTitle': 'Remove Remote Title',
'gitView.history.binary': 'Binary',
'gitView.history.binaryNoDiff': 'Binary file — no diff available',
'gitView.history.commitsPlaceholder': 'Commits Placeholder',
'gitView.history.copySha': 'Copy SHA',
'gitView.history.dialogDescription': 'Browse recent commits and inspect changed files.',
'gitView.history.diffError': 'Failed to load diff. Click to retry.',
'gitView.history.largeDiffDescription': 'Rendering may be slow. You can still view the diff by clicking below.',
'gitView.history.largeDiffTitle': 'Large diff ({count} changed lines)',
'gitView.history.loadingDiff': 'Loading diff...',
'gitView.history.loadingFiles': 'Loading files...',
'gitView.history.logSize100': 'Log Size100',
'gitView.history.logSize25': 'Log Size25',
'gitView.history.logSize50': 'Log Size50',
'gitView.history.noCommits': 'No commits found',
'gitView.history.noFiles': 'No files',
'gitView.history.renamedNoDiff': 'Renamed file — diff not supported',
'gitView.history.renderDiffAnyway': 'Render anyway',
'gitView.history.title': 'History',
'gitView.integrate.checking': 'Sprawdzanie…',
'gitView.integrate.cherryPickAbortedToast': 'Cherry-pick został przerwany',
@@ -484,14 +484,21 @@ export const dict: Record<I18nKey, string> = {
"gitView.header.removeRemoteAria": "Excluir remoto",
"gitView.header.removeRemoteTitle": "Excluir remoto",
"gitView.history.binary": "Binario",
"gitView.history.binaryNoDiff": "Arquivo binário — diff não disponível",
"gitView.history.commitsPlaceholder": "Buscar commits...",
"gitView.history.copySha": "Copiar SHA",
"gitView.history.diffError": "Falha ao carregar diff. Clique para tentar novamente.",
"gitView.history.largeDiffDescription": "A renderização pode ser lenta. Você ainda pode ver o diff clicando abaixo.",
"gitView.history.largeDiffTitle": "Diff grande ({count} linhas alteradas)",
"gitView.history.loadingDiff": "Carregando diff...",
"gitView.history.loadingFiles": "Carregando arquivos...",
"gitView.history.logSize100": "100 commits",
"gitView.history.logSize25": "25 commits",
"gitView.history.logSize50": "50 commits",
"gitView.history.noCommits": "Nenhum commit encontrado",
"gitView.history.noFiles": "Não há arquivos",
"gitView.history.renamedNoDiff": "Arquivo renomeado — diff não suportado",
"gitView.history.renderDiffAnyway": "Renderizar mesmo assim",
"gitView.history.title": "Histórico",
"gitView.integrate.checking": "Verificando…",
"gitView.integrate.cherryPickAbortedToast": "Cherry-pick abortado",
+7
View File
@@ -484,14 +484,21 @@ export const dict: Record<I18nKey, string> = {
"gitView.header.removeRemoteAria": "Видалити remote",
"gitView.header.removeRemoteTitle": "Видалити remote",
"gitView.history.binary": "Бінарний",
"gitView.history.binaryNoDiff": "Бінарний файл — diff недоступний",
"gitView.history.commitsPlaceholder": "Пошук комітів",
"gitView.history.copySha": "Скопіювати SHA",
"gitView.history.diffError": "Не вдалося завантажити diff. Натисніть, щоб повторити.",
"gitView.history.largeDiffDescription": "Рендеринг може бути повільним. Diff все одно можна переглянути нижче.",
"gitView.history.largeDiffTitle": "Великий diff ({count} змінених рядків)",
"gitView.history.loadingDiff": "Завантаження diff...",
"gitView.history.loadingFiles": "Завантаження файлів...",
"gitView.history.logSize100": "Розмір журналу 100",
"gitView.history.logSize25": "Розмір журналу 25",
"gitView.history.logSize50": "Розмір журналу 50",
"gitView.history.noCommits": "Комітів не знайдено",
"gitView.history.noFiles": "Немає файлів",
"gitView.history.renamedNoDiff": "Перейменований файл — diff не підтримується",
"gitView.history.renderDiffAnyway": "Показати все одно",
"gitView.history.title": "Історія",
"gitView.integrate.checking": "Перевірка…",
"gitView.integrate.cherryPickAbortedToast": "Cherry-pick перервано",
@@ -484,14 +484,21 @@ export const dict: Record<I18nKey, string> = {
'gitView.header.removeRemoteAria': '移除远程 {name}',
'gitView.header.removeRemoteTitle': '移除 {name}',
'gitView.history.binary': '二进制',
'gitView.history.binaryNoDiff': '二进制文件 — 无可用 diff',
'gitView.history.commitsPlaceholder': '提交数',
'gitView.history.copySha': '复制 SHA',
'gitView.history.diffError': '加载 diff 失败,点击重试。',
'gitView.history.largeDiffDescription': '渲染可能较慢。你仍可点击下方查看 diff。',
'gitView.history.largeDiffTitle': '大型 diff{count} 行变更)',
'gitView.history.loadingDiff': '正在加载 diff...',
'gitView.history.loadingFiles': '正在加载文件...',
'gitView.history.logSize100': '100 个提交',
'gitView.history.logSize25': '25 个提交',
'gitView.history.logSize50': '50 个提交',
'gitView.history.noCommits': '未找到提交',
'gitView.history.noFiles': '没有文件',
'gitView.history.renamedNoDiff': '已重命名文件 — 不支持 diff',
'gitView.history.renderDiffAnyway': '仍然渲染',
'gitView.history.title': '历史',
'gitView.integrate.checking': '检查中…',
'gitView.integrate.cherryPickAbortedToast': '已中止 cherry-pick',