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

@@ -102,8 +102,67 @@ export async function getGitLog(repoPath: string, opts: GetGitLogOptions): Promi
['log', '--no-color', '-z', '-n', String(n), '--format=%h%x1f%ct%x1f%s'],
{ cwd: repoPath, timeout: opts.timeoutMs, maxBuffer: GIT_LOG_MAX_BUFFER },
)
return parseGitLog(stdout, n)
const parsed = parseGitLog(stdout, n)
return await markUnpushed(repoPath, parsed, opts.timeoutMs)
} catch {
return { commits: [], truncated: false }
}
}
/** Cap on how many unpushed commits we enumerate — a runaway branch must not
* turn one log read into an unbounded buffer. */
const UNPUSHED_MAX = 200
/**
* w6/G4 — tag the commits that exist only locally, and name the upstream so the
* client can draw the boundary between pushed and unpushed history.
*
* Marking is done HERE, from the authoritative `rev-list` set, and never inferred
* on the client from row order: `git log` is date-ordered, so merging an older
* branch interleaves unpushed commits below pushed ones and "the first N rows are
* unpushed" silently mislabels them as pushed — the same class of confident lie
* the sync band exists to prevent.
*
* No upstream / detached / empty repo → the input is returned untouched (no
* `upstream`, no marks), so the client draws no boundary. Never throws.
*/
async function markUnpushed(
repoPath: string,
result: GitLogResult,
timeoutMs: number,
): Promise<GitLogResult> {
if (result.commits.length === 0) return result
let upstream: string
try {
const { stdout } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
{ cwd: repoPath, timeout: timeoutMs, maxBuffer: GIT_LOG_MAX_BUFFER },
)
upstream = stdout.trim()
if (upstream === '') return result
} catch {
return result // no upstream configured / detached — nothing to compare against
}
let fullHashes: string[]
try {
const { stdout } = await execFileAsync(
'git',
['rev-list', `--max-count=${UNPUSHED_MAX}`, '@{u}..HEAD'],
{ cwd: repoPath, timeout: timeoutMs, maxBuffer: GIT_LOG_MAX_BUFFER },
)
fullHashes = stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '')
} catch {
return result
}
// `hash` is abbreviated (%h) while rev-list yields full SHAs, and the two can
// disagree on width, so match by prefix rather than equality. Bounded by
// GIT_LOG_MAX × UNPUSHED_MAX string compares — trivial, and always correct.
const commits = result.commits.map((c) =>
fullHashes.some((full) => full.startsWith(c.hash)) ? { ...c, unpushed: true } : c,
)
return { ...result, commits, upstream }
}

View File

