* Recursively collect files from a directory for zipping. * Uses lstat to detect symlinks and tracks visited inodes for cycle detection.
( baseDir: string, relativePath: string, files: Record<string, ZipEntry>, visited: Set<string>, )
| 233 | * Uses lstat to detect symlinks and tracks visited inodes for cycle detection. |
| 234 | */ |
| 235 | async function collectFilesForZip( |
| 236 | baseDir: string, |
| 237 | relativePath: string, |
| 238 | files: Record<string, ZipEntry>, |
| 239 | visited: Set<string>, |
| 240 | ): Promise<void> { |
| 241 | const currentDir = relativePath ? join(baseDir, relativePath) : baseDir |
| 242 | let entries: string[] |
| 243 | try { |
| 244 | entries = await readdir(currentDir) |
| 245 | } catch { |
| 246 | return |
| 247 | } |
| 248 | |
| 249 | // Track visited directories by dev+ino to detect symlink cycles. |
| 250 | // bigint: true is required — on Windows NTFS, the file index packs a 16-bit |
| 251 | // sequence number into the high bits. Once that sequence exceeds ~32 (very |
| 252 | // common on a busy CI runner that churns through temp files), the value |
| 253 | // exceeds Number.MAX_SAFE_INTEGER and two adjacent directories round to the |
| 254 | // same JS number, causing subdirs to be silently skipped as "cycles". This |
| 255 | // broke the round-trip test on Windows CI when sharding shuffled which tests |
| 256 | // ran first and pushed MFT sequence numbers over the precision cliff. |
| 257 | // See also: markdownConfigLoader.ts getFileIdentity, anthropics/claude-code#13893 |
| 258 | try { |
| 259 | const dirStat = await stat(currentDir, { bigint: true }) |
| 260 | // ReFS (Dev Drive), NFS, some FUSE mounts report dev=0 and ino=0 for |
| 261 | // everything. Fail open: skip cycle detection rather than skip the |
| 262 | // directory. We already skip symlinked directories unconditionally below, |
| 263 | // so the only cycle left here is a bind mount, which we accept. |
| 264 | if (dirStat.dev !== 0n || dirStat.ino !== 0n) { |
| 265 | const key = `${dirStat.dev}:${dirStat.ino}` |
| 266 | if (visited.has(key)) { |
| 267 | logForDebugging(`Skipping symlink cycle at ${currentDir}`) |
| 268 | return |
| 269 | } |
| 270 | visited.add(key) |
| 271 | } |
| 272 | } catch { |
| 273 | return |
| 274 | } |
| 275 | |
| 276 | for (const entry of entries) { |
| 277 | // Skip hidden files that are git-related |
| 278 | if (entry === '.git') { |
| 279 | continue |
| 280 | } |
| 281 | |
| 282 | const fullPath = join(currentDir, entry) |
| 283 | const relPath = relativePath ? `${relativePath}/${entry}` : entry |
| 284 | |
| 285 | let fileStat |
| 286 | try { |
| 287 | fileStat = await lstat(fullPath) |
| 288 | } catch { |
| 289 | continue |
| 290 | } |
| 291 | |
| 292 | // Skip symlinked directories (follow symlinked files) |
no test coverage detected