Skip to content

Conversation

@CosticaPuntaru
Copy link

@CosticaPuntaru CosticaPuntaru commented Jan 27, 2026

On Windows, openspec archive could fail with:

Error: EPERM: operation not permitted, rename '...\openspec\changes\<change>' -> '...\openspec\changes\archive\<date>-<change>'

even when there is no real permission issue and the same move works in PowerShell.

Cause

fs.rename() often throws EPERM for non-empty directories on Windows (and EXDEV across devices). The CLI now treats EPERM/EXDEV as non-fatal and falls back to copy-then-remove: recursively copy the change dir into the archive path, then fs.rm(src, { recursive: true, force: true }).

Changes

  • moveDirectory() in src/core/archive.ts: try fs.rename; on EPERM/EXDEV use copyDirRecursive + fs.rm.

Testing

  • Existing test/core/archive.test.ts exercises the archive path; the new behavior is used whenever rename throws EPERM/EXDEV.
  • Manual: run openspec archive <change> --yes on Windows in a repo where it previously failed; it should complete and the change should appear under openspec/changes/archive/.

Fixes #197

Summary by CodeRabbit

  • Bug Fixes
    • Enhanced directory archiving and moving operations with improved fallback mechanisms for handling edge cases. The system now gracefully manages file operation failures that may occur across different platforms and environments, providing better reliability and ensuring consistent performance when archiving or managing directories.

✏️ Tip: You can customize this high-level summary in your review settings.

…AI#197)

On Windows, fs.rename() often fails with EPERM when moving non-empty
directories. Fall back to recursive copy then rm when rename throws
EPERM or EXDEV so 'openspec archive' succeeds where Move-Item works.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 27, 2026

📝 Walkthrough

Walkthrough

Adds two internal helper functions to src/core/archive.ts: copyDirRecursive() for recursive directory copying and moveDirectory() for directory relocation with a fallback mechanism. Replaces direct fs.rename() calls with moveDirectory() to gracefully handle EPERM and EXDEV errors by attempting a copy-then-remove strategy before re-throwing other errors.

Changes

Cohort / File(s) Summary
Directory Move Fallback Logic
src/core/archive.ts
Added copyDirRecursive() helper for recursive directory traversal and copying. Added moveDirectory() helper that attempts rename operation and falls back to copy-then-delete on EPERM or EXDEV errors. Updated archive operation to use moveDirectory() instead of fs.rename().

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~15 minutes

Poem

A rabbit's fix for Windows woes, 🐰
Where rename fails and error grows,
Now copy-fallback saves the day,
Archives hop the proper way! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly references the main change: handling EPERM/EXDEV errors in archive by falling back to copy+remove, and explicitly links to issue #197.
Linked Issues check ✅ Passed The PR successfully addresses issue #197 by implementing fallback copy+remove logic for EPERM/EXDEV errors during directory rename operations, resolving Windows archive failures.
Out of Scope Changes check ✅ Passed All changes are scoped to src/core/archive.ts with new helpers (copyDirRecursive, moveDirectory) and updated archive logic, directly addressing the rename failure issue without unrelated modifications.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link

greptile-apps bot commented Jan 27, 2026

Greptile Overview

Greptile Summary

This PR fixes a Windows-specific issue where openspec archive fails with EPERM errors even when permissions are correct. The fix adds a moveDirectory helper function that attempts fs.rename first, then falls back to copy-then-remove for EPERM/EXDEV errors (common on Windows for non-empty directories and cross-device moves).

Key Changes:

  • Added copyDirRecursive helper to recursively copy directories
  • Added moveDirectory wrapper that tries fs.rename and falls back to copy+remove on EPERM/EXDEV
  • Replaced direct fs.rename call with moveDirectory in the archive flow

Implementation Quality:
The implementation is clean and follows the pattern used by many file utilities. The fallback mechanism properly handles the error cases without silently masking other errors. The existing test suite provides coverage, though it doesn't explicitly test the EPERM fallback path (which is difficult to simulate reliably in tests).

Minor Consideration:
The copyDirRecursive function doesn't preserve symlinks (it follows and copies their targets instead). This is likely acceptable for the use case, but could be enhanced if symlinks in change directories are expected.

Confidence Score: 4/5

  • This PR is safe to merge with minimal risk - addresses a specific Windows bug with a well-established fallback pattern
  • The implementation uses a standard fallback approach for handling fs.rename failures. The code is straightforward, error handling is appropriate, and existing tests provide coverage of the archive workflow. One minor consideration is symlink handling, but this is unlikely to be an issue in practice for this use case.
  • No files require special attention

