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,

View File

@@ -45,7 +45,7 @@ import {
import { parseHookEvent } from './http/hook.js'
import { deriveApprovalPreview } from './http/approval-preview.js'
import { listSessions } from './http/history.js'
import { buildProjects, buildProjectDetail } from './http/projects.js'
import { buildProjects, buildProjectDetail, buildWorktreeState } from './http/projects.js'
import { openInEditor, openFileInEditor } from './http/editor.js'
import { getDiff, isPlausibleRev } from './http/diff.js'
import { getGitLog } from './http/git-log.js'
@@ -54,7 +54,12 @@ import { getPrStatus } from './http/gh.js'
import { parseStatusLine } from './http/statusline.js'
import { createWorktree, removeWorktree, pruneWorktrees } from './http/worktrees.js'
import { groupSessionsByRepo } from './http/session-groups.js'
import { stageFiles, commit as gitCommit, push as gitPush } from './http/git-ops.js'
import {
stageFiles,
commit as gitCommit,
push as gitPush,
fetch as gitFetch,
} from './http/git-ops.js'
import { createSessionManager } from './session/manager.js'
import { detachWs, writeInput, setClientDims } from './session/session.js'
import { loadSubscriptionStore } from './push/subscription-store.js'
@@ -248,6 +253,9 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
const queueLimiter = createRateLimiter(QUEUE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W2
const gitWriteLimiter = createRateLimiter(GIT_WRITE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 stage/commit
const gitPushLimiter = createRateLimiter(GIT_PUSH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 push
// w6/G2 fetch gets its OWN bucket: it is the panel's refresh button, so a burst
// of fetches must never eat the budget a real push needs.
const gitFetchLimiter = createRateLimiter(GIT_PUSH_RATE_MAX, RATE_LIMIT_WINDOW_MS)
const authLimiter = createRateLimiter(AUTH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // w5-access-token
// W2: per-session settle timers for the idle-drain. A Stop/SessionEnd hook
@@ -527,6 +535,32 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// Project detail (v0.6) — branch/worktrees + running sessions for one repo.
// Read-only (git branch/status/worktree-list); same threat model as /projects.
// ?path must be an absolute existing directory (validated in buildProjectDetail).
// w6/G7 — one worktree's git state, fetched lazily per row. Read-only, and
// deliberately narrower than /projects/detail: a row shows branch/sync/dirty,
// so listing worktrees and reading CLAUDE.md for each of N rows would spend
// spawns on data nothing renders.
app.get('/projects/worktree/state', async (req, res) => {
const target = req.query['path']
if (typeof target !== 'string' || target === '') {
res.status(400).json({ error: 'path query parameter is required' })
return
}
try {
const state = await buildWorktreeState(cfg, target)
if (state === null) {
res.status(404).json({ error: 'worktree not found' })
return
}
res.json(state)
} catch (err) {
console.error(
'[server] /projects/worktree/state failed:',
err instanceof Error ? err.message : String(err),
)
res.status(500).json({ error: 'failed to read worktree state' })
}
})
app.get('/projects/detail', async (req, res) => {
const target = req.query['path']
if (typeof target !== 'string' || target === '') {
@@ -1248,6 +1282,40 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' })
})
// w6/G2 — refresh remote-tracking refs so the panel's `behind` stops being a
// stale guess. Read-only against the working tree (refs/remotes only, never a
// pull); the remote is derived server-side inside gitFetch, never from the body.
// Shares the gitOpsEnabled kill-switch because it is the one route that talks to
// the network with the host's git credentials.
app.post('/projects/git/fetch', express.json({ limit: '4kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!cfg.gitOpsEnabled) {
res.status(403).json({ error: 'Git operations are disabled.' })
return
}
if (!gitFetchLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).json({ error: 'Too many requests.' })
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
if (repoPath === undefined) {
res.status(400).json({ error: 'path is required' })
return
}
if (!(await isValidGitDir(repoPath))) {
res.status(404).json({ error: 'project not found' })
return
}
console.error(`[server] git fetch: path=${sanitizeForLog(repoPath)}`)
const result = await gitFetch(repoPath, { timeoutMs: cfg.gitPushTimeoutMs })
if (result.ok) {
res.status(200).json({ ok: true, remote: result.remote, lastFetchMs: result.lastFetchMs })
return
}
res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' })
})
// ── GET /config/ui (review #4) — client-readable UI config (read-only) ────
app.get('/config/ui', (_req, res) => {
const uiConfig: UiConfig = {

View File

@@ -350,6 +350,7 @@ export interface ProjectSessionRef {
clientCount: number; // mirror devices currently attached
createdAt: number;
exited: boolean;
cwd?: string; // w6/G7: where it runs — lets the UI attribute it to a worktree
}
/** A discovered project (git repo or recently-used cwd) for the Projects panel.
@@ -379,14 +380,43 @@ export interface WorktreeInfo {
prunable?: boolean; // git considers it prunable (gone working tree)
}
/** w6/G1: upstream sync state for one repo or worktree (impl: src/http/projects.ts
* readSyncState). Every field degrades independently — no upstream, detached HEAD,
* empty repo and never-fetched are all normal, and each leaves its field undefined.
*
* `ahead` is always trustworthy (local refs only). `behind` is only as fresh as
* `lastFetchMs`, because `@{u}` is a locally cached remote ref that a fetch is the
* only thing that moves — the UI MUST NOT render a stale `behind: 0` as "in sync".
* Likewise `upstream === undefined` means "nothing to compare against", which is
* NOT the same as "nothing to push" and must never render as synced. */
export interface SyncState {
upstream?: string; // e.g. 'origin/develop'; undefined ⇒ branch tracks nothing
ahead?: number; // commits on HEAD not on @{u}
behind?: number; // commits on @{u} not on HEAD — trust only with a fresh lastFetchMs
lastFetchMs?: number; // FETCH_HEAD mtime; undefined ⇒ never fetched
detached?: boolean; // HEAD is not on a branch ⇒ no branch, no ahead/behind
}
/** w6/G7: the git state of ONE worktree, fetched lazily per row.
* impl: src/http/projects.ts buildWorktreeState — GET /projects/worktree/state. */
export interface WorktreeState {
path: string;
branch?: string;
sync?: SyncState;
dirtyCount?: number;
}
/** Detailed view of one project: branch/worktrees + its running sessions.
* impl: src/http/projects.ts buildProjectDetail(cfg, path, liveSessions). */
* impl: src/http/projects.ts buildProjectDetail(cfg, path, liveSessions).
* NOTE: fields here are decoded by the Android/iOS clients — additive only. */
export interface ProjectDetail {
name: string;
path: string;
isGit: boolean;
branch?: string;
dirty?: boolean;
dirtyCount?: number; // w6/G1: porcelain line count (same gate as `dirty`)
sync?: SyncState; // w6/G1: git repos only; undefined for a non-git dir
worktrees: WorktreeInfo[]; // empty for a non-git dir
sessions: ProjectSessionRef[]; // running sessions under this path (fresh)
hasClaudeMd: boolean; // a CLAUDE.md exists at the project root
@@ -662,7 +692,8 @@ export interface GitOpResult {
count?: number; // stage: number of files affected
commit?: string; // commit: short SHA of the new commit
branch?: string; // push: branch that was pushed
remote?: string; // push: remote it was pushed to
remote?: string; // push/fetch: remote it talked to
lastFetchMs?: number; // fetch: FETCH_HEAD mtime after the fetch (w6/G2)
}
/* ── v0.6 Projects UI preferences (server-persisted, cross-device) ── */
@@ -727,12 +758,16 @@ export interface CommitLogEntry {
hash: string;
at: number;
subject: string;
unpushed?: boolean; // w6/G4: reachable from HEAD but not from @{u}
}
/** GET /projects/log result. `truncated` = more commits exist beyond the cap. */
export interface GitLogResult {
commits: CommitLogEntry[];
truncated: boolean;
/** w6/G4: upstream short name, used to label the pushed/unpushed boundary.
* Undefined ⇒ nothing to compare against, so no boundary may be drawn. */
upstream?: string;
}
/* ─────────────────────── frontend (§5/§6.3) ──────────────────── */