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
40 changes: 38 additions & 2 deletions src/main/kotlin/com/gitvantage/app/AppState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,23 @@ sealed interface Popup {
data class Settings(val section: SettingsSection = SettingsSection.APPEARANCE) : Popup {
override val ids: Set<String> = emptySet()
}

/**
* A [Confirm] with one checkbox, for a destructive action with an optional extra reach — the
* worktree "Remove + branch" that can also delete the remote branch. The box is in the dialog,
* not a second button on the row, so the choice is stated where the consequences are.
*/
data class ConfirmWithOption(
val title: String,
val message: String,
val option: String,
val optionDefault: Boolean,
val confirmLabel: (Boolean) -> String,
val danger: Boolean,
val onConfirm: (Boolean) -> Unit,
) : Popup {
override val ids: Set<String> = emptySet()
}
data class Confirm(
val title: String,
val message: String,
Expand Down Expand Up @@ -2019,16 +2036,18 @@ class AppState(private val scope: CoroutineScope) {
*
* [removeBranch] also deletes the branch it held — offered only for an agent worktree whose
* branch has already landed or never held anything, where the branch is leftover bookkeeping
* rather than work.
* rather than work. [removeUpstream] takes its remote branch with it too, when
* [Worktree.deletableUpstream] says that's safe.
*/
fun removeWorktree(id: String, wt: Worktree, removeBranch: Boolean = false) {
fun removeWorktree(id: String, wt: Worktree, removeBranch: Boolean = false, removeUpstream: Boolean = false) {
if (worktreesBusy) return
worktreesBusy = true
scope.launch {
val r = withContext(Dispatchers.IO) {
WorktreeOps.remove(
id, wt.path, force = wt.dirtyCount > 0, locked = wt.locked,
alsoBranch = wt.ownBranch?.takeIf { removeBranch },
alsoUpstream = wt.deletableUpstream?.takeIf { removeBranch && removeUpstream },
)
}
toast(r.message)
Expand Down Expand Up @@ -2282,6 +2301,23 @@ class AppState(private val scope: CoroutineScope) {
}
}

/**
* Delete a merged branch together with its upstream (see [BranchOps.deleteWithUpstream]).
* Shares [deletingRemoteBranch] with the remote list's delete: it's the same network round
* trip, and both rows for the pair then say "Deleting…".
*/
fun deleteBranchWithUpstream(id: String, name: String, upstream: String) {
if (deletingRemoteBranch != null) return
deletingRemoteBranch = upstream
scope.launch {
val r = withContext(Dispatchers.IO) { BranchOps.deleteWithUpstream(id, name, upstream) }
toast(r.message)
reloadBranches(id)
rescanRepos(listOf(id), fetch = false)
deletingRemoteBranch = null
}
}

/** Open the diff viewer for a repo and load its working-tree changes off the UI thread. */
fun openDiff(id: String) {
diffRepoName = File(id).name
Expand Down
50 changes: 50 additions & 0 deletions src/main/kotlin/com/gitvantage/git/BranchOps.kt
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,56 @@ object BranchOps {
else OpResult(repoPath, false, res.firstError("remote delete failed"))
}

/**
* 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.
*
* The remote goes first, because it's the half that can fail (network, permissions, a
* protected branch): if it does, the local branch is left alone and the whole thing can just be
* tried again. Deleting local first would strand the remote branch with nothing on this side
* left to offer the retry from.
*
* The remote delete carries a lease on the tip we last fetched. The caller only offers this
* when that tip had nothing the local branch lacks, but the fetch may be old — if someone has
* pushed to the branch since, git refuses the delete instead of throwing their commits away.
*/
suspend fun deleteWithUpstream(repoPath: String, name: String, upstream: String): OpResult =
withContext(Dispatchers.IO) {
val dir = File(repoPath)
val pushed = deleteUpstreamLeased(dir, upstream)
if (!pushed.ok) return@withContext OpResult(repoPath, false, pushed.firstError("remote delete failed"))
val local = Git.run(dir, listOf("branch", "-D", name))
if (local.ok) OpResult(repoPath, true, "Deleted $name and $upstream")
else OpResult(repoPath, false, "Deleted $upstream, but not $name: ${local.firstError("delete failed")}")
}

/**
* `git push <remote> --delete refs/heads/<branch>` for [upstream] (e.g. "origin/foo"), leased on
* the tip last fetched into `refs/remotes/<upstream>`: a branch someone pushed to since is
* refused rather than deleted. Shared by [deleteWithUpstream] and the worktree "Remove + branch".
*/
internal fun deleteUpstreamLeased(dir: File, upstream: String): Git.Result {
val ref = "refs/heads/${upstream.substringAfter('/')}"
val seen = Git.read(dir, "rev-parse", "--verify", "-q", "refs/remotes/$upstream").trim()
val lease = if (seen.isNotEmpty()) "--force-with-lease=$ref:$seen" else "--force-with-lease"
return Git.run(dir, listOf("push", lease, upstream.substringBefore('/'), "--delete", ref), Git.NETWORK_TIMEOUT)
}

/**
* The upstream of local [branch] when deleting it on the remote loses nothing *beyond* the local
* branch: it still exists, has no commits [branch] lacks (so if [branch] is merged, so is it),
* and isn't a shared integration branch. Null otherwise, and for a branch with no upstream.
*/
internal fun deletableUpstream(dir: File, branch: String): String? {
val (upstream, track) = Git.read(
dir, "for-each-ref", "--format=%(upstream:short)$SEP%(upstream:track)", "refs/heads/$branch",
).trim().split(SEP).let { it.getOrElse(0) { "" } to it.getOrElse(1) { "" } }
if (upstream.isEmpty() || !upstream.contains('/')) return null
val (_, behind, gone) = parseTrack(track)
if (gone || behind > 0 || Meta.isIntegrationBranch(upstream.substringAfter('/'))) return null
return upstream
}

/** Parse git's `%(upstream:track)` — "[ahead 2, behind 1]", "[gone]", or "". */
private fun parseTrack(track: String): Triple<Int, Int, Boolean> {
if ("gone" in track) return Triple(0, 0, true)
Expand Down
32 changes: 24 additions & 8 deletions src/main/kotlin/com/gitvantage/git/WorktreeOps.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,8 @@ import kotlinx.coroutines.withContext
*
* Everything is read on the scan path because the *list* views show it now, not only the panel:
* a worktree is a sub-row under its repo, and that row's badges are "3 modified", "↑2 vs main",
* "merged", its right-hand column is "2 hours ago · Ada Okafor", and whether its branch has landed
* is what decides if "Remove + branch" is offered at all. A lazily-loaded field would mean a row
* that silently omits a badge and hides an action until something else happened to open the pane.
* "merged", and its right-hand column is "2 hours ago · Ada Okafor". A lazily-loaded field would
* mean a row that silently omits a badge until something else happened to open the pane.
*
* That makes a repo with linked worktrees about twice the scan it used to be. It buys the four
* questions the row asks, it is bounded by the number of worktrees the repo actually has, and it is
Expand Down Expand Up @@ -170,6 +169,7 @@ object WorktreeOps {
branchMerged = merged,
branchEmpty = contained && !merged,
tipBranch = tipBranch,
deletableUpstream = if (merged) BranchOps.deletableUpstream(dir, own!!) else null,
)
}
}
Expand Down Expand Up @@ -263,13 +263,19 @@ object WorktreeOps {
* only offers this for a branch it has already seen land, and if that changed underneath us
* the branch survives and the message says so. Removal itself still counts as success — the
* folder is gone either way, and reporting failure would invite a retry that can't help.
*
* [alsoUpstream] (only alongside [alsoBranch]) deletes the branch's remote counterpart as well,
* leased the way [BranchOps.deleteUpstreamLeased] describes. It goes before the local branch so
* the local `-d` still has the tracking ref around to be checked against. If the remote refuses,
* the rest still happens: the remote branch stays in the remote list, deletable from there.
*/
suspend fun remove(
runFrom: String,
path: String,
force: Boolean,
locked: Boolean,
alsoBranch: String? = null,
alsoUpstream: String? = null,
): OpResult = withContext(Dispatchers.IO) {
val dir = File(runFrom)
val args = buildList {
Expand All @@ -284,12 +290,22 @@ object WorktreeOps {
val name = File(path).name
tidyAgentParent(path)
if (alsoBranch == null) return@withContext OpResult(runFrom, true, "Removed worktree $name")
val upstreamRes = alsoUpstream?.let { BranchOps.deleteUpstreamLeased(dir, it) }
val branchRes = Git.run(dir, listOf("branch", "-d", alsoBranch))
OpResult(
runFrom, true,
if (branchRes.ok) "Removed worktree $name and branch $alsoBranch"
else "Removed worktree $name — branch kept: ${branchRes.firstError("delete failed")}",
)
val removed = buildList {
add("worktree $name")
if (branchRes.ok) add("branch $alsoBranch")
if (upstreamRes?.ok == true) add(alsoUpstream)
}
val kept = buildList {
if (!branchRes.ok) add("branch kept: ${branchRes.firstError("delete failed")}")
if (upstreamRes?.ok == false) add("$alsoUpstream kept: ${upstreamRes.firstError("remote delete failed")}")
}
val said = "Removed " + when (removed.size) {
1 -> removed[0]
else -> removed.dropLast(1).joinToString(", ") + " and " + removed.last()
}
OpResult(runFrom, true, if (kept.isEmpty()) said else "$said — ${kept.joinToString("; ")}")
}

/**
Expand Down
3 changes: 3 additions & 0 deletions src/main/kotlin/com/gitvantage/model/Worktrees.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ data class Worktree(
// Sessions detach their worktree once the work is pushed, which leaves the tree and its branch
// unconnected as far as git is concerned while being one piece of work as far as anyone else is.
val tipBranch: String? = null,
// The remote branch [ownBranch] tracks, when [branchMerged] and deleting it too loses nothing —
// see BranchOps.deletableUpstream. What lets "Remove + branch" offer to clean up the remote too.
val deletableUpstream: String? = null,
val lastRelative: String = "", // git's relative date for the worktree's HEAD commit
val lastAuthor: String = "", // who made that commit
// Epoch *seconds* of that commit, as git reports them. Carried alongside the relative string
Expand Down
21 changes: 21 additions & 0 deletions src/main/kotlin/com/gitvantage/ui/DetailPanel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1156,6 +1156,14 @@ private fun BranchRow(
else -> null
}
val pushing = state.pushingBranch == b.name
// The upstream to take down with the branch, when that loses nothing: the branch is merged, and
// its upstream has no commits the branch lacks — so the upstream is merged too. Off for the
// shared branches the remote list won't delete either, and for an upstream already gone.
val deletableUpstream = b.upstream?.takeIf {
canDelete && hasRemote && b.merged && !b.upstreamGone && b.upstreamBehind == 0 &&
!Meta.isIntegrationBranch(it.substringAfter('/'))
}
val deletingUpstream = deletableUpstream != null && state.deletingRemoteBranch == deletableUpstream
HoverRow { hovered ->
// Line 1: branch name · mainline-relationship badge · ahead-of-mainline
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Expand Down Expand Up @@ -1288,6 +1296,19 @@ private fun BranchRow(
) { state.deleteBranch(id, b.name, force = true) }
}
}
// The after-a-merged-PR clean-up in one click rather than a delete in each list.
// No prompt, for the same reason neither half has one on its own: both are merged.
if (deletableUpstream != null) {
HoverTip(
"Deletes “${b.name}” here and “$deletableUpstream” on the remote. Both are " +
"already in mainline, so nothing is lost — and if someone has pushed to " +
"it since your last fetch, the remote refuses and both stay.",
) {
RowAction(if (deletingUpstream) "Deleting…" else "Delete both", danger = true) {
if (!deletingUpstream) state.deleteBranchWithUpstream(id, b.name, deletableUpstream)
}
}
}
}
}
}
Expand Down
25 changes: 25 additions & 0 deletions src/main/kotlin/com/gitvantage/ui/Popups.kt
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ fun PopupHost(state: AppState, popup: Popup) {
is Popup.Commit -> CommitPopup(state, popup.id)
is Popup.CommitWorktree -> CommitWorktreePopup(state, popup)
is Popup.Confirm -> ConfirmPopup(state, popup)
is Popup.ConfirmWithOption -> ConfirmWithOptionPopup(state, popup)
is Popup.Settings -> SettingsPopup(state, popup.section)
}
}
Expand Down Expand Up @@ -684,6 +685,30 @@ private fun ConfirmPopup(state: AppState, p: Popup.Confirm) {
}
}

@Composable
private fun ConfirmWithOptionPopup(state: AppState, p: Popup.ConfirmWithOption) {
var checked by remember(p) { mutableStateOf(p.optionDefault) }
Modal({ state.popup = null }) {
ModalHeader(p.title, p.message)
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
Modifier.onTap { checked = !checked },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
SelectBox(checked = checked)
// Wraps rather than truncating: the option names a ref, and agent branch names are
// long enough that one line would cut off the half saying where it's deleted.
Txt(p.option, 12.sp, Tokens.text2, maxLines = 3, modifier = Modifier.weight(1f))
}
ModalButtons({ state.popup = null }, p.confirmLabel(checked), true, p.danger) {
state.popup = null
p.onConfirm(checked)
}
}
}
}

// ---------- toast ----------

/** Transient op-result pill, bottom-center. The empty area is click-through. */
Expand Down
36 changes: 28 additions & 8 deletions src/main/kotlin/com/gitvantage/ui/RepoList.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
Expand All @@ -31,6 +32,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.gitvantage.app.AppState
Expand Down Expand Up @@ -113,21 +115,26 @@ private fun EmptyState(registryEmpty: Boolean, onAdd: () -> Unit) {

@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
private fun TableView(state: AppState, groups: List<RepoGroup>) {
private fun TableView(state: AppState, groups: List<RepoGroup>) = BoxWithConstraints(Modifier.fillMaxSize()) {
// The name column's width, decided once for the whole table so a repo row and its worktree
// sub-rows keep one column edge between them. A share of the table rather than a fixed width:
// on a wide window the names — long `claude/…` branch names especially — are what's worth the
// room, and the badge and last-commit columns already had more slack than they could use.
val nameWidth = (maxWidth * NAME_SHARE).coerceIn(NAME_MIN, NAME_MAX)
LazyColumn(Modifier.fillMaxSize()) {
groups.forEach { group ->
if (group.showHeader) {
stickyHeader(key = "h-${group.nsLabel}-${group.name}") { GroupHeaderTable(group) }
}
group.repos.forEach { rv ->
item(key = "${group.name}-${rv.id}") { RepoRow(state, rv) }
item(key = "${group.name}-${rv.id}") { RepoRow(state, rv, nameWidth) }
// Sub-rows are separate list items rather than a Column inside the parent's row, so
// a repo with a dozen worktrees doesn't build a dozen rows' worth of layout to
// scroll past off-screen — the same reason the parents are items and not one Column.
if (state.worktreeRowsOpen(rv.id)) {
rv.worktrees.forEach { wtv ->
item(key = "${group.name}-${rv.id}-${wtv.path}") {
WorktreeSubRow(state, rv, wtv, barWidth(state))
WorktreeSubRow(state, rv, wtv, barWidth(state), nameWidth - WORKTREE_INDENT)
}
}
}
Expand All @@ -136,6 +143,19 @@ private fun TableView(state: AppState, groups: List<RepoGroup>) {
}
}

/** The table's name column: [NAME_SHARE] of its width, within these bounds. */
private const val NAME_SHARE = 0.3f
private val NAME_MIN = 262.dp
private val NAME_MAX = 560.dp

/**
* How much narrower a worktree sub-row's name column is than its repo's: the sub-row's name starts
* further right, and this is what's left of that once the two rows' leading pieces are summed, so
* the badge columns start on one edge. Measured on screen, at 1× — it was 24 when both widths were
* fixed, which left the sub-row's badges 16dp left of the repo's.
*/
private val WORKTREE_INDENT = 8.dp

/** The row accent bar's width, which the sub-rows continue. */
private fun barWidth(state: AppState) = if (state.emphasis == Emphasis.SUBTLE) 2f else 3f

Expand All @@ -156,7 +176,7 @@ private fun GroupHeaderTable(group: RepoGroup) {

@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
private fun RepoRow(state: AppState, rv: RepoView) {
private fun RepoRow(state: AppState, rv: RepoView, nameWidth: Dp) {
val selected = rv.id == state.selectedId
val checked = rv.id in state.bulkSelected
val rowBg = when {
Expand Down Expand Up @@ -184,11 +204,11 @@ private fun RepoRow(state: AppState, rv: RepoView) {
Box(Modifier.width(12.dp), contentAlignment = Alignment.CenterStart) {
StatusDot(rv.accent, 9)
}
// Name stays tight on the left; the badges and last-commit columns share the row's slack by
// weight, so on wide windows the space sits as one balanced center gap rather than a single
// huge empty box — and the last-commit column is wide enough to never truncate.
// Name takes its share of the table (see TableView); the badges and last-commit columns
// split what's left by weight, so the remaining space sits as one balanced center gap rather
// than a single huge empty box — and the last-commit column is wide enough to never truncate.
// Col 2 — name / branch / tags
Column(Modifier.width(262.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Column(Modifier.width(nameWidth), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Txt(rv.repo.name, 13.5.sp, Tokens.text, FontWeight.Bold, modifier = Modifier.weight(1f, fill = false))
// Only where there is something to disclose. A chevron on every repo would promise a
Expand Down
Loading
Loading