Important Files Changed

Filename Overview
src/core/archive.ts Added fallback copy+remove for fs.rename failures with EPERM/EXDEV errors, properly handling Windows directory move issues

Sequence Diagram

sequenceDiagram
    participant User
    participant ArchiveCommand
    participant moveDirectory
    participant fs
    participant copyDirRecursive

    User->>ArchiveCommand: execute(changeName)
    ArchiveCommand->>ArchiveCommand: Validate change exists
    ArchiveCommand->>ArchiveCommand: Create archive path
    ArchiveCommand->>moveDirectory: moveDirectory(changeDir, archivePath)
    
    moveDirectory->>fs: rename(src, dest)
    
    alt Rename succeeds
        fs-->>moveDirectory: Success
        moveDirectory-->>ArchiveCommand: Directory moved
    else Rename fails with EPERM/EXDEV
        fs-->>moveDirectory: Error (EPERM or EXDEV)
        moveDirectory->>copyDirRecursive: copyDirRecursive(src, dest)
        copyDirRecursive->>fs: mkdir(dest, recursive)
        copyDirRecursive->>fs: readdir(src, withFileTypes)
        loop For each entry
            alt Entry is directory
                copyDirRecursive->>copyDirRecursive: copyDirRecursive(srcPath, destPath)
            else Entry is file
                copyDirRecursive->>fs: copyFile(srcPath, destPath)
            end
        end
        copyDirRecursive-->>moveDirectory: Copy complete
        moveDirectory->>fs: rm(src, recursive, force)
        fs-->>moveDirectory: Source removed
        moveDirectory-->>ArchiveCommand: Directory moved via copy+remove
    else Other error
        fs-->>moveDirectory: Error (other)
        moveDirectory-->>ArchiveCommand: Throw error
    end
    
    ArchiveCommand-->>User: Archive complete
Loading

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 file reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +16 to +28
async function copyDirRecursive(src: string, dest: string): Promise<void> {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirRecursive(srcPath, destPath);
} else {
await fs.copyFile(srcPath, destPath);
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider handling symlinks - copyFile follows symlinks rather than copying them, which could lead to unexpected behavior if the change directory contains symlinks. Use fs.lstat instead of checking entry.isDirectory() and handle symlinks with fs.symlink to preserve them.

Suggested change
async function copyDirRecursive(src: string, dest: string): Promise<void> {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirRecursive(srcPath, destPath);
} else {
await fs.copyFile(srcPath, destPath);
}
}
}
async function copyDirRecursive(src: string, dest: string): Promise<void> {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isSymbolicLink()) {
const target = await fs.readlink(srcPath);
await fs.symlink(target, destPath);
} else if (entry.isDirectory()) {
await copyDirRecursive(srcPath, destPath);
} else {
await fs.copyFile(srcPath, destPath);
}
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/archive.ts
Line: 16:28

Comment:
Consider handling symlinks - `copyFile` follows symlinks rather than copying them, which could lead to unexpected behavior if the change directory contains symlinks. Use `fs.lstat` instead of checking `entry.isDirectory()` and handle symlinks with `fs.symlink` to preserve them.

```suggestion
async function copyDirRecursive(src: string, dest: string): Promise<void> {
  await fs.mkdir(dest, { recursive: true });
  const entries = await fs.readdir(src, { withFileTypes: true });
  for (const entry of entries) {
    const srcPath = path.join(src, entry.name);
    const destPath = path.join(dest, entry.name);
    if (entry.isSymbolicLink()) {
      const target = await fs.readlink(srcPath);
      await fs.symlink(target, destPath);
    } else if (entry.isDirectory()) {
      await copyDirRecursive(srcPath, destPath);
    } else {
      await fs.copyFile(srcPath, destPath);
    }
  }
}
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think handling for symlinks makes sense here mr greptile

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. For the specific use case of archiving OpenSpec changes, symlink handling would be overkill. The changes directory typically contains straightforward files and folders - handling symlinks would add unnecessary complexity for a scenario that's unlikely to occur in practice.

The current implementation with copyFile and directory recursion is appropriate for the archive functionality.

@vibe-kanban-cloud
Copy link

Review Complete

Your review story is ready!

View Story

Comment !reviewfast on this PR to re-generate the story.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants