/** * Per-project dev-container git plumbing. * * The dev container's /workspace is a scratch volume by default. Without * this module, anything the AI writes there is invisible to the user — * the Product/Hosting/Infrastructure tabs all read from Gitea + Coolify, * not from the dev container's filesystem. * * Two responsibilities: * * 1. ensureProjectRepoCloned() * Clones the project's Gitea repo into /workspace// on first * use. Configures the AI's git author identity and embeds Gitea * credentials into the remote URL so subsequent pushes work without * another credential exchange. Idempotent — if the repo already * exists at the expected path, it's a no-op. * * 2. commitAndPushIfDirty() * Run at the end of each AI assistant turn. If there are changes * under /workspace//, stages them, makes a commit with a * one-line summary, and pushes to origin. Failures are logged but * don't fail the chat turn — the user already saw their reply. * * Why we shell out vs. using a JS git lib: the dev container has a real * git binary, the credentials are simple (HTTPS+token), and isomorphic * git would mean shipping a ~2MB JS dep just to mirror what `git` already * does. Cost outweighs benefit. * * Security note: the Gitea token is embedded in the remote URL, which * lives in `.git/config` inside the dev container's volume. The volume * is per-project and only reachable through our own SSH-bridge exec, so * the blast radius is the same as `shell.exec` itself — anything the AI * can do can read this token. That's already true today; this doesn't * widen it. */ import { execInDevContainer } from "@/lib/dev-container"; const GITEA_API_URL = process.env.GITEA_API_URL ?? ""; // Falls back to GITEA_ADMIN_USER because production historically only set the // admin var; missing GITEA_USERNAME used to silently disable auto-clone. const GITEA_USERNAME = process.env.GITEA_USERNAME || process.env.GITEA_ADMIN_USER || ""; const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? ""; const AI_GIT_AUTHOR_NAME = "Vibn AI"; const AI_GIT_AUTHOR_EMAIL = "ai@vibnai.com"; /** Where each project's repo lives inside the dev container. */ export function projectRepoPath(projectSlug: string): string { return `/workspace`; } function isGiteaConfigured(): boolean { return Boolean(GITEA_API_URL && GITEA_USERNAME && GITEA_API_TOKEN); } /** * Take a Gitea HTTPS URL and embed the AI's credentials inline so * `git clone` / `git push` work without an interactive prompt. * * Input: https://git.vibnai.com/vibn-mark/manifest.git * Output: https://mark:@git.vibnai.com/vibn-mark/manifest.git */ function authedCloneUrl(plainCloneUrl: string): string { if (!isGiteaConfigured()) return plainCloneUrl; try { const u = new URL(plainCloneUrl); u.username = GITEA_USERNAME; u.password = GITEA_API_TOKEN; return u.toString(); } catch { return plainCloneUrl; } } export interface EnsureRepoClonedOpts { projectId: string; projectSlug: string; giteaCloneUrl: string; } export interface EnsureRepoClonedResult { cloned: boolean; alreadyPresent: boolean; reason?: string; } /** * Make sure the project's Gitea repo is checked out at /workspace//. * * Skips silently if the repo is already present (detected by * `git rev-parse --is-inside-work-tree`). Logs and returns a reason * string if Gitea isn't configured or the clone fails — callers should * treat clone failure as non-fatal: the dev container is still usable, * the AI just won't be able to surface its work in the UI until the * repo is fixed. */ export async function ensureProjectRepoCloned( opts: EnsureRepoClonedOpts, ): Promise { if (!isGiteaConfigured()) { return { cloned: false, alreadyPresent: false, reason: "gitea_not_configured", }; } if (!opts.giteaCloneUrl) { return { cloned: false, alreadyPresent: false, reason: "no_clone_url" }; } const repoDir = projectRepoPath(opts.projectSlug); const authed = authedCloneUrl(opts.giteaCloneUrl); // Tri-state probe: // "git" → /workspace//.git exists (proper repo, no-op) // "dir" → /workspace// exists but isn't a repo (init in place) // "absent" → nothing there yet (full clone) // // The "dir" case happens when an earlier AI turn (pre-this-fix) // created files under /workspace// before we had auto-clone. // We init-git in place so those files become the first commit // instead of being clobbered by a clone-and-rename. const probe = await execInDevContainer({ projectId: opts.projectId, command: `if [ -d ${shellQ(repoDir + "/.git")} ]; then echo git; ` + `elif [ -d ${shellQ(repoDir)} ]; then echo dir; ` + `else echo absent; fi`, timeoutMs: 5_000, }); const probeState = probe.stdout.trim(); if (probeState === "git") { return { cloned: false, alreadyPresent: true }; } if (probeState === "dir") { // Init in place, hook up the remote, fetch, set tracking. We // don't try to merge — the directory's contents become whatever // the next auto-commit-and-push picks up. If the remote has // existing commits that conflict, the push will fail loudly // and the AI can resolve via shell.exec. const initCmd = [ `cd ${shellQ(repoDir)}`, `git init -b main`, `git config user.name ${shellQ(AI_GIT_AUTHOR_NAME)}`, `git config user.email ${shellQ(AI_GIT_AUTHOR_EMAIL)}`, `git remote add origin ${shellQ(authed)}`, // Best-effort fetch; if remote is empty this errors out and // we proceed anyway. The `|| true` keeps the chain going. `(git fetch origin main 2>/dev/null && git reset --soft origin/main 2>/dev/null) || true`, ].join(" && "); const result = await execInDevContainer({ projectId: opts.projectId, command: initCmd, timeoutMs: 30_000, }); if (result.exitCode !== 0) { return { cloned: false, alreadyPresent: false, reason: `init_in_place_failed:${result.stderr.slice(0, 200)}`, }; } return { cloned: true, alreadyPresent: false }; } // probeState === 'absent' → full clone path. // Clone into a temp path then atomic-rename, so a clone interrupted // halfway through doesn't leave a half-checked-out tree at the real // path that the next attempt would mistake for "already present". const tmpDir = `${repoDir}.cloning.${Date.now()}`; const cloneCmd = [ `mkdir -p /workspace`, // --depth 50 keeps the dev container small; we don't need full // history, and `--no-single-branch` would let the AI fetch other // branches if needed. For empty repos this errors with "remote // HEAD refers to nonexistent ref" — handle that with the dir // fallback below. `git clone --depth 50 ${shellQ(authed)} ${shellQ(tmpDir)}`, `cd ${shellQ(tmpDir)}`, `git config user.name ${shellQ(AI_GIT_AUTHOR_NAME)}`, `git config user.email ${shellQ(AI_GIT_AUTHOR_EMAIL)}`, `git remote set-url origin ${shellQ(authed)}`, `cd /workspace && mv ${shellQ(tmpDir)} ${shellQ(repoDir)}`, ].join(" && "); const result = await execInDevContainer({ projectId: opts.projectId, command: cloneCmd, timeoutMs: 60_000, }); if (result.exitCode !== 0) { await execInDevContainer({ projectId: opts.projectId, command: `rm -rf ${shellQ(tmpDir)}`, timeoutMs: 5_000, }).catch(() => {}); // Empty-repo fallback: if Gitea returned the repo is empty // (warning: "remote HEAD refers to nonexistent ref"), clone // didn't actually fail — just there's no history to check // out. Make an empty dir + init so subsequent commits land. if ( /empty|warning: You appear to have cloned an empty/i.test( result.stderr + result.stdout, ) ) { const initEmpty = [ `mkdir -p ${shellQ(repoDir)}`, `cd ${shellQ(repoDir)}`, `git init -b main`, `git config user.name ${shellQ(AI_GIT_AUTHOR_NAME)}`, `git config user.email ${shellQ(AI_GIT_AUTHOR_EMAIL)}`, `git remote add origin ${shellQ(authed)}`, ].join(" && "); const initR = await execInDevContainer({ projectId: opts.projectId, command: initEmpty, timeoutMs: 15_000, }).catch(() => null); if (initR && initR.exitCode === 0) { return { cloned: true, alreadyPresent: false }; } } return { cloned: false, alreadyPresent: false, reason: `clone_failed:${result.stderr.slice(0, 200)}`, }; } return { cloned: true, alreadyPresent: false }; } export interface CommitAndPushOpts { projectId: string; projectSlug: string; /** One-line summary; defaults to a generic checkpoint message. */ message?: string; } export interface CommitAndPushResult { committed: boolean; pushed: boolean; /** Short reason ('clean', 'no_repo', 'commit_failed:', etc.). */ reason?: string; /** Commit hash if a commit was made. */ sha?: string; } /** * Stage every change under /workspace//, commit if there's a diff, * and push to origin. * * Designed to be called at the end of each assistant turn. Cheap when * there are no changes (one `git status --porcelain` and we're out). * Never throws — the chat reply has already been streamed to the user * by the time this runs, and a failed push shouldn't surface as a * user-visible error in the conversation. */ export async function commitAndPushIfDirty( opts: CommitAndPushOpts, ): Promise { const repoDir = projectRepoPath(opts.projectSlug); const message = (opts.message ?? "").trim() || "AI checkpoint"; // Sanitize: keep messages single-line and bounded so we can't be // tricked into shell-escape or commit-message injection. const safeMessage = message.replace(/[\r\n]+/g, " ").slice(0, 200); // Bail fast if there's no repo to commit against. Don't treat as // an error — projects without a clone (e.g. cloning failed earlier) // should still be able to chat. const probe = await execInDevContainer({ projectId: opts.projectId, command: `test -d ${shellQ(repoDir + "/.git")} && echo present || echo absent`, timeoutMs: 5_000, }).catch(() => null); if (!probe || probe.stdout.trim() !== "present") { return { committed: false, pushed: false, reason: "no_repo" }; } const cmd = [ `cd ${shellQ(repoDir)}`, `git add -A`, // If there's nothing staged, `diff --cached --quiet` exits 0 and // we short-circuit with a clean status. Otherwise commit + push. `if git diff --cached --quiet; then echo CLEAN && exit 0; fi`, `git commit -m ${shellQ(safeMessage)}`, `SHA=$(git rev-parse --short HEAD)`, `git push -u origin HEAD 2>&1 | tail -n 5`, `echo COMMITTED $SHA`, ].join(" && "); const result = await execInDevContainer({ projectId: opts.projectId, command: cmd, timeoutMs: 30_000, }).catch( (err: unknown) => ({ stdout: "", stderr: err instanceof Error ? err.message : String(err), exitCode: -1, }) satisfies { stdout: string; stderr: string; exitCode: number }, ); const out = result.stdout || ""; if (out.includes("CLEAN")) { return { committed: false, pushed: false, reason: "clean" }; } const m = /COMMITTED\s+([0-9a-f]+)/.exec(out); if (m) { return { committed: true, pushed: true, sha: m[1] }; } // We staged something but didn't see the COMMITTED marker — likely // commit succeeded but push failed (offline, auth rotated, etc.). // Surface the stderr tail so debugging is possible. return { committed: false, pushed: false, reason: `commit_or_push_failed:${(result.stderr || out).slice(-200)}`, }; } function shellQ(s: string): string { return `'${s.replace(/'/g, `'\\''`)}'`; }