fix(history): never treat the empty split segment as a directory name

Follow-up to #202. The dotdir decode landed there was reachable only when
nothing else matched first, and in the greedy half it was not reachable at all.

decodeProjectKey() splits the project key on '-', so the '/.' that the encoder
collapses leaves an EMPTY segment behind. Both loops offered that empty string
as a candidate directory name, and isDir(current + '/' + '') stats current + '/',
which always succeeds. So the empty segment matched unconditionally:

  - backtracking half: ~/.sib resolved to "/home/x//sib" whenever a non-dot
    sibling ~/sib existed (wrong directory, and a doubled slash that then fails
    every string comparison against session.workingDir). Without a sibling it
    only backtracked out by luck.
  - greedy half: that loop is shortest-match-first, so the empty candidate
    matched on the FIRST iteration and set matched=true, leaving #202's dotdir
    branch permanently dead there.

An empty string is never a real path component, so skip it in both loops. The
unmatched tail then has to handle the empty segment too, or it would append a
bare '/' and re-introduce the '//' path it just stopped producing; it now emits
the dotdir guess instead, which is what the encoder implies.

Regression test asserts both halves: the dotdir wins over the non-dot sibling,
and the result never contains '//'. Verified it fails on #202 as merged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-05 01:34:16 +02:00
parent 45ae9f4064
commit c15b8345b5
2 changed files with 54 additions and 4 deletions
+25 -4
View File
@@ -2551,7 +2551,12 @@ export function registerSessionRoutes(
for (let end = maxLook - 1; end >= idx; end--) {
const candidates: string[] = [];
if (end === idx) {
candidates.push(segments[idx]);
// Skip an EMPTY segment: `isDir(current + '/' + '')` stats `current + '/'`,
// which always succeeds, so the empty candidate would match unconditionally
// and swallow the doubled dash that is the whole signature of a dotdir. It
// then resolves "/home/x/.sib" to "/home/x//sib" whenever a non-dot sibling
// exists, and shadows the dotdir branch below in every other case.
if (segments[idx] !== '') candidates.push(segments[idx]);
} else {
candidates.push(segments.slice(idx, end + 1).join('-'));
candidates.push(segments.slice(idx, end + 1).join('_'));
@@ -2600,7 +2605,10 @@ export function registerSessionRoutes(
for (let end = i; end < maxLook; end++) {
const candidates: string[] = [];
if (end === i) {
candidates.push(segments[i]);
// Same empty-segment skip as tryDecode above. This loop is shortest-match
// first, so without it the empty candidate matches on the very first try
// and sets `matched`, leaving the dotdir branch below permanently dead.
if (segments[i] !== '') candidates.push(segments[i]);
} else {
candidates.push(segments.slice(i, end + 1).join('_'));
candidates.push(segments.slice(i, end + 1).join('-'));
@@ -2636,8 +2644,21 @@ export function registerSessionRoutes(
}
}
if (!matched) {
current = current + '/' + segments[i];
i++;
if (segments[i] === '') {
// Nothing on disk matched (the usual reason this fallback runs at all is
// that the directory was deleted). An empty segment still means the
// encoder ate a literal '.', so guess the dotdir form rather than
// appending a bare '/' and emitting a "//" path.
if (i + 1 < segments.length) {
current = current + '/.' + segments[i + 1];
i += 2;
} else {
i++;
}
} else {
current = current + '/' + segments[i];
i++;
}
}
}
const finalExists = await fs
+29
View File
@@ -1321,6 +1321,35 @@ describe('session-routes', () => {
expect(row.workingDir).toBe(realDir);
expect(row.workingDir).not.toBe(home);
});
it('prefers the dotdir over a same-named non-dot sibling, and never emits a "//" path', async () => {
// A doubled dash also lets the decoder read the empty split segment as a
// directory NAME. `isDir(current + '/' + '')` stats `current + '/'`, which
// always succeeds, so `~/.sib` + `~/sib` both existing used to resolve to
// "/home/x//sib": the wrong directory, spelled with a double slash that
// then fails every string comparison against session.workingDir. The empty
// candidate is never a real path component, so it is skipped outright,
// which is also what lets the dotdir branch below it run at all.
const home = process.env.HOME as string;
const dotDir = join(home, '.sib');
await mkdir(dotDir, { recursive: true });
await mkdir(join(home, 'sib'), { recursive: true });
const projectKey = dotDir.replace(/\//g, '-').replace(/\./g, '-');
const projDir = join(home, '.claude', 'projects', projectKey);
await mkdir(projDir, { recursive: true });
const sessionId = '22222222-2222-2222-2222-222222222222';
const line = JSON.stringify({ type: 'user', message: { role: 'user', content: 'hello world' } }) + '\n';
await writeFile(join(projDir, `${sessionId}.jsonl`), line + '#'.repeat(4200 - line.length));
const res = await harness.app.inject({ method: 'GET', url: `/api/history/sessions?projectKey=${projectKey}` });
expect(res.statusCode).toBe(200);
const row = JSON.parse(res.body).data.sessions.find((s: { sessionId: string }) => s.sessionId === sessionId);
expect(row).toBeDefined();
expect(row.workingDir).toBe(dotDir);
expect(row.workingDir).not.toContain('//');
});
});
// ========== POST /api/sessions (with resumeSessionId) ==========