/** * 04 – Force-push simulation and backup verification. * * This is the critical test that proves data loss can happen from a * force-push on the source repo, and verifies that the backup system * (when enabled) preserves the old state. * * Scenario: * 1. Confirm my-project is already mirrored with known commits / content * 2. Record the pre-force-push state (branch SHAs, commit messages, file content) * 3. Rewrite history in the source bare repo (simulate a force-push) * 4. Trigger Gitea mirror-sync WITHOUT backup * 5. Verify Gitea now reflects the rewritten history — old commits are GONE * 6. Restore the source repo, re-mirror, then enable backup * 7. Force-push again and sync WITH backup enabled * 8. Verify backup activity was recorded (snapshot attempted before sync) * * The source bare repos live on the host filesystem at * tests/e2e/git-repos//.git and are served read-only into the * git-server container. Because the bind-mount is :ro in docker-compose, * we modify the repos on the host and Gitea's dumb-HTTP clone picks up * the changes on the next fetch. * * Prerequisites: 02-mirror-workflow.spec.ts must have run first so that * my-project is already mirrored into Gitea. */ import { execSync } from "node:child_process"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join, resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { test, expect } from "@playwright/test"; import { APP_URL, GITEA_URL, GITEA_MIRROR_ORG, GiteaAPI, getAppSessionCookies, saveConfig, waitFor, getRepositoryIds, triggerSyncRepo, } from "./helpers"; // ─── Paths ─────────────────────────────────────────────────────────────────── const E2E_DIR = resolve(dirname(fileURLToPath(import.meta.url))); const GIT_REPOS_DIR = join(E2E_DIR, "git-repos"); const MY_PROJECT_BARE = join(GIT_REPOS_DIR, "e2e-test-user", "my-project.git"); // ─── Git helpers ───────────────────────────────────────────────────────────── /** Run a git command in a given directory. */ function git(args: string, cwd: string): string { try { return execSync(`git ${args}`, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, GIT_AUTHOR_NAME: "Force Push Bot", GIT_AUTHOR_EMAIL: "force-push@test.local", GIT_COMMITTER_NAME: "Force Push Bot", GIT_COMMITTER_EMAIL: "force-push@test.local", }, }).trim(); } catch (err: any) { const stderr = err.stderr?.toString() ?? ""; const stdout = err.stdout?.toString() ?? ""; throw new Error( `git ${args} failed in ${cwd}:\n${stderr || stdout || err.message}`, ); } } /** * Get the SHA of a ref in a bare repository. * Uses `git rev-parse` so it works for branches and tags. */ function getRefSha(bareRepo: string, ref: string): string { return git(`rev-parse ${ref}`, bareRepo); } /** * Clone the bare repo to a temporary working copy, execute a callback that * mutates the working copy, then force-push back to the bare repo and * update server-info for dumb-HTTP serving. */ function mutateSourceRepo( bareRepo: string, tmpName: string, mutate: (workDir: string) => void, ): void { const tmpDir = join(GIT_REPOS_DIR, ".work-force-push", tmpName); rmSync(tmpDir, { recursive: true, force: true }); mkdirSync(join(GIT_REPOS_DIR, ".work-force-push"), { recursive: true }); try { // Clone from the bare repo git(`clone "${bareRepo}" "${tmpDir}"`, GIT_REPOS_DIR); git("config user.name 'Force Push Bot'", tmpDir); git("config user.email 'force-push@test.local'", tmpDir); // Let the caller rewrite history mutate(tmpDir); // Force-push all refs back to the bare repo git(`push --force --all "${bareRepo}"`, tmpDir); git(`push --force --tags "${bareRepo}"`, tmpDir); // Update server-info so the dumb-HTTP server picks up the new refs git("update-server-info", bareRepo); } finally { rmSync(tmpDir, { recursive: true, force: true }); } } /** Helper to clean up the temporary working directory. */ function cleanupWorkDir(): void { const workDir = join(GIT_REPOS_DIR, ".work-force-push"); rmSync(workDir, { recursive: true, force: true }); } // ─── Tests ─────────────────────────────────────────────────────────────────── test.describe("E2E: Force-push simulation", () => { let giteaApi: GiteaAPI; let appCookies = ""; /** SHA of the main branch BEFORE we force-push. */ let originalMainSha = ""; /** The commit message of the HEAD commit before force-push. */ let originalHeadMessage = ""; /** Content of README.md before force-push. */ let originalReadmeContent = ""; /** Number of commits on main before force-push. */ let originalCommitCount = 0; test.beforeAll(async () => { giteaApi = new GiteaAPI(GITEA_URL); try { await giteaApi.createToken(); } catch { console.log("[ForcePush] Could not create Gitea token"); } }); test.afterAll(async () => { cleanupWorkDir(); await giteaApi.dispose(); }); // ── F0: Preconditions ──────────────────────────────────────────────────── test("F0: Confirm my-project is mirrored and record its state", async ({ request, }) => { // Verify the source bare repo exists on the host expect( existsSync(MY_PROJECT_BARE), `Bare repo should exist at ${MY_PROJECT_BARE}`, ).toBeTruthy(); // Verify it is mirrored in Gitea const repo = await giteaApi.getRepo(GITEA_MIRROR_ORG, "my-project"); expect(repo, "my-project should exist in Gitea").toBeTruthy(); console.log( `[ForcePush] my-project in Gitea: mirror=${repo.mirror}, ` + `default_branch=${repo.default_branch}`, ); // Record the current state of main in Gitea const mainBranch = await giteaApi.getBranch( GITEA_MIRROR_ORG, "my-project", "main", ); expect(mainBranch, "main branch should exist").toBeTruthy(); originalMainSha = mainBranch.commit.id; originalHeadMessage = mainBranch.commit.message?.trim() ?? "(unknown message)"; console.log( `[ForcePush] Original main HEAD: ${originalMainSha.substring(0, 12)} ` + `"${originalHeadMessage}"`, ); // Record commit count const commits = await giteaApi.listCommits(GITEA_MIRROR_ORG, "my-project", { limit: 50, }); originalCommitCount = commits.length; console.log( `[ForcePush] Original commit count on main: ${originalCommitCount}`, ); // Record README content const readme = await giteaApi.getFileContent( GITEA_MIRROR_ORG, "my-project", "README.md", ); originalReadmeContent = readme ?? ""; expect(originalReadmeContent).toContain("My Project"); console.log( `[ForcePush] Original README length: ${originalReadmeContent.length} chars`, ); // Also verify the source bare repo matches const sourceSha = getRefSha(MY_PROJECT_BARE, "refs/heads/main"); console.log( `[ForcePush] Source bare main SHA: ${sourceSha.substring(0, 12)}`, ); // They may differ slightly if Gitea hasn't synced the very latest, but // the important thing is that both exist. }); // ── F1: Rewrite history on the source repo ─────────────────────────────── test("F1: Force-push rewritten history to source repo", async () => { const shaBeforeRewrite = getRefSha(MY_PROJECT_BARE, "refs/heads/main"); console.log( `[ForcePush] Source main before rewrite: ${shaBeforeRewrite.substring(0, 12)}`, ); mutateSourceRepo(MY_PROJECT_BARE, "my-project-rewrite", (workDir) => { // We're on the main branch. // Rewrite history: remove the last commit (the LICENSE commit) via // reset --hard HEAD~1, then add a completely different commit. git("checkout main", workDir); // Record what HEAD is for logging const headBefore = git("log --oneline -1", workDir); console.log(`[ForcePush] Working copy HEAD before reset: ${headBefore}`); // Hard reset to remove the last commit (this drops "Add MIT license") git("reset --hard HEAD~1", workDir); const headAfterReset = git("log --oneline -1", workDir); console.log(`[ForcePush] After reset HEAD~1: ${headAfterReset}`); // Write a replacement commit with different content (simulates someone // rewriting history with different changes) writeFileSync( join(workDir, "README.md"), "# My Project\n\nThis README was FORCE-PUSHED.\n\nOriginal history has been rewritten.\n", ); writeFileSync( join(workDir, "FORCE_PUSH_MARKER.txt"), `Force-pushed at ${new Date().toISOString()}\n`, ); git("add -A", workDir); execSync('git commit -m "FORCE PUSH: Rewritten history"', { cwd: workDir, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, GIT_AUTHOR_NAME: "Force Push Bot", GIT_AUTHOR_EMAIL: "force-push@test.local", GIT_AUTHOR_DATE: "2024-06-15T12:00:00+00:00", GIT_COMMITTER_NAME: "Force Push Bot", GIT_COMMITTER_EMAIL: "force-push@test.local", GIT_COMMITTER_DATE: "2024-06-15T12:00:00+00:00", }, }); const headAfterRewrite = git("log --oneline -3", workDir); console.log(`[ForcePush] After rewrite (last 3):\n${headAfterRewrite}`); }); const shaAfterRewrite = getRefSha(MY_PROJECT_BARE, "refs/heads/main"); console.log( `[ForcePush] Source main after rewrite: ${shaAfterRewrite.substring(0, 12)}`, ); // The SHA must have changed — this proves the force-push happened expect( shaAfterRewrite, "Source repo main SHA should change after force-push", ).not.toBe(originalMainSha); // Verify the old SHA is no longer reachable on main const logOutput = git("log --oneline main", MY_PROJECT_BARE); expect( logOutput, "Rewritten history should NOT contain the old head commit", ).toContain("FORCE PUSH"); }); // ── F2: Sync to Gitea WITHOUT backup ───────────────────────────────────── test("F2: Disable backup and sync force-pushed repo to Gitea", async ({ request, }) => { appCookies = await getAppSessionCookies(request); const giteaToken = giteaApi.getTokenValue(); expect(giteaToken).toBeTruthy(); // Ensure backup is disabled for this test await saveConfig(request, giteaToken, appCookies, { giteaConfig: { backupStrategy: "disabled", blockSyncOnBackupFailure: false, }, }); console.log("[ForcePush] Backup disabled for unprotected sync test"); // Trigger Gitea's mirror-sync directly via the Gitea API. // This is more reliable than going through the app for this test because // the app's sync-repo endpoint involves extra processing. We want to test // the raw effect of Gitea pulling the rewritten refs. const synced = await giteaApi.triggerMirrorSync( GITEA_MIRROR_ORG, "my-project", ); console.log(`[ForcePush] Gitea mirror-sync triggered: ${synced}`); // Wait for Gitea to pull the new refs from the git-server console.log("[ForcePush] Waiting for Gitea to pull rewritten refs..."); await new Promise((r) => setTimeout(r, 15_000)); }); // ── F3: Verify Gitea reflects the rewritten history ────────────────────── test("F3: Verify Gitea has the force-pushed content (old history GONE)", async () => { // Poll until Gitea picks up the new HEAD await waitFor( async () => { const branch = await giteaApi.getBranch( GITEA_MIRROR_ORG, "my-project", "main", ); if (!branch) return false; return branch.commit.id !== originalMainSha; }, { timeout: 60_000, interval: 5_000, label: "Gitea main branch updates to new SHA", }, ); // Read the new state const newMainBranch = await giteaApi.getBranch( GITEA_MIRROR_ORG, "my-project", "main", ); expect(newMainBranch).toBeTruthy(); const newSha = newMainBranch.commit.id; const newMsg = newMainBranch.commit.message?.trim() ?? ""; console.log( `[ForcePush] New main HEAD: ${newSha.substring(0, 12)} "${newMsg}"`, ); // The SHA MUST be different from the original expect( newSha, "Gitea main SHA should have changed after force-push sync", ).not.toBe(originalMainSha); // The new commit message should be the force-pushed one expect(newMsg).toContain("FORCE PUSH"); // Verify the force-push marker file now exists in Gitea const markerContent = await giteaApi.getFileContent( GITEA_MIRROR_ORG, "my-project", "FORCE_PUSH_MARKER.txt", ); expect( markerContent, "FORCE_PUSH_MARKER.txt should appear after sync", ).toBeTruthy(); console.log( `[ForcePush] Marker file present: ${markerContent?.substring(0, 40)}...`, ); // Verify the README was overwritten const newReadme = await giteaApi.getFileContent( GITEA_MIRROR_ORG, "my-project", "README.md", ); expect(newReadme).toContain("FORCE-PUSHED"); expect(newReadme).not.toBe(originalReadmeContent); console.log("[ForcePush] README.md confirms overwritten content"); // Verify the LICENSE file is GONE (it was in the dropped commit) const licenseContent = await giteaApi.getFileContent( GITEA_MIRROR_ORG, "my-project", "LICENSE", ); expect( licenseContent, "LICENSE should be GONE after force-push removed that commit", ).toBeNull(); console.log("[ForcePush] ✗ LICENSE file is GONE — data loss confirmed"); // Verify the old commit SHA is no longer accessible const oldCommit = await giteaApi.getCommit( GITEA_MIRROR_ORG, "my-project", originalMainSha, ); // Gitea may or may not GC the unreachable commit immediately, so this // is informational rather than a hard assertion. if (oldCommit) { console.log( `[ForcePush] Old commit ${originalMainSha.substring(0, 12)} is ` + `still in Gitea's object store (not yet GC'd)`, ); } else { console.log( `[ForcePush] Old commit ${originalMainSha.substring(0, 12)} is ` + `no longer accessible — data loss complete`, ); } // Check commit count changed const newCommits = await giteaApi.listCommits( GITEA_MIRROR_ORG, "my-project", { limit: 50 }, ); console.log( `[ForcePush] Commit count: was ${originalCommitCount}, now ${newCommits.length}`, ); // The rewrite dropped one commit and added one, so the count should differ // or at minimum the commit list should not contain the old head message. const commitMessages = newCommits.map( (c: any) => c.commit?.message?.trim() ?? "", ); expect( commitMessages.some((m: string) => m.includes("FORCE PUSH")), "New commit list should contain the force-pushed commit", ).toBeTruthy(); console.log( "\n[ForcePush] ════════════════════════════════════════════════════", ); console.log( "[ForcePush] CONFIRMED: Force-push without backup = DATA LOSS", ); console.log( "[ForcePush] The LICENSE file and original HEAD commit are gone.", ); console.log( "[ForcePush] ════════════════════════════════════════════════════\n", ); }); // ── F4: Restore source, re-mirror, then test WITH backup ───────────────── test("F4: Restore source repo to a good state and re-mirror", async ({ request, }) => { // To test the backup path we need a clean slate. Re-create the original // my-project content in the source repo so it has known good history. mutateSourceRepo(MY_PROJECT_BARE, "my-project-restore", (workDir) => { git("checkout main", workDir); // Remove the force-push marker try { execSync("rm -f FORCE_PUSH_MARKER.txt", { cwd: workDir }); } catch { // may not exist } // Restore README writeFileSync( join(workDir, "README.md"), "# My Project\n\nA sample project for E2E testing.\n\n" + "## Features\n- Greeting module\n- Math utilities\n", ); // Restore LICENSE writeFileSync( join(workDir, "LICENSE"), "MIT License\n\nCopyright (c) 2024 E2E Test\n", ); git("add -A", workDir); execSync( 'git commit -m "Restore original content after force-push test"', { cwd: workDir, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, GIT_AUTHOR_NAME: "E2E Test Bot", GIT_AUTHOR_EMAIL: "e2e-bot@test.local", GIT_COMMITTER_NAME: "E2E Test Bot", GIT_COMMITTER_EMAIL: "e2e-bot@test.local", }, }, ); const newHead = git("log --oneline -1", workDir); console.log(`[ForcePush] Restored source HEAD: ${newHead}`); }); // Sync Gitea to pick up the restored state const synced = await giteaApi.triggerMirrorSync( GITEA_MIRROR_ORG, "my-project", ); console.log(`[ForcePush] Gitea mirror-sync for restore: ${synced}`); await new Promise((r) => setTimeout(r, 15_000)); // Verify Gitea has the restored content await waitFor( async () => { const readme = await giteaApi.getFileContent( GITEA_MIRROR_ORG, "my-project", "README.md", ); return readme !== null && readme.includes("Features"); }, { timeout: 60_000, interval: 5_000, label: "Gitea picks up restored content", }, ); const license = await giteaApi.getFileContent( GITEA_MIRROR_ORG, "my-project", "LICENSE", ); expect(license, "LICENSE should be restored").toBeTruthy(); console.log("[ForcePush] Gitea restored to good state"); // Record the new "good" SHA for the next force-push test const restoredBranch = await giteaApi.getBranch( GITEA_MIRROR_ORG, "my-project", "main", ); originalMainSha = restoredBranch.commit.id; console.log( `[ForcePush] Restored main SHA: ${originalMainSha.substring(0, 12)}`, ); }); // ── F5: Force-push AGAIN, this time with backup enabled ────────────────── test("F5: Enable backup, force-push, and sync", async ({ request }) => { if (!appCookies) { appCookies = await getAppSessionCookies(request); } const giteaToken = giteaApi.getTokenValue(); // Enable backup with "always" strategy await saveConfig(request, giteaToken, appCookies, { giteaConfig: { backupStrategy: "always", blockSyncOnBackupFailure: false, // don't block — we want to see both backup + sync happen backupRetentionCount: 5, backupDirectory: "data/repo-backups", }, }); console.log("[ForcePush] Backup enabled (strategy=always) for protected sync test"); // Force-push again mutateSourceRepo(MY_PROJECT_BARE, "my-project-rewrite2", (workDir) => { git("checkout main", workDir); writeFileSync( join(workDir, "README.md"), "# My Project\n\nSECOND FORCE-PUSH — backup should have preserved old state.\n", ); writeFileSync( join(workDir, "SECOND_FORCE_PUSH.txt"), `Second force-push at ${new Date().toISOString()}\n`, ); // Remove LICENSE again to simulate destructive rewrite try { execSync("rm -f LICENSE", { cwd: workDir }); } catch { // may not exist } git("add -A", workDir); execSync('git commit -m "SECOND FORCE PUSH: backup should catch this"', { cwd: workDir, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, GIT_AUTHOR_NAME: "Force Push Bot", GIT_AUTHOR_EMAIL: "force-push@test.local", GIT_COMMITTER_NAME: "Force Push Bot", GIT_COMMITTER_EMAIL: "force-push@test.local", }, }); }); console.log("[ForcePush] Second force-push applied to source repo"); // Use the app's sync-repo to trigger the sync (this goes through // syncGiteaRepoEnhanced which runs the backup code path) const { ids: repoIds } = await getRepositoryIds(request, appCookies); // Find the my-project repo ID const dashResp = await request.get(`${APP_URL}/api/dashboard`, { headers: { Cookie: appCookies }, failOnStatusCode: false, }); let myProjectId = ""; if (dashResp.ok()) { const data = await dashResp.json(); const repos: any[] = data.repositories ?? []; const myProj = repos.find((r: any) => r.name === "my-project"); if (myProj) myProjectId = myProj.id; } if (myProjectId) { console.log( `[ForcePush] Triggering app sync-repo for my-project (${myProjectId})`, ); const status = await triggerSyncRepo( request, appCookies, [myProjectId], 25_000, ); console.log(`[ForcePush] App sync-repo response: ${status}`); } else { // Fallback: trigger via Gitea API directly console.log( "[ForcePush] Could not find my-project ID, using Gitea API directly", ); await giteaApi.triggerMirrorSync(GITEA_MIRROR_ORG, "my-project"); await new Promise((r) => setTimeout(r, 15_000)); } }); // ── F6: Verify Gitea picked up the second force-push ───────────────────── test("F6: Verify Gitea reflects second force-push", async () => { await waitFor( async () => { const branch = await giteaApi.getBranch( GITEA_MIRROR_ORG, "my-project", "main", ); if (!branch) return false; return branch.commit.id !== originalMainSha; }, { timeout: 60_000, interval: 5_000, label: "Gitea main branch updates after second force-push", }, ); const newBranch = await giteaApi.getBranch( GITEA_MIRROR_ORG, "my-project", "main", ); const newSha = newBranch.commit.id; console.log( `[ForcePush] After 2nd force-push: main=${newSha.substring(0, 12)}, ` + `msg="${newBranch.commit.message?.trim()}"`, ); expect(newSha).not.toBe(originalMainSha); // Verify the second force-push marker const marker = await giteaApi.getFileContent( GITEA_MIRROR_ORG, "my-project", "SECOND_FORCE_PUSH.txt", ); expect(marker, "Second force-push marker should exist").toBeTruthy(); // LICENSE should be gone again const license = await giteaApi.getFileContent( GITEA_MIRROR_ORG, "my-project", "LICENSE", ); expect(license, "LICENSE gone again after 2nd force-push").toBeNull(); console.log("[ForcePush] Second force-push verified in Gitea"); }); // ── F7: Verify backup activity was logged for the second force-push ────── test("F7: Verify backup activity was recorded for protected sync", async ({ request, }) => { if (!appCookies) { appCookies = await getAppSessionCookies(request); } const activitiesResp = await request.get(`${APP_URL}/api/activities`, { headers: { Cookie: appCookies }, failOnStatusCode: false, }); if (!activitiesResp.ok()) { console.log( `[ForcePush] Could not fetch activities: ${activitiesResp.status()}`, ); return; } const activities = await activitiesResp.json(); const jobs: any[] = Array.isArray(activities) ? activities : (activities.jobs ?? activities.activities ?? []); // Filter to backup/snapshot entries for my-project const backupJobs = jobs.filter( (j: any) => (j.repositoryName === "my-project" || j.repositoryName === "my-project") && (j.message?.toLowerCase().includes("snapshot") || j.message?.toLowerCase().includes("backup") || j.details?.toLowerCase().includes("snapshot") || j.details?.toLowerCase().includes("backup") || j.details?.toLowerCase().includes("bundle")), ); console.log( `[ForcePush] Backup activity for my-project: ${backupJobs.length} entries`, ); for (const j of backupJobs) { console.log( `[ForcePush] • [${j.status}] ${j.message ?? ""} | ${(j.details ?? "").substring(0, 100)}`, ); } // The backup system should have been invoked and must succeed. expect( backupJobs.length, "At least one backup/snapshot activity should exist for my-project " + "when backupStrategy is 'always'", ).toBeGreaterThan(0); // Check whether any backups actually succeeded const successfulBackups = backupJobs.filter( (j: any) => j.status === "syncing" || j.message?.includes("Snapshot created") || j.details?.includes("Pre-sync snapshot created"), ); const failedBackups = backupJobs.filter( (j: any) => j.status === "failed" && (j.message?.includes("Snapshot failed") || j.details?.includes("snapshot failed")), ); if (successfulBackups.length > 0) { console.log( `[ForcePush] ✓ ${successfulBackups.length} backup(s) SUCCEEDED — ` + `old state was preserved in bundle`, ); } if (failedBackups.length > 0) { console.log( `[ForcePush] ⚠ ${failedBackups.length} backup(s) FAILED`, ); // Extract and log the first failure reason for visibility const firstFailure = failedBackups[0]; console.log( `[ForcePush] Failure reason: ${firstFailure.details?.substring(0, 200)}`, ); } console.log( "[ForcePush] ════════════════════════════════════════════════════", ); if (successfulBackups.length > 0) { console.log( "[ForcePush] RESULT: Backup system PROTECTED against force-push", ); } else { console.log("[ForcePush] RESULT: Backup system was INVOKED but FAILED."); } console.log( "[ForcePush] ════════════════════════════════════════════════════\n", ); // Fail the test if any backups failed expect( failedBackups.length, `Expected all backups to succeed, but ${failedBackups.length} backup(s) failed. ` + `First failure: ${failedBackups[0]?.details || "unknown error"}`, ).toBe(0); }); // ── F8: Restore source repo for subsequent test suites ─────────────────── test("F8: Restore source repo to clean state for other tests", async () => { mutateSourceRepo(MY_PROJECT_BARE, "my-project-final-restore", (workDir) => { git("checkout main", workDir); // Remove force-push artifacts try { execSync("rm -f FORCE_PUSH_MARKER.txt SECOND_FORCE_PUSH.txt", { cwd: workDir, }); } catch { // ignore } // Restore content writeFileSync( join(workDir, "README.md"), "# My Project\n\nA sample project for E2E testing.\n\n" + "## Features\n- Greeting module\n- Math utilities\n", ); writeFileSync( join(workDir, "LICENSE"), "MIT License\n\nCopyright (c) 2024 E2E Test\n", ); git("add -A", workDir); execSync( 'git commit --allow-empty -m "Final restore after force-push tests"', { cwd: workDir, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, GIT_AUTHOR_NAME: "E2E Test Bot", GIT_AUTHOR_EMAIL: "e2e-bot@test.local", GIT_COMMITTER_NAME: "E2E Test Bot", GIT_COMMITTER_EMAIL: "e2e-bot@test.local", }, }, ); }); // Sync Gitea await giteaApi.triggerMirrorSync(GITEA_MIRROR_ORG, "my-project"); await new Promise((r) => setTimeout(r, 10_000)); // Verify restoration const license = await giteaApi.getFileContent( GITEA_MIRROR_ORG, "my-project", "LICENSE", ); if (license) { console.log("[ForcePush] Source repo restored for subsequent tests"); } else { console.log( "[ForcePush] Warning: restoration may not have synced yet (Gitea async)", ); } }); });