The server shipped the project-detail git panel in 8fe1f52/f711db9/042c4cd,
all after the last Android commit, so the client silently ignored every new
field (they are additive and optional, so nothing crashed — it just rendered
the pre-w6 shape: one bare dirty dot and no sync information at all).
Added: SyncState + ProjectDetail.sync/dirtyCount, ProjectInfo.dirtyCount,
ProjectSessionRef.cwd, GitLogResult.upstream, CommitLogEntry.unpushed, plus
GET /projects/worktree/state (read) and POST /projects/git/fetch (write).
Two rules from the server's own commit message are encoded as behaviour
rather than left to each call site to remember:
`SyncState.isFullySynced(now)` is the only sanctioned way to ask whether
the green all-clear may be rendered, and it answers false for anything
unknown. ahead/behind compare against @{u}, a locally cached ref that only
a fetch moves, so `behind = 0` from a stale FETCH_HEAD proves nothing. Most
importantly NO UPSTREAM leaves both counts undefined — which is not zero —
and that is the normal state of a fresh worktree branch. The server calls
that fall-through the easiest bug to ship here; there is a test per way it
could regress, including a fetch timestamp in the future, which is clock
skew rather than evidence that anything was checked.
`GitLogResult.upstreamBoundaryIndex` finds the LAST unpushed commit rather
than assuming the unpushed ones come first. git log is date-ordered, so
merging an older branch interleaves unpushed commits below pushed ones, and
the row-count shortcut fails in the dangerous direction — it would label an
unpushed commit as pushed. It returns null, not -1, when no boundary may be
drawn, so "no upstream" cannot be mistaken for "boundary before everything".
Origin policy is asserted in both directions: worktree/state carries no
Origin, git/fetch carries a byte-equal one, and a test also asserts the fetch
body contains no remote or refspec — the remote is derived server-side
precisely so a client cannot aim a fetch at an arbitrary URL. Fetch has its
own rate-limit bucket, so a 429 is surfaced rather than retried, and a failed
fetch leaves lastFetchMs unknown so the UI keeps saying "stale" instead of
pretending it refreshed.
Verified: :api-client:test + koverVerify green; 22 new tests.
- wire the enroll flow into the app UI (EnrollmentScreen + ViewModel + Hilt DI +
host-menu "自动获取证书"), mirroring iOS — Android previously only had manual
.p12 import; the enroll library was built but unreachable.
- renew is now mTLS-only ({csr}-only body, no Authorization header) matching the
/device/:id/renew contract (the enroll bearer is minutes-lived → silent
rotation would have thrown weeks later).
- enroll refreshes the identity-repository cache so a mid-session-enrolled cert
is presented on the next mTLS handshake without a process restart.
gradle :app:assembleDebug + api-client/client-tls-android unit tests + koverVerify
green. On-device QA (keygen/enroll/present) is the operator's step.