From f5df8112b824ea9310661fff0d049222e40d6e7f Mon Sep 17 00:00:00 2001 From: Raman Gupta Date: Tue, 27 Oct 2026 17:29:05 -0400 Subject: [PATCH 1/2] feat(worktrees): create a worktree from any ref Until now worktrees could only be removed or pruned from the app. A "Worktree" action on local branch, remote branch, PR and log commit rows opens one prefilled dialog; "+ New worktree" in the Worktrees section (now shown even when a repo has none) takes a typed ref with suggestions. The folder defaults to a sibling of the repo, -, so the tree stays out of the repo's status and file watcher. A remote branch gets a tracking local branch, a tag or commit is detached unless a branch is named, and a PR is a detached tree that `gh pr checkout` fills in (removed again if gh fails). git's refusals are shown in the dialog rather than forced past; --quiet keeps the progress line from masking them. Co-Authored-By: Claude Signed-off-by: Raman Gupta --- .../kotlin/com/gitvantage/app/AppState.kt | 80 +++++++++ .../kotlin/com/gitvantage/git/WorktreeOps.kt | 138 +++++++++++++++ .../gitvantage/git/model/WorktreeSource.kt | 37 ++++ .../kotlin/com/gitvantage/ui/DetailPanel.kt | 22 +++ src/main/kotlin/com/gitvantage/ui/LogView.kt | 9 + src/main/kotlin/com/gitvantage/ui/Popups.kt | 166 ++++++++++++++++++ .../kotlin/com/gitvantage/ui/Worktrees.kt | 29 ++- .../com/gitvantage/git/WorktreeCreateTest.kt | 155 ++++++++++++++++ 8 files changed, 627 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/com/gitvantage/git/model/WorktreeSource.kt create mode 100644 src/test/kotlin/com/gitvantage/git/WorktreeCreateTest.kt diff --git a/src/main/kotlin/com/gitvantage/app/AppState.kt b/src/main/kotlin/com/gitvantage/app/AppState.kt index 6376469..4f29ae6 100644 --- a/src/main/kotlin/com/gitvantage/app/AppState.kt +++ b/src/main/kotlin/com/gitvantage/app/AppState.kt @@ -23,8 +23,10 @@ import com.gitvantage.git.WorktreeOps import com.gitvantage.git.model.Branch import com.gitvantage.git.model.Commit import com.gitvantage.git.model.Diff +import com.gitvantage.git.model.OpResult import com.gitvantage.git.model.RemoteBranch import com.gitvantage.git.model.Submodule +import com.gitvantage.git.model.WorktreeSource import com.gitvantage.model.FetchPolicy import com.gitvantage.model.Meta import com.gitvantage.model.Perf @@ -102,6 +104,15 @@ sealed interface Popup { override val ids: Set = setOf(repoId) } + /** + * Make a new worktree of [repoId] (see [AppState.addWorktree]). [source] is the ref the row it + * was opened from already names — a branch, remote branch, PR or commit — and null when it came + * from the Worktrees section's own "+ New worktree", where the dialog asks for one. + */ + data class NewWorktree(val repoId: String, val source: WorktreeSource?) : Popup { + override val ids: Set = setOf(repoId) + } + /** * The app's settings, opened at [section]. * @@ -311,6 +322,10 @@ class AppState(private val scope: CoroutineScope) { var worktreesBusy by mutableStateOf(false) private set + /** Why the open New worktree dialog's last Create was refused — shown in the dialog, not a toast. */ + var newWorktreeError by mutableStateOf(null) + private set + // Diff viewer (GitHub-style overlay). var diffOpen by mutableStateOf(false) private set @@ -2122,6 +2137,71 @@ class AppState(private val scope: CoroutineScope) { } } + /** Open the New worktree dialog for [id], from [source] when the row that opened it names one. */ + fun openNewWorktree(id: String, source: WorktreeSource?) { + newWorktreeError = null + popup = Popup.NewWorktree(id, source) + } + + /** Every branch, remote branch and tag of [id], for the dialog's suggestions. */ + suspend fun worktreeRefNames(id: String): List = withContext(Dispatchers.IO) { WorktreeOps.refNames(id) } + + /** What a ref typed into the dialog names, or null (see [WorktreeOps.resolve]). */ + suspend fun resolveWorktreeRef(id: String, ref: String): WorktreeSource? = + withContext(Dispatchers.IO) { WorktreeOps.resolve(id, ref) } + + /** + * `git worktree add` — the New worktree dialog's Create (see [WorktreeOps.add]). + * + * The dialog stays up until this lands. A refusal goes into [newWorktreeError] and is shown in + * the dialog, next to the folder and branch name that caused it, rather than in a toast after + * those fields are gone. On success the dialog closes, the new tree's card opens in the pane + * when [id] is the repo on screen, and [openTerminal] has a terminal waiting in the folder. + * + * A pull request is two steps: a detached tree, then `gh pr checkout` inside it, which works out + * the branch (a fork's too) the same way the PR row's Checkout does. If the second step fails + * the first is undone, so a refused PR doesn't leave behind a folder at the wrong commit. + */ + fun addWorktree(id: String, path: String, source: WorktreeSource, newBranch: String?, openTerminal: Boolean) { + if (worktreesBusy) return + worktreesBusy = true + newWorktreeError = null + scope.launch { + val target = WorktreeOps.expandHome(path.trim()) + var r = WorktreeOps.add(id, target, source, newBranch) + if (r.ok && source is WorktreeSource.PullRequest) { + val pr = GitHub.checkoutPr(target, source.number) + r = if (pr.ok) { + OpResult(id, true, "Created worktree ${File(target).name} for PR #${source.number}") + } else { + WorktreeOps.remove(id, target, force = true, locked = false) + OpResult(id, false, pr.message) + } + } + if (!r.ok) { + newWorktreeError = r.message + worktreesBusy = false + return@launch + } + popup = null + toast(r.message) + if (openTerminal) openTerminal(target) + val list = withContext(Dispatchers.IO) { WorktreeOps.load(id) } + if (selectedId == id) { + worktreesRepo = id + worktrees = list + val made = runCatching { File(target).canonicalPath }.getOrDefault(target) + list.firstOrNull { runCatching { File(it.path).canonicalPath }.getOrDefault(it.path) == made } + ?.let { if (wtExpanded != it.path) toggleWorktreeCard(it.path) } + } else if (worktreesRepo == id) { + worktrees = list + } + // The parent's sub-rows and worktree count come from the scan. + rescanRepos(listOf(id), fetch = false) + worktreesBusy = false + } + } + // ---- one worktree's own contents (all reads run `git -C `) ---- /** Which worktree card is expanded in the detail pane, by path; null when all are collapsed. */ diff --git a/src/main/kotlin/com/gitvantage/git/WorktreeOps.kt b/src/main/kotlin/com/gitvantage/git/WorktreeOps.kt index a1d8f9d..308083a 100644 --- a/src/main/kotlin/com/gitvantage/git/WorktreeOps.kt +++ b/src/main/kotlin/com/gitvantage/git/WorktreeOps.kt @@ -4,6 +4,7 @@ package com.gitvantage.git import com.gitvantage.git.model.OpResult +import com.gitvantage.git.model.WorktreeSource import com.gitvantage.model.Worktree import com.gitvantage.model.WorktreeChange import java.io.File @@ -254,6 +255,143 @@ object WorktreeOps { private fun Int?.orZero() = this ?: 0 + // ---- creating one ---- + + /** + * Where a new worktree goes unless the user says otherwise: beside the repo, as + * `-`, numbered on from `-2` when that's taken. + * + * Beside rather than inside, on purpose. A tree inside the checkout shows up in its status as + * untracked files, and lands under the file watcher the repo already has — the churn + * [com.gitvantage.model.WatchPolicy] has to special-case `.claude/worktrees/` for. A sibling + * has neither problem. + */ + fun defaultPath(repoPath: String, name: String, taken: (File) -> Boolean = File::exists): String { + val repo = File(canonical(repoPath)) + val parent = repo.parentFile ?: repo + val base = "${repo.name}-${slug(name).ifEmpty { "worktree" }}" + return generateSequence(1) { it + 1 } + .map { n -> File(parent, if (n == 1) base else "$base-$n") } + .first { !taken(it) }.path + } + + /** A ref name as a folder name: `/`, spaces and anything else a shell would trip on become `-`. */ + internal fun slug(name: String): String = name.replace(UNSAFE, "-").trim('-', '.') + + private val UNSAFE = Regex("[^A-Za-z0-9._-]+") + + /** A leading `~/` as the home directory — the dialog shows paths folded that way, so it accepts them back. */ + fun expandHome(path: String): String { + val home = System.getProperty("user.home").orEmpty() + return when { + home.isEmpty() -> path + path == "~" -> home + path.startsWith("~/") -> home + path.removePrefix("~") + else -> path + } + } + + /** + * Why [path] can't take a new worktree, or null when it can. git accepts a folder that exists + * but is empty, so this does too; it makes missing parents itself. + */ + fun pathProblem(path: String): String? { + if (path.isBlank()) return "Choose a folder" + val f = File(expandHome(path.trim())) + return when { + !f.isAbsolute -> "Use a full path" + f.isFile -> "A file is already there" + f.isDirectory && f.list()?.isNotEmpty() == true -> "That folder already exists and isn't empty" + else -> null + } + } + + /** + * What a ref typed into the dialog names, checked in the order `git worktree add` itself would + * take it: a local branch; a remote-tracking one, either spelled out ("origin/x") or by its + * short name when exactly one remote has it (git's own guess); then anything that peels to a + * commit — a tag, a sha, `HEAD~3`. Null when it names nothing. + */ + fun resolve(repoPath: String, ref: String): WorktreeSource? { + val r = ref.trim() + if (r.isEmpty() || r.startsWith("-")) return null + val dir = File(repoPath) + fun exists(full: String) = Git.exitCode(dir, "show-ref", "--verify", "--quiet", full) == 0 + if (exists("refs/heads/$r")) return WorktreeSource.LocalBranch(r) + if (!r.endsWith("/HEAD") && exists("refs/remotes/$r")) return WorktreeSource.RemoteBranch(r) + val onRemotes = Git.read(dir, "for-each-ref", "--format=%(refname:short)", "refs/remotes") + .lines().filter { it.substringAfter('/', "") == r } + if (onRemotes.size == 1) return WorktreeSource.RemoteBranch(onRemotes[0]) + val sha = Git.readOrNull(dir, "rev-parse", "--verify", "--quiet", "$r^{commit}")?.trim() + return sha?.takeIf { it.isNotEmpty() }?.let { WorktreeSource.Commit(it, r) } + } + + /** + * Every local branch, remote branch and tag, newest first — what the dialog suggests as you + * type a ref. One `for-each-ref`, read once when the dialog opens. A remote's `HEAD` alias is + * left out: it is another name for a branch already in the list. + */ + fun refNames(repoPath: String): List = + Git.read( + File(repoPath), "for-each-ref", "--sort=-committerdate", + "--format=%(refname) %(refname:short)", "refs/heads", "refs/remotes", "refs/tags", + ).lines().mapNotNull { line -> + val full = line.substringBefore(' ') + line.substringAfter(' ', "").takeIf { it.isNotEmpty() && !full.endsWith("/HEAD") } + } + + /** + * `git worktree add` — a new working tree at [path] holding [source]. + * + * [newBranch] names a branch to create there. A remote branch always gets one — the short name + * when [newBranch] is null — tracking the remote, because a tree detached at `origin/x` isn't + * what "check this out" means and commits made there would belong to no branch. For a tag or + * commit it's optional, and null is git's own default for those: a detached HEAD. A local + * branch is checked out as itself and ignores it. + * + * Never `--force`. git's refusals here — the branch is checked out in another tree, a branch of + * that name already exists, the folder isn't empty — are each the right answer, and the dialog + * shows the message rather than overriding it. + * + * A [WorktreeSource.PullRequest] gets only the git half: a tree detached at `HEAD`, which the + * caller then has `gh pr checkout` replace with the PR's branch. That step lives with the rest + * of the GitHub CLI calls. + */ + suspend fun add(repoPath: String, path: String, source: WorktreeSource, newBranch: String?): OpResult = + withContext(Dispatchers.IO) { + val target = expandHome(path.trim()) + val named = newBranch?.trim()?.takeIf { it.isNotEmpty() } + val branch = when (source) { + is WorktreeSource.LocalBranch -> source.name + is WorktreeSource.RemoteBranch -> named ?: source.shortName + is WorktreeSource.Commit -> named + is WorktreeSource.PullRequest -> null + } + val args = buildList { + // Quiet, so a refusal's first line is the refusal: without it git leads stderr with + // "Preparing worktree (new branch …)", and that is what the dialog would show. + add("worktree"); add("add"); add("--quiet") + when (source) { + is WorktreeSource.LocalBranch -> { add(target); add(source.name) } + is WorktreeSource.RemoteBranch -> { + add("--track"); add("-b"); add(branch!!); add(target); add(source.name) + } + is WorktreeSource.Commit -> { + if (branch == null) add("--detach") else { add("--no-track"); add("-b"); add(branch) } + add(target); add(source.rev) + } + is WorktreeSource.PullRequest -> { add("--detach"); add(target); add("HEAD") } + } + } + val res = Git.run(File(repoPath), args) + if (!res.ok) return@withContext OpResult(repoPath, false, res.firstError("worktree add failed")) + val name = File(target).name + OpResult( + repoPath, true, + if (branch != null) "Created worktree $name on $branch" else "Created worktree $name at ${source.label}", + ) + } + /** * `git worktree remove` — deletes the worktree's folder and its files. The branch it held is * untouched. Destructive: callers confirm first. diff --git a/src/main/kotlin/com/gitvantage/git/model/WorktreeSource.kt b/src/main/kotlin/com/gitvantage/git/model/WorktreeSource.kt new file mode 100644 index 0000000..b028f35 --- /dev/null +++ b/src/main/kotlin/com/gitvantage/git/model/WorktreeSource.kt @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 Raman Gupta +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.gitvantage.git.model + +/** + * What a new worktree is made from — the ref a row handed to the New worktree dialog, or what a + * ref typed into it resolved to. + * + * Split by kind because each one asks git for something different: a local branch is checked out + * as itself, a remote branch needs a local branch made to track it, and a tag or commit has no + * branch at all unless one is named. A pull request is a fourth thing again — `gh` fetches it + * into the folder after git has made one. + */ +sealed interface WorktreeSource { + /** What the dialog's subtitle and the folder name are made from. */ + val label: String + + /** An existing local branch, checked out as itself. git refuses it if another tree holds it. */ + data class LocalBranch(val name: String) : WorktreeSource { + override val label get() = name + } + + /** A remote-tracking branch with [name] like "origin/fix-dpi"; a local branch is made to track it. */ + data class RemoteBranch(val name: String) : WorktreeSource { + override val label get() = name + val shortName get() = name.substringAfter('/') + } + + /** A tag or bare commit. [rev] is what git is given; [label] is how it was named — the tag, or the short hash. */ + data class Commit(val rev: String, override val label: String) : WorktreeSource + + /** A pull request, checked out into the new folder by `gh pr checkout`, which picks its branch name. */ + data class PullRequest(val number: Int, val title: String) : WorktreeSource { + override val label get() = "PR #$number" + } +} diff --git a/src/main/kotlin/com/gitvantage/ui/DetailPanel.kt b/src/main/kotlin/com/gitvantage/ui/DetailPanel.kt index 0f422cc..958e081 100644 --- a/src/main/kotlin/com/gitvantage/ui/DetailPanel.kt +++ b/src/main/kotlin/com/gitvantage/ui/DetailPanel.kt @@ -73,6 +73,7 @@ import com.gitvantage.git.model.Branch import com.gitvantage.git.model.RemoteBranch import com.gitvantage.git.model.groupByRemote import com.gitvantage.git.model.Submodule +import com.gitvantage.git.model.WorktreeSource import com.gitvantage.model.ChangedFile import com.gitvantage.model.Meta import com.gitvantage.model.Repo @@ -1398,6 +1399,12 @@ private fun BranchRow( "Switch anyway", danger = false, ) { state.switchBranch(id, b.name) } } + // Beside Switch because it's the other way to get onto the branch — one that leaves + // this checkout, and anything uncommitted in it, exactly where it is. Off for the same + // branches Switch is: git holds a branch in one tree at a time. + if (!b.isCurrent && !b.inOtherWorktree) RowAction("Worktree") { + state.openNewWorktree(id, WorktreeSource.LocalBranch(b.name)) + } // Where "Switch" would have been: the branch is already checked out somewhere, so the // useful move is to go there. Only when that worktree is tracked — offering to open an // untracked one is the "+ Add Repo" flow, and it belongs on the worktree list, not here. @@ -1561,6 +1568,16 @@ private fun RemoteBranchRow( if (rb.hasLocal) "Switch anyway" else "Checkout anyway", danger = false, ) { state.checkoutRemoteBranch(id, rb) } } + // The local branch when there is one, as Switch does — a second local branch made to + // track the same remote would be a surprise. Off when that local branch is already + // checked out somewhere, which git would refuse. + val local = if (rb.hasLocal) state.branches.find { it.name == rb.shortName } else null + if (local == null || (!local.isCurrent && !local.inOtherWorktree)) RowAction("Worktree") { + state.openNewWorktree( + id, + if (rb.hasLocal) WorktreeSource.LocalBranch(rb.shortName) else WorktreeSource.RemoteBranch(rb.name), + ) + } // Absent on the branches a workflow shares — main, develop, next, pu and the rest of // [Meta.INTEGRATION_BRANCHES]. Deleting one of those on the remote isn't a mistake // you make for yourself, and no confirmation wording makes an accidental click on a @@ -1890,6 +1907,11 @@ private fun IssueRow(state: AppState, id: String, item: GitHub.Item, clean: Bool ) { state.checkoutPr(id, item.number) } } } + HoverTip("Checks this PR out into a new folder of its own, leaving this checkout as it is.") { + RowAction("Worktree") { + state.openNewWorktree(id, WorktreeSource.PullRequest(item.number, item.title)) + } + } } } } diff --git a/src/main/kotlin/com/gitvantage/ui/LogView.kt b/src/main/kotlin/com/gitvantage/ui/LogView.kt index b70d966..6a7cb7c 100644 --- a/src/main/kotlin/com/gitvantage/ui/LogView.kt +++ b/src/main/kotlin/com/gitvantage/ui/LogView.kt @@ -56,6 +56,7 @@ import com.gitvantage.app.Tokens import com.gitvantage.git.model.Commit import com.gitvantage.git.model.CommitRef import com.gitvantage.git.model.RefKind +import com.gitvantage.git.model.WorktreeSource /** * Commit-list overlay for an arbitrary revision range (see [AppState.openRangeLog]). Each row @@ -132,6 +133,7 @@ private fun CommitRow(state: AppState, c: Commit) { // Only worth offering for commits the remote actually has: a link to a hash that was never // pushed lands on GitHub's 404, so the button would be a promise the row can't keep. val webBase = state.logWebBase?.takeIf { c.pushed } + val worktreeable = state.repos.any { it.id == state.logRepoId } // Held behind hover for the reason the branch and worktree lists are — see [HoverRow]. A log is // the longest of these lists, so a permanent pair of pills on every row is the worst offender. val source = remember { MutableInteractionSource() } @@ -187,6 +189,13 @@ private fun CommitRow(state: AppState, c: Commit) { horizontalArrangement = Arrangement.spacedBy(10.dp), ) { LogPill("Diff", accent) { state.openCommitDiff(state.logRepoId, c.fullHash, c.shortHash) } + // Only for a log of a tracked repo: a submodule's or a worktree's log has no parent + // entry here for the new tree to appear under. Named after the tag when the commit + // carries one, since "v1.4.0" is what you'd call that folder. + if (worktreeable) LogPill("Worktree", accent) { + val tag = c.refs.firstOrNull { it.kind == RefKind.TAG }?.label + state.openNewWorktree(state.logRepoId, WorktreeSource.Commit(c.fullHash, tag ?: c.shortHash)) + } if (webBase != null) LogPill("↗ GitHub", accent) { state.openUrl("$webBase/commit/${c.fullHash}") } } } diff --git a/src/main/kotlin/com/gitvantage/ui/Popups.kt b/src/main/kotlin/com/gitvantage/ui/Popups.kt index af0da3f..b7e3c0f 100644 --- a/src/main/kotlin/com/gitvantage/ui/Popups.kt +++ b/src/main/kotlin/com/gitvantage/ui/Popups.kt @@ -65,6 +65,8 @@ import com.gitvantage.app.ThemeMode import com.gitvantage.app.Tokens import com.gitvantage.app.UiFont import com.gitvantage.app.UiScale +import com.gitvantage.git.WorktreeOps +import com.gitvantage.git.model.WorktreeSource import com.gitvantage.model.Meta import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalClipboardManager @@ -88,6 +90,7 @@ fun PopupHost(state: AppState, popup: Popup) { is Popup.Note -> NotePopup(state, popup.id) is Popup.Commit -> CommitPopup(state, popup.id) is Popup.CommitWorktree -> CommitWorktreePopup(state, popup) + is Popup.NewWorktree -> NewWorktreePopup(state, popup) is Popup.Confirm -> ConfirmPopup(state, popup) is Popup.ConfirmWithOption -> ConfirmWithOptionPopup(state, popup) is Popup.Settings -> SettingsPopup(state, popup.section) @@ -672,6 +675,169 @@ private fun CommitForm( } } +/** + * A new worktree, from a ref — the dialog every "Worktree" row action and the Worktrees section's + * "+ New worktree" open. + * + * Prefilled so that Enter is usually the whole job: the branch is the ref's own (or the remote + * branch's short name), and the folder sits beside the repo named after it — see + * [WorktreeOps.defaultPath] for why beside and not inside. The folder follows the branch name as it + * is edited, until the folder itself is touched; after that it's the user's. + * + * Opened without a ref, it asks for one first, suggesting from the repo's branches and tags and + * saying under the field what the text resolves to — the same order git would take it in, so what + * the hint says is what Create will do. + * + * Create keeps the dialog up until git answers. A refusal (branch checked out elsewhere, a branch of + * that name already exists) is shown here, where the fields that caused it can be changed. + */ +@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) +@Composable +private fun NewWorktreePopup(state: AppState, p: Popup.NewWorktree) { + val repoName = remember(p.repoId) { java.io.File(p.repoId).name } + var ref by remember(p) { mutableStateOf("") } + var resolved by remember(p) { mutableStateOf(null) } + var resolving by remember(p) { mutableStateOf(false) } + var refNames by remember(p) { mutableStateOf>(emptyList()) } + if (p.source == null) { + LaunchedEffect(p) { refNames = state.worktreeRefNames(p.repoId) } + LaunchedEffect(p, ref) { + resolving = true + delay(200.milliseconds) // typing pace: resolve what was settled on, not every keystroke + resolved = if (ref.isBlank()) null else state.resolveWorktreeRef(p.repoId, ref) + resolving = false + } + } + val source = p.source ?: resolved + + // The branch field, reset whenever the source changes kind or name. + val defaultBranch = when (source) { + is WorktreeSource.RemoteBranch -> source.shortName + else -> "" + } + var branch by remember(p, source) { mutableStateOf(defaultBranch) } + val nameForFolder = when (source) { + is WorktreeSource.LocalBranch -> source.name + is WorktreeSource.RemoteBranch -> branch.ifBlank { source.shortName } + is WorktreeSource.Commit -> branch.ifBlank { source.label } + is WorktreeSource.PullRequest -> "pr-${source.number}" + null -> ref + } + var folder by remember(p) { mutableStateOf("") } + var folderEdited by remember(p) { mutableStateOf(false) } + if (!folderEdited) { + val suggested = shortPath(WorktreeOps.defaultPath(p.repoId, nameForFolder)) + if (folder != suggested) folder = suggested + } + var openTerminal by remember(p) { mutableStateOf(true) } + + val folderProblem = WorktreeOps.pathProblem(folder) + val busy = state.worktreesBusy + val canCreate = source != null && folderProblem == null && !busy && + !(source is WorktreeSource.RemoteBranch && branch.isBlank()) + fun create() { + if (!canCreate) return + state.addWorktree(p.repoId, folder, source!!, branch.takeIf { source.takesBranch }, openTerminal) + } + val dismiss = { state.popup = null } + + Modal(dismiss, width = 520) { + ModalHeader( + "New worktree", + when (p.source) { + null -> "A branch, tag or commit of $repoName, checked out in a folder of its own." + is WorktreeSource.PullRequest -> "From PR #${p.source.number} · ${p.source.title}" + else -> "From ${p.source.label} — in a folder of its own, leaving this checkout as it is." + }, + ) + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (p.source == null) { + Field("Ref") { + TextInput(ref, { ref = it }, "Branch, remote branch, tag or commit", state.accent, ::create, dismiss, font = MonoFont) + val matches = remember(refNames, ref) { + val q = ref.trim() + if (q.isEmpty()) refNames.take(6) + else refNames.filter { it.contains(q, ignoreCase = true) && it != q }.take(6) + } + if (matches.isNotEmpty()) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + matches.forEach { name -> PresetChip(name, false, state.accent) { ref = name } } + } + } + Hint( + when { + ref.isBlank() -> "Type a name, or pick one above." + resolving -> "Looking it up…" + resolved == null -> "Nothing by that name in $repoName." + else -> describe(resolved!!) + }, + warn = ref.isNotBlank() && !resolving && resolved == null, + ) + } + } + when (source) { + is WorktreeSource.LocalBranch -> Field("Branch") { + Txt("⎇ ${source.name}", 12.5.sp, Tokens.text2, font = MonoFont, maxLines = 1) + Hint("Checked out as itself. git refuses if another worktree already holds it.") + } + is WorktreeSource.RemoteBranch -> Field("Branch") { + TextInput(branch, { branch = it }, source.shortName, state.accent, ::create, dismiss, font = MonoFont) + Hint("A new local branch tracking ${source.name}.", warn = branch.isBlank()) + } + is WorktreeSource.Commit -> Field("Branch") { + TextInput(branch, { branch = it }, "Leave empty for a detached HEAD", state.accent, ::create, dismiss, font = MonoFont) + Hint( + if (branch.isBlank()) "Detached at ${source.label} — name a branch to commit on one." + else "A new branch starting at ${source.label}.", + ) + } + is WorktreeSource.PullRequest -> Field("Branch") { + Hint("gh names it after the PR's own branch, and sets it up to pull from a fork too.") + } + null -> Unit + } + Field("Folder") { + TextInput(folder, { folder = it; folderEdited = true }, "~/source/$repoName-branch", state.accent, ::create, dismiss, font = MonoFont) + Hint(folderProblem ?: "Beside the repo, so it stays out of its status and its file watcher.", warn = folderProblem != null) + } + Row( + Modifier.onTap { openTerminal = !openTerminal }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SelectBox(checked = openTerminal) + Txt("Open a terminal there after creating", 12.sp, Tokens.text2) + } + state.newWorktreeError?.let { Txt(it, 12.sp, Tokens.redText, maxLines = 4) } + ModalButtons(dismiss, if (busy) "Creating…" else "Create worktree", canCreate, false, ::create) + } + } +} + +/** Whether the dialog's branch field means anything for [this] — sent only when it does. */ +private val WorktreeSource.takesBranch get() = this is WorktreeSource.RemoteBranch || this is WorktreeSource.Commit + +/** The hint under the Ref field: what the typed text resolved to, in the words the branch field then uses. */ +private fun describe(s: WorktreeSource): String = when (s) { + is WorktreeSource.LocalBranch -> "Local branch ${s.name}" + is WorktreeSource.RemoteBranch -> "Remote branch ${s.name}" + is WorktreeSource.Commit -> "Commit ${s.rev.take(7)}" + is WorktreeSource.PullRequest -> s.label +} + +@Composable +private fun Field(label: String, content: @Composable ColumnScope.() -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { + Txt(label, 11.5.sp, Tokens.muted, FontWeight.SemiBold) + content() + } +} + +@Composable +private fun Hint(text: String, warn: Boolean = false) { + Txt(text, 11.sp, if (warn) Tokens.redText else Tokens.muted2, maxLines = 2) +} + @Composable private fun ConfirmPopup(state: AppState, p: Popup.Confirm) { Modal({ state.popup = null }) { diff --git a/src/main/kotlin/com/gitvantage/ui/Worktrees.kt b/src/main/kotlin/com/gitvantage/ui/Worktrees.kt index aad2e36..7e31052 100644 --- a/src/main/kotlin/com/gitvantage/ui/Worktrees.kt +++ b/src/main/kotlin/com/gitvantage/ui/Worktrees.kt @@ -216,20 +216,26 @@ private fun verdictColor(wt: Worktree): Color = when { */ @Composable fun WorktreesPaneSection(state: AppState, rv: RepoView) { - // Nothing to load or draw for a repo with no linked worktrees, which is nearly all of them. The - // scan already answered that, so this costs no git: without the guard, selecting any repo would - // run a `worktree list` to rediscover what the row it was clicked on already knew. - if (rv.worktrees.isEmpty()) return - LaunchedEffect(rv.id) { state.loadWorktrees(rv.id) } + if (!rv.repo.isGitRepo) return + // Nothing to load for a repo with no linked worktrees, which is nearly all of them. The scan + // already answered that, so this costs no git: without the guard, selecting any repo would run a + // `worktree list` to rediscover what the row it was clicked on already knew. What's drawn then + // is the heading alone, because it carries "+ New worktree" — the one way to make the first. + // + // Except straight after that first one is made: [AppState.addWorktree] hands the pane the new + // list before the rescan catches up, and the card should be there when the dialog closes. + val justMade = state.worktreesRepo == rv.id && state.worktrees.any { !it.isCurrent } + if (rv.worktrees.isNotEmpty()) LaunchedEffect(rv.id) { state.loadWorktrees(rv.id) } // The scan's list until the richer one lands, so the section doesn't blink in a frame late — it // has every field these cards show except "has its branch landed", which only adds a badge. - val loaded = if (state.worktreesRepo == rv.id && state.worktrees.isNotEmpty()) { + val loaded = if ((rv.worktrees.isNotEmpty() || justMade) && state.worktreesRepo == rv.id && state.worktrees.isNotEmpty()) { state.worktreeViews(rv.id, rv.repo.snoozed, state.worktrees) } else { rv.worktrees } - if (loaded.isEmpty()) return - val stale = state.worktrees.count { it.prunable || it.missing } + // [AppState.worktrees] is whichever repo last loaded them, which with the heading now drawn for + // every repo can be a different one. + val stale = if (state.worktreesRepo == rv.id) state.worktrees.count { it.prunable || it.missing } else 0 Column(Modifier.fillMaxWidth().padding(top = 20.dp).drawTopBorder(Tokens.sectionBorder).padding(top = 14.dp)) { Row( Modifier.padding(bottom = 8.dp), @@ -237,7 +243,7 @@ fun WorktreesPaneSection(state: AppState, rv: RepoView) { horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Txt("Worktrees", 12.5.sp, Tokens.text, FontWeight.Bold) - Txt("— ${loaded.size}", 11.sp, Tokens.muted2) + Txt(if (loaded.isEmpty()) "— none" else "— ${loaded.size}", 11.sp, Tokens.muted2) InfoTip( "Branches this repository has checked out in other folders. They share one set of " + "commits, branches, remotes and stashes with this checkout — so what's per-worktree " + @@ -257,7 +263,12 @@ fun WorktreesPaneSection(state: AppState, rv: RepoView) { ) { state.pruneWorktrees(rv.id) } } } + // For a ref with no row of its own to start from — a tag, a sha, `HEAD~3`. Branches, + // remote branches, PRs and log commits each have a "Worktree" action that opens the + // same dialog already filled in. + FlatAction("+ New worktree", size = 12.sp) { state.openNewWorktree(rv.id, null) } } + if (loaded.isEmpty()) return@Column Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { loaded.forEach { wtv -> WorktreeCard(state, rv, wtv) } } diff --git a/src/test/kotlin/com/gitvantage/git/WorktreeCreateTest.kt b/src/test/kotlin/com/gitvantage/git/WorktreeCreateTest.kt new file mode 100644 index 0000000..a95af8e --- /dev/null +++ b/src/test/kotlin/com/gitvantage/git/WorktreeCreateTest.kt @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: 2026 Raman Gupta +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.gitvantage.git + +import com.gitvantage.git.model.WorktreeSource +import de.infix.testBalloon.framework.core.testSuite +import java.io.File + +/** Creating a worktree from a ref: what each kind of source checks out, and how a typed ref resolves. */ +val WorktreeCreation by testSuite { + testFixture { Sandbox() } asContextForEach { + + test("a local branch is checked out as itself") { + val work = repo() + git(work, "branch", "side") + val tree = File(root, "wt") + + val result = WorktreeOps.add(work.path, tree.path, WorktreeSource.LocalBranch("side"), newBranch = null) + + assert(result.ok) { result.message } + assert(result.message == "Created worktree wt on side") { result.message } + assert(git(tree, "symbolic-ref", "--short", "HEAD").trim() == "side") + } + + test("a local branch checked out elsewhere is refused, and nothing is left behind") { + val work = repo() + val tree = File(root, "wt") + + val result = WorktreeOps.add(work.path, tree.path, WorktreeSource.LocalBranch(Sandbox.MAIN), newBranch = null) + + assert(!result.ok) + assert(result.message.startsWith("fatal:")) { result.message } + assert(!tree.exists()) + } + + test("a remote branch whose local name is taken says so, rather than git's progress line") { + val work = repo() + val tree = File(root, "wt") + + val result = WorktreeOps.add( + work.path, tree.path, WorktreeSource.RemoteBranch("origin/${Sandbox.MAIN}"), newBranch = Sandbox.MAIN, + ) + + assert(!result.ok) + assert("already exists" in result.message) { result.message } + assert(!tree.exists()) + } + + test("a remote branch gets a local branch of the same name, tracking it") { + val work = repo() + git(work, "switch", "-q", "-c", "feat") + commit(work, "f.txt", "feat\n", "feat work") + git(work, "push", "-q", "origin", "feat") + git(work, "switch", "-q", Sandbox.MAIN) + git(work, "branch", "-D", "feat") + val tree = File(root, "wt") + + val result = WorktreeOps.add(work.path, tree.path, WorktreeSource.RemoteBranch("origin/feat"), newBranch = null) + + assert(result.ok) { result.message } + assert(git(tree, "symbolic-ref", "--short", "HEAD").trim() == "feat") + assert(git(tree, "rev-parse", "--abbrev-ref", "@{u}").trim() == "origin/feat") + } + + test("a remote branch can be given a different local name") { + val work = repo() + git(work, "push", "-q", "origin", "${Sandbox.MAIN}:feat") + git(work, "fetch", "-q") + val tree = File(root, "wt") + + val result = WorktreeOps.add(work.path, tree.path, WorktreeSource.RemoteBranch("origin/feat"), newBranch = "mine") + + assert(result.ok) { result.message } + assert(git(tree, "symbolic-ref", "--short", "HEAD").trim() == "mine") + assert(git(tree, "rev-parse", "--abbrev-ref", "@{u}").trim() == "origin/feat") + } + + test("a tag with no branch name is a detached tree at that commit") { + val work = repo() + git(work, "tag", "v1") + commit(work, "b.txt", "later\n", "after the tag") + val tree = File(root, "wt") + + val result = WorktreeOps.add(work.path, tree.path, WorktreeSource.Commit("v1", "v1"), newBranch = " ") + + assert(result.ok) { result.message } + assert(result.message == "Created worktree wt at v1") { result.message } + assert(gitAllowingFailure(tree, "symbolic-ref", "-q", "HEAD") != 0) + assert(rev(tree, "HEAD") == rev(work, "v1")) + } + + test("a commit with a branch name starts a new branch there, tracking nothing") { + val work = repo() + val first = rev(work, "HEAD") + commit(work, "b.txt", "later\n", "second") + val tree = File(root, "wt") + + val result = WorktreeOps.add(work.path, tree.path, WorktreeSource.Commit(first, first.take(7)), newBranch = "fix") + + assert(result.ok) { result.message } + assert(git(tree, "symbolic-ref", "--short", "HEAD").trim() == "fix") + assert(rev(tree, "HEAD") == first) + assert(gitAllowingFailure(tree, "rev-parse", "--abbrev-ref", "@{u}") != 0) + } + + test("resolve takes a local branch, then a remote one, then any commit") { + val work = repo() + git(work, "branch", "side") + git(work, "push", "-q", "origin", "${Sandbox.MAIN}:only-remote") + git(work, "fetch", "-q") + git(work, "tag", "v1") + + assert(WorktreeOps.resolve(work.path, "side") == WorktreeSource.LocalBranch("side")) + assert(WorktreeOps.resolve(work.path, "origin/only-remote") == WorktreeSource.RemoteBranch("origin/only-remote")) + // The short name alone, when exactly one remote has it — git's own guess. + assert(WorktreeOps.resolve(work.path, "only-remote") == WorktreeSource.RemoteBranch("origin/only-remote")) + assert(WorktreeOps.resolve(work.path, "v1") == WorktreeSource.Commit(rev(work, "v1"), "v1")) + assert(WorktreeOps.resolve(work.path, "no-such-thing") == null) + assert(WorktreeOps.resolve(work.path, "--detach") == null) + } + + test("refNames lists branches, remote branches and tags, without a remote's HEAD alias") { + val work = repo() + git(work, "remote", "set-head", "origin", Sandbox.MAIN) + git(work, "tag", "v1") + + val names = WorktreeOps.refNames(work.path) + + assert(Sandbox.MAIN in names && "origin/${Sandbox.MAIN}" in names && "v1" in names) { names.toString() } + assert("origin" !in names && names.none { it.endsWith("/HEAD") }) { names.toString() } + } + + test("the default folder sits beside the repo and numbers past what's taken") { + val work = repo() + val parent = work.canonicalFile.parentFile + + assert(WorktreeOps.defaultPath(work.path, "feature/log search") == File(parent, "repo-feature-log-search").path) + File(parent, "repo-fix").mkdirs() + assert(WorktreeOps.defaultPath(work.path, "fix") == File(parent, "repo-fix-2").path) + assert(WorktreeOps.defaultPath(work.path, "///") == File(parent, "repo-worktree").path) + } + + test("pathProblem accepts a missing or empty folder and refuses one with files in it") { + val empty = File(root, "empty").apply { mkdirs() } + val full = File(root, "full").apply { mkdirs(); File(this, "x").writeText("x") } + + assert(WorktreeOps.pathProblem(File(root, "nope").path) == null) + assert(WorktreeOps.pathProblem(empty.path) == null) + assert(WorktreeOps.pathProblem(full.path) != null) + assert(WorktreeOps.pathProblem("relative/path") != null) + assert(WorktreeOps.pathProblem(" ") != null) + } + } +} From 5a378c49fc8486ec621c3191244882cfb4a20a81 Mon Sep 17 00:00:00 2001 From: Raman Gupta Date: Tue, 27 Oct 2026 17:33:43 -0400 Subject: [PATCH 2/2] fix(worktrees): keep the New worktree dialog off the git layer The dialog called WorktreeOps.defaultPath and pathProblem directly, which the architecture test forbids for the UI package. Both now go through AppState, as the dialog's ref lookups already did. Co-Authored-By: Claude Signed-off-by: Raman Gupta --- src/main/kotlin/com/gitvantage/app/AppState.kt | 6 ++++++ src/main/kotlin/com/gitvantage/ui/Popups.kt | 7 +++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/gitvantage/app/AppState.kt b/src/main/kotlin/com/gitvantage/app/AppState.kt index 4f29ae6..a219bd5 100644 --- a/src/main/kotlin/com/gitvantage/app/AppState.kt +++ b/src/main/kotlin/com/gitvantage/app/AppState.kt @@ -2146,6 +2146,12 @@ class AppState(private val scope: CoroutineScope) { /** Every branch, remote branch and tag of [id], for the dialog's suggestions. */ suspend fun worktreeRefNames(id: String): List = withContext(Dispatchers.IO) { WorktreeOps.refNames(id) } + /** The folder the dialog suggests for a worktree of [id] named [name] (see [WorktreeOps.defaultPath]). */ + fun defaultWorktreePath(id: String, name: String): String = WorktreeOps.defaultPath(id, name) + + /** Why the dialog's folder can't take a new worktree, or null (see [WorktreeOps.pathProblem]). */ + fun worktreePathProblem(path: String): String? = WorktreeOps.pathProblem(path) + /** What a ref typed into the dialog names, or null (see [WorktreeOps.resolve]). */ suspend fun resolveWorktreeRef(id: String, ref: String): WorktreeSource? = withContext(Dispatchers.IO) { WorktreeOps.resolve(id, ref) } diff --git a/src/main/kotlin/com/gitvantage/ui/Popups.kt b/src/main/kotlin/com/gitvantage/ui/Popups.kt index b7e3c0f..e1fc97d 100644 --- a/src/main/kotlin/com/gitvantage/ui/Popups.kt +++ b/src/main/kotlin/com/gitvantage/ui/Popups.kt @@ -65,7 +65,6 @@ import com.gitvantage.app.ThemeMode import com.gitvantage.app.Tokens import com.gitvantage.app.UiFont import com.gitvantage.app.UiScale -import com.gitvantage.git.WorktreeOps import com.gitvantage.git.model.WorktreeSource import com.gitvantage.model.Meta import androidx.compose.runtime.rememberCoroutineScope @@ -681,7 +680,7 @@ private fun CommitForm( * * Prefilled so that Enter is usually the whole job: the branch is the ref's own (or the remote * branch's short name), and the folder sits beside the repo named after it — see - * [WorktreeOps.defaultPath] for why beside and not inside. The folder follows the branch name as it + * [com.gitvantage.git.WorktreeOps.defaultPath] for why beside and not inside. The folder follows the branch name as it * is edited, until the folder itself is touched; after that it's the user's. * * Opened without a ref, it asks for one first, suggesting from the repo's branches and tags and @@ -726,12 +725,12 @@ private fun NewWorktreePopup(state: AppState, p: Popup.NewWorktree) { var folder by remember(p) { mutableStateOf("") } var folderEdited by remember(p) { mutableStateOf(false) } if (!folderEdited) { - val suggested = shortPath(WorktreeOps.defaultPath(p.repoId, nameForFolder)) + val suggested = shortPath(state.defaultWorktreePath(p.repoId, nameForFolder)) if (folder != suggested) folder = suggested } var openTerminal by remember(p) { mutableStateOf(true) } - val folderProblem = WorktreeOps.pathProblem(folder) + val folderProblem = state.worktreePathProblem(folder) val busy = state.worktreesBusy val canCreate = source != null && folderProblem == null && !busy && !(source is WorktreeSource.RemoteBranch && branch.isBlank())