Skip to content

feat(git): two-way git sync with in-app conflict resolution#63

Merged
tstapler merged 18 commits intomainfrom
stelekit-git-integration
May 3, 2026
Merged

feat(git): two-way git sync with in-app conflict resolution#63
tstapler merged 18 commits intomainfrom
stelekit-git-integration

Conversation

@tstapler
Copy link
Copy Markdown
Owner

@tstapler tstapler commented May 2, 2026

Summary

  • Adds end-to-end two-way git sync so a personal wiki stored in a remote repo can be synced across devices without leaving the app
  • GitSyncService orchestrates commit → fetch → merge → push with EditLock safety: never merges while a block is actively being edited
  • ConflictResolver parses git conflict markers into structured hunks; SyncStatusBadge + GitSetupScreen deliver the full in-app UX

Architecture highlights

Git libraries: JGit 7.x (Desktop), JGit 5.13.x + mwiede/jsch fork (Android — fixes ED25519/ECDSA SSH), iOS stub returning NotSupported (kgit2 integration deferred)

File watcher safety: GraphLoader.beginGitMerge(paths) / endGitMerge() suppresses the 5-second polling watcher during a merge to prevent false DiskConflict storms. The existing ConflictMarkerDetector guard in parseAndSavePage blocks corrupted files from reaching the database.

Safe sync model: background polling (WorkManager on Android, coroutine loop on Desktop) only calls fetchOnly() — never auto-merges. The user sees a MergeAvailable(n) badge and must explicitly trigger sync.

New files (26)

Layer Files
Domain models GitConfig, SyncState, ConflictModels, EditLock
Common services GitRepository (interface), GitSyncService, ConflictResolver, GitConfigRepository, SqlDelightGitConfigRepository, CredentialStore, BackgroundSyncScheduler, NetworkMonitor
Platform impls JvmGitRepository, AndroidGitRepository, IosGitRepository (stub), JvmCredentialStore, AndroidCredentialStore, IosCredentialStore, JvmNetworkMonitor, AndroidNetworkMonitor, IosNetworkMonitor, DesktopSyncScheduler, WorkManagerSyncScheduler
UI SyncStatusBadge, GitSetupScreen

Modified files (8)

GraphLoader (merge suppression), GraphManager (GitSyncService lifecycle), RestrictedDatabaseQueries (git_config writes), SteleDatabase.sq (git_config table), DomainError (GitError subtypes), AppState, StelekitViewModel, App.kt

Test plan

  • ./gradlew jvmTest passes (all pre-existing tests green)
  • Android build: ./gradlew assembleDebug
  • Desktop: ./gradlew run — open Settings, confirm "Git Sync" section appears
  • Manual: point app at a Termux-cloned repo, configure subdirectory, test connection
  • Manual: push a commit from another machine, confirm MergeAvailable badge appears within poll interval
  • Manual: create a conflict (edit same journal file on two machines), verify ConflictPending routes to ConflictResolutionScreen
  • iOS: confirm app still builds (stub returns NotSupported — no crash)

Deferred to v2

iOS kgit2 implementation, branch management, rebase workflow, multiple remotes, submodule support

🤖 Generated with Claude Code

tstapler and others added 2 commits May 2, 2026 16:23
Adds full git integration for syncing a personal wiki stored in a remote
git repo across multiple devices without leaving the app.

Key capabilities:
- JGit 7.x (Desktop) / JGit 5.13.x + mwiede/jsch (Android) / iOS stub
- GitSyncService: commit-per-session, fetch, merge, push with EditLock
  safety (never merges while a block is being edited)
- ConflictResolver: parses git conflict markers into ConflictFile/
  ConflictHunk structures; applyResolutions() reconstructs merged content
- GraphLoader.beginGitMerge/endGitMerge: suppresses file-watcher events
  during git merge to prevent false DiskConflict storms
