Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4d93e08
feat(git): add two-way git sync with conflict resolution UI
tstapler May 2, 2026
6744268
build: upgrade to Gradle 9.5.0 + Kotlin 2.3.21 for Java 25 support
tstapler May 2, 2026
dc050f8
fix(ci): resolve detekt JVM 25 rejection and Android jsch duplicate c…
tstapler May 3, 2026
338a61e
fix(ci): resolve Wasm, Android desugaring, and detekt issues
tstapler May 3, 2026
1773886
fix: add wasmJs actuals and cap Android Kotlin JVM target to 21
tstapler May 3, 2026
0e199a2
fix: revert Kotlin 2.3.21→2.3.10 and jvmToolchain 25→21 for R8 compat…
tstapler May 3, 2026
6ee2ab0
fix: restore jvmToolchain(25) for local Java 25 desktop run with Andr…
tstapler May 3, 2026
afe7384
fix: revert jvmToolchain to 21 — JDK 25 toolchain breaks R8 8.9.32 me…
tstapler May 3, 2026
b06bdb6
fix: upgrade AGP 8.9.1→8.13.2, re-upgrade Kotlin 2.3.10→2.3.21, resto…
tstapler May 3, 2026
3bfb56c
fix: re-throw CancellationException in git catch blocks; exclude plug…
tstapler May 3, 2026
3d709a6
fix: upgrade Robolectric 4.13→4.16 and pin test SDK to 34 for AGP 8.1…
tstapler May 3, 2026
fa1ea7e
fix(git): address review comment bugs in sync and UI components
tstapler May 3, 2026
6e2c64a
fix(git): implement all deferred review items
tstapler May 3, 2026
1052c0d
fix(ci): resolve compileCommonMainKotlinMetadata failures in editor f…
tstapler May 3, 2026
c3e28e2
fix(ci): disable classpath snapshots for iOS metadata compilation
tstapler May 3, 2026
14fdd9f
fix(ci): replace compileCommonMainKotlinMetadata with compileKotlinJvm
tstapler May 3, 2026
7758019
fix(editor): remove call to non-existent getFormatAt method
tstapler May 3, 2026
9f16cd8
fix(git): store HTTPS token in CredentialStore before test connection
tstapler May 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 14 additions & 26 deletions .github/workflows/ci-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,18 @@ jobs:
ios-framework:
name: iOS Framework Link Check
runs-on: macos-latest
# One pre-existing blocker prevents full iOS compilation from passing:
# Gradle issue #17559 — classloader mismatch: in a multi-project build where :kmp uses
# kotlin-multiplatform and :androidApp uses AGP, KotlinNativeBundleBuildService is loaded
# by different classloaders. The KGP sets the service on tasks via
# `Property<KotlinNativeBundleBuildService>.value(provider)` which Gradle 8.8+ rejects
# when the property and provider types are the same class from different loaders. Fix
# requires JetBrains to annotate with @ServiceReference or use Property<Any> upstream.
# No Kotlin version (2.1.x, 2.2.x, 2.3.x) contains this fix. Affects compileKotlinIos*
# tasks but NOT compileCommonMainKotlinMetadata (which is what we run here).
# Keep non-blocking until we can verify compileCommonMainKotlinMetadata passes consistently.
continue-on-error: true
# Full iOS compilation is blocked by Gradle issue #17559 — KotlinNativeBundleBuildService
# classloader mismatch in multi-project builds with AGP. Affects compileKotlinIos* tasks.
#
# compileCommonMainKotlinMetadata is also unusable: Kotlin 2.3.x K2 cannot read
# Compose Multiplatform 1.7.x klibs (compiled with Kotlin 2.1.x), causing
# "Unresolved reference" errors for all androidx.compose.runtime.* symbols
# across every commonMain file. JVM compilation uses JVM jars instead of klibs
# and is not affected by this incompatibility.
#
# This job validates all commonMain sources via compileKotlinJvm, which exercises
# the same shared code. Restore full compileKotlinIosSimulatorArm64 once KT-68400
# is resolved and CMP is upgraded to a Kotlin 2.3.x-compatible release.
if: github.event.pull_request.draft == false

steps:
Expand All @@ -48,26 +49,13 @@ jobs:
java-version: '21'
distribution: 'temurin'

# Cache the Kotlin Native toolchain — critical on macOS (300-500 MB download otherwise)
- uses: actions/cache@v4
with:
path: |
~/.konan/cache
~/.konan/dependencies
~/.konan/kotlin-native-prebuilt-*
key: ${{ runner.os }}-konan-v1-${{ hashFiles('**/build.gradle.kts', '**/settings.gradle.kts') }}
restore-keys: |
${{ runner.os }}-konan-v1-

