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 diff --git a/git-jaspr/build.gradle.kts b/git-jaspr/build.gradle.kts index 6b699ebf..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") +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/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 71592fa7..3d9d3350 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.* @@ -568,25 +569,32 @@ 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." ) } 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() @@ -670,7 +678,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) } } @@ -940,9 +948,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() } @@ -950,14 +971,71 @@ 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) } } } -class Edit : 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`. */ +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(name: String? = null) : GitJasprSubcommand(name = name) { // language=Markdown override fun help(context: Context) = """ @@ -970,6 +1048,16 @@ class Edit : GitJasprSubcommand() { 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 @@ -1003,11 +1091,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" @@ -1036,12 +1128,212 @@ 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() } } +// 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) + } +} + +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, 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(targetOpts.target) + } +} + +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." } + } + } +} + +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") { + 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 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" @@ -1050,29 +1342,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("") } @@ -1374,11 +1676,21 @@ fun buildCommand(): SuspendingCliktCommand = Clean(), Rebase(), Edit(), + Edit(name = "reorder"), Stack().subcommands(StackList(), StackRename(), StackDelete()), + Down(), + Bottom(), + Up(), + Top(), + Nav().subcommands(NavClear()), + Drop(), + Fixup(), + Continue(), + Sync(), PreviewTheme(), LogPath(), Init(), - InstallCommitIdHook(), + InstallHook(), NoOp(), ) 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..aa7f5f40 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()) { @@ -96,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( @@ -254,6 +260,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") } @@ -289,7 +297,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) @@ -590,6 +598,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/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 6736ef9d..98e8944c 100644 --- a/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt +++ b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/GitJaspr.kt @@ -1,17 +1,18 @@ package sims.michael.gitjaspr -import java.nio.file.Files +import java.io.File +import java.io.RandomAccessFile import java.time.ZonedDateTime import java.util.SortedSet 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 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 +32,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) @@ -189,7 +191,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 +245,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() @@ -329,12 +346,15 @@ 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." ) } + installCommitIdHook() + val remoteName = config.remoteName gitClient.fetch(remoteName) @@ -402,8 +422,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, @@ -417,7 +438,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." + } } } @@ -654,6 +681,7 @@ class GitJaspr( pollingIntervalSeconds: Int = 10, maxAttempts: Int = Int.MAX_VALUE, count: Int? = null, + theme: Theme = MonoTheme, ) { logger.trace("autoMerge {} {}", refSpec, pollingIntervalSeconds) @@ -678,67 +706,24 @@ 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 remoteUri = - requireNotNull(gitClient.getRemoteUriOrNull(remoteName)) { - "Could not find remote URI for remote: $remoteName" - } - - val tempGit = OptimizedCliGitClient(tempDir, config.remoteBranchPrefix) - try { - renderer.info { "Cloning repository to temporary directory for auto-merge..." } - val cloneTime = measureTime { - coroutineScope { - val heartbeat = launch { - delay(5.seconds) - while (isActive) { - renderer.info { "Still cloning, please wait... (CTRL-C to cancel)" } - delay(5.seconds) - } - } - withContext(Dispatchers.IO) { - tempGit.clone(remoteUri, remoteName) + val autoMergeRefSpec = refSpec.copy(localRef = adjustedLocalRef) + logger.trace("autoMerge refSpec: {}", autoMergeRefSpec) - // 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") + val cacheDir = getAutoMergeCacheDir() + val cacheDirGit = OptimizedCliGitClient(cacheDir, config.remoteBranchPrefix) + val lockFile = acquireAutoMergeLock(cacheDirGit, currentRef) - tempGit.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 - } - - // 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 = cacheDirGit.workingDirectory), newUuid, commitIdentOverride, renderer, @@ -747,36 +732,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, theme)) if (statuses.any { status -> status.checksPass == false }) { renderer.warn { "Checks are failing. Aborting auto-merge." } @@ -793,21 +781,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: {}", + cacheDirGit.workingDirectory.absolutePath, e, ) throw e + } finally { + // Closing the file releases the lock acquired above + withContext(Dispatchers.IO) { lockFile.close() } } } @@ -843,13 +828,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. */ @@ -1052,16 +1072,19 @@ 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 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 +1430,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) @@ -1561,7 +1581,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) @@ -1575,7 +1595,7 @@ class GitJaspr( */ fun suggestStackNames( refSpec: RefSpec = RefSpec(DEFAULT_LOCAL_OBJECT, DEFAULT_TARGET_REF) - ): List { + ): StackNameSuggestions { val remoteName = config.remoteName gitClient.fetch(remoteName) @@ -1592,18 +1612,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" @@ -1622,14 +1651,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 + } } /** @@ -1731,21 +1770,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 */ @@ -1759,7 +1821,667 @@ 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() } + + 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") + + 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 + + /** + * Navigate down N commits in the stack (toward the target branch). Detaches HEAD and writes nav + * state. + */ + fun navigateDown(targetRef: String, n: Int) { + val existingState = readNavState() + val state = + if (existingState != null && gitClient.isHeadDetached()) { + reconcile(existingState, targetRef) + } else { + initNavState(targetRef) + } + + val targetIndex = state.cursorIndex - n + require(targetIndex >= 0) { + "Cannot move down $n commit(s) — only ${state.cursorIndex} commit(s) below current position." + } + + 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 existingState = readNavState() + val state = + if (existingState != null && gitClient.isHeadDetached()) { + reconcile(existingState, targetRef) + } else { + initNavState(targetRef) + } + + 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, targetRef: String? = null) { + val state = requireActiveNavSession(targetRef) + + 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 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 newCursor = state.cursorIndex + n + if (newCursor == updatedStack.lastIndex) { // We've replayed all commits, end the session + endNavSession(state.copy(stack = updatedStack)) + } else { + writeNavState(state.copy(stack = updatedStack, cursorIndex = newCursor)) + } + } + + /** Navigate to the top of the stack by replaying all remaining commits. */ + fun navigateToTop(targetRef: String? = null) { + val state = requireActiveNavSession(targetRef) + + val aboveCount = state.stack.size - state.cursorIndex - 1 + require(aboveCount > 0) { "Already at the top of the stack." } + + 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) + } + + // 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 (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(targetRef: String? = null): NavState { + val state = readNavState() + val detached = gitClient.isHeadDetached() + return when { + detached && state != null -> + if (targetRef != null) reconcile(state, targetRef) else state + detached -> throw IllegalArgumentException(DETACHED_HEAD_NO_NAV_STATE) + else -> { + if (state != null) clearNavState() + throw IllegalArgumentException( + "No navigation session in progress (already at the top of the stack)." + ) + } + } + } + + /** + * 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() + } + + /** + * 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) + + /** + * 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" } + } + 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/main/kotlin/sims/michael/gitjaspr/JGitClient.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/JGitClient.kt index 29eeb4e7..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( @@ -268,7 +271,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? @@ -324,9 +329,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/main/kotlin/sims/michael/gitjaspr/Model.kt b/git-jaspr/src/main/kotlin/sims/michael/gitjaspr/Model.kt index 43a51347..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,6 +111,28 @@ 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, + /** 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) { constructor(message: String, cause: Throwable) : this(message) { initCause(cause) 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) 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`). 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/CliTest.kt b/git-jaspr/src/test/kotlin/sims/michael/gitjaspr/CliTest.kt index 6092af18..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() @@ -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( 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..5320c9be 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,8 @@ 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 import org.slf4j.Logger @@ -20,10 +22,12 @@ 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 import sims.michael.gitjaspr.testing.Status +import sims.michael.gitjaspr.testing.Sync interface GitJasprTest { @@ -81,6 +85,864 @@ interface GitJasprTest { assertEquals(expected, actual) } + // region nav state tests + @Nav + @Test + fun `nav state round-trips through write and read`() { + withTestSetup(useFakeRemote) { + val state = + NavState( + headBeforeDetach = "my-feature", + stack = + listOf( + StackEntry(sha = "abc123", commitId = "id-1"), + StackEntry(sha = "def456", commitId = "id-2"), + ), + cursorIndex = 0, + ) + gitJaspr.writeNavState(state) + assertEquals(state, gitJaspr.readNavState()) + } + } + + @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) { + val state = + NavState( + headBeforeDetach = "my-feature", + stack = + listOf( + StackEntry(sha = "abc123", commitId = "id-1"), + StackEntry(sha = "def456", commitId = "id-2"), + ), + cursorIndex = 0, + ) + gitJaspr.writeNavState(state) + gitJaspr.clearNavState() + assertNull(gitJaspr.readNavState()) + } + } + + // 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(3, state.stack.size) + assertEquals(1, state.cursorIndex) + assertEquals(stack[1].hash, state.stack[1].sha) + } + } + + @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 cursor index`() { + 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 stack should be preserved from first navigation + assertNotNull(firstState) + assertNotNull(secondState) + assertEquals(firstState.headBeforeDetach, secondState.headBeforeDetach) + 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) + } + } + + @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) } + } + } + + @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")) + } + } + + @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()) + } + } + + @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 + @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 @@ -102,6 +964,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) { @@ -1247,6 +2129,60 @@ 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 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`() { @@ -2058,7 +2994,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( """ @@ -2066,7 +3002,10 @@ interface GitJasprTest { |[✅✅✅✅✅✅] %s : %s : one """ .trimMargin() - .toStatusString(detachedHeadActual, null), + .toStatusString( + detachedHeadActual, + ambiguousStackNames = listOf(secondStackName, stackName), + ), detachedHeadActual, ) } @@ -2354,7 +3293,7 @@ interface GitJasprTest { ) val suggested = gitJaspr.suggestStackNames() - assertEquals(listOf("one", "two"), suggested) + assertEquals(listOf("one", "two"), suggested.candidates) } } @@ -2380,7 +3319,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) } } @@ -3917,6 +4856,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`() { @@ -4780,6 +5798,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. @@ -4804,6 +5823,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}") @@ -5748,6 +6774,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`() { @@ -5945,6 +7020,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 { 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")) 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..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 @@ -221,7 +222,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() } @@ -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()) 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/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..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 @@ -42,6 +42,16 @@ annotation class Checkout @Tag("stack") annotation class Stack +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@Tag("nav") +annotation class Nav + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@Tag("sync") +annotation class Sync + @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.RUNTIME) @Tag("functional") 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