diff --git a/docs/public/operations/security.md b/docs/public/operations/security.md index ad51401..c91b7a7 100644 --- a/docs/public/operations/security.md +++ b/docs/public/operations/security.md @@ -59,6 +59,7 @@ VectorFlow encrypts sensitive data before storing it in PostgreSQL: | Certificates | AES-256-GCM | SHA-256 hash of `NEXTAUTH_SECRET` | | OIDC client secret | AES-256-GCM | SHA-256 hash of `NEXTAUTH_SECRET` | | Sensitive node config fields | AES-256-GCM | SHA-256 hash of `NEXTAUTH_SECRET` | +| Git access tokens | AES-256-GCM | SHA-256 hash of `NEXTAUTH_SECRET` | | User passwords | bcrypt (cost 12) | Built-in salt | | TOTP secrets | AES-256-GCM | SHA-256 hash of `NEXTAUTH_SECRET` | | 2FA backup codes | SHA-256 hash | -- | diff --git a/docs/public/user-guide/environments.md b/docs/public/user-guide/environments.md index e32ed8b..a5a3079 100644 --- a/docs/public/user-guide/environments.md +++ b/docs/public/user-guide/environments.md @@ -93,3 +93,35 @@ Secrets and certificates are stripped during promotion. After promoting a pipeli - **Edit** -- Click the **Edit** button on the environment detail page to rename the environment or change its secret backend configuration. - **Delete** -- Click the **Delete** button to permanently remove the environment. You must have the Admin role on the team to delete an environment. + +## Git Integration + +VectorFlow can automatically commit pipeline YAML files to a Git repository whenever a pipeline is deployed or deleted. This provides an audit trail, version history, and hook points for external CI/CD workflows. + +### Setup + +{% stepper %} +{% step %} +### Navigate to Environment Settings +Go to **Environments** and click on the environment you want to configure. +{% endstep %} +{% step %} +### Configure Git Integration +In the **Git Integration** card, enter: +- **Repository URL**: The HTTPS URL of your Git repo (e.g., `https://github.com/org/pipeline-configs.git`) +- **Branch**: The branch to commit to (default: `main`) +- **Access Token**: A personal access token with write access to the repo +{% endstep %} +{% step %} +### Test Connection +Click **Test Connection** to verify VectorFlow can reach the repository. +{% endstep %} +{% endstepper %} + +### How It Works + +When you deploy a pipeline, VectorFlow commits the generated YAML to `{environment-name}/{pipeline-name}.yaml` in the configured repository. When you delete a pipeline, the file is removed with a commit. + +{% hint style="info" %} +Git sync is a post-deploy side effect. If the Git push fails, the pipeline deploy still succeeds — you will see a warning toast in the UI. +{% endhint %} diff --git a/package.json b/package.json index 772a804..5cb628b 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "react-dom": "19.2.3", "react-hook-form": "^7.71.2", "recharts": "2.15.4", + "simple-git": "^3.32.3", "sonner": "^2.0.7", "superjson": "^2.2.6", "tailwind-merge": "^3.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6699f11..e42fde6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: recharts: specifier: 2.15.4 version: 2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + simple-git: + specifier: ^3.32.3 + version: 3.32.3 sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -824,6 +827,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@kwsites/file-exists@1.1.1': + resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} + + '@kwsites/promise-deferred@1.1.1': + resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + '@modelcontextprotocol/sdk@1.27.1': resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} engines: {node: '>=18'} @@ -4319,6 +4328,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-git@3.32.3: + resolution: {integrity: sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw==} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -5383,6 +5395,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kwsites/file-exists@1.1.1': + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@kwsites/promise-deferred@1.1.1': {} + '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.9(hono@4.12.4) @@ -9204,6 +9224,14 @@ snapshots: signal-exit@4.1.0: {} + simple-git@3.32.3: + dependencies: + '@kwsites/file-exists': 1.1.1 + '@kwsites/promise-deferred': 1.1.1 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + sisteransi@1.0.5: {} sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3): diff --git a/prisma/migrations/20260306000000_add_environment_git_config/migration.sql b/prisma/migrations/20260306000000_add_environment_git_config/migration.sql new file mode 100644 index 0000000..bee2f8f --- /dev/null +++ b/prisma/migrations/20260306000000_add_environment_git_config/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "gitRepoUrl" TEXT, +ADD COLUMN "gitBranch" TEXT DEFAULT 'main', +ADD COLUMN "gitToken" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 85011e5..d8bef2f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -79,6 +79,9 @@ model Environment { enrollmentTokenHint String? secretBackend SecretBackend @default(BUILTIN) secretBackendConfig Json? + gitRepoUrl String? + gitBranch String? @default("main") + gitToken String? // Stored encrypted via crypto.ts alertRules AlertRule[] alertWebhooks AlertWebhook[] createdAt DateTime @default(now()) diff --git a/src/app/(dashboard)/environments/[id]/page.tsx b/src/app/(dashboard)/environments/[id]/page.tsx index 8afaf7c..ad92770 100644 --- a/src/app/(dashboard)/environments/[id]/page.tsx +++ b/src/app/(dashboard)/environments/[id]/page.tsx @@ -49,6 +49,7 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { SecretsSection } from "@/components/environment/secrets-section"; import { CertificatesSection } from "@/components/environment/certificates-section"; +import { GitSyncSection } from "@/components/environment/git-sync-section"; import { nodeStatusVariant, nodeStatusLabel } from "@/lib/status"; export default function EnvironmentDetailPage({ @@ -514,6 +515,13 @@ export default function EnvironmentDetailPage({ + + {/* Created info */}

