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