feat(android): decode the v0.6 git-panel surface

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.
This commit is contained in:
Yaojia Wang
2026-07-30 09:45:59 +02:00
parent 3fe719f874
commit 35d32f4670
8 changed files with 512 additions and 1 deletions

View File

@@ -14,6 +14,15 @@ public data class CommitLogEntry(
val hash: String,
val at: Long,
val subject: String = "",
/**
* w6/G4: reachable from HEAD but NOT from `@{u}` — i.e. this commit has not been pushed.
*
* Server-computed from `rev-list`, and deliberately NOT "the first N rows": `git log` is
* date-ordered, so merging an older branch interleaves unpushed commits BELOW pushed ones. The
* row-count shortcut fails in the dangerous direction — it would call an unpushed commit pushed.
* Absent ⇒ unknown (no upstream to compare against), which must not render as "pushed".
*/
val unpushed: Boolean? = null,
)
/**
@@ -25,7 +34,29 @@ public data class GitLogResult(
@Serializable(with = CommitLogEntryListSerializer::class)
val commits: List<CommitLogEntry> = emptyList(),
val truncated: Boolean = false,
)
/**
* w6/G4: upstream short name used to label the pushed/unpushed boundary. Null ⇒ nothing to compare
* against, so **no boundary may be drawn at all** — not a boundary drawn at position zero.
*/
val upstream: String? = null,
) {
/**
* Index of the LAST unpushed commit, or null when no boundary may be drawn.
*
* The boundary is drawn ONCE, after this index — never once per unpushed commit, and never at all
* when [upstream] is absent. Returns null rather than -1 so a caller cannot accidentally treat
* "no boundary" as "boundary before everything".
*/
public val upstreamBoundaryIndex: Int?
get() {
if (upstream == null) return null
val last = commits.indexOfLast { it.unpushed == true }
return last.takeIf { it >= 0 }
}
/** How many commits are waiting to be pushed; 0 when unknown or nothing is pending. */
public val unpushedCount: Int get() = commits.count { it.unpushed == true }
}
/** Drops a commit missing `hash`/`at`, keeps the rest (nested list-lossy, like worktrees). */
internal object CommitLogEntryListSerializer :

View File

@@ -42,6 +42,16 @@ public data class CommitResult(val commit: String = "")
@Serializable
public data class PushResult(val branch: String? = null, val remote: String? = null)
/**
* `POST /projects/git/fetch` 200 payload (w6/G2).
*
* [lastFetchMs] is FETCH_HEAD's mtime AFTER the fetch, so the UI can stop saying "stale" without
* re-probing. On FAILURE the server deliberately leaves the old value alone, which is why this is
* nullable: a fetch that did not happen must keep reading as stale rather than pretending it refreshed.
*/
@Serializable
public data class FetchResult(val lastFetchMs: Long? = null)
/** `POST /projects/worktree` 200 → `{ ok, path, branch }`. */
@Serializable
public data class CreateWorktreeResult(val path: String? = null, val branch: String? = null)

View File

