diff --git a/packages/app/tests/hooks/session-backup-gist.test.ts b/packages/app/tests/hooks/session-backup-gist.test.ts new file mode 100644 index 0000000..3877836 --- /dev/null +++ b/packages/app/tests/hooks/session-backup-gist.test.ts @@ -0,0 +1,69 @@ +// CHANGE: add regression coverage for transient session backup paths +// WHY: `.codex/tmp` contains ephemeral runtime files that should not break snapshot creation +// REF: transient-session-backup-tmp +// PURITY: SHELL (tests filesystem traversal against committed backup script) + +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" + +import sessionBackupGist from "../../../../scripts/session-backup-gist.js" + +const { collectSessionFiles, shouldIgnoreSessionPath } = sessionBackupGist + +const withTempDir = Effect.acquireRelease( + Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "session-backup-gist-"))), + (tmpDir) => + Effect.sync(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) +) + +describe("session-backup-gist transient path filtering", () => { + it.effect("ignores .codex/tmp while keeping persistent session files", () => + Effect.scoped( + Effect.gen(function*(_) { + const tmpDir = yield* _(withTempDir) + const codexDir = path.join(tmpDir, ".codex") + const claudeDir = path.join(tmpDir, ".claude") + + yield* _( + Effect.sync(() => { + fs.mkdirSync(path.join(codexDir, "tmp", "run"), { recursive: true }) + fs.mkdirSync(path.join(codexDir, "memory"), { recursive: true }) + fs.mkdirSync(path.join(claudeDir, "tmp"), { recursive: true }) + + fs.writeFileSync(path.join(codexDir, "tmp", "run", ".lock"), "lock") + fs.writeFileSync(path.join(codexDir, "history.jsonl"), "{\"event\":1}\n") + fs.writeFileSync(path.join(codexDir, "memory", "notes.md"), "# notes\n") + fs.writeFileSync(path.join(claudeDir, "tmp", "should-stay.txt"), "keep") + }) + ) + + const codexFiles = collectSessionFiles(codexDir, ".codex", false) + const claudeFiles = collectSessionFiles(claudeDir, ".claude", false) + const logicalNames = [...codexFiles, ...claudeFiles] + .map((file) => file.logicalName) + .toSorted((left, right) => left.localeCompare(right)) + + yield* _( + Effect.sync(() => { + expect(logicalNames).toContain(".codex/history.jsonl") + expect(logicalNames).toContain(".codex/memory/notes.md") + expect(logicalNames).toContain(".claude/tmp/should-stay.txt") + expect(logicalNames.some((name) => name.startsWith(".codex/tmp/"))).toBe(false) + }) + ) + }) + )) + + it.effect("marks only targeted transient .codex tmp paths for exclusion", () => + Effect.sync(() => { + expect(shouldIgnoreSessionPath(".codex", "tmp")).toBe(true) + expect(shouldIgnoreSessionPath(".codex", "tmp/run/.lock")).toBe(true) + expect(shouldIgnoreSessionPath(".codex", "history.jsonl")).toBe(false) + expect(shouldIgnoreSessionPath(".claude", "tmp/run/.lock")).toBe(false) + })) +}) diff --git a/scripts/session-backup-gist.d.ts b/scripts/session-backup-gist.d.ts new file mode 100644 index 0000000..6804e5f --- /dev/null +++ b/scripts/session-backup-gist.d.ts @@ -0,0 +1,23 @@ +export type SessionBackupFile = { + readonly logicalName: string + readonly sourcePath: string + readonly size: number +} + +export declare const collectSessionFiles: ( + dirPath: string, + baseName: string, + verbose: boolean +) => ReadonlyArray + +export declare const shouldIgnoreSessionPath: ( + baseName: string, + relativePath: string +) => boolean + +declare const sessionBackupGist: { + readonly collectSessionFiles: typeof collectSessionFiles + readonly shouldIgnoreSessionPath: typeof shouldIgnoreSessionPath +} + +export default sessionBackupGist diff --git a/scripts/session-backup-gist.js b/scripts/session-backup-gist.js index 738d9a3..492e565 100644 --- a/scripts/session-backup-gist.js +++ b/scripts/session-backup-gist.js @@ -41,6 +41,9 @@ const { } = require("./session-backup-repo.js"); const SESSION_DIR_NAMES = [".codex", ".claude", ".qwen", ".gemini"]; +const TRANSIENT_SESSION_PREFIXES = { + ".codex": ["tmp"], +}; const isPathWithinParent = (targetPath, parentPath) => { const relative = path.relative(parentPath, targetPath); @@ -71,6 +74,16 @@ const resolveAllowedSessionDir = (candidatePath, verbose) => { return null; }; +const toLogicalRelativePath = (relativePath) => + relativePath.split(path.sep).join(path.posix.sep); + +const shouldIgnoreSessionPath = (baseName, relativePath) => { + const prefixes = TRANSIENT_SESSION_PREFIXES[baseName] ?? []; + const logicalPath = toLogicalRelativePath(relativePath); + + return prefixes.some((prefix) => logicalPath === prefix || logicalPath.startsWith(`${prefix}/`)); +}; + const parseArgs = () => { const args = process.argv.slice(2); const result = { @@ -364,6 +377,12 @@ const collectSessionFiles = (dirPath, baseName, verbose) => { for (const entry of entries) { const fullPath = path.join(currentPath, entry.name); const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; + const logicalRelPath = toLogicalRelativePath(relPath); + + if (shouldIgnoreSessionPath(baseName, logicalRelPath)) { + log(verbose, `Skipping transient path: ${path.posix.join(baseName, logicalRelPath)}`); + continue; + } if (entry.isDirectory()) { if (entry.name === "node_modules" || entry.name === ".git") { @@ -373,7 +392,7 @@ const collectSessionFiles = (dirPath, baseName, verbose) => { } else if (entry.isFile()) { try { const stats = fs.statSync(fullPath); - const logicalName = path.posix.join(baseName, relPath.split(path.sep).join(path.posix.sep)); + const logicalName = path.posix.join(baseName, logicalRelPath); files.push({ logicalName, sourcePath: fullPath, @@ -661,4 +680,11 @@ const main = () => { } }; -main(); +if (require.main === module) { + main(); +} + +module.exports = { + collectSessionFiles, + shouldIgnoreSessionPath, +};