feat(android): Projects/Diff/Worktree/git-write parity with web+iOS (W5)

Brings the native Android client to parity with the web/iOS Projects + git surface,
consuming the existing server endpoints — ZERO server change. (The "SDK-gated modules"
premise was stale; they were already online.)

- :api-client (pure Kotlin/JVM): PrStatus/GitLog/GitWrite models (tolerant decoders,
  safe-error), ProjectInfo + ahead/behind/lastCommitMs; 8 new Endpoints builders
  (projectPr/projectLog read-only; createWorktree/removeWorktree/prune/gitStage/
  gitCommit/gitPush guarded w/ Origin) + ApiClient methods (PR/log degrade in body;
  writes 200→Ok / 429→RateLimited / 4xx-5xx→Rejected(safe msg)).
- :app presenters (JVM-tested): DiffViewModel base-compare + git stage/commit/push;
  new WorktreeViewModel (create/remove/prune + client branch validation + isMain block);
  ProjectDetailViewModel failure-isolated PR-chip + recent-commits.
- Compose screens wired: ProjectDetail (PR chip tappable only for https, recent commits,
  worktree create/remove-with-force/prune, view-diff), Diff (base input, per-file
  stage/unstage, commit/push bar), Projects (ahead/behind sync chip). Nav closes the
  pre-existing "no inbound link to the diff screen" gap.

Verified independently: `./gradlew :app:assembleDebug test :api-client:koverVerify`
BUILD SUCCESSFUL; 348 unit tests pass (api-client 102 / app 246); coverage gate held;
git status android-only. Compose rendering/interaction deferred to on-device QA
(android/DEVICE_QA_CHECKLIST.md), as prior Android waves did.
This commit is contained in:
Yaojia Wang
2026-07-13 05:58:28 +02:00
parent 469037cb94
commit bc31de85dd
29 changed files with 2180 additions and 156 deletions

View File

@@ -0,0 +1,32 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
/**
* One commit from `git log` (`src/types.ts` `CommitLogEntry`). [hash] and [at] are REQUIRED — a
* commit missing either is dropped by the list-lossy [CommitLogEntryListSerializer] (its siblings
* survive). [subject] defaults to empty so a subject-less commit still decodes. `at` = `%ct * 1000`
* (epoch millis). All fields are rendered INERT (plain text; no autolink) at the screen (plan §8).
*/
@Serializable
public data class CommitLogEntry(
val hash: String,
val at: Long,
val subject: String = "",
)
/**
* `GET /projects/log` result (`src/types.ts` `GitLogResult`). [truncated] = more commits exist
* beyond the server cap. The commit list decodes lossily (drop-one-keep-rest).
*/
@Serializable
public data class GitLogResult(
@Serializable(with = CommitLogEntryListSerializer::class)
val commits: List<CommitLogEntry> = emptyList(),
val truncated: Boolean = false,
)
/** Drops a commit missing `hash`/`at`, keeps the rest (nested list-lossy, like worktrees). */
internal object CommitLogEntryListSerializer :
KSerializer<List<CommitLogEntry>> by LossyListSerializer(CommitLogEntry.serializer())

View File