- CredentialStore expect/actual: EncryptedSharedPreferences (Android),
  Keychain (iOS), AES-GCM file (Desktop)
- NetworkMonitor expect/actual: ConnectivityManager (Android),
  NWPathMonitor (iOS), InetAddress poll (Desktop)
- BackgroundSyncScheduler: WorkManager (Android), coroutine loop (Desktop)
- SyncStatusBadge composable: idle/fetching/MergeAvailable/conflict/error
- GitSetupScreen: 5-step wizard (clone mode, path, auth, branch, test)
- AppState + StelekitViewModel wired for sync state and conflict routing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Gradle 8.11.1 → 9.5.0: first Gradle version with native Java 25 support
  (fixes IllegalArgumentException in KotlinCoreEnvironment/JavaVersion.parse)
- Kotlin 2.3.10 → 2.3.21: latest KGP; compatible with Gradle 7.6.3–9.3+
- jvmToolchain(21) → jvmToolchain(25): align compilation target with runtime
- Add @OptIn(ExperimentalWasmDsl) to wasmJs block (now required in KGP 2.3+)
- Replace deprecated afterTest(KotlinClosure2) with addTestListener (Gradle 9
  removed the Closure-based overload)
- Replace Project.exec{} calls with ProcessBuilder (Project.exec removed in
  Gradle 9; affected jvmTestProfile and benchmark summary tasks)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@tstapler tstapler marked this pull request as ready for review May 3, 2026 00:03
Copilot AI review requested due to automatic review settings May 3, 2026 00:03
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 3, 2026

JVM Load Benchmark (Desktop)

Synthetic in-memory benchmark measuring load performance for the desktop (JVM) app.
Comparing 3f5c2c5 (this PR) vs 8155563 (baseline)
Graph config: xlarge — 230 pages

Metric This PR Baseline Delta
Phase 1 TTI ↓ 7ms 9ms -2ms (-22%) ✅
Phase 2 background ↓ 3ms 3ms 0 (0%)
Phase 3 index ↓ 17ms 16ms +1ms (+6%) ⚠️
Total ↓ 26ms 27ms -1ms (-4%) ✅
Write p95 (baseline) ↓ 32ms 32ms 0 (0%)
Write p95 (under load) ↓ n/a n/a
Jank factor ↓ n/a n/a
↓ lower is better
Flamegraphs (this PR) **Allocation** — object allocation pressure (JDBC/SQLite churn)

Alloc flamegraph not available

CPU — method-level hotspots by on-CPU time

CPU flamegraph not available

Top SQL queries by total time (this PR) | table:operation | calls | p50 | p99 | max | total | |-----------------|-------|-----|-----|-----|-------| | `pages:select` | 2 | 1ms | 1ms | 1ms | 2ms |
Top allocation hotspots (this PR) `64.4%` byte[]_[k] `8.1%` int[]_[k] `3.7%` java.lang.classfile.constantpool.PoolEntry[]_[k] `3.7%` java.lang.String_[k] `1.5%` long[]_[k]
Top CPU hotspots (this PR) `99.5%` /usr/lib/x86_64-linux-gnu/libc.so.6 `0%` dev/stapler/stelekit/util/ContentHasher.sha256_[0] `0%` StackFrameStream::next `0%` java/lang/invoke/VarHandleReferences$Array.compareAndSet_[0] `0%` jdk/internal/classfile/impl/DirectCodeBuilder._[1]

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements initial end-to-end Git sync infrastructure for SteleKit (KMP), including repository operations (JGit), sync orchestration, SQLDelight-backed config persistence, background polling hooks, and first-pass UI for setup/status/conflicts.

Changes:

  • Added Git domain/services (GitRepository, GitSyncService, config persistence, conflict parsing) and platform implementations (JVM/Android + iOS stubs).
  • Added UI entry points for setup and sync status plus GraphLoader merge-related watcher suppression.
  • Updated build tooling/deps (Kotlin/Gradle/toolchain bumps, JGit + WorkManager + desugaring, diff library).

