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 hash: String,
val at: Long, val at: Long,
val subject: String = "", 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) @Serializable(with = CommitLogEntryListSerializer::class)
val commits: List<CommitLogEntry> = emptyList(), val commits: List<CommitLogEntry> = emptyList(),
val truncated: Boolean = false, 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). */ /** Drops a commit missing `hash`/`at`, keeps the rest (nested list-lossy, like worktrees). */
internal object CommitLogEntryListSerializer : internal object CommitLogEntryListSerializer :

View File

@@ -42,6 +42,16 @@ public data class CommitResult(val commit: String = "")
@Serializable @Serializable
public data class PushResult(val branch: String? = null, val remote: String? = null) 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 }`. */ /** `POST /projects/worktree` 200 → `{ ok, path, branch }`. */
@Serializable @Serializable
public data class CreateWorktreeResult(val path: String? = null, val branch: String? = null) 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 clientCount: Int,
val createdAt: Long, val createdAt: Long,
val exited: Boolean, 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, val behind: Int? = null,
/** HEAD commit time in ms (`git log -1 --format=%ct * 1000`); absent on a fresh/empty repo. */ /** HEAD commit time in ms (`git log -1 --format=%ct * 1000`); absent on a fresh/empty repo. */
val lastCommitMs: Long? = null, val lastCommitMs: Long? = null,
/** w6/G1: porcelain line count, gated the same way as [dirty]. */
val dirtyCount: Int? = null,
@Serializable(with = ProjectSessionRefListSerializer::class) @Serializable(with = ProjectSessionRefListSerializer::class)
val sessions: List<ProjectSessionRef> = emptyList(), val sessions: List<ProjectSessionRef> = emptyList(),
) )
@@ -65,6 +73,10 @@ public data class ProjectDetail(
val isGit: Boolean, val isGit: Boolean,
val branch: String? = null, val branch: String? = null,
val dirty: Boolean? = 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) @Serializable(with = WorktreeInfoListSerializer::class)
val worktrees: List<WorktreeInfo> = emptyList(), val worktrees: List<WorktreeInfo> = emptyList(),
@Serializable(with = ProjectSessionRefListSerializer::class) @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.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult 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.PushResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.SessionPreview import wang.yaojia.webterm.api.models.SessionPreview
import wang.yaojia.webterm.api.models.StageResult import wang.yaojia.webterm.api.models.StageResult
import wang.yaojia.webterm.api.models.UiConfig import wang.yaojia.webterm.api.models.UiConfig
import wang.yaojia.webterm.api.models.UiPrefs 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.decodeGitError
import wang.yaojia.webterm.api.models.decodeGitPayload import wang.yaojia.webterm.api.models.decodeGitPayload
import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostEndpoint
@@ -206,6 +208,36 @@ public class ApiClient(
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult> = public suspend fun gitPush(path: String): GitWriteOutcome<PushResult> =
gitWrite(Endpoints.gitPush(path), PushResult.serializer()) 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 * 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] * decoded payload; 429→[GitWriteOutcome.RateLimited]; any other 4xx/5xx→[GitWriteOutcome.Rejected]

View File

@@ -54,6 +54,19 @@ internal object Endpoints {
percentEncodedQuery = "path=${percentEncode(path)}", 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 = fun getPrefs(): ApiRoute =
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY) ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
@@ -148,6 +161,16 @@ internal object Endpoints {
fun gitCommit(path: String, message: String): ApiRoute = fun gitCommit(path: String, message: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message)) 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 }`. */ /** `POST /projects/git/push` — `{ path }`. */
fun gitPush(path: String): ApiRoute = fun gitPush(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path)) jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
@@ -216,4 +239,7 @@ internal object Endpoints {
@Serializable @Serializable
private data class PushBody(val path: String) private data class PushBody(val path: String)
@kotlinx.serialization.Serializable
private data class FetchBody(val path: String)
} }

View File

