Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94565e9d6d | ||
|
|
56c2e6fa07 | ||
|
|
28629f3d93 | ||
|
|
c9a5c767c6 | ||
|
|
0d1faf47a0 | ||
|
|
00288a2b3b | ||
|
|
8f52feffba | ||
|
|
524e319f95 | ||
|
|
647d1f9aea | ||
|
|
ee29e49361 |
@@ -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
|
||||
|
||||
@@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Returning from parent settings keeps Supervised Chat rendered.** Parent access now relocks without rebuilding the active navigation graph, and full Settings keeps a prominent shortcut back to Supervised Mode controls.
|
||||
|
||||
## [Android 1.13.1] - 2026-08-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android session activity now follows live Hermes runtime truth.** Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work no longer come from the Dashboard's five-minute recency hint, and only complete, unambiguously resolved live snapshots clear stale state.
|
||||
|
||||
## [Android 1.13.0] - 2026-08-25
|
||||
|
||||
+8
-19
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay Android v1.13.0
|
||||
# Hermes-Relay Android v1.13.1
|
||||
|
||||
**Release Date:** August 25, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.13.0-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,28 +12,17 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This feature release adds Bot Mode across saved Hermes gateways, provider usage and limits, and bounded Assistant screen context. It also settles stale Gateway composer state, improves onboarding, and keeps idle Sphere motion efficient.
|
||||
|
||||
## Added
|
||||
|
||||
- Use Bot Mode as one messenger-style workspace across saved Hermes gateways, with exact gateway/profile ownership and read-only group rooms.
|
||||
- Review Codex credential pools, Nous balances, and OpenCode Go windows from one provider-neutral Usage & limits screen.
|
||||
- Start a compatible unlocked Assistant invocation with bounded visible text and an available screenshot in the first Standard voice turn.
|
||||
|
||||
## Changed
|
||||
|
||||
- Follow the Dashboard-first setup path with current screenshots and clearer separation between standard Hermes and optional Relay extensions.
|
||||
- Use clear `Hermes-Relay Android` and isolated `HR Candidate` product names without changing package identities or update behavior.
|
||||
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
|
||||
|
||||
- Settle orphaned Gateway busy state automatically while preserving active or detached turns owned by another session.
|
||||
- Keep the visible idle Sphere gently animated without running hidden, backgrounded, or motion-disabled loops.
|
||||
- Retry Windows-hosted `MEDIA:` attachments through the Relay by-path route instead of treating drive-letter paths as expired tokens.
|
||||
- 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.0** (versionCode **49**).
|
||||
- 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 enhances provider usage, media retry, and device surfaces but remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
|
||||
- The optional Relay plugin remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
|
||||
|
||||
@@ -1 +1 @@
|
||||
Bot Mode now brings bots from saved Hermes gateways into one messenger-style workspace. Settings adds provider-neutral Codex, Nous, and OpenCode Go usage. Compatible Assistant launches can include bounded visible text and an available screenshot. Gateway chats now settle stale busy state automatically, onboarding is clearer, and idle Sphere motion uses less power.
|
||||
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 @@
|
||||
Bot 模式现在可将已保存 Hermes 网关中的机器人汇集到一个消息式工作区。设置新增统一的 Codex、Nous 和 OpenCode Go 用量视图。兼容的助手启动可在首个语音回合中包含受限的可见文本和可用截图。Gateway 聊天会自动清除过期的忙碌状态,引导更清晰,空闲 Sphere 动画也更省电。
|
||||
会话活动现在依据 Hermes 的实时运行状态,而不是最近活动时间估算。工作中、启动中、需要输入、空闲、检查中、不可用和后台工作等状态会保持准确;只有完整且明确的更新才会清除旧状态。
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.13.1",
|
||||
"title": "Accurate session activity",
|
||||
"date": "2026-08-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Follow live Hermes state",
|
||||
"bullets": [
|
||||
"Show Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work from live runtime state instead of a recent-activity estimate.",
|
||||
"Keep stale activity visible until a complete, unambiguous snapshot safely clears it."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.13.0",
|
||||
"title": "Bots, usage, and reliable chat",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
v1.13.0 - Bots, usage, and reliable chat
|
||||
v1.13.1 - Accurate session activity
|
||||
|
||||
* Use Bot Mode across saved Hermes gateways without changing the foreground connection.
|
||||
* Review Codex, Nous, and OpenCode Go usage from one provider-neutral screen.
|
||||
* Include bounded visible text and an available screenshot in compatible Assistant turns.
|
||||
* Keep the composer accurate when Gateway completion frames and visible bubbles settle separately.
|
||||
* 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.
|
||||
|
||||
@@ -54,6 +54,7 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
@@ -855,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,
|
||||
@@ -868,24 +874,28 @@ fun RelayApp() {
|
||||
dashboardFactory = connectionViewModel::dashboardClientForActive,
|
||||
sessionId = currentChatSessionId,
|
||||
)
|
||||
}
|
||||
LaunchedEffect(gitOwnerKey) {
|
||||
val dashboard = effectiveDashboardUrl
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let { connectionViewModel.dashboardClientForActive(it) }
|
||||
gitStateViewModel.configure(dashboard)
|
||||
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) {
|
||||
val granted = (pluginsHubState as? PluginsHubState.Ready)
|
||||
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(granted)
|
||||
gitStateViewModel.setWriteGrant(gitOwnerKey, granted)
|
||||
}
|
||||
|
||||
// What's New auto-show
|
||||
@@ -1189,12 +1199,12 @@ fun RelayApp() {
|
||||
parentAccessForCurrentRoute,
|
||||
currentRoute,
|
||||
) {
|
||||
if (shouldRedirectSupervisedRoute(
|
||||
supervisedEnabled = supervisedPolicy.enabled,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
currentRoute = currentRoute,
|
||||
)
|
||||
) {
|
||||
val redirect = shouldRedirectSupervisedRoute(
|
||||
supervisedEnabled = supervisedPolicy.enabled,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
currentRoute = currentRoute,
|
||||
)
|
||||
if (redirect) {
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(navController.graph.findStartDestination().id) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
@@ -1203,6 +1213,12 @@ fun RelayApp() {
|
||||
}
|
||||
LaunchedEffect(supervisedPolicy.enabled, parentAccessUnlocked, currentRoute) {
|
||||
if (shouldRelockParentAccess(supervisedPolicy.enabled, parentAccessUnlocked, currentRoute)) {
|
||||
// Route-scoped authority is already false on Chat. Let Navigation
|
||||
// finish committing the new destination before clearing the raw
|
||||
// parent grant, otherwise the same-frame root recomposition can
|
||||
// leave a themed but contentless surface.
|
||||
withFrameNanos { }
|
||||
withFrameNanos { }
|
||||
parentAccessUnlocked = false
|
||||
}
|
||||
}
|
||||
@@ -2597,7 +2613,7 @@ fun RelayApp() {
|
||||
connectionViewModel = connectionViewModel,
|
||||
chatViewModel = chatViewModel,
|
||||
supervisedPolicy = supervisedPolicy,
|
||||
parentAccessUnlocked = parentAccessUnlocked,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
onRequestParentAccess = { parentAccessUnlocked = true },
|
||||
onUpdateSupervisedPolicy = { policy ->
|
||||
activeConnectionId?.let { connectionId ->
|
||||
@@ -2612,6 +2628,9 @@ fun RelayApp() {
|
||||
onNavigateToSupervisedAppearance = {
|
||||
navController.navigate(Screen.SupervisedAppearanceSettings.route)
|
||||
},
|
||||
onNavigateToSupervisedControls = {
|
||||
navController.navigate(Screen.SupervisedControls.route)
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
// (The `onNavigateToChatWithAgentSheet` callback that
|
||||
// used to live here was removed 2026-04-21. Tapping
|
||||
@@ -2687,7 +2706,7 @@ fun RelayApp() {
|
||||
)
|
||||
}
|
||||
composable(Screen.AdvancedSettings.route) {
|
||||
if (!parentAccessUnlocked && supervisedPolicy.enabled) {
|
||||
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
} else {
|
||||
AdvancedSettingsScreen(
|
||||
@@ -2700,7 +2719,7 @@ fun RelayApp() {
|
||||
}
|
||||
}
|
||||
composable(Screen.SupervisedAppearanceSettings.route) {
|
||||
if (!supervisedPolicy.enabled && !parentAccessUnlocked) {
|
||||
if (!supervisedPolicy.enabled && !parentAccessForCurrentRoute) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
} else {
|
||||
SupervisedAppearanceSettingsScreen(
|
||||
@@ -2718,7 +2737,7 @@ fun RelayApp() {
|
||||
}
|
||||
}
|
||||
composable(Screen.SupervisedControls.route) {
|
||||
if (!parentAccessUnlocked && supervisedPolicy.enabled) {
|
||||
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
} else {
|
||||
SupervisedControlsScreen(
|
||||
@@ -3230,7 +3249,7 @@ fun RelayApp() {
|
||||
AboutScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
allowDeveloperUnlock = !supervisedPolicy.enabled || parentAccessUnlocked,
|
||||
allowDeveloperUnlock = !supervisedPolicy.enabled || parentAccessForCurrentRoute,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
|
||||
@@ -55,6 +55,7 @@ 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 →
|
||||
@@ -72,7 +73,7 @@ fun GitStateScreen(
|
||||
val detailState by viewModel.detail.collectAsState()
|
||||
val contentState by viewModel.content.collectAsState()
|
||||
val mutationState by viewModel.mutation.collectAsState()
|
||||
val hasGrant = viewModel.hasWriteGrant()
|
||||
val hasGrant by viewModel.writeGrant.collectAsState()
|
||||
|
||||
// Hoisted at screen level so confirmation/commit dialogs are modal.
|
||||
var pendingConfirm by remember { mutableStateOf<ConfirmationRequest?>(null) }
|
||||
@@ -150,18 +151,26 @@ fun GitStateScreen(
|
||||
onStage = { path -> viewModel.stage(listOf(path)) },
|
||||
onUnstage = { path -> viewModel.unstage(listOf(path)) },
|
||||
onDiscard = { paths, deleteUntracked ->
|
||||
pendingConfirm = ConfirmationRequest.Discard(paths, deleteUntracked)
|
||||
viewModel.currentTarget()?.let { target ->
|
||||
pendingConfirm = ConfirmationRequest.Discard(paths, deleteUntracked, target)
|
||||
}
|
||||
},
|
||||
onCommitRequest = { showCommitDialog = true },
|
||||
onFetch = { viewModel.fetch() },
|
||||
onPull = { viewModel.pull() },
|
||||
onPush = { pendingConfirm = ConfirmationRequest.Push },
|
||||
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) {
|
||||
pendingConfirm = ConfirmationRequest.DirtyCheckout(ref)
|
||||
viewModel.currentTarget()?.let { target ->
|
||||
pendingConfirm = ConfirmationRequest.DirtyCheckout(ref, target)
|
||||
}
|
||||
} else {
|
||||
viewModel.checkout(ref)
|
||||
}
|
||||
@@ -197,9 +206,10 @@ fun GitStateScreen(
|
||||
onPushAfterCommitChange = { pushAfterCommit = it },
|
||||
onCommit = { message ->
|
||||
showCommitDialog = false
|
||||
viewModel.commit(message)
|
||||
if (pushAfterCommit) {
|
||||
pendingConfirm = ConfirmationRequest.Push
|
||||
viewModel.commit(message) { committedTarget ->
|
||||
if (pushAfterCommit) {
|
||||
pendingConfirm = ConfirmationRequest.Push(committedTarget)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -219,6 +229,7 @@ fun GitStateScreen(
|
||||
request.paths,
|
||||
GitConfirmationStrings.DISCARD,
|
||||
request.deleteUntracked,
|
||||
request.target,
|
||||
)
|
||||
}) {
|
||||
Text(stringResource(R.string.git_state_confirm_discard_confirm))
|
||||
@@ -230,14 +241,17 @@ fun GitStateScreen(
|
||||
}
|
||||
},
|
||||
)
|
||||
ConfirmationRequest.Push -> AlertDialog(
|
||||
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)
|
||||
viewModel.push(
|
||||
GitConfirmationStrings.PUSH,
|
||||
expectedTarget = request.target,
|
||||
)
|
||||
}) {
|
||||
Text(stringResource(R.string.git_state_confirm_push_confirm))
|
||||
}
|
||||
@@ -255,7 +269,11 @@ fun GitStateScreen(
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
pendingConfirm = null
|
||||
viewModel.checkout(request.ref, GitConfirmationStrings.DIRTY_CHECKOUT)
|
||||
viewModel.checkout(
|
||||
request.ref,
|
||||
GitConfirmationStrings.DIRTY_CHECKOUT,
|
||||
expectedTarget = request.target,
|
||||
)
|
||||
}) {
|
||||
Text(stringResource(R.string.git_state_confirm_checkout_confirm))
|
||||
}
|
||||
@@ -272,9 +290,14 @@ fun GitStateScreen(
|
||||
|
||||
/** A destructive action awaiting explicit user confirmation. */
|
||||
private sealed interface ConfirmationRequest {
|
||||
data class Discard(val paths: List<String>, val deleteUntracked: Boolean) : ConfirmationRequest
|
||||
data object Push : ConfirmationRequest
|
||||
data class DirtyCheckout(val ref: String) : 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
|
||||
|
||||
@@ -165,6 +165,7 @@ fun SettingsScreen(
|
||||
onUpdateSupervisedPolicy: (SupervisedModePolicy) -> Unit = {},
|
||||
onNavigateToAdvancedSettings: () -> Unit = {},
|
||||
onNavigateToSupervisedAppearance: () -> Unit = {},
|
||||
onNavigateToSupervisedControls: () -> Unit = {},
|
||||
// Needed by the Active Agent summary card at the top of the screen — it
|
||||
// reads the current personality pick so the subtitle can render
|
||||
// `connection · model · personality` without re-reading ChatViewModel
|
||||
@@ -455,6 +456,20 @@ fun SettingsScreen(
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (supervisedPolicy?.enabled == true && parentAccessUnlocked) {
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Security,
|
||||
title = "Supervised mode",
|
||||
subtitle = "On · ${supervisedPolicy.pinnedProfileName.orEmpty()}",
|
||||
badge = SettingsStatusPillModel(
|
||||
label = "On",
|
||||
tone = SettingsStatusTone.Good,
|
||||
),
|
||||
onClick = onNavigateToSupervisedControls,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Active Agent summary ───────────────────────────────────
|
||||
// Mirrors the ChatScreen TopAppBar title block (avatar + name
|
||||
// + one-line `connection · model · personality` subtitle).
|
||||
|
||||
@@ -388,6 +388,16 @@ fun SupervisedControlsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (policy.enabled) {
|
||||
SupervisedNavigationRow(
|
||||
icon = Icons.Filled.Lock,
|
||||
title = "Return to supervised view",
|
||||
subtitle = "Lock parent access and open the pinned agent chat",
|
||||
onClick = onReturnToSupervisedView,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
"This mode restricts this Android client. The selected Hermes profile remains responsible for agent tools and content policy.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -746,15 +756,6 @@ fun SupervisedControlsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (policy.enabled) {
|
||||
TextButton(
|
||||
onClick = onReturnToSupervisedView,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.Lock, contentDescription = null)
|
||||
Text("Return to supervised view", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,14 +62,21 @@ object GitConfirmationStrings {
|
||||
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`` receives the grant set and every mutation refuses (surfacing a
|
||||
* readable message, never a POST) when the grant is absent. Destructive ops
|
||||
* ``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.
|
||||
*/
|
||||
@@ -96,37 +103,73 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
|
||||
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 loadJob: Job? = 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
|
||||
private var writeGrant: Boolean = false
|
||||
|
||||
fun selectedRepoIdForDisplay(): String? = selectedRepoId
|
||||
|
||||
fun configure(dashboard: DashboardApiClient?) {
|
||||
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(granted: Boolean) {
|
||||
writeGrant = granted
|
||||
fun setWriteGrant(ownerKey: String?, granted: Boolean) {
|
||||
if (ownerKey != scopeKey) return
|
||||
_writeGrant.value = granted
|
||||
}
|
||||
|
||||
fun hasWriteGrant(): Boolean = writeGrant
|
||||
fun hasWriteGrant(): Boolean = _writeGrant.value
|
||||
|
||||
fun loadRepos() {
|
||||
val client = api ?: run {
|
||||
_repos.value = GitStateUiState.Error("Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
loadJob?.cancel()
|
||||
loadJob = viewModelScope.launch {
|
||||
val expectedScope = scopeKey
|
||||
reposJob?.cancel()
|
||||
reposJob = viewModelScope.launch {
|
||||
_repos.value = GitStateUiState.Loading
|
||||
client.repos().fold(
|
||||
onSuccess = { list -> _repos.value = GitStateUiState.Ready(list, null) },
|
||||
onSuccess = { list ->
|
||||
if (scopeKey == expectedScope) {
|
||||
_repos.value = GitStateUiState.Ready(list, null)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
_repos.value = GitStateUiState.Error(error.message ?: "Failed to load repositories")
|
||||
if (scopeKey == expectedScope) {
|
||||
_repos.value = GitStateUiState.Error(error.message ?: "Failed to load repositories")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -134,14 +177,18 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
|
||||
|
||||
fun selectRepo(repoId: String) {
|
||||
val client = api ?: return
|
||||
targetGeneration += 1
|
||||
selectedRepoId = repoId
|
||||
val target = currentTarget() ?: return
|
||||
_content.value = GitContentViewState.Idle
|
||||
_mutation.value = GitMutationState.Idle
|
||||
loadJob?.cancel()
|
||||
loadJob = viewModelScope.launch {
|
||||
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",
|
||||
@@ -154,87 +201,114 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs a mutation through the shared gate (grant + confirmation). */
|
||||
private fun runMutation(label: String, block: suspend (GitStateApiClient, String) -> Result<GitMutationState>) {
|
||||
/** 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 repoId = selectedRepoId ?: run {
|
||||
val target = currentTarget() ?: run {
|
||||
_mutation.value = GitMutationState.Error(label, "No repository selected")
|
||||
return
|
||||
}
|
||||
if (!writeGrant) {
|
||||
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
|
||||
}
|
||||
loadJob?.cancel()
|
||||
loadJob = viewModelScope.launch {
|
||||
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, repoId).fold(
|
||||
block(client, target.repoId).fold(
|
||||
onSuccess = {
|
||||
if (currentTarget() != target) return@fold
|
||||
_mutation.value = it
|
||||
_content.value = GitContentViewState.Idle
|
||||
refreshDetail(repoId)
|
||||
refreshDetail(client, target)
|
||||
onSuccess(target)
|
||||
},
|
||||
onFailure = { error ->
|
||||
_mutation.value = GitMutationState.Error(
|
||||
label,
|
||||
error.message ?: "Git action failed",
|
||||
)
|
||||
if (currentTarget() == target) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
label,
|
||||
error.message ?: "Git action failed",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshDetail(repoId: String) {
|
||||
val client = api ?: return
|
||||
viewModelScope.launch {
|
||||
val statusResult = client.status(repoId)
|
||||
val branchesResult = client.branches(repoId)
|
||||
if (statusResult.isSuccess) {
|
||||
_detail.value = GitRepoDetailState.Ready(
|
||||
statusResult.getOrDefault(GitStatus()),
|
||||
branchesResult.getOrDefault(emptyList()),
|
||||
)
|
||||
}
|
||||
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 repoId = selectedRepoId ?: return
|
||||
val target = currentTarget() ?: return
|
||||
val client = api ?: return
|
||||
loadJob?.cancel()
|
||||
loadJob = viewModelScope.launch {
|
||||
contentJob?.cancel()
|
||||
contentJob = viewModelScope.launch {
|
||||
_content.value = GitContentViewState.Loading
|
||||
client.diff(repoId, path, kind).fold(
|
||||
onSuccess = { diff -> _content.value = GitContentViewState.Diff(diff) },
|
||||
client.diff(target.repoId, path, kind).fold(
|
||||
onSuccess = { diff ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.Diff(diff)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
_content.value = GitContentViewState.Error(
|
||||
error.message ?: "Failed to load diff",
|
||||
)
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.Error(
|
||||
error.message ?: "Failed to load diff",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFile(path: String) {
|
||||
val repoId = selectedRepoId ?: return
|
||||
val target = currentTarget() ?: return
|
||||
val client = api ?: return
|
||||
loadJob?.cancel()
|
||||
loadJob = viewModelScope.launch {
|
||||
contentJob?.cancel()
|
||||
contentJob = viewModelScope.launch {
|
||||
_content.value = GitContentViewState.Loading
|
||||
client.file(repoId, path).fold(
|
||||
onSuccess = { file -> _content.value = GitContentViewState.File(file) },
|
||||
client.file(target.repoId, path).fold(
|
||||
onSuccess = { file ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.File(file)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
_content.value = GitContentViewState.Error(
|
||||
error.message ?: "Failed to load file",
|
||||
)
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.Error(
|
||||
error.message ?: "Failed to load file",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -250,17 +324,23 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
|
||||
c.unstage(r, paths).map { GitMutationState.Success("unstage", it.head) }
|
||||
}
|
||||
|
||||
fun discard(paths: List<String>, confirmation: String, deleteUntracked: Boolean = false) =
|
||||
runMutation("Discard") { c, r ->
|
||||
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) = runMutation("Commit") { c, r ->
|
||||
c.commit(r, message).map {
|
||||
GitMutationState.Success("commit", 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 {
|
||||
@@ -276,8 +356,13 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
|
||||
c.pull(r, remote, branch).map { GitMutationState.Success("pull", it.head) }
|
||||
}
|
||||
|
||||
fun push(confirmation: String, remote: String = "origin", branch: String = "") =
|
||||
runMutation("Push") { c, r ->
|
||||
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) }
|
||||
}
|
||||
|
||||
@@ -286,7 +371,8 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
|
||||
confirmation: String? = null,
|
||||
newBranch: String = "",
|
||||
track: Boolean = false,
|
||||
) = runMutation("Checkout") { c, r ->
|
||||
expectedTarget: GitTarget? = null,
|
||||
) = runMutation("Checkout", expectedTarget = expectedTarget) { c, r ->
|
||||
c.checkout(r, ref, confirmation, newBranch, track)
|
||||
.map { GitMutationState.Success("checkout", it.head) }
|
||||
}
|
||||
@@ -309,25 +395,26 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
|
||||
GitMessageGenerationState.Ready("", "Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val repoId = selectedRepoId ?: run {
|
||||
val target = currentTarget() ?: run {
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready("", "No repository selected")
|
||||
return
|
||||
}
|
||||
if (!writeGrant) {
|
||||
if (!_writeGrant.value) {
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready(
|
||||
"",
|
||||
"Allow plugin changes (plugin.api.write) before using this action.",
|
||||
)
|
||||
return
|
||||
}
|
||||
loadJob?.cancel()
|
||||
loadJob = viewModelScope.launch {
|
||||
messageJob?.cancel()
|
||||
messageJob = viewModelScope.launch {
|
||||
_messageGeneration.value = GitMessageGenerationState.Loading
|
||||
val result = if (paths != null) {
|
||||
client.commitMessageSelected(repoId, paths)
|
||||
client.commitMessageSelected(target.repoId, paths)
|
||||
} else {
|
||||
client.commitMessage(repoId)
|
||||
client.commitMessage(target.repoId)
|
||||
}
|
||||
if (currentTarget() != target) return@launch
|
||||
result.fold(
|
||||
onSuccess = { msg ->
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready(msg.message, msg.notice)
|
||||
@@ -351,11 +438,11 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
|
||||
_mutation.value = GitMutationState.Error("Stash Checkout", "Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val repoId = selectedRepoId ?: run {
|
||||
val target = currentTarget() ?: run {
|
||||
_mutation.value = GitMutationState.Error("Stash Checkout", "No repository selected")
|
||||
return
|
||||
}
|
||||
if (!writeGrant) {
|
||||
if (!_writeGrant.value) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
"Stash Checkout",
|
||||
"Allow plugin changes (plugin.api.write) before using this action.",
|
||||
@@ -363,24 +450,35 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
|
||||
return
|
||||
}
|
||||
_stashNotice.value = null
|
||||
loadJob?.cancel()
|
||||
loadJob = viewModelScope.launch {
|
||||
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(repoId, ref, newBranch, track).fold(
|
||||
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(repoId)
|
||||
refreshDetail(client, target)
|
||||
},
|
||||
onFailure = { error ->
|
||||
_mutation.value = GitMutationState.Error(
|
||||
"Stash Checkout",
|
||||
error.message ?: "Git action failed",
|
||||
)
|
||||
if (currentTarget() == target) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
"Stash Checkout",
|
||||
error.message ?: "Git action failed",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
+3
-2
@@ -27,6 +27,7 @@ import org.robolectric.annotation.Config
|
||||
@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
|
||||
@@ -46,8 +47,8 @@ class GitStateExtrasViewModelTest {
|
||||
|
||||
private fun viewModel(grant: Boolean = true): GitStateViewModel {
|
||||
val vm = GitStateViewModel(application)
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()))
|
||||
vm.setWriteGrant(grant)
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
|
||||
vm.setWriteGrant(ownerKey, grant)
|
||||
return vm
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import org.robolectric.annotation.Config
|
||||
@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
|
||||
@@ -48,7 +49,7 @@ class GitStateViewModelTest {
|
||||
|
||||
private fun viewModel(): GitStateViewModel {
|
||||
val vm = GitStateViewModel(application)
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()))
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
|
||||
return vm
|
||||
}
|
||||
|
||||
|
||||
+47
-2
@@ -16,6 +16,7 @@ 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
|
||||
@@ -27,6 +28,7 @@ import org.robolectric.annotation.Config
|
||||
@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
|
||||
@@ -46,8 +48,8 @@ class GitStateWriteViewModelTest {
|
||||
|
||||
private fun viewModel(grant: Boolean = true): GitStateViewModel {
|
||||
val vm = GitStateViewModel(application)
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()))
|
||||
vm.setWriteGrant(grant)
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
|
||||
vm.setWriteGrant(ownerKey, grant)
|
||||
return vm
|
||||
}
|
||||
|
||||
@@ -139,6 +141,49 @@ class GitStateWriteViewModelTest {
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.0 - Bots, usage, and reliable chat
|
||||
v1.13.1 - Accurate session activity
|
||||
|
||||
Bot Mode now brings bots from saved Hermes gateways into one messenger-style workspace. Settings adds provider-neutral Codex, Nous, and OpenCode Go usage. Compatible Assistant launches can include bounded visible text and an available screenshot. Gateway chats now settle stale busy state automatically, onboarding is clearer, and idle Sphere motion uses less power.
|
||||
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.0"
|
||||
appVersionCode = "49"
|
||||
appVersionName = "1.13.1"
|
||||
appVersionCode = "50"
|
||||
agp = "9.3.2"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.08.00"
|
||||
|
||||
Vendored
+7
-7
File diff suppressed because one or more lines are too long
@@ -228,11 +228,12 @@ async def post_push(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
@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),
|
||||
_ref(body, allow_empty=bool(new_branch)),
|
||||
confirmation=body.get("confirmation"),
|
||||
new_branch=_str_opt(body, "new_branch"),
|
||||
new_branch=new_branch,
|
||||
track=bool(body.get("track", False)),
|
||||
)
|
||||
except git_state.GitError as exc:
|
||||
@@ -251,10 +252,11 @@ async def post_stash_checkout(body: dict[str, Any] = Body(...)) -> dict[str, Any
|
||||
stash after a successful switch.
|
||||
"""
|
||||
try:
|
||||
new_branch = _str_opt(body, "new_branch")
|
||||
return git_state.stash_checkout(
|
||||
_require_repo(body),
|
||||
_ref(body),
|
||||
new_branch=_str_opt(body, "new_branch"),
|
||||
_ref(body, allow_empty=bool(new_branch)),
|
||||
new_branch=new_branch,
|
||||
track=bool(body.get("track", False)),
|
||||
)
|
||||
except git_state.GitError as exc:
|
||||
@@ -307,16 +309,22 @@ def _message(body: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def _remote(body: dict[str, Any]) -> str:
|
||||
return body.get("remote") or "origin"
|
||||
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:
|
||||
return body.get("branch") or ""
|
||||
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]) -> str:
|
||||
ref = body.get("ref")
|
||||
if not isinstance(ref, str) or not ref:
|
||||
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
|
||||
|
||||
|
||||
@@ -167,9 +167,9 @@ export function getGitFile(repo, path) {
|
||||
}
|
||||
|
||||
// ── Git State write operations ────────────────────────────────────────────
|
||||
// Every write POST goes through the authenticated plugin namespace and is
|
||||
// gated by the plugin.api.write grant (enforced client-side before any POST
|
||||
// is sent). Destructive ops pass a per-use confirmation token in the body.
|
||||
// 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, {
|
||||
@@ -200,10 +200,6 @@ export function gitCommit(repo, message) {
|
||||
return postGit("/git/commit", { repo, message });
|
||||
}
|
||||
|
||||
export function gitCommitSelected(repo, message, paths) {
|
||||
return postGit("/git/commit_selected", { repo, message, paths });
|
||||
}
|
||||
|
||||
export function gitFetch(repo, remote = "origin") {
|
||||
return postGit("/git/fetch", { repo, remote });
|
||||
}
|
||||
|
||||
@@ -126,6 +126,14 @@ 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}.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const SDK = window.__HERMES_PLUGIN_SDK__;
|
||||
const { React } = SDK;
|
||||
const { useState, useEffect, useCallback } = SDK.hooks;
|
||||
const { useState, useEffect, useCallback, useRef } = SDK.hooks;
|
||||
|
||||
import {
|
||||
getGitRepos,
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
hasCommitSuggestion,
|
||||
requiresConfirmation,
|
||||
confirmationFor,
|
||||
isCurrentRepoRequest,
|
||||
shouldOfferPushAfterCommit,
|
||||
} from "../lib/git-state.mjs";
|
||||
import {
|
||||
Alert,
|
||||
@@ -122,10 +124,9 @@ function BranchesRow({ branches }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Write controls for the GitState tab. Every mutation is gated by the
|
||||
* plugin.api.write grant (the tab is only reachable after the user grants it)
|
||||
* and destructive ops (discard/push/dirty-checkout) are confirmed via the
|
||||
* per-use confirmation-string mechanics before the POST is sent.
|
||||
* 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,
|
||||
@@ -143,6 +144,8 @@ function WriteControls({
|
||||
pushAfterCommit,
|
||||
onPushAfterCommitChange,
|
||||
onStageAll,
|
||||
onUnstageAll,
|
||||
onDiscardAll,
|
||||
onStage,
|
||||
onUnstage,
|
||||
onDiscard,
|
||||
@@ -162,13 +165,13 @@ function WriteControls({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={mutating || modified.length === 0} onClick={() => modified.forEach(onStage)}>
|
||||
<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={() => staged.forEach(onUnstage)}>
|
||||
<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={() => staged.forEach(onDiscard)}>
|
||||
<Button size="sm" variant="outline" disabled={mutating || staged.length === 0} onClick={onDiscardAll}>
|
||||
Discard staged
|
||||
</Button>
|
||||
</div>
|
||||
@@ -264,6 +267,9 @@ export default function GitState({ autoRefresh }) {
|
||||
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);
|
||||
@@ -272,19 +278,24 @@ export default function GitState({ autoRefresh }) {
|
||||
const list = (data && data.repos) || [];
|
||||
setRepos(list);
|
||||
setNotice((data && data.notice) || null);
|
||||
if (selected && !list.some((r) => r.id === selected)) {
|
||||
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);
|
||||
}
|
||||
}, [selected]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadRepos();
|
||||
@@ -297,47 +308,64 @@ export default function GitState({ autoRefresh }) {
|
||||
}, [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) => {
|
||||
if (!selected) return;
|
||||
const repoId = selectedRef.current;
|
||||
if (!repoId) return;
|
||||
const generation = requestGenerationRef.current;
|
||||
setFile(null);
|
||||
setError(null);
|
||||
try {
|
||||
setDiff(await getGitDiff(selected, path, kind));
|
||||
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));
|
||||
}
|
||||
}, [selected]);
|
||||
}, []);
|
||||
|
||||
const showFile = useCallback(async (path) => {
|
||||
if (!selected) return;
|
||||
const repoId = selectedRef.current;
|
||||
if (!repoId) return;
|
||||
const generation = requestGenerationRef.current;
|
||||
setDiff(null);
|
||||
setError(null);
|
||||
try {
|
||||
setFile(await getGitFile(selected, path));
|
||||
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));
|
||||
}
|
||||
}, [selected]);
|
||||
}, []);
|
||||
|
||||
// ── Write controls (gated by plugin.api.write + confirmations) ───────────
|
||||
// ── Authenticated Dashboard write controls + confirmations ──────────────
|
||||
const [commitMessage, setCommitMessage] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
const [branchRef, setBranchRef] = useState("");
|
||||
@@ -347,44 +375,55 @@ export default function GitState({ autoRefresh }) {
|
||||
const [commitNotice, setCommitNotice] = useState(null);
|
||||
const [pushAfterCommit, setPushAfterCommit] = useState(false);
|
||||
|
||||
const refreshDetail = useCallback(async (repoId) => {
|
||||
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) => {
|
||||
if (!selected) return;
|
||||
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(selected, paths);
|
||||
else if (op === "unstage") await gitUnstage(selected, paths);
|
||||
else if (op === "fetch") await gitFetch(selected, opts?.remote || "origin");
|
||||
else if (op === "pull") await gitPull(selected, opts?.remote || "origin", opts?.branch || "");
|
||||
else if (op === "commit") await gitCommit(selected, opts?.message);
|
||||
else if (op === "commitSelected") await gitCommitSelected(selected, opts?.message, paths);
|
||||
else if (op === "discard") await gitDiscard(selected, paths, opts?.confirmation, opts?.deleteUntracked);
|
||||
else if (op === "push") await gitPush(selected, opts?.confirmation, opts?.remote || "origin", opts?.branch || "");
|
||||
else if (op === "dirty-checkout") {
|
||||
await gitCheckout(selected, opts.ref, {
|
||||
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(selected);
|
||||
await refreshDetail(repoId, generation);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setMutationError(err && err.message ? err.message : String(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);
|
||||
}
|
||||
},
|
||||
[selected, refreshDetail],
|
||||
[refreshDetail],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -392,14 +431,17 @@ export default function GitState({ autoRefresh }) {
|
||||
* staged diff / model-unavailable degrade to a notice, never an error.
|
||||
*/
|
||||
const generateMessage = useCallback(async () => {
|
||||
if (!selected) return;
|
||||
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
|
||||
? await gitCommitMessageSelected(selected, stagedPaths)
|
||||
: await gitCommitMessage(selected);
|
||||
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);
|
||||
@@ -408,11 +450,14 @@ export default function GitState({ autoRefresh }) {
|
||||
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 {
|
||||
setGeneratingMessage(false);
|
||||
if (isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) {
|
||||
setGeneratingMessage(false);
|
||||
}
|
||||
}
|
||||
}, [selected, status]);
|
||||
}, [status]);
|
||||
|
||||
/**
|
||||
* Stash-checkout: switch branches, auto-stashing a dirty tree first. No
|
||||
@@ -421,30 +466,37 @@ export default function GitState({ autoRefresh }) {
|
||||
*/
|
||||
const doStashCheckout = useCallback(async () => {
|
||||
const ref = branchRef.trim();
|
||||
if (!ref || !selected) return;
|
||||
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(selected, ref, {
|
||||
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(selected);
|
||||
await refreshDetail(repoId, generation);
|
||||
} catch (err) {
|
||||
setMutationError(err && err.message ? err.message : String(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("");
|
||||
}
|
||||
}, [selected, branchRef, newBranch, refreshDetail]);
|
||||
}, [branchRef, newBranch, refreshDetail]);
|
||||
|
||||
/**
|
||||
* Destructive ops (discard, push, dirty-checkout) gate on a per-use
|
||||
@@ -476,17 +528,13 @@ export default function GitState({ autoRefresh }) {
|
||||
setMutationError("Commit message must not be empty.");
|
||||
return;
|
||||
}
|
||||
const stagedPaths = (status && status.staged || []).map((e) => e.path);
|
||||
if (stagedPaths.length > 0) {
|
||||
await applyMutation("commitSelected", stagedPaths, { message });
|
||||
} else {
|
||||
await applyMutation("commit", [], { message });
|
||||
}
|
||||
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 (pushAfterCommit) {
|
||||
if (shouldOfferPushAfterCommit(succeeded, pushAfterCommit)) {
|
||||
requestMutation("push", {});
|
||||
}
|
||||
}, [commitMessage, status, applyMutation, pushAfterCommit, requestMutation]);
|
||||
@@ -601,6 +649,8 @@ export default function GitState({ autoRefresh }) {
|
||||
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 })}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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", () => {
|
||||
@@ -49,6 +52,33 @@ test("requiresConfirmation gates only destructive ops", () => {
|
||||
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 {
|
||||
|
||||
@@ -88,6 +88,17 @@ class GitWriteApiTests(unittest.TestCase):
|
||||
)
|
||||
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")
|
||||
|
||||
@@ -92,6 +92,15 @@ class MobilePluginApiTests(unittest.TestCase):
|
||||
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(
|
||||
"/mobile/plugins/..%5Coutside/draft",
|
||||
|
||||
+228
-75
@@ -11,8 +11,7 @@ Security contract
|
||||
- ``repo`` params are opaque ids resolved against the scanned repo set; an
|
||||
unknown id is rejected before any filesystem access.
|
||||
- File paths are validated to reject traversal (``..``), absolute escapes, and
|
||||
null bytes. Git itself treats paths as repo-relative, so this is defense in
|
||||
depth.
|
||||
null bytes. Working-tree reads additionally require canonical containment.
|
||||
- Remote URLs are scrubbed of embedded userinfo before they reach any client.
|
||||
"""
|
||||
|
||||
@@ -22,6 +21,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -33,6 +33,9 @@ logger = logging.getLogger(__name__)
|
||||
MAX_STATUS_ENTRIES = int(os.environ.get("GIT_STATE_MAX_STATUS_ENTRIES", "200"))
|
||||
MAX_DIFF_BYTES = int(os.environ.get("GIT_STATE_MAX_DIFF_BYTES", "64_000"))
|
||||
MAX_FILE_BYTES = int(os.environ.get("GIT_STATE_MAX_FILE_BYTES", "256_000"))
|
||||
MAX_GIT_OUTPUT_BYTES = int(os.environ.get("GIT_STATE_MAX_OUTPUT_BYTES", "1_000_000"))
|
||||
MAX_GIT_ERROR_BYTES = int(os.environ.get("GIT_STATE_MAX_ERROR_BYTES", "16_000"))
|
||||
MAX_GIT_SCALAR_LENGTH = 512
|
||||
GIT_TIMEOUT_SECONDS = float(os.environ.get("GIT_STATE_TIMEOUT_SECONDS", "10"))
|
||||
|
||||
# Default base path for repo discovery.
|
||||
@@ -42,6 +45,8 @@ _BASE_PATH_ENV = "GIT_STATE_BASE_PATH"
|
||||
# Matches a remote URL's userinfo (user[:password]@) so it can be scrubbed.
|
||||
_USERINFO_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*://)([^/@]+)@")
|
||||
_SSH_USERINFO_RE = re.compile(r"^([^/@:]+)@([^:]+):")
|
||||
_ERROR_USERINFO_RE = re.compile(r"([a-zA-Z][a-zA-Z0-9+.-]*://)([^\s/@]+)@")
|
||||
_ERROR_SSH_USERINFO_RE = re.compile(r"(?<![\w@])([^\s/@:]+)@([^\s:]+):")
|
||||
|
||||
|
||||
class GitStateError(ValueError):
|
||||
@@ -81,26 +86,68 @@ def base_path() -> Path:
|
||||
return Path(raw).expanduser()
|
||||
|
||||
|
||||
def _run_git_bounded(
|
||||
repo: Path,
|
||||
args: list[str],
|
||||
*,
|
||||
mutation: bool,
|
||||
) -> tuple[int, str, str]:
|
||||
"""Run Git without materializing unbounded stdout or stderr in memory."""
|
||||
error_type = GitError if mutation else GitStateError
|
||||
with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
stdout=stdout_file,
|
||||
stderr=stderr_file,
|
||||
timeout=GIT_TIMEOUT_SECONDS,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
if mutation:
|
||||
raise GitError(f"git timed out for {repo.name}", code="network") from exc
|
||||
raise GitStateError(f"git timed out for {repo.name}") from exc
|
||||
except OSError as exc:
|
||||
if mutation:
|
||||
raise GitError(
|
||||
f"could not run git for {repo.name}: {exc}",
|
||||
code="non-repo",
|
||||
) from exc
|
||||
raise GitStateError(f"could not run git for {repo.name}: {exc}") from exc
|
||||
|
||||
stdout_file.seek(0)
|
||||
stderr_file.seek(0)
|
||||
stdout_bytes = stdout_file.read(MAX_GIT_OUTPUT_BYTES + 1)
|
||||
stderr_bytes = stderr_file.read(MAX_GIT_ERROR_BYTES + 1)
|
||||
if len(stdout_bytes) > MAX_GIT_OUTPUT_BYTES:
|
||||
message = f"git {args[0] if args else 'command'} output exceeded the limit"
|
||||
if mutation:
|
||||
raise GitError(message, code="invalid-input")
|
||||
raise error_type(message)
|
||||
|
||||
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
||||
stderr = stderr_bytes[:MAX_GIT_ERROR_BYTES].decode("utf-8", errors="replace")
|
||||
if len(stderr_bytes) > MAX_GIT_ERROR_BYTES:
|
||||
stderr += "\n[error output truncated]"
|
||||
return result.returncode, stdout, stderr
|
||||
|
||||
|
||||
def _safe_git_error(text: str) -> str:
|
||||
"""Bound and scrub URL userinfo before returning Git diagnostics."""
|
||||
scrubbed = _ERROR_USERINFO_RE.sub(r"\1", text)
|
||||
scrubbed = _ERROR_SSH_USERINFO_RE.sub(r"\2:", scrubbed)
|
||||
return scrubbed[:MAX_GIT_ERROR_BYTES].strip()
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
"""Run ``git -C <repo> <args>`` and return stdout. Raises on failure."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=GIT_TIMEOUT_SECONDS,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise GitStateError(f"git timed out for {repo.name}") from exc
|
||||
except OSError as exc:
|
||||
raise GitStateError(f"could not run git for {repo.name}: {exc}") from exc
|
||||
if result.returncode != 0:
|
||||
"""Run ``git -C <repo> <args>`` and return bounded stdout."""
|
||||
returncode, stdout, stderr = _run_git_bounded(repo, list(args), mutation=False)
|
||||
if returncode != 0:
|
||||
raise GitStateError(
|
||||
f"git {args[0] if args else 'command'} failed for {repo.name}: "
|
||||
f"{result.stderr.strip() or result.stdout.strip()}"
|
||||
f"{_safe_git_error(stderr or stdout)}"
|
||||
)
|
||||
return result.stdout
|
||||
return stdout
|
||||
|
||||
|
||||
def _is_git_repo(path: Path) -> bool:
|
||||
@@ -108,9 +155,33 @@ def _is_git_repo(path: Path) -> bool:
|
||||
return (path / ".git").exists()
|
||||
|
||||
|
||||
def repo_id(repo: Path) -> str:
|
||||
"""Opaque, stable id for a repo — its directory basename."""
|
||||
return repo.name
|
||||
def _is_link_or_junction(path: Path) -> bool:
|
||||
is_junction = getattr(path, "is_junction", None)
|
||||
return path.is_symlink() or bool(is_junction and is_junction())
|
||||
|
||||
|
||||
def _is_within(root: Path, candidate: Path) -> bool:
|
||||
return candidate == root or root in candidate.parents
|
||||
|
||||
|
||||
def _has_link_component(base: Path, path: Path) -> bool:
|
||||
current = base
|
||||
try:
|
||||
relative = path.relative_to(base)
|
||||
except ValueError:
|
||||
return True
|
||||
for part in relative.parts:
|
||||
current /= part
|
||||
if _is_link_or_junction(current):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def repo_id(repo: Path, base: Path | None = None) -> str:
|
||||
"""Stable collision-free id relative to the configured canonical base."""
|
||||
if base is None:
|
||||
return repo.name
|
||||
return repo.relative_to(base).as_posix()
|
||||
|
||||
|
||||
def scan_repos(base: Path) -> list[dict[str, Any]]:
|
||||
@@ -123,19 +194,25 @@ def scan_repos(base: Path) -> list[dict[str, Any]]:
|
||||
if not base.is_dir():
|
||||
return []
|
||||
|
||||
canonical_base = base.resolve()
|
||||
repos: list[dict[str, Any]] = []
|
||||
for root in sorted(base.rglob("*")):
|
||||
if not root.is_dir():
|
||||
continue
|
||||
if root.name == ".git":
|
||||
continue
|
||||
if not _is_git_repo(root):
|
||||
if _has_link_component(base, root):
|
||||
continue
|
||||
repos.append(_describe_repo(root))
|
||||
canonical_root = root.resolve()
|
||||
if not _is_within(canonical_base, canonical_root):
|
||||
continue
|
||||
if not _is_git_repo(canonical_root):
|
||||
continue
|
||||
repos.append(_describe_repo(canonical_root, canonical_base))
|
||||
return repos
|
||||
|
||||
|
||||
def _describe_repo(repo: Path) -> dict[str, Any]:
|
||||
def _describe_repo(repo: Path, base: Path) -> dict[str, Any]:
|
||||
"""Build the scan entry for one repository."""
|
||||
current_branch = ""
|
||||
try:
|
||||
@@ -151,7 +228,7 @@ def _describe_repo(repo: Path) -> dict[str, Any]:
|
||||
pass
|
||||
|
||||
return {
|
||||
"id": repo_id(repo),
|
||||
"id": repo_id(repo, base),
|
||||
"name": repo.name,
|
||||
"root": str(repo),
|
||||
"current_branch": current_branch,
|
||||
@@ -329,9 +406,13 @@ def read_file(repo: Path, path: str) -> dict[str, Any]:
|
||||
# modified-but-uncommitted file returns what is on disk. Read bytes first:
|
||||
# binary content dies on the NUL check (before any decode), and non-UTF-8
|
||||
# text raises a clear GitStateError instead of an unhandled 500.
|
||||
disk_path = repo / safe_path
|
||||
root = repo.resolve()
|
||||
try:
|
||||
raw = disk_path.read_bytes()
|
||||
disk_path = (repo / safe_path).resolve(strict=True)
|
||||
if not _is_within(root, disk_path):
|
||||
raise GitStateError(f"path escapes repository: {safe_path}")
|
||||
with disk_path.open("rb") as handle:
|
||||
raw = handle.read(MAX_FILE_BYTES + 1)
|
||||
except OSError as exc:
|
||||
raise GitStateError(f"could not read file: {safe_path}") from exc
|
||||
|
||||
@@ -450,25 +531,14 @@ def _run_mutation(repo: Path, args: list[str]) -> str:
|
||||
|
||||
Arg lists only (never shell interpolation); bounded by a timeout.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=GIT_TIMEOUT_SECONDS,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise GitError(f"git timed out for {repo.name}", code="network") from exc
|
||||
except OSError as exc:
|
||||
raise GitError(f"could not run git for {repo.name}: {exc}", code="non-repo") from exc
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip() or result.stdout.strip()
|
||||
returncode, stdout, stderr_output = _run_git_bounded(repo, args, mutation=True)
|
||||
if returncode != 0:
|
||||
stderr = _safe_git_error(stderr_output or stdout)
|
||||
raise GitError(
|
||||
f"git {args[0] if args else 'command'} failed for {repo.name}: {stderr}",
|
||||
code=_classify_git_failure(stderr),
|
||||
)
|
||||
return result.stdout
|
||||
return stdout
|
||||
|
||||
|
||||
def _mutate(repo: Path, args: list[str]) -> str:
|
||||
@@ -491,6 +561,93 @@ def _validate_commit_message(message: str) -> str:
|
||||
return message.strip()[:MAX_COMMIT_MESSAGE]
|
||||
|
||||
|
||||
def _validate_git_scalar(value: str, label: str, *, allow_empty: bool = False) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise GitError(f"{label} must be a string", code="invalid-input")
|
||||
value = value.strip()
|
||||
if not value:
|
||||
if allow_empty:
|
||||
return ""
|
||||
raise GitError(f"{label} is required", code="invalid-input")
|
||||
if len(value) > MAX_GIT_SCALAR_LENGTH:
|
||||
raise GitError(f"{label} is too long", code="invalid-input")
|
||||
if value.startswith("-"):
|
||||
raise GitError(f"{label} must not be a git option", code="invalid-input")
|
||||
if "\x00" in value or any(ord(char) < 32 for char in value):
|
||||
raise GitError(f"{label} contains invalid characters", code="invalid-input")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_remote(repo: Path, remote: str) -> str:
|
||||
remote = _validate_git_scalar(remote, "remote")
|
||||
configured = {line.strip() for line in _git(repo, "remote").splitlines() if line.strip()}
|
||||
if remote not in configured:
|
||||
raise GitError(f"unknown remote: {remote}", code="invalid-input")
|
||||
return remote
|
||||
|
||||
|
||||
def _validate_branch(repo: Path, branch: str, *, allow_empty: bool = False) -> str:
|
||||
branch = _validate_git_scalar(branch, "branch", allow_empty=allow_empty)
|
||||
if not branch:
|
||||
return ""
|
||||
try:
|
||||
_git(repo, "check-ref-format", "--branch", branch)
|
||||
except GitStateError as exc:
|
||||
raise GitError(f"invalid branch: {branch}", code="invalid-input") from exc
|
||||
return branch
|
||||
|
||||
|
||||
def _validate_revision(repo: Path, ref: str, *, allow_empty: bool = False) -> str:
|
||||
ref = _validate_git_scalar(ref, "ref", allow_empty=allow_empty)
|
||||
if not ref:
|
||||
return ""
|
||||
try:
|
||||
_git(repo, "rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}")
|
||||
except GitStateError as exc:
|
||||
raise GitError(f"unknown ref: {ref}", code="invalid-input") from exc
|
||||
return ref
|
||||
|
||||
|
||||
def _checkout_args(
|
||||
repo: Path,
|
||||
ref: str,
|
||||
*,
|
||||
new_branch: str,
|
||||
track: bool,
|
||||
) -> list[str]:
|
||||
new_branch = _validate_branch(repo, new_branch, allow_empty=True)
|
||||
ref = _validate_revision(repo, ref, allow_empty=bool(new_branch))
|
||||
if track and not ref:
|
||||
raise GitError("track requires a source ref", code="invalid-input")
|
||||
if new_branch:
|
||||
if not ref:
|
||||
_validate_revision(repo, "HEAD")
|
||||
returncode, _, error = _run_git_bounded(
|
||||
repo,
|
||||
["show-ref", "--verify", "--quiet", f"refs/heads/{new_branch}"],
|
||||
mutation=False,
|
||||
)
|
||||
if returncode == 0:
|
||||
raise GitError(f"branch already exists: {new_branch}", code="invalid-input")
|
||||
if returncode != 1:
|
||||
raise GitError(
|
||||
f"could not validate branch {new_branch}: {_safe_git_error(error)}",
|
||||
code="invalid-input",
|
||||
)
|
||||
args = ["checkout"]
|
||||
if track:
|
||||
args.append("--track")
|
||||
args.extend(["-b", new_branch])
|
||||
if ref:
|
||||
args.append(ref)
|
||||
return args
|
||||
args = ["checkout"]
|
||||
if track:
|
||||
args.append("--track")
|
||||
args.append(ref)
|
||||
return args
|
||||
|
||||
|
||||
def _fresh_mutation_result(
|
||||
repo: Path,
|
||||
extra: dict[str, Any] | None = None,
|
||||
@@ -573,6 +730,7 @@ def commit_selected(repo: Path, message: str, paths: list[str]) -> dict[str, Any
|
||||
|
||||
def fetch(repo: Path, remote: str = "origin") -> dict[str, Any]:
|
||||
"""Fetch from ``remote`` (default origin) and return fresh status/branches."""
|
||||
remote = _validate_remote(repo, remote)
|
||||
_mutate(repo, ["fetch", "--prune", remote])
|
||||
return _fresh_mutation_result(repo, {"branches": repo_branches(repo)})
|
||||
|
||||
@@ -583,6 +741,8 @@ def pull(repo: Path, remote: str = "origin", branch: str = "") -> dict[str, Any]
|
||||
Pull never clobbers local work: a tree git refuses to fast-forward without
|
||||
discarding local changes surfaces as a structured ``dirty`` GitError.
|
||||
"""
|
||||
remote = _validate_remote(repo, remote)
|
||||
branch = _validate_branch(repo, branch, allow_empty=True)
|
||||
args = ["pull", "--ff-only", remote]
|
||||
if branch:
|
||||
args.append(branch)
|
||||
@@ -610,6 +770,8 @@ def push(
|
||||
are returned so the UI can reflect ahead/behind after a successful push.
|
||||
"""
|
||||
_require_confirmation(confirmation, CONFIRM_PUSH)
|
||||
remote = _validate_remote(repo, remote)
|
||||
branch = _validate_branch(repo, branch, allow_empty=True)
|
||||
args = ["push", remote]
|
||||
if branch:
|
||||
args.append(branch)
|
||||
@@ -629,23 +791,10 @@ def checkout(
|
||||
A dirty tree switch requires confirmation. Git still refuses to overwrite
|
||||
conflicting local changes, so there is no data-loss path.
|
||||
"""
|
||||
if not ref:
|
||||
raise GitStateError("ref is required")
|
||||
if new_branch:
|
||||
args = ["checkout", "-b", new_branch]
|
||||
if track:
|
||||
args.append("--track")
|
||||
_mutate(repo, args)
|
||||
return _fresh_mutation_result(repo, {"branches": repo_branches(repo)})
|
||||
args = _checkout_args(repo, ref, new_branch=new_branch, track=track)
|
||||
|
||||
if _is_dirty(repo):
|
||||
if _is_dirty(repo) and not new_branch:
|
||||
_require_confirmation(confirmation, CONFIRM_DIRTY_CHECKOUT)
|
||||
args = ["checkout"]
|
||||
if track:
|
||||
# ``git checkout --track <remote>/<branch>`` creates a local tracking
|
||||
# branch; only meaningful when the target is a remote-tracking ref.
|
||||
args.append("--track")
|
||||
args.append(ref)
|
||||
_mutate(repo, args)
|
||||
return _fresh_mutation_result(repo, {"branches": repo_branches(repo)})
|
||||
|
||||
@@ -765,31 +914,35 @@ def stash_checkout(
|
||||
so there is no data-loss path. ``new_branch``/``track`` mirror the plain
|
||||
checkout surface.
|
||||
"""
|
||||
if not ref:
|
||||
raise GitStateError("ref is required")
|
||||
args = _checkout_args(repo, ref, new_branch=new_branch, track=track)
|
||||
|
||||
stashed = False
|
||||
stash_message = ""
|
||||
stash_oid = ""
|
||||
if _is_dirty(repo):
|
||||
stash_message = f"git-state: {ref}"
|
||||
_mutate(repo, ["stash", "push", "-m", stash_message])
|
||||
stash_message = f"git-state: {ref or new_branch}"
|
||||
_mutate(repo, ["stash", "push", "--include-untracked", "-m", stash_message])
|
||||
stash_oid = _git(repo, "rev-parse", "--verify", "refs/stash").strip()
|
||||
stashed = True
|
||||
|
||||
if new_branch:
|
||||
args = ["checkout", "-b", new_branch]
|
||||
if track:
|
||||
args.append("--track")
|
||||
try:
|
||||
_mutate(repo, args)
|
||||
return {
|
||||
**{"stashed": stashed, "stash_message": stash_message},
|
||||
**_fresh_mutation_result(repo, {"branches": repo_branches(repo)}),
|
||||
}
|
||||
|
||||
args = ["checkout"]
|
||||
if track:
|
||||
args.append("--track")
|
||||
args.append(ref)
|
||||
_mutate(repo, args)
|
||||
except GitError as checkout_error:
|
||||
if not stashed:
|
||||
raise
|
||||
try:
|
||||
_mutate(repo, ["stash", "apply", "--index", stash_oid])
|
||||
except GitError as restore_error:
|
||||
raise GitError(
|
||||
f"{checkout_error}; changes remain in stash {stash_oid}; "
|
||||
f"automatic restore failed: {restore_error}",
|
||||
code=checkout_error.code,
|
||||
) from checkout_error
|
||||
raise GitError(
|
||||
f"{checkout_error}; working changes were restored and remain backed up "
|
||||
f"in stash {stash_oid}",
|
||||
code=checkout_error.code,
|
||||
) from checkout_error
|
||||
return {
|
||||
**{"stashed": stashed, "stash_message": stash_message},
|
||||
**_fresh_mutation_result(repo, {"branches": repo_branches(repo)}),
|
||||
|
||||
@@ -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"})
|
||||
@@ -200,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
|
||||
@@ -331,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)
|
||||
|
||||
@@ -10,6 +10,7 @@ import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from plugin import git_state
|
||||
|
||||
@@ -45,6 +46,15 @@ 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())
|
||||
@@ -93,6 +103,26 @@ class GitStateScanTests(unittest.TestCase):
|
||||
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:
|
||||
@@ -297,6 +327,35 @@ class GitStateFileTests(unittest.TestCase):
|
||||
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:
|
||||
@@ -343,12 +402,26 @@ class GitStateSecurityTests(unittest.TestCase):
|
||||
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()
|
||||
|
||||
@@ -217,8 +217,52 @@ class StashCheckoutTests(_ExtrasBase):
|
||||
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__":
|
||||
|
||||
@@ -200,6 +200,18 @@ class FetchPullPushTests(_MutationBase):
|
||||
_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
|
||||
@@ -290,6 +302,10 @@ class FetchPullPushTests(_MutationBase):
|
||||
|
||||
|
||||
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")
|
||||
@@ -305,6 +321,17 @@ class CheckoutTests(_MutationBase):
|
||||
)
|
||||
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")
|
||||
|
||||
@@ -84,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