paperclipai/paperclip · error

Invalid project repositories directory

Error message

Invalid project repositories directory

What it means

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.

Solutions

  1. Inspect `<localDir>/project-repositories` with `ls -la` and check whether it is a file or symlink (`file`, `readlink`).
  2. Remove the file or symlink: `rm <localDir>/project-repositories` (or `rm <localDir>/project-repositories && mkdir <localDir>/project-repositories` if it should exist).
  3. Re-create the workspace via the normal workspace-sync flow so repositories are re-cloned into a genuine directory.
  4. If a symlink to a shared clone cache is intentional, restructure: mount/bind or configure the workspace root instead of symlinking inside it.

Example fix

// before (workspace layout)
project-repositories -> /var/cache/shared-clones   # symlink: rejected
// after
rm project-repositories
mkdir project-repositories
# let workspace sync clone repositories into the real directory
Defensive patterns

Strategy: validation

Validate before calling

import fs from "node:fs/promises";
async function assertPlainDir(p: string) {
  const st = await fs.lstat(p);
  if (!st.isDirectory() || st.isSymbolicLink()) throw new Error(`Not a plain directory: ${p}`);
}
await assertPlainDir(path.join(localDir, PROJECT_REPOSITORIES_DIR));

Type guard

function isPlainDirectoryStat(st: fs.Stats): boolean {
  return st.isDirectory() && !st.isSymbolicLink();
}

Try / catch

try {
  await snapshot(localDir);
} catch (err) {
  if (err instanceof Error && err.message === "Invalid project repositories directory") {
    // repair: remove file/symlink and re-run workspace sync
  } else throw err;
}

Prevention

When it happens

Trigger: 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).

Common situations: 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.

Related errors


AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18). Data as JSON: /api/errors/5db3c716c9b9b47f. Report an issue: GitHub.

Appendix: source

Thrown at packages/adapter-utils/src/git-workspace-sync.ts:152

      operation,
      timeout: options.timeout,
      maxBuffer: options.maxBuffer,
      env: options.env,
    });
  }
  return await runLocalGit(localDir, args, options);
}

export async function readGitWorkspaceSnapshot(localDir: string, includeRepositories = true): Promise<GitWorkspaceSnapshot | null> {
  const repositories: NonNullable<GitWorkspaceSnapshot["repositories"]> = [];
  if (includeRepositories) {
    const root = path.join(localDir, PROJECT_REPOSITORIES_DIR);
    const rootStat = await fs.lstat(root).catch((error: NodeJS.ErrnoException) => {
      if (error.code === "ENOENT") return null;
      throw error;
    });
    if (rootStat) {
      if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error("Invalid project repositories directory");
      for (const entry of (await fs.readdir(root, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
        if (!entry.isDirectory() || !/^[a-zA-Z0-9_-]+$/.test(entry.name)) throw new Error("Invalid project repository directory");
        const relative = `${PROJECT_REPOSITORIES_DIR}/${entry.name}`;
        const snapshot = await readGitWorkspaceSnapshot(path.join(localDir, relative), false);
        if (!snapshot) throw new Error(`Project repository is not a Git checkout: ${relative}`);
        repositories.push({ path: relative, snapshot });
      }
    }
  }
  // Only repository discovery may report an ordinary directory. A failed
  // snapshot of a confirmed repository must never fall back to directory sync.
  let insideWorkTree: GitCommandResult;
  try {
    insideWorkTree = await runLocalGit(localDir, ["rev-parse", "--is-inside-work-tree"], {
      timeout: 10_000,
      maxBuffer: 16 * 1024,
    });
  } catch (error) {

View on GitHub (pinned to 3f1d897a7c)