mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 14:08:19 +00:00
3c4c057467
* 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.
260 lines
8.7 KiB
TypeScript
260 lines
8.7 KiB
TypeScript
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 { 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;
|
|
validation: { ok: boolean; error?: string };
|
|
refusals?: Array<{ sourcePath: string | null; kind: string; reason: string; actionable: boolean }>;
|
|
warnings?: string[];
|
|
plan: PublicGitChangePlan | null;
|
|
planFingerprint: string | null;
|
|
}
|
|
|
|
interface GitSourceDiffDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
stackName: string;
|
|
pull: PullResult | null;
|
|
autoDeployDefault: boolean;
|
|
applying: boolean;
|
|
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,
|
|
autoDeployDefault,
|
|
applying,
|
|
onApply,
|
|
onDismiss,
|
|
}: GitSourceDiffDialogProps) {
|
|
const [deployAfter, setDeployAfter] = useState<boolean>(autoDeployDefault);
|
|
|
|
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 () => {
|
|
if (!pull.planFingerprint || blocked) return;
|
|
await onApply(pull.commitSha, deployAfter, pull.planFingerprint);
|
|
};
|
|
|
|
return (
|
|
<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>
|
|
);
|
|
}
|