feat: ordered multi-file Compose for Git sources (#1380)

* feat: ordered multi-file Compose for Git sources

Extend Git sources to deploy an ordered list of compose files merged with
docker compose -f base.yaml -f override.yaml ..., plus an optional project
directory.

- Pick and reorder compose files from the repository tree (drag to reorder on
  desktop, up/down arrows on phones); manual path entry is also supported.
- The ordered set drives every stack-scoped compose command (deploy, update,
  start/stop/restart/down, image scans, Compose Doctor) and the container
  lookup, so a service or image declared only in an override is handled too.
- Runtime keys off the materialized set, not the saved configuration: saving a
  source does not change deploy args until the pull is applied, and apply
  materializes from the pending snapshot rather than live config.
- The project directory is passed as --project-directory, with -p <stack>
  pinning the Compose project so container labels stay stable.
- The Mesh override is layered last; single-file sources are byte-identical to
  before, and existing rows keep working via the single-path fallback.

Docs cover the picker, ordering, project directory, and the new troubleshooting
and limitations (referenced files are not materialized; the dependency graph,
drift, and networking views read the primary file).

* fix: harden multi-file Git source (hash, unlink, collisions, node id)

- hashContent folds ordered file CONTENTS (not paths) so a clean multi-file
  stack is not flagged as locally edited: create/apply hash the fetched files
  (repo paths) while pull hashes the on-disk files (materialized paths), which
  previously disagreed and showed a false "local edits detected".
- Block unlinking a multi-file or project-directory Git source (409): the deploy
  spec lives on the source row, so removing it would silently revert deploys to
  root compose.yaml. Single-file sources still unlink.
- Reject materialized-path collisions in the selection validator: an additional
  file equal to or nested under compose.yaml, an ancestor/descendant overlap
  between selected files, and a project directory nested under a compose file
  (previously a 500 at materialization).
- DockerController.getContainersByStack uses the controller's node compose dir
  and passes its node id to the authored prefix, instead of the process default.

* fix: CI failures on multi-file Git source (test crash, aria query, path barrier)

- GitSourceFields no longer crashes when repoUrl/branch are falsy: the canBrowse
  trim() is optional-chained, so a reusable field component tolerates partial
  props. Fixes the apply-binding panel test, which feeds a minimal source object.
- GitSourcePanel tests query the footer Remove button by its exact name, so the
  picker's per-file "Remove <path>" buttons no longer collide with the broad
  /remove/i match (the test intent, footer Remove present/absent, is unchanged).
- validateCompose uses an inline resolve + startsWith barrier at the context-dir
  mkdir sink (CodeQL does not credit the wrapped isPathWithinBase helper),
  clearing the js/path-injection alert. The containment check is equivalent and
  contextDir is also validated upstream.

* test: update Git source E2E spec for the multi-file compose picker

The compose-file picker replaced the single #git-source-path input and added
per-file Remove buttons, so the E2E spec drove selectors that no longer exist:

- Drop the redundant compose.yaml fills (the picker defaults to compose.yaml).
- Select the footer Remove button by exact name so the picker's per-file
  "Remove <path>" buttons no longer make the locator ambiguous.
- Set a custom compose path through the picker (add via the manual input, press
  Enter, then remove the default compose.yaml).

* test: match the footer Remove button with an exact Playwright name

Playwright's getByRole name option is a substring match by default, so
{ name: 'Remove' } also matched the picker's "Remove <path>" buttons. Require an
exact match so only the footer Remove button is selected.
This commit is contained in:
Anso
2026-06-17 13:24:55 -04:00
committed by GitHub
parent 7ce045accb
commit f23b7e1bac
34 changed files with 2299 additions and 385 deletions
@@ -0,0 +1,207 @@
import { useState } from 'react';
import { GripVertical, ArrowUp, ArrowDown, X, FolderGit2, Plus } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import { useIsMobile } from '@/hooks/use-is-mobile';
export interface GitBrowseResult {
files: string[];
truncated: boolean;
}
interface GitComposeFilePickerProps {
composePaths: string[];
contextDir: string;
onComposePathsChange: (paths: string[]) => void;
onContextDirChange: (value: string) => void;
/** Parent runs the correct browse endpoint (create vs edit) and returns the repo file list, or null on failure. */
onBrowse: () => Promise<GitBrowseResult | null>;
/** True when the repo URL + branch are filled, so a browse can succeed. */
canBrowse: boolean;
disabled?: boolean;
}
const isComposeLike = (p: string) => /\.ya?ml$/i.test(p);
export function GitComposeFilePicker({
composePaths,
contextDir,
onComposePathsChange,
onContextDirChange,
onBrowse,
canBrowse,
disabled = false,
}: GitComposeFilePickerProps) {
const isMobile = useIsMobile();
const [manualPath, setManualPath] = useState('');
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [browsing, setBrowsing] = useState(false);
const [repoFiles, setRepoFiles] = useState<string[] | null>(null);
const [truncated, setTruncated] = useState(false);
const addPath = (raw: string) => {
const value = raw.trim().replace(/\\/g, '/').replace(/^\.\//, '');
if (!value || composePaths.includes(value)) return;
onComposePathsChange([...composePaths, value]);
};
const removeAt = (index: number) => {
onComposePathsChange(composePaths.filter((_, i) => i !== index));
};
const move = (from: number, to: number) => {
if (to < 0 || to >= composePaths.length || from === to) return;
const next = [...composePaths];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
onComposePathsChange(next);
};
const runBrowse = async () => {
setBrowsing(true);
try {
const result = await onBrowse();
if (result) {
// Compose-like files first so they are easy to pick out of a large repo.
const sorted = [...result.files].sort((a, b) => {
const ac = isComposeLike(a) ? 0 : 1;
const bc = isComposeLike(b) ? 0 : 1;
return ac - bc || a.localeCompare(b);
});
setRepoFiles(sorted);
setTruncated(result.truncated);
}
} finally {
setBrowsing(false);
}
};
return (
<div className="space-y-3">
<Label>Compose files <span className="text-stat-subtitle font-normal">(merged in order)</span></Label>
{composePaths.length === 0 ? (
<p className="text-[11px] text-stat-subtitle rounded-md border border-dashed border-glass-border px-3 py-2">
No compose files selected. Browse the repository or add a path below.
</p>
) : (
<ul className="space-y-1.5">
{composePaths.map((p, index) => (
<li
key={`${p}-${index}`}
draggable={!disabled && !isMobile}
onDragStart={() => setDragIndex(index)}
onDragOver={(e) => { if (dragIndex !== null) e.preventDefault(); }}
onDrop={(e) => {
e.preventDefault();
if (dragIndex !== null) move(dragIndex, index);
setDragIndex(null);
}}
onDragEnd={() => setDragIndex(null)}
className={cn(
'flex items-center gap-2 rounded-md border border-glass-border bg-card-bg/40 px-2 py-1.5',
dragIndex === index && 'opacity-50',
!disabled && !isMobile && 'cursor-grab',
)}
>
{isMobile ? (
<div className="flex flex-col -my-1">
<button type="button" disabled={disabled || index === 0} onClick={() => move(index, index - 1)}
className="text-stat-subtitle hover:text-foreground disabled:opacity-30" aria-label="Move up">
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button type="button" disabled={disabled || index === composePaths.length - 1} onClick={() => move(index, index + 1)}
className="text-stat-subtitle hover:text-foreground disabled:opacity-30" aria-label="Move down">
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
) : (
<GripVertical className="w-3.5 h-3.5 text-stat-subtitle shrink-0" />
)}
<span className="font-mono text-xs truncate flex-1" title={p}>{p}</span>
{index === 0 && <span className="text-[10px] text-stat-subtitle shrink-0">primary</span>}
<button type="button" disabled={disabled} onClick={() => removeAt(index)}
className="text-stat-subtitle hover:text-danger disabled:opacity-30 shrink-0" aria-label={`Remove ${p}`}>
<X className="w-3.5 h-3.5" />
</button>
</li>
))}
</ul>
)}
<div className="flex items-center gap-2">
<Input
placeholder="path/to/compose.yaml"
value={manualPath}
onChange={(e) => setManualPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); addPath(manualPath); setManualPath(''); }
}}
disabled={disabled}
className="font-mono text-xs"
/>
<Button type="button" variant="outline" size="sm" disabled={disabled || !manualPath.trim()}
onClick={() => { addPath(manualPath); setManualPath(''); }}>
<Plus className="w-3.5 h-3.5" />
</Button>
<Button type="button" variant="outline" size="sm" disabled={disabled || !canBrowse || browsing}
onClick={runBrowse}>
<FolderGit2 className="w-3.5 h-3.5 mr-1" />
{browsing ? 'Browsing...' : 'Browse'}
</Button>
</div>
{repoFiles && (
<div className="rounded-md border border-glass-border">
<ScrollArea className="max-h-48">
<div className="p-2 space-y-1">
{repoFiles.length === 0 && <p className="text-[11px] text-stat-subtitle px-1 py-2">No files found in the repository.</p>}
{repoFiles.map((file) => {
const selected = composePaths.includes(file);
return (
<label key={file} className="flex items-center gap-2 px-1 py-0.5 cursor-pointer">
<Checkbox
checked={selected}
disabled={disabled}
onCheckedChange={(c) => {
if (c === true) addPath(file);
else onComposePathsChange(composePaths.filter(p => p !== file));
}}
/>
<span className={cn('font-mono text-xs truncate', isComposeLike(file) ? 'text-foreground' : 'text-stat-subtitle')} title={file}>
{file}
</span>
</label>
);
})}
</div>
</ScrollArea>
{truncated && (
<p className="text-[10px] text-stat-subtitle px-2 py-1 border-t border-glass-border">
Repository has many files; only the first 2000 are listed. Add other paths manually.
</p>
)}
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="git-context-dir" className="text-xs">Project directory <span className="text-stat-subtitle font-normal">(optional)</span></Label>
<Input
id="git-context-dir"
placeholder="e.g. deploy"
value={contextDir}
onChange={(e) => onContextDirChange(e.target.value)}
disabled={disabled}
className="font-mono text-xs"
/>
<p className="text-[11px] text-stat-subtitle">
Sets <span className="font-mono">--project-directory</span> for relative paths. Leave blank to use the stack root.
</p>
</div>
</div>
);
}
@@ -103,7 +103,7 @@ export function GitSourceDiffDialog({
<TabsList>
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
<TabsHighlightItem value="compose">
<TabsTrigger value="compose">compose.yaml</TabsTrigger>
<TabsTrigger value="compose">Compose</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="env">
<TabsTrigger value="env">.env</TabsTrigger>
@@ -2,6 +2,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import { GitComposeFilePicker, type GitBrowseResult } from './GitComposeFilePicker';
export type ApplyMode = 'review' | 'auto-write' | 'auto-deploy';
@@ -9,8 +10,8 @@ export type ApplyMode = 'review' | 'auto-write' | 'auto-deploy';
* Mirror of the backend's env-path default (see `/api/stacks/from-git` and
* the git-source PUT handler): if the user ticks "Sync .env" without
* specifying an explicit path, the service reads `<dirname>/.env`
* alongside the compose file. Surfacing this in the form saves the user
* a round-trip to figure out which directory the `.env` will come from.
* alongside the primary compose file. Surfacing this in the form saves the
* user a round-trip to figure out which directory the `.env` will come from.
*/
function computeDefaultEnvPath(composePath: string): string {
const normalized = composePath.trim().replace(/\\/g, '/').replace(/^\.\//, '');
@@ -22,7 +23,8 @@ function computeDefaultEnvPath(composePath: string): string {
export interface GitSourceFieldsState {
repoUrl: string;
branch: string;
composePath: string;
composePaths: string[];
contextDir: string;
syncEnv: boolean;
authType: 'none' | 'token';
token: string;
@@ -37,11 +39,14 @@ export interface GitSourceFieldsProps extends GitSourceFieldsState {
variant: 'edit' | 'create';
onRepoUrlChange: (value: string) => void;
onBranchChange: (value: string) => void;
onComposePathChange: (value: string) => void;
onComposePathsChange: (value: string[]) => void;
onContextDirChange: (value: string) => void;
onSyncEnvChange: (value: boolean) => void;
onAuthTypeChange: (value: 'none' | 'token') => void;
onTokenChange: (value: string) => void;
onApplyModeChange: (value: ApplyMode) => void;
/** Runs the correct browse endpoint (create vs edit); returns the repo file list or null on failure. */
onBrowse: () => Promise<GitBrowseResult | null>;
}
const APPLY_MODE_COPY: Record<'edit' | 'create', Record<ApplyMode, { title: string; description: string }>> = {
@@ -60,7 +65,8 @@ const APPLY_MODE_COPY: Record<'edit' | 'create', Record<ApplyMode, { title: stri
export function GitSourceFields({
repoUrl,
branch,
composePath,
composePaths,
contextDir,
syncEnv,
authType,
token,
@@ -70,13 +76,17 @@ export function GitSourceFields({
variant,
onRepoUrlChange,
onBranchChange,
onComposePathChange,
onComposePathsChange,
onContextDirChange,
onSyncEnvChange,
onAuthTypeChange,
onTokenChange,
onApplyModeChange,
onBrowse,
}: GitSourceFieldsProps) {
const copy = APPLY_MODE_COPY[variant];
const primaryComposePath = composePaths[0] ?? '';
const canBrowse = !!repoUrl?.trim() && !!branch?.trim();
const radioOption = (mode: ApplyMode) => (
<button
@@ -119,31 +129,28 @@ export function GitSourceFields({
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="git-source-branch">Branch</Label>
<Input
id="git-source-branch"
placeholder="main"
value={branch}
onChange={(e) => onBranchChange(e.target.value)}
disabled={disabled}
className="font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="git-source-path">Compose file path</Label>
<Input
id="git-source-path"
placeholder="compose.yaml"
value={composePath}
onChange={(e) => onComposePathChange(e.target.value)}
disabled={disabled}
className="font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="git-source-branch">Branch</Label>
<Input
id="git-source-branch"
placeholder="main"
value={branch}
onChange={(e) => onBranchChange(e.target.value)}
disabled={disabled}
className="font-mono text-xs"
/>
</div>
<GitComposeFilePicker
composePaths={composePaths}
contextDir={contextDir}
onComposePathsChange={onComposePathsChange}
onContextDirChange={onContextDirChange}
onBrowse={onBrowse}
canBrowse={canBrowse}
disabled={disabled}
/>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Checkbox
@@ -156,11 +163,11 @@ export function GitSourceFields({
Also sync sibling <span className="font-mono">.env</span> file
</Label>
</div>
{syncEnv && composePath.trim() !== '' && (
{syncEnv && primaryComposePath.trim() !== '' && (
<p className="text-[11px] text-stat-subtitle pl-6">
Will read{' '}
<span className="font-mono">
{computeDefaultEnvPath(composePath)}
{computeDefaultEnvPath(primaryComposePath)}
</span>{' '}
from the repository.
</p>
@@ -101,7 +101,7 @@ describe('GitSourcePanel load', () => {
await screen.findByRole('button', { name: /^save$/i });
expect(screen.queryByRole('button', { name: /update/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /pull now/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /remove/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
expect(screen.getByLabelText(/repository url/i)).toHaveValue('');
});
@@ -113,7 +113,7 @@ describe('GitSourcePanel load', () => {
// A real source flips the primary action to Update and exposes Pull now / Remove.
await screen.findByRole('button', { name: /update/i });
expect(screen.getByRole('button', { name: /pull now/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /remove/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument();
await waitFor(() =>
expect(screen.getByLabelText(/repository url/i)).toHaveValue('https://github.com/org/repo.git'),
);
@@ -10,6 +10,7 @@ import { useNodes } from '@/context/NodeContext';
import { toast } from '@/components/ui/toast-store';
import { GitSourceDiffDialog, type PullResult } from './GitSourceDiffDialog';
import { GitSourceFields, type ApplyMode } from './GitSourceFields';
import type { GitBrowseResult } from './GitComposeFilePicker';
export interface GitSource {
id: number;
@@ -17,6 +18,8 @@ export interface GitSource {
repo_url: string;
branch: string;
compose_path: string;
compose_paths: string[];
context_dir: string | null;
sync_env: boolean;
env_path: string | null;
auth_type: 'none' | 'token';
@@ -64,7 +67,8 @@ export function GitSourcePanel({
const [repoUrl, setRepoUrl] = useState('');
const [branch, setBranch] = useState('main');
const [composePath, setComposePath] = useState('compose.yaml');
const [composePaths, setComposePaths] = useState<string[]>(['compose.yaml']);
const [contextDir, setContextDir] = useState('');
const [syncEnv, setSyncEnv] = useState(false);
const [authType, setAuthType] = useState<'none' | 'token'>('none');
const [token, setToken] = useState('');
@@ -82,7 +86,8 @@ export function GitSourcePanel({
setSource(null);
setRepoUrl('');
setBranch('main');
setComposePath('compose.yaml');
setComposePaths(['compose.yaml']);
setContextDir('');
setSyncEnv(false);
setAuthType('none');
setToken('');
@@ -102,7 +107,8 @@ export function GitSourcePanel({
setSource(data);
setRepoUrl(data.repo_url);
setBranch(data.branch);
setComposePath(data.compose_path);
setComposePaths(data.compose_paths?.length ? data.compose_paths : [data.compose_path]);
setContextDir(data.context_dir ?? '');
setSyncEnv(data.sync_env);
setAuthType(data.auth_type);
setToken('');
@@ -131,8 +137,8 @@ export function GitSourcePanel({
}, [open, load]);
const save = async () => {
if (!repoUrl.trim() || !branch.trim() || !composePath.trim()) {
toast.error('Repository URL, branch, and compose path are required.');
if (!repoUrl.trim() || !branch.trim() || composePaths.length === 0) {
toast.error('Repository URL, branch, and at least one compose file are required.');
return;
}
if (!/^https:\/\//i.test(repoUrl.trim())) {
@@ -147,7 +153,8 @@ export function GitSourcePanel({
const body: Record<string, unknown> = {
repo_url: repoUrl.trim(),
branch: branch.trim(),
compose_path: composePath.trim(),
compose_paths: composePaths,
context_dir: contextDir.trim() || null,
sync_env: syncEnv,
auth_type: authType,
auto_apply_on_webhook: autoApply,
@@ -179,6 +186,35 @@ export function GitSourcePanel({
}
};
const browseRepo = async (): Promise<GitBrowseResult | null> => {
if (!repoUrl.trim() || !branch.trim()) {
toast.error('Enter a repository URL and branch first.');
return null;
}
try {
const body: Record<string, unknown> = {
repo_url: repoUrl.trim(),
branch: branch.trim(),
auth_type: authType,
};
if (authType === 'token' && token !== '') body.token = token;
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source/browse`, {
method: 'POST',
body: JSON.stringify(body),
});
if (res.ok) {
const data = await res.json();
return { files: data.files ?? [], truncated: data.truncated ?? false };
}
const err = await res.json().catch(() => ({}));
toast.error(err?.error || 'Failed to browse repository.');
return null;
} catch (e) {
toast.error((e as Error)?.message || 'Network error.');
return null;
}
};
const remove = async () => {
if (!source) return;
setRemoveConfirmOpen(false);
@@ -343,7 +379,8 @@ export function GitSourcePanel({
disabled={!canEdit || saving}
repoUrl={repoUrl}
branch={branch}
composePath={composePath}
composePaths={composePaths}
contextDir={contextDir}
syncEnv={syncEnv}
authType={authType}
token={token}
@@ -351,11 +388,13 @@ export function GitSourcePanel({
applyMode={applyMode}
onRepoUrlChange={setRepoUrl}
onBranchChange={setBranch}
onComposePathChange={setComposePath}
onComposePathsChange={setComposePaths}
onContextDirChange={setContextDir}
onSyncEnvChange={setSyncEnv}
onAuthTypeChange={setAuthType}
onTokenChange={setToken}
onApplyModeChange={setApplyModeOverride}
onBrowse={browseRepo}
/>
{source && (