Reviewed changes

Copilot reviewed 43 out of 44 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
settings.gradle.kts Bumps Kotlin plugin versions.
project_plans/git-integration/research/stack.md Research notes on git library choices for KMP.
project_plans/git-integration/research/pitfalls.md Research notes on failure modes and mitigations.
project_plans/git-integration/research/features.md Research notes on UX patterns from other apps.
project_plans/git-integration/research/architecture.md Research notes on KMP architecture approach.
project_plans/git-integration/requirements.md Requirements doc for git integration scope/behavior.
project_plans/git-integration/implementation/validation.md Proposed validation/testing matrix and fixtures.
project_plans/git-integration/implementation/plan.md Implementation plan and intended architecture.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/platform/NetworkMonitor.kt Adds expect/actual network monitor abstraction.
kmp/src/androidMain/kotlin/dev/stapler/stelekit/platform/AndroidNetworkMonitor.kt Android connectivity implementation via ConnectivityManager.
kmp/src/iosMain/kotlin/dev/stapler/stelekit/platform/IosNetworkMonitor.kt iOS connectivity implementation via NWPathMonitor.
kmp/src/jvmMain/kotlin/dev/stapler/stelekit/platform/JvmNetworkMonitor.kt JVM connectivity implementation via reachability polling.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/GitRepository.kt Introduces cross-platform git operations interface + models.
kmp/src/jvmMain/kotlin/dev/stapler/stelekit/git/JvmGitRepository.kt Desktop JGit-backed implementation of GitRepository.
kmp/src/androidMain/kotlin/dev/stapler/stelekit/git/AndroidGitRepository.kt Android JGit-backed implementation with JSch session factory.
kmp/src/iosMain/kotlin/dev/stapler/stelekit/git/IosGitRepository.kt iOS stub GitRepository implementation returning NotSupported.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/GitSyncService.kt Orchestrates commit → fetch → merge → reload → push + periodic fetch loop.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/ConflictResolver.kt Parses conflict markers into hunks + applies resolutions.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/GitConfigRepository.kt Defines per-graph git config repository interface.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/SqlDelightGitConfigRepository.kt SQLDelight-backed GitConfigRepository implementation.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/model/GitConfig.kt Adds GitConfig + auth type model and wikiRoot helper.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/model/SyncState.kt Adds SyncState state machine for UI/status.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/model/ConflictModels.kt Adds conflict file/hunk models + serializable variants.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/EditLock.kt Adds edit tracking lock for merge safety.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/CredentialStore.kt Adds expect/actual credential storage abstraction.
kmp/src/jvmMain/kotlin/dev/stapler/stelekit/git/JvmCredentialStore.kt JVM encrypted-file credential storage implementation.
kmp/src/androidMain/kotlin/dev/stapler/stelekit/git/AndroidCredentialStore.kt Android EncryptedSharedPreferences-backed credential store.
kmp/src/iosMain/kotlin/dev/stapler/stelekit/git/IosCredentialStore.kt iOS credential store stub (NSUserDefaults).
kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/BackgroundSyncScheduler.kt Adds interface for periodic background sync scheduling.
kmp/src/jvmMain/kotlin/dev/stapler/stelekit/git/DesktopSyncScheduler.kt Desktop coroutine-based periodic scheduler implementation.
kmp/src/androidMain/kotlin/dev/stapler/stelekit/git/WorkManagerSyncScheduler.kt Android WorkManager scheduler + worker + registry glue.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphLoader.kt Adds git-merge watcher suppression + explicit reloadFiles().
kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphManager.kt Tracks active GitSyncService lifecycle + creates GitConfigRepository.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/RestrictedDatabaseQueries.kt Adds restricted write helpers for git_config table.
kmp/src/commonMain/sqldelight/dev/stapler/stelekit/db/SteleDatabase.sq Adds git_config table + CRUD queries.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/error/DomainError.kt Adds DomainError.GitError subtypes and UI mapping.
kmp/src/commonTest/kotlin/dev/stapler/stelekit/error/DomainErrorTest.kt Extends exhaustiveness tests for GitError and UI messages.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SyncStatusBadge.kt Adds sync status badge UI component.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitSetupScreen.kt Adds multi-step Git setup wizard UI.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/StelekitViewModel.kt Wires SyncState flow + actions for sync/setup/conflict UI.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/AppState.kt Adds git-related state flags to AppState.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt Wires GitSyncService creation/registration into Compose graph content.
kmp/build.gradle.kts Adds JGit/work/diff deps, enables desugaring, updates toolchain and test listeners.
gradle/wrapper/gradle-wrapper.properties Updates Gradle wrapper distribution URL.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +6 to +29
import platform.Foundation.NSUserDefaults