Created {new Date(env.createdAt).toLocaleDateString()} diff --git a/src/components/environment/git-sync-section.tsx b/src/components/environment/git-sync-section.tsx new file mode 100644 index 0000000..01ec6ec --- /dev/null +++ b/src/components/environment/git-sync-section.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useState } from "react"; +import { useTRPC } from "@/trpc/client"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { GitBranch, Eye, EyeOff, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +interface GitSyncSectionProps { + environmentId: string; + gitRepoUrl: string | null; + gitBranch: string | null; + hasGitToken: boolean; +} + +export function GitSyncSection({ + environmentId, + gitRepoUrl, + gitBranch, + hasGitToken, +}: GitSyncSectionProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const [repoUrl, setRepoUrl] = useState(gitRepoUrl ?? ""); + const [branch, setBranch] = useState(gitBranch ?? "main"); + const [token, setToken] = useState(""); + const [showToken, setShowToken] = useState(false); + const [isTesting, setIsTesting] = useState(false); + + const updateMutation = useMutation( + trpc.environment.update.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.environment.get.queryKey({ id: environmentId }) }); + }, + onError: (err) => toast.error(err.message || "Failed to save Git settings"), + }) + ); + + const testMutation = useMutation( + trpc.environment.testGitConnection.mutationOptions({ + onSuccess: (result) => { + if (result.success) { + toast.success("Git connection successful"); + } else { + toast.error("Git connection failed", { description: result.error }); + } + setIsTesting(false); + }, + onError: (err) => { + toast.error("Connection test failed", { description: err.message }); + setIsTesting(false); + }, + }) + ); + + function handleSave() { + updateMutation.mutate( + { + id: environmentId, + gitRepoUrl: repoUrl || null, + gitBranch: branch || null, + gitToken: token || undefined, // Only send if user entered a new token + }, + { + onSuccess: () => { + toast.success("Git integration settings saved"); + setToken(""); + }, + }, + ); + } + + function handleTest() { + const testToken = token || undefined; + if (!repoUrl) { + toast.error("Enter a repository URL first"); + return; + } + if (!testToken && !hasGitToken) { + toast.error("Enter an access token first"); + return; + } + if (testToken) { + setIsTesting(true); + testMutation.mutate({ environmentId, repoUrl, branch, token: testToken }); + } else { + toast.warning("Enter a new token to test the connection"); + } + } + + function handleDisconnect() { + updateMutation.mutate( + { + id: environmentId, + gitRepoUrl: null, + gitBranch: null, + gitToken: null, + }, + { + onSuccess: () => { + toast.success("Git integration disconnected"); + setRepoUrl(""); + setBranch("main"); + setToken(""); + }, + }, + ); + } + + const hasChanges = repoUrl !== (gitRepoUrl ?? "") || branch !== (gitBranch ?? "main") || token !== ""; + const isConfigured = !!gitRepoUrl; + + return ( + + +

+ +
+ Git Integration + + Automatically commit pipeline YAML to a Git repository on deploy and delete. + +
+
+ + +
+ + setRepoUrl(e.target.value)} + /> +
+ +
+ + setBranch(e.target.value)} + /> +
+ +
+ +
+
+ setToken(e.target.value)} + /> + +
+
+
+ +
+ + + {isConfigured && ( + + )} +
+
+ + ); +} diff --git a/src/components/flow/deploy-dialog.tsx b/src/components/flow/deploy-dialog.tsx index 0470ce0..79b12af 100644 --- a/src/components/flow/deploy-dialog.tsx +++ b/src/components/flow/deploy-dialog.tsx @@ -57,6 +57,12 @@ export function DeployDialog({ pipelineId, open, onOpenChange }: DeployDialogPro toast.success("Pipeline published to agents", { description: result.versionNumber ? `Version v${result.versionNumber}` : undefined, }); + if (result.gitSyncError) { + toast.warning("Pipeline deployed but Git sync failed", { + description: result.gitSyncError, + duration: 8000, + }); + } onOpenChange(false); }, onError: (err) => { diff --git a/src/server/routers/environment.ts b/src/server/routers/environment.ts index dcbad64..22e515a 100644 --- a/src/server/routers/environment.ts +++ b/src/server/routers/environment.ts @@ -4,6 +4,7 @@ import { router, protectedProcedure, withTeamAccess, requireSuperAdmin } from "@ import { prisma } from "@/lib/prisma"; import { withAudit } from "@/server/middleware/audit"; import { generateEnrollmentToken } from "@/server/services/agent-token"; +import { encrypt } from "@/server/services/crypto"; export const environmentRouter = router({ list: protectedProcedure @@ -52,9 +53,11 @@ export const environmentRouter = router({ }); } + const { gitToken, enrollmentTokenHash, ...safe } = environment; return { - ...environment, - hasEnrollmentToken: !!environment.enrollmentTokenHash, + ...safe, + hasEnrollmentToken: !!enrollmentTokenHash, + hasGitToken: !!gitToken, }; }), @@ -94,12 +97,15 @@ export const environmentRouter = router({ name: z.string().min(1).max(100).optional(), secretBackend: z.enum(["BUILTIN", "VAULT", "AWS_SM", "EXEC"]).optional(), secretBackendConfig: z.any().optional(), + gitRepoUrl: z.string().url().optional().nullable(), + gitBranch: z.string().min(1).max(100).optional().nullable(), + gitToken: z.string().optional().nullable(), }) ) .use(withTeamAccess("EDITOR")) .use(withAudit("environment.updated", "Environment")) .mutation(async ({ input }) => { - const { id, ...data } = input; + const { id, gitToken, ...rest } = input; const existing = await prisma.environment.findUnique({ where: { id }, }); @@ -115,10 +121,70 @@ export const environmentRouter = router({ message: "The system environment cannot be modified directly", }); } - return prisma.environment.update({ + + // Build update data, encrypting git token if provided + const data: Record = { ...rest }; + if (gitToken !== undefined) { + data.gitToken = gitToken ? encrypt(gitToken) : null; + } + + const updated = await prisma.environment.update({ where: { id }, data, }); + const { gitToken: _gt, enrollmentTokenHash: _eth, ...safeUpdate } = updated; + return { + ...safeUpdate, + hasEnrollmentToken: !!_eth, + hasGitToken: !!_gt, + }; + }), + + testGitConnection: protectedProcedure + .input(z.object({ + environmentId: z.string(), + repoUrl: z.string().url(), + branch: z.string().min(1).max(100).regex(/^[a-zA-Z0-9._\/-]+$/), + token: z.string().min(1), + })) + .use(withTeamAccess("EDITOR")) + .use(withAudit("environment.gitConnection.tested", "Environment")) + .mutation(async ({ input }) => { + const parsedUrl = new URL(input.repoUrl); + if (parsedUrl.protocol !== "https:") { + return { success: false, error: "Only HTTPS repository URLs are supported" }; + } + + const simpleGit = (await import("simple-git")).default; + const { mkdtemp, rm } = await import("fs/promises"); + const { join } = await import("path"); + const { tmpdir } = await import("os"); + + let workdir: string | null = null; + try { + workdir = await mkdtemp(join(tmpdir(), "vf-git-test-")); + const repoDir = join(workdir, "repo"); + const git = simpleGit(workdir); + parsedUrl.username = input.token; + parsedUrl.password = ""; + await git.clone(parsedUrl.toString(), repoDir, [ + "--branch", input.branch, + "--depth", "1", + "--single-branch", + ]); + return { success: true }; + } catch (err) { + const raw = err instanceof Error ? err.message : String(err); + const sanitized = raw.replace(/https?:\/\/[^@\s]+@/g, "https://[redacted]@"); + return { + success: false, + error: sanitized, + }; + } finally { + if (workdir) { + await rm(workdir, { recursive: true, force: true }).catch(() => {}); + } + } }), delete: protectedProcedure diff --git a/src/server/routers/pipeline.ts b/src/server/routers/pipeline.ts index 346110c..967be2f 100644 --- a/src/server/routers/pipeline.ts +++ b/src/server/routers/pipeline.ts @@ -16,6 +16,7 @@ import { generateVectorYaml } from "@/lib/config-generator"; import { getOrCreateSystemEnvironment } from "@/server/services/system-environment"; import { copyPipelineGraph } from "@/server/services/copy-pipeline-graph"; import { stripEnvRefs, type StrippedRef } from "@/server/services/strip-env-refs"; +import { gitSyncDeletePipeline } from "@/server/services/git-sync"; /** Pipeline names must be safe identifiers */ const pipelineNameSchema = z @@ -384,7 +385,7 @@ export const pipelineRouter = router({ .input(z.object({ id: z.string() })) .use(withTeamAccess("EDITOR")) .use(withAudit("pipeline.deleted", "Pipeline")) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { const existing = await prisma.pipeline.findUnique({ where: { id: input.id }, }); @@ -409,6 +410,29 @@ export const pipelineRouter = router({ }); } + // Git sync: delete pipeline YAML from repo (non-blocking) + const environment = await prisma.environment.findUnique({ + where: { id: existing.environmentId }, + }); + if (environment?.gitRepoUrl && environment?.gitToken) { + const user = ctx.session?.user; + const dbUser = user?.id + ? await prisma.user.findUnique({ where: { id: user.id } }) + : null; + await gitSyncDeletePipeline( + { + repoUrl: environment.gitRepoUrl, + branch: environment.gitBranch ?? "main", + encryptedToken: environment.gitToken, + }, + environment.name, + existing.name, + { name: dbUser?.name ?? "VectorFlow User", email: dbUser?.email ?? "noreply@vectorflow" }, + ).catch((err) => { + console.error("[git-sync] Delete failed for pipeline:", existing.name, err); + }); + } + return prisma.pipeline.delete({ where: { id: input.id }, }); diff --git a/src/server/services/deploy-agent.ts b/src/server/services/deploy-agent.ts index f81bc01..c2f8587 100644 --- a/src/server/services/deploy-agent.ts +++ b/src/server/services/deploy-agent.ts @@ -5,6 +5,7 @@ import { validateConfig } from "@/server/services/validator"; import { createVersion } from "@/server/services/pipeline-version"; import { decryptNodeConfig } from "@/server/services/config-crypto"; import { startSystemVector, stopSystemVector } from "@/server/services/system-vector"; +import { gitSyncCommitPipeline } from "@/server/services/git-sync"; export interface AgentDeployResult { success: boolean; @@ -12,6 +13,7 @@ export interface AgentDeployResult { versionId?: string; versionNumber?: number; validationErrors?: Array<{ message: string; componentKey?: string }>; + gitSyncError?: string; } /** @@ -87,6 +89,30 @@ export async function deployAgent( gc, ); + // 3b. Git sync (non-blocking side effect) + let gitSyncError: string | undefined; + const environment = await prisma.environment.findUnique({ + where: { id: pipeline.environmentId }, + }); + if (environment?.gitRepoUrl && environment?.gitToken) { + const user = await prisma.user.findUnique({ where: { id: userId } }); + const result = await gitSyncCommitPipeline( + { + repoUrl: environment.gitRepoUrl, + branch: environment.gitBranch ?? "main", + encryptedToken: environment.gitToken, + }, + environment.name, + pipeline.name, + configYaml, + { name: user?.name ?? "VectorFlow User", email: user?.email ?? "noreply@vectorflow" }, + changelog ?? `Deploy pipeline: ${pipeline.name}`, + ); + if (!result.success) { + gitSyncError = result.error; + } + } + // 4. For system pipelines, start the local Vector process instead of // relying on agents to pick up the config. if (pipeline.isSystem) { @@ -97,6 +123,7 @@ export async function deployAgent( success: true, versionId: version.id, versionNumber: version.version, + gitSyncError, }; } diff --git a/src/server/services/git-sync.ts b/src/server/services/git-sync.ts new file mode 100644 index 0000000..105689c --- /dev/null +++ b/src/server/services/git-sync.ts @@ -0,0 +1,169 @@ +import simpleGit, { SimpleGit } from "simple-git"; +import { mkdtemp, writeFile, rm, mkdir } from "fs/promises"; +import { join } from "path"; +import { tmpdir } from "os"; +import { decrypt } from "@/server/services/crypto"; + +export interface GitSyncConfig { + repoUrl: string; + branch: string; + encryptedToken: string; +} + +export interface GitSyncResult { + success: boolean; + commitSha?: string; + error?: string; +} + +interface GitAuthor { + name: string; + email: string; +} + +/** + * Build an authenticated HTTPS URL by injecting the PAT. + * Supports GitHub, GitLab, Bitbucket URL formats. + * Example: https://github.com/org/repo.git → https://@github.com/org/repo.git + */ +function authenticatedUrl(repoUrl: string, token: string): string { + const url = new URL(repoUrl); + url.username = token; + url.password = ""; + return url.toString(); +} + +function sanitizeError(message: string): string { + return message.replace(/https?:\/\/[^@\s]+@/g, "https://[redacted]@"); +} + +function sanitizeAuthor(name: string, email: string): string { + const cleanName = (name || "VectorFlow User").replace(/[<>\n\r]/g, ""); + const cleanEmail = email.replace(/[<>\n\r]/g, ""); + return `${cleanName} <${cleanEmail}>`; +} + +/** + * Slugify a string for use as a filename. + */ +export function toFilenameSlug(name: string): string { + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + return slug || "unnamed"; +} + +/** + * Commit a pipeline YAML file to the configured Git repo. + * Used after successful deploy. + */ +export async function gitSyncCommitPipeline( + config: GitSyncConfig, + environmentName: string, + pipelineName: string, + configYaml: string, + author: GitAuthor, + commitMessage: string, +): Promise { + let workdir: string | null = null; + + try { + const token = decrypt(config.encryptedToken); + const url = authenticatedUrl(config.repoUrl, token); + workdir = await mkdtemp(join(tmpdir(), "vf-git-sync-")); + const repoDir = join(workdir, "repo"); + + const git: SimpleGit = simpleGit(workdir); + await git.clone(url, repoDir, ["--branch", config.branch, "--depth", "1", "--single-branch"]); + const repoGit: SimpleGit = simpleGit(repoDir); + + // Write the pipeline YAML file + const envDir = toFilenameSlug(environmentName); + const filename = `${toFilenameSlug(pipelineName)}.yaml`; + const filePath = join(envDir, filename); + const fullPath = join(repoDir, filePath); + + await mkdir(join(repoDir, envDir), { recursive: true }); + await writeFile(fullPath, configYaml, "utf-8"); + + await repoGit.add(filePath); + + // Check if there are actually changes to commit + const status = await repoGit.status(); + if (status.isClean()) { + return { success: true, commitSha: "no-change" }; + } + + await repoGit.addConfig("user.name", author.name || "VectorFlow User"); + await repoGit.addConfig("user.email", author.email || "noreply@vectorflow"); + await repoGit.commit(commitMessage, filePath, { + "--author": sanitizeAuthor(author.name, author.email), + }); + await repoGit.push("origin", config.branch); + + const log = await repoGit.log({ maxCount: 1 }); + return { success: true, commitSha: log.latest?.hash }; + } catch (err) { + const message = sanitizeError(err instanceof Error ? err.message : String(err)); + console.error("[git-sync] Commit failed:", message); + return { success: false, error: message }; + } finally { + if (workdir) { + await rm(workdir, { recursive: true, force: true }).catch(() => {}); + } + } +} + +/** + * Delete a pipeline YAML file from the configured Git repo. + * Used after pipeline deletion. + */ +export async function gitSyncDeletePipeline( + config: GitSyncConfig, + environmentName: string, + pipelineName: string, + author: GitAuthor, +): Promise { + let workdir: string | null = null; + + try { + const token = decrypt(config.encryptedToken); + const url = authenticatedUrl(config.repoUrl, token); + workdir = await mkdtemp(join(tmpdir(), "vf-git-sync-")); + const repoDir = join(workdir, "repo"); + + const git: SimpleGit = simpleGit(workdir); + await git.clone(url, repoDir, ["--branch", config.branch, "--depth", "1", "--single-branch"]); + const repoGit: SimpleGit = simpleGit(repoDir); + + const envDir = toFilenameSlug(environmentName); + const filename = `${toFilenameSlug(pipelineName)}.yaml`; + const filePath = join(envDir, filename); + + try { + await repoGit.rm(filePath); + } catch { + // File not tracked in repo — nothing to delete + return { success: true, commitSha: "no-file" }; + } + + await repoGit.addConfig("user.name", author.name || "VectorFlow User"); + await repoGit.addConfig("user.email", author.email || "noreply@vectorflow"); + await repoGit.commit(`Delete pipeline: ${pipelineName}`, filePath, { + "--author": sanitizeAuthor(author.name, author.email), + }); + await repoGit.push("origin", config.branch); + + const log = await repoGit.log({ maxCount: 1 }); + return { success: true, commitSha: log.latest?.hash }; + } catch (err) { + const message = sanitizeError(err instanceof Error ? err.message : String(err)); + console.error("[git-sync] Delete failed:", message); + return { success: false, error: message }; + } finally { + if (workdir) { + await rm(workdir, { recursive: true, force: true }).catch(() => {}); + } + } +}