Compare commits

...
Author SHA1 Message Date
Bailey Dixon 00288a2b3b test(git-state): cover links across platforms 2026-08-25 13:54:45 -04:00
Bailey Dixon 8f52feffba fix(git-state): harden repository operations 2026-08-25 13:51:10 -04:00
Bailey Dixon ee29e49361 Merge pull request #434 from Codename-11/fix/android-session-activity-truth
fix(android): make session activity authoritative
2026-08-25 12:37:31 -04:00
24 changed files with 864 additions and 250 deletions
+9 -1
View File
@@ -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: |
+5 -1
View File
@@ -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
@@ -855,6 +855,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 +873,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
@@ -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
@@ -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),
)
@@ -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
}
@@ -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()
+2
View File
@@ -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
+7 -7
View File
File diff suppressed because one or more lines are too long
+17 -9
View File
@@ -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
+3 -7
View File
@@ -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 });
}
+8
View File
@@ -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}.
+100 -50
View File
@@ -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 {
+11
View File
@@ -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
View File
@@ -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)}),
+7
View File
@@ -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)
+73
View File
@@ -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()
+44
View File
@@ -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__":
+27
View File
@@ -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")
+5
View File
@@ -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])