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:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
39
src/types.ts
39
src/types.ts
@@ -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) ──────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user