[git] IJPL-211607 IJPL-204771 Working tree creation actions IJ-MR-185641

GitOrigin-RevId: 52a5062f5e732d15103da68bf3684ee45cbbb54d
This commit is contained in:
Elena Shaverdova
2026-01-08 13:43:45 +00:00
committed by intellij-monorepo-bot
parent 258afe3f84
commit 61ff67fcf5
28 changed files with 1028 additions and 256 deletions
@@ -41,6 +41,7 @@
<action id="Git.Show.WorkingTrees" class="git4idea.actions.workingTree.ShowWorkingTreesAction">
<add-to-group group-id="Vcs.Log.ToolWindow.TabActions.DropDown" relative-to-action="Vcs.ShowConsoleTab" anchor="after"/>
</action>
<action id="Git.CreateNewWorkingTree" class="git4idea.actions.workingTree.GitCreateWorkingTreeAction"/>
<action id="Git.Drop.Selected.Changes" class="git4idea.rebase.log.changes.GitDropSelectedChangesAction">
<add-to-group group-id="Vcs.RepositoryChangesBrowserMenu" relative-to-action="Vcs.ApplySelectedChanges" anchor="after"/>
@@ -161,6 +162,7 @@
<action id="Git.Tag" class="git4idea.actions.GitTag"/>
<action id="Git.Reset" class="git4idea.actions.GitResetHead"/>
<separator/>
<reference ref="Git.CreateNewWorkingTree"/>
<reference ref="Git.Show.WorkingTrees"/>
<separator/>
<reference ref="Vcs.Show.Log"/>
@@ -250,6 +252,8 @@
<action class="git4idea.actions.branch.GitRebaseBranchAction"/>
<action class="git4idea.actions.ref.GitMergeRefAction"/>
<separator/>
<reference ref="Git.CreateNewWorkingTree"/>
<separator/>
<action class="git4idea.actions.branch.GitUpdateSelectedBranchAction"/>
<action class="git4idea.actions.branch.GitPushBranchAction"/>
<group id="Git.Branch.Tracked" class="git4idea.actions.branch.GitTrackedBranchActionGroup" popup="true"/>
@@ -338,6 +342,8 @@
<separator/>
<reference ref="Git.Show.WorkingTrees"/>
<separator/>
<reference ref="Git.CreateNewWorkingTree"/>
<separator/>
<add-to-group group-id="Vcs.Log.ContextMenu" relative-to-action="Vcs.Log.GoToChild" anchor="before"/>
</group>
@@ -548,6 +554,7 @@
</action>
<group id="Git.WorkingTrees.ToolwindowGroup.Toolbar">
<reference ref="Git.CreateNewWorkingTree"/>
<reference ref="Git.WorkingTrees.Remove"/>
</group>
@@ -886,6 +893,7 @@
predicateClassName="git4idea.workingTrees.ui.GitWorkingTreesContentVisibilityPredicate"
preloaderClassName="git4idea.workingTrees.ui.GitWorkingTreesContentPreloader"
isInCommitToolWindow="false"/>
<statistics.actionCustomPlaceAllowlist places="GitWorkingTreeToolWindowTabToolbar"/>
<registryKey key="git.enable.working.trees.feature" defaultValue="false" restartRequired="true"
description="Enables the 'Working Trees' feature, see https://youtrack.jetbrains.com/issue/IJPL-204771"/>
+1
View File
@@ -35,6 +35,7 @@ jvm_library(
"//platform/platform-impl/ui",
"//platform/diagnostic/telemetry",
"@lib//:opentelemetry",
"//platform/vcs-api/shared",
]
)
### auto-generated section `build intellij.vcs.git.shared` end
@@ -54,5 +54,6 @@
<orderEntry type="module" module-name="intellij.platform.ide.ui" />
<orderEntry type="module" module-name="intellij.platform.diagnostic.telemetry" />
<orderEntry type="library" name="opentelemetry" level="project" />
<orderEntry type="module" module-name="intellij.platform.vcs.shared" />
</component>
</module>
@@ -1841,3 +1841,28 @@ action.Git.WorkingTrees.Open.text=Open
action.Git.WorkingTrees.Open.description=Open the worktree as a project
Git.WorkingTrees.delete.worktree.success.message=Successfully deleted worktree `{0}`
Git.WorkingTrees.delete.worktrees.failure.notification.title=Worktree deletion failed
action.Git.CreateNewWorkingTree.text = New Worktree\u2026
action.Git.CreateNewWorkingTree.description = Create a new Git working tree from the current repository
working.tree.dialog.title=New Worktree
working.tree.dialog.button.ok=Create Worktree
working.tree.dialog.label.existing.branch=From branch:
working.tree.dialog.label.name=Project name:
working.tree.dialog.label.location=Location:
working.tree.dialog.label.location.file.chooser.title=Worktree Location
working.tree.dialog.checkbox.new.branch=New branch:
working.tree.dialog.label.new.branch.comment=Based on ''{0}'' branch
working.tree.dialog.label.new.branch.detached.comment=Based on current repository state
working.tree.dialog.label.location.comment=The worktree will be created in:<br/>{0}
working.tree.dialog.existing.branch.combo.box.empty.text=Select branch
progress.title.creating.worktree=Creating worktree
working.tree.dialog.location.validation.empty=Provide a location for a worktree
working.tree.dialog.location.validation.is.a.file=''{0}'' is a file
working.tree.dialog.location.validation.is.not.empty=''{0}'' is not empty
working.tree.dialog.location.validation.select.branch=Select a branch to create a worktree
working.tree.dialog.location.validation.provide.name=Provide a name for a worktree
working.tree.dialog.location.validation.provide.new.branch.name=Provide a name for the new branch
working.tree.dialog.branch.validation.already.checked.out.in.working.tree=Branch {0} is already checked out in working tree `{1}`
notification.title.worktree.creation.failed=Worktree creation failed
action.Git.CreateNewWorkingTree.from.branch.text=New Worktree from ''{0}''\u2026
action.Git.CreateNewWorkingTree.from.branch.description=Create a new Git working tree from the ''{0}'' branch of the current repository
@@ -4,6 +4,7 @@ package git4idea
import com.intellij.openapi.util.NlsSafe
import com.intellij.openapi.vcs.FilePath
import com.intellij.openapi.vcs.LocalFilePath
import com.intellij.openapi.vcs.actions.VcsContextFactory
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
@@ -29,7 +30,7 @@ data class GitWorkingTree(
constructor(path: @NlsSafe String, fullBranchName: @NlsSafe String?, isMain: Boolean, isCurrent: Boolean) :
this(
LocalFilePath(path, true),
VcsContextFactory.getInstance().createFilePath(path, true),
if (fullBranchName == null) null else GitStandardLocalBranch(fullBranchName),
isMain,
isCurrent
@@ -1,4 +1,4 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea
import com.intellij.notification.impl.NotificationIdsHolder
@@ -129,6 +129,8 @@ class GitNotificationIdsHolder : NotificationIdsHolder {
GPG_AGENT_CONFIGURATION_PROPOSE,
GPG_AGENT_CONFIGURATION_PROPOSE_SUGGESTION,
MODAL_COMMIT_DEPRECATION,
WORKTREE_ADD_FAILED,
WORKTREE_COULD_NOT_CREATE_TARGET_DIR,
WORKING_TREE_DELETED,
WORKING_TREE_COULD_NOT_DELETE,
)
@@ -259,6 +261,8 @@ class GitNotificationIdsHolder : NotificationIdsHolder {
const val GPG_AGENT_CONFIGURATION_PROPOSE: String = "git.gpg.agent.configuration.propose"
const val GPG_AGENT_CONFIGURATION_PROPOSE_SUGGESTION: String = "git.gpg.agent.configuration.propose.suggestion"
const val MODAL_COMMIT_DEPRECATION: String = "git.commit.modal.deprecation"
const val WORKTREE_ADD_FAILED: String = "git.worktree.add.failed"
const val WORKTREE_COULD_NOT_CREATE_TARGET_DIR: String = "git.worktree.could.not.create.target.dir"
const val WORKING_TREE_DELETED: String = "git.working.tree.deleted"
const val WORKING_TREE_COULD_NOT_DELETE: String = "git.working.tree.not.deleted"
}
@@ -1,4 +1,4 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea
import com.intellij.internal.statistic.StructuredIdeActivity
@@ -6,7 +6,9 @@ import com.intellij.internal.statistic.eventLog.EventLogGroup
import com.intellij.internal.statistic.eventLog.events.EventFields
import com.intellij.internal.statistic.eventLog.events.RoundedIntEventField
import com.intellij.internal.statistic.service.fus.collectors.CounterUsagesCollector
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.project.Project
import git4idea.actions.workingTree.GitWorkingTreeDialogData
import git4idea.commands.GitCommandResult
import git4idea.inMemory.rebase.InMemoryRebaseResult
import git4idea.push.GitPushRepoResult
@@ -17,7 +19,7 @@ import git4idea.rebase.interactive.CantRebaseUsingLogException
internal object GitOperationsCollector : CounterUsagesCollector() {
override fun getGroup(): EventLogGroup = GROUP
private val GROUP: EventLogGroup = EventLogGroup("git.operations", 6)
private val GROUP: EventLogGroup = EventLogGroup("git.operations", 7)
internal val UPDATE_FORCE_PUSHED_BRANCH_ACTIVITY = GROUP.registerIdeActivity("update.force.pushed")
@@ -45,10 +47,12 @@ internal object GitOperationsCollector : CounterUsagesCollector() {
)
private val INTERACTIVE_REBASE_WAS_SUCCESSFUL = EventFields.Boolean("was_successful")
private val INTERACTIVE_REBASE_ACTIVITY = GROUP.registerIdeActivity("interactive.rebase",
finishEventAdditionalFields = arrayOf(INTERACTIVE_REBASE_WAS_SUCCESSFUL))
finishEventAdditionalFields = arrayOf(
INTERACTIVE_REBASE_WAS_SUCCESSFUL))
private val IN_MEMORY_REBASE_RESULT = EventFields.Enum<InMemoryRebaseResult>("in_memory_rebase_result")
private val IN_MEMORY_INTERACTIVE_REBASE_ACTIVITY = GROUP.registerIdeActivity("in.memory.interactive.rebase",
finishEventAdditionalFields = arrayOf(IN_MEMORY_REBASE_RESULT))
finishEventAdditionalFields = arrayOf(
IN_MEMORY_REBASE_RESULT))
private val CANT_REBASE_USING_LOG_REASON = EventFields.Enum<CantRebaseUsingLogException.Reason>("cant_rebase_using_log_reason")
private val CANT_REBASE_USING_LOG_EVENT = GROUP.registerEvent("cant.rebase.using.log", CANT_REBASE_USING_LOG_REASON)
@@ -59,6 +63,20 @@ internal object GitOperationsCollector : CounterUsagesCollector() {
private val REBASE_START_USING_LOG_EVENT = GROUP.registerVarargEvent("rebase.start.using.log",
*REBASE_ENTRY_TYPE_FIELDS.values.toTypedArray())
private val WITH_PROVIDED_BRANCH = EventFields.Boolean("with_provided_branch")
private val WORKING_TREE_CREATION_ACTIVITY =
GROUP.registerIdeActivity("create.worktree",
startEventAdditionalFields = arrayOf(EventFields.ActionPlace, WITH_PROVIDED_BRANCH),
subStepWithStepId = true)
private val WITH_EXISTING_BRANCH = EventFields.Boolean("with_existing_branch")
private val WORKTREE_CREATION_DIALOG_EXIT_OK_STAGE =
WORKING_TREE_CREATION_ACTIVITY.registerStage("worktree.creation.dialog.exited.with.ok",
arrayOf(WITH_EXISTING_BRANCH))
private val WORKTREE_PROJECT_WAS_OPENED_AFTER_CREATION_STAGE =
WORKING_TREE_CREATION_ACTIVITY.registerStage("worktree.project.was.opened.after.creation")
@JvmStatic
fun startLogPush(project: Project): StructuredIdeActivity {
return PUSH_ACTIVITY.started(project)
@@ -122,4 +140,20 @@ internal object GitOperationsCollector : CounterUsagesCollector() {
REBASE_START_USING_LOG_EVENT.log(project, *eventPairs)
}
fun logCreateWorktreeActionInvoked(e: AnActionEvent, branch: GitStandardLocalBranch?): StructuredIdeActivity {
return WORKING_TREE_CREATION_ACTIVITY.started(e.project) {
listOf(EventFields.ActionPlace.with(e.place), WITH_PROVIDED_BRANCH.with(branch != null))
}
}
fun logWorktreeCreationDialogExitedWithOk(activity: StructuredIdeActivity, workingTreeData: GitWorkingTreeDialogData) {
activity.stageStarted(WORKTREE_CREATION_DIALOG_EXIT_OK_STAGE) {
listOf(WITH_EXISTING_BRANCH.with(workingTreeData.newBranchName == null))
}
}
fun logWorktreeProjectOpenedAfterCreation(activity: StructuredIdeActivity) {
activity.stageStarted(WORKTREE_PROJECT_WAS_OPENED_AFTER_CREATION_STAGE)
}
}
@@ -1,10 +1,11 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.actions;
import com.intellij.dvcs.actions.DvcsQuickListContentProvider;
import com.intellij.openapi.actionSystem.ActionManager;
import com.intellij.openapi.actionSystem.AnAction;
import com.intellij.openapi.actionSystem.IdeActions;
import com.intellij.openapi.actionSystem.Separator;
import git4idea.GitVcs;
import org.jetbrains.annotations.NotNull;
@@ -25,6 +26,11 @@ public class GitQuickListContentProvider extends DvcsQuickListContentProvider {
add("Git.Stash", manager, actions);
add("Git.Unstash", manager, actions);
actions.add(Separator.getInstance());
add("Git.CreateNewWorkingTree", manager, actions);
add("Show.WorkingTrees", manager, actions);
actions.add(Separator.getInstance());
add("ChangesView.AddUnversioned", manager, actions);
add("Vcs.CopyCurrentBranchName", manager, actions);
add("Git.ResolveConflicts", manager, actions);
@@ -0,0 +1,149 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.actions.workingTree
import com.intellij.dvcs.ui.CloneDvcsValidationUtils
import com.intellij.icons.AllIcons
import com.intellij.ide.impl.ProjectUtil
import com.intellij.internal.statistic.StructuredIdeActivity
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.application.UiWithModelAccess
import com.intellij.openapi.application.readAction
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.guessProjectDir
import com.intellij.openapi.roots.ProjectFileIndex
import com.intellij.openapi.vcs.VcsNotifier
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.util.concurrency.annotations.RequiresReadLock
import com.intellij.vcs.git.actions.GitSingleRefActions
import fleet.util.safeAs
import git4idea.GitNotificationIdsHolder
import git4idea.GitOperationsCollector
import git4idea.GitStandardLocalBranch
import git4idea.actions.branch.GitBranchActionsDataKeys
import git4idea.i18n.GitBundle
import git4idea.repo.GitRepository
import git4idea.workingTrees.GitWorkingTreesService
import git4idea.workingTrees.ui.GitWorkingTreesContentProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.swing.Icon
internal class GitCreateWorkingTreeAction : DumbAwareAction() {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
override fun update(e: AnActionEvent) {
val project = e.project
val singleRepository = GitWorkingTreesService.getRepoForWorkingTreesSupport(project)
if (singleRepository == null) {
e.presentation.isEnabledAndVisible = false
return
}
e.presentation.isEnabledAndVisible = true
e.presentation.icon = computeIcon(e)
val localBranchFromContext = getLocalBranchFromContext(e, singleRepository)
if (localBranchFromContext == null) {
e.presentation.text = GitBundle.message("action.Git.CreateNewWorkingTree.text")
e.presentation.description = GitBundle.message("action.Git.CreateNewWorkingTree.description")
}
else {
val branchName = localBranchFromContext.name
e.presentation.text = GitBundle.message("action.Git.CreateNewWorkingTree.from.branch.text", branchName)
e.presentation.description = GitBundle.message("action.Git.CreateNewWorkingTree.from.branch.description", branchName)
}
}
private fun computeIcon(e: AnActionEvent): Icon? {
return if (e.place == GitWorkingTreesContentProvider.GIT_WORKING_TREE_TOOLWINDOW_TAB_TOOLBAR) {
AllIcons.General.Add
}
else {
null
}
}
override fun actionPerformed(e: AnActionEvent) {
val project = e.project ?: return
val repository = GitWorkingTreesService.getRepoForWorkingTreesSupport(project) ?: return
val localBranchFromContext = getLocalBranchFromContext(e, repository)
val ideActivity = GitOperationsCollector.logCreateWorktreeActionInvoked(e, localBranchFromContext)
e.coroutineScope.launch(Dispatchers.Default) {
val preDialogData = readAction {
val initialParentPath = computeInitialParentPath(project, repository)
GitWorkingTreePreDialogData(project, repository, ideActivity, localBranchFromContext, initialParentPath)
}
withContext(Dispatchers.UiWithModelAccess) {
val dialog = GitWorkingTreeDialog(preDialogData)
if (dialog.showAndGet()) {
val workingTreeData = dialog.getWorkTreeData()
withContext(Dispatchers.Default) {
doCreateWorkingTree(preDialogData.project, preDialogData.repository, preDialogData.ideActivity, workingTreeData)
}
}
}
}
}
private fun getLocalBranchFromContext(e: AnActionEvent, repository: GitRepository?): GitStandardLocalBranch? {
val explicitRefFromCtx = e.getData(GitSingleRefActions.SELECTED_REF_DATA_KEY)
val ref = when {
explicitRefFromCtx != null -> explicitRefFromCtx
e.getData(GitBranchActionsDataKeys.USE_CURRENT_BRANCH) == true -> repository?.currentBranch
else -> null
}
return ref.safeAs<GitStandardLocalBranch>()
}
/**
* Searches for a directory that doesn't lie under any of roots of the [project].
*/
@RequiresReadLock
private fun computeInitialParentPath(project: Project, repository: GitRepository): VirtualFile? {
val fromProject = project.guessProjectDir()?.parent
var root: VirtualFile? = fromProject ?: repository.root.parent
val index = ProjectFileIndex.getInstance(project)
while (root != null && index.isInProjectOrExcluded(root)) {
root = root.parent
}
return root
}
private suspend fun doCreateWorkingTree(
project: Project,
repository: GitRepository,
ideActivity: StructuredIdeActivity,
workingTreeData: GitWorkingTreeDialogData,
) {
GitOperationsCollector.logWorktreeCreationDialogExitedWithOk(ideActivity, workingTreeData)
val path = workingTreeData.workingTreePath.path
val destinationValidation = CloneDvcsValidationUtils.createDestination(path)
if (destinationValidation != null) {
VcsNotifier.getInstance(project).notifyError(GitNotificationIdsHolder.WORKTREE_COULD_NOT_CREATE_TARGET_DIR,
GitBundle.message("notification.title.worktree.creation.failed"),
destinationValidation.message,
true)
return
}
val result = GitWorkingTreesService.getInstance(project).createWorkingTree(repository, workingTreeData)
if (!result.success) {
VcsNotifier.getInstance(project).notifyError(GitNotificationIdsHolder.WORKTREE_ADD_FAILED,
GitBundle.message("notification.title.worktree.creation.failed"),
result.errorOutputAsHtmlString,
true)
return
}
val worktreeProject = ProjectUtil.openOrImport(workingTreeData.workingTreePath.path, null, false)
if (worktreeProject != null) {
GitOperationsCollector.logWorktreeProjectOpenedAfterCreation(ideActivity)
}
}
}
@@ -0,0 +1,304 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.actions.workingTree
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.application.asContextElement
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.observable.properties.AtomicLazyProperty
import com.intellij.openapi.observable.properties.ObservableMutableProperty
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.*
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.NlsContexts
import com.intellij.platform.util.coroutines.childScope
import com.intellij.ui.ColoredListCellRenderer
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.JBTextField
import com.intellij.ui.dsl.builder.*
import com.intellij.ui.layout.ValidationInfoBuilder
import com.intellij.util.ui.JBUI
import com.intellij.vcs.git.ui.GitBranchesTreeIconProvider
import com.intellij.vcsUtil.VcsUtil
import git4idea.GitBranch
import git4idea.GitStandardLocalBranch
import git4idea.GitWorkingTree
import git4idea.i18n.GitBundle
import git4idea.repo.GitRepository
import git4idea.workingTrees.GitWorkingTreesService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedSendChannelException
import kotlinx.coroutines.launch
import org.jetbrains.annotations.VisibleForTesting
import java.awt.Dimension
import java.nio.file.Path
import java.nio.file.Paths
import javax.swing.JComponent
import javax.swing.JList
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.name
internal class GitWorkingTreeDialog(
private val data: GitWorkingTreePreDialogData,
) : DialogWrapper(data.project, false) {
private val uiScope: CoroutineScope = GitWorkingTreesService.getInstance(data.project).coroutineScope.childScope("GitWorkingTreeDialog")
private val branchToWorkingTreeMap = data.repository.workingTreeHolder.getWorkingTrees()
.filter { it.currentBranch != null }
.associateBy { it.currentBranch!! }
private lateinit var parentPathCell: Cell<TextFieldWithBrowseButton>
private lateinit var projectNameCell: Cell<JBTextField>
private val existingBranchWithWorkingTree: ObservableMutableProperty<BranchWithWorkingTree?> = AtomicLazyProperty {
data.initialExistingBranch?.toBranchWithWorkingTree()
}
private val projectName: ObservableMutableProperty<String> = AtomicLazyProperty { "" }
private val parentPath: ObservableMutableProperty<String> = AtomicLazyProperty { data.initialParentPath?.path ?: "" }
private val createNewBranch: ObservableMutableProperty<Boolean> = AtomicLazyProperty { false }
private val newBranchName: ObservableMutableProperty<String> = AtomicLazyProperty { "" }
private var lastSuggestedProjectName: String = ""
private var projectNameEdited: Boolean = false
private val lastPathValidationChannel = Channel<PathValidationMessage>(Channel.CONFLATED)
init {
init()
title = GitBundle.message("working.tree.dialog.title")
setOKButtonText(GitBundle.message("working.tree.dialog.button.ok"))
Disposer.register(disposable) {
uiScope.cancel()
lastPathValidationChannel.close()
}
}
private data class BranchWithWorkingTree(val branch: GitBranch, val workingTree: GitWorkingTree?)
private fun GitStandardLocalBranch.toBranchWithWorkingTree(): BranchWithWorkingTree =
BranchWithWorkingTree(this, branchToWorkingTreeMap[this])
override fun createCenterPanel(): JComponent {
return panel {
row(GitBundle.message("working.tree.dialog.label.existing.branch")) {
val localBranchesWithTrees: List<BranchWithWorkingTree?> = computeBranchesWithWorkingTrees()
comboBox(localBranchesWithTrees, BranchWithTreeCellRenderer(data.project, data.repository))
.bindItem(existingBranchWithWorkingTree).align(Align.FILL).validationOnApply { validateBranchOnApply(it) }
existingBranchWithWorkingTree.afterChange { updateSuggestedProjectName() }
}
row {
checkBox(GitBundle.message("working.tree.dialog.checkbox.new.branch")).bindSelected(createNewBranch).gap(RightGap.SMALL)
createNewBranch.afterChange { updateSuggestedProjectName() }
textField().bindText(newBranchName).align(Align.FILL).validationOnApply { validateBranchNameOnApply(it) }
.comment(getNewBranchComment())
.enabledIf(createNewBranch)
newBranchName.afterChange { updateSuggestedProjectName() }
}
.bottomGap(BottomGap.MEDIUM)
.layout(RowLayout.LABEL_ALIGNED)
row(GitBundle.message("working.tree.dialog.label.name")) {
projectNameCell = textField().bindText(projectName).align(Align.FILL).validationOnApply { validateProjectNameOnApply(it) }
lastSuggestedProjectName = projectNameCell.component.text
updateSuggestedProjectName()
}
row(GitBundle.message("working.tree.dialog.label.location")) {
val descriptor = FileChooserDescriptorFactory.singleDir()
.withTitle(GitBundle.message("working.tree.dialog.label.location.file.chooser.title"))
parentPathCell = textFieldWithBrowseButton(descriptor, data.project)
.bindText(parentPath).align(Align.FILL).validationOnApply { validateLocationOnApply() }
.comment("")
supportFieldCommentsAndPathValidation()
}
}
}
private fun ValidationInfoBuilder.validateBranchOnApply(box: ComboBox<BranchWithWorkingTree?>): ValidationInfo? {
val value = existingBranchWithWorkingTree.get()
return when {
value == null -> error(GitBundle.message("working.tree.dialog.location.validation.select.branch"))
value.workingTree != null -> {
val item = (box.selectedItem as? BranchWithWorkingTree)!!
error(GitBundle.message("working.tree.dialog.branch.validation.already.checked.out.in.working.tree",
item.branch.name, item.workingTree!!.path.name))
}
else -> null
}
}
private fun ValidationInfoBuilder.validateProjectNameOnApply(field: JBTextField): ValidationInfo? {
return if (field.text.isBlank()) {
error(GitBundle.message("working.tree.dialog.location.validation.provide.name"))
}
else {
null
}
}
private fun ValidationInfoBuilder.validateBranchNameOnApply(field: JBTextField): ValidationInfo? {
return if (field.isEnabled && field.text.isBlank())
error(GitBundle.message("working.tree.dialog.location.validation.provide.new.branch.name"))
else
null
}
private fun ValidationInfoBuilder.validateLocationOnApply(): ValidationInfo? {
if (parentPath.get().isBlank()) return error(GitBundle.message("working.tree.dialog.location.validation.empty"))
val validation = lastPathValidationChannel.tryReceive().getOrNull() ?: return null
if (parentPath.get() == validation.parentPath && projectName.get() == validation.dirName && validation.message != null) {
return error(validation.message)
}
return null
}
private fun supportFieldCommentsAndPathValidation() {
updateParentPathCellComment()
parentPath.afterChange {
updateParentPathCellComment()
precomputePathValidation()
}
projectName.afterChange {
precomputePathValidation()
if (hasErrors(parentPathCell.component.textField)) {
initValidation()
}
}
}
private fun computeBranchesWithWorkingTrees(): List<BranchWithWorkingTree> {
val branches = data.repository.branches
val result = branches.localBranches.sortedBy { it.name }
.map { it.toBranchWithWorkingTree() }.toMutableList()
val remotes = branches.remoteBranches.sortedBy { it.name }
.map { BranchWithWorkingTree(it, null) }
result.addAll(remotes)
return result
}
private fun getNewBranchComment(): @NlsContexts.DetailedDescription String {
val name = existingBranchWithWorkingTree.get()?.branch?.name
return if (name == null) {
GitBundle.message("working.tree.dialog.label.new.branch.detached.comment")
}
else {
GitBundle.message("working.tree.dialog.label.new.branch.comment", name)
}
}
fun updateSuggestedProjectName() {
if (projectNameEdited) return
if (lastSuggestedProjectName != projectNameCell.component.text) {
projectNameEdited = true
return
}
val branchToUse = if (createNewBranch.get()) newBranchName.get() else existingBranchWithWorkingTree.get()?.branch?.name
val newName = createInitialWorkingTreeName(data.projectNameBase, branchToUse)
projectName.set(newName)
lastSuggestedProjectName = newName
}
private fun createInitialWorkingTreeName(root: Path, branchName: String?): String {
return if (branchName.isNullOrEmpty()) {
""
}
else {
root.name + "-" + branchName.substringAfterLast("/")
}
}
private fun updateParentPathCellComment() {
parentPathCell.comment?.text = GitBundle.message("working.tree.dialog.label.location.comment", getPresentablePath(parentPath.get()))
}
private class BranchWithTreeCellRenderer(project: Project, repository: GitRepository) :
ColoredListCellRenderer<BranchWithWorkingTree?>() {
private val repositoryModel = GitWorkingTreesService.getInstance(project).repositoryToModel(repository)
override fun customizeCellRenderer(
list: JList<out BranchWithWorkingTree?>,
value: BranchWithWorkingTree?,
index: Int,
selected: Boolean,
hasFocus: Boolean,
) {
if (value == null) {
append(GitBundle.message("working.tree.dialog.existing.branch.combo.box.empty.text"))
return
}
val branch = value.branch
append(branch.name)
val isCurrent = repositoryModel?.state?.isCurrentRef(branch) ?: false
val isFavorite = repositoryModel?.favoriteRefs?.contains(branch) ?: false
icon = GitBranchesTreeIconProvider.forRef(branch, current = isCurrent, favorite = isFavorite,
favoriteToggleOnClick = false, selected = selected)
val workingTreeName = value.workingTree?.path?.name ?: return
append(" ")
append(workingTreeName, SimpleTextAttributes.GRAYED_ATTRIBUTES)
}
}
fun getWorkTreeData(): GitWorkingTreeDialogData {
val path = VcsUtil.getFilePath(Paths.get(parentPath.get()).resolve(projectName.get()), true)
return if (createNewBranch.get()) {
GitWorkingTreeDialogData.createForNewBranch(path, existingBranchWithWorkingTree.get()!!.branch, newBranchName.get())
}
else {
GitWorkingTreeDialogData.createForExistingBranch(path, existingBranchWithWorkingTree.get()!!.branch)
}
}
private fun precomputePathValidation() {
val parentPath = parentPath.get()
val dirName = projectName.get()
uiScope.launch(Dispatchers.IO + ModalityState.current().asContextElement()) {
val message = getPathValidationMessage(parentPath, dirName)
try {
lastPathValidationChannel.send(PathValidationMessage(parentPath, dirName, message))
}
catch (e: ClosedSendChannelException) {
LOG.error(e)
}
}
}
private data class PathValidationMessage(val parentPath: String, val dirName: String, val message: @NlsContexts.DialogMessage String?)
override fun getDimensionServiceKey(): String = "Git.CreateWorkingTreeDialog"
override fun getInitialSize(): Dimension {
return Dimension(JBUI.DialogSizes.medium().width, -1)
}
companion object {
private val LOG: Logger = logger<GitWorkingTreeDialog>()
@VisibleForTesting
internal fun getPathValidationMessage(parentPath: String, dirName: String): @NlsContexts.DialogMessage String? {
if (dirName.isBlank()) return null
if (parentPath.isBlank()) return GitBundle.message("working.tree.dialog.location.validation.empty")
val fullPath = Paths.get(parentPath).resolve(dirName)
return when {
!fullPath.exists() -> null
!fullPath.isDirectory() -> GitBundle.message("working.tree.dialog.location.validation.is.a.file",
getPresentablePath(fullPath.toString()))
fullPath.listDirectoryEntries().isNotEmpty() -> GitBundle.message("working.tree.dialog.location.validation.is.not.empty",
getPresentablePath(fullPath.toString()))
else -> null
}
}
}
}
@@ -0,0 +1,20 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.actions.workingTree
import com.intellij.openapi.vcs.FilePath
import git4idea.GitBranch
internal class GitWorkingTreeDialogData private constructor(
val workingTreePath: FilePath,
val sourceBranch: GitBranch,
val newBranchName: String?,
) {
companion object {
fun createForNewBranch(workingTreePath: FilePath, sourceBranch: GitBranch, newBranchName: String) =
GitWorkingTreeDialogData(workingTreePath, sourceBranch, newBranchName)
fun createForExistingBranch(workingTreePath: FilePath, sourceBranch: GitBranch) =
GitWorkingTreeDialogData(workingTreePath, sourceBranch, null)
}
}
@@ -0,0 +1,19 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.actions.workingTree
import com.intellij.internal.statistic.StructuredIdeActivity
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import git4idea.GitStandardLocalBranch
import git4idea.repo.GitRepository
import java.nio.file.Path
internal data class GitWorkingTreePreDialogData(
val project: Project,
val repository: GitRepository,
val ideActivity: StructuredIdeActivity,
val initialExistingBranch: GitStandardLocalBranch?,
val initialParentPath: VirtualFile?,
//should be the main repo root in case of working in a worktree
val projectNameBase: Path = repository.repositoryFiles.configFile.toPath().parent.parent,
)
@@ -22,7 +22,7 @@ internal class ShowWorkingTreesAction : DumbAwareAction() {
private fun shouldShow(e: AnActionEvent): Boolean {
val project = e.project ?: return false
if (ToolWindowManager.getInstance(project).getToolWindow(ChangesViewContentManager.TOOLWINDOW_ID) == null) return false
return GitWorkingTreesService.getSingleRepositoryOrNullIfEnabled(project) != null
return GitWorkingTreesService.getRepoForWorkingTreesSupport(project) != null
}
override fun actionPerformed(e: AnActionEvent) {
@@ -1,4 +1,4 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.commands;
import com.intellij.openapi.application.ApplicationManager;
@@ -8,6 +8,8 @@ import com.intellij.openapi.vcs.FilePath;
import com.intellij.openapi.vcs.VcsException;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.vcs.log.Hash;
import git4idea.GitBranch;
import git4idea.GitWorkingTree;
import git4idea.branch.GitRebaseParams;
import git4idea.push.GitPushParams;
import git4idea.repo.GitRemote;
@@ -340,4 +342,16 @@ public interface Git {
@Nullable
GitObjectType getObjectTypeEnum(@NotNull GitRepository repository, @NotNull String object);
@NotNull
GitCommandResult deleteWorkingTree(@NotNull Project project, @NotNull GitWorkingTree tree);
@NotNull
GitCommandResult listWorktrees(@NotNull GitRepository repository, GitLineHandlerListener @NotNull ... listeners);
@NotNull
GitCommandResult createWorkingTree(@NotNull GitRepository repository,
@NotNull FilePath workingTreePath,
@NotNull GitBranch sourceBranch,
@Nullable String newBranchName);
}
@@ -1,4 +1,4 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.commands;
import com.google.common.annotations.VisibleForTesting;
@@ -26,8 +26,10 @@ import com.intellij.vcs.log.Hash;
import com.intellij.vcs.log.impl.HashImpl;
import com.intellij.vcsUtil.VcsFileUtil;
import com.intellij.vcsUtil.VcsUtil;
import git4idea.GitBranch;
import git4idea.GitContentRevision;
import git4idea.GitUtil;
import git4idea.GitWorkingTree;
import git4idea.branch.GitRebaseParams;
import git4idea.config.GitConfigUtil;
import git4idea.config.GitExecutable;
@@ -47,6 +49,7 @@ import org.jetbrains.annotations.Unmodifiable;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import static git4idea.GitUtil.COMMENT_CHAR;
@@ -842,6 +845,43 @@ public class GitImpl extends GitImplBase {
}
}
@Override
public @NotNull GitCommandResult deleteWorkingTree(@NotNull Project project, @NotNull GitWorkingTree tree) {
GitLineHandler handler = new GitLineHandler(project, Paths.get(tree.getPath().getPath()), GitCommand.WORKTREE);
handler.setSilent(false);
handler.setStdoutSuppressed(false);
handler.setStderrSuppressed(false);
handler.addParameters(asList("remove", tree.getPath().getPath(), "--force"));
return Git.getInstance().runCommand(handler);
}
@Override
public @NotNull GitCommandResult listWorktrees(@NotNull GitRepository repository, GitLineHandlerListener @NotNull ... listeners) {
GitLineHandler handler = new GitLineHandler(repository.getProject(), repository.getRoot(), GitCommand.WORKTREE);
handler.addParameters("list");
handler.addParameters("--porcelain");
addListeners(handler, listeners);
return Git.getInstance().runCommand(handler);
}
@Override
public @NotNull GitCommandResult createWorkingTree(@NotNull GitRepository repository,
@NotNull FilePath workingTreePath,
@NotNull GitBranch sourceBranch,
@Nullable String newBranchName) {
GitLineHandler handler = new GitLineHandler(repository.getProject(), repository.getRoot(), GitCommand.WORKTREE);
handler.setSilent(false);
handler.setStdoutSuppressed(false);
handler.setStderrSuppressed(false);
handler.addParameters("add");
if (newBranchName != null) {
handler.addParameters("-b", newBranchName);
}
handler.addParameters(workingTreePath.getPath(), sourceBranch.getName());
return Git.getInstance().runCommand(handler);
}
private static void addListeners(@NotNull GitLineHandler handler, GitLineHandlerListener @NotNull ... listeners) {
addListeners(handler, asList(listeners));
}
@@ -1,13 +1,13 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.repo
import com.intellij.openapi.diagnostic.debug
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.progress.util.BackgroundTaskUtil
import git4idea.GitWorkingTree
import git4idea.commands.Git
import git4idea.remoteApi.GitRepositoryFrontendSynchronizer
import git4idea.workingTrees.GitListWorktreeLineListener
import git4idea.workingTrees.GitWorkingTreesCommandService
class GitWorkingTreeHolder(repository: GitRepository) : GitRepositoryDataHolder(repository, "GitWorkingTreeHolder") {
private var workingTrees: Collection<GitWorkingTree> = emptyList()
@@ -25,7 +25,7 @@ class GitWorkingTreeHolder(repository: GitRepository) : GitRepositoryDataHolder(
LOG.debug { "Reloading working trees for ${repository.root}" }
val listener = GitListWorktreeLineListener(repository)
val commandResult = GitWorkingTreesCommandService.getInstance().listWorktrees(repository, listener)
val commandResult = Git.getInstance().listWorktrees(repository, listener)
if (!commandResult.success()) {
LOG.info("Failed to list worktrees: $commandResult.errorOutputAsJoinedString")
}
@@ -1,35 +0,0 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.workingTrees
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import git4idea.GitWorkingTree
import git4idea.commands.*
import git4idea.repo.GitRepository
import kotlin.io.path.Path
@Service
internal class GitWorkingTreesCommandService {
companion object {
fun getInstance(): GitWorkingTreesCommandService = service<GitWorkingTreesCommandService>()
}
fun deleteWorkingTree(project: Project, tree: GitWorkingTree): GitCommandResult {
val handler = GitLineHandler(project, Path(tree.path.path), GitCommand.WORKTREE)
handler.addParameters(listOf("remove", tree.path.path, "--force"))
return Git.getInstance().runCommand(handler)
}
fun listWorktrees(repository: GitRepository, vararg listeners: GitLineHandlerListener): GitCommandResult {
val handler = GitLineHandler(repository.project, repository.root, GitCommand.WORKTREE)
handler.addParameters("list")
handler.addParameters("--porcelain")
for (listener in listeners) {
handler.addLineListener(listener)
}
return Git.getInstance().runCommand(handler)
}
}
@@ -1,9 +1,19 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.workingTrees
import com.intellij.dvcs.repo.repositoryId
import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.components.Service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.NlsContexts
import com.intellij.openapi.util.NlsSafe
import com.intellij.platform.ide.progress.withBackgroundProgress
import com.intellij.vcs.git.repo.GitRepositoriesHolder
import com.intellij.vcs.git.repo.GitRepositoryModel
import git4idea.GitRemoteBranch
import git4idea.actions.workingTree.GitWorkingTreeDialogData
import git4idea.commands.Git
import git4idea.i18n.GitBundle
import git4idea.repo.GitRepository
import git4idea.repo.GitRepositoryManager
import kotlinx.coroutines.CoroutineScope
@@ -19,7 +29,7 @@ internal class GitWorkingTreesService(private val project: Project, val coroutin
/**
* So far only the `single repository` case is supported for working trees
*/
fun getSingleRepositoryOrNullIfEnabled(project: Project?): GitRepository? {
fun getRepoForWorkingTreesSupport(project: Project?): GitRepository? {
if (project == null) return null
if (!GitWorkingTreesUtil.isWorkingTreesFeatureEnabled()) return null
val repositories = GitRepositoryManager.getInstance(project).repositories
@@ -27,8 +37,12 @@ internal class GitWorkingTreesService(private val project: Project, val coroutin
}
}
fun repositoryToModel(repository: GitRepository): GitRepositoryModel? {
return GitRepositoriesHolder.getInstance(project).get(repository.repositoryId())
}
fun shouldWorkingTreesTabBeShown(): Boolean {
if (getSingleRepositoryOrNullIfEnabled(project) == null) {
if (getRepoForWorkingTreesSupport(project) == null) {
return false
}
return !PropertiesComponent.getInstance(project).getBoolean(WORKING_TREE_TAB_CLOSED_BY_USER_PROPERTY, false)
@@ -41,4 +55,34 @@ internal class GitWorkingTreesService(private val project: Project, val coroutin
fun workingTreesTabClosedByUser() {
PropertiesComponent.getInstance(project).setValue(WORKING_TREE_TAB_CLOSED_BY_USER_PROPERTY, true)
}
class Result private constructor(
val success: Boolean,
val errorOutputAsHtmlString: @NlsSafe @NlsContexts.NotificationContent String,
) {
companion object {
val SUCCESS = Result(true, "")
fun createFailure(@NlsContexts.NotificationContent errorOutputAsHtmlString: @NlsSafe String): Result {
return Result(false, errorOutputAsHtmlString)
}
}
}
suspend fun createWorkingTree(repository: GitRepository, data: GitWorkingTreeDialogData): Result {
return withBackgroundProgress(project, GitBundle.message("progress.title.creating.worktree"), cancellable = true) {
val newBranchName = when {
data.newBranchName != null -> data.newBranchName
data.sourceBranch is GitRemoteBranch -> data.sourceBranch.nameForRemoteOperations
else -> null
}
val commandResult = Git.getInstance().createWorkingTree(repository, data.workingTreePath, data.sourceBranch, newBranchName)
if (commandResult.success()) {
Result.SUCCESS
}
else {
Result.createFailure(commandResult.errorOutputAsHtmlString)
}
}
}
}
@@ -1,4 +1,4 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.workingTrees
import com.intellij.openapi.util.registry.Registry
@@ -3,7 +3,6 @@ package git4idea.workingTrees.ui
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.*
import com.intellij.openapi.actionSystem.ActionPlaces.CHANGES_VIEW_TOOLBAR
import com.intellij.openapi.actionSystem.toolbarLayout.ToolbarLayoutStrategy
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.extensions.ExtensionNotApplicableException
@@ -36,6 +35,12 @@ import javax.swing.JList
import javax.swing.SwingConstants
internal class GitWorkingTreesContentProvider(private val project: Project) : ChangesViewContentProvider {
companion object {
//registered with com.intellij.statistics.actionCustomPlaceAllowlist ExtensionPoint
internal const val GIT_WORKING_TREE_TOOLWINDOW_TAB_TOOLBAR: String = "GitWorkingTreeToolWindowTabToolbar"
}
override fun initTabContent(content: Content) {
content.component = GitWorkingTreesUi()
}
@@ -46,8 +51,10 @@ internal class GitWorkingTreesContentProvider(private val project: Project) : Ch
val scrollPane = ScrollPaneFactory.createScrollPane(list, true)
addToCenter(scrollPane)
val toolbarActionGroup = ActionManager.getInstance().getAction("Git.WorkingTrees.ToolwindowGroup.Toolbar") as ActionGroup
val toolbar = ActionManager.getInstance().createActionToolbar(CHANGES_VIEW_TOOLBAR, toolbarActionGroup, false)
val actionManager = ActionManager.getInstance()
val toolbarActionGroup = actionManager.getAction("Git.WorkingTrees.ToolwindowGroup.Toolbar") as ActionGroup
val toolbar = actionManager.createActionToolbar(GIT_WORKING_TREE_TOOLWINDOW_TAB_TOOLBAR,
toolbarActionGroup, false)
toolbar.setTargetComponent(list)
toolbar.layoutStrategy = ToolbarLayoutStrategy.AUTOLAYOUT_STRATEGY
toolbar.setOrientation(SwingConstants.VERTICAL)
@@ -103,7 +110,7 @@ internal class GitWorkingTreesContentProvider(private val project: Project) : Ch
fun reload(project: Project) {
clear()
val currentRepository = GitWorkingTreesService.getSingleRepositoryOrNullIfEnabled(project)
val currentRepository = GitWorkingTreesService.getRepoForWorkingTreesSupport(project)
repository = currentRepository
val workingTrees = currentRepository?.workingTreeHolder?.getWorkingTrees()
if (workingTrees != null && workingTrees.size > 1) {
@@ -15,9 +15,9 @@ import git4idea.GitNotificationIdsHolder
import git4idea.GitWorkingTree
import git4idea.actions.workingTree.GitWorkingTreeTabActionsDataKeys
import git4idea.actions.workingTree.GitWorkingTreeTabActionsDataKeys.SELECTED_WORKING_TREES
import git4idea.commands.Git
import git4idea.i18n.GitBundle
import git4idea.repo.GitRepository
import git4idea.workingTrees.GitWorkingTreesCommandService
import git4idea.workingTrees.GitWorkingTreesService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -71,7 +71,7 @@ internal class RemoveWorkingTreeAction : DumbAwareAction() {
private suspend fun delete(project: Project, tree: GitWorkingTree, repository: GitRepository) {
val commandResult = withBackgroundProgress(project, GitBundle.message("progress.title.deleting.worktree"), cancellable = true) {
service<GitWorkingTreesCommandService>().deleteWorkingTree(project, tree)
service<Git>().deleteWorkingTree(project, tree)
}
if (commandResult.success()) {
repository.workingTreeHolder.reload()
@@ -1,58 +0,0 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.repo
import com.intellij.ide.impl.OpenProjectTask
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.ex.ProjectManagerEx
import com.intellij.openapi.vcs.Executor.cd
import git4idea.GitBranch
import git4idea.GitWorkingTree
import git4idea.test.git
import git4idea.test.initRepo
import git4idea.test.registerRepo
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
class GitLinkedWorktreeTest : GitRepositoriesFrontendHolderTestBase() {
private val branchName = "feature"
private val mainRepoFileName = "mainRepo"
val mainRepoPath: Path
get() = testNioRoot.resolve(mainRepoFileName)
override fun doCreateAndOpenProject(): Project {
val projectRootPath = getProjectDirOrFile(true)
return setUpProjectAndWorkingTree(testNioRoot, projectRootPath, mainRepoFileName, branchName)
}
override fun createRepository(): GitRepository {
return registerRepo(project, projectNioRoot)
}
fun `test creating a worktree on a main repo`() {
doTestWorkingTreeCreation(
mainRepoPath,
GitWorkingTree(mainRepoPath.toString(), "refs/heads/master", true, false),
GitWorkingTree(repo.root.path, GitBranch.REFS_HEADS_PREFIX + branchName, false, true),
)
}
companion object {
fun setUpProjectAndWorkingTree(
testNioRoot: Path,
projectRootPath: Path,
mainRepoRelativePath: String,
branchName: String,
): Project {
val mainRepoPath = testNioRoot.resolve(mainRepoRelativePath)
initRepo(null, mainRepoPath, true)
cd(mainRepoPath)
git(null, "worktree add -B $branchName ../${projectRootPath.fileName}")
Files.createDirectories(projectRootPath.resolve(Project.DIRECTORY_STORE_FOLDER))
return runBlocking {
ProjectManagerEx.getInstanceEx().openProjectAsync(projectIdentityFile = projectRootPath, options = OpenProjectTask {})!!
}
}
}
}
@@ -1,27 +1,80 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.repo
import com.intellij.dvcs.repo.repositoryId
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.platform.project.projectId
import com.intellij.testFramework.assertErrorLogged
import com.intellij.vcs.git.repo.GitRepositoriesHolder
import com.intellij.vcs.git.repo.GitRepositoryModel
import com.intellij.vcs.git.rpc.GitRepositoryApi
import com.intellij.vcs.git.rpc.GitUiSettingsApi
import com.intellij.vcsUtil.VcsUtil.getFilePath
import git4idea.GitStandardLocalBranch
import git4idea.GitTag
import git4idea.GitWorkingTree
import git4idea.branch.GitBranchType
import git4idea.branch.GitBranchesCollection
import git4idea.test.GitSingleRepoTest
import git4idea.test.checkoutNew
import git4idea.test.createSubRepository
import git4idea.test.git
import git4idea.ui.branch.GitBranchManager
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class)
class GitRepositoriesFrontendHolderTest : GitRepositoriesFrontendHolderTestBase() {
class GitRepositoriesFrontendHolderTest : GitSingleRepoTest() {
class GitRepositoriesHolderEventHandler {
private val updatesChanel = Channel<GitRepositoriesHolder.UpdateType>(capacity = 1000, onBufferOverflow = BufferOverflow.DROP_OLDEST)
fun subscribe(project: Project) {
project.messageBus.connect().subscribe(GitRepositoriesHolder.UPDATES,
GitRepositoriesHolder.UpdatesListener { updateType -> updatesChanel.trySend(updateType) })
}
fun executeAndExpectEvent(
operation: suspend () -> Unit,
condition: (currentEvent: GitRepositoriesHolder.UpdateType, previousEvents: List<GitRepositoriesHolder.UpdateType>) -> Boolean,
) {
runBlocking {
skipEvents()
operation()
val collected = mutableListOf<GitRepositoriesHolder.UpdateType>()
withTimeout(5.seconds) {
updatesChanel.consumeAsFlow().first {
LOG.info("Received update: $it")
collected.add(it)
condition(it, collected)
}
}
}
}
private suspend fun skipEvents() {
withTimeout(1.seconds) {
while (!updatesChanel.isEmpty) {
updatesChanel.tryReceive()
}
}
}
}
private val eventHandler = GitRepositoriesHolderEventHandler()
override fun getDebugLogCategories() = super.getDebugLogCategories().plus(GitRepositoriesHolder::class.java.name)
override fun setUp() {
super.setUp()
eventHandler.subscribe(project)
}
fun `test single repository data is available`() {
val holder = GitRepositoriesHolder.getInstance(project)
@@ -50,7 +103,7 @@ class GitRepositoriesFrontendHolderTest : GitRepositoriesFrontendHolderTestBase(
holder.init()
}
assertSize(1, holder.getAll())
executeAndExpectEvent(
eventHandler.executeAndExpectEvent(
{ vcsManager.unregisterVcs(vcs) },
{ event, _ -> event == GitRepositoriesHolder.UpdateType.REPOSITORY_DELETED })
@@ -64,7 +117,7 @@ class GitRepositoriesFrontendHolderTest : GitRepositoriesFrontendHolderTestBase(
}
assertSize(1, holder.getAll())
executeAndExpectEvent(
eventHandler.executeAndExpectEvent(
{ repo.createSubRepository("nested") },
{ event, _ -> event == GitRepositoriesHolder.UpdateType.REPOSITORY_CREATED })
@@ -80,7 +133,7 @@ class GitRepositoriesFrontendHolderTest : GitRepositoriesFrontendHolderTestBase(
assertTrue("master should be favorite",
holder.getTestRepo().favoriteRefs.contains(GitStandardLocalBranch("master")))
executeAndExpectEvent(
eventHandler.executeAndExpectEvent(
{
project.service<GitBranchManager>().setFavorite(
GitBranchType.LOCAL,
@@ -108,7 +161,7 @@ class GitRepositoriesFrontendHolderTest : GitRepositoriesFrontendHolderTestBase(
branchesToCheckout.forEach { branch -> repo.checkoutNew(branch.name) }
val newCurrentBranch = GitStandardLocalBranch("new-branch")
executeAndExpectEvent(
eventHandler.executeAndExpectEvent(
{ repo.checkoutNew(newCurrentBranch.name) },
{ event, _ -> event == GitRepositoriesHolder.UpdateType.REPOSITORY_STATE_UPDATED })
@@ -132,7 +185,7 @@ class GitRepositoriesFrontendHolderTest : GitRepositoriesFrontendHolderTestBase(
assertEquals(setOf(GitTag(tagName)), holder.getTestRepo().state.tags)
executeAndExpectEvent(
eventHandler.executeAndExpectEvent(
{ GitUiSettingsApi.getInstance().setShowTags(project.projectId(), false) },
{ event, _ -> event == GitRepositoriesHolder.UpdateType.TAGS_HIDDEN }
)
@@ -150,7 +203,7 @@ class GitRepositoriesFrontendHolderTest : GitRepositoriesFrontendHolderTestBase(
holder.init()
}
executeAndExpectEvent(
eventHandler.executeAndExpectEvent(
{
GitUiSettingsApi.getInstance().setShowTags(project.projectId(), false)
GitUiSettingsApi.getInstance().setShowTags(project.projectId(), true)
@@ -169,7 +222,7 @@ class GitRepositoriesFrontendHolderTest : GitRepositoriesFrontendHolderTestBase(
holder.init()
}
executeAndExpectEvent(
eventHandler.executeAndExpectEvent(
{ GitRepositoryApi.getInstance().forceSync(project.projectId()) },
{ event, _ -> event == GitRepositoriesHolder.UpdateType.RELOAD_STATE }
)
@@ -181,7 +234,7 @@ class GitRepositoriesFrontendHolderTest : GitRepositoriesFrontendHolderTestBase(
holder.init()
}
executeAndExpectEvent(
eventHandler.executeAndExpectEvent(
{
holder.clearRepositories()
assertTrue(holder.getAll().isEmpty())
@@ -193,10 +246,9 @@ class GitRepositoriesFrontendHolderTest : GitRepositoriesFrontendHolderTestBase(
holder.getTestRepo()
}
fun `test creating a worktree on a main repo`() {
doTestWorkingTreeCreation(
projectNioRoot,
GitWorkingTree(repo.root.path, repo.currentBranch!!.fullName, true, true)
)
private fun GitRepositoriesHolder.getTestRepo(): GitRepositoryModel {
val holderRepo = checkNotNull(get(repo.repositoryId()))
assertEquals(holderRepo.root, getFilePath(repo.root))
return holderRepo
}
}
@@ -1,98 +0,0 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.repo
import com.intellij.dvcs.repo.repositoryId
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.vcs.git.repo.GitRepositoriesHolder
import com.intellij.vcs.git.repo.GitRepositoryModel
import com.intellij.vcsUtil.VcsUtil.getFilePath
import git4idea.GitWorkingTree
import git4idea.test.GitSingleRepoTest
import git4idea.test.git
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import java.nio.file.Path
import kotlin.io.path.pathString
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class)
abstract class GitRepositoriesFrontendHolderTestBase : GitSingleRepoTest() {
private val updatesChanel = Channel<GitRepositoriesHolder.UpdateType>(capacity = 1000, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override fun getDebugLogCategories() = super.getDebugLogCategories().plus(GitRepositoriesHolder::class.java.name)
override fun setUp() {
super.setUp()
project.messageBus.connect().subscribe(GitRepositoriesHolder.UPDATES,
GitRepositoriesHolder.UpdatesListener { updateType -> updatesChanel.trySend(updateType) })
}
protected fun GitRepositoriesHolder.getTestRepo(): GitRepositoryModel {
val holderRepo = checkNotNull(get(repo.repositoryId()))
assertEquals(holderRepo.root, getFilePath(repo.root))
return holderRepo
}
protected fun executeAndExpectEvent(
operation: suspend () -> Unit,
condition: (currentEvent: GitRepositoriesHolder.UpdateType, previousEvents: List<GitRepositoriesHolder.UpdateType>) -> Boolean,
) {
runBlocking {
skipEvents()
operation()
val collected = mutableListOf<GitRepositoriesHolder.UpdateType>()
withTimeout(5.seconds) {
updatesChanel.consumeAsFlow().first {
LOG.info("Received update: $it")
collected.add(it)
condition(it, collected)
}
}
}
}
private suspend fun skipEvents() {
withTimeout(1.seconds) {
while (!updatesChanel.isEmpty) {
updatesChanel.tryReceive()
}
}
}
protected fun doTestWorkingTreeCreation(mainDirectoryRepoPath: Path, vararg initialExpectedWorkingTrees: GitWorkingTree) {
val holder = GitRepositoriesHolder.getInstance(project)
runBlocking {
holder.init()
}
assertSameElements(holder.getTestRepo().state.workingTrees, initialExpectedWorkingTrees.toList())
val branch = "tree"
val treeRoot = "treeRoot"
executeAndExpectEvent(
{
repo.git("worktree add -B $branch ../$treeRoot")
val worktreesDir = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(mainDirectoryRepoPath.resolve(".git/worktrees"))
refresh(worktreesDir!!)
},
{ event, _ ->
return@executeAndExpectEvent event == GitRepositoriesHolder.UpdateType.WORKING_TREES_LOADED
}
)
val workingTrees = holder.getTestRepo().state.workingTrees
val expected = initialExpectedWorkingTrees.toMutableList()
expected.add(
GitWorkingTree("${testNioRoot.pathString}/$treeRoot", GitRefUtil.addRefsHeadsPrefixIfNeeded(branch)!!, false, false)
)
assertSameElements(workingTrees, expected)
}
}
@@ -1,20 +1,44 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.workingTrees
import com.intellij.openapi.ui.getPresentablePath
import com.intellij.openapi.vcs.LocalFilePath
import com.intellij.vcs.git.repo.GitRepositoriesHolder
import git4idea.GitLocalBranch
import git4idea.GitWorkingTree
import git4idea.repo.GitRepository
import git4idea.actions.workingTree.GitWorkingTreeDialog
import git4idea.commands.Git
import git4idea.commands.GitCommand
import git4idea.commands.GitLineHandler
import git4idea.test.GitSingleRepoTest
import git4idea.test.branch
import git4idea.test.registerRepo
import git4idea.workingTrees.GitWorkingTreeTestBase.Companion.createBranch
import kotlinx.coroutines.runBlocking
import java.io.File
import java.nio.file.Paths
import kotlin.io.path.pathString
class GitCreateWorkingTreeTest : GitSingleRepoTest() {
fun `test create worktree validation in non-empty directory`() {
val workingTreeDir = "worktreeDir"
val parent = File(repo.root.path, workingTreeDir)
assertTrue(parent.mkdir())
val childFile = parent.resolve("a.txt")
assertTrue(childFile.createNewFile())
val validationMessage = GitWorkingTreeDialog.getPathValidationMessage(repo.root.path, workingTreeDir)
assertEquals("'${getPresentablePath(parent.path)}' is not empty", validationMessage)
}
fun `test create worktree validation in invalid directory`() {
val workingTreeDir = "worktreeDir"
val parent = File(repo.root.path, workingTreeDir)
assertTrue(parent.createNewFile())
val validationMessage = GitWorkingTreeDialog.getPathValidationMessage(repo.root.path, workingTreeDir)
assertEquals("'${getPresentablePath(parent.path)}' is a file", validationMessage)
}
fun `test listing detached working tree`() {
val treeRoot = "rootOfTree"
val branch = "branch"
@@ -27,8 +51,19 @@ class GitCreateWorkingTreeTest : GitSingleRepoTest() {
}
val workingTreePath = LocalFilePath(testNioRoot.resolve(treeRoot).toString(), true)
val output = git("worktree add ../$treeRoot $branchParameterForWorkingTree")
assertTrue(output.contains("Preparing worktree (detached HEAD"))
val handler = GitLineHandler(repo.getProject(), repo.getRoot(), GitCommand.WORKTREE)
handler.addParameters("add", workingTreePath.path, branchParameterForWorkingTree)
handler.setSilent(false)
@Suppress("UsePropertyAccessSyntax")
handler.apply {
setStdoutSuppressed(false)
setStderrSuppressed(false)
}
val result = Git.getInstance().runCommand(handler)
val errorOutput = result.errorOutputAsJoinedString
assertTrue(errorOutput, result.success())
assertTrue(errorOutput, errorOutput.contains("Preparing worktree (detached HEAD"))
val workingTreeRepo = registerRepo(project, Paths.get(workingTreePath.path))
assertNull("Current branch is should be null, got ${workingTreeRepo.currentBranchName} instead",
@@ -47,14 +82,4 @@ class GitCreateWorkingTreeTest : GitSingleRepoTest() {
assertSameElements(workingTrees, expected)
}
companion object {
fun createBranch(repo: GitRepository, branchName: String): GitLocalBranch {
repo.branch(branchName)
repo.update()
val newBranch = repo.branches.findLocalBranch(branchName)
assertNotNull(newBranch)
return newBranch!!
}
}
}
@@ -0,0 +1,84 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.workingTrees
import com.intellij.ide.impl.OpenProjectTask
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.ex.ProjectManagerEx
import com.intellij.openapi.vcs.Executor.touch
import com.intellij.openapi.vcs.LocalFilePath
import git4idea.GitWorkingTree
import git4idea.actions.workingTree.GitWorkingTreeDialogData
import git4idea.repo.GitRefUtil
import git4idea.repo.GitRepository
import git4idea.test.cloneRepo
import git4idea.test.git
import git4idea.test.initRepo
import git4idea.test.registerRepo
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
internal class GitWorkingTreeFromRemoteBranchesTest : GitWorkingTreeTestBase() {
val remoteBranchName = "remoteBranch"
val remoteRepoRelativePath = "remoteRepo"
lateinit var lastCommitInRemoteBranch: String
override val mainRepoPath: Path
get() = projectNioRoot
override fun doCreateAndOpenProject(): Project {
val remoteRepoPath = testNioRoot.resolve(remoteRepoRelativePath)
initRepo(null, remoteRepoPath, true)
val file = "a.txt"
touch(file, "content" + Math.random())
git(null, "add $file")
git(null, "commit -m initial")
lastCommitInRemoteBranch = git(null, "log -1 --pretty=%H")
git(null, "branch $remoteBranchName")
val projectRootPath = getProjectDirOrFile(true)
cloneRepo(remoteRepoPath.toString(), projectRootPath.toString(), false)
Files.createDirectories(projectRootPath.resolve(Project.DIRECTORY_STORE_FOLDER))
return runBlocking {
ProjectManagerEx.getInstanceEx().openProjectAsync(projectIdentityFile = projectRootPath, options = OpenProjectTask {})!!
}
}
override fun createRepository(): GitRepository {
return registerRepo(project, projectNioRoot)
}
override fun getExpectedDefaultWorkingTrees(): List<GitWorkingTree> {
return listOf(
GitWorkingTree(repo.toString(), "refs/heads/master", true, true)
)
}
fun `test creating a worktree from remote branch`() {
doTestWorkingTreeFromRemoteBranchCreation(false)
}
fun `test creating a worktree from remote branch with custom name`() {
doTestWorkingTreeFromRemoteBranchCreation(true)
}
private fun doTestWorkingTreeFromRemoteBranchCreation(withNewBranch: Boolean) {
val remoteBranch = repo.branches.findRemoteBranch("origin/$remoteBranchName")!!
val workingTreeDataPath = LocalFilePath(testNioRoot.resolve("treeRoot"), true)
val data = if (withNewBranch) {
GitWorkingTreeDialogData.createForNewBranch(workingTreeDataPath, remoteBranch, remoteBranchName)
}
else {
GitWorkingTreeDialogData.createForExistingBranch(workingTreeDataPath, remoteBranch)
}
doTestWorkingTreeCreation(data,
GitWorkingTree(data.workingTreePath.path,
GitRefUtil.addRefsHeadsPrefixIfNeeded(remoteBranchName)!!,
false, false),
remoteBranchName,
lastCommitInRemoteBranch)
}
}
@@ -1,19 +1,33 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.workingTrees
import com.intellij.ide.impl.OpenProjectTask
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.ex.ProjectManagerEx
import com.intellij.openapi.vcs.Executor.cd
import com.intellij.openapi.vcs.LocalFilePath
import com.intellij.openapi.vfs.LocalFileSystem
import git4idea.GitBranch
import git4idea.GitWorkingTree
import git4idea.repo.GitLinkedWorktreeTest
import git4idea.actions.workingTree.GitWorkingTreeDialogData
import git4idea.commands.Git
import git4idea.repo.GitRefUtil
import git4idea.repo.GitRepository
import git4idea.test.GitSingleRepoTest
import git4idea.test.git
import git4idea.test.initRepo
import git4idea.test.registerRepo
import git4idea.test.tac
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.pathString
abstract class GitWorkingTreeTest : GitSingleRepoTest() {
internal abstract class GitWorkingTreeTest : GitWorkingTreeTestBase() {
class GitWorkingTreeOnMainRepoTest : GitWorkingTreeTest() {
override val mainRepoPath: Path
get() = repo.root.toNioPath()
override fun getExpectedDefaultWorkingTrees(): List<GitWorkingTree> {
return listOf(GitWorkingTree(repo.root.path, repo.currentBranch!!.fullName, true, true))
}
@@ -24,12 +38,19 @@ abstract class GitWorkingTreeTest : GitSingleRepoTest() {
val branchName = "feature"
val mainRepoRelativePath = "mainRepo"
val mainRepoPath: Path
override val mainRepoPath: Path
get() = testNioRoot.resolve(mainRepoRelativePath)
override fun doCreateAndOpenProject(): Project {
val projectRootPath = getProjectDirOrFile(true)
return GitLinkedWorktreeTest.setUpProjectAndWorkingTree(testNioRoot, projectRootPath, mainRepoRelativePath, branchName)
initRepo(null, mainRepoPath, true)
cd(mainRepoPath)
git(null, "worktree add -B $branchName ../${projectRootPath.fileName}")
Files.createDirectories(projectRootPath.resolve(Project.DIRECTORY_STORE_FOLDER))
return runBlocking {
ProjectManagerEx.getInstanceEx().openProjectAsync(projectIdentityFile = projectRootPath, options = OpenProjectTask {})!!
}
}
override fun createRepository(): GitRepository {
@@ -44,9 +65,6 @@ abstract class GitWorkingTreeTest : GitSingleRepoTest() {
}
}
abstract fun getExpectedDefaultWorkingTrees(): List<GitWorkingTree>
fun `test listing working trees`() {
val trees = listTrees()
val expected = getExpectedDefaultWorkingTrees()
@@ -68,7 +86,7 @@ abstract class GitWorkingTreeTest : GitSingleRepoTest() {
val workingTree = createdWorkingTrees.firstOrNull { it.path.path.endsWith(treeRoot) }
assertNotNull(workingTree)
GitWorkingTreesCommandService.getInstance().deleteWorkingTree(project, workingTree!!)
Git.getInstance().deleteWorkingTree(project, workingTree!!)
val removedWorkingTree = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(newWorkingTreeRootPath)
assertNull(removedWorkingTree)
@@ -78,9 +96,39 @@ abstract class GitWorkingTreeTest : GitSingleRepoTest() {
fun listTrees(): List<GitWorkingTree> {
val listener = GitListWorktreeLineListener(repo)
val commandResult = GitWorkingTreesCommandService.getInstance().listWorktrees(repo, listener)
val commandResult = Git.getInstance().listWorktrees(repo, listener)
commandResult.throwOnError()
return listener.trees
}
fun `test creating a worktree with new branch`() {
doTestWorkingTreeCreation(workingTreeWithNewBranch = true)
}
fun `test creating a worktree with existing branch`() {
doTestWorkingTreeCreation(workingTreeWithNewBranch = false)
}
protected fun doTestWorkingTreeCreation(
workingTreeWithNewBranch: Boolean,
treeRoot: String = "treeRoot",
branchName: String = "tree",
expectedWorkingTree: GitWorkingTree = GitWorkingTree("${testNioRoot.pathString}/$treeRoot",
GitRefUtil.addRefsHeadsPrefixIfNeeded("tree")!!,
false, false),
) {
val commit = tac("a.txt")
val workingTreeDataPath = LocalFilePath(testNioRoot.resolve(treeRoot), true)
val data = if (workingTreeWithNewBranch) {
val localBranch = createBranch(repo, "initial-$branchName")
GitWorkingTreeDialogData.createForNewBranch(workingTreeDataPath, localBranch, branchName)
}
else {
val localBranch = createBranch(repo, branchName)
GitWorkingTreeDialogData.createForExistingBranch(workingTreeDataPath, localBranch)
}
doTestWorkingTreeCreation(data, expectedWorkingTree, branchName, commit)
}
}
@@ -0,0 +1,77 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.workingTrees
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.vcs.git.repo.GitRepositoriesHolder
import git4idea.GitLocalBranch
import git4idea.GitWorkingTree
import git4idea.actions.workingTree.GitWorkingTreeDialogData
import git4idea.repo.GitRepositoriesFrontendHolderTest
import git4idea.repo.GitRepository
import git4idea.test.GitSingleRepoTest
import git4idea.test.branch
import git4idea.test.registerRepo
import kotlinx.coroutines.runBlocking
import java.nio.file.Path
import java.nio.file.Paths
internal abstract class GitWorkingTreeTestBase : GitSingleRepoTest() {
abstract fun getExpectedDefaultWorkingTrees(): List<GitWorkingTree>
abstract val mainRepoPath: Path
protected val eventHandler = GitRepositoriesFrontendHolderTest.GitRepositoriesHolderEventHandler()
override fun setUp() {
super.setUp()
eventHandler.subscribe(project)
}
protected fun doTestWorkingTreeCreation(
data: GitWorkingTreeDialogData,
expectedWorkingTree: GitWorkingTree,
expectedWorkingTreeBranchName: String,
expectedWorkingTreeLastCommit: String,
) {
val holder = GitRepositoriesHolder.getInstance(project)
runBlocking {
holder.init()
}
assertSameElements(repo.workingTreeHolder.getWorkingTrees(), getExpectedDefaultWorkingTrees())
eventHandler.executeAndExpectEvent(
{
val result = GitWorkingTreesService.getInstance(project).createWorkingTree(repo, data)
assertTrue(result.errorOutputAsHtmlString, result.success)
val worktreesDir = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(mainRepoPath.resolve(".git/worktrees"))
refresh(worktreesDir!!)
},
{ event, _ ->
return@executeAndExpectEvent event == GitRepositoriesHolder.UpdateType.WORKING_TREES_LOADED
}
)
val workingTrees = repo.workingTreeHolder.getWorkingTrees()
val expected = getExpectedDefaultWorkingTrees().toMutableList()
expected.add(expectedWorkingTree)
assertSameElements(workingTrees, expected)
val workingTreeRepo = registerRepo(project, Paths.get(data.workingTreePath.path))
assertEquals("Current branch of the created working tree is incorrect",
expectedWorkingTreeBranchName,
workingTreeRepo.currentBranchName)
assertEquals("Last commit of the created working tree is incorrect", expectedWorkingTreeLastCommit, workingTreeRepo.currentRevision)
}
companion object {
fun createBranch(repo: GitRepository, branchName: String): GitLocalBranch {
repo.branch(branchName)
repo.update()
val newBranch = repo.branches.findLocalBranch(branchName)
assertNotNull(newBranch)
return newBranch!!
}
}
}