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:
Yaojia Wang
2026-07-29 17:12:00 +02:00
parent 553a00c32f
commit 8fe1f52e5d
16 changed files with 2005 additions and 30 deletions

View File

@@ -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)
})
})