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:
148
public/style.css
148
public/style.css
@@ -1559,6 +1559,154 @@ body {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ── w6/G3: the project detail sync band ──────────────────────────────────────
|
||||
Semantic colours here are deliberately NOT the accent: --ok reads "verified,
|
||||
ignore this" and is the one state that may look reassuring, so it must never
|
||||
be reachable from a stale or unknowable comparison (see makeSyncBand). */
|
||||
.proj-syncband {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin: 10px 0 4px;
|
||||
padding: 9px 12px;
|
||||
background: var(--bg-sunk, rgba(0, 0, 0, 0.22));
|
||||
border: 1px solid var(--border, rgba(255, 255, 255, 0.08));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.proj-sync-upstream {
|
||||
font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 11.5px;
|
||||
color: var(--fg-dim, #a8a299);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.proj-sync-chip {
|
||||
font-size: 11.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
border-radius: 5px;
|
||||
padding: 2px 8px;
|
||||
white-space: nowrap;
|
||||
flex: none;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.proj-sync-ahead {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.proj-sync-behind {
|
||||
color: var(--fg-dim, #a8a299);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* The one reassuring state. Reachable only with an upstream AND a fresh fetch. */
|
||||
.proj-sync-ok {
|
||||
color: #7d9b76;
|
||||
background: rgba(125, 155, 118, 0.13);
|
||||
border-color: rgba(125, 155, 118, 0.3);
|
||||
}
|
||||
|
||||
/* "This number may be lying" — never applied to ahead, which needs no fetch. */
|
||||
.proj-sync-stale,
|
||||
.proj-sync-noupstream,
|
||||
.proj-sync-detached {
|
||||
color: #cf6b4f;
|
||||
background: rgba(207, 107, 79, 0.14);
|
||||
border-color: rgba(207, 107, 79, 0.36);
|
||||
}
|
||||
|
||||
.proj-sync-dirty {
|
||||
color: var(--amber);
|
||||
background: rgba(224, 151, 90, 0.13);
|
||||
}
|
||||
|
||||
.proj-sync-fetch {
|
||||
margin-left: auto;
|
||||
font: inherit;
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-strong, rgba(255, 255, 255, 0.14));
|
||||
background: transparent;
|
||||
color: var(--fg, #ece9e3);
|
||||
}
|
||||
|
||||
.proj-sync-fetch:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.proj-sync-fetch:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── w6/G4: unpushed commits + the upstream boundary ───────────────────────── */
|
||||
.proj-commit-unpushed {
|
||||
position: relative;
|
||||
background: linear-gradient(90deg, var(--accent-soft), transparent 55%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.proj-commit-unpushed::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.proj-commit-mark {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
flex: none;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Where the upstream actually sits in this history — never drawn without one. */
|
||||
.proj-commit-boundary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 7px 0 5px;
|
||||
}
|
||||
|
||||
.proj-commit-boundary-label {
|
||||
font-size: 10.5px;
|
||||
color: var(--fg-dim, #a8a299);
|
||||
border: 1px solid var(--border, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
white-space: nowrap;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.proj-commit-boundary-rule {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-strong, rgba(255, 255, 255, 0.14));
|
||||
}
|
||||
|
||||
/* w6/G7: running sessions attributed to one worktree row. */
|
||||
.proj-wt-sessions {
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border-radius: 5px;
|
||||
padding: 2px 7px;
|
||||
white-space: nowrap;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* W3(b): cost chip in the per-tab telemetry gauge, warn-styled over budget. */
|
||||
.tg-cost-warn {
|
||||
color: var(--red);
|
||||
|
||||
Reference in New Issue
Block a user