mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Closes #266. Sessions from different worktrees of the same repo were indistinguishable in the Resume list, Cmd+K and search — the row showed a session name and a case label, nothing about which worktree it ran in. Claude Code already stamps "cwd" and "gitBranch" on every user/assistant record, and writes a worktree-state record naming the worktree when the session was started through its own worktree feature. scanProjectDir() already buffers the head of every transcript for prompt extraction, so extractTranscriptGitInfo() parses buffers that are already in memory: no extra file reads, no git subprocess. (Measured on this machine: a git rev-parse per directory costs 482ms for 35 rows; parsing the existing buffers costs nothing.) cwd is taken from the first record that carries it, since a session's cwd does not move. gitBranch is taken from the last, since a branch genuinely changes mid-session. The badge requires a worktree NAME. An earlier revision rendered whenever a branch was known, which put a badge on all 35 rows of a real history -- "master" on every ordinary session, burying the ten rows the badge exists to distinguish. A hand-made `git worktree add` therefore gets no badge rather than a guessed name; Claude's own <repo>/.claude/worktrees/<name> layout is recognised from the path when no worktree-state record is present. worktreeName and gitBranch join the filterAndPaginate haystack so the session manager can search by them. panels-ui re-projects the unified item into a 5-field record before rendering, so the new fields are carried there explicitly -- omitting that silently drops them from Cmd+K only. Also prefers the transcript cwd over decodeProjectKey()'s stat-walked guess, which falls back to $HOME when nothing resolves (#265). Note that path is currently LATENT, not active: on the install this was developed against, every project key whose directory is gone has zero transcripts and so produces no row at all. The transcript value is used because it is authoritative and non-lossy, not because a live bug was reproduced. Verified against a real 35-session history on an isolated CODEMAN_INSTANCE: 10 of 36 rows badged, history row count unchanged at 35 (nothing dropped), no page errors. 129 tests pass across the new suite plus the unified service, unified route and session route suites. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uTqt8ttmsBLXbm5JFHis3
103 lines
4.4 KiB
TypeScript
103 lines
4.4 KiB
TypeScript
/**
|
|
* @fileoverview Worktree/branch identity on session rows (#265, #266).
|
|
*
|
|
* Two behaviours are pinned here:
|
|
* - the unified merge carries gitBranch/worktreeName/worktreeRepo through from
|
|
* the history source, and filterAndPaginate can search them;
|
|
* - the client-side badge helper renders `⑂ name · branch`, and stays SILENT
|
|
* when only a branch is known (a branch is not a worktree — badging those
|
|
* would put `⑂ master` on every ordinary session).
|
|
*
|
|
* The transcript extractor itself lives inside a closure in session-routes.ts
|
|
* and is covered by the route tests; what matters at this level is that the
|
|
* fields survive the merge and reach a label.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
mergeUnifiedSessions,
|
|
filterAndPaginate,
|
|
type UnifiedSessionItem,
|
|
} from '../src/services/unified-session-service.js';
|
|
|
|
const historyRow = (over: Record<string, unknown> = {}) => ({
|
|
sessionId: 's1',
|
|
workingDir: '/repo/.claude/worktrees/autodev',
|
|
sizeBytes: 9000,
|
|
lastModified: '2026-01-01T00:00:00.000Z',
|
|
...over,
|
|
});
|
|
|
|
describe('worktree fields through the unified merge (#266)', () => {
|
|
it('carries gitBranch / worktreeName / worktreeRepo from the history source', () => {
|
|
const merged = mergeUnifiedSessions({
|
|
history: [historyRow({ gitBranch: 'feat/CF-195', worktreeName: 'autodev', worktreeRepo: '/repo' })],
|
|
});
|
|
expect(merged).toHaveLength(1);
|
|
expect(merged[0].worktreeName).toBe('autodev');
|
|
expect(merged[0].gitBranch).toBe('feat/CF-195');
|
|
expect(merged[0].worktreeRepo).toBe('/repo');
|
|
});
|
|
|
|
it('leaves the fields undefined for a non-worktree session rather than inventing them', () => {
|
|
const merged = mergeUnifiedSessions({ history: [historyRow({ workingDir: '/plain/repo' })] });
|
|
expect(merged[0].worktreeName).toBeUndefined();
|
|
expect(merged[0].gitBranch).toBeUndefined();
|
|
});
|
|
|
|
it('finds a session by worktree name and by branch', () => {
|
|
const items = [
|
|
{ sessionId: 'a', worktreeName: 'autodev', sources: ['history'] },
|
|
{ sessionId: 'b', gitBranch: 'feat/CF-195', sources: ['history'] },
|
|
{ sessionId: 'c', sources: ['history'] },
|
|
] as unknown as UnifiedSessionItem[];
|
|
|
|
expect(filterAndPaginate(items, { q: 'autodev' }).sessions.map((s) => s.sessionId)).toEqual(['a']);
|
|
expect(filterAndPaginate(items, { q: 'cf-195' }).sessions.map((s) => s.sessionId)).toEqual(['b']);
|
|
expect(filterAndPaginate(items, { q: 'nothing' }).sessions).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Mirrors `_worktreeLabel` in terminal-ui.js. The frontend is plain browser JS
|
|
* with no module exports, so the logic is restated here; the rule it encodes —
|
|
* never print a branch that merely restates the worktree name — is the part
|
|
* worth pinning.
|
|
*/
|
|
function worktreeLabel(s: { worktreeName?: string; gitBranch?: string }): string {
|
|
const name = s.worktreeName;
|
|
if (!name) return '';
|
|
let branch = s.gitBranch || '';
|
|
if (branch === name || branch === `worktree-${name}`) branch = '';
|
|
if (branch.length > 24) branch = branch.slice(0, 23) + '…';
|
|
return '⑂ ' + [name, branch].filter(Boolean).join(' · ');
|
|
}
|
|
|
|
describe('worktree badge label', () => {
|
|
it('renders name and branch together', () => {
|
|
expect(worktreeLabel({ worktreeName: 'autodev', gitBranch: 'feat/CF-195' })).toBe('⑂ autodev · feat/CF-195');
|
|
});
|
|
|
|
it('renders NOTHING when only a branch is known — a branch is not a worktree', () => {
|
|
// Every ordinary repo session carries gitBranch. Badging those would put
|
|
// `⑂ master` on every row and bury the worktree rows this badge is for.
|
|
expect(worktreeLabel({ gitBranch: 'master' })).toBe('');
|
|
expect(worktreeLabel({ gitBranch: 'feat/CF-200' })).toBe('');
|
|
});
|
|
|
|
it('does not repeat the name when the branch just restates it', () => {
|
|
expect(worktreeLabel({ worktreeName: 'autodev', gitBranch: 'autodev' })).toBe('⑂ autodev');
|
|
expect(worktreeLabel({ worktreeName: 'autodev', gitBranch: 'worktree-autodev' })).toBe('⑂ autodev');
|
|
});
|
|
|
|
it('is empty for a session that is not on a worktree', () => {
|
|
expect(worktreeLabel({})).toBe('');
|
|
});
|
|
|
|
it('truncates a long branch so the single-line badge row cannot blow out', () => {
|
|
const label = worktreeLabel({ worktreeName: 'wt', gitBranch: 'feature/VERY-LONG-BRANCH-NAME-THAT-KEEPS-GOING' });
|
|
expect(label.length).toBeLessThanOrEqual(2 + 2 + 3 + 24);
|
|
expect(label.endsWith('…')).toBe(true);
|
|
});
|
|
});
|