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:
@@ -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 :
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user