feat(git): classify managed-file changes before apply (#1832)

* feat(git): classify managed-file changes before apply

Pull now builds a fingerprint-bound plan of adds, modifies, deletes, and local conflicts. Apply refuses stale or blocked plans instead of overwriting live files, and promotion stays the only filesystem mutator.

* fix(git): contain stack-dir probes before filesystem access

The missing-stack and root-.env existence checks now resolve against the compose base and refuse paths that escape it before lstat or existsSync.

* fix(git): address managed-file change plan audit blockers

Wire build-context live inventory into the planner, reject special file nodes without readFile, fingerprint configured project env files, enrich plan metadata, and compute the create plan before promotion. Redact drift ledger service keys for managed-path conflicts and clear pending plan columns on revision reset.

* fix(git): unblock change-plan CI sinks and fifo test

Hash stack files through a contained open plus fstat on the same handle so CodeQL no longer flags the lstat/read race, and create fifo fixtures with mkfifo instead of mkfifoSync.

* fix(git): preserve unowned context files and align candidate validation

Inspect prior and candidate build contexts together, delete only owned paths, reject context-root symlinks before walking, and validate with the env-file model deploy will use after promotion.

* fix(git): contain live context and candidate env path sinks

Inline resolve and startsWith at the lstat and access calls so containment is checked at the filesystem sink.

* fix(git): resolve live context walks from the compose root

Rebuild readdir, lstat, and access paths from the compose directory at each sink so containment is checked against a known-safe base.

* fix(git): validate synced env removal against post-promotion files

A managed .env that the next revision omits must not be used for candidate validation or invocation, because promotion deletes it. Context walks now bound directory entries and skip descendants under nested symlinks. Plan fingerprints bind review metadata and secret-path matching covers .env.* names.

* docs(git): capture classified change-plan review screenshots

Replace the old Monaco pull-preview images with the classified operation list used by Apply.

* fix(git): treat invocation drift as reviewable, not a file conflict

A live Compose command-line change is not a managed-file conflict. Reviewed apply records the incoming invocation; webhook auto-apply still refuses.
This commit is contained in:
Anso
2026-08-14 09:53:31 -04:00
committed by GitHub
parent 4c93947004
commit 3c4c057467
38 changed files with 4877 additions and 673 deletions
@@ -228,4 +228,17 @@ describe('DriftPanel', () => {
await screen.findByTestId('drift-status');
expect(screen.queryByText(/checked/i)).not.toBeInTheDocument();
});
it('labels a managed-path conflict without rendering the opaque service key', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
status: 'in-sync',
ledger: [
{ service: 'deadbeefcafebabe', kind: 'managed-path-conflict', message: 'compose-primary local-modified', detectedAt: Date.now(), resolvedAt: null },
],
})));
render(<DriftPanel stackName="web" />);
await screen.findByText('managed path');
expect(screen.queryByText('deadbeefcafebabe')).not.toBeInTheDocument();
expect(screen.getByText('compose-primary local-modified')).toBeInTheDocument();
});
});
+10 -3
View File
@@ -13,7 +13,7 @@ import { useNodes } from '@/context/NodeContext';
type StackDriftStatus = 'in-sync' | 'drifted' | 'missing-runtime' | 'unreachable';
type DriftFindingKind =
| 'service-missing' | 'service-undeclared' | 'image-mismatch' | 'ports-mismatch'
| 'network-undeclared' | 'network-missing';
| 'network-undeclared' | 'network-missing' | 'managed-path-conflict';
interface StackDriftFinding {
kind: DriftFindingKind;
@@ -91,6 +91,7 @@ const FINDING_LABEL: Record<DriftFindingKind, string> = {
'ports-mismatch': 'ports',
'network-undeclared': 'network',
'network-missing': 'network missing',
'managed-path-conflict': 'managed path',
};
/** The temporal overlay: how the on-disk compose compares to the last deploy baseline. */
@@ -125,10 +126,13 @@ function temporalMeta(temporal: DriftTemporal): { label: string; icon: LucideIco
}
function Finding({ finding }: { finding: StackDriftFinding }) {
const gitPath = finding.kind === 'managed-path-conflict';
return (
<div className="border-t border-muted py-2 first:border-t-0">
<div className="flex items-center gap-2">
<span className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">{finding.service}</span>
{!gitPath && (
<span className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">{finding.service}</span>
)}
<span className="font-mono text-[10px] uppercase tracking-wide text-stat-subtitle">{FINDING_LABEL[finding.kind]}</span>
</div>
<div className="mt-1 text-[12px] text-foreground/90">{finding.detail}</div>
@@ -146,10 +150,13 @@ function Finding({ finding }: { finding: StackDriftFinding }) {
function LedgerRow({ entry }: { entry: DriftLedgerEntry }) {
const resolved = entry.resolvedAt != null;
const gitPath = entry.kind === 'managed-path-conflict';
return (
<div className="border-t border-muted py-2 first:border-t-0">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">{entry.service}</span>
{!gitPath && (
<span className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">{entry.service}</span>
)}
<span className="font-mono text-[10px] uppercase tracking-wide text-stat-subtitle">{FINDING_LABEL[entry.kind] ?? entry.kind}</span>
<span className={cn('font-mono text-[10px] uppercase tracking-wide', resolved ? 'text-success' : 'text-warning')}>
{resolved ? 'resolved' : 'open'}
@@ -0,0 +1,125 @@
/**
* Classified Git change-plan review: operations render, Apply stays disabled
* when blocked or the plan is missing, and Apply never posts source bytes.
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { GitSourceDiffDialog, type PullResult } from './GitSourceDiffDialog';
function emptyCounts() {
return {
add: 0, modify: 0, delete: 0, rename: 0, unchanged: 0,
localModified: 0, localMissing: 0, typeChanged: 0, unmanagedCollision: 0, invocation: 0,
};
}
function pull(over: Partial<PullResult> = {}): PullResult {
return {
commitSha: 'abcdef1234567890abcdef1234567890abcdef12',
validation: { ok: true },
plan: {
blocked: false,
counts: { ...emptyCounts(), modify: 1, unchanged: 3 },
operations: [
{ path: 'compose.yaml', op: 'modify', role: 'compose-primary' },
],
invocation: { candidateChanged: false, liveDiverged: false },
},
planFingerprint: 'fp-clean',
...over,
};
}
function renderDialog(over: Partial<PullResult> = {}, onApply = vi.fn()) {
render(
<GitSourceDiffDialog
open
onOpenChange={vi.fn()}
stackName="web"
pull={pull(over)}
autoDeployDefault={false}
applying={false}
onApply={onApply}
onDismiss={vi.fn()}
/>,
);
return onApply;
}
describe('GitSourceDiffDialog', () => {
it('lists classified operations and collapses unchanged files', () => {
renderDialog();
expect(screen.getByText('Modify')).toBeInTheDocument();
expect(screen.getByText('compose.yaml')).toBeInTheDocument();
expect(screen.getByText('3 unchanged files')).toBeInTheDocument();
expect(screen.queryByText(/Monaco|Overwrite local edits/i)).not.toBeInTheDocument();
});
it('disables Apply when the plan is blocked', () => {
renderDialog({
plan: {
blocked: true,
counts: { ...emptyCounts(), localModified: 1 },
operations: [{ path: 'compose.yaml', op: 'local-modified', role: 'compose-primary' }],
invocation: { candidateChanged: false, liveDiverged: false },
},
});
expect(screen.getByRole('button', { name: /^Apply$/ })).toBeDisabled();
expect(screen.getAllByText(/Local conflicts block apply/i).length).toBeGreaterThan(0);
});
it('keeps Apply enabled for invocation drift and does not claim file conflicts', () => {
const onApply = renderDialog({
plan: {
blocked: false,
counts: { ...emptyCounts(), invocation: 1, modify: 1 },
operations: [
{ path: 'compose.yaml', op: 'modify', role: 'compose-primary' },
{ path: null, op: 'invocation', role: 'invocation' },
],
invocation: { candidateChanged: false, liveDiverged: true },
},
planFingerprint: 'fp-inv',
});
expect(screen.queryByText(/Local conflicts block apply/i)).not.toBeInTheDocument();
expect(screen.getByText(/Live Compose invocation changed/i)).toBeInTheDocument();
expect(screen.getByText('Compose command line')).toBeInTheDocument();
expect(screen.queryByText(/secret-bearing managed path/i)).not.toBeInTheDocument();
const apply = screen.getByRole('button', { name: /^Apply$/ });
expect(apply).toBeEnabled();
fireEvent.click(apply);
expect(onApply).toHaveBeenCalledWith(
'abcdef1234567890abcdef1234567890abcdef12',
false,
'fp-inv',
);
});
it('disables Apply when the plan is missing', () => {
renderDialog({ plan: null, planFingerprint: null });
expect(screen.getByRole('button', { name: /^Apply$/ })).toBeDisabled();
expect(screen.getByText(/Change plan unavailable/i)).toBeInTheDocument();
});
it('calls onApply with commitSha, deploy, and planFingerprint', () => {
const onApply = renderDialog();
fireEvent.click(screen.getByRole('button', { name: /^Apply$/ }));
expect(onApply).toHaveBeenCalledWith(
'abcdef1234567890abcdef1234567890abcdef12',
false,
'fp-clean',
);
});
it('redacts a missing path as a secret-bearing managed path', () => {
renderDialog({
plan: {
blocked: false,
counts: { ...emptyCounts(), modify: 1 },
operations: [{ path: null, op: 'modify', role: 'env' }],
invocation: { candidateChanged: false, liveDiverged: false },
},
});
expect(screen.getByText('secret-bearing managed path')).toBeInTheDocument();
});
});
@@ -1,25 +1,67 @@
import { useState, Suspense } from 'react';
import { SafeDiffEditor } from '@/lib/SafeDiffEditor';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { Modal, ModalHeader, ModalFooter, ConfirmModal } from '@/components/ui/modal';
import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
import { useState } from 'react';
import { AlertTriangle, GitBranch, Loader2 } from 'lucide-react';
import { Modal, ModalHeader, ModalFooter } from '@/components/ui/modal';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { springs } from '@/lib/motion';
import { ScrollArea } from '@/components/ui/scroll-area';
export type GitChangePlanOp =
| 'add'
| 'modify'
| 'delete'
| 'rename'
| 'unchanged'
| 'local-modified'
| 'local-missing'
| 'type-changed'
| 'unmanaged-collision'
| 'invocation';
export interface PublicGitChangePlanOperation {
path: string | null;
op: GitChangePlanOp;
role: string;
fromPath?: string | null;
}
export interface GitChangePlanCounts {
add: number;
modify: number;
delete: number;
rename: number;
unchanged: number;
localModified: number;
localMissing: number;
typeChanged: number;
unmanagedCollision: number;
invocation: number;
}
export interface PublicGitChangePlan {
blocked: boolean;
counts: GitChangePlanCounts;
operations: PublicGitChangePlanOperation[];
invocation: {
candidateChanged: boolean;
liveDiverged: boolean;
};
}
export interface PublicPendingPlan {
fingerprint: string;
blocked: boolean;
counts: GitChangePlanCounts;
operations: PublicGitChangePlanOperation[];
}
export interface PullResult {
commitSha: string;
incomingCompose: string;
incomingEnv: string | null;
currentCompose: string;
currentEnv: string | null;
validation: { ok: boolean; error?: string };
hasLocalChanges: boolean;
/** Tolerated refusals from complete-project discovery; intentionally not surfaced in this dialog (actionable refusals abort the pull, so this is always empty). */
refusals?: Array<{ sourcePath: string | null; kind: string; reason: string; actionable: boolean }>;
/** Clone-time warnings (submodules present, for example). */
warnings?: string[];
plan: PublicGitChangePlan | null;
planFingerprint: string | null;
}
interface GitSourceDiffDialogProps {
@@ -27,178 +69,191 @@ interface GitSourceDiffDialogProps {
onOpenChange: (open: boolean) => void;
stackName: string;
pull: PullResult | null;
syncEnv: boolean;
autoDeployDefault: boolean;
isDarkMode: boolean;
applying: boolean;
onApply: (commitSha: string, deploy: boolean) => Promise<void>;
onApply: (commitSha: string, deploy: boolean, planFingerprint: string) => Promise<void>;
onDismiss: () => Promise<void>;
}
const OP_LABEL: Record<GitChangePlanOp, string> = {
add: 'Add',
modify: 'Modify',
delete: 'Remove',
rename: 'Rename',
unchanged: 'Unchanged',
'local-modified': 'Locally modified',
'local-missing': 'Missing on disk',
'type-changed': 'Type changed',
'unmanaged-collision': 'Unmanaged file in the way',
invocation: 'Compose invocation',
};
const BLOCKING_OPS = new Set<GitChangePlanOp>([
'local-modified',
'local-missing',
'type-changed',
'unmanaged-collision',
]);
function opPathLabel(op: PublicGitChangePlanOperation): string {
if (op.op === 'invocation') return 'Compose command line';
if (op.op === 'rename' && op.fromPath) {
return `${op.fromPath}${op.path ?? 'secret-bearing path'}`;
}
return op.path ?? 'secret-bearing managed path';
}
export function GitSourceDiffDialog({
open,
onOpenChange,
stackName,
pull,
syncEnv,
autoDeployDefault,
isDarkMode,
applying,
onApply,
onDismiss,
}: GitSourceDiffDialogProps) {
const [diffTab, setDiffTab] = useState<'compose' | 'env'>('compose');
const [deployAfter, setDeployAfter] = useState<boolean>(autoDeployDefault);
const [confirmOpen, setConfirmOpen] = useState(false);
const envAvailable = syncEnv && pull?.incomingEnv !== null;
const effectiveTab = envAvailable ? diffTab : 'compose';
if (!pull) return null;
const shortSha = pull.commitSha.slice(0, 7);
const missingPlan = !pull.plan || !pull.planFingerprint;
// plan.blocked is file conflicts only; invocation.liveDiverged does not disable Apply.
const blocked = missingPlan || pull.plan?.blocked === true || !pull.validation.ok;
const ops = pull.plan?.operations ?? [];
const unchanged = pull.plan?.counts.unchanged ?? 0;
const apply = async () => {
await onApply(pull.commitSha, deployAfter);
if (!pull.planFingerprint || blocked) return;
await onApply(pull.commitSha, deployAfter, pull.planFingerprint);
};
const handleApplyClick = () => {
if (pull.hasLocalChanges) {
setConfirmOpen(true);
return;
}
apply();
};
const currentValue = effectiveTab === 'compose' ? pull.currentCompose : (pull.currentEnv ?? '');
const incomingValue = effectiveTab === 'compose' ? pull.incomingCompose : (pull.incomingEnv ?? '');
return (
<>
<Modal size="wide" open={open} onOpenChange={onOpenChange}>
<ModalHeader
kicker="GIT · PULL PREVIEW"
title={stackName}
description={`Incoming commit ${shortSha}. Review the diff between the current on-disk stack files and the incoming Git commit.`}
/>
<div className="px-6 pt-4 space-y-3">
{!pull.validation.ok && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" strokeWidth={1.5} />
<div>
<p className="font-medium">Incoming compose failed validation</p>
<pre className="font-mono text-[11px] whitespace-pre-wrap mt-1">{pull.validation.error}</pre>
</div>
</div>
)}
{pull.hasLocalChanges && (
<div className="flex items-start gap-2 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" strokeWidth={1.5} />
<div>
<p className="font-medium">Local edits detected on disk</p>
<p className="mt-0.5">Applying will overwrite changes that differ from the last applied commit.</p>
</div>
</div>
)}
{envAvailable && (
<Tabs value={diffTab} onValueChange={(v) => setDiffTab(v as 'compose' | 'env')}>
<TabsList>
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
<TabsHighlightItem value="compose">
<TabsTrigger value="compose">Compose</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="env">
<TabsTrigger value="env">.env</TabsTrigger>
</TabsHighlightItem>
</TabsHighlight>
</TabsList>
</Tabs>
)}
</div>
<div className="px-6 pb-4 pt-3">
<div className="h-[55vh] border border-glass-border rounded-md overflow-hidden">
<Suspense fallback={<div className="w-full h-full" aria-busy="true" />}>
<SafeDiffEditor
height="100%"
language={effectiveTab === 'compose' ? 'yaml' : 'ini'}
theme={isDarkMode ? 'vs-dark' : 'vs'}
original={currentValue}
modified={incomingValue}
options={{
readOnly: true,
renderSideBySide: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontFamily: "'Geist Mono', monospace",
fontSize: 12,
}}
/>
</Suspense>
</div>
</div>
<ModalFooter
hint={
<div className="flex items-center gap-2">
<Checkbox
id="git-source-deploy-after"
checked={deployAfter}
onCheckedChange={(checked) => setDeployAfter(checked === true)}
disabled={applying || !pull.validation.ok}
/>
<Label
htmlFor="git-source-deploy-after"
className="text-xs normal-case tracking-normal cursor-pointer"
>
Deploy after apply
</Label>
</div>
}
secondary={
<Button
variant="outline"
size="sm"
onClick={() => onDismiss()}
disabled={applying}
>
Dismiss
</Button>
}
primary={
<Button
size="sm"
onClick={handleApplyClick}
disabled={applying || !pull.validation.ok}
>
{applying ? (
<>
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />
Applying...
</>
) : (
'Apply'
)}
</Button>
}
/>
</Modal>
<ConfirmModal
open={confirmOpen}
onOpenChange={setConfirmOpen}
variant="destructive"
kicker="GIT · LOCAL CHANGES"
title="Overwrite local edits?"
description="The on-disk stack files differ from the last applied commit. Applying this pull will replace them with the incoming content."
confirmLabel="Overwrite and apply"
confirming={applying}
onConfirm={async () => {
setConfirmOpen(false);
await apply();
}}
<Modal size="wide" open={open} onOpenChange={onOpenChange} mobileFullScreen>
<ModalHeader
kicker="GIT · CHANGE PLAN"
title={stackName}
description={`Incoming commit ${shortSha}. Review classified file operations before applying. Unmanaged files are left untouched.`}
/>
</>
<div className="px-6 pt-4 space-y-3 max-md:px-4">
{!pull.validation.ok && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" strokeWidth={1.5} />
<div>
<p className="font-medium">Incoming compose failed validation</p>
<pre className="font-mono text-[11px] whitespace-pre-wrap mt-1">{pull.validation.error}</pre>
</div>
</div>
)}
{missingPlan && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" strokeWidth={1.5} />
<div>
<p className="font-medium">Change plan unavailable</p>
<p className="mt-0.5">This node did not return a classified plan. Pull again after updating the remote instance.</p>
</div>
</div>
)}
{pull.plan?.blocked && (
<div className="flex items-start gap-2 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" strokeWidth={1.5} />
<div>
<p className="font-medium">Local conflicts block apply</p>
<p className="mt-0.5">Resolve locally modified, missing, or colliding files, then pull again. Sencho will not overwrite them.</p>
</div>
</div>
)}
{pull.plan?.invocation.liveDiverged && (
<div className="flex items-start gap-2 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" strokeWidth={1.5} />
<div>
<p className="font-medium">Live Compose invocation changed</p>
<p className="mt-0.5">The Compose command line on disk no longer matches the last applied generation, for example a .env file was added or removed outside Git. Apply records the incoming invocation as the new baseline. Unmanaged files stay on disk.</p>
</div>
</div>
)}
</div>
<div className="px-6 pb-4 pt-3 max-md:px-4">
<ScrollArea className="h-[45vh] max-md:h-[40vh] border border-glass-border rounded-md">
<ul className="divide-y divide-glass-border text-sm">
{ops.map((op, i) => (
<li
key={`${op.op}-${op.path ?? 'redacted'}-${i}`}
className="flex items-start gap-3 px-3 py-2"
data-testid="git-plan-op"
data-op={op.op}
>
<GitBranch className="w-3.5 h-3.5 shrink-0 mt-0.5 text-stat-subtitle" strokeWidth={1.5} />
<div className="min-w-0 flex-1">
<p className="font-medium">
{OP_LABEL[op.op]}
{BLOCKING_OPS.has(op.op) ? ' (blocks apply)' : ''}
</p>
<p className="font-mono text-xs text-stat-subtitle truncate">
{opPathLabel(op)}
</p>
</div>
</li>
))}
{unchanged > 0 && (
<li className="px-3 py-2 text-xs text-stat-subtitle">
{unchanged} unchanged file{unchanged === 1 ? '' : 's'}
</li>
)}
{ops.length === 0 && unchanged === 0 && !missingPlan && (
<li className="px-3 py-2 text-xs text-stat-subtitle">No file operations in this plan.</li>
)}
</ul>
</ScrollArea>
</div>
<ModalFooter
hint={
<div className="flex items-center gap-2">
<Checkbox
id="git-source-deploy-after"
checked={deployAfter}
onCheckedChange={(checked) => setDeployAfter(checked === true)}
disabled={applying || blocked}
/>
<Label
htmlFor="git-source-deploy-after"
className="text-xs normal-case tracking-normal cursor-pointer"
>
Deploy after apply
</Label>
</div>
}
secondary={
<Button
variant="outline"
size="sm"
onClick={() => onDismiss()}
disabled={applying}
>
Dismiss
</Button>
}
primary={
<Button
size="sm"
onClick={apply}
disabled={applying || blocked}
>
{applying ? (
<>
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />
Applying...
</>
) : (
'Apply'
)}
</Button>
}
/>
</Modal>
);
}
@@ -32,8 +32,28 @@ vi.mock('@/context/NodeContext', () => ({
// Drive applyPull(commitSha, deploy=true) directly without standing up the real
// diff UI; the panel passes applyPull as onApply.
vi.mock('./GitSourceDiffDialog', () => ({
GitSourceDiffDialog: ({ onApply }: { onApply: (sha: string, deploy: boolean) => void }) => (
<button data-testid="apply-deploy" onClick={() => onApply('sha-123', true)}>apply</button>
GitSourceDiffDialog: ({
onApply,
pull,
}: {
onApply: (sha: string, deploy: boolean, fp: string) => void;
pull: PullResult | null;
}) => (
<div>
<span data-testid="plan-fingerprint">{pull?.planFingerprint ?? ''}</span>
<button
data-testid="apply-deploy"
onClick={() => onApply('sha-123', true, pull?.planFingerprint ?? 'fp-test')}
>
apply deploy
</button>
<button
data-testid="apply-only"
onClick={() => onApply('sha-123', false, pull?.planFingerprint ?? 'fp-test')}
>
apply
</button>
</div>
),
}));
vi.mock('@/components/ui/toast-store', () => ({
@@ -48,6 +68,8 @@ vi.mock('@/components/ui/toast-store', () => ({
import { apiFetch } from '@/lib/api';
import { GitSourcePanel } from './GitSourcePanel';
import { toast } from '@/components/ui/toast-store';
import type { PullResult } from './GitSourceDiffDialog';
function jsonRes(body: unknown, ok = true, status = 200) {
return { ok, status, json: async () => body, text: async () => '' } as unknown as Response;
@@ -90,6 +112,9 @@ beforeEach(() => {
vi.mocked(apiFetch).mockReset();
nodeCtl.activeNode = null;
dfCtl.params = null;
vi.mocked(toast.success).mockClear();
vi.mocked(toast.warning).mockClear();
vi.mocked(toast.error).mockClear();
});
describe('GitSourcePanel load', () => {
@@ -129,17 +154,87 @@ describe('GitSourcePanel deploy-mode apply node binding', () => {
});
it('binds both runWithLog and the apply POST to the captured node when deploying', async () => {
vi.mocked(apiFetch).mockImplementation(async (url: string) => {
if (String(url).includes('/git-source/apply')) {
return jsonRes({ applied: true, deployed: true });
}
return jsonRes(LINKED_SOURCE);
});
render(panel());
fireEvent.click(await screen.findByTestId('apply-deploy'));
await waitFor(() => {
const applyCall = vi.mocked(apiFetch).mock.calls.find(c => String(c[0]).includes('/git-source/apply'));
expect(applyCall?.[1]).toEqual(expect.objectContaining({ nodeId: 4 }));
expect(JSON.parse(String((applyCall?.[1] as { body?: string })?.body))).toEqual({
commitSha: 'sha-123',
planFingerprint: 'fp-test',
deploy: true,
});
});
expect(dfCtl.params).toEqual(expect.objectContaining({ action: 'deploy', nodeId: 4 }));
});
});
describe('GitSourcePanel stale plan handling', () => {
const PULL_RESULT: PullResult = {
commitSha: 'sha-old',
validation: { ok: true },
refusals: [],
warnings: [],
plan: {
blocked: false,
counts: {
add: 0,
modify: 0,
delete: 0,
rename: 0,
unchanged: 1,
localModified: 0,
localMissing: 0,
typeChanged: 0,
unmanagedCollision: 0,
invocation: 0,
},
operations: [],
invocation: { candidateChanged: false, liveDiverged: false },
},
planFingerprint: 'fp-old',
};
beforeEach(() => {
vi.mocked(apiFetch).mockImplementation(async (url: string) => {
if (String(url).includes('/git-source/pull')) {
return jsonRes(PULL_RESULT);
}
if (String(url).includes('/git-source/apply')) {
return jsonRes({
error: 'The change plan is stale.',
code: 'STALE_PLAN',
planFingerprint: 'fp-new',
plan: { ...PULL_RESULT.plan, blocked: true },
}, false, 409);
}
return jsonRes(LINKED_SOURCE);
});
});
it('keeps the diff open and replaces the pending plan on STALE_PLAN', async () => {
render(panel());
fireEvent.click(await screen.findByRole('button', { name: /pull now/i }));
await screen.findByTestId('plan-fingerprint');
expect(screen.getByTestId('plan-fingerprint')).toHaveTextContent('fp-old');
fireEvent.click(screen.getByTestId('apply-only'));
await waitFor(() => {
expect(toast.warning).toHaveBeenCalledWith(expect.stringMatching(/stale/i));
expect(screen.getByTestId('plan-fingerprint')).toHaveTextContent('fp-new');
});
expect(toast.success).not.toHaveBeenCalled();
});
});
describe('GitSourcePanel manifest summary', () => {
it('renders the managed-project section when the source carries a manifest', async () => {
const summary = {
@@ -8,7 +8,7 @@ import { apiFetch } from '@/lib/api';
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
import { useNodes } from '@/context/NodeContext';
import { toast } from '@/components/ui/toast-store';
import { GitSourceDiffDialog, type PullResult } from './GitSourceDiffDialog';
import { GitSourceDiffDialog, type PullResult, type PublicPendingPlan } from './GitSourceDiffDialog';
import { GitSourceFields, type ApplyMode } from './GitSourceFields';
import { GitManifestSummary, type ManifestSummary } from './GitManifestSummary';
import type { GitBrowseResult } from './GitComposeFilePicker';
@@ -30,6 +30,9 @@ export interface GitSource {
last_applied_commit_sha: string | null;
pending_commit_sha: string | null;
pending_fetched_at: number | null;
pending_plan: PublicPendingPlan | null;
last_plan_fingerprint: string | null;
last_plan_outcome: string | null;
created_at: number;
updated_at: number;
manifest_state: ManifestSummary['state'] | null;
@@ -58,7 +61,6 @@ export function GitSourcePanel({
onOpenChange,
stackName,
canEdit,
isDarkMode,
onSourceChanged,
}: GitSourcePanelProps) {
const [loading, setLoading] = useState(true);
@@ -269,7 +271,7 @@ export function GitSourcePanel({
}
};
const applyPull = async (commitSha: string, deploy: boolean) => {
const applyPull = async (commitSha: string, deploy: boolean, planFingerprint: string) => {
setApplying(true);
const loadingId = toast.loading(deploy ? 'Applying and deploying...' : 'Applying changes...');
// Snapshot the node once so the apply (and any deploy it triggers) stays
@@ -281,7 +283,7 @@ export function GitSourcePanel({
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source/apply`, {
method: 'POST',
nodeId: opNodeId,
body: JSON.stringify({ commitSha, deploy }),
body: JSON.stringify({ commitSha, planFingerprint, deploy }),
});
if (res.ok) {
const data: { applied: boolean; deployed: boolean; deployError?: string } = await res.json();
@@ -298,8 +300,20 @@ export function GitSourcePanel({
onSourceChanged?.();
return { ok: true };
} else {
const err = await res.json().catch(() => ({}));
const msg = (err as { error?: string }).error || 'Failed to apply changes.';
const err = await res.json().catch(() => ({})) as {
error?: string;
code?: string;
plan?: PullResult['plan'];
planFingerprint?: string;
};
if (res.status === 409 && err.code === 'STALE_PLAN' && err.plan && err.planFingerprint) {
setPull((prev) => prev
? { ...prev, plan: err.plan ?? null, planFingerprint: err.planFingerprint ?? null }
: prev);
toast.warning(err.error || 'The change plan is stale. Review the updated plan before applying.');
return { ok: false, errorMessage: err.error };
}
const msg = err.error || 'Failed to apply changes.';
toast.error(msg);
return { ok: false, errorMessage: msg };
}
@@ -360,12 +374,21 @@ export function GitSourcePanel({
) : (
<>
{source?.pending_commit_sha && (
<div className="flex items-start gap-2 rounded-md border border-brand/30 bg-brand/5 px-3 py-2 text-xs shadow-card-bevel">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5 text-brand" strokeWidth={1.5} />
<div className={`flex items-start gap-2 rounded-md border px-3 py-2 text-xs shadow-card-bevel ${
source.pending_plan?.blocked
? 'border-warning/30 bg-warning/10'
: 'border-brand/30 bg-brand/5'
}`}>
<AlertCircle className={`w-4 h-4 shrink-0 mt-0.5 ${source.pending_plan?.blocked ? 'text-warning' : 'text-brand'}`} strokeWidth={1.5} />
<div className="flex-1">
<p className="font-medium">Pending update</p>
<p className="font-medium">
{source.pending_plan?.blocked ? 'Pending update blocked' : 'Pending update'}
</p>
<p className="text-stat-subtitle mt-0.5">
Commit <span className="font-mono tabular-nums">{source.pending_commit_sha.slice(0, 7)}</span> is ready to review.
Commit <span className="font-mono tabular-nums">{source.pending_commit_sha.slice(0, 7)}</span>
{source.pending_plan?.blocked
? ' has local conflicts. Review the plan; apply stays disabled until they are resolved.'
: ' is ready to review.'}
</p>
</div>
<Button
@@ -494,9 +517,7 @@ export function GitSourcePanel({
onOpenChange={setDiffOpen}
stackName={stackName}
pull={pull}
syncEnv={syncEnv}
autoDeployDefault={applyMode === 'auto-deploy'}
isDarkMode={isDarkMode}
applying={applying}
onApply={applyPull}
onDismiss={dismissPending}
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Rocket, RefreshCcw, CircleStop, Play, ArrowUp, Activity, Loader2, AlertCircle,
TriangleAlert, CircleCheck, HeartPulse, HeartCrack, ArrowDownToLine, Unlock,
GitBranch, GitPullRequest, CircleX, Ban,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -50,6 +51,13 @@ const CATEGORY_ICON: Record<string, LucideIcon> = {
health_gate_passed: HeartPulse,
health_gate_failed: HeartCrack,
rollback_generation_released: Unlock,
git_pull_ready: GitPullRequest,
git_plan_blocked: Ban,
git_pull_failed: CircleX,
git_apply: GitBranch,
git_apply_failed: CircleX,
git_apply_rolled_back: RefreshCcw,
git_create: GitBranch,
};
const DAY_MS = 86_400_000;
@@ -65,6 +73,8 @@ const SYSTEM_ACTOR_LABEL: Record<string, string> = {
'system:blueprint': 'Blueprint',
'system:monitor': 'Monitor',
'system:policy': 'Policy',
'system:webhook': 'Webhook',
'system:git-source': 'Git source',
};
function formatActor(actor: string): { label: string; isSystem: boolean } {
@@ -244,5 +244,19 @@ describe('StackActivityTimeline - actor rendering', () => {
await waitFor(() => expect(screen.getByText('event')).toBeTruthy());
expect(screen.queryByText(/^(by|via)\s/)).toBeNull();
});
it('renders webhook and git-source system actors with product labels', async () => {
mockFetch.mockReturnValueOnce(jsonResponse([
evt({ id: 1, message: 'pull ready', actor_username: 'system:webhook' }),
]));
const { unmount } = render(<StackActivityTimeline stackName="web" />);
await waitFor(() => expect(screen.getByText(/via Webhook/)).toBeTruthy());
unmount();
mockFetch.mockReturnValueOnce(jsonResponse([
evt({ id: 2, message: 'applied', actor_username: 'system:git-source' }),
]));
render(<StackActivityTimeline stackName="web" />);
await waitFor(() => expect(screen.getByText(/via Git source/)).toBeTruthy());
});
});