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:
@@ -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