- uses: gradle/actions/setup-gradle@v4
with:
gradle-home-cache-cleanup: true
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}

# Best-effort iOS source validation. Restore full compileKotlinIosSimulatorArm64
# once the Kotlin plugin upstream KT-68400 regression is fixed.
- name: Compile common metadata
- name: Compile commonMain via JVM target
run: |
./gradlew :kmp:compileCommonMainKotlinMetadata \
./gradlew :kmp:compileKotlinJvm \
--no-daemon \
--build-cache
8 changes: 8 additions & 0 deletions androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ android {
}

packaging {
resources {
// Both org.eclipse.jgit and org.eclipse.jgit.ssh.jsch include plugin.properties;
// exclude it to prevent duplicate-resource merge failure.
excludes += "plugin.properties"
}
jniLibs {
useLegacyPackaging = false
}
Expand All @@ -61,6 +66,8 @@ android {
}

compileOptions {
// JGit 5.13.x in :kmp uses java.time and other Java 8+ APIs — desugaring required here too
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
Expand All @@ -71,6 +78,7 @@ android {
}

dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
implementation(project(":kmp"))
implementation("io.arrow-kt:arrow-core:2.2.1.1")
implementation("androidx.activity:activity-compose:1.9.2")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.app.Application
import android.util.Log
import dev.stapler.stelekit.db.DriverFactory
import dev.stapler.stelekit.db.GraphManager
import dev.stapler.stelekit.git.CredentialStore
import dev.stapler.stelekit.platform.PlatformFileSystem
import dev.stapler.stelekit.platform.PlatformSettings
import dev.stapler.stelekit.platform.SteleKitContext
Expand Down Expand Up @@ -37,6 +38,7 @@ class SteleKitApplication : Application() {
try {
SteleKitContext.init(this)
DriverFactory.setContext(this)
CredentialStore.init(this)
fileSystem = PlatformFileSystem().apply { init(applicationContext) }
graphManager = GraphManager(
platformSettings = PlatformSettings(),
Expand Down
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
101 changes: 78 additions & 23 deletions kmp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ plugins {
}

kotlin {
jvmToolchain(21)
jvmToolchain(25)
applyDefaultHierarchyTemplate()

compilerOptions {
Expand All @@ -30,13 +30,23 @@ kotlin {
jvm()

if (project.findProperty("enableJs") == "true") {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
binaries.executable()
}
}

androidTarget()
androidTarget {
compilations.configureEach {
compileTaskProvider.configure {
compilerOptions {
// Cap Android class files at JVM 21; D8 does not support JVM 25 bytecode.
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
}
}
}
}

iosX64()
iosArm64()
Expand All @@ -46,6 +56,9 @@ kotlin {
sourceSets {
val commonMain by getting {
dependencies {
// Kotlin Multiplatform Diff — used for conflict hunk display
implementation("io.github.petertrr:kotlin-multiplatform-diff:1.3.0")

// Arrow
implementation("io.arrow-kt:arrow-core:2.2.1.1")
implementation("io.arrow-kt:arrow-optics:2.2.1.1")
Expand Down Expand Up @@ -110,6 +123,10 @@ kotlin {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.10.2")
// sqlite-jdbc 3.51.3+ bundled here (verified: 3.51.3.0) — fixes WAL data-race in 3.7.0–3.51.2
implementation("app.cash.sqldelight:sqlite-driver:2.3.2")

// JGit 7.x — Desktop git operations
implementation("org.eclipse.jgit:org.eclipse.jgit:7.3.0.202506031305-r")
implementation("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:7.3.0.202506031305-r")
implementation("org.jetbrains.kotlinx:kotlinx-benchmark-runtime:0.4.13")

// Ktor engine for JVM (used by coil-network-ktor3)
Expand Down Expand Up @@ -193,14 +210,27 @@ kotlin {
// Use 1.1.1 (not 1.1.0) to pick up a protobuf security fix.
implementation("androidx.glance:glance-appwidget:1.1.1")
implementation("androidx.glance:glance-material3:1.1.1")

// WorkManager — periodic background git sync
implementation("androidx.work:work-runtime-ktx:2.9.1")

// JGit 5.13.x — Android git operations (Android-safe; Java 11 APIs with desugaring)
implementation("org.eclipse.jgit:org.eclipse.jgit:5.13.3.202401111512-r")
// JGit SSH/JSch integration module (provides JschConfigSessionFactory)
// Excludes com.jcraft:jsch so the mwiede fork below is the sole jsch on classpath
implementation("org.eclipse.jgit:org.eclipse.jgit.ssh.jsch:5.13.3.202401111512-r") {
exclude(group = "com.jcraft", module = "jsch")
}
// mwiede/jsch fork — ED25519/ECDSA/OpenSSH key support for Android SSH
implementation("com.github.mwiede:jsch:0.2.21")
}
}

val androidUnitTest by getting {
dependencies {
implementation(kotlin("test"))
implementation("junit:junit:4.13.2")
implementation("org.robolectric:robolectric:4.13")
implementation("org.robolectric:robolectric:4.16")
implementation("androidx.test:core:1.6.1")
implementation("androidx.test.ext:junit:1.2.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
Expand Down Expand Up @@ -321,12 +351,15 @@ tasks.named<Test>("jvmTest") {
showStandardStreams = false
}
// Print timing after each test using a listener
afterTest(KotlinClosure2({ desc: TestDescriptor, result: TestResult ->
val ms = result.endTime - result.startTime
if (ms > 1000) {
println(" SLOW (${ms}ms) ${desc.className}#${desc.name}")
addTestListener(object : org.gradle.api.tasks.testing.TestListener {
override fun beforeSuite(suite: TestDescriptor) {}
override fun afterSuite(suite: TestDescriptor, result: TestResult) {}
override fun beforeTest(desc: TestDescriptor) {}
override fun afterTest(desc: TestDescriptor, result: TestResult) {
val ms = result.endTime - result.startTime
if (ms > 1000) println(" SLOW (${ms}ms) ${desc.className}#${desc.name}")
}
}))
})

// Run non-Roborazzi tests in parallel (screenshot tests require AWT and must serialize)
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
Expand Down Expand Up @@ -354,10 +387,15 @@ tasks.register<Test>("jvmTestFast") {
events("PASSED", "FAILED", "SKIPPED")
showExceptions = true
}
afterTest(KotlinClosure2({ desc: TestDescriptor, result: TestResult ->
val ms = result.endTime - result.startTime
if (ms > 500) println(" SLOW (${ms}ms) ${desc.className}#${desc.name}")
}))
addTestListener(object : org.gradle.api.tasks.testing.TestListener {
override fun beforeSuite(suite: TestDescriptor) {}
override fun afterSuite(suite: TestDescriptor, result: TestResult) {}
override fun beforeTest(desc: TestDescriptor) {}
override fun afterTest(desc: TestDescriptor, result: TestResult) {
val ms = result.endTime - result.startTime
if (ms > 500) println(" SLOW (${ms}ms) ${desc.className}#${desc.name}")
}
})
}

// ── graph load TTI profiling ────────────────────────────────────────────────
Expand Down Expand Up @@ -446,22 +484,26 @@ tasks.register<Test>("jvmTestProfile") {
threadsFile.delete()
}

fun runCmd(vararg args: String) {
ProcessBuilder(*args).inheritIO().start().waitFor()
}

if (jfrconvPath != null) {
// Alloc flamegraph from standard JFR (allocation events are unaffected by profiling mode)
exec { commandLine(jfrconvPath, "--alloc", "-o", "collapsed", "$jfrFile", "$allocCollapsedFile"); isIgnoreExitValue = true }
exec { commandLine(jfrconvPath, "--alloc", "$jfrFile", "$htmlFile"); isIgnoreExitValue = true }
runCmd(jfrconvPath, "--alloc", "-o", "collapsed", "$jfrFile", "$allocCollapsedFile")
runCmd(jfrconvPath, "--alloc", "$jfrFile", "$htmlFile")

// CPU/wall flamegraph: prefer wall-clock from async-profiler if available, fall
// back to JFR CPU samples. Both are filtered to coroutine worker threads.
if (wallJfrFile.exists()) {
exec { commandLine(jfrconvPath, "--wall", "--threads", "-o", "collapsed", "$wallJfrFile", "$wallThreadsFile"); isIgnoreExitValue = true }
runCmd(jfrconvPath, "--wall", "--threads", "-o", "collapsed", "$wallJfrFile", "$wallThreadsFile")
if (wallThreadsFile.exists()) {
filterToCoroutineThreads(wallThreadsFile, cpuCollapsedFile) {
exec { commandLine(jfrconvPath, "--wall", "-o", "collapsed", "$wallJfrFile", "$cpuCollapsedFile"); isIgnoreExitValue = true }
runCmd(jfrconvPath, "--wall", "-o", "collapsed", "$wallJfrFile", "$cpuCollapsedFile")
}
}
} else {
exec { commandLine(jfrconvPath, "--threads", "-o", "collapsed", "$jfrFile", "$cpuThreadsFile"); isIgnoreExitValue = true }
runCmd(jfrconvPath, "--threads", "-o", "collapsed", "$jfrFile", "$cpuThreadsFile")
if (cpuThreadsFile.exists()) {
filterToCoroutineThreads(cpuThreadsFile, cpuCollapsedFile) {
cpuThreadsFile.copyTo(cpuCollapsedFile, overwrite = true)
Expand Down Expand Up @@ -559,10 +601,8 @@ with open(out_file, "w") as f:
json.dump(summary, f, indent=2)
print(out_file)
""".trimIndent())
exec {
commandLine("python3", scriptFile.absolutePath, project.rootDir.absolutePath, reportsDir.absolutePath)
isIgnoreExitValue = true
}
ProcessBuilder("python3", scriptFile.absolutePath, project.rootDir.absolutePath, reportsDir.absolutePath)
.inheritIO().start().waitFor()
scriptFile.delete()
}
}
Expand Down Expand Up @@ -632,6 +672,8 @@ detekt {
}

tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
// Detekt 1.23.x max supported --jvm-target is 22; cap it so jvmToolchain(25) doesn't propagate.
jvmTarget = "22"
reports {
html.required.set(true)
sarif.required.set(true)
Expand All @@ -645,6 +687,9 @@ tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
dependencies {
detektPlugins(files("${rootProject.projectDir}/buildSrc/build/libs/buildSrc.jar"))
detektPlugins("io.nlopez.compose.rules:detekt:0.4.27")
// Core library desugaring for Android — required by JGit 5.13.x (java.time, java.util.stream, etc.)
// Must be at module root level (not inside kotlin { sourceSets { } })
"coreLibraryDesugaring"("com.android.tools:desugar_jdk_libs:2.1.4")
}

// ── Local CI check ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -713,15 +758,15 @@ afterEvaluate {

// --cpu reads profiler.ExecutionSample (async-profiler agent events).
// If the agent wasn't active the file will be empty — detect and warn.
exec { commandLine(jfrconvPath, "--cpu", "-o", "collapsed", "$jfr", "$cpuCollapsed"); isIgnoreExitValue = true }
ProcessBuilder(jfrconvPath, "--cpu", "-o", "collapsed", "$jfr", "$cpuCollapsed").inheritIO().start().waitFor()
if (cpuCollapsed.length() == 0L) {
cpuCollapsed.delete()
println("── CPU stacks: (empty — async-profiler agent was not active)")
} else {
println("── CPU stacks: $cpuCollapsed")
}

exec { commandLine(jfrconvPath, "--alloc", "-o", "collapsed", "$jfr", "$allocCollapsed"); isIgnoreExitValue = true }
ProcessBuilder(jfrconvPath, "--alloc", "-o", "collapsed", "$jfr", "$allocCollapsed").inheritIO().start().waitFor()
if (allocCollapsed.exists() && allocCollapsed.length() > 0) println("── Alloc stacks: $allocCollapsed")

// Prune: keep the 20 most recent .jfr files and their collapsed siblings.
Expand Down Expand Up @@ -787,11 +832,21 @@ android {
}

compileOptions {
// coreLibraryDesugar enables Java 8+ API compatibility for JGit 5.13.x on older Android APIs
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}

testOptions {
unitTests.isIncludeAndroidResources = true
}

packaging {
resources {
// Both org.eclipse.jgit and org.eclipse.jgit.ssh.jsch include plugin.properties;
// exclude it to prevent duplicate-resource merge failure in test APKs.
excludes += "plugin.properties"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright (c) 2026 Tyler Stapler
// SPDX-License-Identifier: Elastic-2.0

package dev.stapler.stelekit.git

import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey

/**
* Android implementation of [CredentialStore] using [EncryptedSharedPreferences].
* Credentials are encrypted with AES-256-GCM and stored in the app's private storage.
*
* Note: This class requires an Android Context. Since it is an expect/actual class with
* no-arg constructor in the expect declaration, this implementation retrieves the application
* context via a companion object that must be initialized from the Application class.
*/
actual class CredentialStore actual constructor() {

companion object {
private var applicationContext: Context? = null

/**
* Must be called from Application.onCreate() before any CredentialStore usage.
*/
fun init(context: Context) {
applicationContext = context.applicationContext
}
}

private val prefs: SharedPreferences by lazy {
val ctx = requireNotNull(applicationContext) {
"CredentialStore.init(context) must be called before using CredentialStore"
}
val masterKey = MasterKey.Builder(ctx)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
ctx,
"stelekit_credentials",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}

actual fun store(key: String, value: String) {
prefs.edit().putString(key, value).apply()
}

actual fun retrieve(key: String): String? = prefs.getString(key, null)

actual fun delete(key: String) {
prefs.edit().remove(key).apply()
}
}
Loading
Loading