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:
@@ -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())
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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。")
|
||||
}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* GitLogResult list-lossy decode (plan Phase A.2): a well-formed `{commits,truncated}` decodes; a
|
||||
* commit missing `hash`/`at` is dropped while its siblings survive; `truncated` passes through; a
|
||||
* subject-less commit still decodes (subject defaults to empty).
|
||||
*/
|
||||
class GitLogTest {
|
||||
|
||||
private fun decode(json: String): GitLogResult? =
|
||||
LossyDecode.objectOrNull(json.toByteArray(), GitLogResult.serializer())
|
||||
|
||||
@Test
|
||||
fun `decodes commits and truncated`() {
|
||||
val json = """
|
||||
{ "truncated": true, "commits": [
|
||||
{ "hash":"abc123", "at": 1710000000000, "subject":"first" },
|
||||
{ "hash":"def456", "at": 1710000005000, "subject":"second" }
|
||||
] }
|
||||
""".trimIndent()
|
||||
|
||||
val result = decode(json)!!
|
||||
assertTrue(result.truncated)
|
||||
assertEquals(2, result.commits.size)
|
||||
assertEquals("abc123", result.commits[0].hash)
|
||||
assertEquals(1710000000000L, result.commits[0].at)
|
||||
assertEquals("first", result.commits[0].subject)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drops a commit missing hash or at, keeping the rest`() {
|
||||
val json = """
|
||||
{ "truncated": false, "commits": [
|
||||
{ "at": 1, "subject":"no hash" },
|
||||
{ "hash":"keep", "at": 2, "subject":"kept" },
|
||||
{ "hash":"noAt", "subject":"no at" }
|
||||
] }
|
||||
""".trimIndent()
|
||||
|
||||
val result = decode(json)!!
|
||||
assertFalse(result.truncated)
|
||||
assertEquals(1, result.commits.size)
|
||||
assertEquals("keep", result.commits.single().hash)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a subject-less commit still decodes with an empty subject`() {
|
||||
val result = decode("""{ "commits":[ { "hash":"h", "at": 5 } ] }""")!!
|
||||
assertEquals(1, result.commits.size)
|
||||
assertEquals("", result.commits.single().subject)
|
||||
assertFalse(result.truncated) // default
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-object body degrades to null`() {
|
||||
org.junit.jupiter.api.Assertions.assertNull(decode("[]"))
|
||||
org.junit.jupiter.api.Assertions.assertNull(decode("garbage"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* GitWrite payload + error decode (plan Phase A.3): each 200 payload decodes; a failure body
|
||||
* `{ok:false,error:"…"}` (git-ops) and `{error:"…"}` (worktrees) both yield the SAFE `error` string;
|
||||
* a garbled 200 body degrades to the payload defaults (never throws). The empty-sha commit case is
|
||||
* exercised (server can return `{ok:true, commit:""}`).
|
||||
*/
|
||||
class GitWriteTest {
|
||||
|
||||
@Test
|
||||
fun `stage payload decodes staged and count`() {
|
||||
val r = decodeGitPayload("""{"ok":true,"staged":true,"count":3}""".toByteArray(), StageResult.serializer())
|
||||
assertEquals(StageResult(staged = true, count = 3), r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commit payload decodes the sha and tolerates an empty sha`() {
|
||||
assertEquals("a1b2c3", decodeGitPayload("""{"ok":true,"commit":"a1b2c3"}""".toByteArray(), CommitResult.serializer()).commit)
|
||||
assertEquals("", decodeGitPayload("""{"ok":true,"commit":""}""".toByteArray(), CommitResult.serializer()).commit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `push payload decodes branch and remote`() {
|
||||
val r = decodeGitPayload("""{"ok":true,"branch":"main","remote":"origin"}""".toByteArray(), PushResult.serializer())
|
||||
assertEquals("main", r.branch)
|
||||
assertEquals("origin", r.remote)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `worktree create and remove and prune payloads decode`() {
|
||||
val create = decodeGitPayload("""{"ok":true,"path":"/wt/x","branch":"feat"}""".toByteArray(), CreateWorktreeResult.serializer())
|
||||
assertEquals("/wt/x", create.path)
|
||||
assertEquals("feat", create.branch)
|
||||
|
||||
val remove = decodeGitPayload("""{"ok":true,"path":"/wt/x"}""".toByteArray(), RemoveWorktreeResult.serializer())
|
||||
assertEquals("/wt/x", remove.path)
|
||||
|
||||
val prune = decodeGitPayload("""{"ok":true,"pruned":["a","b"]}""".toByteArray(), PruneWorktreesResult.serializer())
|
||||
assertEquals(listOf("a", "b"), prune.pruned)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a garbled 200 body degrades to payload defaults, never throwing`() {
|
||||
assertEquals(StageResult(), decodeGitPayload("not json".toByteArray(), StageResult.serializer()))
|
||||
assertEquals(CommitResult(), decodeGitPayload("[]".toByteArray(), CommitResult.serializer()))
|
||||
assertTrue(decodeGitPayload("{}".toByteArray(), PruneWorktreesResult.serializer()).pruned.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a git-ops failure body yields the safe error string`() {
|
||||
assertEquals("Nothing to commit.", decodeGitError("""{"ok":false,"error":"Nothing to commit."}""".toByteArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a worktree failure body (no ok field) still yields the error string`() {
|
||||
assertEquals("Worktree creation is disabled.", decodeGitError("""{"error":"Worktree creation is disabled."}""".toByteArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty or errorless failure body yields null`() {
|
||||
assertNull(decodeGitError(ByteArray(0)))
|
||||
assertNull(decodeGitError("""{"ok":false}""".toByteArray()))
|
||||
assertNull(decodeGitError("not json".toByteArray()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* PrStatus tolerant decode (plan Phase A.1): a full `availability:"ok"` body decodes every field; an
|
||||
* unknown/missing `availability` degrades to [PrAvailability.ERROR] (never throws); `PrCheckSummary`
|
||||
* counts round-trip; a non-object body degrades rather than crashing. Mirrors the FE never treating a
|
||||
* non-`ok` availability as an HTTP error.
|
||||
*/
|
||||
class PrStatusTest {
|
||||
|
||||
private fun decode(json: String): PrStatus? =
|
||||
LossyDecode.objectOrNull(json.toByteArray(), PrStatus.serializer())
|
||||
|
||||
@Test
|
||||
fun `decodes a full ok body with all fields and check counts`() {
|
||||
val json = """
|
||||
{ "availability":"ok", "number":42, "title":"Add worktrees", "url":"https://x/pull/42",
|
||||
"state":"open", "isDraft":false, "mergeable":"mergeable",
|
||||
"headRefName":"feat/wt", "baseRefName":"main",
|
||||
"checks": { "total":5, "passing":3, "failing":1, "pending":1 } }
|
||||
""".trimIndent()
|
||||
|
||||
val pr = decode(json)!!
|
||||
assertEquals(PrAvailability.OK, pr.availability)
|
||||
assertEquals(42, pr.number)
|
||||
assertEquals("Add worktrees", pr.title)
|
||||
assertEquals("https://x/pull/42", pr.url)
|
||||
assertEquals("open", pr.state)
|
||||
assertEquals(false, pr.isDraft)
|
||||
assertEquals("mergeable", pr.mergeable)
|
||||
assertEquals("feat/wt", pr.headRefName)
|
||||
assertEquals("main", pr.baseRefName)
|
||||
assertEquals(PrCheckSummary(total = 5, passing = 3, failing = 1, pending = 1), pr.checks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown availability degrades to ERROR, never throwing`() {
|
||||
val pr = decode("""{ "availability":"quantum-flux" }""")!!
|
||||
assertEquals(PrAvailability.ERROR, pr.availability)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a body missing availability defaults to ERROR and leaves optional fields null`() {
|
||||
val pr = decode("""{ "number":7 }""")!!
|
||||
assertEquals(PrAvailability.ERROR, pr.availability)
|
||||
assertEquals(7, pr.number)
|
||||
assertNull(pr.title)
|
||||
assertNull(pr.checks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each known availability maps from its wire value`() {
|
||||
assertEquals(PrAvailability.NO_PR, PrAvailability.fromWire("no-pr"))
|
||||
assertEquals(PrAvailability.NOT_INSTALLED, PrAvailability.fromWire("not-installed"))
|
||||
assertEquals(PrAvailability.UNAUTHENTICATED, PrAvailability.fromWire("unauthenticated"))
|
||||
assertEquals(PrAvailability.DISABLED, PrAvailability.fromWire("disabled"))
|
||||
assertEquals(PrAvailability.ERROR, PrAvailability.fromWire("error"))
|
||||
assertEquals(PrAvailability.ERROR, PrAvailability.fromWire("")) // empty → ERROR
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-object body degrades to null rather than throwing`() {
|
||||
assertNull(decode("[]"))
|
||||
assertNull(decode("not json"))
|
||||
assertNull(LossyDecode.objectOrNull(ByteArray(0), PrStatus.serializer()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown top-level keys are ignored`() {
|
||||
val pr = decode("""{ "availability":"ok", "futureField":123, "nested":{"a":1} }""")!!
|
||||
assertEquals(PrAvailability.OK, pr.availability)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||
import wang.yaojia.webterm.api.models.PrAvailability
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
|
||||
/**
|
||||
* Status-code → outcome mapping for the W5 git surface (plan Phase A.5): PR 200/400/404; log
|
||||
* decode + errors; each guarded write 200→Ok, 403→Rejected(body.error), 409→Rejected, 429→
|
||||
* RateLimited. Also asserts the transport RECEIVED an Origin on writes and NOT on reads.
|
||||
*/
|
||||
class ApiClientGitTest {
|
||||
private companion object {
|
||||
const val BASE = "http://h:3000"
|
||||
}
|
||||
|
||||
private val transport = FakeHttpTransport()
|
||||
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
||||
|
||||
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
|
||||
|
||||
// ── PR (RO; degrade lives in the 200 body, not the status) ───────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `projectPr decodes a 200 degrade body and maps 400 404`() = runTest {
|
||||
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = """{"availability":"not-installed"}""".toByteArray())
|
||||
assertEquals(PrAvailability.NOT_INSTALLED, client.projectPr("/r").availability)
|
||||
|
||||
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", status = 400)
|
||||
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectPr("/r") })
|
||||
|
||||
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", status = 404)
|
||||
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectPr("/r") })
|
||||
|
||||
// Empty path is rejected before any I/O.
|
||||
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectPr("") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `projectPr never treats a garbled 200 body as an error (degrades to ERROR)`() = runTest {
|
||||
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = "not json".toByteArray())
|
||||
assertEquals(PrAvailability.ERROR, client.projectPr("/r").availability)
|
||||
}
|
||||
|
||||
// ── log ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `projectLog decodes 200 and maps 404 and 500`() = runTest {
|
||||
transport.queueSuccess(
|
||||
url = "$BASE/projects/log?path=%2Fr",
|
||||
body = """{"commits":[{"hash":"h","at":1,"subject":"s"}],"truncated":false}""".toByteArray(),
|
||||
)
|
||||
assertEquals(1, client.projectLog("/r").commits.size)
|
||||
|
||||
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", status = 404)
|
||||
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectLog("/r") })
|
||||
|
||||
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", status = 500)
|
||||
assertEquals(ApiClientError.GitLogUnavailable, errorOf { client.projectLog("/r") })
|
||||
}
|
||||
|
||||
// ── guarded writes: outcome mapping ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a guarded write 200 yields Ok with the decoded payload`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true,"commit":"abc"}""".toByteArray())
|
||||
val outcome = client.gitCommit("/r", "msg")
|
||||
assertTrue(outcome is GitWriteOutcome.Ok)
|
||||
assertEquals("abc", (outcome as GitWriteOutcome.Ok).payload.commit)
|
||||
// The write stamped an Origin.
|
||||
assertTrue(transport.recordedRequests.last().headers.containsKey(HeaderName.ORIGIN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `403 disabled and 409 both surface Rejected with the safe error string`() = runTest {
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST, url = "$BASE/projects/worktree",
|
||||
status = 403, body = """{"error":"Worktree creation is disabled."}""".toByteArray(),
|
||||
)
|
||||
val disabled = client.createWorktree("/r", "b", null)
|
||||
assertEquals(GitWriteOutcome.Rejected(403, "Worktree creation is disabled."), disabled)
|
||||
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.DELETE, url = "$BASE/projects/worktree",
|
||||
status = 409, body = """{"error":"Worktree has uncommitted changes; force required."}""".toByteArray(),
|
||||
)
|
||||
val dirty = client.removeWorktree("/r", "/r/x", false)
|
||||
assertEquals(GitWriteOutcome.Rejected(409, "Worktree has uncommitted changes; force required."), dirty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `429 yields RateLimited and never auto-retries`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", status = 429, body = """{"error":"Too many requests."}""".toByteArray())
|
||||
assertEquals(GitWriteOutcome.RateLimited, client.gitPush("/r"))
|
||||
assertEquals(1, transport.recordedRequests.size) // exactly one attempt
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stage 200 decodes staged and count and threads the files body`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true,"staged":true,"count":2}""".toByteArray())
|
||||
val outcome = client.gitStage("/r", listOf("a", "b"), stage = true)
|
||||
assertTrue(outcome is GitWriteOutcome.Ok)
|
||||
assertEquals(2, (outcome as GitWriteOutcome.Ok).payload.count)
|
||||
assertEquals("""{"path":"/r","files":["a","b"],"stage":true}""", transport.recordedRequests.last().body?.decodeToString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
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.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Request-shape + Origin-iff-guarded (plan §4.3 铁律) for the W5 git surface: the two NEW reads
|
||||
* (`/projects/pr`, `/projects/log`) carry **no** Origin; the six writes (worktree×3, git×3) carry a
|
||||
* byte-equal Origin and a JSON body — including a `DELETE /projects/worktree` that carries a body
|
||||
* (the highest-risk integration gotcha). A route reclassified read↔write turns this red.
|
||||
*/
|
||||
class GitRouteShapeTest {
|
||||
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()
|
||||
|
||||
private fun assertGuarded(r: HttpRequest) =
|
||||
assertEquals(ORIGIN, r.headers[HeaderName.ORIGIN], "guarded write must stamp byte-equal Origin")
|
||||
|
||||
private fun assertReadOnly(r: HttpRequest) =
|
||||
assertFalse(r.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin")
|
||||
|
||||
// ── reads: no Origin, correct verb + strict-encoded query ────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `projectPr is a read-only GET with a strict-encoded path and no Origin`() = runTest {
|
||||
val path = "/home/me/my repo/a+b&c"
|
||||
val url = "$BASE/projects/pr?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c"
|
||||
transport.queueSuccess(url = url, body = """{"availability":"no-pr"}""".toByteArray())
|
||||
|
||||
client.projectPr(path)
|
||||
|
||||
val r = last()
|
||||
assertEquals(HttpMethod.GET, r.method)
|
||||
assertEquals(url, r.url)
|
||||
assertReadOnly(r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `projectLog omits n when null and appends a clamped n when set`() = runTest {
|
||||
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp", body = """{"commits":[],"truncated":false}""".toByteArray())
|
||||
client.projectLog("/p", n = null)
|
||||
assertEquals("$BASE/projects/log?path=%2Fp", last().url)
|
||||
assertReadOnly(last())
|
||||
|
||||
// n above GIT_LOG_MAX (50) clamps to 50; below 1 clamps to 1.
|
||||
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp&n=50", body = """{"commits":[],"truncated":false}""".toByteArray())
|
||||
client.projectLog("/p", n = 999)
|
||||
assertEquals("$BASE/projects/log?path=%2Fp&n=50", last().url)
|
||||
|
||||
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp&n=1", body = """{"commits":[],"truncated":false}""".toByteArray())
|
||||
client.projectLog("/p", n = 0)
|
||||
assertEquals("$BASE/projects/log?path=%2Fp&n=1", last().url)
|
||||
}
|
||||
|
||||
// ── writes: Origin stamped, correct verb, JSON body ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `createWorktree is a guarded POST with a path-branch-base body`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
||||
client.createWorktree("/repo", "feat/x", base = "main")
|
||||
|
||||
val r = last()
|
||||
assertEquals(HttpMethod.POST, r.method)
|
||||
assertEquals("$BASE/projects/worktree", r.url)
|
||||
assertGuarded(r)
|
||||
assertEquals(ContentType.JSON, r.headers[HeaderName.CONTENT_TYPE])
|
||||
assertEquals("""{"path":"/repo","branch":"feat/x","base":"main"}""", r.body?.decodeToString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createWorktree omits base when null`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
||||
client.createWorktree("/repo", "feat/x", base = null)
|
||||
assertEquals("""{"path":"/repo","branch":"feat/x"}""", last().body?.decodeToString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeWorktree is a guarded DELETE that CARRIES a JSON body`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
||||
client.removeWorktree("/repo", "/repo-worktrees/x", force = true)
|
||||
|
||||
val r = last()
|
||||
assertEquals(HttpMethod.DELETE, r.method)
|
||||
assertEquals("$BASE/projects/worktree", r.url)
|
||||
assertGuarded(r)
|
||||
assertNotNull(r.body, "DELETE /projects/worktree MUST carry a request body")
|
||||
assertEquals("""{"path":"/repo","worktreePath":"/repo-worktrees/x","force":true}""", r.body?.decodeToString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pruneWorktrees is a guarded POST with a path body`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree/prune", body = """{"ok":true,"pruned":[]}""".toByteArray())
|
||||
client.pruneWorktrees("/repo")
|
||||
|
||||
val r = last()
|
||||
assertEquals(HttpMethod.POST, r.method)
|
||||
assertEquals("$BASE/projects/worktree/prune", r.url)
|
||||
assertGuarded(r)
|
||||
assertEquals("""{"path":"/repo"}""", r.body?.decodeToString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gitStage commit push are guarded POSTs with exact bodies`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true}""".toByteArray())
|
||||
client.gitStage("/repo", listOf("a.kt", "b.kt"), stage = true)
|
||||
assertEquals("$BASE/projects/git/stage", last().url)
|
||||
assertGuarded(last())
|
||||
assertEquals("""{"path":"/repo","files":["a.kt","b.kt"],"stage":true}""", last().body?.decodeToString())
|
||||
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true,"commit":"x"}""".toByteArray())
|
||||
client.gitCommit("/repo", "a message")
|
||||
assertEquals("""{"path":"/repo","message":"a message"}""", last().body?.decodeToString())
|
||||
assertGuarded(last())
|
||||
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", body = """{"ok":true}""".toByteArray())
|
||||
client.gitPush("/repo")
|
||||
assertEquals("$BASE/projects/git/push", last().url)
|
||||
assertEquals("""{"path":"/repo"}""", last().body?.decodeToString())
|
||||
assertGuarded(last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every guarded write carries Origin and every read does not (batch invariant)`() = runTest {
|
||||
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = """{"availability":"ok"}""".toByteArray())
|
||||
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", body = """{"commits":[],"truncated":false}""".toByteArray())
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
||||
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree/prune", body = """{"ok":true}""".toByteArray())
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true}""".toByteArray())
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true}""".toByteArray())
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", body = """{"ok":true}""".toByteArray())
|
||||
|
||||
client.projectPr("/r"); client.projectLog("/r", null)
|
||||
assertReadOnly(transport.recordedRequests[0])
|
||||
assertReadOnly(transport.recordedRequests[1])
|
||||
|
||||
client.createWorktree("/r", "b", null)
|
||||
client.removeWorktree("/r", "/r/x", false)
|
||||
client.pruneWorktrees("/r")
|
||||
client.gitStage("/r", listOf("f"), true)
|
||||
client.gitCommit("/r", "m")
|
||||
client.gitPush("/r")
|
||||
assertTrue(transport.recordedRequests.drop(2).all { it.headers.containsKey(HeaderName.ORIGIN) })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user