@@ -0,0 +1,71 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
/**
* A client result union for the six guarded git-write ops (worktree create/remove/prune, git
* stage/commit/push). It carries the server's SAFE body only — never raw git stderr (the server
* classifies + sanitizes every failure, `src/http/git-ops.ts` / `worktrees.ts`, SEC-M10):
*
* - [Ok] — a 200 with the op's route-specific payload [T].
* - [Rejected] — a 4xx/5xx with the server's inert `error` string ([message]) to display verbatim.
* 403 is OVERLOADED (Origin-guard failure AND the disabled kill-switch both 403) so the client
* cannot tell them apart by status — it surfaces [message] inertly rather than inventing a typed
* variant (plan Edge cases / Security).
* - [RateLimited] — a 429 (stage/commit share one limiter, push a tighter one). Do NOT auto-retry.
*
* Nothing here throws on a bad body: a missing/garbled payload degrades to defaults (empty sha,
* empty pruned list) rather than crashing (tolerant-decode discipline, plan §8).
*/
public sealed interface GitWriteOutcome<out T> {
/** 200 — the op succeeded; [payload] is the route-specific success body. */
public data class Ok<out T>(val payload: T) : GitWriteOutcome<T>
/** A 4xx/5xx failure carrying the server's SAFE [message] (inert; may be null if unparseable). */
public data class Rejected(val status: Int, val message: String?) : GitWriteOutcome<Nothing>
/** 429 — the server rate-limited this write. */
public data object RateLimited : GitWriteOutcome<Nothing>
}
// ── Per-op 200 payloads (all fields optional/defaulted → a garbled body degrades, never throws) ──
/** `POST /projects/git/stage` 200 → `{ ok, staged, count }`. */
@Serializable
public data class StageResult(val staged: Boolean = false, val count: Int = 0)
/** `POST /projects/git/commit` 200 → `{ ok, commit }` (short sha; may be `""` — empty is valid). */
@Serializable
public data class CommitResult(val commit: String = "")
/** `POST /projects/git/push` 200 → `{ ok, branch, remote }`. */
@Serializable
public data class PushResult(val branch: String? = null, val remote: String? = null)
/** `POST /projects/worktree` 200 → `{ ok, path, branch }`. */
@Serializable
public data class CreateWorktreeResult(val path: String? = null, val branch: String? = null)
/** `DELETE /projects/worktree` 200 → `{ ok, path }` (git's canonical removed path). */
@Serializable
public data class RemoveWorktreeResult(val path: String? = null)
/** `POST /projects/worktree/prune` 200 → `{ ok, pruned: [...] }` (empty = nothing to prune). */
@Serializable
public data class PruneWorktreesResult(val pruned: List<String> = emptyList())
/** Shape of a failure body — worktree routes emit `{ error }`, git-ops `{ ok:false, error }`; both
* carry `error` as a SAFE string. Decoded to surface [error] inertly. */
@Serializable
internal data class GitErrorBody(val ok: Boolean = false, val error: String? = null)
/**
* Decode a guarded 200 body into [T], degrading a missing/garbled body to the payload's defaults
* (never throws — the caller already knows the status is 200).
*/
internal fun <T> decodeGitPayload(bytes: ByteArray, deserializer: kotlinx.serialization.KSerializer<T>): T =
LossyDecode.objectOrNull(bytes, deserializer) ?: ModelJson.decodeFromString(deserializer, "{}")
/** Read the SAFE `error` string from a failure body; null when the body is empty/unparseable. */
internal fun decodeGitError(bytes: ByteArray): String? =
LossyDecode.objectOrNull(bytes, GitErrorBody.serializer())?.error

View File

@@ -0,0 +1,88 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
/**
* Why a [PrStatus] has (or lacks) PR data (`src/types.ts` `PrAvailability`). Drives the detail
* chip's copy. Decoded via [PrAvailabilitySerializer]: an unknown/future value **degrades to
* [ERROR]** (never throws) — a new server availability must never make the chip crash.
*/
public enum class PrAvailability(public val wire: String) {
/** A PR exists for the current branch; the sibling fields are populated. */
OK("ok"),
/** gh works but the branch has no PR (or no remote/default repo). */
NO_PR("no-pr"),
/** `gh` binary not found on PATH (ENOENT). */
NOT_INSTALLED("not-installed"),
/** gh present but not logged in (needs `gh auth login`). */
UNAUTHENTICATED("unauthenticated"),
/** `GH_ENABLED=0` — feature off, never spawns gh. */
DISABLED("disabled"),
/** gh spawned but failed for another reason (timeout, etc.); also the unknown/missing fallback. */
ERROR("error"),
;
public companion object {
/** Map the wire string; unknown → [ERROR] (mirror of the FE never treating non-`ok` as fatal). */
public fun fromWire(wire: String): PrAvailability =
entries.firstOrNull { it.wire == wire } ?: ERROR
}
}
/**
* Decode [PrAvailability] by its `wire` value; an unknown/future value maps to [PrAvailability.ERROR]
* rather than throwing (mirror of [ClaudeStatusSerializer]). Serializes back the `wire` string.
*/
internal object PrAvailabilitySerializer : KSerializer<PrAvailability> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("PrAvailability", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): PrAvailability =
PrAvailability.fromWire(decoder.decodeString())
override fun serialize(encoder: Encoder, value: PrAvailability) =
encoder.encodeString(value.wire)
}
/** Rolled-up CI check counts from gh's statusCheckRollup (`src/types.ts` `PrCheckSummary`). */
@Serializable
public data class PrCheckSummary(
val total: Int = 0,
val passing: Int = 0,
val failing: Int = 0,
val pending: Int = 0,
)
/**
* `GET /projects/pr` result (`src/types.ts` `PrStatus`). Every field except [availability] is
* optional (present only when `availability == ok`); [availability] itself defaults to
* [PrAvailability.ERROR] so a body missing the field still decodes (never throws). `state` /
* `mergeable` are lower-cased string unions on the wire — kept as raw INERT strings here (rendered
* as plain text; no enum needed for display).
*/
@Serializable
public data class PrStatus(
@Serializable(with = PrAvailabilitySerializer::class)
val availability: PrAvailability = PrAvailability.ERROR,
val number: Int? = null,
val title: String? = null,
val url: String? = null,
val state: String? = null,
val isDraft: Boolean? = null,
val mergeable: String? = null,
val headRefName: String? = null,
val baseRefName: String? = null,
val checks: PrCheckSummary? = null,
)

