Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae9b22a9e6 | ||
|
|
96a9e8077e | ||
|
|
a97e6a2b14 | ||
|
|
4317da85fd | ||
|
|
45fde0ad9a | ||
|
|
94565e9d6d | ||
|
|
56c2e6fa07 | ||
|
|
28629f3d93 | ||
|
|
0d1faf47a0 | ||
|
|
00288a2b3b | ||
|
|
8f52feffba | ||
|
|
ee29e49361 | ||
|
|
e2073b7692 | ||
|
|
70b6d8ee5a | ||
|
|
1f5e50ccd7 | ||
|
|
5580c9d9bb | ||
|
|
2ebdf55501 | ||
|
|
ad107ea205 | ||
|
|
f5aeb27e5a | ||
|
|
fdaeb121d5 | ||
|
|
06c0df6304 | ||
|
|
71a2b3a7fb | ||
|
|
65e48084cb | ||
|
|
1074ecc24f | ||
|
|
f2a23e32aa | ||
|
|
630cc6d316 | ||
|
|
e16205d82a | ||
|
|
676c37e5ca | ||
|
|
9b6fed9bdd | ||
|
|
4d90eef3d8 | ||
|
|
478323893a | ||
|
|
fcddeeb810 | ||
|
|
49002b7141 | ||
|
|
326eb47df3 |
@@ -11,9 +11,6 @@
|
||||
|
||||
name: CI — Upstream Contract
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
@@ -43,64 +40,45 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout hermes-relay
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve upstream ref
|
||||
id: ref
|
||||
env:
|
||||
REQUESTED_REF: ${{ github.event.inputs.upstream_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# PR/push runs use a known-good NousResearch/hermes-agent commit so
|
||||
# normal CI is stable. The weekly schedule below intentionally tracks
|
||||
# main as the upstream-drift siren.
|
||||
DEFAULT_REF="ef4b897a1843cd32c4f141f55db60f0f0602cc98"
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
REF="main" # weekly drift siren
|
||||
elif [ -n "$REQUESTED_REF" ]; then
|
||||
REF="$REQUESTED_REF" # manual override
|
||||
elif [ -n "${{ github.event.inputs.upstream_ref }}" ]; then
|
||||
REF="${{ github.event.inputs.upstream_ref }}" # manual override
|
||||
else
|
||||
REF="$DEFAULT_REF"
|
||||
fi
|
||||
|
||||
# The ref is passed to git below, so reject option-like or malformed
|
||||
# values before it reaches that boundary. Full commit IDs and normal
|
||||
# branch/tag names remain supported for manual contract checks.
|
||||
if [[ "$REF" == -* ]] ||
|
||||
! git check-ref-format --allow-onelevel "$REF" >/dev/null; then
|
||||
echo "FAIL: invalid upstream branch or tag name." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
echo "Checking standard-path route contract against upstream ref: $REF"
|
||||
|
||||
- name: Extract trusted upstream contract sources
|
||||
env:
|
||||
UPSTREAM_REF: ${{ steps.ref.outputs.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
UPSTREAM_GIT="$RUNNER_TEMP/hermes-agent-contract.git"
|
||||
git init --bare "$UPSTREAM_GIT"
|
||||
git -C "$UPSTREAM_GIT" remote add origin \
|
||||
"https://github.com/NousResearch/hermes-agent.git"
|
||||
git -C "$UPSTREAM_GIT" fetch --no-tags --depth=1 origin -- "$UPSTREAM_REF"
|
||||
UPSTREAM_COMMIT="$(git -C "$UPSTREAM_GIT" rev-parse 'FETCH_HEAD^{commit}')"
|
||||
|
||||
mkdir -p _upstream/gateway/platforms _upstream/hermes_cli
|
||||
git -C "$UPSTREAM_GIT" show \
|
||||
"$UPSTREAM_COMMIT:gateway/platforms/api_server.py" \
|
||||
> _upstream/gateway/platforms/api_server.py
|
||||
git -C "$UPSTREAM_GIT" show \
|
||||
"$UPSTREAM_COMMIT:hermes_cli/web_server.py" \
|
||||
> _upstream/hermes_cli/web_server.py
|
||||
echo "Extracted contract sources from upstream commit: $UPSTREAM_COMMIT"
|
||||
- name: Checkout vanilla upstream (no plugin, no bootstrap)
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: NousResearch/hermes-agent
|
||||
ref: ${{ steps.ref.outputs.ref }}
|
||||
path: _upstream
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Assert upstream checkout is vanilla (no relay bootstrap/plugin)
|
||||
run: |
|
||||
if [ -e "_upstream/hermes_relay_bootstrap" ] || \
|
||||
[ -e "_upstream/plugin/hermes_relay_bootstrap" ] || \
|
||||
find _upstream -name "hermes_relay_bootstrap.pth" 2>/dev/null | grep -q .; then
|
||||
echo "FAIL: upstream checkout contains a relay bootstrap — not vanilla."; exit 1
|
||||
fi
|
||||
echo "OK: upstream checkout carries no relay plugin/bootstrap."
|
||||
|
||||
- name: Run route-surface contract
|
||||
run: python scripts/check-upstream-route-contract.py "_upstream"
|
||||
|
||||
@@ -38,6 +38,10 @@ jobs:
|
||||
working-directory: plugin/dashboard
|
||||
run: npm run build
|
||||
|
||||
- name: Test dashboard source
|
||||
working-directory: plugin/dashboard
|
||||
run: npm test
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
@@ -54,7 +58,11 @@ jobs:
|
||||
run: pip install -r relay_server/requirements.txt fastapi httpx requests
|
||||
|
||||
- name: Run dashboard API tests
|
||||
run: python -m unittest plugin.dashboard.test_plugin_api
|
||||
run: >-
|
||||
python -m unittest
|
||||
plugin.dashboard.test_plugin_api
|
||||
plugin.dashboard.test_git_api
|
||||
plugin.dashboard.test_mobile_plugin_api
|
||||
|
||||
- name: Verify dashboard bundle outputs
|
||||
run: |
|
||||
|
||||
@@ -106,4 +106,8 @@ jobs:
|
||||
plugin/tests/test_session_grants.py \
|
||||
plugin/tests/test_native_layout_imports.py \
|
||||
plugin/tests/test_profile_discovery.py \
|
||||
plugin/tests/test_profiles_updated_broadcast.py
|
||||
plugin/tests/test_profiles_updated_broadcast.py \
|
||||
plugin/tests/test_git_state.py \
|
||||
plugin/tests/test_git_state_write.py \
|
||||
plugin/tests/test_git_state_extras.py \
|
||||
plugin/tests/test_mobile_plugin_store.py
|
||||
|
||||
+1
-6
@@ -6,12 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [Android 1.13.2] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Android Supervised Mode presents a parent-controlled, profile-pinned chat surface.** Parents can limit attachments, Standard voice, generated media, conversation history, actions, and technical metadata while device authentication protects full settings. Hermes-Relay can identify and revoke a paired supervised client without becoming the policy enforcement boundary.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android session rows stay neutral when optional live activity is unavailable or still loading.** Directory refreshes no longer restore a persistent Checking state, and full-row activity borders are reserved for actual Starting or Working turns.
|
||||
@@ -30,6 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
- **Provider usage and limits are available from top-level Settings.** Codex credential pools, Nous balances, and OpenCode Go account windows share one provider-neutral screen with Summary, Expanded, and Hidden presentation modes. Provider credentials remain on the Hermes host.
|
||||
- **Android Bot Mode provides one messenger-style workspace across saved Hermes gateways.** Bots and read-only group rooms aggregate without changing the foreground connection, Bot Chats retain exact gateway/profile ownership, and unavailable gateways keep clearly marked last-known roster entries.
|
||||
- **Android Assistant screen context.** Compatible unlocked assistant-button invocations can open Hermes, begin listening, and include bounded visible text plus an available screenshot in the first Standard voice turn. Ordinary wake and keyguard invocations remain screen-context free.
|
||||
- **Android Supervised Mode presents a parent-controlled, profile-pinned chat surface.** Parents can limit attachments, Standard voice, generated media, conversation history, actions, and technical metadata while device authentication protects full settings. Hermes-Relay can identify and revoke a paired supervised client without becoming the policy enforcement boundary.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
+7
-10
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay Android v1.13.2
|
||||
# Hermes-Relay Android v1.13.1
|
||||
|
||||
**Release Date:** August 25, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.13.2-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.13.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,20 +12,17 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This release adds a parent-configured Supervised Mode and improves its return from full settings. It also keeps session rows neutral until live activity is confirmed.
|
||||
|
||||
## Added
|
||||
|
||||
- Use a profile-pinned Supervised Mode with parent-controlled attachments, Standard voice, generated media, history, actions, and technical details. Device authentication protects full settings; this remains a client-side restricted view rather than a server-enforced account boundary.
|
||||
This patch makes Android session activity follow live Hermes runtime state instead of a five-minute recency estimate. It keeps Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work accurate while preserving stale state until a complete, unambiguous snapshot can safely replace it.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Keep session rows neutral while optional live activity is unavailable or still loading, and reserve full-row activity borders for actual Starting or Working turns.
|
||||
- Keep Supervised Chat rendered when parent access relocks after visiting full settings.
|
||||
- Derive session activity from the authoritative live runtime snapshot rather than Dashboard recency.
|
||||
- Preserve prior activity when a refresh is incomplete, unsupported, or ambiguously scoped.
|
||||
- Keep session drawer labels, timestamps, and active-turn ownership aligned with the exact profile and session.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.13.2** (versionCode **51**).
|
||||
- App version: **1.13.1** (versionCode **50**).
|
||||
- Standard Chat, sessions, Manage, sharing, profile switching, and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
- Granular Device Control remains sideload-only; the Google Play build continues to ship Hermes Bridge Core without AccessibilityService Device Control.
|
||||
- The optional Relay plugin remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
|
||||
|
||||
@@ -1 +1 @@
|
||||
Supervised Mode adds a parent-configured, profile-pinned chat view with device-authenticated settings. Parents can limit attachments, Standard voice, generated media, history, actions, and technical details. Session rows stay neutral while live activity is unavailable, and returning from parent settings no longer blanks Supervised Chat.
|
||||
Session activity now follows live Hermes runtime state instead of a recent-activity estimate. Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work stay accurate, and stale state clears only after a complete, unambiguous update.
|
||||
|
||||
@@ -1 +1 @@
|
||||
新增监督模式:家长可配置并固定到指定配置文件,设置受设备身份验证保护。家长可限制附件、标准语音、生成媒体、历史记录、操作和技术详情。实时活动不可用时会话行保持中性显示,从家长设置返回时监督聊天也不再空白。
|
||||
会话活动现在依据 Hermes 的实时运行状态,而不是最近活动时间估算。工作中、启动中、需要输入、空闲、检查中、不可用和后台工作等状态会保持准确;只有完整且明确的更新才会清除旧状态。
|
||||
|
||||
@@ -1,26 +1,5 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.13.2",
|
||||
"title": "Supervised Mode and clearer activity",
|
||||
"date": "2026-08-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Use a supervised chat",
|
||||
"bullets": [
|
||||
"Configure a profile-pinned restricted chat with parent-controlled attachments, voice, media, history, actions, and technical details.",
|
||||
"Protect full settings with device authentication and keep Supervised Chat visible when parent access relocks."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Show only confirmed activity",
|
||||
"bullets": [
|
||||
"Keep session rows neutral while optional live activity is unavailable or still loading.",
|
||||
"Show full-row activity borders only during actual Starting or Working turns."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.13.1",
|
||||
"title": "Accurate session activity",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
v1.13.2 - Supervised Mode and clearer activity
|
||||
v1.13.1 - Accurate session activity
|
||||
|
||||
* Configure a profile-pinned Supervised Mode with device-authenticated parent settings.
|
||||
* Keep Supervised Chat visible when parent access relocks after full settings.
|
||||
* Keep uncertain session activity neutral until live work is confirmed.
|
||||
* Follow live Hermes runtime state for Working, Starting, Needs input, and Idle.
|
||||
* Keep stale activity visible until a complete, unambiguous snapshot clears it.
|
||||
* Distinguish Checking, Unavailable, and Background work in the session drawer.
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.plugins.runtime.ScopedPluginApiClient
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
// Read + write client for the Hermes-Relay Git State endpoints.
|
||||
// All requests are confined to the ``hermes-relay`` plugin namespace and the
|
||||
// ``git/*`` sub-path via ScopedPluginApiClient, which rejects traversal and
|
||||
// encodes query values.
|
||||
|
||||
private fun pathsArray(paths: List<String>) = buildJsonArray { paths.forEach { add(JsonPrimitive(it)) } }
|
||||
|
||||
class GitStateApiClient(
|
||||
dashboard: DashboardApiClient,
|
||||
) {
|
||||
private val scoped = ScopedPluginApiClient("hermes-relay", dashboard)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun repos(): Result<List<GitRepo>> = scoped
|
||||
.get("git/repos")
|
||||
.mapCatching { element ->
|
||||
json.decodeFromJsonElement<ReposResponse>(element).repos
|
||||
}
|
||||
|
||||
suspend fun status(repo: String): Result<GitStatus> = scoped
|
||||
.get("git/status", mapOf("repo" to repo))
|
||||
.mapCatching { element -> json.decodeFromJsonElement<GitStatus>(element) }
|
||||
|
||||
suspend fun branches(repo: String): Result<List<GitBranch>> = scoped
|
||||
.get("git/branches", mapOf("repo" to repo))
|
||||
.mapCatching { element ->
|
||||
json.decodeFromJsonElement<BranchesResponse>(element).branches
|
||||
}
|
||||
|
||||
suspend fun diff(repo: String, path: String, kind: String): Result<GitDiff> = scoped
|
||||
.get("git/diff", mapOf("repo" to repo, "path" to path, "kind" to kind))
|
||||
.mapCatching { element -> json.decodeFromJsonElement<GitDiff>(element) }
|
||||
|
||||
suspend fun file(repo: String, path: String): Result<GitFile> = scoped
|
||||
.get("git/file", mapOf("repo" to repo, "path" to path))
|
||||
.mapCatching { element -> json.decodeFromJsonElement<GitFile>(element) }
|
||||
|
||||
// ── Write operations ───────────────────────────────────────────────────
|
||||
// Every write requires the plugin.api.write grant, which the app enforces
|
||||
// (see GitStateViewModel: a POST is never sent without the grant). The
|
||||
// server additionally enforces per-use confirmation strings for destructive
|
||||
// ops (discard/push/dirty-checkout) — the caller passes the echoed token.
|
||||
|
||||
suspend fun stage(repo: String, paths: List<String>): Result<GitMutationResult> =
|
||||
scoped.post("git/stage", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun unstage(repo: String, paths: List<String>): Result<GitMutationResult> =
|
||||
scoped.post("git/unstage", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun discard(
|
||||
repo: String,
|
||||
paths: List<String>,
|
||||
confirmation: String,
|
||||
deleteUntracked: Boolean = false,
|
||||
): Result<GitMutationResult> = scoped.post("git/discard", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
put("confirmation", confirmation)
|
||||
put("delete_untracked", deleteUntracked)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun commit(repo: String, message: String): Result<GitMutationResult> =
|
||||
scoped.post("git/commit", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("message", message)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun commitSelected(
|
||||
repo: String,
|
||||
message: String,
|
||||
paths: List<String>,
|
||||
): Result<GitMutationResult> = scoped.post("git/commit_selected", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("message", message)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun fetch(repo: String, remote: String = "origin"): Result<GitMutationResult> =
|
||||
scoped.post("git/fetch", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("remote", remote)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun pull(repo: String, remote: String = "origin", branch: String = ""): Result<GitMutationResult> =
|
||||
scoped.post("git/pull", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("remote", remote)
|
||||
put("branch", branch)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun push(
|
||||
repo: String,
|
||||
confirmation: String,
|
||||
remote: String = "origin",
|
||||
branch: String = "",
|
||||
): Result<GitMutationResult> = scoped.post("git/push", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("remote", remote)
|
||||
put("branch", branch)
|
||||
put("confirmation", confirmation)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun checkout(
|
||||
repo: String,
|
||||
ref: String,
|
||||
confirmation: String? = null,
|
||||
newBranch: String = "",
|
||||
track: Boolean = false,
|
||||
): Result<GitMutationResult> = scoped.post("git/checkout", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("ref", ref)
|
||||
if (confirmation != null) put("confirmation", confirmation)
|
||||
if (newBranch.isNotEmpty()) put("new_branch", newBranch)
|
||||
put("track", track)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
// ── Phase 3 extras ─────────────────────────────────────────────────────
|
||||
|
||||
/** Generate a commit-message suggestion from the staged diff. */
|
||||
suspend fun commitMessage(repo: String): Result<GitCommitMessage> =
|
||||
scoped.post("git/commit_message", buildJsonObject {
|
||||
put("repo", repo)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitCommitMessage>(it) }
|
||||
|
||||
/** Generate a commit-message suggestion from the given paths' staged diff. */
|
||||
suspend fun commitMessageSelected(
|
||||
repo: String,
|
||||
paths: List<String>,
|
||||
): Result<GitCommitMessage> = scoped.post("git/commit_message_selected", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitCommitMessage>(it) }
|
||||
|
||||
/** Checkout that auto-stashes a dirty tree first. */
|
||||
suspend fun stashCheckout(
|
||||
repo: String,
|
||||
ref: String,
|
||||
newBranch: String = "",
|
||||
track: Boolean = false,
|
||||
): Result<GitStashCheckoutResult> = scoped.post("git/stash_checkout", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("ref", ref)
|
||||
if (newBranch.isNotEmpty()) put("new_branch", newBranch)
|
||||
put("track", track)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitStashCheckoutResult>(it) }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** A repository discovered by the plugin's /git/repos endpoint. */
|
||||
@Serializable
|
||||
data class GitRepo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val root: String,
|
||||
@SerialName("current_branch") val currentBranch: String? = null,
|
||||
val dirty: Boolean = false,
|
||||
)
|
||||
|
||||
/** Working-tree status from /git/status. */
|
||||
@Serializable
|
||||
data class GitStatus(
|
||||
val counts: GitStatusCounts = GitStatusCounts(),
|
||||
val staged: List<GitStatusEntry> = emptyList(),
|
||||
val modified: List<GitStatusEntry> = emptyList(),
|
||||
val untracked: List<GitStatusEntry> = emptyList(),
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GitStatusCounts(
|
||||
val staged: Int = 0,
|
||||
val modified: Int = 0,
|
||||
val untracked: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GitStatusEntry(
|
||||
val path: String,
|
||||
)
|
||||
|
||||
/** A branch from /git/branches. */
|
||||
@Serializable
|
||||
data class GitBranch(
|
||||
val name: String,
|
||||
val upstream: String? = null,
|
||||
val ahead: Int = 0,
|
||||
val behind: Int = 0,
|
||||
@SerialName("is_current") val isCurrent: Boolean = false,
|
||||
)
|
||||
|
||||
/** A per-file diff from /git/diff. */
|
||||
@Serializable
|
||||
data class GitDiff(
|
||||
val path: String,
|
||||
val kind: String,
|
||||
val diff: String,
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
/** A tracked-file read from /git/file. */
|
||||
@Serializable
|
||||
data class GitFile(
|
||||
val path: String,
|
||||
val content: String,
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
/** Wrapper for /git/repos response. */
|
||||
@Serializable
|
||||
internal data class ReposResponse(
|
||||
val repos: List<GitRepo> = emptyList(),
|
||||
val notice: String? = null,
|
||||
)
|
||||
|
||||
/** Wrapper for /git/branches response. */
|
||||
@Serializable
|
||||
internal data class BranchesResponse(
|
||||
val branches: List<GitBranch> = emptyList(),
|
||||
)
|
||||
|
||||
/** A mutation response: fresh HEAD oid + working-tree status (+ branches). */
|
||||
@Serializable
|
||||
data class GitMutationResult(
|
||||
val head: String = "",
|
||||
val status: GitStatus = GitStatus(),
|
||||
val branches: List<GitBranch> = emptyList(),
|
||||
)
|
||||
|
||||
/** A /git/commit_message suggestion: generated message + optional notice. */
|
||||
@Serializable
|
||||
data class GitCommitMessage(
|
||||
val message: String = "",
|
||||
val notice: String = "",
|
||||
)
|
||||
|
||||
/** A /git/stash_checkout result: standard mutation shape + stash flag/message. */
|
||||
@Serializable
|
||||
data class GitStashCheckoutResult(
|
||||
val head: String = "",
|
||||
val status: GitStatus = GitStatus(),
|
||||
val branches: List<GitBranch> = emptyList(),
|
||||
val stashed: Boolean = false,
|
||||
@SerialName("stash_message") val stashMessage: String = "",
|
||||
)
|
||||
@@ -179,6 +179,8 @@ import com.hermesandroid.relay.ui.screens.SupervisedAppearanceSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.UsageLimitsScreen
|
||||
import com.hermesandroid.relay.ui.screens.PluginsScreen
|
||||
import com.hermesandroid.relay.ui.screens.PluginPageScreen
|
||||
import com.hermesandroid.relay.ui.screens.GitStateScreen
|
||||
import com.hermesandroid.relay.viewmodel.GitStateViewModel
|
||||
import com.hermesandroid.relay.ui.screens.TerminalScreen
|
||||
import com.hermesandroid.relay.ui.screens.NotificationCompanionSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.ProactiveSettingsScreen
|
||||
@@ -198,7 +200,9 @@ import com.hermesandroid.relay.viewmodel.ChatTransportPath
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.plugins.runtime.PLUGIN_API_WRITE_CAPABILITY
|
||||
import com.hermesandroid.relay.viewmodel.PluginsViewModel
|
||||
import com.hermesandroid.relay.viewmodel.PluginsHubState
|
||||
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
|
||||
import com.hermesandroid.relay.viewmodel.TerminalViewModel
|
||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||
@@ -446,6 +450,7 @@ sealed class Screen(
|
||||
}
|
||||
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
|
||||
data object Plugins : Screen("plugins", "Plugins", Icons.Filled.Extension)
|
||||
data object GitState : Screen("git_state", "Git", Icons.Filled.Code)
|
||||
data object PluginPage : Screen(
|
||||
"plugins/{pluginId}/pages/{pageId}",
|
||||
"Plugin",
|
||||
@@ -632,6 +637,7 @@ fun RelayApp() {
|
||||
val chatViewModel: ChatViewModel = processRuntime.chatViewModel
|
||||
val terminalViewModel: TerminalViewModel = viewModel()
|
||||
val pluginsViewModel: PluginsViewModel = viewModel()
|
||||
val gitStateViewModel: GitStateViewModel = viewModel()
|
||||
val voiceViewModel: VoiceViewModel = processRuntime.voiceViewModel
|
||||
val runtimeInitializationState by processRuntime.initializationState.collectAsState()
|
||||
|
||||
@@ -850,6 +856,11 @@ fun RelayApp() {
|
||||
val serverCapabilities by connectionViewModel.serverCapabilities.collectAsState()
|
||||
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
|
||||
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
val gitOwnerKey = activeConnectionId?.takeIf { it.isNotBlank() }?.let { connectionId ->
|
||||
effectiveDashboardUrl.takeIf { it.isNotBlank() }?.let { dashboardUrl ->
|
||||
"$connectionId\u0000${effectiveSessionProfileName.orEmpty()}\u0000$dashboardUrl"
|
||||
}
|
||||
}
|
||||
LaunchedEffect(
|
||||
activeConnectionId,
|
||||
effectiveDashboardUrl,
|
||||
@@ -864,6 +875,28 @@ fun RelayApp() {
|
||||
sessionId = currentChatSessionId,
|
||||
)
|
||||
}
|
||||
LaunchedEffect(gitOwnerKey) {
|
||||
val dashboard = effectiveDashboardUrl
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let { connectionViewModel.dashboardClientForActive(it) }
|
||||
gitStateViewModel.configure(dashboard, gitOwnerKey)
|
||||
}
|
||||
|
||||
// Mirror the plugin.api.write grant into the Git view model so write
|
||||
// mutations are refused client-side until the user grants write access
|
||||
// (matches the plug-in's grant gating in PluginsViewModel).
|
||||
val pluginsHubState by pluginsViewModel.hubState.collectAsState()
|
||||
LaunchedEffect(pluginsHubState, gitOwnerKey) {
|
||||
val ready = pluginsHubState as? PluginsHubState.Ready
|
||||
val granted = ready
|
||||
?.takeIf { it.ownerKey == gitOwnerKey }
|
||||
?.plugins
|
||||
?.firstOrNull { it.catalog.id == "hermes-relay" }
|
||||
?.preferences
|
||||
?.grants
|
||||
?.contains(PLUGIN_API_WRITE_CAPABILITY) == true
|
||||
gitStateViewModel.setWriteGrant(gitOwnerKey, granted)
|
||||
}
|
||||
|
||||
// What's New auto-show
|
||||
val showWhatsNew by connectionViewModel.showWhatsNew.collectAsState()
|
||||
@@ -2740,10 +2773,20 @@ fun RelayApp() {
|
||||
viewModel = pluginsViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenPage = { pluginId, pageId ->
|
||||
navController.navigate(Screen.PluginPage.route(pluginId, pageId))
|
||||
if (pluginId == "hermes-relay" && pageId == "git") {
|
||||
navController.navigate(Screen.GitState.route)
|
||||
} else {
|
||||
navController.navigate(Screen.PluginPage.route(pluginId, pageId))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.GitState.route) {
|
||||
GitStateScreen(
|
||||
viewModel = gitStateViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.PluginPage.route,
|
||||
arguments = listOf(
|
||||
|
||||
@@ -0,0 +1,817 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.GitBranch
|
||||
import com.hermesandroid.relay.data.GitDiff
|
||||
import com.hermesandroid.relay.data.GitFile
|
||||
import com.hermesandroid.relay.data.GitRepo
|
||||
import com.hermesandroid.relay.data.GitStatus
|
||||
import com.hermesandroid.relay.viewmodel.GitConfirmationStrings
|
||||
import com.hermesandroid.relay.viewmodel.GitContentViewState
|
||||
import com.hermesandroid.relay.viewmodel.GitMessageGenerationState
|
||||
import com.hermesandroid.relay.viewmodel.GitMutationState
|
||||
import com.hermesandroid.relay.viewmodel.GitRepoDetailState
|
||||
import com.hermesandroid.relay.viewmodel.GitStateUiState
|
||||
import com.hermesandroid.relay.viewmodel.GitStateViewModel
|
||||
import com.hermesandroid.relay.viewmodel.GitTarget
|
||||
|
||||
/**
|
||||
* Git State screen (read + write): repo picker → working-tree status/branches →
|
||||
* per-file diff or content. Writes require the ``plugin.api.write`` grant and
|
||||
* destructive ops (discard/push/dirty-checkout) require an explicit per-use
|
||||
* confirmation dialog; the fixed confirmation token is sent only on confirm.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GitStateScreen(
|
||||
viewModel: GitStateViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val reposState by viewModel.repos.collectAsState()
|
||||
val detailState by viewModel.detail.collectAsState()
|
||||
val contentState by viewModel.content.collectAsState()
|
||||
val mutationState by viewModel.mutation.collectAsState()
|
||||
val hasGrant by viewModel.writeGrant.collectAsState()
|
||||
|
||||
// Hoisted at screen level so confirmation/commit dialogs are modal.
|
||||
var pendingConfirm by remember { mutableStateOf<ConfirmationRequest?>(null) }
|
||||
var showCommitDialog by remember { mutableStateOf(false) }
|
||||
var pushAfterCommit by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Staged paths for the AI magic-wand (commit_message_selected) + commit.
|
||||
val stagedPaths = (detailState as? GitRepoDetailState.Ready)
|
||||
?.status?.staged?.map { it.path } ?: emptyList()
|
||||
|
||||
val messageGenerationState by viewModel.messageGeneration.collectAsState()
|
||||
val stashNotice by viewModel.stashNotice.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.git_state_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
stringResource(R.string.git_state_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
val repos = reposState
|
||||
when (repos) {
|
||||
GitStateUiState.Loading -> CenteredSpinner()
|
||||
is GitStateUiState.Error -> ErrorText(repos.message)
|
||||
is GitStateUiState.Ready -> {
|
||||
repos.notice?.let { ErrorText(it, warning = true) }
|
||||
RepoPicker(
|
||||
repos = repos.repos,
|
||||
selectedId = viewModel.selectedRepoIdForDisplay(),
|
||||
onSelect = viewModel::selectRepo,
|
||||
)
|
||||
when (val current = detailState) {
|
||||
GitRepoDetailState.Idle -> Unit
|
||||
GitRepoDetailState.Loading -> CenteredSpinner()
|
||||
is GitRepoDetailState.Error -> ErrorText(current.message)
|
||||
is GitRepoDetailState.Ready -> {
|
||||
MutationBanner(
|
||||
mutation = mutationState,
|
||||
onClear = viewModel::clearMutationError,
|
||||
)
|
||||
stashNotice?.let { notice ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
notice,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!hasGrant) {
|
||||
WriteGrantNotice()
|
||||
}
|
||||
RepoDetail(
|
||||
status = current.status,
|
||||
branches = current.branches,
|
||||
hasGrant = hasGrant,
|
||||
onShowDiff = viewModel::loadDiff,
|
||||
onShowFile = viewModel::loadFile,
|
||||
onStage = { path -> viewModel.stage(listOf(path)) },
|
||||
onUnstage = { path -> viewModel.unstage(listOf(path)) },
|
||||
onDiscard = { paths, deleteUntracked ->
|
||||
viewModel.currentTarget()?.let { target ->
|
||||
pendingConfirm = ConfirmationRequest.Discard(paths, deleteUntracked, target)
|
||||
}
|
||||
},
|
||||
onCommitRequest = { showCommitDialog = true },
|
||||
onFetch = { viewModel.fetch() },
|
||||
onPull = { viewModel.pull() },
|
||||
onPush = {
|
||||
viewModel.currentTarget()?.let { target ->
|
||||
pendingConfirm = ConfirmationRequest.Push(target)
|
||||
}
|
||||
},
|
||||
onSwitchBranch = { ref ->
|
||||
val dirty = current.status.counts.staged > 0 ||
|
||||
current.status.counts.modified > 0 ||
|
||||
current.status.counts.untracked > 0
|
||||
if (dirty) {
|
||||
viewModel.currentTarget()?.let { target ->
|
||||
pendingConfirm = ConfirmationRequest.DirtyCheckout(ref, target)
|
||||
}
|
||||
} else {
|
||||
viewModel.checkout(ref)
|
||||
}
|
||||
},
|
||||
onStashSwitchBranch = { ref ->
|
||||
viewModel.stashCheckout(ref)
|
||||
},
|
||||
onCreateBranch = { name, track ->
|
||||
viewModel.checkout("", newBranch = name, track = track)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
ContentView(state = contentState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCommitDialog) {
|
||||
CommitDialog(
|
||||
onDismiss = { showCommitDialog = false },
|
||||
hasStaged = stagedPaths.isNotEmpty(),
|
||||
generatingMessage = messageGenerationState is GitMessageGenerationState.Loading,
|
||||
onGenerate = {
|
||||
viewModel.generateCommitMessage(
|
||||
if (stagedPaths.isNotEmpty()) stagedPaths else null,
|
||||
)
|
||||
},
|
||||
generatedMessage = (messageGenerationState as? GitMessageGenerationState.Ready)?.message ?: "",
|
||||
generationNotice = (messageGenerationState as? GitMessageGenerationState.Ready)?.notice,
|
||||
pushAfterCommit = pushAfterCommit,
|
||||
onPushAfterCommitChange = { pushAfterCommit = it },
|
||||
onCommit = { message ->
|
||||
showCommitDialog = false
|
||||
viewModel.commit(message) { committedTarget ->
|
||||
if (pushAfterCommit) {
|
||||
pendingConfirm = ConfirmationRequest.Push(committedTarget)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pendingConfirm?.let { request ->
|
||||
val onDismiss = { pendingConfirm = null }
|
||||
when (request) {
|
||||
is ConfirmationRequest.Discard -> AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.git_state_confirm_discard_title)) },
|
||||
text = { Text(stringResource(R.string.git_state_confirm_discard_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
pendingConfirm = null
|
||||
viewModel.discard(
|
||||
request.paths,
|
||||
GitConfirmationStrings.DISCARD,
|
||||
request.deleteUntracked,
|
||||
request.target,
|
||||
)
|
||||
}) {
|
||||
Text(stringResource(R.string.git_state_confirm_discard_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.git_state_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
is ConfirmationRequest.Push -> AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.git_state_confirm_push_title)) },
|
||||
text = { Text(stringResource(R.string.git_state_confirm_push_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
pendingConfirm = null
|
||||
viewModel.push(
|
||||
GitConfirmationStrings.PUSH,
|
||||
expectedTarget = request.target,
|
||||
)
|
||||
}) {
|
||||
Text(stringResource(R.string.git_state_confirm_push_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.git_state_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
is ConfirmationRequest.DirtyCheckout -> AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.git_state_confirm_checkout_dirty_title)) },
|
||||
text = { Text(stringResource(R.string.git_state_confirm_checkout_dirty_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
pendingConfirm = null
|
||||
viewModel.checkout(
|
||||
request.ref,
|
||||
GitConfirmationStrings.DIRTY_CHECKOUT,
|
||||
expectedTarget = request.target,
|
||||
)
|
||||
}) {
|
||||
Text(stringResource(R.string.git_state_confirm_checkout_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.git_state_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A destructive action awaiting explicit user confirmation. */
|
||||
private sealed interface ConfirmationRequest {
|
||||
data class Discard(
|
||||
val paths: List<String>,
|
||||
val deleteUntracked: Boolean,
|
||||
val target: GitTarget,
|
||||
) : ConfirmationRequest
|
||||
|
||||
data class Push(val target: GitTarget) : ConfirmationRequest
|
||||
data class DirtyCheckout(val ref: String, val target: GitTarget) : ConfirmationRequest
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommitDialog(
|
||||
onDismiss: () -> Unit,
|
||||
hasStaged: Boolean,
|
||||
generatingMessage: Boolean,
|
||||
onGenerate: () -> Unit,
|
||||
generatedMessage: String,
|
||||
generationNotice: String?,
|
||||
pushAfterCommit: Boolean,
|
||||
onPushAfterCommitChange: (Boolean) -> Unit,
|
||||
onCommit: (String) -> Unit,
|
||||
) {
|
||||
var message by rememberSaveable { mutableStateOf("") }
|
||||
// Pre-fill with the latest generated suggestion when it arrives.
|
||||
if (generatedMessage.isNotEmpty() && message.isBlank()) {
|
||||
message = generatedMessage
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.git_state_commit_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = message,
|
||||
onValueChange = { message = it },
|
||||
label = { Text(stringResource(R.string.git_state_commit_message_hint)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = false,
|
||||
trailingIcon = {
|
||||
IconButton(onClick = onGenerate, enabled = hasStaged && !generatingMessage) {
|
||||
Icon(
|
||||
Icons.Filled.AutoAwesome,
|
||||
stringResource(R.string.git_state_generate_message),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (generatingMessage) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_generating_message),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
generationNotice?.let { notice ->
|
||||
Text(
|
||||
notice,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = pushAfterCommit, onCheckedChange = onPushAfterCommitChange)
|
||||
Text(stringResource(R.string.git_state_push_after_commit))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onCommit(message) },
|
||||
enabled = message.isNotBlank(),
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_commit_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.git_state_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RepoPicker(
|
||||
repos: List<GitRepo>,
|
||||
selectedId: String?,
|
||||
onSelect: (String) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
repos.forEach { repo ->
|
||||
AssistChip(
|
||||
onClick = { onSelect(repo.id) },
|
||||
label = {
|
||||
Text(
|
||||
if (repo.dirty) "${repo.name} •" else repo.name,
|
||||
fontWeight = if (repo.id == selectedId) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutationBanner(
|
||||
mutation: GitMutationState,
|
||||
onClear: () -> Unit,
|
||||
) {
|
||||
when (mutation) {
|
||||
GitMutationState.Idle -> Unit
|
||||
is GitMutationState.InProgress -> Card(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp)
|
||||
Text(
|
||||
stringResource(R.string.git_state_mutation_in_progress, displayLabel(mutation.label)),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
is GitMutationState.Success -> Card(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.git_state_mutation_success,
|
||||
displayLabel(mutation.label),
|
||||
mutation.head,
|
||||
),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
is GitMutationState.Error -> Card(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.git_state_mutation_failed,
|
||||
displayLabel(mutation.label),
|
||||
mutation.message,
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
TextButton(onClick = onClear) {
|
||||
Text(stringResource(R.string.git_state_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayLabel(label: String): String =
|
||||
label.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
|
||||
@Composable
|
||||
private fun WriteGrantNotice() {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_write_grant_required),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RepoDetail(
|
||||
status: GitStatus,
|
||||
branches: List<GitBranch>,
|
||||
hasGrant: Boolean,
|
||||
onShowDiff: (String, String) -> Unit,
|
||||
onShowFile: (String) -> Unit,
|
||||
onStage: (String) -> Unit,
|
||||
onUnstage: (String) -> Unit,
|
||||
onDiscard: (List<String>, Boolean) -> Unit,
|
||||
onCommitRequest: () -> Unit,
|
||||
onFetch: () -> Unit,
|
||||
onPull: () -> Unit,
|
||||
onPush: () -> Unit,
|
||||
onSwitchBranch: (String) -> Unit,
|
||||
onStashSwitchBranch: (String) -> Unit,
|
||||
onCreateBranch: (String, Boolean) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"${stringResource(R.string.git_state_staged)} ${status.counts.staged} · " +
|
||||
"${stringResource(R.string.git_state_modified)} ${status.counts.modified} · " +
|
||||
"${stringResource(R.string.git_state_untracked)} ${status.counts.untracked}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
if (status.truncated) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_truncated),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
status.staged.takeIf { it.isNotEmpty() }?.let { staged ->
|
||||
GroupHeader(stringResource(R.string.git_state_staged))
|
||||
staged.forEach { file ->
|
||||
StatusRow(
|
||||
path = file.path,
|
||||
primaryLabel = stringResource(R.string.git_state_unstage),
|
||||
onPrimary = { onUnstage(file.path) },
|
||||
secondaryLabel = stringResource(R.string.git_state_discard),
|
||||
onSecondary = { onDiscard(listOf(file.path), false) },
|
||||
onOpen = { onShowDiff(file.path, "staged") },
|
||||
enabled = hasGrant,
|
||||
)
|
||||
}
|
||||
}
|
||||
status.modified.takeIf { it.isNotEmpty() }?.let { modified ->
|
||||
GroupHeader(stringResource(R.string.git_state_modified))
|
||||
modified.forEach { file ->
|
||||
StatusRow(
|
||||
path = file.path,
|
||||
primaryLabel = stringResource(R.string.git_state_stage),
|
||||
onPrimary = { onStage(file.path) },
|
||||
secondaryLabel = stringResource(R.string.git_state_discard),
|
||||
onSecondary = { onDiscard(listOf(file.path), false) },
|
||||
onOpen = { onShowDiff(file.path, "unstaged") },
|
||||
enabled = hasGrant,
|
||||
)
|
||||
}
|
||||
}
|
||||
status.untracked.takeIf { it.isNotEmpty() }?.let { untracked ->
|
||||
GroupHeader(stringResource(R.string.git_state_untracked))
|
||||
untracked.forEach { file ->
|
||||
StatusRow(
|
||||
path = file.path,
|
||||
primaryLabel = stringResource(R.string.git_state_stage),
|
||||
onPrimary = { onStage(file.path) },
|
||||
secondaryLabel = stringResource(R.string.git_state_discard),
|
||||
onSecondary = { onDiscard(listOf(file.path), true) },
|
||||
onOpen = { onShowFile(file.path) },
|
||||
enabled = hasGrant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Commit + sync controls (writes; all gated by the grant).
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onFetch,
|
||||
enabled = hasGrant && status.counts.untracked == 0,
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_fetch))
|
||||
}
|
||||
OutlinedButton(onClick = onPull, enabled = hasGrant) {
|
||||
Text(stringResource(R.string.git_state_pull))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onPush,
|
||||
enabled = hasGrant && status.counts.staged == 0,
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_push))
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = onCommitRequest,
|
||||
enabled = hasGrant && status.counts.staged > 0,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_commit))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (branches.isNotEmpty()) {
|
||||
BranchCard(
|
||||
branches = branches,
|
||||
hasGrant = hasGrant,
|
||||
onSwitch = onSwitchBranch,
|
||||
onStashSwitch = onStashSwitchBranch,
|
||||
onCreate = onCreateBranch,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusRow(
|
||||
path: String,
|
||||
primaryLabel: String,
|
||||
onPrimary: () -> Unit,
|
||||
secondaryLabel: String,
|
||||
onSecondary: () -> Unit,
|
||||
onOpen: () -> Unit,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = onOpen, modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
path,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onPrimary, enabled = enabled) {
|
||||
Text(primaryLabel)
|
||||
}
|
||||
TextButton(onClick = onSecondary, enabled = enabled) {
|
||||
Text(secondaryLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BranchCard(
|
||||
branches: List<GitBranch>,
|
||||
hasGrant: Boolean,
|
||||
onSwitch: (String) -> Unit,
|
||||
onStashSwitch: (String) -> Unit,
|
||||
onCreate: (String, Boolean) -> Unit,
|
||||
) {
|
||||
var newBranchName by rememberSaveable { mutableStateOf("") }
|
||||
var track by rememberSaveable { mutableStateOf(false) }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_branches),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
branches.forEach { branch ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
branchLabel(branch),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
if (branch.isCurrent) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_current),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = { onSwitch(branch.name) },
|
||||
enabled = hasGrant,
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_switch))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onStashSwitch(branch.name) },
|
||||
enabled = hasGrant,
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_switch_stash))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new branch (optionally tracking the remote).
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = newBranchName,
|
||||
onValueChange = { newBranchName = it },
|
||||
label = { Text(stringResource(R.string.git_state_new_branch_hint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
val name = newBranchName.trim()
|
||||
if (name.isNotEmpty()) {
|
||||
onCreate(name, track)
|
||||
newBranchName = ""
|
||||
track = false
|
||||
}
|
||||
},
|
||||
enabled = hasGrant && newBranchName.isNotBlank(),
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_create_branch))
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = track, onCheckedChange = { track = it }, enabled = hasGrant)
|
||||
Text(stringResource(R.string.git_state_track_remote))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun branchLabel(branch: GitBranch): String {
|
||||
val base = branch.name
|
||||
if (branch.upstream == null) return base
|
||||
val track =
|
||||
if (branch.ahead > 0 || branch.behind > 0) {
|
||||
" (ahead ${branch.ahead}, behind ${branch.behind})"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
return "$base → ${branch.upstream}$track"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupHeader(label: String) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContentView(state: GitContentViewState) {
|
||||
when (state) {
|
||||
GitContentViewState.Idle -> Unit
|
||||
GitContentViewState.Loading -> CenteredSpinner()
|
||||
is GitContentViewState.Error -> ErrorText(state.message)
|
||||
is GitContentViewState.Diff -> MonospaceBlock(state.diff)
|
||||
is GitContentViewState.File -> MonospaceBlock(state.file)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonospaceBlock(diff: GitDiff) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"${diff.path} (${diff.kind})",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
if (diff.truncated) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_truncated),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
diff.diff.ifEmpty { stringResource(R.string.git_state_no_changes) },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonospaceBlock(file: GitFile) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
file.path,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
if (file.truncated) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_truncated),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
file.content,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredSpinner() {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorText(message: String, warning: Boolean = false) {
|
||||
Text(
|
||||
message,
|
||||
color = if (warning) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.hermesandroid.relay.data.GitBranch
|
||||
import com.hermesandroid.relay.data.GitDiff
|
||||
import com.hermesandroid.relay.data.GitFile
|
||||
import com.hermesandroid.relay.data.GitRepo
|
||||
import com.hermesandroid.relay.data.GitStateApiClient
|
||||
import com.hermesandroid.relay.data.GitStatus
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface GitStateUiState {
|
||||
data object Loading : GitStateUiState
|
||||
data class Error(val message: String) : GitStateUiState
|
||||
data class Ready(val repos: List<GitRepo>, val notice: String?) : GitStateUiState
|
||||
}
|
||||
|
||||
sealed interface GitRepoDetailState {
|
||||
data object Idle : GitRepoDetailState
|
||||
data object Loading : GitRepoDetailState
|
||||
data class Error(val message: String) : GitRepoDetailState
|
||||
data class Ready(
|
||||
val status: GitStatus,
|
||||
val branches: List<GitBranch>,
|
||||
) : GitRepoDetailState
|
||||
}
|
||||
|
||||
sealed interface GitContentViewState {
|
||||
data object Idle : GitContentViewState
|
||||
data object Loading : GitContentViewState
|
||||
data class Error(val message: String) : GitContentViewState
|
||||
data class Diff(val diff: GitDiff) : GitContentViewState
|
||||
data class File(val file: GitFile) : GitContentViewState
|
||||
}
|
||||
|
||||
/** A single in-flight or completed write mutation on the selected repo. */
|
||||
sealed interface GitMutationState {
|
||||
data object Idle : GitMutationState
|
||||
data class InProgress(val label: String) : GitMutationState
|
||||
data class Error(val label: String, val message: String) : GitMutationState
|
||||
data class Success(val label: String, val head: String) : GitMutationState
|
||||
}
|
||||
|
||||
/** A commit-message generation attempt (AI magic-wand). */
|
||||
sealed interface GitMessageGenerationState {
|
||||
data object Idle : GitMessageGenerationState
|
||||
data object Loading : GitMessageGenerationState
|
||||
data class Ready(val message: String, val notice: String) : GitMessageGenerationState
|
||||
}
|
||||
|
||||
/** Fixed per-use confirmation tokens matching the plugin's server constants. */
|
||||
object GitConfirmationStrings {
|
||||
const val DISCARD = "discard"
|
||||
const val PUSH = "push"
|
||||
const val DIRTY_CHECKOUT = "checkout-dirty"
|
||||
}
|
||||
|
||||
data class GitTarget(
|
||||
val scopeKey: String,
|
||||
val repoId: String,
|
||||
val generation: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* View model for the Git State Android surface (read + write).
|
||||
*
|
||||
* Loads the scanned repo list from the Hermes-Relay plugin and, on selection,
|
||||
* fetches working-tree status + branches. Mutations (stage/unstage/discard/
|
||||
* commit/fetch/pull/push/checkout) all require the ``plugin.api.write`` grant:
|
||||
* ``configure`` binds one connection/profile/Dashboard owner and every mutation
|
||||
* refuses (surfacing a readable message, never a POST) when that owner's grant
|
||||
* is absent. Destructive ops
|
||||
* (discard/push/dirty-checkout) additionally require a per-use confirmation
|
||||
* string the caller echoes from GitConfirmationStrings.
|
||||
*/
|
||||
class GitStateViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val _repos = MutableStateFlow<GitStateUiState>(GitStateUiState.Loading)
|
||||
val repos: StateFlow<GitStateUiState> = _repos.asStateFlow()
|
||||
|
||||
private val _detail = MutableStateFlow<GitRepoDetailState>(GitRepoDetailState.Idle)
|
||||
val detail: StateFlow<GitRepoDetailState> = _detail.asStateFlow()
|
||||
|
||||
private val _content = MutableStateFlow<GitContentViewState>(GitContentViewState.Idle)
|
||||
val content: StateFlow<GitContentViewState> = _content.asStateFlow()
|
||||
|
||||
private val _mutation = MutableStateFlow<GitMutationState>(GitMutationState.Idle)
|
||||
val mutation: StateFlow<GitMutationState> = _mutation.asStateFlow()
|
||||
|
||||
private val _messageGeneration =
|
||||
MutableStateFlow<GitMessageGenerationState>(GitMessageGenerationState.Idle)
|
||||
val messageGeneration: StateFlow<GitMessageGenerationState> = _messageGeneration.asStateFlow()
|
||||
|
||||
private val _pushAfterCommit = MutableStateFlow(false)
|
||||
val pushAfterCommit: StateFlow<Boolean> = _pushAfterCommit.asStateFlow()
|
||||
|
||||
private val _stashNotice = MutableStateFlow<String?>(null)
|
||||
val stashNotice: StateFlow<String?> = _stashNotice.asStateFlow()
|
||||
|
||||
private val _writeGrant = MutableStateFlow(false)
|
||||
val writeGrant: StateFlow<Boolean> = _writeGrant.asStateFlow()
|
||||
|
||||
private var api: GitStateApiClient? = null
|
||||
private var reposJob: Job? = null
|
||||
private var detailJob: Job? = null
|
||||
private var contentJob: Job? = null
|
||||
private var mutationJob: Job? = null
|
||||
private var messageJob: Job? = null
|
||||
private var scopeKey: String? = null
|
||||
private var targetGeneration: Long = 0
|
||||
private var selectedRepoId: String? = null
|
||||
|
||||
fun selectedRepoIdForDisplay(): String? = selectedRepoId
|
||||
|
||||
fun currentTarget(): GitTarget? {
|
||||
val owner = scopeKey ?: return null
|
||||
val repo = selectedRepoId ?: return null
|
||||
return GitTarget(owner, repo, targetGeneration)
|
||||
}
|
||||
|
||||
fun configure(dashboard: DashboardApiClient?, ownerKey: String?) {
|
||||
reposJob?.cancel()
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
mutationJob?.cancel()
|
||||
messageJob?.cancel()
|
||||
targetGeneration += 1
|
||||
scopeKey = ownerKey
|
||||
selectedRepoId = null
|
||||
_writeGrant.value = false
|
||||
_detail.value = GitRepoDetailState.Idle
|
||||
_content.value = GitContentViewState.Idle
|
||||
_mutation.value = GitMutationState.Idle
|
||||
_messageGeneration.value = GitMessageGenerationState.Idle
|
||||
_stashNotice.value = null
|
||||
api = dashboard?.let(::GitStateApiClient)
|
||||
loadRepos()
|
||||
}
|
||||
|
||||
/** Grants the plugin.api.write capability for this connection/profile. */
|
||||
fun setWriteGrant(ownerKey: String?, granted: Boolean) {
|
||||
if (ownerKey != scopeKey) return
|
||||
_writeGrant.value = granted
|
||||
}
|
||||
|
||||
fun hasWriteGrant(): Boolean = _writeGrant.value
|
||||
|
||||
fun loadRepos() {
|
||||
val client = api ?: run {
|
||||
_repos.value = GitStateUiState.Error("Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val expectedScope = scopeKey
|
||||
reposJob?.cancel()
|
||||
reposJob = viewModelScope.launch {
|
||||
_repos.value = GitStateUiState.Loading
|
||||
client.repos().fold(
|
||||
onSuccess = { list ->
|
||||
if (scopeKey == expectedScope) {
|
||||
_repos.value = GitStateUiState.Ready(list, null)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (scopeKey == expectedScope) {
|
||||
_repos.value = GitStateUiState.Error(error.message ?: "Failed to load repositories")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectRepo(repoId: String) {
|
||||
val client = api ?: return
|
||||
targetGeneration += 1
|
||||
selectedRepoId = repoId
|
||||
val target = currentTarget() ?: return
|
||||
_content.value = GitContentViewState.Idle
|
||||
_mutation.value = GitMutationState.Idle
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
detailJob = viewModelScope.launch {
|
||||
_detail.value = GitRepoDetailState.Loading
|
||||
val statusResult = client.status(repoId)
|
||||
val branchesResult = client.branches(repoId)
|
||||
if (currentTarget() != target) return@launch
|
||||
if (statusResult.isFailure) {
|
||||
_detail.value = GitRepoDetailState.Error(
|
||||
statusResult.exceptionOrNull()?.message ?: "Failed to load status",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
val status: GitStatus = statusResult.getOrThrow()
|
||||
val branches: List<GitBranch> = branchesResult.getOrDefault(emptyList())
|
||||
_detail.value = GitRepoDetailState.Ready(status, branches)
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs one owner/repository-bound mutation without cancelling another mutation. */
|
||||
private fun runMutation(
|
||||
label: String,
|
||||
expectedTarget: GitTarget? = null,
|
||||
onSuccess: (GitTarget) -> Unit = {},
|
||||
block: suspend (GitStateApiClient, String) -> Result<GitMutationState>,
|
||||
) {
|
||||
val client = api ?: run {
|
||||
_mutation.value = GitMutationState.Error(label, "Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val target = currentTarget() ?: run {
|
||||
_mutation.value = GitMutationState.Error(label, "No repository selected")
|
||||
return
|
||||
}
|
||||
if (expectedTarget != null && expectedTarget != target) {
|
||||
_mutation.value = GitMutationState.Error(label, "Repository context changed; review the action again.")
|
||||
return
|
||||
}
|
||||
if (!_writeGrant.value) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
label,
|
||||
"Allow plugin changes (plugin.api.write) before using this action.",
|
||||
)
|
||||
return
|
||||
}
|
||||
if (mutationJob?.isActive == true) {
|
||||
_mutation.value = GitMutationState.Error(label, "Another Git action is still in progress.")
|
||||
return
|
||||
}
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
mutationJob = viewModelScope.launch {
|
||||
_mutation.value = GitMutationState.InProgress(label)
|
||||
block(client, target.repoId).fold(
|
||||
onSuccess = {
|
||||
if (currentTarget() != target) return@fold
|
||||
_mutation.value = it
|
||||
_content.value = GitContentViewState.Idle
|
||||
refreshDetail(client, target)
|
||||
onSuccess(target)
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
label,
|
||||
error.message ?: "Git action failed",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshDetail(client: GitStateApiClient, target: GitTarget) {
|
||||
val statusResult = client.status(target.repoId)
|
||||
val branchesResult = client.branches(target.repoId)
|
||||
if (currentTarget() == target && statusResult.isSuccess) {
|
||||
_detail.value = GitRepoDetailState.Ready(
|
||||
statusResult.getOrDefault(GitStatus()),
|
||||
branchesResult.getOrDefault(emptyList()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read operations ────────────────────────────────────────────────────
|
||||
|
||||
fun loadDiff(path: String, kind: String) {
|
||||
val target = currentTarget() ?: return
|
||||
val client = api ?: return
|
||||
contentJob?.cancel()
|
||||
contentJob = viewModelScope.launch {
|
||||
_content.value = GitContentViewState.Loading
|
||||
client.diff(target.repoId, path, kind).fold(
|
||||
onSuccess = { diff ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.Diff(diff)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.Error(
|
||||
error.message ?: "Failed to load diff",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFile(path: String) {
|
||||
val target = currentTarget() ?: return
|
||||
val client = api ?: return
|
||||
contentJob?.cancel()
|
||||
contentJob = viewModelScope.launch {
|
||||
_content.value = GitContentViewState.Loading
|
||||
client.file(target.repoId, path).fold(
|
||||
onSuccess = { file ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.File(file)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.Error(
|
||||
error.message ?: "Failed to load file",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Write operations ───────────────────────────────────────────────────
|
||||
|
||||
fun stage(paths: List<String>) = runMutation("Stage") { c, r ->
|
||||
c.stage(r, paths).map { GitMutationState.Success("stage", it.head) }
|
||||
}
|
||||
|
||||
fun unstage(paths: List<String>) = runMutation("Unstage") { c, r ->
|
||||
c.unstage(r, paths).map { GitMutationState.Success("unstage", it.head) }
|
||||
}
|
||||
|
||||
fun discard(
|
||||
paths: List<String>,
|
||||
confirmation: String,
|
||||
deleteUntracked: Boolean = false,
|
||||
expectedTarget: GitTarget? = null,
|
||||
) =
|
||||
runMutation("Discard", expectedTarget = expectedTarget) { c, r ->
|
||||
c.discard(r, paths, confirmation, deleteUntracked)
|
||||
.map { GitMutationState.Success("discard", it.head) }
|
||||
}
|
||||
|
||||
fun commit(message: String, onSuccess: (GitTarget) -> Unit = {}) =
|
||||
runMutation("Commit", onSuccess = onSuccess) { c, r ->
|
||||
c.commit(r, message).map {
|
||||
GitMutationState.Success("commit", it.head)
|
||||
}
|
||||
}
|
||||
|
||||
fun commitSelected(message: String, paths: List<String>) = runMutation("Commit") { c, r ->
|
||||
c.commitSelected(r, message, paths).map {
|
||||
GitMutationState.Success("commit", it.head)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetch(remote: String = "origin") = runMutation("Fetch") { c, r ->
|
||||
c.fetch(r, remote).map { GitMutationState.Success("fetch", it.head) }
|
||||
}
|
||||
|
||||
fun pull(remote: String = "origin", branch: String = "") = runMutation("Pull") { c, r ->
|
||||
c.pull(r, remote, branch).map { GitMutationState.Success("pull", it.head) }
|
||||
}
|
||||
|
||||
fun push(
|
||||
confirmation: String,
|
||||
remote: String = "origin",
|
||||
branch: String = "",
|
||||
expectedTarget: GitTarget? = null,
|
||||
) =
|
||||
runMutation("Push", expectedTarget = expectedTarget) { c, r ->
|
||||
c.push(r, confirmation, remote, branch).map { GitMutationState.Success("push", it.head) }
|
||||
}
|
||||
|
||||
fun checkout(
|
||||
ref: String,
|
||||
confirmation: String? = null,
|
||||
newBranch: String = "",
|
||||
track: Boolean = false,
|
||||
expectedTarget: GitTarget? = null,
|
||||
) = runMutation("Checkout", expectedTarget = expectedTarget) { c, r ->
|
||||
c.checkout(r, ref, confirmation, newBranch, track)
|
||||
.map { GitMutationState.Success("checkout", it.head) }
|
||||
}
|
||||
|
||||
// ── Phase 3 extras ─────────────────────────────────────────────────────
|
||||
|
||||
/** Toggle the push-after-commit flow (default OFF; never bypasses confirm). */
|
||||
fun setPushAfterCommit(enabled: Boolean) {
|
||||
_pushAfterCommit.value = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a commit-message suggestion from the staged diff (AI magic-wand).
|
||||
* Empty staged diff / model-unavailable degrade to a notice, never an error.
|
||||
* Uses the shared write grant gate (a POST is never sent without the grant).
|
||||
*/
|
||||
fun generateCommitMessage(paths: List<String>? = null) {
|
||||
val client = api ?: run {
|
||||
_messageGeneration.value =
|
||||
GitMessageGenerationState.Ready("", "Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val target = currentTarget() ?: run {
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready("", "No repository selected")
|
||||
return
|
||||
}
|
||||
if (!_writeGrant.value) {
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready(
|
||||
"",
|
||||
"Allow plugin changes (plugin.api.write) before using this action.",
|
||||
)
|
||||
return
|
||||
}
|
||||
messageJob?.cancel()
|
||||
messageJob = viewModelScope.launch {
|
||||
_messageGeneration.value = GitMessageGenerationState.Loading
|
||||
val result = if (paths != null) {
|
||||
client.commitMessageSelected(target.repoId, paths)
|
||||
} else {
|
||||
client.commitMessage(target.repoId)
|
||||
}
|
||||
if (currentTarget() != target) return@launch
|
||||
result.fold(
|
||||
onSuccess = { msg ->
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready(msg.message, msg.notice)
|
||||
},
|
||||
onFailure = { error ->
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready(
|
||||
"",
|
||||
error.message ?: "Could not generate a commit message.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout that auto-stashes a dirty tree first. No confirmation is needed
|
||||
* because a stash is recoverable. Surfaces the stash message via [stashNotice].
|
||||
*/
|
||||
fun stashCheckout(ref: String, newBranch: String = "", track: Boolean = false) {
|
||||
val client = api ?: run {
|
||||
_mutation.value = GitMutationState.Error("Stash Checkout", "Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val target = currentTarget() ?: run {
|
||||
_mutation.value = GitMutationState.Error("Stash Checkout", "No repository selected")
|
||||
return
|
||||
}
|
||||
if (!_writeGrant.value) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
"Stash Checkout",
|
||||
"Allow plugin changes (plugin.api.write) before using this action.",
|
||||
)
|
||||
return
|
||||
}
|
||||
_stashNotice.value = null
|
||||
if (mutationJob?.isActive == true) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
"Stash Checkout",
|
||||
"Another Git action is still in progress.",
|
||||
)
|
||||
return
|
||||
}
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
mutationJob = viewModelScope.launch {
|
||||
_mutation.value = GitMutationState.InProgress("Stash Checkout")
|
||||
client.stashCheckout(target.repoId, ref, newBranch, track).fold(
|
||||
onSuccess = { result ->
|
||||
if (currentTarget() != target) return@fold
|
||||
if (result.stashed) {
|
||||
_stashNotice.value =
|
||||
"Stashed changes on $ref as \"${result.stashMessage}\". Use \"git stash pop\" to restore them."
|
||||
}
|
||||
_mutation.value = GitMutationState.Success("stash-checkout", result.head)
|
||||
_content.value = GitContentViewState.Idle
|
||||
refreshDetail(client, target)
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
"Stash Checkout",
|
||||
error.message ?: "Git action failed",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearStashNotice() {
|
||||
_stashNotice.value = null
|
||||
}
|
||||
|
||||
/** True when the push-after-commit toggle is currently enabled. */
|
||||
fun isPushAfterCommitEnabled(): Boolean = _pushAfterCommit.value
|
||||
|
||||
/** True when the named destructive op needs a confirmation echo. */
|
||||
fun requiresConfirmation(op: String): Boolean = op in setOf("discard", "push", "dirty-checkout")
|
||||
|
||||
/** Fixed confirmation token for a destructive op (matches the server). */
|
||||
fun confirmationFor(op: String): String? = when (op) {
|
||||
"discard" -> GitConfirmationStrings.DISCARD
|
||||
"push" -> GitConfirmationStrings.PUSH
|
||||
"dirty-checkout" -> GitConfirmationStrings.DIRTY_CHECKOUT
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun clearMutationError() {
|
||||
if (_mutation.value is GitMutationState.Error) {
|
||||
_mutation.value = GitMutationState.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ sealed interface PluginsHubState {
|
||||
data object Disconnected : PluginsHubState
|
||||
data object Loading : PluginsHubState
|
||||
data class Ready(
|
||||
val ownerKey: String,
|
||||
val plugins: List<PluginHubItem>,
|
||||
val preview: PluginCatalogPreview,
|
||||
val refreshing: Boolean = false,
|
||||
@@ -220,6 +221,7 @@ class PluginsViewModel(application: Application) : AndroidViewModel(application)
|
||||
_hubState.value = result.fold(
|
||||
onSuccess = { items ->
|
||||
PluginsHubState.Ready(
|
||||
ownerKey = expectedKey,
|
||||
plugins = items,
|
||||
preview = catalogPreview(items),
|
||||
)
|
||||
|
||||
@@ -3858,6 +3858,48 @@
|
||||
<string name="plugins_keep">Manter</string>
|
||||
<string name="plugins_remove">Remover</string>
|
||||
<string name="plugins_remove_confirm">Remover “%1$s”? Esta página do plugin não aparecerá mais nos dispositivos Android conectados.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Voltar</string>
|
||||
<string name="git_state_staged">Preparados</string>
|
||||
<string name="git_state_modified">Modificados</string>
|
||||
<string name="git_state_untracked">Não rastreados</string>
|
||||
<string name="git_state_branches">Ramos</string>
|
||||
<string name="git_state_truncated">Resultados truncados — apenas as primeiras entradas são exibidas.</string>
|
||||
<string name="git_state_no_changes">(sem alterações)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">Alterações de gravação exigem a permissão de alterações de plugin. Ative "Permitir alterações de plugins (plugin.api.write)" nas configurações de permissão de Plugins.</string>
|
||||
<string name="git_state_stage">Preparar</string>
|
||||
<string name="git_state_unstage">Despreparar</string>
|
||||
<string name="git_state_discard">Descartar</string>
|
||||
<string name="git_state_commit">Confirmar</string>
|
||||
<string name="git_state_commit_title">Confirmar alterações preparadas</string>
|
||||
<string name="git_state_commit_message_hint">Mensagem do commit</string>
|
||||
<string name="git_state_commit_confirm">Confirmar</string>
|
||||
<string name="git_state_cancel">Cancelar</string>
|
||||
<string name="git_state_fetch">Buscar</string>
|
||||
<string name="git_state_pull">Puxar</string>
|
||||
<string name="git_state_push">Enviar</string>
|
||||
<string name="git_state_new_branch_hint">Nome da nova ramificação</string>
|
||||
<string name="git_state_create_branch">Criar ramificação</string>
|
||||
<string name="git_state_track_remote">Rastrear ramificação remota</string>
|
||||
<string name="git_state_switch">Alternar</string>
|
||||
<string name="git_state_current">Atual</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s em andamento…</string>
|
||||
<string name="git_state_mutation_success">%1$s concluído. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s falhou: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">Descartar alterações?</string>
|
||||
<string name="git_state_confirm_discard_text">As alterações locais dos arquivos selecionados serão descartadas e não poderão ser recuperadas.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Descartar</string>
|
||||
<string name="git_state_confirm_push_title">Enviar alterações?</string>
|
||||
<string name="git_state_confirm_push_text">Envia a ramificação atual para o repositório remoto. Isso publica os commits locais no repositório remoto.</string>
|
||||
<string name="git_state_confirm_push_confirm">Enviar</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">Alternar ramificação com alterações locais?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">A árvore de trabalho tem alterações não confirmadas. A alternância pode carregá-las para a ramificação de destino.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Alternar</string>
|
||||
<string name="git_state_generate_message">Gerar mensagem de commit</string>
|
||||
<string name="git_state_generating_message">Gerando mensagem de commit…</string>
|
||||
<string name="git_state_push_after_commit">Enviar após o commit</string>
|
||||
<string name="git_state_switch_stash">Alternar (guardar se houver alterações)</string>
|
||||
<string name="support_bundle_review">Revisar informações de suporte</string>
|
||||
<string name="support_bundle_title">Informações de suporte</string>
|
||||
<string name="support_bundle_privacy">Nada é enviado automaticamente. Revise a exportação local com até %1$d relatórios.</string>
|
||||
|
||||
@@ -3946,6 +3946,48 @@
|
||||
<string name="plugins_keep">保留</string>
|
||||
<string name="plugins_remove">移除</string>
|
||||
<string name="plugins_remove_confirm">要移除“%1$s”吗?此插件页面将不再显示在已连接的 Android 设备上。</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">返回</string>
|
||||
<string name="git_state_staged">已暂存</string>
|
||||
<string name="git_state_modified">已修改</string>
|
||||
<string name="git_state_untracked">未跟踪</string>
|
||||
<string name="git_state_branches">分支</string>
|
||||
<string name="git_state_truncated">结果已截断 — 仅显示前几条。</string>
|
||||
<string name="git_state_no_changes">(无更改)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">写入更改需要插件更改权限。请在“插件”的权限设置中启用“允许插件更改 (plugin.api.write)”。</string>
|
||||
<string name="git_state_stage">暂存</string>
|
||||
<string name="git_state_unstage">取消暂存</string>
|
||||
<string name="git_state_discard">丢弃</string>
|
||||
<string name="git_state_commit">提交</string>
|
||||
<string name="git_state_commit_title">提交已暂存的更改</string>
|
||||
<string name="git_state_commit_message_hint">提交消息</string>
|
||||
<string name="git_state_commit_confirm">提交</string>
|
||||
<string name="git_state_cancel">取消</string>
|
||||
<string name="git_state_fetch">获取</string>
|
||||
<string name="git_state_pull">拉取</string>
|
||||
<string name="git_state_push">推送</string>
|
||||
<string name="git_state_new_branch_hint">新分支名称</string>
|
||||
<string name="git_state_create_branch">创建分支</string>
|
||||
<string name="git_state_track_remote">跟踪远程分支</string>
|
||||
<string name="git_state_switch">切换</string>
|
||||
<string name="git_state_current">当前</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s 正在进行…</string>
|
||||
<string name="git_state_mutation_success">%1$s 已完成。HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s 失败:%2$s</string>
|
||||
<string name="git_state_confirm_discard_title">丢弃更改?</string>
|
||||
<string name="git_state_confirm_discard_text">所选文件的本地更改将被丢弃,且无法恢复。</string>
|
||||
<string name="git_state_confirm_discard_confirm">丢弃</string>
|
||||
<string name="git_state_confirm_push_title">推送更改?</string>
|
||||
<string name="git_state_confirm_push_text">将当前分支推送到其远程仓库。这会把本地提交发送到远程仓库。</string>
|
||||
<string name="git_state_confirm_push_confirm">推送</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">切换包含本地更改的分支?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">工作区有未提交的更改。切换后,这些更改可能会被带到目标分支。</string>
|
||||
<string name="git_state_confirm_checkout_confirm">切换</string>
|
||||
<string name="git_state_generate_message">生成提交信息</string>
|
||||
<string name="git_state_generating_message">正在生成提交信息…</string>
|
||||
<string name="git_state_push_after_commit">提交后推送</string>
|
||||
<string name="git_state_switch_stash">切换(有更改时暂存)</string>
|
||||
<string name="support_bundle_review">查看支持信息</string>
|
||||
<string name="support_bundle_title">支持信息</string>
|
||||
<string name="support_bundle_privacy">不会自动上传任何内容。请查看最多包含 %1$d 个报告的本地导出。</string>
|
||||
|
||||
@@ -4018,6 +4018,48 @@
|
||||
<string name="plugins_keep">Behalten</string>
|
||||
<string name="plugins_remove">Entfernen</string>
|
||||
<string name="plugins_remove_confirm">„%1$s“ entfernen? Diese Plugin-Seite wird auf verbundenen Android-Geräten nicht mehr angezeigt.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Zurück</string>
|
||||
<string name="git_state_staged">Gestaged</string>
|
||||
<string name="git_state_modified">Geändert</string>
|
||||
<string name="git_state_untracked">Unverfolgt</string>
|
||||
<string name="git_state_branches">Branches</string>
|
||||
<string name="git_state_truncated">Ergebnisse abgeschnitten – nur die ersten Einträge werden angezeigt.</string>
|
||||
<string name="git_state_no_changes">(keine Änderungen)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">Für Schreibvorgänge ist die Plugin-Änderungsberechtigung erforderlich. Aktivieren Sie „Änderungen durch Plugins zulassen (plugin.api.write)“ in den Plugin-Berechtigungseinstellungen.</string>
|
||||
<string name="git_state_stage">Bereitstellen</string>
|
||||
<string name="git_state_unstage">Zurücksetzen</string>
|
||||
<string name="git_state_discard">Verwerfen</string>
|
||||
<string name="git_state_commit">Committen</string>
|
||||
<string name="git_state_commit_title">Bereitgestellte Änderungen committen</string>
|
||||
<string name="git_state_commit_message_hint">Commit-Nachricht</string>
|
||||
<string name="git_state_commit_confirm">Committen</string>
|
||||
<string name="git_state_cancel">Abbrechen</string>
|
||||
<string name="git_state_fetch">Abrufen</string>
|
||||
<string name="git_state_pull">Pullen</string>
|
||||
<string name="git_state_push">Pushen</string>
|
||||
<string name="git_state_new_branch_hint">Name des neuen Branches</string>
|
||||
<string name="git_state_create_branch">Branch erstellen</string>
|
||||
<string name="git_state_track_remote">Remote-Branch verfolgen</string>
|
||||
<string name="git_state_switch">Wechseln</string>
|
||||
<string name="git_state_current">Aktuell</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s läuft…</string>
|
||||
<string name="git_state_mutation_success">%1$s abgeschlossen. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s fehlgeschlagen: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">Änderungen verwerfen?</string>
|
||||
<string name="git_state_confirm_discard_text">Lokale Änderungen an den ausgewählten Dateien werden verworfen und können nicht wiederhergestellt werden.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Verwerfen</string>
|
||||
<string name="git_state_confirm_push_title">Änderungen pushen?</string>
|
||||
<string name="git_state_confirm_push_text">Pushen Sie den aktuellen Branch zu seinem Remote. Dabei werden lokale Commits an das Remote-Repository gesendet.</string>
|
||||
<string name="git_state_confirm_push_confirm">Pushen</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">Branch mit lokalen Änderungen wechseln?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">Der Arbeitsbaum enthält nicht committete Änderungen. Beim Wechseln werden sie möglicherweise auf den Zielbranch übertragen.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Wechseln</string>
|
||||
<string name="git_state_generate_message">Commit-Nachricht generieren</string>
|
||||
<string name="git_state_generating_message">Commit-Nachricht wird generiert…</string>
|
||||
<string name="git_state_push_after_commit">Nach Commit pushen</string>
|
||||
<string name="git_state_switch_stash">Wechseln (bei Änderungen stashen)</string>
|
||||
<string name="support_bundle_review">Supportinformationen prüfen</string>
|
||||
<string name="support_bundle_title">Supportinformationen</string>
|
||||
<string name="support_bundle_privacy">Nichts wird automatisch hochgeladen. Prüfen Sie den lokalen Export mit bis zu %1$d Berichten.</string>
|
||||
|
||||
@@ -3703,6 +3703,48 @@
|
||||
<string name="plugins_keep">Conservar</string>
|
||||
<string name="plugins_remove">Eliminar</string>
|
||||
<string name="plugins_remove_confirm">¿Eliminar «%1$s»? Esta página del plugin dejará de aparecer en los dispositivos Android conectados.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Atrás</string>
|
||||
<string name="git_state_staged">Preparados</string>
|
||||
<string name="git_state_modified">Modificados</string>
|
||||
<string name="git_state_untracked">Sin seguimiento</string>
|
||||
<string name="git_state_branches">Ramas</string>
|
||||
<string name="git_state_truncated">Resultados truncados: solo se muestran las primeras entradas.</string>
|
||||
<string name="git_state_no_changes">(sin cambios)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">Los cambios de escritura requieren el permiso de cambios de plugin. Activa «Permitir cambios de plugins (plugin.api.write)» en la configuración de permisos de Plugins.</string>
|
||||
<string name="git_state_stage">Preparar</string>
|
||||
<string name="git_state_unstage">Quitar de preparados</string>
|
||||
<string name="git_state_discard">Descartar</string>
|
||||
<string name="git_state_commit">Confirmar</string>
|
||||
<string name="git_state_commit_title">Confirmar cambios preparados</string>
|
||||
<string name="git_state_commit_message_hint">Mensaje de confirmación</string>
|
||||
<string name="git_state_commit_confirm">Confirmar</string>
|
||||
<string name="git_state_cancel">Cancelar</string>
|
||||
<string name="git_state_fetch">Obtener</string>
|
||||
<string name="git_state_pull">Traer</string>
|
||||
<string name="git_state_push">Enviar</string>
|
||||
<string name="git_state_new_branch_hint">Nombre de la nueva rama</string>
|
||||
<string name="git_state_create_branch">Crear rama</string>
|
||||
<string name="git_state_track_remote">Seguir rama remota</string>
|
||||
<string name="git_state_switch">Cambiar</string>
|
||||
<string name="git_state_current">Actual</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s en curso…</string>
|
||||
<string name="git_state_mutation_success">%1$s completado. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s falló: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">¿Descartar cambios?</string>
|
||||
<string name="git_state_confirm_discard_text">Los cambios locales de los archivos seleccionados se descartarán y no podrán recuperarse.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Descartar</string>
|
||||
<string name="git_state_confirm_push_title">¿Enviar cambios?</string>
|
||||
<string name="git_state_confirm_push_text">Envía la rama actual a su remoto. Esto sube los commits locales al repositorio remoto.</string>
|
||||
<string name="git_state_confirm_push_confirm">Enviar</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">¿Cambiar de rama con cambios locales?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">El árbol de trabajo tiene cambios sin confirmar. Cambiar puede trasladarlos a la rama de destino.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Cambiar</string>
|
||||
<string name="git_state_generate_message">Generar mensaje de confirmación</string>
|
||||
<string name="git_state_generating_message">Generando mensaje de confirmación…</string>
|
||||
<string name="git_state_push_after_commit">Enviar después de confirmar</string>
|
||||
<string name="git_state_switch_stash">Cambiar (guardar en stash si hay cambios)</string>
|
||||
<string name="support_bundle_review">Revisar información de soporte</string>
|
||||
<string name="support_bundle_title">Información de soporte</string>
|
||||
<string name="support_bundle_privacy">Nada se sube automáticamente. Revisa la exportación local con hasta %1$d informes.</string>
|
||||
|
||||
@@ -4017,6 +4017,48 @@
|
||||
<string name="plugins_keep">保持</string>
|
||||
<string name="plugins_remove">削除</string>
|
||||
<string name="plugins_remove_confirm">「%1$s」を削除しますか?接続された Android 端末にこのプラグインページは表示されなくなります。</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">戻る</string>
|
||||
<string name="git_state_staged">ステージ済み</string>
|
||||
<string name="git_state_modified">変更</string>
|
||||
<string name="git_state_untracked">未追跡</string>
|
||||
<string name="git_state_branches">ブランチ</string>
|
||||
<string name="git_state_truncated">結果は切り詰められています。最初の項目のみ表示されます。</string>
|
||||
<string name="git_state_no_changes">(変更なし)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">書き込み変更にはプラグイン変更権限が必要です。Plugins の権限設定で「プラグインの変更を許可する(plugin.api.write)」を有効にしてください。</string>
|
||||
<string name="git_state_stage">ステージ</string>
|
||||
<string name="git_state_unstage">ステージ解除</string>
|
||||
<string name="git_state_discard">破棄</string>
|
||||
<string name="git_state_commit">コミット</string>
|
||||
<string name="git_state_commit_title">ステージ済みの変更をコミット</string>
|
||||
<string name="git_state_commit_message_hint">コミットメッセージ</string>
|
||||
<string name="git_state_commit_confirm">コミット</string>
|
||||
<string name="git_state_cancel">キャンセル</string>
|
||||
<string name="git_state_fetch">フェッチ</string>
|
||||
<string name="git_state_pull">プル</string>
|
||||
<string name="git_state_push">プッシュ</string>
|
||||
<string name="git_state_new_branch_hint">新しいブランチ名</string>
|
||||
<string name="git_state_create_branch">ブランチを作成</string>
|
||||
<string name="git_state_track_remote">リモートブランチを追跡</string>
|
||||
<string name="git_state_switch">切り替え</string>
|
||||
<string name="git_state_current">現在</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s を実行中…</string>
|
||||
<string name="git_state_mutation_success">%1$s が完了しました。HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s に失敗しました: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">変更を破棄しますか?</string>
|
||||
<string name="git_state_confirm_discard_text">選択したファイルへのローカル変更は破棄され、元に戻せません。</string>
|
||||
<string name="git_state_confirm_discard_confirm">破棄</string>
|
||||
<string name="git_state_confirm_push_title">変更をプッシュしますか?</string>
|
||||
<string name="git_state_confirm_push_text">現在のブランチをリモートにプッシュします。ローカルのコミットがリモートリポジトリに送信されます。</string>
|
||||
<string name="git_state_confirm_push_confirm">プッシュ</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">ローカル変更のあるブランチに切り替えますか?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">作業ツリーに未コミットの変更があります。切り替えると、それらが対象ブランチへ引き継がれる可能性があります。</string>
|
||||
<string name="git_state_confirm_checkout_confirm">切り替え</string>
|
||||
<string name="git_state_generate_message">コミットメッセージを生成</string>
|
||||
<string name="git_state_generating_message">コミットメッセージを生成中…</string>
|
||||
<string name="git_state_push_after_commit">コミット後にプッシュ</string>
|
||||
<string name="git_state_switch_stash">切り替え(変更があればスタッシュ)</string>
|
||||
<string name="support_bundle_review">サポート情報を確認</string>
|
||||
<string name="support_bundle_title">サポート情報</string>
|
||||
<string name="support_bundle_privacy">自動アップロードはありません。最大 %1$d 件のレポートを含む端末内エクスポートを確認してください。</string>
|
||||
|
||||
@@ -3739,6 +3739,48 @@
|
||||
<string name="plugins_keep">Оставить</string>
|
||||
<string name="plugins_remove">Удалить</string>
|
||||
<string name="plugins_remove_confirm">Удалить «%1$s»? Эта страница плагина больше не будет отображаться на подключённых устройствах Android.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Назад</string>
|
||||
<string name="git_state_staged">В индексе</string>
|
||||
<string name="git_state_modified">Изменённые</string>
|
||||
<string name="git_state_untracked">Неотслеживаемые</string>
|
||||
<string name="git_state_branches">Ветки</string>
|
||||
<string name="git_state_truncated">Результаты усечены — показаны только первые записи.</string>
|
||||
<string name="git_state_no_changes">(без изменений)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">Для записи изменений требуется разрешение на изменение плагина. Включите «Разрешить изменения плагинов (plugin.api.write)» в настройках разрешений Plugins.</string>
|
||||
<string name="git_state_stage">Добавить в индекс</string>
|
||||
<string name="git_state_unstage">Убрать из индекса</string>
|
||||
<string name="git_state_discard">Отменить</string>
|
||||
<string name="git_state_commit">Коммит</string>
|
||||
<string name="git_state_commit_title">Зафиксировать индексированные изменения</string>
|
||||
<string name="git_state_commit_message_hint">Сообщение коммита</string>
|
||||
<string name="git_state_commit_confirm">Коммит</string>
|
||||
<string name="git_state_cancel">Отмена</string>
|
||||
<string name="git_state_fetch">Обновить</string>
|
||||
<string name="git_state_pull">Вытянуть</string>
|
||||
<string name="git_state_push">Отправить</string>
|
||||
<string name="git_state_new_branch_hint">Имя новой ветки</string>
|
||||
<string name="git_state_create_branch">Создать ветку</string>
|
||||
<string name="git_state_track_remote">Отслеживать удалённую ветку</string>
|
||||
<string name="git_state_switch">Переключить</string>
|
||||
<string name="git_state_current">Текущая</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s выполняется…</string>
|
||||
<string name="git_state_mutation_success">%1$s завершено. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s не удалось: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">Отменить изменения?</string>
|
||||
<string name="git_state_confirm_discard_text">Локальные изменения выбранных файлов будут отменены и их нельзя будет восстановить.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Отменить</string>
|
||||
<string name="git_state_confirm_push_title">Отправить изменения?</string>
|
||||
<string name="git_state_confirm_push_text">Отправить текущую ветку в её удалённый репозиторий. Это передаст локальные коммиты в удалённый репозиторий.</string>
|
||||
<string name="git_state_confirm_push_confirm">Отправить</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">Переключить ветку с локальными изменениями?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">В рабочем дереве есть незакоммиченные изменения. Переключение может перенести их в целевую ветку.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Переключить</string>
|
||||
<string name="git_state_generate_message">Создать сообщение коммита</string>
|
||||
<string name="git_state_generating_message">Создание сообщения коммита…</string>
|
||||
<string name="git_state_push_after_commit">Отправить после коммита</string>
|
||||
<string name="git_state_switch_stash">Переключить (сохранить изменения, если есть)</string>
|
||||
<string name="support_bundle_review">Проверить сведения для поддержки</string>
|
||||
<string name="support_bundle_title">Сведения для поддержки</string>
|
||||
<string name="support_bundle_privacy">Ничего не загружается автоматически. Проверьте локальный экспорт с не более чем %1$d отчётами.</string>
|
||||
|
||||
@@ -4214,6 +4214,51 @@
|
||||
<string name="plugins_keep">Keep</string>
|
||||
<string name="plugins_remove">Remove</string>
|
||||
<string name="plugins_remove_confirm">Remove “%1$s”? This plugin page will no longer appear on connected Android devices.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Back</string>
|
||||
<string name="git_state_staged">Staged</string>
|
||||
<string name="git_state_modified">Modified</string>
|
||||
<string name="git_state_untracked">Untracked</string>
|
||||
<string name="git_state_branches">Branches</string>
|
||||
<string name="git_state_truncated">Results truncated — showing the first entries only.</string>
|
||||
<string name="git_state_no_changes">(no changes)</string>
|
||||
|
||||
<!-- Git write surface (Phase 2). All mutations require the plugin.api.write grant. -->
|
||||
<string name="git_state_write_grant_required">Write changes require the plugin change permission. Enable “Allow plugin changes (plugin.api.write)” in the Plugins grant settings.</string>
|
||||
<string name="git_state_stage">Stage</string>
|
||||
<string name="git_state_unstage">Unstage</string>
|
||||
<string name="git_state_discard">Discard</string>
|
||||
<string name="git_state_commit">Commit</string>
|
||||
<string name="git_state_commit_title">Commit staged changes</string>
|
||||
<string name="git_state_commit_message_hint">Commit message</string>
|
||||
<string name="git_state_commit_confirm">Commit</string>
|
||||
<string name="git_state_cancel">Cancel</string>
|
||||
<string name="git_state_fetch">Fetch</string>
|
||||
<string name="git_state_pull">Pull</string>
|
||||
<string name="git_state_push">Push</string>
|
||||
<string name="git_state_new_branch_hint">New branch name</string>
|
||||
<string name="git_state_create_branch">Create branch</string>
|
||||
<string name="git_state_track_remote">Track remote branch</string>
|
||||
<string name="git_state_switch">Switch</string>
|
||||
<string name="git_state_current">Current</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s in progress…</string>
|
||||
<string name="git_state_mutation_success">%1$s completed. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s failed: %2$s</string>
|
||||
<!-- Destructive-op confirmations (token is sent only on explicit user confirmation). -->
|
||||
<string name="git_state_confirm_discard_title">Discard changes?</string>
|
||||
<string name="git_state_confirm_discard_text">Local changes to the selected file(s) will be discarded and cannot be recovered.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Discard</string>
|
||||
<string name="git_state_confirm_push_title">Push changes?</string>
|
||||
<string name="git_state_confirm_push_text">Push the current branch to its remote. This sends local commits to the remote repository.</string>
|
||||
<string name="git_state_confirm_push_confirm">Push</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">Switch branch with local changes?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">The working tree has uncommitted changes. Switching may carry them onto the target branch.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Switch</string>
|
||||
<string name="git_state_generate_message">Generate commit message</string>
|
||||
<string name="git_state_generating_message">Generating commit message…</string>
|
||||
<string name="git_state_push_after_commit">Push after commit</string>
|
||||
<string name="git_state_switch_stash">Switch (stash if dirty)</string>
|
||||
|
||||
<string name="support_bundle_review">Review support information</string>
|
||||
<string name="support_bundle_title">Support information</string>
|
||||
<string name="support_bundle_privacy">Nothing is uploaded automatically. Review the exact local export below. It contains up to %1$d recent reports.</string>
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class GitStateExtrasViewModelTest {
|
||||
private val ownerKey = "connection-a\u0000default\u0000dashboard"
|
||||
private val mainDispatcher = UnconfinedTestDispatcher()
|
||||
private lateinit var application: Application
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(mainDispatcher)
|
||||
application = ApplicationProvider.getApplicationContext()
|
||||
server = MockWebServer().apply { start() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun viewModel(grant: Boolean = true): GitStateViewModel {
|
||||
val vm = GitStateViewModel(application)
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
|
||||
vm.setWriteGrant(ownerKey, grant)
|
||||
return vm
|
||||
}
|
||||
|
||||
private fun enqueueJson(body: String) {
|
||||
server.enqueue(
|
||||
MockResponse().setHeader("Content-Type", "application/json").setBody(body),
|
||||
)
|
||||
}
|
||||
|
||||
private fun selectAlpha(vm: GitStateViewModel) {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""")
|
||||
enqueueJson("""{"counts":{"staged":1,"modified":0,"untracked":0},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[{"name":"main","upstream":"origin/main","ahead":0,"behind":0,"is_current":true}]}""")
|
||||
runBlocking { withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() } }
|
||||
vm.selectRepo("alpha")
|
||||
runBlocking { withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() } }
|
||||
repeat(3) { server.takeRequest() }
|
||||
}
|
||||
|
||||
// ── AI commit message (magic-wand) ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `generate commit message pre-fills the suggestion via selected paths`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueueJson("""{"message":"feat: add feature","notice":""}""")
|
||||
vm.generateCommitMessage(listOf("a.txt"))
|
||||
val state = withTimeout(5_000) {
|
||||
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
|
||||
}
|
||||
assertEquals("feat: add feature", state.message)
|
||||
assertEquals("", state.notice)
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/commit_message_selected"))
|
||||
assertTrue(req.body.readUtf8().contains("a.txt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generate message without grant is refused before any POST`() = runBlocking {
|
||||
val vm = viewModel(grant = false)
|
||||
selectAlpha(vm)
|
||||
vm.generateCommitMessage(null)
|
||||
val state = withTimeout(5_000) {
|
||||
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
|
||||
}
|
||||
assertTrue(state.notice.contains("plugin.api.write"))
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty staged diff surfaces notice without error`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueueJson("""{"message":"","notice":"nothing staged"}""")
|
||||
vm.generateCommitMessage(listOf("a.txt"))
|
||||
val state = withTimeout(5_000) {
|
||||
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
|
||||
}
|
||||
assertEquals("", state.message)
|
||||
assertEquals("nothing staged", state.notice)
|
||||
}
|
||||
|
||||
// ── Push-after-commit toggle ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `push after commit defaults off and toggles`() {
|
||||
val vm = viewModel()
|
||||
assertTrue(!vm.isPushAfterCommitEnabled())
|
||||
vm.setPushAfterCommit(true)
|
||||
assertTrue(vm.isPushAfterCommitEnabled())
|
||||
vm.setPushAfterCommit(false)
|
||||
assertTrue(!vm.isPushAfterCommitEnabled())
|
||||
}
|
||||
|
||||
// ── Stash-checkout ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `stash checkout surfaces the stash notice on success`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueueJson(
|
||||
"""{"head":"abc","stashed":true,"stash_message":"git-state: feature","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false},"branches":[]}""",
|
||||
)
|
||||
// refreshDetail fires two reads (status + branches).
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
vm.stashCheckout("feature")
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
val notice = withTimeout(5_000) { vm.stashNotice.first { it != null } }!!
|
||||
assertTrue(notice.contains("git-state: feature"))
|
||||
assertTrue(notice.contains("git stash pop"))
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/stash_checkout"))
|
||||
assertTrue(req.body.readUtf8().contains("feature"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stash checkout without grant is refused before any POST`() = runBlocking {
|
||||
val vm = viewModel(grant = false)
|
||||
selectAlpha(vm)
|
||||
vm.stashCheckout("feature")
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("plugin.api.write"))
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clean stash checkout yields no stash notice`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueueJson(
|
||||
"""{"head":"abc","stashed":false,"stash_message":"","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false},"branches":[]}""",
|
||||
)
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
vm.stashCheckout("feature")
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
// No stash notice for a clean checkout.
|
||||
assertEquals(null, vm.stashNotice.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class GitStateViewModelTest {
|
||||
private val ownerKey = "connection-a\u0000default\u0000dashboard"
|
||||
private val mainDispatcher = UnconfinedTestDispatcher()
|
||||
private lateinit var application: Application
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(mainDispatcher)
|
||||
application = ApplicationProvider.getApplicationContext()
|
||||
server = MockWebServer().apply { start() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun viewModel(): GitStateViewModel {
|
||||
val vm = GitStateViewModel(application)
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
|
||||
return vm
|
||||
}
|
||||
|
||||
private fun enqueueJson(body: String) {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadRepos maps repo list and notice`() = runBlocking {
|
||||
enqueueJson(
|
||||
"""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":false}],"notice":null}""",
|
||||
)
|
||||
val vm = viewModel()
|
||||
val state = withTimeout(5_000) {
|
||||
vm.repos.filterIsInstance<GitStateUiState.Ready>().first()
|
||||
}
|
||||
assertEquals(1, state.repos.size)
|
||||
assertEquals("alpha", state.repos.single().name)
|
||||
assertNotNull(vm.repos.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadRepos surfaces server error`() = runBlocking {
|
||||
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"unknown repository"}"""))
|
||||
val vm = viewModel()
|
||||
val state = withTimeout(5_000) {
|
||||
vm.repos.filterIsInstance<GitStateUiState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.isNotBlank())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectRepo loads status and branches and preserves truncation flag`() = runBlocking {
|
||||
enqueueJson(
|
||||
"""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""",
|
||||
)
|
||||
// status
|
||||
enqueueJson(
|
||||
"""{"counts":{"staged":1,"modified":2,"untracked":3},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":true}""",
|
||||
)
|
||||
// branches
|
||||
enqueueJson(
|
||||
"""{"branches":[{"name":"main","upstream":"origin/main","ahead":1,"behind":0,"is_current":true}]}""",
|
||||
)
|
||||
val vm = viewModel()
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.selectRepo("alpha")
|
||||
val ready = withTimeout(5_000) {
|
||||
vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first()
|
||||
}
|
||||
assertEquals(1, ready.status.counts.staged)
|
||||
assertEquals(2, ready.status.counts.modified)
|
||||
assertEquals(3, ready.status.counts.untracked)
|
||||
assertTrue(ready.status.truncated)
|
||||
assertEquals("main", ready.branches.single().name)
|
||||
assertTrue(ready.branches.single().isCurrent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadDiff surfaces truncated diff`() = runBlocking {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":1,"untracked":0},"staged":[],"modified":[{"path":"a.txt"}],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
enqueueJson("""{"path":"a.txt","kind":"unstaged","diff":"+change","truncated":true}""")
|
||||
|
||||
val vm = viewModel()
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.selectRepo("alpha")
|
||||
withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() }
|
||||
vm.loadDiff("a.txt", "unstaged")
|
||||
val content = withTimeout(5_000) {
|
||||
vm.content.filterIsInstance<GitContentViewState.Diff>().first()
|
||||
}
|
||||
assertEquals("a.txt", content.diff.path)
|
||||
assertTrue(content.diff.truncated)
|
||||
assertTrue(content.diff.diff.contains("change"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadFile surfaces content and truncation`() = runBlocking {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":1},"staged":[],"modified":[],"untracked":[{"path":"new.txt"}],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
enqueueJson("""{"path":"new.txt","content":"hello world","truncated":false}""")
|
||||
|
||||
val vm = viewModel()
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.selectRepo("alpha")
|
||||
withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() }
|
||||
vm.loadFile("new.txt")
|
||||
val content = withTimeout(5_000) {
|
||||
vm.content.filterIsInstance<GitContentViewState.File>().first()
|
||||
}
|
||||
assertEquals("hello world", content.file.content)
|
||||
assertFalse(content.file.truncated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectRepo surfaces status error for unknown repo`() = runBlocking {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
|
||||
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"unknown repository: bogus"}"""))
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
val vm = viewModel()
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.selectRepo("bogus")
|
||||
val error = withTimeout(5_000) {
|
||||
vm.detail.filterIsInstance<GitRepoDetailState.Error>().first()
|
||||
}
|
||||
assertTrue(error.message.contains("unknown repository"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class GitStateWriteViewModelTest {
|
||||
private val ownerKey = "connection-a\u0000default\u0000dashboard"
|
||||
private val mainDispatcher = UnconfinedTestDispatcher()
|
||||
private lateinit var application: Application
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(mainDispatcher)
|
||||
application = ApplicationProvider.getApplicationContext()
|
||||
server = MockWebServer().apply { start() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun viewModel(grant: Boolean = true): GitStateViewModel {
|
||||
val vm = GitStateViewModel(application)
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
|
||||
vm.setWriteGrant(ownerKey, grant)
|
||||
return vm
|
||||
}
|
||||
|
||||
private fun enqueueJson(body: String) {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body),
|
||||
)
|
||||
}
|
||||
|
||||
/** Loads the repo list + selects ``alpha`` so a mutation has a target. */
|
||||
private fun selectAlpha(vm: GitStateViewModel) {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""")
|
||||
enqueueJson("""{"counts":{"staged":1,"modified":0,"untracked":0},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[{"name":"main","upstream":"origin/main","ahead":0,"behind":0,"is_current":true}]}""")
|
||||
runBlocking { withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() } }
|
||||
vm.selectRepo("alpha")
|
||||
runBlocking { withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() } }
|
||||
// Drain the three read requests (repos/status/branches) so the next
|
||||
// takeRequest() returns the mutation POST we actually assert on.
|
||||
repeat(3) { server.takeRequest() }
|
||||
}
|
||||
|
||||
private fun enqueuePostSuccess(head: String) {
|
||||
enqueueJson("""{"head":"$head","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}}""")
|
||||
// refreshDetail fires two more requests (status + branches).
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
}
|
||||
|
||||
// ── Grant gating (security first) ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `stage without write grant is refused before any POST`() = runBlocking {
|
||||
val vm = viewModel(grant = false)
|
||||
selectAlpha(vm)
|
||||
vm.stage(listOf("a.txt"))
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("plugin.api.write"))
|
||||
// No write POST was sent (only the 3 read requests for load/select).
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discard without write grant is refused`() = runBlocking {
|
||||
val vm = viewModel(grant = false)
|
||||
selectAlpha(vm)
|
||||
vm.discard(listOf("a.txt"), GitConfirmationStrings.DISCARD)
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("plugin.api.write"))
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
|
||||
// ── Happy paths ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `stage sends POST and surfaces success + fresh status`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc123")
|
||||
vm.stage(listOf("a.txt"))
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Success>().first()
|
||||
}
|
||||
assertEquals("stage", state.label)
|
||||
assertEquals("abc123", state.head)
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/stage"))
|
||||
assertTrue(req.body.readUtf8().contains("a.txt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commit sends message and returns head`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("deadbeef")
|
||||
vm.commit("add feature")
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Success>().first()
|
||||
}
|
||||
assertEquals("commit", state.label)
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/commit"))
|
||||
assertTrue(req.body.readUtf8().contains("add feature"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commit success callback fires only after successful response`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc")
|
||||
var committedTarget: GitTarget? = null
|
||||
|
||||
vm.commit("add feature") { committedTarget = it }
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
|
||||
assertEquals("alpha", committedTarget?.repoId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commit failure never invokes success callback`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"failed"}"""))
|
||||
var callbackCalled = false
|
||||
|
||||
vm.commit("add feature") { callbackCalled = true }
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Error>().first() }
|
||||
|
||||
assertFalse(callbackCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connection change revokes grant and rejects prior target`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
val priorTarget = vm.currentTarget()!!
|
||||
enqueueJson("""{"repos":[]}""")
|
||||
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()), "connection-b")
|
||||
vm.setWriteGrant(ownerKey, true)
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.push(GitConfirmationStrings.PUSH, expectedTarget = priorTarget)
|
||||
|
||||
assertFalse(vm.hasWriteGrant())
|
||||
assertEquals(null, vm.currentTarget())
|
||||
assertTrue(vm.mutation.value is GitMutationState.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discard echoes the fixed confirmation token`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc")
|
||||
vm.discard(listOf("a.txt"), GitConfirmationStrings.DISCARD)
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/discard"))
|
||||
assertTrue(req.body.readUtf8().contains(GitConfirmationStrings.DISCARD))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `push echoes the confirmation token`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc")
|
||||
vm.push(GitConfirmationStrings.PUSH)
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/push"))
|
||||
assertTrue(req.body.readUtf8().contains(GitConfirmationStrings.PUSH))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch and pull send their endpoints`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc")
|
||||
vm.fetch()
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
assertTrue(server.takeRequest().path!!.contains("/git/fetch"))
|
||||
// Drain the two refreshDetail reads (status + branches) so the next
|
||||
// takeRequest() sees only the pull POST.
|
||||
repeat(2) { server.takeRequest() }
|
||||
|
||||
enqueuePostSuccess("xyz")
|
||||
vm.pull("origin", "main")
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
assertTrue(server.takeRequest().path!!.contains("/git/pull"))
|
||||
}
|
||||
|
||||
// ── Error branches ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `commit surfaces server error as readable message`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(400).setBody("""{"detail":"commit message must not be empty"}"""),
|
||||
)
|
||||
vm.commit(" ")
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("must not be empty"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discard wrong confirmation surfaces server 403`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(403).setBody("""{"detail":"confirmation did not match"}"""),
|
||||
)
|
||||
vm.discard(listOf("a.txt"), "wrong")
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("confirmation") || state.message.contains("403"))
|
||||
}
|
||||
|
||||
// ── Confirmation gating helpers ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `requiresConfirmation and confirmationFor match destructive ops`() {
|
||||
val vm = viewModel()
|
||||
assertTrue(vm.requiresConfirmation("discard"))
|
||||
assertTrue(vm.requiresConfirmation("push"))
|
||||
assertTrue(vm.requiresConfirmation("dirty-checkout"))
|
||||
assertEquals(GitConfirmationStrings.DISCARD, vm.confirmationFor("discard"))
|
||||
assertEquals(GitConfirmationStrings.PUSH, vm.confirmationFor("push"))
|
||||
assertEquals(GitConfirmationStrings.DIRTY_CHECKOUT, vm.confirmationFor("dirty-checkout"))
|
||||
assertEquals(null, vm.confirmationFor("commit"))
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,8 @@ the `relay_plugin_draft` tool to create or replace a generated declarative page.
|
||||
tool accepts the same bounded schema as Android, stores JSON atomically below
|
||||
`HERMES_HOME/mobile-plugins`, and rejects every `action.request`. Generated previews
|
||||
therefore cannot reach Relay management APIs or acquire executable backend behavior.
|
||||
The contribution ID `git` is reserved for the Relay plugin's native Git workspace;
|
||||
generated drafts cannot shadow or duplicate that route.
|
||||
|
||||
The Relay mobile manifest exposes drafts as preview pages under the authenticated
|
||||
`hermes-relay` plugin namespace. Android polls the catalog every five seconds while
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "f34c3772ee5a6b73514f4ce891cc2b5cbedcfc941798dcdf563682c725ef7bf0",
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -44,11 +44,11 @@
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"native_name": "Español",
|
||||
"native_name": "Espa\u00f1ol",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "f34c3772ee5a6b73514f4ce891cc2b5cbedcfc941798dcdf563682c725ef7bf0",
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -68,11 +68,11 @@
|
||||
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
|
||||
},
|
||||
"ja": {
|
||||
"native_name": "日本語",
|
||||
"native_name": "\u65e5\u672c\u8a9e",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "f34c3772ee5a6b73514f4ce891cc2b5cbedcfc941798dcdf563682c725ef7bf0",
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -92,11 +92,11 @@
|
||||
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
|
||||
},
|
||||
"pt-BR": {
|
||||
"native_name": "Português (Brasil)",
|
||||
"native_name": "Portugu\u00eas (Brasil)",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "f34c3772ee5a6b73514f4ce891cc2b5cbedcfc941798dcdf563682c725ef7bf0",
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -116,11 +116,11 @@
|
||||
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
|
||||
},
|
||||
"ru": {
|
||||
"native_name": "Русский",
|
||||
"native_name": "\u0420\u0443\u0441\u0441\u043a\u0438\u0439",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "f34c3772ee5a6b73514f4ce891cc2b5cbedcfc941798dcdf563682c725ef7bf0",
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -131,11 +131,11 @@
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"native_name": "简体中文",
|
||||
"native_name": "\u7b80\u4f53\u4e2d\u6587",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "f34c3772ee5a6b73514f4ce891cc2b5cbedcfc941798dcdf563682c725ef7bf0",
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
|
||||
@@ -91,9 +91,9 @@ This app is a community project and is not affiliated with or endorsed by NousRe
|
||||
Paste into Play Console → **What's new** (≤500 characters):
|
||||
|
||||
```
|
||||
v1.13.2 - Supervised Mode and clearer activity
|
||||
v1.13.1 - Accurate session activity
|
||||
|
||||
Supervised Mode adds a parent-configured, profile-pinned chat view with device-authenticated settings. Parents can limit attachments, Standard voice, generated media, history, actions, and technical details. Session rows stay neutral while live activity is unavailable, and returning from parent settings no longer blanks Supervised Chat.
|
||||
Session activity now follows live Hermes runtime state instead of a recent-activity estimate. Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work stay accurate, and stale state clears only after a complete, unambiguous update.
|
||||
```
|
||||
## Category
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
appVersionName = "1.13.2"
|
||||
appVersionCode = "51"
|
||||
appVersionName = "1.13.1"
|
||||
appVersionCode = "50"
|
||||
agp = "9.3.2"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.08.00"
|
||||
|
||||
Vendored
+8
-8
File diff suppressed because one or more lines are too long
@@ -0,0 +1,337 @@
|
||||
"""Read and write Git state endpoints for the Hermes-Relay dashboard plugin.
|
||||
|
||||
Mounted by hermes-agent at ``/api/plugins/hermes-relay/git/*``. These routes
|
||||
power both the dashboard tab and the Android mobile page.
|
||||
|
||||
Route map
|
||||
---------
|
||||
Read (GET, no grant):
|
||||
- ``GET /git/repos`` → scanned repo list under the configured base path
|
||||
- ``GET /git/status`` → grouped working-tree status for one repo
|
||||
- ``GET /git/branches`` → branch list (name, upstream, ahead/behind, current)
|
||||
- ``GET /git/diff`` → per-file diff (kind=staged|unstaged)
|
||||
- ``GET /git/file`` → read a tracked file
|
||||
|
||||
Write (POST, require the plugin's ``plugin.api.write`` grant, which the app
|
||||
enforces client-side before ever sending the request — the same gate used for
|
||||
every mutating plugin action in PluginsViewModel.invokeAction):
|
||||
- ``POST /git/stage`` — stage path list → fresh status
|
||||
- ``POST /git/unstage`` — unstage path list → fresh status
|
||||
- ``POST /git/discard`` — discard paths (confirmation) → fresh status
|
||||
- ``POST /git/commit`` — commit staged index (message) → {head,status}
|
||||
- ``POST /git/commit_selected``— commit selected paths (message+paths)
|
||||
- ``POST /git/fetch`` — fetch a remote → {branches,status}
|
||||
- ``POST /git/pull`` — pull remote/branch → fresh status
|
||||
- ``POST /git/push`` — push (confirmation) → {branches,status}
|
||||
- ``POST /git/checkout`` — switch branch (new_branch/track; dirty→confirmation)
|
||||
|
||||
Destructive writes (discard, push, dirty checkout) enforce a per-use
|
||||
confirmation string server-side: missing/wrong → 403. Dirty/conflict trees →
|
||||
409. The HTTP mapping keeps raw stack traces and JSON dumps out of the UI.
|
||||
|
||||
Security
|
||||
--------
|
||||
- ``repo`` is an opaque id validated against the scanned allowlist; unknown
|
||||
ids → 400.
|
||||
- File paths are validated to reject traversal and absolute escapes.
|
||||
- Remote URLs are scrubbed of embedded userinfo.
|
||||
- No ``shell=True`` anywhere: every git invocation uses argument lists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query
|
||||
|
||||
from .. import git_state
|
||||
|
||||
router = APIRouter(prefix="/git")
|
||||
|
||||
|
||||
def _bad_request(exc: Exception) -> HTTPException:
|
||||
return HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
# Structured error taxonomy → HTTP status. The UI renders ``detail`` (a
|
||||
# human-readable message) plus ``code`` for styling; no raw traces/JSON dumps.
|
||||
_GIT_ERROR_STATUS = {
|
||||
"non-repo": 400,
|
||||
"dirty": 409,
|
||||
"conflict": 409,
|
||||
"auth": 502,
|
||||
"network": 502,
|
||||
"invalid-input": 400,
|
||||
"missing-confirmation": 403,
|
||||
"wrong-confirmation": 403,
|
||||
}
|
||||
|
||||
|
||||
def _write_error(exc: git_state.GitError) -> HTTPException:
|
||||
status = _GIT_ERROR_STATUS.get(exc.code, 400)
|
||||
return HTTPException(status_code=status, detail=str(exc))
|
||||
|
||||
|
||||
def _resolve(repo: str) -> Any:
|
||||
return git_state.resolve_repo(git_state.base_path(), repo)
|
||||
|
||||
|
||||
@router.get("/repos")
|
||||
async def get_repos() -> dict[str, Any]:
|
||||
"""Return the scanned repo list plus a notice when the base path is missing."""
|
||||
base = git_state.base_path()
|
||||
repos = git_state.scan_repos(base)
|
||||
notice = None
|
||||
if not base.is_dir():
|
||||
notice = f"Git base path not found: {base}"
|
||||
return {"repos": repos, "base_path": str(base), "notice": notice}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status(repo: str = Query(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.repo_status(_resolve(repo))
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.get("/branches")
|
||||
async def get_branches(repo: str = Query(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return {"branches": git_state.repo_branches(_resolve(repo))}
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.get("/diff")
|
||||
async def get_diff(
|
||||
repo: str = Query(...),
|
||||
path: str = Query(...),
|
||||
kind: str = Query("unstaged"),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.repo_diff(_resolve(repo), path, kind)
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.get("/file")
|
||||
async def get_file(repo: str = Query(...), path: str = Query(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.read_file(_resolve(repo), path)
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
# ── Write endpoints ────────────────────────────────────────────────────────
|
||||
# Every write requires the plugin.api.write grant (enforced client-side). The
|
||||
# router re-validates repo + paths against the allowlist/traversal rules and
|
||||
# classifies git failures into a structured error the UI can render.
|
||||
|
||||
def _require_repo(body: dict[str, Any]) -> Any:
|
||||
repo = body.get("repo")
|
||||
if not isinstance(repo, str) or not repo:
|
||||
raise git_state.GitStateError("repo is required")
|
||||
return git_state.resolve_repo(git_state.base_path(), repo)
|
||||
|
||||
|
||||
@router.post("/stage")
|
||||
async def post_stage(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.stage(_require_repo(body), _paths(body))
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/unstage")
|
||||
async def post_unstage(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.unstage(_require_repo(body), _paths(body))
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/discard")
|
||||
async def post_discard(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.discard(
|
||||
_require_repo(body),
|
||||
_paths(body),
|
||||
confirmation=body.get("confirmation"),
|
||||
delete_untracked=bool(body.get("delete_untracked", False)),
|
||||
)
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/commit")
|
||||
async def post_commit(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.commit(_require_repo(body), _message(body))
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/commit_selected")
|
||||
async def post_commit_selected(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.commit_selected(_require_repo(body), _message(body), _paths(body))
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/fetch")
|
||||
async def post_fetch(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.fetch(_require_repo(body), _remote(body))
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/pull")
|
||||
async def post_pull(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.pull(_require_repo(body), _remote(body), _branch(body))
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/push")
|
||||
async def post_push(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.push(
|
||||
_require_repo(body),
|
||||
_remote(body),
|
||||
_branch(body),
|
||||
confirmation=body.get("confirmation"),
|
||||
)
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/checkout")
|
||||
async def post_checkout(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
new_branch = _str_opt(body, "new_branch")
|
||||
return git_state.checkout(
|
||||
_require_repo(body),
|
||||
_ref(body, allow_empty=bool(new_branch)),
|
||||
confirmation=body.get("confirmation"),
|
||||
new_branch=new_branch,
|
||||
track=bool(body.get("track", False)),
|
||||
)
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/stash_checkout")
|
||||
async def post_stash_checkout(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Checkout that auto-stashes a dirty tree first.
|
||||
|
||||
Unlike a plain dirty checkout, no confirmation is required: a stash is
|
||||
recoverable (``git stash pop``), so this is not a data-loss path. The
|
||||
response carries ``stashed`` + ``stash_message`` so the UI can surface the
|
||||
stash after a successful switch.
|
||||
"""
|
||||
try:
|
||||
new_branch = _str_opt(body, "new_branch")
|
||||
return git_state.stash_checkout(
|
||||
_require_repo(body),
|
||||
_ref(body, allow_empty=bool(new_branch)),
|
||||
new_branch=new_branch,
|
||||
track=bool(body.get("track", False)),
|
||||
)
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/commit_message")
|
||||
async def post_commit_message(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Generate a conventional-style commit message from the staged diff.
|
||||
|
||||
Empty staged diff → ``{message:"", notice:"nothing staged"}`` without calling
|
||||
the model. A missing/failed model degrades to an empty message + a
|
||||
``model unavailable`` notice — never a 500. Only staged content is sent.
|
||||
"""
|
||||
try:
|
||||
return await git_state.commit_message(_require_repo(body))
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/commit_message_selected")
|
||||
async def post_commit_message_selected(
|
||||
body: dict[str, Any] = Body(...),
|
||||
) -> dict[str, Any]:
|
||||
"""Generate a message from the staged diff of the given ``paths`` only."""
|
||||
try:
|
||||
return await git_state.commit_message_selected(
|
||||
_require_repo(body), _paths(body)
|
||||
)
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
def _paths(body: dict[str, Any]) -> list[str]:
|
||||
paths = body.get("paths")
|
||||
if not isinstance(paths, list) or not paths or not all(isinstance(p, str) for p in paths):
|
||||
raise git_state.GitStateError("paths must be a non-empty list of strings")
|
||||
return paths
|
||||
|
||||
|
||||
def _message(body: dict[str, Any]) -> str:
|
||||
message = body.get("message")
|
||||
if not isinstance(message, str):
|
||||
raise git_state.GitStateError("message is required")
|
||||
return message
|
||||
|
||||
|
||||
def _remote(body: dict[str, Any]) -> str:
|
||||
remote = body.get("remote", "origin")
|
||||
if not isinstance(remote, str):
|
||||
raise git_state.GitStateError("remote must be a string")
|
||||
return remote or "origin"
|
||||
|
||||
|
||||
def _branch(body: dict[str, Any]) -> str:
|
||||
branch = body.get("branch", "")
|
||||
if not isinstance(branch, str):
|
||||
raise git_state.GitStateError("branch must be a string")
|
||||
return branch
|
||||
|
||||
|
||||
def _ref(body: dict[str, Any], *, allow_empty: bool = False) -> str:
|
||||
ref = body.get("ref", "" if allow_empty else None)
|
||||
if not isinstance(ref, str) or (not ref and not allow_empty):
|
||||
raise git_state.GitStateError("ref is required")
|
||||
return ref
|
||||
|
||||
|
||||
def _str_opt(body: dict[str, Any], key: str) -> str:
|
||||
value = body.get(key)
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -32,6 +32,15 @@ async def get_mobile_manifest() -> dict[str, Any]:
|
||||
|
||||
@router.get("/pages/{plugin_id}")
|
||||
async def get_mobile_page(plugin_id: str = Path(...)) -> dict[str, Any]:
|
||||
# The static read-only Git page is served directly from the git_state
|
||||
# module (not the generated-page store). It carries no filesystem paths
|
||||
# per the android-plugins.md document contract.
|
||||
if plugin_id == "git":
|
||||
from .. import git_state
|
||||
|
||||
document = git_state.build_git_document(git_state.base_path())
|
||||
document["host_revision"] = 1
|
||||
return document
|
||||
try:
|
||||
entry = _store().get(plugin_id)
|
||||
document = dict(entry["document"])
|
||||
|
||||
@@ -142,6 +142,7 @@ def _validate_public_url(url: str) -> str:
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(_plugin_module("dashboard.mobile_plugin_api").router)
|
||||
router.include_router(_plugin_module("dashboard.git_api").router)
|
||||
|
||||
|
||||
def _relay_unreachable(err: Exception) -> HTTPException:
|
||||
|
||||
@@ -6,6 +6,7 @@ import RelayManagement from "./tabs/RelayManagement.jsx";
|
||||
import BridgeActivity from "./tabs/BridgeActivity.jsx";
|
||||
import MediaInspector from "./tabs/MediaInspector.jsx";
|
||||
import RemoteAccess from "./tabs/RemoteAccess.jsx";
|
||||
import GitState from "./tabs/GitState.jsx";
|
||||
import RelayStatusSlot from "./components/RelayStatusSlot.jsx";
|
||||
import MobileConnectDialog from "./components/MobileConnectDialog.jsx";
|
||||
import { Button, Switch } from "./lib/ui-shims.jsx";
|
||||
@@ -19,6 +20,7 @@ const TABS = [
|
||||
{ key: "activity", label: "Activity" },
|
||||
{ key: "media", label: "Media" },
|
||||
{ key: "remote", label: "Remote Access" },
|
||||
{ key: "git", label: "Git" },
|
||||
];
|
||||
|
||||
function readAutoRefresh() {
|
||||
@@ -113,6 +115,7 @@ function RelayPluginRoot() {
|
||||
{tab === "activity" && <BridgeActivity autoRefresh={autoRefresh} />}
|
||||
{tab === "media" && <MediaInspector autoRefresh={autoRefresh} />}
|
||||
{tab === "remote" && <RemoteAccess autoRefresh={autoRefresh} />}
|
||||
{tab === "git" && <GitState autoRefresh={autoRefresh} />}
|
||||
</div>
|
||||
<MobileConnectDialog
|
||||
open={mobileConnectOpen}
|
||||
|
||||
@@ -139,3 +139,107 @@ export function mintPairingWithMode({ mode, publicUrl, prefer, ...rest } = {}) {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Git State tab ───────────────────────────────────────────────────────────
|
||||
|
||||
export function getGitRepos() {
|
||||
return fetchJSON("/git/repos");
|
||||
}
|
||||
|
||||
export function getGitStatus(repo) {
|
||||
return fetchJSON(`/git/status?repo=${encodeURIComponent(repo)}`);
|
||||
}
|
||||
|
||||
export function getGitBranches(repo) {
|
||||
return fetchJSON(`/git/branches?repo=${encodeURIComponent(repo)}`);
|
||||
}
|
||||
|
||||
export function getGitDiff(repo, path, kind = "unstaged") {
|
||||
return fetchJSON(
|
||||
`/git/diff?repo=${encodeURIComponent(repo)}&path=${encodeURIComponent(path)}&kind=${encodeURIComponent(kind)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getGitFile(repo, path) {
|
||||
return fetchJSON(
|
||||
`/git/file?repo=${encodeURIComponent(repo)}&path=${encodeURIComponent(path)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Git State write operations ────────────────────────────────────────────
|
||||
// Every write POST goes through the authenticated Dashboard plugin namespace.
|
||||
// Android separately enforces its local plugin.api.write preference before it
|
||||
// calls this namespace. Destructive ops pass a per-use confirmation token.
|
||||
|
||||
function postGit(path, body) {
|
||||
return fetchJSON(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function gitStage(repo, paths) {
|
||||
return postGit("/git/stage", { repo, paths });
|
||||
}
|
||||
|
||||
export function gitUnstage(repo, paths) {
|
||||
return postGit("/git/unstage", { repo, paths });
|
||||
}
|
||||
|
||||
export function gitDiscard(repo, paths, confirmation, deleteUntracked = false) {
|
||||
return postGit("/git/discard", {
|
||||
repo,
|
||||
paths,
|
||||
confirmation,
|
||||
delete_untracked: deleteUntracked,
|
||||
});
|
||||
}
|
||||
|
||||
export function gitCommit(repo, message) {
|
||||
return postGit("/git/commit", { repo, message });
|
||||
}
|
||||
|
||||
export function gitFetch(repo, remote = "origin") {
|
||||
return postGit("/git/fetch", { repo, remote });
|
||||
}
|
||||
|
||||
export function gitPull(repo, remote = "origin", branch = "") {
|
||||
return postGit("/git/pull", { repo, remote, branch });
|
||||
}
|
||||
|
||||
export function gitPush(repo, confirmation, remote = "origin", branch = "") {
|
||||
return postGit("/git/push", { repo, remote, branch, confirmation });
|
||||
}
|
||||
|
||||
export function gitCheckout(repo, ref, opts = {}) {
|
||||
const body = {
|
||||
repo,
|
||||
ref,
|
||||
confirmation: opts.confirmation,
|
||||
new_branch: opts.newBranch || "",
|
||||
track: !!opts.track,
|
||||
};
|
||||
return postGit("/git/checkout", body);
|
||||
}
|
||||
|
||||
// ── Git State Phase 3 extras ───────────────────────────────────────────────
|
||||
// AI commit-message suggestions + auto-stashing checkout.
|
||||
|
||||
export function gitCommitMessage(repo) {
|
||||
return postGit("/git/commit_message", { repo });
|
||||
}
|
||||
|
||||
export function gitCommitMessageSelected(repo, paths) {
|
||||
return postGit("/git/commit_message_selected", { repo, paths });
|
||||
}
|
||||
|
||||
export function gitStashCheckout(repo, ref, opts = {}) {
|
||||
const body = {
|
||||
repo,
|
||||
ref,
|
||||
new_branch: opts.newBranch || "",
|
||||
track: !!opts.track,
|
||||
};
|
||||
return postGit("/git/stash_checkout", body);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Pure data-mapping helpers for the Git State dashboard tab.
|
||||
// Kept free of React/DOM so they are unit-testable with node:test.
|
||||
|
||||
/**
|
||||
* Normalize a /git/repos response into a stable repo list.
|
||||
* Accepts {repos:[...]} or a bare array.
|
||||
*/
|
||||
export function normalizeRepos(data) {
|
||||
const list = Array.isArray(data) ? data : (data && data.repos) || [];
|
||||
return list.filter((r) => r && typeof r.id === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a /git/status response into {counts, staged, modified, untracked,
|
||||
* truncated}. Missing groups become empty arrays.
|
||||
*/
|
||||
export function normalizeStatus(data) {
|
||||
const src = data && typeof data === "object" ? data : {};
|
||||
return {
|
||||
counts: {
|
||||
staged: Number(src.counts && src.counts.staged) || 0,
|
||||
modified: Number(src.counts && src.counts.modified) || 0,
|
||||
untracked: Number(src.counts && src.counts.untracked) || 0,
|
||||
},
|
||||
staged: Array.isArray(src.staged) ? src.staged : [],
|
||||
modified: Array.isArray(src.modified) ? src.modified : [],
|
||||
untracked: Array.isArray(src.untracked) ? src.untracked : [],
|
||||
truncated: !!src.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a /git/branches response into a branch list.
|
||||
* Accepts {branches:[...]} or a bare array.
|
||||
*/
|
||||
export function normalizeBranches(data) {
|
||||
const list = Array.isArray(data) ? data : (data && data.branches) || [];
|
||||
return list.filter((b) => b && typeof b.name === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a human-readable branch label, e.g. "main → origin/main (ahead 1)".
|
||||
*/
|
||||
export function branchLabel(branch) {
|
||||
if (!branch) return "";
|
||||
const base = branch.name || "";
|
||||
if (!branch.upstream) return base;
|
||||
const track =
|
||||
branch.ahead > 0 || branch.behind > 0
|
||||
? ` (ahead ${branch.ahead}, behind ${branch.behind})`
|
||||
: "";
|
||||
return `${base} → ${branch.upstream}${track}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a status response is truncated (over the server cap).
|
||||
*/
|
||||
export function isTruncated(status) {
|
||||
return !!(status && status.truncated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed per-use confirmation tokens for destructive git mutations. These
|
||||
* mirror the plugin's server-side constants (plugin/git_state.py) and are
|
||||
* sent in the POST body so the server can enforce the destructive gate.
|
||||
* The dashboard tab shows a human-readable description before echoing these.
|
||||
*/
|
||||
export const CONFIRMATIONS = {
|
||||
discard: "discard",
|
||||
push: "push",
|
||||
dirtyCheckout: "checkout-dirty",
|
||||
};
|
||||
|
||||
/** Ops that require a per-use confirmation string before the POST is sent. */
|
||||
const DESTRUCTIVE_OPS = new Set(["discard", "push", "dirty-checkout"]);
|
||||
|
||||
/**
|
||||
* True when the named mutation requires a per-use confirmation string.
|
||||
* The tab must not send the POST without it (matches the server gate).
|
||||
*/
|
||||
export function requiresConfirmation(op) {
|
||||
return DESTRUCTIVE_OPS.has(op);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the fixed confirmation token for a destructive op, or null when the
|
||||
* op is non-destructive (no confirmation needed).
|
||||
*/
|
||||
export function confirmationFor(op) {
|
||||
if (!requiresConfirmation(op)) return null;
|
||||
if (op === "discard") return CONFIRMATIONS.discard;
|
||||
if (op === "push") return CONFIRMATIONS.push;
|
||||
return CONFIRMATIONS.dirtyCheckout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a mutation response ({head, status, branches}) into a stable
|
||||
* shape, filling missing groups so the tab can render without defensive
|
||||
* branching.
|
||||
*/
|
||||
export function normalizeMutationResult(data) {
|
||||
const src = data && typeof data === "object" ? data : {};
|
||||
return {
|
||||
head: typeof src.head === "string" ? src.head : "",
|
||||
status: normalizeStatus(src.status),
|
||||
branches: normalizeBranches(src.branches),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a /git/commit_message response into {message, notice, stashed}.
|
||||
* Missing fields degrade safely so the tab can render without branching.
|
||||
*/
|
||||
export function normalizeCommitMessage(data) {
|
||||
const src = data && typeof data === "object" ? data : {};
|
||||
return {
|
||||
message: typeof src.message === "string" ? src.message : "",
|
||||
notice: typeof src.notice === "string" ? src.notice : "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a commit-message suggestion is usable (non-empty message).
|
||||
*/
|
||||
export function hasCommitSuggestion(result) {
|
||||
return !!(result && result.message && result.message.trim());
|
||||
}
|
||||
|
||||
export function isCurrentRepoRequest(currentRepo, currentGeneration, repo, generation) {
|
||||
return currentRepo === repo && currentGeneration === generation;
|
||||
}
|
||||
|
||||
export function shouldOfferPushAfterCommit(commitSucceeded, pushAfterCommit) {
|
||||
return commitSucceeded === true && pushAfterCommit === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a /git/stash_checkout response: the standard mutation shape plus
|
||||
* {stashed, stash_message}.
|
||||
*/
|
||||
export function normalizeStashCheckout(data) {
|
||||
const src = data && typeof data === "object" ? data : {};
|
||||
return {
|
||||
...normalizeMutationResult(data),
|
||||
stashed: !!src.stashed,
|
||||
stashMessage: typeof src.stash_message === "string" ? src.stash_message : "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
const SDK = window.__HERMES_PLUGIN_SDK__;
|
||||
const { React } = SDK;
|
||||
const { useState, useEffect, useCallback, useRef } = SDK.hooks;
|
||||
|
||||
import {
|
||||
getGitRepos,
|
||||
getGitStatus,
|
||||
getGitBranches,
|
||||
getGitDiff,
|
||||
getGitFile,
|
||||
gitStage,
|
||||
gitUnstage,
|
||||
gitDiscard,
|
||||
gitCommit,
|
||||
gitCommitMessage,
|
||||
gitCommitMessageSelected,
|
||||
gitStashCheckout,
|
||||
gitFetch,
|
||||
gitPull,
|
||||
gitPush,
|
||||
gitCheckout,
|
||||
} from "../lib/api.js";
|
||||
import {
|
||||
normalizeMutationResult,
|
||||
normalizeCommitMessage,
|
||||
normalizeStashCheckout,
|
||||
hasCommitSuggestion,
|
||||
requiresConfirmation,
|
||||
confirmationFor,
|
||||
isCurrentRepoRequest,
|
||||
shouldOfferPushAfterCommit,
|
||||
} from "../lib/git-state.mjs";
|
||||
import {
|
||||
Alert,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
CardDescription,
|
||||
Button,
|
||||
Badge,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from "../lib/ui-shims.jsx";
|
||||
|
||||
const {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Label,
|
||||
} = SDK.components;
|
||||
|
||||
function TruncationNotice({ truncated }) {
|
||||
if (!truncated) return null;
|
||||
return (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-600">
|
||||
Results truncated — showing the first entries only.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusRow({ status }) {
|
||||
if (!status) return null;
|
||||
const counts = status.counts || {};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{counts.staged || 0} staged
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{counts.modified || 0} modified
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{counts.untracked || 0} untracked
|
||||
</Badge>
|
||||
</div>
|
||||
<TruncationNotice truncated={status.truncated} />
|
||||
{["staged", "modified", "untracked"].map((group) => {
|
||||
const items = status[group] || [];
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={group}>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{items.map((item) => (
|
||||
<li key={`${group}-${item.path}`} className="font-mono text-xs">
|
||||
{item.path}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BranchesRow({ branches }) {
|
||||
if (!branches || branches.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{branches.map((b) => (
|
||||
<div key={b.name} className="flex items-center gap-2 text-xs">
|
||||
<span className="font-mono">{b.name}</span>
|
||||
{b.is_current ? <Badge className="text-xs">current</Badge> : null}
|
||||
{b.upstream ? (
|
||||
<span className="text-muted-foreground">
|
||||
→ {b.upstream}
|
||||
{b.ahead > 0 || b.behind > 0
|
||||
? ` (ahead ${b.ahead}, behind ${b.behind})`
|
||||
: ""}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write controls for the authenticated Dashboard Git tab. Destructive ops
|
||||
* (discard/push/dirty-checkout) are confirmed via the per-use confirmation
|
||||
* mechanics before the POST is sent.
|
||||
*/
|
||||
function WriteControls({
|
||||
status,
|
||||
selected,
|
||||
commitMessage,
|
||||
onCommitMessageChange,
|
||||
generatingMessage,
|
||||
onGenerateMessage,
|
||||
commitNotice,
|
||||
newBranch,
|
||||
onNewBranchChange,
|
||||
branchRef,
|
||||
onBranchRefChange,
|
||||
mutating,
|
||||
pushAfterCommit,
|
||||
onPushAfterCommitChange,
|
||||
onStageAll,
|
||||
onUnstageAll,
|
||||
onDiscardAll,
|
||||
onStage,
|
||||
onUnstage,
|
||||
onDiscard,
|
||||
onCommit,
|
||||
onFetch,
|
||||
onPull,
|
||||
onPush,
|
||||
onCheckout,
|
||||
onStashCheckout,
|
||||
}) {
|
||||
const staged = (status && status.staged || []).map((e) => e.path);
|
||||
const modified = (status && status.modified || []).map((e) => e.path);
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Write controls
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={mutating || modified.length === 0} onClick={onStageAll}>
|
||||
Stage modified
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={mutating || staged.length === 0} onClick={onUnstageAll}>
|
||||
Unstage staged
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={mutating || staged.length === 0} onClick={onDiscardAll}>
|
||||
Discard staged
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={commitMessage}
|
||||
onChange={(e) => onCommitMessageChange(e.target.value)}
|
||||
placeholder="Commit message"
|
||||
className="w-full rounded-md border px-2 py-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={mutating || generatingMessage || staged.length === 0}
|
||||
onClick={onGenerateMessage}
|
||||
title="Generate a commit message from the staged diff"
|
||||
>
|
||||
{generatingMessage ? "Generating…" : "Generate"}
|
||||
</Button>
|
||||
</div>
|
||||
{commitNotice ? (
|
||||
<div className="text-xs text-amber-600">{commitNotice}</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pushAfterCommit}
|
||||
onChange={(e) => onPushAfterCommitChange(e.target.checked)}
|
||||
/>
|
||||
Push after commit
|
||||
</label>
|
||||
<Button size="sm" disabled={mutating || !commitMessage.trim()} onClick={onCommit}>
|
||||
Commit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={branchRef}
|
||||
onChange={(e) => onBranchRefChange(e.target.value)}
|
||||
placeholder="Branch to switch to"
|
||||
className="w-40 rounded-md border px-2 py-1 text-xs"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newBranch}
|
||||
onChange={(e) => onNewBranchChange(e.target.value)}
|
||||
placeholder="New branch name"
|
||||
className="w-40 rounded-md border px-2 py-1 text-xs"
|
||||
/>
|
||||
<Button size="sm" variant="outline" disabled={mutating || !branchRef.trim()} onClick={onCheckout}>
|
||||
Checkout
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={mutating || !branchRef.trim()}
|
||||
onClick={onStashCheckout}
|
||||
title="Switch branches, auto-stashing a dirty tree first"
|
||||
>
|
||||
Stash-checkout
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={mutating} onClick={onFetch}>
|
||||
Fetch
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={mutating} onClick={onPull}>
|
||||
Pull
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" disabled={mutating} onClick={onPush}>
|
||||
Push
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GitState({ autoRefresh }) {
|
||||
const [repos, setRepos] = useState(null);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [branches, setBranches] = useState(null);
|
||||
const [diff, setDiff] = useState(null);
|
||||
const [file, setFile] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [notice, setNotice] = useState(null);
|
||||
const selectedRef = useRef(null);
|
||||
const requestGenerationRef = useRef(0);
|
||||
const mutationActiveRef = useRef(false);
|
||||
|
||||
const loadRepos = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getGitRepos();
|
||||
const list = (data && data.repos) || [];
|
||||
setRepos(list);
|
||||
setNotice((data && data.notice) || null);
|
||||
const currentSelected = selectedRef.current;
|
||||
if (currentSelected && !list.some((r) => r.id === currentSelected)) {
|
||||
selectedRef.current = null;
|
||||
requestGenerationRef.current += 1;
|
||||
setSelected(null);
|
||||
setStatus(null);
|
||||
setBranches(null);
|
||||
setDiff(null);
|
||||
setFile(null);
|
||||
setGeneratingMessage(false);
|
||||
setCommitNotice(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err && err.message ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadRepos();
|
||||
}, [loadRepos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return undefined;
|
||||
const id = setInterval(loadRepos, 15000);
|
||||
return () => clearInterval(id);
|
||||
}, [autoRefresh, loadRepos]);
|
||||
|
||||
const selectRepo = useCallback(async (repoId) => {
|
||||
const generation = requestGenerationRef.current + 1;
|
||||
requestGenerationRef.current = generation;
|
||||
selectedRef.current = repoId;
|
||||
setSelected(repoId);
|
||||
setStatus(null);
|
||||
setBranches(null);
|
||||
setDiff(null);
|
||||
setFile(null);
|
||||
setGeneratingMessage(false);
|
||||
setCommitNotice(null);
|
||||
setError(null);
|
||||
try {
|
||||
const [st, br] = await Promise.all([
|
||||
getGitStatus(repoId),
|
||||
getGitBranches(repoId),
|
||||
]);
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setStatus(st);
|
||||
setBranches(br && br.branches);
|
||||
} catch (err) {
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setError(err && err.message ? err.message : String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showDiff = useCallback(async (path, kind) => {
|
||||
const repoId = selectedRef.current;
|
||||
if (!repoId) return;
|
||||
const generation = requestGenerationRef.current;
|
||||
setFile(null);
|
||||
setError(null);
|
||||
try {
|
||||
const nextDiff = await getGitDiff(repoId, path, kind);
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setDiff(nextDiff);
|
||||
} catch (err) {
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setError(err && err.message ? err.message : String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showFile = useCallback(async (path) => {
|
||||
const repoId = selectedRef.current;
|
||||
if (!repoId) return;
|
||||
const generation = requestGenerationRef.current;
|
||||
setDiff(null);
|
||||
setError(null);
|
||||
try {
|
||||
const nextFile = await getGitFile(repoId, path);
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setFile(nextFile);
|
||||
} catch (err) {
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setError(err && err.message ? err.message : String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Authenticated Dashboard write controls + confirmations ──────────────
|
||||
const [commitMessage, setCommitMessage] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
const [branchRef, setBranchRef] = useState("");
|
||||
const [mutating, setMutating] = useState(false);
|
||||
const [mutationError, setMutationError] = useState(null);
|
||||
const [generatingMessage, setGeneratingMessage] = useState(false);
|
||||
const [commitNotice, setCommitNotice] = useState(null);
|
||||
const [pushAfterCommit, setPushAfterCommit] = useState(false);
|
||||
|
||||
const refreshDetail = useCallback(async (repoId, generation) => {
|
||||
const [st, br] = await Promise.all([
|
||||
getGitStatus(repoId),
|
||||
getGitBranches(repoId),
|
||||
]);
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setStatus(st);
|
||||
setBranches(br && br.branches);
|
||||
}, []);
|
||||
|
||||
const applyMutation = useCallback(
|
||||
async (op, paths, opts) => {
|
||||
const repoId = selectedRef.current;
|
||||
const generation = requestGenerationRef.current;
|
||||
if (!repoId || mutationActiveRef.current) return false;
|
||||
mutationActiveRef.current = true;
|
||||
setMutationError(null);
|
||||
setMutating(true);
|
||||
try {
|
||||
if (op === "stage") await gitStage(repoId, paths);
|
||||
else if (op === "unstage") await gitUnstage(repoId, paths);
|
||||
else if (op === "fetch") await gitFetch(repoId, opts?.remote || "origin");
|
||||
else if (op === "pull") await gitPull(repoId, opts?.remote || "origin", opts?.branch || "");
|
||||
else if (op === "commit") await gitCommit(repoId, opts?.message);
|
||||
else if (op === "discard") await gitDiscard(repoId, paths, opts?.confirmation, opts?.deleteUntracked);
|
||||
else if (op === "push") await gitPush(repoId, opts?.confirmation, opts?.remote || "origin", opts?.branch || "");
|
||||
else if (op === "checkout" || op === "dirty-checkout") {
|
||||
await gitCheckout(repoId, opts.ref, {
|
||||
confirmation: opts.confirmation,
|
||||
newBranch: opts.newBranch,
|
||||
track: opts.track,
|
||||
});
|
||||
} else throw new Error(`Unknown Git operation: ${op}`);
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) {
|
||||
return false;
|
||||
}
|
||||
await refreshDetail(repoId, generation);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) {
|
||||
setMutationError(err && err.message ? err.message : String(err));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
mutationActiveRef.current = false;
|
||||
setMutating(false);
|
||||
}
|
||||
},
|
||||
[refreshDetail],
|
||||
);
|
||||
|
||||
/**
|
||||
* Generate a commit-message suggestion from the staged diff (AI). Empty
|
||||
* staged diff / model-unavailable degrade to a notice, never an error.
|
||||
*/
|
||||
const generateMessage = useCallback(async () => {
|
||||
const repoId = selectedRef.current;
|
||||
if (!repoId) return;
|
||||
const generation = requestGenerationRef.current;
|
||||
setGeneratingMessage(true);
|
||||
setCommitNotice(null);
|
||||
try {
|
||||
const stagedPaths = (status && status.staged || []).map((e) => e.path);
|
||||
const data = stagedPaths.length > 0 && !status?.truncated
|
||||
? await gitCommitMessageSelected(repoId, stagedPaths)
|
||||
: await gitCommitMessage(repoId);
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
const result = normalizeCommitMessage(data);
|
||||
if (hasCommitSuggestion(result)) {
|
||||
setCommitMessage(result.message);
|
||||
setCommitNotice(result.notice || null);
|
||||
} else {
|
||||
setCommitNotice(result.notice || "Nothing staged to generate a message from.");
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setCommitNotice(err && err.message ? err.message : String(err));
|
||||
} finally {
|
||||
if (isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) {
|
||||
setGeneratingMessage(false);
|
||||
}
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
/**
|
||||
* Stash-checkout: switch branches, auto-stashing a dirty tree first. No
|
||||
* confirmation is needed because a stash is recoverable (git stash pop).
|
||||
* On success, surface the stash message so the user can pop it later.
|
||||
*/
|
||||
const doStashCheckout = useCallback(async () => {
|
||||
const ref = branchRef.trim();
|
||||
const repoId = selectedRef.current;
|
||||
const generation = requestGenerationRef.current;
|
||||
if (!ref || !repoId || mutationActiveRef.current) return;
|
||||
mutationActiveRef.current = true;
|
||||
setMutationError(null);
|
||||
setCommitNotice(null);
|
||||
setMutating(true);
|
||||
try {
|
||||
const data = await gitStashCheckout(repoId, ref, {
|
||||
newBranch: newBranch.trim(),
|
||||
track: false,
|
||||
});
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
const result = normalizeStashCheckout(data);
|
||||
if (result.stashed) {
|
||||
setCommitNotice(
|
||||
`Stashed changes on ${ref} as “${result.stashMessage}”. Use “git stash pop” to restore them.`,
|
||||
);
|
||||
}
|
||||
await refreshDetail(repoId, generation);
|
||||
} catch (err) {
|
||||
if (isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) {
|
||||
setMutationError(err && err.message ? err.message : String(err));
|
||||
}
|
||||
} finally {
|
||||
mutationActiveRef.current = false;
|
||||
setMutating(false);
|
||||
setBranchRef("");
|
||||
setNewBranch("");
|
||||
}
|
||||
}, [branchRef, newBranch, refreshDetail]);
|
||||
|
||||
/**
|
||||
* Destructive ops (discard, push, dirty-checkout) gate on a per-use
|
||||
* confirmation echoed back to the server. Matches the dashboard's existing
|
||||
* confirm-before-destructive pattern (see RelayManagement onRevoke) and the
|
||||
* plugin's confirmation-string mechanics.
|
||||
*/
|
||||
const requestMutation = useCallback((op, opts) => {
|
||||
if (requiresConfirmation(op)) {
|
||||
const description =
|
||||
op === "discard"
|
||||
? "Discard local changes? This cannot be undone."
|
||||
: op === "push"
|
||||
? "Push local commits to the remote repository?"
|
||||
: "Working tree has uncommitted changes. Switch branches anyway?";
|
||||
if (!window.confirm(description)) return;
|
||||
const token = confirmationFor(op);
|
||||
if (op === "discard") applyMutation("discard", opts?.paths, { confirmation: token, deleteUntracked: opts?.deleteUntracked });
|
||||
else if (op === "push") applyMutation("push", [], { confirmation: token, remote: opts?.remote, branch: opts?.branch });
|
||||
else if (op === "dirty-checkout") applyMutation("dirty-checkout", [], { ...opts, confirmation: token });
|
||||
return;
|
||||
}
|
||||
applyMutation(op, opts?.paths, opts);
|
||||
}, [applyMutation]);
|
||||
|
||||
const doCommit = useCallback(async () => {
|
||||
const message = commitMessage.trim();
|
||||
if (!message) {
|
||||
setMutationError("Commit message must not be empty.");
|
||||
return;
|
||||
}
|
||||
const succeeded = await applyMutation("commit", [], { message });
|
||||
if (!succeeded) return;
|
||||
setCommitMessage("");
|
||||
// Push-after-commit: when the toggle is ON, immediately start the existing
|
||||
// push confirmation flow. Confirmation is still required (never bypassed);
|
||||
// the toggle only auto-starts it after a successful commit.
|
||||
if (shouldOfferPushAfterCommit(succeeded, pushAfterCommit)) {
|
||||
requestMutation("push", {});
|
||||
}
|
||||
}, [commitMessage, status, applyMutation, pushAfterCommit, requestMutation]);
|
||||
|
||||
const doCheckout = useCallback(async () => {
|
||||
const ref = branchRef.trim();
|
||||
if (!ref) return;
|
||||
const dirty = status && (status.counts.modified + status.counts.staged + status.counts.untracked) > 0;
|
||||
const opts = { ref, newBranch: newBranch.trim(), track: false };
|
||||
if (dirty && !opts.newBranch) {
|
||||
requestMutation("dirty-checkout", opts);
|
||||
setBranchRef("");
|
||||
setNewBranch("");
|
||||
return;
|
||||
}
|
||||
await applyMutation("checkout", [], opts);
|
||||
setBranchRef("");
|
||||
setNewBranch("");
|
||||
}, [branchRef, newBranch, status, applyMutation, requestMutation]);
|
||||
|
||||
if (loading && repos === null) {
|
||||
return <div className="text-sm text-muted-foreground">Loading repositories…</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Git state unavailable</AlertTitle>
|
||||
<AlertDescription>
|
||||
<pre className="whitespace-pre-wrap text-xs">{error}</pre>
|
||||
<Button className="mt-2" size="sm" variant="outline" onClick={loadRepos}>
|
||||
Retry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const list = repos || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Repositories</CardTitle>
|
||||
<CardDescription>
|
||||
Repositories scanned from the configured Git base path.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{notice ? (
|
||||
<div className="mb-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-600">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
{list.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No repositories found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{list.map((repo) => (
|
||||
<Button
|
||||
key={repo.id}
|
||||
size="sm"
|
||||
variant={selected === repo.id ? "default" : "outline"}
|
||||
onClick={() => selectRepo(repo.id)}
|
||||
>
|
||||
{repo.name}
|
||||
{repo.dirty ? " •" : ""}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selected ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{selected}</CardTitle>
|
||||
<CardDescription>
|
||||
Working tree and branches. Tap a changed file to view its diff or
|
||||
content.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{mutationError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Git mutation failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
<pre className="whitespace-pre-wrap text-xs">{mutationError}</pre>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<StatusRow status={status} />
|
||||
<BranchesRow branches={branches} />
|
||||
<WriteControls
|
||||
status={status}
|
||||
branches={branches}
|
||||
selected={selected}
|
||||
commitMessage={commitMessage}
|
||||
onCommitMessageChange={setCommitMessage}
|
||||
generatingMessage={generatingMessage}
|
||||
onGenerateMessage={generateMessage}
|
||||
commitNotice={commitNotice}
|
||||
newBranch={newBranch}
|
||||
onNewBranchChange={setNewBranch}
|
||||
branchRef={branchRef}
|
||||
onBranchRefChange={setBranchRef}
|
||||
mutating={mutating}
|
||||
pushAfterCommit={pushAfterCommit}
|
||||
onPushAfterCommitChange={setPushAfterCommit}
|
||||
onStageAll={() => requestMutation("stage", { paths: (status && status.modified || []).map((e) => e.path) })}
|
||||
onUnstageAll={() => requestMutation("unstage", { paths: (status && status.staged || []).map((e) => e.path) })}
|
||||
onDiscardAll={() => requestMutation("discard", { paths: (status && status.staged || []).map((e) => e.path), deleteUntracked: false })}
|
||||
onStage={(path) => requestMutation("stage", { paths: [path] })}
|
||||
onUnstage={(path) => requestMutation("unstage", { paths: [path] })}
|
||||
onDiscard={(path) => requestMutation("discard", { paths: [path], deleteUntracked: false })}
|
||||
onCommit={doCommit}
|
||||
onFetch={() => requestMutation("fetch", {})}
|
||||
onPull={() => requestMutation("pull", {})}
|
||||
onPush={() => requestMutation("push", {})}
|
||||
onCheckout={doCheckout}
|
||||
onStashCheckout={doStashCheckout}
|
||||
/>
|
||||
|
||||
{status && (status.staged || []).length > 0 ? (
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Staged diffs
|
||||
</div>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{(status.staged || []).map((item) => (
|
||||
<li key={`sd-${item.path}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="font-mono text-xs text-primary underline"
|
||||
onClick={() => showDiff(item.path, "staged")}
|
||||
>
|
||||
{item.path}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status && (status.modified || []).length > 0 ? (
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Unstaged diffs
|
||||
</div>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{(status.modified || []).map((item) => (
|
||||
<li key={`ud-${item.path}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="font-mono text-xs text-primary underline"
|
||||
onClick={() => showDiff(item.path, "unstaged")}
|
||||
>
|
||||
{item.path}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status && (status.untracked || []).length > 0 ? (
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Untracked files
|
||||
</div>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{(status.untracked || []).map((item) => (
|
||||
<li key={`uf-${item.path}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="font-mono text-xs text-primary underline"
|
||||
onClick={() => showFile(item.path)}
|
||||
>
|
||||
{item.path}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{diff ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Diff — {diff.path} ({diff.kind})
|
||||
</div>
|
||||
<TruncationNotice truncated={diff.truncated} />
|
||||
</div>
|
||||
<pre className="mt-1 max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-2 font-mono text-xs">
|
||||
{diff.diff || "(no changes)"}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{file ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
File — {file.path}
|
||||
</div>
|
||||
<TruncationNotice truncated={file.truncated} />
|
||||
</div>
|
||||
<pre className="mt-1 max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-2 font-mono text-xs">
|
||||
{file.content}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
normalizeMutationResult,
|
||||
confirmationFor,
|
||||
requiresConfirmation,
|
||||
CONFIRMATIONS,
|
||||
isCurrentRepoRequest,
|
||||
shouldOfferPushAfterCommit,
|
||||
} from "../src/lib/git-state.mjs";
|
||||
|
||||
test("normalizeMutationResult maps head/status/branches safely", () => {
|
||||
const r = normalizeMutationResult({
|
||||
head: "abc123",
|
||||
status: { counts: { staged: 1 } },
|
||||
branches: [{ name: "main" }],
|
||||
});
|
||||
assert.equal(r.head, "abc123");
|
||||
assert.equal(r.status.counts.staged, 1);
|
||||
assert.equal(r.branches.length, 1);
|
||||
});
|
||||
|
||||
test("normalizeMutationResult tolerates missing fields", () => {
|
||||
const r = normalizeMutationResult(null);
|
||||
assert.equal(r.head, "");
|
||||
assert.deepEqual(r.status.counts, { staged: 0, modified: 0, untracked: 0 });
|
||||
assert.deepEqual(r.branches, []);
|
||||
});
|
||||
|
||||
test("CONFIRMATIONS holds the fixed confirmation tokens", () => {
|
||||
assert.equal(CONFIRMATIONS.discard, "discard");
|
||||
assert.equal(CONFIRMATIONS.push, "push");
|
||||
assert.equal(CONFIRMATIONS.dirtyCheckout, "checkout-dirty");
|
||||
});
|
||||
|
||||
test("confirmationFor returns the token only for destructive ops", () => {
|
||||
assert.equal(confirmationFor("discard"), CONFIRMATIONS.discard);
|
||||
assert.equal(confirmationFor("push"), CONFIRMATIONS.push);
|
||||
assert.equal(confirmationFor("dirty-checkout"), CONFIRMATIONS.dirtyCheckout);
|
||||
assert.equal(confirmationFor("commit"), null);
|
||||
assert.equal(confirmationFor("stage"), null);
|
||||
});
|
||||
|
||||
test("requiresConfirmation gates only destructive ops", () => {
|
||||
assert.equal(requiresConfirmation("discard"), true);
|
||||
assert.equal(requiresConfirmation("push"), true);
|
||||
assert.equal(requiresConfirmation("dirty-checkout"), true);
|
||||
assert.equal(requiresConfirmation("stage"), false);
|
||||
assert.equal(requiresConfirmation("commit"), false);
|
||||
assert.equal(requiresConfirmation("fetch"), false);
|
||||
});
|
||||
|
||||
test("repository request ownership rejects stale repo or generation", () => {
|
||||
assert.equal(isCurrentRepoRequest("a", 2, "a", 2), true);
|
||||
assert.equal(isCurrentRepoRequest("b", 2, "a", 2), false);
|
||||
assert.equal(isCurrentRepoRequest("a", 3, "a", 2), false);
|
||||
});
|
||||
|
||||
test("push-after-commit requires the exact commit to succeed", () => {
|
||||
assert.equal(shouldOfferPushAfterCommit(true, true), true);
|
||||
assert.equal(shouldOfferPushAfterCommit(false, true), false);
|
||||
assert.equal(shouldOfferPushAfterCommit(true, false), false);
|
||||
});
|
||||
|
||||
test("GitState commits the complete index and dispatches clean checkout", () => {
|
||||
const source = readFileSync(new URL("../src/tabs/GitState.jsx", import.meta.url), "utf8");
|
||||
assert.match(source, /applyMutation\("commit", \[\], \{ message \}\)/);
|
||||
assert.doesNotMatch(source, /applyMutation\("commitSelected"/);
|
||||
assert.match(source, /op === "checkout" \|\| op === "dirty-checkout"/);
|
||||
});
|
||||
|
||||
test("GitState bulk actions dispatch one bounded path array", () => {
|
||||
const source = readFileSync(new URL("../src/tabs/GitState.jsx", import.meta.url), "utf8");
|
||||
assert.match(source, /onClick=\{onStageAll\}/);
|
||||
assert.match(source, /onClick=\{onUnstageAll\}/);
|
||||
assert.match(source, /onClick=\{onDiscardAll\}/);
|
||||
assert.doesNotMatch(source, /forEach\(onStage\)|forEach\(onUnstage\)|forEach\(onDiscard\)/);
|
||||
});
|
||||
|
||||
// ── Phase 3 extras ─────────────────────────────────────────────────────────
|
||||
|
||||
import {
|
||||
normalizeCommitMessage,
|
||||
hasCommitSuggestion,
|
||||
normalizeStashCheckout,
|
||||
} from "../src/lib/git-state.mjs";
|
||||
|
||||
test("normalizeCommitMessage maps message/notice safely", () => {
|
||||
const r = normalizeCommitMessage({ message: "feat: add x", notice: "" });
|
||||
assert.equal(r.message, "feat: add x");
|
||||
assert.equal(r.notice, "");
|
||||
});
|
||||
|
||||
test("normalizeCommitMessage tolerates missing/empty fields", () => {
|
||||
assert.deepEqual(normalizeCommitMessage(null), { message: "", notice: "" });
|
||||
assert.deepEqual(normalizeCommitMessage({}), { message: "", notice: "" });
|
||||
});
|
||||
|
||||
test("hasCommitSuggestion is false for empty/whitespace messages", () => {
|
||||
assert.equal(hasCommitSuggestion({ message: "feat: add x" }), true);
|
||||
assert.equal(hasCommitSuggestion({ message: "" }), false);
|
||||
assert.equal(hasCommitSuggestion({ message: " " }), false);
|
||||
assert.equal(hasCommitSuggestion(null), false);
|
||||
});
|
||||
|
||||
test("normalizeStashCheckout carries stashed + stash_message", () => {
|
||||
const r = normalizeStashCheckout({
|
||||
head: "abc",
|
||||
stashed: true,
|
||||
stash_message: "git-state: feature",
|
||||
status: { counts: { staged: 0 } },
|
||||
branches: [],
|
||||
});
|
||||
assert.equal(r.head, "abc");
|
||||
assert.equal(r.stashed, true);
|
||||
assert.equal(r.stashMessage, "git-state: feature");
|
||||
assert.equal(r.status.counts.staged, 0);
|
||||
});
|
||||
|
||||
test("normalizeStashCheckout defaults stashed false when absent", () => {
|
||||
const r = normalizeStashCheckout({});
|
||||
assert.equal(r.stashed, false);
|
||||
assert.equal(r.stashMessage, "");
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
normalizeRepos,
|
||||
normalizeStatus,
|
||||
normalizeBranches,
|
||||
branchLabel,
|
||||
isTruncated,
|
||||
} from "../src/lib/git-state.mjs";
|
||||
|
||||
test("normalizeRepos accepts object and bare-array shapes", () => {
|
||||
assert.deepEqual(
|
||||
normalizeRepos({ repos: [{ id: "a", name: "A" }] }),
|
||||
[{ id: "a", name: "A" }],
|
||||
);
|
||||
assert.deepEqual(normalizeRepos([{ id: "b", name: "B" }]), [
|
||||
{ id: "b", name: "B" },
|
||||
]);
|
||||
assert.deepEqual(normalizeRepos(null), []);
|
||||
assert.deepEqual(normalizeRepos({ repos: [{ id: 1 }, null] }), []);
|
||||
});
|
||||
|
||||
test("normalizeStatus fills missing groups and preserves truncation", () => {
|
||||
const status = normalizeStatus({
|
||||
counts: { staged: 1, modified: 2, untracked: 3 },
|
||||
staged: [{ path: "a" }],
|
||||
truncated: true,
|
||||
});
|
||||
assert.equal(status.counts.staged, 1);
|
||||
assert.equal(status.counts.modified, 2);
|
||||
assert.equal(status.counts.untracked, 3);
|
||||
assert.deepEqual(status.staged, [{ path: "a" }]);
|
||||
assert.deepEqual(status.modified, []);
|
||||
assert.deepEqual(status.untracked, []);
|
||||
assert.equal(status.truncated, true);
|
||||
assert.equal(isTruncated(status), true);
|
||||
assert.equal(isTruncated(normalizeStatus({})), false);
|
||||
});
|
||||
|
||||
test("normalizeBranches accepts object and bare-array shapes", () => {
|
||||
assert.deepEqual(normalizeBranches({ branches: [{ name: "main" }] }), [
|
||||
{ name: "main" },
|
||||
]);
|
||||
assert.deepEqual(normalizeBranches([{ name: "dev" }]), [{ name: "dev" }]);
|
||||
assert.deepEqual(normalizeBranches(null), []);
|
||||
});
|
||||
|
||||
test("branchLabel renders upstream and ahead/behind", () => {
|
||||
assert.equal(branchLabel({ name: "main" }), "main");
|
||||
assert.equal(
|
||||
branchLabel({ name: "main", upstream: "origin/main" }),
|
||||
"main → origin/main",
|
||||
);
|
||||
assert.equal(
|
||||
branchLabel({ name: "feature", upstream: "origin/feature", ahead: 1, behind: 2 }),
|
||||
"feature → origin/feature (ahead 1, behind 2)",
|
||||
);
|
||||
assert.equal(branchLabel(null), "");
|
||||
});
|
||||
@@ -0,0 +1,559 @@
|
||||
"""Tests for the write (POST) Git State endpoints in plugin/dashboard/git_api.py.
|
||||
|
||||
Write endpoints are POST and rely on the plugin's ``plugin.api.write`` grant
|
||||
which the app enforces client-side (see PluginsViewModel.invokeAction — the
|
||||
precedent gate). Server-side, destructive operations are additionally enforced
|
||||
by a required confirmation string; missing/wrong confirmation maps to 403 and
|
||||
dirty/conflict trees map to 409, so the UI can render a readable message.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from plugin.dashboard import git_api
|
||||
from plugin import git_state
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path) -> str:
|
||||
return subprocess.run(
|
||||
cmd, cwd=cwd, capture_output=True, text=True, check=True
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
return _run(["git", "-C", str(repo), *args], repo)
|
||||
|
||||
|
||||
def _init_repo(root: Path, name: str) -> Path:
|
||||
repo = root / name
|
||||
repo.mkdir(parents=True)
|
||||
_run(["git", "init", "-q", "-b", "main"], repo)
|
||||
_run(["git", "config", "user.email", "test@example.com"], repo)
|
||||
_run(["git", "config", "user.name", "Test User"], repo)
|
||||
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
|
||||
_git(repo, "add", "README.md")
|
||||
_git(repo, "commit", "-q", "-m", "initial commit")
|
||||
return repo
|
||||
|
||||
|
||||
def _init_bare_remote(root: Path, name: str) -> Path:
|
||||
remote = root / name
|
||||
remote.mkdir(parents=True, exist_ok=True)
|
||||
_run(["git", "init", "-q", "--bare", "-b", "main"], remote)
|
||||
return remote
|
||||
|
||||
|
||||
class GitWriteApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.base = Path(self.temp.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "alpha")
|
||||
self.env = patch.dict(
|
||||
os.environ,
|
||||
{"HERMES_HOME": self.temp.name, "GIT_STATE_BASE_PATH": str(self.base)},
|
||||
)
|
||||
self.env.start()
|
||||
self.addCleanup(self.env.stop)
|
||||
app = FastAPI()
|
||||
app.include_router(git_api.router)
|
||||
self.client = TestClient(app)
|
||||
|
||||
def _stage(self, path: str) -> None:
|
||||
(self.repo / path).write_text("x", encoding="utf-8")
|
||||
self.client.post("/git/stage", json={"repo": "alpha", "paths": [path]})
|
||||
|
||||
def test_stage_returns_fresh_status(self) -> None:
|
||||
(self.repo / "new.txt").write_text("x", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/stage", json={"repo": "alpha", "paths": ["new.txt"]}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertEqual(1, body["status"]["counts"]["staged"])
|
||||
|
||||
def test_unknown_repo_rejected(self) -> None:
|
||||
response = self.client.post(
|
||||
"/git/stage", json={"repo": "bogus", "paths": ["x"]}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
def test_remote_operations_reject_urls_options_and_wrong_types(self) -> None:
|
||||
for path, payload in (
|
||||
("/git/fetch", {"remote": "https://example.invalid/repo.git"}),
|
||||
("/git/fetch", {"remote": "--all"}),
|
||||
("/git/pull", {"remote": ["origin"], "branch": "main"}),
|
||||
("/git/push", {"remote": "origin", "branch": "--mirror", "confirmation": "push"}),
|
||||
):
|
||||
with self.subTest(path=path, payload=payload):
|
||||
response = self.client.post(path, json={"repo": "alpha", **payload})
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
def test_commit_creates_commit(self) -> None:
|
||||
self._stage("feature.txt")
|
||||
before = _git(self.repo, "rev-parse", "HEAD")
|
||||
response = self.client.post(
|
||||
"/git/commit", json={"repo": "alpha", "message": "add feature"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
after = _git(self.repo, "rev-parse", "HEAD")
|
||||
self.assertNotEqual(before, after)
|
||||
|
||||
def test_commit_empty_message_rejected(self) -> None:
|
||||
self._stage("feature.txt")
|
||||
response = self.client.post(
|
||||
"/git/commit", json={"repo": "alpha", "message": " "}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
def test_discard_without_confirmation_is_403(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/discard", json={"repo": "alpha", "paths": ["tracked.txt"]}
|
||||
)
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
|
||||
def test_discard_wrong_confirmation_is_403(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/discard",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"paths": ["tracked.txt"],
|
||||
"confirmation": "wrong",
|
||||
},
|
||||
)
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
|
||||
def test_discard_with_confirmation_succeeds(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/discard",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"paths": ["tracked.txt"],
|
||||
"confirmation": git_state.CONFIRM_DISCARD,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.assertEqual("v1", (self.repo / "tracked.txt").read_text(encoding="utf-8"))
|
||||
|
||||
def test_push_requires_confirmation(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "origin-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
self._stage("feature.txt")
|
||||
self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
|
||||
response = self.client.post(
|
||||
"/git/push", json={"repo": "alpha", "remote": "origin", "branch": "main"}
|
||||
)
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
|
||||
def test_push_with_confirmation_succeeds(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "remote-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
self._stage("feature.txt")
|
||||
self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
|
||||
response = self.client.post(
|
||||
"/git/push",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"remote": "origin",
|
||||
"branch": "main",
|
||||
"confirmation": git_state.CONFIRM_PUSH,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
|
||||
def test_checkout_dirty_requires_confirmation(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/checkout", json={"repo": "alpha", "ref": "feature"}
|
||||
)
|
||||
# Missing confirmation on a dirty-tree switch is the destructive gate.
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
# Confirming proceeds (git still refuses to overwrite conflicting work).
|
||||
response = self.client.post(
|
||||
"/git/checkout",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"ref": "feature",
|
||||
"confirmation": git_state.CONFIRM_DIRTY_CHECKOUT,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
|
||||
def test_pull_dirty_returns_409_never_clobbers(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "remote-bare2")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
# Advance the remote from a descendant clone.
|
||||
other = self.base / "other"
|
||||
_run(["git", "clone", "-q", str(remote), str(other)], self.base)
|
||||
_git(other, "config", "user.email", "t@e.c")
|
||||
_git(other, "config", "user.name", "T")
|
||||
(other / "tracked.txt").write_text("remote", encoding="utf-8")
|
||||
_git(other, "add", "tracked.txt")
|
||||
_git(other, "commit", "-q", "-m", "remote")
|
||||
_git(other, "push", "-q", "origin", "main")
|
||||
(self.repo / "tracked.txt").write_text("local-uncommitted", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/pull", json={"repo": "alpha", "remote": "origin", "branch": "main"}
|
||||
)
|
||||
self.assertEqual(409, response.status_code, response.text)
|
||||
self.assertEqual("local-uncommitted", (self.repo / "tracked.txt").read_text(encoding="utf-8"))
|
||||
|
||||
def test_fetch_returns_branches(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "remote-bare3")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
response = self.client.post(
|
||||
"/git/fetch", json={"repo": "alpha", "remote": "origin"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("branches", body)
|
||||
|
||||
def test_structured_error_taxonomy_is_readable(self) -> None:
|
||||
# Unknown repo → 400 with a readable detail, never a stack trace.
|
||||
response = self.client.post(
|
||||
"/git/stage", json={"repo": "missing", "paths": ["x"]}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
self.assertNotIn("Traceback", response.text)
|
||||
self.assertNotIn("subprocess", response.text.lower())
|
||||
|
||||
|
||||
def test_push_wrong_confirmation_is_403(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "push-wrong-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
self._stage("feature.txt")
|
||||
self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
|
||||
response = self.client.post(
|
||||
"/git/push",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"remote": "origin",
|
||||
"branch": "main",
|
||||
"confirmation": "nope",
|
||||
},
|
||||
)
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
|
||||
def test_push_with_confirmation_updates_remote_and_returns_branches(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "push-ok-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
|
||||
self._stage("feature.txt")
|
||||
r = self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
|
||||
self.assertEqual(200, r.status_code, r.text)
|
||||
head = r.json()["head"]
|
||||
response = self.client.post(
|
||||
"/git/push",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"remote": "origin",
|
||||
"branch": "main",
|
||||
"confirmation": git_state.CONFIRM_PUSH,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("branches", body)
|
||||
self.assertIn("status", body)
|
||||
remote_head = _run(
|
||||
["git", "ls-remote", str(remote), "refs/heads/main"], self.base
|
||||
)
|
||||
self.assertIn(head, remote_head)
|
||||
|
||||
def test_checkout_dirty_tree_wrong_confirmation_is_403(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/checkout",
|
||||
json={"repo": "alpha", "ref": "feature", "confirmation": "wrong"},
|
||||
)
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
|
||||
def test_checkout_clean_tree_works_without_confirmation(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
response = self.client.post(
|
||||
"/git/checkout", json={"repo": "alpha", "ref": "feature"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("branches", body)
|
||||
self.assertIn("status", body)
|
||||
self.assertEqual(
|
||||
"feature", _git(self.repo, "symbolic-ref", "--short", "HEAD")
|
||||
)
|
||||
|
||||
def test_checkout_new_branch_with_track_sets_upstream(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "newbranch-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
|
||||
response = self.client.post(
|
||||
"/git/checkout",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"ref": "main",
|
||||
"new_branch": "exp",
|
||||
"track": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("branches", body)
|
||||
self.assertIn("status", body)
|
||||
self.assertEqual("exp", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
# Upstream is set — rev-parse resolves to a commit, not an error.
|
||||
self.assertTrue(_git(self.repo, "rev-parse", "exp@{upstream}"))
|
||||
|
||||
def test_fetch_updates_remote_tracking_ref(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "fetch-adv-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
other = self.base / "other"
|
||||
_run(["git", "clone", "-q", str(remote), str(other)], self.base)
|
||||
_git(other, "config", "user.email", "t@e.c")
|
||||
_git(other, "config", "user.name", "T")
|
||||
(other / "remote.txt").write_text("rc fetch", encoding="utf-8")
|
||||
_git(other, "add", "remote.txt")
|
||||
_git(other, "commit", "-q", "-m", "rc fetch")
|
||||
_git(other, "push", "-q", "origin", "main")
|
||||
response = self.client.post(
|
||||
"/git/fetch", json={"repo": "alpha", "remote": "origin"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("branches", body)
|
||||
self.assertIn("status", body)
|
||||
remote_main = _git(self.repo, "rev-parse", "origin/main")
|
||||
self.assertNotEqual(remote_main, _git(self.repo, "rev-parse", "HEAD"))
|
||||
|
||||
def test_pull_returns_200_with_remote_commit(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "pull-ok-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
|
||||
other = self.base / "other"
|
||||
_run(["git", "clone", "-q", str(remote), str(other)], self.base)
|
||||
_git(other, "config", "user.email", "t@e.c")
|
||||
_git(other, "config", "user.name", "T")
|
||||
(other / "remote.txt").write_text("rc pull", encoding="utf-8")
|
||||
_git(other, "add", "remote.txt")
|
||||
_git(other, "commit", "-q", "-m", "rc pull")
|
||||
_git(other, "push", "-q", "origin", "main")
|
||||
response = self.client.post(
|
||||
"/git/pull",
|
||||
json={"repo": "alpha", "remote": "origin", "branch": "main"},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("status", body)
|
||||
head_files = _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD")
|
||||
self.assertIn("remote.txt", head_files)
|
||||
|
||||
def test_commit_selected_commits_only_given_paths(self) -> None:
|
||||
(self.repo / "kept.txt").write_text("keep", encoding="utf-8")
|
||||
(self.repo / "skip.txt").write_text("skip", encoding="utf-8")
|
||||
_git(self.repo, "add", "kept.txt", "skip.txt")
|
||||
response = self.client.post(
|
||||
"/git/commit_selected",
|
||||
json={"repo": "alpha", "message": "commit kept", "paths": ["kept.txt"]},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("head", body)
|
||||
self.assertIn("status", body)
|
||||
head_files = _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD")
|
||||
self.assertIn("kept.txt", head_files)
|
||||
self.assertNotIn("skip.txt", head_files)
|
||||
|
||||
def test_commit_response_includes_head_and_fresh_status(self) -> None:
|
||||
self._stage("feature.txt")
|
||||
response = self.client.post(
|
||||
"/git/commit", json={"repo": "alpha", "message": "add feature"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("head", body)
|
||||
self.assertEqual(body["head"], _git(self.repo, "rev-parse", "HEAD"))
|
||||
self.assertIn("status", body)
|
||||
self.assertEqual(0, body["status"]["counts"]["staged"])
|
||||
self.assertEqual(0, body["status"]["counts"]["modified"])
|
||||
|
||||
def test_unstage_returns_fresh_status(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
r = self.client.post(
|
||||
"/git/stage", json={"repo": "alpha", "paths": ["tracked.txt"]}
|
||||
)
|
||||
self.assertEqual(200, r.status_code, r.text)
|
||||
response = self.client.post(
|
||||
"/git/unstage", json={"repo": "alpha", "paths": ["tracked.txt"]}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("status", body)
|
||||
status = body["status"]
|
||||
self.assertNotIn(
|
||||
"tracked.txt", [e["path"] for e in status["staged"]]
|
||||
)
|
||||
self.assertIn(
|
||||
"tracked.txt", [e["path"] for e in status["modified"]]
|
||||
)
|
||||
|
||||
def test_stage_missing_repo_in_body_is_400(self) -> None:
|
||||
response = self.client.post("/git/stage", json={"paths": ["x"]})
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
def test_stage_traversal_path_is_400(self) -> None:
|
||||
response = self.client.post(
|
||||
"/git/stage", json={"repo": "alpha", "paths": ["../escape"]}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
def test_stage_too_many_paths_is_400(self) -> None:
|
||||
paths = [f"f{i}.txt" for i in range(201)]
|
||||
response = self.client.post(
|
||||
"/git/stage", json={"repo": "alpha", "paths": paths}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
|
||||
class GitExtrasApiTests(unittest.TestCase):
|
||||
"""Endpoints for the Phase 3 extras surface (AI messages + stash-checkout)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.base = Path(self.temp.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "alpha")
|
||||
self.env = patch.dict(
|
||||
os.environ,
|
||||
{"HERMES_HOME": self.temp.name, "GIT_STATE_BASE_PATH": str(self.base)},
|
||||
)
|
||||
self.env.start()
|
||||
self.addCleanup(self.env.stop)
|
||||
app = FastAPI()
|
||||
app.include_router(git_api.router)
|
||||
self.client = TestClient(app)
|
||||
|
||||
def _staged(self, path: str, content: str) -> None:
|
||||
(self.repo / path).write_text(content, encoding="utf-8")
|
||||
self.client.post("/git/stage", json={"repo": "alpha", "paths": [path]})
|
||||
|
||||
def test_commit_message_empty_staged_returns_notice_without_model(self) -> None:
|
||||
# Clean tree → nothing staged → no model needed, no 500.
|
||||
response = self.client.post("/git/commit_message", json={"repo": "alpha"})
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertEqual("", body["message"])
|
||||
self.assertEqual("nothing staged", body["notice"])
|
||||
|
||||
def test_commit_message_with_staged_diff_generates_message(self) -> None:
|
||||
self._staged("feature.txt", "new feature\n")
|
||||
with patch.object(git_state, "_llm_call", new=AsyncMock(return_value="resp")), patch.object(
|
||||
git_state, "_llm_extract", return_value="feat: add feature"
|
||||
):
|
||||
response = self.client.post(
|
||||
"/git/commit_message", json={"repo": "alpha"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.assertEqual("feat: add feature", response.json()["message"])
|
||||
|
||||
def test_commit_message_model_failure_degrades_gracefully(self) -> None:
|
||||
self._staged("a.txt", "x\n")
|
||||
with patch.object(
|
||||
git_state, "_llm_call", new=AsyncMock(side_effect=RuntimeError("no model"))
|
||||
):
|
||||
response = self.client.post(
|
||||
"/git/commit_message", json={"repo": "alpha"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertEqual("", body["message"])
|
||||
self.assertIn("model", body["notice"].lower())
|
||||
|
||||
def test_commit_message_selected_honors_only_given_paths(self) -> None:
|
||||
self._staged("kept.txt", "kept\n")
|
||||
self._staged("skip.txt", "skip\n")
|
||||
with patch.object(git_state, "_llm_call", new=AsyncMock(return_value="ok")), patch.object(
|
||||
git_state, "_llm_extract", return_value="add kept"
|
||||
):
|
||||
response = self.client.post(
|
||||
"/git/commit_message_selected",
|
||||
json={"repo": "alpha", "paths": ["kept.txt"]},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.assertEqual("add kept", response.json()["message"])
|
||||
|
||||
def test_commit_message_unknown_repo_is_400(self) -> None:
|
||||
response = self.client.post(
|
||||
"/git/commit_message", json={"repo": "bogus"}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
def test_stash_checkout_dirty_tree_returns_stash_notice(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
(self.repo / "README.md").write_text("dirty", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/stash_checkout", json={"repo": "alpha", "ref": "feature"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertTrue(body["stashed"])
|
||||
self.assertEqual("git-state: feature", body["stash_message"])
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
self.assertIn("git-state: feature", _git(self.repo, "stash", "list"))
|
||||
|
||||
def test_stash_checkout_clean_tree_plain_checkout(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
response = self.client.post(
|
||||
"/git/stash_checkout", json={"repo": "alpha", "ref": "feature"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertFalse(body["stashed"])
|
||||
self.assertEqual("", body["stash_message"])
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
|
||||
def test_stash_checkout_bad_ref_is_400(self) -> None:
|
||||
response = self.client.post(
|
||||
"/git/stash_checkout", json={"repo": "alpha", "ref": "nope"}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -50,7 +50,8 @@ class MobilePluginApiTests(unittest.TestCase):
|
||||
|
||||
manifest = self.client.get("/mobile/manifest").json()
|
||||
self.assertEqual("hermes-relay", manifest["id"])
|
||||
self.assertEqual("draft", manifest["contributions"][0]["status"])
|
||||
draft = next(c for c in manifest["contributions"] if c["id"] == "system-status")
|
||||
self.assertEqual("draft", draft["status"])
|
||||
loaded_document = self.client.get("/mobile/pages/system-status").json()
|
||||
self.assertEqual(1, loaded_document.pop("host_revision"))
|
||||
self.assertEqual(_document(), loaded_document)
|
||||
@@ -70,7 +71,35 @@ class MobilePluginApiTests(unittest.TestCase):
|
||||
json={"expected_digest": published_digest},
|
||||
)
|
||||
self.assertEqual({"ok": True, "id": "system-status"}, removed.json())
|
||||
self.assertEqual([], self.client.get("/mobile/manifest").json()["contributions"])
|
||||
remaining = self.client.get("/mobile/manifest").json()["contributions"]
|
||||
self.assertEqual(["git"], [c["id"] for c in remaining])
|
||||
|
||||
def test_manifest_exposes_static_git_page(self) -> None:
|
||||
manifest = self.client.get("/mobile/manifest").json()
|
||||
git = next(c for c in manifest["contributions"] if c["id"] == "git")
|
||||
self.assertEqual("page", git["surface"])
|
||||
self.assertEqual("mobile/pages/git", git["document"]["path"])
|
||||
# The Git page document is a GET-only read surface; it must not carry
|
||||
# any action.request (which would require the write grant).
|
||||
page = self.client.get("/mobile/pages/git").json()
|
||||
self.assertNotIn("action", str(page))
|
||||
|
||||
def test_git_page_document_is_served(self) -> None:
|
||||
response = self.client.get("/mobile/pages/git")
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertEqual(1, body["schemaVersion"])
|
||||
self.assertEqual("git", body["pages"][0]["id"])
|
||||
self.assertEqual(1, body["host_revision"])
|
||||
|
||||
def test_reserved_git_plugin_id_is_rejected(self) -> None:
|
||||
response = self.client.put(
|
||||
"/mobile/plugins/git/draft",
|
||||
json={"title": "Shadow", "document": _document()},
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
manifest = self.client.get("/mobile/manifest").json()
|
||||
self.assertEqual(["git"], [item["id"] for item in manifest["contributions"]])
|
||||
|
||||
def test_traversal_and_bad_document_are_rejected(self) -> None:
|
||||
traversal = self.client.put(
|
||||
|
||||
+1063
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ from typing import Any, Optional
|
||||
|
||||
|
||||
PLUGIN_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
|
||||
RESERVED_PLUGIN_IDS = frozenset({"git"})
|
||||
MAX_DOCUMENT_BYTES = 512 * 1024
|
||||
ALLOWED_LIFECYCLES = frozenset({"session", "persistent"})
|
||||
ALLOWED_ELEMENT_TYPES = frozenset(
|
||||
@@ -132,6 +133,8 @@ class MobilePluginStore:
|
||||
for path in sorted(self.root.glob("*.json")):
|
||||
if not PLUGIN_ID_RE.fullmatch(path.stem):
|
||||
continue
|
||||
if path.stem in RESERVED_PLUGIN_IDS:
|
||||
continue
|
||||
entry = self._read(path.stem, required=False)
|
||||
if entry:
|
||||
entries.append({k: v for k, v in entry.items() if k != "document"})
|
||||
@@ -139,6 +142,26 @@ class MobilePluginStore:
|
||||
|
||||
def manifest(self) -> dict[str, Any]:
|
||||
contributions = []
|
||||
# Static read-only Git page contributed by the relay plugin itself.
|
||||
# It carries no filesystem paths (per the android-plugins.md document
|
||||
# contract); repo data flows through the /git/* API responses and is
|
||||
# rendered as plain text by the host. No plugin.api.write grant is
|
||||
# required for the read-only Git surface.
|
||||
contributions.append(
|
||||
{
|
||||
"id": "git",
|
||||
"surface": "page",
|
||||
"title": "Git",
|
||||
"description": "Browse repositories on this Hermes host",
|
||||
"status": "published",
|
||||
"lifecycle": "persistent",
|
||||
"revision": 1,
|
||||
"document": {
|
||||
"method": "GET",
|
||||
"path": "mobile/pages/git",
|
||||
},
|
||||
}
|
||||
)
|
||||
for summary in self.list():
|
||||
is_draft = summary["status"] == "draft"
|
||||
contributions.append(
|
||||
@@ -180,6 +203,8 @@ class MobilePluginStore:
|
||||
normalized = str(plugin_id).strip().lower()
|
||||
if not PLUGIN_ID_RE.fullmatch(normalized):
|
||||
raise MobilePluginStoreError("invalid plugin id")
|
||||
if normalized in RESERVED_PLUGIN_IDS:
|
||||
raise MobilePluginStoreError("plugin id is reserved")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
@@ -311,6 +336,8 @@ class MobilePluginStore:
|
||||
if required:
|
||||
raise MobilePluginNotFoundError(plugin_id)
|
||||
return {}
|
||||
except MobilePluginStoreError:
|
||||
raise
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
if required:
|
||||
raise MobilePluginNotFoundError(plugin_id)
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
"""Tests for the read-only Git state surface (plugin/git_state.py).
|
||||
|
||||
Fixtures create REAL throwaway git repositories in tmp_path — init, config
|
||||
user, commits, branches, and bare remotes. Git itself is never mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from plugin import git_state
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path) -> str:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
return _run(["git", "-C", str(repo), *args], repo)
|
||||
|
||||
|
||||
def _init_repo(root: Path, name: str) -> Path:
|
||||
repo = root / name
|
||||
repo.mkdir(parents=True)
|
||||
_run(["git", "init", "-q", "-b", "main"], repo)
|
||||
_run(["git", "config", "user.email", "test@example.com"], repo)
|
||||
_run(["git", "config", "user.name", "Test User"], repo)
|
||||
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
|
||||
_git(repo, "add", "README.md")
|
||||
_git(repo, "commit", "-q", "-m", "initial commit")
|
||||
return repo
|
||||
|
||||
|
||||
def _add_remote(repo: Path, remote_url: str, name: str = "origin") -> None:
|
||||
_git(repo, "remote", "add", name, remote_url)
|
||||
|
||||
|
||||
def _link_directory(link: Path, target: Path) -> None:
|
||||
try:
|
||||
link.symlink_to(target, target_is_directory=True)
|
||||
except OSError:
|
||||
if os.name != "nt":
|
||||
raise
|
||||
_run(["cmd", "/c", "mklink", "/J", str(link), str(target)], link.parent)
|
||||
|
||||
|
||||
class GitStateScanTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp = Path(self.tempdir())
|
||||
|
||||
def tempdir(self) -> str:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
return self._td.name
|
||||
|
||||
def test_scan_finds_nested_repos_and_ignores_non_repos(self) -> None:
|
||||
base = self.tmp / "projects"
|
||||
base.mkdir(parents=True)
|
||||
_init_repo(base, "alpha")
|
||||
_init_repo(base / "nested", "beta")
|
||||
# A plain directory with no .git must be ignored.
|
||||
(base / "plain").mkdir()
|
||||
(base / "plain" / "file.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
repos = git_state.scan_repos(base)
|
||||
names = {r["name"] for r in repos}
|
||||
self.assertEqual({"alpha", "beta"}, names)
|
||||
for repo in repos:
|
||||
self.assertEqual("main", repo["current_branch"])
|
||||
self.assertFalse(repo["dirty"])
|
||||
|
||||
def test_scan_missing_base_path_returns_empty(self) -> None:
|
||||
missing = self.tmp / "does-not-exist"
|
||||
self.assertEqual([], git_state.scan_repos(missing))
|
||||
|
||||
def test_scan_excludes_git_internals(self) -> None:
|
||||
base = self.tmp / "projects"
|
||||
base.mkdir(parents=True)
|
||||
_init_repo(base, "alpha")
|
||||
# A .git directory itself must never be reported as a repo.
|
||||
repos = git_state.scan_repos(base)
|
||||
self.assertTrue(all(".git" not in r["name"] for r in repos))
|
||||
|
||||
def test_scan_marks_dirty_repo(self) -> None:
|
||||
base = self.tmp / "projects"
|
||||
base.mkdir(parents=True)
|
||||
repo = _init_repo(base, "dirty")
|
||||
(repo / "new.txt").write_text("untracked", encoding="utf-8")
|
||||
repos = git_state.scan_repos(base)
|
||||
dirty = next(r for r in repos if r["name"] == "dirty")
|
||||
self.assertTrue(dirty["dirty"])
|
||||
|
||||
def test_nested_same_name_repos_have_distinct_round_trip_ids(self) -> None:
|
||||
base = self.tmp / "projects"
|
||||
base.mkdir(parents=True)
|
||||
first = _init_repo(base / "team-a", "service")
|
||||
second = _init_repo(base / "team-b", "service")
|
||||
|
||||
repos = git_state.scan_repos(base)
|
||||
self.assertEqual({"team-a/service", "team-b/service"}, {repo["id"] for repo in repos})
|
||||
self.assertEqual(first.resolve(), git_state.resolve_repo(base, "team-a/service"))
|
||||
self.assertEqual(second.resolve(), git_state.resolve_repo(base, "team-b/service"))
|
||||
|
||||
def test_scan_rejects_linked_repo_outside_base(self) -> None:
|
||||
base = self.tmp / "projects"
|
||||
base.mkdir(parents=True)
|
||||
outside = _init_repo(self.tmp, "outside")
|
||||
link = base / "linked"
|
||||
_link_directory(link, outside)
|
||||
|
||||
self.assertEqual([], git_state.scan_repos(base))
|
||||
|
||||
|
||||
class GitStateStatusTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "status-repo")
|
||||
|
||||
def test_status_groups_modified_untracked_and_staged(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
# staged change
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
# unstaged change
|
||||
(self.repo / "README.md").write_text("# Changed\n", encoding="utf-8")
|
||||
# untracked
|
||||
(self.repo / "untracked.txt").write_text("new", encoding="utf-8")
|
||||
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertEqual(1, status["counts"]["staged"])
|
||||
self.assertEqual(1, status["counts"]["modified"])
|
||||
self.assertEqual(1, status["counts"]["untracked"])
|
||||
self.assertEqual("tracked.txt", status["staged"][0]["path"])
|
||||
self.assertEqual("README.md", status["modified"][0]["path"])
|
||||
self.assertEqual("untracked.txt", status["untracked"][0]["path"])
|
||||
self.assertFalse(status["truncated"])
|
||||
|
||||
def test_status_truncates_when_over_cap(self) -> None:
|
||||
for i in range(git_state.MAX_STATUS_ENTRIES + 5):
|
||||
(self.repo / f"file-{i}.txt").write_text("x", encoding="utf-8")
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertTrue(status["truncated"])
|
||||
self.assertLessEqual(
|
||||
len(status["untracked"]),
|
||||
git_state.MAX_STATUS_ENTRIES,
|
||||
)
|
||||
|
||||
def test_status_lists_staged_and_modified_same_file(self) -> None:
|
||||
# A file staged AND then modified again ("MM" in porcelain) must appear
|
||||
# in BOTH the staged and modified groups (independent checks, not elif).
|
||||
(self.repo / "mm.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "mm.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add mm")
|
||||
(self.repo / "mm.txt").write_text("v2", encoding="utf-8")
|
||||
_git(self.repo, "add", "mm.txt")
|
||||
(self.repo / "mm.txt").write_text("v3", encoding="utf-8")
|
||||
|
||||
status = git_state.repo_status(self.repo)
|
||||
staged_paths = {e["path"] for e in status["staged"]}
|
||||
modified_paths = {e["path"] for e in status["modified"]}
|
||||
self.assertIn("mm.txt", staged_paths)
|
||||
self.assertIn("mm.txt", modified_paths)
|
||||
|
||||
def test_status_rename_emits_single_staged_entry(self) -> None:
|
||||
# `git mv` produces two NUL-separated porcelain records ("R new\0old\0");
|
||||
# the bare source-path record must be skipped, not misparsed as an XY
|
||||
# record. Use a source name starting with "M" so a naive parser would
|
||||
# misclassify the bare source record as staged with a truncated path.
|
||||
(self.repo / "Moved.txt").write_text("content", encoding="utf-8")
|
||||
_git(self.repo, "add", "Moved.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add Moved")
|
||||
_git(self.repo, "mv", "Moved.txt", "new.txt")
|
||||
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertEqual(["new.txt"], [e["path"] for e in status["staged"]])
|
||||
self.assertEqual([], status["modified"])
|
||||
self.assertEqual([], status["untracked"])
|
||||
|
||||
def test_status_unstaged_rename_shows_delete_and_untracked(self) -> None:
|
||||
# Rename on disk only (no `git mv`): delete + create → D + ??.
|
||||
(self.repo / "old.txt").write_text("content", encoding="utf-8")
|
||||
_git(self.repo, "add", "old.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add old")
|
||||
(self.repo / "old.txt").unlink()
|
||||
(self.repo / "new.txt").write_text("content", encoding="utf-8")
|
||||
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertEqual([], status["staged"])
|
||||
self.assertEqual(["old.txt"], [e["path"] for e in status["modified"]])
|
||||
self.assertEqual(["new.txt"], [e["path"] for e in status["untracked"]])
|
||||
|
||||
|
||||
class GitStateBranchesTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "branch-repo")
|
||||
|
||||
def test_branches_reports_current_upstream_ahead_behind(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
|
||||
_git(self.repo, "add", "feature.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "feature work")
|
||||
|
||||
branches = git_state.repo_branches(self.repo)
|
||||
by_name = {b["name"]: b for b in branches}
|
||||
self.assertIn("main", by_name)
|
||||
self.assertIn("feature", by_name)
|
||||
self.assertTrue(by_name["feature"]["is_current"])
|
||||
self.assertFalse(by_name["main"]["is_current"])
|
||||
|
||||
|
||||
class GitStateDiffTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "diff-repo")
|
||||
|
||||
def test_diff_unstaged_and_staged_kinds(self) -> None:
|
||||
(self.repo / "a.txt").write_text("one\n", encoding="utf-8")
|
||||
_git(self.repo, "add", "a.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add a")
|
||||
# staged change
|
||||
(self.repo / "a.txt").write_text("two\n", encoding="utf-8")
|
||||
_git(self.repo, "add", "a.txt")
|
||||
# unstaged change
|
||||
(self.repo / "a.txt").write_text("three\n", encoding="utf-8")
|
||||
|
||||
staged = git_state.repo_diff(self.repo, "a.txt", kind="staged")
|
||||
self.assertIn("+two", staged["diff"])
|
||||
unstaged = git_state.repo_diff(self.repo, "a.txt", kind="unstaged")
|
||||
self.assertIn("+three", unstaged["diff"])
|
||||
|
||||
def test_diff_invalid_kind_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
git_state.repo_diff(self.repo, "a.txt", kind="bogus")
|
||||
|
||||
def test_diff_truncates_large_output(self) -> None:
|
||||
big = "x" * 200_000
|
||||
(self.repo / "big.txt").write_text(big + "\n", encoding="utf-8")
|
||||
_git(self.repo, "add", "big.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add big")
|
||||
(self.repo / "big.txt").write_text(big + "y\n", encoding="utf-8")
|
||||
result = git_state.repo_diff(self.repo, "big.txt", kind="unstaged")
|
||||
self.assertTrue(result["truncated"])
|
||||
self.assertLessEqual(len(result["diff"]), git_state.MAX_DIFF_BYTES)
|
||||
|
||||
|
||||
class GitStateFileTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "file-repo")
|
||||
|
||||
def test_read_tracked_file(self) -> None:
|
||||
content = git_state.read_file(self.repo, "README.md")
|
||||
self.assertIn("Hello", content["content"])
|
||||
|
||||
def test_read_tracked_file_returns_working_tree_not_committed(self) -> None:
|
||||
# A modified-but-uncommitted tracked file must return the on-disk
|
||||
# (working-tree) content, not the last committed version.
|
||||
(self.repo / "README.md").write_text("# Working Tree\n", encoding="utf-8")
|
||||
content = git_state.read_file(self.repo, "README.md")
|
||||
self.assertIn("Working Tree", content["content"])
|
||||
self.assertNotIn("Hello", content["content"])
|
||||
|
||||
def test_read_untracked_file_raises(self) -> None:
|
||||
(self.repo / "untracked.txt").write_text("new", encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
git_state.read_file(self.repo, "untracked.txt")
|
||||
|
||||
def test_read_missing_file_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
git_state.read_file(self.repo, "nope.txt")
|
||||
|
||||
def test_read_tracked_binary_file_raises_gitstateerror(self) -> None:
|
||||
# A TRACKED binary file (e.g. a committed PNG) must raise GitStateError
|
||||
# ("binary file is not supported" / UTF-8), never an unhandled
|
||||
# UnicodeDecodeError that escapes as a 500.
|
||||
(self.repo / "image.png").write_bytes(b"\x89PNG\r\n\x1a\n\x00binary\xff\xfe")
|
||||
_git(self.repo, "add", "image.png")
|
||||
_git(self.repo, "commit", "-q", "-m", "add binary")
|
||||
with self.assertRaises(git_state.GitStateError) as ctx:
|
||||
git_state.read_file(self.repo, "image.png")
|
||||
message = str(ctx.exception)
|
||||
self.assertTrue("binary" in message or "UTF-8" in message)
|
||||
|
||||
def test_read_tracked_non_utf8_text_raises(self) -> None:
|
||||
# A tracked, NUL-free but non-UTF-8 text file (Latin-1) must raise a
|
||||
# clear GitStateError, not an unhandled UnicodeDecodeError.
|
||||
(self.repo / "latin1.txt").write_bytes(b"caf\xe9 latin1")
|
||||
_git(self.repo, "add", "latin1.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add latin1")
|
||||
with self.assertRaises(git_state.GitStateError) as ctx:
|
||||
git_state.read_file(self.repo, "latin1.txt")
|
||||
self.assertIn("not valid UTF-8 text", str(ctx.exception))
|
||||
|
||||
def test_read_tracked_link_outside_repo_is_rejected(self) -> None:
|
||||
if os.name == "nt":
|
||||
outside = self.base / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "secret.txt").write_text("secret", encoding="utf-8")
|
||||
link = self.repo / "leak"
|
||||
_link_directory(link, outside)
|
||||
tracked_path = "leak/secret.txt"
|
||||
else:
|
||||
outside = self.base / "outside.txt"
|
||||
outside.write_text("secret", encoding="utf-8")
|
||||
(self.repo / "leak.txt").symlink_to(outside)
|
||||
tracked_path = "leak.txt"
|
||||
_git(self.repo, "add", tracked_path)
|
||||
_git(self.repo, "commit", "-q", "-m", "track link")
|
||||
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "escapes repository"):
|
||||
git_state.read_file(self.repo, tracked_path)
|
||||
|
||||
def test_read_tracked_file_is_bounded_during_read(self) -> None:
|
||||
(self.repo / "large.txt").write_text("x" * (git_state.MAX_FILE_BYTES + 100), encoding="utf-8")
|
||||
_git(self.repo, "add", "large.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "large")
|
||||
|
||||
result = git_state.read_file(self.repo, "large.txt")
|
||||
|
||||
self.assertTrue(result["truncated"])
|
||||
self.assertEqual(git_state.MAX_FILE_BYTES, len(result["content"]))
|
||||
|
||||
|
||||
class GitStateDocumentTests(unittest.TestCase):
|
||||
def test_document_missing_base_notice_leaks_no_path(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
missing = Path(self._td.name) / "does-not-exist"
|
||||
|
||||
doc = git_state.build_git_document(missing)
|
||||
notice = doc["pages"][0]["content"]["children"][0]
|
||||
self.assertEqual("notice", notice["id"])
|
||||
value = notice["text"]["value"]
|
||||
# The document contract forbids filesystem paths: no "/" and no
|
||||
# path-like substring (e.g. the tmp dir name).
|
||||
self.assertNotIn("/", value)
|
||||
self.assertNotIn(self._td.name, value)
|
||||
|
||||
|
||||
class GitStateSecurityTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "sec-repo")
|
||||
|
||||
def test_path_traversal_rejected(self) -> None:
|
||||
for bad in ("../outside", "/etc/passwd", "a/../../b", "..%2Fescape"):
|
||||
with self.subTest(path=bad):
|
||||
with self.assertRaises(ValueError):
|
||||
git_state.resolve_repo_path(self.repo, bad)
|
||||
|
||||
def test_remote_urls_scrubbed_of_userinfo(self) -> None:
|
||||
_add_remote(self.repo, "https://user:secret@example.com/org/repo.git", "https")
|
||||
_add_remote(self.repo, "ssh://git@example.com:2222/org/repo.git", "ssh")
|
||||
_add_remote(self.repo, "git@example.com:org/repo.git", "scp")
|
||||
remotes = git_state.repo_remotes(self.repo)
|
||||
self.assertEqual(3, len(remotes))
|
||||
for remote in remotes:
|
||||
self.assertNotIn("secret", remote["url"])
|
||||
self.assertNotIn("user:", remote["url"])
|
||||
self.assertNotIn("git@", remote["url"])
|
||||
|
||||
def test_git_error_text_scrubs_embedded_remote_credentials(self) -> None:
|
||||
message = git_state._safe_git_error(
|
||||
"fatal: unable to access 'https://user:secret@example.com/repo.git'"
|
||||
)
|
||||
self.assertNotIn("user", message)
|
||||
self.assertNotIn("secret", message)
|
||||
|
||||
def test_allowlist_accepts_only_scanned_repos(self) -> None:
|
||||
scanned = git_state.scan_repos(self.base)
|
||||
ids = {r["id"] for r in scanned}
|
||||
self.assertIn(git_state.repo_id(self.repo), ids)
|
||||
self.assertNotIn("bogus-id", ids)
|
||||
|
||||
def test_git_output_over_cap_fails_closed(self) -> None:
|
||||
for index in range(20):
|
||||
(self.repo / f"long-untracked-name-{index}.txt").write_text("x", encoding="utf-8")
|
||||
with patch.object(git_state, "MAX_GIT_OUTPUT_BYTES", 32):
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "output exceeded"):
|
||||
git_state.repo_status(self.repo)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Tests for the Phase 3 extras surface of git_state.
|
||||
|
||||
Covers:
|
||||
- ``commit_message`` / ``commit_message_selected``: staged-diff → LLM → a
|
||||
conventional-style message suggestion. The model client is MONKEYPATCHED
|
||||
(never a real API call); empty staged diff must skip the LLM entirely; a
|
||||
missing/failing model degrades to an empty message + notice, never a 500.
|
||||
- ``stash_checkout``: dirty tree → auto-stash + switch (stash list entry
|
||||
present); clean tree → plain checkout; unknown ref → error.
|
||||
|
||||
Fixtures build REAL throwaway git repos in tmp_path. git itself is never
|
||||
mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from plugin import git_state
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path) -> str:
|
||||
return subprocess.run(
|
||||
cmd, cwd=cwd, capture_output=True, text=True, check=True
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
return _run(["git", "-C", str(repo), *args], repo)
|
||||
|
||||
|
||||
def _init_repo(root: Path, name: str) -> Path:
|
||||
repo = root / name
|
||||
repo.mkdir(parents=True)
|
||||
_run(["git", "init", "-q", "-b", "main"], repo)
|
||||
_run(["git", "config", "user.email", "test@example.com"], repo)
|
||||
_run(["git", "config", "user.name", "Test User"], repo)
|
||||
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
|
||||
_git(repo, "add", "README.md")
|
||||
_git(repo, "commit", "-q", "-m", "initial commit")
|
||||
return repo
|
||||
|
||||
|
||||
class _ExtrasBase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "extras-repo")
|
||||
|
||||
def _staged_change(self, path: str, content: str) -> None:
|
||||
(self.repo / path).write_text(content, encoding="utf-8")
|
||||
_git(self.repo, "add", path)
|
||||
|
||||
def _branch(self, name: str) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", name)
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
|
||||
def _patch_llm(self, *, text: str = "", exc: Exception | None = None):
|
||||
"""Inject a fake agent.auxiliary_client chain into git_state.
|
||||
|
||||
``_llm_call`` is the async client; ``_llm_extract`` turns its return
|
||||
into text. The failure path raises ``exc`` from the client.
|
||||
"""
|
||||
client = AsyncMock(side_effect=exc) if exc else AsyncMock(return_value="resp")
|
||||
extractor = MagicMock(return_value=text)
|
||||
return patch.object(git_state, "_llm_call", client), client, extractor, patch.object(
|
||||
git_state, "_llm_extract", extractor
|
||||
)
|
||||
|
||||
|
||||
class CommitMessageTests(_ExtrasBase):
|
||||
def test_commit_message_returns_suggestion_from_staged_diff(self) -> None:
|
||||
self._staged_change("feature.txt", "new feature body\n")
|
||||
llm_patch, client, extractor, extract_patch = self._patch_llm(
|
||||
text="feat: add new feature\n\nBrings a new feature."
|
||||
)
|
||||
with llm_patch, extract_patch:
|
||||
result = asyncio.run(git_state.commit_message(self.repo))
|
||||
# The generated message is the LLM text (subject + optional body) so it
|
||||
# can flow into the commit dialog and be edited.
|
||||
self.assertEqual(
|
||||
{"message": "feat: add new feature\n\nBrings a new feature.", "notice": ""},
|
||||
result,
|
||||
)
|
||||
# The client was called with a messages list containing the staged diff.
|
||||
_, kwargs = client.call_args
|
||||
self.assertIn("messages", kwargs)
|
||||
payload = " ".join(str(m) for m in kwargs["messages"])
|
||||
self.assertIn("new feature", payload)
|
||||
extractor.assert_called_once_with("resp")
|
||||
|
||||
def test_commit_message_empty_staged_diff_skips_llm(self) -> None:
|
||||
# Clean tree: nothing staged → no LLM call.
|
||||
p = patch.object(git_state, "_llm_call", AsyncMock())
|
||||
with p as client:
|
||||
result = asyncio.run(git_state.commit_message(self.repo))
|
||||
self.assertEqual({"message": "", "notice": "nothing staged"}, result)
|
||||
client.assert_not_called()
|
||||
|
||||
def test_commit_message_skips_llm_when_staged_diff_is_empty(self) -> None:
|
||||
# A tracked file modified but NOT staged must never be sent.
|
||||
(self.repo / "README.md").write_text("# Hello v2\n", encoding="utf-8")
|
||||
p = patch.object(git_state, "_llm_call", AsyncMock())
|
||||
with p as client:
|
||||
result = asyncio.run(git_state.commit_message(self.repo))
|
||||
self.assertEqual({"message": "", "notice": "nothing staged"}, result)
|
||||
client.assert_not_called()
|
||||
|
||||
def test_commit_message_degrades_when_model_fails(self) -> None:
|
||||
self._staged_change("feature.txt", "boom\n")
|
||||
p, _, _, _ = self._patch_llm(exc=RuntimeError("no provider configured"))
|
||||
with p:
|
||||
result = asyncio.run(git_state.commit_message(self.repo))
|
||||
self.assertEqual("", result["message"])
|
||||
self.assertIn("model", result["notice"].lower())
|
||||
|
||||
def test_commit_message_degrades_when_model_unavailable(self) -> None:
|
||||
self._staged_change("feature.txt", "x\n")
|
||||
# Resolver itself fails (e.g. `agent` not installed).
|
||||
with patch.object(git_state, "_resolve_llm", side_effect=ImportError("no agent")):
|
||||
result = asyncio.run(git_state.commit_message(self.repo))
|
||||
self.assertEqual("", result["message"])
|
||||
self.assertIn("model", result["notice"].lower())
|
||||
|
||||
def test_commit_message_bounds_staged_diff(self) -> None:
|
||||
# A large staged diff is truncated to MAX_DIFF_BYTES and flagged.
|
||||
big = "x" * (git_state.MAX_DIFF_BYTES + 10)
|
||||
self._staged_change("big.txt", big)
|
||||
p, client, extractor, ep = self._patch_llm(text="add big")
|
||||
with p, ep:
|
||||
result = asyncio.run(git_state.commit_message(self.repo))
|
||||
self.assertEqual("add big", result["message"])
|
||||
_, kwargs = client.call_args
|
||||
payload = "".join(str(m) for m in kwargs["messages"])
|
||||
self.assertLessEqual(len(payload), git_state.MAX_DIFF_BYTES + 2048)
|
||||
self.assertNotIn("nothing staged", result.get("notice", ""))
|
||||
|
||||
|
||||
class CommitMessageSelectedTests(_ExtrasBase):
|
||||
def test_commit_message_selected_honors_only_given_paths(self) -> None:
|
||||
self._staged_change("kept.txt", "kept content\n")
|
||||
self._staged_change("skip.txt", "skip content\n")
|
||||
p, client, extractor, extract_p = self._patch_llm(text="add kept")
|
||||
with p, extract_p:
|
||||
result = asyncio.run(
|
||||
git_state.commit_message_selected(self.repo, ["kept.txt"])
|
||||
)
|
||||
self.assertEqual("add kept", result["message"])
|
||||
_, kwargs = client.call_args
|
||||
payload = "".join(str(m) for m in kwargs["messages"])
|
||||
self.assertIn("kept content", payload)
|
||||
self.assertNotIn("skip content", payload)
|
||||
|
||||
def test_commit_message_selected_skips_llm_when_none_selected_staged(self) -> None:
|
||||
self._staged_change("kept.txt", "kept\n")
|
||||
# A path with no staged content → nothing staged → no call.
|
||||
(self.repo / "other.txt").write_text("other\n", encoding="utf-8")
|
||||
_git(self.repo, "add", "other.txt")
|
||||
# Stage only kept.txt, ask for other.txt's diff (not staged) → nothing.
|
||||
p = patch.object(git_state, "_llm_call", AsyncMock())
|
||||
with p as client:
|
||||
# Ask for a path that has no STAGED diff: unstage other first.
|
||||
_git(self.repo, "restore", "--staged", "other.txt")
|
||||
result = asyncio.run(
|
||||
git_state.commit_message_selected(self.repo, ["other.txt"])
|
||||
)
|
||||
self.assertEqual({"message": "", "notice": "nothing staged"}, result)
|
||||
client.assert_not_called()
|
||||
|
||||
def test_commit_message_selected_rejects_traversal_path(self) -> None:
|
||||
self._staged_change("kept.txt", "kept\n")
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
asyncio.run(
|
||||
git_state.commit_message_selected(self.repo, ["../escape"])
|
||||
)
|
||||
|
||||
|
||||
class StashCheckoutTests(_ExtrasBase):
|
||||
def test_dirty_tree_stashes_then_switches(self) -> None:
|
||||
self._branch("feature")
|
||||
(self.repo / "README.md").write_text("dirty change\n", encoding="utf-8")
|
||||
result = git_state.stash_checkout(self.repo, "feature")
|
||||
self.assertTrue(result["stashed"])
|
||||
self.assertEqual("git-state: feature", result["stash_message"])
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
stash_list = _git(self.repo, "stash", "list")
|
||||
self.assertIn("git-state: feature", stash_list)
|
||||
|
||||
def test_clean_tree_plain_checkout_no_stash(self) -> None:
|
||||
self._branch("feature")
|
||||
result = git_state.stash_checkout(self.repo, "feature")
|
||||
self.assertFalse(result["stashed"])
|
||||
self.assertEqual("", result["stash_message"])
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
self.assertEqual("", _git(self.repo, "stash", "list"))
|
||||
|
||||
def test_clean_tree_new_branch_switch(self) -> None:
|
||||
result = git_state.stash_checkout(self.repo, "main", new_branch="exp")
|
||||
self.assertFalse(result["stashed"])
|
||||
self.assertEqual("exp", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
|
||||
def test_stash_returns_fresh_status_and_branches(self) -> None:
|
||||
self._branch("feature")
|
||||
(self.repo / "README.md").write_text("dirty\n", encoding="utf-8")
|
||||
result = git_state.stash_checkout(self.repo, "feature")
|
||||
self.assertIn("status", result)
|
||||
self.assertIn("branches", result)
|
||||
self.assertIn("head", result)
|
||||
|
||||
def test_bad_ref_raises(self) -> None:
|
||||
(self.repo / "README.md").write_text("still here\n", encoding="utf-8")
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
git_state.stash_checkout(self.repo, "no-such-branch")
|
||||
self.assertEqual("still here\n", (self.repo / "README.md").read_text(encoding="utf-8"))
|
||||
self.assertEqual("", _git(self.repo, "stash", "list"))
|
||||
|
||||
def test_existing_new_branch_is_rejected_before_stashing(self) -> None:
|
||||
(self.repo / "README.md").write_text("still here\n", encoding="utf-8")
|
||||
with self.assertRaisesRegex(git_state.GitError, "already exists"):
|
||||
git_state.stash_checkout(self.repo, "main", new_branch="main")
|
||||
self.assertEqual("still here\n", (self.repo / "README.md").read_text(encoding="utf-8"))
|
||||
self.assertEqual("", _git(self.repo, "stash", "list"))
|
||||
|
||||
def test_checkout_failure_restores_tracked_staged_and_untracked_changes(self) -> None:
|
||||
self._branch("feature")
|
||||
(self.repo / "README.md").write_text("dirty\n", encoding="utf-8")
|
||||
(self.repo / "staged.txt").write_text("staged\n", encoding="utf-8")
|
||||
_git(self.repo, "add", "staged.txt")
|
||||
(self.repo / "untracked.txt").write_text("untracked\n", encoding="utf-8")
|
||||
original_mutate = git_state._mutate
|
||||
|
||||
def fail_checkout(repo: Path, args: list[str]) -> str:
|
||||
if args[0] == "checkout":
|
||||
raise git_state.GitError("forced checkout failure", code="conflict")
|
||||
return original_mutate(repo, args)
|
||||
|
||||
with patch.object(git_state, "_mutate", side_effect=fail_checkout):
|
||||
with self.assertRaisesRegex(git_state.GitError, "working changes were restored"):
|
||||
git_state.stash_checkout(self.repo, "feature")
|
||||
|
||||
self.assertEqual("dirty\n", (self.repo / "README.md").read_text(encoding="utf-8"))
|
||||
self.assertTrue((self.repo / "staged.txt").exists())
|
||||
self.assertTrue((self.repo / "untracked.txt").exists())
|
||||
self.assertIn("staged.txt", _git(self.repo, "diff", "--cached", "--name-only"))
|
||||
self.assertIn("git-state: feature", _git(self.repo, "stash", "list"))
|
||||
|
||||
def test_new_branch_uses_requested_start_point(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
(self.repo / "feature-only.txt").write_text("feature", encoding="utf-8")
|
||||
_git(self.repo, "add", "feature-only.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
|
||||
git_state.stash_checkout(self.repo, "feature", new_branch="from-feature")
|
||||
|
||||
self.assertTrue((self.repo / "feature-only.txt").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Tests for the write (mutation) surface of git_state.
|
||||
|
||||
Security denials are tested FIRST: destructive mutations (discard, push, dirty
|
||||
checkout) reject when their confirmation string is missing or wrong. Then happy
|
||||
paths, then error branches. Fixtures build REAL throwaway git repos and bare
|
||||
remotes in tmp_path — git itself is never mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from plugin import git_state
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path) -> str:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
return _run(["git", "-C", str(repo), *args], repo)
|
||||
|
||||
|
||||
def _init_repo(root: Path, name: str) -> Path:
|
||||
repo = root / name
|
||||
repo.mkdir(parents=True)
|
||||
_run(["git", "init", "-q", "-b", "main"], repo)
|
||||
_run(["git", "config", "user.email", "test@example.com"], repo)
|
||||
_run(["git", "config", "user.name", "Test User"], repo)
|
||||
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
|
||||
_git(repo, "add", "README.md")
|
||||
_git(repo, "commit", "-q", "-m", "initial commit")
|
||||
return repo
|
||||
|
||||
|
||||
def _init_bare_remote(root: Path, name: str) -> Path:
|
||||
remote = root / name
|
||||
remote.mkdir(parents=True, exist_ok=True)
|
||||
_run(["git", "init", "-q", "--bare", "-b", "main"], remote)
|
||||
return remote
|
||||
|
||||
|
||||
class _MutationBase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "write-repo")
|
||||
|
||||
def _make_change(self, path: str = "feature.txt", content: str = "hello") -> None:
|
||||
(self.repo / path).write_text(content, encoding="utf-8")
|
||||
_git(self.repo, "add", path)
|
||||
|
||||
def _head(self) -> str:
|
||||
return _git(self.repo, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
class StageUnstageTests(_MutationBase):
|
||||
def test_stage_moves_untracked_to_staged(self) -> None:
|
||||
(self.repo / "new.txt").write_text("x", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["new.txt"])
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertIn("new.txt", [e["path"] for e in status["staged"]])
|
||||
self.assertNotIn("new.txt", [e["path"] for e in status["untracked"]])
|
||||
|
||||
def test_stage_accepts_multiple_paths(self) -> None:
|
||||
(self.repo / "a.txt").write_text("a", encoding="utf-8")
|
||||
(self.repo / "b.txt").write_text("b", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["a.txt", "b.txt"])
|
||||
status = git_state.repo_status(self.repo)
|
||||
staged = {e["path"] for e in status["staged"]}
|
||||
self.assertTrue({"a.txt", "b.txt"} <= staged)
|
||||
|
||||
def test_unstage_returns_to_modified(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
git_state.unstage(self.repo, ["tracked.txt"])
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertNotIn("tracked.txt", [e["path"] for e in status["staged"]])
|
||||
self.assertIn("tracked.txt", [e["path"] for e in status["modified"]])
|
||||
|
||||
def test_stage_rejects_traversal_path(self) -> None:
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
git_state.stage(self.repo, ["../outside"])
|
||||
|
||||
|
||||
class CommitTests(_MutationBase):
|
||||
def test_commit_creates_a_real_commit(self) -> None:
|
||||
before = self._head()
|
||||
(self.repo / "feature.txt").write_text("feature", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
git_state.commit(self.repo, "add feature")
|
||||
after = self._head()
|
||||
self.assertNotEqual(before, after)
|
||||
message = _git(self.repo, "log", "-1", "--format=%s")
|
||||
self.assertEqual("add feature", message)
|
||||
|
||||
def test_commit_rejects_empty_message(self) -> None:
|
||||
(self.repo / "feature.txt").write_text("feature", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
git_state.commit(self.repo, " ")
|
||||
|
||||
def test_commit_returns_fresh_status(self) -> None:
|
||||
(self.repo / "feature.txt").write_text("feature", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
result = git_state.commit(self.repo, "add feature")
|
||||
self.assertEqual(result["head"], self._head())
|
||||
self.assertEqual(result["status"]["counts"]["staged"], 0)
|
||||
self.assertEqual(result["status"]["counts"]["modified"], 0)
|
||||
|
||||
def test_commit_selected_commits_only_given_paths(self) -> None:
|
||||
(self.repo / "kept.txt").write_text("keep", encoding="utf-8")
|
||||
(self.repo / "skip.txt").write_text("skip", encoding="utf-8")
|
||||
_git(self.repo, "add", "kept.txt", "skip.txt")
|
||||
git_state.commit_selected(self.repo, "commit kept only", ["kept.txt"])
|
||||
head_files = _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD")
|
||||
self.assertIn("kept.txt", head_files)
|
||||
self.assertNotIn("skip.txt", head_files)
|
||||
|
||||
def test_commit_selected_returns_fresh_status(self) -> None:
|
||||
(self.repo / "a.txt").write_text("a", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["a.txt"])
|
||||
result = git_state.commit_selected(self.repo, "commit a", ["a.txt"])
|
||||
self.assertEqual(result["head"], self._head())
|
||||
self.assertEqual(result["status"]["counts"]["staged"], 0)
|
||||
|
||||
def test_commit_selected_rejects_empty_message(self) -> None:
|
||||
(self.repo / "a.txt").write_text("a", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["a.txt"])
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.commit_selected(self.repo, "", ["a.txt"])
|
||||
|
||||
|
||||
class DiscardConfirmationTests(_MutationBase):
|
||||
def test_discard_requires_confirmation_string(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
with self.assertRaises(git_state.GitError) as ctx:
|
||||
git_state.discard(self.repo, ["tracked.txt"], confirmation="")
|
||||
self.assertIn("confirmation", str(ctx.exception).lower())
|
||||
|
||||
def test_discard_rejects_wrong_confirmation(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.discard(self.repo, ["tracked.txt"], confirmation="wrong")
|
||||
|
||||
def test_discard_with_confirmation_reverts_tracked_changes(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
git_state.discard(self.repo, ["tracked.txt"], confirmation=git_state.CONFIRM_DISCARD)
|
||||
content = (self.repo / "tracked.txt").read_text(encoding="utf-8")
|
||||
self.assertEqual("v1", content)
|
||||
|
||||
def test_discard_delete_untracked_removes_untracked(self) -> None:
|
||||
(self.repo / "untracked.txt").write_text("new", encoding="utf-8")
|
||||
git_state.discard(
|
||||
self.repo,
|
||||
["untracked.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
delete_untracked=True,
|
||||
)
|
||||
self.assertFalse((self.repo / "untracked.txt").exists())
|
||||
|
||||
def test_discard_returns_fresh_status(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
result = git_state.discard(
|
||||
self.repo,
|
||||
["tracked.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
)
|
||||
self.assertEqual(result["status"]["counts"]["modified"], 0)
|
||||
|
||||
|
||||
class FetchPullPushTests(_MutationBase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.remote = _init_bare_remote(self.base, "origin-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(self.remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
|
||||
|
||||
def test_fetch_rejects_unknown_and_option_like_remote(self) -> None:
|
||||
for remote in ("https://example.invalid/repo.git", "--all", "missing"):
|
||||
with self.subTest(remote=remote):
|
||||
with self.assertRaisesRegex(git_state.GitError, "remote"):
|
||||
git_state.fetch(self.repo, remote)
|
||||
|
||||
def test_pull_and_push_reject_option_like_branch(self) -> None:
|
||||
with self.assertRaisesRegex(git_state.GitError, "branch"):
|
||||
git_state.pull(self.repo, "origin", "--all")
|
||||
with self.assertRaisesRegex(git_state.GitError, "branch"):
|
||||
git_state.push(self.repo, "origin", "--mirror", git_state.CONFIRM_PUSH)
|
||||
|
||||
def test_fetch_updates_remote_refs(self) -> None:
|
||||
# Advance the remote from a descendant clone (not an independent repo:
|
||||
# an independent root has its own "initial commit" SHA, and when it
|
||||
# lands in a different second than the remote's initial commit the push
|
||||
# is rejected as non-fast-forward, flaking the test).
|
||||
other = self.base / "other"
|
||||
_run(["git", "clone", "-q", str(self.remote), str(other)], self.base)
|
||||
_git(other, "config", "user.email", "test@example.com")
|
||||
_git(other, "config", "user.name", "Test User")
|
||||
(other / "remote.txt").write_text("remote change fetch", encoding="utf-8")
|
||||
_git(other, "add", "remote.txt")
|
||||
_git(other, "commit", "-q", "-m", "remote change fetch")
|
||||
_git(other, "push", "-q", "origin", "main")
|
||||
|
||||
git_state.fetch(self.repo, "origin")
|
||||
remote_main = _git(self.repo, "rev-parse", "origin/main")
|
||||
self.assertNotEqual(remote_main, self._head())
|
||||
|
||||
def test_pull_brings_remote_commits(self) -> None:
|
||||
other = self.base / "other"
|
||||
_run(["git", "clone", "-q", str(self.remote), str(other)], self.base)
|
||||
_git(other, "config", "user.email", "test@example.com")
|
||||
_git(other, "config", "user.name", "Test User")
|
||||
(other / "remote.txt").write_text("remote change pull", encoding="utf-8")
|
||||
_git(other, "add", "remote.txt")
|
||||
_git(other, "commit", "-q", "-m", "remote change pull")
|
||||
_git(other, "push", "-q", "origin", "main")
|
||||
|
||||
git_state.pull(self.repo, "origin", "main")
|
||||
self.assertIn("remote.txt", _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD"))
|
||||
|
||||
def test_push_requires_confirmation(self) -> None:
|
||||
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
git_state.commit(self.repo, "feature")
|
||||
with self.assertRaises(git_state.GitError) as ctx:
|
||||
git_state.push(self.repo, "origin", "main", confirmation="")
|
||||
self.assertIn("confirmation", str(ctx.exception).lower())
|
||||
|
||||
def test_push_rejects_wrong_confirmation(self) -> None:
|
||||
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
git_state.commit(self.repo, "feature")
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.push(self.repo, "origin", "main", confirmation="nope")
|
||||
|
||||
def test_push_with_confirmation_updates_remote(self) -> None:
|
||||
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
git_state.commit(self.repo, "feature")
|
||||
git_state.push(self.repo, "origin", "main", confirmation=git_state.CONFIRM_PUSH)
|
||||
remote_head = _run(["git", "ls-remote", str(self.remote), "refs/heads/main"], self.remote)
|
||||
self.assertIn(self._head(), remote_head)
|
||||
|
||||
def test_pull_with_local_changes_yields_structured_dirty_error(self) -> None:
|
||||
# Repo tracks a file, then the remote advances it. A local uncommitted
|
||||
# edit to that file must never be clobbered by pull → structured dirty.
|
||||
(self.repo / "tracked.txt").write_text("base", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
|
||||
# Advance the remote from a clean clone whose history descends from it.
|
||||
other = self.base / "other"
|
||||
_run(["git", "clone", "-q", str(self.remote), str(other)], self.base)
|
||||
_git(other, "config", "user.email", "test@example.com")
|
||||
_git(other, "config", "user.name", "Test User")
|
||||
(other / "tracked.txt").write_text("remote", encoding="utf-8")
|
||||
_git(other, "add", "tracked.txt")
|
||||
_git(other, "commit", "-q", "-m", "remote tracked")
|
||||
_git(other, "push", "-q", "origin", "main")
|
||||
|
||||
# Local uncommitted change to tracked.txt → git refuses to clobber.
|
||||
(self.repo / "tracked.txt").write_text("uncommitted", encoding="utf-8")
|
||||
with self.assertRaises(git_state.GitError) as ctx:
|
||||
git_state.pull(self.repo, "origin", "main")
|
||||
self.assertEqual("dirty", ctx.exception.code)
|
||||
|
||||
def test_push_auth_failure_maps_to_auth_error(self) -> None:
|
||||
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
git_state.commit(self.repo, "feature")
|
||||
# Point origin at a host that will reject credentials.
|
||||
_git(self.repo, "remote", "set-url", "origin", "https://invalid.invalid/x.git")
|
||||
with self.assertRaises(git_state.GitError) as ctx:
|
||||
git_state.push(self.repo, "origin", "main", confirmation=git_state.CONFIRM_PUSH)
|
||||
self.assertIn(ctx.exception.code, ("auth", "network"))
|
||||
|
||||
|
||||
class CheckoutTests(_MutationBase):
|
||||
def test_checkout_rejects_option_like_ref(self) -> None:
|
||||
with self.assertRaisesRegex(git_state.GitError, "git option"):
|
||||
git_state.checkout(self.repo, "--detach")
|
||||
|
||||
def test_checkout_switches_branch(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
git_state.checkout(self.repo, "feature", confirmation="")
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
|
||||
def test_checkout_new_branch_creates_branch(self) -> None:
|
||||
git_state.checkout(
|
||||
self.repo,
|
||||
"main",
|
||||
new_branch="exp",
|
||||
confirmation="",
|
||||
)
|
||||
self.assertEqual("exp", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
|
||||
def test_checkout_new_branch_uses_requested_start_point(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
(self.repo / "feature-only.txt").write_text("feature", encoding="utf-8")
|
||||
_git(self.repo, "add", "feature-only.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
|
||||
git_state.checkout(self.repo, "feature", new_branch="from-feature")
|
||||
|
||||
self.assertTrue((self.repo / "feature-only.txt").exists())
|
||||
|
||||
def test_checkout_clean_tree_needs_no_confirmation(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
git_state.checkout(self.repo, "feature", confirmation="")
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
|
||||
def test_checkout_dirty_tree_requires_confirmation(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
with self.assertRaises(git_state.GitError) as ctx:
|
||||
git_state.checkout(self.repo, "feature", confirmation="")
|
||||
self.assertIn("confirmation", str(ctx.exception).lower())
|
||||
|
||||
def test_checkout_dirty_tree_wrong_confirmation_rejected(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.checkout(self.repo, "feature", confirmation="wrong")
|
||||
|
||||
def test_checkout_dirty_tree_with_confirmation_switches(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
git_state.checkout(
|
||||
self.repo,
|
||||
"feature",
|
||||
confirmation=git_state.CONFIRM_DIRTY_CHECKOUT,
|
||||
)
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
|
||||
def test_checkout_track_sets_upstream(self) -> None:
|
||||
self.remote = _init_bare_remote(self.base, "remote-bare.git")
|
||||
_git(self.repo, "remote", "add", "origin", str(self.remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
# Create origin/feature remotely via a second clone.
|
||||
other = _init_repo(self.base, "other")
|
||||
_git(other, "remote", "add", "origin", str(self.remote))
|
||||
_git(other, "checkout", "-q", "-b", "feature")
|
||||
(other / "feature.txt").write_text("f", encoding="utf-8")
|
||||
_git(other, "add", "feature.txt")
|
||||
_git(other, "commit", "-q", "-m", "feature")
|
||||
_git(other, "push", "-q", "origin", "feature")
|
||||
# Bring the remote-tracking ref into this repo, then track it.
|
||||
git_state.fetch(self.repo, "origin")
|
||||
git_state.checkout(
|
||||
self.repo,
|
||||
"origin/feature",
|
||||
confirmation="",
|
||||
track=True,
|
||||
)
|
||||
current = _git(self.repo, "symbolic-ref", "--short", "HEAD")
|
||||
self.assertEqual("feature", current)
|
||||
upstream = _git(self.repo, "rev-parse", "--abbrev-ref", "feature@{upstream}")
|
||||
self.assertEqual("origin/feature", upstream)
|
||||
|
||||
|
||||
class AllowlistTests(_MutationBase):
|
||||
def test_mutations_reject_unknown_repo(self) -> None:
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
git_state.stage(Path("/nonexistent"), ["x.txt"])
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
git_state.discard(
|
||||
Path("/nonexistent"),
|
||||
["x.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -46,14 +46,17 @@ class MobilePluginStoreTests(unittest.TestCase):
|
||||
|
||||
manifest = self.store.manifest()
|
||||
self.assertEqual("hermes-relay", manifest["id"])
|
||||
contribution = manifest["contributions"][0]
|
||||
contribution = next(c for c in manifest["contributions"] if c["id"] == "daily-brief")
|
||||
self.assertEqual("Draft: Daily Brief", contribution["title"])
|
||||
self.assertEqual("mobile/pages/daily-brief", contribution["document"]["path"])
|
||||
self.assertEqual(_document(), self.store.get("daily-brief")["document"])
|
||||
|
||||
published = self.store.publish("daily-brief")
|
||||
self.assertEqual("published", published["status"])
|
||||
self.assertEqual("Daily Brief", self.store.manifest()["contributions"][0]["title"])
|
||||
published_contribution = next(
|
||||
c for c in self.store.manifest()["contributions"] if c["id"] == "daily-brief"
|
||||
)
|
||||
self.assertEqual("Daily Brief", published_contribution["title"])
|
||||
|
||||
self.assertEqual({"ok": True, "id": "daily-brief"}, self.store.remove("daily-brief"))
|
||||
self.assertEqual([], self.store.list())
|
||||
@@ -81,6 +84,11 @@ class MobilePluginStoreTests(unittest.TestCase):
|
||||
document={"schemaVersion": 1, "pages": []},
|
||||
)
|
||||
|
||||
def test_rejects_reserved_git_id(self) -> None:
|
||||
with self.assertRaisesRegex(MobilePluginStoreError, "reserved"):
|
||||
self.store.draft("git", title="Shadow", description="", document=_document())
|
||||
self.assertEqual(["git"], [item["id"] for item in self.store.manifest()["contributions"]])
|
||||
|
||||
def test_listing_omits_document_payload(self) -> None:
|
||||
self.store.draft("compact", title="Compact", description="", document=_document())
|
||||
self.assertNotIn("document", self.store.list()[0])
|
||||
|
||||
Reference in New Issue
Block a user