Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions src/main/kotlin/com/gitvantage/app/AppState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = emptyList(),
val onConfirm: () -> Unit,
) : Popup {
override val ids: Set<String> = emptySet()
Expand Down Expand Up @@ -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

Expand All @@ -306,6 +313,10 @@ class AppState(private val scope: CoroutineScope) {
var deletingRemoteBranch by mutableStateOf<String?>(null)
private set

/** Full refs being deleted by a "Clean up merged" sweep, so each of their rows says "Deleting…". */
var cleaningRemoteBranches by mutableStateOf<Set<String>>(emptySet())
private set

// Submodules for the currently-open detail panel (loaded lazily on selection).
var submodulesRepo by mutableStateOf<String?>(null)
private set
Expand Down Expand Up @@ -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) }
Expand All @@ -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 }
}
}
}
Expand Down Expand Up @@ -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) }
Expand All @@ -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<RemoteBranch>) {
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) {
Expand Down Expand Up @@ -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) }
Expand Down
165 changes: 142 additions & 23 deletions src/main/kotlin/com/gitvantage/git/BranchOps.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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<Branch> = withContext(Dispatchers.IO) {
val dir = File(repoPath)
if (!Git.isRepo(dir)) return@withContext emptyList()
Expand Down Expand Up @@ -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<String, String>? = 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<String, Boolean>(256, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Boolean>) = 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)
Expand All @@ -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")
Expand All @@ -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)
Expand Down Expand Up @@ -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<RemoteBranch>): OpResult =
withContext(Dispatchers.IO) {
val dir = File(repoPath)
val failed = mutableListOf<String>() // "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<Pair<RemoteBranch, String>>()
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: "<flag>\t<src>:<dst>\t<summary>", 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.
Expand Down
Loading
Loading