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