View File

@@ -34,6 +34,12 @@ public data class ProjectInfo(
val dirty: Boolean? = null,
/** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */
val lastActiveMs: Long? = null,
/** W3 sync chip — commits on HEAD not on `@{u}` (best-effort; absent when no upstream). */
val ahead: Int? = null,
/** W3 sync chip — commits on `@{u}` not on HEAD (best-effort; absent when no upstream). */
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,
@Serializable(with = ProjectSessionRefListSerializer::class)
val sessions: List<ProjectSessionRef> = emptyList(),
)

View File

@@ -1,13 +1,24 @@
package wang.yaojia.webterm.api.routes
import wang.yaojia.webterm.api.models.CommitResult
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.api.models.LossyDecode
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.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.decodeGitError
import wang.yaojia.webterm.api.models.decodeGitPayload
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
@@ -87,6 +98,43 @@ public class ApiClient(
}
}
/**
* `GET /projects/pr?path=` — PR + CI status for the project's current branch. The PR *degrade*
* (gh missing / unauth / no-PR / disabled) is `availability` inside a **200** body, NOT an HTTP
* status — so every valid git dir returns 200 and the chip renders from [PrStatus.availability].
* A garbled body degrades to `availability=ERROR` (tolerant decode). 400→path invalid; 404→not a
* repo. Empty path rejected client-side before any I/O.
*/
public suspend fun projectPr(path: String): PrStatus {
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
val response = perform(Endpoints.projectPr(path))
return when (response.status) {
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, PrStatus.serializer())
?: PrStatus() // availability defaults to ERROR — never throw on a bad PR body
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/**
* `GET /projects/log?path=[&n=]` — recent commits (list-lossy: malformed commits dropped). 400→
* path invalid; 404→not a repo; 500→[ApiClientError.GitLogUnavailable]. Empty path rejected
* client-side before any I/O; `n` is clamped in the route builder.
*/
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult {
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
val response = perform(Endpoints.projectLog(path, n))
return when (response.status) {
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, GitLogResult.serializer())
?: throw ApiClientError.InvalidResponseBody
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
HttpStatus.INTERNAL_SERVER_ERROR -> throw ApiClientError.GitLogUnavailable
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws
* `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
public suspend fun prefs(): UiPrefs {
@@ -132,6 +180,50 @@ public class ApiClient(
}
}
// ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ──────────────
/** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */
public suspend fun createWorktree(path: String, branch: String, base: String? = null): GitWriteOutcome<CreateWorktreeResult> =
gitWrite(Endpoints.createWorktree(path, branch, base), CreateWorktreeResult.serializer())
/** `DELETE /projects/worktree` — remove a worktree (409 "uncommitted" unless `force`). */
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean = false): GitWriteOutcome<RemoveWorktreeResult> =
gitWrite(Endpoints.removeWorktree(path, worktreePath, force), RemoveWorktreeResult.serializer())
/** `POST /projects/worktree/prune` — reclaim stale worktrees (idempotent). */
public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> =
gitWrite(Endpoints.pruneWorktrees(path), PruneWorktreesResult.serializer())
/** `POST /projects/git/stage` — stage (`stage=true`) or unstage the given files. */
public suspend fun gitStage(path: String, files: List<String>, stage: Boolean = true): GitWriteOutcome<StageResult> =
gitWrite(Endpoints.gitStage(path, files, stage), StageResult.serializer())
/** `POST /projects/git/commit` — commit the staged changes (empty sha possible). */
public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
gitWrite(Endpoints.gitCommit(path, message), CommitResult.serializer())
/** `POST /projects/git/push` — push the current branch to its upstream (tighter rate limit). */
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult> =
gitWrite(Endpoints.gitPush(path), PushResult.serializer())
/**
* 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]
* carrying the server's SAFE `error` string (403 is overloaded — Origin-guard AND disabled
* kill-switch both 403 — so the message, not a typed variant, is surfaced). A non-HTTP status
* (e.g. an odd 2xx/3xx) is [ApiClientError.UnexpectedStatus].
*/
private suspend fun <T> gitWrite(route: ApiRoute, serializer: kotlinx.serialization.KSerializer<T>): GitWriteOutcome<T> {
val response = perform(route)
return when (response.status) {
HttpStatus.OK -> GitWriteOutcome.Ok(decodeGitPayload(response.body, serializer))
HttpStatus.TOO_MANY_REQUESTS -> GitWriteOutcome.RateLimited
in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX ->
GitWriteOutcome.Rejected(response.status, decodeGitError(response.body))
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `POST /push/fcm-token` — register this device's FCM token (idempotent upsert → 204). Invalid
* tokens are rejected client-side (`InvalidFcmToken`) before any network I/O. */
public suspend fun registerFcmToken(token: String) {

View File

@@ -44,6 +44,9 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
/** 500 from `GET /projects/detail` — the server failed reading the repo. */
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
/** 500 from `GET /projects/log` — the server failed reading the git log. */
public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。")
/** Any other non-success status code. */
public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status")
}

View File

@@ -14,6 +14,10 @@ internal object HttpStatus {
const val NOT_FOUND = 404
const val TOO_MANY_REQUESTS = 429
const val INTERNAL_SERVER_ERROR = 500
/** Inclusive bounds of the 4xx/5xx band a guarded-write maps to a `Rejected` outcome. */
const val CLIENT_ERROR_MIN = 400
const val SERVER_ERROR_MAX = 599
}
/** Header / content-type names (no magic strings inline). */

View File

@@ -57,6 +57,28 @@ internal object Endpoints {
fun getPrefs(): ApiRoute =
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
/** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */
fun projectPr(path: String): ApiRoute =
ApiRoute(
HttpMethod.GET,
"/projects/pr",
OriginPolicy.READ_ONLY,
percentEncodedQuery = "path=${percentEncode(path)}",
)
/**
* `GET /projects/log?path=[&n=<int>]` — RO recent-commit log. `n` is clamped client-side to
* `1..GIT_LOG_MAX` (the server re-clamps regardless); a null/out-of-range `n` omits the param.
*/
fun projectLog(path: String, n: Int?): ApiRoute {
val query = StringBuilder("path=").append(percentEncode(path))
if (n != null) {
val clamped = n.coerceIn(1, GIT_LOG_MAX)
query.append("&n=").append(clamped)
}
return ApiRoute(HttpMethod.GET, "/projects/log", OriginPolicy.READ_ONLY, percentEncodedQuery = query.toString())
}
// ── G ────────────────────────────────────────────────────────────────────────────────
fun killSession(id: UUID): ApiRoute =
@@ -92,6 +114,58 @@ internal object Endpoints {
private const val FCM_TOKEN_PATH = "/push/fcm-token"
// ── G: worktree write (create / remove / prune) ────────────────────────────────────────
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
fun createWorktree(path: String, branch: String, base: String?): ApiRoute =
jsonBodyRoute(
HttpMethod.POST,
"/projects/worktree",
CreateWorktreeBody.serializer(),
CreateWorktreeBody(path, branch, base),
)
/** `DELETE /projects/worktree` — `{ path, worktreePath, force }` (DELETE **with** a JSON body). */
fun removeWorktree(path: String, worktreePath: String, force: Boolean): ApiRoute =
jsonBodyRoute(
HttpMethod.DELETE,
"/projects/worktree",
RemoveWorktreeBody.serializer(),
RemoveWorktreeBody(path, worktreePath, force),
)
/** `POST /projects/worktree/prune` — `{ path }`. */
fun pruneWorktrees(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/worktree/prune", PruneBody.serializer(), PruneBody(path))
// ── G: git write (stage / commit / push) ───────────────────────────────────────────────
/** `POST /projects/git/stage` — `{ path, files, stage }`. */
fun gitStage(path: String, files: List<String>, stage: Boolean): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
/** `POST /projects/git/commit` — `{ path, message }`. */
fun gitCommit(path: String, message: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
/** `POST /projects/git/push` — `{ path }`. */
fun gitPush(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
/** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
private fun <T> jsonBodyRoute(
method: HttpMethod,
path: String,
serializer: kotlinx.serialization.KSerializer<T>,
value: T,
): ApiRoute {
val body = ModelJson.encodeToString(serializer, value).encodeToByteArray()
return ApiRoute(method, path, OriginPolicy.GUARDED, body = body)
}
/** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */
private const val GIT_LOG_MAX = 50
/**
* Server session ids are lowercase `crypto.randomUUID()` strings and `:id` route params are
* matched as EXACT strings — always serialize lowercase. `UUID.toString()` is already lowercase
@@ -124,4 +198,22 @@ internal object Endpoints {
@Serializable
private data class FcmTokenBody(val token: String)
@Serializable
private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)
@Serializable
private data class RemoveWorktreeBody(val path: String, val worktreePath: String, val force: Boolean)
@Serializable
private data class PruneBody(val path: String)
@Serializable
private data class StageBody(val path: String, val files: List<String>, val stage: Boolean)
@Serializable
private data class CommitBody(val path: String, val message: String)
@Serializable
private data class PushBody(val path: String)
}