Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/list-skips-unresolvable-entries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fission-ai/openspec': patch
---

Stop one unresolvable file from breaking `openspec list`. To sort changes by recency, `list` stats every file inside each change, and any entry it could not stat failed the whole command: a dangling symlink, such as the `.#tasks.md` lock Emacs keeps beside every file with unsaved edits, or a symlink loop made `list` exit 1 and `list --json` report `"changes": []`, so agents discovering work through it saw no changes at all. An entry that no longer resolves (removed mid-walk, a dangling symlink, or a loop) is now skipped when computing a change's last-modified time. Valid symlinks are dated as before, and any other error, such as a permission failure, still fails the listing.
28 changes: 22 additions & 6 deletions src/core/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ function isMissingPathError(error: unknown): boolean {
);
}

/**
* An entry that cannot be dated because it no longer resolves: it was removed
* after `readdir` listed it, or it is a symlink whose target is missing (an
* Emacs `.#file` lock) or that loops back on itself.
*/
function isUnresolvableEntryError(error: unknown): boolean {
if (typeof error !== 'object' || error === null || !('code' in error)) return false;
const code = (error as NodeJS.ErrnoException).code;
return code === 'ENOENT' || code === 'ELOOP';
}

async function readChangeDirectoryEntries(changesDir: string): Promise<Dirent[]> {
try {
return await fs.readdir(changesDir, { withFileTypes: true });
Expand All @@ -48,13 +59,18 @@ async function getLastModified(dirPath: string): Promise<Date> {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else {
const stat = await fs.stat(fullPath);
if (latest === null || stat.mtime > latest) {
latest = stat.mtime;
try {
if (entry.isDirectory()) {
await walk(fullPath);
} else {
const stat = await fs.stat(fullPath);
if (latest === null || stat.mtime > latest) {
latest = stat.mtime;
}
}
} catch (error) {
// Skip the one entry rather than fail the listing of every change.
if (!isUnresolvableEntryError(error)) throw error;
}
}
}
Expand Down
102 changes: 102 additions & 0 deletions test/core/list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { ListCommand } from '../../src/core/list.js';
import { runCLI } from '../helpers/run-cli.js';

describe('ListCommand', () => {
let tempDir: string;
Expand Down Expand Up @@ -187,4 +188,105 @@ Regular text that should be ignored
expect(logOutput.some(line => line.includes('no-tasks') && line.includes('No tasks'))).toBe(true);
});
});

describe('entries that cannot be stat-ed', () => {
// Emacs drops a `.#<file>` lock symlink, pointing at a nonexistent target,
// next to every file with unsaved edits. One such entry used to fail the
// whole listing with ENOENT, so `list --json` reported zero changes.
async function writeChange(name: string): Promise<string> {
const changeDir = path.join(tempDir, 'openspec', 'changes', name);
await fs.mkdir(changeDir, { recursive: true });
await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n- [ ] Task 2\n');
return changeDir;
}

async function listJson(): Promise<Array<{ name: string; completedTasks: number; totalTasks: number }>> {
await new ListCommand().execute(tempDir, 'changes', { json: true });
return JSON.parse(logOutput.join('\n')).changes;
}

it.skipIf(process.platform === 'win32')('lists every change while a dangling symlink sits inside one', async () => {
const changeDir = await writeChange('locked-change');
await writeChange('other-change');
await fs.symlink('user@host.4242:1789000000', path.join(changeDir, '.#tasks.md'));

const changes = await listJson();

expect(changes.map(change => change.name).sort()).toEqual(['locked-change', 'other-change']);
expect(changes.find(change => change.name === 'locked-change')).toMatchObject({
completedTasks: 1,
totalTasks: 2,
});
});

it.skipIf(process.platform === 'win32')('lists a change holding a dangling symlink in a nested directory', async () => {
const changeDir = await writeChange('nested-lock');
await fs.mkdir(path.join(changeDir, 'specs', 'billing'), { recursive: true });
await fs.symlink('missing-target', path.join(changeDir, 'specs', 'billing', '.#spec.md'));

expect((await listJson()).map(change => change.name)).toEqual(['nested-lock']);
});

it.skipIf(process.platform === 'win32')('lists a change holding a symlink loop', async () => {
const changeDir = await writeChange('loop-change');
await fs.symlink('loop-b', path.join(changeDir, 'loop-a'));
await fs.symlink('loop-a', path.join(changeDir, 'loop-b'));

expect((await listJson()).map(change => change.name)).toEqual(['loop-change']);
});

it.skipIf(process.platform === 'win32')('still lists and dates a change holding a valid symlink', async () => {
const changeDir = await writeChange('linked-change');
const target = path.join(changeDir, 'notes.md');
await fs.writeFile(target, 'notes\n');
const future = new Date('2099-01-01T00:00:00.000Z');
await fs.utimes(target, future, future);
await fs.symlink('notes.md', path.join(changeDir, 'notes-link.md'));

await new ListCommand().execute(tempDir, 'changes', { json: true });
const [change] = JSON.parse(logOutput.join('\n')).changes;

expect(change.lastModified).toBe(future.toISOString());
});

// Permissions are not enforced on Windows or for root.
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
'still fails on a permission error rather than hiding it',
async () => {
const changeDir = await writeChange('locked-dir');
const unreadable = path.join(changeDir, 'specs');
await fs.mkdir(unreadable);
await fs.chmod(unreadable, 0o000);
try {
await expect(new ListCommand().execute(tempDir, 'changes', { json: true })).rejects.toMatchObject({
code: 'EACCES',
});
} finally {
await fs.chmod(unreadable, 0o755);
}
}
);

it.skipIf(process.platform === 'win32')('keeps list --json working end to end', async () => {
const changeDir = await writeChange('cli-change');
await fs.mkdir(path.join(tempDir, 'openspec', 'specs'), { recursive: true });
await fs.symlink('user@host.4242:1789000000', path.join(changeDir, '.#tasks.md'));
const home = path.join(tempDir, 'home');
await fs.mkdir(home, { recursive: true });

const result = await runCLI(['list', '--json'], {
cwd: tempDir,
env: {
HOME: home,
XDG_CONFIG_HOME: path.join(home, '.config'),
XDG_DATA_HOME: path.join(home, '.local', 'share'),
},
});

expect(result.exitCode).toBe(0);
expect(JSON.parse(result.stdout).changes.map((change: { name: string }) => change.name)).toEqual([
'cli-change',
]);
}, 60_000);
});
});
Loading