{"record":{"id":"5db3c716c9b9b47f","repo":"paperclipai/paperclip","slug":"invalid-project-repositories-directory","errorCode":null,"errorMessage":"Invalid project repositories directory","messagePattern":"Invalid project repositories directory","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/adapter-utils/src/git-workspace-sync.ts","lineNumber":152,"sourceCode":"      operation,\n      timeout: options.timeout,\n      maxBuffer: options.maxBuffer,\n      env: options.env,\n    });\n  }\n  return await runLocalGit(localDir, args, options);\n}\n\nexport async function readGitWorkspaceSnapshot(localDir: string, includeRepositories = true): Promise<GitWorkspaceSnapshot | null> {\n  const repositories: NonNullable<GitWorkspaceSnapshot[\"repositories\"]> = [];\n  if (includeRepositories) {\n    const root = path.join(localDir, PROJECT_REPOSITORIES_DIR);\n    const rootStat = await fs.lstat(root).catch((error: NodeJS.ErrnoException) => {\n      if (error.code === \"ENOENT\") return null;\n      throw error;\n    });\n    if (rootStat) {\n      if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error(\"Invalid project repositories directory\");\n      for (const entry of (await fs.readdir(root, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {\n        if (!entry.isDirectory() || !/^[a-zA-Z0-9_-]+$/.test(entry.name)) throw new Error(\"Invalid project repository directory\");\n        const relative = `${PROJECT_REPOSITORIES_DIR}/${entry.name}`;\n        const snapshot = await readGitWorkspaceSnapshot(path.join(localDir, relative), false);\n        if (!snapshot) throw new Error(`Project repository is not a Git checkout: ${relative}`);\n        repositories.push({ path: relative, snapshot });\n      }\n    }\n  }\n  // Only repository discovery may report an ordinary directory. A failed\n  // snapshot of a confirmed repository must never fall back to directory sync.\n  let insideWorkTree: GitCommandResult;\n  try {\n    insideWorkTree = await runLocalGit(localDir, [\"rev-parse\", \"--is-inside-work-tree\"], {\n      timeout: 10_000,\n      maxBuffer: 16 * 1024,\n    });\n  } catch (error) {","sourceCodeStart":134,"sourceCodeEnd":170,"githubUrl":"https://github.com/paperclipai/paperclip/blob/3f1d897a7c018d76563a21c6e39c3c9b03933622/packages/adapter-utils/src/git-workspace-sync.ts#L134-L170","documentation":"readGitWorkspaceSnapshot scans the `project-repositories` directory inside a workspace and snapshots each repository. Before enumerating, it lstats that root directory and requires it to be a real directory (not a symlink, not a file). If it exists but is not a plain directory, the library throws this error because the workspace layout is corrupt or has been tampered with.","triggerScenarios":"Calling snapshot()/gitSnapshot() on a workspace localDir where `<localDir>/project-repositories` exists but is a regular file, or is a symbolic link (symlinks are explicitly rejected to prevent path escape).","commonSituations":"A file was accidentally created named `project-repositories`; a setup script symlinked the repositories dir to a shared cache or another volume; a restore/backup tool replaced the directory with a symlink; container image layers substituted the path.","solutions":["Inspect `<localDir>/project-repositories` with `ls -la` and check whether it is a file or symlink (`file`, `readlink`).","Remove the file or symlink: `rm <localDir>/project-repositories` (or `rm <localDir>/project-repositories && mkdir <localDir>/project-repositories` if it should exist).","Re-create the workspace via the normal workspace-sync flow so repositories are re-cloned into a genuine directory.","If a symlink to a shared clone cache is intentional, restructure: mount/bind or configure the workspace root instead of symlinking inside it."],"exampleFix":"// before (workspace layout)\nproject-repositories -> /var/cache/shared-clones   # symlink: rejected\n// after\nrm project-repositories\nmkdir project-repositories\n# let workspace sync clone repositories into the real directory","handlingStrategy":"validation","validationCode":"import fs from \"node:fs/promises\";\nasync function assertPlainDir(p: string) {\n  const st = await fs.lstat(p);\n  if (!st.isDirectory() || st.isSymbolicLink()) throw new Error(`Not a plain directory: ${p}`);\n}\nawait assertPlainDir(path.join(localDir, PROJECT_REPOSITORIES_DIR));","typeGuard":"function isPlainDirectoryStat(st: fs.Stats): boolean {\n  return st.isDirectory() && !st.isSymbolicLink();\n}","tryCatchPattern":"try {\n  await snapshot(localDir);\n} catch (err) {\n  if (err instanceof Error && err.message === \"Invalid project repositories directory\") {\n    // repair: remove file/symlink and re-run workspace sync\n  } else throw err;\n}","preventionTips":["Never symlink or mount anything named project-repositories inside a synced workspace.","Provision workspaces only through the library's own sync flow.","Add a startup health check that lstats the repositories root.","Document the no-symlink invariant for backup/restore tooling."],"tags":["filesystem","git","workspace-sync","validation"],"backgroundTag":"path-is-not-a-directory","analyzedSha":"3f1d897a7c018d76563a21c6e39c3c9b03933622","analyzedAt":"2026-09-18T08:03:59.046Z","contentChangedAt":"2026-09-18T08:03:59.046Z","schemaVersion":2},"datasetVersion":"2026-09-22T06:17:15.046Z"}