diff --git a/.github/workflows/ci-ios.yml b/.github/workflows/ci-ios.yml index 2f069052..9d4fdd4c 100644 --- a/.github/workflows/ci-ios.yml +++ b/.github/workflows/ci-ios.yml @@ -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.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 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: @@ -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 diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 2ab9fcbe..2b8fbb85 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -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 } @@ -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 } @@ -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") diff --git a/androidApp/src/main/kotlin/dev/stapler/stelekit/SteleKitApplication.kt b/androidApp/src/main/kotlin/dev/stapler/stelekit/SteleKitApplication.kt index 8b0008b0..6bacd088 100644 --- a/androidApp/src/main/kotlin/dev/stapler/stelekit/SteleKitApplication.kt +++ b/androidApp/src/main/kotlin/dev/stapler/stelekit/SteleKitApplication.kt @@ -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 @@ -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(), diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1d35f3cf..c231e54c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 \ No newline at end of file diff --git a/kmp/build.gradle.kts b/kmp/build.gradle.kts index c0e34637..b80b8b5a 100644 --- a/kmp/build.gradle.kts +++ b/kmp/build.gradle.kts @@ -18,7 +18,7 @@ plugins { } kotlin { - jvmToolchain(21) + jvmToolchain(25) applyDefaultHierarchyTemplate() compilerOptions { @@ -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() @@ -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") @@ -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) @@ -193,6 +210,19 @@ 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") } } @@ -200,7 +230,7 @@ kotlin { 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") @@ -321,12 +351,15 @@ tasks.named("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) @@ -354,10 +387,15 @@ tasks.register("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 ──────────────────────────────────────────────── @@ -446,22 +484,26 @@ tasks.register("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) @@ -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() } } @@ -632,6 +672,8 @@ detekt { } tasks.withType().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) @@ -645,6 +687,9 @@ tasks.withType().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 ─────────────────────────────────────────────────────────── @@ -713,7 +758,7 @@ 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)") @@ -721,7 +766,7 @@ afterEvaluate { 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. @@ -787,6 +832,8 @@ 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 } @@ -794,4 +841,12 @@ android { 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" + } + } } diff --git a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/git/AndroidCredentialStore.kt b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/git/AndroidCredentialStore.kt new file mode 100644 index 00000000..70b1027e --- /dev/null +++ b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/git/AndroidCredentialStore.kt @@ -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() + } +} diff --git a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/git/AndroidGitRepository.kt b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/git/AndroidGitRepository.kt new file mode 100644 index 00000000..fde81d02 --- /dev/null +++ b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/git/AndroidGitRepository.kt @@ -0,0 +1,461 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.git + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.jcraft.jsch.JSch +import com.jcraft.jsch.Session +import dev.stapler.stelekit.coroutines.PlatformDispatcher +import dev.stapler.stelekit.error.DomainError +import dev.stapler.stelekit.git.model.ConflictFile +import dev.stapler.stelekit.git.model.GitAuthType +import dev.stapler.stelekit.git.model.GitConfig +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withContext +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.MergeCommand +import org.eclipse.jgit.api.errors.TransportException +import org.eclipse.jgit.merge.MergeStrategy +import org.eclipse.jgit.transport.JschConfigSessionFactory +import org.eclipse.jgit.transport.OpenSshConfig +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider +import org.eclipse.jgit.util.FS +import java.io.File + +/** + * Android implementation of GitRepository using JGit 5.13.x + mwiede/jsch for SSH. + * All I/O runs on PlatformDispatcher.IO. + * + * @param sshKeyProvider Optional provider for SSH private key bytes, used for + * configurable key loading (from user-configured path or Android storage). + */ +class AndroidGitRepository( + private val sshKeyProvider: (() -> ByteArray)? = null, + private val credentialStore: CredentialStore = CredentialStore(), +) : GitRepository { + + override suspend fun isGitRepo(path: String): Boolean = withContext(PlatformDispatcher.IO) { + File(path, ".git").exists() + } + + override suspend fun init(repoRoot: String): Either = + withContext(PlatformDispatcher.IO) { + try { + Git.init().setDirectory(File(repoRoot)).call().close() + Unit.right() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.CloneFailed("init failed: ${e.message}").left() + } + } + + override suspend fun clone( + url: String, + localPath: String, + auth: GitAuth, + onProgress: (String) -> Unit, + ): Either = withContext(PlatformDispatcher.IO) { + try { + val cmd = Git.cloneRepository() + .setURI(url) + .setDirectory(File(localPath)) + .setProgressMonitor(object : org.eclipse.jgit.lib.ProgressMonitor { + override fun start(totalTasks: Int) {} + override fun beginTask(title: String, totalWork: Int) { onProgress(title) } + override fun update(completed: Int) {} + override fun endTask() {} + override fun isCancelled() = false + }) + + configureAuth(cmd, auth) + cmd.call().close() + Unit.right() + } catch (e: TransportException) { + DomainError.GitError.AuthFailed(e.message ?: "Authentication failed").left() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.CloneFailed(e.message ?: "Clone failed").left() + } + } + + override suspend fun fetch(config: GitConfig): Either = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + val repo = git.repository + val headBefore = repo.resolve("HEAD") + + git.fetch() + .setRemote(config.remoteName) + .also { configureTransport(it, config) } + .call() + + val remoteRef = repo.resolve("${config.remoteName}/${config.remoteBranch}") + val hasChanges = remoteRef != null && remoteRef != headBefore + + val remoteCommitCount = if (hasChanges && headBefore != null && remoteRef != null) { + try { + git.log().addRange(headBefore, remoteRef).setMaxCount(100).call().toList().size + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + 0 + } + } else { + 0 + } + + FetchResult(hasRemoteChanges = hasChanges, remoteCommitCount = remoteCommitCount).right() + } + } catch (e: TransportException) { + DomainError.GitError.AuthFailed(e.message ?: "Authentication failed").left() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.FetchFailed(e.message ?: "Fetch failed").left() + } + } + + override suspend fun status(config: GitConfig): Either = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + val statusResult = git.status() + .also { cmd -> + if (config.wikiSubdir.isNotEmpty()) { + cmd.addPath(config.wikiSubdir) + } + } + .call() + + GitStatus( + hasLocalChanges = !statusResult.isClean, + untrackedFiles = statusResult.untracked.toList(), + modifiedFiles = (statusResult.modified + statusResult.changed).toList(), + ).right() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.FetchFailed("Status failed: ${e.message}").left() + } + } + + override suspend fun stageSubdir(config: GitConfig): Either = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + val pattern = if (config.wikiSubdir.isEmpty()) "." else "${config.wikiSubdir}/" + git.add().addFilepattern(pattern).call() + git.add().setUpdate(true).addFilepattern(pattern).call() + Unit.right() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.CommitFailed("Stage failed: ${e.message}").left() + } + } + + override suspend fun commit(config: GitConfig, message: String): Either = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + val commit = git.commit().setMessage(message).call() + commit.name.right() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.CommitFailed(e.message ?: "Commit failed").left() + } + } + + override suspend fun merge(config: GitConfig): Either = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + val repo = git.repository + val remoteRef = repo.resolve("${config.remoteName}/${config.remoteBranch}") + ?: return@withContext DomainError.GitError.FetchFailed( + "Remote ref not found" + ).left() + + val mergeResult = git.merge() + .include(remoteRef) + .setStrategy(MergeStrategy.RECURSIVE) + .setFastForward(MergeCommand.FastForwardMode.NO_FF) + .call() + + val hasConflicts = mergeResult.mergeStatus == + org.eclipse.jgit.api.MergeResult.MergeStatus.CONFLICTING + + val conflictFiles = if (hasConflicts) { + mergeResult.conflicts?.keys?.map { filePath -> + val absolutePath = "${config.repoRoot}/$filePath" + val wikiRelPath = if (config.wikiSubdir.isNotEmpty() && + filePath.startsWith("${config.wikiSubdir}/")) { + filePath.removePrefix("${config.wikiSubdir}/") + } else { + filePath + } + ConflictFile( + filePath = absolutePath, + wikiRelativePath = wikiRelPath, + hunks = emptyList(), + ) + } ?: emptyList() + } else { + emptyList() + } + + 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 (e: CancellationException) { + throw e + } catch (_: Exception) { + emptyList() + } + + val wikiChangedFiles = if (config.wikiSubdir.isNotEmpty()) { + changedFiles.filter { it.startsWith("${config.repoRoot}/${config.wikiSubdir}/") } + } else { + changedFiles + } + + MergeResult( + hasConflicts = hasConflicts, + conflicts = conflictFiles, + changedFiles = wikiChangedFiles, + ).right() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.FetchFailed("Merge failed: ${e.message}").left() + } + } + + override suspend fun push(config: GitConfig): Either = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + git.push() + .setRemote(config.remoteName) + .also { configureTransport(it, config) } + .call() + Unit.right() + } + } catch (e: TransportException) { + DomainError.GitError.AuthFailed(e.message ?: "Push authentication failed").left() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.PushFailed(e.message ?: "Push failed").left() + } + } + + override suspend fun log(config: GitConfig, maxCount: Int): Either> = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + val commits = git.log() + .setMaxCount(maxCount) + .call() + .map { revCommit -> + GitCommit( + sha = revCommit.name, + shortMessage = revCommit.shortMessage, + authorName = revCommit.authorIdent.name, + timestamp = revCommit.authorIdent.`when`.time, + ) + } + commits.right() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.FetchFailed("Log failed: ${e.message}").left() + } + } + + override suspend fun abortMerge(config: GitConfig): Either = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + git.reset() + .setMode(org.eclipse.jgit.api.ResetCommand.ResetType.MERGE) + .call() + Unit.right() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.CommitFailed("Abort merge failed: ${e.message}").left() + } + } + + override suspend fun checkoutFile( + config: GitConfig, + filePath: String, + side: MergeSide, + ): Either = withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + val stage = when (side) { + MergeSide.LOCAL -> org.eclipse.jgit.api.CheckoutCommand.Stage.OURS + MergeSide.REMOTE -> org.eclipse.jgit.api.CheckoutCommand.Stage.THEIRS + } + git.checkout() + .setStage(stage) + .addPath(filePath.removePrefix("${config.repoRoot}/")) + .call() + Unit.right() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.CommitFailed("Checkout file failed: ${e.message}").left() + } + } + + override suspend fun markResolved(config: GitConfig, filePath: String): Either = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + git.add().addFilepattern(filePath.removePrefix("${config.repoRoot}/")).call() + Unit.right() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.CommitFailed("Mark resolved failed: ${e.message}").left() + } + } + + override suspend fun hasDetachedHead(config: GitConfig): Boolean = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + val fullBranch = git.repository.fullBranch ?: return@use false + !fullBranch.startsWith("refs/heads/") + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + false + } + } + + override suspend fun removeStaleLockFile(config: GitConfig): Either = + withContext(PlatformDispatcher.IO) { + try { + val lockFile = File(config.repoRoot, ".git/index.lock") + if (!lockFile.exists()) return@withContext Unit.right() + + val ageMs = System.currentTimeMillis() - lockFile.lastModified() + if (ageMs > 60_000L) { + if (lockFile.delete()) Unit.right() + else DomainError.GitError.StaleLockFile(lockFile.absolutePath).left() + } else { + DomainError.GitError.StaleLockFile(lockFile.absolutePath).left() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.StaleLockFile("${config.repoRoot}/.git/index.lock").left() + } + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private fun openGit(repoRoot: String): Git = Git.open(File(repoRoot)) + + private fun buildJschSessionFactory(keyPath: String): JschConfigSessionFactory { + return object : JschConfigSessionFactory() { + override fun configure(host: OpenSshConfig.Host, session: Session) { + session.setConfig("StrictHostKeyChecking", "accept-new") + } + + override fun createDefaultJSch(fs: FS): JSch { + val jsch = super.createDefaultJSch(fs) + val keyBytes = sshKeyProvider?.invoke() + if (keyBytes != null) { + jsch.addIdentity("stelekit-key", keyBytes, null, null) + } else if (keyPath.isNotEmpty()) { + jsch.addIdentity(keyPath) + } + return jsch + } + } + } + + private fun configureTransport( + cmd: org.eclipse.jgit.api.TransportCommand<*, *>, + config: GitConfig, + ) { + when (config.authType) { + GitAuthType.HTTPS_TOKEN -> { + val token = config.httpsTokenKey?.let { credentialStore.retrieve(it) } ?: return + cmd.setCredentialsProvider(UsernamePasswordCredentialsProvider("", token)) + } + GitAuthType.SSH_KEY -> { + cmd.setTransportConfigCallback { transport -> + if (transport is org.eclipse.jgit.transport.SshTransport && config.sshKeyPath != null) { + transport.sshSessionFactory = buildJschSessionFactory(config.sshKeyPath) + } + } + } + GitAuthType.NONE -> {} + } + } + + private fun configureAuth( + cmd: org.eclipse.jgit.api.TransportCommand<*, *>, + auth: GitAuth, + ) { + when (auth) { + is GitAuth.HttpsToken -> { + val token = runCatching { + kotlinx.coroutines.runBlocking { auth.tokenProvider() } + }.getOrNull() ?: return + cmd.setCredentialsProvider( + UsernamePasswordCredentialsProvider(auth.username, token) + ) + } + is GitAuth.SshKey -> { + cmd.setTransportConfigCallback { transport -> + if (transport is org.eclipse.jgit.transport.SshTransport) { + transport.sshSessionFactory = buildJschSessionFactory(auth.keyPath) + } + } + } + is GitAuth.None -> { /* no auth */ } + } + } +} diff --git a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/git/WorkManagerSyncScheduler.kt b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/git/WorkManagerSyncScheduler.kt new file mode 100644 index 00000000..48110b07 --- /dev/null +++ b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/git/WorkManagerSyncScheduler.kt @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.git + +import android.content.Context +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dev.stapler.stelekit.db.DriverFactory +import dev.stapler.stelekit.git.model.GitAuthType +import dev.stapler.stelekit.git.model.GitConfig +import kotlinx.coroutines.CancellationException +import java.util.concurrent.TimeUnit + +/** + * Android implementation of [BackgroundSyncScheduler] using WorkManager. + * + * Schedules [GitSyncWorker] with a [NetworkType.CONNECTED] constraint. + * Android enforces a minimum 15-minute repeat interval for periodic work. + * + * For sub-15-minute polling when the app is foregrounded, [GitSyncService] manages + * its own coroutine timer via [GitSyncService.startPeriodicSync]. + */ +class WorkManagerSyncScheduler( + private val context: Context, + private val graphId: String, +) : BackgroundSyncScheduler { + + companion object { + private const val WORK_NAME = "stelekit_git_sync" + } + + override fun schedule(intervalMinutes: Int) { + val repeatInterval = maxOf(intervalMinutes.toLong(), 15L) + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = PeriodicWorkRequestBuilder(repeatInterval, TimeUnit.MINUTES) + .setConstraints(constraints) + .setInputData( + androidx.work.Data.Builder() + .putString(GitSyncWorker.KEY_GRAPH_ID, graphId) + .build() + ) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + request, + ) + } + + override fun cancel() { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + } +} + +/** + * WorkManager worker that calls [GitSyncService.fetchOnly] to check for remote changes. + * + * Only fetches — does not merge — to minimise battery impact and avoid data loss + * from automatic merges when the user may be editing. + * + * The [GitSyncService] instance is obtained from [GitSyncServiceRegistry], which must be + * populated from the Application class before WorkManager starts any work. + */ +class GitSyncWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + companion object { + const val KEY_GRAPH_ID = "graph_id" + } + + override suspend fun doWork(): Result { + val graphId = inputData.getString(KEY_GRAPH_ID) ?: return Result.failure() + + // Fast path: app is running and service is registered + val service = GitSyncServiceRegistry.getService(graphId) + if (service != null) { + return try { + service.fetchOnly(graphId) + Result.success() + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + Result.retry() + } + } + + // Slow path: process was killed and WorkManager restarted it. + // Application.onCreate() has run, so DriverFactory is initialized. + // Perform a standalone fetch directly without a full GitSyncService. + return try { + CredentialStore.init(applicationContext) + DriverFactory.setContext(applicationContext) + + val factory = DriverFactory() + val dbUrl = factory.getDatabaseUrl(graphId) + val driver = factory.createDriver(dbUrl) + val db = dev.stapler.stelekit.db.SteleDatabase(driver) + + val row = db.steleDatabaseQueries.selectGitConfig(graphId).executeAsOneOrNull() + ?: run { driver.close(); return Result.success() } + + val config = row.toGitConfig() + val gitRepository = AndroidGitRepository() + gitRepository.fetch(config) + + driver.close() + Result.success() + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + Result.retry() + } + } +} + +private fun dev.stapler.stelekit.db.Git_config.toGitConfig() = + dev.stapler.stelekit.git.model.GitConfig( + graphId = graph_id, + repoRoot = repo_root, + wikiSubdir = wiki_subdir, + remoteName = remote_name, + remoteBranch = remote_branch, + authType = runCatching { + dev.stapler.stelekit.git.model.GitAuthType.valueOf(auth_type) + }.getOrDefault(dev.stapler.stelekit.git.model.GitAuthType.NONE), + sshKeyPath = ssh_key_path, + sshKeyPassphraseKey = ssh_key_passphrase_key, + httpsTokenKey = https_token_key, + pollIntervalMinutes = poll_interval_minutes.toInt(), + autoCommit = auto_commit != 0L, + commitMessageTemplate = commit_message_template, + ) + +/** + * Simple static registry that maps graphId → [GitSyncService]. + * + * Populated from the Application class (or equivalent host) so [GitSyncWorker] can + * retrieve the service without a DI container. + * + * In a future iteration this should be replaced with Hilt injection. + */ +object GitSyncServiceRegistry { + private val services = mutableMapOf() + + fun register(graphId: String, service: GitSyncService) { + services[graphId] = service + } + + fun unregister(graphId: String) { + services.remove(graphId) + } + + fun getService(graphId: String): GitSyncService? = services[graphId] +} diff --git a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/platform/AndroidNetworkMonitor.kt b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/platform/AndroidNetworkMonitor.kt new file mode 100644 index 00000000..c798658a --- /dev/null +++ b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/platform/AndroidNetworkMonitor.kt @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.platform + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged + +/** + * Android implementation of [NetworkMonitor] using ConnectivityManager callbacks. + * + * Uses a companion object to hold the application context, initialized from Application.onCreate(). + */ +actual class NetworkMonitor actual constructor() { + + companion object { + private var applicationContext: Context? = null + + fun init(context: Context) { + applicationContext = context.applicationContext + } + } + + private val connectivityManager: ConnectivityManager? + get() = applicationContext?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + + actual val isOnline: Boolean + get() { + val cm = connectivityManager ?: return false + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } + + actual fun observeConnectivity(): Flow = callbackFlow { + val cm = connectivityManager + if (cm == null) { + trySend(false) + awaitClose() + return@callbackFlow + } + + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(true) + } + + override fun onLost(network: Network) { + trySend(false) + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities, + ) { + val online = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + trySend(online) + } + } + + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + // Emit current state first + trySend(isOnline) + + cm.registerNetworkCallback(request, callback) + awaitClose { cm.unregisterNetworkCallback(callback) } + }.distinctUntilChanged() +} diff --git a/kmp/src/androidUnitTest/resources/robolectric.properties b/kmp/src/androidUnitTest/resources/robolectric.properties index 3d78689f..979b5eeb 100644 --- a/kmp/src/androidUnitTest/resources/robolectric.properties +++ b/kmp/src/androidUnitTest/resources/robolectric.properties @@ -1 +1 @@ -sdk=29 +sdk=34 diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphLoader.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphLoader.kt index dc31cafb..2d6b4e6a 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphLoader.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphLoader.kt @@ -149,8 +149,18 @@ class GraphLoader( private val _writeErrors = MutableSharedFlow(extraBufferCapacity = 16) val writeErrors: SharedFlow = _writeErrors.asSharedFlow() + // Files suppressed from external-change processing. + // Two modes: + // 1. Single-shot: subscriber calls suppress() in ExternalFileChange handler; the path is + // added here and then removed by checkDirectoryForChanges via .remove(). + // 2. Sticky (git merge): beginGitMerge() pre-adds paths; they are checked with .contains() + // and not removed until endGitMerge() calls .clear(). + // Both modes share the same set; sticky paths survive across multiple watcher ticks. private val suppressedFiles = mutableSetOf() + // Paths added by beginGitMerge() — kept for sticky suppression across watcher ticks. + private val gitMergeSuppressedFiles = mutableSetOf() + /** * Derives a stable UUID for a block from its position in the page tree. * @@ -511,6 +521,13 @@ class GraphLoader( for (changed in changeSet.changedFiles) { logger.info("File modification detected: ${changed.entry.filePath}") + // Sticky git-merge suppression: if the path was added by beginGitMerge, + // skip it without consuming the entry (it remains suppressed until endGitMerge). + if (gitMergeSuppressedFiles.contains(changed.entry.filePath)) { + logger.debug("Skipping watcher reload for git-merge-suppressed file: ${changed.entry.filePath}") + continue + } + // Emit event so subscribers can suppress the re-import _externalFileChanges.tryEmit(ExternalFileChange(changed.entry.filePath, changed.content) { suppressedFiles.add(changed.entry.filePath) @@ -584,6 +601,43 @@ class GraphLoader( writeActor.close() } + /** + * Adds [pathsBeingMerged] to the sticky git-merge suppression set so the 5-second + * polling watcher ignores changes to these files during a git merge operation. + * + * Unlike single-shot suppression, these entries persist across multiple watcher + * ticks until [endGitMerge] is called. + * + * Call this immediately before [reloadFiles] after git merge completes. + * Always paired with [endGitMerge]. + */ + fun beginGitMerge(pathsBeingMerged: List) { + gitMergeSuppressedFiles.addAll(pathsBeingMerged) + } + + /** + * Clears the git-merge suppression set, restoring normal file-watcher behaviour. + * Must be called after [reloadFiles] completes (or if merge is aborted). + */ + fun endGitMerge() { + gitMergeSuppressedFiles.clear() + } + + /** + * Explicitly reloads [filePaths] from disk and saves them to the database, + * bypassing the file-watcher change-detection loop. + * + * Used after a successful git merge to push merged content into the DB. + * Files with conflict markers are skipped by the existing [ConflictMarkerDetector] + * guard inside [parseAndSavePage]. + */ + suspend fun reloadFiles(filePaths: List) { + for (path in filePaths) { + val content = fileSystem.readFile(path) ?: continue + parseAndSavePage(path, content, ParseMode.FULL, DatabaseWriteActor.Priority.HIGH) + } + } + suspend fun loadFullPage(pageUuid: String) { PerformanceMonitor.startTrace("loadFullPage") var filePath: String? = null diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphManager.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphManager.kt index 36762e7c..109467fa 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphManager.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphManager.kt @@ -62,7 +62,12 @@ class GraphManager( // Track active coroutines for cleanup during graph switches private val activeGraphJobs = mutableMapOf() - + + // Git sync service for the currently active graph. + // Set externally via registerGitSyncService() after GraphLoader/GraphWriter are wired. + private val _activeGitSyncService = MutableStateFlow(null) + val activeGitSyncService: StateFlow = _activeGitSyncService.asStateFlow() + init { loadRegistry() } @@ -258,7 +263,11 @@ class GraphManager( // Cancel any existing coroutines for the previous graph val currentGraphId = registry.activeGraphId currentGraphId?.let { activeGraphJobs.remove(it)?.cancel() } - + + // Shutdown any git sync service from the previous graph + _activeGitSyncService.value?.shutdown() + _activeGitSyncService.value = null + // Close current factory and its database connection currentFactory?.close() currentFactory = null @@ -362,6 +371,32 @@ class GraphManager( } } + /** + * Registers the [GitSyncService] for the currently active graph. + * + * Called from [GraphContent] after [GraphLoader] and [GraphWriter] are constructed, + * because those objects are Compose-managed and cannot be created inside [GraphManager]. + * The service is automatically shut down on the next [switchGraph] call or on [shutdown]. + */ + fun registerGitSyncService(service: dev.stapler.stelekit.git.GitSyncService?) { + _activeGitSyncService.value = service + } + + /** + * Creates a [GitConfigRepository] backed by the currently active graph's database. + * Returns null if the database is not yet open or the backend is not SQLDELIGHT. + * + * Called from [GraphContent] to wire the [GitSyncService] construction. + */ + fun createGitConfigRepository(): dev.stapler.stelekit.git.GitConfigRepository? { + val factory = currentFactory as? dev.stapler.stelekit.repository.RepositoryFactoryImpl ?: return null + val actor = _activeRepositorySet.value?.writeActor ?: return null + return dev.stapler.stelekit.git.SqlDelightGitConfigRepository( + database = factory.steleDatabase(), + writeActor = actor, + ) + } + /** * Clean up all resources when shutting down */ @@ -369,10 +404,14 @@ class GraphManager( // Cancel all graph-specific coroutines activeGraphJobs.values.forEach { it.cancel() } activeGraphJobs.clear() - + + // Shutdown git sync service + _activeGitSyncService.value?.shutdown() + _activeGitSyncService.value = null + // Close database connection currentFactory?.close() - + // Clear repository set _activeRepositorySet.value = null currentFactory = null diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/RestrictedDatabaseQueries.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/RestrictedDatabaseQueries.kt index e7e530e1..f09b6daa 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/RestrictedDatabaseQueries.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/RestrictedDatabaseQueries.kt @@ -396,4 +396,29 @@ class RestrictedDatabaseQueries(private val queries: SteleDatabaseQueries) { @DirectSqlWrite fun pragmaWalCheckpointTruncate() = queries.pragmaWalCheckpointTruncate() + + // ── Git config writes ───────────────────────────────────────────────────── + + @DirectSqlWrite + fun insertOrReplaceGitConfig( + graph_id: String, + repo_root: String, + wiki_subdir: String, + remote_name: String, + remote_branch: String, + auth_type: String, + ssh_key_path: String?, + ssh_key_passphrase_key: String?, + https_token_key: String?, + poll_interval_minutes: Long, + auto_commit: Long, + commit_message_template: String, + ) = queries.insertOrReplaceGitConfig( + graph_id, repo_root, wiki_subdir, remote_name, remote_branch, + auth_type, ssh_key_path, ssh_key_passphrase_key, https_token_key, + poll_interval_minutes, auto_commit, commit_message_template, + ) + + @DirectSqlWrite + fun deleteGitConfig(graph_id: String) = queries.deleteGitConfig(graph_id) } diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/editor/Editor.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/editor/Editor.kt index adc46b49..f8157049 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/editor/Editor.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/editor/Editor.kt @@ -7,12 +7,12 @@ import arrow.core.left import arrow.core.right import dev.stapler.stelekit.error.DomainError -import androidx.compose.runtime.* import dev.stapler.stelekit.repository.DirectRepositoryWrite import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.isCtrlPressed +import kotlinx.coroutines.CoroutineScope import dev.stapler.stelekit.model.Page import dev.stapler.stelekit.model.CursorState import dev.stapler.stelekit.editor.commands.* @@ -41,7 +41,7 @@ class Editor( private val commandSystem: ICommandSystem, ) : IEditor { - private val scope = mutableStateOf(null) + private var scope: CoroutineScope? = null private val _editorState = MutableStateFlow(EditorState(textOperations = textOperations)) private val _currentPage = MutableStateFlow(null) private val _cursorState = MutableStateFlow(CursorState()) @@ -93,35 +93,35 @@ class Editor( when { keyEvent.isCtrlPressed && key == Key.S -> { // Save - scope.value?.launch { + scope?.launch { executeCommand("system.save", emptyMap()) } return true } keyEvent.isCtrlPressed && key == Key.Z -> { // Undo - scope.value?.launch { + scope?.launch { executeCommand("system.undo", emptyMap()) } return true } keyEvent.isCtrlPressed && key == Key.Y -> { // Redo - scope.value?.launch { + scope?.launch { executeCommand("system.redo", emptyMap()) } return true } keyEvent.isCtrlPressed && key == Key.F -> { // Search - scope.value?.launch { + scope?.launch { executeCommand("navigation.search", emptyMap()) } return true } key == Key.Escape -> { // Command palette - scope.value?.launch { + scope?.launch { _editorState.update { it.copy(mode = EditorMode.VIEW) } } return true diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/editor/RichTextEditor.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/editor/RichTextEditor.kt index afcf3b4c..d4669cb2 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/editor/RichTextEditor.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/editor/RichTextEditor.kt @@ -1,25 +1,42 @@ package dev.stapler.stelekit.editor -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import dev.stapler.stelekit.model.Block import dev.stapler.stelekit.ui.theme.StelekitTheme import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType import dev.stapler.stelekit.editor.text.ITextOperations -import dev.stapler.stelekit.editor.IFormatProcessor +import dev.stapler.stelekit.editor.format.IFormatProcessor import dev.stapler.stelekit.editor.text.TextRange as EditorTextRange /** @@ -144,15 +161,7 @@ fun RichFormattedEditor( val scope = rememberCoroutineScope() val textState by textOperations.getTextState(block.uuid).collectAsState() - // Get format at cursor for visual feedback - val currentFormat by remember(textState.cursorPosition) { - derivedStateOf { - runCatching { - formatProcessor.getFormatAt(textState.content, textState.cursorPosition).getOrNull() - ?: TextFormat() - }.getOrDefault(TextFormat()) - } - } + val currentFormat by remember { derivedStateOf { TextFormat() } } Column(modifier = modifier) { // Formatting toolbar diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/editor/components/RichTextEditor.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/editor/components/RichTextEditor.kt index 4da3e719..cb38ca71 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/editor/components/RichTextEditor.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/editor/components/RichTextEditor.kt @@ -7,7 +7,13 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/error/DomainError.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/error/DomainError.kt index 0d459722..a0c031e3 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/error/DomainError.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/error/DomainError.kt @@ -47,6 +47,35 @@ sealed interface DomainError { data class CircuitOpen(override val message: String = "Circuit breaker is open") : NetworkError data class Timeout(override val message: String) : NetworkError } + + sealed interface GitError : DomainError { + data class CloneFailed(override val message: String) : GitError + data class FetchFailed(override val message: String) : GitError + data class PushFailed(override val message: String) : GitError + data class AuthFailed(override val message: String) : GitError + data class MergeConflict(val conflictCount: Int, val conflictPaths: List = emptyList()) : GitError { + override val message: String = "Merge conflict in $conflictCount file(s)" + } + data class CommitFailed(override val message: String) : GitError + data class NotAGitRepo(val path: String) : GitError { + override val message: String = "Not a git repository: $path" + } + data class DetachedHead(val path: String) : GitError { + override val message: String = "Repository is in detached HEAD state: $path" + } + data class StaleLockFile(val lockPath: String) : GitError { + override val message: String = "Stale git lock file found: $lockPath" + } + data class NotSupported(val platform: String) : GitError { + override val message: String = "Git integration not yet supported on $platform" + } + data object Offline : GitError { + override val message: String = "No network connection available" + } + data object EditingInProgress : GitError { + override val message: String = "Cannot sync while editing is in progress" + } + } } fun Throwable.toDatabaseError(): DomainError.DatabaseError.WriteFailed = @@ -72,4 +101,16 @@ fun DomainError.toUiMessage(): String = when (this) { is DomainError.NetworkError.HttpError -> "HTTP $statusCode: $message" is DomainError.NetworkError.CircuitOpen -> message is DomainError.NetworkError.Timeout -> "Request timed out: $message" + is DomainError.GitError.CloneFailed -> "Git clone failed: $message" + is DomainError.GitError.FetchFailed -> "Git fetch failed: $message" + is DomainError.GitError.PushFailed -> "Git push failed: $message" + is DomainError.GitError.AuthFailed -> "Git authentication failed: $message" + is DomainError.GitError.MergeConflict -> message + is DomainError.GitError.CommitFailed -> "Git commit failed: $message" + is DomainError.GitError.NotAGitRepo -> message + is DomainError.GitError.DetachedHead -> message + is DomainError.GitError.StaleLockFile -> message + is DomainError.GitError.NotSupported -> message + is DomainError.GitError.Offline -> message + is DomainError.GitError.EditingInProgress -> message } diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/BackgroundSyncScheduler.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/BackgroundSyncScheduler.kt new file mode 100644 index 00000000..d165a62a --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/BackgroundSyncScheduler.kt @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.git + +/** + * Platform-agnostic interface for scheduling periodic background git sync. + */ +interface BackgroundSyncScheduler { + /** Schedule periodic sync at the given interval (minimum 15 minutes on Android). */ + fun schedule(intervalMinutes: Int) + + /** Cancel any scheduled sync. */ + fun cancel() +} diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/ConflictResolver.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/ConflictResolver.kt new file mode 100644 index 00000000..88291fed Binary files /dev/null and b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/ConflictResolver.kt differ diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/CredentialStore.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/CredentialStore.kt new file mode 100644 index 00000000..4707e85b --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/CredentialStore.kt @@ -0,0 +1,14 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.git + +/** + * Secure storage for git credentials (SSH passphrases, HTTPS tokens). + * Each platform provides an appropriate secure backend. + */ +expect class CredentialStore() { + fun store(key: String, value: String) + fun retrieve(key: String): String? + fun delete(key: String) +} diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/EditLock.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/EditLock.kt new file mode 100644 index 00000000..6a3e61c1 --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/EditLock.kt @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.git + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update + +/** + * Tracks whether any blocks are currently being edited. + * Used by GitSyncService to wait until editing is idle before merging. + * + * Owns its own CoroutineScope — never accept rememberCoroutineScope(). + */ +class EditLock { + private val editLockScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val _editingCount = MutableStateFlow(0) + + fun beginEdit() { + _editingCount.update { it + 1 } + } + + fun endEdit() { + _editingCount.update { maxOf(it - 1, 0) } + } + + /** + * Suspends until no blocks are in edit mode. + * Called by GitSyncService before merge. + */ + suspend fun awaitIdle() { + _editingCount.first { it == 0 } + } + + val isEditing: StateFlow = _editingCount + .map { it > 0 } + .stateIn(editLockScope, SharingStarted.Eagerly, false) +} diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/GitConfigRepository.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/GitConfigRepository.kt new file mode 100644 index 00000000..effb7c26 --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/GitConfigRepository.kt @@ -0,0 +1,20 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.git + +import arrow.core.Either +import dev.stapler.stelekit.error.DomainError +import dev.stapler.stelekit.git.model.GitConfig +import kotlinx.coroutines.flow.Flow + +/** + * Persists per-graph git configuration in SQLDelight. + */ +@Suppress("MissingDirectRepositoryWrite") // actor routing is internalized in SqlDelightGitConfigRepository +interface GitConfigRepository { + suspend fun getConfig(graphId: String): Either + suspend fun saveConfig(config: GitConfig): Either + suspend fun deleteConfig(graphId: String): Either + fun observeConfig(graphId: String): Flow> +} diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/GitRepository.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/GitRepository.kt new file mode 100644 index 00000000..5174201a --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/GitRepository.kt @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.git + +import arrow.core.Either +import dev.stapler.stelekit.error.DomainError +import dev.stapler.stelekit.git.model.ConflictFile +import dev.stapler.stelekit.git.model.GitConfig + +/** + * Platform-agnostic git operations interface. + * Platform implementations: JvmGitRepository (Desktop), AndroidGitRepository (Android), + * IosGitRepository (iOS stub). + */ +@Suppress("MissingDirectRepositoryWrite") // git network/filesystem ops — not DB writes, not actor-routed +interface GitRepository { + suspend fun isGitRepo(path: String): Boolean + suspend fun init(repoRoot: String): Either + suspend fun clone( + url: String, + localPath: String, + auth: GitAuth, + onProgress: (String) -> Unit, + ): Either + suspend fun fetch(config: GitConfig): Either + suspend fun status(config: GitConfig): Either + suspend fun stageSubdir(config: GitConfig): Either + suspend fun commit(config: GitConfig, message: String): Either + suspend fun merge(config: GitConfig): Either + suspend fun push(config: GitConfig): Either + suspend fun log(config: GitConfig, maxCount: Int = 50): Either> + suspend fun abortMerge(config: GitConfig): Either + suspend fun checkoutFile(config: GitConfig, filePath: String, side: MergeSide): Either + suspend fun markResolved(config: GitConfig, filePath: String): Either + suspend fun hasDetachedHead(config: GitConfig): Boolean + suspend fun removeStaleLockFile(config: GitConfig): Either +} + +data class FetchResult(val hasRemoteChanges: Boolean, val remoteCommitCount: Int) + +data class GitStatus( + val hasLocalChanges: Boolean, + val untrackedFiles: List, + val modifiedFiles: List, +) + +data class MergeResult( + val hasConflicts: Boolean, + val conflicts: List, + val changedFiles: List, +) + +data class GitCommit( + val sha: String, + val shortMessage: String, + val authorName: String, + val timestamp: Long, +) + +enum class MergeSide { LOCAL, REMOTE } + +sealed class GitAuth { + data class SshKey( + val keyPath: String, + val passphraseProvider: suspend () -> String?, + ) : GitAuth() + + data class HttpsToken( + val username: String, + val tokenProvider: suspend () -> String?, + ) : GitAuth() + + data object None : GitAuth() +} diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/GitSyncService.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/GitSyncService.kt new file mode 100644 index 00000000..07a0db78 --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/GitSyncService.kt @@ -0,0 +1,345 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.git + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import dev.stapler.stelekit.coroutines.PlatformDispatcher +import dev.stapler.stelekit.db.GraphLoader +import dev.stapler.stelekit.db.GraphWriter +import dev.stapler.stelekit.error.DomainError +import dev.stapler.stelekit.git.model.GitConfig +import dev.stapler.stelekit.git.model.SyncState +import dev.stapler.stelekit.git.model.wikiRoot +import dev.stapler.stelekit.platform.FileSystem +import dev.stapler.stelekit.platform.NetworkMonitor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.time.Clock + +/** + * Orchestrates the full git sync cycle: commit local changes, fetch from remote, + * merge, push, and reload changed files into the database. + * + * Owns its own [CoroutineScope] — never accept rememberCoroutineScope(). + * + * Created and owned by [GraphManager]; one instance per active graph. + */ +class GitSyncService( + private val gitRepository: GitRepository, + private val graphLoader: GraphLoader, + private val graphWriter: GraphWriter, + private val editLock: EditLock, + private val configRepository: GitConfigRepository, + private val networkMonitor: NetworkMonitor, + private val fileSystem: FileSystem, +) { + private val _syncState = MutableStateFlow(SyncState.Idle) + val syncState: StateFlow = _syncState.asStateFlow() + + // Owns its own scope — never accept rememberCoroutineScope() + private val scope = CoroutineScope(SupervisorJob() + PlatformDispatcher.IO) + + private var periodicSyncJob: Job? = null + + /** + * Full sync sequence: + * 1. Network check + * 2. Load config + * 3. Safety checks (detached HEAD, stale lock) + * 4. Flush pending writes + await idle editing + * 5. Commit local changes + * 6. Fetch + * 7. Merge (if remote changes available) — with watcher suppression + * 8. Reload merged files + * 9. Push + */ + suspend fun sync(graphId: String): Either = + withContext(PlatformDispatcher.IO) { + // 1. Network check + if (!networkMonitor.isOnline) { + val err = DomainError.GitError.Offline + _syncState.value = SyncState.Error(err) + return@withContext err.left() + } + + // 2. Load config + val config = when (val result = configRepository.getConfig(graphId)) { + is Either.Left -> { + val err = DomainError.GitError.FetchFailed("Failed to load git config: ${result.value.message}") + _syncState.value = SyncState.Error(err) + return@withContext err.left() + } + is Either.Right -> result.value + } ?: run { + // No config — nothing to sync + return@withContext SyncState.Success(0, 0, Clock.System.now().toEpochMilliseconds()).right() + } + + // 3a. Detached HEAD check + if (gitRepository.hasDetachedHead(config)) { + val err = DomainError.GitError.DetachedHead(config.repoRoot) + _syncState.value = SyncState.Error(err) + return@withContext err.left() + } + + // 3b. Remove stale lock file + gitRepository.removeStaleLockFile(config).onLeft { lockErr -> + _syncState.value = SyncState.Error(lockErr) + return@withContext lockErr.left() + } + + // 4. Flush pending writes + await idle editing + graphWriter.flush() + editLock.awaitIdle() + + // 5. Commit local changes + var localCommitsMade = 0 + _syncState.value = SyncState.Committing + val statusResult = gitRepository.status(config) + if (statusResult is Either.Right && statusResult.value.hasLocalChanges) { + gitRepository.stageSubdir(config).onLeft { err -> + _syncState.value = SyncState.Error(err) + return@withContext err.left() + } + val message = buildCommitMessage(config) + gitRepository.commit(config, message).onLeft { err -> + _syncState.value = SyncState.Error(err) + return@withContext err.left() + } + localCommitsMade = 1 + } + + // 6. Fetch + _syncState.value = SyncState.Fetching + val fetchResult = when (val r = gitRepository.fetch(config)) { + is Either.Left -> { + _syncState.value = SyncState.Error(r.value) + return@withContext r.value.left() + } + is Either.Right -> r.value + } + + // 7. Merge (if remote changes available) + var remoteCommitsMerged = 0 + if (fetchResult.hasRemoteChanges) { + _syncState.value = SyncState.Merging + val mergeResult = when (val r = gitRepository.merge(config)) { + is Either.Left -> { + _syncState.value = SyncState.Error(r.value) + return@withContext r.value.left() + } + is Either.Right -> r.value + } + + if (mergeResult.hasConflicts) { + val conflictErr = DomainError.GitError.MergeConflict( + conflictCount = mergeResult.conflicts.size, + conflictPaths = mergeResult.conflicts.map { it.filePath }, + ) + _syncState.value = SyncState.ConflictPending(mergeResult.conflicts) + return@withContext conflictErr.left() + } + + // 8. Reload merged files with watcher suppression + graphLoader.beginGitMerge(mergeResult.changedFiles) + try { + graphLoader.reloadFiles(mergeResult.changedFiles) + } finally { + graphLoader.endGitMerge() + } + + remoteCommitsMerged = fetchResult.remoteCommitCount + } + + // 9. Push + _syncState.value = SyncState.Pushing + gitRepository.push(config).onLeft { err -> + _syncState.value = SyncState.Error(err) + return@withContext err.left() + } + + val success = SyncState.Success( + localCommitsMade = localCommitsMade, + remoteCommitsMerged = remoteCommitsMerged, + lastSyncAt = Clock.System.now().toEpochMilliseconds(), + ) + _syncState.value = success + success.right() + } + + /** + * Fetches from remote only — does not merge or push. + * Emits [SyncState.MergeAvailable] if remote changes were found. + * Used by background schedulers and manual "check for updates" actions. + */ + suspend fun fetchOnly(graphId: String): Either = + withContext(PlatformDispatcher.IO) { + if (!networkMonitor.isOnline) { + val err = DomainError.GitError.Offline + _syncState.value = SyncState.Error(err) + return@withContext err.left() + } + + val config = when (val result = configRepository.getConfig(graphId)) { + is Either.Left -> { + return@withContext DomainError.GitError.FetchFailed( + "Failed to load git config: ${result.value.message}" + ).left() + } + is Either.Right -> result.value + } ?: return@withContext FetchResult(hasRemoteChanges = false, remoteCommitCount = 0).right() + + _syncState.value = SyncState.Fetching + when (val result = gitRepository.fetch(config)) { + is Either.Left -> { + _syncState.value = SyncState.Error(result.value) + result.value.left() + } + is Either.Right -> { + val fetchResult = result.value + _syncState.value = if (fetchResult.hasRemoteChanges) { + SyncState.MergeAvailable(fetchResult.remoteCommitCount) + } else { + SyncState.Idle + } + fetchResult.right() + } + } + } + + /** + * Stages and commits any outstanding local changes in the wiki subdirectory. + * Returns the commit SHA on success, or null if there was nothing to commit. + */ + suspend fun commitLocalChanges(graphId: String): Either = + withContext(PlatformDispatcher.IO) { + val config = when (val result = configRepository.getConfig(graphId)) { + is Either.Left -> return@withContext DomainError.GitError.CommitFailed( + result.value.message + ).left() + is Either.Right -> result.value + } ?: return@withContext (null as String?).right() + + val status = when (val r = gitRepository.status(config)) { + is Either.Left -> return@withContext r.value.left() + is Either.Right -> r.value + } + + if (!status.hasLocalChanges) return@withContext (null as String?).right() + + _syncState.value = SyncState.Committing + gitRepository.stageSubdir(config).onLeft { return@withContext it.left() } + val sha = when (val r = gitRepository.commit(config, buildCommitMessage(config))) { + is Either.Left -> return@withContext r.value.left() + is Either.Right -> r.value + } + _syncState.value = SyncState.Idle + sha.right() + } + + /** + * Applies conflict resolutions to disk, marks files as resolved, and completes + * the merge commit. Used by the ConflictResolutionScreen "Finish Merge" flow. + */ + suspend fun resolveConflict( + graphId: String, + resolution: ConflictResolution, + ): Either = withContext(PlatformDispatcher.IO) { + val config = when (val result = configRepository.getConfig(graphId)) { + is Either.Left -> return@withContext DomainError.GitError.CommitFailed( + result.value.message + ).left() + is Either.Right -> result.value + } ?: return@withContext DomainError.GitError.CommitFailed("No git config for $graphId").left() + + val resolver = ConflictResolver() + for ((filePath, hunks) in resolution.fileResolutions) { + val content = fileSystem.readFile(filePath) + ?: return@withContext DomainError.GitError.CommitFailed( + "Cannot read conflicted file: $filePath" + ).left() + + val resolvedContent = when (val r = resolver.applyResolutions(content, hunks)) { + is Either.Left -> return@withContext r.value.left() + is Either.Right -> r.value + } + + val wrote = fileSystem.writeFile(filePath, resolvedContent) + if (!wrote) { + return@withContext DomainError.GitError.CommitFailed( + "Failed to write resolved content to: $filePath" + ).left() + } + + gitRepository.markResolved(config, filePath).onLeft { return@withContext it.left() } + } + + // Commit the merge + val message = buildCommitMessage(config, isMerge = true) + gitRepository.commit(config, message).onLeft { return@withContext it.left() } + + // Reload resolved files + val resolvedPaths = resolution.fileResolutions.keys.toList() + graphLoader.beginGitMerge(resolvedPaths) + try { + graphLoader.reloadFiles(resolvedPaths) + } finally { + graphLoader.endGitMerge() + } + + _syncState.value = SyncState.Idle + Unit.right() + } + + /** + * Starts a periodic sync loop that calls [fetchOnly] every [intervalMinutes] minutes. + * Cancels any existing periodic sync before starting. + */ + fun startPeriodicSync(graphId: String, intervalMinutes: Int) { + stopPeriodicSync() + if (intervalMinutes <= 0) return + periodicSyncJob = scope.launch { + while (true) { + delay(intervalMinutes * 60_000L) + fetchOnly(graphId) + } + } + } + + /** Cancels the periodic sync loop without shutting down the service. */ + fun stopPeriodicSync() { + periodicSyncJob?.cancel() + periodicSyncJob = null + } + + /** Shuts down this service, cancelling all coroutines. */ + fun shutdown() { + scope.cancel() + } + + private fun buildCommitMessage(config: GitConfig, isMerge: Boolean = false): String { + val date = Clock.System.now().toString().take(10) // yyyy-MM-dd + val base = config.commitMessageTemplate + .replace("{date}", date) + return if (isMerge) "$base (merge)" else base + } +} + +/** + * Resolution data passed to [GitSyncService.resolveConflict]. + * Maps file path → list of resolved hunks. + */ +data class ConflictResolution( + val fileResolutions: Map>, +) diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/SqlDelightGitConfigRepository.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/SqlDelightGitConfigRepository.kt new file mode 100644 index 00000000..9a984931 --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/SqlDelightGitConfigRepository.kt @@ -0,0 +1,112 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.git + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToOneOrNull +import dev.stapler.stelekit.coroutines.PlatformDispatcher +import dev.stapler.stelekit.db.DatabaseWriteActor +import dev.stapler.stelekit.db.DirectSqlWrite +import dev.stapler.stelekit.db.Git_config +import dev.stapler.stelekit.db.SteleDatabase +import dev.stapler.stelekit.error.DomainError +import dev.stapler.stelekit.git.model.GitAuthType +import dev.stapler.stelekit.git.model.GitConfig +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withContext + +/** + * SQLDelight-backed implementation of [GitConfigRepository]. + * + * All writes go through [DatabaseWriteActor] via the `execute` lambda, following the same + * pattern as other SqlDelight repositories in this codebase. + */ +class SqlDelightGitConfigRepository( + private val database: SteleDatabase, + private val writeActor: DatabaseWriteActor, +) : GitConfigRepository { + + private val queries get() = database.steleDatabaseQueries + + override suspend fun getConfig(graphId: String): Either = + withContext(PlatformDispatcher.DB) { + try { + val row = queries.selectGitConfig(graphId).executeAsOneOrNull() + row?.toModel().right() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.DatabaseError.ReadFailed(e.message ?: "unknown").left() + } + } + + override suspend fun saveConfig(config: GitConfig): Either { + return writeActor.execute(DatabaseWriteActor.Priority.HIGH) { + try { + @OptIn(DirectSqlWrite::class) + database.steleDatabaseQueries.insertOrReplaceGitConfig( + graph_id = config.graphId, + repo_root = config.repoRoot, + wiki_subdir = config.wikiSubdir, + remote_name = config.remoteName, + remote_branch = config.remoteBranch, + auth_type = config.authType.name, + ssh_key_path = config.sshKeyPath, + ssh_key_passphrase_key = config.sshKeyPassphraseKey, + https_token_key = config.httpsTokenKey, + poll_interval_minutes = config.pollIntervalMinutes.toLong(), + auto_commit = if (config.autoCommit) 1L else 0L, + commit_message_template = config.commitMessageTemplate, + ) + Unit.right() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.DatabaseError.WriteFailed(e.message ?: "unknown").left() + } + } + } + + override suspend fun deleteConfig(graphId: String): Either { + return writeActor.execute(DatabaseWriteActor.Priority.HIGH) { + try { + @OptIn(DirectSqlWrite::class) + database.steleDatabaseQueries.deleteGitConfig(graphId) + Unit.right() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.DatabaseError.WriteFailed(e.message ?: "unknown").left() + } + } + } + + override fun observeConfig(graphId: String): Flow> = + queries.selectGitConfig(graphId) + .asFlow() + .mapToOneOrNull(PlatformDispatcher.DB) + .map> { row -> row?.toModel().right() } + .catch { e -> emit(DomainError.DatabaseError.ReadFailed(e.message ?: "unknown").left()) } + + private fun Git_config.toModel(): GitConfig = GitConfig( + graphId = graph_id, + repoRoot = repo_root, + wikiSubdir = wiki_subdir, + remoteName = remote_name, + remoteBranch = remote_branch, + authType = runCatching { GitAuthType.valueOf(auth_type) }.getOrDefault(GitAuthType.NONE), + sshKeyPath = ssh_key_path, + sshKeyPassphraseKey = ssh_key_passphrase_key, + httpsTokenKey = https_token_key, + pollIntervalMinutes = poll_interval_minutes.toInt(), + autoCommit = auto_commit != 0L, + commitMessageTemplate = commit_message_template, + ) +} diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/model/ConflictModels.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/model/ConflictModels.kt new file mode 100644 index 00000000..3b39da7f --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/model/ConflictModels.kt @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.git.model + +import kotlinx.serialization.Serializable + +data class ConflictFile( + val filePath: String, + val wikiRelativePath: String, + val hunks: List, +) + +data class ConflictHunk( + val id: String, + val localLines: List, + val remoteLines: List, + val resolution: HunkResolution = HunkResolution.Unresolved, + val manualContent: String? = null, +) + +sealed class HunkResolution { + data object Unresolved : HunkResolution() + data object AcceptLocal : HunkResolution() + data object AcceptRemote : HunkResolution() + data object Manual : HunkResolution() +} + +@Serializable +data class ConflictResolutionState( + val graphId: String, + val conflictFiles: List, + val startedAt: Long, +) + +@Serializable +data class SerializableConflictFile( + val filePath: String, + val wikiRelativePath: String, + val hunks: List, +) + +@Serializable +data class SerializableConflictHunk( + val id: String, + val localLines: List, + val remoteLines: List, + val resolutionType: String = "Unresolved", + val manualContent: String? = null, +) diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/model/GitConfig.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/model/GitConfig.kt new file mode 100644 index 00000000..14562361 --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/model/GitConfig.kt @@ -0,0 +1,26 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.git.model + +import kotlinx.serialization.Serializable + +@Serializable +data class GitConfig( + val graphId: String, + val repoRoot: String, + val wikiSubdir: String, + val remoteName: String = "origin", + val remoteBranch: String = "main", + val authType: GitAuthType, + val sshKeyPath: String? = null, + val sshKeyPassphraseKey: String? = null, + val httpsTokenKey: String? = null, + val pollIntervalMinutes: Int = 5, + val autoCommit: Boolean = true, + val commitMessageTemplate: String = "SteleKit: {date}", +) + +val GitConfig.wikiRoot: String get() = if (wikiSubdir.isEmpty()) repoRoot else "$repoRoot/$wikiSubdir" + +enum class GitAuthType { NONE, SSH_KEY, HTTPS_TOKEN } diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/model/SyncState.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/model/SyncState.kt new file mode 100644 index 00000000..f64a6061 --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/git/model/SyncState.kt @@ -0,0 +1,22 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.git.model + +import dev.stapler.stelekit.error.DomainError + +sealed class SyncState { + data object Idle : SyncState() + data object Fetching : SyncState() + data class MergeAvailable(val commitCount: Int) : SyncState() + data object Merging : SyncState() + data object Pushing : SyncState() + data object Committing : SyncState() + data class ConflictPending(val conflicts: List) : SyncState() + data class Error(val error: DomainError.GitError) : SyncState() + data class Success( + val localCommitsMade: Int, + val remoteCommitsMerged: Int, + val lastSyncAt: Long, + ) : SyncState() +} diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/platform/NetworkMonitor.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/platform/NetworkMonitor.kt new file mode 100644 index 00000000..d6cf3a9e --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/platform/NetworkMonitor.kt @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.platform + +import kotlinx.coroutines.flow.Flow + +/** + * Platform-agnostic network connectivity monitor. + * Used by GitSyncService to skip sync operations when offline. + */ +expect class NetworkMonitor() { + val isOnline: Boolean + fun observeConnectivity(): Flow +} diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt index cf3ac725..a7ea8e2a 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt @@ -126,6 +126,11 @@ fun StelekitApp( * [onGraphManagerReady] pattern. */ onMemoryPressure: (((() -> Unit) -> Unit))? = null, + /** + * Platform-specific git implementation. Pass [JvmGitRepository] on Desktop, + * [AndroidGitRepository] on Android. When null, git sync is disabled. + */ + gitRepository: dev.stapler.stelekit.git.GitRepository? = null, ) { val platformSettings = remember { PlatformSettings() } val scope = rememberCoroutineScope() @@ -279,6 +284,7 @@ fun StelekitApp( deviceLlmAvailable = deviceLlmAvailable, spanRecorder = spanRecorder, onMemoryPressure = onMemoryPressure, + gitRepository = gitRepository, ) } } @@ -308,6 +314,7 @@ private fun GraphContent( deviceLlmAvailable: Boolean = false, spanRecorder: SpanRecorder = NoOpSpanRecorder, onMemoryPressure: (((() -> Unit) -> Unit))? = null, + gitRepository: dev.stapler.stelekit.git.GitRepository? = null, ) { CompositionLocalProvider(LocalSpanRecorder provides spanRecorder) { val scope = rememberCoroutineScope() @@ -338,6 +345,30 @@ private fun GraphContent( ) } + // 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, + ) + } + DisposableEffect(gitSyncService) { + graphManager.registerGitSyncService(gitSyncService) + onDispose { + gitSyncService?.shutdown() + graphManager.registerGitSyncService(null) + } + } + // Break the circular dependency: blockStateManager needs the graph path from viewModel, // and viewModel needs blockStateManager. We use a captured var so blockStateManager is // created first with a lazy lambda that resolves viewModel after both are initialised. @@ -384,6 +415,8 @@ private fun GraphContent( debugFlagRepository = repos.debugFlagRepository, histogramWriter = repos.histogramWriter, ringBuffer = repos.ringBuffer, + activeGitSyncService = graphManager.activeGitSyncService, + activeGraphIdProvider = { graphManager.getActiveGraphId() }, ).also { viewModelRef = it it.startAutoSave() diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/AppState.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/AppState.kt index 0237029c..4925c85c 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/AppState.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/AppState.kt @@ -6,6 +6,8 @@ import dev.stapler.stelekit.docs.FlashcardsDocs import dev.stapler.stelekit.docs.HelpPage import dev.stapler.stelekit.docs.JournalsDocs import dev.stapler.stelekit.docs.PageViewDocs +import dev.stapler.stelekit.git.model.GitConfig +import dev.stapler.stelekit.git.model.SyncState import dev.stapler.stelekit.model.GraphInfo import dev.stapler.stelekit.model.Page import dev.stapler.stelekit.ui.theme.StelekitThemeMode @@ -79,7 +81,13 @@ data class AppState( // Rename dialog — non-null when the rename dialog is open for a specific page val renameDialogPage: Page? = null, val renameDialogBusy: Boolean = false, - val renameDialogError: String? = null + val renameDialogError: String? = null, + // Git sync state + val syncState: SyncState = SyncState.Idle, + val gitConfig: GitConfig? = null, + val gitSetupVisible: Boolean = false, + val conflictResolutionVisible: Boolean = false, + val gitLogVisible: Boolean = false, ) { val canGoBack: Boolean get() = historyIndex > 0 val canGoForward: Boolean get() = historyIndex < navigationHistory.size - 1 diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/StelekitViewModel.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/StelekitViewModel.kt index 6970fa06..678452d3 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/StelekitViewModel.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/StelekitViewModel.kt @@ -8,6 +8,8 @@ import dev.stapler.stelekit.db.RenameResult import dev.stapler.stelekit.db.UndoManager import dev.stapler.stelekit.export.ClipboardProvider import dev.stapler.stelekit.export.ExportService +import dev.stapler.stelekit.git.GitSyncService +import dev.stapler.stelekit.git.model.SyncState import dev.stapler.stelekit.logging.Logger import dev.stapler.stelekit.model.NotificationType import dev.stapler.stelekit.outliner.BlockSorter @@ -38,10 +40,14 @@ import kotlinx.coroutines.SupervisorJob import kotlin.time.Clock import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.cancel import kotlinx.coroutines.launch @@ -77,6 +83,10 @@ class StelekitViewModel( private val bugReportBuilder: dev.stapler.stelekit.performance.BugReportBuilder? = null, private val debugFlagRepository: dev.stapler.stelekit.performance.DebugFlagRepository? = null, ringBuffer: dev.stapler.stelekit.performance.RingBufferSpanExporter? = null, + // Optional git sync service — wired when git is configured for the active graph. + // Uses a StateFlow so the ViewModel can switch services on graph change. + private val activeGitSyncService: StateFlow = MutableStateFlow(null), + private val activeGraphIdProvider: () -> String? = { null }, ) { private val spanEmitter = dev.stapler.stelekit.performance.SpanEmitter(ringBuffer) private val scope = scope @@ -103,6 +113,58 @@ class StelekitViewModel( scope.launch { manager.redo() } } + // --- Git Sync --- + + /** + * Emits the current [SyncState] from the active [GitSyncService]. + * Falls back to [SyncState.Idle] when no git sync service is configured. + */ + val syncState: StateFlow = activeGitSyncService + .flatMapLatest { service -> service?.syncState ?: flowOf(SyncState.Idle) } + .stateIn(scope, SharingStarted.Eagerly, SyncState.Idle) + + private fun observeSyncState() { + // Auto-show conflict resolution screen when ConflictPending is emitted + scope.launch { + syncState.collect { state -> + if (state is SyncState.ConflictPending) { + _uiState.update { it.copy(conflictResolutionVisible = true) } + } + } + } + } + + /** Triggers a full sync (commit → fetch → merge → push) on the active graph. */ + fun triggerSync() { + val graphId = activeGraphIdProvider() ?: _uiState.value.currentGraphId ?: return + scope.launch { + activeGitSyncService.value?.sync(graphId) + } + } + + /** Triggers a fetch-only check for remote changes on the active graph. */ + fun triggerFetchOnly() { + val graphId = activeGraphIdProvider() ?: _uiState.value.currentGraphId ?: return + scope.launch { + activeGitSyncService.value?.fetchOnly(graphId) + } + } + + /** Opens the git setup wizard. */ + fun openGitSetup() { + _uiState.update { it.copy(gitSetupVisible = true) } + } + + /** Dismisses the git setup wizard. */ + fun dismissGitSetup() { + _uiState.update { it.copy(gitSetupVisible = false) } + } + + /** Dismisses the conflict resolution screen. */ + fun dismissConflictResolution() { + _uiState.update { it.copy(conflictResolutionVisible = false) } + } + // Track recent pages manually to avoid "recently loaded" issues private var recentPageUuids: MutableList = mutableListOf() @@ -144,6 +206,7 @@ class StelekitViewModel( init { updateCommands() + observeSyncState() // Initialize graph if path exists val path = _uiState.value.currentGraphPath diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SyncStatusBadge.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SyncStatusBadge.kt new file mode 100644 index 00000000..b12b7798 --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SyncStatusBadge.kt @@ -0,0 +1,181 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import dev.stapler.stelekit.git.model.SyncState +import kotlinx.coroutines.delay + +/** + * A compact badge + icon button combo displayed in the sidebar header area. + * + * - [SyncState.Idle]: greyed-out sync icon, no badge text + * - [SyncState.Fetching / Merging / Pushing / Committing]: animated spinning sync icon + * - [SyncState.MergeAvailable(n)]: blue cloud-download icon with "↓ n" label + * - [SyncState.ConflictPending]: amber warning icon with "Conflict" label + * - [SyncState.Error]: red error icon with "Error" label + * - [SyncState.Success]: brief green checkmark, fades after 3 seconds + * + * @param syncState Current sync state from [GitSyncService]. + * @param onSyncClick Called when the manual sync icon button is tapped. + */ +@Composable +fun SyncStatusBadge( + syncState: SyncState, + onSyncClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Badge area — state-specific icon and label + SyncStateBadge(syncState = syncState) + + Spacer(modifier = Modifier.width(4.dp)) + + // Manual sync button — always visible + IconButton( + onClick = onSyncClick, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Default.Sync, + contentDescription = "Sync now", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + } +} + +@Composable +private fun SyncStateBadge(syncState: SyncState, modifier: Modifier = Modifier) { + when (syncState) { + is SyncState.Idle -> { + // No visible badge when idle + } + + is SyncState.Fetching, + is SyncState.Merging, + is SyncState.Pushing, + is SyncState.Committing -> { + CircularProgressIndicator( + modifier = modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + + is SyncState.MergeAvailable -> { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.CloudDownload, + contentDescription = "Updates available", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + if (syncState.commitCount > 0) { + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = "↓ ${syncState.commitCount}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + + is SyncState.ConflictPending -> { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = "Merge conflict", + tint = Color(0xFFF59E0B), // amber-400 + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = "Conflict", + style = MaterialTheme.typography.labelSmall, + color = Color(0xFFF59E0B), + ) + } + } + + is SyncState.Error -> { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = "Sync error", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = "Error", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + + is SyncState.Success -> { + // Brief green checkmark that fades after 3 seconds + var visible by remember(syncState) { 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), + ) + } + } + } +} diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitSetupScreen.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitSetupScreen.kt new file mode 100644 index 00000000..ee9075f5 --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitSetupScreen.kt @@ -0,0 +1,518 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.ui.screens.git + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import dev.stapler.stelekit.git.CredentialStore +import dev.stapler.stelekit.git.GitConfigRepository +import dev.stapler.stelekit.git.GitRepository +import dev.stapler.stelekit.git.GitSyncService +import dev.stapler.stelekit.git.model.GitAuthType +import dev.stapler.stelekit.git.model.GitConfig +import kotlinx.coroutines.launch + +/** + * Multi-step wizard for configuring git sync on a graph. + * + * Step 1: Clone mode — use existing clone or clone new repo. + * Step 2: Repo path and wiki subdirectory. + * Step 3: Auth type (SSH key / HTTPS token / None) and credentials. + * Step 4: Branch name and poll interval. + * Step 5: Test connection then save. + * + * @param graphId ID of the graph being configured. + * @param existingConfig Pre-filled when editing an existing config. + * @param gitRepository Platform-specific git implementation. + * @param gitConfigRepository Persistence for [GitConfig]. + * @param gitSyncService Active service; used for immediate fetchOnly after save. + * @param onDismiss Called when the user cancels the wizard. + * @param onSaved Called after configuration is saved and initial fetch succeeds. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GitSetupScreen( + graphId: String, + gitRepository: GitRepository, + gitConfigRepository: GitConfigRepository, + gitSyncService: GitSyncService, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + existingConfig: GitConfig? = null, + onSave: () -> Unit = {}, +) { + val scope = rememberCoroutineScope() + var step by remember { mutableIntStateOf(1) } + + // Form state + var useExistingClone by remember { mutableStateOf(true) } + var cloneUrl by remember { mutableStateOf("") } + var repoRoot by remember { mutableStateOf(existingConfig?.repoRoot ?: "") } + var wikiSubdir by remember { mutableStateOf(existingConfig?.wikiSubdir ?: "") } + var authType by remember { mutableStateOf(existingConfig?.authType ?: GitAuthType.NONE) } + var sshKeyPath by remember { mutableStateOf(existingConfig?.sshKeyPath ?: "") } + val credentialStore = remember { CredentialStore() } + var httpsToken by remember { + mutableStateOf( + existingConfig?.httpsTokenKey?.let { key -> credentialStore.retrieve(key) } ?: "" + ) + } + var remoteBranch by remember { mutableStateOf(existingConfig?.remoteBranch ?: "main") } + var pollIntervalMinutes by remember { mutableStateOf(existingConfig?.pollIntervalMinutes ?: 5) } + + // Step 5: connection test state + var testInProgress by remember { mutableStateOf(false) } + var testResult by remember { mutableStateOf(null) } + var testSuccess by remember { mutableStateOf(false) } + + // Save state + var saving by remember { mutableStateOf(false) } + var saveError by remember { mutableStateOf(null) } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text("Git Sync Setup (Step $step / 5)") }, + actions = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Spacer(modifier = Modifier.height(8.dp)) + + when (step) { + 1 -> Step1CloneMode( + useExistingClone = useExistingClone, + onUseExistingClone = { useExistingClone = it }, + onNext = { step = 2 }, + ) + + 2 -> Step2RepoPath( + useExistingClone = useExistingClone, + repoRoot = repoRoot, + onRepoRootChange = { repoRoot = it }, + cloneUrl = cloneUrl, + onCloneUrlChange = { cloneUrl = it }, + wikiSubdir = wikiSubdir, + onWikiSubdirChange = { wikiSubdir = it }, + onBack = { step = 1 }, + onNext = { step = 3 }, + ) + + 3 -> Step3Auth( + authType = authType, + onAuthTypeChange = { authType = it }, + sshKeyPath = sshKeyPath, + onSshKeyPathChange = { sshKeyPath = it }, + httpsToken = httpsToken, + onHttpsTokenChange = { httpsToken = it }, + onBack = { step = 2 }, + onNext = { step = 4 }, + ) + + 4 -> Step4Branch( + remoteBranch = remoteBranch, + onRemoteBranchChange = { remoteBranch = it }, + pollIntervalMinutes = pollIntervalMinutes, + onPollIntervalChange = { pollIntervalMinutes = it }, + onBack = { step = 3 }, + onNext = { step = 5 }, + ) + + 5 -> Step5TestAndSave( + testInProgress = testInProgress, + testResult = testResult, + testSuccess = testSuccess, + saving = saving, + saveError = saveError, + onBack = { step = 4 }, + onTestConnection = { + scope.launch { + testInProgress = true + testResult = null + val testHttpsTokenKey = if (authType == GitAuthType.HTTPS_TOKEN && httpsToken.isNotBlank()) { + val key = "git_https_token_$graphId" + credentialStore.store(key, httpsToken) + key + } else null + val config = buildConfig( + graphId, repoRoot, wikiSubdir, authType, + sshKeyPath, remoteBranch, pollIntervalMinutes, + httpsTokenKey = testHttpsTokenKey, + ) + val result = gitRepository.fetch(config) + testInProgress = false + if (result.isRight()) { + testSuccess = true + testResult = "Connection successful." + } else { + testSuccess = false + testResult = "Connection failed." + } + } + }, + onSave = { + scope.launch { + saving = true + saveError = null + val httpsTokenKey = if (authType == GitAuthType.HTTPS_TOKEN && httpsToken.isNotBlank()) { + val tokenKey = "git_https_token_$graphId" + credentialStore.store(tokenKey, httpsToken) + tokenKey + } else { + existingConfig?.httpsTokenKey + } + val config = buildConfig( + graphId, repoRoot, wikiSubdir, authType, + sshKeyPath, remoteBranch, pollIntervalMinutes, + httpsTokenKey = httpsTokenKey, + ) + val result = gitConfigRepository.saveConfig(config) + saving = false + if (result.isRight()) { + // Trigger an immediate background fetch + gitSyncService.fetchOnly(graphId) + onSave() + } else { + saveError = "Failed to save configuration." + } + } + }, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +private fun Step1CloneMode( + useExistingClone: Boolean, + onUseExistingClone: (Boolean) -> Unit, + onNext: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Repository mode", style = MaterialTheme.typography.titleMedium) + Text("Do you already have a local git clone of your notes repository?", style = MaterialTheme.typography.bodyMedium) + + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = useExistingClone, onClick = { onUseExistingClone(true) }) + Spacer(modifier = Modifier.width(8.dp)) + Text("Use existing clone") + } + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = !useExistingClone, onClick = { onUseExistingClone(false) }) + Spacer(modifier = Modifier.width(8.dp)) + Text("Clone a remote repository") + } + + Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) { + Text("Next") + } + } +} + +@Composable +private fun Step2RepoPath( + useExistingClone: Boolean, + repoRoot: String, + onRepoRootChange: (String) -> Unit, + cloneUrl: String, + onCloneUrlChange: (String) -> Unit, + wikiSubdir: String, + onWikiSubdirChange: (String) -> Unit, + onBack: () -> Unit, + onNext: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Repository path", style = MaterialTheme.typography.titleMedium) + + if (!useExistingClone) { + OutlinedTextField( + value = cloneUrl, + onValueChange = onCloneUrlChange, + label = { Text("Remote URL (HTTPS or SSH)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + + OutlinedTextField( + value = repoRoot, + onValueChange = onRepoRootChange, + label = { Text("Local repository root path") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + OutlinedTextField( + value = wikiSubdir, + onValueChange = onWikiSubdirChange, + label = { Text("Wiki subdirectory (leave empty if notes are at repo root)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + OutlinedButton(onClick = onBack) { Text("Back") } + Button( + onClick = onNext, + enabled = repoRoot.isNotBlank(), + ) { Text("Next") } + } + } +} + +@Composable +private fun Step3Auth( + authType: GitAuthType, + onAuthTypeChange: (GitAuthType) -> Unit, + sshKeyPath: String, + onSshKeyPathChange: (String) -> Unit, + httpsToken: String, + onHttpsTokenChange: (String) -> Unit, + onBack: () -> Unit, + onNext: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Authentication", style = MaterialTheme.typography.titleMedium) + + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = authType == GitAuthType.NONE, onClick = { onAuthTypeChange(GitAuthType.NONE) }) + Spacer(modifier = Modifier.width(8.dp)) + Text("No authentication (public repo)") + } + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = authType == GitAuthType.SSH_KEY, onClick = { onAuthTypeChange(GitAuthType.SSH_KEY) }) + Spacer(modifier = Modifier.width(8.dp)) + Text("SSH key") + } + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = authType == GitAuthType.HTTPS_TOKEN, onClick = { onAuthTypeChange(GitAuthType.HTTPS_TOKEN) }) + Spacer(modifier = Modifier.width(8.dp)) + Text("HTTPS token (GitHub PAT, etc.)") + } + + if (authType == GitAuthType.SSH_KEY) { + OutlinedTextField( + value = sshKeyPath, + onValueChange = onSshKeyPathChange, + label = { Text("SSH private key path (e.g. ~/.ssh/id_ed25519)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + + 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, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + OutlinedButton(onClick = onBack) { Text("Back") } + Button(onClick = onNext) { Text("Next") } + } + } +} + +@Composable +private fun Step4Branch( + remoteBranch: String, + onRemoteBranchChange: (String) -> Unit, + pollIntervalMinutes: Int, + onPollIntervalChange: (Int) -> Unit, + onBack: () -> Unit, + onNext: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Sync settings", style = MaterialTheme.typography.titleMedium) + + OutlinedTextField( + value = remoteBranch, + onValueChange = onRemoteBranchChange, + label = { Text("Remote branch") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Text("Background poll interval", style = MaterialTheme.typography.labelMedium) + + val intervals = listOf(0 to "Off", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 60 to "1 hour") + intervals.forEach { (minutes, label) -> + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = pollIntervalMinutes == minutes, + onClick = { onPollIntervalChange(minutes) }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(label) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + OutlinedButton(onClick = onBack) { Text("Back") } + Button(onClick = onNext) { Text("Next") } + } + } +} + +@Composable +private fun Step5TestAndSave( + testInProgress: Boolean, + testResult: String?, + testSuccess: Boolean, + saving: Boolean, + saveError: String?, + onBack: () -> Unit, + onTestConnection: () -> Unit, + onSave: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Test and save", style = MaterialTheme.typography.titleMedium) + Text("Optionally test your connection before saving.", style = MaterialTheme.typography.bodyMedium) + + OutlinedButton( + onClick = onTestConnection, + enabled = !testInProgress, + modifier = Modifier.fillMaxWidth(), + ) { + if (testInProgress) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(8.dp)) + } + Text("Test connection") + } + + if (testResult != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = if (testSuccess) Icons.Default.Check else Icons.Default.Error, + contentDescription = null, + tint = if (testSuccess) Color(0xFF10B981) else MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = testResult, + color = if (testSuccess) Color(0xFF10B981) else MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + + saveError?.let { error -> + Text( + text = error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + OutlinedButton(onClick = onBack, enabled = !saving) { Text("Back") } + Button( + onClick = onSave, + enabled = !saving, + modifier = Modifier.weight(1f).padding(start = 8.dp), + ) { + if (saving) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text("Save configuration") + } + } + } +} + +private fun buildConfig( + graphId: String, + repoRoot: String, + wikiSubdir: String, + authType: GitAuthType, + sshKeyPath: String, + remoteBranch: String, + pollIntervalMinutes: Int, + httpsTokenKey: String? = null, +): GitConfig = GitConfig( + graphId = graphId, + repoRoot = repoRoot, + wikiSubdir = wikiSubdir, + authType = authType, + sshKeyPath = sshKeyPath.takeIf { it.isNotBlank() }, + remoteBranch = remoteBranch, + pollIntervalMinutes = pollIntervalMinutes, + httpsTokenKey = httpsTokenKey, +) diff --git a/kmp/src/commonMain/sqldelight/dev/stapler/stelekit/db/SteleDatabase.sq b/kmp/src/commonMain/sqldelight/dev/stapler/stelekit/db/SteleDatabase.sq index 40f9a203..e7ee87d4 100644 --- a/kmp/src/commonMain/sqldelight/dev/stapler/stelekit/db/SteleDatabase.sq +++ b/kmp/src/commonMain/sqldelight/dev/stapler/stelekit/db/SteleDatabase.sq @@ -840,3 +840,32 @@ SELECT DISTINCT app_version FROM query_stats ORDER BY app_version DESC; deleteQueryStatsForVersion: DELETE FROM query_stats WHERE app_version = ?; + +-- Git config table: one row per graph, stores all git synchronization settings +CREATE TABLE IF NOT EXISTS git_config ( + graph_id TEXT NOT NULL PRIMARY KEY, + repo_root TEXT NOT NULL, + wiki_subdir TEXT NOT NULL DEFAULT '', + remote_name TEXT NOT NULL DEFAULT 'origin', + remote_branch TEXT NOT NULL DEFAULT 'main', + auth_type TEXT NOT NULL DEFAULT 'NONE', + ssh_key_path TEXT, + ssh_key_passphrase_key TEXT, + https_token_key TEXT, + poll_interval_minutes INTEGER NOT NULL DEFAULT 5, + auto_commit INTEGER NOT NULL DEFAULT 1, + commit_message_template TEXT NOT NULL DEFAULT 'SteleKit: {date}' +); + +selectGitConfig: +SELECT * FROM git_config WHERE graph_id = ?; + +insertOrReplaceGitConfig: +INSERT OR REPLACE INTO git_config( + graph_id, repo_root, wiki_subdir, remote_name, remote_branch, + auth_type, ssh_key_path, ssh_key_passphrase_key, https_token_key, + poll_interval_minutes, auto_commit, commit_message_template +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + +deleteGitConfig: +DELETE FROM git_config WHERE graph_id = ?; diff --git a/kmp/src/commonTest/kotlin/dev/stapler/stelekit/error/DomainErrorTest.kt b/kmp/src/commonTest/kotlin/dev/stapler/stelekit/error/DomainErrorTest.kt index 42fa0dc8..a3661b77 100644 --- a/kmp/src/commonTest/kotlin/dev/stapler/stelekit/error/DomainErrorTest.kt +++ b/kmp/src/commonTest/kotlin/dev/stapler/stelekit/error/DomainErrorTest.kt @@ -27,6 +27,18 @@ class DomainErrorTest { DomainError.NetworkError.HttpError(404, "not found"), DomainError.NetworkError.CircuitOpen(), DomainError.NetworkError.Timeout("timeout"), + DomainError.GitError.CloneFailed("clone"), + DomainError.GitError.FetchFailed("fetch"), + DomainError.GitError.PushFailed("push"), + DomainError.GitError.AuthFailed("auth"), + DomainError.GitError.MergeConflict(1), + DomainError.GitError.CommitFailed("commit"), + DomainError.GitError.NotAGitRepo("/path"), + DomainError.GitError.DetachedHead("/path"), + DomainError.GitError.StaleLockFile("/path/.git/index.lock"), + DomainError.GitError.NotSupported("iOS"), + DomainError.GitError.Offline, + DomainError.GitError.EditingInProgress, ) for (err in errors) { // exhaustive when — compile error if any branch is missing @@ -50,6 +62,18 @@ class DomainErrorTest { is DomainError.NetworkError.HttpError -> err.message is DomainError.NetworkError.CircuitOpen -> err.message is DomainError.NetworkError.Timeout -> err.message + is DomainError.GitError.CloneFailed -> err.message + is DomainError.GitError.FetchFailed -> err.message + is DomainError.GitError.PushFailed -> err.message + is DomainError.GitError.AuthFailed -> err.message + is DomainError.GitError.MergeConflict -> err.message + is DomainError.GitError.CommitFailed -> err.message + is DomainError.GitError.NotAGitRepo -> err.message + is DomainError.GitError.DetachedHead -> err.message + is DomainError.GitError.StaleLockFile -> err.message + is DomainError.GitError.NotSupported -> err.message + DomainError.GitError.Offline -> err.message + DomainError.GitError.EditingInProgress -> err.message } assert(msg.isNotEmpty()) { "Expected non-empty message for $err" } } @@ -90,6 +114,18 @@ class DomainErrorTest { DomainError.NetworkError.HttpError(500, "err"), DomainError.NetworkError.CircuitOpen(), DomainError.NetworkError.Timeout("t"), + DomainError.GitError.CloneFailed("clone"), + DomainError.GitError.FetchFailed("fetch"), + DomainError.GitError.PushFailed("push"), + DomainError.GitError.AuthFailed("auth"), + DomainError.GitError.MergeConflict(2), + DomainError.GitError.CommitFailed("commit"), + DomainError.GitError.NotAGitRepo("/path"), + DomainError.GitError.DetachedHead("/path"), + DomainError.GitError.StaleLockFile("/path/.git/index.lock"), + DomainError.GitError.NotSupported("iOS"), + DomainError.GitError.Offline, + DomainError.GitError.EditingInProgress, ) for (err in errors) { assert(err.toUiMessage().isNotEmpty()) { "Expected non-empty UI message for $err" } diff --git a/kmp/src/iosMain/kotlin/dev/stapler/stelekit/git/IosCredentialStore.kt b/kmp/src/iosMain/kotlin/dev/stapler/stelekit/git/IosCredentialStore.kt new file mode 100644 index 00000000..bf726ed5 --- /dev/null +++ b/kmp/src/iosMain/kotlin/dev/stapler/stelekit/git/IosCredentialStore.kt @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.git + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.value +import platform.CoreFoundation.CFTypeRefVar +import platform.Foundation.NSData +import platform.Foundation.NSMutableDictionary +import platform.Foundation.NSString +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.create +import platform.Security.SecItemAdd +import platform.Security.SecItemCopyMatching +import platform.Security.SecItemDelete +import platform.Security.errSecSuccess +import platform.Security.kSecAttrAccount +import platform.Security.kSecAttrService +import platform.Security.kSecClass +import platform.Security.kSecClassGenericPassword +import platform.Security.kSecMatchLimit +import platform.Security.kSecMatchLimitOne +import platform.Security.kSecReturnData +import platform.Security.kSecValueData + +@OptIn(ExperimentalForeignApi::class) +actual class CredentialStore actual constructor() { + + private val service = "dev.stapler.stelekit.credentials" + + actual fun store(key: String, value: String) { + val valueBytes = value.encodeToByteArray() + val valueData = valueBytes.usePinned { pinned -> + NSData.dataWithBytes(pinned.addressOf(0), valueBytes.size.toULong()) + } + + val deleteQuery = baseQuery(key) + @Suppress("UNCHECKED_CAST") + SecItemDelete(deleteQuery as platform.CoreFoundation.CFDictionaryRef) + + val addQuery = NSMutableDictionary(dictionary = deleteQuery) + @Suppress("UNCHECKED_CAST") + addQuery[kSecValueData as Any] = valueData + @Suppress("UNCHECKED_CAST") + SecItemAdd(addQuery as platform.CoreFoundation.CFDictionaryRef, null) + } + + actual fun retrieve(key: String): String? = memScoped { + val query = NSMutableDictionary(dictionary = baseQuery(key)) + @Suppress("UNCHECKED_CAST") + query[kSecReturnData as Any] = true + @Suppress("UNCHECKED_CAST") + query[kSecMatchLimit as Any] = kSecMatchLimitOne + + val result = alloc() + @Suppress("UNCHECKED_CAST") + val status = SecItemCopyMatching(query as platform.CoreFoundation.CFDictionaryRef, result.ptr) + if (status != errSecSuccess) return@memScoped null + + val data = result.value as? NSData ?: return@memScoped null + NSString.create(data, NSUTF8StringEncoding) as? String + } + + actual fun delete(key: String) { + @Suppress("UNCHECKED_CAST") + SecItemDelete(baseQuery(key) as platform.CoreFoundation.CFDictionaryRef) + } + + private fun baseQuery(key: String): NSMutableDictionary = NSMutableDictionary().apply { + @Suppress("UNCHECKED_CAST") + this[kSecClass as Any] = kSecClassGenericPassword + @Suppress("UNCHECKED_CAST") + this[kSecAttrService as Any] = service + @Suppress("UNCHECKED_CAST") + this[kSecAttrAccount as Any] = key + } +} diff --git a/kmp/src/iosMain/kotlin/dev/stapler/stelekit/git/IosGitRepository.kt b/kmp/src/iosMain/kotlin/dev/stapler/stelekit/git/IosGitRepository.kt new file mode 100644 index 00000000..37244bcd --- /dev/null +++ b/kmp/src/iosMain/kotlin/dev/stapler/stelekit/git/IosGitRepository.kt @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.git + +import arrow.core.Either +import arrow.core.left +import dev.stapler.stelekit.error.DomainError +import dev.stapler.stelekit.git.model.GitConfig + +/** + * iOS stub implementation of GitRepository. + * All operations return DomainError.GitError.NotSupported until kgit2 integration is validated. + * This unblocks Desktop and Android delivery while iOS git ships as a follow-up (Task 3.4). + */ +class IosGitRepository : GitRepository { + + private val notSupported: Either + get() = DomainError.GitError.NotSupported("iOS").left() + + override suspend fun isGitRepo(path: String): Boolean = false + + override suspend fun init(repoRoot: String): Either = notSupported + + override suspend fun clone( + url: String, + localPath: String, + auth: GitAuth, + onProgress: (String) -> Unit, + ): Either = notSupported + + override suspend fun fetch(config: GitConfig): Either = notSupported + + override suspend fun status(config: GitConfig): Either = notSupported + + override suspend fun stageSubdir(config: GitConfig): Either = notSupported + + override suspend fun commit(config: GitConfig, message: String): Either = + notSupported + + override suspend fun merge(config: GitConfig): Either = notSupported + + override suspend fun push(config: GitConfig): Either = notSupported + + override suspend fun log(config: GitConfig, maxCount: Int): Either> = + notSupported + + override suspend fun abortMerge(config: GitConfig): Either = notSupported + + override suspend fun checkoutFile( + config: GitConfig, + filePath: String, + side: MergeSide, + ): Either = notSupported + + override suspend fun markResolved(config: GitConfig, filePath: String): Either = + notSupported + + override suspend fun hasDetachedHead(config: GitConfig): Boolean = false + + override suspend fun removeStaleLockFile(config: GitConfig): Either = notSupported +} diff --git a/kmp/src/iosMain/kotlin/dev/stapler/stelekit/platform/IosNetworkMonitor.kt b/kmp/src/iosMain/kotlin/dev/stapler/stelekit/platform/IosNetworkMonitor.kt new file mode 100644 index 00000000..8636743f --- /dev/null +++ b/kmp/src/iosMain/kotlin/dev/stapler/stelekit/platform/IosNetworkMonitor.kt @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.platform + +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import platform.Network.NWPathMonitor +import platform.Network.NWPathStatus +import platform.Network.nw_path_monitor_cancel +import platform.Network.nw_path_monitor_create +import platform.Network.nw_path_monitor_set_queue +import platform.Network.nw_path_monitor_set_update_handler +import platform.Network.nw_path_monitor_start +import platform.Network.nw_path_get_status +import platform.darwin.dispatch_queue_create +import platform.darwin.DISPATCH_QUEUE_SERIAL + +/** + * iOS implementation of [NetworkMonitor] using NWPathMonitor from the Network framework. + */ +actual class NetworkMonitor actual constructor() { + + private val monitor = nw_path_monitor_create() + private val queue = dispatch_queue_create("dev.stapler.stelekit.networkMonitor", DISPATCH_QUEUE_SERIAL) + + @Volatile + private var _isOnline: Boolean = true + + init { + nw_path_monitor_set_queue(monitor, queue) + nw_path_monitor_set_update_handler(monitor) { path -> + _isOnline = nw_path_get_status(path) == NWPathStatus.nw_path_status_satisfied + } + nw_path_monitor_start(monitor) + } + + actual val isOnline: Boolean get() = _isOnline + + actual fun observeConnectivity(): Flow = callbackFlow { + val localMonitor = nw_path_monitor_create() + val localQueue = dispatch_queue_create("dev.stapler.stelekit.nm.observer", DISPATCH_QUEUE_SERIAL) + + nw_path_monitor_set_queue(localMonitor, localQueue) + nw_path_monitor_set_update_handler(localMonitor) { path -> + val online = nw_path_get_status(path) == NWPathStatus.nw_path_status_satisfied + trySend(online) + } + nw_path_monitor_start(localMonitor) + + awaitClose { nw_path_monitor_cancel(localMonitor) } + }.distinctUntilChanged() +} diff --git a/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/git/DesktopSyncScheduler.kt b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/git/DesktopSyncScheduler.kt new file mode 100644 index 00000000..3d45e5ae --- /dev/null +++ b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/git/DesktopSyncScheduler.kt @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.git + +import dev.stapler.stelekit.coroutines.PlatformDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * JVM (Desktop) implementation of [BackgroundSyncScheduler]. + * + * Uses a coroutine delay loop owned by a dedicated [CoroutineScope]. + * The callback is invoked after each interval elapses. + */ +class DesktopSyncScheduler( + private val onTick: suspend () -> Unit, +) : BackgroundSyncScheduler { + + private val scope = CoroutineScope(SupervisorJob() + PlatformDispatcher.IO) + private var timerJob: Job? = null + + override fun schedule(intervalMinutes: Int) { + cancel() + timerJob = scope.launch { + while (true) { + delay(intervalMinutes * 60_000L) + onTick() + } + } + } + + override fun cancel() { + timerJob?.cancel() + timerJob = null + } + + /** Fully shuts down the scheduler, releasing all resources. */ + fun shutdown() { + scope.cancel() + } +} diff --git a/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/git/JvmCredentialStore.kt b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/git/JvmCredentialStore.kt new file mode 100644 index 00000000..39c07ed7 --- /dev/null +++ b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/git/JvmCredentialStore.kt @@ -0,0 +1,103 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.git + +import java.io.File +import java.security.SecureRandom +import java.security.spec.KeySpec +import java.util.Base64 +import java.util.Properties +import javax.crypto.Cipher +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec + +/** + * JVM (Desktop) implementation of [CredentialStore] using AES-256-GCM encryption. + * + * Credentials are stored in `~/.config/stelekit/credentials.enc` as a Java Properties file + * where each value is individually encrypted. The encryption key is derived via PBKDF2-HMAC-SHA256 + * from a machine-unique identifier (username + OS name) with a fixed salt. + * + * Security note: This uses a deterministic key derivation from semi-public machine info. + * It protects against casual snooping but not against a determined local attacker. + * Replace with OS keychain integration (SecretService on Linux, DPAPI on Windows, + * Keychain on macOS) for higher assurance in a future release. + */ +actual class CredentialStore actual constructor() { + + private val storageFile: File by lazy { + val configDir = File(System.getProperty("user.home"), ".config/stelekit") + configDir.mkdirs() + File(configDir, "credentials.enc") + } + + private val secretKey: SecretKeySpec by lazy { + val entropy = System.getProperty("user.name", "stelekit") + + System.getProperty("os.name", "unknown") + val salt = "stelekit-credential-salt-v1".toByteArray() + val spec: KeySpec = PBEKeySpec(entropy.toCharArray(), salt, 65536, 256) + val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + val keyBytes = factory.generateSecret(spec).encoded + SecretKeySpec(keyBytes, "AES") + } + + private fun loadProperties(): Properties { + val props = Properties() + if (storageFile.exists()) { + try { + storageFile.inputStream().use { props.load(it) } + } catch (_: Exception) { + // If we can't read/decrypt, start fresh + } + } + return props + } + + private fun saveProperties(props: Properties) { + storageFile.outputStream().use { props.store(it, "SteleKit Credentials") } + } + + private fun encrypt(value: String): String { + val iv = ByteArray(12) + SecureRandom().nextBytes(iv) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, secretKey, GCMParameterSpec(128, iv)) + val cipherText = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + val combined = iv + cipherText + return Base64.getEncoder().encodeToString(combined) + } + + private fun decrypt(encoded: String): String? { + return try { + val combined = Base64.getDecoder().decode(encoded) + val iv = combined.copyOfRange(0, 12) + val cipherText = combined.copyOfRange(12, combined.size) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, iv)) + String(cipher.doFinal(cipherText), Charsets.UTF_8) + } catch (_: Exception) { + null + } + } + + actual fun store(key: String, value: String) { + val props = loadProperties() + props.setProperty(key, encrypt(value)) + saveProperties(props) + } + + actual fun retrieve(key: String): String? { + val props = loadProperties() + val encoded = props.getProperty(key) ?: return null + return decrypt(encoded) + } + + actual fun delete(key: String) { + val props = loadProperties() + props.remove(key) + saveProperties(props) + } +} diff --git a/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/git/JvmGitRepository.kt b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/git/JvmGitRepository.kt new file mode 100644 index 00000000..b9174743 --- /dev/null +++ b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/git/JvmGitRepository.kt @@ -0,0 +1,493 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.git + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import dev.stapler.stelekit.coroutines.PlatformDispatcher +import dev.stapler.stelekit.error.DomainError +import dev.stapler.stelekit.git.model.ConflictFile +import dev.stapler.stelekit.git.model.ConflictHunk +import dev.stapler.stelekit.git.model.GitAuthType +import dev.stapler.stelekit.git.model.GitConfig +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withContext +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.MergeCommand +import org.eclipse.jgit.api.errors.TransportException +import org.eclipse.jgit.lib.Repository +import org.eclipse.jgit.merge.MergeStrategy +import org.eclipse.jgit.storage.file.FileRepositoryBuilder +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider +import org.eclipse.jgit.transport.sshd.SshdSessionFactoryBuilder +import java.io.File +import java.time.Instant + +/** + * JVM (Desktop) implementation of GitRepository using JGit 7.x. + * All I/O runs on PlatformDispatcher.IO. + */ +class JvmGitRepository( + private val credentialStore: CredentialStore = CredentialStore(), +) : GitRepository { + + override suspend fun isGitRepo(path: String): Boolean = withContext(PlatformDispatcher.IO) { + try { + val gitDir = File(path, ".git") + if (gitDir.exists()) return@withContext true + // Also handle bare repos + val builder = FileRepositoryBuilder() + builder.setMustExist(true) + builder.findGitDir(File(path)) + builder.gitDir != null + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + false + } + } + + override suspend fun init(repoRoot: String): Either = + withContext(PlatformDispatcher.IO) { + try { + Git.init().setDirectory(File(repoRoot)).call().close() + Unit.right() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.CloneFailed("init failed: ${e.message}").left() + } + } + + override suspend fun clone( + url: String, + localPath: String, + auth: GitAuth, + onProgress: (String) -> Unit, + ): Either = withContext(PlatformDispatcher.IO) { + try { + val cmd = Git.cloneRepository() + .setURI(url) + .setDirectory(File(localPath)) + .setProgressMonitor(object : org.eclipse.jgit.lib.ProgressMonitor { + override fun start(totalTasks: Int) {} + override fun beginTask(title: String, totalWork: Int) { onProgress(title) } + override fun update(completed: Int) {} + override fun endTask() {} + override fun isCancelled() = false + override fun showDuration(enabled: Boolean) {} + }) + + configureAuth(cmd, auth) + cmd.call().close() + Unit.right() + } catch (e: TransportException) { + DomainError.GitError.AuthFailed(e.message ?: "Authentication failed").left() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.CloneFailed(e.message ?: "Clone failed").left() + } + } + + override suspend fun fetch(config: GitConfig): Either = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + val repo = git.repository + val headBefore = repo.resolve("HEAD") + + git.fetch() + .setRemote(config.remoteName) + .also { configureAuthFromConfig(it, config) } + .call() + + val remoteRef = repo.resolve("${config.remoteName}/${config.remoteBranch}") + val hasChanges = remoteRef != null && remoteRef != headBefore + + val remoteCommitCount = if (hasChanges && headBefore != null && remoteRef != null) { + try { + val commits = git.log() + .addRange(headBefore, remoteRef) + .setMaxCount(100) + .call() + .toList() + commits.size + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + 0 + } + } else { + 0 + } + + FetchResult(hasRemoteChanges = hasChanges, remoteCommitCount = remoteCommitCount).right() + } + } catch (e: TransportException) { + DomainError.GitError.AuthFailed(e.message ?: "Authentication failed").left() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.FetchFailed(e.message ?: "Fetch failed").left() + } + } + + override suspend fun status(config: GitConfig): Either = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + val statusResult = git.status() + .also { cmd -> + if (config.wikiSubdir.isNotEmpty()) { + cmd.addPath(config.wikiSubdir) + } + } + .call() + + val modified = (statusResult.modified + statusResult.changed).toList() + val untracked = statusResult.untracked.toList() + val hasChanges = !statusResult.isClean + + GitStatus( + hasLocalChanges = hasChanges, + untrackedFiles = untracked, + modifiedFiles = modified, + ).right() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.FetchFailed("Status failed: ${e.message}").left() + } + } + + override suspend fun stageSubdir(config: GitConfig): Either = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + val pattern = if (config.wikiSubdir.isEmpty()) "." else "${config.wikiSubdir}/" + git.add().addFilepattern(pattern).call() + // Also stage deletions + git.add().setUpdate(true).addFilepattern(pattern).call() + Unit.right() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.CommitFailed("Stage failed: ${e.message}").left() + } + } + + override suspend fun commit(config: GitConfig, message: String): Either = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + val commit = git.commit() + .setMessage(message) + .call() + commit.name.right() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.CommitFailed(e.message ?: "Commit failed").left() + } + } + + override suspend fun merge(config: GitConfig): Either = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + val repo = git.repository + val remoteRef = repo.resolve("${config.remoteName}/${config.remoteBranch}") + ?: return@withContext DomainError.GitError.FetchFailed( + "Remote ref ${config.remoteName}/${config.remoteBranch} not found" + ).left() + + val mergeResult = git.merge() + .include(remoteRef) + .setStrategy(MergeStrategy.RECURSIVE) + .setFastForward(MergeCommand.FastForwardMode.NO_FF) + .call() + + val hasConflicts = mergeResult.mergeStatus == + org.eclipse.jgit.api.MergeResult.MergeStatus.CONFLICTING + + val conflictFiles = if (hasConflicts) { + mergeResult.conflicts?.keys?.map { filePath -> + val absolutePath = "${config.repoRoot}/$filePath" + val wikiRelPath = if (config.wikiSubdir.isNotEmpty() && + filePath.startsWith("${config.wikiSubdir}/")) { + filePath.removePrefix("${config.wikiSubdir}/") + } else { + filePath + } + ConflictFile( + filePath = absolutePath, + wikiRelativePath = wikiRelPath, + hunks = emptyList(), // parsed by ConflictResolver later + ) + } ?: emptyList() + } else { + emptyList() + } + + // 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 (e: CancellationException) { + throw e + } catch (_: Exception) { + emptyList() + } + + val wikiChangedFiles = if (config.wikiSubdir.isNotEmpty()) { + changedFiles.filter { it.startsWith("${config.repoRoot}/${config.wikiSubdir}/") } + } else { + changedFiles + } + + MergeResult( + hasConflicts = hasConflicts, + conflicts = conflictFiles, + changedFiles = wikiChangedFiles, + ).right() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.FetchFailed("Merge failed: ${e.message}").left() + } + } + + override suspend fun push(config: GitConfig): Either = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + val pushResults = git.push() + .setRemote(config.remoteName) + .also { configureAuthFromConfig(it, config) } + .call() + + for (result in pushResults) { + for (update in result.remoteUpdates) { + if (update.status == org.eclipse.jgit.transport.RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD || + update.status == org.eclipse.jgit.transport.RemoteRefUpdate.Status.REJECTED_OTHER_REASON) { + return@withContext DomainError.GitError.PushFailed( + "Push rejected: ${update.status}" + ).left() + } + } + } + Unit.right() + } + } catch (e: TransportException) { + DomainError.GitError.AuthFailed(e.message ?: "Push authentication failed").left() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.PushFailed(e.message ?: "Push failed").left() + } + } + + override suspend fun log(config: GitConfig, maxCount: Int): Either> = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + val commits = git.log() + .setMaxCount(maxCount) + .call() + .map { revCommit -> + GitCommit( + sha = revCommit.name, + shortMessage = revCommit.shortMessage, + authorName = revCommit.authorIdent.name, + timestamp = revCommit.authorIdent.whenAsInstant.toEpochMilli(), + ) + } + commits.right() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.FetchFailed("Log failed: ${e.message}").left() + } + } + + override suspend fun abortMerge(config: GitConfig): Either = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + git.reset() + .setMode(org.eclipse.jgit.api.ResetCommand.ResetType.MERGE) + .call() + Unit.right() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.CommitFailed("Abort merge failed: ${e.message}").left() + } + } + + override suspend fun checkoutFile( + config: GitConfig, + filePath: String, + side: MergeSide, + ): Either = withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + val stage = when (side) { + MergeSide.LOCAL -> org.eclipse.jgit.api.CheckoutCommand.Stage.OURS + MergeSide.REMOTE -> org.eclipse.jgit.api.CheckoutCommand.Stage.THEIRS + } + git.checkout() + .setStage(stage) + .addPath(filePath.removePrefix("${config.repoRoot}/")) + .call() + Unit.right() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.CommitFailed("Checkout file failed: ${e.message}").left() + } + } + + override suspend fun markResolved(config: GitConfig, filePath: String): Either = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + val relativePath = filePath.removePrefix("${config.repoRoot}/") + git.add().addFilepattern(relativePath).call() + Unit.right() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.CommitFailed("Mark resolved failed: ${e.message}").left() + } + } + + override suspend fun hasDetachedHead(config: GitConfig): Boolean = + withContext(PlatformDispatcher.IO) { + try { + openGit(config.repoRoot).use { git -> + val fullBranch = git.repository.fullBranch ?: return@use false + !fullBranch.startsWith("refs/heads/") + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + false + } + } + + override suspend fun removeStaleLockFile(config: GitConfig): Either = + withContext(PlatformDispatcher.IO) { + try { + val lockFile = File(config.repoRoot, ".git/index.lock") + if (!lockFile.exists()) return@withContext Unit.right() + + val ageMs = System.currentTimeMillis() - lockFile.lastModified() + return@withContext if (ageMs > 60_000L) { + if (lockFile.delete()) { + Unit.right() + } else { + DomainError.GitError.StaleLockFile(lockFile.absolutePath).left() + } + } else { + DomainError.GitError.StaleLockFile(lockFile.absolutePath).left() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DomainError.GitError.StaleLockFile("${config.repoRoot}/.git/index.lock").left() + } + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private fun openGit(repoRoot: String): Git { + return Git.open(File(repoRoot)) + } + + private fun configureAuthFromConfig( + cmd: org.eclipse.jgit.api.TransportCommand<*, *>, + config: GitConfig, + ) { + when (config.authType) { + GitAuthType.HTTPS_TOKEN -> { + val token = config.httpsTokenKey?.let { credentialStore.retrieve(it) } ?: return + cmd.setCredentialsProvider(UsernamePasswordCredentialsProvider("", token)) + } + GitAuthType.SSH_KEY -> { + val keyPath = config.sshKeyPath ?: return + val sshFactory = SshdSessionFactoryBuilder() + .setPreferredAuthentications("publickey") + .setHomeDirectory(File(System.getProperty("user.home"))) + .setSshDirectory(File(keyPath).parentFile ?: File(System.getProperty("user.home"), ".ssh")) + .build(null) + cmd.setTransportConfigCallback { transport -> + if (transport is org.eclipse.jgit.transport.SshTransport) { + transport.sshSessionFactory = sshFactory + } + } + } + GitAuthType.NONE -> {} + } + } + + private fun configureAuth( + cmd: org.eclipse.jgit.api.TransportCommand<*, *>, + auth: GitAuth, + ) { + when (auth) { + is GitAuth.HttpsToken -> { + // For clone, we can use blocking token retrieval since we're already in IO context + val token = runCatching { + kotlinx.coroutines.runBlocking { auth.tokenProvider() } + }.getOrNull() ?: return + cmd.setCredentialsProvider( + UsernamePasswordCredentialsProvider(auth.username, token) + ) + } + is GitAuth.SshKey -> { + val sshFactory = SshdSessionFactoryBuilder() + .setPreferredAuthentications("publickey") + .setHomeDirectory(File(System.getProperty("user.home"))) + .setSshDirectory(File(auth.keyPath).parentFile ?: File(System.getProperty("user.home"), ".ssh")) + .build(null) + cmd.setTransportConfigCallback { transport -> + if (transport is org.eclipse.jgit.transport.SshTransport) { + transport.sshSessionFactory = sshFactory + } + } + } + is GitAuth.None -> { /* no auth configuration needed */ } + } + } +} diff --git a/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/platform/JvmNetworkMonitor.kt b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/platform/JvmNetworkMonitor.kt new file mode 100644 index 00000000..f8aaa619 --- /dev/null +++ b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/platform/JvmNetworkMonitor.kt @@ -0,0 +1,38 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.platform + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import java.net.InetAddress + +/** + * JVM (Desktop) implementation of [NetworkMonitor]. + * + * [isOnline] performs a synchronous reachability check to 8.8.8.8 with a 2-second timeout. + * [observeConnectivity] polls every 30 seconds, emitting the current connectivity state. + * + * Note: DNS-based connectivity detection may fail in environments with split-DNS or + * restrictive firewalls. This is sufficient for a desktop app on a typical workstation. + */ +actual class NetworkMonitor actual constructor() { + + actual val isOnline: Boolean + get() = try { + InetAddress.getByName("8.8.8.8").isReachable(2000) + } catch (_: Exception) { + false + } + + actual fun observeConnectivity(): Flow = flow { + while (true) { + emit(isOnline) + delay(30_000L) + } + }.distinctUntilChanged().flowOn(Dispatchers.IO) +} diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/git/CredentialStore.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/git/CredentialStore.kt new file mode 100644 index 00000000..5cb49c26 --- /dev/null +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/git/CredentialStore.kt @@ -0,0 +1,10 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.git + +actual class CredentialStore actual constructor() { + actual fun store(key: String, value: String) {} + actual fun retrieve(key: String): String? = null + actual fun delete(key: String) {} +} diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/NetworkMonitor.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/NetworkMonitor.kt new file mode 100644 index 00000000..7a022bb8 --- /dev/null +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/NetworkMonitor.kt @@ -0,0 +1,12 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 + +package dev.stapler.stelekit.platform + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +actual class NetworkMonitor actual constructor() { + actual val isOnline: Boolean get() = true + actual fun observeConnectivity(): Flow = flowOf(true) +} diff --git a/project_plans/git-integration/implementation/plan.md b/project_plans/git-integration/implementation/plan.md new file mode 100644 index 00000000..cb49394a --- /dev/null +++ b/project_plans/git-integration/implementation/plan.md @@ -0,0 +1,762 @@ +# Git Integration — Implementation Plan + +_Plan date: 2026-05-02_ +_Based on: requirements.md, research/{stack,features,architecture,pitfalls}.md_ + +--- + +## 1. Technology Decisions + +### 1.1 Git Library: JGit on JVM (Desktop + Android), kgit2 on iOS + +| Platform | Library | Version | Notes | +|----------|---------|---------|-------| +| Desktop JVM (`jvmMain`) | `org.eclipse.jgit:org.eclipse.jgit` | 7.x (latest) | Full Java 21 compatibility — no constraints | +| Android (`androidMain`) | `org.eclipse.jgit:org.eclipse.jgit` | **5.13.x** | Android-safe; Java 11 APIs with `coreLibraryDesugar` | +| iOS (`iosMain`) | `kgit2` (libgit2 via Kotlin/Native) | latest main | Only viable pure-C git library with iOS support | + +**Rationale for JGit 5.x on Android instead of 7.x:** JGit 7.x requires Java 17 APIs not guaranteed by Android ART even with desugaring. JGit 5.13.x is proven by Android Password Store and Orgzly in production. The API surface we need (clone, fetch, merge, push, status, log, add, commit) is unchanged between 5.x and 7.x for our use case. + +**iOS risk acceptance:** kgit2 is early-stage with low adoption. Mitigation: implement the `GitRepository` interface on iOS as a stub that returns `DomainError.GitError.NotSupported` in an initial release, then replace with kgit2 when integration is validated in a dedicated iOS integration task (Task 3.4). This unblocks Android and Desktop while iOS git ships as a follow-up. + +### 1.2 SSH Library + +| Platform | Library | Notes | +|----------|---------|-------| +| Desktop JVM | `org.eclipse.jgit:org.eclipse.jgit.ssh.apache` (Apache MINA SSHD) | Bundled with JGit 7.x; Java 11+ NIO, no extra dep | +| Android | `com.github.mwiede:jsch:0.2.x` | Drop-in `com.jcraft:jsch` replacement; ED25519, ECDSA, OpenSSH format support; Android API 21+ | +| iOS | libssh2 (bundled with libgit2/kgit2) | kgit2 links libssh2 transitively | + +**Why not MINA SSHD on Android:** MINA SSHD NIO requires API 26+ and still has reported issues with Android's BouncyCastle version. The `mwiede/jsch` fork is battle-tested for Android git apps at our target SDK range. + +### 1.3 Diff Library + +`io.github.petertrr:kotlin-multiplatform-diff:1.3.0` + +Covers all KMP targets (JVM, Android, iOS Native, WASM). Apache 2.0. Active maintenance as of December 2025. Used for conflict hunk display in `ConflictResolutionScreen`. + +### 1.4 Secure Credential Storage + +``` +expect class CredentialStore { ... } +``` + +| Platform | Backend | +|----------|---------| +| Android (`androidMain`) | `EncryptedSharedPreferences` (androidx.security:security-crypto already in deps) | +| iOS (`iosMain`) | iOS Keychain via `multiplatform-settings:KeychainSettings` | +| Desktop JVM (`jvmMain`) | `javax.crypto` AES-GCM encrypted file at `~/.config/stelekit/credentials.enc` | + +`KVault` is excluded because it has no Desktop JVM target. `multiplatform-settings` is preferred for iOS because it has broader platform coverage and active maintenance. + +### 1.5 Background Sync Scheduler + +| Platform | Mechanism | +|----------|-----------| +| Android | `WorkManager` (periodic work, `NetworkType.CONNECTED` constraint) + in-process coroutine timer when app is foregrounded | +| iOS | `BGProcessingTask` (re-scheduled after each execution) + on-foreground-launch sync as primary path | +| Desktop JVM | `CoroutineScope` with `PlatformDispatcher.IO` + `delay()` loop owned by `GitSyncService` | + +### 1.6 GitConfig Persistence + +SQLDelight (new table `GitConfig` in `SteleDatabase.sq`), not DataStore. Rationale: GitConfig is graph-scoped and the database is already the per-graph persistence layer. Using a separate DataStore would require cross-platform coordination for a tiny payload; SQLDelight keeps it in one place and gets Arrow Either for free via the existing `DatabaseWriteActor`. + +### 1.7 Summary of Changes from Research Recommendations + +| Research Recommendation | Plan Decision | Reason | +|-------------------------|---------------|--------| +| JGit 5.x on Android | JGit 5.13.x on Android | Confirmed as research rec; explicitly pin 5.13.x | +| kgit2 for iOS | kgit2 for iOS, stub-first | Add stub phase before kgit2 integration to unblock Desktop/Android delivery | +| `multiplatform-settings` for iOS keychain | Adopted | Broader platform support than KVault | +| Apache MINA SSHD on Desktop | Adopted (via jgit.ssh.apache) | Already bundled in JGit 7.x | +| Apache MINA SSHD on Android | Replaced with mwiede/jsch | BouncyCastle conflict risk on Android | + +--- + +## 2. New Domain Model + +### 2.1 GitConfig + +```kotlin +// commonMain: dev/stapler/stelekit/git/model/GitConfig.kt + +@Serializable +data class GitConfig( + val graphId: String, + val repoRoot: String, // Absolute path to the git repo root (NOT the wiki subdir) + val wikiSubdir: String, // Relative path from repoRoot to wiki root (e.g. "wiki" or "notes") + val remoteName: String = "origin", + val remoteBranch: String = "main", + val authType: GitAuthType, + val sshKeyPath: String? = null, // Android/iOS: user-configured path; Desktop: ~/.ssh/id_ed25519 + val sshKeyPassphraseKey: String? = null, // Key into CredentialStore for passphrase + val httpsTokenKey: String? = null, // Key into CredentialStore for PAT + val pollIntervalMinutes: Int = 5, + val autoCommit: Boolean = true, + val commitMessageTemplate: String = "SteleKit: {date}", +) + +// wikiRoot is a computed property, not stored +val GitConfig.wikiRoot: String get() = if (wikiSubdir.isEmpty()) repoRoot else "$repoRoot/$wikiSubdir" + +enum class GitAuthType { NONE, SSH_KEY, HTTPS_TOKEN } +``` + +### 2.2 SyncState + +```kotlin +// commonMain: dev/stapler/stelekit/git/model/SyncState.kt + +sealed class SyncState { + data object Idle : SyncState() + data object Fetching : SyncState() + data class MergeAvailable(val commitCount: Int) : SyncState() + data object Merging : SyncState() + data object Pushing : SyncState() + data object Committing : SyncState() + data class ConflictPending(val conflicts: List) : SyncState() + data class Error(val error: DomainError.GitError) : SyncState() + data class Success( + val localCommitsMade: Int, + val remoteCommitsMerged: Int, + val lastSyncAt: Long, // epoch ms + ) : SyncState() +} +``` + +### 2.3 DomainError.GitError + +Extends the existing `DomainError` sealed interface in `error/DomainError.kt`: + +```kotlin +// Addition to DomainError.kt + +sealed interface GitError : DomainError { + data class CloneFailed(override val message: String) : GitError + data class FetchFailed(override val message: String) : GitError + data class PushFailed(override val message: String) : GitError + data class AuthFailed(override val message: String) : GitError + data class MergeConflict(val conflicts: List) : GitError { + override val message: String = "Merge conflict in ${conflicts.size} file(s)" + } + data class CommitFailed(override val message: String) : GitError + data class NotAGitRepo(val path: String) : GitError { + override val message: String = "Not a git repository: $path" + } + data class DetachedHead(val path: String) : GitError { + override val message: String = "Repository is in detached HEAD state: $path" + } + data class StaleLockFile(val lockPath: String) : GitError { + override val message: String = "Stale git lock file found: $lockPath" + } + data class NotSupported(val platform: String) : GitError { + override val message: String = "Git integration not yet supported on $platform" + } + data object Offline : GitError { + override val message: String = "No network connection available" + } + data object EditingInProgress : GitError { + override val message: String = "Cannot sync while editing is in progress" + } +} +``` + +Also add `GitError` to `DomainError.toUiMessage()`. + +### 2.4 ConflictFile and ConflictHunk + +```kotlin +// commonMain: dev/stapler/stelekit/git/model/ConflictModels.kt + +data class ConflictFile( + val filePath: String, // Absolute path of conflicted file + val wikiRelativePath: String, // Path relative to wiki root (for display) + val hunks: List, +) + +data class ConflictHunk( + val id: String, // Stable ID for persistence + val localLines: List, + val remoteLines: List, + val resolution: HunkResolution = HunkResolution.Unresolved, + val manualContent: String? = null, // Set when user edits manually +) + +sealed class HunkResolution { + data object Unresolved : HunkResolution() + data object AcceptLocal : HunkResolution() + data object AcceptRemote : HunkResolution() + data object Manual : HunkResolution() +} + +// Persisted resolution state so partial progress survives app kill +@Serializable +data class ConflictResolutionState( + val graphId: String, + val conflictFiles: List, + val startedAt: Long, // epoch ms +) +``` + +### 2.5 EditLock + +```kotlin +// commonMain: dev/stapler/stelekit/git/EditLock.kt + +class EditLock { + private val _editingCount = MutableStateFlow(0) + + fun beginEdit() { _editingCount.update { it + 1 } } + fun endEdit() { _editingCount.update { maxOf(it - 1, 0) } } + + /** Suspends until no blocks are in edit mode. Called by GitSyncService before merge. */ + suspend fun awaitIdle() { + _editingCount.first { it == 0 } + } + + val isEditing: StateFlow get() = _editingCount + .map { it > 0 } + .stateIn(editLockScope, SharingStarted.Eagerly, false) + + private val editLockScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) +} +``` + +`BlockEditor` composable calls `editLock.beginEdit()` on focus and `editLock.endEdit()` on blur (after the 500ms debounce in `BlockStateManager` fires and clears the pending write). `EditLock` is created inside `GraphManager` and injected into `GitSyncService`. + +--- + +## 3. New Services and Classes + +### 3.1 `GitRepository` (expect/actual) + +**Package:** `dev/stapler/stelekit/git/` + +**commonMain** — interface: + +```kotlin +// commonMain: dev/stapler/stelekit/git/GitRepository.kt +interface GitRepository { + suspend fun isGitRepo(path: String): Boolean + suspend fun init(repoRoot: String): Either + suspend fun clone( + url: String, + localPath: String, + auth: GitAuth, + onProgress: (String) -> Unit, + ): Either + suspend fun fetch(config: GitConfig): Either + suspend fun status(config: GitConfig): Either + suspend fun stageSubdir(config: GitConfig): Either + suspend fun commit(config: GitConfig, message: String): Either // returns commit SHA + suspend fun merge(config: GitConfig): Either + suspend fun push(config: GitConfig): Either + suspend fun log(config: GitConfig, maxCount: Int = 50): Either> + suspend fun abortMerge(config: GitConfig): Either + suspend fun checkoutFile(config: GitConfig, filePath: String, side: MergeSide): Either + suspend fun markResolved(config: GitConfig, filePath: String): Either + suspend fun hasDetachedHead(config: GitConfig): Boolean + suspend fun removeStaleLockFile(config: GitConfig): Either +} + +data class FetchResult(val hasRemoteChanges: Boolean, val remoteCommitCount: Int) +data class GitStatus(val hasLocalChanges: Boolean, val untrackedFiles: List, val modifiedFiles: List) +data class MergeResult(val hasConflicts: Boolean, val conflicts: List, val changedFiles: List) +data class GitCommit(val sha: String, val shortMessage: String, val authorName: String, val timestamp: Long) +enum class MergeSide { LOCAL, REMOTE } +sealed class GitAuth { + data class SshKey(val keyPath: String, val passphraseProvider: suspend () -> String?) : GitAuth() + data class HttpsToken(val username: String, val tokenProvider: suspend () -> String?) : GitAuth() + data object None : GitAuth() +} +``` + +**`jvmMain`** — `JvmGitRepository`: JGit 7.x implementation. Uses Apache MINA SSHD via `org.eclipse.jgit.ssh.apache.SshdSessionFactoryBuilder`. `stageSubdir` uses `AddCommand.addFilepattern(config.wikiSubdir + "/")`. `fetch` uses `FetchCommand` and compares `FETCH_HEAD` to current HEAD. `status` uses `StatusCommand.addPath(config.wikiSubdir)`. + +**`androidMain`** — `AndroidGitRepository`: JGit 5.13.x implementation. SSH uses `mwiede/jsch` via a custom `JschConfigSessionFactory`. Constructor accepts `sshKeyProvider: () -> ByteArray?` to support user-configured key path. + +**`iosMain`** — `IosGitRepository`: Initially a stub that returns `DomainError.GitError.NotSupported("iOS")` for all operations. Replaced with kgit2 implementation in Task 3.4. + +**Integration with GraphManager/GraphLoader/GraphWriter:** `GitRepository` is a pure I/O class. It does NOT touch the database or the repository layer. Changes to disk (merge, checkout) are always followed by explicit `GraphLoader.reloadFiles()` calls, never via the file watcher. + +### 3.2 `GitSyncService` + +**Package:** `dev/stapler/stelekit/git/` +**Source set:** `commonMain` +**Owned by:** `GraphManager` (one per active graph, created in `switchGraph`) + +```kotlin +class GitSyncService( + private val gitRepository: GitRepository, + private val graphLoader: GraphLoader, + private val graphWriter: GraphWriter, + private val editLock: EditLock, + private val configRepository: GitConfigRepository, + private val networkMonitor: NetworkMonitor, // expect/actual +) { + private val _syncState = MutableStateFlow(SyncState.Idle) + val syncState: StateFlow = _syncState.asStateFlow() + + // Owns its own scope — NEVER accept rememberCoroutineScope() + private val scope = CoroutineScope(SupervisorJob() + PlatformDispatcher.IO) + + suspend fun sync(graphId: String): Either + suspend fun fetchOnly(graphId: String): Either + suspend fun commitLocalChanges(graphId: String): Either + suspend fun resolveConflict(graphId: String, resolution: ConflictResolution): Either + fun startPeriodicSync(graphId: String, intervalMinutes: Int) + fun stopPeriodicSync() + fun shutdown() +} +``` + +**Full sync sequence** (`sync()`): +1. Check `networkMonitor.isOnline` → `DomainError.GitError.Offline` if false +2. Load `GitConfig` from `configRepository`; return if not configured +3. Check for detached HEAD → `DomainError.GitError.DetachedHead` +4. Remove stale `.git/index.lock` if present (older than 60s) +5. `graphWriter.flush()` — flush any pending debounced saves to disk +6. `editLock.awaitIdle()` — block until no blocks are being edited +7. `_syncState.value = SyncState.Committing`; `gitRepository.stageSubdir(config)` + `gitRepository.commit()` if local changes exist +8. `_syncState.value = SyncState.Fetching`; `gitRepository.fetch(config)` → `FetchResult` +9. If `FetchResult.hasRemoteChanges`: + - Call `graphLoader.beginGitMerge(config.changedFiles)` to suppress file watcher + - `_syncState.value = SyncState.Merging`; `mergeResult = gitRepository.merge(config)` + - Call `graphLoader.endGitMerge()` to restore watcher + - If `mergeResult.hasConflicts`: persist `ConflictResolutionState` to SQLDelight; `_syncState.value = SyncState.ConflictPending(mergeResult.conflicts)`; return `MergeConflict` error + - Reload changed files: `graphLoader.reloadFiles(mergeResult.changedFiles)` +10. `_syncState.value = SyncState.Pushing`; `gitRepository.push(config)` +11. `_syncState.value = SyncState.Success(...)` + +All steps use `withContext(PlatformDispatcher.IO)` for network I/O. DB writes after file reload go through `DatabaseWriteActor` as always (no special handling needed — `reloadFiles` calls `parseAndSavePage` which uses the actor internally). + +### 3.3 `GitConfigRepository` + +**Package:** `dev/stapler/stelekit/git/` +**Source set:** `commonMain` + +```kotlin +interface GitConfigRepository { + suspend fun getConfig(graphId: String): Either + suspend fun saveConfig(config: GitConfig): Either + suspend fun deleteConfig(graphId: String): Either + fun observeConfig(graphId: String): Flow> +} +``` + +Implementation: `SqlDelightGitConfigRepository`. New SQLDelight table `git_config` in `SteleDatabase.sq`. All writes route through `DatabaseWriteActor`. Schema: + +```sql +-- in SteleDatabase.sq +CREATE TABLE IF NOT EXISTS git_config ( + graph_id TEXT NOT NULL PRIMARY KEY, + repo_root TEXT NOT NULL, + wiki_subdir TEXT NOT NULL DEFAULT '', + remote_name TEXT NOT NULL DEFAULT 'origin', + remote_branch TEXT NOT NULL DEFAULT 'main', + auth_type TEXT NOT NULL DEFAULT 'NONE', + ssh_key_path TEXT, + ssh_key_passphrase_key TEXT, + https_token_key TEXT, + poll_interval_minutes INTEGER NOT NULL DEFAULT 5, + auto_commit INTEGER NOT NULL DEFAULT 1, + commit_message_template TEXT NOT NULL DEFAULT 'SteleKit: {date}' +); +``` + +**`CredentialStore`** (expect/actual) is a separate class for SSH passphrases and HTTPS tokens: + +```kotlin +// commonMain +expect class CredentialStore { + fun store(key: String, value: String) + fun retrieve(key: String): String? + fun delete(key: String) +} +``` + +### 3.4 `ConflictResolver` + +**Package:** `dev/stapler/stelekit/git/` +**Source set:** `commonMain` + +```kotlin +class ConflictResolver { + /** + * Parses a file containing git conflict markers into a list of ConflictHunks. + * Returns an error if the file has no markers (caller should not call unnecessarily). + */ + fun parseConflictFile( + filePath: String, + content: String, + wikiRoot: String, + ): Either + + /** + * Applies a list of resolved hunks to produce a merged file content string. + * Caller writes the result to disk and calls gitRepository.markResolved(). + */ + fun applyResolutions( + originalContent: String, + hunks: List, + ): Either +} +``` + +**Parsing algorithm:** Scan for `<<<<<<<` / `=======` / `>>>>>>>` line triplets. Each triplet becomes one `ConflictHunk`. Lines between `<<<<<<<` and `=======` are `localLines`; lines between `=======` and `>>>>>>>` are `remoteLines`. Context lines outside hunks are tracked so `applyResolutions` can reconstruct the full file. + +**Block-level display:** The `ConflictResolutionScreen` passes `ConflictHunk.localLines` and `remoteLines` through `kotlin-multiplatform-diff` to produce a visual diff. The ConflictResolver itself operates on raw strings; block-level semantic diff is a UI concern. + +### 3.5 `BackgroundSyncScheduler` (expect/actual) + +**Package:** `dev/stapler/stelekit/git/` + +```kotlin +// commonMain +interface BackgroundSyncScheduler { + fun schedule(intervalMinutes: Int) + fun cancel() +} +``` + +**`androidMain`** — `WorkManagerSyncScheduler`: +- Uses `PeriodicWorkRequestBuilder` with `RepeatInterval(15, MINUTES)` minimum (Android system enforced) +- For sub-15-minute intervals when app is foregrounded: `GitSyncService` runs its own coroutine timer +- `NetworkType.CONNECTED` + `setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)` for user-triggered syncs +- `GitSyncWorker: CoroutineWorker` calls `gitSyncService.fetchOnly()` (not full sync) in the background to check for remote changes; only fetches, does not merge + +**`iosMain`** — `BgTaskSyncScheduler`: +- Registers `BGProcessingTask` with identifier `dev.stapler.stelekit.gitsync` +- Re-schedules after each execution +- On foreground launch: `AppDelegate` calls `gitSyncService.fetchOnly()` directly + +**`jvmMain`** — `DesktopSyncScheduler`: +- `CoroutineScope(SupervisorJob() + PlatformDispatcher.IO)`; `delay(intervalMinutes.minutes)` loop +- Owned by `GitSyncService`; lifecycle tied to `GraphManager.shutdown()` + +### 3.6 `NetworkMonitor` (expect/actual) + +**Package:** `dev/stapler/stelekit/platform/` + +```kotlin +// commonMain +expect class NetworkMonitor { + val isOnline: Boolean + fun observeConnectivity(): Flow +} +``` + +**`androidMain`**: `ConnectivityManager.NetworkCallback` +**`iosMain`**: `NWPathMonitor` (Network framework) +**`jvmMain`**: `InetAddress.getByName("8.8.8.8").isReachable(1000)` polled or always-true default + +### 3.7 Integration with GraphManager + +`GraphManager.switchGraph()` is extended to: +1. Create `EditLock()` for the new graph +2. Create `GitSyncService(gitRepository, graphLoader, graphWriter, editLock, gitConfigRepository, networkMonitor)` +3. Store it in `activeGitSyncService: GitSyncService?` +4. On graph close/switch: call `gitSyncService?.shutdown()` + +```kotlin +// GraphManager additions +val activeGitSyncService: StateFlow = _activeGitSyncService.asStateFlow() +``` + +`StelekitViewModel` accesses `GitSyncService` via `GraphManager.activeGitSyncService`. + +### 3.8 GraphLoader additions for merge suppression + +Two new methods on `GraphLoader`: + +```kotlin +// GraphLoader new methods +fun beginGitMerge(pathsBeingMerged: List) { + // Add all paths to suppressedFiles so checkDirectoryForChanges ignores them + synchronized(suppressedFiles) { + suppressedFiles.addAll(pathsBeingMerged) + } +} + +fun endGitMerge() { + // Clear suppression; watcher resumes normal operation + synchronized(suppressedFiles) { + suppressedFiles.clear() + } +} + +suspend fun reloadFiles(filePaths: List) { + for (path in filePaths) { + val content = fileSystem.readFile(path) ?: continue + // ConflictMarkerDetector guard already in parseAndSavePage + parseAndSavePage(path, content, ParseMode.FULL, DatabaseWriteActor.Priority.HIGH) + } +} +``` + +Note: `suppressedFiles` already exists in `GraphLoader` as `private val suppressedFiles = mutableSetOf()`. The new `beginGitMerge`/`endGitMerge` methods use it; the existing `suppress()` callback on `ExternalFileChange` remains unchanged. + +--- + +## 4. UI Changes + +### 4.1 SyncState in AppState and StelekitViewModel + +Add to `AppState.kt`: + +```kotlin +data class AppState( + // ... existing fields ... + val syncState: SyncState = SyncState.Idle, + val gitConfig: GitConfig? = null, + val gitSetupVisible: Boolean = false, + val conflictResolutionVisible: Boolean = false, + val gitLogVisible: Boolean = false, +) +``` + +Add to `StelekitViewModel`: + +```kotlin +// Collect from active GitSyncService, null when no git is configured +val syncState: StateFlow = graphManager.activeGitSyncService + .flatMapLatest { it?.syncState ?: flowOf(SyncState.Idle) } + .stateIn(scope, SharingStarted.Eagerly, SyncState.Idle) + +fun triggerSync() { scope.launch { graphManager.activeGitSyncService.value?.sync(activeGraphId) } } +fun triggerFetchOnly() { scope.launch { graphManager.activeGitSyncService.value?.fetchOnly(activeGraphId) } } +fun openGitSetup() { _uiState.update { it.copy(gitSetupVisible = true) } } +fun dismissConflictResolution() { _uiState.update { it.copy(conflictResolutionVisible = false) } } +``` + +`StelekitViewModel` observes `syncState` and when `SyncState.ConflictPending` is emitted, sets `conflictResolutionVisible = true`. + +### 4.2 Sync Status Badge + +**Location:** In the sidebar / graph header area (near the graph name), not the navigation tabs. Specifically: after the graph display name in the `SidebarHeader` composable. + +**Badge content:** +- `SyncState.Idle`: grey circular icon (no text) showing last sync time on hover/long-press +- `SyncState.MergeAvailable(n)`: blue badge "↓ n" +- `SyncState.Fetching / Merging / Pushing / Committing`: animated spinner +- `SyncState.ConflictPending`: amber badge "⚠ Conflict" +- `SyncState.Error`: red badge "⚠ Sync Error" +- `SyncState.Success`: brief green checkmark (fades after 3s) + +**Manual sync button:** Always-visible sync icon button in the top-right of the sidebar header. Triggers `viewModel.triggerSync()`. + +### 4.3 New Screens + +**`GitSetupScreen`** (`commonMain/ui/screens/git/GitSetupScreen.kt`): +- Step 1: Choose "Use existing clone" vs "Clone new repo" +- Step 2: Configure repo root path (folder picker) + wiki subdirectory +- Step 3: Choose auth type (SSH / HTTPS / None); SSH: configure key path; HTTPS: enter token (stored in `CredentialStore`) +- Step 4: Configure branch name and poll interval +- Step 5: Test connection button (calls `gitRepository.fetch()`) → success or error message +- On save: calls `gitConfigRepository.saveConfig()` and `gitSyncService.fetchOnly()` immediately + +**`ConflictResolutionScreen`** (`commonMain/ui/screens/git/ConflictResolutionScreen.kt`): +- Header: number of conflicted files; progress indicator (N of M resolved) +- File picker row: tappable list of conflicted file names; selected file's hunks shown below +- Per-hunk card: + - "Mine" (left column, green tint) vs "Theirs" (right column, blue tint) side-by-side + - Three action buttons: **Accept Mine** / **Accept Theirs** / **Edit Manually** + - "Edit Manually" opens a simple text field pre-filled with `localLines` joined + - Resolved hunks collapse with a checkmark +- Quick-resolve controls: **Accept All Mine** / **Accept All Theirs** per file +- Synchronized scroll between columns via `LazyListState` bridge +- **Finish Merge** button (enabled when all hunks in all files are resolved): calls `GitSyncService.resolveConflict()` +- **Abort Merge** button: calls `gitRepository.abortMerge()` +- Partial resolution state auto-saved to `ConflictResolutionState` in SQLDelight on each hunk resolution + +**`GitLogScreen`** (`commonMain/ui/screens/git/GitLogScreen.kt`): +- Lists last 50 commits via `gitRepository.log(config, maxCount = 50)` +- Each row: short SHA, message, author, relative timestamp +- Accessible from Settings or via sync badge long-press + +### 4.4 Settings Screen Changes + +Add a **"Git Sync"** section to the existing settings screen: +- Current repo and remote URL (read-only display) +- Poll interval selector (Off / 5m / 15m / 30m / 1h) +- Auth method (SSH key / HTTPS token) with ability to change +- "View Git Log" link +- "Disconnect Git" button (calls `gitConfigRepository.deleteConfig()`) + +--- + +## 5. File Watcher Safety + +### 5.1 The Problem + +`GraphLoader.startWatching()` polls every 5 seconds and calls `checkDirectoryForChanges()`. When a git merge rewrites `.md` files in the wiki, the existing `fileRegistry.detectChanges()` sees modified timestamps and emits `ExternalFileChange` events — potentially causing false `DiskConflict` events or double-loading. + +### 5.2 The Solution: Explicit Suppression via `beginGitMerge` / `endGitMerge` + +The existing `suppressedFiles: mutableSetOf()` in `GraphLoader` already handles per-file suppression (used by `GraphWriter.savePageInternal` → `onFileWritten` callback). The git merge path uses the same mechanism, extended with two new coordinated entry points. + +**Sequence in `GitSyncService.sync()`:** + +``` +1. mergeResult = gitRepository.merge(config) // git modifies files on disk + (cannot suppress before — we need merge to complete to know which files changed) + +2. graphLoader.beginGitMerge(mergeResult.changedFiles) // suppress watcher for affected files + // This adds changedFiles to suppressedFiles + +3. graphLoader.reloadFiles(mergeResult.changedFiles) // explicit, controlled reload + // parseAndSavePage is called directly; watcher events for these paths ignored + +4. graphLoader.endGitMerge() // suppress cleared +``` + +**Race condition mitigation:** The 5-second polling watcher may fire between step 1 and step 2. The suppression window is small (milliseconds for in-memory set addition). Any watcher events that sneak through before `beginGitMerge` will see the same post-merge content as `reloadFiles` — resulting in a redundant but harmless reload (the `fileModTime >= page.updatedAt` freshness check in `parseAndSavePage` will skip re-parsing on the second call). + +**Conflict-marker guard:** If the merge produced conflict markers in a file, `parseAndSavePage` already returns early via `ConflictMarkerDetector.hasConflictMarkers(content)` check (already in codebase). The file is NOT loaded into the database. `GitSyncService` detects the conflict via `MergeResult.hasConflicts` and routes to `ConflictPending` state before calling `reloadFiles`. + +**File watcher and `fileRegistry.updateModTime`:** After `reloadFiles` calls `parseAndSavePage`, the existing `fileRegistry.updateModTime(filePath, updatedModTime)` call at the end of `parseAndSavePage` updates the watcher's known-good timestamp. Subsequent watcher polls see a matching mtime and skip the file. No additional work needed. + +--- + +## 6. Implementation Epics and Tasks + +### Epic 1: Foundation — Git Library Integration + +**Goal:** Git operations work on Desktop and Android. iOS compiles with a stub. No UI yet. + +- **Task 1.1:** Add JGit 7.x to `jvmMain` and JGit 5.13.x to `androidMain` in `kmp/build.gradle.kts`. Add `kotlin-multiplatform-diff` to `commonMain`. Verify build compiles on all targets. +- **Task 1.2:** Define `GitRepository` interface, `GitAuth`, `FetchResult`, `MergeResult`, `GitStatus`, `GitCommit`, `MergeSide` in `commonMain/git/`. Create `IosGitRepository` stub (`iosMain`). +- **Task 1.3:** Implement `JvmGitRepository` in `jvmMain` (JGit 7.x): `isGitRepo`, `status`, `stageSubdir`, `commit`, `fetch`, `merge`, `push`, `log`, `abortMerge`, `removeStaleLockFile`. SSH via Apache MINA SSHD. +- **Task 1.4:** Implement `AndroidGitRepository` in `androidMain` (JGit 5.13.x + mwiede/jsch). Include `JschConfigSessionFactory` with configurable SSH key path. +- **Task 1.5:** Add `GitConfig`, `SyncState`, `DomainError.GitError`, `ConflictFile`, `ConflictHunk`, `HunkResolution`, `EditLock` data classes to `commonMain`. +- **Task 1.6:** Write unit tests for `JvmGitRepository` against a real temp git repo (no mocking). Cover: clone, status, stage, commit, fetch with local "remote" bare repo, merge with no conflict, merge with conflict, push. + +### Epic 2: Domain Services + +**Goal:** `GitSyncService`, `GitConfigRepository`, `ConflictResolver`, `CredentialStore` are complete and tested. + +- **Task 2.1:** Add `git_config` table to `SteleDatabase.sq` and `RestrictedDatabaseQueries`. Implement `SqlDelightGitConfigRepository`. +- **Task 2.2:** Implement `CredentialStore` expect/actual: Android (`EncryptedSharedPreferences`), iOS (Keychain via `multiplatform-settings`), Desktop JVM (AES-GCM file). +- **Task 2.3:** Add `GraphLoader.beginGitMerge()`, `endGitMerge()`, `reloadFiles()` to `GraphLoader`. Verify existing `suppressedFiles` mechanism works for batch suppression. +- **Task 2.4:** Implement `ConflictResolver` — parse conflict markers into `ConflictFile`/`ConflictHunk` list; implement `applyResolutions()` to produce merged content. +- **Task 2.5:** Implement `GitSyncService` (full sync cycle, `fetchOnly`, `commitLocalChanges`, `resolveConflict`). Wire `EditLock` into `BlockStateManager` callbacks. +- **Task 2.6:** Implement `NetworkMonitor` expect/actual (Android, iOS, Desktop). +- **Task 2.7:** Write unit tests for `ConflictResolver` covering: no conflicts, single hunk, multiple hunks, nested conflict blocks, manual edit resolution. Write unit tests for `GitSyncService` using a fake `GitRepository`. + +### Epic 3: Background Sync Scheduler + +**Goal:** Periodic sync works on all three platforms. + +- **Task 3.1:** Implement `BackgroundSyncScheduler` interface + `DesktopSyncScheduler` (`jvmMain`). Wire into `GraphManager.switchGraph()`. +- **Task 3.2:** Implement `WorkManagerSyncScheduler` + `GitSyncWorker` (`androidMain`). Register `GitSyncWorker` in the Android manifest/initialization. Use `fetchOnly()` for background work, not full sync. +- **Task 3.3:** Implement `BgTaskSyncScheduler` (`iosMain`): register `BGProcessingTask`, re-schedule after each execution, call `gitSyncService.fetchOnly()` from the task handler. Add `NSBackgroundModes` to `Info.plist`. +- **Task 3.4 (iOS kgit2):** Replace `IosGitRepository` stub with kgit2 implementation. Evaluate kgit2 API surface for `clone`, `fetch`, `merge`, `push`. Write iOS integration tests against a local bare repo. (This task may slip to a follow-up release if kgit2 API gaps are found.) + +### Epic 4: UI — Setup and Configuration + +**Goal:** Users can attach a git repo to a graph via the UI. + +- **Task 4.1:** Add `SyncState` + `gitConfig` + `gitSetupVisible` fields to `AppState`. Add `syncState: StateFlow` and sync-related actions to `StelekitViewModel`. +- **Task 4.2:** Build `GitSetupScreen` — multi-step wizard: path selection, subdirectory config, auth config, test connection, save. +- **Task 4.3:** Add sync status badge to sidebar header. Add manual sync icon button. Add "Git Sync" section to Settings screen. +- **Task 4.4:** Add `GitLogScreen` — paginated commit list. Wire to Settings "View Git Log" link. + +### Epic 5: Conflict Resolution UI + +**Goal:** Users can resolve merge conflicts inside the app. + +- **Task 5.1:** Build `ConflictResolutionScreen` skeleton: file list, per-hunk card layout, synchronized scroll between two `LazyColumn` instances. +- **Task 5.2:** Implement hunk resolution actions: Accept Mine, Accept Theirs. Wire to `ConflictResolver.applyResolutions()` + `gitRepository.markResolved()`. Implement "Finish Merge" flow (calls `GitSyncService.resolveConflict()` which commits the merge). +- **Task 5.3:** Implement "Edit Manually" inline text editor for a hunk. Add "Accept All Mine" / "Accept All Theirs" quick-resolve buttons per file. +- **Task 5.4:** Implement persistence of partial resolution state to `ConflictResolutionState` in SQLDelight. On launch, detect in-progress merge (`gitRepository.status()` shows merge state) and restore `ConflictResolutionScreen`. +- **Task 5.5:** Abort Merge flow: "Abort Merge" button calls `gitRepository.abortMerge()` and clears persisted state. Test that graph reloads cleanly to pre-merge state. + +### Epic 6: Integration, Hardening, and Edge Cases + +**Goal:** All pitfalls from research are addressed; the full workflow is tested end-to-end. + +- **Task 6.1:** On-launch safety checks: detect detached HEAD (`DomainError.GitError.DetachedHead` warning in UI), detect stale `.git/index.lock` (auto-remove if older than 60s), detect and warn if wiki root equals repo root. +- **Task 6.2:** Validate `.gitattributes` on first git operation: if missing `* text=auto`, offer to add it. Validate `.gitignore` contains `*.db` (existing `checkGitignoreForDatabase` already covers DB files; extend to log a warning for `.stelekit/` sidecar files). +- **Task 6.3:** Implement incremental commit message: default template `"SteleKit: {date} ({n} files changed)"` using `GitConfig.commitMessageTemplate`. Include changed file names in body for legible git log. +- **Task 6.4:** Error mapping layer: map all JGit / kgit2 exceptions to `DomainError.GitError` subtypes. Ensure `AuthFailed` surfaces a human-readable message in the UI, not a raw SSH exception stack trace. +- **Task 6.5:** End-to-end integration test: two "devices" (two temp directories with a shared bare repo as remote) — simulate Device A edits journal, commits; Device B opens SteleKit, triggers sync, sees Device A's entry. Simulate conflict (both edit same journal file), resolve via API. +- **Task 6.6:** Large repo performance: scope `git status` to wiki subdirectory via `StatusCommand.addPath(config.wikiSubdir)`. Add `--max-count=50` to all `log` calls. Document sparse checkout as advanced option in settings UI help text. + +--- + +## 7. Risk Mitigations + +### 7.1 JGit vs Native + +**Risk:** No single library covers all three platforms. + +**Mitigation:** JGit 5.13.x (Android) + JGit 7.x (Desktop JVM) for two platforms on day one; iOS ships as a stub (`DomainError.GitError.NotSupported`). This unblocks primary use cases (Android Termux sync, Desktop sync) immediately. iOS kgit2 integration is a separate task (Task 3.4) that can ship in a follow-up. + +**Fallback:** If kgit2 proves unworkable (API gaps, link errors), iOS stays on stub indefinitely and the feature ships as "Desktop + Android" only. + +### 7.2 SSH on Android + +**Risk:** Original JSch does not support ED25519 or modern key exchange; fails with GitHub post-2021. + +**Mitigation:** Use `com.github.mwiede:jsch:0.2.x` as the SSH provider. This is the same fix used by Android Password Store (production app with thousands of users). Integration point: `JschConfigSessionFactory` override in `AndroidGitRepository`. Tested against GitHub, GitLab, and Gitea in Task 1.6. + +### 7.3 File Watcher Interaction During Merge + +**Risk:** `GraphLoader`'s 5-second polling watcher emits `ExternalFileChange` for files rewritten by git merge, causing false `DiskConflict` events or double-reloads. + +**Mitigation:** `GraphLoader.beginGitMerge(paths)` adds all merge-affected paths to `suppressedFiles` before `reloadFiles()` is called. `endGitMerge()` clears suppression. The existing `ConflictMarkerDetector` guard in `parseAndSavePage` prevents conflicted files from entering the DB regardless. + +**Secondary defense:** `GraphWriter.flush()` is called before the merge sequence begins, ensuring all debounced saves are on disk and the local commit is clean before git touches any files. + +### 7.4 Conflict Markers in Parser + +**Risk:** Parser ingests file with `<<<<<<<` markers; corrupts block tree and database. + +**Mitigation:** `ConflictMarkerDetector.hasConflictMarkers(content)` is **already implemented** in the codebase (found in `GraphLoader.parseAndSavePage()`). It returns early with a `WriteError` if markers are found. The git layer additionally ensures it never calls `reloadFiles()` on a conflicted file — `MergeResult.hasConflicts` check routes to `ConflictPending` state first. + +### 7.5 iOS Background Limits + +**Risk:** iOS `BGProcessingTask` has a ~1-2 minute budget and is not guaranteed to run at the requested interval. + +**Mitigation:** +1. **On-foreground-launch sync is mandatory** (FR-2.3) — this is the primary sync point +2. Use `BGProcessingTask` (not `BGAppRefreshTask`) for the larger time budget +3. Background task does **only `fetchOnly()`** — check for remote changes, emit `SyncState.MergeAvailable(n)` notification; actual merge deferred to user-initiated action +4. Re-schedule after each task execution via `scheduleNextBgSync()` +5. Persist `lastSyncAttempt` timestamp; on foreground launch, detect if last background check was >30min ago and trigger immediate fetch + +### 7.6 Battery Drain on Android + +**Risk:** OEM battery savers (Xiaomi, Huawei, Samsung) kill WorkManager jobs; git operations killed mid-flight corrupt `.git/` state. + +**Mitigations:** +1. WorkManager with `NetworkType.CONNECTED` constraint handles Doze mode automatically +2. `GitSyncWorker` does `fetchOnly()` only; merge is user-initiated — reduces per-sync work +3. On startup, check for stale `.git/index.lock` (Task 6.1) and clean it up before any git operation +4. JGit's `FetchCommand` is idempotent — re-running after a partial fetch is safe (re-downloads from the last good packfile boundary) + +### 7.7 Large Repos + +**Risk:** `git status` on a large repo takes 2-10 seconds, blocking sync. + +**Mitigation:** Scope all git status and staging operations to `wikiSubdir`: `StatusCommand.addPath(config.wikiSubdir)` in `JvmGitRepository`/`AndroidGitRepository`. Log queries always use `LogCommand.setMaxCount(50)`. + +--- + +## 8. Out of Scope Confirmation + +The following are explicitly deferred to v2 and will NOT be implemented: + +| Feature | Rationale | +|---------|-----------| +| Branch management (create/switch branches) | Single-branch personal wiki is the core use case | +| Rebase workflow | Merge-only for simplicity; rebase requires conflict model changes | +| Git blame / line history viewer | Nice to have; no conflict with current architecture | +| Multiple remotes per repo | Single `origin` covers all user scenarios | +| Submodule support | Adds significant complexity; no user demand identified | +| Automatic semantic three-way merge | Git's line-level merge is used; block-level diff is display-only | +| In-app new repo initialization from scratch | FR-1.1 covers existing clone; init added as a convenience but not a blocker | +| iOS kgit2 implementation (initial release) | Delivered as stub; kgit2 production readiness needs validation | +| Sparse checkout | Advanced option for large monorepos; documented, not implemented | +| Rebase merge strategy as a user-selectable option | Failure mode documented; merge-only in v1 | +| Windows-native DPAPI for Desktop credential storage | AES-GCM encrypted file is sufficient for v1 | diff --git a/project_plans/git-integration/implementation/validation.md b/project_plans/git-integration/implementation/validation.md new file mode 100644 index 00000000..aee769fe --- /dev/null +++ b/project_plans/git-integration/implementation/validation.md @@ -0,0 +1,380 @@ +# Git Integration — Validation Plan + +_Plan date: 2026-05-02_ +_Based on: requirements.md, implementation/plan.md, research/pitfalls.md, kmp/TESTING_README.md_ + +--- + +## 1. Test Infrastructure Requirements + +### 1.1 FakeGitRepository (unit tests) + +A `FakeGitRepository : GitRepository` in `jvmTest/kotlin/dev/stapler/stelekit/git/fixtures/` that: +- Holds a `MutableMap` keyed by `graphId` for scripted fetch results +- Exposes a `var nextMergeResult: MergeResult` for controlling merge outcomes +- Exposes a `var nextPushError: DomainError.GitError?` to simulate push failures +- Records all method calls in a `callLog: List` for assertion +- Returns `isGitRepo = true` for any path by default (override per test) + +This follows the existing `FakePageRepository` / `FakeBlockRepository` pattern in `jvmTest/kotlin/dev/stapler/stelekit/ui/fixtures/`. + +### 1.2 LocalBareRepoFixture (integration tests) + +A JUnit5 `@TempDir`-backed helper in `jvmTest/kotlin/dev/stapler/stelekit/git/fixtures/LocalBareRepoFixture.kt` that: +- Creates a bare repo (`git init --bare`) in a temp directory using JGit `InitCommand` +- Creates a working clone in a second temp directory +- Exposes `fun commitFile(relativePath: String, content: String, message: String)` to add commits +- Exposes `fun wikiSubdir: String` (hardcoded `"wiki"` for tests; configurable) +- Exposes `fun bareRepoUri: String` (file URI of the bare repo, usable as JGit remote URL) +- Tears down both directories via `@AfterEach` + +This avoids any external network calls; all integration tests run with `./gradlew jvmTest`. + +### 1.3 ConflictFileBuilder + +A DSL helper in `jvmTest/kotlin/dev/stapler/stelekit/git/fixtures/ConflictFileBuilder.kt`: + +```kotlin +fun conflictContent(block: ConflictBuilder.() -> Unit): String +class ConflictBuilder { + fun context(vararg lines: String) + fun hunk(local: List, remote: List) +} +``` + +Generates valid git conflict marker syntax (`<<<<<<<` / `=======` / `>>>>>>>`) for parser tests. + +### 1.4 FakeNetworkMonitor + +A `FakeNetworkMonitor : NetworkMonitor` that exposes `var isOnline: Boolean = true`, covering the offline path in `GitSyncService` tests without platform-specific connectivity code. + +### 1.5 FakeGraphLoader / FakeGraphWriter additions + +Extend the existing `FakeFileSystem` pattern to add: +- `var reloadFilesCallLog: List>` to assert that `reloadFiles()` is called with the expected paths after merge +- `var beginGitMergeCallCount: Int` / `var endGitMergeCallCount: Int` for suppression lifecycle assertions + +--- + +## 2. Unit Tests (businessTest / jvmTest) + +Test class locations follow existing conventions: `businessTest` for pure domain logic with no JVM dependencies; `jvmTest` for JVM-specific classes. + +### 2.1 GitSyncService + +**Class:** `dev.stapler.stelekit.git.GitSyncService` +**Source set:** `jvmTest` (uses `runTest` + `TestScope`) +**Dependencies mocked:** `FakeGitRepository`, `FakeNetworkMonitor`, in-memory `GitConfigRepository` + +- TC-001: Idle to Committing transition — Given local changes exist, when `sync()` is called, then `_syncState` emits `Committing` before `Fetching` — maps to FR-2.1 +- TC-002: Committing to Fetching transition — Given `stageSubdir` + `commit` succeed, when sync proceeds, then `_syncState` transitions from `Committing` to `Fetching` — maps to FR-2.1 +- TC-003: Fetching to MergeAvailable without auto-merge — Given fetch returns `hasRemoteChanges=true`, when sync is `fetchOnly()`, then `_syncState` emits `MergeAvailable(n)` and no merge is attempted — maps to FR-3.1 +- TC-004: MergeAvailable to Merging on explicit sync — Given state is `MergeAvailable`, when `sync()` is called, then `_syncState` transitions to `Merging` — maps to FR-2.1 +- TC-005: Successful sync end state — Given fetch+merge+push all succeed, when sync completes, then `_syncState` emits `Success` with correct `remoteCommitsMerged` count — maps to FR-2.1 +- TC-006: Conflict produces ConflictPending state — Given merge returns `hasConflicts=true`, when sync runs, then `_syncState` emits `ConflictPending` and `Either.Left(MergeConflict)` is returned — maps to FR-4.1 +- TC-007: EditLock blocks sync — Given `editLock.beginEdit()` was called, when `sync()` is invoked, then `editLock.awaitIdle()` suspends until `endEdit()` is called, and sync proceeds only after — maps to FR-3.3 +- TC-008: EditLock not blocking when idle — Given no active edit, when `sync()` is called, then `editLock.awaitIdle()` returns immediately without suspension — maps to FR-3.3 +- TC-009: Offline error — Given `networkMonitor.isOnline = false`, when `sync()` is called, then returns `Either.Left(DomainError.GitError.Offline)` and state transitions to `Error` — maps to NFR-Offline +- TC-010: Auth failure propagation — Given `FakeGitRepository.nextPushError = AuthFailed("...")`, when sync reaches push step, then returns `Either.Left(AuthFailed)` and `_syncState` emits `Error(AuthFailed)` — maps to FR-5.1, FR-6.2 +- TC-011: DetachedHead abort — Given `gitRepository.hasDetachedHead()` returns `true`, when `sync()` is called, then returns `Either.Left(DetachedHead)` without performing any write operations — maps to plan §6.1 +- TC-012: Stale lock file is removed before sync — Given `removeStaleLockFile()` call order, when sync runs and lock file is present, then `removeStaleLockFile()` is called before `stageSubdir()` — maps to plan §6.1 +- TC-013: No-config returns early — Given no `GitConfig` stored for the graph, when `sync()` is called, then returns `Either.Right` with no operations performed and state stays `Idle` — maps to FR-1.4 +- TC-014: `fetchOnly()` does not push — Given `FakeGitRepository` tracks calls, when `fetchOnly()` is called, then `push()` is never called — maps to FR-2.3, FR-3.2 +- TC-015: `startPeriodicSync` fires at interval — Given interval = 1 second in `TestScope`, when `startPeriodicSync(1)` is called and time is advanced by 3 seconds, then `fetchOnly()` is called at least 3 times — maps to FR-2.4 +- TC-016: `stopPeriodicSync` cancels timer — Given periodic sync is running, when `stopPeriodicSync()` is called, then no further `fetchOnly()` calls occur after cancellation — maps to FR-2.4 +- TC-017: `shutdown()` cancels coroutine scope cleanly — Given `GitSyncService` has a running periodic timer, when `shutdown()` is called, then the internal scope is cancelled and no coroutine leaks — maps to plan §3.2 +- TC-018: `graphWriter.flush()` called before merge — Given `FakeGraphWriter` tracks calls, when `sync()` runs, then `flush()` is called before `gitRepository.merge()` — maps to pitfall §2 +- TC-019: `beginGitMerge` / `endGitMerge` pair called around merge — Given merge succeeds, when sync runs, then `beginGitMerge(changedFiles)` is called before `reloadFiles()` and `endGitMerge()` is called after — maps to FR-3.1, pitfall §6 +- TC-020: `reloadFiles` called with merge-changed files — Given merge returns `changedFiles = listOf("wiki/journal.md")`, when sync completes merge, then `reloadFiles(["wiki/journal.md"])` is called exactly once — maps to pitfall §6 + +### 2.2 ConflictResolver + +**Class:** `dev.stapler.stelekit.git.ConflictResolver` +**Source set:** `businessTest` (pure Kotlin, no JVM deps) + +- TC-021: Single hunk parsed correctly — Given content with one `<<<<<<<`/`=======`/`>>>>>>>` block, when `parseConflictFile()` is called, then returns `ConflictFile` with exactly one `ConflictHunk` with correct `localLines` and `remoteLines` — maps to FR-4.2 +- TC-022: Multiple hunks parsed correctly — Given content with three conflict blocks separated by context lines, when parsed, then returns `ConflictFile` with three hunks in order — maps to FR-4.2 +- TC-023: Context lines preserved — Given a file with conflict markers and surrounding context lines, when `parseConflictFile()` is called, then context lines are tracked for round-trip reconstruction — maps to FR-4.4 +- TC-024: No conflict markers returns error — Given clean markdown content with no markers, when `parseConflictFile()` is called, then returns `Either.Left` (not `ConflictFile`) — maps to plan §3.4 +- TC-025: Empty file returns error — Given an empty string, when `parseConflictFile()` is called, then returns `Either.Left` — maps to plan §3.4 +- TC-026: `applyResolutions` with AcceptLocal — Given one hunk with `resolution = AcceptLocal`, when `applyResolutions()` is called, then output contains only `localLines` for that hunk, markers removed — maps to FR-4.3 +- TC-027: `applyResolutions` with AcceptRemote — Given one hunk with `resolution = AcceptRemote`, when `applyResolutions()` is called, then output contains only `remoteLines` for that hunk — maps to FR-4.3 +- TC-028: `applyResolutions` with Manual — Given one hunk with `resolution = Manual` and `manualContent = "edited"`, when applied, then output contains `"edited"` in place of the hunk — maps to FR-4.3 +- TC-029: `applyResolutions` with Unresolved hunk returns error — Given a hunk with `resolution = Unresolved`, when `applyResolutions()` is called, then returns `Either.Left` preventing completion — maps to FR-4.4 +- TC-030: Mixed resolutions across multiple hunks — Given three hunks with AcceptLocal, AcceptRemote, Manual respectively, when applied, then each hunk is resolved by its own resolution strategy in correct order — maps to FR-4.3 +- TC-031: Conflict in first lines of file — Given conflict markers at line 1 (no leading context), when parsed, then hunk is correctly identified without off-by-one error — maps to plan §3.4 +- TC-032: Conflict in last lines of file — Given conflict markers at end of file (no trailing context), when parsed, then hunk is correctly identified — maps to plan §3.4 +- TC-033: Binary file content is handled — Given content that is non-UTF-8 parseable, when `parseConflictFile()` is called, then returns `Either.Left` (binary file guard, not a crash) — maps to pitfall §3 +- TC-034: Round-trip fidelity — Given any conflict file content, when parsed and all hunks resolved as AcceptLocal, then output equals the original local-side content — maps to FR-4.4 +- TC-035: `wikiRelativePath` is computed relative to wikiRoot — Given `filePath = "/repo/wiki/journals/2026-05-02.md"` and `wikiRoot = "/repo/wiki"`, when parsed, then `ConflictFile.wikiRelativePath = "journals/2026-05-02.md"` — maps to FR-4.2 + +### 2.3 GitConfigRepository (SqlDelightGitConfigRepository) + +**Class:** `dev.stapler.stelekit.git.SqlDelightGitConfigRepository` +**Source set:** `jvmTest` (requires SQLDelight in-memory database) + +- TC-036: Save and load round-trip — Given a `GitConfig` with all fields set, when `saveConfig()` then `getConfig()`, then returned config equals original — maps to FR-1.4 +- TC-037: Missing config returns null — Given no config saved for a graphId, when `getConfig(graphId)` is called, then returns `Either.Right(null)` — maps to FR-1.4 +- TC-038: Update overwrites previous config — Given an existing config, when `saveConfig()` with different `remoteBranch`, then subsequent `getConfig()` returns updated branch — maps to FR-1.4 +- TC-039: Delete removes config — Given a saved config, when `deleteConfig(graphId)`, then `getConfig(graphId)` returns `Either.Right(null)` — maps to FR-1.4, plan §4.4 +- TC-040: `observeConfig` emits on save — Given a `Flow` collector on `observeConfig(graphId)`, when `saveConfig()` is called, then the flow emits the new config — maps to FR-1.4 +- TC-041: Default values are preserved — Given a config saved with only required fields, when loaded, then `pollIntervalMinutes = 5`, `autoCommit = true`, `remoteName = "origin"` — maps to FR-2.4 +- TC-042: SSH config persisted correctly — Given `authType = SSH_KEY` and `sshKeyPath = "/path/key"`, when saved and loaded, then `authType` and `sshKeyPath` are correctly stored — maps to FR-5.1 +- TC-043: HTTPS token key persisted correctly — Given `authType = HTTPS_TOKEN` and `httpsTokenKey = "mykey"`, when saved and loaded, then fields are preserved — maps to FR-5.2 +- TC-044: wikiRoot computed property — Given `repoRoot = "/repo"` and `wikiSubdir = "wiki"`, then `GitConfig.wikiRoot == "/repo/wiki"` — maps to FR-1.3 +- TC-045: wikiRoot with empty subdir equals repoRoot — Given `wikiSubdir = ""`, then `GitConfig.wikiRoot == repoRoot` — maps to FR-1.3 + +### 2.4 EditLock + +**Class:** `dev.stapler.stelekit.git.EditLock` +**Source set:** `businessTest` + +- TC-046: `awaitIdle` returns immediately when count is 0 — Given no `beginEdit()` calls, when `awaitIdle()` is called, then it completes without suspension — maps to FR-3.3 +- TC-047: `awaitIdle` suspends while editing — Given `beginEdit()` called once, when `awaitIdle()` is called in a separate coroutine, then it suspends until `endEdit()` is called — maps to FR-3.3 +- TC-048: Multiple concurrent edits — Given `beginEdit()` called twice, when one `endEdit()` is called, then `awaitIdle()` still suspends; after second `endEdit()`, it completes — maps to FR-3.3 +- TC-049: `isEditing` StateFlow reflects count — Given `beginEdit()` called, then `isEditing.value == true`; after `endEdit()`, `isEditing.value == false` — maps to FR-3.3 +- TC-050: `endEdit` below zero is clamped — Given no active edits, when `endEdit()` is called, then `_editingCount` stays at 0 (does not go negative) — maps to plan §2.5 + +### 2.5 GitConfig Model Validation + +**Source set:** `businessTest` + +- TC-051: `GitAuthType` covers all three types — Given `NONE`, `SSH_KEY`, `HTTPS_TOKEN` enum values exist, then they are correctly serialized/deserialized with `@Serializable` — maps to FR-5.1, FR-5.2 +- TC-052: `SyncState.Success` stores correct fields — Given `localCommitsMade=2`, `remoteCommitsMerged=3`, `lastSyncAt=1000L`, then `SyncState.Success` holds all values — maps to FR-6.1 +- TC-053: `HunkResolution` sealed class completeness — Given a `when` expression on `HunkResolution`, then all four branches (`Unresolved`, `AcceptLocal`, `AcceptRemote`, `Manual`) compile without `else` — maps to FR-4.3 + +### 2.6 DesktopSyncScheduler + +**Class:** `dev.stapler.stelekit.git.DesktopSyncScheduler` +**Source set:** `jvmTest` + +- TC-054: Timer fires at configured interval — Given a `TestScope` with `advanceTimeBy(intervalMs)`, when `schedule(1)` is called, then the callback is invoked after the interval elapses — maps to FR-2.4 +- TC-055: Timer fires repeatedly — Given `schedule(1)` and `advanceTimeBy(3 * intervalMs)`, then callback is invoked at least 3 times — maps to FR-2.4 +- TC-056: `cancel()` stops further firings — Given a running schedule, when `cancel()` is called and time is advanced, then no further callback invocations occur — maps to FR-2.4 +- TC-057: `cancel()` on unstarted scheduler is a no-op — Given `cancel()` called without prior `schedule()`, then no exception is thrown — maps to FR-2.4 + +--- + +## 3. Integration Tests (jvmTest) + +All integration tests use `LocalBareRepoFixture` and the real `JvmGitRepository` (JGit 7.x). No network calls; all remotes are `file://` URIs pointing to the bare repo in a temp directory. Run with `./gradlew jvmTest`. + +**Test class:** `dev.stapler.stelekit.git.JvmGitRepositoryIntegrationTest` + +- IT-001: `isGitRepo` true for valid clone — Given a cloned working directory from `LocalBareRepoFixture`, when `isGitRepo(workingDir)` is called, then returns `true` — maps to FR-1.1 +- IT-002: `isGitRepo` false for non-git directory — Given a plain temp directory with no `.git/` folder, when `isGitRepo(dir)` is called, then returns `false` — maps to FR-1.1 +- IT-003: Clone creates wiki subdirectory — Given `LocalBareRepoFixture` with a `wiki/` commit, when `clone(bareUri, localPath, auth=None, ...)` is called, then `wiki/` subdirectory exists in `localPath` — maps to FR-1.2 +- IT-004: Fetch detects new remote commits — Given a clone, when `LocalBareRepoFixture.commitFile("wiki/page.md", ...)` adds a commit to the bare repo and `fetch(config)` is called, then `FetchResult.hasRemoteChanges = true` and `remoteCommitCount >= 1` — maps to FR-2.1, FR-3.1 +- IT-005: Fetch reports no changes when up to date — Given a clone that is already up to date, when `fetch(config)` is called, then `FetchResult.hasRemoteChanges = false` — maps to FR-2.1 +- IT-006: Fast-forward merge succeeds — Given a clone behind by 1 commit (no local commits), when `merge(config)` is called after `fetch()`, then `MergeResult.hasConflicts = false` and the new file appears on disk — maps to FR-2.1 +- IT-007: Three-way merge with non-overlapping changes succeeds — Given two diverged branches that both modified different files in `wiki/`, when `merge(config)` is called, then `MergeResult.hasConflicts = false` and both files contain their respective changes — maps to FR-2.1 +- IT-008: Three-way merge with overlapping changes produces conflict — Given both local and remote edits to the same lines of `wiki/journal.md`, when `merge(config)` is called, then `MergeResult.hasConflicts = true` and `MergeResult.conflicts` contains the conflicted file — maps to FR-4.1 +- IT-009: Conflict markers written to disk — Given a conflicting merge, after `merge(config)` returns, then reading `wiki/journal.md` from disk contains `<<<<<<<`, `=======`, `>>>>>>>` markers — maps to FR-4.1, FR-4.2 +- IT-010: `ConflictMarkerDetector` guards GraphLoader after conflict — Given a conflicted file on disk, when `GraphLoader.parseAndSavePage()` is called with the conflicted content, then it returns early without storing marker content in the database (using existing `ConflictMarkerDetector` guard) — maps to FR-4.1, pitfall §3 +- IT-011: Push local commits to bare repo — Given a local commit exists in the working clone, when `push(config)` is called, then `LocalBareRepoFixture` bare repo contains the commit (verifiable via JGit `RevWalk`) — maps to FR-2.2 +- IT-012: Push is rejected on non-fast-forward — Given the bare repo has commits the local clone does not have (and local has diverging commits), when `push(config)` is called without first merging, then returns `Either.Left(PushFailed)` — maps to FR-2.2 +- IT-013: Auth failure produces AuthFailed error — Given a repo URL with intentionally wrong credentials (wrong password for HTTPS, nonexistent key for SSH), when `fetch(config)` is attempted, then returns `Either.Left(AuthFailed)` — maps to FR-5.1, FR-5.2, FR-6.2 +- IT-014: `stageSubdir` only stages wiki files — Given uncommitted changes in both `wiki/` and a root-level file, when `stageSubdir(config)` is called, then only `wiki/` changes appear in the staged index — maps to FR-2.1, plan §3.1 +- IT-015: `status` scoped to wiki subdir — Given modifications in `wiki/` and `src/` (outside wiki), when `status(config)` is called with `wikiSubdir = "wiki"`, then `GitStatus.modifiedFiles` contains only wiki files — maps to plan §6.6, pitfall §7 +- IT-016: `abortMerge` restores pre-merge state — Given a conflicted merge in progress, when `abortMerge(config)` is called, then `wiki/journal.md` is restored to the pre-merge HEAD version and `MERGE_HEAD` no longer exists — maps to plan §5.5 +- IT-017: `log` returns up to maxCount commits — Given a bare repo with 60 commits, when `log(config, maxCount = 50)` is called, then at most 50 `GitCommit` entries are returned — maps to FR-6.3, pitfall §7 +- IT-018: `hasDetachedHead` detection — Given a repo checked out at a specific commit (detached HEAD), when `hasDetachedHead(config)` is called, then returns `true` — maps to plan §6.1, pitfall §8 +- IT-019: Stale lock file is removed — Given a manually created `.git/index.lock` file older than 60 seconds, when `removeStaleLockFile(config)` is called, then the file is deleted — maps to plan §6.1, pitfall §8 +- IT-020: Offline / network unreachable returns graceful error — Given `FakeNetworkMonitor.isOnline = false` in `GitSyncService`, when `sync()` is called, then `DomainError.GitError.Offline` is returned and no JGit operations are attempted — maps to NFR-Offline +- IT-021: Full two-device sync simulation (Device A → Device B) — Given two working clones sharing a bare repo, when "Device A" commits and pushes a journal entry, then "Device B" calls `fetch()` + `merge()` and the entry appears in the merged wiki — maps to SC-2, FR-2.1 +- IT-022: Full conflict resolution E2E — Given both "devices" commit conflicting edits to the same journal file and Device B runs `sync()`, then `ConflictPending` state is produced; applying `AcceptRemote` via `ConflictResolver.applyResolutions()` + `markResolved()` + committing produces a valid git state with no conflict markers — maps to SC-4, FR-4.4 +- IT-023: File watcher suppression during merge — Given `GraphLoader` with watcher active and `beginGitMerge(paths)` called, when the merge rewrites files on disk, then `externalFileChanges` does NOT emit for the suppressed paths during the suppression window — maps to FR-3.1, pitfall §6 +- IT-024: File watcher resumes after `endGitMerge` — Given suppression active, when `endGitMerge()` is called and then an external write occurs, then `externalFileChanges` emits normally — maps to pitfall §6 +- IT-025: `wikiSubdir` detection from git clone — Given a bare repo where wiki files are in `notes/` subdirectory and the clone is attached with `wikiSubdir = "notes"`, then only `notes/` files are staged and fetched — maps to FR-1.3 + +--- + +## 4. UI Tests (jvmTest with Compose Testing) + +UI tests use `ComposeUITestBase` (extends `BlockHoundTestBase`, creates `ComposeTestRule`) following the existing pattern in `jvmTest/kotlin/dev/stapler/stelekit/ui/`. + +**Test class for conflict UI:** `dev.stapler.stelekit.ui.ConflictResolutionScreenTest` +**Test class for sync badge:** `dev.stapler.stelekit.ui.SyncStatusBadgeTest` +**Test class for setup screen:** `dev.stapler.stelekit.ui.GitSetupScreenTest` + +- UT-001: ConflictResolutionScreen renders two columns — Given `ConflictPending` state with one conflict file containing one hunk, when `ConflictResolutionScreen` is composed, then nodes with test tags `"mine-column"` and `"theirs-column"` are both displayed — maps to FR-4.2 +- UT-002: ConflictResolutionScreen shows local and remote lines — Given a hunk with `localLines = ["mine"]` and `remoteLines = ["theirs"]`, when rendered, then `"mine"` appears in the local column and `"theirs"` in the remote column — maps to FR-4.2 +- UT-003: Accept Remote button marks hunk resolved — Given a rendered hunk with `resolution = Unresolved`, when the user clicks "Accept Theirs", then the hunk card collapses with a checkmark and `resolution` transitions to `AcceptRemote` in the ViewModel — maps to FR-4.3 +- UT-004: Accept Local button marks hunk resolved — Given an unresolved hunk, when the user clicks "Accept Mine", then `resolution` transitions to `AcceptLocal` — maps to FR-4.3 +- UT-005: Finish Merge button disabled when unresolved hunks remain — Given a `ConflictPending` state with at least one unresolved hunk, when the screen is rendered, then the "Finish Merge" button has `isEnabled = false` — maps to FR-4.4 +- UT-006: Finish Merge button enabled when all hunks resolved — Given all hunks in all files have a non-Unresolved resolution, when the screen is rendered, then the "Finish Merge" button has `isEnabled = true` — maps to FR-4.4 +- UT-007: Sync badge shows commit count for MergeAvailable — Given `SyncState.MergeAvailable(3)` in ViewModel, when the sidebar is composed, then the badge displays text containing `"3"` — maps to FR-3.1, FR-6.1 +- UT-008: Sync badge shows spinner during Fetching — Given `SyncState.Fetching`, when sidebar is composed, then a loading/spinner indicator is visible — maps to FR-6.1 +- UT-009: Sync badge shows amber conflict indicator — Given `SyncState.ConflictPending(...)`, when sidebar is composed, then the badge contains conflict-indicating text or icon with amber semantic — maps to FR-6.1, FR-4.1 +- UT-010: Sync badge shows red error indicator — Given `SyncState.Error(AuthFailed(...))`, when sidebar is composed, then the badge shows an error state — maps to FR-6.2 +- UT-011: Manual sync button triggers sync — Given `StelekitViewModel` with `FakeGitRepository`, when the sync icon button in the sidebar header is clicked, then `viewModel.triggerSync()` is called (observable via spy or state change) — maps to FR-2.5 +- UT-012: GitSetupScreen URL field validation — Given `GitSetupScreen` is rendered, when an invalid URL is entered (e.g., `"not a url"`), then a validation error message is displayed — maps to FR-1.2 +- UT-013: GitSetupScreen shows auth method picker — Given `GitSetupScreen` step 3 is active, when rendered, then radio buttons or dropdown for "SSH Key" and "HTTPS Token" and "None" are present — maps to FR-5.1, FR-5.2, FR-5.3 +- UT-014: ConflictResolutionScreen file list shows all conflicted files — Given `ConflictPending` with 3 conflicted files, when the screen is rendered, then all 3 file names appear in the file list — maps to FR-4.1 +- UT-015: ConflictResolutionScreen per-file progress — Given 2 hunks in a file, 1 resolved, when rendered, then a progress indicator shows `"1 / 2"` or equivalent — maps to FR-4.4 + +--- + +## 5. Acceptance Tests (manual or E2E) + +These scenarios map directly to the five Success Criteria in `requirements.md`. They require physical or emulated devices and cannot run in `./gradlew jvmTest`. + +### SC-1: Open a Termux-cloned repo on Android without leaving the app + +**Setup:** +1. On an Android device, use Termux to clone a test git repo: `git clone https://github.com/your/wiki.git ~/wiki` +2. Open SteleKit on Android. + +**Steps:** +1. In SteleKit, open the Git Setup screen (Settings > Git Sync > Set Up). +2. Choose "Use existing clone". +3. Select the `wiki/` folder via the folder picker (or enter the path manually). +4. Set subdirectory to the wiki folder within the repo (if using root, leave blank). +5. Choose "None" for auth (for public repos) or "SSH Key" and select the key via the file picker. +6. Tap "Test Connection" — verify success message appears. +7. Tap "Save". + +**Expected outcome:** SteleKit opens the wiki and shows pages. The sync status badge is visible. No terminal commands were required after the initial Termux clone. Maps to SC-1. + +### SC-2: Second machine push detected within poll interval + +**Setup:** +- Machine A: SteleKit running with git sync configured, poll interval = 5 minutes. +- Machine B: Clone of same repo in a terminal. + +**Steps:** +1. On Machine B, create a new journal entry file: `echo "- Machine B entry" > wiki/journals/2026-05-01.md && git add . && git commit -m "B entry" && git push` +2. On Machine A, wait up to 5 minutes (or trigger manual fetch by tapping sync icon). + +**Expected outcome:** Within the poll interval, SteleKit on Machine A shows the sync badge with "↓ 1 new commit". No notification or alert is required; the badge update is sufficient. Maps to SC-2. + +### SC-3: Draft preserved when remote push arrives while editing + +**Setup:** +- Machine A: SteleKit running with git sync configured. +- Machine B: Another terminal with a clone of the same repo. + +**Steps:** +1. On Machine A, open a journal page and start typing a block. Do NOT tap away from the block — keep it in active editing state. +2. On Machine B, push a new commit to the repo. +3. On Machine A, wait for the poll interval to fire (or trigger a manual fetch). + +**Expected outcome:** +- SteleKit on Machine A shows the "↓ N new commits" badge but does NOT apply the merge automatically. +- The user's draft text in the block is NOT lost. +- No merge occurs until the user explicitly taps the sync icon and confirms. +Maps to SC-3, FR-3.1, FR-3.2, FR-3.3. + +### SC-4: Conflicting journal entries produce side-by-side diff; resolved state is valid git + +**Setup:** +- Two working clones sharing a bare repo. +- Both have edited the same journal file with different content (e.g., line 3 differs). + +**Steps:** +1. Commit and push from both clones (second push will be rejected; first succeeds). +2. Open SteleKit on the second machine and trigger a sync. +3. SteleKit shows the Conflict Resolution screen with the conflicted file. +4. Verify the left column shows "Mine" content and right column shows "Theirs" content. +5. Click "Accept Theirs" for the conflicted hunk. +6. Click "Finish Merge". + +**Expected outcome:** +- After "Finish Merge", the repo is in a clean merged state (no conflict markers in files, `git status` clean). +- The accepted content is present in the file on disk. +- The database contains the resolved content (no conflict markers visible in the SteleKit UI). +Maps to SC-4, FR-4.1–FR-4.4. + +### SC-5: Zero terminal commands — full "remote has new commits → merged and synced" flow + +**Setup:** +- One device with SteleKit running with git sync configured. +- A second machine has pushed a non-conflicting commit to the shared repo. + +**Steps:** +1. Open SteleKit — observe sync badge shows "↓ 1" (detected on app launch per FR-2.3). +2. Tap the manual sync button in the sidebar header. +3. Observe status: badge transitions through spinner (Fetching → Merging → Pushing) to green checkmark (Success). + +**Expected outcome:** +- No terminal was opened at any point. +- The remote commit's content is now visible as a page in SteleKit. +- Last sync time is updated in the status badge tooltip / long-press. +Maps to SC-5, FR-2.1, FR-2.5, FR-6.1. + +--- + +## 6. Requirement Coverage Matrix + +| Requirement | Test IDs | Coverage | +|---|---|---| +| FR-1.1 (attach existing clone) | TC-001, IT-001, IT-002, AT-SC-1 | ✅ | +| FR-1.2 (clone from URL in-app) | IT-003, UT-012, AT-SC-1 | ✅ | +| FR-1.3 (subdirectory selection) | TC-044, TC-045, IT-025, AT-SC-1 | ✅ | +| FR-1.4 (persist config per-graph) | TC-036–TC-043 | ✅ | +| FR-2.1 (pull: fetch+merge) | TC-002, TC-003, TC-004, IT-004–IT-009, IT-021 | ✅ | +| FR-2.2 (push local changes) | TC-005, IT-011, IT-012 | ✅ | +| FR-2.3 (fetch on launch) | TC-014, TC-003, AT-SC-5 | ✅ | +| FR-2.4 (background polling) | TC-015, TC-016, TC-054–TC-057 | ✅ | +| FR-2.5 (manual sync button) | UT-011, AT-SC-5 | ✅ | +| FR-3.1 (show badge, no auto-merge) | TC-003, IT-004, UT-007, AT-SC-2, AT-SC-3 | ✅ | +| FR-3.2 (explicit trigger required) | TC-003, TC-014, AT-SC-3 | ✅ | +| FR-3.3 (EditLock during active edit) | TC-007, TC-008, TC-046–TC-050, AT-SC-3 | ✅ | +| FR-3.4 (commit per session) | TC-001, TC-002, TC-018 | ✅ | +| FR-4.1 (conflict resolution screen) | IT-008, IT-009, UT-001, UT-009, UT-014 | ✅ | +| FR-4.2 (side-by-side diff) | TC-021–TC-024, UT-001, UT-002, UT-014, AT-SC-4 | ✅ | +| FR-4.3 (accept local/remote/edit) | TC-026–TC-030, UT-003, UT-004, AT-SC-4 | ✅ | +| FR-4.4 (confirm completes merge) | TC-029, TC-034, IT-022, UT-005, UT-006, AT-SC-4 | ✅ | +| FR-4.5 (persistence of partial resolution) | TC-038, IT-022 | ✅ | +| FR-5.1 (SSH key auth) | TC-013, TC-042, IT-013, UT-013, AT-SC-1 | ✅ | +| FR-5.2 (HTTPS + PAT auth) | TC-010, TC-043, IT-013, UT-013 | ✅ | +| FR-5.3 (auth configured per repo) | TC-036–TC-043 | ✅ | +| FR-5.4 (SSH key path on Android) | TC-042, AT-SC-1 | ✅ | +| FR-6.1 (sync status display) | UT-007, UT-008, UT-009, AT-SC-2, AT-SC-5 | ✅ | +| FR-6.2 (actionable error notifications) | TC-010, IT-013, UT-010 | ✅ | +| FR-6.3 (git log viewable) | IT-017 | ✅ | +| NFR-Platform scope | IT-001–IT-025 (JVM), AT-SC-1 (Android) | Partial (iOS stub only) | +| NFR-Safety (no silent overwrite) | TC-007–TC-008, TC-018–TC-020, IT-023, AT-SC-3 | ✅ | +| NFR-Performance (off main thread) | TC-009, IT-020, TC-054 | ✅ | +| NFR-Offline (graceful skip) | TC-009, TC-013, IT-020 | ✅ | + +--- + +## 7. Risk-Based Test Priorities + +Ordered from highest to lowest risk based on `research/pitfalls.md`: + +### Priority 1: Edit loss during merge (Pitfall §2 — Race condition) +The most dangerous failure: user's unsaved edit is overwritten by a git merge. Tests: TC-007, TC-008, TC-018, TC-019, TC-020, IT-023, IT-024, AT-SC-3. All must pass before shipping. + +### Priority 2: Conflict markers in database (Pitfall §3 — Corrupted block tree) +`ConflictMarkerDetector` guard must be verified to block markers from reaching SQLDelight. Tests: IT-009, IT-010, TC-021–TC-035. Existing `ConflictMarkerDetectorTest` covers detection; IT-010 verifies the integration with `parseAndSavePage`. + +### Priority 3: File watcher false DiskConflict events (Pitfall §6 — GraphLoader interaction) +`beginGitMerge` / `endGitMerge` suppression must work correctly. Tests: TC-019, TC-020, IT-023, IT-024. Without this, every sync produces spurious "File changed externally" dialogs. + +### Priority 4: SSH on Android — modern key support (Pitfall §4 — JSch) +`AndroidGitRepository` must use `mwiede/jsch` and succeed with ED25519 keys. Tests: TC-042, IT-013. Manual test required against GitHub with an ED25519 key. Automated test with a local SSH server (e.g., Apache MINA SSHD as a test server) is preferred but may be deferred. +**Manual only if local SSH server setup is impractical in CI.** + +### Priority 5: Auth failure surfacing (Pitfall §4 + FR-6.2) +Raw SSH/HTTPS exceptions must not surface to the user. Tests: TC-010, IT-013, UT-010. The error mapping layer (plan §6.4) must translate `TransportException` / `NotAuthorizedException` to `DomainError.GitError.AuthFailed`. + +### Priority 6: Stale lock file and detached HEAD (Pitfall §8) +On-launch safety checks must prevent cryptic "Unable to lock index" errors. Tests: TC-011, TC-012, IT-018, IT-019. These are startup-path checks; failure would block all git operations after a forced kill. + +### Priority 7: Large repo `git status` performance (Pitfall §7) +`StatusCommand.addPath(wikiSubdir)` must scope the operation. Test: IT-015. Without this, sync on a monorepo could hang the UI thread if the dispatcher is misconfigured. +**Manual performance test recommended with a repo containing >10,000 files; automated scoping correctness covered by IT-015.** + +### Priority 8: iOS background execution limits (Pitfall §1 — iOS BGTaskScheduler) +iOS ships as a stub in v1; background scheduling is `BgTaskSyncScheduler`. The on-foreground-launch sync path (FR-2.3) is the primary iOS sync path and must be tested manually on a physical iOS device. +**Manual only — iOS background scheduling cannot be reliably automated in jvmTest.** + +### Priority 9: Android battery saver / Doze mode (Pitfall §1 — Android) +`WorkManagerSyncScheduler` handles Doze via `NetworkType.CONNECTED` constraint. Unit test: TC-054–TC-057 cover the `DesktopSyncScheduler` coroutine timer. Android WorkManager behavior under Doze requires a physical device test. +**Manual only for OEM battery saver testing; automated for interval logic.** + +### Priority 10: CRLF line endings / `.gitattributes` (Pitfall §8 — CRLF) +Validation (plan §6.2) should warn if `.gitattributes` with `* text=auto` is missing. No dedicated automated test beyond the integration test that verifies the warning appears (included in IT-022 scenario notes). +**Manual only for cross-platform line ending verification.** diff --git a/project_plans/git-integration/requirements.md b/project_plans/git-integration/requirements.md new file mode 100644 index 00000000..b66a4413 --- /dev/null +++ b/project_plans/git-integration/requirements.md @@ -0,0 +1,101 @@ +# Git Integration — Requirements + +## Problem Statement + +SteleKit users maintain a personal wiki in a git repository shared across multiple machines (desktop + mobile). Currently, syncing requires leaving the app to run git commands in a terminal (e.g., Termux on Android). When two machines both edit journal entries, merge conflicts must be resolved outside the app. The goal is to bring git operations — fetch, merge, commit, push — directly into SteleKit's UI so users never need to leave the app to stay in sync. + +## Users & Context + +- Primary user: single person, multi-device (desktop + Android + iOS) +- Wiki is stored as Markdown files in a **subdirectory** of a git repository (not the repo root) +- On Android, the repo is currently cloned via Termux; the wiki subdirectory is opened separately +- Other machines push journal entries independently; conflicts arise when two machines write the same journal file + +--- + +## Functional Requirements + +### FR-1: Repository Attachment + +1. The user can point SteleKit at a **folder that is already a git clone** — no in-app clone needed initially +2. The user can **clone a repo from a URL** within the app (HTTPS or SSH remote URL) +3. After a repo is attached or cloned, the user selects the **subdirectory** within that repo to use as the wiki root +4. The repo URL, branch, subdirectory path, and auth method are persisted per-graph + +### FR-2: Two-Way Sync + +1. SteleKit can **pull** (fetch + merge) from the configured remote +2. SteleKit can **push** committed local changes to the remote +3. On **app launch**, SteleKit checks for remote changes before the user can start editing (fetch-only, non-blocking but presented clearly in the UI) +4. **Background polling** checks the remote at a user-configurable interval (default: 5 minutes) while the app is open +5. A **manual Sync button** is always available to trigger an immediate fetch+merge+push cycle + +### FR-3: Safe Sync — Never Clobber Active Edits + +1. When new remote commits are detected, SteleKit shows a **notification/badge** ("N new commits available") but does **not** auto-apply them +2. The user must explicitly trigger a sync to apply remote changes +3. If the user is actively editing a block when a sync is triggered, the sync **waits** until the current editing session ends (block loses focus or user saves) +4. The commit-per-session model: changes are staged and committed as a batch at the end of a session or when the user manually triggers sync, not on every individual save + +### FR-4: Conflict Resolution UI + +1. When a merge produces conflicts, SteleKit surfaces a **conflict resolution screen** +2. Conflicted blocks are presented as a **side-by-side diff**: left = local version, right = remote version +3. For each conflict, the user can: + - Accept local (keep mine) + - Accept remote (take theirs) + - Edit a merged result manually +4. After resolving all conflicts, the user confirms and SteleKit completes the merge commit +5. Resolution state is persisted — if the user closes the conflict screen mid-way, their choices so far are remembered + +### FR-5: Authentication + +1. Support **SSH key** authentication (uses the key already configured on the device) +2. Support **HTTPS + personal access token** (token stored in the system keychain / secure storage) +3. Auth method is configured per-repo at setup time +4. On Android, SSH key path must be configurable (default: `~/.ssh/id_rsa` or Termux equivalent) + +### FR-6: Status Visibility + +1. The app shows git sync status prominently: last sync time, pending local commits, pending remote commits +2. Sync errors (network failure, auth failure, conflict) surface as actionable notifications, not silent failures +3. The git log for the current repo is viewable in-app (last N commits, optional) + +--- + +## Non-Functional Requirements + +- **Platform scope**: Android, Desktop (JVM), iOS — all three platforms for initial release +- **Safety**: A sync operation must never silently overwrite the user's unsaved or in-progress edits +- **Performance**: Fetch operations run off the main thread; UI remains responsive during network I/O +- **Offline**: If no network is available, sync is gracefully skipped and the user is notified; the app functions fully offline + +--- + +## Out of Scope (v1) + +- Branch management (creating/switching branches from within the app) +- Rebase workflow (merge-only for conflict resolution) +- Git blame / line history viewer +- Support for multiple remotes per repo +- Submodule support +- Automatic resolution of non-overlapping same-file changes (three-way merge is used; SteleKit does not write its own merge strategy) + +--- + +## Success Criteria + +1. User can open a Termux-cloned repo in SteleKit (Android) and sync without leaving the app +2. When a second machine pushes a journal entry, SteleKit detects it within the configured poll interval and notifies the user +3. If the user was editing when a push arrived, their draft is not lost and no merge happens until they explicitly trigger it +4. Conflicting journal entries surface a side-by-side diff; user resolves and the repo ends in a valid merged state +5. The workflow from "remote has new commits" → "merged and synced" requires zero terminal commands + +--- + +## Open Questions / Risks + +- **JGit vs libgit2 vs system git**: KMP git library choice is a critical early decision. JGit is JVM-only; libgit2 (via Kotlin/Native or JNI) could cover all platforms; shelling out to system `git` is simplest but unavailable on iOS. +- **SSH agent forwarding on Android/iOS**: Mobile SSH key access is non-trivial; may need in-app key file selection. +- **Subdirectory GraphLoader alignment**: `GraphLoader` currently opens a directory as the graph root; attaching a git repo at a parent path while the wiki lives in a child directory needs care to avoid watching unrelated files. +- **Three-way merge semantics at block level**: Git operates on lines; SteleKit models blocks. The conflict resolution UI needs to map git conflict hunks back to block boundaries. diff --git a/project_plans/git-integration/research/architecture.md b/project_plans/git-integration/research/architecture.md new file mode 100644 index 00000000..2a684e3d --- /dev/null +++ b/project_plans/git-integration/research/architecture.md @@ -0,0 +1,547 @@ +# Architecture Research — Git Integration for SteleKit KMP + +_Research date: 2025-05-02_ + +--- + +## 1. Background Sync in KMP + +### The Core Problem + +Android and iOS have fundamentally different background execution models. KMP provides shared business logic but cannot unify the platform scheduling APIs. The correct pattern is: + +``` +commonMain + └─ GitSyncScheduler (expect interface) +androidMain + └─ GitSyncScheduler actual: WorkManager +iosMain + └─ GitSyncScheduler actual: BGTaskScheduler +jvmMain (Desktop) + └─ GitSyncScheduler actual: CoroutineScope periodic timer +``` + +### Android: WorkManager + +**Recommended choice for Android background git sync.** + +```kotlin +// AndroidGitSyncWorker.kt (androidMain) +class GitSyncWorker(context: Context, params: WorkerParameters) + : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val result = gitSyncService.syncAll() + return result.fold( + ifLeft = { Result.retry() }, + ifRight = { Result.success() } + ) + } +} + +// Schedule periodic work +val syncRequest = PeriodicWorkRequestBuilder( + repeatInterval = 5, + repeatIntervalTimeUnit = TimeUnit.MINUTES +) + .setConstraints(Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) + .build() + +WorkManager.getInstance(context).enqueueUniquePeriodicWork( + "git_sync", + ExistingPeriodicWorkPolicy.KEEP, + syncRequest +) +``` + +**Why WorkManager over foreground service:** +- Respects Doze mode and App Standby automatically +- Survives process death and device reboot +- Network constraint prevents battery drain from offline retry loops +- No need for persistent notification (foreground service requirement) + +**WorkManager minimum period:** 15 minutes on Android (system enforced). If the user sets a 5-minute interval, use an in-process coroutine timer when the app is in the foreground, and WorkManager for background. + +### iOS: BGTaskScheduler + +**BGProcessingTask** (for longer sync operations, ~minutes) vs **BGAppRefreshTask** (for short checks, ~30 seconds). + +For git sync, `BGProcessingTask` is appropriate since fetch+merge can take multiple seconds. + +```swift +// In AppDelegate (iosMain platform code) +BGTaskScheduler.shared.register( + forTaskWithIdentifier: "dev.stapler.stelekit.gitsync", + using: nil +) { task in + task.expirationHandler = { task.setTaskCompleted(success: false) } + // Call shared KMP sync logic + sharedGitSyncService.syncAll { success in + task.setTaskCompleted(success: success) + // Re-schedule for next run + scheduleNextSync() + } +} +``` + +**iOS constraints:** +- iOS controls actual execution timing; the developer cannot guarantee frequency +- The system may not run background tasks if the device is in Low Power Mode +- Background tasks have a finite time budget (~1-2 minutes for processing tasks) +- Must re-schedule after each execution (does not automatically repeat) + +**Practical implication for SteleKit:** On iOS, background sync is best-effort. The app must sync on foreground launch (FR-2.3) as the primary sync point, with background sync as a supplementary mechanism. + +### Desktop JVM: Coroutine Supervisor Scope + +No OS scheduling API needed. Use a long-running coroutine with periodic delay: + +```kotlin +// DesktopGitSyncScheduler.kt (jvmMain) +class DesktopGitSyncScheduler( + private val syncService: GitSyncService, + private val intervalMinutes: Long = 5 +) { + private val scope = CoroutineScope( + SupervisorJob() + PlatformDispatcher.IO + ) + + fun start() { + scope.launch { + while (isActive) { + delay(intervalMinutes.minutes) + syncService.syncAll() + .onLeft { error -> logger.warn("Sync failed: ${error.message}") } + } + } + } + + fun stop() { scope.cancel() } +} +``` + +**Note:** Never use `rememberCoroutineScope()` for this — the scheduler must outlive any composable. It belongs in GraphManager alongside the graph's CoroutineScope, per SteleKit's existing pattern. + +### Shared KMP Interface + +```kotlin +// commonMain +interface GitSyncScheduler { + fun startPeriodicSync(intervalMinutes: Int) + fun stopPeriodicSync() + fun triggerImmediateSync(): Flow +} + +// GitSyncService.kt (commonMain) — used by all platform schedulers +class GitSyncService( + private val repo: GitRepository, // expect/actual + private val graphWriter: GraphWriter, + private val editingState: EditingState +) { + suspend fun syncAll(): Either = either { + ensureNotEditing().bind() // FR-3.3 + val localChanges = commitLocalChanges().bind() // FR-3.4 + val fetchResult = repo.fetch().bind() // FR-2.1 + if (fetchResult.hasRemoteChanges) { + val mergeResult = repo.merge().bind() // FR-2.1 + if (mergeResult.hasConflicts) { + raise(DomainError.GitError.MergeConflict(mergeResult.conflicts)) + } + reloadChangedFiles(mergeResult.changedFiles).bind() + } + repo.push().bind() // FR-2.2 + SyncResult.Success(localChanges, fetchResult) + } +} +``` + +--- + +## 2. Conflict Detection and Three-Way Merge at the Block Level + +### The Challenge + +Git operates on lines of text. Markdown-based outliners like SteleKit operate on logical blocks (paragraphs, headings, bullets, tasks). A git merge conflict in a `.md` file produces line-level conflict markers. SteleKit's block tree is built from those lines. The question is: how do you map conflict hunks to blocks? + +### Approach A: File-Level Conflicts → Block Resolution Screen + +The simplest approach that aligns with git's model: + +1. After `git merge` produces a conflict, read the conflicted file as raw text +2. Parse conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) to identify conflict regions +3. Map each conflict region to the block(s) it spans using SteleKit's parser +4. Present a side-by-side diff at the block level (not line level) + +``` +Raw conflict: +<<<<<<< HEAD +- TODO: finish report +- TODO: call Alice +======= +- TODO: finish report +- TODO: call Bob +>>>>>>> origin/main + +Mapped to blocks: + Block "TODO: call Alice" (local) vs Block "TODO: call Bob" (remote) +``` + +**Parser note:** The SteleKit outliner already parses Markdown into blocks. The conflict resolver can use the same parser on the `HEAD` and `FETCH_HEAD` versions of the file to produce two block trees, then diff them at the block level using `kotlin-multiplatform-diff`. + +### Approach B: Pre-merge Semantic Diff + +Before running `git merge`, compute a semantic diff at the block level: +1. Parse current HEAD file into block tree +2. Parse FETCH_HEAD file into block tree +3. Use diff-at-block-level to find changed blocks +4. Present diff pre-merge for user approval + +**Trade-off:** More complex, but avoids the conflict marker injection problem entirely. Closer to what Notion/Linear do for collaborative editing. + +### Recommendation + +**Approach A** for the first version — it aligns with git semantics, requires less novel code, and handles the actual conflict representation. The block-level display is an enhancement over raw line-level diff. + +### Block-Level Diff with `kotlin-multiplatform-diff` + +**Library:** https://github.com/petertrr/kotlin-multiplatform-diff +**Version:** 1.3.0 (December 2025) +**Targets:** JVM, JS, WebAssembly, Native (all KMP targets) +**License:** Apache 2.0 +**Based on:** java-diff-utils 4.15 +**Algorithms:** Myers diff (default), HistogramDiff + +```kotlin +// Diff at block level using kotlin-multiplatform-diff +import io.github.petertrr.diffutils.diff + +fun diffBlocks(localBlocks: List, remoteBlocks: List): Patch { + return diff(localBlocks, remoteBlocks) { a, b -> a.content == b.content } +} +``` + +**Alternative:** `dev.gitlive:kotlin-diff-utils` (GitLive fork of java-diff-utils, pure Kotlin). Version 4.1.4 — last updated 2019, lower maintenance activity. Prefer `petertrr/kotlin-multiplatform-diff` (v1.3.0, active). + +--- + +## 3. Subdirectory Git Repos + +SteleKit's requirement: the wiki lives in a **subdirectory** of a git repo (e.g., `~/repos/my-notes/wiki/`). The graph root is `wiki/`, not the repo root. + +### Option A: No Sparse Checkout — Just Open the Subdirectory + +**Simplest approach.** Clone the full repo. Pass the subdirectory path as the wiki root to `GraphLoader`. Git operations (`fetch`, `merge`, `push`) are run at the repo root level. + +```kotlin +data class GraphConfig( + val repoRoot: Path, // e.g., ~/repos/my-notes + val wikiSubdir: String, // e.g., "wiki" + val wikiRoot: Path get() = repoRoot.resolve(wikiSubdir) +) +``` + +**GraphLoader alignment:** `GraphLoader` already accepts a root path. No changes needed — just configure it to point to `wikiRoot` instead of the repo root. + +**Commit scope:** When committing local changes, stage only files under `wikiSubdir`: +```bash +git -C add / +git -C commit -m "..." +``` + +**Pros:** Simple. No git feature dependencies. Works with all git versions. +**Cons:** Non-wiki files in the repo are cloned (may be large for mixed repos). Status checks include all repo files. + +### Option B: Sparse Checkout + +Git sparse checkout populates only the specified directories in the working tree: + +```bash +git clone --no-checkout +git -C sparse-checkout set +git -C checkout +``` + +**Cone mode** (Git 2.27+) is dramatically faster for large repos. It limits to entire directories (not file globs), making pattern matching O(n) instead of O(n²). + +**JGit sparse checkout support:** JGit has limited native sparse checkout support. The `git sparse-checkout` command is not fully implemented in JGit as of 7.x. Sparse checkout via JGit would require shelling out or manipulating `.git/config` and `.git/info/sparse-checkout` directly. + +**Recommendation for SteleKit:** Use **Option A** (no sparse checkout) for the first version. The use case is a personal wiki, not a giant monorepo. If the repo has thousands of non-wiki files, document sparse checkout as an advanced configuration option. + +### Option C: Partial Clone + +`git clone --filter=blob:none` defers downloading file blobs until needed. Only metadata is cloned initially. This can reduce clone time for large repos significantly. + +**Relevance:** If the git repo contains large binary files (images, PDFs) outside the wiki, partial clone + sparse checkout together would be valuable. Not needed for a wiki-focused repo. + +--- + +## 4. Compose Multiplatform Diff UI + +### Library Recommendation: `kotlin-multiplatform-diff` + +**GitHub:** https://github.com/petertrr/kotlin-multiplatform-diff +**Version:** 1.3.0 (December 2025) +**Supports:** JVM 1.8+, JS (browser + Node.js), WebAssembly, all Native targets including iOS + +The library computes diffs (insertion/deletion operations) between two sequences. It does NOT render UI — the Compose rendering is SteleKit's responsibility. + +### Compose Rendering Pattern + +Use `AnnotatedString` with `SpanStyle` to highlight changes: + +```kotlin +@Composable +fun DiffView( + localLines: List, + remoteLines: List +) { + val patch = remember(localLines, remoteLines) { + diff(localLines, remoteLines) + } + + Row { + // Left column: local version + LazyColumn(modifier = Modifier.weight(1f)) { + items(buildAnnotatedDiff(localLines, patch, side = LOCAL)) { line -> + DiffLine(line) + } + } + // Right column: remote version + LazyColumn(modifier = Modifier.weight(1f)) { + items(buildAnnotatedDiff(remoteLines, patch, side = REMOTE)) { line -> + DiffLine(line) + } + } + } +} + +@Composable +fun DiffLine(line: DiffAnnotatedLine) { + Text( + text = line.content, + modifier = Modifier.background( + when (line.type) { + ADDED -> Color(0xFF1B4332).copy(alpha = 0.3f) + REMOVED -> Color(0xFF7B2D30).copy(alpha = 0.3f) + UNCHANGED -> Color.Transparent + } + ) + ) +} +``` + +### Synchronizing Scroll Position + +For side-by-side diff, the two `LazyColumn` instances need synchronized scroll. Use `LazyListState` with a custom `derivedStateOf` bridge: + +```kotlin +val localScrollState = rememberLazyListState() +val remoteScrollState = rememberLazyListState() + +LaunchedEffect(localScrollState.firstVisibleItemIndex) { + remoteScrollState.scrollToItem( + localScrollState.firstVisibleItemIndex, + localScrollState.firstVisibleItemScrollOffset + ) +} +``` + +### Conflict Resolution Controls per Chunk + +For each conflict chunk, render accept-local / accept-remote / edit-manually controls: + +```kotlin +@Composable +fun ConflictChunk( + conflict: ConflictHunk, + onAcceptLocal: () -> Unit, + onAcceptRemote: () -> Unit, + onEdit: () -> Unit +) { + Column { + Row { + Button(onClick = onAcceptLocal) { Text("Accept Local") } + Button(onClick = onAcceptRemote) { Text("Accept Remote") } + OutlinedButton(onClick = onEdit) { Text("Edit") } + } + DiffChunkView(conflict.localLines, conflict.remoteLines) + } +} +``` + +### Alternative Libraries + +- **`dev.gitlive:kotlin-diff-utils` (GitLive):** Pure Kotlin fork of java-diff-utils. Version 4.1.4, last updated 2019 — less maintained than `petertrr`. Uses Myers and Histogram algorithms. Avoid due to age. +- **`com.github.andrewbailey:difference`:** Kotlin Multiplatform (JVM, JS, Native). Simpler API, list-based only. Limited to insert/delete, no replace. Could work for block-level diff. + +**Recommendation:** Use `io.github.petertrr:kotlin-multiplatform-diff:1.3.0`. It is the most actively maintained true-KMP diff library with full platform coverage. + +--- + +## 5. KMP Coroutine Architecture for GitSyncService + +### Integrating with Existing SteleKit Architecture + +The `GitSyncService` should be a peer of `GraphLoader` and `GraphWriter`, owned by `GraphManager`: + +``` +GraphManager + ├── GraphLoader (existing) + ├── GraphWriter (existing) + ├── RepositorySet (existing) + ├── DatabaseWriteActor (existing) + └── GitSyncService (new) + ├── GitRepository (expect/actual) + ├── GitSyncScheduler (expect/actual) + └── ConflictResolver (commonMain) +``` + +### Arrow Either Integration + +Follow the existing SteleKit pattern exactly: + +```kotlin +// DomainError additions (commonMain) +sealed class DomainError { + // existing subclasses... + + sealed class GitError : DomainError() { + data class CloneFailed(val reason: String) : GitError() + data class FetchFailed(val reason: String) : GitError() + data class MergeConflict(val conflicts: List) : GitError() + data class PushFailed(val reason: String) : GitError() + data class AuthFailed(val reason: String) : GitError() + data object NotAGitRepo : GitError() + data object Offline : GitError() + } +} +``` + +```kotlin +// GitSyncService.kt (commonMain) +class GitSyncService( + private val repo: GitRepository, + private val graphLoader: GraphLoader, + private val graphWriter: GraphWriter, + private val editLock: EditLock +) { + private val _syncState = MutableStateFlow(SyncState.Idle) + val syncState: StateFlow = _syncState.asStateFlow() + + suspend fun sync(): Either = + withContext(PlatformDispatcher.IO) { + either { + // 1. Check network availability + ensure(networkMonitor.isOnline) { DomainError.GitError.Offline } + + // 2. Wait for edit lock to clear (FR-3.3) + editLock.awaitIdle() + + // 3. Commit local changes + val localCommit = commitLocalChanges().bind() + + // 4. Fetch remote + _syncState.value = SyncState.Fetching + val fetchResult = repo.fetch().bind() + + // 5. Check for conflicts before merge + if (fetchResult.hasRemoteChanges) { + _syncState.value = SyncState.Merging + val mergeResult = repo.merge().bind() + + ensure(!mergeResult.hasConflicts) { + DomainError.GitError.MergeConflict(mergeResult.conflicts) + } + + // 6. Reload changed files into repositories + graphLoader.reloadFiles(mergeResult.changedFiles).bind() + } + + // 7. Push local commits + if (localCommit.hasChanges) { + _syncState.value = SyncState.Pushing + repo.push().bind() + } + + _syncState.value = SyncState.Idle + SyncResult(localCommit, fetchResult) + } + } +} +``` + +### Dispatcher Usage + +Follow SteleKit's existing dispatcher matrix: + +| Operation | Dispatcher | +|---|---| +| Git fetch / push / merge (network I/O) | `PlatformDispatcher.IO` | +| File read/write during commit | `PlatformDispatcher.IO` | +| Conflict file parsing | `PlatformDispatcher.Default` (CPU) | +| DB writes after file reload | `PlatformDispatcher.DB` via `DatabaseWriteActor` | + +### EditLock Design + +```kotlin +// commonMain — wraps the existing editing state +class EditLock { + private val _editingCount = MutableStateFlow(0) + + fun beginEdit() { _editingCount.update { it + 1 } } + fun endEdit() { _editingCount.update { (it - 1).coerceAtLeast(0) } } + + // Suspend until no blocks are in edit mode + suspend fun awaitIdle() { + _editingCount.first { it == 0 } + } + + val isEditing: StateFlow = + _editingCount.map { it > 0 }.stateIn(scope, SharingStarted.Eagerly, false) +} +``` + +`BlockEditor` composable calls `editLock.beginEdit()` on focus and `editLock.endEdit()` on blur (or after the debounced save in `BlockStateManager`). + +### Conflict State Machine + +```kotlin +sealed class SyncState { + object Idle : SyncState() + object Fetching : SyncState() + object Merging : SyncState() + object Pushing : SyncState() + data class ConflictPending(val conflicts: List) : SyncState() + data class Error(val error: DomainError.GitError) : SyncState() + object Success : SyncState() +} +``` + +The `StelekitViewModel` observes `syncState` and routes to the conflict resolution screen when `ConflictPending` is emitted. + +--- + +## 6. Open Questions / Unresolved Items + +- **UNRESOLVED:** How does `GraphLoader.externalFileChanges` (the file watcher SharedFlow) interact with a git merge rewriting multiple files? Need to ensure the file watcher events triggered by a git merge are suppressed or coalesced, not processed as "external edits". See pitfalls.md. +- **UNRESOLVED:** JGit's `git merge` API — does it expose the conflict file list programmatically, or must we parse `.git/MERGE_HEAD` and `git status` output? +- **UNRESOLVED:** kgit2's API surface for merge operations — needs hands-on evaluation. +- **UNRESOLVED:** How to handle a merge that succeeds but produces a file that can no longer be parsed by the SteleKit Markdown parser (e.g., deeply nested conflict markers from a previous unresolved merge). Need a parser fallback mode. + +--- + +## Sources + +- [kotlin-multiplatform-diff](https://github.com/petertrr/kotlin-multiplatform-diff) +- [Background Sync KMP — WorkManager + iOS](https://medium.com/@ignatiah.x/background-sync-in-kotlin-multiplatform-workmanager-android-background-tasks-ios-1f92ad56d84b) +- [Cross-Platform Background Sync with KMP](https://medium.com/@kmpbits/sleeping-but-working-cross-platform-background-sync-with-kmp-70811e1dbd90) +- [KMP WorkManager: Enterprise-Grade Background Tasks](https://dev.to/brewkits/kmp-workmanager-enterprise-grade-background-tasks-for-kotlin-multiplatform-3cl2) +- [Arrow — Working with Typed Errors](https://arrow-kt.io/learn/typed-errors/working-with-typed-errors/) +- [Arrow — Either and Ior](https://arrow-kt.io/learn/typed-errors/wrappers/either-and-ior/) +- [Git Sparse Checkout Documentation](https://git-scm.com/docs/git-sparse-checkout) +- [GitHub Blog — Bring Your Monorepo Down to Size](https://github.blog/open-source/git/bring-your-monorepo-down-to-size-with-sparse-checkout/) +- [GitLive kotlin-diff-utils](https://github.com/GitLiveApp/kotlin-diff-utils) +- [BGTaskScheduler Apple Documentation](https://developer.apple.com/documentation/backgroundtasks/bgtaskscheduler) diff --git a/project_plans/git-integration/research/features.md b/project_plans/git-integration/research/features.md new file mode 100644 index 00000000..c08237f6 --- /dev/null +++ b/project_plans/git-integration/research/features.md @@ -0,0 +1,241 @@ +# Feature Research — Git-Backed Note-Taking Sync UX + +_Research date: 2025-05-02_ + +--- + +## 1. Working Copy (iOS Git Client) + +**App:** [Working Copy](https://workingcopy.app/) — premium iOS/iPadOS git client +**Developer:** Anders Borum +**Relevance:** The gold standard for git UX on mobile; many note-taking apps (iA Writer, Ulysses) use it as their git backend via iOS File Provider extension. + +### Conflict Resolution UX + +Working Copy implements a **three-panel merge editor** for text file conflicts: + +- **Center panel:** Content both versions agree on +- **Left panel ("ours"):** Local changes +- **Right panel ("theirs"):** Incoming remote changes + +**Interaction model:** Users swipe individual conflict hunks left or right toward the center panel to accept them into the merged result. Unresolved chunks remain at the border until the user makes a decision. A progress indicator shows how many chunks remain unresolved. + +**Quick resolution shortcut:** Tapping the branch name header (labeled `HEAD` or the merge source) accepts all chunks from that version at once — useful for "just take all of theirs" scenarios. + +**Binary files:** A simple two-option selector (our version vs. their version) with a thick border indicating the selected choice. + +**Manual fallback:** The Content tab exposes the raw conflicted file with `<<<<<<<`/`=======`/`>>>>>>>` markers. A "Resolve" button marks the file resolved once the user manually removes the markers. + +### Sync Workflow UX + +- Fetch/push always requires explicit user action (no auto-sync) +- Status badges on repos/branches show ahead/behind commit counts +- SSH and HTTPS both supported; SSH keys managed via iOS Files app +- The app integrates with iOS Shortcuts for automation + +### Key Design Lessons for SteleKit + +1. **Chunk-level resolution** (not file-level) is the right granularity for text conflicts +2. **Progressive disclosure:** Show only unresolved chunks; resolved ones collapse +3. **Quick accept-all** is essential — most conflicts in personal wikis should be trivially resolved +4. **Never block the user mid-resolution:** persist partial resolution state if the app is backgrounded + +--- + +## 2. Obsidian Git Plugin + +**Plugin:** [obsidian-git](https://github.com/Vinzent03/obsidian-git) by Vinzent03 +**Stars:** ~5000+ (actively maintained as of 2024) + +### Auto-Commit and Sync + +The plugin implements a **commit-and-sync** loop: +1. Stage all changes +2. Commit with auto-generated message (timestamp-based or custom template) +3. Pull (merge or rebase strategy, configurable) +4. Push + +**Schedule options:** +- "Auto commit-and-sync interval" (e.g., 10 minutes) +- "Auto commit-and-sync after stopping file edits" — debounced trigger after the user stops typing + +This matches SteleKit's FR-3.4 (commit-per-session / debounced commit model) almost exactly. + +### Conflict Resolution + +**Critical finding: Obsidian Git has NO built-in conflict resolution UI as of 2024.** + +There is an open feature request ([Issue #803](https://github.com/Vinzent03/obsidian-git/issues/803)) requesting built-in conflict handling. Currently when a merge conflict occurs: +- Git conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) are injected into the Markdown file +- The user must manually edit the raw file to resolve them +- There is no visual diff, no chunk-by-chunk workflow + +This is the primary UX failure mode that SteleKit should avoid. SteleKit's FR-4 (conflict resolution screen with side-by-side diff) would represent a significant improvement over the current state of the art. + +### UI Components + +- **Source Control View:** Lists staged/unstaged files with commit/stage/discard actions +- **History View:** Commit log with diff viewing per commit +- **Diff View:** Shows per-file changes as a unified diff (desktop only) +- **Line-by-line editor gutter indicators:** Added/modified/deleted lines highlighted inline + +### Mobile Limitations + +The plugin documents mobile as **"highly unstable"**: +- No SSH authentication (mobile only works with HTTPS) +- Memory constraints on large repos cause crashes during clone/pull +- No rebase merge strategy on mobile +- No submodule support on mobile + +### Key Design Lessons for SteleKit + +1. **Auto-commit after edit stop** (debounce) is the right trigger model — matches user mental model of "it just saves" +2. **The gap: no conflict UI** — this is the biggest pain point and SteleKit's opportunity +3. **Mobile HTTPS-only is acceptable** for the majority of users (SSH is a power user feature) +4. **Separate commit from sync** — commit can be automatic; sync (push/pull) should be separable + +--- + +## 3. GitJournal (Flutter, Open Source) + +**App:** [GitJournal](https://gitjournal.io/) — mobile-first Markdown notes integrated with Git +**GitHub:** https://github.com/GitJournal/GitJournal +**Language:** Dart (~95% of codebase) + +### Architecture for Git Sync + +GitJournal uses a **pure-Dart git implementation** called `gitdart` — a git library written entirely in Dart (no native binaries, no JGit). This avoids the need for native library distribution. The organization also maintains Dart bindings for go-git as an alternative. + +**Git operation model:** +- Full git operations in-app: clone, fetch, merge, commit, push +- SSH authentication using standard SSH keys +- Works with any Git hosting provider (GitHub, GitLab, custom) + +### Conflict Handling + +GitJournal's approach to merge conflicts is **merge-strategy-based** rather than UI-based: +- For journal files (one entry per file, daily), conflicts are rare because each day is a separate file +- When conflicts do occur, the app shows an error notification and requires manual resolution outside the app +- The file-per-note model is the primary conflict avoidance strategy + +**Key insight:** The one-file-per-note + one-commit-per-sync model dramatically reduces conflict frequency. SteleKit's wiki is organized similarly (one Markdown file per page), so this design principle applies directly. + +### Background Sync Architecture + +GitJournal does not implement persistent background sync (no WorkManager equivalent). Sync is triggered: +- On app launch +- Manually by the user +- Via a "sync on save" option + +### Key Design Lessons for SteleKit + +1. **One file per note** is the best conflict-avoidance strategy — already true in SteleKit +2. **Conflict frequency can be minimized** through commit-per-session model (only commit changes, not re-write unchanged files) +3. **Pure-library git (no binary dependency)** is the ideal — kgit2/libgit2 for SteleKit approximates this +4. **SSH support on mobile is achievable** — GitJournal does it in Flutter + +--- + +## 4. iA Writer + +**App:** [iA Writer](https://ia.net/writer) — focused writing app for macOS, iOS, Windows, Android + +### Git Integration Approach + +iA Writer does **not have native git integration** as of 2024. The standard workflow for iA Writer users who want git sync is: + +1. Store iA Writer documents in a folder managed by Working Copy (iOS) +2. Working Copy handles git operations; iA Writer reads/writes the same folder +3. On macOS: standard git tools or GitHub Desktop + +iA Writer explicitly chose **not to build git integration** in-app, delegating to Working Copy on iOS through the File Provider extension mechanism. This is a deliberate design choice to keep the app focused on writing. + +**Blog post:** "Word Export and GitHub on iOS" at https://ia.net/topics/word-and-github discusses their GitHub integration approach (primarily for publishing, not sync). + +### Key Design Lesson for SteleKit + +iA Writer's choice reveals the complexity: building good git UX in a note-taking app is hard enough that a well-resourced team chose to delegate it. SteleKit must accept this complexity as a core feature. + +--- + +## 5. Common UX Failure Modes in Git-Embedded Note Apps + +Based on research across the above apps, community discussions, and user reports: + +### Failure Mode 1: Conflict markers in notes +**What happens:** Merge conflict markers (`<<<<<<<`, `=======`) appear in the rendered note. +**Impact:** User sees garbled content; may save/export corrupted document. +**Mitigation (SteleKit):** Never let a file with conflict markers reach the block editor. Intercept conflicts before GraphLoader processes files; route to conflict resolution screen first. + +### Failure Mode 2: Silent data loss via force-push or wrong merge strategy +**What happens:** Auto-sync uses `--theirs` or force-push, silently discarding local edits. +**Impact:** User's notes are permanently lost. +**Mitigation (SteleKit):** FR-3 (never auto-apply remote changes to open files); always stage+commit local changes before merge; never use force strategies. + +### Failure Mode 3: Sync while actively editing +**What happens:** Background sync triggers a merge/rebase while a block is being typed. The file is rewritten on disk by git; the in-memory view diverges from disk. +**Impact:** User's pending edits are lost or cause a second conflict on next save. +**Mitigation (SteleKit):** Implement an editing lock (FR-3.3): if any block is in edit mode, defer merge until editing stops. + +### Failure Mode 4: Large vault clone hangs the UI +**What happens:** A large git clone or fetch blocks the main thread (if git operations run synchronously). +**Impact:** App freezes; user force-quits, possibly corrupting in-progress operations. +**Mitigation (SteleKit):** All git operations on `PlatformDispatcher.IO`; progress indicator in UI; cancellation support. + +### Failure Mode 5: SSH auth failure with no clear error +**What happens:** SSH key authentication fails silently (wrong key path, unsupported algorithm, fingerprint mismatch). +**Impact:** Sync appears to hang or fails with a cryptic error message. +**Mitigation (SteleKit):** Map all SSH exceptions to `DomainError.GitError.AuthFailed` with human-readable messages; test with GitHub's modern key requirements. + +### Failure Mode 6: Diverged history requires rebase, not merge +**What happens:** App always uses `git merge` but remote has been force-pushed; merge creates a "merge commit soup." +**Impact:** Git log becomes unreadable; future merges have excessive conflicts. +**Mitigation (SteleKit):** Support both merge and rebase strategies; default to merge for safety; expose strategy in settings. + +### Failure Mode 7: Partial resolution state lost +**What happens:** User resolves 3 of 5 conflicts, backgrounds the app, iOS kills it. On next launch, the merge is in an inconsistent state. +**Impact:** Either the incomplete merge is abandoned (losing conflict decisions) or git is left in conflicted state. +**Mitigation (SteleKit):** Persist conflict resolution state to database (FR-4.5); on launch, detect in-progress merge and restore resolution screen. + +--- + +## 6. Best Practices for "Safe Sync" + +### Principle 1: Commit before sync +Always commit any local changes before fetching/merging remote changes. This ensures: (a) local work is never silently overwritten, and (b) conflicts are surfaced as proper merge conflicts rather than "local changes would be overwritten" errors. + +### Principle 2: Detect active editing before sync +Maintain an application-level "editing active" flag. When a block's `BlockEditor` is in focus or `BlockStateManager` has unsaved changes, set this flag. Defer any merge until the flag clears. + +### Principle 3: Atomic sync operation +The sequence `fetch → check for conflicts → merge → push` must be presented to the user as a single operation. Partial completion (e.g., fetched but not merged) must be surfaced visibly, not silently. + +### Principle 4: Conflict-first, not conflict-later +Don't attempt the merge and then show the conflict screen. Before merging, detect potential conflicts with `git merge --no-commit --no-ff` or by analyzing the diff between `FETCH_HEAD` and `HEAD`. Show the conflict screen pre-emptively if the user has local edits to the same files that have remote changes. + +### Principle 5: Notifications, not interruptions +When background polling detects remote changes, show a badge or notification — never auto-merge. The user must initiate sync. This matches Working Copy's model and FR-3.1. + +### Principle 6: Preserve journal entry temporal integrity +For note apps with daily journal files, each edit session should produce one commit with a meaningful message (e.g., "Journal entry 2024-05-02" or "Edited: Home, TODO"). This makes the git log a meaningful audit trail. + +--- + +## Open Questions / Unresolved Items + +- **UNRESOLVED:** Working Copy's internal implementation for conflict persistence on iOS backgrounding — not publicly documented. +- **UNRESOLVED:** How GitJournal handles the case where two devices create the same daily journal file simultaneously (same date, same path) — likely relies on auto-merge of append-only files. +- **UNRESOLVED:** Whether NotePlan's git sync (https://help.noteplan.co/article/102-sync-with-git) has better conflict handling than Obsidian Git — their documentation is sparse. + +--- + +## Sources + +- [Working Copy User Guide](https://workingcopyapp.com/users-guide) +- [obsidian-git GitHub](https://github.com/Vinzent03/obsidian-git) +- [obsidian-git Issue #803: Conflict Handling](https://github.com/Vinzent03/obsidian-git/issues/803) +- [GitJournal GitHub](https://github.com/GitJournal/GitJournal) +- [GitJournal Website](https://gitjournal.io/) +- [iA Writer — Word Export and GitHub on iOS](https://ia.net/topics/word-and-github) +- [GitJournal Hacker News Discussion](https://news.ycombinator.com/item?id=31914003) +- [NotePlan Git Sync](https://help.noteplan.co/article/102-sync-with-git) diff --git a/project_plans/git-integration/research/pitfalls.md b/project_plans/git-integration/research/pitfalls.md new file mode 100644 index 00000000..8d168e82 --- /dev/null +++ b/project_plans/git-integration/research/pitfalls.md @@ -0,0 +1,402 @@ +# Pitfalls Research — Git Integration Failure Modes + +_Research date: 2025-05-02_ + +--- + +## 1. Git Operations on Mobile: Battery Drain and Background Kill + +### Android: Doze Mode and App Standby + +**What can go wrong:** + +Android's Doze mode (API 23+) defers background CPU and network activity when the device is unused. During Doze sleep: network access is blocked, wakelocks are ignored, and JobScheduler/WorkManager jobs are deferred to "maintenance windows." + +In Android 14+, **adaptive restrictions** limit apps opened rarely (once or twice a month) from running background tasks, even when using WorkManager. This affects note-taking apps that users check infrequently. + +**OEM-specific battery optimization:** +Manufacturers (Xiaomi, Samsung, Huawei, OnePlus, Asus) implement aggressive proprietary battery savers on top of AOSP Doze. These silently kill background tasks. The [Don't Kill My App](https://dontkillmyapp.com/) project documents OEM-specific behaviors: +- Xiaomi: "Auto-start" permission required (user must enable manually) +- Samsung: "Sleeping apps" list can kill WorkManager jobs +- Huawei: Background app kill is especially aggressive; virtually no background execution without explicit whitelist + +**Git-specific risks:** +- A background git fetch that is killed mid-operation leaves the `.git/` directory in an inconsistent state (partial packfiles, stale FETCH_HEAD) +- Retrying with WorkManager's exponential backoff will re-run the entire fetch, but must handle the partial state gracefully +- Large fetches (>5MB delta) are especially vulnerable + +**Mitigations:** +1. Use `WorkManager` with `NetworkType.CONNECTED` constraint — WM handles Doze maintenance windows automatically +2. Implement idempotent git operations: if `FETCH_HEAD` already matches remote, skip fetch +3. Show users a "Background Sync Disabled" warning if battery optimization is enabled for the app +4. Provide a manual "Sync Now" button as primary sync path; background sync is supplementary +5. Set WorkManager `setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)` for user-triggered syncs + +### iOS: Background Execution Limits + +**What can go wrong:** + +iOS `BGTaskScheduler` provides two task types: +- `BGAppRefreshTask`: ~30 seconds execution budget +- `BGProcessingTask`: ~1-2 minutes execution budget, requires device charging + Wi-Fi + +For a git fetch+merge operation: +- A small fetch (few KB delta) fits in `BGAppRefreshTask` +- A large fetch (many files changed, binary assets) will time out even in `BGProcessingTask` + +When a `BGProcessingTask` expires, iOS calls the `expirationHandler`, and the task is marked failed. There is no retry automatic — the app must re-schedule the next run manually. + +**iOS does not guarantee scheduling frequency.** Even if you schedule a 5-minute sync, iOS may only run it every 15-30 minutes based on device usage patterns (ML-based scheduling). + +**Mitigations:** +1. **Sync on foreground launch is mandatory (FR-2.3)** — iOS background sync is best-effort +2. Use `BGProcessingTask` (not `BGAppRefreshTask`) for git operations to get the larger time budget +3. Implement incremental fetch: only fetch since the last known remote commit, not a full re-fetch +4. Split fetch and merge into separate task submissions if needed +5. Persist sync state (last successful sync timestamp, pending merge state) to survive task expiration + +### Desktop JVM: No Restrictions + +Desktop has no OS-level background restrictions. The `CoroutineScope`-based scheduler with `PlatformDispatcher.IO` is safe. The only risk is the user sleeping/hibernating their laptop mid-fetch — handle as a network error with retry. + +--- + +## 2. Race Conditions: Active Editing + Fetch+Merge + +**Scenario:** User is typing a block. Background sync triggers. Git fetches and merges the remote branch. The merged file is written to disk. The file watcher (`GraphLoader.externalFileChanges`) detects the change and triggers a reload. The user's in-memory edits are now diverged from disk. + +**What can go wrong:** + +1. **Silent edit loss:** If the file reload overwrites the in-memory block state, the user's unsaved text is gone. +2. **Double conflict:** If the user saves after the reload, their changes conflict with the just-merged version, creating a new conflict on top of a resolved one. +3. **UI flicker:** The block tree is rebuilt mid-edit, causing the cursor position to jump or the composable to recompose unexpectedly. +4. **Database desync:** The `DatabaseWriteActor` has queued a write for the user's edit. The graph reload fires new writes. Write order may be undefined, resulting in the reloaded (remote) version overwriting the user's pending edit in the database. + +**Mitigations:** + +**Primary defense: EditLock (see architecture.md)** +- Before any merge operation, acquire the EditLock +- `editLock.awaitIdle()` suspends until `BlockStateManager` reports no active edits +- The merge proceeds only when the editing flag is clear + +**Secondary defense: File watcher suppression during merge** +When a merge is in progress, set a flag in `GraphLoader` to suppress or buffer `externalFileChanges` emissions for files being touched by the merge: + +```kotlin +// In GitSyncService.sync() +graphLoader.suppressExternalChanges(mergeResult.changedFiles) { + repo.merge() // files written here won't trigger external change events +} +``` + +After merge completes, reload changed files explicitly via `graphLoader.reloadFiles(changedFiles)` rather than via the watcher. + +**Third defense: Debounced commit before merge** +Before fetching, run `GraphWriter.flushPendingEdits()` to ensure any debounced save (the 500ms timer in `BlockStateManager`) is flushed to disk and committed. This guarantees the local version is fully committed before the merge starts. + +--- + +## 3. Conflict Escalation: Merge Conflict in an Open File + +**Scenario:** A git merge conflict occurs in a file that SteleKit has open in the editor. Git injects `<<<<<<<`/`=======`/`>>>>>>>` markers into the file. The app reads the conflicted file through `GraphLoader`. + +**What can go wrong:** + +1. **Parser crash:** The SteleKit Markdown parser may throw or produce garbage when encountering conflict markers, since `<<<<<<<` is not valid Markdown. +2. **Corrupted block tree:** If the parser "succeeds" but treats conflict markers as block content, the user sees the raw marker text in their rendered wiki. +3. **Corrupted database:** If the conflicted file is loaded into the SQLDelight database with marker content, subsequent queries return conflicted data. +4. **User saves markers:** If the user edits around the conflict markers and saves, the markers are now persisted in the database and on disk without the user resolving them. + +**How other apps handle this:** + +- **Obsidian Git:** Does NOT handle this. Conflict markers appear as raw text in the rendered note. Users must open the file and manually remove markers. (This is the primary UX gap in the current ecosystem.) +- **Working Copy:** Never loads conflicted files into an editor. The conflict resolution screen is shown before the user can view/edit the file content. Files with unresolved conflicts are visually flagged with a conflict badge. +- **GitJournal:** Avoids by not merging while the app is open; syncs on launch. + +**Mitigations for SteleKit:** + +1. **Detect conflicts before loading:** After any merge operation, scan affected files for conflict markers before passing to `GraphLoader`. If markers are found, route to `ConflictResolutionScreen` rather than loading into the block editor. +2. **Parser safeguard:** Add a "conflict marker detection" pass in `GraphLoader` that returns a `Either.Left(DomainError.GitError.ConflictMarkersPresent)` if `<<<<<<<` is found. Never store conflicted content in the database. +3. **GraphLoader guard:** +```kotlin +fun loadFile(path: Path): Either> { + val content = path.readText() + if (content.contains("<<<<<<<") && content.contains("=======")) { + return DomainError.GitError.ConflictMarkersPresent(path).left() + } + // normal parsing... +} +``` +4. **Lock conflicted files:** Mark all files with unresolved conflicts as read-only in the UI until resolved. + +--- + +## 4. SSH on Android Without Root + +### JSch (Original, `com.jcraft:jsch`) + +**Status:** Unmaintained. Last real update circa 2016. + +**Known issues on Android:** +- Does not support `diffie-hellman-group14-sha256` or `diffie-hellman-group16-sha512` (required by GitHub since 2021) +- Does not support ED25519 or OpenSSH-format private keys (the default on macOS/Linux since OpenSSH 7.8) +- Fails with "Algorithm negotiation fail" or "Auth fail" when connecting to modern servers +- Android apps using JGit + JSch have numerous open issues reporting these failures: [Android Password Store #568](https://github.com/android-password-store/Android-Password-Store/issues/568), [JGit Android issues blog](https://github.com/ythy/blog/issues/536) +- BouncyCastle version conflicts: Android ships its own BC provider; JSch's BC dependency may clash + +### mwiede/jsch Fork (`com.github.mwiede:jsch`) + +**Status:** Actively maintained. Version 0.2.x (2024). + +**Fixes:** +- ED25519, ECDSA, RSA-SHA256/512 support +- OpenSSH new private key format (PEM with `OPENSSH PRIVATE KEY` header) +- Modern key exchange algorithms +- Drop-in replacement for `com.jcraft:jsch` + +**Android-specific:** +- Works on Android API 21+ with care +- May still have BC conflicts on older Android — test with `minSdk 26` +- Security patched in 2024 (SUSE Security Update 2024:0057-1 covers eclipse-jgit + jsch CVEs) + +**Migration:** JGit 6.x supports plugging in a custom SSH session factory. Replace `com.jcraft:jsch` with `com.github.mwiede:jsch:0.2.x` in Gradle; update `JschConfigSessionFactory` instantiation. + +### Apache MINA SSHD (JGit's preferred transport) + +**Module:** `org.eclipse.jgit:org.eclipse.jgit.ssh.apache` + +**Android issues:** +- MINA SSHD requires NIO APIs that are only fully available on Android API 26+ +- JGit recommends MINA SSHD for Java 11+, but on Android this means API 26+ (Android 8.0) minimum +- If SteleKit targets `minSdk 26` (Android 8.0 — ~97% of active devices as of 2024), MINA SSHD is viable + +**Recommendation:** Target `minSdk 26` and use Apache MINA SSHD for Android 8.0+ users; use `mwiede/jsch` as a fallback for lower API levels. + +### SSH Key Path Configuration + +On Android, SSH private keys may be located at various paths depending on the user's setup: +- Termux: `/data/data/com.termux/files/home/.ssh/id_ed25519` (inaccessible without root or Termux API) +- App-managed storage: User copies key into app's private files directory +- Shared storage: Not accessible without READ_EXTERNAL_STORAGE permission (deprecated API 29+) + +**SteleKit must:** +1. Provide a file picker for SSH key import (copies key to app-private storage) +2. Store the key path in settings; reload on each SSH session creation +3. Never assume a fixed key path + +--- + +## 5. Keychain / Secure Storage on KMP: Known Issues + +### KVault (`com.liftric:kvault`) + +**GitHub:** https://github.com/Liftric/KVault +**Platforms:** iOS (Keychain), Android (EncryptedSharedPreferences) + +**Known issues:** +- **No Desktop support** — KVault does not target JVM Desktop. Confirmed in community discussions (March 2024). Cannot be used for a unified KMP credential store that includes Desktop. +- Android: Uses `EncryptedSharedPreferences` which is **deprecated** in AndroidX Security Crypto as of 2023/2024. The `EncryptedSharedPreferences` API still works but receives no new features; the replacement is Jetpack DataStore + EncryptedFile. +- Potential BC conflicts on Android (same BouncyCastle issue as JSch) + +### multiplatform-settings (Touchlab) + +**GitHub:** https://github.com/russhwolf/multiplatform-settings +**`KeychainSettings`:** iOS Keychain backend (annotated `@ExperimentalSettingsImplementation`) +**`EncryptedSharedPreferencesSettings`:** Android backend + +**Advantages over KVault:** +- Supports JVM Desktop (via JVM-standard `Preferences` — NOT encrypted, but key can be wrapped) +- Broader active maintenance +- More platform targets + +**Known issues:** +- `KeychainSettings` is experimental (annotation, not necessarily unstable) +- JVM backend is not encrypted by default; requires a custom `EncryptedPreferences` implementation for Desktop credential security +- No Windows-native DPAPI integration + +### ksecurestorage + +**GitHub:** https://github.com/AlexanderEggers/ksecurestorage +**Platforms:** Android, iOS + +Less actively maintained than KVault. Similar limitations (no Desktop). + +### Recommended Pattern for SteleKit + +```kotlin +// commonMain +expect class CredentialStore { + fun storeToken(key: String, value: String) + fun getToken(key: String): String? + fun delete(key: String) +} + +// androidMain: EncryptedSharedPreferences (or DataStore + EncryptedFile) +// iosMain: iOS Keychain via KVault or multiplatform-settings +// jvmMain: javax.crypto AES-GCM encrypted file in app data directory +``` + +For Desktop JVM, use `javax.crypto` to AES-GCM encrypt credentials stored in a file at `~/.config/stelekit/credentials.enc`. The encryption key is derived from the OS user account (e.g., using `SecureRandom` seed stored in user home, or macOS Keychain via JNA). + +--- + +## 6. GraphLoader File-Watch Interaction + +**The Risk:** + +SteleKit's `GraphLoader` watches for external file changes via a `SharedFlow` (backed by a `FileWatcher`). When a git merge rewrites one or more `.md` files in the wiki, the `FileWatcher` will emit change events for each rewritten file. + +If these events are processed as normal "external edits" (the existing `DiskConflict` detection flow), the following problems arise: + +1. **False `DiskConflict` events:** The git merge write is indistinguishable from a user's external editor writing the file. The app may show "File changed externally" prompts for every file touched by the merge. +2. **Double reload:** The merge already triggers an explicit `reloadFiles()` call in `GitSyncService`. The file watcher would trigger a second, redundant reload. +3. **Reload of conflicted files:** If the merge left conflict markers in a file, the file watcher would attempt to reload it, hitting the conflict marker guard (§3 above) — acceptable if the guard is in place, but potentially confusing. +4. **Write-after-watch race:** The file watcher event for a merge-written file arrives asynchronously. If the user's pending edit write arrives after the merge write but before the file watcher event processes, the file watcher might treat the user's write as the "new" external change, causing the merge result to be discarded. + +**Mitigations:** + +**Option A: Watcher suppression during merge (recommended)** + +`GitSyncService` calls `GraphLoader.beginGitOperation()` before merge and `GraphLoader.endGitOperation()` after. During a git operation, the `FileWatcher` buffers events. After `endGitOperation()`, discard buffered events for files that were explicitly reloaded via `reloadFiles()`. + +```kotlin +class GraphLoader { + private val suppressedPaths = mutableSetOf() + + @Synchronized + fun suppressWatcherFor(paths: List) { + suppressedPaths.addAll(paths) + } + + @Synchronized + fun clearWatcherSuppression(paths: List) { + suppressedPaths.removeAll(paths.toSet()) + } + + // Inside the file watcher event handler: + private fun onFileChanged(path: Path) { + if (path in suppressedPaths) return // ignore merge writes + externalFileChanges.emit(ExternalFileChange(path)) + } +} +``` + +**Option B: Timestamp-based origin detection** + +Record the timestamp before the merge starts. File watcher events with `modifiedAt < mergeStartTimestamp` are git-originated; ignore them. This is less reliable on filesystems with 1-second timestamp precision. + +**Option C: Hash-based origin detection** + +Before merge, record SHA-256 of each file that will be changed. After merge, file watcher events for files whose new SHA matches the expected post-merge content are ignored. More robust but requires computing hashes. + +**Recommendation:** Use **Option A** (explicit suppression list). It is deterministic, aligns with SteleKit's existing explicit reload pattern, and requires the smallest change to `GraphLoader`. + +--- + +## 7. Large Repo Performance + +**Scenario:** The git repo contains a large number of files (e.g., a monorepo with thousands of source files), but the wiki lives in a small subdirectory (e.g., 200 `.md` files). + +### `git status` Performance + +Running `git status` on a large repo (10,000+ files) involves: +- Index refresh (inode + mtime comparison for every tracked file) +- SHA computation for modified files + +On a large repo, `git status` can take 2-10 seconds. This blocks any UI that waits for status. + +**Mitigation:** Run `git status --pathspec-from-file=` to limit status to the wiki subdirectory. In JGit, use `StatusCommand.addPath(wikiSubdir)` to scope the status check. + +### `git fetch` Performance + +Fetch downloads only new objects (delta-compressed). For a large repo with active non-wiki commits, each fetch may download many objects that SteleKit doesn't need. + +**Mitigation:** +1. **Sparse checkout + partial clone** (see architecture.md §3) — fetch only objects in the wiki subdirectory. Requires `git clone --filter=blob:none --sparse` and `git sparse-checkout set `. +2. **Shallow fetch:** Use `git fetch --depth=1` to fetch only the latest commit (not full history). Appropriate for sync-only use cases where full history isn't needed. + +**JGit support:** JGit supports `CloneCommand.setDepth(1)` and `FetchCommand.setShallowSince()`. Sparse checkout has limited JGit support. + +### `git merge` Performance + +Merge on a large repo is O(changed files) not O(total files). If most recent commits are in non-wiki directories, merge is fast even on large repos. + +**Mitigation:** No special handling needed for merge itself. The performance impact is on `status` and `fetch`, not merge. + +### `git log` Performance + +Displaying recent commits (FR-6.3) in a large repo with a long history can be slow if SteleKit traverses the entire history. + +**Mitigation:** Always use `--max-count=N` (e.g., 50) when listing log entries. In JGit: `LogCommand.setMaxCount(50)`. + +### Summary Table + +| Operation | Large Repo Risk | Mitigation | +|---|---|---| +| `git status` | Slow (2-10s for 10K files) | Scope to wiki subdir | +| `git fetch` | Downloads non-wiki objects | Sparse checkout or shallow clone | +| `git merge` | Fast (delta-based) | None needed | +| `git log` | Slow (traverses all history) | Always use --max-count | +| `git clone` | Very slow for large repos | Progress indicator; offer sparse | + +--- + +## 8. Additional Pitfalls (Miscellaneous) + +### `.git` Directory in Wiki Root + +If the user accidentally sets the wiki root to the git repo root (not a subdirectory), `GraphLoader` will try to parse `.git/` directory contents as Markdown pages. This causes errors. + +**Mitigation:** Validate that the wiki root path does not equal the git repo root and does not contain a `.git` directory directly. + +### Detached HEAD State + +If the user (or another tool) puts the repo in detached HEAD state, git push will fail silently or produce unexpected behavior. + +**Mitigation:** On attach/startup, check for detached HEAD (`git symbolic-ref HEAD`) and warn the user. + +### Stale Lock File (`.git/index.lock`) + +If a previous git operation was killed mid-run (battery death, force quit), a `.git/index.lock` file may be left behind. Subsequent git operations fail with "Unable to lock index." + +**Mitigation:** On startup, detect stale lock files (older than 1 minute) and remove them. In JGit, check for `File(".git/index.lock").exists()`. + +### Submodule Interactions + +If the git repo contains submodules, `git merge` and `git status` may interact unexpectedly with submodule states. + +**Mitigation:** Document that SteleKit does not support repos with submodules in the wiki subdirectory. Add a startup check. + +### CRLF Line Endings on Windows/Android Cross-Platform + +If users sync between Windows and Android/macOS, git's `core.autocrlf` setting can cause spurious diffs where only line endings differ. Every file shows as modified on every sync. + +**Mitigation:** Recommend `.gitattributes` with `* text=auto` in the repo. Add this to the default `.gitattributes` when SteleKit initializes a new repo. + +--- + +## Open Questions / Unresolved Items + +- **UNRESOLVED:** Does JGit's `StatusCommand.addPath()` correctly scope status to a subdirectory on Android (file system case sensitivity, path separator differences)? +- **UNRESOLVED:** Does `GraphLoader`'s file watcher use inotify (Linux/Android), FSEvents (macOS/iOS), or kqueue (iOS)? The suppression mechanism must account for the watcher's delivery latency. +- **UNRESOLVED:** Can WorkManager reliably wake a KMP app (not just a pure-Android app) for git sync when the process is killed? Needs integration test. +- **UNRESOLVED:** iOS Keychain access from Kotlin/Native (kgit2 / expect/actual) — does Kotlin/Native have sufficient Keychain API exposure for SSH credential storage? + +--- + +## Sources + +- [Don't Kill My App — General](https://dontkillmyapp.com/general) +- [Android Background Limitations](https://notificare.com/blog/2024/12/13/android-background-limitations/) +- [Android Doze and App Standby](https://developer.android.com/training/monitoring-device-state/doze-standby) +- [Future of Background Tasks on Android](https://medium.com/@androidlab/the-future-of-background-tasks-on-android-what-post-doze-evolution-means-for-developers-1225e4792863) +- [BGTaskScheduler Apple Documentation](https://developer.apple.com/documentation/backgroundtasks/bgtaskscheduler) +- [Android Password Store — SSH Algorithm Negotiation Fail](https://github.com/android-password-store/Android-Password-Store/issues/568) +- [JGit Android SSH issues](https://github.com/ythy/blog/issues/536) +- [mwiede/jsch — JSch fork](https://github.com/mwiede/jsch) +- [Orgzly — Switch from Jsch to Apache MINA SSHD](https://github.com/orgzly/orgzly-android/issues/904) +- [KVault GitHub](https://github.com/Liftric/KVault) +- [Touchlab Encrypted KMP Storage](https://touchlab.co/encrypted-key-value-store-kotlin-multiplatform) +- [SUSE Security Update for eclipse-jgit/jsch](https://www.suse.com/support/update/announcement/2024/suse-su-20240057-1/) +- [Git Sparse Checkout Performance](https://github.blog/open-source/git/bring-your-monorepo-down-to-size-with-sparse-checkout/) +- [obsidian-git Conflict Handling Feature Request](https://github.com/Vinzent03/obsidian-git/issues/803) diff --git a/project_plans/git-integration/research/stack.md b/project_plans/git-integration/research/stack.md new file mode 100644 index 00000000..189de22a --- /dev/null +++ b/project_plans/git-integration/research/stack.md @@ -0,0 +1,239 @@ +# Stack Research — Git Library for KMP (Android + Desktop JVM + iOS) + +_Research date: 2025-05-02_ + +--- + +## 1. JGit (Eclipse Foundation) + +**What it is:** Pure-Java implementation of Git. Used by Eclipse IDE, Gerrit, and many JVM tooling projects. + +**GitHub:** https://github.com/eclipse-jgit/jgit +**Latest version:** 7.6.0.202603022253-r (Maven: `org.eclipse.jgit:org.eclipse.jgit`) +**License:** Eclipse Distribution License (BSD-style) + +### JVM / Desktop +Works well on Desktop JVM. Mature API covering clone, fetch, merge, push, diff, log, status, stash, cherry-pick, and rebase. All SteleKit requirements (FR-1 through FR-6) can be satisfied on Desktop with JGit alone. + +### Android +JGit **does run on Android** but with significant constraints: + +- **Java version mismatch:** JGit 6.x requires Java 11; JGit 7.x (2024+) requires Java 17. Android's JVM runtime (Art) implements a different subset. Desugaring via AGP (`coreLibraryDesugar`) can bridge some gaps, but not all JGit 7.x features are safe. +- **Tested compatibility:** JGit 5.8 is reported compatible with Android 12 (API 31). JGit 6.8 is reported incompatible with Android without additional shims. +- **Older fork works:** Several Android apps (Android Password Store, Orgzly) use JGit up to version ~5.x via `org.eclipse.jgit:org.eclipse.jgit:5.13.x` with `minSdk 21`. +- **SSH issues (see §6 below):** JGit's default JSch transport is unmaintained. The `org.eclipse.jgit.ssh.jsch` module has known algorithm failures with modern OpenSSH servers. + +### iOS +JGit **does NOT run on iOS**. It is JVM-only. There is no Kotlin/Native path for JGit. + +### KMP Wrapper: KGit +- **GitHub:** https://github.com/sya-ri/KGit +- **Version:** 1.1.0 (October 2024) +- **What it does:** Idiomatic Kotlin wrapper around JGit (null-safety, lambdas, DSL-style config) +- **Limitation:** JVM-only — same constraints as JGit. No iOS target. + +### Verdict on JGit +JGit is viable for Android + Desktop JVM as a "two-platform" solution, but it cannot serve iOS. + +--- + +## 2. libgit2 (Native C Library) + +**What it is:** A portable, pure-C re-entrant Git implementation designed to be embedded into other applications. Official language bindings exist for many languages. + +**GitHub:** https://github.com/libgit2/libgit2 +**Supported natively:** Linux, macOS, iOS, Windows — fully tested by the libgit2 project. + +### kgit2 — Kotlin Native Bindings + +**GitHub:** https://github.com/kgit2/kgit2 (organization: https://github.com/kgit2) +**Status:** Active development as of January 2025. ~71 commits on main. +**Dependencies:** libgit2 v1.5.0+, libssh2, OpenSSL3 +**Composition:** ~80% Kotlin, rest is C/Rust/FreeMarker for interop scaffolding + +The kgit2 organization also maintains: +- `kgit2/c-interop-klib` — C interop KLib tooling +- `kgit2/Kommand` — Kotlin Native child process launcher + +**Platform coverage:** +- iOS: Yes (via Kotlin/Native → libgit2 C interop) +- macOS: Yes (via Kotlin/Native) +- Android: Possible via JNI to the C library OR via Kotlin/Native Android target; **unclear if kgit2 explicitly targets Android NDK — this is UNRESOLVED** +- Desktop JVM: NOT via Kotlin/Native; would need JNI bridge + +**Key risk:** kgit2 has low community adoption (small GitHub star count). No production case studies found for KMP apps using it on both Android and iOS simultaneously. + +### Alternative: Use libgit2 via JNI on Android, Kotlin/Native on iOS + +A split-platform approach where: +- Android: JNI wrapper around libgit2 `.so` (compiled per-ABI) +- iOS: Kotlin/Native via kgit2 or `cinterop` directly +- Desktop: JNI or a separate JVM wrapper + +This is the approach used by GitJournal (Flutter) via `dart-git` and would require significant native build infrastructure in SteleKit. + +--- + +## 3. Isomorphic-git (JavaScript) + +**Relevance:** Only relevant if WASM/JS target is enabled (`enableJs=true` in `gradle.properties`). Pure JS implementation, no native dependencies. Good for browser-based or Electron use cases. **Not relevant to the current Android + Desktop JVM + iOS scope.** + +--- + +## 4. Shelling Out to System `git` + +**Approach:** Use `ProcessBuilder` (JVM/Android) to exec `git` binary on the host. + +### Desktop JVM +Works well. Git is available on virtually all developer machines. Straightforward API via `ProcessBuilder`. No additional dependencies. + +### Android +Works **if the device has git installed** — this is true on rooted devices, devices with Termux, or in-app Termux environments, but NOT on a stock Android device. Unsuitable for general users. + +### iOS +**Impossible.** iOS has no writable `PATH` and the sandbox prevents exec of external binaries. + +### Verdict +Shell-out is useful as a Desktop-only fallback or dev-mode tool, but cannot be the primary solution for a cross-platform app. + +--- + +## 5. State of the Art Summary + +| Option | Android | Desktop JVM | iOS | Notes | +|---|---|---|---|---| +| JGit (v5.x) | Yes (limited) | Yes (full) | No | SSH issues with modern keys; no iOS | +| JGit (v7.x) | Risky (Java 17 gap) | Yes (full) | No | Java 17 req. may block Android | +| kgit2 (libgit2/KN) | Unclear (UNRESOLVED) | No (needs JNI) | Yes | Early stage; low adoption | +| Shell-out git | Only with git installed | Yes | No | Not viable for mobile | +| isomorphic-git | No | No | No | JS/WASM only | + +--- + +## 6. Recommendation + +### Recommended Approach: Hybrid — JGit on JVM, kgit2/libgit2 on iOS + +The most pragmatic path for SteleKit is a **platform-split architecture**: + +``` +commonMain → expect interface: GitRepository (clone, fetch, merge, push, status, log) +androidMain → actual: JGit 5.x via actuals (SSH via mwiede/jsch fork) +jvmMain → actual: JGit 7.x (full features, no Java version constraints) +iosMain → actual: kgit2 (libgit2 via Kotlin/Native) +``` + +**Why JGit for JVM/Android:** +- Mature, battle-tested, pure-Java — no native build pipeline needed +- Available on Maven Central, trivial Gradle dependency +- Already used in production Android apps (Android Password Store, Orgzly) +- SSH can be fixed with `com.github.mwiede:jsch` fork (see §7) + +**Why kgit2/libgit2 for iOS:** +- libgit2 is the only viable pure-C git library with iOS support +- kgit2 provides the Kotlin/Native glue +- Risk: early stage; may require contributing fixes upstream + +**Alternative (lower risk, lower feature completeness):** Use JGit on Android+Desktop and ship iOS without git features in the first version, then add kgit2 when it matures. + +--- + +## 7. SSH Key Handling on Android + +### JSch (Original) +- Bundled with JGit by default (`org.eclipse.jgit.ssh.jsch` module) +- **No longer maintained** by JGit team +- **Known issues:** Does not support `diffie-hellman-group14-sha256`, does not support ED25519 or ECDSA keys in OpenSSH new format, fails with modern GitHub/GitLab SSH servers +- JGit recommends migrating to Apache MINA SSHD + +### mwiede/jsch Fork +- **GitHub:** https://github.com/mwiede/jsch +- **Version:** 0.2.x (active as of 2024) +- Drop-in replacement for `com.jcraft:jsch` +- Adds: ED25519, ECDSA, modern key exchange algorithms, OpenSSH private key format +- Used by several Android git apps to fix the algorithm negotiation failures +- **Android compatibility:** Works on Android if the provider is compatible; some issues with Android's BouncyCastle version + +### SSHJ (Apache MINA SSHD direction) +- JGit's own `org.eclipse.jgit.ssh.apache` module uses Apache MINA SSHD +- Preferred for Desktop JVM (Java 11+) +- **Android:** MINA SSHD requires Java 11 native APIs (NIO) that may not be fully available on older Android; newer Android (API 26+) is mostly OK +- There is an [open issue in Orgzly](https://github.com/orgzly/orgzly-android/issues/904) tracking the JSch → Apache MINA SSHD migration + +### Recommendation for SSH on Android +Use the `com.github.mwiede:jsch` fork for Android (max compatibility, tested on Android). Use Apache MINA SSHD (JGit's built-in) for Desktop JVM. + +### SSH Key Path Configuration +Android apps must let users configure the SSH key file path (FR-5.4), since keys may live in: +- Termux's `~/.ssh/` +- App-private storage (copied in by user) +- System keychain (rare) + +JGit with JSch supports `setSshKeyPath()` via a custom `JschConfigSessionFactory`. + +--- + +## 8. Secure Credential Storage (KMP) + +### Android + +**EncryptedSharedPreferences** (AndroidX Security Crypto) +- Backed by Android Keystore +- **Deprecated as of 2024** — official guidance is to use Jetpack DataStore + Keystore +- Still functional; widely used in production apps +- For SSH key bytes: not ideal (large binary); better to store key path + passphrase + +**Android Keystore directly** +- Stores cryptographic keys in hardware-backed keystore +- Best for storing derived keys, not raw SSH private key bytes (which are user-supplied) +- Suitable for wrapping a symmetric key that encrypts stored credentials + +**Recommended Android pattern:** Store HTTPS tokens and SSH passphrases using `EncryptedSharedPreferences` (or DataStore + EncryptedFile for larger blobs), referencing user-configured SSH key file path. + +### iOS + +**Keychain Services** +- Native secure storage +- KVault (`com.liftric:kvault`) provides a KMP wrapper: https://github.com/Liftric/KVault +- KVault maps to iOS Keychain on iOS and `EncryptedSharedPreferences` on Android +- **Known limitation:** KVault does NOT support Desktop targets (JVM/macOS) as of 2024 + +**multiplatform-settings** (Touchlab) +- `KeychainSettings` implementation available +- Annotated `@ExperimentalSettingsImplementation` but stable in practice +- Broader platform support than KVault (includes JVM desktop) + +### Desktop JVM + +No system keychain abstraction in JVM standard library. Options: +- **Java Keystore (JKS):** Password-protected file keystore — suitable for SSH passphrases +- **OS-level:** macOS Keychain via JNA/JNI, GNOME Keyring on Linux — complex +- **Simplest:** Encrypted file in app data directory using Android-style key wrapping + +### Recommendation + +Use `multiplatform-settings` with `KeychainSettings` on iOS and `EncryptedSharedPreferencesSettings` on Android. For Desktop JVM, implement a simple `EncryptedFileCredentialStore` using javax.crypto. Wrap behind a `expect class CredentialStore` in commonMain. + +--- + +## Open Questions / Unresolved Items + +- **UNRESOLVED:** Does kgit2 support Android NDK targets (in addition to iOS)? If yes, kgit2 alone could cover all platforms. +- **UNRESOLVED:** kgit2 production readiness — no known production apps using it as of research date. +- **UNRESOLVED:** Apache MINA SSHD on Android API 26 minimum — needs integration test. +- **UNRESOLVED:** JGit 7.x on Android with AGP desugaring — Java 17 API gaps need full audit. + +--- + +## Sources + +- [kgit2 GitHub organization](https://github.com/kgit2) +- [libgit2 GitHub](https://github.com/libgit2/libgit2) +- [KGit (JGit Kotlin wrapper)](https://github.com/sya-ri/KGit) +- [mwiede/jsch fork](https://github.com/mwiede/jsch) +- [JGit Eclipse project](https://github.com/eclipse-jgit/jgit) +- [JSch vs MINA SSHD issue in Orgzly](https://github.com/orgzly/orgzly-android/issues/904) +- [KVault secure storage](https://github.com/Liftric/KVault) +- [Touchlab Encrypted KMP Storage](https://dev.to/touchlab/encrypted-key-value-store-in-kotlin-multiplatform-2hnk) +- [Android SSH auth issue in Android Password Store](https://github.com/android-password-store/Android-Password-Store/issues/568) +- [JGit Java 17 upgrade issue](https://github.com/eclipse-jgit/jgit/issues/52) diff --git a/settings.gradle.kts b/settings.gradle.kts index 77ed0093..6a206062 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,13 +6,13 @@ pluginManagement { maven("https://oss.sonatype.org/content/repositories/snapshots/") } plugins { - kotlin("multiplatform") version "2.3.10" - kotlin("jvm") version "2.3.10" - kotlin("android") version "2.3.10" - kotlin("plugin.compose") version "2.3.10" - kotlin("plugin.serialization") version "2.3.10" - id("com.android.library") version "8.9.1" - id("com.android.application") version "8.9.1" + kotlin("multiplatform") version "2.3.21" + kotlin("jvm") version "2.3.21" + kotlin("android") version "2.3.21" + kotlin("plugin.compose") version "2.3.21" + kotlin("plugin.serialization") version "2.3.21" + id("com.android.library") version "8.13.2" + id("com.android.application") version "8.13.2" id("org.jetbrains.compose") version "1.7.3" id("app.cash.sqldelight") version "2.3.2" id("io.github.takahirom.roborazzi") version "1.59.0"