diff --git a/src/main/kotlin/com/gitvantage/app/AppState.kt b/src/main/kotlin/com/gitvantage/app/AppState.kt index 6376469..a219bd5 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,77 @@ 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) } + + /** 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) } + + /** + * `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..e1fc97d 100644 --- a/src/main/kotlin/com/gitvantage/ui/Popups.kt +++ b/src/main/kotlin/com/gitvantage/ui/Popups.kt @@ -65,6 +65,7 @@ import com.gitvantage.app.ThemeMode import com.gitvantage.app.Tokens import com.gitvantage.app.UiFont import com.gitvantage.app.UiScale +import com.gitvantage.git.model.WorktreeSource import com.gitvantage.model.Meta import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalClipboardManager @@ -88,6 +89,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 +674,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 + * [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 + * 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(state.defaultWorktreePath(p.repoId, nameForFolder)) + if (folder != suggested) folder = suggested + } + var openTerminal by remember(p) { mutableStateOf(true) } + + val folderProblem = state.worktreePathProblem(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) + } + } +}