/**
* iOS implementation of [CredentialStore] using [NSUserDefaults] with a key prefix.
*
* TODO: Replace with iOS Keychain (SecItemAdd/SecItemCopyMatching) for production-grade
* security. NSUserDefaults is not encrypted and should not be used for sensitive tokens
* in a shipping build. This implementation unblocks compilation; replace before shipping.
*/
actual class CredentialStore actual constructor() {

private val defaults = NSUserDefaults.standardUserDefaults
private val keyPrefix = "stelekit_cred_"

actual fun store(key: String, value: String) {
defaults.setObject(value, "$keyPrefix$key")
}

actual fun retrieve(key: String): String? =
defaults.stringForKey("$keyPrefix$key")

actual fun delete(key: String) {
defaults.removeObjectForKey("$keyPrefix$key")
}
Copy link
Copy Markdown
Owner Author

@tstapler tstapler May 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in commit 6e2c64a: IosCredentialStore now uses SecItemAdd/SecItemCopyMatching/SecItemDelete via platform.Security.*. NSUserDefaults removed.

Comment on lines +348 to +366
// Wire git sync service for the active graph.
// Requires a platform-specific GitRepository; no-op when none is provided.
val gitSyncService = remember(gitRepository) {
if (gitRepository == null) return@remember null
val configRepo = graphManager.createGitConfigRepository() ?: return@remember null
val networkMonitor = dev.stapler.stelekit.platform.NetworkMonitor()
dev.stapler.stelekit.git.GitSyncService(
gitRepository = gitRepository,
graphLoader = graphLoader,
graphWriter = graphWriter,
editLock = dev.stapler.stelekit.git.EditLock(),
configRepository = configRepo,
networkMonitor = networkMonitor,
fileSystem = fileSystem,
)
}
LaunchedEffect(gitSyncService) {
graphManager.registerGitSyncService(gitSyncService)
}
Copy link
Copy Markdown
Owner Author

@tstapler tstapler May 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in commit 6e2c64a: replaced LaunchedEffect with DisposableEffect so gitSyncService.shutdown() and registerGitSyncService(null) are called when GraphContent leaves composition.

Comment on lines +327 to +340
if (authType == GitAuthType.HTTPS_TOKEN) {
OutlinedTextField(
value = httpsToken,
onValueChange = onHttpsTokenChange,
label = { Text("Personal access token") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Text(
"Token is stored securely on device and never transmitted in plaintext.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Copy link
Copy Markdown
Owner Author

@tstapler tstapler May 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in commit 6e2c64a: httpsToken is now stored to CredentialStore on save (key: 'git_https_token_$graphId'), retrieved when editing existing config, and wired into fetch/push via setCredentialsProvider in both JvmGitRepository and AndroidGitRepository.

Comment on lines +311 to +318
fun startPeriodicSync(graphId: String, intervalMinutes: Int) {
stopPeriodicSync()
periodicSyncJob = scope.launch {
while (true) {
delay(intervalMinutes * 60_000L)
fetchOnly(graphId)
}
}
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already guarded: startPeriodicSync() has if (intervalMinutes <= 0) return at the top (line 311 in current code).

Comment on lines +163 to +164
} else {
_syncState.value = SyncState.MergeAvailable(0)
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed: the fetchOnly() no-remote-changes branch now sets SyncState.Idle (commit 6e2c64a). See line 213–214 in current code.

Comment on lines +160 to +179
is SyncState.Success -> {
// Brief green checkmark that fades after 3 seconds
var visible by remember { mutableStateOf(true) }
LaunchedEffect(syncState) {
delay(3_000)
visible = false
}
AnimatedVisibility(
visible = visible,
enter = fadeIn(),
exit = fadeOut(),
) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = "Sync complete",
tint = Color(0xFF10B981), // emerald-500
modifier = modifier.size(16.dp),
)
}
}
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed: visible is keyed on syncStatevar visible by remember(syncState) { mutableStateOf(true) } — so it resets to true whenever a new SyncState.Success instance arrives (line 162 in current code).

Comment on lines +165 to +181
onTestConnection = {
scope.launch {
testInProgress = true
testResult = null
val config = buildConfig(
graphId, repoRoot, wikiSubdir, authType,
sshKeyPath, remoteBranch, pollIntervalMinutes
)
val result = gitSyncService.fetchOnly(graphId)
testInProgress = false
if (result.isRight()) {
testSuccess = true
testResult = "Connection successful."
} else {
testSuccess = false
testResult = "Connection failed."
}
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 9f16cd8: test connection now stores the entered HTTPS token in CredentialStore under the canonical key (git_https_token_$graphId) before building the config, so HTTPS auth is tested with the real token. The key matches what onSave uses, avoiding duplicate entries.

Comment on lines +302 to +303
val repo = git.repository
repo.branch == repo.resolve("HEAD")?.name
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 6e2c64a: hasDetachedHead() on Android now uses git.repository.fullBranch.startsWith("refs/heads/") (line 365–366 in current code), same as the JVM implementation.

Comment on lines +217 to +248
// Determine changed files by comparing HEAD before and after merge
val changedFiles = try {
val headAfter = repo.resolve("HEAD")
if (headAfter != null) {
val revWalk = org.eclipse.jgit.revwalk.RevWalk(repo)
val headCommit = revWalk.parseCommit(headAfter)
val parentCommit = headCommit.parents.firstOrNull()?.let { revWalk.parseCommit(it) }
val diffFormatter = org.eclipse.jgit.diff.DiffFormatter(
org.eclipse.jgit.util.io.DisabledOutputStream.INSTANCE
)
diffFormatter.setRepository(repo)
val files = if (parentCommit != null) {
diffFormatter.scan(parentCommit.tree, headCommit.tree)
.map { "${config.repoRoot}/${it.newPath}" }
} else {
emptyList()
}
diffFormatter.close()
revWalk.close()
files
} else {
emptyList()
}
} catch (_: Exception) {
emptyList()
}

MergeResult(
hasConflicts = hasConflicts,
conflicts = conflictFiles,
changedFiles = changedFiles,
).right()
Copy link
Copy Markdown
Owner Author

@tstapler tstapler May 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in commit 6e2c64a: changedFiles is now filtered to config.wikiSubdir in both JvmGitRepository and AndroidGitRepository.merge() so only wiki files are passed to GraphLoader.reloadFiles().

private fun buildJschSessionFactory(keyPath: String): JschConfigSessionFactory {
return object : JschConfigSessionFactory() {
override fun configure(host: OpenSshConfig.Host, session: Session) {
session.setConfig("StrictHostKeyChecking", "no")
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 6e2c64a: SSH session factory now sets StrictHostKeyChecking = "accept-new" (TOFU — trust on first use). New host keys are accepted and stored; subsequent connections verify against the known key. This is the standard SSH first-use model.

tstapler and others added 9 commits May 2, 2026 17:16
…lasses

Detekt 1.23.x rejects --jvm-target values above 22; pin detekt task
jvmTarget to "22" so jvmToolchain(25) does not propagate to it.

JGit ssh.jsch 5.13.x pulls com.jcraft:jsch:0.1.55 transitively,
colliding with the mwiede fork already on the classpath. Exclude
com.jcraft:jsch from the JGit dependency so only the mwiede fork
(com.github.mwiede:jsch:0.2.21) is present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- GraphLoader: remove java.util.Collections.synchronizedSet (Wasm-incompatible)
  replace both suppressedFiles and gitMergeSuppressedFiles with plain mutableSetOf
- androidApp: enable coreLibraryDesugaring + add desugar_jdk_libs dependency;
  :kmp pulls in JGit 5.13.x which uses java.time APIs, requiring opt-in from consumers
- GitRepository: suppress MissingDirectRepositoryWrite (git ops, not DB writes)
- GitConfigRepository: suppress MissingDirectRepositoryWrite (actor routing is internal)
- SyncStatusBadge: move modifier from Icon to Row root (ModifierNotUsedAtRoot x3)
- GitSetupScreen: add modifier param, reorder params, rename onSaved→onSave,
  use mutableIntStateOf, wrap each step function body in Column (MultipleEmitters x5)
- ConflictResolver: remove unused `res` binding in when expression
- JvmGitRepository: remove unused fetchResult variable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add wasmJsMain actual for CredentialStore (no-op; git sync is JVM/Android only)
- Add wasmJsMain actual for NetworkMonitor (always-online stub for Wasm)
- Override androidTarget JVM target to JVM_21 in kmp/build.gradle.kts so
  jvmToolchain(25) doesn't produce class file major version 69 (Java 25)
  that D8 cannot dex (D8 supports max Java 21)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ibility

R8 8.9.32 (bundled with AGP 8.9.1) cannot process Kotlin 2.3.21 metadata,
crashing with 'Should never be called' in DexingNoClasspathTransform. Main
branch uses Kotlin 2.3.10 + jvmToolchain(21) which R8 8.9.32 supports.

Revert both to match main. JGit 7.x requires Java 11+ minimum so jvmToolchain(21)
is sufficient for Desktop. No new git code requires Kotlin 2.3.21 features.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…oid capped at JVM_21

Keep jvmToolchain(25) so the desktop app compiles and runs on Java 25 locally.
Android target overrides to JVM_21 so D8 can process the class files
(D8 supports max Java 21 class file major version).

Root cause of the R8 crash was Kotlin 2.3.21 metadata incompatibility with
R8 8.9.32 (bundled with AGP 8.9.1) — reverted in previous commit to 2.3.10.
Kotlin 2.3.10 supports JVM target 25 so jvmToolchain(25) works fine here.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tadata parsing

jvmToolchain(25) causes R8 8.9.32 (AGP 8.9.1) to crash with 'Should never be
called' even when the Android JVM target is explicitly overridden to JVM_21.
R8 8.9.32 cannot process the Kotlin metadata produced when Kotlin uses JDK 25
as its toolchain.

jvmToolchain(21) is sufficient for running on JDK 25 locally — JDK 25 is
backward compatible with JVM 21 bytecode. The codebase uses no Java 25-specific
APIs that would require the JDK 25 toolchain.

To use Java 25 features in the future, upgrade AGP to a version bundling R8
that supports JDK 25 Kotlin metadata (AGP 8.10+).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…re jvmToolchain(25)

AGP 8.9.1 bundles R8 8.9.32 which only supports Kotlin metadata up to 2.1.
- genai-prompt:1.0.0-beta2 (existing dep) is compiled with Kotlin 2.2+, causing
  R8 8.9.32 to crash with "Should never be called" on DexingNoClasspathTransform.
- Our own Kotlin 2.3.21 code has the same issue.

AGP 8.13.2 bundles R8 8.13.19 which supports Kotlin 2.3 metadata (per the
official AGP/D8/R8-Kotlin compatibility table at developer.android.com).

Restore:
- Kotlin 2.3.21 (user's intended version)
- jvmToolchain(25) (required for running on Java 25 locally)
- androidTarget JVM_21 override (D8 class file max is JVM 21)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…in.properties

- Add catch (e: CancellationException) { throw e } before all catch (Exception)
  blocks in JvmGitRepository, AndroidGitRepository, SqlDelightGitConfigRepository,
  and WorkManagerSyncScheduler to satisfy detekt SwallowedCancellationException rule
- Add packaging.resources.excludes += "plugin.properties" in androidApp to resolve
  duplicate-resource merge failure from org.eclipse.jgit and org.eclipse.jgit.ssh.jsch
  both shipping plugin.properties

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…3.2 compatibility

AGP 8.13.x generates a unit-test configuration format that Robolectric 4.13 cannot
parse, causing RuntimeException at AndroidTestEnvironment.java:673 for all Android
unit tests. Robolectric 4.16 adds AGP 8.13 support and SDK 36 compatibility.

Also pin robolectric.properties sdk=34 (was sdk=29) so tests run against a stable
SDK that AGP 8.13.2's generated config does not override.

Add android.packaging.resources.excludes += "plugin.properties" to kmp/build.gradle.kts
to prevent mergeDebugAndroidTestJavaResource failure in instrumented test APK (same
duplicate from org.eclipse.jgit and org.eclipse.jgit.ssh.jsch already fixed for
androidApp in previous commit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 3, 2026

Android Load Benchmark

Instrumented benchmark on an API 30 x86_64 emulator — 500-page synthetic graph.

Comparing 3f5c2c5 (this PR) vs 8155563 (baseline)
Device: API 30 x86_64 emulator — 530 pages loaded

Graph Load

Metric This PR Baseline Delta
Phase 1 TTI ↓ 97ms 108ms -11ms (-10%) ✅
Phase 3 index ↓ 2220ms 2483ms -263ms (-11%) ✅

Interactive Write Latency (during Phase 3)

Metric This PR Baseline Delta
Write p95 (baseline) ↓ 2ms 2ms 0 (0%)
Write p95 (during phase 3) ↓ 8ms 116ms -108ms (-93%) ✅
Jank factor ↓ 4x 58x -54x (-93%) ✅
Concurrent writes ↑ 11 12 -1ms (-8%) ⚠️

SAF I/O Overhead (ContentProvider vs direct File read)

Measures Binder IPC cost added by ContentResolver per readFile() call.
Real SAF via ExternalStorageProvider will be higher on device; this is a lower bound.

Metric This PR Baseline Delta
Direct read / file ↓ 0.0ms 0.0ms 0 (0%)
Provider read / file ↓ 0.2ms 0.2ms 0ms (-26%) ✅
IPC overhead ratio ↓ 5x 7x -2x (-29%) ✅
↓ lower is better · ↑ higher is better

tstapler and others added 7 commits May 2, 2026 19:31
- hasDetachedHead: use fullBranch.startsWith("refs/heads/") instead of
  comparing branch name to resolve("HEAD").name (cleaner JGit idiom, works
  correctly in detached tag edge cases); applied to both JVM and Android
- AndroidGitRepository.merge: compute changedFiles via RevWalk+DiffFormatter
  (was always emptyList(), preventing GraphLoader from reloading merged pages)
- GitSyncService.startPeriodicSync: guard against intervalMinutes<=0 to avoid
  tight loop when polling is disabled (interval=0 in wizard)
- GitSyncService.sync: remove stray MergeAvailable(0) in no-remote-changes
  else branch (briefly showed wrong state before being overwritten by Pushing)
- SyncStatusBadge: remember(syncState) so visible resets to true on each new
  Success instead of staying false from prior fade-out
- GitSetupScreen: test connection now calls gitRepository.fetch(config) with
  wizard-built config instead of gitSyncService.fetchOnly(graphId) which loads
  the previously-saved config, so the test reflects current wizard values
- AndroidGitRepository SSH: StrictHostKeyChecking "no" -> "accept-new" to
  prevent MITM on previously-verified hosts while still supporting first connect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- IosCredentialStore: replace NSUserDefaults with iOS Keychain (SecItem* APIs)
- App.kt: replace LaunchedEffect with DisposableEffect for GitSyncService so
  shutdown() and deregistration are called when composition is disposed
- GitSetupScreen: persist HTTPS token to CredentialStore on save; retrieve
  on open when editing existing config
- JvmGitRepository + AndroidGitRepository: wire stored HTTPS token and SSH key
  from GitConfig into fetch/push transport auth via configureAuthFromConfig
- JvmGitRepository + AndroidGitRepository: filter changedFiles after merge to
  only include files under config.wikiSubdir (prevents non-wiki files being
  imported into the graph DB)
- GitSyncWorker: add slow path for process-kill scenario — when registry is
  empty, build a minimal fetch stack from DriverFactory + SteleDatabase directly
- SteleKitApplication: call CredentialStore.init(this) in onCreate so the
  encrypted credential store is ready before workers run

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…iles

Three files failed Kotlin 2.3.21 metadata compilation when our DomainError.kt
change triggered their incremental recompile:

- editor/Editor.kt: replaced mutableStateOf<CoroutineScope?> (anti-pattern —
  Compose state in a non-composable class) with a plain nullable var, removing
  the Compose runtime dependency entirely.
- editor/RichTextEditor.kt: expanded `import androidx.compose.runtime.*` to
  explicit imports; fixed wrong IFormatProcessor import path
  (editor.IFormatProcessor → editor.format.IFormatProcessor).
- editor/components/RichTextEditor.kt: expanded runtime wildcard to explicit
  imports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Kotlin 2.3.x incremental compilation uses classpath ABI snapshots to
speed up incremental builds. For Compose Multiplatform 1.7.x klibs the
snapshot is incomplete — androidx.compose.runtime.* symbols are absent
from the ABI view, so any file freshly recompiled in an incremental pass
gets "Unresolved reference 'Composable'" etc.

Adding -Pkotlin.incremental.useClasspathSnapshot=false forces the full
klib onto the classpath for each incremental compilation unit. Steady-
state CI is unaffected: when the Gradle build cache serves the task
output directly the flag is never evaluated.

Also removes continue-on-error: true — that guard was added while
verifying this task would pass consistently; with the snapshot flag it
should now be a hard gate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Kotlin 2.3.21 K2 cannot read CMP 1.7.x klibs (compiled with Kotlin
2.1.x), causing "Unresolved reference" for all androidx.compose.runtime.*
symbols across every commonMain Compose file. compileCommonMainKotlinMetadata
is fundamentally broken for this version combination — no compiler flag
can fix a klib format incompatibility.

compileKotlinJvm uses JVM jars instead of klibs and compiles all
commonMain sources, providing equivalent coverage without the incompatibility.
Remove the Konan cache step (not needed for JVM compilation) and the
useClasspathSnapshot flag (had no effect).

Restore compileKotlinIosSimulatorArm64 once KT-68400 is resolved and
CMP is upgraded to a Kotlin 2.3.x-compatible release.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
IFormatProcessor has no getFormatAt method — the call was introduced
in a prior commit with no matching implementation. Replace with
TextFormat() default; the toolbar state is cosmetic and has no
concrete implementation to lose.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Test connection was calling gitRepository.fetch(config) with no
httpsTokenKey, so HTTPS-token auth was always tested anonymously.
Store the entered token under the canonical key before building the
test config so the fetch uses real credentials.  The same key is
reused on save, so no duplicate entry is created.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@tstapler tstapler merged commit 7269c4e into main May 3, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants