From 58e0194aa62f79f122968970f1899df50392ffa9 Mon Sep 17 00:00:00 2001 From: ARUNAVO RAY Date: Sun, 1 Mar 2026 08:37:18 +0530 Subject: [PATCH] fix(nix): ensure absolute bundle path in pre-sync backup (#204) * fix(nix): ensure absolute bundle path in pre-sync backup (#203) Use path.resolve() instead of conditional path.isAbsolute() check to guarantee bundlePath is always absolute before passing to git -C. On NixOS, relative paths were interpreted relative to the temp mirror clone directory, causing "No such file or directory" errors. Closes #203 Co-Authored-By: Claude Opus 4.6 * fix(nix): ensure absolute bundle path in pre-sync backup (#203) Use path.resolve() instead of conditional path.isAbsolute() check to guarantee bundlePath is always absolute before passing to git -C. On NixOS, relative paths were interpreted relative to the temp mirror clone directory, causing "No such file or directory" errors. Extract resolveBackupPaths() for testability. Bump version to 3.10.1. Closes #203 Co-Authored-By: Claude Opus 4.6 * ci: drop macos matrix and only run nix build on main/tags - Remove macos-latest from Nix CI matrix (ubuntu-only) - Only run `nix build` on main branch and version tags, skip on PRs - `nix flake check` still runs on all PRs for validation Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/nix-build.yml | 6 +- package.json | 2 +- src/lib/repo-backup.test.ts | 115 ++++++++++++++++++++++++++++++++ src/lib/repo-backup.ts | 52 ++++++++++----- 4 files changed, 152 insertions(+), 23 deletions(-) create mode 100644 src/lib/repo-backup.test.ts diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml index de65809..77dfdf6 100644 --- a/.github/workflows/nix-build.yml +++ b/.github/workflows/nix-build.yml @@ -13,10 +13,7 @@ permissions: jobs: check: - strategy: - matrix: - os: [ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -34,4 +31,5 @@ jobs: run: nix flake show - name: Build package + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') run: nix build --print-build-logs diff --git a/package.json b/package.json index bacf9d2..6d221cc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gitea-mirror", "type": "module", - "version": "3.10.0", + "version": "3.10.1", "engines": { "bun": ">=1.2.9" }, diff --git a/src/lib/repo-backup.test.ts b/src/lib/repo-backup.test.ts new file mode 100644 index 0000000..5d0e498 --- /dev/null +++ b/src/lib/repo-backup.test.ts @@ -0,0 +1,115 @@ +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import type { Config } from "@/types/config"; +import { resolveBackupPaths } from "@/lib/repo-backup"; + +describe("resolveBackupPaths", () => { + let originalBackupDirEnv: string | undefined; + + beforeEach(() => { + originalBackupDirEnv = process.env.PRE_SYNC_BACKUP_DIR; + delete process.env.PRE_SYNC_BACKUP_DIR; + }); + + afterEach(() => { + if (originalBackupDirEnv === undefined) { + delete process.env.PRE_SYNC_BACKUP_DIR; + } else { + process.env.PRE_SYNC_BACKUP_DIR = originalBackupDirEnv; + } + }); + + test("returns absolute paths when backupDirectory is relative", () => { + const config: Partial = { + userId: "user-123", + giteaConfig: { + backupDirectory: "data/repo-backups", + } as Config["giteaConfig"], + }; + + const { backupRoot, repoBackupDir } = resolveBackupPaths({ + config, + owner: "RayLabsHQ", + repoName: "gitea-mirror", + }); + + expect(path.isAbsolute(backupRoot)).toBe(true); + expect(path.isAbsolute(repoBackupDir)).toBe(true); + expect(repoBackupDir).toBe( + path.join(backupRoot, "user-123", "RayLabsHQ", "gitea-mirror") + ); + }); + + test("returns absolute paths when backupDirectory is already absolute", () => { + const config: Partial = { + userId: "user-123", + giteaConfig: { + backupDirectory: "/data/repo-backups", + } as Config["giteaConfig"], + }; + + const { backupRoot, repoBackupDir } = resolveBackupPaths({ + config, + owner: "owner", + repoName: "repo", + }); + + expect(backupRoot).toBe("/data/repo-backups"); + expect(path.isAbsolute(repoBackupDir)).toBe(true); + }); + + test("falls back to cwd-based path when no backupDirectory is set", () => { + const config: Partial = { + userId: "user-123", + giteaConfig: {} as Config["giteaConfig"], + }; + + const { backupRoot } = resolveBackupPaths({ + config, + owner: "owner", + repoName: "repo", + }); + + expect(path.isAbsolute(backupRoot)).toBe(true); + expect(backupRoot).toBe( + path.resolve(process.cwd(), "data", "repo-backups") + ); + }); + + test("uses PRE_SYNC_BACKUP_DIR env var when config has no backupDirectory", () => { + process.env.PRE_SYNC_BACKUP_DIR = "custom/backup/path"; + + const config: Partial = { + userId: "user-123", + giteaConfig: {} as Config["giteaConfig"], + }; + + const { backupRoot } = resolveBackupPaths({ + config, + owner: "owner", + repoName: "repo", + }); + + expect(path.isAbsolute(backupRoot)).toBe(true); + expect(backupRoot).toBe(path.resolve("custom/backup/path")); + }); + + test("sanitizes owner and repoName in path segments", () => { + const config: Partial = { + userId: "user-123", + giteaConfig: { + backupDirectory: "/backups", + } as Config["giteaConfig"], + }; + + const { repoBackupDir } = resolveBackupPaths({ + config, + owner: "org/with-slash", + repoName: "repo name!", + }); + + expect(repoBackupDir).toBe( + path.join("/backups", "user-123", "org_with-slash", "repo_name_") + ); + }); +}); diff --git a/src/lib/repo-backup.ts b/src/lib/repo-backup.ts index 5a1f789..f84b8bb 100644 --- a/src/lib/repo-backup.ts +++ b/src/lib/repo-backup.ts @@ -101,6 +101,36 @@ export function shouldBlockSyncOnBackupFailure(config: Partial): boolean return configSetting === undefined ? true : Boolean(configSetting); } +export function resolveBackupPaths({ + config, + owner, + repoName, +}: { + config: Partial; + owner: string; + repoName: string; +}): { backupRoot: string; repoBackupDir: string } { + let backupRoot = + config.giteaConfig?.backupDirectory?.trim() || + process.env.PRE_SYNC_BACKUP_DIR?.trim() || + path.join(process.cwd(), "data", "repo-backups"); + + // Ensure backupRoot is absolute - relative paths break git bundle creation + // because git runs with -C mirrorClonePath and interprets relative paths from there. + // Always use path.resolve() which guarantees an absolute path, rather than a + // conditional check that can miss edge cases (e.g., NixOS systemd services). + backupRoot = path.resolve(backupRoot); + + const repoBackupDir = path.join( + backupRoot, + sanitizePathSegment(config.userId || "unknown-user"), + sanitizePathSegment(owner), + sanitizePathSegment(repoName) + ); + + return { backupRoot, repoBackupDir }; +} + export async function createPreSyncBundleBackup({ config, owner, @@ -126,16 +156,7 @@ export async function createPreSyncBundleBackup({ throw new Error("Decrypted Gitea token is required for pre-sync backup."); } - let backupRoot = - config.giteaConfig?.backupDirectory?.trim() || - process.env.PRE_SYNC_BACKUP_DIR?.trim() || - path.join(process.cwd(), "data", "repo-backups"); - - // Ensure backupRoot is absolute - relative paths break git bundle creation - // because git runs with -C mirrorClonePath and interprets relative paths from there - if (!path.isAbsolute(backupRoot)) { - backupRoot = path.resolve(process.cwd(), backupRoot); - } + const { repoBackupDir } = resolveBackupPaths({ config, owner, repoName }); const retention = Math.max( 1, Number.isFinite(config.giteaConfig?.backupRetentionCount) @@ -143,18 +164,13 @@ export async function createPreSyncBundleBackup({ : parsePositiveInt(process.env.PRE_SYNC_BACKUP_KEEP_COUNT, 20) ); - const repoBackupDir = path.join( - backupRoot, - sanitizePathSegment(config.userId || "unknown-user"), - sanitizePathSegment(owner), - sanitizePathSegment(repoName) - ); - await mkdir(repoBackupDir, { recursive: true }); const tmpDir = await mkdtemp(path.join(os.tmpdir(), "gitea-mirror-backup-")); const mirrorClonePath = path.join(tmpDir, "repo.git"); - const bundlePath = path.join(repoBackupDir, `${buildTimestamp()}.bundle`); + // path.resolve guarantees an absolute path, critical because git -C changes + // the working directory and would misinterpret a relative bundlePath + const bundlePath = path.resolve(repoBackupDir, `${buildTimestamp()}.bundle`); try { const authCloneUrl = buildAuthenticatedCloneUrl(cloneUrl, giteaToken);