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

The server shipped the project-detail git panel in 8fe1f52/f711db9/042c4cd,
all after the last Android commit, so the client silently ignored every new
field (they are additive and optional, so nothing crashed — it just rendered
the pre-w6 shape: one bare dirty dot and no sync information at all).

Added: SyncState + ProjectDetail.sync/dirtyCount, ProjectInfo.dirtyCount,
ProjectSessionRef.cwd, GitLogResult.upstream, CommitLogEntry.unpushed, plus
GET /projects/worktree/state (read) and POST /projects/git/fetch (write).

Two rules from the server's own commit message are encoded as behaviour
rather than left to each call site to remember:

  `SyncState.isFullySynced(now)` is the only sanctioned way to ask whether
  the green all-clear may be rendered, and it answers false for anything
  unknown. ahead/behind compare against @{u}, a locally cached ref that only
  a fetch moves, so `behind = 0` from a stale FETCH_HEAD proves nothing. Most
  importantly NO UPSTREAM leaves both counts undefined — which is not zero —
  and that is the normal state of a fresh worktree branch. The server calls
  that fall-through the easiest bug to ship here; there is a test per way it
  could regress, including a fetch timestamp in the future, which is clock
  skew rather than evidence that anything was checked.

  `GitLogResult.upstreamBoundaryIndex` finds the LAST unpushed commit rather
  than assuming the unpushed ones come first. git log is date-ordered, so
  merging an older branch interleaves unpushed commits below pushed ones, and
  the row-count shortcut fails in the dangerous direction — it would label an
  unpushed commit as pushed. It returns null, not -1, when no boundary may be
  drawn, so "no upstream" cannot be mistaken for "boundary before everything".

Origin policy is asserted in both directions: worktree/state carries no
Origin, git/fetch carries a byte-equal one, and a test also asserts the fetch
body contains no remote or refspec — the remote is derived server-side
precisely so a client cannot aim a fetch at an arbitrary URL. Fetch has its
own rate-limit bucket, so a 429 is surfaced rather than retried, and a failed
fetch leaves lastFetchMs unknown so the UI keeps saying "stale" instead of
pretending it refreshed.

Verified: :api-client:test + koverVerify green; 22 new tests.
This commit is contained in:
Yaojia Wang
2026-07-30 09:45:59 +02:00
parent 3fe719f874
commit 35d32f4670
8 changed files with 512 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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)
}