@@ -359,3 +359,85 @@ export async function push(repoPath: string, opts: PushOptions): Promise<GitOpRe
return failFromError(err)
}
}
// ── fetch (w6/G2) ─────────────────────────────────────────────────────────────
/**
* Refresh the remote-tracking refs so `behind` stops lying. This is the ONLY
* network read the panel performs, and it is deliberately the weakest possible
* one: `git fetch` updates `refs/remotes/*` and nothing else — no working tree,
* no index, no branch, no merge. It is NOT a pull.
*
* Same discipline as push (SEC): the remote is ALWAYS derived server-side — the
* current branch's upstream, else the sole remote — and no remote or refspec is
* ever read from the caller, so a client cannot point it at an arbitrary URL.
* Zero remotes → 400; ≥2 remotes with no upstream → 409 (ambiguous).
*
* Returns {ok, remote, lastFetchMs}. Never throws.
*/
export async function fetch(repoPath: string, opts: PushOptions): Promise<GitOpResult> {
if (!(await isGitDir(repoPath))) {
return { ok: false, status: 404, error: 'Not a git repository.' }
}
const upstream = await upstreamRemote(repoPath, opts.timeoutMs)
let args: string[]
let targetRemote: string
if (upstream !== null) {
// Upstream set → bare fetch; git resolves the current branch's remote itself.
args = ['fetch', '--no-tags', '--quiet']
targetRemote = upstream
} else {
const remotes = await listRemotes(repoPath, opts.timeoutMs)
if (remotes.length === 0) {
return { ok: false, status: 400, error: 'No remote configured.' }
}
if (remotes.length > 1) {
return { ok: false, status: 409, error: 'Set an upstream first (multiple remotes).' }
}
const sole = remotes[0] as string
// A remote named like a flag would otherwise be parsed as one.
if (sole.startsWith('-')) {
return { ok: false, status: 400, error: 'Cannot fetch from this remote.' }
}
args = ['fetch', '--no-tags', '--quiet', sole]
targetRemote = sole
}
try {
await execFileAsync('git', args, {
cwd: repoPath,
timeout: opts.timeoutMs,
maxBuffer: GIT_MAX_BUFFER,
env: NO_PROMPT_ENV,
})
} catch (err: unknown) {
// Offline / auth failure: leave lastFetchMs alone so the UI keeps showing
// "stale" rather than silently marking the data as freshly verified.
return failFromError(err)
}
return { ok: true, remote: targetRemote, lastFetchMs: await fetchHeadMtime(repoPath) }
}
/** FETCH_HEAD mtime after a successful fetch; undefined if it cannot be read.
* Resolves `.git` first so a linked worktree reads the shared common dir. */
async function fetchHeadMtime(repoPath: string): Promise<number | undefined> {
try {
const dotGit = path.join(repoPath, '.git')
const stat = await fs.stat(dotGit)
let commonDir = dotGit
if (!stat.isDirectory()) {
const match = /^gitdir:\s*(.+)$/m.exec(await fs.readFile(dotGit, 'utf8'))
const raw = match?.[1]?.trim()
if (raw === undefined || raw === '') return undefined
const gitDir = path.isAbsolute(raw) ? raw : path.resolve(repoPath, raw)
const parent = path.dirname(gitDir)
commonDir = path.basename(parent) === 'worktrees' ? path.dirname(parent) : gitDir
}
return (await fs.stat(path.join(commonDir, 'FETCH_HEAD'))).mtimeMs
} catch {
return undefined
}
}

View File

