diff --git a/plugins/git4idea/resources/intellij.vcs.git.xml b/plugins/git4idea/resources/intellij.vcs.git.xml
index cd1d5b936dd4..6cb4cc375804 100644
--- a/plugins/git4idea/resources/intellij.vcs.git.xml
+++ b/plugins/git4idea/resources/intellij.vcs.git.xml
@@ -41,6 +41,7 @@
+
@@ -161,6 +162,7 @@
+
@@ -250,6 +252,8 @@
+
+
@@ -338,6 +342,8 @@
+
+
@@ -548,6 +554,7 @@
+
@@ -886,6 +893,7 @@
predicateClassName="git4idea.workingTrees.ui.GitWorkingTreesContentVisibilityPredicate"
preloaderClassName="git4idea.workingTrees.ui.GitWorkingTreesContentPreloader"
isInCommitToolWindow="false"/>
+
diff --git a/plugins/git4idea/shared/BUILD.bazel b/plugins/git4idea/shared/BUILD.bazel
index 379dc02eca4c..1395fc2c276f 100644
--- a/plugins/git4idea/shared/BUILD.bazel
+++ b/plugins/git4idea/shared/BUILD.bazel
@@ -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
\ No newline at end of file
diff --git a/plugins/git4idea/shared/intellij.vcs.git.shared.iml b/plugins/git4idea/shared/intellij.vcs.git.shared.iml
index 2a6c1f1b1d09..6da20c888555 100644
--- a/plugins/git4idea/shared/intellij.vcs.git.shared.iml
+++ b/plugins/git4idea/shared/intellij.vcs.git.shared.iml
@@ -54,5 +54,6 @@
+
\ No newline at end of file
diff --git a/plugins/git4idea/shared/resources/messages/GitBundle.properties b/plugins/git4idea/shared/resources/messages/GitBundle.properties
index c28bd61f5056..2e8311e74ffc 100644
--- a/plugins/git4idea/shared/resources/messages/GitBundle.properties
+++ b/plugins/git4idea/shared/resources/messages/GitBundle.properties
@@ -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:
{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
diff --git a/plugins/git4idea/shared/src/git4idea/GitWorkingTree.kt b/plugins/git4idea/shared/src/git4idea/GitWorkingTree.kt
index ed3372d54fe9..cb42fd63ba3f 100644
--- a/plugins/git4idea/shared/src/git4idea/GitWorkingTree.kt
+++ b/plugins/git4idea/shared/src/git4idea/GitWorkingTree.kt
@@ -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
diff --git a/plugins/git4idea/src/git4idea/GitNotificationIdsHolder.kt b/plugins/git4idea/src/git4idea/GitNotificationIdsHolder.kt
index 69adf4375520..927b15a0385b 100644
--- a/plugins/git4idea/src/git4idea/GitNotificationIdsHolder.kt
+++ b/plugins/git4idea/src/git4idea/GitNotificationIdsHolder.kt
@@ -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"
}
diff --git a/plugins/git4idea/src/git4idea/GitOperationsCollector.kt b/plugins/git4idea/src/git4idea/GitOperationsCollector.kt
index 712a29e29d70..c9f32fe94dec 100644
--- a/plugins/git4idea/src/git4idea/GitOperationsCollector.kt
+++ b/plugins/git4idea/src/git4idea/GitOperationsCollector.kt
@@ -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("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("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)
+ }
}
diff --git a/plugins/git4idea/src/git4idea/actions/GitQuickListContentProvider.java b/plugins/git4idea/src/git4idea/actions/GitQuickListContentProvider.java
index 70b5f1d6ee93..05e464f55ee6 100644
--- a/plugins/git4idea/src/git4idea/actions/GitQuickListContentProvider.java
+++ b/plugins/git4idea/src/git4idea/actions/GitQuickListContentProvider.java
@@ -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);
diff --git a/plugins/git4idea/src/git4idea/actions/workingTree/GitCreateWorkingTreeAction.kt b/plugins/git4idea/src/git4idea/actions/workingTree/GitCreateWorkingTreeAction.kt
new file mode 100644
index 000000000000..5a2c400c720d
--- /dev/null
+++ b/plugins/git4idea/src/git4idea/actions/workingTree/GitCreateWorkingTreeAction.kt
@@ -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()
+ }
+
+ /**
+ * 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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/plugins/git4idea/src/git4idea/actions/workingTree/GitWorkingTreeDialog.kt b/plugins/git4idea/src/git4idea/actions/workingTree/GitWorkingTreeDialog.kt
new file mode 100644
index 000000000000..f29900f0972a
--- /dev/null
+++ b/plugins/git4idea/src/git4idea/actions/workingTree/GitWorkingTreeDialog.kt
@@ -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
+ private lateinit var projectNameCell: Cell
+
+ private val existingBranchWithWorkingTree: ObservableMutableProperty = AtomicLazyProperty {
+ data.initialExistingBranch?.toBranchWithWorkingTree()
+ }
+ private val projectName: ObservableMutableProperty = AtomicLazyProperty { "" }
+ private val parentPath: ObservableMutableProperty = AtomicLazyProperty { data.initialParentPath?.path ?: "" }
+ private val createNewBranch: ObservableMutableProperty = AtomicLazyProperty { false }
+ private val newBranchName: ObservableMutableProperty = AtomicLazyProperty { "" }
+
+ private var lastSuggestedProjectName: String = ""
+ private var projectNameEdited: Boolean = false
+ private val lastPathValidationChannel = Channel(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 = 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): 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 {
+ 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() {
+
+ private val repositoryModel = GitWorkingTreesService.getInstance(project).repositoryToModel(repository)
+
+ override fun customizeCellRenderer(
+ list: JList,
+ 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()
+
+ @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
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/plugins/git4idea/src/git4idea/actions/workingTree/GitWorkingTreeDialogData.kt b/plugins/git4idea/src/git4idea/actions/workingTree/GitWorkingTreeDialogData.kt
new file mode 100644
index 000000000000..34d14df64344
--- /dev/null
+++ b/plugins/git4idea/src/git4idea/actions/workingTree/GitWorkingTreeDialogData.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/plugins/git4idea/src/git4idea/actions/workingTree/GitWorkingTreePreDialogData.kt b/plugins/git4idea/src/git4idea/actions/workingTree/GitWorkingTreePreDialogData.kt
new file mode 100644
index 000000000000..b37c3a6c5d69
--- /dev/null
+++ b/plugins/git4idea/src/git4idea/actions/workingTree/GitWorkingTreePreDialogData.kt
@@ -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,
+)
\ No newline at end of file
diff --git a/plugins/git4idea/src/git4idea/actions/workingTree/ShowWorkingTreesAction.kt b/plugins/git4idea/src/git4idea/actions/workingTree/ShowWorkingTreesAction.kt
index 34db1df46bcb..8d4ad40c2ec2 100644
--- a/plugins/git4idea/src/git4idea/actions/workingTree/ShowWorkingTreesAction.kt
+++ b/plugins/git4idea/src/git4idea/actions/workingTree/ShowWorkingTreesAction.kt
@@ -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) {
diff --git a/plugins/git4idea/src/git4idea/commands/Git.java b/plugins/git4idea/src/git4idea/commands/Git.java
index 9b830791d7a9..6b30ba7dee0c 100644
--- a/plugins/git4idea/src/git4idea/commands/Git.java
+++ b/plugins/git4idea/src/git4idea/commands/Git.java
@@ -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);
}
diff --git a/plugins/git4idea/src/git4idea/commands/GitImpl.java b/plugins/git4idea/src/git4idea/commands/GitImpl.java
index a9e97ffa2f61..69bebc332c19 100644
--- a/plugins/git4idea/src/git4idea/commands/GitImpl.java
+++ b/plugins/git4idea/src/git4idea/commands/GitImpl.java
@@ -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));
}
diff --git a/plugins/git4idea/src/git4idea/repo/GitWorkingTreeHolder.kt b/plugins/git4idea/src/git4idea/repo/GitWorkingTreeHolder.kt
index ba721bf70ef3..68f919b2f5cc 100644
--- a/plugins/git4idea/src/git4idea/repo/GitWorkingTreeHolder.kt
+++ b/plugins/git4idea/src/git4idea/repo/GitWorkingTreeHolder.kt
@@ -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 = 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")
}
diff --git a/plugins/git4idea/src/git4idea/workingTrees/GitWorkingTreesCommandService.kt b/plugins/git4idea/src/git4idea/workingTrees/GitWorkingTreesCommandService.kt
deleted file mode 100644
index b93eea997e5f..000000000000
--- a/plugins/git4idea/src/git4idea/workingTrees/GitWorkingTreesCommandService.kt
+++ /dev/null
@@ -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()
- }
-
- 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)
- }
-}
\ No newline at end of file
diff --git a/plugins/git4idea/src/git4idea/workingTrees/GitWorkingTreesService.kt b/plugins/git4idea/src/git4idea/workingTrees/GitWorkingTreesService.kt
index 0b7bb6d11e7f..b350504cf921 100644
--- a/plugins/git4idea/src/git4idea/workingTrees/GitWorkingTreesService.kt
+++ b/plugins/git4idea/src/git4idea/workingTrees/GitWorkingTreesService.kt
@@ -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)
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/plugins/git4idea/src/git4idea/workingTrees/GitWorkingTreesUtil.kt b/plugins/git4idea/src/git4idea/workingTrees/GitWorkingTreesUtil.kt
index d4f85f5bdd5c..8b670c774fc6 100644
--- a/plugins/git4idea/src/git4idea/workingTrees/GitWorkingTreesUtil.kt
+++ b/plugins/git4idea/src/git4idea/workingTrees/GitWorkingTreesUtil.kt
@@ -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
diff --git a/plugins/git4idea/src/git4idea/workingTrees/ui/GitWorkingTreesContentProvider.kt b/plugins/git4idea/src/git4idea/workingTrees/ui/GitWorkingTreesContentProvider.kt
index a67ec3b38a33..7d64ed046ca1 100644
--- a/plugins/git4idea/src/git4idea/workingTrees/ui/GitWorkingTreesContentProvider.kt
+++ b/plugins/git4idea/src/git4idea/workingTrees/ui/GitWorkingTreesContentProvider.kt
@@ -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) {
diff --git a/plugins/git4idea/src/git4idea/workingTrees/ui/RemoveWorkingTreeAction.kt b/plugins/git4idea/src/git4idea/workingTrees/ui/RemoveWorkingTreeAction.kt
index 65d295f11645..947892c4b066 100644
--- a/plugins/git4idea/src/git4idea/workingTrees/ui/RemoveWorkingTreeAction.kt
+++ b/plugins/git4idea/src/git4idea/workingTrees/ui/RemoveWorkingTreeAction.kt
@@ -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().deleteWorkingTree(project, tree)
+ service().deleteWorkingTree(project, tree)
}
if (commandResult.success()) {
repository.workingTreeHolder.reload()
diff --git a/plugins/git4idea/tests/git4idea/repo/GitLinkedWorktreeTest.kt b/plugins/git4idea/tests/git4idea/repo/GitLinkedWorktreeTest.kt
deleted file mode 100644
index 35a3f95e6c50..000000000000
--- a/plugins/git4idea/tests/git4idea/repo/GitLinkedWorktreeTest.kt
+++ /dev/null
@@ -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 {})!!
- }
- }
- }
-}
\ No newline at end of file
diff --git a/plugins/git4idea/tests/git4idea/repo/GitRepositoriesFrontendHolderTest.kt b/plugins/git4idea/tests/git4idea/repo/GitRepositoriesFrontendHolderTest.kt
index f972cdc04fc1..bc1c5911c8e8 100644
--- a/plugins/git4idea/tests/git4idea/repo/GitRepositoriesFrontendHolderTest.kt
+++ b/plugins/git4idea/tests/git4idea/repo/GitRepositoriesFrontendHolderTest.kt
@@ -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(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) -> Boolean,
+ ) {
+ runBlocking {
+ skipEvents()
+ operation()
+ val collected = mutableListOf()
+ 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().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
}
}
\ No newline at end of file
diff --git a/plugins/git4idea/tests/git4idea/repo/GitRepositoriesFrontendHolderTestBase.kt b/plugins/git4idea/tests/git4idea/repo/GitRepositoriesFrontendHolderTestBase.kt
deleted file mode 100644
index 826bee9eb243..000000000000
--- a/plugins/git4idea/tests/git4idea/repo/GitRepositoriesFrontendHolderTestBase.kt
+++ /dev/null
@@ -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(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) -> Boolean,
- ) {
- runBlocking {
- skipEvents()
- operation()
- val collected = mutableListOf()
- 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)
- }
-}
\ No newline at end of file
diff --git a/plugins/git4idea/tests/git4idea/workingTrees/GitCreateWorkingTreeTest.kt b/plugins/git4idea/tests/git4idea/workingTrees/GitCreateWorkingTreeTest.kt
index 742cb18b5999..c7bf0f4526f1 100644
--- a/plugins/git4idea/tests/git4idea/workingTrees/GitCreateWorkingTreeTest.kt
+++ b/plugins/git4idea/tests/git4idea/workingTrees/GitCreateWorkingTreeTest.kt
@@ -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!!
- }
- }
}
\ No newline at end of file
diff --git a/plugins/git4idea/tests/git4idea/workingTrees/GitWorkingTreeFromRemoteBranchesTest.kt b/plugins/git4idea/tests/git4idea/workingTrees/GitWorkingTreeFromRemoteBranchesTest.kt
new file mode 100644
index 000000000000..46218824b404
--- /dev/null
+++ b/plugins/git4idea/tests/git4idea/workingTrees/GitWorkingTreeFromRemoteBranchesTest.kt
@@ -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 {
+ 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)
+ }
+}
\ No newline at end of file
diff --git a/plugins/git4idea/tests/git4idea/workingTrees/GitWorkingTreeTest.kt b/plugins/git4idea/tests/git4idea/workingTrees/GitWorkingTreeTest.kt
index 912d4a16f09b..74c01dc724b7 100644
--- a/plugins/git4idea/tests/git4idea/workingTrees/GitWorkingTreeTest.kt
+++ b/plugins/git4idea/tests/git4idea/workingTrees/GitWorkingTreeTest.kt
@@ -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 {
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
-
-
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 {
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)
+ }
}
\ No newline at end of file
diff --git a/plugins/git4idea/tests/git4idea/workingTrees/GitWorkingTreeTestBase.kt b/plugins/git4idea/tests/git4idea/workingTrees/GitWorkingTreeTestBase.kt
new file mode 100644
index 000000000000..6ac08f47ebe6
--- /dev/null
+++ b/plugins/git4idea/tests/git4idea/workingTrees/GitWorkingTreeTestBase.kt
@@ -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
+ 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!!
+ }
+ }
+}
\ No newline at end of file