@@ -0,0 +1,141 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
/**
* w6/G1 sync state.
*
* The load-bearing rule, quoted from the server commit that introduced the feature: *"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."* Most of this file exists to pin the ways that could go wrong — above all the
* no-upstream fall-through, which the server's own commit message calls "the easiest bug to ship here".
*/
@DisplayName("SyncState — when the all-clear may be rendered")
class SyncStateTest {
private val json = Json { ignoreUnknownKeys = true }
private val now = 1_700_000_000_000L
private fun fresh() = now - 60_000L // a minute ago
private fun stale() = now - (SyncState.FETCH_FRESH_WINDOW_MS + 60_000L)
@Test
@DisplayName("decodes the full server shape")
fun decodesFull() {
val s = json.decodeFromString<SyncState>(
"""{"upstream":"origin/develop","ahead":9,"behind":0,"lastFetchMs":$now,"detached":false}""",
)
assertEquals("origin/develop", s.upstream)
assertEquals(9, s.ahead)
assertEquals(0, s.behind)
}
@Test
@DisplayName("every field is optional — each degrades independently on the server")
fun decodesEmpty() {
val s = json.decodeFromString<SyncState>("{}")
assertNull(s.upstream)
assertNull(s.ahead)
assertNull(s.behind)
assertNull(s.lastFetchMs)
assertFalse(s.detached)
assertFalse(s.hasUpstream)
}
// ── the green state ──────────────────────────────────────────────────────────────────────────────
@Test
@DisplayName("↑0 ↓0 with a fresh fetch is the ONLY green state")
fun theOneGreenState() {
val s = SyncState(upstream = "origin/main", ahead = 0, behind = 0, lastFetchMs = fresh())
assertTrue(s.isFullySynced(now))
}
@Test
@DisplayName("NO UPSTREAM is never green — the fall-through the server calls the easiest bug here")
fun noUpstreamIsNeverGreen() {
// The normal state of a fresh worktree branch. ahead/behind are UNDEFINED, not zero.
val s = SyncState(upstream = null, ahead = null, behind = null, lastFetchMs = fresh())
assertFalse(s.isFullySynced(now), "a branch tracking nothing has not been verified as in sync")
assertFalse(s.hasUpstream)
}
@Test
@DisplayName("undefined counts are not zero, even with an upstream and a fresh fetch")
fun undefinedCountsAreNotZero() {
assertFalse(SyncState("origin/main", ahead = null, behind = 0, lastFetchMs = fresh()).isFullySynced(now))
assertFalse(SyncState("origin/main", ahead = 0, behind = null, lastFetchMs = fresh()).isFullySynced(now))
}
@Test
@DisplayName("a stale fetch is not green — behind=0 from a stale FETCH_HEAD proves nothing")
fun staleFetchIsNotGreen() {
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = stale())
assertFalse(s.isFullySynced(now))
assertTrue(s.isBehindStale(now))
}
@Test
@DisplayName("never fetched is not green")
fun neverFetchedIsNotGreen() {
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = null)
assertFalse(s.isFullySynced(now))
assertFalse(s.isFetchFresh(now))
}
@Test
@DisplayName("a detached HEAD is not green")
fun detachedIsNotGreen() {
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = fresh(), detached = true)
assertFalse(s.isFullySynced(now))
}
@Test
@DisplayName("anything unpushed or unpulled is not green")
fun pendingWorkIsNotGreen() {
assertFalse(SyncState("origin/main", ahead = 1, behind = 0, lastFetchMs = fresh()).isFullySynced(now))
assertFalse(SyncState("origin/main", ahead = 0, behind = 1, lastFetchMs = fresh()).isFullySynced(now))
}
// ── freshness edges ──────────────────────────────────────────────────────────────────────────────
@Test
@DisplayName("a fetch timestamp in the future is not treated as fresh")
fun futureFetchIsNotFresh() {
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now + 60_000L)
assertFalse(s.isFetchFresh(now), "clock skew is not evidence that we checked")
assertFalse(s.isFullySynced(now))
}
@Test
@DisplayName("the freshness boundary is inclusive at exactly the window")
fun boundaryInclusive() {
val atEdge = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now - SyncState.FETCH_FRESH_WINDOW_MS)
assertTrue(atEdge.isFetchFresh(now))
val pastEdge = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now - SyncState.FETCH_FRESH_WINDOW_MS - 1)
assertFalse(pastEdge.isFetchFresh(now))
}
@Test
@DisplayName("staleness is only claimed when there is an upstream to be stale about")
fun noUpstreamIsNotStale() {
assertFalse(SyncState(upstream = null, lastFetchMs = null).isBehindStale(now))
}
@Test
@DisplayName("WorktreeState decodes the narrow per-row shape")
fun worktreeStateDecodes() {
val w = json.decodeFromString<WorktreeState>(
"""{"path":"/repo/.claude/worktrees/x","branch":"wt-x","dirtyCount":3,
"sync":{"upstream":"origin/wt-x","ahead":2}}""",
)
assertEquals("wt-x", w.branch)
assertEquals(3, w.dirtyCount)
assertEquals(2, w.sync?.ahead)
}
}

View File

