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:
@@ -25,6 +25,7 @@ import {
|
||||
stageFiles,
|
||||
commit,
|
||||
push,
|
||||
fetch as gitFetch,
|
||||
} from '../../src/http/git-ops.js'
|
||||
|
||||
const execFileP = promisify(execFile)
|
||||
@@ -148,7 +149,7 @@ describe('validateRepoFiles', () => {
|
||||
|
||||
// ── stageFiles (real temp repos) ────────────────────────────────────────────────
|
||||
|
||||
describe('stageFiles', () => {
|
||||
describe('stageFiles', { timeout: 30_000 }, () => {
|
||||
it('stages the given files (git add) and unstages them (restore --staged)', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
@@ -183,7 +184,7 @@ describe('stageFiles', () => {
|
||||
|
||||
// ── commit (real temp repos) ────────────────────────────────────────────────────
|
||||
|
||||
describe('commit', () => {
|
||||
describe('commit', { timeout: 30_000 }, () => {
|
||||
it('commits staged changes and returns a short SHA', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
@@ -256,7 +257,7 @@ describe('commit', () => {
|
||||
|
||||
// ── push (real bare-repo remote, no network) ────────────────────────────────────
|
||||
|
||||
describe('push', () => {
|
||||
describe('push', { timeout: 30_000 }, () => {
|
||||
it('first push sets upstream (-u) and the bare remote receives the ref', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
@@ -320,3 +321,99 @@ describe('push', () => {
|
||||
expect(await push(plain, { timeoutMs: TIMEOUT_MS })).toMatchObject({ ok: false, status: 404 })
|
||||
})
|
||||
})
|
||||
|
||||
// ── w6/G2 fetch — read-only refresh of remote-tracking refs ────────────────────
|
||||
// Fetch exists so the panel can stop lying about `behind`. It touches no working
|
||||
// tree, no index and no branch: the only thing it may change is refs/remotes.
|
||||
|
||||
// Same reason as the G1 block: clone + push + fetch is well past a 5 s default.
|
||||
describe('fetch (w6 G2)', { timeout: 30_000 }, () => {
|
||||
it('refuses a non-git directory without spawning git', async () => {
|
||||
if (!gitAvailable) return
|
||||
const plain = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-plain-')))
|
||||
const res = await gitFetch(plain, { timeoutMs: TIMEOUT_MS })
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.ok === false && res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('reports a plain message — never raw git stderr — when no remote exists', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
const res = await gitFetch(repo, { timeoutMs: TIMEOUT_MS })
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.ok === false && res.status).toBe(400)
|
||||
expect(res.ok === false && res.error).toBe('No remote configured.')
|
||||
})
|
||||
|
||||
it('fetches from the sole remote and leaves HEAD and the working tree untouched', async () => {
|
||||
if (!gitAvailable) return
|
||||
const bare = await makeBareRemote()
|
||||
const repo = await makeRepo()
|
||||
const git = async (args: string[]): Promise<void> => {
|
||||
await execFileP('git', args, { cwd: repo })
|
||||
}
|
||||
await git(['remote', 'add', 'origin', bare])
|
||||
await git(['push', '-q', '-u', 'origin', 'main'])
|
||||
|
||||
const headBefore = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
|
||||
const res = await gitFetch(repo, { timeoutMs: TIMEOUT_MS })
|
||||
expect(res.ok).toBe(true)
|
||||
|
||||
const headAfter = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
|
||||
expect(headAfter).toBe(headBefore)
|
||||
const status = (await execFileP('git', ['status', '--porcelain'], { cwd: repo })).stdout
|
||||
expect(status.trim()).toBe('')
|
||||
})
|
||||
|
||||
it('moves the remote-tracking ref so `behind` becomes true after fetching', async () => {
|
||||
if (!gitAvailable) return
|
||||
const bare = await makeBareRemote()
|
||||
const repo = await makeRepo()
|
||||
await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo })
|
||||
await execFileP('git', ['push', '-q', '-u', 'origin', 'main'], { cwd: repo })
|
||||
|
||||
// A second clone pushes one commit; `repo` cannot know until it fetches.
|
||||
const other = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-other-')))
|
||||
const clone = path.join(other, 'c')
|
||||
await execFileP('git', ['clone', '-q', bare, clone])
|
||||
await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: clone })
|
||||
await execFileP('git', ['config', 'user.name', 'tester'], { cwd: clone })
|
||||
await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd: clone })
|
||||
await fs.writeFile(path.join(clone, 'remote.txt'), 'r\n', 'utf8')
|
||||
await execFileP('git', ['add', '.'], { cwd: clone })
|
||||
await execFileP('git', ['commit', '-q', '-m', 'from elsewhere'], { cwd: clone })
|
||||
await execFileP('git', ['push', '-q'], { cwd: clone })
|
||||
|
||||
const countBefore = (
|
||||
await execFileP('git', ['rev-list', '--count', 'HEAD..@{u}'], { cwd: repo })
|
||||
).stdout.trim()
|
||||
expect(countBefore).toBe('0') // the stale lie the panel would have shown
|
||||
|
||||
const res = await gitFetch(repo, { timeoutMs: TIMEOUT_MS })
|
||||
expect(res.ok).toBe(true)
|
||||
|
||||
const countAfter = (
|
||||
await execFileP('git', ['rev-list', '--count', 'HEAD..@{u}'], { cwd: repo })
|
||||
).stdout.trim()
|
||||
expect(countAfter).toBe('1')
|
||||
})
|
||||
|
||||
it('ignores any remote or refspec the caller tries to smuggle in', async () => {
|
||||
if (!gitAvailable) return
|
||||
const bare = await makeBareRemote()
|
||||
const repo = await makeRepo()
|
||||
await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo })
|
||||
await execFileP('git', ['push', '-q', '-u', 'origin', 'main'], { cwd: repo })
|
||||
|
||||
// The options type carries only timeoutMs; a caller passing extra keys must
|
||||
// not be able to steer the command at a remote of their choosing.
|
||||
const smuggled = {
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
remote: 'file:///tmp/evil',
|
||||
refspec: '+refs/heads/*:refs/heads/*',
|
||||
} as unknown as { timeoutMs: number }
|
||||
const res = await gitFetch(repo, smuggled)
|
||||
expect(res.ok).toBe(true)
|
||||
expect(res.ok === true && res.remote).toBe('origin')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user