@@ -25,6 +25,8 @@ import type {
ProjectInfo,
ProjectSessionRef,
ProjectDetail,
SyncState,
WorktreeState,
} from '../types.js'
import { listSessions } from './history.js'
import { listWorktrees } from './worktrees.js'
@@ -84,10 +86,14 @@ function shouldSkipDir(name: string): boolean {
// ── per-repo metadata (best-effort) ─────────────────────────────────────────────
/** Read the current branch from `<repo>/.git/HEAD`; undefined if unreadable. */
/** Read the current branch from the repo's HEAD; undefined if unreadable or
* detached. Resolves `.git` first (w6/G1) so a linked worktree — where `.git` is
* a file, not a directory — reports its own branch instead of nothing. */
async function readBranch(repoPath: string): Promise<string | undefined> {
const dirs = await resolveGitDirs(repoPath)
if (dirs === null) return undefined
try {
const head = await fs.readFile(path.join(repoPath, '.git', 'HEAD'), 'utf8')
const head = await fs.readFile(path.join(dirs.gitDir, 'HEAD'), 'utf8')
return parseGitHead(head) ?? undefined
} catch {
return undefined
@@ -96,18 +102,117 @@ async function readBranch(repoPath: string): Promise<string | undefined> {
/** `git status --porcelain` → dirty?; undefined on error/timeout/non-repo. */
async function readDirty(repoPath: string): Promise<boolean | undefined> {
return (await readDirtyCount(repoPath)).dirty
}
/** w6/G1: one `git status --porcelain` → both the boolean and the line count, so
* the header can show `● 3` instead of a bare dot without a second spawn. Both
* fields degrade together (undefined on error/timeout/non-repo). */
async function readDirtyCount(
repoPath: string,
): Promise<{ dirty?: boolean; dirtyCount?: number }> {
try {
const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
cwd: repoPath,
timeout: GIT_STATUS_TIMEOUT_MS,
maxBuffer: GIT_STATUS_MAX_BUFFER,
})
return stdout.trim().length > 0
const body = stdout.replace(/\n+$/, '')
const count = body.length === 0 ? 0 : body.split('\n').length
return { dirty: count > 0, dirtyCount: count }
} catch {
return {}
}
}
/**
* w6/G1: resolve a repo's git directories without spawning git.
*
* - `gitDir` — where HEAD lives. For a linked worktree `<repo>/.git` is a *file*
* containing `gitdir: <path>`, not a directory (the pre-existing readBranch
* assumed a directory, so it silently failed inside every worktree).
* - `commonDir` — where FETCH_HEAD lives, shared by all worktrees of a repo. For a
* linked worktree that is `<common>/worktrees/<name>` → up two levels.
*
* Returns null when `<repo>/.git` is missing or unreadable.
*/
async function resolveGitDirs(
repoPath: string,
): Promise<{ gitDir: string; commonDir: string } | null> {
const dotGit = path.join(repoPath, '.git')
let stat
try {
stat = await fs.stat(dotGit)
} catch {
return null
}
if (stat.isDirectory()) return { gitDir: dotGit, commonDir: dotGit }
try {
const text = await fs.readFile(dotGit, 'utf8')
const match = /^gitdir:\s*(.+)$/m.exec(text)
const raw = match?.[1]?.trim()
if (raw === undefined || raw === '') return null
const gitDir = path.isAbsolute(raw) ? raw : path.resolve(repoPath, raw)
const parent = path.dirname(gitDir)
const commonDir = path.basename(parent) === 'worktrees' ? path.dirname(parent) : gitDir
return { gitDir, commonDir }
} catch {
return null
}
}
/** w6/G1: `<commonDir>/FETCH_HEAD` mtime, or undefined if the repo was never
* fetched. A file stat, no spawn. Undefined must stay undefined — inventing a
* timestamp here would make a stale `behind` look freshly verified. */
async function readLastFetchMs(commonDir: string): Promise<number | undefined> {
try {
const stat = await fs.stat(path.join(commonDir, 'FETCH_HEAD'))
return stat.mtimeMs
} catch {
return undefined
}
}
/** w6/G1: the upstream's short name (`origin/develop`) for the current branch;
* undefined when the branch tracks nothing or HEAD is detached. */
async function readUpstream(repoPath: string): Promise<string | undefined> {
try {
const { stdout } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
{ cwd: repoPath, timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: GIT_STATUS_MAX_BUFFER },
)
const name = stdout.trim()
return name === '' ? undefined : name
} catch {
return undefined
}
}
/** w6/G1: the full sync state for the detail header — upstream + ahead/behind +
* last fetch + detached. Reuses readSync for the counts (same two git calls the
* project list already makes) and adds two cheap reads on top. Never throws. */
async function readSyncState(repoPath: string): Promise<SyncState> {
const dirs = await resolveGitDirs(repoPath)
const [counts, upstream, lastFetchMs, headText] = await Promise.all([
readSync(repoPath),
readUpstream(repoPath),
dirs === null ? Promise.resolve(undefined) : readLastFetchMs(dirs.commonDir),
dirs === null
? Promise.resolve(null)
: fs.readFile(path.join(dirs.gitDir, 'HEAD'), 'utf8').catch(() => null),
])
const state: SyncState = {}
if (upstream !== undefined) state.upstream = upstream
if (counts.ahead !== undefined) state.ahead = counts.ahead
if (counts.behind !== undefined) state.behind = counts.behind
if (lastFetchMs !== undefined) state.lastFetchMs = lastFetchMs
if (headText !== null && parseGitHead(headText) === null) state.detached = true
return state
}
/** W3 quick-wins (a): best-effort ahead/behind vs upstream + last-commit time.
* Two read-only git calls (no shell), each bounded by timeout + maxBuffer:
* 1. `git rev-list --count --left-right @{u}...HEAD` → "<behind>\t<ahead>"
@@ -391,6 +496,9 @@ function toSessionRef(s: LiveSessionInfo): ProjectSessionRef {
clientCount: s.clientCount,
createdAt: s.createdAt,
exited: s.exited,
// w6/G7: without cwd the UI cannot say WHICH worktree a session runs in —
// and with one worktree per session that is the question being asked.
...(s.cwd !== null ? { cwd: s.cwd } : {}),
}
}
@@ -470,7 +578,78 @@ async function readClaudeMd(projectPath: string): Promise<string | undefined> {
* cached: the detail view is opened on demand and wants fresh worktree/session
* state. Reads only (git branch/status/worktree-list); never throws.
*/
export async function buildProjectDetail(
/**
* w6/G7 — the git state of ONE worktree, probed on demand.
*
* A linked worktree is just a directory with a `.git` FILE, so the same readers
* the project detail uses work verbatim once resolveGitDirs handles that shape.
* Kept as its own small call (rather than reusing buildProjectDetail) because a
* row only needs branch/sync/dirty — listing worktrees and reading CLAUDE.md for
* every row would spend spawns on data the row never shows.
*
* Returns null for a missing path or a non-git directory. Never throws.
*/
export async function buildWorktreeState(
cfg: Config,
worktreePath: string,
): Promise<WorktreeState | null> {
if (typeof worktreePath !== 'string' || !path.isAbsolute(worktreePath)) return null
try {
const stat = await fs.stat(worktreePath)
if (!stat.isDirectory()) return null
} catch {
return null
}
if (!(await hasGitEntry(worktreePath))) return null
const [branch, dirtyState, sync] = await Promise.all([
readBranch(worktreePath),
cfg.projectDirtyCheck
? readDirtyCount(worktreePath)
: Promise.resolve<{ dirty?: boolean; dirtyCount?: number }>({}),
readSyncState(worktreePath),
])
return {
path: worktreePath,
...(branch !== undefined ? { branch } : {}),
...(dirtyState.dirtyCount !== undefined ? { dirtyCount: dirtyState.dirtyCount } : {}),
sync,
}
}
/**
* w6/G6 — coalesce concurrent probes of the SAME repo into one.
*
* A detail view refreshes every 5 s and spawns several `git` processes per pass,
* on the machine that is also running Claude Code and builds. With the app open
* on a laptop, a phone and a tablet, that cost multiplied by the number of
* devices for no added information.
*
* This deliberately caches NOTHING across time: the entry is dropped the moment
* it settles, so a later call always re-probes. A time-based cache would be the
* cheaper fix and the wrong one — this panel's entire value is that its numbers
* are true right now, and a stale `ahead` after a push is exactly the kind of
* confident lie the design rules out.
*/
const inFlightDetails = new Map<string, Promise<ProjectDetail | null>>()
export function buildProjectDetail(
cfg: Config,
projectPath: string,
liveSessions: readonly LiveSessionInfo[],
): Promise<ProjectDetail | null> {
const existing = inFlightDetails.get(projectPath)
if (existing !== undefined) return existing
const pending = buildProjectDetailUncoalesced(cfg, projectPath, liveSessions).finally(() => {
inFlightDetails.delete(projectPath)
})
inFlightDetails.set(projectPath, pending)
return pending
}
async function buildProjectDetailUncoalesced(
cfg: Config,
projectPath: string,
liveSessions: readonly LiveSessionInfo[],
@@ -486,9 +665,12 @@ export async function buildProjectDetail(
if (!stat.isDirectory()) return null
const isGit = await hasGitEntry(projectPath)
const [branch, dirty, worktrees, claudeMd] = await Promise.all([
const [branch, dirtyState, sync, worktrees, claudeMd] = await Promise.all([
isGit ? readBranch(projectPath) : Promise.resolve(undefined),
isGit && cfg.projectDirtyCheck ? readDirty(projectPath) : Promise.resolve(undefined),
isGit && cfg.projectDirtyCheck
? readDirtyCount(projectPath)
: Promise.resolve<{ dirty?: boolean; dirtyCount?: number }>({}),
isGit ? readSyncState(projectPath) : Promise.resolve(undefined),
isGit ? listWorktrees(projectPath) : Promise.resolve([]),
readClaudeMd(projectPath),
])
@@ -498,7 +680,9 @@ export async function buildProjectDetail(
path: projectPath,
isGit,
branch,
dirty,
dirty: dirtyState.dirty,
dirtyCount: dirtyState.dirtyCount,
sync,
worktrees,
sessions: matchSessions(projectPath, liveSessions),
hasClaudeMd: claudeMd !== undefined,