@@ -0,0 +1,171 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
/**
* Request shape + **Origin-iff-guarded** (plan §4.3 铁律) for the two routes the v0.6 git panel added
* after the last Android commit: `GET /projects/worktree/state` (read) and `POST /projects/git/fetch`
* (write). A route reclassified read↔write turns this red rather than silently shipping either a CSWSH
* hole or a write the server will 403.
*
* Also pins the commit-log boundary arithmetic, which the server's own commit warns "fails in the
* dangerous direction" if shortcut to "the first N rows".
*/
@DisplayName("w6 routes — shape, Origin policy, and the unpushed boundary")
class W6RouteShapeTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val ORIGIN = "http://192.168.1.5:3000"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private fun last(): HttpRequest = transport.recordedRequests.last()
// ── GET /projects/worktree/state — read-only, no Origin ──────────────────────────────────────────
@Test
fun `worktreeState is a read-only GET with a strict-encoded path and no Origin`() = runTest {
val path = "/home/me/my repo/a+b&c"
transport.queueSuccess(
HttpMethod.GET,
"$BASE/projects/worktree/state?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c",
200,
body = """{"path":"$path","branch":"wt","dirtyCount":2}""".toByteArray(),
)
val state = client.worktreeState(path)
val r = last()
assertEquals(HttpMethod.GET, r.method)
assertFalse(
r.headers.containsKey(HeaderName.ORIGIN),
"a read must NOT stamp Origin — Origin is the CSWSH marker for state-changing routes only",
)
assertTrue(r.url.contains("%2Bb"), "a bare + would decode to a SPACE in Express's qs parser")
assertEquals(2, state.dirtyCount)
}
// ── POST /projects/git/fetch — guarded, byte-equal Origin, body carries only the path ────────────
@Test
fun `gitFetch is a guarded POST that stamps a byte-equal Origin`() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 200, body = """{"ok":true,"lastFetchMs":42}""".toByteArray())
val outcome = client.gitFetch("/repo")
val r = last()
assertEquals(HttpMethod.POST, r.method)
assertEquals(
ORIGIN,
r.headers[HeaderName.ORIGIN],
"fetch is state-changing (it moves refs/remotes and spawns a network git), so it must be guarded",
)
assertNotNull(r.body, "the path travels in a JSON body")
val body = r.body!!.decodeToString()
assertTrue(body.contains("\"path\""))
assertFalse(
body.contains("remote") || body.contains("refspec"),
"the remote is derived SERVER-side; a client must never be able to aim a fetch at an arbitrary URL",
)
assertEquals(42L, (outcome as GitWriteOutcome.Ok).payload.lastFetchMs)
}
@Test
fun `a fetch that fails leaves lastFetchMs unknown rather than claiming it refreshed`() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 200, body = """{"ok":true}""".toByteArray())
val outcome = client.gitFetch("/repo")
assertNull(
(outcome as GitWriteOutcome.Ok).payload.lastFetchMs,
"the server leaves the old value alone on failure, so the UI must keep saying stale",
)
}
@Test
fun `fetch surfaces its own rate limit instead of retrying`() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 429, body = """{"error":"slow down"}""".toByteArray())
val outcome = client.gitFetch("/repo")
assertTrue(
outcome is GitWriteOutcome.RateLimited,
"fetch has its OWN bucket so refreshes cannot eat the budget a real push needs",
)
}
@Test
fun `a disabled or Origin-rejected fetch surfaces the server's safe message`() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 403, body = """{"error":"git ops disabled"}""".toByteArray())
val outcome = client.gitFetch("/repo")
// 403 is overloaded — kill-switch AND Origin failure both return it — so the message is
// surfaced rather than guessed at with a typed variant.
assertEquals("git ops disabled", (outcome as GitWriteOutcome.Rejected).message)
}
// ── the unpushed boundary (w6/G4) ────────────────────────────────────────────────────────────────
@Test
@DisplayName("the boundary sits after the LAST unpushed commit, even when they interleave")
fun boundaryHandlesInterleaving() {
// A backdated merge puts an unpushed commit BELOW a pushed one, because git log is date-ordered.
// "The first N rows" would mark the wrong set and call an unpushed commit pushed.
val log = GitLogResult(
commits = listOf(
commit("aaa", unpushed = true),
commit("bbb", unpushed = false),
commit("ccc", unpushed = true),
commit("ddd", unpushed = false),
),
upstream = "origin/main",
)
assertEquals(2, log.upstreamBoundaryIndex, "after index 2 — the last unpushed one")
assertEquals(2, log.unpushedCount)
}
@Test
@DisplayName("no upstream means NO boundary may be drawn at all")
fun noUpstreamNoBoundary() {
val log = GitLogResult(commits = listOf(commit("aaa", unpushed = true)), upstream = null)
assertNull(
log.upstreamBoundaryIndex,
"null rather than -1, so a caller cannot mistake 'no boundary' for 'boundary before everything'",
)
}
@Test
@DisplayName("nothing unpushed means no boundary")
fun nothingUnpushed() {
val log = GitLogResult(commits = listOf(commit("aaa", unpushed = false)), upstream = "origin/main")
assertNull(log.upstreamBoundaryIndex)
assertEquals(0, log.unpushedCount)
}
@Test
@DisplayName("an unknown unpushed flag is not counted as pushed")
fun unknownIsNotPushed() {
val log = GitLogResult(commits = listOf(commit("aaa", unpushed = null)), upstream = "origin/main")
assertEquals(0, log.unpushedCount, "unknown is not 'unpushed'…")
assertNull(log.upstreamBoundaryIndex, "…and it must not invent a boundary either")
}
private fun commit(hash: String, unpushed: Boolean?) =
wang.yaojia.webterm.api.models.CommitLogEntry(hash = hash, at = 1L, subject = "s", unpushed = unpushed)
}