Files
web-terminal/test/http/git-ops.test.ts
Yaojia Wang 8fe1f52e5d 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.
2026-07-29 17:12:00 +02:00

420 lines
19 KiB
TypeScript

/**
* test/http/git-ops.test.ts (w4-commit-push) — the git WRITE engine.
*
* Covers src/http/git-ops.ts:
* - classifyGitError (pure: stderr → safe {status,error}, SEC-M10)
* - validateRepoFiles (realpath containment: rejects escapes/absolute/flags)
* - stageFiles (git add / restore --staged, real temp repos)
* - commit (staged commit, empty/identity/length guards)
* - push (upstream vs no-upstream argv, bare-remote real push)
*
* Pure functions are unit-tested; the git verbs run against real throwaway git
* repos in os.tmpdir() (no network — a local bare repo is the push remote),
* mirroring test/http/worktrees-create.test.ts.
*/
import { describe, it, expect, beforeAll } from 'vitest'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import {
classifyGitError,
validateRepoFiles,
stageFiles,
commit,
push,
fetch as gitFetch,
} from '../../src/http/git-ops.js'
const execFileP = promisify(execFile)
const TIMEOUT_MS = 15000
const OPTS = { timeoutMs: TIMEOUT_MS, maxFiles: 300 }
let gitAvailable = false
beforeAll(async () => {
try {
await execFileP('git', ['--version'])
gitAvailable = true
} catch {
gitAvailable = false
}
})
/** Init a temp git repo with one commit; returns its REALPATH (macOS /var fix). */
async function makeRepo(): Promise<string> {
const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-')))
const git = async (args: string[]): Promise<void> => {
await execFileP('git', args, { cwd: dir })
}
await git(['init', '-q', '-b', 'main'])
await git(['config', 'user.email', 't@t.local'])
await git(['config', 'user.name', 'tester'])
await git(['config', 'commit.gpgsign', 'false'])
await fs.writeFile(path.join(dir, 'tracked.txt'), 'v1\n', 'utf8')
await git(['add', '.'])
await git(['commit', '-q', '-m', 'init'])
return dir
}
/** A bare repo to serve as a push `origin`. Returns its REALPATH. */
async function makeBareRemote(): Promise<string> {
const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-bare-')))
await execFileP('git', ['init', '-q', '--bare', '-b', 'main', dir])
return dir
}
// ── classifyGitError (pure, SEC-M10) ────────────────────────────────────────────
describe('classifyGitError', () => {
const cases: Array<[string, number]> = [
['nothing to commit, working tree clean', 409],
['no changes added to commit (use "git add")', 409],
['Please tell me who you are', 400],
[' ! [rejected] main -> main (non-fast-forward)', 409],
['Updates were rejected because the tip of your current branch is behind\nfetch first', 409],
['fatal: Authentication failed for \'https://github.com/x/y.git/\'', 401],
["fatal: could not read Username for 'https://github.com': terminal prompts disabled", 401],
['git@github.com: Permission denied (publickey).', 401],
["fatal: Unable to create '/repo/.git/index.lock': File exists.", 409],
['some totally unexpected failure', 500],
]
it('maps each stderr signature to the expected status', () => {
for (const [stderr, status] of cases) {
expect(classifyGitError(stderr).status).toBe(status)
}
})
it('never leaks raw git stderr (no "fatal:" in any safe message)', () => {
for (const [stderr] of cases) {
expect(classifyGitError(stderr).error.toLowerCase()).not.toContain('fatal:')
}
})
it('is case-insensitive and tolerant of a non-string input', () => {
expect(classifyGitError('NOTHING TO COMMIT').status).toBe(409)
// @ts-expect-error — defensive: non-string must not throw
expect(classifyGitError(undefined).status).toBe(500)
})
})
// ── validateRepoFiles (realpath containment) ────────────────────────────────────
describe('validateRepoFiles', () => {
it('rejects an empty list', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
expect(await validateRepoFiles(repo, [], 300)).toBeNull()
})
it('rejects more than maxFiles entries', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
expect(await validateRepoFiles(repo, ['a', 'b', 'c'], 2)).toBeNull()
})
it('rejects non-string, absolute, and leading-dash entries', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
expect(await validateRepoFiles(repo, [123], 300)).toBeNull()
expect(await validateRepoFiles(repo, [''], 300)).toBeNull()
expect(await validateRepoFiles(repo, ['/etc/passwd'], 300)).toBeNull()
expect(await validateRepoFiles(repo, ['-rf'], 300)).toBeNull()
})
it('rejects a ../ traversal escape', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
expect(await validateRepoFiles(repo, ['../escape'], 300)).toBeNull()
})
it('rejects the repo root itself (".")', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
expect(await validateRepoFiles(repo, ['.'], 300)).toBeNull()
})
it('accepts in-repo relative paths (existing AND not-yet-on-disk / deleted)', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
await fs.writeFile(path.join(repo, 'exists.txt'), 'x', 'utf8')
const ok = await validateRepoFiles(repo, ['exists.txt', 'sub/deleted.txt'], 300)
expect(ok).toEqual(['exists.txt', 'sub/deleted.txt'])
})
it('rejects a pre-planted symlink that escapes the repo after realpath (M2)', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
const outside = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-out-')))
await fs.writeFile(path.join(outside, 'secret.txt'), 'top', 'utf8')
await fs.symlink(path.join(outside, 'secret.txt'), path.join(repo, 'link.txt'))
expect(await validateRepoFiles(repo, ['link.txt'], 300)).toBeNull()
})
})
// ── stageFiles (real temp repos) ────────────────────────────────────────────────
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()
await fs.writeFile(path.join(repo, 'tracked.txt'), 'v2\n', 'utf8') // modify tracked
await fs.writeFile(path.join(repo, 'new.txt'), 'new\n', 'utf8') // add untracked
const staged = await stageFiles(repo, ['tracked.txt', 'new.txt'], true, OPTS)
expect(staged).toMatchObject({ ok: true, staged: true, count: 2 })
const cached = (await execFileP('git', ['diff', '--cached', '--name-only'], { cwd: repo })).stdout
expect(cached).toContain('tracked.txt')
expect(cached).toContain('new.txt')
const unstaged = await stageFiles(repo, ['tracked.txt'], false, OPTS)
expect(unstaged).toMatchObject({ ok: true, staged: false, count: 1 })
const cached2 = (await execFileP('git', ['diff', '--cached', '--name-only'], { cwd: repo })).stdout
expect(cached2).not.toContain('tracked.txt')
})
it('returns 404 for a non-git directory', async () => {
const plain = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-plain-')))
const res = await stageFiles(plain, ['x.txt'], true, OPTS)
expect(res).toMatchObject({ ok: false, status: 404 })
})
it('returns 400 for an escaping file path (never runs git)', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const res = await stageFiles(repo, ['../../etc/passwd'], true, OPTS)
expect(res).toMatchObject({ ok: false, status: 400 })
})
})
// ── commit (real temp repos) ────────────────────────────────────────────────────
describe('commit', { timeout: 30_000 }, () => {
it('commits staged changes and returns a short SHA', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
await fs.writeFile(path.join(repo, 'tracked.txt'), 'v2\n', 'utf8')
await execFileP('git', ['add', 'tracked.txt'], { cwd: repo })
const res = await commit(repo, 'second commit', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })
expect(res.ok).toBe(true)
expect(typeof res.commit).toBe('string')
const head = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
expect(head.startsWith(res.commit as string)).toBe(true)
})
it('returns 409 when nothing is staged', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const res = await commit(repo, 'noop', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })
expect(res).toMatchObject({ ok: false, status: 409 })
expect(res.error).not.toContain('fatal:')
})
it('rejects an empty message with 400 (never runs git)', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
expect(await commit(repo, '', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })).toMatchObject({
ok: false,
status: 400,
})
expect(await commit(repo, ' \n ', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })).toMatchObject({
ok: false,
status: 400,
})
})
it('rejects an over-long message with 400', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const res = await commit(repo, 'x'.repeat(51), { timeoutMs: TIMEOUT_MS, maxLen: 50 })
expect(res).toMatchObject({ ok: false, status: 400 })
})
it('returns 400 when the author identity is unset', async () => {
if (!gitAvailable) return
// Init WITHOUT any local identity, then hide the global/system config so no
// identity resolves — git must fail with "Please tell me who you are" → 400.
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-noid-')))
await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: repo })
// useConfigOnly=true stops git auto-detecting an identity from the OS user, so
// with no configured identity it hard-fails "Please tell me who you are".
await execFileP('git', ['config', '--local', 'user.useConfigOnly', 'true'], { cwd: repo })
await fs.writeFile(path.join(repo, 'f.txt'), 'x\n', 'utf8')
await execFileP('git', ['add', 'f.txt'], { cwd: repo }) // stage on unborn HEAD
const prevG = process.env['GIT_CONFIG_GLOBAL']
const prevS = process.env['GIT_CONFIG_SYSTEM']
process.env['GIT_CONFIG_GLOBAL'] = '/dev/null'
process.env['GIT_CONFIG_SYSTEM'] = '/dev/null'
try {
const res = await commit(repo, 'msg', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })
expect(res).toMatchObject({ ok: false, status: 400 })
expect(res.error).not.toContain('fatal:')
} finally {
if (prevG === undefined) delete process.env['GIT_CONFIG_GLOBAL']
else process.env['GIT_CONFIG_GLOBAL'] = prevG
if (prevS === undefined) delete process.env['GIT_CONFIG_SYSTEM']
else process.env['GIT_CONFIG_SYSTEM'] = prevS
}
})
})
// ── push (real bare-repo remote, no network) ────────────────────────────────────
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()
const bare = await makeBareRemote()
await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo })
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: true, branch: 'main', remote: 'origin' })
const remoteHead = (await execFileP('git', ['--git-dir', bare, 'rev-parse', 'main'], {})).stdout.trim()
const localHead = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
expect(remoteHead).toBe(localHead)
})
it('a second push (upstream now set) succeeds via a plain push', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const bare = await makeBareRemote()
await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo })
expect((await push(repo, { timeoutMs: TIMEOUT_MS })).ok).toBe(true)
await fs.writeFile(path.join(repo, 'tracked.txt'), 'v2\n', 'utf8')
await execFileP('git', ['add', 'tracked.txt'], { cwd: repo })
await execFileP('git', ['commit', '-q', '-m', 'second'], { cwd: repo })
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: true, branch: 'main', remote: 'origin' })
const remoteHead = (await execFileP('git', ['--git-dir', bare, 'rev-parse', 'main'], {})).stdout.trim()
const localHead = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
expect(remoteHead).toBe(localHead)
})
it('returns 400 for a detached HEAD', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const sha = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
await execFileP('git', ['checkout', '-q', sha], { cwd: repo }) // detach
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: false, status: 400 })
})
it('returns 400 when the repo has zero remotes', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: false, status: 400 })
})
it('returns 409 with two remotes and no upstream (ambiguous)', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const bare1 = await makeBareRemote()
const bare2 = await makeBareRemote()
await execFileP('git', ['remote', 'add', 'origin', bare1], { cwd: repo })
await execFileP('git', ['remote', 'add', 'backup', bare2], { cwd: repo })
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: false, status: 409 })
})
it('returns 404 for a non-git directory', async () => {
const plain = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-nrepo-')))
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')
})
})