From 5af72d54afde07c60abfefedc97fed2a8a50525c Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Fri, 20 Mar 2026 12:37:49 -0500 Subject: [PATCH 01/27] Auto-install commit-id hook on push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Push now calls installCommitIdHook() early in the flow, ensuring the commit-msg hook is always present without requiring a separate manual step. The install is idempotent — it skips the write when the hook is already up-to-date. The stale warning suggesting users run install-commit-id-hook manually is removed. Closes git-jaspr-39h commit-id: If2ec2508 --- .../kotlin/sims/michael/gitjaspr/GitJaspr.kt | 16 +++++++----- .../sims/michael/gitjaspr/GitJasprTest.kt | 26 +++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt index 6736ef9d..06b43be1 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt @@ -335,6 +335,8 @@ class GitJaspr( ) } + installCommitIdHook() + val remoteName = config.remoteName gitClient.fetch(remoteName) @@ -1055,13 +1057,16 @@ class GitJaspr( val hooksDir = config.workingDirectory.resolve(".git").resolve("hooks") require(hooksDir.isDirectory) val hook = hooksDir.resolve(COMMIT_MSG_HOOK) - val source = checkNotNull(javaClass.getResourceAsStream("/$COMMIT_MSG_HOOK")) + val bundledContent = + checkNotNull(javaClass.getResourceAsStream("/$COMMIT_MSG_HOOK")).use { it.readBytes() } + if (hook.exists() && hook.canExecute() && hook.readBytes().contentEquals(bundledContent)) { + logger.trace("Commit-msg hook is already up-to-date") + return + } renderer.info { "Installing/overwriting ${entity(COMMIT_MSG_HOOK)} to ${entity(hook.toString())} and setting the executable bit" } - source.use { inStream -> - hook.outputStream().use { outStream -> inStream.copyTo(outStream) } - } + hook.writeBytes(bundledContent) check(hook.setExecutable(true)) { "Failed to set the executable bit on $hook" } } @@ -1407,9 +1412,6 @@ class GitJaspr( renderer.warn { "Some commits in your local stack are missing commit IDs and are being amended to add them." } - renderer.warn { - "Consider running ${command(InstallCommitIdHook().commandName)} to avoid this in the future." - } val missing = commits.slice(indexOfFirstCommitMissingId until commits.size) val refName = "${missing.first().hash}^" gitClient.reset(refName) diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt index 623ac1bb..2badde34 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt @@ -1247,6 +1247,32 @@ interface GitJasprTest { // endregion // region push tests + @Push + @Test + fun `push installs commit-id hook`() { + withTestSetup(useFakeRemote) { + createCommitsFrom( + testCase { + repository { + commit { + title = "one" + localRefs += "main" + remoteRefs += "main" + } + } + } + ) + + val hook = localRepo.resolve(".git").resolve("hooks").resolve("commit-msg") + assertFalse(hook.exists()) + + push() + + assertTrue(hook.exists()) + assertTrue(hook.canExecute()) + } + } + @Push @Test fun `push fetches from remote`() { From c21f7298e752ddeba535bea8b7482c33cd034c7c Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Fri, 20 Mar 2026 13:12:17 -0500 Subject: [PATCH 02/27] Cache the auto-merge clone in .git/jaspr/automerge Auto-merge now reuses a cached clone inside .git/jaspr/automerge/ instead of creating a fresh temp directory each time. On subsequent runs it fetches instead of cloning, avoiding redundant network and disk I/O. A file lock (.git/jaspr/automerge.lock) prevents concurrent auto-merge processes from colliding. Closes git-jaspr-9hl commit-id: I128274c1 --- .../kotlin/sims/michael/gitjaspr/GitJaspr.kt | 119 ++++++++++-------- 1 file changed, 66 insertions(+), 53 deletions(-) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt index 06b43be1..6551f648 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt @@ -1,6 +1,8 @@ package sims.michael.gitjaspr -import java.nio.file.Files +import java.io.File +import java.io.RandomAccessFile +import java.nio.channels.FileLock import java.time.ZonedDateTime import java.util.SortedSet import kotlin.text.RegexOption.IGNORE_CASE @@ -680,67 +682,65 @@ class GitJaspr( // Use the topmost non-excluded commit as the localRef for auto-merge val adjustedLocalRef = filteredStack.last().hash - // We'll execute the auto-merge in a temporary clone after grabbing the current HEAD ref. + // We'll execute the auto-merge in a cached clone after grabbing the current HEAD ref. // This way the user can run this in the background or in another terminal and continue to // use their working copy without interfering with the auto-merge process. val currentRef = gitClient.log(refSpec.localRef, 1).first().hash - val tempRefSpec = refSpec.copy(localRef = adjustedLocalRef) - logger.trace("autoMerge refSpec: {}", tempRefSpec) - - val tempDir = - withContext(Dispatchers.IO) { - Files.createTempDirectory("git-jaspr-automerge-").toFile() - } - logger.debug("Created temporary directory for auto-merge: {}", tempDir.absolutePath) + val autoMergeRefSpec = refSpec.copy(localRef = adjustedLocalRef) + logger.trace("autoMerge refSpec: {}", autoMergeRefSpec) val remoteUri = requireNotNull(gitClient.getRemoteUriOrNull(remoteName)) { "Could not find remote URI for remote: $remoteName" } - val tempGit = OptimizedCliGitClient(tempDir, config.remoteBranchPrefix) + val cacheDir = getAutoMergeCacheDir() + val lockFile = RandomAccessFile(File("${cacheDir.absolutePath}.lock"), "rw") + val lock = acquireAutoMergeLock(lockFile) + try { - renderer.info { "Cloning repository to temporary directory for auto-merge..." } - val cloneTime = measureTime { + val cacheDirGit = OptimizedCliGitClient(cacheDir, config.remoteBranchPrefix) + val isCached = cacheDir.resolve(".git").isDirectory + val setupTime = measureTime { coroutineScope { val heartbeat = launch { delay(5.seconds) while (isActive) { - renderer.info { "Still cloning, please wait... (CTRL-C to cancel)" } + renderer.info { "Still working, please wait... (CTRL-C to cancel)" } delay(5.seconds) } } withContext(Dispatchers.IO) { - tempGit.clone(remoteUri, remoteName) + if (isCached) { + renderer.info { "Fetching latest changes into auto-merge cache..." } + cacheDirGit.fetch(remoteName) + } else { + renderer.info { "Cloning repository into auto-merge cache..." } + cacheDirGit.clone(remoteUri, remoteName) + } - // Add the original working directory as a remote so we can fetch unpushed - // commits - logger.debug("Adding local remote for unpushed commits") - tempGit.addRemote("local", config.workingDirectory.absolutePath) - tempGit.fetch("local") + // Add/update the original working directory as a remote so we can fetch + // unpushed commits + if (cacheDirGit.getRemoteUriOrNull("local") == null) { + logger.debug("Adding local remote for unpushed commits") + cacheDirGit.addRemote("local", config.workingDirectory.absolutePath) + } + cacheDirGit.fetch("local") - tempGit.checkout(currentRef) + cacheDirGit.checkout(currentRef) } heartbeat.cancel() } } - logger.debug("Cloned repository to temporary directory in {}", cloneTime) - } catch (e: Exception) { - logger.error( - "Failed to set up temporary clone for auto-merge in ${tempDir.absolutePath}", - e, - ) - tempDir.deleteRecursively() - throw e - } + val verb = if (isCached) "Fetched" else "Cloned" + logger.debug("{} auto-merge cache in {}", verb, setupTime) - // Run the auto-merge loop - try { - val tempJaspr = + // Run the auto-merge loop + val cacheDirJaspr = GitJaspr( ghClient, - tempGit, - config.copy(workingDirectory = tempDir), + cacheDirGit, + config.copy(workingDirectory = cacheDir), newUuid, commitIdentOverride, renderer, @@ -749,36 +749,39 @@ class GitJaspr( var attemptsMade = 0 while (attemptsMade < maxAttempts) { val numCommitsBehind = - tempGit - .logRange(tempRefSpec.localRef, "$remoteName/${tempRefSpec.remoteRef}") + cacheDirGit + .logRange( + autoMergeRefSpec.localRef, + "$remoteName/${autoMergeRefSpec.remoteRef}", + ) .size if (numCommitsBehind > 0) { val commits = if (numCommitsBehind > 1) "commits" else "commit" - showMergeOutOfDateWarning(numCommitsBehind, commits, tempRefSpec) + showMergeOutOfDateWarning(numCommitsBehind, commits, autoMergeRefSpec) break } val stack = - tempGit.getLocalCommitStack( + cacheDirGit.getLocalCommitStack( remoteName, - tempRefSpec.localRef, - tempRefSpec.remoteRef, + autoMergeRefSpec.localRef, + autoMergeRefSpec.remoteRef, ) if (stack.isEmpty()) { showStackIsEmptyWarning() break } - val statuses = tempJaspr.getRemoteCommitStatuses(stack) + val statuses = cacheDirJaspr.getRemoteCommitStatuses(stack) if (statuses.all(RemoteCommitStatus::isMergeable)) { - tempJaspr.merge(tempRefSpec) + cacheDirJaspr.merge(autoMergeRefSpec) // Since we merged from a separate directory, the local working copy will be // out of date, so let's fetch the latest changes. gitClient.fetch(remoteName) break } - print(tempJaspr.getStatusString(tempRefSpec)) + print(cacheDirJaspr.getStatusString(autoMergeRefSpec)) if (statuses.any { status -> status.checksPass == false }) { renderer.warn { "Checks are failing. Aborting auto-merge." } @@ -795,21 +798,18 @@ class GitJaspr( } delay(pollingIntervalSeconds.seconds) // Fetch the latest changes before we try again - tempGit.fetch(remoteName) + cacheDirGit.fetch(remoteName) } - - // Either the merge was successful, or we exited the loop because the stack was not - // mergeable. Either way we delete the temp directory. - tempDir.deleteRecursively() - logger.debug("Cleaned up temporary directory: {}", tempDir.absolutePath) } catch (e: Exception) { - // Keep the temporary directory on exception for troubleshooting logger.error( - "Auto-merge failed with exception. Temporary directory has been retained for troubleshooting: {}", - tempDir.absolutePath, + "Auto-merge failed with exception. Cache directory: {}", + cacheDir.absolutePath, e, ) throw e + } finally { + lock.release() + lockFile.close() } } @@ -1761,6 +1761,19 @@ class GitJaspr( renderer, ) + private fun getAutoMergeCacheDir(): File { + val jaspDir = config.workingDirectory.resolve(".git/jaspr") + jaspDir.mkdirs() + return jaspDir.resolve("automerge") + } + + private fun acquireAutoMergeLock(lockFile: RandomAccessFile): FileLock = + lockFile.channel.tryLock() + ?: throw GitJasprException( + "Another auto-merge is already running for this repository. " + + "If this is unexpected, check for stale processes." + ) + companion object { private val HEADER = """ From 5454c79d4fb732eaa9280590b66aa4889824ec73 Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Fri, 20 Mar 2026 13:22:33 -0500 Subject: [PATCH 03/27] Improve auto-merge clone output Show git clone/fetch progress on stderr during auto-merge setup via a new showStderr toggle on CliGitClient, replacing the heartbeat polling message. Pass the user's theme through to the status display so the auto-merge polling loop renders in color. Closes git-jaspr-290 commit-id: I28fec58d --- .../main/kotlin/sims/michael/gitjaspr/Cli.kt | 2 +- .../sims/michael/gitjaspr/CliGitClient.kt | 4 ++ .../kotlin/sims/michael/gitjaspr/GitJaspr.kt | 47 ++++++++----------- .../michael/gitjaspr/OptimizedCliGitClient.kt | 9 +++- 4 files changed, 32 insertions(+), 30 deletions(-) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt index 71592fa7..46270f13 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt @@ -670,7 +670,7 @@ class AutoMerge : GitJasprSubcommand(helpText = "Wait for checks then merge") { override suspend fun doRun() { requireCountLocalExclusive(count, targetRef.local) - appWiring.gitJaspr.autoMerge(targetRef.refSpec, interval, count = count) + appWiring.gitJaspr.autoMerge(targetRef.refSpec, interval, count = count, theme = theme) } } diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/CliGitClient.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/CliGitClient.kt index 74234d65..59a0e0e5 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/CliGitClient.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/CliGitClient.kt @@ -14,6 +14,9 @@ class CliGitClient( private val logger = LoggerFactory.getLogger(CliGitClient::class.java) + /** When true, git commands write their stderr (e.g. progress) directly to the terminal. */ + var showStderr = false + override fun init(): GitClient { logger.trace("init") require(workingDirectory.exists() || workingDirectory.mkdir()) { @@ -590,6 +593,7 @@ class CliGitClient( .command(command) .destroyOnExit() .readOutput(true) + .apply { if (showStderr) redirectError(System.err) } .execute() .also(ProcessResult::requireZeroExitValue) } diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt index 6551f648..1aa2305e 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt @@ -9,9 +9,7 @@ import kotlin.text.RegexOption.IGNORE_CASE import kotlin.time.Duration.Companion.seconds import kotlin.time.measureTime import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.slf4j.LoggerFactory @@ -658,6 +656,7 @@ class GitJaspr( pollingIntervalSeconds: Int = 10, maxAttempts: Int = Int.MAX_VALUE, count: Int? = null, + theme: Theme = MonoTheme, ) { logger.trace("autoMerge {} {}", refSpec, pollingIntervalSeconds) @@ -702,34 +701,26 @@ class GitJaspr( val cacheDirGit = OptimizedCliGitClient(cacheDir, config.remoteBranchPrefix) val isCached = cacheDir.resolve(".git").isDirectory val setupTime = measureTime { - coroutineScope { - val heartbeat = launch { - delay(5.seconds) - while (isActive) { - renderer.info { "Still working, please wait... (CTRL-C to cancel)" } - delay(5.seconds) - } + withContext(Dispatchers.IO) { + cacheDirGit.showStderr = true + if (isCached) { + renderer.info { "Fetching latest changes into auto-merge cache..." } + cacheDirGit.fetch(remoteName) + } else { + renderer.info { "Cloning repository into auto-merge cache..." } + cacheDirGit.clone(remoteUri, remoteName) } - withContext(Dispatchers.IO) { - if (isCached) { - renderer.info { "Fetching latest changes into auto-merge cache..." } - cacheDirGit.fetch(remoteName) - } else { - renderer.info { "Cloning repository into auto-merge cache..." } - cacheDirGit.clone(remoteUri, remoteName) - } - - // Add/update the original working directory as a remote so we can fetch - // unpushed commits - if (cacheDirGit.getRemoteUriOrNull("local") == null) { - logger.debug("Adding local remote for unpushed commits") - cacheDirGit.addRemote("local", config.workingDirectory.absolutePath) - } - cacheDirGit.fetch("local") - cacheDirGit.checkout(currentRef) + // Add/update the original working directory as a remote so we can fetch + // unpushed commits + if (cacheDirGit.getRemoteUriOrNull("local") == null) { + logger.debug("Adding local remote for unpushed commits") + cacheDirGit.addRemote("local", config.workingDirectory.absolutePath) } - heartbeat.cancel() + cacheDirGit.fetch("local") + cacheDirGit.showStderr = false + + cacheDirGit.checkout(currentRef) } } val verb = if (isCached) "Fetched" else "Cloned" @@ -781,7 +772,7 @@ class GitJaspr( gitClient.fetch(remoteName) break } - print(cacheDirJaspr.getStatusString(autoMergeRefSpec)) + print(cacheDirJaspr.getStatusString(autoMergeRefSpec, theme)) if (statuses.any { status -> status.checksPass == false }) { renderer.warn { "Checks are failing. Aborting auto-merge." } diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/OptimizedCliGitClient.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/OptimizedCliGitClient.kt index f02b6a32..62453813 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/OptimizedCliGitClient.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/OptimizedCliGitClient.kt @@ -14,6 +14,13 @@ class OptimizedCliGitClient private constructor(private val cliGitClient: CliGitClient, private val jGitClient: JGitClient) : GitClient by jGitClient { + /** When true, git commands write their stderr (e.g. progress) directly to the terminal. */ + var showStderr: Boolean + get() = cliGitClient.showStderr + set(value) { + cliGitClient.showStderr = value + } + override fun clone(uri: String, remoteName: String, bare: Boolean): GitClient { cliGitClient.clone(uri, remoteName, bare) return this @@ -39,7 +46,7 @@ private constructor(private val cliGitClient: CliGitClient, private val jGitClie operator fun invoke( workingDirectory: File, remoteBranchPrefix: String = RemoteRefEncoding.DEFAULT_REMOTE_BRANCH_PREFIX, - ): GitClient { + ): OptimizedCliGitClient { val cliGitClient = CliGitClient(workingDirectory, remoteBranchPrefix) val jGitClient = JGitClient(workingDirectory, remoteBranchPrefix) return OptimizedCliGitClient(cliGitClient, jGitClient) From 7dfbee4c3996a5ac288672f2c98367a697a4c26c Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Fri, 20 Mar 2026 15:08:03 -0500 Subject: [PATCH 04/27] Allow empty commits in git client and test DSL Always pass --allow-empty / setAllowEmpty(true) in both CliGitClient and JGitClient commit methods so empty commits are never rejected. Add an `empty` property to the test DSL so tests can create empty commits declaratively. commit-id: Ia9144a37 --- .../sims/michael/gitjaspr/CliGitClient.kt | 2 + .../sims/michael/gitjaspr/JGitClient.kt | 4 +- .../gitjaspr/githubtests/GitHubTestHarness.kt | 63 ++++++++++--------- .../gitjaspr/githubtests/GitHubDslModel.kt | 1 + 4 files changed, 38 insertions(+), 32 deletions(-) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/CliGitClient.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/CliGitClient.kt index 59a0e0e5..8a949b85 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/CliGitClient.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/CliGitClient.kt @@ -257,6 +257,8 @@ class CliGitClient( val command = buildList { add("git") add("commit") + // Allow empty so commits with no tree changes (e.g. --allow-empty) don't get rejected + add("--allow-empty") if (amend) { add("--amend") } diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/JGitClient.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/JGitClient.kt index 29eeb4e7..c9115220 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/JGitClient.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/JGitClient.kt @@ -268,7 +268,9 @@ class JGitClient( return useGit { git -> fun createCommitCommand(): CommitCommand { - val commitCommand = git.commit().setAmend(amend) + // Allow empty so commits with no tree changes (e.g. --allow-empty) don't get + // rejected + val commitCommand = git.commit().setAmend(amend).setAllowEmpty(true) if (message != null || footerLines != null) { val existingFullMessage: String? val existingFooterLines: Map? diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/githubtests/GitHubTestHarness.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/githubtests/GitHubTestHarness.kt index 1d1a0f5d..d0406195 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/githubtests/GitHubTestHarness.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/githubtests/GitHubTestHarness.kt @@ -386,39 +386,40 @@ private constructor( } private fun CommitData.create(): Commit { - val file = localRepo.resolve("${title.sanitize()}.txt") - file.writeText("Title: $title\n") + if (!empty) { + val file = localRepo.resolve("${title.sanitize()}.txt") + file.writeText("Title: $title\n") + localGit.add(file.name) + } val safeId = id val safeWillPassVerification = willPassVerification - return localGit - .add(file.name) - .commit( - message = - if (body.isNotEmpty()) { - title.trim() + "\n\n" + body.trim() + "\n" - } else { - title - }, - footerLines = - buildMap { - putAll(footerLines) - if (safeId == null || safeId.isNotBlank()) { - put( - COMMIT_ID_LABEL, - safeId - ?: title.also { - require(!it.contains("\\s+".toRegex())) { - "ID wasn't provided and title '$it' can\'t be used as it contains whitespace." - } - }, - ) - } - if (safeWillPassVerification != null) { - put("verify-result", if (safeWillPassVerification) "0" else "13") - } - }, - committer = committer.toIdent(), - ) + return localGit.commit( + message = + if (body.isNotEmpty()) { + title.trim() + "\n\n" + body.trim() + "\n" + } else { + title + }, + footerLines = + buildMap { + putAll(footerLines) + if (safeId == null || safeId.isNotBlank()) { + put( + COMMIT_ID_LABEL, + safeId + ?: title.also { + require(!it.contains("\\s+".toRegex())) { + "ID wasn't provided and title '$it' can\'t be used as it contains whitespace." + } + }, + ) + } + if (safeWillPassVerification != null) { + put("verify-result", if (safeWillPassVerification) "0" else "13") + } + }, + committer = committer.toIdent(), + ) } private fun IdentData.toIdent(): Ident = diff --git a/github-dsl-model/src/main/java/sims/michael/gitjaspr/githubtests/GitHubDslModel.kt b/github-dsl-model/src/main/java/sims/michael/gitjaspr/githubtests/GitHubDslModel.kt index 099eb153..522e41f0 100644 --- a/github-dsl-model/src/main/java/sims/michael/gitjaspr/githubtests/GitHubDslModel.kt +++ b/github-dsl-model/src/main/java/sims/michael/gitjaspr/githubtests/GitHubDslModel.kt @@ -34,6 +34,7 @@ interface Commit : DataClassFragment { val prEndTitle: StringPropertyNotNull val footerLines: MapPropertyNotNull val willPassVerification: BooleanProperty + val empty: BooleanPropertyNotNull } @GenerateDataClassFragmentDataClass From 0b6992607aa9adaf9141a04b0b087f3dbf8029af Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Fri, 20 Mar 2026 15:08:55 -0500 Subject: [PATCH 05/27] Fix setCommitId crash on empty commits CliGitClient: add --allow-empty to cherry-pick so it doesn't fail on commits with no file changes. JGitClient: detect when cherry-pick silently skips an empty commit (HEAD doesn't advance despite Ok status) and create the commit manually with allowEmpty. Use noVerify to prevent the commit-msg hook from adding a duplicate commit-id footer. Fixes git-jaspr-78b commit-id: I2d842b88 --- .../sims/michael/gitjaspr/CliGitClient.kt | 2 +- .../sims/michael/gitjaspr/JGitClient.kt | 20 +++++++++++-- .../sims/michael/gitjaspr/GitJasprTest.kt | 28 +++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/CliGitClient.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/CliGitClient.kt index 8a949b85..0807f1d9 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/CliGitClient.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/CliGitClient.kt @@ -294,7 +294,7 @@ class CliGitClient( override fun cherryPick(commit: Commit, committer: Ident?, author: Ident?): Commit { logger.trace("cherryPick {} {} {}", commit, committer, author) val env = getIdentEnvironmentMap(committer, author) - executeCommand(listOf("git", "cherry-pick", commit.hash), env) + executeCommand(listOf("git", "cherry-pick", "--allow-empty", commit.hash), env) if (author != null && log("HEAD", 1).single().author != author) { logger.debug("Resetting author to {} after cherry-pick via commit --amend", author) executeCommand(listOf("git", "commit", "--amend", "--no-edit", "--reset-author"), env) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/JGitClient.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/JGitClient.kt index c9115220..256250b7 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/JGitClient.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/JGitClient.kt @@ -326,9 +326,25 @@ class JGitClient( override fun cherryPick(commit: Commit, committer: Ident?, author: Ident?): Commit { logger.trace("cherryPick {} {} {}", commit, committer, author) return useGit { git -> - git.cherryPick().include(git.repository.resolve(commit.hash)).call() - val r = git.repository + val headBefore = r.findRef(GitClient.HEAD).objectId + git.cherryPick().include(r.resolve(commit.hash)).call() + val headAfter = r.findRef(GitClient.HEAD).objectId + + if (headBefore == headAfter) { + // JGit's cherry-pick reports Ok for empty commits but doesn't create a new commit. + // Clean up any leftover cherry-pick state and create the commit manually. + r.writeCherryPickHead(null) + r.writeMergeCommitMsg(null) + val sourceCommit = r.parseCommit(r.resolve(commit.hash)) + git.commit() + .setAllowEmpty(true) + .setNoVerify(true) + .setMessage(sourceCommit.fullMessage) + .setAuthor(sourceCommit.authorIdent) + .call() + } + val headCommit = r.parseCommit(r.findRef(GitClient.HEAD).objectId).toCommit(git) val isUpdatingCommitter = committer != null && committer != headCommit.committer diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt index 2badde34..ad6231bf 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt @@ -1273,6 +1273,34 @@ interface GitJasprTest { } } + @Push + @Test + fun `push handles empty commits without commit IDs`() { + withTestSetup(useFakeRemote) { + createCommitsFrom( + testCase { + repository { + commit { + title = "one" + remoteRefs += "main" + } + commit { + title = "empty" + id = "" // no commit-id footer + empty = true + localRefs += "development" + } + } + } + ) + + push() + + val stack = localGit.getLocalCommitStack(remoteName, "HEAD", DEFAULT_TARGET_REF) + assertTrue(stack.all { it.id != null }) + } + } + @Push @Test fun `push fetches from remote`() { From f7f47444c1831a501a62317a6785c6aecffb3157 Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Fri, 20 Mar 2026 15:47:24 -0500 Subject: [PATCH 06/27] Surface ambiguous stack name in status and push prompt When commits exist in multiple named stacks, jaspr now explains the ambiguity instead of silently falling through. The status display shows a warning with the conflicting stack names. The push prompt includes the conflicting names as suggestions alongside commit-based candidates so the user can pick an existing stack or create a new one. suggestStackNames now returns a StackNameSuggestions type that carries both candidates and any ambiguous stack names. The push error message for missing stack names also distinguishes ambiguity from not-found. Fixes git-jaspr-37l commit-id: I2ed45ef5 --- .../main/kotlin/sims/michael/gitjaspr/Cli.kt | 17 ++++-- .../kotlin/sims/michael/gitjaspr/GitJaspr.kt | 59 ++++++++++++++----- .../sims/michael/gitjaspr/GitJasprTest.kt | 19 ++++-- 3 files changed, 72 insertions(+), 23 deletions(-) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt index 46270f13..1434f61b 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt @@ -577,16 +577,23 @@ class Push : GitJasprSubcommand(helpText = "Push commits and create/update PRs") val jaspr = appWiring.gitJaspr fun promptForNameIfNecessary(): String? { - val candidates = jaspr.suggestStackNames(targetRef.refSpec) - if (candidates.isEmpty()) return null - if (useFzf && candidates.size > 1) { - when (val result = selectNameViaFzf(candidates)) { + val suggestions = jaspr.suggestStackNames(targetRef.refSpec) + if (suggestions.candidates.isEmpty()) return null + if (suggestions.ambiguousStackNames.isNotEmpty()) { + renderer.warn { + "Commits exist in multiple stacks: " + + suggestions.ambiguousStackNames.joinToString(", ") { entity(it) } + + ". Select one to update it, or choose a new name." + } + } + if (useFzf && suggestions.candidates.size > 1) { + when (val result = selectNameViaFzf(suggestions.candidates)) { is FzfResult.Selected -> return result.value is FzfResult.Cancelled -> throw ProgramResult(130) is FzfResult.NotAvailable -> {} // fall through to prompt } } - return promptForStackName(candidates.first()) + return promptForStackName(suggestions.candidates.first()) } val effectiveName = name ?: promptForNameIfNecessary() diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt index 1aa2305e..b26c8f17 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt @@ -189,7 +189,17 @@ class GitJaspr( val numCommitsAhead: Int, val numCommitsBehind: Int, ) - val stackName = (getExistingStackName(stack, remoteBranches, strategy) as? Found)?.name + val stackSearchResult = getExistingStackName(stack, remoteBranches, strategy) + if (stackSearchResult is MultipleStacksContainCommit) { + appendLine() + appendLine( + theme.warning( + "Stack name could not be determined: commits exist in multiple stacks: " + + stackSearchResult.stackNames.joinToString(", ") { theme.entity(it) } + ) + ) + } + val stackName = (stackSearchResult as? Found)?.name if (stackName != null) { val headStackCommit = stack.last().hash val trackingBranch = "$remoteName/$stackName" @@ -233,6 +243,11 @@ class GitJaspr( } } + data class StackNameSuggestions( + val candidates: List, + val ambiguousStackNames: List = emptyList(), + ) + private sealed class NamedStackSearchResult private data class Found(val name: String) : NamedStackSearchResult() @@ -404,8 +419,9 @@ class GitJaspr( val localRef = gitClient.log(filteredRefSpec.localRef, 1).single().hash // Determine the effective stack name + val stackSearchResult = getExistingStackName(stack) val existingStackName = - (getExistingStackName(stack) as? Found)?.name?.let { existingBranchName -> + (stackSearchResult as? Found)?.name?.let { existingBranchName -> checkNotNull( RemoteNamedStackRef.parse( existingBranchName, @@ -419,7 +435,13 @@ class GitJaspr( RemoteNamedStackRef(stackName, targetRef, config.remoteNamedStackBranchPrefix) } else { checkNotNull(existingStackName) { - "No stack name provided and no existing stack name found on the remote." + if (stackSearchResult is MultipleStacksContainCommit) { + "No stack name provided and commits exist in multiple stacks: " + + stackSearchResult.stackNames.joinToString(", ") + + ". Use --name to specify which stack to push to." + } else { + "No stack name provided and no existing stack name found on the remote." + } } } @@ -1568,7 +1590,7 @@ class GitJaspr( */ fun suggestStackNames( refSpec: RefSpec = RefSpec(DEFAULT_LOCAL_OBJECT, DEFAULT_TARGET_REF) - ): List { + ): StackNameSuggestions { val remoteName = config.remoteName gitClient.fetch(remoteName) @@ -1585,18 +1607,27 @@ class GitJaspr( .included } - if (stack.isEmpty()) return emptyList() + if (stack.isEmpty()) return StackNameSuggestions(emptyList()) - val existingName = - (getExistingStackName(stack) as? Found)?.name?.let { existingBranchName -> - RemoteNamedStackRef.parse(existingBranchName, config.remoteNamedStackBranchPrefix) - ?.stackName - } - if (existingName != null) return emptyList() + val searchResult = getExistingStackName(stack) - return stack - .flatMap { commit -> StackNameGenerator.generateNameCandidates(commit.shortMessage) } - .distinct() + if (searchResult is Found) return StackNameSuggestions(emptyList()) + + val commitBasedCandidates = + stack + .flatMap { commit -> + StackNameGenerator.generateNameCandidates(commit.shortMessage) + } + .distinct() + + return when (searchResult) { + is MultipleStacksContainCommit -> + StackNameSuggestions( + candidates = searchResult.stackNames + commitBasedCandidates, + ambiguousStackNames = searchResult.stackNames, + ) + else -> StackNameSuggestions(commitBasedCandidates) + } } private fun refOrRefs(count: Int) = if (count == 1) "ref" else "refs" diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt index ad6231bf..6fc83403 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt @@ -2112,7 +2112,7 @@ interface GitJasprTest { localGit.checkout(localGit.log("main", 2).last().hash) // Now that our HEAD commit is reachable by two stacks, this should be considered - // ambiguous, which displays the same as not finding a stack + // ambiguous — a warning is displayed with the conflicting stack names val detachedHeadActual = getAndPrintStatusString() assertEquals( """ @@ -2120,7 +2120,10 @@ interface GitJasprTest { |[✅✅✅✅✅✅] %s : %s : one """ .trimMargin() - .toStatusString(detachedHeadActual, null), + .toStatusString( + detachedHeadActual, + ambiguousStackNames = listOf(secondStackName, stackName), + ), detachedHeadActual, ) } @@ -2408,7 +2411,7 @@ interface GitJasprTest { ) val suggested = gitJaspr.suggestStackNames() - assertEquals(listOf("one", "two"), suggested) + assertEquals(listOf("one", "two"), suggested.candidates) } } @@ -2434,7 +2437,7 @@ interface GitJasprTest { // suggestStackNames should return empty since the stack already has a name val suggested = gitJaspr.suggestStackNames() - assertEquals(emptyList(), suggested) + assertEquals(emptyList(), suggested.candidates) } } @@ -4834,6 +4837,7 @@ interface GitJasprTest { private fun String.toStatusString( actual: String, namedStackInfo: NamedStackInfo? = null, + ambiguousStackNames: List = emptyList(), ): String { // Extract commit hashes and URLs from the actual string and put them into the expected. I // can't predict what they will be, so I only want to validate that they are present. @@ -4858,6 +4862,13 @@ interface GitJasprTest { val namedStackInfoString = buildString { // As above, this duplicates the string building logic defined in GitJaspr, but this is // so any changes to the rendering is done very deliberately. + if (ambiguousStackNames.isNotEmpty()) { + appendLine() + appendLine( + "Stack name could not be determined: commits exist in multiple stacks: " + + ambiguousStackNames.joinToString(", ") + ) + } if (namedStackInfo != null) { appendLine() appendLine("Stack name: ${namedStackInfo.name}") From a7ed6f55835675bf7dca3ef80a9aabbd983d7ea9 Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Fri, 10 Apr 2026 15:23:16 -0500 Subject: [PATCH 07/27] Allow push with untracked files in working directory Narrow the working directory clean check to only consider tracked file changes (staged, modified, deleted, conflicting), ignoring untracked files which are not affected by push's commit rewriting. Rename the interface method to isWorkingDirectoryCleanIgnoringUntracked to make the behavior explicit. Requested by Clark Perkins. commit-id: Ib3cf569f --- .../main/kotlin/sims/michael/gitjaspr/Cli.kt | 4 +-- .../sims/michael/gitjaspr/CliGitClient.kt | 9 +++-- .../kotlin/sims/michael/gitjaspr/GitClient.kt | 2 +- .../kotlin/sims/michael/gitjaspr/GitJaspr.kt | 5 +-- .../sims/michael/gitjaspr/JGitClient.kt | 9 +++-- .../sims/michael/gitjaspr/CliGitClientTest.kt | 33 +++++++++++++++++-- .../sims/michael/gitjaspr/GitJasprTest.kt | 20 +++++++++++ .../michael/gitjaspr/WorktreeSupportTest.kt | 2 +- 8 files changed, 69 insertions(+), 15 deletions(-) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt index 1434f61b..f0cb576d 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt @@ -568,9 +568,9 @@ class Push : GitJasprSubcommand(helpText = "Push commits and create/update PRs") override suspend fun doRun() { requireCountLocalExclusive(count, targetRef.local) - if (!appWiring.gitClient.isWorkingDirectoryClean()) { + if (appWiring.gitClient.hasUncommittedChangesToTrackedFiles()) { throw GitJasprException( - "Your working directory has local changes. " + + "Your working directory has uncommitted changes to tracked files. " + "Please commit or stash them and re-run the command." ) } diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/CliGitClient.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/CliGitClient.kt index 0807f1d9..aa7f5f40 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/CliGitClient.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/CliGitClient.kt @@ -99,9 +99,12 @@ class CliGitClient( .also { logger.trace("getParents {} {}", commit, it) } } - override fun isWorkingDirectoryClean(): Boolean { - logger.trace("isWorkingDirectoryClean") - return executeCommand(listOf("git", "status", "-s")).output.lines.isEmpty() + override fun hasUncommittedChangesToTrackedFiles(): Boolean { + logger.trace("hasUncommittedChangesToTrackedFiles") + return executeCommand(listOf("git", "status", "-s", "--untracked-files=no")) + .output + .lines + .isNotEmpty() } override fun getLocalCommitStack( diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitClient.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitClient.kt index 4080baca..370d36da 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitClient.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitClient.kt @@ -29,7 +29,7 @@ interface GitClient { fun logRange(since: String, until: String): List - fun isWorkingDirectoryClean(): Boolean + fun hasUncommittedChangesToTrackedFiles(): Boolean fun getLocalCommitStack( remoteName: String, diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt index b26c8f17..3c86cd12 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt @@ -344,9 +344,10 @@ class GitJaspr( ) { logger.trace("push {}", refSpec) - if (!gitClient.isWorkingDirectoryClean()) { + if (gitClient.hasUncommittedChangesToTrackedFiles()) { throw GitJasprException( - "Your working directory has local changes. Please commit or stash them and re-run the command." + "Your working directory has uncommitted changes to tracked files. " + + "Please commit or stash them and re-run the command." ) } diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/JGitClient.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/JGitClient.kt index 256250b7..104e8090 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/JGitClient.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/JGitClient.kt @@ -123,9 +123,12 @@ class JGitClient( commits.map { revCommit -> revCommit.toCommit(git) }.reversed() } - override fun isWorkingDirectoryClean(): Boolean { - logger.trace("isWorkingDirectoryClean") - return useGit { git -> git.status().call().isClean } + override fun hasUncommittedChangesToTrackedFiles(): Boolean { + logger.trace("hasUncommittedChangesToTrackedFiles") + return useGit { git -> + val call = git.status().call() + call.run { added + changed + removed + modified + missing + conflicting }.isNotEmpty() + } } override fun getLocalCommitStack( diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/CliGitClientTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/CliGitClientTest.kt index 923838eb..a80d6a66 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/CliGitClientTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/CliGitClientTest.kt @@ -232,7 +232,7 @@ class CliGitClientTest : GitClientTest { } @Test - fun `isWorkingDirectoryClean returns expected value`() { + fun `hasUncommittedChangesToTrackedFiles returns expected value`() { withTestSetup { createCommitsFrom( testCase { @@ -263,7 +263,34 @@ class CliGitClientTest : GitClientTest { check(readme.exists()) readme.appendText("This is a change") val git = CliGitClient(localGit.workingDirectory) - assertFalse(git.isWorkingDirectoryClean()) + assertTrue(git.hasUncommittedChangesToTrackedFiles()) + } + } + + @Test + fun `hasUncommittedChangesToTrackedFiles ignores untracked files`() { + withTestSetup { + createCommitsFrom( + testCase { + repository { + commit { + title = "one" + localRefs += "main" + } + } + } + ) + + val git = CliGitClient(localGit.workingDirectory) + assertFalse(git.hasUncommittedChangesToTrackedFiles()) + + // Create an untracked file — should still have no uncommitted changes + localRepo.resolve("untracked-file.txt").writeText("Not tracked by git") + assertFalse(git.hasUncommittedChangesToTrackedFiles()) + + // Modify a tracked file — now it should have uncommitted changes + localRepo.resolve("README.txt").appendText("Modified") + assertTrue(git.hasUncommittedChangesToTrackedFiles()) } } @@ -780,7 +807,7 @@ class CliGitClientTest : GitClientTest { mapOf("Co-authored-by" to "Michael Sims"), DEFAULT_COMMITTER, ) - assertTrue(git.isWorkingDirectoryClean()) + assertFalse(git.hasUncommittedChangesToTrackedFiles()) } } diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt index 6fc83403..bd942e94 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt @@ -102,6 +102,26 @@ interface GitJasprTest { } } + @Push + @Test + fun `push succeeds with untracked files in working directory`() { + withTestSetup(useFakeRemote) { + createCommitsFrom( + testCase { + repository { + commit { + title = "some_commit" + localRefs += "development" + } + } + } + ) + // Create an untracked file — this should not block push + localRepo.resolve("untracked-file.txt").writeText("This file is not tracked by git.\n") + push() + } + } + @Test fun `getRemoteCommitStatuses produces expected result`() { withTestSetup(useFakeRemote) { diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/WorktreeSupportTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/WorktreeSupportTest.kt index 8765ffac..fa93fc3e 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/WorktreeSupportTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/WorktreeSupportTest.kt @@ -221,7 +221,7 @@ class WorktreeSupportTest { assertTrue(branches.contains("worktree-branch"), "Should see worktree-branch") // JGit correctly reports working directory status - assertTrue(jgit.isWorkingDirectoryClean()) + assertFalse(jgit.hasUncommittedChangesToTrackedFiles()) } finally { tempDir.deleteRecursively() } From 1af8a9e086a3b44fc890c68e747dd385a16da11b Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Fri, 10 Apr 2026 15:45:34 -0500 Subject: [PATCH 08/27] Enable --autosquash in jaspr rebase Always pass --autosquash to git rebase so fixup/squash commits are automatically processed. On git >= 2.44 this works directly; on older versions falls back to --interactive with GIT_SEQUENCE_EDITOR=true to suppress the editor. commit-id: I0cd0f7a8 --- .../main/kotlin/sims/michael/gitjaspr/Cli.kt | 49 ++++++++++++++++++- .../kotlin/sims/michael/gitjaspr/CliTest.kt | 24 +++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt index f0cb576d..e68a83d2 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt @@ -947,9 +947,22 @@ class Rebase : GitJasprSubcommand() { throw ProgramResult(fetchResult) } - ProcessBuilder("git", "rebase", "$remoteName/$target") + val rebaseArgs = buildList { + add("git") + add("rebase") + add("--autosquash") + if (!gitSupportsNonInteractiveAutosquash(workingDirectory)) { + add("--interactive") + } + add("$remoteName/$target") + } + + ProcessBuilder(rebaseArgs) .directory(workingDirectory) .inheritIO() + // GIT_SEQUENCE_EDITOR=true is needed when --interactive is used to prevent + // the editor from opening. It's harmless when --interactive is absent. + .apply { environment()["GIT_SEQUENCE_EDITOR"] = "true" } .start() .waitFor() } @@ -964,6 +977,40 @@ class Rebase : GitJasprSubcommand() { } } +private const val GIT_AUTOSQUASH_MIN_VERSION = "2.44.0" + +/** Returns true if the installed git version supports `--autosquash` without `--interactive`. */ +private fun gitSupportsNonInteractiveAutosquash(workingDirectory: File): Boolean { + val versionOutput = + ProcessBuilder("git", "--version") + .directory(workingDirectory) + .redirectErrorStream(true) + .start() + .inputStream + .bufferedReader() + .readText() + .trim() + return isGitVersionAtLeast(versionOutput, GIT_AUTOSQUASH_MIN_VERSION) +} + +/** Packs a major.minor.patch version into a single comparable integer. */ +private fun versionNumber(parts: List) = parts[0] * 1_000_000 + parts[1] * 1_000 + parts[2] + +private fun parseVersionParts(version: String) = + version.split(".").take(3).mapNotNull { it.toIntOrNull() } + +/** + * Parses a `git --version` output string and returns true if the version is at least [minVersion]. + * + * Expected format: `"git version 2.51.2"` (may have additional suffix like `".windows.1"`). + * [minVersion] is a dotted version string like `"2.44.0"`. + */ +internal fun isGitVersionAtLeast(gitVersionOutput: String, minVersion: String): Boolean { + val actual = parseVersionParts(gitVersionOutput.removePrefix("git version ")) + if (actual.size < 3) return false + return versionNumber(actual) >= versionNumber(parseVersionParts(minVersion)) +} + class Edit : GitJasprSubcommand() { // language=Markdown override fun help(context: Context) = diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/CliTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/CliTest.kt index 6092af18..f3669989 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/CliTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/CliTest.kt @@ -419,6 +419,30 @@ class CliTest { logger.info("Temp dir created in {}", dir.toStringWithClickableURI()) return dir } + + @TestFactory + fun `isGitVersionAtLeast parses version strings correctly`(): List { + val minVersion = "2.44.0" + data class Case(val input: String, val expected: Boolean) + return listOf( + Case("git version 2.44.0", true), // Exact match + Case("git version 2.44.1", true), // Newer patch + Case("git version 2.51.2", true), // Newer minor + Case("git version 3.0.0", true), // Newer major + Case("git version 2.43.9", false), // Older minor + Case("git version 1.99.99", false), // Older major + Case("git version 2.43.99", false), // Just below threshold + Case("git version 2.44.0.windows.1", true), // Windows suffix + Case("git version 2.39.5 (Apple Git-154)", false), // macOS Xcode format + Case("not a version string", false), // Garbage input + Case("", false), // Empty + ) + .map { (input, expected) -> + dynamicTest("\"$input\" -> $expected") { + assertEquals(expected, isGitVersionAtLeast(input, minVersion)) + } + } + } } private fun getEffectiveConfigFromCli( From 725aa9c624563e369e4ea38f30d5bf7da1ebe9e5 Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Fri, 10 Apr 2026 16:03:41 -0500 Subject: [PATCH 09/27] Rename install-commit-id-hook to install-hook Shorter name that doesn't expose implementation detail. commit-id: Ia289b29f --- git-jaspr/src/docs/asciidoc/jaspr.1.adoc | 2 +- git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt | 4 ++-- git-jaspr/src/test/kotlin/sims/michael/gitjaspr/CliTest.kt | 4 ++-- .../kotlin/sims/michael/gitjaspr/NativeImageMetadataTest.kt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/git-jaspr/src/docs/asciidoc/jaspr.1.adoc b/git-jaspr/src/docs/asciidoc/jaspr.1.adoc index ac4f8bc9..a9f5161d 100644 --- a/git-jaspr/src/docs/asciidoc/jaspr.1.adoc +++ b/git-jaspr/src/docs/asciidoc/jaspr.1.adoc @@ -138,7 +138,7 @@ carried over automatically. *--show*::: Display the example config without writing it. -=== jaspr install-commit-id-hook +=== jaspr install-hook Install the Git *commit-msg* hook that appends a *commit-id* trailer to new commits. Run this once per repository. diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt index e68a83d2..640c2096 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt @@ -1090,7 +1090,7 @@ class Edit : GitJasprSubcommand() { } } -class InstallCommitIdHook : GitJasprSubcommand(helpText = "Install commit-id hook") { +class InstallHook : GitJasprSubcommand(helpText = "Install the jaspr commit-msg hook") { override suspend fun doRun() { appWiring.gitJaspr.installCommitIdHook() } @@ -1432,7 +1432,7 @@ fun buildCommand(): SuspendingCliktCommand = PreviewTheme(), LogPath(), Init(), - InstallCommitIdHook(), + InstallHook(), NoOp(), ) diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/CliTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/CliTest.kt index f3669989..30cb3984 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/CliTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/CliTest.kt @@ -253,7 +253,7 @@ class CliTest { "git@host:owner/name.git", expected.remoteName, homeDirConfig = mapOf("logs-directory" to scratchDir.logsDir().absolutePath), - strings = listOf("install-commit-id-hook"), + strings = listOf("install-hook"), ) assertTrue( scratchDir.repoDir().resolve(".git").resolve("hooks").resolve("commit-msg").canExecute() @@ -371,7 +371,7 @@ class CliTest { Stack().subcommands(StackList(), StackRename(), StackDelete()), PreviewTheme(), Init(), - InstallCommitIdHook(), + InstallHook(), ) val validOptionKeys = mutableSetOf() diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/NativeImageMetadataTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/NativeImageMetadataTest.kt index 859088d4..d4f8f467 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/NativeImageMetadataTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/NativeImageMetadataTest.kt @@ -284,7 +284,7 @@ class NativeImageMetadataTest { Stack().subcommands(StackList(), StackRename(), StackDelete()), PreviewTheme(), Init(), - InstallCommitIdHook(), + InstallHook(), NoOp(), ) .parse(listOf("--help")) From 7e1e961f71a6d502011dbbd6919c2cad9439e43d Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Fri, 10 Apr 2026 16:49:55 -0500 Subject: [PATCH 10/27] Add nav state infrastructure for stack navigation NavState data class (sourceBranch, savedTip, divergePoint) persisted as .git/jaspr/nav-state.json. Read/write/clear methods on GitJaspr plus staleness detection (state exists but HEAD is not detached). commit-id: Icd243b68 --- .../kotlin/sims/michael/gitjaspr/GitJaspr.kt | 36 ++++++++++++++++-- .../kotlin/sims/michael/gitjaspr/Model.kt | 22 +++++++++++ .../sims/michael/gitjaspr/GitJasprTest.kt | 38 +++++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt index 3c86cd12..216c8887 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt @@ -12,6 +12,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import org.slf4j.LoggerFactory import sims.michael.gitjaspr.CommitParsers.getSubjectAndBodyFromFullMessage import sims.michael.gitjaspr.CommitParsers.trimFooters @@ -31,6 +33,7 @@ class GitJaspr( private val newUuid: () -> String = { generateUuid() }, private val commitIdentOverride: Ident? = null, private val renderer: Renderer = NoOpRenderer, + private val json: Json = Json { prettyPrint = true }, ) { private val logger = LoggerFactory.getLogger(GitJaspr::class.java) @@ -1784,12 +1787,37 @@ class GitJaspr( renderer, ) - private fun getAutoMergeCacheDir(): File { - val jaspDir = config.workingDirectory.resolve(".git/jaspr") - jaspDir.mkdirs() - return jaspDir.resolve("automerge") + private fun getJasprDir(): File = + config.workingDirectory.resolve(".git/jaspr").also { it.mkdirs() } + + private fun getAutoMergeCacheDir(): File = getJasprDir().resolve("automerge") + + private val navStateFile + get() = getJasprDir().resolve("nav-state.json") + + fun readNavState(): NavState? { + val file = navStateFile + if (!file.exists()) return null + return try { + json.decodeFromString(file.readText()) + } catch (e: Exception) { + logger.warn("Failed to read nav state, clearing", e) + clearNavState() + null + } } + fun writeNavState(state: NavState) { + navStateFile.writeText(json.encodeToString(state)) + } + + fun clearNavState() { + navStateFile.delete() + } + + /** Returns true if HEAD is on a branch (not detached) and nav state exists */ + fun isNavStateStale(): Boolean = !gitClient.isHeadDetached() && readNavState() != null + private fun acquireAutoMergeLock(lockFile: RandomAccessFile): FileLock = lockFile.channel.tryLock() ?: throw GitJasprException( diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Model.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Model.kt index 43a51347..c5e16ae0 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Model.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Model.kt @@ -111,6 +111,28 @@ fun RateLimitFields.toRateLimitInfo(): GitHubRateLimitInfo = private fun String.iso8601ToLocalDate(): LocalDateTime = Instant.parse(this).atZone(ZoneId.systemDefault()).toLocalDateTime() +/** + * Ephemeral session state for stack navigation, persisted in `.git/jaspr/nav-state.json`. Exists + * only while the user is navigated to a mid-stack commit (detached HEAD). + */ +@Serializable +data class NavState( + /** The branch name HEAD was on before detaching, restored when the session ends. */ + val headBeforeDetach: String, + /** + * SHA that [headBeforeDetach] pointed to right before we entered deteached HEAD. Defines the + * top of the replay range. + */ + val headBeforeDetachSha: String, + /** + * SHA that HEAD pointed to as of the last navigation operation. Updated on each `jaspr + * down`/`up` invocation. If the user creates additional commits between nav operations, this + * becomes a true diverge point — the merge base between HEAD and the original stack. The replay + * range for `jaspr up`/`top` is `divergePoint..headBeforeDetachSha`. + */ + val divergePoint: String, +) + class GitJasprException(override val message: String) : RuntimeException(message) { constructor(message: String, cause: Throwable) : this(message) { initCause(cause) diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt index bd942e94..849b522b 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt @@ -4,6 +4,7 @@ import java.util.MissingFormatArgumentException import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import org.junit.jupiter.api.* import org.junit.jupiter.api.Assertions.assertTrue import org.slf4j.Logger @@ -81,6 +82,43 @@ interface GitJasprTest { assertEquals(expected, actual) } + // region nav state tests + @Test + fun `nav state round-trips through write and read`() { + withTestSetup(useFakeRemote) { + val state = + NavState( + headBeforeDetach = "my-feature", + headBeforeDetachSha = "abc123", + divergePoint = "def456", + ) + gitJaspr.writeNavState(state) + assertEquals(state, gitJaspr.readNavState()) + } + } + + @Test + fun `readNavState returns null when no state file exists`() { + withTestSetup(useFakeRemote) { assertNull(gitJaspr.readNavState()) } + } + + @Test + fun `clearNavState removes state file`() { + withTestSetup(useFakeRemote) { + val state = + NavState( + headBeforeDetach = "my-feature", + headBeforeDetachSha = "abc123", + divergePoint = "def456", + ) + gitJaspr.writeNavState(state) + gitJaspr.clearNavState() + assertNull(gitJaspr.readNavState()) + } + } + + // endregion + @Test fun `push fails unless workdir is clean`() { // This test fails when ran from GitJasprFunctionalExternalProcessTest because the exception From 0ec6faa773d5f259bf023143e358edc469f73396 Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Fri, 10 Apr 2026 16:59:34 -0500 Subject: [PATCH 11/27] Add jaspr down and jaspr bottom navigation commands Navigate down N commits in the stack (toward the target branch) or jump to the bottom. Detaches HEAD and records nav state so the session can be resumed with future up/top commands. Subsequent down commands within a session preserve the original source branch and saved tip. commit-id: I9ceae3f9 --- git-jaspr/build.gradle.kts | 2 +- .../main/kotlin/sims/michael/gitjaspr/Cli.kt | 39 +++++ .../kotlin/sims/michael/gitjaspr/GitJaspr.kt | 66 ++++++++ .../sims/michael/gitjaspr/GitJasprTest.kt | 151 ++++++++++++++++++ .../sims/michael/gitjaspr/testing/TestTags.kt | 5 + 5 files changed, 262 insertions(+), 1 deletion(-) diff --git a/git-jaspr/build.gradle.kts b/git-jaspr/build.gradle.kts index 6b699ebf..0af3f3f7 100644 --- a/git-jaspr/build.gradle.kts +++ b/git-jaspr/build.gradle.kts @@ -224,7 +224,7 @@ tasks.named("nativeImageMetadata") { // The easiest way to run these test groups in IDEA is to go to, f.e., GitJasprDefaultTest and click // the run button in the gutter. When prompted to choose tasks, choose the `test*` task(s) you want // to run. -val testGroups = listOf("status", "push", "prBody", "merge", "clean", "dontPush") +val testGroups = listOf("status", "push", "prBody", "merge", "clean", "dontPush", "nav") for (testTag in testGroups) { val taskName = "test" + testTag.replaceFirstChar { char -> char.uppercase() } diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt index 640c2096..3cb4acb2 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt @@ -22,6 +22,7 @@ import com.github.ajalt.clikt.core.terminal import com.github.ajalt.clikt.output.MordantHelpFormatter import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.parameters.arguments.convert +import com.github.ajalt.clikt.parameters.arguments.optional import com.github.ajalt.clikt.parameters.groups.OptionGroup import com.github.ajalt.clikt.parameters.groups.provideDelegate import com.github.ajalt.clikt.parameters.options.* @@ -1096,6 +1097,42 @@ class InstallHook : GitJasprSubcommand(helpText = "Install the jaspr commit-msg } } +// region Navigation commands +class Down : GitJasprSubcommand(helpText = "Move down in the stack (toward the target branch)") { + private val targetOpts by TargetOptions() + private val n by argument("n").int().optional() + + override suspend fun doRun() { + val jaspr = appWiring.gitJaspr + if (jaspr.isNavStateStale()) { + renderer.warn { + "Stale navigation state detected (you are on a branch). " + + "Run ${command("jaspr nav clear")} to clear it, or it will be replaced." + } + jaspr.clearNavState() + } + jaspr.navigateDown(targetOpts.target, n ?: 1) + } +} + +class Bottom : GitJasprSubcommand(helpText = "Move to the bottom of the stack") { + private val targetOpts by TargetOptions() + + override suspend fun doRun() { + val jaspr = appWiring.gitJaspr + if (jaspr.isNavStateStale()) { + renderer.warn { + "Stale navigation state detected (you are on a branch). " + + "Run ${command("jaspr nav clear")} to clear it, or it will be replaced." + } + jaspr.clearNavState() + } + jaspr.navigateToBottom(targetOpts.target) + } +} + +// endregion + class Stack : SuspendingCliktCommand(name = "stack") { override fun help(context: Context) = "Manage named stacks" @@ -1429,6 +1466,8 @@ fun buildCommand(): SuspendingCliktCommand = Rebase(), Edit(), Stack().subcommands(StackList(), StackRename(), StackDelete()), + Down(), + Bottom(), PreviewTheme(), LogPath(), Init(), diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt index 216c8887..8715e612 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt @@ -1818,6 +1818,68 @@ class GitJaspr( /** Returns true if HEAD is on a branch (not detached) and nav state exists */ fun isNavStateStale(): Boolean = !gitClient.isHeadDetached() && readNavState() != null + /** + * Navigate down N commits in the stack (toward the target branch). Detaches HEAD and writes nav + * state. + */ + fun navigateDown(targetRef: String, n: Int) { + val remoteName = config.remoteName + gitClient.fetch(remoteName) + val stack = gitClient.getLocalCommitStack(remoteName, GitClient.HEAD, targetRef) + require(stack.isNotEmpty()) { "Stack is empty." } + + val existingState = readNavState() + val currentIndex = + if (existingState != null && gitClient.isHeadDetached()) { + // Already in a nav session — find the current position + val headHash = gitClient.log(GitClient.HEAD, 1).single().hash + stack + .indexOfFirst { it.hash == headHash } + .also { index -> require(index >= 0) { "Current HEAD is not in the stack." } } + } else { + // Not in a session — HEAD is at the tip + stack.lastIndex + } + + val targetIndex = currentIndex - n + require(targetIndex >= 0) { + "Cannot move down $n commit(s) — only $currentIndex commit(s) below current position." + } + + val targetCommit = stack[targetIndex] + val headBeforeDetach = + existingState?.headBeforeDetach + ?: gitClient.getCurrentBranchName().also { + require(it.isNotEmpty()) { DETACHED_HEAD_NO_NAV_STATE } + } + val headBeforeDetachSha = + existingState?.headBeforeDetachSha ?: gitClient.log(GitClient.HEAD, 1).single().hash + + gitClient.checkout(targetCommit.hash) + writeNavState(NavState(headBeforeDetach, headBeforeDetachSha, targetCommit.hash)) + } + + /** Navigate to the bottom of the stack (first commit above the target branch). */ + fun navigateToBottom(targetRef: String) { + val remoteName = config.remoteName + gitClient.fetch(remoteName) + val stack = gitClient.getLocalCommitStack(remoteName, GitClient.HEAD, targetRef) + require(stack.isNotEmpty()) { "Stack is empty." } + + val existingState = readNavState() + val headBeforeDetach = + existingState?.headBeforeDetach + ?: gitClient.getCurrentBranchName().also { + require(it.isNotEmpty()) { DETACHED_HEAD_NO_NAV_STATE } + } + val headBeforeDetachSha = + existingState?.headBeforeDetachSha ?: gitClient.log(GitClient.HEAD, 1).single().hash + val targetCommit = stack.first() + + gitClient.checkout(targetCommit.hash) + writeNavState(NavState(headBeforeDetach, headBeforeDetachSha, targetCommit.hash)) + } + private fun acquireAutoMergeLock(lockFile: RandomAccessFile): FileLock = lockFile.channel.tryLock() ?: throw GitJasprException( @@ -1826,6 +1888,10 @@ class GitJaspr( ) companion object { + private const val DETACHED_HEAD_NO_NAV_STATE = + "HEAD is detached but no jaspr navigation state was found. " + + "Check out a branch pointing to a commit with a jaspr ID before navigating." + private val HEADER = """ | ┌─────────── commit pushed diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt index 849b522b..51068970 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt @@ -4,6 +4,7 @@ import java.util.MissingFormatArgumentException import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull import org.junit.jupiter.api.* import org.junit.jupiter.api.Assertions.assertTrue @@ -21,6 +22,7 @@ import sims.michael.gitjaspr.testing.Clean import sims.michael.gitjaspr.testing.DEFAULT_COMMITTER import sims.michael.gitjaspr.testing.DontPush import sims.michael.gitjaspr.testing.Merge +import sims.michael.gitjaspr.testing.Nav import sims.michael.gitjaspr.testing.PrBody import sims.michael.gitjaspr.testing.Push import sims.michael.gitjaspr.testing.Stack @@ -83,6 +85,7 @@ interface GitJasprTest { } // region nav state tests + @Nav @Test fun `nav state round-trips through write and read`() { withTestSetup(useFakeRemote) { @@ -97,11 +100,13 @@ interface GitJasprTest { } } + @Nav @Test fun `readNavState returns null when no state file exists`() { withTestSetup(useFakeRemote) { assertNull(gitJaspr.readNavState()) } } + @Nav @Test fun `clearNavState removes state file`() { withTestSetup(useFakeRemote) { @@ -119,6 +124,152 @@ interface GitJasprTest { // endregion + // region navigation tests + @Nav + @Test + fun `down detaches HEAD and writes nav state`() { + withTestSetup(useFakeRemote) { + createCommitsFrom( + testCase { + repository { + commit { title = "one" } + commit { title = "two" } + commit { + title = "three" + localRefs += "development" + } + } + checkout = "development" + } + ) + localGit.fetch(remoteName) + val stack = localGit.getLocalCommitStack(remoteName, GitClient.HEAD, DEFAULT_TARGET_REF) + assertEquals(3, stack.size) + + gitJaspr.navigateDown(DEFAULT_TARGET_REF, 1) + + assertTrue(localGit.isHeadDetached()) + assertEquals(stack[1].hash, localGit.log(GitClient.HEAD, 1).single().hash) + + val state = gitJaspr.readNavState() + assertNotNull(state) + assertEquals("development", state.headBeforeDetach) + assertEquals(stack.last().hash, state.headBeforeDetachSha) + assertEquals(stack[1].hash, state.divergePoint) + } + } + + @Nav + @Test + fun `down N navigates multiple commits`() { + withTestSetup(useFakeRemote) { + createCommitsFrom( + testCase { + repository { + commit { title = "one" } + commit { title = "two" } + commit { + title = "three" + localRefs += "development" + } + } + checkout = "development" + } + ) + localGit.fetch(remoteName) + val stack = localGit.getLocalCommitStack(remoteName, GitClient.HEAD, DEFAULT_TARGET_REF) + + gitJaspr.navigateDown(DEFAULT_TARGET_REF, 2) + + assertEquals(stack[0].hash, localGit.log(GitClient.HEAD, 1).single().hash) + } + } + + @Nav + @Test + fun `down past bottom of stack fails`() { + withTestSetup(useFakeRemote) { + createCommitsFrom( + testCase { + repository { + commit { title = "one" } + commit { + title = "two" + localRefs += "development" + } + } + checkout = "development" + } + ) + assertThrows { gitJaspr.navigateDown(DEFAULT_TARGET_REF, 3) } + } + } + + @Nav + @Test + fun `bottom navigates to first commit in stack`() { + withTestSetup(useFakeRemote) { + createCommitsFrom( + testCase { + repository { + commit { title = "one" } + commit { title = "two" } + commit { + title = "three" + localRefs += "development" + } + } + checkout = "development" + } + ) + localGit.fetch(remoteName) + val stack = localGit.getLocalCommitStack(remoteName, GitClient.HEAD, DEFAULT_TARGET_REF) + + gitJaspr.navigateToBottom(DEFAULT_TARGET_REF) + + assertTrue(localGit.isHeadDetached()) + assertEquals(stack.first().hash, localGit.log(GitClient.HEAD, 1).single().hash) + } + } + + @Nav + @Test + fun `down within active nav session updates diverge point`() { + withTestSetup(useFakeRemote) { + createCommitsFrom( + testCase { + repository { + commit { title = "one" } + commit { title = "two" } + commit { + title = "three" + localRefs += "development" + } + } + checkout = "development" + } + ) + localGit.fetch(remoteName) + val stack = localGit.getLocalCommitStack(remoteName, GitClient.HEAD, DEFAULT_TARGET_REF) + + gitJaspr.navigateDown(DEFAULT_TARGET_REF, 1) + val firstState = gitJaspr.readNavState() + + gitJaspr.navigateDown(DEFAULT_TARGET_REF, 1) + val secondState = gitJaspr.readNavState() + + // Source branch and saved tip should be preserved from first navigation + assertNotNull(firstState) + assertNotNull(secondState) + assertEquals(firstState.headBeforeDetach, secondState.headBeforeDetach) + assertEquals(firstState.headBeforeDetachSha, secondState.headBeforeDetachSha) + // Diverge point should have moved down + assertEquals(stack[0].hash, secondState.divergePoint) + } + } + + // endregion + @Test fun `push fails unless workdir is clean`() { // This test fails when ran from GitJasprFunctionalExternalProcessTest because the exception diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/testing/TestTags.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/testing/TestTags.kt index e3abea79..96fe34ff 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/testing/TestTags.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/testing/TestTags.kt @@ -42,6 +42,11 @@ annotation class Checkout @Tag("stack") annotation class Stack +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@Tag("nav") +annotation class Nav + @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.RUNTIME) @Tag("functional") From e5b27a5bc084185c42666c8934d634f000cb80db Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Fri, 10 Apr 2026 17:02:55 -0500 Subject: [PATCH 12/27] Add jaspr up and jaspr top navigation commands Cherry-pick commits from the saved stack above HEAD to move back up. jaspr up [N] replays N commits and stays in the nav session. jaspr top replays all remaining commits, updates the source branch ref, checks it out, and ends the session. commit-id: I2d1954ad --- .../main/kotlin/sims/michael/gitjaspr/Cli.kt | 17 ++++ .../kotlin/sims/michael/gitjaspr/GitJaspr.kt | 83 ++++++++++++++++++ .../sims/michael/gitjaspr/GitJasprTest.kt | 85 +++++++++++++++++++ 3 files changed, 185 insertions(+) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt index 3cb4acb2..da0cab2e 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt @@ -1131,6 +1131,21 @@ class Bottom : GitJasprSubcommand(helpText = "Move to the bottom of the stack") } } +class Up : GitJasprSubcommand(helpText = "Move up in the stack (replay commits toward the tip)") { + private val n by argument("n").int().optional() + + override suspend fun doRun() { + appWiring.gitJaspr.navigateUp(n ?: 1) + } +} + +class Top : + GitJasprSubcommand(helpText = "Move to the top of the stack (replay all remaining commits)") { + override suspend fun doRun() { + appWiring.gitJaspr.navigateToTop() + } +} + // endregion class Stack : SuspendingCliktCommand(name = "stack") { @@ -1468,6 +1483,8 @@ fun buildCommand(): SuspendingCliktCommand = Stack().subcommands(StackList(), StackRename(), StackDelete()), Down(), Bottom(), + Up(), + Top(), PreviewTheme(), LogPath(), Init(), diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt index 8715e612..7cde6a84 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt @@ -1880,6 +1880,89 @@ class GitJaspr( writeNavState(NavState(headBeforeDetach, headBeforeDetachSha, targetCommit.hash)) } + /** + * Navigate up N commits by cherry-picking from the saved stack above the current HEAD. If all + * remaining commits are replayed, restores the source branch and ends the session. + */ + fun navigateUp(n: Int) { + val state = requireActiveNavSession() + + val commitsAbove = getCommitsAboveHead(state) + require(commitsAbove.isNotEmpty()) { "Already at the top of the stack." } + require(n <= commitsAbove.size) { + "Cannot move up $n commit(s) — only ${commitsAbove.size} commit(s) above current position." + } + + val toReplay = commitsAbove.take(n) + for (commit in toReplay) { + gitClient.cherryPick(commit, commitIdentOverride) + } + + val replayedAll = n == commitsAbove.size + if (replayedAll) { + endNavSession(state) + } else { + val newHead = gitClient.log(GitClient.HEAD, 1).single().hash + writeNavState(state.copy(divergePoint = newHead)) + } + } + + /** Navigate to the top of the stack by replaying all remaining commits. */ + fun navigateToTop() { + val state = requireActiveNavSession() + + val commitsAbove = getCommitsAboveHead(state) + require(commitsAbove.isNotEmpty()) { "Already at the top of the stack." } + + for (commit in commitsAbove) { + gitClient.cherryPick(commit, commitIdentOverride) + } + + endNavSession(state) + } + + /** + * Returns the active nav session state, or throws with an appropriate message. Handles all + * combinations of detached/attached HEAD and present/absent nav state, including auto-clearing + * stale state. + */ + private fun requireActiveNavSession(): NavState { + val state = readNavState() + val detached = gitClient.isHeadDetached() + return when { + // Active session — normal case + detached && state != null -> state + // Detached HEAD without jaspr nav state + detached -> throw IllegalArgumentException(DETACHED_HEAD_NO_NAV_STATE) + // On a branch — no active session. Clear stale state if present. + else -> { + if (state != null) clearNavState() + throw IllegalArgumentException( + "No navigation session in progress (already at the top of the stack)." + ) + } + } + } + + /** + * Returns the commits from the saved stack that are above the current HEAD (i.e., the commits + * between divergePoint and headBeforeDetachSha, exclusive of divergePoint). + */ + private fun getCommitsAboveHead(state: NavState): List { + return gitClient.logRange(state.divergePoint, state.headBeforeDetachSha) + } + + /** + * End the navigation session: update the original branch to the new tip, check it out, clear + * the state. + */ + private fun endNavSession(state: NavState) { + val newTip = gitClient.log(GitClient.HEAD, 1).single().hash + gitClient.branch(state.headBeforeDetach, startPoint = newTip, force = true) + gitClient.checkout(state.headBeforeDetach) + clearNavState() + } + private fun acquireAutoMergeLock(lockFile: RandomAccessFile): FileLock = lockFile.channel.tryLock() ?: throw GitJasprException( diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt index 51068970..7034bc07 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt @@ -268,6 +268,91 @@ interface GitJasprTest { } } + @Test + fun `up replays one commit via cherry-pick`() { + withTestSetup(useFakeRemote) { + createCommitsFrom( + testCase { + repository { + commit { title = "one" } + commit { title = "two" } + commit { + title = "three" + localRefs += "development" + } + } + checkout = "development" + } + ) + localGit.fetch(remoteName) + val stack = localGit.getLocalCommitStack(remoteName, GitClient.HEAD, DEFAULT_TARGET_REF) + + // Navigate down 2, then up 1 + gitJaspr.navigateDown(DEFAULT_TARGET_REF, 2) + assertEquals(stack[0].hash, localGit.log(GitClient.HEAD, 1).single().hash) + + gitJaspr.navigateUp(1) + + // Should still be detached, with "two" replayed on top + assertTrue(localGit.isHeadDetached()) + assertEquals("two", localGit.log(GitClient.HEAD, 1).single().shortMessage) + assertNotNull(gitJaspr.readNavState()) + } + } + + @Test + fun `top replays all remaining commits and restores branch`() { + withTestSetup(useFakeRemote) { + createCommitsFrom( + testCase { + repository { + commit { title = "one" } + commit { title = "two" } + commit { + title = "three" + localRefs += "development" + } + } + checkout = "development" + } + ) + localGit.fetch(remoteName) + + // Navigate to bottom, then back to top + gitJaspr.navigateToBottom(DEFAULT_TARGET_REF) + gitJaspr.navigateToTop() + + // Should be back on the branch with all commits replayed + assertFalse(localGit.isHeadDetached()) + assertEquals("development", localGit.getCurrentBranchName()) + assertNull(gitJaspr.readNavState()) + + // Stack should still have 3 commits with the same messages + val newStack = + localGit.getLocalCommitStack(remoteName, GitClient.HEAD, DEFAULT_TARGET_REF) + assertEquals(3, newStack.size) + assertEquals(listOf("one", "two", "three"), newStack.map(Commit::shortMessage)) + } + } + + @Test + fun `up with no active session fails`() { + withTestSetup(useFakeRemote) { + createCommitsFrom( + testCase { + repository { + commit { + title = "one" + localRefs += "development" + } + } + checkout = "development" + } + ) + assertThrows { gitJaspr.navigateUp(1) } + } + } + // endregion @Test From efebe0c60b1ed7d292db2076f0a624c5bbc91045 Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Fri, 10 Apr 2026 17:04:08 -0500 Subject: [PATCH 13/27] Add jaspr nav clear command Wipes navigation state file. No-op with a message if no state exists. Stale state warnings were already integrated into down/bottom commands. commit-id: I61423d6d --- .../main/kotlin/sims/michael/gitjaspr/Cli.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt index da0cab2e..46ab6799 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt @@ -1146,6 +1146,25 @@ class Top : } } +class Nav : SuspendingCliktCommand(name = "nav") { + override fun help(context: Context) = "Navigation session management" + + override suspend fun run() = Unit +} + +class NavClear : GitJasprSubcommand(name = "clear", helpText = "Clear navigation state") { + override suspend fun doRun() { + val jaspr = appWiring.gitJaspr + val state = jaspr.readNavState() + if (state == null) { + renderer.info { "No navigation state to clear." } + } else { + jaspr.clearNavState() + renderer.info { "Navigation state cleared." } + } + } +} + // endregion class Stack : SuspendingCliktCommand(name = "stack") { @@ -1485,6 +1504,7 @@ fun buildCommand(): SuspendingCliktCommand = Bottom(), Up(), Top(), + Nav().subcommands(NavClear()), PreviewTheme(), LogPath(), Init(), From fc4ff864b07841ce9d206be169135ac2c8ac60f7 Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Fri, 10 Apr 2026 17:35:10 -0500 Subject: [PATCH 14/27] Add jaspr fixup command Interactively select a stack commit and create a fixup commit targeting it. Uses fzf for fuzzy selection with numbered-list fallback. Requires staged changes. The fixup is folded in on the next jaspr rebase (which has --autosquash enabled). commit-id: I018de68b --- .../main/kotlin/sims/michael/gitjaspr/Cli.kt | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt index 46ab6799..e7bdae73 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt @@ -1167,6 +1167,97 @@ class NavClear : GitJasprSubcommand(name = "clear", helpText = "Clear navigation // endregion +class Fixup : GitJasprSubcommand(helpText = "Create a fixup commit targeting a stack commit") { + private val targetOpts by TargetOptions() + + override suspend fun doRun() { + val gitClient = appWiring.gitClient + val remoteName = appWiring.config.remoteName + val workingDirectory = gitClient.workingDirectory + + // Check for staged changes + val hasStagedChanges = + withContext(Dispatchers.IO) { + ProcessBuilder("git", "diff", "--cached", "--quiet") + .directory(workingDirectory) + .start() + .waitFor() != 0 + } + if (!hasStagedChanges) { + renderer.error { + "No staged changes. Stage the changes you want to fix up, then run ${command("jaspr fixup")} again." + } + throw ProgramResult(1) + } + + gitClient.fetch(remoteName) + val stack = gitClient.getLocalCommitStack(remoteName, GitClient.HEAD, targetOpts.target) + if (stack.isEmpty()) { + renderer.error { "Stack is empty." } + throw ProgramResult(1) + } + + val fzfResult = + if (!useFzf) { + FzfResult.NotAvailable + } else { + fzfSelect( + items = stack, + displayLine = { commit -> + "\u001b[33m${commit.hash.take(7)}\u001b[0m \u001b[97m${commit.shortMessage}\u001b[0m" + }, + header = "Select commit to fix up:", + ) + } + val selected = + when (fzfResult) { + is FzfResult.Selected -> fzfResult.value + is FzfResult.Cancelled -> throw ProgramResult(130) + is FzfResult.NotAvailable -> selectFixupViaPrompt(stack) + } + + val result = + withContext(Dispatchers.IO) { + ProcessBuilder("git", "commit", "--fixup=${selected.hash}") + .directory(workingDirectory) + .inheritIO() + .start() + .waitFor() + } + if (result != 0) throw ProgramResult(result) + + renderer.info { + "Fixup commit created targeting ${entity(selected.hash.take(7))} " + + "(${entity(selected.shortMessage)}). " + + "Run ${command("jaspr rebase")} to fold it in." + } + } + + private suspend fun selectFixupViaPrompt(stack: List): Commit { + val lines = buildList { + add(theme.heading("Commits in stack:")) + for ((index, commit) in stack.withIndex()) { + add( + " ${theme.keyHint("${index + 1}.")} " + + "${theme.muted(commit.hash.take(7))} ${theme.commitSubject(commit.shortMessage)}" + ) + } + } + withContext(Dispatchers.IO) { printPaged(lines) } + val terminal = currentContext.terminal + while (true) { + val input = terminal.prompt("Select commit to fix up (1-${stack.size})") + val selection = input?.toIntOrNull() + if (selection != null && selection in 1..stack.size) { + return stack[selection - 1] + } + renderer.error { + "Invalid selection. Please enter a number between 1 and ${stack.size}." + } + } + } +} + class Stack : SuspendingCliktCommand(name = "stack") { override fun help(context: Context) = "Manage named stacks" @@ -1505,6 +1596,7 @@ fun buildCommand(): SuspendingCliktCommand = Up(), Top(), Nav().subcommands(NavClear()), + Fixup(), PreviewTheme(), LogPath(), Init(), From 2727e49bbdf5092d8ad4a8eddebea90162f0bbf4 Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Fri, 10 Apr 2026 17:36:50 -0500 Subject: [PATCH 15/27] Add jaspr continue command Detects whether a rebase or cherry-pick is in progress and runs the appropriate --continue command. Prefers rebase over cherry-pick when both are somehow in progress. Prints a message when nothing is active. commit-id: Id56b27b2 --- .../main/kotlin/sims/michael/gitjaspr/Cli.kt | 30 ++++++++++++++++++- git-jaspr/src/main/resources/tips.md | 2 +- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt index e7bdae73..6eb077a5 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt @@ -971,7 +971,7 @@ class Rebase : GitJasprSubcommand() { if (rebaseResult != 0) { renderer.warn { "Rebase stopped (exit code $rebaseResult). " + - "Resolve any conflicts, stage the files, then run ${command("git rebase --continue")}." + "Resolve any conflicts, stage the files, then run ${command("jaspr continue")}." } throw ProgramResult(rebaseResult) } @@ -1258,6 +1258,33 @@ class Fixup : GitJasprSubcommand(helpText = "Create a fixup commit targeting a s } } +class Continue : + GitJasprSubcommand(helpText = "Continue an in-progress operation (rebase or cherry-pick)") { + override suspend fun doRun() { + val workingDirectory = appWiring.gitClient.workingDirectory + val gitDir = workingDirectory.resolve(".git") + + // Prefer rebase over cherry-pick since rebase is the higher-level operation + val command = + when { + gitDir.resolve("rebase-merge").exists() || + gitDir.resolve("rebase-apply").exists() -> listOf("git", "rebase", "--continue") + gitDir.resolve("CHERRY_PICK_HEAD").exists() -> + listOf("git", "cherry-pick", "--continue") + else -> { + renderer.info { "Nothing to continue." } + return + } + } + + val result = + withContext(Dispatchers.IO) { + ProcessBuilder(command).directory(workingDirectory).inheritIO().start().waitFor() + } + if (result != 0) throw ProgramResult(result) + } +} + class Stack : SuspendingCliktCommand(name = "stack") { override fun help(context: Context) = "Manage named stacks" @@ -1597,6 +1624,7 @@ fun buildCommand(): SuspendingCliktCommand = Top(), Nav().subcommands(NavClear()), Fixup(), + Continue(), PreviewTheme(), LogPath(), Init(), diff --git a/git-jaspr/src/main/resources/tips.md b/git-jaspr/src/main/resources/tips.md index f98bab50..1101fb4a 100644 --- a/git-jaspr/src/main/resources/tips.md +++ b/git-jaspr/src/main/resources/tips.md @@ -15,6 +15,6 @@ Lines beginning with `- ` are parsed as individual tips. - Working on multiple stacks? Use `jaspr checkout` to interactively switch between your named stacks. If you have fzf installed, you'll get fuzzy search with a preview pane — scroll it with Shift+Up/Down. - Don't want to wait for checks to pass? Run `jaspr auto-merge` and jaspr will poll for check completion and merge automatically when ready. - Only want to push part of your stack? Use `jaspr push -c 3` to push just the bottom 3 commits, or `jaspr push -c -1` to exclude the top commit. -- If you hit a merge conflict during `jaspr edit` or `jaspr rebase`, resolve the conflict, stage the files with `git add`, then run `git rebase --continue`. +- If you hit a merge conflict during `jaspr edit` or `jaspr rebase`, resolve the conflict, stage the files with `git add`, then run `jaspr continue` (or `git rebase --continue`). - Have a work-in-progress commit you never want to accidentally push? Start it with `dont push` (or `dont-push` / `dontpush`) and jaspr will skip it. - Run `jaspr init` to generate a config file with all available options and documentation. Per-repo config (`.jaspr.properties`) overrides your user-wide config (`~/.jaspr.properties`). From b625b571c23a43798b23ed7999acd27729c09756 Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Fri, 10 Apr 2026 22:34:54 -0500 Subject: [PATCH 16/27] Clean removes matching local branches after deleting remotes When jaspr clean deletes remote branches, it now also removes local branches whose upstream matched a deleted remote branch AND whose tip equals the remote tracking ref. Skips the currently checked-out branch. Local branches are identified before the remote push so tracking refs are still available for comparison. commit-id: I548f8215 --- .../kotlin/sims/michael/gitjaspr/GitJaspr.kt | 37 ++++++++- .../sims/michael/gitjaspr/GitJasprTest.kt | 79 +++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt index 7cde6a84..db17ce87 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt @@ -862,13 +862,48 @@ class GitJaspr( fun executeCleanPlan(plan: CleanPlan) { logger.trace("executeCleanPlan") val branchesToDelete = plan.allBranches() + + // Identify matching local branches before deleting remotes (we need tracking refs intact) + val localBranchesToDelete = findMatchingLocalBranches(branchesToDelete.toSet()) + renderer.info { - "Deleting ${branchesToDelete.size} ${branchOrBranches(branchesToDelete.size)}" + "Deleting ${branchesToDelete.size} remote ${branchOrBranches(branchesToDelete.size)}" } gitClient.push( branchesToDelete.map { name -> RefSpec(FORCE_PUSH_PREFIX, name) }, config.remoteName, ) + + if (localBranchesToDelete.isNotEmpty()) { + gitClient.deleteBranches(localBranchesToDelete) + renderer.info { + "Removed ${localBranchesToDelete.size} local " + + "${branchOrBranches(localBranchesToDelete.size)}: " + + localBranchesToDelete.joinToString(", ") + } + } + } + + /** + * Finds local branches whose upstream matches any of the given remote branch names and whose + * tip equals the remote tracking ref tip. Skips the current branch. + */ + private fun findMatchingLocalBranches(remoteBranchNames: Set): List { + val remoteName = config.remoteName + val currentBranch = gitClient.getCurrentBranchName() + return buildList { + for (localBranch in gitClient.getBranchNames()) { + if (localBranch == currentBranch) continue + val upstream = gitClient.getUpstreamBranchName(localBranch, remoteName) ?: continue + if (upstream !in remoteBranchNames) continue + val localTip = gitClient.log(localBranch, 1).singleOrNull()?.hash ?: continue + val trackingRef = "$remoteName/$upstream" + val remoteTip = gitClient.log(trackingRef, 1).singleOrNull()?.hash + if (localTip == remoteTip) { + add(localBranch) + } + } + } } /** Returns short commit messages for the given branch names, prefixed with the remote name. */ diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt index 7034bc07..aac48146 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt @@ -4268,6 +4268,85 @@ interface GitJasprTest { } } + @Clean + @Test + fun `clean also removes matching local branches`() { + withTestSetup(useFakeRemote) { + // Set up orphaned remote branches (no open PRs pointing to them) + createCommitsFrom( + testCase { + repository { + commit { + title = "a" + willPassVerification = true + remoteRefs += buildRemoteRef("a") + } + commit { + title = "b" + willPassVerification = true + localRefs += "development" + remoteRefs += buildRemoteRef("b") + } + } + } + ) + + // Create a local branch tracking one of the orphaned remote branches + val orphanedRef = buildRemoteRef("b") + localGit.branch("my-local-branch", startPoint = "$remoteName/$orphanedRef") + localGit.setUpstreamBranchForLocalBranch("my-local-branch", remoteName, orphanedRef) + + // Verify the local branch exists before clean + assertTrue("my-local-branch" in localGit.getBranchNames()) + + gitJaspr.executeCleanPlan( + gitJaspr.getCleanPlan(cleanAbandonedPrs = false, cleanAllCommits = false) + ) + + // The local branch should have been removed along with the remote + assertFalse("my-local-branch" in localGit.getBranchNames()) + } + } + + @Clean + @Test + fun `clean does not remove local branch with divergent tip`() { + withTestSetup(useFakeRemote) { + createCommitsFrom( + testCase { + repository { + commit { + title = "a" + willPassVerification = true + remoteRefs += buildRemoteRef("a") + } + commit { + title = "b" + willPassVerification = true + localRefs += "development" + remoteRefs += buildRemoteRef("b") + } + } + } + ) + + // Create a local branch tracking the orphaned remote, then add a local commit + val orphanedRef = buildRemoteRef("b") + localGit.branch("my-diverged-branch", startPoint = "$remoteName/$orphanedRef") + localGit.setUpstreamBranchForLocalBranch("my-diverged-branch", remoteName, orphanedRef) + localGit.checkout("my-diverged-branch") + localGit.commit("local-only commit") + localGit.checkout("development") + + gitJaspr.executeCleanPlan( + gitJaspr.getCleanPlan(cleanAbandonedPrs = false, cleanAllCommits = false) + ) + + // The diverged local branch should NOT be deleted (tip doesn't match remote) + assertTrue("my-diverged-branch" in localGit.getBranchNames()) + } + } + @Clean @Test fun `getOrphanedBranches prunes stale tracking branches`() { From dc9736167cfd4f51b363fd7ea932e022780b08ca Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Fri, 10 Apr 2026 22:37:39 -0500 Subject: [PATCH 17/27] Resolve git common dir for hook installation in worktrees installCommitIdHook now uses git rev-parse --git-common-dir to find the hooks directory instead of assuming .git/hooks. This correctly handles git worktrees where .git is a file pointing to the main repo. commit-id: Icf9840f3 --- .../kotlin/sims/michael/gitjaspr/GitJaspr.kt | 19 +++++- .../michael/gitjaspr/WorktreeSupportTest.kt | 62 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt index db17ce87..df887263 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt @@ -1106,8 +1106,8 @@ class GitJaspr( fun installCommitIdHook() { logger.trace("installCommitIdHook") - val hooksDir = config.workingDirectory.resolve(".git").resolve("hooks") - require(hooksDir.isDirectory) + val hooksDir = resolveGitCommonDir().resolve("hooks") + require(hooksDir.isDirectory) { "Hooks directory not found at $hooksDir" } val hook = hooksDir.resolve(COMMIT_MSG_HOOK) val bundledContent = checkNotNull(javaClass.getResourceAsStream("/$COMMIT_MSG_HOOK")).use { it.readBytes() } @@ -1822,6 +1822,21 @@ class GitJaspr( renderer, ) + /** + * Resolves the shared git directory. In a worktree `.git` is a file pointing elsewhere, so we + * use `git rev-parse --git-common-dir` which works in both normal repos and worktrees. + */ + private fun resolveGitCommonDir(): File { + val process = + ProcessBuilder("git", "rev-parse", "--git-common-dir") + .directory(config.workingDirectory) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText().trim() + check(process.waitFor() == 0) { "Failed to resolve git common dir: $output" } + return config.workingDirectory.resolve(output).canonicalFile + } + private fun getJasprDir(): File = config.workingDirectory.resolve(".git/jaspr").also { it.mkdirs() } diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/WorktreeSupportTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/WorktreeSupportTest.kt index fa93fc3e..0ae85e93 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/WorktreeSupportTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/WorktreeSupportTest.kt @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test import org.slf4j.LoggerFactory import org.zeroturnaround.exec.ProcessExecutor +import sims.michael.gitjaspr.githubtests.GitHubStubClient import sims.michael.gitjaspr.testing.DEFAULT_COMMITTER import sims.michael.gitjaspr.testing.toStringWithClickableURI @@ -305,6 +306,67 @@ class WorktreeSupportTest { } } + @Test + fun `installCommitIdHook installs hook in main repo when run from worktree`() { + val tempDir = createTempDir() + try { + // Create the main repository with a remote (required by GitJaspr config) + val mainRepoDir = tempDir.resolve("main-repo") + val mainGit = CliGitClient(mainRepoDir).init() + mainRepoDir.resolve("README.txt").writeText("Test repo") + mainGit.add("README.txt").commit("Initial commit", committer = DEFAULT_COMMITTER) + ProcessExecutor() + .directory(mainRepoDir) + .command("git", "remote", "add", "origin", "https://github.com/test/test.git") + .execute() + + // Create a worktree + val worktreeDir = tempDir.resolve("worktree") + ProcessExecutor() + .directory(mainRepoDir) + .command("git", "worktree", "add", worktreeDir.absolutePath, "-b", "wt-branch") + .destroyOnExit() + .execute() + + // Verify .git is a file in the worktree (not a directory) + assertTrue(worktreeDir.resolve(".git").isFile) + + // Create a GitJaspr pointed at the worktree + val worktreeGit = CliGitClient(worktreeDir) + val gitJaspr = + GitJaspr( + ghClient = + GitHubStubClient( + remoteBranchPrefix = "jaspr", + remoteName = "origin", + localGit = worktreeGit, + ), + gitClient = worktreeGit, + config = + Config( + workingDirectory = worktreeDir, + remoteName = "origin", + gitHubInfo = GitHubInfo("github.com", "test", "test"), + ), + ) + + gitJaspr.installCommitIdHook() + + // Hook should be in the MAIN repo's hooks dir, not the worktree + val mainHook = mainRepoDir.resolve(".git/hooks/commit-msg") + assertTrue(mainHook.exists(), "Hook should exist in main repo's .git/hooks") + assertTrue(mainHook.canExecute(), "Hook should be executable") + + // Worktree should NOT have a hooks dir (it's a file-based .git) + assertFalse( + worktreeDir.resolve(".git/hooks").exists(), + "Worktree should not have its own hooks dir", + ) + } finally { + tempDir.deleteRecursively() + } + } + private fun createTempDir(): File { return Files.createTempDirectory(WorktreeSupportTest::class.java.simpleName).toFile().also { logger.info("Temp dir created in {}", it.toStringWithClickableURI()) From 3dc46268d03b51fe1e74dc0274e9a23bcd16b366 Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Fri, 10 Apr 2026 22:40:43 -0500 Subject: [PATCH 18/27] Enhance jaspr edit header and add reorder alias Expanded the rebase TODO header to cover all common operations (reorder, drop, squash, edit) with concise instructions. Added jaspr reorder as an alias for jaspr edit for discoverability. commit-id: I2b0c1c6a --- .../main/kotlin/sims/michael/gitjaspr/Cli.kt | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt index 6eb077a5..a29a6743 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt @@ -1012,7 +1012,7 @@ internal fun isGitVersionAtLeast(gitVersionOutput: String, minVersion: String): return versionNumber(actual) >= versionNumber(parseVersionParts(minVersion)) } -class Edit : GitJasprSubcommand() { +class Edit(name: String? = null) : GitJasprSubcommand(name = name) { // language=Markdown override fun help(context: Context) = """ @@ -1058,11 +1058,15 @@ class Edit : GitJasprSubcommand() { header_file=$(mktemp) cat > "$header_file" << 'HEADER' # Edit your stack by modifying the list below. - # To amend a commit, change 'pick' to 'edit', then: - # 1. Make your changes - # 2. Stage them with: git add - # 3. Amend the commit: git commit --amend - # 4. Continue the rebase: git rebase --continue + # + # Reorder: Move lines up or down to change commit order. + # Drop: Delete a line to remove that commit. + # Squash: Change 'pick' to 's' to merge into the previous commit. + # Edit: Change 'pick' to 'e', then: + # 1. Make your changes + # 2. Stage them: git add + # 3. Amend: git commit --amend + # 4. Continue: jaspr continue # HEADER cat "$1" >> "$header_file" @@ -1617,6 +1621,7 @@ fun buildCommand(): SuspendingCliktCommand = Clean(), Rebase(), Edit(), + Edit(name = "reorder"), Stack().subcommands(StackList(), StackRename(), StackDelete()), Down(), Bottom(), From a4dbb645156bee841927e38b31fb036d3b6852fd Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Sat, 11 Apr 2026 07:39:20 -0500 Subject: [PATCH 19/27] Add --mine flag to jaspr stack list Filters the stack list to only show stacks authored by the current user (matched by git user.name and user.email). Also shows the author name on each stack when listing all stacks. commit-id: I2b53ab13 --- .../main/kotlin/sims/michael/gitjaspr/Cli.kt | 30 ++++++++---- .../kotlin/sims/michael/gitjaspr/GitJaspr.kt | 28 +++++++---- .../sims/michael/gitjaspr/GitJasprTest.kt | 49 +++++++++++++++++++ 3 files changed, 88 insertions(+), 19 deletions(-) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt index a29a6743..cec97ba5 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt @@ -1297,29 +1297,39 @@ class Stack : SuspendingCliktCommand(name = "stack") { class StackList : GitJasprSubcommand(name = "list", helpText = "List all named stacks") { + private val mine by + option("--mine").flag().help { "Show only stacks authored by the current user" } + override suspend fun doRun() { val gitJaspr = appWiring.gitJaspr val config = appWiring.config - val allStacks = gitJaspr.getAllNamedStacks() + val stacks = gitJaspr.getAllNamedStacks(mineOnly = mine) - if (allStacks.isEmpty()) { - renderer.info { "No named stacks found." } + if (stacks.isEmpty()) { + renderer.info { if (mine) "No matching stacks found." else "No named stacks found." } return } val remoteName = config.remoteName - val refs = allStacks.map { ref -> "${remoteName}/${ref.name()}" } - val shortMessages = appWiring.gitClient.getShortMessages(refs) + val refs = stacks.map { ref -> "${remoteName}/${ref.name()}" } + val commits = appWiring.gitClient.getCommits(refs) - val stacksByTarget = allStacks.groupBy(RemoteNamedStackRef::targetRef) + val stacksByTarget = stacks.groupBy(RemoteNamedStackRef::targetRef) val lines = buildList { - for ((targetRef, stacks) in stacksByTarget) { + for ((targetRef, targetStacks) in stacksByTarget) { add(theme.heading("Stacks targeting ${theme.entity(targetRef)}:")) - for (stack in stacks) { + for (stack in targetStacks) { val ref = "${remoteName}/${stack.name()}" + val commit = commits[ref] val message = - shortMessages[ref]?.let { " ${theme.commitSubject(it)}" }.orEmpty() - add(" [${theme.entity(stack.stackName)}]$message") + commit?.shortMessage?.let { " ${theme.commitSubject(it)}" }.orEmpty() + val author = + if (!mine) { + commit?.author?.name?.let { " ${theme.muted("<$it>")}" }.orEmpty() + } else { + "" + } + add(" [${theme.entity(stack.stackName)}]$message$author") } add("") } diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt index df887263..8c703ce5 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt @@ -1615,7 +1615,7 @@ class GitJaspr( } /** Get the current user's commit author identity that would be used for new commits. */ - private fun getCurrentUserIdent(): Ident { + fun getCurrentUserIdent(): Ident { val name = gitClient.getConfigValue("user.name") ?: System.getenv("USER") ?: "unknown" val email = gitClient.getConfigValue("user.email") ?: "unknown@unknown.com" return Ident(name, email) @@ -1685,14 +1685,24 @@ class GitJaspr( fun getNamedStacks(targetRef: String) = getAllNamedStacks().filter { it.targetRef == targetRef } /** Returns all named stacks on the remote, sorted by stack name. */ - fun getAllNamedStacks(): List { - gitClient.fetch(config.remoteName, prune = true) - return gitClient - .getRemoteBranches(config.remoteName) - .mapNotNull { branch -> - RemoteNamedStackRef.parse(branch.name, config.remoteNamedStackBranchPrefix) - } - .sortedBy(RemoteNamedStackRef::stackName) + fun getAllNamedStacks(mineOnly: Boolean = false): List { + val remoteName = config.remoteName + gitClient.fetch(remoteName, prune = true) + val allStacks = + gitClient + .getRemoteBranches(remoteName) + .mapNotNull { branch -> + RemoteNamedStackRef.parse(branch.name, config.remoteNamedStackBranchPrefix) + } + .sortedBy(RemoteNamedStackRef::stackName) + if (!mineOnly) return allStacks + + val userIdent = getCurrentUserIdent() + val refs = allStacks.map { ref -> "$remoteName/${ref.name()}" } + val commits = gitClient.getCommits(refs) + return allStacks.filter { stack -> + commits["$remoteName/${stack.name()}"]?.author == userIdent + } } /** diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt index aac48146..7a4c54a7 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt @@ -6186,6 +6186,55 @@ interface GitJasprTest { // region stack tests + @Stack + @Test + fun `getAllNamedStacks with mineOnly filters by current user`() { + withTestSetup(useFakeRemote) { + val otherAuthor = Ident("Other Person", "other@example.com") + + // Push a stack as the default committer (Frank Grimes) + createCommitsFrom( + testCase { + repository { + commit { + title = "my_commit" + localRefs += "development" + } + } + checkout = "development" + } + ) + push(stackName = "my-stack") + + // Push a stack with a different committer + createCommitsFrom( + testCase { + repository { + commit { + title = "other_commit" + committer { + name = otherAuthor.name + email = otherAuthor.email + } + localRefs += "development" + } + } + checkout = "development" + } + ) + push(stackName = "other-stack") + + // Without filter: both stacks + val allStacks = gitJaspr.getAllNamedStacks() + assertEquals(2, allStacks.size) + + // With mineOnly: only the stack authored by the current user (DEFAULT_COMMITTER) + val myStacks = gitJaspr.getAllNamedStacks(mineOnly = true) + assertEquals(1, myStacks.size) + assertEquals("my-stack", myStacks.single().stackName) + } + } + @Stack @Test fun `rename stack changes remote branch name`() { From 3da6cba0108dc950549484430141f59bc07ba0da Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Sat, 11 Apr 2026 09:13:23 -0500 Subject: [PATCH 20/27] Delete matching local branches when deleting a stack jaspr stack delete now removes local branches that tracked the deleted remote stack branch when their tip matches the remote tip. Skips the current branch. Remaining tracking branches (divergent tip or current branch) have their upstream unset as before. commit-id: Iab29cac7 --- .../kotlin/sims/michael/gitjaspr/GitJaspr.kt | 41 ++++++-- .../sims/michael/gitjaspr/GitJasprTest.kt | 93 +++++++++++++++++++ 2 files changed, 125 insertions(+), 9 deletions(-) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt index 8c703ce5..5e6b7368 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt @@ -1804,21 +1804,44 @@ class GitJaspr( throw GitJasprException("Named stack '$name' not found (looking for $stackRef).") } + // Identify local branches to delete before the remote push (tracking refs must be intact) + val currentBranch = gitClient.getCurrentBranchName() + val localBranchesToDelete = buildList { + for (localBranch in gitClient.getBranchNames()) { + if (localBranch == currentBranch) continue + val upstreamName = gitClient.getUpstreamBranchName(localBranch, remoteName) + if (upstreamName != stackRef) continue + val localTip = gitClient.log(localBranch, 1).singleOrNull()?.hash + val remoteTip = gitClient.log("$remoteName/$stackRef", 1).singleOrNull()?.hash + if (localTip != null && localTip == remoteTip) { + add(localBranch) + } + } + } + // Force-delete the remote branch gitClient.push(listOf(RefSpec(FORCE_PUSH_PREFIX, stackRef)), remoteName) renderer.info { "Deleted remote stack branch ${entity(stackRef)}" } + if (localBranchesToDelete.isNotEmpty()) { + gitClient.deleteBranches(localBranchesToDelete) + for (branch in localBranchesToDelete) { + renderer.info { "Deleted local branch '${entity(branch)}'" } + } + } - // Unset upstream tracking for any local branches that pointed to the deleted ref - val affectedBranches = mutableListOf() - for (localBranch in gitClient.getBranchNames()) { - val upstreamName = gitClient.getUpstreamBranchName(localBranch, remoteName) - if (upstreamName == stackRef) { - gitClient.setUpstreamBranchForLocalBranch(localBranch, remoteName, null) - affectedBranches.add(localBranch) - renderer.info { "Unset upstream for local branch '${entity(localBranch)}'" } + // Unset upstream for any remaining local branches that tracked the deleted ref + // (e.g. current branch, or branches with divergent tips that weren't deleted) + val remainingBranches = buildList { + for (localBranch in gitClient.getBranchNames()) { + val upstreamName = gitClient.getUpstreamBranchName(localBranch, remoteName) + if (upstreamName == stackRef) { + gitClient.setUpstreamBranchForLocalBranch(localBranch, remoteName, null) + add(localBranch) + renderer.info { "Unset upstream for local branch '${entity(localBranch)}'" } + } } } - return affectedBranches + return localBranchesToDelete + remainingBranches } /** Intended for tests */ diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt index 7a4c54a7..0e5c865f 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt @@ -6432,6 +6432,99 @@ interface GitJasprTest { } } + @Stack + @Test + fun `delete stack also removes matching local branch`() { + withTestSetup(useFakeRemote) { + createCommitsFrom( + testCase { + repository { + commit { title = "one" } + commit { + title = "two" + localRefs += "development" + } + } + checkout = "development" + } + ) + push(stackName = "my-stack") + + // Use checkout to create a local branch tracking the named stack + checkout("my-stack") + // Switch back to development so my-stack isn't the current branch + localGit.checkout("development") + + assertTrue("my-stack" in localGit.getBranchNames()) + + deleteStack("my-stack") + + assertFalse("my-stack" in localGit.getBranchNames()) + } + } + + @Stack + @Test + fun `delete stack preserves diverged local branch and unsets upstream`() { + withTestSetup(useFakeRemote) { + createCommitsFrom( + testCase { + repository { + commit { title = "one" } + commit { + title = "two" + localRefs += "development" + } + } + checkout = "development" + } + ) + push(stackName = "my-stack") + + // Create a local branch tracking the stack, then add a local-only commit + checkout("my-stack") + localGit.commit("local-only commit") + localGit.checkout("development") + + assertTrue("my-stack" in localGit.getBranchNames()) + + deleteStack("my-stack") + + // Branch should still exist (diverged tip) but upstream should be unset + assertTrue("my-stack" in localGit.getBranchNames()) + assertNull(localGit.getUpstreamBranchName("my-stack", remoteName)) + } + } + + @Stack + @Test + fun `delete stack skips current branch and unsets upstream`() { + withTestSetup(useFakeRemote) { + createCommitsFrom( + testCase { + repository { + commit { title = "one" } + commit { + title = "two" + localRefs += "development" + } + } + checkout = "development" + } + ) + push(stackName = "my-stack") + + // Check out the stack branch so it's the current branch + checkout("my-stack") + + deleteStack("my-stack") + + // Current branch should still exist but upstream should be unset + assertEquals("my-stack", localGit.getCurrentBranchName()) + assertNull(localGit.getUpstreamBranchName("my-stack", remoteName)) + } + } + // endregion private fun isNamedStackBranch(branch: RemoteBranch): Boolean { From ae942a54d6e61d8e2612b6b35360ba3265c83d13 Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Sat, 11 Apr 2026 15:27:08 -0500 Subject: [PATCH 21/27] Add jaspr sync command Rebases all local jaspr-managed branches onto the latest target branch. Uses a temporary worktree for non-checked-out branches with topological ordering (shallowest first) and commit mapping to avoid duplicating shared commits across overlapping branches. The current branch is rebased last, using cherry-pick when it shares commits with already- rebased branches to maintain commit identity. Conflicts cause the branch (and its dependents) to be skipped with a warning. commit-id: Id0e44357 --- git-jaspr/build.gradle.kts | 2 +- .../main/kotlin/sims/michael/gitjaspr/Cli.kt | 24 ++ .../kotlin/sims/michael/gitjaspr/GitJaspr.kt | 321 +++++++++++++++++ .../sims/michael/gitjaspr/GitJasprTest.kt | 335 ++++++++++++++++++ .../sims/michael/gitjaspr/testing/TestTags.kt | 5 + 5 files changed, 686 insertions(+), 1 deletion(-) diff --git a/git-jaspr/build.gradle.kts b/git-jaspr/build.gradle.kts index 0af3f3f7..a7ee853b 100644 --- a/git-jaspr/build.gradle.kts +++ b/git-jaspr/build.gradle.kts @@ -224,7 +224,7 @@ tasks.named("nativeImageMetadata") { // The easiest way to run these test groups in IDEA is to go to, f.e., GitJasprDefaultTest and click // the run button in the gutter. When prompted to choose tasks, choose the `test*` task(s) you want // to run. -val testGroups = listOf("status", "push", "prBody", "merge", "clean", "dontPush", "nav") +val testGroups = listOf("status", "push", "prBody", "merge", "clean", "dontPush", "nav", "sync") for (testTag in testGroups) { val taskName = "test" + testTag.replaceFirstChar { char -> char.uppercase() } diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt index cec97ba5..0034da90 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt @@ -978,6 +978,29 @@ class Rebase : GitJasprSubcommand() { } } +class Sync : + GitJasprSubcommand(helpText = "Rebase all local jaspr stacks onto the latest target branch") { + private val targetOpts by TargetOptions() + + override suspend fun doRun() { + val results = appWiring.gitJaspr.sync(targetOpts.target) + val succeeded = results.count { it.success } + val failed = results.count { !it.success } + if (failed > 0) { + renderer.warn { + "Sync complete: $succeeded succeeded, $failed failed. " + + "Failed branches: ${results.filter { !it.success }.joinToString(", ") { entity(it.branch) }}" + } + } else if (succeeded > 0) { + renderer.info { + success( + "Sync complete: $succeeded ${if (succeeded == 1) "branch" else "branches"} rebased." + ) + } + } + } +} + private const val GIT_AUTOSQUASH_MIN_VERSION = "2.44.0" /** Returns true if the installed git version supports `--autosquash` without `--interactive`. */ @@ -1640,6 +1663,7 @@ fun buildCommand(): SuspendingCliktCommand = Nav().subcommands(NavClear()), Fixup(), Continue(), + Sync(), PreviewTheme(), LogPath(), Init(), diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt index 5e6b7368..8660c1e6 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt @@ -2046,6 +2046,327 @@ class GitJaspr( clearNavState() } + /** Result of syncing a single branch. */ + data class SyncBranchResult(val branch: String, val success: Boolean, val message: String) + + /** + * Rebases all local jaspr-managed branches onto the latest target ref. Uses a temporary + * worktree for branches other than the current one, with topological ordering and commit + * mapping to avoid duplicating shared commits. The current branch is rebased last in the main + * working copy. + * + * @return list of results for each branch attempted + */ + fun sync(targetRef: String): List { + val remoteName = config.remoteName + gitClient.fetch(remoteName) + + val currentBranch = gitClient.getCurrentBranchName() + val targetBase = "$remoteName/$targetRef" + + // Find all local branches with jaspr commit-IDs above the target + data class BranchStack(val branch: String, val commits: List) + + val branchStacks = buildList { + for (branch in gitClient.getBranchNames()) { + val commits = gitClient.getLocalCommitStack(remoteName, branch, targetRef) + val hasJasprCommits = commits.any { commit -> commit.id != null } + if (hasJasprCommits) { + add(BranchStack(branch, commits)) + } + } + } + + if (branchStacks.isEmpty()) { + renderer.info { "No jaspr-managed branches to sync." } + return emptyList() + } + + // Check which branches actually need rebasing + val targetBaseHash = gitClient.log(targetBase, 1).singleOrNull()?.hash + val branchesNeedingRebase = branchStacks.filter { (_, commits) -> + // A branch needs rebase if the target base is not already the parent of its first + // commit + commits.isNotEmpty() && + gitClient.getParents(commits.first()).none { parent -> + parent.hash == targetBaseHash + } + } + + if (branchesNeedingRebase.isEmpty()) { + renderer.info { "All branches are already up to date." } + return branchStacks.map { SyncBranchResult(it.branch, true, "Already up to date") } + } + + // Sort by stack depth (shallowest first) for topological ordering + val sorted = branchesNeedingRebase.sortedBy { it.commits.size } + + // Separate the current branch from others + val otherBranches = sorted.filter { it.branch != currentBranch } + val currentBranchStack = sorted.find { it.branch == currentBranch } + + val results = mutableListOf() + val commitMap = mutableMapOf() // oldHash -> newHash + val skippedCommits = mutableSetOf() // commits whose branches conflicted + + // Rebase non-current branches in a worktree + if (otherBranches.isNotEmpty()) { + val worktreeDir = getJasprDir().resolve("sync-worktree") + try { + // Create detached worktree + worktreeDir.deleteRecursively() + val addResult = + ProcessBuilder("git", "worktree", "add", "--detach", worktreeDir.absolutePath) + .directory(config.workingDirectory) + .redirectErrorStream(true) + .start() + .let { proc -> + proc.inputStream.bufferedReader().readText() + proc.waitFor() + } + check(addResult == 0) { "Failed to create worktree" } + + for ((branch, commits) in otherBranches) { + // Check if any of this branch's commits are in a skipped set + val hasSkippedAncestor = commits.any { it.hash in skippedCommits } + if (hasSkippedAncestor) { + renderer.warn { + "Skipping ${entity(branch)} — depends on a conflicted branch" + } + results.add( + SyncBranchResult( + branch, + false, + "Skipped (depends on conflicted branch)", + ) + ) + commits.forEach { skippedCommits.add(it.hash) } + continue + } + + val result = + rebaseBranchInWorktree(worktreeDir, branch, commits, targetBase, commitMap) + results.add(result) + if (!result.success) { + commits.forEach { skippedCommits.add(it.hash) } + } + } + } finally { + // Clean up the worktree + ProcessBuilder("git", "worktree", "remove", "--force", worktreeDir.absolutePath) + .directory(config.workingDirectory) + .redirectErrorStream(true) + .start() + .let { proc -> + proc.inputStream.bufferedReader().readText() + proc.waitFor() + } + } + } + + // Rebase the current branch last + if (currentBranchStack != null) { + val hasSkippedAncestor = currentBranchStack.commits.any { it.hash in skippedCommits } + if (hasSkippedAncestor) { + renderer.warn { + "Skipping ${entity(currentBranch)} — depends on a conflicted branch" + } + results.add( + SyncBranchResult(currentBranch, false, "Skipped (depends on conflicted branch)") + ) + } else { + // If some commits are already mapped from worktree rebases of shallower branches, + // use cherry-pick to maintain shared commit identity. Otherwise use normal rebase. + val hasMappedCommits = currentBranchStack.commits.any { it.hash in commitMap } + val result = + if (hasMappedCommits) { + rebaseCurrentBranchViaCherryPick( + currentBranch, + currentBranchStack.commits, + targetBase, + commitMap, + ) + } else { + val rebaseResult = rebaseCurrentBranch(targetBase) + SyncBranchResult( + currentBranch, + rebaseResult == 0, + if (rebaseResult == 0) "Rebased" + else "Conflict (exit code $rebaseResult)", + ) + } + results.add(result) + } + } + + // Add results for branches that didn't need rebasing + val attemptedBranches = results.map(SyncBranchResult::branch).toSet() + for ((branch, _) in branchStacks) { + if (branch !in attemptedBranches) { + results.add(SyncBranchResult(branch, true, "Already up to date")) + } + } + + return results + } + + /** + * Rebases a single branch in the worktree using cherry-pick, respecting the commit map to avoid + * duplicating commits that were already rebased as part of a shallower branch. + */ + private fun rebaseBranchInWorktree( + worktreeDir: File, + branch: String, + commits: List, + targetBase: String, + commitMap: MutableMap, + ): SyncBranchResult { + logger.trace("rebaseBranchInWorktree {} ({} commits)", branch, commits.size) + + // Find the deepest commit that has already been mapped (rebased by an earlier branch) + var newBase = targetBase + var commitsToReplay = commits + for (i in commits.indices.reversed()) { + val mapped = commitMap[commits[i].hash] + if (mapped != null) { + newBase = mapped + commitsToReplay = commits.subList(i + 1, commits.size) + break + } + } + + if (commitsToReplay.isEmpty()) { + // All commits were already rebased by a shallower branch, just update the ref + val tip = commitMap[commits.last().hash] + if (tip != null) { + gitBranchForce(config.workingDirectory, branch, tip) + renderer.info { + "Updated ${entity(branch)} (all commits shared with earlier branch)" + } + } + return SyncBranchResult(branch, true, "Rebased (shared commits)") + } + + // Checkout the new base in the worktree + val checkoutResult = gitCommand(worktreeDir, "checkout", "--detach", newBase) + if (checkoutResult != 0) { + return SyncBranchResult(branch, false, "Failed to checkout base") + } + + // Cherry-pick each commit + for (commit in commitsToReplay) { + val cpResult = gitCommand(worktreeDir, "cherry-pick", "--allow-empty", commit.hash) + if (cpResult != 0) { + // Abort the cherry-pick and bail + gitCommand(worktreeDir, "cherry-pick", "--abort") + renderer.warn { + "Conflict rebasing ${entity(branch)} at commit ${entity(commit.hash.take(7))} (${commit.shortMessage})" + } + return SyncBranchResult(branch, false, "Conflict at ${commit.hash.take(7)}") + } + // Record the mapping: old hash -> new hash in worktree + val newHash = gitOutput(worktreeDir, "rev-parse", "HEAD") + commitMap[commit.hash] = newHash + } + + // Update the branch ref in the main repo to point at the new tip + val newTip = gitOutput(worktreeDir, "rev-parse", "HEAD") + gitBranchForce(config.workingDirectory, branch, newTip) + renderer.info { "Rebased ${entity(branch)}" } + return SyncBranchResult(branch, true, "Rebased") + } + + /** Rebases the current branch onto the target using normal git rebase with autosquash. */ + private fun rebaseCurrentBranch(targetBase: String): Int { + val workingDirectory = config.workingDirectory + val rebaseArgs = buildList { + add("git") + add("rebase") + add("--autosquash") + add(targetBase) + } + return ProcessBuilder(rebaseArgs) + .directory(workingDirectory) + .inheritIO() + .apply { environment()["GIT_SEQUENCE_EDITOR"] = "true" } + .start() + .waitFor() + } + + /** + * Rebases the current branch using cherry-pick to maintain shared commit identity with branches + * that were already rebased in the worktree. + */ + private fun rebaseCurrentBranchViaCherryPick( + branch: String, + commits: List, + targetBase: String, + commitMap: Map, + ): SyncBranchResult { + val workingDirectory = config.workingDirectory + + // Find the deepest mapped commit and determine what to replay + var newBase = targetBase + var commitsToReplay = commits + for (i in commits.indices.reversed()) { + val mapped = commitMap[commits[i].hash] + if (mapped != null) { + newBase = mapped + commitsToReplay = commits.subList(i + 1, commits.size) + break + } + } + + // Detach HEAD at the new base + gitCommand(workingDirectory, "checkout", "--detach", newBase) + + // Cherry-pick remaining commits + for (commit in commitsToReplay) { + val result = gitCommand(workingDirectory, "cherry-pick", "--allow-empty", commit.hash) + if (result != 0) { + gitCommand(workingDirectory, "cherry-pick", "--abort") + // Try to get back on the branch + gitCommand(workingDirectory, "checkout", branch) + renderer.warn { + "Conflict rebasing ${entity(branch)} at commit " + + "${entity(commit.hash.take(7))} (${commit.shortMessage})" + } + return SyncBranchResult(branch, false, "Conflict at ${commit.hash.take(7)}") + } + } + + // Update branch and check it out + val newTip = gitOutput(workingDirectory, "rev-parse", "HEAD") + gitBranchForce(workingDirectory, branch, newTip) + gitCommand(workingDirectory, "checkout", branch) + renderer.info { "Rebased ${entity(branch)}" } + return SyncBranchResult(branch, true, "Rebased") + } + + /** Run a git command in a directory and return the exit code. */ + private fun gitCommand(dir: File, vararg args: String): Int = + ProcessBuilder(listOf("git") + args).directory(dir).redirectErrorStream(true).start().let { + proc -> + proc.inputStream.bufferedReader().readText() + proc.waitFor() + } + + /** Run a git command and return trimmed stdout. */ + @Suppress("SameParameterValue") + private fun gitOutput(dir: File, vararg args: String): String = + ProcessBuilder(listOf("git") + args).directory(dir).redirectErrorStream(true).start().let { + proc -> + val output = proc.inputStream.bufferedReader().readText().trim() + check(proc.waitFor() == 0) { "git ${args.toList()} failed: $output" } + output + } + + /** Force-update a branch ref to point at a specific commit. */ + private fun gitBranchForce(dir: File, branch: String, commit: String) { + val result = gitCommand(dir, "branch", "-f", branch, commit) + check(result == 0) { "Failed to update branch $branch to $commit" } + } + private fun acquireAutoMergeLock(lockFile: RandomAccessFile): FileLock = lockFile.channel.tryLock() ?: throw GitJasprException( diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt index 0e5c865f..6531c89f 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt @@ -27,6 +27,7 @@ import sims.michael.gitjaspr.testing.PrBody import sims.michael.gitjaspr.testing.Push import sims.michael.gitjaspr.testing.Stack import sims.michael.gitjaspr.testing.Status +import sims.michael.gitjaspr.testing.Sync interface GitJasprTest { @@ -355,6 +356,340 @@ interface GitJasprTest { // endregion + // region sync tests + @Sync + @Test + fun `sync rebases two non-overlapping branches`() { + withTestSetup(useFakeRemote) { + // Two independent stacks behind main. Main advances with "advance_main". + createCommitsFrom( + testCase { + repository { + commit { + title = "fork_point" + id = "" // No jaspr ID for the shared base + branch { + commit { + title = "advance_main" + id = "" + remoteRefs += "main" + } + } + branch { + commit { + title = "A" + localRefs += "branch_a" + } + } + } + commit { + title = "B" + localRefs += "branch_b" + } + } + checkout = "branch_b" + } + ) + localGit.fetch(remoteName) + + val results = gitJaspr.sync(DEFAULT_TARGET_REF) + + assertTrue( + results.all { it.success }, + "All branches should sync successfully: $results", + ) + // Both branches should now be rebased on top of advance_main + for (branchName in listOf("branch_a", "branch_b")) { + val stack = localGit.getLocalCommitStack(remoteName, branchName, DEFAULT_TARGET_REF) + assertTrue(stack.isNotEmpty(), "$branchName should have commits above main") + } + } + } + + @Sync + @Test + fun `sync rebases overlapping branches without duplicating commits`() { + withTestSetup(useFakeRemote) { + // Stack: fork_point -> A -> B -> C + // branch_a at A, branch_c at C (checked out) + // Main advances past fork_point + createCommitsFrom( + testCase { + repository { + commit { + title = "fork_point" + id = "" + branch { + commit { + title = "advance_main" + id = "" + remoteRefs += "main" + } + } + } + commit { + title = "A" + localRefs += "branch_a" + } + commit { title = "B" } + commit { + title = "C" + localRefs += "branch_c" + } + } + checkout = "branch_c" + } + ) + localGit.fetch(remoteName) + + val results = gitJaspr.sync(DEFAULT_TARGET_REF) + + assertTrue( + results.all { it.success }, + "All branches should sync successfully: $results", + ) + + // branch_a should have: advance_main -> A' + val stackA = localGit.getLocalCommitStack(remoteName, "branch_a", DEFAULT_TARGET_REF) + assertEquals(1, stackA.size) + assertEquals("A", stackA[0].shortMessage) + + // branch_c should have: advance_main -> A' -> B' -> C' + val stackC = localGit.getLocalCommitStack(remoteName, "branch_c", DEFAULT_TARGET_REF) + assertEquals(3, stackC.size) + assertEquals(listOf("A", "B", "C"), stackC.map(Commit::shortMessage)) + + // Crucially: A' in branch_a should be the SAME commit as A' in branch_c + assertEquals(stackA[0].hash, stackC[0].hash) + } + } + + @Sync + @Test + fun `sync skips conflicting non-checked-out branch`() { + withTestSetup(useFakeRemote) { + // branch_a will conflict, branch_b (checked out) will not + createCommitsFrom( + testCase { + repository { + commit { + title = "fork_point" + id = "" + localRefs += "fork_ref" + branch { + commit { + title = "advance_main" + id = "" + remoteRefs += "main" + } + } + } + commit { + title = "B" + localRefs += "branch_b" + } + } + checkout = "branch_b" + } + ) + // Create branch_a that modifies the same file as advance_main to cause a conflict + localGit.checkout("fork_ref") + val conflictFile = localRepo.resolve("advance_main.txt") + conflictFile.writeText("conflicting content from branch_a\n") + localGit.add("advance_main.txt") + localGit.commit( + "A_conflicting", + footerLines = mapOf(COMMIT_ID_LABEL to "A_conflicting"), + ) + localGit.branch("branch_a", force = true) + localGit.checkout("branch_b") + localGit.fetch(remoteName) + + val results = gitJaspr.sync(DEFAULT_TARGET_REF) + + val failed = results.filter { !it.success } + val succeeded = results.filter { it.success } + assertTrue(failed.any { it.branch == "branch_a" }, "branch_a should fail: $results") + assertTrue( + succeeded.any { it.branch == "branch_b" }, + "branch_b should succeed: $results", + ) + } + } + + @Sync + @Test + fun `sync handles conflict on checked-out branch`() { + withTestSetup(useFakeRemote) { + // branch_a (not checked out) will succeed + // branch_b (checked out) will conflict + createCommitsFrom( + testCase { + repository { + commit { + title = "fork_point" + id = "" + localRefs += "fork_ref" + branch { + commit { + title = "advance_main" + id = "" + remoteRefs += "main" + } + } + branch { + commit { + title = "A" + localRefs += "branch_a" + } + } + } + } + } + ) + // Create branch_b with a conflicting commit + localGit.checkout("fork_ref") + val conflictFile = localRepo.resolve("advance_main.txt") + conflictFile.writeText("conflicting content from branch_b\n") + localGit.add("advance_main.txt") + localGit.commit( + "B_conflicting", + footerLines = mapOf(COMMIT_ID_LABEL to "B_conflicting"), + ) + localGit.branch("branch_b", force = true) + localGit.checkout("branch_b") + localGit.fetch(remoteName) + + val results = gitJaspr.sync(DEFAULT_TARGET_REF) + + val failed = results.filter { !it.success } + val succeeded = results.filter { it.success } + assertTrue(succeeded.any { it.branch == "branch_a" }) + assertTrue(failed.any { it.branch == "branch_b" }) + } + } + + @Sync + @Test + fun `sync skips dependent branch when ancestor conflicts`() { + withTestSetup(useFakeRemote) { + // branch_a at A (will conflict), branch_b at A -> B (depends on A, should be skipped) + createCommitsFrom( + testCase { + repository { + commit { + title = "fork_point" + id = "" + localRefs += "fork_ref" + branch { + commit { + title = "advance_main" + id = "" + remoteRefs += "main" + } + } + } + } + } + ) + // Create conflicting commit A on branch_a + localGit.checkout("fork_ref") + val conflictFile = localRepo.resolve("advance_main.txt") + conflictFile.writeText("conflicting content\n") + localGit.add("advance_main.txt") + localGit.commit( + "A_conflicting", + footerLines = mapOf(COMMIT_ID_LABEL to "A_conflicting"), + ) + localGit.branch("branch_a", force = true) + // Create B on top of A → branch_b + localGit.commit( + "B_depends_on_A", + footerLines = mapOf(COMMIT_ID_LABEL to "B_depends_on_A"), + ) + localGit.branch("branch_b", force = true) + localGit.checkout("branch_b") + localGit.fetch(remoteName) + + val results = gitJaspr.sync(DEFAULT_TARGET_REF) + + val failed = results.filter { !it.success } + assertTrue(failed.any { it.branch == "branch_a" }, "branch_a should fail: $results") + assertTrue( + failed.any { it.branch == "branch_b" }, + "branch_b should be skipped (depends on branch_a): $results", + ) + } + } + + @Sync + @Test + fun `sync does nothing when branches are already up to date`() { + withTestSetup(useFakeRemote) { + createCommitsFrom( + testCase { + repository { + commit { + title = "A" + localRefs += "development" + } + } + checkout = "development" + } + ) + localGit.fetch(remoteName) + + val results = gitJaspr.sync(DEFAULT_TARGET_REF) + + assertTrue(results.all { it.success }) + } + } + + @Sync + @Test + fun `sync ignores branches without jaspr commit IDs`() { + withTestSetup(useFakeRemote) { + createCommitsFrom( + testCase { + repository { + commit { + title = "fork_point" + id = "" + localRefs += "fork_ref" + branch { + commit { + title = "advance_main" + id = "" + remoteRefs += "main" + } + } + } + commit { + title = "A" + localRefs += "jaspr_branch" + } + } + checkout = "jaspr_branch" + } + ) + // Create a non-jaspr branch (commit without footer) + localGit.checkout("fork_ref") + localGit.commit("plain commit without id") + localGit.branch("plain_branch", force = true) + localGit.checkout("jaspr_branch") + localGit.fetch(remoteName) + + val results = gitJaspr.sync(DEFAULT_TARGET_REF) + + // Only jaspr_branch should be in results, not plain_branch + val branchNames = results.map(GitJaspr.SyncBranchResult::branch) + assertTrue("jaspr_branch" in branchNames) + assertFalse("plain_branch" in branchNames) + } + } + + // endregion + @Test fun `push fails unless workdir is clean`() { // This test fails when ran from GitJasprFunctionalExternalProcessTest because the exception diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/testing/TestTags.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/testing/TestTags.kt index 96fe34ff..1536f909 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/testing/TestTags.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/testing/TestTags.kt @@ -47,6 +47,11 @@ annotation class Stack @Tag("nav") annotation class Nav +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@Tag("sync") +annotation class Sync + @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.RUNTIME) @Tag("functional") From 7cdb6270d70c92029a81e977adaa24a5504b6086 Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Sun, 12 Apr 2026 13:47:31 -0500 Subject: [PATCH 22/27] Redesign NavState to use stack with cursor index Replace the old NavState (headBeforeDetachSha + divergePoint bookends) with a full stack of StackEntry(sha, commitId) and a cursorIndex. This enables reconciliation between nav operations: detecting inserted commits, removed commits (hard reset), and amended commits by comparing Commit-Ids against the actual git state. Nav down/bottom now build the initial stack on first call and reconcile on subsequent calls. Nav up/top cherry-pick from the replay queue (above cursor) and update SHAs immediately after each cherry-pick. commit-id: I5916211d --- .../main/kotlin/sims/michael/gitjaspr/Cli.kt | 7 +- .../kotlin/sims/michael/gitjaspr/GitJaspr.kt | 209 +++++++++++------- .../kotlin/sims/michael/gitjaspr/Model.kt | 24 +- .../sims/michael/gitjaspr/GitJasprTest.kt | 36 ++- 4 files changed, 173 insertions(+), 103 deletions(-) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt index 0034da90..ba3282e3 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt @@ -1159,17 +1159,20 @@ class Bottom : GitJasprSubcommand(helpText = "Move to the bottom of the stack") } class Up : GitJasprSubcommand(helpText = "Move up in the stack (replay commits toward the tip)") { + private val targetOpts by TargetOptions() private val n by argument("n").int().optional() override suspend fun doRun() { - appWiring.gitJaspr.navigateUp(n ?: 1) + appWiring.gitJaspr.navigateUp(n ?: 1, targetOpts.target) } } class Top : GitJasprSubcommand(helpText = "Move to the top of the stack (replay all remaining commits)") { + private val targetOpts by TargetOptions() + override suspend fun doRun() { - appWiring.gitJaspr.navigateToTop() + appWiring.gitJaspr.navigateToTop(targetOpts.target) } } diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt index 8660c1e6..a4a57822 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt @@ -1906,118 +1906,179 @@ class GitJaspr( * state. */ fun navigateDown(targetRef: String, n: Int) { - val remoteName = config.remoteName - gitClient.fetch(remoteName) - val stack = gitClient.getLocalCommitStack(remoteName, GitClient.HEAD, targetRef) - require(stack.isNotEmpty()) { "Stack is empty." } - val existingState = readNavState() - val currentIndex = + val state = if (existingState != null && gitClient.isHeadDetached()) { - // Already in a nav session — find the current position - val headHash = gitClient.log(GitClient.HEAD, 1).single().hash - stack - .indexOfFirst { it.hash == headHash } - .also { index -> require(index >= 0) { "Current HEAD is not in the stack." } } + reconcile(existingState, targetRef) } else { - // Not in a session — HEAD is at the tip - stack.lastIndex + initNavState(targetRef) } - val targetIndex = currentIndex - n + val targetIndex = state.cursorIndex - n require(targetIndex >= 0) { - "Cannot move down $n commit(s) — only $currentIndex commit(s) below current position." + "Cannot move down $n commit(s) — only ${state.cursorIndex} commit(s) below current position." } - val targetCommit = stack[targetIndex] - val headBeforeDetach = - existingState?.headBeforeDetach - ?: gitClient.getCurrentBranchName().also { - require(it.isNotEmpty()) { DETACHED_HEAD_NO_NAV_STATE } - } - val headBeforeDetachSha = - existingState?.headBeforeDetachSha ?: gitClient.log(GitClient.HEAD, 1).single().hash - - gitClient.checkout(targetCommit.hash) - writeNavState(NavState(headBeforeDetach, headBeforeDetachSha, targetCommit.hash)) + gitClient.checkout(state.stack[targetIndex].sha) + writeNavState(state.copy(cursorIndex = targetIndex)) } /** Navigate to the bottom of the stack (first commit above the target branch). */ fun navigateToBottom(targetRef: String) { - val remoteName = config.remoteName - gitClient.fetch(remoteName) - val stack = gitClient.getLocalCommitStack(remoteName, GitClient.HEAD, targetRef) - require(stack.isNotEmpty()) { "Stack is empty." } - val existingState = readNavState() - val headBeforeDetach = - existingState?.headBeforeDetach - ?: gitClient.getCurrentBranchName().also { - require(it.isNotEmpty()) { DETACHED_HEAD_NO_NAV_STATE } - } - val headBeforeDetachSha = - existingState?.headBeforeDetachSha ?: gitClient.log(GitClient.HEAD, 1).single().hash - val targetCommit = stack.first() + val state = + if (existingState != null && gitClient.isHeadDetached()) { + reconcile(existingState, targetRef) + } else { + initNavState(targetRef) + } - gitClient.checkout(targetCommit.hash) - writeNavState(NavState(headBeforeDetach, headBeforeDetachSha, targetCommit.hash)) + require(state.cursorIndex > 0) { "Already at the bottom of the stack." } + + gitClient.checkout(state.stack.first().sha) + writeNavState(state.copy(cursorIndex = 0)) } /** * Navigate up N commits by cherry-picking from the saved stack above the current HEAD. If all * remaining commits are replayed, restores the source branch and ends the session. */ - fun navigateUp(n: Int) { - val state = requireActiveNavSession() + fun navigateUp(n: Int, targetRef: String? = null) { + val state = requireActiveNavSession(targetRef) - val commitsAbove = getCommitsAboveHead(state) - require(commitsAbove.isNotEmpty()) { "Already at the top of the stack." } - require(n <= commitsAbove.size) { - "Cannot move up $n commit(s) — only ${commitsAbove.size} commit(s) above current position." + val aboveCount = state.stack.size - state.cursorIndex - 1 + require(aboveCount > 0) { "Already at the top of the stack." } + require(n <= aboveCount) { + "Cannot move up $n commit(s) — only $aboveCount commit(s) above current position." } - val toReplay = commitsAbove.take(n) - for (commit in toReplay) { - gitClient.cherryPick(commit, commitIdentOverride) + val updatedStack = state.stack.toMutableList() + for (i in 1..n) { + val entry = updatedStack[state.cursorIndex + i] + val newCommit = + gitClient.cherryPick(gitClient.log(entry.sha, 1).single(), commitIdentOverride) + updatedStack[state.cursorIndex + i] = entry.copy(sha = newCommit.hash) } - val replayedAll = n == commitsAbove.size - if (replayedAll) { - endNavSession(state) + val newCursor = state.cursorIndex + n + if (newCursor == updatedStack.lastIndex) { // We've replayed all commits, end the session + endNavSession(state.copy(stack = updatedStack)) } else { - val newHead = gitClient.log(GitClient.HEAD, 1).single().hash - writeNavState(state.copy(divergePoint = newHead)) + writeNavState(state.copy(stack = updatedStack, cursorIndex = newCursor)) } } /** Navigate to the top of the stack by replaying all remaining commits. */ - fun navigateToTop() { - val state = requireActiveNavSession() + fun navigateToTop(targetRef: String? = null) { + val state = requireActiveNavSession(targetRef) - val commitsAbove = getCommitsAboveHead(state) - require(commitsAbove.isNotEmpty()) { "Already at the top of the stack." } + val aboveCount = state.stack.size - state.cursorIndex - 1 + require(aboveCount > 0) { "Already at the top of the stack." } - for (commit in commitsAbove) { - gitClient.cherryPick(commit, commitIdentOverride) + val updatedStack = state.stack.toMutableList() + for (i in (state.cursorIndex + 1)..updatedStack.lastIndex) { + val entry = updatedStack[i] + val newCommit = + gitClient.cherryPick(gitClient.log(entry.sha, 1).single(), commitIdentOverride) + updatedStack[i] = entry.copy(sha = newCommit.hash) + } + + endNavSession(state.copy(stack = updatedStack)) + } + + /** + * Build the initial [NavState] from the current branch. Called on the first nav down/bottom + * when no session exists yet. + */ + private fun initNavState(targetRef: String): NavState { + val branchName = gitClient.getCurrentBranchName() + require(branchName.isNotEmpty()) { DETACHED_HEAD_NO_NAV_STATE } + + val remoteName = config.remoteName + gitClient.fetch(remoteName) + val commits = gitClient.getLocalCommitStack(remoteName, GitClient.HEAD, targetRef) + require(commits.isNotEmpty()) { "Stack is empty." } + + val stack = commits.map { commit -> + StackEntry( + sha = commit.hash, + commitId = + checkNotNull(commit.id) { + "Commit ${commit.hash} (\"${commit.shortMessage}\") has no jaspr commit ID. " + + "Run jaspr push to add IDs before navigating." + }, + ) + } + return NavState(headBeforeDetach = branchName, stack = stack, cursorIndex = stack.lastIndex) + } + + /** + * Reconcile the persisted [NavState] with the actual git state. Detects new commits (inserted + * by the user), removed commits (hard reset), and amended commits (same Commit-Id, different + * SHA). + * + * - New commits are inserted into the stack at their actual position. + * - Missing commits are prepended to the replay queue (above cursor) in their original order. + * - SHA changes for the same Commit-Id are updated in place. + */ + fun reconcile(state: NavState, targetRef: String): NavState { + val remoteName = config.remoteName + gitClient.fetch(remoteName) + + // Walk from HEAD to the merge base to get what's actually materialized + val actualBelow = gitClient.getLocalCommitStack(remoteName, GitClient.HEAD, targetRef) + + // The expected "below" portion of the stack + val expectedBelow = state.stack.subList(0, state.cursorIndex + 1) + + // Build a map of actual commits by Commit-Id + val actualByCommitId = linkedMapOf() + for (commit in actualBelow) { + val commitId = commit.id ?: continue + actualByCommitId[commitId] = StackEntry(sha = commit.hash, commitId = commitId) + } + + // Detect missing commits (were below cursor but no longer in git) + val missingFromBelow = expectedBelow.filter { it.commitId !in actualByCommitId } + + // Build the new below-cursor portion: actual commits in their git order, + // keeping only those with Commit-Ids + val newBelow = actualBelow.mapNotNull { commit -> + val commitId = commit.id ?: return@mapNotNull null + StackEntry(sha = commit.hash, commitId = commitId) } - endNavSession(state) + // Build the new above-cursor portion: missing commits first (in original order), + // then the original above-cursor entries with any SHA updates + val aboveCursor = state.stack.subList(state.cursorIndex + 1, state.stack.size) + val newAbove = + missingFromBelow + + aboveCursor.map { entry -> + // If a commit from the above portion happens to be in the actual below + // (e.g., user cherry-picked it), keep the original SHA for replay + actualByCommitId[entry.commitId] ?: entry + } + + val newStack = newBelow + newAbove + val newCursor = (newBelow.size - 1).coerceAtLeast(0) + + require(newStack.isNotEmpty()) { "Stack is empty after reconciliation." } + + return state.copy(stack = newStack, cursorIndex = newCursor) } /** - * Returns the active nav session state, or throws with an appropriate message. Handles all - * combinations of detached/attached HEAD and present/absent nav state, including auto-clearing - * stale state. + * Returns the active nav session state (after reconciliation), or throws with an appropriate + * message. Handles all combinations of detached/attached HEAD and present/absent nav state, + * including auto-clearing stale state. */ - private fun requireActiveNavSession(): NavState { + private fun requireActiveNavSession(targetRef: String? = null): NavState { val state = readNavState() val detached = gitClient.isHeadDetached() return when { - // Active session — normal case - detached && state != null -> state - // Detached HEAD without jaspr nav state + detached && state != null -> + if (targetRef != null) reconcile(state, targetRef) else state detached -> throw IllegalArgumentException(DETACHED_HEAD_NO_NAV_STATE) - // On a branch — no active session. Clear stale state if present. else -> { if (state != null) clearNavState() throw IllegalArgumentException( @@ -2027,14 +2088,6 @@ class GitJaspr( } } - /** - * Returns the commits from the saved stack that are above the current HEAD (i.e., the commits - * between divergePoint and headBeforeDetachSha, exclusive of divergePoint). - */ - private fun getCommitsAboveHead(state: NavState): List { - return gitClient.logRange(state.divergePoint, state.headBeforeDetachSha) - } - /** * End the navigation session: update the original branch to the new tip, check it out, clear * the state. diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Model.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Model.kt index c5e16ae0..c8cfb495 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Model.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Model.kt @@ -111,26 +111,26 @@ fun RateLimitFields.toRateLimitInfo(): GitHubRateLimitInfo = private fun String.iso8601ToLocalDate(): LocalDateTime = Instant.parse(this).atZone(ZoneId.systemDefault()).toLocalDateTime() +/** A commit in a navigation stack, identified by its jaspr Commit-Id. */ +@Serializable data class StackEntry(val sha: String, val commitId: String) + /** * Ephemeral session state for stack navigation, persisted in `.git/jaspr/nav-state.json`. Exists * only while the user is navigated to a mid-stack commit (detached HEAD). + * + * The [stack] is ordered bottom-to-top (index 0 is closest to the target branch). [cursorIndex] + * points at the commit HEAD is currently on. Entries at `0..cursorIndex` are materialized in git; + * entries at `cursorIndex+1..lastIndex` form the replay queue that gets cherry-picked on `jaspr up` + * / `jaspr top`. */ @Serializable data class NavState( /** The branch name HEAD was on before detaching, restored when the session ends. */ val headBeforeDetach: String, - /** - * SHA that [headBeforeDetach] pointed to right before we entered deteached HEAD. Defines the - * top of the replay range. - */ - val headBeforeDetachSha: String, - /** - * SHA that HEAD pointed to as of the last navigation operation. Updated on each `jaspr - * down`/`up` invocation. If the user creates additional commits between nav operations, this - * becomes a true diverge point — the merge base between HEAD and the original stack. The replay - * range for `jaspr up`/`top` is `divergePoint..headBeforeDetachSha`. - */ - val divergePoint: String, + /** The full stack, bottom-to-top. Each entry carries the latest known SHA for its Commit-Id. */ + val stack: List, + /** Index into [stack] of the commit HEAD is currently on. */ + val cursorIndex: Int, ) class GitJasprException(override val message: String) : RuntimeException(message) { diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt index 6531c89f..d043ce61 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt @@ -93,8 +93,12 @@ interface GitJasprTest { val state = NavState( headBeforeDetach = "my-feature", - headBeforeDetachSha = "abc123", - divergePoint = "def456", + stack = + listOf( + StackEntry(sha = "abc123", commitId = "id-1"), + StackEntry(sha = "def456", commitId = "id-2"), + ), + cursorIndex = 0, ) gitJaspr.writeNavState(state) assertEquals(state, gitJaspr.readNavState()) @@ -114,8 +118,12 @@ interface GitJasprTest { val state = NavState( headBeforeDetach = "my-feature", - headBeforeDetachSha = "abc123", - divergePoint = "def456", + stack = + listOf( + StackEntry(sha = "abc123", commitId = "id-1"), + StackEntry(sha = "def456", commitId = "id-2"), + ), + cursorIndex = 0, ) gitJaspr.writeNavState(state) gitJaspr.clearNavState() @@ -155,8 +163,9 @@ interface GitJasprTest { val state = gitJaspr.readNavState() assertNotNull(state) assertEquals("development", state.headBeforeDetach) - assertEquals(stack.last().hash, state.headBeforeDetachSha) - assertEquals(stack[1].hash, state.divergePoint) + assertEquals(3, state.stack.size) + assertEquals(1, state.cursorIndex) + assertEquals(stack[1].hash, state.stack[1].sha) } } @@ -235,7 +244,7 @@ interface GitJasprTest { @Nav @Test - fun `down within active nav session updates diverge point`() { + fun `down within active nav session updates cursor index`() { withTestSetup(useFakeRemote) { createCommitsFrom( testCase { @@ -259,13 +268,18 @@ interface GitJasprTest { gitJaspr.navigateDown(DEFAULT_TARGET_REF, 1) val secondState = gitJaspr.readNavState() - // Source branch and saved tip should be preserved from first navigation + // Source branch and stack should be preserved from first navigation assertNotNull(firstState) assertNotNull(secondState) assertEquals(firstState.headBeforeDetach, secondState.headBeforeDetach) - assertEquals(firstState.headBeforeDetachSha, secondState.headBeforeDetachSha) - // Diverge point should have moved down - assertEquals(stack[0].hash, secondState.divergePoint) + assertEquals( + firstState.stack.map(StackEntry::commitId), + secondState.stack.map(StackEntry::commitId), + ) + // Cursor should have moved down + assertEquals(1, firstState.cursorIndex) + assertEquals(0, secondState.cursorIndex) + assertEquals(stack[0].hash, localGit.log(GitClient.HEAD, 1).single().hash) } } From e088a923ada96abb55a92d4e80c938cbd2760860 Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Sun, 12 Apr 2026 13:54:27 -0500 Subject: [PATCH 23/27] Add tests for mid-navigation mutations Tests for the three key reconciliation scenarios: - New commit during nav session: inserted into the stack - Hard reset during nav session: removed commits move to replay queue - Amend during nav session: SHA updated, amended version used on replay commit-id: I52e5aac4 --- .../sims/michael/gitjaspr/GitJasprTest.kt | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt index d043ce61..f0fa4d47 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt @@ -368,6 +368,136 @@ interface GitJasprTest { } } + @Nav + @Test + fun `new commit during nav session is inserted into stack`() { + withTestSetup(useFakeRemote) { + // Stack: A -> B -> C -> D on "development" + createCommitsFrom( + testCase { + repository { + commit { title = "A" } + commit { title = "B" } + commit { title = "C" } + commit { + title = "D" + localRefs += "development" + } + } + checkout = "development" + } + ) + + // Nav down 2: HEAD at B + gitJaspr.navigateDown(DEFAULT_TARGET_REF, 2) + assertEquals("B", localGit.log(GitClient.HEAD, 1).single().shortMessage) + + // Create a new commit E on top of B + localRepo.resolve("new_file.txt").writeText("inserted commit\n") + localGit.add("new_file.txt") + localGit.commit("E", footerLines = mapOf(COMMIT_ID_LABEL to "E")) + + // Nav up 1: reconciliation detects E, cherry-picks C + gitJaspr.navigateUp(1, DEFAULT_TARGET_REF) + assertEquals("C", localGit.log(GitClient.HEAD, 1).single().shortMessage) + + // Nav up 1: cherry-picks D, restores branch + gitJaspr.navigateUp(1, DEFAULT_TARGET_REF) + assertFalse(localGit.isHeadDetached()) + assertEquals("development", localGit.getCurrentBranchName()) + assertNull(gitJaspr.readNavState()) + + // Final stack should be A -> B -> E -> C -> D + val finalStack = + localGit.getLocalCommitStack(remoteName, GitClient.HEAD, DEFAULT_TARGET_REF) + assertEquals(listOf("A", "B", "E", "C", "D"), finalStack.map(Commit::shortMessage)) + } + } + + @Nav + @Test + fun `hard reset during nav session moves removed commits to replay queue`() { + withTestSetup(useFakeRemote) { + // Stack: A -> B -> C -> D on "development" + createCommitsFrom( + testCase { + repository { + commit { title = "A" } + commit { title = "B" } + commit { title = "C" } + commit { + title = "D" + localRefs += "development" + } + } + checkout = "development" + } + ) + + // Nav down 2: HEAD at B + gitJaspr.navigateDown(DEFAULT_TARGET_REF, 2) + assertEquals("B", localGit.log(GitClient.HEAD, 1).single().shortMessage) + + // Hard reset to A (removing B from materialized commits) + localGit.reset("HEAD~1") + + // Nav up 1: reconciliation detects B is missing, prepends to replay queue. + // Replay queue was [C, D], now [B, C, D]. Cherry-picks B. + gitJaspr.navigateUp(1, DEFAULT_TARGET_REF) + assertEquals("B", localGit.log(GitClient.HEAD, 1).single().shortMessage) + + // Nav to top: cherry-picks C and D, restores branch + gitJaspr.navigateToTop(DEFAULT_TARGET_REF) + assertFalse(localGit.isHeadDetached()) + + // Final stack should be A -> B -> C -> D (same order, new SHAs) + val finalStack = + localGit.getLocalCommitStack(remoteName, GitClient.HEAD, DEFAULT_TARGET_REF) + assertEquals(listOf("A", "B", "C", "D"), finalStack.map(Commit::shortMessage)) + } + } + + @Nav + @Test + fun `amend during nav session updates SHA in stack`() { + withTestSetup(useFakeRemote) { + // Stack: A -> B -> C on "development" + createCommitsFrom( + testCase { + repository { + commit { title = "A" } + commit { title = "B" } + commit { + title = "C" + localRefs += "development" + } + } + checkout = "development" + } + ) + + // Nav down 1: HEAD at B + gitJaspr.navigateDown(DEFAULT_TARGET_REF, 1) + + // Amend B (fix a typo — same commit ID, different SHA) + localRepo.resolve("amend_fix.txt").writeText("amended content\n") + localGit.add("amend_fix.txt") + localGit.commit("B", footerLines = mapOf(COMMIT_ID_LABEL to "B"), amend = true) + + // Nav down 1: reconciliation should detect the amended SHA + gitJaspr.navigateDown(DEFAULT_TARGET_REF, 1) + assertEquals("A", localGit.log(GitClient.HEAD, 1).single().shortMessage) + + // Nav up 1: should cherry-pick the amended B, not the original + gitJaspr.navigateUp(1, DEFAULT_TARGET_REF) + val replayedB = localGit.log(GitClient.HEAD, 1).single() + assertEquals("B", replayedB.shortMessage) + + // The replayed B should contain the amended content + assertTrue(localRepo.resolve("amend_fix.txt").readText().contains("amended content")) + } + } + // endregion // region sync tests From be8ed7bc5f271acbb791c84454299fa812e87a04 Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Sun, 12 Apr 2026 13:57:24 -0500 Subject: [PATCH 24/27] Add jaspr drop command Drops the HEAD commit(s) from the stack. During a nav session, the dropped commits are removed entirely from the tracked stack (unlike a hard reset, which moves them to the replay queue). Without a nav session, behaves like git reset --hard HEAD~n. commit-id: If5d9dd83 --- .../main/kotlin/sims/michael/gitjaspr/Cli.kt | 10 +++ .../kotlin/sims/michael/gitjaspr/GitJaspr.kt | 39 ++++++++++ .../sims/michael/gitjaspr/GitJasprTest.kt | 76 +++++++++++++++++++ 3 files changed, 125 insertions(+) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt index ba3282e3..b913619c 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt @@ -1195,6 +1195,15 @@ class NavClear : GitJasprSubcommand(name = "clear", helpText = "Clear navigation } } +class Drop : GitJasprSubcommand(helpText = "Drop the top N commits from the stack (default 1)") { + private val targetOpts by TargetOptions() + private val n by argument("n").int().optional() + + override suspend fun doRun() { + appWiring.gitJaspr.drop(n ?: 1, targetOpts.target) + } +} + // endregion class Fixup : GitJasprSubcommand(helpText = "Create a fixup commit targeting a stack commit") { @@ -1664,6 +1673,7 @@ fun buildCommand(): SuspendingCliktCommand = Up(), Top(), Nav().subcommands(NavClear()), + Drop(), Fixup(), Continue(), Sync(), diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt index a4a57822..7ec5004c 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt @@ -2099,6 +2099,45 @@ class GitJaspr( clearNavState() } + /** + * Drop [n] commits from the top of the current stack. + * + * When a nav session is active, the dropped commits are removed from the nav stack and the + * cursor is adjusted. When no session is active, this is equivalent to `git reset --hard + * HEAD~n`. + */ + fun drop(n: Int, targetRef: String? = null) { + require(n > 0) { "Must drop at least 1 commit." } + + val state = readNavState() + if (state != null && gitClient.isHeadDetached()) { + val reconciled = if (targetRef != null) reconcile(state, targetRef) else state + require(n <= reconciled.cursorIndex + 1) { + "Cannot drop $n commit(s) — only ${reconciled.cursorIndex + 1} commit(s) at or below current position." + } + + // Remove the top n entries from below the cursor + val newStack = + reconciled.stack.toMutableList().apply { + val removeFrom = reconciled.cursorIndex - n + 1 + repeat(n) { removeAt(removeFrom) } + } + val newCursor = reconciled.cursorIndex - n + + gitClient.reset("HEAD~$n") + + if (newStack.isEmpty()) { + // Dropped everything — end the session + clearNavState() + } else { + writeNavState(reconciled.copy(stack = newStack, cursorIndex = newCursor)) + } + } else { + // No nav session — just hard reset + gitClient.reset("HEAD~$n") + } + } + /** Result of syncing a single branch. */ data class SyncBranchResult(val branch: String, val success: Boolean, val message: String) diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt index f0fa4d47..0b88fa4f 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt @@ -498,6 +498,82 @@ interface GitJasprTest { } } + @Nav + @Test + fun `drop during nav session removes commit from stack`() { + withTestSetup(useFakeRemote) { + // Stack: A -> B -> C -> D on "development" + createCommitsFrom( + testCase { + repository { + commit { title = "A" } + commit { title = "B" } + commit { title = "C" } + commit { + title = "D" + localRefs += "development" + } + } + checkout = "development" + } + ) + + // Nav down 2: HEAD at B + gitJaspr.navigateDown(DEFAULT_TARGET_REF, 2) + assertEquals("B", localGit.log(GitClient.HEAD, 1).single().shortMessage) + + // Drop B (removes from stack entirely, HEAD moves to A) + gitJaspr.drop(1, DEFAULT_TARGET_REF) + assertEquals("A", localGit.log(GitClient.HEAD, 1).single().shortMessage) + + // Nav state should still exist with B removed + val state = gitJaspr.readNavState() + assertNotNull(state) + assertEquals(3, state.stack.size) // A, C, D (B is gone) + assertEquals(0, state.cursorIndex) // at A + + // Nav up 1: cherry-picks C (B was dropped, not in replay queue) + gitJaspr.navigateUp(1, DEFAULT_TARGET_REF) + assertEquals("C", localGit.log(GitClient.HEAD, 1).single().shortMessage) + + // Nav to top: cherry-picks D, restores branch + gitJaspr.navigateToTop(DEFAULT_TARGET_REF) + assertFalse(localGit.isHeadDetached()) + + // Final stack should be A -> C -> D (B was dropped) + val finalStack = + localGit.getLocalCommitStack(remoteName, GitClient.HEAD, DEFAULT_TARGET_REF) + assertEquals(listOf("A", "C", "D"), finalStack.map(Commit::shortMessage)) + } + } + + @Nav + @Test + fun `drop without nav session resets HEAD`() { + withTestSetup(useFakeRemote) { + createCommitsFrom( + testCase { + repository { + commit { title = "A" } + commit { title = "B" } + commit { + title = "C" + localRefs += "development" + } + } + checkout = "development" + } + ) + + gitJaspr.drop(1, DEFAULT_TARGET_REF) + + // Should still be on the branch, HEAD at B + assertFalse(localGit.isHeadDetached()) + assertEquals("B", localGit.log(GitClient.HEAD, 1).single().shortMessage) + assertNull(gitJaspr.readNavState()) + } + } + // endregion // region sync tests From a546790325439e74464ec31b181db229460d3fcd Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Sun, 12 Apr 2026 13:59:52 -0500 Subject: [PATCH 25/27] Disallow jaspr edit during active nav session Adding an interactive rebase while in detached HEAD with nav state would corrupt the navigation. The guard checks for both conditions and directs the user to jaspr top or jaspr nav clear first. commit-id: I8920d803 --- .../main/kotlin/sims/michael/gitjaspr/Cli.kt | 10 ++++++ .../sims/michael/gitjaspr/GitJasprTest.kt | 33 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt index b913619c..3d9d3350 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Cli.kt @@ -1048,6 +1048,16 @@ class Edit(name: String? = null) : GitJasprSubcommand(name = name) { private val targetOpts by TargetOptions() override suspend fun doRun() { + val jaspr = appWiring.gitJaspr + if (jaspr.readNavState() != null && appWiring.gitClient.isHeadDetached()) { + renderer.error { + "Cannot edit while a navigation session is active. " + + "Run ${command("jaspr top")} to return to the branch first, " + + "or ${command("jaspr nav clear")} to discard the session." + } + throw ProgramResult(1) + } + val config = appWiring.config val remoteName = config.remoteName val target = targetOpts.target diff --git a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt index 0b88fa4f..5320c9be 100644 --- a/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt +++ b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/GitJasprTest.kt @@ -574,6 +574,39 @@ interface GitJasprTest { } } + @Nav + @Test + fun `active nav session is detectable for edit guard`() { + withTestSetup(useFakeRemote) { + createCommitsFrom( + testCase { + repository { + commit { title = "A" } + commit { + title = "B" + localRefs += "development" + } + } + checkout = "development" + } + ) + + // Before nav: no session + assertNull(gitJaspr.readNavState()) + assertFalse(localGit.isHeadDetached()) + + // Navigate down: session is active + gitJaspr.navigateDown(DEFAULT_TARGET_REF, 1) + assertNotNull(gitJaspr.readNavState()) + assertTrue(localGit.isHeadDetached()) + + // Navigate to top: session ends + gitJaspr.navigateToTop(DEFAULT_TARGET_REF) + assertNull(gitJaspr.readNavState()) + assertFalse(localGit.isHeadDetached()) + } + } + // endregion // region sync tests From a817b8c549795cc1c7f311fb34aec9c2b27e7c9c Mon Sep 17 00:00:00 2001 From: Michael Sims Date: Sun, 12 Apr 2026 14:15:28 -0500 Subject: [PATCH 26/27] Wrap blocking file lock operations in Dispatchers.IO RandomAccessFile construction, lock.release(), and lockFile.close() in autoMerge were flagged as blocking calls in a non-blocking context. commit-id: I5feedbfb --- .../kotlin/sims/michael/gitjaspr/GitJaspr.kt | 105 ++++++++++-------- 1 file changed, 58 insertions(+), 47 deletions(-) diff --git a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt index 7ec5004c..98e8944c 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt @@ -2,7 +2,6 @@ package sims.michael.gitjaspr import java.io.File import java.io.RandomAccessFile -import java.nio.channels.FileLock import java.time.ZonedDateTime import java.util.SortedSet import kotlin.text.RegexOption.IGNORE_CASE @@ -714,50 +713,17 @@ class GitJaspr( val autoMergeRefSpec = refSpec.copy(localRef = adjustedLocalRef) logger.trace("autoMerge refSpec: {}", autoMergeRefSpec) - val remoteUri = - requireNotNull(gitClient.getRemoteUriOrNull(remoteName)) { - "Could not find remote URI for remote: $remoteName" - } - val cacheDir = getAutoMergeCacheDir() - val lockFile = RandomAccessFile(File("${cacheDir.absolutePath}.lock"), "rw") - val lock = acquireAutoMergeLock(lockFile) + val cacheDirGit = OptimizedCliGitClient(cacheDir, config.remoteBranchPrefix) + val lockFile = acquireAutoMergeLock(cacheDirGit, currentRef) try { - val cacheDirGit = OptimizedCliGitClient(cacheDir, config.remoteBranchPrefix) - val isCached = cacheDir.resolve(".git").isDirectory - val setupTime = measureTime { - withContext(Dispatchers.IO) { - cacheDirGit.showStderr = true - if (isCached) { - renderer.info { "Fetching latest changes into auto-merge cache..." } - cacheDirGit.fetch(remoteName) - } else { - renderer.info { "Cloning repository into auto-merge cache..." } - cacheDirGit.clone(remoteUri, remoteName) - } - - // Add/update the original working directory as a remote so we can fetch - // unpushed commits - if (cacheDirGit.getRemoteUriOrNull("local") == null) { - logger.debug("Adding local remote for unpushed commits") - cacheDirGit.addRemote("local", config.workingDirectory.absolutePath) - } - cacheDirGit.fetch("local") - cacheDirGit.showStderr = false - - cacheDirGit.checkout(currentRef) - } - } - val verb = if (isCached) "Fetched" else "Cloned" - logger.debug("{} auto-merge cache in {}", verb, setupTime) - // Run the auto-merge loop val cacheDirJaspr = GitJaspr( ghClient, cacheDirGit, - config.copy(workingDirectory = cacheDir), + config.copy(workingDirectory = cacheDirGit.workingDirectory), newUuid, commitIdentOverride, renderer, @@ -820,13 +786,13 @@ class GitJaspr( } catch (e: Exception) { logger.error( "Auto-merge failed with exception. Cache directory: {}", - cacheDir.absolutePath, + cacheDirGit.workingDirectory.absolutePath, e, ) throw e } finally { - lock.release() - lockFile.close() + // Closing the file releases the lock acquired above + withContext(Dispatchers.IO) { lockFile.close() } } } @@ -1875,6 +1841,58 @@ class GitJaspr( private fun getAutoMergeCacheDir(): File = getJasprDir().resolve("automerge") + /** + * Acquires an exclusive file lock on the auto-merge cache, clones or fetches the repo into + * [cacheDirGit], and checks out [currentRef]. Returns the lock file — the caller must close it + * to release the lock. + */ + private suspend fun acquireAutoMergeLock( + cacheDirGit: OptimizedCliGitClient, + currentRef: String, + ): RandomAccessFile { + val remoteName = config.remoteName + val remoteUri = + requireNotNull(gitClient.getRemoteUriOrNull(remoteName)) { + "Could not find remote URI for remote: $remoteName" + } + val cacheDir = cacheDirGit.workingDirectory + + return withContext(Dispatchers.IO) { + RandomAccessFile(File("${cacheDir.absolutePath}.lock"), "rw").apply { + channel.tryLock() + ?: throw GitJasprException( + "Another auto-merge is already running for this repository. " + + "If this is unexpected, check for stale processes." + ) + + val isCached = cacheDir.resolve(".git").isDirectory + val setupTime = measureTime { + cacheDirGit.showStderr = true + if (isCached) { + renderer.info { "Fetching latest changes into auto-merge cache..." } + cacheDirGit.fetch(remoteName) + } else { + renderer.info { "Cloning repository into auto-merge cache..." } + cacheDirGit.clone(remoteUri, remoteName) + } + + // Add/update the original working directory as a remote so we can fetch + // unpushed commits + if (cacheDirGit.getRemoteUriOrNull("local") == null) { + logger.debug("Adding local remote for unpushed commits") + cacheDirGit.addRemote("local", config.workingDirectory.absolutePath) + } + cacheDirGit.fetch("local") + cacheDirGit.showStderr = false + + cacheDirGit.checkout(currentRef) + } + val verb = if (isCached) "Fetched" else "Cloned" + logger.debug("{} auto-merge cache in {}", verb, setupTime) + } + } + } + private val navStateFile get() = getJasprDir().resolve("nav-state.json") @@ -2459,13 +2477,6 @@ class GitJaspr( check(result == 0) { "Failed to update branch $branch to $commit" } } - private fun acquireAutoMergeLock(lockFile: RandomAccessFile): FileLock = - lockFile.channel.tryLock() - ?: throw GitJasprException( - "Another auto-merge is already running for this repository. " + - "If this is unexpected, check for stale processes." - ) - companion object { private const val DETACHED_HEAD_NO_NAV_STATE = "HEAD is detached but no jaspr navigation state was found. " + From 412c345f7cf58257fa708c2d5089d42046dd9a5c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 01:24:59 +0000 Subject: [PATCH 27/27] Bump dawidd6/action-download-artifact from 20 to 21 Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 20 to 21. - [Release notes](https://github.com/dawidd6/action-download-artifact/releases) - [Commits](https://github.com/dawidd6/action-download-artifact/compare/8305c0f1062bb0d184d09ef4493ecb9288447732...b6e2e70617bc3265edd6dab6c906732b2f1ae151) --- updated-dependencies: - dependency-name: dawidd6/action-download-artifact dependency-version: '21' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/test_results.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_results.yml b/.github/workflows/test_results.yml index 3d0412bc..f180edaa 100644 --- a/.github/workflows/test_results.yml +++ b/.github/workflows/test_results.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Download Artifacts - uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v6 + uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v6 with: run_id: ${{ github.event.workflow_run.id }} path: artifacts