diff --git a/src/main/kotlin/com/gitvantage/app/AppState.kt b/src/main/kotlin/com/gitvantage/app/AppState.kt index a219bd5..75796ea 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() @@ -294,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 @@ -306,6 +313,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 @@ -1821,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) } @@ -1844,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 } } } } @@ -1960,7 +1975,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 +1986,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 +2475,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..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 /** @@ -21,6 +24,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() @@ -178,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) @@ -191,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") @@ -215,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) @@ -389,6 +444,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..93c7383 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,19 @@ 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)) + Txt( + if (state.remoteBranchesLoading) "Loading…" else "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 +1513,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 +1522,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 +1570,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") {