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