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(() => {});
+ }
+ }
+}