From 6ef26b706f8a5be3d21a90f7269f05b98034f3f7 Mon Sep 17 00:00:00 2001 From: Raman Gupta Date: Thu, 29 Oct 2026 12:23:26 -0400 Subject: [PATCH 1/2] feat(branches): clean up all merged remote branches Each remote's heading in Remote branches gets "Clean up N merged", which deletes every merged (or squash-merged) branch shown for that remote, skipping integration branches. Unlike a single merged delete it asks first, listing the branches, since it deletes ones the click never named on a server others share. BranchOps.deleteRemotes does one ls-remote and a few chunked pushes per remote. Each delete is leased on the tip last fetched, so a branch pushed to since is skipped; one already deleted on the remote counts as deleted and its stale copy is dropped. Popup.Confirm gains an optional details list to show the names, as the message is capped at three lines. Signed-off-by: Raman Gupta --- .../kotlin/com/gitvantage/app/AppState.kt | 23 +++++- .../kotlin/com/gitvantage/git/BranchOps.kt | 67 ++++++++++++++++ .../kotlin/com/gitvantage/ui/DetailPanel.kt | 51 ++++++++++-- src/main/kotlin/com/gitvantage/ui/Popups.kt | 7 +- .../com/gitvantage/git/BranchOpsTest.kt | 79 +++++++++++++++++++ 5 files changed, 219 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/gitvantage/app/AppState.kt b/src/main/kotlin/com/gitvantage/app/AppState.kt index a219bd5..d4cd409 100644 --- a/src/main/kotlin/com/gitvantage/app/AppState.kt +++ b/src/main/kotlin/com/gitvantage/app/AppState.kt @@ -149,6 +149,8 @@ sealed interface Popup { val message: String, val confirmLabel: String, val danger: Boolean, + /** What the confirm acts on, one per line, when that's a list — the message is three lines at most. */ + val details: List = emptyList(), val onConfirm: () -> Unit, ) : Popup { override val ids: Set = emptySet() @@ -306,6 +308,10 @@ class AppState(private val scope: CoroutineScope) { var deletingRemoteBranch by mutableStateOf(null) private set + /** Full refs being deleted by a "Clean up merged" sweep, so each of their rows says "Deleting…". */ + var cleaningRemoteBranches by mutableStateOf>(emptySet()) + private set + // Submodules for the currently-open detail panel (loaded lazily on selection). var submodulesRepo by mutableStateOf(null) private set @@ -1960,7 +1966,7 @@ class AppState(private val scope: CoroutineScope) { * only thing left on screen saying the delete happened. */ fun deleteRemoteBranch(id: String, rb: RemoteBranch) { - if (deletingRemoteBranch != null) return + if (deletingRemoteBranch != null || cleaningRemoteBranches.isNotEmpty()) return deletingRemoteBranch = rb.name scope.launch { val r = withContext(Dispatchers.IO) { BranchOps.deleteRemote(id, rb) } @@ -1971,6 +1977,19 @@ class AppState(private val scope: CoroutineScope) { } } + /** Delete a set of merged remote branches in one go (see [BranchOps.deleteRemotes]). */ + fun deleteMergedRemoteBranches(id: String, list: List) { + if (list.isEmpty() || deletingRemoteBranch != null || cleaningRemoteBranches.isNotEmpty()) return + cleaningRemoteBranches = list.map { it.name }.toSet() + scope.launch { + val r = withContext(Dispatchers.IO) { BranchOps.deleteRemotes(id, list) } + toast(r.message) + reloadBranches(id) + rescanRepos(listOf(id), fetch = false) + cleaningRemoteBranches = emptySet() + } + } + // ---- submodules (detail panel) ---- fun loadSubmodules(id: String) { @@ -2447,7 +2466,7 @@ class AppState(private val scope: CoroutineScope) { * trip, and both rows for the pair then say "Deleting…". */ fun deleteBranchWithUpstream(id: String, name: String, upstream: String) { - if (deletingRemoteBranch != null) return + if (deletingRemoteBranch != null || cleaningRemoteBranches.isNotEmpty()) return deletingRemoteBranch = upstream scope.launch { val r = withContext(Dispatchers.IO) { BranchOps.deleteWithUpstream(id, name, upstream) } diff --git a/src/main/kotlin/com/gitvantage/git/BranchOps.kt b/src/main/kotlin/com/gitvantage/git/BranchOps.kt index 97166d6..7afff34 100644 --- a/src/main/kotlin/com/gitvantage/git/BranchOps.kt +++ b/src/main/kotlin/com/gitvantage/git/BranchOps.kt @@ -21,6 +21,9 @@ object BranchOps { private const val SEP = "" + /** Refs per push in [deleteRemotes] — each carries a lease too, and Windows caps a command line at 32K. */ + private const val PUSH_CHUNK = 100 + suspend fun load(repoPath: String): List = withContext(Dispatchers.IO) { val dir = File(repoPath) if (!Git.isRepo(dir)) return@withContext emptyList() @@ -389,6 +392,70 @@ object BranchOps { else OpResult(repoPath, false, res.firstError("remote delete failed")) } + /** + * Delete many remote branches at once — the "Clean up merged" sweep of the remote list. One + * push per remote (in chunks, to stay under Windows' command-line limit) rather than one per + * branch, since each push is a network round trip. + * + * The caller picked these as merged from the last fetch, which may be old, so every delete is + * leased on the tip we saw: a branch someone has pushed to since is refused, not deleted along + * with their new commits. A branch the remote already deleted (GitHub does, on merging a PR, and + * a repo that doesn't prune keeps the stale copy) counts as deleted and its stale copy goes — + * the same rules as [deleteUpstreamLeased], with one `ls-remote` per remote instead of one per + * branch to tell the two apart. + */ + suspend fun deleteRemotes(repoPath: String, branches: List): OpResult = + withContext(Dispatchers.IO) { + val dir = File(repoPath) + val failed = mutableListOf() // "origin/foo (reason)" + var deleted = 0 + branches.groupBy { it.remote }.forEach { (remote, list) -> + val listed = Git.run(dir, listOf("ls-remote", "--heads", remote), Git.NETWORK_TIMEOUT) + if (!listed.ok) { + val why = listed.firstError("couldn't reach $remote") + list.forEach { failed += "${it.name} ($why)" } + return@forEach + } + val there = listed.out.lineSequence().mapNotNull { line -> + line.split('\t').takeIf { it.size == 2 }?.let { (sha, ref) -> ref to sha } + }.toMap() + val leased = mutableListOf>() + list.forEach { rb -> + val ref = "refs/heads/${rb.shortName}" + val seen = Git.read(dir, "rev-parse", "--verify", "-q", "refs/remotes/${rb.name}").trim() + when (there[ref]) { + null -> { + if (seen.isNotEmpty()) Git.run(dir, listOf("update-ref", "-d", "refs/remotes/${rb.name}", seen)) + deleted++ + } + seen -> leased += rb to seen + else -> failed += "${rb.name} (pushed to since the last fetch)" + } + } + leased.chunked(PUSH_CHUNK).forEach { chunk -> + val leases = chunk.map { (rb, seen) -> "--force-with-lease=refs/heads/${rb.shortName}:$seen" } + val refs = chunk.map { (rb, _) -> "refs/heads/${rb.shortName}" } + val res = Git.run(dir, listOf("push", "--porcelain") + leases + listOf(remote, "--delete") + refs, + Git.NETWORK_TIMEOUT) + // Porcelain: "\t:\t", where "-" is a successful delete. + val done = res.out.lineSequence().mapNotNull { line -> + val p = line.split('\t') + if (p.size >= 2 && p[0] == "-") p[1].substringAfter(':') else null + }.toSet() + chunk.forEach { (rb, _) -> + if ("refs/heads/${rb.shortName}" in done) deleted++ + else failed += "${rb.name} (${res.firstError("refused")})" + } + } + } + val noun = if (branches.size == 1) "branch" else "branches" + when { + failed.isEmpty() -> OpResult(repoPath, true, "Deleted $deleted merged $noun") + else -> OpResult(repoPath, false, + "Deleted $deleted of ${branches.size} merged $noun — not ${failed.joinToString(", ")}") + } + } + /** * Delete a local branch *and* the remote branch it tracks, for a branch whose work has landed * — the clean-up after a merged PR, which otherwise takes two deletes in two lists. diff --git a/src/main/kotlin/com/gitvantage/ui/DetailPanel.kt b/src/main/kotlin/com/gitvantage/ui/DetailPanel.kt index 958e081..96d67a3 100644 --- a/src/main/kotlin/com/gitvantage/ui/DetailPanel.kt +++ b/src/main/kotlin/com/gitvantage/ui/DetailPanel.kt @@ -1466,6 +1466,10 @@ internal fun MergedBadge(squashed: Boolean) { private fun RemoteBranchesSection(state: AppState, id: String, clean: Boolean, hide: List) { val hidden = state.remoteBranches.filter { Meta.isBranchHidden(it.name, hide) } val shown = if (state.showHiddenBranches) state.remoteBranches else state.remoteBranches - hidden.toSet() + // Where the mainline pushes leads, then where it pulls from: in a fork, your copy first. + val main = state.branches.firstOrNull { it.isMainline } + val lead = listOfNotNull(main?.pushRef, main?.upstream).map { it.substringBefore('/') } + val groups = groupByRemote(shown, lead) Column(Modifier.fillMaxWidth().padding(top = 10.dp)) { Row( Modifier.padding(vertical = 4.dp), @@ -1486,15 +1490,16 @@ private fun RemoteBranchesSection(state: AppState, id: String, clean: Boolean, h // Only once the list is open: the count is meaningless next to a collapsed section, // and before the first expand the remote list hasn't been loaded to count at all. if (state.showRemoteBranches) HiddenBranchToggle(state, hidden.size) + // A lone remote has no heading of its own, so its clean-up sits on the section's. + if (state.showRemoteBranches && groups.size == 1) { + val (remote, list) = groups.single() + CleanUpMergedAction(state, id, remote, list) + } } if (state.showRemoteBranches) { if (state.remoteBranches.isEmpty()) { Txt("No remote branches", 11.sp, Tokens.muted2, modifier = Modifier.padding(vertical = 4.dp)) } - // Where the mainline pushes leads, then where it pulls from: in a fork, your copy first. - val main = state.branches.firstOrNull { it.isMainline } - val lead = listOfNotNull(main?.pushRef, main?.upstream).map { it.substringBefore('/') } - val groups = groupByRemote(shown, lead) groups.forEach { (remote, list) -> // A lone remote needs no heading: every row already starts with its name. if (groups.size > 1) { @@ -1505,6 +1510,7 @@ private fun RemoteBranchesSection(state: AppState, id: String, clean: Boolean, h ) { Txt(remote, 11.sp, Tokens.secondary, FontWeight.SemiBold, font = MonoFont) Txt("— ${list.size}", 11.sp, Tokens.muted2) + CleanUpMergedAction(state, id, remote, list) } } list.forEach { rb -> RemoteBranchRow(state, id, rb, clean, hidden = rb in hidden) } @@ -1513,6 +1519,41 @@ private fun RemoteBranchesSection(state: AppState, id: String, clean: Boolean, h } } +/** + * "Clean up N merged" on a remote's heading: deletes every merged branch in [list] from [remote] + * in one go. Per remote rather than for the whole list, because in a fork the project's remote + * isn't yours to sweep the way your own copy is — and one heading, one remote, says plainly which + * server the deletes land on. Only what's on screen: a hidden branch stays unless it's shown. + * + * Confirmed although a single merged delete isn't. One click on a row deletes the branch named on + * it; this one deletes branches the click never named, on a server other people share. + */ +@Composable +private fun CleanUpMergedAction(state: AppState, id: String, remote: String, list: List) { + val merged = list.filter { it.merged && !it.integration } + if (merged.isEmpty()) return + val cleaning = state.cleaningRemoteBranches.isNotEmpty() + Txt( + if (cleaning) "Cleaning up…" else "· Clean up ${merged.size} merged", + 11.sp, if (cleaning) Tokens.muted2 else state.accent, FontWeight.SemiBold, + modifier = Modifier.pointerHoverIcon(PointerIcon.Hand).onTap { + if (cleaning) return@onTap + val names = merged.take(CLEANUP_LISTED).map { it.shortName } + + listOfNotNull((merged.size - CLEANUP_LISTED).takeIf { it > 0 }?.let { "…and $it more" }) + val noun = if (merged.size == 1) "branch" else "branches" + state.popup = Popup.Confirm( + "Delete ${merged.size} merged $noun from $remote?", + "Their commits are already in mainline, so nothing is lost — but they go for everyone " + + "who uses $remote. Local branches stay. One pushed to since the last fetch is skipped.", + "Delete ${merged.size} on $remote", danger = true, details = names, + ) { state.deleteMergedRemoteBranches(id, merged) } + }, + ) +} + +/** How many branch names the clean-up confirmation spells out before summing up the rest. */ +private const val CLEANUP_LISTED = 12 + @Composable private fun RemoteBranchRow( state: AppState, @@ -1526,7 +1567,7 @@ private fun RemoteBranchRow( val heldBy = state.branches.firstOrNull { it.name == rb.shortName }?.takeIf { it.inOtherWorktree } val canSwitch = !state.switchingBranch && heldBy == null val isMainline = rb.shortName == "main" || rb.shortName == "master" - val deleting = state.deletingRemoteBranch == rb.name + val deleting = state.deletingRemoteBranch == rb.name || rb.name in state.cleaningRemoteBranches HoverRow(vertical = 4) { hovered -> // Line 1: remote ref · merged · tracked Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { diff --git a/src/main/kotlin/com/gitvantage/ui/Popups.kt b/src/main/kotlin/com/gitvantage/ui/Popups.kt index 92ca17b..92347f2 100644 --- a/src/main/kotlin/com/gitvantage/ui/Popups.kt +++ b/src/main/kotlin/com/gitvantage/ui/Popups.kt @@ -840,7 +840,12 @@ private fun Hint(text: String, warn: Boolean = false) { private fun ConfirmPopup(state: AppState, p: Popup.Confirm) { Modal({ state.popup = null }) { ModalHeader(p.title, p.message) - Box(Modifier.padding(18.dp)) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (p.details.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + p.details.forEach { Txt(it, 11.5.sp, Tokens.text2, font = MonoFont, maxLines = 1) } + } + } ModalButtons({ state.popup = null }, p.confirmLabel, true, p.danger) { state.popup = null p.onConfirm() diff --git a/src/test/kotlin/com/gitvantage/git/BranchOpsTest.kt b/src/test/kotlin/com/gitvantage/git/BranchOpsTest.kt index a5f3d2f..899fb4a 100644 --- a/src/test/kotlin/com/gitvantage/git/BranchOpsTest.kt +++ b/src/test/kotlin/com/gitvantage/git/BranchOpsTest.kt @@ -239,6 +239,85 @@ val BranchOperations by testSuite { assert(gitAllowingFailure(originOf(work), "rev-parse", "--verify", "-q", Sandbox.MAIN) == 0) } + // --- deleteRemotes ------------------------------------------------------------------ + + fun Sandbox.pushed(work: java.io.File, name: String) { + git(work, "switch", "-q", "-c", name) + commit(work, "$name.txt", "$name\n", "$name work") + git(work, "push", "-q", "-u", "origin", name) + git(work, "switch", "-q", Sandbox.MAIN) + } + + fun remoteBranch(name: String) = RemoteBranch( + name = "origin/$name", shortName = name, + author = "Test", lastRelative = "now", hasLocal = true, merged = true, + ) + + fun Sandbox.onOrigin(work: java.io.File, name: String) = + gitAllowingFailure(originOf(work), "rev-parse", "--verify", "-q", "refs/heads/$name") == 0 + + test("deleteRemotes removes every branch in one go") { + val work = repo() + pushed(work, "a") + pushed(work, "b") + pushed(work, "c") + + val result = BranchOps.deleteRemotes(work.path, listOf(remoteBranch("a"), remoteBranch("b"))) + + assert(result.ok) { result.message } + assert(result.message == "Deleted 2 merged branches") + assert(!onOrigin(work, "a") && !onOrigin(work, "b")) + assert(onOrigin(work, "c")) // not asked for + assert(gitAllowingFailure(work, "rev-parse", "--verify", "-q", "refs/remotes/origin/a") != 0) + // Only the remote copies. + assert("a" in branches(work) && "b" in branches(work)) + } + + test("deleteRemotes skips a branch pushed to since the last fetch and deletes the rest") { + val work = repo() + pushed(work, "a") + pushed(work, "b") + val other = clone(originOf(work), "other") + git(other, "switch", "-q", "b") + commit(other, "g.txt", "theirs\n", "their follow-up") + git(other, "push", "-q", "origin", "b") + + val result = BranchOps.deleteRemotes(work.path, listOf(remoteBranch("a"), remoteBranch("b"))) + + assert(!result.ok) + assert(result.message.startsWith("Deleted 1 of 2 merged branches")) { result.message } + assert("origin/b" in result.message) + assert(!onOrigin(work, "a")) + assert(onOrigin(work, "b")) // their commit survives + } + + test("deleteRemotes counts a branch already deleted on the remote as deleted") { + val work = repo() + pushed(work, "a") + pushed(work, "b") + git(originOf(work), "branch", "-D", "a") + + val result = BranchOps.deleteRemotes(work.path, listOf(remoteBranch("a"), remoteBranch("b"))) + + assert(result.ok) { result.message } + assert(!onOrigin(work, "b")) + // The stale copy goes, or the list would keep offering it. + assert(gitAllowingFailure(work, "rev-parse", "--verify", "-q", "refs/remotes/origin/a") != 0) + } + + test("deleteRemotes reports a branch the server refuses") { + val work = repo() + pushed(work, "a") + + val result = BranchOps.deleteRemotes(work.path, listOf(remoteBranch("a"), remoteBranch(Sandbox.MAIN))) + + assert(!result.ok) + assert(result.message.startsWith("Deleted 1 of 2")) { result.message } + assert("origin/${Sandbox.MAIN}" in result.message) + assert(!onOrigin(work, "a")) + assert(onOrigin(work, Sandbox.MAIN)) + } + // --- deleteWithUpstream ------------------------------------------------------------- test("deleteWithUpstream removes both the local branch and its remote branch") { From 412f94c4f72a822370433e4c41e37a9d2c9739d8 Mon Sep 17 00:00:00 2001 From: Raman Gupta Date: Thu, 29 Oct 2026 12:23:27 -0400 Subject: [PATCH 2/2] perf(branches): load the remote branch list in seconds, and say it's loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a repo with ~250 remote branches the list took ~22s to appear, showing "No remote branches" meanwhile. It now shows "Loading…", and loads in ~1.7s cold and ~25ms when returning to the repo. Nearly all the time was the squash-merge check, and within it the pathspec on `git log -p .. -- `: git matches every pathspec against every commit walked, 5-7s for one branch of a few thousand files. Candidates now come from a pathspec-free --name-only log (commits touching all of the branch's files) and only those are diffed. Answers are identical on all 249 branches checked. Also: results are cached by (branch tip, mainline tip), the ancestry test is one for-each-ref --merged instead of a process per branch, and branches are tested six at a time. Reopening the section mid-load no longer starts a second load, and leaving the repo cancels it. Signed-off-by: Raman Gupta --- .../kotlin/com/gitvantage/app/AppState.kt | 15 ++- .../kotlin/com/gitvantage/git/BranchOps.kt | 98 ++++++++++++++----- .../kotlin/com/gitvantage/ui/DetailPanel.kt | 5 +- 3 files changed, 91 insertions(+), 27 deletions(-) diff --git a/src/main/kotlin/com/gitvantage/app/AppState.kt b/src/main/kotlin/com/gitvantage/app/AppState.kt index d4cd409..75796ea 100644 --- a/src/main/kotlin/com/gitvantage/app/AppState.kt +++ b/src/main/kotlin/com/gitvantage/app/AppState.kt @@ -296,6 +296,11 @@ class AppState(private val scope: CoroutineScope) { private set var showRemoteBranches by mutableStateOf(false) private set + + /** The remote list is being read — on a repo with hundreds of branches, long enough to need saying. */ + var remoteBranchesLoading by mutableStateOf(false) + private set + private var remoteLoad: Job? = null var switchingBranch by mutableStateOf(false) private set @@ -1827,6 +1832,8 @@ class AppState(private val scope: CoroutineScope) { branches = emptyList() remoteBranches = emptyList() showRemoteBranches = false + remoteLoad?.cancel() + remoteBranchesLoading = false branchesLoading = true scope.launch { val list = withContext(Dispatchers.IO) { BranchOps.load(id) } @@ -1850,11 +1857,13 @@ class AppState(private val scope: CoroutineScope) { /** Toggle the collapsible remote-branches list; loads it lazily the first time. */ fun toggleRemoteBranches(id: String) { showRemoteBranches = !showRemoteBranches - if (showRemoteBranches && remoteBranches.isEmpty()) { - scope.launch { + // Not again while a load is still out: collapsing and reopening mid-load would start a second. + if (showRemoteBranches && remoteBranches.isEmpty() && !remoteBranchesLoading) { + remoteBranchesLoading = true + remoteLoad = scope.launch { val locals = branches.map { it.name }.toSet() val list = withContext(Dispatchers.IO) { BranchOps.loadRemotes(id, locals) } - if (branchesRepo == id) remoteBranches = list + if (branchesRepo == id) { remoteBranches = list; remoteBranchesLoading = false } } } } diff --git a/src/main/kotlin/com/gitvantage/git/BranchOps.kt b/src/main/kotlin/com/gitvantage/git/BranchOps.kt index 7afff34..1caeba0 100644 --- a/src/main/kotlin/com/gitvantage/git/BranchOps.kt +++ b/src/main/kotlin/com/gitvantage/git/BranchOps.kt @@ -9,6 +9,9 @@ import com.gitvantage.git.model.RemoteBranch import com.gitvantage.model.Meta import java.io.File import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext /** @@ -181,8 +184,29 @@ object BranchOps { * the next one, about once a second for as long as the app was open. * * Callers only ask for a branch that isn't an ancestor of mainline; that case is [hasOwnWork]'s. + * + * The answer depends only on the two commits, so it's remembered by their ids: a repo with + * hundreds of old branches pays for the check once, not on every open of its remote list. + * [tips] is those ids — the branch's and mainline's — when the caller already has them. */ - internal fun landedByContent(dir: File, branch: String, mainline: String): Boolean { + internal fun landedByContent(dir: File, branch: String, mainline: String, tips: Pair? = null): Boolean { + val shas = tips?.toList() + ?: Git.read(dir, "rev-parse", "--verify", "-q", "$branch^{commit}", "$mainline^{commit}").lines() + val key = shas.takeIf { it.size >= 2 && it[0].isNotBlank() && it[1].isNotBlank() } + ?.let { "${dir.path}\u0000${it[0]}\u0000${it[1]}" } + key?.let { k -> synchronized(landedCache) { landedCache[k] } }?.let { return it } + val landed = computeLandedByContent(dir, branch, mainline) + if (key != null) synchronized(landedCache) { landedCache[key] = landed } + return landed + } + + /** [landedByContent]'s answers by (repo, branch tip, mainline tip); least recently used goes first. */ + private val landedCache = object : LinkedHashMap(256, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry) = size > LANDED_CACHE_SIZE + } + private const val LANDED_CACHE_SIZE = 8192 + + private fun computeLandedByContent(dir: File, branch: String, mainline: String): Boolean { val base = Git.read(dir, "merge-base", mainline, branch).trim() if (base.isEmpty()) return false val files = Git.read(dir, "diff", "--no-renames", "--name-only", base, branch) @@ -194,10 +218,20 @@ object BranchOps { return true } val want = patchKey(Git.read(dir, "diff", *DIFF_OPTS, base, branch)) - // Only mainline commits that touched those files, and only their diff of those files: a - // squash commit carrying the branch plus a fix elsewhere still matches. - return Git.read(dir, "log", "-p", *DIFF_OPTS, "--no-merges", "--format=%x00", "$base..$mainline", "--", *paths) - .split('\u0000').any { it.isNotBlank() && patchKey(it) == want } + // Only mainline commits that touched every one of those files can carry the branch's whole + // diff, and only their diff of those files is compared: a squash commit carrying the branch + // plus a fix elsewhere still matches. The candidates are found from file names alone, and + // without a pathspec — git matches each pathspec against every commit it walks, which on a + // branch of a few thousand files over a few thousand commits is seconds against milliseconds. + val wanted = files.toSet() + val candidates = Git.read(dir, "log", "--no-renames", "--no-merges", "--format=%x00%H", "--name-only", "$base..$mainline") + .split('\u0000').mapNotNull { chunk -> + val lines = chunk.lines().filter { it.isNotBlank() } + lines.firstOrNull()?.takeIf { lines.size > wanted.size && lines.drop(1).toHashSet().containsAll(wanted) } + } + return candidates.any { sha -> + patchKey(Git.read(dir, "show", *DIFF_OPTS, "--format=", sha, "--", *paths)) == want + } } private val DIFF_OPTS = arrayOf("--no-renames", "--no-color", "--no-ext-diff") @@ -218,31 +252,49 @@ object BranchOps { val mainline = listOf("main", "master", "origin/main", "origin/master") .firstOrNull { Git.read(dir, "rev-parse", "--verify", "-q", it).isNotBlank() } val raw = Git.read(dir, "for-each-ref", "--sort=-committerdate", - "--format=%(refname:short)$SEP%(committerdate:relative)$SEP%(authorname)", "refs/remotes") - raw.lineSequence().filter { it.isNotBlank() }.mapNotNull { line -> - val p = line.split(SEP) + "--format=%(refname:short)$SEP%(committerdate:relative)$SEP%(authorname)$SEP%(objectname)", "refs/remotes") + // Every branch already in mainline's history, in one call rather than an + // `is-ancestor` per branch — a process each, which on hundreds of branches adds up. + val contained = mainline?.let { + Git.read(dir, "for-each-ref", "--merged=$it", "--format=%(refname:short)", "refs/remotes").lines().toSet() + }.orEmpty() + val mainlineSha = mainline?.let { Git.read(dir, "rev-parse", "--verify", "-q", "$it^{commit}").trim() } + val rows = raw.lineSequence().filter { it.isNotBlank() }.map { it.split(SEP) }.filter { p -> val name = p.getOrElse(0) { "" } // Skip the remote HEAD symref. Git shortens `refs/remotes/origin/HEAD` to just the // remote name ("origin"), so a no-slash entry is the symref, not a real branch; // some git versions instead keep it as "origin/HEAD". - if (name.isEmpty() || name.endsWith("/HEAD") || !name.contains('/')) return@mapNotNull null - val short = name.substringAfter('/') // drop the remote name prefix - // The mainline itself, on whichever remote, is never "merged" into itself. - val against = mainline?.takeIf { it.substringAfter('/') != short } - val contained = against != null && Git.exitCode(dir, "merge-base", "--is-ancestor", name, against) == 0 - val squashed = against != null && !contained && landedByContent(dir, name, against) - RemoteBranch( - name = name, - shortName = short, - author = p.getOrElse(2) { "" }.ifBlank { "—" }, - lastRelative = p.getOrElse(1) { "" }, - hasLocal = short in localNames, - merged = contained || squashed, - squashed = squashed, - ) + name.isNotEmpty() && !name.endsWith("/HEAD") && name.contains('/') }.toList() + // A few git processes at a time: each branch's merged test is a handful of them, and a + // repo with hundreds of old branches is otherwise a long wait spent one process at a time. + coroutineScope { + rows.map { p -> + async(remoteLimiter) { + val name = p[0] + val short = name.substringAfter('/') // drop the remote name prefix + // The mainline itself, on whichever remote, is never "merged" into itself. + val against = mainline?.takeIf { it.substringAfter('/') != short } + val inHistory = against != null && name in contained + val tips = p.getOrNull(3)?.takeIf { it.isNotBlank() }?.let { tip -> mainlineSha?.let { tip to it } } + val squashed = against != null && !inHistory && landedByContent(dir, name, against, tips) + RemoteBranch( + name = name, + shortName = short, + author = p.getOrElse(2) { "" }.ifBlank { "—" }, + lastRelative = p.getOrElse(1) { "" }, + hasLocal = short in localNames, + merged = inHistory || squashed, + squashed = squashed, + ) + } + }.awaitAll() + } } + /** At most this many remote branches are tested for "merged" at once — see [loadRemotes]. */ + private val remoteLimiter = Dispatchers.IO.limitedParallelism(6) + /** Check out an existing local branch. Fails (not throws) if the working tree would be clobbered. */ suspend fun switch(repoPath: String, name: String): OpResult = withContext(Dispatchers.IO) { val dir = File(repoPath) diff --git a/src/main/kotlin/com/gitvantage/ui/DetailPanel.kt b/src/main/kotlin/com/gitvantage/ui/DetailPanel.kt index 96d67a3..93c7383 100644 --- a/src/main/kotlin/com/gitvantage/ui/DetailPanel.kt +++ b/src/main/kotlin/com/gitvantage/ui/DetailPanel.kt @@ -1498,7 +1498,10 @@ private fun RemoteBranchesSection(state: AppState, id: String, clean: Boolean, h } if (state.showRemoteBranches) { if (state.remoteBranches.isEmpty()) { - Txt("No remote branches", 11.sp, Tokens.muted2, modifier = Modifier.padding(vertical = 4.dp)) + Txt( + if (state.remoteBranchesLoading) "Loading…" else "No remote branches", + 11.sp, Tokens.muted2, modifier = Modifier.padding(vertical = 4.dp), + ) } groups.forEach { (remote, list) -> // A lone remote needs no heading: every row already starts with its name.