Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00288a2b3b | ||
|
|
8f52feffba | ||
|
|
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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
+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
|
||||
|
||||
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