From 35d32f467002b37a43fd90e2e8793b9978c68609 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 30 Jul 2026 09:45:59 +0200 Subject: [PATCH] feat(android): decode the v0.6 git-panel surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../wang/yaojia/webterm/api/models/GitLog.kt | 33 +++- .../yaojia/webterm/api/models/GitWrite.kt | 10 + .../yaojia/webterm/api/models/Projects.kt | 12 ++ .../yaojia/webterm/api/models/SyncState.kt | 88 +++++++++ .../yaojia/webterm/api/routes/ApiClient.kt | 32 ++++ .../yaojia/webterm/api/routes/Endpoints.kt | 26 +++ .../webterm/api/models/SyncStateTest.kt | 141 +++++++++++++++ .../webterm/api/routes/W6RouteShapeTest.kt | 171 ++++++++++++++++++ 8 files changed, 512 insertions(+), 1 deletion(-) create mode 100644 android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/SyncState.kt create mode 100644 android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/SyncStateTest.kt create mode 100644 android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/W6RouteShapeTest.kt diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitLog.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitLog.kt index 5a17990..f1dbb84 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitLog.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitLog.kt @@ -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 = 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 : diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitWrite.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitWrite.kt index e543ea1..11e6234 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitWrite.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitWrite.kt @@ -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) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Projects.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Projects.kt index a96395d..642fdaf 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Projects.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Projects.kt @@ -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/` 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 = 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 = emptyList(), @Serializable(with = ProjectSessionRefListSerializer::class) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/SyncState.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/SyncState.kt new file mode 100644 index 0000000..09135b5 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/SyncState.kt @@ -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, +) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt index 60005c4..4e2b0a3 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt @@ -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 = 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 = + 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] diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt index 21ac018..eb27f8f 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt @@ -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) } diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/SyncStateTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/SyncStateTest.kt new file mode 100644 index 0000000..78bce28 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/SyncStateTest.kt @@ -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( + """{"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("{}") + 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( + """{"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) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/W6RouteShapeTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/W6RouteShapeTest.kt new file mode 100644 index 0000000..185108d --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/W6RouteShapeTest.kt @@ -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) +}