@@ -17,6 +17,12 @@ public data class ProjectSessionRef(
val clientCount: Int,
val createdAt: Long,
val exited: Boolean,
/**
* w6/G7: the session's working directory, needed to attribute it to a worktree. Matching is
* DEEPEST-first, because `.claude/worktrees/<name>` lives INSIDE the main checkout and a prefix
* match would count every worktree session against the parent repo too.
*/
val cwd: String? = null,
)
/**
@@ -40,6 +46,8 @@ public data class ProjectInfo(
val behind: Int? = null,
/** HEAD commit time in ms (`git log -1 --format=%ct * 1000`); absent on a fresh/empty repo. */
val lastCommitMs: Long? = null,
/** w6/G1: porcelain line count, gated the same way as [dirty]. */
val dirtyCount: Int? = null,
@Serializable(with = ProjectSessionRefListSerializer::class)
val sessions: List<ProjectSessionRef> = emptyList(),
)
@@ -65,6 +73,10 @@ public data class ProjectDetail(
val isGit: Boolean,
val branch: String? = null,
val dirty: Boolean? = null,
/** w6/G1: porcelain line count, gated the same way as [dirty]. */
val dirtyCount: Int? = null,
/** w6/G1: git repos only; null for a non-git dir. See [SyncState] for what may be trusted. */
val sync: SyncState? = null,
@Serializable(with = WorktreeInfoListSerializer::class)
val worktrees: List<WorktreeInfo> = emptyList(),
@Serializable(with = ProjectSessionRefListSerializer::class)

View File

@@ -0,0 +1,88 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
/**
* w6/G1 — how far the checked-out branch has drifted from its upstream (`src/types.ts` `SyncState`).
*
* ## The one rule this whole feature turns on
* `ahead`/`behind` are computed against `@{u}`, a **locally cached** remote ref that only a `fetch`
* moves. That asymmetry decides what may be trusted:
*
* - [ahead] needs only local refs, so it is always trustworthy.
* - [behind] is only meaningful if [lastFetchMs] is recent. A stale FETCH_HEAD reports `behind = 0`
* for a branch that is in fact many commits behind — the server's own commit message records finding
* exactly that (`↓0` while FETCH_HEAD had not moved in 19 days).
* - **No upstream leaves [ahead] and [behind] undefined**, which is NOT the same as zero. A fresh
* worktree branch normally has no upstream, and rendering it as "in sync" is the single easiest bug
* to ship here. [isFullySynced] is the only sanctioned way to ask "may I render the all-clear?" and
* it answers false whenever anything is unknown. Do not re-derive that test at a call site.
*
* Every field is optional because every one of them degrades independently on the server (no upstream,
* detached HEAD, never fetched, not a git repo).
*/
@Serializable
public data class SyncState(
/** e.g. `origin/develop`; null ⇒ the branch tracks nothing. */
val upstream: String? = null,
/** Commits on HEAD not on `@{u}`. Null ⇒ unknown (no upstream), NOT zero. */
val ahead: Int? = null,
/** Commits on `@{u}` not on HEAD. Null ⇒ unknown. Trust only when [isFetchFresh]. */
val behind: Int? = null,
/** FETCH_HEAD mtime in ms; null ⇒ never fetched. */
val lastFetchMs: Long? = null,
/** HEAD is not on a branch ⇒ no branch, no ahead/behind, and fetch/push are not offerable. */
val detached: Boolean = false,
) {
/** True when there is an upstream to compare against at all. */
public val hasUpstream: Boolean get() = upstream != null
/**
* True when [lastFetchMs] is recent enough for [behind] to mean anything.
*
* @param nowMs caller-supplied clock so this stays pure and testable.
*/
public fun isFetchFresh(nowMs: Long): Boolean {
val fetched = lastFetchMs ?: return false
val age = nowMs - fetched
// A clock skew that puts the fetch in the future is not evidence of freshness.
return age in 0..FETCH_FRESH_WINDOW_MS
}
/** True when [behind] should be shown with a "stale — I have not checked" qualifier. */
public fun isBehindStale(nowMs: Long): Boolean = hasUpstream && !isFetchFresh(nowMs)
/**
* The ONLY state that may render as the green all-clear: nothing to push, nothing to pull, and a
* fresh enough fetch to justify saying so.
*
* Green means "I checked, you can ignore this". Getting it wrong is lying to the user, so anything
* unknown — no upstream, detached, never fetched, stale fetch, null counts — is false.
*/
public fun isFullySynced(nowMs: Long): Boolean =
hasUpstream && !detached && ahead == 0 && behind == 0 && isFetchFresh(nowMs)
public companion object {
/**
* How long a fetch stays "fresh". Mirrors the server rule that `behind` is flagged stale once
* FETCH_HEAD is older than an hour (w6/G1).
*/
public const val FETCH_FRESH_WINDOW_MS: Long = 60L * 60L * 1000L
}
}
/**
* w6/G7 — the git state of ONE worktree, fetched lazily per row via `GET /projects/worktree/state`
* (`src/types.ts` `WorktreeState`).
*
* Deliberately narrower than `ProjectDetail`: N rows must not each pay for a worktree listing and a
* CLAUDE.md read that nothing renders.
*/
@Serializable
public data class WorktreeState(
val path: String,
val branch: String? = null,
val sync: SyncState? = null,
val dirtyCount: Int? = null,
)

View File

@@ -11,12 +11,14 @@ import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.FetchResult
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.SessionPreview
import wang.yaojia.webterm.api.models.StageResult
import wang.yaojia.webterm.api.models.UiConfig
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.models.WorktreeState
import wang.yaojia.webterm.api.models.decodeGitError
import wang.yaojia.webterm.api.models.decodeGitPayload
import wang.yaojia.webterm.wire.HostEndpoint
@@ -206,6 +208,36 @@ public class ApiClient(
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult> =
gitWrite(Endpoints.gitPush(path), PushResult.serializer())
/**
* `POST /projects/git/fetch` (w6/G2) — refresh `refs/remotes` so `behind` becomes trustworthy.
*
* Shares the guarded-write dispatch, so a disabled kill-switch or an Origin failure both surface as
* [GitWriteOutcome.Rejected] with the server's safe message. Fetch has its OWN rate-limit bucket on
* the server precisely so refreshes cannot eat the budget a real push needs — surface a 429 rather
* than retrying.
*/
public suspend fun gitFetch(path: String): GitWriteOutcome<FetchResult> =
gitWrite(Endpoints.gitFetch(path), FetchResult.serializer())
/**
* `GET /projects/worktree/state?path=` (w6/G7) — the per-row git state of ONE worktree.
*
* Mapped like the other project reads. A malformed body throws rather than degrading to an empty
* state: a row that silently claims "clean, in sync" would be a lie, and the caller is expected to
* isolate this failure to its own row (the panel must still render).
*/
public suspend fun worktreeState(path: String): WorktreeState {
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
val response = perform(Endpoints.worktreeState(path))
return when (response.status) {
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, WorktreeState.serializer())
?: throw ApiClientError.InvalidResponseBody
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/**
* Shared guarded-write dispatch + status mapping (plan §4.3): 200→[GitWriteOutcome.Ok] with the
* decoded payload; 429→[GitWriteOutcome.RateLimited]; any other 4xx/5xx→[GitWriteOutcome.Rejected]

View File

@@ -54,6 +54,19 @@ internal object Endpoints {
percentEncodedQuery = "path=${percentEncode(path)}",
)
/**
* `GET /projects/worktree/state?path=` — RO (w6/G7). Deliberately NARROWER than
* `/projects/detail`: it is fetched once per worktree row, so N rows must not each pay for a
* worktree listing and a CLAUDE.md read that nothing renders.
*/
fun worktreeState(path: String): ApiRoute =
ApiRoute(
HttpMethod.GET,
"/projects/worktree/state",
OriginPolicy.READ_ONLY,
percentEncodedQuery = "path=${percentEncode(path)}",
)
fun getPrefs(): ApiRoute =
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
@@ -148,6 +161,16 @@ internal object Endpoints {
fun gitCommit(path: String, message: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
/**
* `POST /projects/git/fetch` — `{ path }` (w6/G2). GUARDED even though it only updates
* `refs/remotes`: it is state-changing and spawns a network git process, so it goes through the
* single Origin-stamping point like every other write. 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.
* It is NOT a pull: no working tree, no index, no merge.
*/
fun gitFetch(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/fetch", FetchBody.serializer(), FetchBody(path))
/** `POST /projects/git/push` — `{ path }`. */
fun gitPush(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
@@ -216,4 +239,7 @@ internal object Endpoints {
@Serializable
private data class PushBody(val path: String)
@kotlinx.serialization.Serializable
private data class FetchBody(val path: String)
}