feat(FN-3863): add stash recovery dashboard surface and API routes
Merged FN-3863 brings stash recovery to the dashboard via three coordinated steps: engine-side orphan stash surfacing API in `merger.ts`, dashboard API routes for stash recovery data, and a new `StashRecoveryView` component with mobile support and inspect-diff row actions, integrated into the header Fusion-Task-Id: FN-3863
This commit is contained in:
5
.changeset/FN-3863-stash-recovery-surface.md
Normal file
5
.changeset/FN-3863-stash-recovery-surface.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add stash recovery APIs and dashboard surface for listing, diffing, applying, and safely dropping orphaned merger autostashes.
|
||||||
@@ -626,10 +626,10 @@ The test config (`vitest.config.ts`) includes `test.css: { include: [/.+/] }` so
|
|||||||
|
|
||||||
### Lazy-Loaded Heavy Views
|
### Lazy-Loaded Heavy Views
|
||||||
|
|
||||||
These 15 views are lazy-loaded via `React.lazy()` to manage bundle size:
|
These 16 views are lazy-loaded via `React.lazy()` to manage bundle size:
|
||||||
|
|
||||||
- `AgentsView`, `NodesView`, `ChatView`, `MemoryView`
|
- `AgentsView`, `NodesView`, `ChatView`, `MemoryView`
|
||||||
- `DevServerView`, `InsightsView`, `DocumentsView`, `SkillsView`, `ResearchView`, `EvalsView`, `TodoView`
|
- `DevServerView`, `InsightsView`, `DocumentsView`, `SkillsView`, `ResearchView`, `EvalsView`, `TodoView`, `StashRecoveryView`
|
||||||
- `SetupWizardModal`, `PluginManager`, `PiExtensionsManager`, `AgentDetailView`
|
- `SetupWizardModal`, `PluginManager`, `PiExtensionsManager`, `AgentDetailView`
|
||||||
|
|
||||||
They are loaded in `App.tsx` / `AppModals.tsx` / `SettingsModal.tsx` / `AgentsView.tsx` with `<Suspense fallback={null}>`.
|
They are loaded in `App.tsx` / `AppModals.tsx` / `SettingsModal.tsx` / `AgentsView.tsx` with `<Suspense fallback={null}>`.
|
||||||
|
|||||||
@@ -1251,6 +1251,13 @@ Git dashboard routes are registered in `register-git-github.ts`.
|
|||||||
- If restore fails with unresolved developer work (`failed`/`conflict-needs-manual`), cleanup uses a keep-if-live rule so still-live stashes are preserved for manual recovery.
|
- If restore fails with unresolved developer work (`failed`/`conflict-needs-manual`), cleanup uses a keep-if-live rule so still-live stashes are preserved for manual recovery.
|
||||||
- `sweepAutostashOrphans()` keeps its subsumed/live classification for prior-run leftovers, and `sweepStaleAutostashes()` adds an age-based backstop that drops `fusion-merger-autostash:*` entries older than the configured threshold (default 24h).
|
- `sweepAutostashOrphans()` keeps its subsumed/live classification for prior-run leftovers, and `sweepStaleAutostashes()` adds an age-based backstop that drops `fusion-merger-autostash:*` entries older than the configured threshold (default 24h).
|
||||||
|
|
||||||
|
#### Stash Recovery surface
|
||||||
|
- Orphans are typically residual `fusion-merger-autostash:*` entries from older merge runs where restore could not safely complete.
|
||||||
|
- Existing task-scoped surfacing remains: merger warnings still log to `mergerLog.warn` and `store.logEntry` for the active merge task.
|
||||||
|
- New global surfacing adds `merger:autostashOrphans` TaskStore events, engine helpers (`listAutostashOrphans`, `getAutostashDiff`, `applyAutostashBySha`, `dropAutostashBySha`), and dashboard API endpoints under `/api/stash-recovery/*`.
|
||||||
|
- Dashboard operators can inspect orphan counts, review diffs, apply stashes, and explicitly drop entries with confirmation.
|
||||||
|
- Decision: recovery stays user-gated. Auto-apply was rejected because clean-tree checks are racy, stash placement is ambiguous after source task merge, and apply conflicts can produce hard-to-untangle state. `sweepAutostashOrphans` continues to auto-drop only subsumed entries while preserving live developer work.
|
||||||
|
|
||||||
### Conflict handling
|
### Conflict handling
|
||||||
`merger.ts` includes conflict classification and auto-resolution helpers:
|
`merger.ts` includes conflict classification and auto-resolution helpers:
|
||||||
- lock files (`LOCKFILE_PATTERNS`)
|
- lock files (`LOCKFILE_PATTERNS`)
|
||||||
|
|||||||
@@ -427,6 +427,18 @@ export interface TaskStoreEvents {
|
|||||||
"task:merged": [result: MergeResult];
|
"task:merged": [result: MergeResult];
|
||||||
"settings:updated": [data: { settings: Settings; previous: Settings }];
|
"settings:updated": [data: { settings: Settings; previous: Settings }];
|
||||||
"agent:log": [entry: AgentLogEntry];
|
"agent:log": [entry: AgentLogEntry];
|
||||||
|
"merger:autostashOrphans": [data: {
|
||||||
|
rootDir: string;
|
||||||
|
records: Array<{
|
||||||
|
sha: string;
|
||||||
|
ref: string;
|
||||||
|
label: string;
|
||||||
|
sourceTaskId: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
changedPaths: string[];
|
||||||
|
classification: "subsumed" | "live" | "unknown";
|
||||||
|
}>;
|
||||||
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ import { NativeShellConnectionManager } from "./components/NativeShellConnection
|
|||||||
import { ShellConnectionStatus } from "./components/ShellConnectionStatus";
|
import { ShellConnectionStatus } from "./components/ShellConnectionStatus";
|
||||||
import { getShellConnectionNativeResult, type ShellConnectionNativeResult } from "./shell-native";
|
import { getShellConnectionNativeResult, type ShellConnectionNativeResult } from "./shell-native";
|
||||||
import type { AiSessionSummary } from "./api";
|
import type { AiSessionSummary } from "./api";
|
||||||
import { fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps } from "./api";
|
import { api, fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps } from "./api";
|
||||||
import { getScopedItem, setScopedItem } from "./utils/projectStorage";
|
import { getScopedItem, setScopedItem } from "./utils/projectStorage";
|
||||||
import { subscribeSse } from "./sse-bus";
|
import { subscribeSse } from "./sse-bus";
|
||||||
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth";
|
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth";
|
||||||
@@ -92,6 +92,7 @@ const SkillsView = lazy(() => import("./components/SkillsView").then((m) => ({ d
|
|||||||
const MemoryView = lazy(() => import("./components/MemoryView").then((m) => ({ default: m.MemoryView })));
|
const MemoryView = lazy(() => import("./components/MemoryView").then((m) => ({ default: m.MemoryView })));
|
||||||
const DevServerView = lazy(() => import("./components/DevServerView").then((m) => ({ default: m.DevServerView })));
|
const DevServerView = lazy(() => import("./components/DevServerView").then((m) => ({ default: m.DevServerView })));
|
||||||
const _TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView })));
|
const _TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView })));
|
||||||
|
const StashRecoveryView = lazy(() => import("./components/StashRecoveryView").then((m) => ({ default: m.StashRecoveryView })));
|
||||||
|
|
||||||
// Warm lazy chunks during browser idle so first navigation to each view is
|
// Warm lazy chunks during browser idle so first navigation to each view is
|
||||||
// instant. Each chunk is ~10–80 kB; total prefetch finishes well under a
|
// instant. Each chunk is ~10–80 kB; total prefetch finishes well under a
|
||||||
@@ -117,6 +118,7 @@ function prefetchLazyViews() {
|
|||||||
void import("./components/MemoryView");
|
void import("./components/MemoryView");
|
||||||
void import("./components/DevServerView");
|
void import("./components/DevServerView");
|
||||||
void import("./components/TodoView");
|
void import("./components/TodoView");
|
||||||
|
void import("./components/StashRecoveryView");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,6 +435,7 @@ function AppInner() {
|
|||||||
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
|
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
|
||||||
const [mailboxPendingApprovalCount, setMailboxPendingApprovalCount] = useState(0);
|
const [mailboxPendingApprovalCount, setMailboxPendingApprovalCount] = useState(0);
|
||||||
const [chatHasUnreadResponse, setChatHasUnreadResponse] = useState(false);
|
const [chatHasUnreadResponse, setChatHasUnreadResponse] = useState(false);
|
||||||
|
const [stashOrphanCount, setStashOrphanCount] = useState(0);
|
||||||
const [approvalBannerCandidate, setApprovalBannerCandidate] = useState<ApprovalBannerCandidate | null>(null);
|
const [approvalBannerCandidate, setApprovalBannerCandidate] = useState<ApprovalBannerCandidate | null>(null);
|
||||||
const taskStatusByIdRef = useRef<Map<string, string | undefined>>(new Map());
|
const taskStatusByIdRef = useRef<Map<string, string | undefined>>(new Map());
|
||||||
const seenApprovalKeysRef = useRef<Set<string>>(new Set());
|
const seenApprovalKeysRef = useRef<Set<string>>(new Set());
|
||||||
@@ -546,6 +549,24 @@ function AppInner() {
|
|||||||
}
|
}
|
||||||
}, [taskView]);
|
}, [taskView]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api<{ count: number }>("/stash-recovery/orphans");
|
||||||
|
if (!cancelled) setStashOrphanCount(data.count ?? 0);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setStashOrphanCount(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void load();
|
||||||
|
const timer = window.setInterval(() => void load(), 30000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [currentProject?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (currentProject?.id) {
|
if (currentProject?.id) {
|
||||||
@@ -1275,6 +1296,16 @@ function AppInner() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (taskView === "stash-recovery") {
|
||||||
|
return (
|
||||||
|
<PageErrorBoundary>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<StashRecoveryView />
|
||||||
|
</Suspense>
|
||||||
|
</PageErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (taskView === "insights") {
|
if (taskView === "insights") {
|
||||||
if (!settingsLoaded || !insightsEnabled) {
|
if (!settingsLoaded || !insightsEnabled) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1463,6 +1494,7 @@ function AppInner() {
|
|||||||
mailboxUnreadCount={mailboxUnreadCount}
|
mailboxUnreadCount={mailboxUnreadCount}
|
||||||
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
|
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
|
||||||
chatHasUnreadResponse={chatHasUnreadResponse}
|
chatHasUnreadResponse={chatHasUnreadResponse}
|
||||||
|
stashOrphanCount={stashOrphanCount}
|
||||||
onOpenSchedules={openSchedulesWithNav}
|
onOpenSchedules={openSchedulesWithNav}
|
||||||
onOpenGitManager={openGitManagerWithNav}
|
onOpenGitManager={openGitManagerWithNav}
|
||||||
onOpenNodes={handleOpenNodesWithNav}
|
onOpenNodes={handleOpenNodesWithNav}
|
||||||
@@ -1612,6 +1644,7 @@ function AppInner() {
|
|||||||
mailboxUnreadCount={mailboxUnreadCount}
|
mailboxUnreadCount={mailboxUnreadCount}
|
||||||
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
|
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
|
||||||
chatHasUnreadResponse={chatHasUnreadResponse}
|
chatHasUnreadResponse={chatHasUnreadResponse}
|
||||||
|
stashOrphanCount={stashOrphanCount}
|
||||||
onOpenGitManager={openGitManagerWithNav}
|
onOpenGitManager={openGitManagerWithNav}
|
||||||
onOpenWorkflowSteps={openWorkflowStepsWithNav}
|
onOpenWorkflowSteps={openWorkflowStepsWithNav}
|
||||||
onOpenSchedules={openSchedulesWithNav}
|
onOpenSchedules={openSchedulesWithNav}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const EXPECTED_DOCUMENTED_VIEWS = new Set([
|
|||||||
"ResearchView",
|
"ResearchView",
|
||||||
"EvalsView",
|
"EvalsView",
|
||||||
"TodoView",
|
"TodoView",
|
||||||
|
"StashRecoveryView",
|
||||||
"SetupWizardModal",
|
"SetupWizardModal",
|
||||||
"PluginManager",
|
"PluginManager",
|
||||||
"PiExtensionsManager",
|
"PiExtensionsManager",
|
||||||
@@ -32,6 +33,7 @@ const EXPECTED_APP_LEVEL_VIEWS = new Set([
|
|||||||
"MemoryView",
|
"MemoryView",
|
||||||
"DevServerView",
|
"DevServerView",
|
||||||
"TodoView",
|
"TodoView",
|
||||||
|
"StashRecoveryView",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function extractLazyLoadedSection(agentsDoc: string): string {
|
function extractLazyLoadedSection(agentsDoc: string): string {
|
||||||
@@ -62,11 +64,11 @@ describe("AGENTS lazy-loaded views inventory", () => {
|
|||||||
const section = extractLazyLoadedSection(agentsDoc);
|
const section = extractLazyLoadedSection(agentsDoc);
|
||||||
const countMatch = section.match(/These\s+(\d+)\s+views\s+are lazy-loaded/);
|
const countMatch = section.match(/These\s+(\d+)\s+views\s+are lazy-loaded/);
|
||||||
expect(countMatch).toBeTruthy();
|
expect(countMatch).toBeTruthy();
|
||||||
expect(Number(countMatch?.[1])).toBe(15);
|
expect(Number(countMatch?.[1])).toBe(16);
|
||||||
|
|
||||||
const documentedViews = extractBacktickedNamesFromBullets(section);
|
const documentedViews = extractBacktickedNamesFromBullets(section);
|
||||||
expect(new Set(documentedViews)).toEqual(EXPECTED_DOCUMENTED_VIEWS);
|
expect(new Set(documentedViews)).toEqual(EXPECTED_DOCUMENTED_VIEWS);
|
||||||
expect(documentedViews).toHaveLength(15);
|
expect(documentedViews).toHaveLength(16);
|
||||||
|
|
||||||
expect(section).toContain("`ResearchView`");
|
expect(section).toContain("`ResearchView`");
|
||||||
expect(section).toContain("`TodoView`");
|
expect(section).toContain("`TodoView`");
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ export interface HeaderProps {
|
|||||||
mailboxPendingApprovalCount?: number;
|
mailboxPendingApprovalCount?: number;
|
||||||
/** Whether chat has an unread assistant response */
|
/** Whether chat has an unread assistant response */
|
||||||
chatHasUnreadResponse?: boolean;
|
chatHasUnreadResponse?: boolean;
|
||||||
|
/** Count of orphaned merger autostashes for stash recovery indicator. */
|
||||||
|
stashOrphanCount?: number;
|
||||||
onOpenSchedules?: () => void;
|
onOpenSchedules?: () => void;
|
||||||
onOpenGitManager?: () => void;
|
onOpenGitManager?: () => void;
|
||||||
onOpenNodes?: () => void;
|
onOpenNodes?: () => void;
|
||||||
@@ -257,6 +259,7 @@ export function Header({
|
|||||||
mailboxUnreadCount = 0,
|
mailboxUnreadCount = 0,
|
||||||
mailboxPendingApprovalCount = 0,
|
mailboxPendingApprovalCount = 0,
|
||||||
chatHasUnreadResponse = false,
|
chatHasUnreadResponse = false,
|
||||||
|
stashOrphanCount = 0,
|
||||||
onOpenSchedules,
|
onOpenSchedules,
|
||||||
onOpenGitManager,
|
onOpenGitManager,
|
||||||
onOpenNodes,
|
onOpenNodes,
|
||||||
@@ -1179,7 +1182,7 @@ export function Header({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
ref={viewOverflowTriggerRef}
|
ref={viewOverflowTriggerRef}
|
||||||
className={`view-toggle-btn${["research", "skills", "insights", "memory", "dev-server", "devserver", "graph"].includes(view) || (experimentalFeatures?.evalsView && view === "evals") || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
|
className={`view-toggle-btn${["research", "skills", "insights", "memory", "dev-server", "devserver", "graph", "stash-recovery"].includes(view) || (experimentalFeatures?.evalsView && view === "evals") || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
|
||||||
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
||||||
title="More views"
|
title="More views"
|
||||||
aria-label="More views"
|
aria-label="More views"
|
||||||
@@ -1210,6 +1213,20 @@ export function Header({
|
|||||||
<span>Evals</span>
|
<span>Evals</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
className={`view-toggle-overflow-item${view === "stash-recovery" ? " active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
onChangeView("stash-recovery");
|
||||||
|
setIsViewOverflowOpen(false);
|
||||||
|
}}
|
||||||
|
role="menuitem"
|
||||||
|
data-testid="view-overflow-stash-recovery"
|
||||||
|
>
|
||||||
|
<History size={14} />
|
||||||
|
<span>Stash Recovery</span>
|
||||||
|
{stashOrphanCount > 0 ? <span className="btn-badge">{stashOrphanCount}</span> : null}
|
||||||
|
</button>
|
||||||
|
|
||||||
{experimentalFeatures?.researchView && (
|
{experimentalFeatures?.researchView && (
|
||||||
<button
|
<button
|
||||||
className={`view-toggle-overflow-item${view === "research" ? " active" : ""}`}
|
className={`view-toggle-overflow-item${view === "research" ? " active" : ""}`}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Folder,
|
Folder,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Grid3X3,
|
Grid3X3,
|
||||||
|
History,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -55,6 +56,7 @@ export interface MobileNavBarProps {
|
|||||||
mailboxUnreadCount?: number;
|
mailboxUnreadCount?: number;
|
||||||
mailboxPendingApprovalCount?: number;
|
mailboxPendingApprovalCount?: number;
|
||||||
chatHasUnreadResponse?: boolean;
|
chatHasUnreadResponse?: boolean;
|
||||||
|
stashOrphanCount?: number;
|
||||||
onOpenGitManager?: () => void;
|
onOpenGitManager?: () => void;
|
||||||
onOpenWorkflowSteps?: () => void;
|
onOpenWorkflowSteps?: () => void;
|
||||||
onOpenSchedules?: () => void;
|
onOpenSchedules?: () => void;
|
||||||
@@ -120,6 +122,7 @@ export function MobileNavBar({
|
|||||||
mailboxUnreadCount = 0,
|
mailboxUnreadCount = 0,
|
||||||
mailboxPendingApprovalCount = 0,
|
mailboxPendingApprovalCount = 0,
|
||||||
chatHasUnreadResponse = false,
|
chatHasUnreadResponse = false,
|
||||||
|
stashOrphanCount = 0,
|
||||||
onOpenGitManager,
|
onOpenGitManager,
|
||||||
onOpenWorkflowSteps,
|
onOpenWorkflowSteps,
|
||||||
onOpenSchedules,
|
onOpenSchedules,
|
||||||
@@ -235,6 +238,7 @@ export function MobileNavBar({
|
|||||||
|| (todosOpen && todoViewEnabled)
|
|| (todosOpen && todoViewEnabled)
|
||||||
|| (view === "skills" && !showSkillsTopLevel)
|
|| (view === "skills" && !showSkillsTopLevel)
|
||||||
|| view === "graph"
|
|| view === "graph"
|
||||||
|
|| view === "stash-recovery"
|
||||||
|| (isPluginViewId(view) && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
|
|| (isPluginViewId(view) && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -628,6 +632,17 @@ export function MobileNavBar({
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mobile-more-item"
|
||||||
|
data-testid="mobile-more-item-stash-recovery"
|
||||||
|
onClick={() => handleMoreAction(() => onChangeView("stash-recovery"))}
|
||||||
|
>
|
||||||
|
<History />
|
||||||
|
<span>Stash Recovery</span>
|
||||||
|
{stashOrphanCount > 0 ? <span className="mobile-more-item-badge">{formatCount(stashOrphanCount)}</span> : null}
|
||||||
|
</button>
|
||||||
|
|
||||||
{experimentalFeatures?.researchView && (
|
{experimentalFeatures?.researchView && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
101
packages/dashboard/app/components/StashRecoveryView.css
Normal file
101
packages/dashboard/app/components/StashRecoveryView.css
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
.stash-recovery-view {
|
||||||
|
padding: var(--space-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stash-recovery-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stash-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr auto auto;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-sm) 0;
|
||||||
|
border-bottom: var(--btn-border-width) solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stash-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stash-field-label {
|
||||||
|
display: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stash-row-actions,
|
||||||
|
.stash-row-actions-danger {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stash-action-btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stash-status {
|
||||||
|
color: var(--text-muted);
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stash-recovery-diff-modal {
|
||||||
|
max-width: 80vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stash-recovery-diff-pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-md);
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--surface);
|
||||||
|
border: var(--btn-border-width) solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stash-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stash-field-label {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stash-row-actions,
|
||||||
|
.stash-row-actions-danger {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stash-action-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-height: var(--mobile-nav-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stash-row-actions-danger .stash-action-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stash-recovery-diff-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
144
packages/dashboard/app/components/StashRecoveryView.tsx
Normal file
144
packages/dashboard/app/components/StashRecoveryView.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { useConfirm } from "../hooks/useConfirm";
|
||||||
|
import "./StashRecoveryView.css";
|
||||||
|
|
||||||
|
type RecordItem = {
|
||||||
|
sha: string;
|
||||||
|
sourceTaskId: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
classification: "subsumed" | "live" | "unknown";
|
||||||
|
changedPaths: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiffResponse = {
|
||||||
|
diff: string;
|
||||||
|
truncated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StashRecoveryView() {
|
||||||
|
const { confirm } = useConfirm();
|
||||||
|
const [records, setRecords] = useState<RecordItem[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [applyState, setApplyState] = useState<Record<string, string>>({});
|
||||||
|
const [diffState, setDiffState] = useState<{ sha: string; diff: string; truncated: boolean; loading: boolean; error: string | null } | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const data = await api<{ records: RecordItem[] }>("/stash-recovery/orphans");
|
||||||
|
setRecords(data.records ?? []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load orphans");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const map = new Map<string, RecordItem[]>();
|
||||||
|
for (const record of records) {
|
||||||
|
const key = record.sourceTaskId ?? "Unknown source";
|
||||||
|
const existing = map.get(key) ?? [];
|
||||||
|
existing.push(record);
|
||||||
|
map.set(key, existing);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries());
|
||||||
|
}, [records]);
|
||||||
|
|
||||||
|
const handleApply = useCallback(async (sha: string) => {
|
||||||
|
const result = await api<{ ok: boolean; reason?: string; stderr?: string }>(`/stash-recovery/orphans/${sha}/apply`, { method: "POST" });
|
||||||
|
setApplyState((prev) => ({ ...prev, [sha]: result.ok ? "Applied" : result.stderr ?? result.reason ?? "Apply failed" }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(async (sha: string) => {
|
||||||
|
const shouldDrop = await confirm({
|
||||||
|
title: "Drop orphaned stash?",
|
||||||
|
message: "This removes the stash entry permanently.",
|
||||||
|
confirmLabel: "Drop",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!shouldDrop) return;
|
||||||
|
await api(`/stash-recovery/orphans/${sha}/drop`, { method: "POST", body: JSON.stringify({ confirm: true }) });
|
||||||
|
await load();
|
||||||
|
}, [confirm, load]);
|
||||||
|
|
||||||
|
const handleInspectDiff = useCallback(async (sha: string) => {
|
||||||
|
setDiffState({ sha, diff: "", truncated: false, loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const data = await api<DiffResponse>(`/stash-recovery/orphans/${sha}/diff`);
|
||||||
|
setDiffState({ sha, diff: data.diff ?? "", truncated: Boolean(data.truncated), loading: false, error: null });
|
||||||
|
} catch (err) {
|
||||||
|
setDiffState({ sha, diff: "", truncated: false, loading: false, error: err instanceof Error ? err.message : "Failed to load diff" });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (records.length === 0 && !error) {
|
||||||
|
return <div className="card stash-recovery-view"><p>No orphaned merger autostashes found.</p><button className="btn btn-sm" onClick={() => void load()}>Refresh</button></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card stash-recovery-view">
|
||||||
|
<div className="stash-recovery-header">
|
||||||
|
<h2>Stash Recovery</h2>
|
||||||
|
<span>{records.length} orphans</span>
|
||||||
|
<button className="btn btn-sm" onClick={() => void load()}>Refresh</button>
|
||||||
|
</div>
|
||||||
|
{error && <div className="form-error">{error}</div>}
|
||||||
|
{groups.map(([group, items]) => (
|
||||||
|
<section key={group}>
|
||||||
|
<h3>{group}</h3>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.sha} className="stash-row">
|
||||||
|
<div className="stash-field">
|
||||||
|
<span className="stash-field-label">SHA</span>
|
||||||
|
<span>{item.sha.slice(0, 7)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stash-field">
|
||||||
|
<span className="stash-field-label">Classification</span>
|
||||||
|
<span>{item.classification}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stash-field">
|
||||||
|
<span className="stash-field-label">Changed paths</span>
|
||||||
|
<span>{item.changedPaths.length} files</span>
|
||||||
|
</div>
|
||||||
|
<div className="stash-row-actions">
|
||||||
|
<button className="btn btn-sm stash-action-btn" onClick={() => void handleInspectDiff(item.sha)}>Inspect diff</button>
|
||||||
|
<button className="btn btn-sm stash-action-btn" onClick={() => void handleApply(item.sha)}>Apply</button>
|
||||||
|
</div>
|
||||||
|
<div className="stash-row-actions-danger">
|
||||||
|
<button className="btn btn-sm btn-danger stash-action-btn" onClick={() => void handleDrop(item.sha)}>Drop</button>
|
||||||
|
</div>
|
||||||
|
{applyState[item.sha] && <div className="stash-status">{applyState[item.sha]}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
{diffState && (
|
||||||
|
<div className="modal-overlay open" onClick={() => setDiffState(null)}>
|
||||||
|
<div className="modal stash-recovery-diff-modal" role="dialog" aria-modal="true" aria-label={`Diff for ${diffState.sha}`} onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>Diff for {diffState.sha.slice(0, 7)}</h3>
|
||||||
|
<button className="modal-close" onClick={() => setDiffState(null)} aria-label="Close diff dialog">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{diffState.loading && <p>Loading diff…</p>}
|
||||||
|
{diffState.error && <div className="form-error">{diffState.error}</div>}
|
||||||
|
{!diffState.loading && !diffState.error && (
|
||||||
|
<>
|
||||||
|
<pre className="stash-recovery-diff-pre">{diffState.diff || "No diff output available."}</pre>
|
||||||
|
{diffState.truncated && <p className="stash-status">Diff output truncated.</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn" onClick={() => setDiffState(null)}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { StashRecoveryView } from "../StashRecoveryView";
|
||||||
|
|
||||||
|
const apiMock = vi.fn();
|
||||||
|
const confirmMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({ api: (...args: unknown[]) => apiMock(...args) }));
|
||||||
|
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: confirmMock }) }));
|
||||||
|
|
||||||
|
describe("StashRecoveryView", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders empty state", async () => {
|
||||||
|
apiMock.mockResolvedValueOnce({ records: [] });
|
||||||
|
render(<StashRecoveryView />);
|
||||||
|
expect(await screen.findByText(/No orphaned merger autostashes found/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders grouped rows and apply", async () => {
|
||||||
|
apiMock.mockResolvedValueOnce({ records: [{ sha: "abcdef123", sourceTaskId: "FN-1", createdAt: null, classification: "live", changedPaths: ["a"] }] });
|
||||||
|
apiMock.mockResolvedValueOnce({ ok: false, reason: "conflict", stderr: "conflict text" });
|
||||||
|
render(<StashRecoveryView />);
|
||||||
|
expect(await screen.findByText("FN-1")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByText("Apply"));
|
||||||
|
await waitFor(() => expect(screen.getByText(/conflict text/i)).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens inspect diff modal", async () => {
|
||||||
|
apiMock.mockResolvedValueOnce({ records: [{ sha: "abcdef123", sourceTaskId: "FN-1", createdAt: null, classification: "live", changedPaths: ["a"] }] });
|
||||||
|
apiMock.mockResolvedValueOnce({ diff: "patch-content", truncated: false });
|
||||||
|
render(<StashRecoveryView />);
|
||||||
|
await screen.findByText("FN-1");
|
||||||
|
fireEvent.click(screen.getByText("Inspect diff"));
|
||||||
|
expect(await screen.findByText(/Diff for abcdef1/i)).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(screen.getByText("patch-content")).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drop requires confirmation", async () => {
|
||||||
|
apiMock.mockResolvedValueOnce({ records: [{ sha: "abcdef123", sourceTaskId: null, createdAt: null, classification: "live", changedPaths: [] }] });
|
||||||
|
confirmMock.mockResolvedValueOnce(false);
|
||||||
|
render(<StashRecoveryView />);
|
||||||
|
await screen.findByText("Unknown source");
|
||||||
|
fireEvent.click(screen.getByText("Drop"));
|
||||||
|
await waitFor(() => expect(confirmMock).toHaveBeenCalled());
|
||||||
|
expect(apiMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
|||||||
import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry";
|
import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry";
|
||||||
|
|
||||||
export type ViewMode = "overview" | "project";
|
export type ViewMode = "overview" | "project";
|
||||||
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
|
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "stash-recovery";
|
||||||
export type PluginTaskView = `plugin:${string}:${string}`;
|
export type PluginTaskView = `plugin:${string}:${string}`;
|
||||||
export type TaskView = BuiltInTaskView | PluginTaskView;
|
export type TaskView = BuiltInTaskView | PluginTaskView;
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
|
|||||||
"memory",
|
"memory",
|
||||||
"devserver",
|
"devserver",
|
||||||
"dev-server",
|
"dev-server",
|
||||||
|
"stash-recovery",
|
||||||
];
|
];
|
||||||
|
|
||||||
function isBuiltInTaskView(value: string | null): value is BuiltInTaskView {
|
function isBuiltInTaskView(value: string | null): value is BuiltInTaskView {
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import type { TaskStore } from "@fusion/core";
|
||||||
|
import { request as REQUEST } from "../../test-request.js";
|
||||||
|
|
||||||
|
const engineMocks = vi.hoisted(() => ({
|
||||||
|
listAutostashOrphans: vi.fn(),
|
||||||
|
getAutostashDiff: vi.fn(),
|
||||||
|
applyAutostashBySha: vi.fn(),
|
||||||
|
dropAutostashBySha: vi.fn(),
|
||||||
|
notifyAutostashOrphans: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@fusion/engine", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@fusion/engine")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
...engineMocks,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { createApiRoutes } from "../../routes.js";
|
||||||
|
|
||||||
|
function createMockStore(): TaskStore {
|
||||||
|
return {
|
||||||
|
getRootDir: vi.fn(() => "/tmp/project"),
|
||||||
|
} as unknown as TaskStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(store: TaskStore) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api", createApiRoutes(store));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("stash recovery routes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.values(engineMocks).forEach((mockFn) => mockFn.mockReset());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns orphan records", async () => {
|
||||||
|
engineMocks.listAutostashOrphans.mockResolvedValue([
|
||||||
|
{
|
||||||
|
sha: "abcdef1",
|
||||||
|
ref: "stash@{0}",
|
||||||
|
label: "fusion-merger-autostash:FN-1:1",
|
||||||
|
sourceTaskId: "FN-1",
|
||||||
|
createdAt: null,
|
||||||
|
changedPaths: ["file.txt"],
|
||||||
|
classification: "live",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await REQUEST(buildApp(createMockStore()), "GET", "/api/stash-recovery/orphans");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.count).toBe(1);
|
||||||
|
expect(res.body.records[0].sha).toBe("abcdef1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns diff + truncated flag", async () => {
|
||||||
|
engineMocks.getAutostashDiff.mockResolvedValue("diff text\n… (diff truncated)");
|
||||||
|
const res = await REQUEST(buildApp(createMockStore()), "GET", "/api/stash-recovery/orphans/abcdef1/diff");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.truncated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies stash with success and conflict responses", async () => {
|
||||||
|
engineMocks.applyAutostashBySha.mockResolvedValueOnce({ ok: true });
|
||||||
|
let res = await REQUEST(
|
||||||
|
buildApp(createMockStore()),
|
||||||
|
"POST",
|
||||||
|
"/api/stash-recovery/orphans/abcdef1/apply",
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ ok: true });
|
||||||
|
|
||||||
|
engineMocks.applyAutostashBySha.mockResolvedValueOnce({ ok: false, reason: "conflict", stderr: "CONFLICT" });
|
||||||
|
res = await REQUEST(
|
||||||
|
buildApp(createMockStore()),
|
||||||
|
"POST",
|
||||||
|
"/api/stash-recovery/orphans/abcdef1/apply",
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.ok).toBe(false);
|
||||||
|
expect(res.body.reason).toBe("conflict");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires confirm for drop", async () => {
|
||||||
|
const app = buildApp(createMockStore());
|
||||||
|
let res = await REQUEST(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/stash-recovery/orphans/abcdef1/drop",
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
|
||||||
|
engineMocks.dropAutostashBySha.mockResolvedValueOnce({ dropped: true });
|
||||||
|
res = await REQUEST(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/stash-recovery/orphans/abcdef1/drop",
|
||||||
|
JSON.stringify({ confirm: true }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(engineMocks.dropAutostashBySha).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid sha before calling engine", async () => {
|
||||||
|
const res = await REQUEST(buildApp(createMockStore()), "GET", "/api/stash-recovery/orphans/not-valid/diff");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(engineMocks.getAutostashDiff).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import { createResearchRouter } from "../research-routes.js";
|
|||||||
import { createTodoRouter } from "../todo-routes.js";
|
import { createTodoRouter } from "../todo-routes.js";
|
||||||
import { createDevServerRouter } from "../dev-server-routes.js";
|
import { createDevServerRouter } from "../dev-server-routes.js";
|
||||||
import type { AiSessionStore } from "../ai-session-store.js";
|
import type { AiSessionStore } from "../ai-session-store.js";
|
||||||
|
import { createStashRecoveryRouter } from "./register-stash-recovery-routes.js";
|
||||||
|
|
||||||
interface IntegratedRoutersOptions {
|
interface IntegratedRoutersOptions {
|
||||||
router: Router;
|
router: Router;
|
||||||
@@ -36,6 +37,7 @@ export function registerIntegratedRouters({
|
|||||||
router.use("/evals", createEvalsRouter(store));
|
router.use("/evals", createEvalsRouter(store));
|
||||||
router.use("/research", createResearchRouter(store));
|
router.use("/research", createResearchRouter(store));
|
||||||
router.use("/todos", createTodoRouter(store));
|
router.use("/todos", createTodoRouter(store));
|
||||||
|
router.use("/stash-recovery", createStashRecoveryRouter(store));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerIntegratedDevServerRouter({ router, store }: DevServerRouterOptions): void {
|
export function registerIntegratedDevServerRouter({ router, store }: DevServerRouterOptions): void {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Router, type Request, type Response } from "express";
|
||||||
|
import type { TaskStore } from "@fusion/core";
|
||||||
|
import {
|
||||||
|
applyAutostashBySha,
|
||||||
|
dropAutostashBySha,
|
||||||
|
getAutostashDiff,
|
||||||
|
listAutostashOrphans,
|
||||||
|
notifyAutostashOrphans,
|
||||||
|
} from "@fusion/engine";
|
||||||
|
import { badRequest } from "../api-error.js";
|
||||||
|
|
||||||
|
const SHA_RE = /^[0-9a-f]{7,40}$/;
|
||||||
|
|
||||||
|
function validateSha(sha: string): boolean {
|
||||||
|
return SHA_RE.test(sha);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRootDir(store: TaskStore): string {
|
||||||
|
return store.getRootDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStashRecoveryRouter(store: TaskStore): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/orphans", async (_req: Request, res: Response) => {
|
||||||
|
const rootDir = getRootDir(store);
|
||||||
|
const records = await listAutostashOrphans(rootDir);
|
||||||
|
res.json({ count: records.length, records, rootDir });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/orphans/:sha/diff", async (req: Request, res: Response) => {
|
||||||
|
const sha = String(req.params.sha ?? "").trim();
|
||||||
|
if (!validateSha(sha)) throw badRequest("Invalid stash sha");
|
||||||
|
const rootDir = getRootDir(store);
|
||||||
|
const diff = await getAutostashDiff(rootDir, sha);
|
||||||
|
const truncated = diff.includes("… (diff truncated)");
|
||||||
|
res.json({ sha, diff, truncated });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/orphans/:sha/apply", async (req: Request, res: Response) => {
|
||||||
|
const sha = String(req.params.sha ?? "").trim();
|
||||||
|
if (!validateSha(sha)) throw badRequest("Invalid stash sha");
|
||||||
|
const rootDir = getRootDir(store);
|
||||||
|
const result = await applyAutostashBySha(rootDir, sha);
|
||||||
|
res.status(200).json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/orphans/:sha/drop", async (req: Request, res: Response) => {
|
||||||
|
const sha = String(req.params.sha ?? "").trim();
|
||||||
|
if (!validateSha(sha)) throw badRequest("Invalid stash sha");
|
||||||
|
if (req.body?.confirm !== true) throw badRequest("confirm: true is required");
|
||||||
|
const rootDir = getRootDir(store);
|
||||||
|
const result = await dropAutostashBySha(rootDir, "stash-recovery", sha);
|
||||||
|
if (!result.dropped) {
|
||||||
|
res.status(200).json({ ok: false, reason: result.reason ?? "drop_failed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/refresh", async (_req: Request, res: Response) => {
|
||||||
|
const rootDir = getRootDir(store);
|
||||||
|
const records = await notifyAutostashOrphans(store, rootDir);
|
||||||
|
res.json({ count: records.length, records, rootDir });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ const qualityAppTests = [
|
|||||||
"app/api/**/*.test.ts",
|
"app/api/**/*.test.ts",
|
||||||
// Representative workflow/component coverage. Exhaustive modal/view suites
|
// Representative workflow/component coverage. Exhaustive modal/view suites
|
||||||
// stay available in the full `dashboard-app` project.
|
// stay available in the full `dashboard-app` project.
|
||||||
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,AuthTokenRecoveryDialog,Board,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,TaskCard,TaskChangesTab,TaskComments,TaskDocumentsTab,TaskForm,ThemeSelectorSwatchContract,WorkflowResultsTab}.test.tsx",
|
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,AuthTokenRecoveryDialog,Board,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,StashRecoveryView,TaskCard,TaskChangesTab,TaskComments,TaskDocumentsTab,TaskForm,ThemeSelectorSwatchContract,WorkflowResultsTab}.test.tsx",
|
||||||
// Hooks and utilities are fast, user-visible state/formatting behavior.
|
// Hooks and utilities are fast, user-visible state/formatting behavior.
|
||||||
"app/context/**/*.test.tsx",
|
"app/context/**/*.test.tsx",
|
||||||
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",
|
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",
|
||||||
@@ -22,7 +22,7 @@ const qualityApiTests = [
|
|||||||
// Critical HTTP/server behavior: auth, task/project/settings mutation,
|
// Critical HTTP/server behavior: auth, task/project/settings mutation,
|
||||||
// git/GitHub, agents, nodes, chat/files, realtime, and isolation guards.
|
// git/GitHub, agents, nodes, chat/files, realtime, and isolation guards.
|
||||||
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,project-routes,project-store-resolver,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-settings,routes-tasks,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket}.test.ts",
|
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,project-routes,project-store-resolver,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-settings,routes-tasks,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket}.test.ts",
|
||||||
"src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes}.test.ts",
|
"src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,stash-recovery-routes}.test.ts",
|
||||||
];
|
];
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { __test__ } from "../merger.js";
|
||||||
|
|
||||||
|
const { listAutostashOrphans, applyAutostashBySha, getAutostashDiff, notifyAutostashOrphans } = __test__;
|
||||||
|
|
||||||
|
function git(cwd: string, cmd: string): string {
|
||||||
|
return execSync(cmd, { cwd, stdio: "pipe" }).toString("utf-8").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initRepo(dir: string): void {
|
||||||
|
git(dir, "git init -b main");
|
||||||
|
git(dir, 'git config user.email "test@example.com"');
|
||||||
|
git(dir, 'git config user.name "Test"');
|
||||||
|
writeFileSync(join(dir, "file.txt"), "base\n");
|
||||||
|
git(dir, "git add file.txt");
|
||||||
|
git(dir, 'git commit -m "init"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAutostash(dir: string, label: string, content: string): string {
|
||||||
|
writeFileSync(join(dir, "file.txt"), content);
|
||||||
|
git(dir, "git add file.txt");
|
||||||
|
const sha = git(dir, "git stash create");
|
||||||
|
git(dir, `git stash store -m ${JSON.stringify(label)} ${sha}`);
|
||||||
|
git(dir, "git reset --hard HEAD");
|
||||||
|
|
||||||
|
const list = git(dir, 'git stash list --format="%H %gd %s"');
|
||||||
|
if (!list.includes(label)) {
|
||||||
|
git(dir, "git stash drop stash@{0}");
|
||||||
|
writeFileSync(join(dir, "file.txt"), content);
|
||||||
|
git(dir, `git stash push -m ${JSON.stringify(label)} file.txt`);
|
||||||
|
return git(dir, 'git stash list --format="%H" -n 1');
|
||||||
|
}
|
||||||
|
|
||||||
|
return sha;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("autostash orphan surface", () => {
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), "fn-autostash-surface-"));
|
||||||
|
initRepo(dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists fusion-merger-autostash entries and ignores unrelated stashes", async () => {
|
||||||
|
const ts = Date.now();
|
||||||
|
createAutostash(dir, `fusion-merger-autostash:FN-2001:${ts}`, "feature\n");
|
||||||
|
writeFileSync(join(dir, "file.txt"), "manual\n");
|
||||||
|
git(dir, 'git stash push -m "manual" file.txt');
|
||||||
|
|
||||||
|
const records = await listAutostashOrphans(dir);
|
||||||
|
|
||||||
|
expect(records).toHaveLength(1);
|
||||||
|
expect(records[0]?.label).toContain("fusion-merger-autostash:FN-2001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses sourceTaskId and createdAt; malformed labels return null fields", async () => {
|
||||||
|
const ts = Date.now();
|
||||||
|
createAutostash(dir, `fusion-merger-autostash:FN-2002:${ts}`, "a\n");
|
||||||
|
createAutostash(dir, "fusion-merger-autostash:FN-2003:not-a-ts", "b\n");
|
||||||
|
|
||||||
|
const records = await listAutostashOrphans(dir);
|
||||||
|
const good = records.find((r) => r.sourceTaskId === "FN-2002");
|
||||||
|
const bad = records.find((r) => r.label.includes("not-a-ts"));
|
||||||
|
|
||||||
|
expect(good?.createdAt).toBe(new Date(ts).toISOString());
|
||||||
|
expect(bad?.sourceTaskId).toBe("FN-2003");
|
||||||
|
expect(bad?.createdAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies subsumed vs live from diff against HEAD", async () => {
|
||||||
|
const subsumedSha = createAutostash(dir, `fusion-merger-autostash:FN-2004:${Date.now()}`, "subsumed\n");
|
||||||
|
writeFileSync(join(dir, "file.txt"), "subsumed\n");
|
||||||
|
git(dir, "git add file.txt");
|
||||||
|
git(dir, 'git commit -m "subsumed"');
|
||||||
|
|
||||||
|
const liveSha = createAutostash(dir, `fusion-merger-autostash:FN-2005:${Date.now()}`, "live\n");
|
||||||
|
|
||||||
|
const records = await listAutostashOrphans(dir);
|
||||||
|
|
||||||
|
expect(records.find((r) => r.sha === subsumedSha)?.classification).toBe("subsumed");
|
||||||
|
expect(records.find((r) => r.sha === liveSha)?.classification).toBe("live");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies stash on clean tree and reports conflict without dropping stash", async () => {
|
||||||
|
const label = `fusion-merger-autostash:FN-2006:${Date.now()}`;
|
||||||
|
const sha = createAutostash(dir, label, "from-stash\n");
|
||||||
|
|
||||||
|
const applyOk = await applyAutostashBySha(dir, sha);
|
||||||
|
expect(applyOk).toEqual({ ok: true });
|
||||||
|
expect(git(dir, "cat file.txt")).toContain("from-stash");
|
||||||
|
|
||||||
|
git(dir, "git checkout -- file.txt");
|
||||||
|
writeFileSync(join(dir, "file.txt"), "other-change\n");
|
||||||
|
git(dir, "git add file.txt");
|
||||||
|
git(dir, 'git commit -m "conflicting commit"');
|
||||||
|
|
||||||
|
const conflict = await applyAutostashBySha(dir, sha);
|
||||||
|
expect(conflict.ok).toBe(false);
|
||||||
|
if (!conflict.ok) {
|
||||||
|
expect(conflict.reason).toBe("conflict");
|
||||||
|
expect(conflict.stderr).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(git(dir, 'git stash list --format="%H %s"')).toContain(sha);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits merger:autostashOrphans event with records payload", async () => {
|
||||||
|
createAutostash(dir, `fusion-merger-autostash:FN-2008:${Date.now()}`, "emit\n");
|
||||||
|
const store = { emit: vi.fn() } as any;
|
||||||
|
|
||||||
|
const records = await notifyAutostashOrphans(store, dir);
|
||||||
|
|
||||||
|
expect(records).toHaveLength(1);
|
||||||
|
expect(store.emit).toHaveBeenCalledWith("merger:autostashOrphans", {
|
||||||
|
rootDir: dir,
|
||||||
|
records,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates diff output beyond cap", async () => {
|
||||||
|
const longContent = `${"x".repeat(70000)}\n`;
|
||||||
|
const sha = createAutostash(dir, `fusion-merger-autostash:FN-2007:${Date.now()}`, longContent);
|
||||||
|
|
||||||
|
const diff = await getAutostashDiff(dir, sha);
|
||||||
|
|
||||||
|
expect(Buffer.byteLength(diff, "utf-8")).toBeLessThanOrEqual(64 * 1024 + 128);
|
||||||
|
expect(diff).toContain("… (diff truncated)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,7 +20,16 @@ export { Scheduler, type SchedulerOptions } from "./scheduler.js";
|
|||||||
export { MeshLeaseManager, type MeshLeaseManagerOptions, type LeaseRecoveryContext } from "./mesh-lease-manager.js";
|
export { MeshLeaseManager, type MeshLeaseManagerOptions, type LeaseRecoveryContext } from "./mesh-lease-manager.js";
|
||||||
export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopilot.js";
|
export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopilot.js";
|
||||||
export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.js";
|
export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.js";
|
||||||
export { aiMergeTask, type MergerOptions } from "./merger.js";
|
export {
|
||||||
|
aiMergeTask,
|
||||||
|
listAutostashOrphans,
|
||||||
|
applyAutostashBySha,
|
||||||
|
dropAutostashBySha,
|
||||||
|
getAutostashDiff,
|
||||||
|
notifyAutostashOrphans,
|
||||||
|
type MergerOptions,
|
||||||
|
type AutostashOrphanRecord,
|
||||||
|
} from "./merger.js";
|
||||||
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
|
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
|
||||||
export { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, type AgentOptions, type AgentResult } from "./pi.js";
|
export { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, type AgentOptions, type AgentResult } from "./pi.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1044,6 +1044,7 @@ interface AutostashHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AUTOSTASH_LABEL_PREFIX = "fusion-merger-autostash:";
|
const AUTOSTASH_LABEL_PREFIX = "fusion-merger-autostash:";
|
||||||
|
const AUTOSTASH_TIMESTAMP_RE = /^fusion-merger-autostash:[A-Za-z]+-\d+:(?:race-rescue-\d+:)?(\d+)$/;
|
||||||
|
|
||||||
/** Return the set of paths a stash commit recorded as changed against its
|
/** Return the set of paths a stash commit recorded as changed against its
|
||||||
* parent (HEAD-at-stash-time). Used to compare a new dirty snapshot against
|
* parent (HEAD-at-stash-time). Used to compare a new dirty snapshot against
|
||||||
@@ -1246,6 +1247,7 @@ export function parsePorcelainZ(raw: string): Set<string> {
|
|||||||
async function listOrphanedAutostashes(
|
async function listOrphanedAutostashes(
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
): Promise<Array<{ sha: string; ref: string; label: string }>> {
|
): Promise<Array<{ sha: string; ref: string; label: string }>> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync(
|
const { stdout } = await execAsync(
|
||||||
`git stash list --format="%H %gd %s"`,
|
`git stash list --format="%H %gd %s"`,
|
||||||
@@ -1274,6 +1276,98 @@ function parseAutostashTaskId(label: string): string | null {
|
|||||||
return match?.[1] ?? null;
|
return match?.[1] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AutostashOrphanRecord {
|
||||||
|
sha: string;
|
||||||
|
ref: string;
|
||||||
|
label: string;
|
||||||
|
sourceTaskId: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
changedPaths: string[];
|
||||||
|
classification: "subsumed" | "live" | "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAutostashCreatedAt(label: string): string | null {
|
||||||
|
const match = AUTOSTASH_TIMESTAMP_RE.exec(label.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
const ts = Number.parseInt(match[1] ?? "", 10);
|
||||||
|
if (!Number.isFinite(ts)) return null;
|
||||||
|
return new Date(ts).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function classifyAutostashOrphan(rootDir: string, sha: string): Promise<"subsumed" | "live" | "unknown"> {
|
||||||
|
try {
|
||||||
|
const stashFiles = await listStashChangedPaths(rootDir, sha);
|
||||||
|
if (stashFiles.size === 0) return "subsumed";
|
||||||
|
const pathsArg = [...stashFiles].map(quoteArg).join(" ");
|
||||||
|
const { stdout: pathDiffOut } = await execAsync(
|
||||||
|
`git diff --name-only HEAD ${quoteArg(sha)} -- ${pathsArg}`,
|
||||||
|
{ cwd: rootDir, encoding: "utf-8" },
|
||||||
|
);
|
||||||
|
return pathDiffOut.trim() === "" ? "subsumed" : "live";
|
||||||
|
} catch {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAutostashOrphans(rootDir: string): Promise<AutostashOrphanRecord[]> {
|
||||||
|
const orphans = await listOrphanedAutostashes(rootDir);
|
||||||
|
const records: AutostashOrphanRecord[] = [];
|
||||||
|
for (const orphan of orphans) {
|
||||||
|
const changedPaths = [...(await listStashChangedPaths(rootDir, orphan.sha))];
|
||||||
|
records.push({
|
||||||
|
sha: orphan.sha,
|
||||||
|
ref: orphan.ref,
|
||||||
|
label: orphan.label,
|
||||||
|
sourceTaskId: parseAutostashTaskId(orphan.label),
|
||||||
|
createdAt: parseAutostashCreatedAt(orphan.label),
|
||||||
|
changedPaths,
|
||||||
|
classification: await classifyAutostashOrphan(rootDir, orphan.sha),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyAutostashOrphans(store: TaskStore, rootDir: string): Promise<AutostashOrphanRecord[]> {
|
||||||
|
const records = await listAutostashOrphans(rootDir);
|
||||||
|
store.emit("merger:autostashOrphans", { rootDir, records });
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyAutostashBySha(
|
||||||
|
rootDir: string,
|
||||||
|
sha: string,
|
||||||
|
): Promise<{ ok: true } | { ok: false; reason: string; stderr?: string }> {
|
||||||
|
try {
|
||||||
|
await execAsync(`git stash apply ${quoteArg(sha)}`, { cwd: rootDir, encoding: "utf-8" });
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const stderr = err && typeof err === "object" && "stderr" in err ? String((err as { stderr?: string }).stderr ?? "") : "";
|
||||||
|
const stdout = err && typeof err === "object" && "stdout" in err ? String((err as { stdout?: string }).stdout ?? "") : "";
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
const details = `${stderr}\n${stdout}\n${message}`;
|
||||||
|
if (/CONFLICT|could not apply|would be overwritten/i.test(details)) {
|
||||||
|
return { ok: false, reason: "conflict", stderr: stderr || details };
|
||||||
|
}
|
||||||
|
return { ok: false, reason: "apply_failed", stderr: stderr || details };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAutostashDiff(rootDir: string, sha: string): Promise<string> {
|
||||||
|
const maxBytes = 64 * 1024;
|
||||||
|
const { stdout } = await execAsync(`git stash show -p ${quoteArg(sha)}`, {
|
||||||
|
cwd: rootDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
maxBuffer: 5 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
const diff = String(stdout);
|
||||||
|
if (Buffer.byteLength(diff, "utf-8") <= maxBytes) return diff;
|
||||||
|
let truncated = diff;
|
||||||
|
while (Buffer.byteLength(truncated, "utf-8") > maxBytes) {
|
||||||
|
truncated = truncated.slice(0, Math.max(0, Math.floor(truncated.length * 0.9)));
|
||||||
|
}
|
||||||
|
return `${truncated}\n… (diff truncated)`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stash any unrelated dirty changes in `rootDir` before a merge runs.
|
* Stash any unrelated dirty changes in `rootDir` before a merge runs.
|
||||||
*
|
*
|
||||||
@@ -1446,9 +1540,9 @@ async function sweepAutostashOrphans(
|
|||||||
)
|
)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const AUTOSTASH_TIMESTAMP_RE = /^fusion-merger-autostash:[A-Za-z]+-\d+:(?:race-rescue-\d+:)?(\d+)$/;
|
await notifyAutostashOrphans(store, rootDir).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
export async function sweepStaleAutostashes(
|
export async function sweepStaleAutostashes(
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
@@ -1488,6 +1582,10 @@ export const __test__ = {
|
|||||||
dropAutostashHandle,
|
dropAutostashHandle,
|
||||||
isAutostashLive,
|
isAutostashLive,
|
||||||
sweepStaleAutostashes,
|
sweepStaleAutostashes,
|
||||||
|
listAutostashOrphans,
|
||||||
|
applyAutostashBySha,
|
||||||
|
getAutostashDiff,
|
||||||
|
notifyAutostashOrphans,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function stashUnrelatedRootDirChanges(
|
async function stashUnrelatedRootDirChanges(
|
||||||
@@ -1644,7 +1742,7 @@ async function findStashRefBySha(rootDir: string, sha: string): Promise<string |
|
|||||||
* with `git rev-parse`, then drop. If the SHA at the ref drifted (race),
|
* with `git rev-parse`, then drop. If the SHA at the ref drifted (race),
|
||||||
* retry up to 5x. Returns whether the drop landed cleanly so callers can
|
* retry up to 5x. Returns whether the drop landed cleanly so callers can
|
||||||
* surface failure to the task feed. */
|
* surface failure to the task feed. */
|
||||||
async function dropAutostashBySha(
|
export async function dropAutostashBySha(
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
sha: string,
|
sha: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user