fix(yield): rescue sessions whose branch shipped past the session's window (#1476)

Sessions read as abandoned when no commit credited to them reached main, but squash merges land work under a new SHA and late merges fall outside the session window, so shipped work looked dead. When a session's turns record the git branch, yield now rescues an abandoned or ambiguous session to productive only when the branch has its own commits, its tip tree matches a commit on main, and at least one of those commits was authored inside the session's window. Ancestry is not used, so a parked ref with no in-window commit is never rescued, and reverted keeps its verdict. Confined to computeYield; the JSON methodology stays timestamp-window. Adds tests including the parked-ref trap and the per-session contribution negative.
This commit is contained in:
ozymandiashh 2026-09-19 03:10:27 +03:00 • committed by GitHub
parent 1f6c3846c0
commit 1cf204a5de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 401 additions and 3 deletions

View file

@ -6,6 +6,9 @@
- **The Models table shows every model that ran, including the ones under a cent.** The CLI's `models` command defaults `minCost` to $0.01, and the desktop bridge passed neither `--min-cost` nor `--unpriced`, so the table silently dropped every row below a cent — which by construction excluded every unpriced row too (a `0 >= 0.01` filter), leaving #1443's dimming and add-alias affordances unreachable in the shipped app. `codeburn:getModels` now passes `--min-cost 0` (and the demo bridge mirrors it), so sub-cent and unpriced rows arrive and render with their existing dim treatment; on a real lifetime corpus that recovers 10 rows and 2,160 calls the default filter hid (43 → 53 rows, verified in both themes). A row priced between $0.00 and $0.01 renders as "$0.00" without dimming — it is genuinely priced, just below the display floor. Fixes #1465.
- **Packaging no longer aborts on a checkout whose path contains a UUID.** `app/scripts/stage-cli.mjs` resolved the CLI's production dependency closure by matching each `npm ls --parseable` line against the checkout's absolute `<root>/node_modules/` prefix, and npm 11 and later redact UUID-shaped path segments to `***` in that output — so on CI runners and scratch worktrees under a UUID directory the match found nothing and packaging died with the misleading "is the root npm installed?". The top-level package name is now read by position (after the N-th `/node_modules` occurrence, N counted off the real root path, which the redaction cannot move) instead of by absolute-prefix match, so redacted, Windows-separated and nested-checkout shapes all resolve; a missing `npm_execpath` also fails with its own message instead of degrading into the empty-closure one. Fixes #1466.
### Fixed
- **`codeburn yield` stops calling work abandoned that shipped through a merge the session's window missed.** A session was labeled abandoned when no commit credited to it landed on main, but commits are credited by timestamp window, so a feature branch merged after the session ended — or merged as a GitHub squash, which lands the work under a brand-new SHA the window can never see — left fully shipped sessions reading as dead. When the session logs record the git branch it ran on (Claude Code captures this per turn; most providers do not), yield now rescues a session whose branch demonstrably shipped through a squash merge: the branch carries commits of its own, its tip's tree hash matches a commit on main (the squashed tree is byte-identical to the branch tip, with the tip different from the branch's base so an untouched or self-reverted branch cannot match), and at least one of those commits was made inside the session's own window — the session contributed the work rather than merely having run on the branch. Every matching ref is examined (a stale local copy cannot hide a merged remote-tracking one). Abandoned and ambiguous sessions rescue to productive; reverted keeps its verdict, which already describes shipped-then-undone work. The rescue is confined to computeYield: sync attribution records keep the pure timestamp-window methodology, and the JSON methodology field is unchanged for compat. Fixes #1442.
### Added
- **The desktop app's Plugins page has a Menu bar card for the macOS menubar app.** On macOS it shows one of three states: nothing installed and an Install button; installed, with its version and Open and Uninstall; running, with a green dot, the version, Open, Quit, Uninstall and a Capacity Dock switch. Install runs the same `codeburn menubar` the command line does, so the release is pinned to the desktop's own version, the checksum and the bundle identity are verified before anything is moved, an older copy is quit before it is replaced and exactly one ends up running — and it runs the CLI the desktop carries, so a person who only ever downloaded the .dmg can install the menu bar without `npm install -g codeburn` first. Before the install the desktop writes a small launcher for that bundled CLI and records it where the menubar already looks for a codeburn before PATH (`~/Library/Application Support/CodeBurn/codeburn-cli-path.v1`), which is what leaves the installed menubar something to run; `codeburn menubar` now keeps an existing runnable record rather than refusing when nothing is on PATH. A menubar installed by hand or by the CLI is found in `~/Applications`, `/Applications`, or wherever Spotlight knows its bundle identifier, so the card describes what is on the machine rather than only what it put there. Install names what it is doing while it runs — Downloading, Verifying, Installing, Starting — read from the CLI's own narration rather than a second progress protocol, because eight megabytes and a launch take about half a minute and a button that says nothing for that long reads as a hang. Quit and Uninstall ask the menubar to go rather than killing it — the login item is the app's own to drop, which nothing outside it can do — and a menubar too old to be asked is never signalled behind its back: the card marks it out of date, offers Update, and disables the Capacity Dock switch, Quit and Uninstall under one line, "Update the menu bar to use this". Asking one anyway gives up after five seconds with "The menu bar app did not respond. Update it and try again." rather than waiting on an answer that is not coming, and what the install actually put on disk decides the card, so a release older than the one these features ship in comes back as the outdated state rather than a cheerful Running. Uninstall takes the bundle away from wherever it was found and the card returns to Not installed. Both confirm in the card itself rather than in a system dialog, the way removing a plugin already does on this page. The Capacity Dock switch writes the menubar's own preference, which a current menubar acts on within a second. There is no link step — both apps already share the CLI, the cache and the config — and no login-item prompt, because the menubar registers itself on first launch. Failures read as sentences (offline, not published for this version, damaged download, no permission), never a stack trace. Install is behind a capability flag that a Mac App Store build turns off, where the card points at the website instead; nothing renders on Windows or Linux.
- **Hovering a dollar amount in the desktop shows the tokens behind it.** Hover or tab to any money figure that has token counts behind it — the Overview hero, the Yesterday, 7-day avg and Month to date figures, the daily chart bars and its Avg/day, Peak and Yesterday chips, the Sessions and Models rows and the two Compare tables — and a small popover lists input, output, cache read and cache write tokens plus the calls they came from. A figure with no token counts behind it shows nothing rather than a fabricated breakdown: projected month and the other estimates, percentages, savings, retry tax, plans and the by-project and by-PR rows are left exactly as they were. The amounts themselves are unchanged in size, weight and position, and the popover is drawn above the card so it never clips. The menubar payload's `periodTotals` now carries the same four counts, so the hero keeps its breakdown when its number comes from the machine-wide generation rather than the payload's own period.

View file

@ -272,7 +272,7 @@ Did the spend actually ship? `codeburn yield` correlates AI sessions with git co
| Abandoned | No commits near session, or commits never merged |
| Ambiguous | Session ran parallel to another and its window's commits were attributed to the tighter one |
Attribution is timestamp-window based (heuristic): each commit is credited to at most one session, the tightest window containing it. The JSON report carries `methodology: "timestamp-window"`.
Attribution is timestamp-window based (heuristic): each commit is credited to at most one session, the tightest window containing it. The JSON report carries `methodology: "timestamp-window"`. A session the window heuristics would leave as abandoned or ambiguous is rescued to productive when a branch it was observed on demonstrably shipped through a squash merge: the branch carries commits of its own, its tip's tree hash matches a commit on main (the squashed tree is byte-identical to the branch tip), and at least one of those commits was made inside the session's own window — so the session contributed the work rather than merely having run on the branch. The rescue needs branch metadata in the session logs, which Claude Code records and most other providers do not.
Requires a git repository. Run from your project directory.

View file

@ -315,6 +315,121 @@ function sessionWindow(session: SessionSummary): SessionWindow | null {
return { start, end, sessionId: session.sessionId }
}
/**
* Branch names a session was observed on, from its turns' `gitBranch` metadata
* (recorded by providers that capture it, e.g. Claude Code). Empty for
* providers without branch metadata — the merged-branch rescue is additive and
* simply never fires for those.
*/
function sessionBranches(session: SessionSummary): Set<string> {
const names = new Set<string>()
for (const turn of session.turns) {
const branch = turn.gitBranch
if (typeof branch === 'string' && branch) names.add(branch)
}
return names
}
function refMatchesSessionBranch(ref: string, branch: string): boolean {
if (ref === branch) return true
// Only the remote-tracking shape (`origin/<branch>`, `upstream/<branch>`):
// a bare suffix match would let an unrelated `topic/<branch>` rescue a
// session that never touched it.
if (!ref.endsWith(`/${branch}`)) return false
const prefix = ref.slice(0, ref.length - branch.length - 1)
return prefix.length > 0 && !prefix.includes('/')
}
/** What one ref can prove about work having shipped. */
type BranchEvidence = {
readonly ref: string
/** Tip tree hash: the squash signature is this tree existing on main. */
readonly tree: string
/** Merge-base tree: the branch must carry changes beyond it. */
readonly baseTree: string | null
/** Author dates of the branch's own commits (`base..ref`), for the per-session cut. */
readonly commitDates: readonly Date[]
}
type MergedBranchIndex = {
/** All tree hashes reachable on main, for squash-merge detection. */
readonly mainTrees: ReadonlySet<string>
/** Every matching ref per session-observed branch name — local AND remote-tracking. */
readonly branches: ReadonlyMap<string, readonly BranchEvidence[]>
/** The main branch's own short name, so it can never rescue a session. */
readonly mainBranch: string
}
/**
* Evidence per session-observed branch name for the squash-merge rescue:
*
* A GitHub squash merge lands the branch's work on main under a brand-new SHA,
* so neither the original SHAs nor the branch ref appear in main's history.
* The signature that survives is the TREE: the squashed commit's tree is
* byte-identical to the branch tip's. The rule therefore requires the branch
* to carry commits of its own (tip tree differs from its merge-base tree, and
* `base..ref` is non-empty) and its tip tree to match a commit on main.
*
* Ancestry is deliberately NOT a rule: a branch that never advanced past an
* old main commit is an ancestor of main by construction, and rescuing through
* it calls dead work shipped — on one real corpus, 567 of 600 first-draft
* rescues came from exactly one such parked ref.
*/
function buildMergedBranchIndex(
gitDir: string,
mainBranch: string,
wanted: ReadonlySet<string>,
): MergedBranchIndex | null {
if (wanted.size === 0) return null
const mainLog = runGit(['log', mainBranch, '--format=%T'], gitDir) ?? ''
const mainTrees = new Set(mainLog.split('\n').filter(Boolean))
// Collect EVERY matching ref per branch name: a stale local copy that does
// not qualify must not hide a remote-tracking ref that does (and vice
// versa), so all candidates become evidence rather than first-wins.
const allRefs = (runGit(['for-each-ref', 'refs/heads', 'refs/remotes', '--format=%(refname:short)'], gitDir) ?? '')
.split('\n').map(l => l.trim()).filter(Boolean)
const branches = new Map<string, BranchEvidence[]>()
for (const branch of wanted) {
const evidence: BranchEvidence[] = []
for (const ref of allRefs) {
if (!refMatchesSessionBranch(ref, branch)) continue
const tree = runGit(['rev-parse', `${ref}^{tree}`], gitDir)
if (!tree) continue
const base = runGit(['merge-base', ref, mainBranch], gitDir)
const baseTree = base ? runGit(['rev-parse', `${base}^{tree}`], gitDir) : null
const ownLog = base ? runGit(['log', '--format=%aI', `${base}..${ref}`], gitDir) ?? '' : ''
const commitDates = ownLog.split('\n').filter(Boolean).map(d => new Date(d))
evidence.push({ ref, tree, baseTree, commitDates })
}
if (evidence.length > 0) branches.set(branch, evidence)
}
return { mainTrees, branches, mainBranch }
}
/**
* Whether this session's own work shipped through a squash merge: the branch it
* was observed on carries commits, its tip tree is on main, and at least one of
* its own commits was made inside the session's window — the session
* contributed, rather than merely having run on a branch that later shipped.
*/
function sessionBranchShipped(session: SessionSummary, branches: ReadonlySet<string>, index: MergedBranchIndex): boolean {
const window = sessionWindow(session)
if (!window) return false
for (const branch of branches) {
if (branch === index.mainBranch) continue
for (const evidence of index.branches.get(branch) ?? []) {
if (evidence.commitDates.length === 0) continue
if (evidence.tree === evidence.baseTree) continue
if (!index.mainTrees.has(evidence.tree)) continue
if (evidence.commitDates.some(date => date >= window.start && date <= window.end)) return true
}
}
return false
}
function attributeCommits(
sessions: SessionSummary[],
commits: CommitInfo[],
@ -480,13 +595,39 @@ export async function computeYield(range: DateRange, cwd: string, provider: stri
for (const group of repoGroups.values()) {
const attributions = attributeCommits(group.sessions, group.commits)
// All branch names observed across this group's sessions, so one index
// covers every session; built lazily below only if a rescue is attempted.
const groupBranches = new Set<string>()
for (const session of group.sessions) {
for (const branch of sessionBranches(session)) groupBranches.add(branch)
}
let mergedBranchIndex: MergedBranchIndex | null | undefined
const rescueIfShipped = (session: SessionSummary, branches: ReadonlySet<string>): boolean => {
if (groupBranches.size === 0 || !group.gitDir) return false
if (mergedBranchIndex === undefined) {
mergedBranchIndex = buildMergedBranchIndex(group.gitDir, getMainBranch(group.gitDir), groupBranches)
}
return mergedBranchIndex !== null && sessionBranchShipped(session, branches, mergedBranchIndex)
}
for (const [index, session] of group.sessions.entries()) {
const attribution = attributions[index]
const { category, commitCount } = categorizeSession(
let { category, commitCount } = categorizeSession(
session,
attribution?.commits ?? [],
attribution?.lostCandidacy ?? false,
)
// #1442: the timestamp-window heuristics call work abandoned that later
// shipped — a squash merge lands the work under a SHA the window can
// never see. When the session was observed on a branch whose tip tree is
// on main AND whose own commits overlap this session's window, the
// session's work demonstrably shipped and it is productive. Reverted
// stays reverted: that verdict already describes shipped-then-undone.
if (category === 'abandoned' || category === 'ambiguous') {
if (rescueIfShipped(session, sessionBranches(session))) {
category = 'productive'
}
}
summary[category].cost += session.totalCostUSD
summary[category].sessions += 1
@ -519,7 +660,7 @@ export function formatYieldSummary(summary: YieldSummary): string {
`Abandoned: ${fmt(abandoned.cost).padStart(8)} (${pct(abandoned.cost)}%) - ${abandoned.sessions} sessions never committed`,
`Ambiguous: ${fmt(ambiguous.cost).padStart(8)} (${pct(ambiguous.cost)}%) - ${ambiguous.sessions} sessions lost commits to concurrent sessions`,
'',
'Attribution: timestamp-window based (heuristic)',
'Attribution: timestamp-window + merged-branch evidence (heuristic)',
'',
`Total: ${fmt(total.cost).padStart(8)} - ${total.sessions} sessions`,
'',

View file

@ -0,0 +1,254 @@
import { execFileSync } from 'node:child_process'
import { writeFileSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import type { ClassifiedTurn, ProjectSummary, SessionSummary } from '../src/types.js'
import { computeYield } from '../src/yield.js'
const { parseAllSessionsMock } = vi.hoisted(() => ({
parseAllSessionsMock: vi.fn(),
}))
vi.mock('../src/parser.js', () => ({
parseAllSessions: parseAllSessionsMock,
filterProjectsByName: (projects: unknown[]) => projects,
unmatchedRootedPatterns: () => [],
isInteractiveScanUI: () => false,
}))
function git(cwd: string, args: string[], env: Record<string, string> = {}): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf-8',
env: { ...process.env, ...env },
}).trim()
}
/** A real (non-empty) commit: writes `file`, commits at `date`. */
function realCommit(cwd: string, message: string, file: string, content: string, date: string): void {
writeFileSync(join(cwd, file), content)
git(cwd, ['add', '.'])
git(cwd, ['commit', '-m', message], {
GIT_AUTHOR_DATE: date,
GIT_COMMITTER_DATE: date,
})
}
function makeSession(overrides: Partial<SessionSummary> & { branch?: string }): SessionSummary {
const turns: ClassifiedTurn[] = overrides.branch
? [{ userMessage: 'work', assistantCalls: [], timestamp: '2026-01-01T10:10:00.000Z', sessionId: overrides.sessionId ?? 'session', category: 'other', retries: 0, hasEdits: true, gitBranch: overrides.branch }]
: []
return {
sessionId: 'session',
project: 'app',
firstTimestamp: '2026-01-01T10:00:00.000Z',
lastTimestamp: '2026-01-01T11:00:00.000Z',
totalCostUSD: 1,
totalSavingsUSD: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalReasoningTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 1,
turns,
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
skillBreakdown: {},
subagentBreakdown: {},
...overrides,
}
}
function projectOf(sessions: SessionSummary[]): ProjectSummary[] {
return [{
project: 'app',
projectPath: '',
sessions,
totalCostUSD: sessions.reduce((sum, s) => sum + s.totalCostUSD, 0),
} as unknown as ProjectSummary]
}
const RANGE = {
start: new Date('2026-01-01T00:00:00.000Z'),
end: new Date('2026-01-02T00:00:00.000Z'),
}
async function categorize(repoDir: string, session: SessionSummary): Promise<string> {
parseAllSessionsMock.mockResolvedValue(projectOf([session]))
const summary = await computeYield(RANGE, repoDir)
expect(summary.details).toHaveLength(1)
return summary.details[0]!.category
}
describe('yield merged-branch rescue (issue #1442)', () => {
let repoDir: string
beforeAll(async () => {
repoDir = await mkdtemp(join(tmpdir(), 'codeburn-yield-rescue-'))
git(repoDir, ['init', '-b', 'main'])
git(repoDir, ['config', 'user.email', 'test@example.com'])
git(repoDir, ['config', 'user.name', 'Test'])
// Base commit on main, before the session window.
realCommit(repoDir, 'chore: base', 'base.txt', 'base\n', '2025-12-01T09:00:00Z')
// Feature branch with real work committed INSIDE the session window.
git(repoDir, ['switch', '-c', 'feature-x'])
realCommit(repoDir, 'feat: session work', 'feature.txt', 'shipped work\n', '2026-01-01T10:30:00Z')
// The session ran on the branch; commits exist in the window but (for the
// squash case) their SHAs never reach main.
})
afterAll(async () => {
await rm(repoDir, { recursive: true, force: true })
})
it('rescues an abandoned session whose branch was merged into main after the window (squash merge)', async () => {
// Squash-merge feature-x into main after the range: one new SHA on main
// carrying the branch tip's exact tree.
git(repoDir, ['switch', 'main'])
git(repoDir, ['merge', '--squash', 'feature-x'], {
GIT_AUTHOR_DATE: '2026-01-05T09:00:00Z',
GIT_COMMITTER_DATE: '2026-01-05T09:00:00Z',
})
git(repoDir, ['commit', '-m', 'feat: session work (#1)'], {
GIT_AUTHOR_DATE: '2026-01-05T09:00:00Z',
GIT_COMMITTER_DATE: '2026-01-05T09:00:00Z',
})
// The window-attributed commit is on feature-x, whose SHA is not in main:
// pre-rescue this session read abandoned.
const category = await categorize(repoDir, makeSession({ sessionId: 'squash-session', branch: 'feature-x' }))
expect(category).toBe('productive')
})
it('keeps an abandoned session abandoned when its branch never merged', async () => {
git(repoDir, ['switch', '-c', 'feature-unmerged'])
realCommit(repoDir, 'feat: never ships', 'unmerged.txt', 'wip\n', '2026-01-01T10:40:00Z')
git(repoDir, ['switch', 'main'])
const category = await categorize(repoDir, makeSession({ sessionId: 'unmerged-session', branch: 'feature-unmerged' }))
expect(category).toBe('abandoned')
})
it('never rescues through the main branch itself', async () => {
// A branch-less session that shipped nothing stays abandoned; a session
// recorded on main must not be rescued just because main is merged into main.
const category = await categorize(repoDir, makeSession({ sessionId: 'main-session', branch: 'main' }))
expect(category).toBe('abandoned')
})
it('never rescues through a ref parked inside main history (review: the parked-ref trap)', async () => {
// A branch cut from main and never advanced is an ancestor of main by
// construction and its tip tree is an OLD main tree. It must not rescue:
// on the reviewer's corpus, 567 of 600 first-draft rescues came from one
// such ref ($6,705 of $6,970 mislabeled productive).
git(repoDir, ['switch', '-c', 'feature-parked', 'main'])
git(repoDir, ['switch', 'main'])
const category = await categorize(repoDir, makeSession({ sessionId: 'parked-branch-session', branch: 'feature-parked' }))
expect(category).toBe('abandoned')
})
it('rescues on a master-based repository (main-branch resolution path)', async () => {
const repo = await mkdtemp(join(tmpdir(), 'codeburn-yield-master-'))
try {
git(repo, ['init', '-b', 'master'])
git(repo, ['config', 'user.email', 'test@example.com'])
git(repo, ['config', 'user.name', 'Test'])
realCommit(repo, 'chore: base', 'base.txt', 'base\n', '2025-12-01T09:00:00Z')
git(repo, ['switch', '-c', 'feature-m'])
realCommit(repo, 'feat: work', 'work.txt', 'work\n', '2026-01-01T10:30:00Z')
git(repo, ['switch', 'master'])
git(repo, ['merge', '--no-ff', 'feature-m', '-m', 'chore: merge'], {
GIT_AUTHOR_DATE: '2026-01-05T09:00:00Z',
GIT_COMMITTER_DATE: '2026-01-05T09:00:00Z',
})
const category = await categorize(repo, makeSession({ sessionId: 'master-session', branch: 'feature-m' }))
expect(category).toBe('productive')
} finally {
await rm(repo, { recursive: true, force: true })
}
})
it('does not rescue a session whose window contains none of the branch\'s commits (review: per-session contribution)', async () => {
git(repoDir, ['merge', '--no-ff', 'feature-x', '-m', 'chore: merge feature-x'], {
GIT_AUTHOR_DATE: '2026-01-06T09:00:00Z',
GIT_COMMITTER_DATE: '2026-01-06T09:00:00Z',
})
// The branch shipped (its tip tree is the merge commit's tree, which is on
// main), but this session ran 15:00-16:00 while every branch commit was
// made at 10:30: nothing ties the session to the work, so it stays
// abandoned — riding a shipped branch is not contributing to it.
const lateSession = makeSession({
sessionId: 'late-session',
branch: 'feature-x',
firstTimestamp: '2026-01-01T15:00:00.000Z',
lastTimestamp: '2026-01-01T16:00:00.000Z',
})
const category = await categorize(repoDir, lateSession)
expect(category).toBe('abandoned')
})
it('rescues a true-merge session whose window overlaps the branch\'s own commits', async () => {
// Same repository state: the 10:00-11:00 session overlapped the 10:30
// branch commit, and the merge left the branch tip's tree on main, so the
// squash rule's evidence (tip tree on main + own commits + window overlap)
// holds for true merges too.
const worker = makeSession({ sessionId: 'worker-session', branch: 'feature-x' })
const category = await categorize(repoDir, worker)
expect(category).toBe('productive')
})
it('examines every matching ref: a stale local copy cannot hide a merged remote-tracking ref (review)', async () => {
const repo = await mkdtemp(join(tmpdir(), 'codeburn-yield-candidates-'))
try {
git(repo, ['init', '-b', 'main'])
git(repo, ['config', 'user.email', 'test@example.com'])
git(repo, ['config', 'user.name', 'Test'])
realCommit(repo, 'chore: base', 'base.txt', 'base\n', '2025-12-01T09:00:00Z')
git(repo, ['switch', '-c', 'feature-r'])
realCommit(repo, 'feat: real work', 'work.txt', 'real\n', '2026-01-01T10:30:00Z')
// Simulate the remote-tracking ref holding the merged work...
const tip = git(repo, ['rev-parse', 'HEAD'])
git(repo, ['update-ref', `refs/remotes/origin/feature-r`, tip])
// ...while the local ref is reset back to the branch point (stale copy).
git(repo, ['reset', '--hard', 'main'])
git(repo, ['switch', 'main'])
// Squash-merge the work so only the tree signature survives.
git(repo, ['merge', '--squash', 'origin/feature-r'], {
GIT_AUTHOR_DATE: '2026-01-05T09:00:00Z',
GIT_COMMITTER_DATE: '2026-01-05T09:00:00Z',
})
git(repo, ['commit', '-m', 'feat: real work (#2)'], {
GIT_AUTHOR_DATE: '2026-01-05T09:00:00Z',
GIT_COMMITTER_DATE: '2026-01-05T09:00:00Z',
})
const category = await categorize(repo, makeSession({ sessionId: 'r-session', branch: 'feature-r' }))
expect(category).toBe('productive')
} finally {
await rm(repo, { recursive: true, force: true })
}
})
it('leaves sessions without branch metadata untouched', async () => {
// A window with no commits of its own and no recorded branch: no rescue
// input exists, so abandoned stands however the repo evolved around it.
const lateNoBranch = makeSession({
sessionId: 'no-branch-session',
firstTimestamp: '2026-01-01T15:00:00.000Z',
lastTimestamp: '2026-01-01T16:00:00.000Z',
})
const category = await categorize(repoDir, lateNoBranch)
expect(category).toBe('abandoned')
})
})