feat(projects): show real git state on the project detail page
The page carried one bare `●` for "dirty" and nothing else, so "do I have
commits I haven't pushed" and "which worktree am I in" still meant dropping
into a terminal. Design mock: docs/mockups/project-detail-git.html; plan and
task breakdown (G1-G7): docs/plans/w6-project-git-panel.md.
One rule drives the whole feature: ahead/behind compare against `@{u}`, a
LOCALLY CACHED remote ref that only a fetch moves. This repo was the live
example while building — `↑9` true, `↓0` false, because FETCH_HEAD had not
moved in 19 days. So:
- `ahead` needs only local refs and is never flagged.
- `behind` is flagged `stale` once FETCH_HEAD is older than an hour.
- Exactly ONE state may render green: ↑0 ↓0 AND a fresh fetch. Green means
"I checked, ignore this"; getting it wrong is lying to the user.
- No upstream (the normal state of a fresh worktree branch) leaves ahead and
behind undefined — it renders an explicit `no upstream`, never the green
path. That fall-through is the easiest bug to ship here.
What landed:
G1 SyncState (upstream/ahead/behind/lastFetchMs/detached) + ProjectDetail.sync
and .dirtyCount. All additive and optional — the Android and iOS clients
decode these shapes. The ahead/behind helper already existed for the list
view; buildProjectDetail had simply never called it.
Fixes a pre-existing bug on the way: readBranch read <repo>/.git/HEAD
directly, so it returned nothing inside a LINKED worktree, where .git is a
file. resolveGitDirs now resolves both the per-worktree gitdir (HEAD) and
the shared common dir (FETCH_HEAD).
G2 POST /projects/git/fetch. Same discipline as push: the remote is derived
server-side and no remote or refspec is ever read from the body, so a
client cannot aim it at an arbitrary URL. Touches refs/remotes only — no
working tree, no index, no merge; it is not a pull. Own rate-limit bucket
so refreshes cannot eat the budget a real push needs. On failure
lastFetchMs is left alone, so the UI keeps saying "stale" instead of
pretending it refreshed.
G3 makeSyncBand replaces the bare dot: upstream name, ↑n, ↓n, stale flag,
dirty count, Fetch button (disabled on a detached HEAD).
G4 The commit list marks unpushed commits and draws the upstream boundary
once, after the last of them. Marking is server-side from `rev-list`,
deliberately NOT "the first N rows": `git log` is date-ordered, so merging
an older branch interleaves unpushed commits BELOW pushed ones, and that
shortcut fails in the dangerous direction — calling an unpushed commit
pushed. A regression test builds exactly that backdated-merge shape.
G5 The worktree section is always "Worktrees (n)" (it used to rename itself
to "Branch" at n=1) and the current row carries its own state chips.
G6 Cost control. The plan called for a .git-mtime cache; that was dropped
during implementation because a fingerprint over HEAD/index/reflog does
NOT move when push updates a remote-tracking ref — the cached `ahead`
would still claim "9 to push" right after a successful push, which is the
exact lie the feature exists to prevent. Replaced with three measures that
cannot go stale: in-flight coalescing (N devices watching one repo cost
one probe, entry dropped as it settles, nothing cached across time),
skipping the re-render when the payload is byte-identical (this also stops
the 5 s re-mount of the commit log, two more git spawns per tick), and
pausing the timer while the document is hidden.
G7 Per-worktree state via GET /projects/worktree/state, kept narrower than
/projects/detail so N rows do not pay for worktree listing and CLAUDE.md
reads nothing renders. Needed an unplanned prerequisite: ProjectSessionRef
carried no cwd, so sessions could not be attributed to a worktree. Added
it, plus countSessionsByWorktree, which matches DEEPEST-first because
.claude/worktrees/<name> lives INSIDE the main checkout and prefix
matching would count every worktree session against the parent repo too.
Out of scope, unchanged: no reset, no checkout, no clean, no rebase, no
force-push. stage/commit/push stay exactly as they were.
Verified: tsc and build clean; 46 new tests.
This commit is contained in:
@@ -149,3 +149,65 @@ describe('mountGitLog', () => {
|
||||
expect(host.querySelector('.proj-commit-row')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
/* ── w6/G4 unpushed rows + the upstream boundary ───────────────────────────── */
|
||||
|
||||
describe('renderGitLog — unpushed boundary (w6 G4)', () => {
|
||||
function entry(hash: string, subject: string, unpushed?: boolean) {
|
||||
return { hash, at: Date.now(), subject, ...(unpushed === true ? { unpushed: true } : {}) }
|
||||
}
|
||||
|
||||
it('marks unpushed rows and draws the boundary exactly once, after the last one', () => {
|
||||
const host = document.createElement('div')
|
||||
renderGitLog(host, {
|
||||
commits: [
|
||||
entry('aaa1111', 'newest local', true),
|
||||
entry('bbb2222', 'older local', true),
|
||||
entry('ccc3333', 'already pushed'),
|
||||
entry('ddd4444', 'also pushed'),
|
||||
],
|
||||
truncated: false,
|
||||
upstream: 'origin/develop',
|
||||
})
|
||||
|
||||
expect(host.querySelectorAll('.proj-commit-unpushed').length).toBe(2)
|
||||
const boundaries = host.querySelectorAll('.proj-commit-boundary')
|
||||
expect(boundaries.length).toBe(1)
|
||||
expect(boundaries[0]!.textContent).toContain('origin/develop')
|
||||
|
||||
const rows = Array.from(host.querySelectorAll('.proj-commit-row, .proj-commit-boundary'))
|
||||
expect(rows[2]!.classList.contains('proj-commit-boundary')).toBe(true)
|
||||
})
|
||||
|
||||
it('draws no boundary when nothing is unpushed', () => {
|
||||
const host = document.createElement('div')
|
||||
renderGitLog(host, {
|
||||
commits: [entry('aaa1111', 'pushed')],
|
||||
truncated: false,
|
||||
upstream: 'origin/main',
|
||||
})
|
||||
expect(host.querySelector('.proj-commit-boundary')).toBeNull()
|
||||
expect(host.querySelector('.proj-commit-unpushed')).toBeNull()
|
||||
})
|
||||
|
||||
it('draws no boundary without an upstream, even if rows claim to be unpushed', () => {
|
||||
const host = document.createElement('div')
|
||||
renderGitLog(host, { commits: [entry('aaa1111', 'local', true)], truncated: false })
|
||||
expect(host.querySelector('.proj-commit-boundary')).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a non-true `unpushed` from the server as pushed', () => {
|
||||
const norm = normalizeGitLog({
|
||||
commits: [{ hash: 'aaa1111', at: 1, subject: 's', unpushed: 'yes' }],
|
||||
truncated: false,
|
||||
})
|
||||
expect(norm!.commits[0]!.unpushed).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a string upstream and drops a junk one', () => {
|
||||
expect(normalizeGitLog({ commits: [], truncated: false, upstream: 'origin/x' })!.upstream).toBe(
|
||||
'origin/x',
|
||||
)
|
||||
expect(normalizeGitLog({ commits: [], truncated: false, upstream: 42 })!.upstream).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -166,3 +166,97 @@ describe('getGitLog (real git repo)', () => {
|
||||
await fs.rm(plain, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
// ── w6/G4 unpushed marking ─────────────────────────────────────────────────────
|
||||
// The marks come from `rev-list`, never from row position — see markUnpushed.
|
||||
|
||||
describe('getGitLog — unpushed marking (w6 G4)', { timeout: 30_000 }, () => {
|
||||
async function git(cwd: string, args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync('git', args, { cwd })
|
||||
return stdout
|
||||
}
|
||||
|
||||
async function initRepo(cwd: string): Promise<void> {
|
||||
await fs.mkdir(cwd, { recursive: true })
|
||||
await git(cwd, ['init', '-q', '-b', 'main'])
|
||||
await git(cwd, ['config', 'user.email', 't@t.local'])
|
||||
await git(cwd, ['config', 'user.name', 'tester'])
|
||||
await git(cwd, ['config', 'commit.gpgsign', 'false'])
|
||||
}
|
||||
|
||||
async function commit(cwd: string, file: string, msg: string): Promise<void> {
|
||||
await fs.writeFile(path.join(cwd, file), `${file}\n`, 'utf8')
|
||||
await git(cwd, ['add', '.'])
|
||||
await git(cwd, ['commit', '-q', '-m', msg])
|
||||
}
|
||||
|
||||
it('marks nothing and names no upstream when the branch tracks nothing', async () => {
|
||||
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitlog-noup-')))
|
||||
await initRepo(repo)
|
||||
await commit(repo, 'a.txt', 'one')
|
||||
|
||||
const res = await getGitLog(repo, { timeoutMs: 20_000 })
|
||||
expect(res.commits.length).toBe(1)
|
||||
expect(res.upstream).toBeUndefined()
|
||||
expect(res.commits.every((c) => c.unpushed !== true)).toBe(true)
|
||||
})
|
||||
|
||||
it('marks only the commits absent from the upstream and names it', async () => {
|
||||
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitlog-up-')))
|
||||
const bare = path.join(root, 'bare.git')
|
||||
await execFileAsync('git', ['init', '-q', '--bare', '-b', 'main', bare])
|
||||
|
||||
const repo = path.join(root, 'work')
|
||||
await initRepo(repo)
|
||||
await commit(repo, 'pushed.txt', 'pushed one')
|
||||
await git(repo, ['remote', 'add', 'origin', bare])
|
||||
await git(repo, ['push', '-q', '-u', 'origin', 'main'])
|
||||
|
||||
await commit(repo, 'local1.txt', 'local one')
|
||||
await commit(repo, 'local2.txt', 'local two')
|
||||
|
||||
const res = await getGitLog(repo, { timeoutMs: 20_000 })
|
||||
expect(res.upstream).toBe('origin/main')
|
||||
const bySubject = new Map(res.commits.map((c) => [c.subject, c.unpushed === true]))
|
||||
expect(bySubject.get('local two')).toBe(true)
|
||||
expect(bySubject.get('local one')).toBe(true)
|
||||
expect(bySubject.get('pushed one')).toBe(false)
|
||||
})
|
||||
|
||||
it('marks an unpushed commit that sorts BELOW a pushed one (merged older branch)', async () => {
|
||||
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitlog-merge-')))
|
||||
const bare = path.join(root, 'bare.git')
|
||||
await execFileAsync('git', ['init', '-q', '--bare', '-b', 'main', bare])
|
||||
|
||||
const repo = path.join(root, 'work')
|
||||
await initRepo(repo)
|
||||
await commit(repo, 'base.txt', 'base')
|
||||
await git(repo, ['remote', 'add', 'origin', bare])
|
||||
await git(repo, ['push', '-q', '-u', 'origin', 'main'])
|
||||
|
||||
// A side branch whose commit is BACKDATED so date-ordered `git log` puts it
|
||||
// below the newer pushed tip — the exact shape "first N rows" gets wrong.
|
||||
await git(repo, ['checkout', '-q', '-b', 'side'])
|
||||
const old = '2001-01-01T00:00:00'
|
||||
await fs.writeFile(path.join(repo, 'old.txt'), 'old\n', 'utf8')
|
||||
await git(repo, ['add', '.'])
|
||||
await execFileAsync(
|
||||
'git',
|
||||
['commit', '-q', '-m', 'backdated side work', '--date', old],
|
||||
{ cwd: repo, env: { ...process.env, GIT_COMMITTER_DATE: old } },
|
||||
)
|
||||
await git(repo, ['checkout', '-q', 'main'])
|
||||
await git(repo, ['merge', '-q', '--no-ff', '-m', 'merge side', 'side'])
|
||||
|
||||
const res = await getGitLog(repo, { timeoutMs: 20_000 })
|
||||
const side = res.commits.find((c) => c.subject === 'backdated side work')
|
||||
expect(side).toBeDefined()
|
||||
expect(side!.unpushed).toBe(true)
|
||||
|
||||
// And it really is out of order, so the test is testing what it claims.
|
||||
const idxSide = res.commits.findIndex((c) => c.subject === 'backdated side work')
|
||||
const idxBase = res.commits.findIndex((c) => c.subject === 'base')
|
||||
expect(idxSide).toBeGreaterThan(idxBase)
|
||||
expect(res.commits[idxBase]!.unpushed).not.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
stageFiles,
|
||||
commit,
|
||||
push,
|
||||
fetch as gitFetch,
|
||||
} from '../../src/http/git-ops.js'
|
||||
|
||||
const execFileP = promisify(execFile)
|
||||
@@ -148,7 +149,7 @@ describe('validateRepoFiles', () => {
|
||||
|
||||
// ── stageFiles (real temp repos) ────────────────────────────────────────────────
|
||||
|
||||
describe('stageFiles', () => {
|
||||
describe('stageFiles', { timeout: 30_000 }, () => {
|
||||
it('stages the given files (git add) and unstages them (restore --staged)', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
@@ -183,7 +184,7 @@ describe('stageFiles', () => {
|
||||
|
||||
// ── commit (real temp repos) ────────────────────────────────────────────────────
|
||||
|
||||
describe('commit', () => {
|
||||
describe('commit', { timeout: 30_000 }, () => {
|
||||
it('commits staged changes and returns a short SHA', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
@@ -256,7 +257,7 @@ describe('commit', () => {
|
||||
|
||||
// ── push (real bare-repo remote, no network) ────────────────────────────────────
|
||||
|
||||
describe('push', () => {
|
||||
describe('push', { timeout: 30_000 }, () => {
|
||||
it('first push sets upstream (-u) and the bare remote receives the ref', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
@@ -320,3 +321,99 @@ describe('push', () => {
|
||||
expect(await push(plain, { timeoutMs: TIMEOUT_MS })).toMatchObject({ ok: false, status: 404 })
|
||||
})
|
||||
})
|
||||
|
||||
// ── w6/G2 fetch — read-only refresh of remote-tracking refs ────────────────────
|
||||
// Fetch exists so the panel can stop lying about `behind`. It touches no working
|
||||
// tree, no index and no branch: the only thing it may change is refs/remotes.
|
||||
|
||||
// Same reason as the G1 block: clone + push + fetch is well past a 5 s default.
|
||||
describe('fetch (w6 G2)', { timeout: 30_000 }, () => {
|
||||
it('refuses a non-git directory without spawning git', async () => {
|
||||
if (!gitAvailable) return
|
||||
const plain = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-plain-')))
|
||||
const res = await gitFetch(plain, { timeoutMs: TIMEOUT_MS })
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.ok === false && res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('reports a plain message — never raw git stderr — when no remote exists', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
const res = await gitFetch(repo, { timeoutMs: TIMEOUT_MS })
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.ok === false && res.status).toBe(400)
|
||||
expect(res.ok === false && res.error).toBe('No remote configured.')
|
||||
})
|
||||
|
||||
it('fetches from the sole remote and leaves HEAD and the working tree untouched', async () => {
|
||||
if (!gitAvailable) return
|
||||
const bare = await makeBareRemote()
|
||||
const repo = await makeRepo()
|
||||
const git = async (args: string[]): Promise<void> => {
|
||||
await execFileP('git', args, { cwd: repo })
|
||||
}
|
||||
await git(['remote', 'add', 'origin', bare])
|
||||
await git(['push', '-q', '-u', 'origin', 'main'])
|
||||
|
||||
const headBefore = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
|
||||
const res = await gitFetch(repo, { timeoutMs: TIMEOUT_MS })
|
||||
expect(res.ok).toBe(true)
|
||||
|
||||
const headAfter = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
|
||||
expect(headAfter).toBe(headBefore)
|
||||
const status = (await execFileP('git', ['status', '--porcelain'], { cwd: repo })).stdout
|
||||
expect(status.trim()).toBe('')
|
||||
})
|
||||
|
||||
it('moves the remote-tracking ref so `behind` becomes true after fetching', async () => {
|
||||
if (!gitAvailable) return
|
||||
const bare = await makeBareRemote()
|
||||
const repo = await makeRepo()
|
||||
await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo })
|
||||
await execFileP('git', ['push', '-q', '-u', 'origin', 'main'], { cwd: repo })
|
||||
|
||||
// A second clone pushes one commit; `repo` cannot know until it fetches.
|
||||
const other = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-other-')))
|
||||
const clone = path.join(other, 'c')
|
||||
await execFileP('git', ['clone', '-q', bare, clone])
|
||||
await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: clone })
|
||||
await execFileP('git', ['config', 'user.name', 'tester'], { cwd: clone })
|
||||
await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd: clone })
|
||||
await fs.writeFile(path.join(clone, 'remote.txt'), 'r\n', 'utf8')
|
||||
await execFileP('git', ['add', '.'], { cwd: clone })
|
||||
await execFileP('git', ['commit', '-q', '-m', 'from elsewhere'], { cwd: clone })
|
||||
await execFileP('git', ['push', '-q'], { cwd: clone })
|
||||
|
||||
const countBefore = (
|
||||
await execFileP('git', ['rev-list', '--count', 'HEAD..@{u}'], { cwd: repo })
|
||||
).stdout.trim()
|
||||
expect(countBefore).toBe('0') // the stale lie the panel would have shown
|
||||
|
||||
const res = await gitFetch(repo, { timeoutMs: TIMEOUT_MS })
|
||||
expect(res.ok).toBe(true)
|
||||
|
||||
const countAfter = (
|
||||
await execFileP('git', ['rev-list', '--count', 'HEAD..@{u}'], { cwd: repo })
|
||||
).stdout.trim()
|
||||
expect(countAfter).toBe('1')
|
||||
})
|
||||
|
||||
it('ignores any remote or refspec the caller tries to smuggle in', async () => {
|
||||
if (!gitAvailable) return
|
||||
const bare = await makeBareRemote()
|
||||
const repo = await makeRepo()
|
||||
await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo })
|
||||
await execFileP('git', ['push', '-q', '-u', 'origin', 'main'], { cwd: repo })
|
||||
|
||||
// The options type carries only timeoutMs; a caller passing extra keys must
|
||||
// not be able to steer the command at a remote of their choosing.
|
||||
const smuggled = {
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
remote: 'file:///tmp/evil',
|
||||
refspec: '+refs/heads/*:refs/heads/*',
|
||||
} as unknown as { timeoutMs: number }
|
||||
const res = await gitFetch(repo, smuggled)
|
||||
expect(res.ok).toBe(true)
|
||||
expect(res.ok === true && res.remote).toBe('origin')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,6 +34,9 @@ const {
|
||||
normalizeProject,
|
||||
makeProjectCard,
|
||||
makeSyncChip,
|
||||
makeSyncBand,
|
||||
makeWorktreeRow,
|
||||
countSessionsByWorktree,
|
||||
renderProjectDetail,
|
||||
groupProjects,
|
||||
displayLabel,
|
||||
@@ -705,3 +708,189 @@ describe('displayLabel', () => {
|
||||
expect(displayLabel('Billo.Platform.Payment', 'billo.platform')).toBe('Payment')
|
||||
})
|
||||
})
|
||||
|
||||
/* ── w6/G3 makeSyncBand — the header's ambient git state ────────────────────── */
|
||||
// The band's contract is mostly about what it must REFUSE to say: a stale or
|
||||
// unknowable comparison must never be dressed up as "in sync".
|
||||
|
||||
describe('makeSyncBand (w6 G3)', () => {
|
||||
const NOW = 1_700_000_000_000
|
||||
const FRESH = NOW - 60_000 // a minute ago
|
||||
const STALE = NOW - 26 * 60 * 60 * 1000 // 26 hours ago
|
||||
|
||||
function band(
|
||||
sync: import('../src/types.js').SyncState | undefined,
|
||||
opts: { dirtyCount?: number } = {},
|
||||
): HTMLElement | null {
|
||||
return makeSyncBand(sync, { nowMs: NOW, ...opts })
|
||||
}
|
||||
|
||||
it('renders nothing for a non-git project', () => {
|
||||
expect(band(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the single green in-sync state only when even with a fresh fetch', () => {
|
||||
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH })!
|
||||
expect(b.querySelector('.proj-sync-ok')).not.toBeNull()
|
||||
expect(b.querySelector('.proj-sync-stale')).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses to call a stale comparison in sync', () => {
|
||||
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: STALE })!
|
||||
expect(b.querySelector('.proj-sync-ok')).toBeNull()
|
||||
expect(b.querySelector('.proj-sync-stale')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('treats a never-fetched repo as stale, not as in sync', () => {
|
||||
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0 })!
|
||||
expect(b.querySelector('.proj-sync-ok')).toBeNull()
|
||||
expect(b.querySelector('.proj-sync-stale')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('never flags ahead as stale — it does not depend on a fetch', () => {
|
||||
const b = band({ upstream: 'origin/main', ahead: 9, behind: 0, lastFetchMs: STALE })!
|
||||
const ahead = b.querySelector('.proj-sync-ahead')!
|
||||
expect(ahead.textContent).toContain('9')
|
||||
expect(ahead.classList.contains('proj-sync-stale')).toBe(false)
|
||||
expect(b.querySelector('.proj-sync-behind')!.classList.contains('proj-sync-stale')).toBe(true)
|
||||
})
|
||||
|
||||
it('says "no upstream" instead of inventing counts, and is not green', () => {
|
||||
const b = band({ ahead: undefined, behind: undefined, lastFetchMs: FRESH })!
|
||||
expect(b.querySelector('.proj-sync-noupstream')).not.toBeNull()
|
||||
expect(b.querySelector('.proj-sync-ahead')).toBeNull()
|
||||
expect(b.querySelector('.proj-sync-behind')).toBeNull()
|
||||
expect(b.querySelector('.proj-sync-ok')).toBeNull()
|
||||
})
|
||||
|
||||
it('flags a detached HEAD, drops the counts and disables fetching', () => {
|
||||
const b = band({ detached: true })!
|
||||
expect(b.querySelector('.proj-sync-detached')).not.toBeNull()
|
||||
expect(b.querySelector('.proj-sync-ahead')).toBeNull()
|
||||
expect(b.querySelector('.proj-sync-ok')).toBeNull()
|
||||
const btn = b.querySelector('.proj-sync-fetch') as HTMLButtonElement
|
||||
expect(btn.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('names the upstream so the arrows are not floating numbers', () => {
|
||||
const b = band({ upstream: 'origin/develop', ahead: 9, behind: 0, lastFetchMs: FRESH })!
|
||||
expect(b.textContent).toContain('origin/develop')
|
||||
})
|
||||
|
||||
it('counts dirty files instead of showing a bare dot', () => {
|
||||
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH }, {
|
||||
dirtyCount: 3,
|
||||
})!
|
||||
expect(b.querySelector('.proj-sync-dirty')!.textContent).toContain('3')
|
||||
})
|
||||
|
||||
it('omits the dirty chip entirely for a clean tree', () => {
|
||||
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH }, {
|
||||
dirtyCount: 0,
|
||||
})!
|
||||
expect(b.querySelector('.proj-sync-dirty')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
/* ── w6/G5 worktree panel ───────────────────────────────────────────────────── */
|
||||
|
||||
describe('makeWorktreeRow — per-worktree state (w6 G5)', () => {
|
||||
const wt = (over: Partial<import('../src/types.js').WorktreeInfo> = {}) => ({
|
||||
path: '/p/repo',
|
||||
isMain: true,
|
||||
isCurrent: true,
|
||||
branch: 'develop',
|
||||
...over,
|
||||
})
|
||||
|
||||
it('shows nothing extra for a worktree whose state was not probed', () => {
|
||||
const row = makeWorktreeRow(wt({ isCurrent: false, isMain: false }))
|
||||
expect(row.querySelector('.proj-sync-chip')).toBeNull()
|
||||
})
|
||||
|
||||
it('says "no upstream" for a fresh worktree branch instead of looking settled', () => {
|
||||
const row = makeWorktreeRow(wt(), undefined, { sync: { ahead: undefined } })
|
||||
expect(row.querySelector('.proj-sync-noupstream')).not.toBeNull()
|
||||
expect(row.querySelector('.proj-sync-ahead')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the unpushed count for a tracking worktree', () => {
|
||||
const row = makeWorktreeRow(wt(), undefined, {
|
||||
sync: { upstream: 'origin/develop', ahead: 9, behind: 0 },
|
||||
dirtyCount: 3,
|
||||
})
|
||||
expect(row.querySelector('.proj-sync-ahead')!.textContent).toContain('9')
|
||||
expect(row.querySelector('.proj-sync-dirty')!.textContent).toContain('3')
|
||||
})
|
||||
|
||||
it('omits the dirty chip on a clean worktree', () => {
|
||||
const row = makeWorktreeRow(wt(), undefined, {
|
||||
sync: { upstream: 'origin/develop', ahead: 0, behind: 0 },
|
||||
dirtyCount: 0,
|
||||
})
|
||||
expect(row.querySelector('.proj-sync-dirty')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
/* ── w6/G7 session attribution across worktrees ─────────────────────────────── */
|
||||
// `.claude/worktrees/<name>` lives INSIDE the main checkout, so this is exactly
|
||||
// the case naive prefix matching double-counts.
|
||||
|
||||
describe('countSessionsByWorktree (w6 G7)', () => {
|
||||
const MAIN = '/Users/x/repo'
|
||||
const WT = '/Users/x/repo/.claude/worktrees/feature'
|
||||
|
||||
const wts = [
|
||||
{ path: MAIN, isMain: true, isCurrent: true, branch: 'develop' },
|
||||
{ path: WT, isMain: false, isCurrent: false, branch: 'worktree-feature' },
|
||||
]
|
||||
|
||||
function sess(id: string, cwd: string | undefined, exited = false) {
|
||||
return { id, status: 'idle' as const, clientCount: 1, createdAt: 0, exited, ...(cwd !== undefined ? { cwd } : {}) }
|
||||
}
|
||||
|
||||
it('attributes a nested worktree session to the worktree, not to the parent', () => {
|
||||
const counts = countSessionsByWorktree([sess('a', `${WT}/src`)], wts)
|
||||
expect(counts.get(WT)).toBe(1)
|
||||
expect(counts.get(MAIN)).toBe(0)
|
||||
})
|
||||
|
||||
it('counts a session in the main checkout against the main worktree', () => {
|
||||
const counts = countSessionsByWorktree([sess('a', `${MAIN}/src`)], wts)
|
||||
expect(counts.get(MAIN)).toBe(1)
|
||||
expect(counts.get(WT)).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores exited sessions and sessions with no cwd', () => {
|
||||
const counts = countSessionsByWorktree(
|
||||
[sess('a', `${MAIN}/src`, true), sess('b', undefined)],
|
||||
wts,
|
||||
)
|
||||
expect(counts.get(MAIN)).toBe(0)
|
||||
})
|
||||
|
||||
it('does not treat a sibling with a shared prefix as inside', () => {
|
||||
const counts = countSessionsByWorktree([sess('a', '/Users/x/repo-old/src')], wts)
|
||||
expect(counts.get(MAIN)).toBe(0)
|
||||
})
|
||||
|
||||
it('gives every worktree an entry so a row can render 0 without a lookup miss', () => {
|
||||
const counts = countSessionsByWorktree([], wts)
|
||||
expect(counts.get(MAIN)).toBe(0)
|
||||
expect(counts.get(WT)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeWorktreeRow — session chip (w6 G7)', () => {
|
||||
const wt = { path: '/p/repo', isMain: true, isCurrent: true, branch: 'develop' }
|
||||
|
||||
it('shows the running-session count', () => {
|
||||
const row = makeWorktreeRow(wt, undefined, { sessionCount: 2 })
|
||||
expect(row.querySelector('.proj-wt-sessions')!.textContent).toContain('2')
|
||||
})
|
||||
|
||||
it('omits the chip when nothing runs there', () => {
|
||||
const row = makeWorktreeRow(wt, undefined, { sessionCount: 0 })
|
||||
expect(row.querySelector('.proj-wt-sessions')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,9 +14,13 @@ vi.mock('../src/http/history.js', () => ({
|
||||
listSessions: (...a: unknown[]) => mockListSessions(...a),
|
||||
}))
|
||||
|
||||
const { parseGitHead, buildProjects, buildProjectDetail, _clearProjectCache } = await import(
|
||||
'../src/http/projects.js'
|
||||
)
|
||||
const {
|
||||
parseGitHead,
|
||||
buildProjects,
|
||||
buildProjectDetail,
|
||||
buildWorktreeState,
|
||||
_clearProjectCache,
|
||||
} = await import('../src/http/projects.js')
|
||||
|
||||
const execFileP = promisify(execFile)
|
||||
|
||||
@@ -90,7 +94,7 @@ afterEach(async () => {
|
||||
|
||||
// ── parseGitHead (pure) ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseGitHead', () => {
|
||||
describe('parseGitHead', { timeout: 30_000 }, () => {
|
||||
it('extracts a simple branch name', () => {
|
||||
expect(parseGitHead('ref: refs/heads/main\n')).toBe('main')
|
||||
})
|
||||
@@ -112,7 +116,7 @@ describe('parseGitHead', () => {
|
||||
|
||||
// ── buildProjects: discovery (real temp dir) ─────────────────────────────────────
|
||||
|
||||
describe('buildProjects — discovery', () => {
|
||||
describe('buildProjects — discovery', { timeout: 30_000 }, () => {
|
||||
it('finds git repos with correct names/branches, skips node_modules, respects depth', async () => {
|
||||
await makeRepo(path.join(tmp, 'repoA'), 'main') // depth 1
|
||||
await makeRepo(path.join(tmp, 'sub', 'repoB'), 'dev') // depth 2
|
||||
@@ -184,7 +188,7 @@ describe('buildProjects — discovery', () => {
|
||||
|
||||
// ── buildProjects: live-session merge ────────────────────────────────────────────
|
||||
|
||||
describe('buildProjects — session merge', () => {
|
||||
describe('buildProjects — session merge', { timeout: 30_000 }, () => {
|
||||
it('merges sessions into the owning project by cwd === path or under path', async () => {
|
||||
await makeRepo(path.join(tmp, 'repoA'), 'main')
|
||||
const repoPath = path.join(tmp, 'repoA')
|
||||
@@ -208,6 +212,7 @@ describe('buildProjects — session merge', () => {
|
||||
clientCount: 2,
|
||||
createdAt: 111,
|
||||
exited: false,
|
||||
cwd: repoPath, // w6/G7 — needed to attribute a session to its worktree
|
||||
})
|
||||
expect(repoA.sessions.find((s) => s.id === 's-under')!.title).toBe('http')
|
||||
})
|
||||
@@ -257,7 +262,7 @@ describe('buildProjects — session merge', () => {
|
||||
|
||||
// ── buildProjects: history merge + sorting ───────────────────────────────────────
|
||||
|
||||
describe('buildProjects — history merge & sorting', () => {
|
||||
describe('buildProjects — history merge & sorting', { timeout: 30_000 }, () => {
|
||||
it('adds history-only cwds as projects and sets lastActiveMs on discovered repos', async () => {
|
||||
await makeRepo(path.join(tmp, 'repoA'), 'main')
|
||||
const repoPath = path.join(tmp, 'repoA')
|
||||
@@ -335,7 +340,7 @@ describe('buildProjects — history merge & sorting', () => {
|
||||
|
||||
// ── buildProjects: in-flight promise deduplication (Finding 4) ─────────────────────
|
||||
|
||||
describe('buildProjects — concurrent cache-miss deduplication', () => {
|
||||
describe('buildProjects — concurrent cache-miss deduplication', { timeout: 30_000 }, () => {
|
||||
it('two concurrent cold-cache calls return consistent results (no corrupt state)', async () => {
|
||||
await makeRepo(path.join(tmp, 'repoA'), 'main')
|
||||
await makeRepo(path.join(tmp, 'repoB'), 'dev')
|
||||
@@ -370,7 +375,7 @@ describe('buildProjects — concurrent cache-miss deduplication', () => {
|
||||
|
||||
// ── buildProjects: dirty check (real git, when available) ─────────────────────────
|
||||
|
||||
describe('buildProjects — dirty check', () => {
|
||||
describe('buildProjects — dirty check', { timeout: 30_000 }, () => {
|
||||
it('computes dirty via git status only when projectDirtyCheck is true', async () => {
|
||||
if (!(await gitAvailable())) return // git not installed -> skip
|
||||
const repo = path.join(tmp, 'gitrepo')
|
||||
@@ -404,7 +409,7 @@ describe('buildProjects — dirty check', () => {
|
||||
|
||||
// ── W3(a) sync chip — ahead/behind vs upstream + last-commit time ──────────────
|
||||
|
||||
describe('buildProjects — sync fields (W3 a)', () => {
|
||||
describe('buildProjects — sync fields (W3 a)', { timeout: 30_000 }, () => {
|
||||
async function commit(cwd: string, file: string, msg: string): Promise<void> {
|
||||
await fs.writeFile(path.join(cwd, file), `${file}\n`)
|
||||
await execFileP('git', ['add', '.'], { cwd })
|
||||
@@ -482,7 +487,7 @@ describe('buildProjects — sync fields (W3 a)', () => {
|
||||
|
||||
// ── buildProjectDetail ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildProjectDetail', () => {
|
||||
describe('buildProjectDetail', { timeout: 30_000 }, () => {
|
||||
it('returns null for a relative path', async () => {
|
||||
expect(await buildProjectDetail(makeCfg({}), 'relative/dir', [])).toBeNull()
|
||||
})
|
||||
@@ -566,3 +571,220 @@ describe('buildProjectDetail', () => {
|
||||
expect(d!.dirty).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── w6/G1 sync state on the detail view ────────────────────────────────────────
|
||||
// The panel's whole point is to be trusted, so these tests pin the *absence* of
|
||||
// numbers as hard as their presence: "no upstream" must never look like "in sync".
|
||||
|
||||
// Each case spawns 6–12 sequential `git` processes; vitest's 5 s default is not a
|
||||
// budget for that once the suite runs these files in parallel.
|
||||
describe('buildProjectDetail — sync state (w6 G1)', { timeout: 30_000 }, () => {
|
||||
async function commit(cwd: string, file: string, msg: string): Promise<void> {
|
||||
await fs.writeFile(path.join(cwd, file), `${file}\n`)
|
||||
await execFileP('git', ['add', '.'], { cwd })
|
||||
await execFileP('git', ['commit', '-q', '-m', msg], { cwd })
|
||||
}
|
||||
|
||||
async function initRepo(cwd: string): Promise<void> {
|
||||
await fs.mkdir(cwd, { recursive: true })
|
||||
await execFileP('git', ['init', '-q', '-b', 'main'], { cwd })
|
||||
await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd })
|
||||
await execFileP('git', ['config', 'user.name', 'tester'], { cwd })
|
||||
await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd })
|
||||
}
|
||||
|
||||
it('reports upstream name, ahead and a fetch timestamp for a tracking clone', async () => {
|
||||
if (!(await gitAvailable())) return
|
||||
|
||||
const upstream = path.join(tmp, 'up-detail')
|
||||
await initRepo(upstream)
|
||||
await commit(upstream, 'a.txt', 'first')
|
||||
|
||||
const cloneRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-syncdetail-'))
|
||||
const clone = path.join(cloneRoot, 'clone')
|
||||
await execFileP('git', ['clone', '-q', upstream, clone])
|
||||
await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: clone })
|
||||
await execFileP('git', ['config', 'user.name', 'tester'], { cwd: clone })
|
||||
await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd: clone })
|
||||
|
||||
await commit(clone, 'local.txt', 'local ahead')
|
||||
await execFileP('git', ['fetch', '-q'], { cwd: clone })
|
||||
|
||||
const cfg = makeCfg({ projectDirtyCheck: true })
|
||||
const d = await buildProjectDetail(cfg, clone, [])
|
||||
|
||||
expect(d!.sync).toBeDefined()
|
||||
expect(d!.sync!.upstream).toBe('origin/main')
|
||||
expect(d!.sync!.ahead).toBe(1)
|
||||
expect(d!.sync!.behind).toBe(0)
|
||||
expect(d!.sync!.detached).not.toBe(true)
|
||||
expect(typeof d!.sync!.lastFetchMs).toBe('number')
|
||||
|
||||
await fs.rm(cloneRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('leaves upstream AND ahead/behind undefined when the branch tracks nothing', async () => {
|
||||
if (!(await gitAvailable())) return
|
||||
const repo = path.join(tmp, 'g1-noupstream')
|
||||
await initRepo(repo)
|
||||
await commit(repo, 'a.txt', 'only')
|
||||
|
||||
const d = await buildProjectDetail(makeCfg({}), repo, [])
|
||||
|
||||
// All three together — a UI that sees ahead===undefined must be able to tell
|
||||
// "nothing to compare against" from "nothing to push".
|
||||
expect(d!.sync!.upstream).toBeUndefined()
|
||||
expect(d!.sync!.ahead).toBeUndefined()
|
||||
expect(d!.sync!.behind).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never invents a fetch time for a repo that was never fetched', async () => {
|
||||
if (!(await gitAvailable())) return
|
||||
const repo = path.join(tmp, 'g1-nofetch')
|
||||
await initRepo(repo)
|
||||
await commit(repo, 'a.txt', 'only')
|
||||
|
||||
const d = await buildProjectDetail(makeCfg({}), repo, [])
|
||||
expect(d!.sync!.lastFetchMs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('flags a detached HEAD and reports no branch or ahead/behind', async () => {
|
||||
if (!(await gitAvailable())) return
|
||||
const repo = path.join(tmp, 'g1-detached')
|
||||
await initRepo(repo)
|
||||
await commit(repo, 'a.txt', 'first')
|
||||
await commit(repo, 'b.txt', 'second')
|
||||
const { stdout } = await execFileP('git', ['rev-parse', 'HEAD~1'], { cwd: repo })
|
||||
await execFileP('git', ['checkout', '-q', stdout.trim()], { cwd: repo })
|
||||
|
||||
const d = await buildProjectDetail(makeCfg({}), repo, [])
|
||||
expect(d!.sync!.detached).toBe(true)
|
||||
expect(d!.branch).toBeUndefined()
|
||||
expect(d!.sync!.ahead).toBeUndefined()
|
||||
expect(d!.sync!.behind).toBeUndefined()
|
||||
})
|
||||
|
||||
it('counts dirty files instead of only flagging them', async () => {
|
||||
if (!(await gitAvailable())) return
|
||||
const repo = path.join(tmp, 'g1-dirty')
|
||||
await initRepo(repo)
|
||||
await commit(repo, 'a.txt', 'first')
|
||||
await fs.writeFile(path.join(repo, 'a.txt'), 'changed\n')
|
||||
await fs.writeFile(path.join(repo, 'new.txt'), 'new\n')
|
||||
|
||||
const d = await buildProjectDetail(makeCfg({ projectDirtyCheck: true }), repo, [])
|
||||
expect(d!.dirtyCount).toBe(2)
|
||||
expect(d!.dirty).toBe(true) // legacy field the mobile clients decode — still there
|
||||
})
|
||||
|
||||
it('omits sync entirely for a non-git directory', async () => {
|
||||
const plain = path.join(tmp, 'g1-plain')
|
||||
await fs.mkdir(plain, { recursive: true })
|
||||
|
||||
const d = await buildProjectDetail(makeCfg({ projectDirtyCheck: true }), plain, [])
|
||||
expect(d!.isGit).toBe(false)
|
||||
expect(d!.sync).toBeUndefined()
|
||||
expect(d!.dirtyCount).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ── w6/G6 concurrent-probe coalescing ─────────────────────────────────────────
|
||||
// N devices watching one repo must cost ONE probe, not N. Deliberately in-flight
|
||||
// only: nothing is cached across time, so the numbers can never go stale.
|
||||
|
||||
describe('buildProjectDetail — coalescing (w6 G6)', { timeout: 30_000 }, () => {
|
||||
it('serves concurrent probes of the same repo from one in-flight promise', async () => {
|
||||
if (!(await gitAvailable())) return
|
||||
const repo = path.join(tmp, 'g6-coalesce')
|
||||
await fs.mkdir(repo, { recursive: true })
|
||||
await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: repo })
|
||||
|
||||
const cfg = makeCfg({ projectDirtyCheck: true })
|
||||
const [a, b, c] = await Promise.all([
|
||||
buildProjectDetail(cfg, repo, []),
|
||||
buildProjectDetail(cfg, repo, []),
|
||||
buildProjectDetail(cfg, repo, []),
|
||||
])
|
||||
expect(a).toBe(b) // same object identity ⇒ one probe served all three
|
||||
expect(b).toBe(c)
|
||||
})
|
||||
|
||||
it('re-probes on the next call — the entry is dropped once it settles', async () => {
|
||||
if (!(await gitAvailable())) return
|
||||
const repo = path.join(tmp, 'g6-fresh')
|
||||
await fs.mkdir(repo, { recursive: true })
|
||||
await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: repo })
|
||||
|
||||
const cfg = makeCfg({ projectDirtyCheck: true })
|
||||
const first = await buildProjectDetail(cfg, repo, [])
|
||||
expect(first!.dirtyCount).toBe(0)
|
||||
|
||||
await fs.writeFile(path.join(repo, 'new.txt'), 'x\n')
|
||||
const second = await buildProjectDetail(cfg, repo, [])
|
||||
expect(second).not.toBe(first) // not a time-based cache
|
||||
expect(second!.dirtyCount).toBe(1) // and it sees the change immediately
|
||||
})
|
||||
|
||||
it('does not let two different repos share one probe', async () => {
|
||||
if (!(await gitAvailable())) return
|
||||
const one = path.join(tmp, 'g6-one')
|
||||
const two = path.join(tmp, 'g6-two')
|
||||
await fs.mkdir(one, { recursive: true })
|
||||
await fs.mkdir(two, { recursive: true })
|
||||
|
||||
const cfg = makeCfg({})
|
||||
const [a, b] = await Promise.all([
|
||||
buildProjectDetail(cfg, one, []),
|
||||
buildProjectDetail(cfg, two, []),
|
||||
])
|
||||
expect(a!.path).toBe(one)
|
||||
expect(b!.path).toBe(two)
|
||||
})
|
||||
})
|
||||
|
||||
// ── w6/G7 per-worktree state ──────────────────────────────────────────────────
|
||||
|
||||
describe('buildWorktreeState (w6 G7)', { timeout: 30_000 }, () => {
|
||||
async function initRepo(cwd: string): Promise<void> {
|
||||
await fs.mkdir(cwd, { recursive: true })
|
||||
await execFileP('git', ['init', '-q', '-b', 'main'], { cwd })
|
||||
await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd })
|
||||
await execFileP('git', ['config', 'user.name', 'tester'], { cwd })
|
||||
await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd })
|
||||
}
|
||||
|
||||
it('reads a LINKED worktree, where .git is a file rather than a directory', async () => {
|
||||
if (!(await gitAvailable())) return
|
||||
const repo = path.join(tmp, 'g7-main')
|
||||
await initRepo(repo)
|
||||
await fs.writeFile(path.join(repo, 'a.txt'), 'a\n')
|
||||
await execFileP('git', ['add', '.'], { cwd: repo })
|
||||
await execFileP('git', ['commit', '-q', '-m', 'first'], { cwd: repo })
|
||||
|
||||
const wtPath = path.join(repo, '.claude', 'worktrees', 'feature')
|
||||
await execFileP('git', ['worktree', 'add', '-q', '-b', 'feature', wtPath], { cwd: repo })
|
||||
|
||||
// The pre-G1 reader assumed <repo>/.git was a directory and returned nothing here.
|
||||
const dotGit = await fs.stat(path.join(wtPath, '.git'))
|
||||
expect(dotGit.isDirectory()).toBe(false)
|
||||
|
||||
const state = await buildWorktreeState(makeCfg({ projectDirtyCheck: true }), wtPath)
|
||||
expect(state!.path).toBe(wtPath)
|
||||
expect(state!.branch).toBe('feature')
|
||||
expect(state!.dirtyCount).toBe(0)
|
||||
// A fresh worktree branch tracks nothing — it must say so, not look settled.
|
||||
expect(state!.sync!.upstream).toBeUndefined()
|
||||
expect(state!.sync!.ahead).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns null for a directory that is not a git repo', async () => {
|
||||
const plain = path.join(tmp, 'g7-plain')
|
||||
await fs.mkdir(plain, { recursive: true })
|
||||
expect(await buildWorktreeState(makeCfg({}), plain)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a relative path and for one that does not exist', async () => {
|
||||
expect(await buildWorktreeState(makeCfg({}), 'relative/path')).toBeNull()
|
||||
expect(await buildWorktreeState(makeCfg({}), path.join(tmp, 'g7-missing'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user