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
86 changes: 86 additions & 0 deletions src/main/kotlin/com/gitvantage/app/AppState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -102,6 +104,15 @@ sealed interface Popup {
override val ids: Set<String> = 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<String> = setOf(repoId)
}

/**
* The app's settings, opened at [section].
*
Expand Down Expand Up @@ -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<String?>(null)
private set

// Diff viewer (GitHub-style overlay).
var diffOpen by mutableStateOf(false)
private set
Expand Down Expand Up @@ -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<String> = 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 <worktree-path>`) ----

/** Which worktree card is expanded in the detail pane, by path; null when all are collapsed. */
Expand Down
138 changes: 138 additions & 0 deletions src/main/kotlin/com/gitvantage/git/WorktreeOps.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
* `<repo>-<slug of name>`, 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<String> =
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.
Expand Down
37 changes: 37 additions & 0 deletions src/main/kotlin/com/gitvantage/git/model/WorktreeSource.kt
Original file line number Diff line number Diff line change
@@ -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"
}
}
22 changes: 22 additions & 0 deletions src/main/kotlin/com/gitvantage/ui/DetailPanel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
}
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions src/main/kotlin/com/gitvantage/ui/LogView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() }
Expand Down Expand Up @@ -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}") }
}
}
Expand Down
Loading
Loading