From 1df42d6d4b6afd822ebe95daab52c08874c44e30 Mon Sep 17 00:00:00 2001 From: Nikolay Rykunov Date: Thu, 12 Dec 2019 18:03:16 +0300 Subject: [PATCH] [git] Interactive Rebase: Use Log to collect Rebase Entries Log will be used if it is interactive rebase on current branch without any merges and fixup/squash commits between. This preprocessing doesn't start the rebase process to collect entries (changes shouldn't be shelved), so it should be faster. GitOrigin-RevId: 4bdf65e8e9bae3365dfa0070fe865e174b663eef --- .../git4idea/resources/META-INF/plugin.xml | 3 + .../rebase/GitInteractiveRebaseAction.kt | 35 +--- .../GitInteractiveRebaseEditorHandler.java | 22 ++- .../interactive/GitInteractiveRebaseDialog.kt | 6 +- .../GitInteractiveRebaseUsingLog.kt | 169 ++++++++++++++++ .../GitInteractiveRebaseUsingLogTest.kt | 182 ++++++++++++++++++ .../tests/git4idea/test/RepoBuilder.kt | 18 +- 7 files changed, 386 insertions(+), 49 deletions(-) create mode 100644 plugins/git4idea/src/git4idea/rebase/interactive/GitInteractiveRebaseUsingLog.kt create mode 100644 plugins/git4idea/tests/git4idea/rebase/interactive/GitInteractiveRebaseUsingLogTest.kt diff --git a/plugins/git4idea/resources/META-INF/plugin.xml b/plugins/git4idea/resources/META-INF/plugin.xml index 234b5a767bea..c613aaacc7cf 100644 --- a/plugins/git4idea/resources/META-INF/plugin.xml +++ b/plugins/git4idea/resources/META-INF/plugin.xml @@ -299,6 +299,9 @@ + + entries = rebaseFile.load(); - List newEntries = showInteractiveRebaseEditor(entries); + List newEntries = collectNewEntries(entries); if (newEntries != null) { rebaseFile.save(newEntries); return true; @@ -127,6 +127,10 @@ public class GitInteractiveRebaseEditorHandler implements GitRebaseEditorHandler } } + protected List collectNewEntries(@NotNull List entries) throws VcsException { + return showInteractiveRebaseEditor(entries); + } + @Nullable private List showInteractiveRebaseEditor(@NotNull List entries) throws VcsException { Ref> newText = Ref.create(); @@ -153,16 +157,20 @@ public class GitInteractiveRebaseEditorHandler implements GitRebaseEditorHandler DialogManager.show(editor); if (editor.isOK()) { List newEntries = editor.getEntries(); - for (GitRebaseEntryWithEditedMessage newEntry : newEntries) { - if (newEntry.getEntry().getAction() instanceof GitRebaseEntry.Action.REWORD) { - myMessagesMapping.add(new Pair<>(newEntry.getEntry().getCommitDetails().getFullMessage(), newEntry.getNewMessage())); - } - } + processNewEntries(newEntries); return ContainerUtil.map(newEntries, entry -> entry.getEntry()); } return null; } + protected void processNewEntries(@NotNull List newEntries) { + for (GitRebaseEntryWithEditedMessage newEntry : newEntries) { + if (newEntry.getEntry().getAction() instanceof GitRebaseEntry.Action.REWORD) { + myMessagesMapping.add(new Pair<>(newEntry.getEntry().getCommitDetails().getFullMessage(), newEntry.getNewMessage())); + } + } + } + private static boolean useNewInteractiveRebaseDialog() { return Registry.is("git.interactive.rebase.new.dialog"); } diff --git a/plugins/git4idea/src/git4idea/rebase/interactive/GitInteractiveRebaseDialog.kt b/plugins/git4idea/src/git4idea/rebase/interactive/GitInteractiveRebaseDialog.kt index 26c6250b58d0..7b667514482e 100644 --- a/plugins/git4idea/src/git4idea/rebase/interactive/GitInteractiveRebaseDialog.kt +++ b/plugins/git4idea/src/git4idea/rebase/interactive/GitInteractiveRebaseDialog.kt @@ -68,7 +68,11 @@ internal class GitInteractiveRebaseDialog( private const val DIALOG_WIDTH = 800 } - private val commitsTableModel = CommitsTableModel(entries.map { GitRebaseEntryWithEditedMessage(it) }) + private val commitsTableModel = CommitsTableModel(entries.map { + GitRebaseEntryWithEditedMessage( + GitRebaseEntryWithDetails(GitRebaseEntry(it.action, it.commit, it.subject), it.commitDetails) + ) + }) private val resetEntriesAction = object : AbstractAction("Reset Entries"), Action { override fun actionPerformed(e: ActionEvent?) { commitsTableModel.resetEntries() diff --git a/plugins/git4idea/src/git4idea/rebase/interactive/GitInteractiveRebaseUsingLog.kt b/plugins/git4idea/src/git4idea/rebase/interactive/GitInteractiveRebaseUsingLog.kt new file mode 100644 index 000000000000..65219d14518c --- /dev/null +++ b/plugins/git4idea/src/git4idea/rebase/interactive/GitInteractiveRebaseUsingLog.kt @@ -0,0 +1,169 @@ +// Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. +package git4idea.rebase.interactive + +import com.google.common.annotations.VisibleForTesting +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.util.text.StringUtil +import com.intellij.openapi.vcs.VcsException +import com.intellij.vcs.log.Hash +import com.intellij.vcs.log.VcsCommitMetadata +import com.intellij.vcs.log.VcsShortCommitDetails +import com.intellij.vcs.log.data.VcsLogData +import com.intellij.vcs.log.graph.api.LiteLinearGraph +import com.intellij.vcs.log.graph.impl.facade.PermanentGraphImpl +import com.intellij.vcs.log.graph.utils.DfsWalk +import com.intellij.vcs.log.graph.utils.LinearGraphUtils +import com.intellij.vcs.log.graph.utils.impl.BitSetFlags +import com.intellij.vcs.log.util.VcsLogUtil +import git4idea.GitUtil.HEAD +import git4idea.GitVcs +import git4idea.branch.GitRebaseParams +import git4idea.history.GitLogUtil +import git4idea.rebase.* +import git4idea.repo.GitRepository + +private val LOG = logger("Git.Interactive.Rebase.Using.Log") + +@VisibleForTesting +@Throws(CantRebaseUsingLogException::class) +internal fun getEntriesUsingLog( + repository: GitRepository, + commit: VcsShortCommitDetails, + logData: VcsLogData +): List { + val project = repository.project + val root = repository.root + + val dataPack = logData.dataPack + val permanentGraph = dataPack.permanentGraph as PermanentGraphImpl + val commitsInfo = permanentGraph.permanentCommitsInfo + + val headRef = VcsLogUtil.findBranch(dataPack.refsModel, root, HEAD) + ?: throw CantRebaseUsingLogException(CantRebaseUsingLogException.Reason.UNRESOLVED_HEAD) + val headIndex = logData.getCommitIndex(headRef.commitHash, root) + val headId = commitsInfo.getNodeId(headIndex) + + val graph = LinearGraphUtils.asLiteLinearGraph(permanentGraph.linearGraph) + val used = BitSetFlags(permanentGraph.linearGraph.nodesCount()) + val commits = mutableListOf() + DfsWalk(listOf(headId), graph, used).walk(true) { nodeId -> + ProgressManager.checkCanceled() + val parents = graph.getNodes(nodeId, LiteLinearGraph.NodeFilter.DOWN) + // commit is not root or merge + if (parents.size == 1) { + val commitId = permanentGraph.permanentCommitsInfo.getCommitId(nodeId) + val hash = logData.getCommitId(commitId)!!.hash + commits.add(hash) + hash != commit.id + } + else { + throw CantRebaseUsingLogException(CantRebaseUsingLogException.Reason.MERGE) + } + } + + if (commits.last() != commit.id) { + throw CantRebaseUsingLogException(CantRebaseUsingLogException.Reason.UNEXPECTED_HASH) + } + + val details = try { + GitLogUtil.collectMetadata( + project, + GitVcs.getInstance(project), + repository.root, + commits.map { it.asString() } + ) + } + catch (e: VcsException) { + throw CantRebaseUsingLogException(CantRebaseUsingLogException.Reason.UNRESOLVED_HASH) + } + + if (details.any { it.subject.startsWith("fixup!") || it.subject.startsWith("squash!") }) { + throw CantRebaseUsingLogException(CantRebaseUsingLogException.Reason.FIXUP_SQUASH) + } + + return details.map { GitRebaseEntryGeneratedUsingLog(it) }.reversed() +} + +internal fun interactivelyRebaseUsingLog(repository: GitRepository, commit: VcsShortCommitDetails, logData: VcsLogData) { + val project = repository.project + val root = repository.root + + object : Task.Backgroundable(project, "Preparing to Rebase${StringUtil.ELLIPSIS}") { + private var generatedEntries: List? = null + + override fun run(indicator: ProgressIndicator) { + try { + generatedEntries = getEntriesUsingLog(repository, commit, logData) + } + catch (e: CantRebaseUsingLogException) { + LOG.warn("Couldn't use log for rebasing: ${e.message}") + } + } + + override fun onSuccess() { + generatedEntries?.let { entries -> + val dialog = GitInteractiveRebaseDialog(project, root, entries.map { it.entryWithDetails }) + dialog.show() + if (dialog.isOK) { + startInteractiveRebase(repository, commit, GitInteractiveRebaseUsingLogEditorHandler(repository, entries, dialog.getEntries())) + } + } ?: startInteractiveRebase(repository, commit) + } + }.queue() +} + +internal fun startInteractiveRebase( + repository: GitRepository, + commit: VcsShortCommitDetails, + editorHandler: GitRebaseEditorHandler? = null +) { + object : Task.Backgroundable(repository.project, "Rebasing${StringUtil.ELLIPSIS}") { + override fun run(indicator: ProgressIndicator) { + val params = GitRebaseParams.editCommits(repository.vcs.version, commit.parents.first().asString(), editorHandler, false) + GitRebaseUtils.rebase(repository.project, listOf(repository), params, indicator) + } + }.queue() +} + +private class GitInteractiveRebaseUsingLogEditorHandler( + repository: GitRepository, + private val entriesGeneratedUsingLog: List, + private val newEntries: List +) : GitInteractiveRebaseEditorHandler(repository.project, repository.root) { + override fun collectNewEntries(entries: List): List { + entriesGeneratedUsingLog.forEachIndexed { i, generatedEntry -> + val realEntry = entries[i] + if (!generatedEntry.equalsWithReal(realEntry)) { + throw VcsException("Couldn't start Rebase using Log") + } + } + processNewEntries(newEntries) + return newEntries.map { it.entry } + } +} + +@VisibleForTesting +internal class CantRebaseUsingLogException(val reason: Reason) : Exception(reason.toString()) { + enum class Reason { + UNRESOLVED_HEAD, + MERGE, + FIXUP_SQUASH, + UNEXPECTED_HASH, + UNRESOLVED_HASH + } +} + +@VisibleForTesting +internal class GitRebaseEntryGeneratedUsingLog(details: VcsCommitMetadata) : + GitRebaseEntry(Action.PICK, details.id.asString(), details.subject) { + + val entryWithDetails = GitRebaseEntryWithDetails(this, details) + + fun equalsWithReal(realEntry: GitRebaseEntry) = + action == realEntry.action && + commit.startsWith(realEntry.commit) && + subject == realEntry.subject +} \ No newline at end of file diff --git a/plugins/git4idea/tests/git4idea/rebase/interactive/GitInteractiveRebaseUsingLogTest.kt b/plugins/git4idea/tests/git4idea/rebase/interactive/GitInteractiveRebaseUsingLogTest.kt new file mode 100644 index 000000000000..e1a12888f22a --- /dev/null +++ b/plugins/git4idea/tests/git4idea/rebase/interactive/GitInteractiveRebaseUsingLogTest.kt @@ -0,0 +1,182 @@ +// Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. +package git4idea.rebase.interactive + +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.progress.EmptyProgressIndicator +import com.intellij.vcs.log.VcsCommitMetadata +import com.intellij.vcs.log.data.DataPackChangeListener +import com.intellij.vcs.log.data.VcsLogData +import com.intellij.vcs.log.impl.FatalErrorHandler +import git4idea.branch.GitRebaseParams +import git4idea.rebase.GitInteractiveRebaseEditorHandler +import git4idea.rebase.GitRebaseEntry +import git4idea.rebase.GitRebaseUtils +import git4idea.test.GitSingleRepoTest +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + +class GitInteractiveRebaseUsingLogTest : GitSingleRepoTest() { + companion object { + private val LOG = logger() + } + + private lateinit var logData: VcsLogData + + override fun setUp() { + super.setUp() + logData = VcsLogData(project, mapOf(repo.root to logProvider), object : FatalErrorHandler { + override fun consume(source: Any?, throwable: Throwable) { + LOG.error(throwable) + } + + override fun displayFatalErrorMessage(message: String) { + LOG.error(message) + } + }, testRootDisposable) + } + + fun `test simple commits`() { + val commit0 = file("firstFile.txt").create("").addCommit("0").details() + build { + 1() + 2() + 3() + 4() + } + checkEntriesGeneration(commit0) + } + + fun `test commit with trailing spaces`() { + checkEntryGenerationForSingleCommitWithMessage { + "Subject with trailing spaces \n\nBody \nwith \nspaces." + } + } + + fun `test commit with tag in subject`() { + checkEntryGenerationForSingleCommitWithMessage { + "Subject with #tag trailing spaces" + } + } + + fun `test commit with huge length`() { + checkEntryGenerationForSingleCommitWithMessage { + buildString { + repeat(1000) { + append('a') + } + } + } + } + + fun `test rebase with merge commit`() { + val firstFile = "firstFile.txt" + val commit0 = file(firstFile).create("").addCommit("0").details() + build { + master { + 1() + 2() + } + feature { + 3() + 4() + } + master { + 5() + 6() + } + } + git("checkout master") + git("merge feature", true) + build { + master { + 7() + 8() + } + } + + repo.update() + assertExceptionDuringEntriesGeneration(commit0, CantRebaseUsingLogException.Reason.MERGE) { + "We shouldn't generate entries if merge commit between HEAD and Rebase Base. Generated entries: $it" + } + } + + fun `test rebase with squash commit`() { + val firstFile = "firstFile.txt" + val commit0 = file(firstFile).create("").addCommit("0").details() + build { + master { + 1(commitMessage = "commit1") + 2(commitMessage = "commit2") + 3(commitMessage = "fixup! commit2") + 4(commitMessage = "commit3") + } + } + assertExceptionDuringEntriesGeneration(commit0, CantRebaseUsingLogException.Reason.FIXUP_SQUASH) { + "We shouldn't generate entries if squash!/fixup! prefix used. Generated entries: $it" + } + } + + private fun refreshLogAndWait() { + val logWaiter = CompletableFuture() + val dataPackChangeListener = DataPackChangeListener { newDataPack -> + if (newDataPack.isFull) { + logWaiter.complete(logData) + } + } + logData.addDataPackChangeListener(dataPackChangeListener) + logData.refresh(listOf(repo.root)) + try { + logWaiter.get(5, TimeUnit.SECONDS) + } + catch (e: Exception) { + fail(e.message) + } + finally { + logData.removeDataPackChangeListener(dataPackChangeListener) + } + } + + private fun getRebaseEntriesUsingGit(commit: VcsCommitMetadata): List { + lateinit var entriesGeneratedUsingGit: List + val editorHandler = object : GitInteractiveRebaseEditorHandler(project, repo.root) { + override fun collectNewEntries(entries: List): List { + entriesGeneratedUsingGit = entries + return entries + } + } + val params = GitRebaseParams.editCommits(repo.vcs.version, commit.parents.first().asString(), editorHandler, false) + GitRebaseUtils.rebase(repo.project, listOf(repo), params, EmptyProgressIndicator()) + return entriesGeneratedUsingGit + } + + private fun checkEntriesGeneration(commit: VcsCommitMetadata) { + refreshLogAndWait() + val entriesGeneratedUsingLog = getEntriesUsingLog(repo, commit, logData) + val entriesGeneratedUsingGit = getRebaseEntriesUsingGit(commit) + assertTrue(entriesGeneratedUsingGit.isNotEmpty() && entriesGeneratedUsingLog.isNotEmpty()) + entriesGeneratedUsingLog.forEachIndexed { i, generatedEntry -> + val realEntry = entriesGeneratedUsingGit[i] + assertTrue("Generated entry: $generatedEntry, Real entry: $realEntry", generatedEntry.equalsWithReal(realEntry)) + } + } + + private fun checkEntryGenerationForSingleCommitWithMessage(message: () -> String) { + val commit = file("firstFile.txt").create("").addCommit(message()).details() + checkEntriesGeneration(commit) + } + + private fun assertExceptionDuringEntriesGeneration( + commit: VcsCommitMetadata, + reason: CantRebaseUsingLogException.Reason, + failMessage: (entries: List) -> String + ) { + refreshLogAndWait() + try { + val entries = getEntriesUsingLog(repo, commit, logData) + fail(failMessage(entries)) + } + catch (e: CantRebaseUsingLogException) { + assertEquals(reason, e.reason) + } + } +} \ No newline at end of file diff --git a/plugins/git4idea/tests/git4idea/test/RepoBuilder.kt b/plugins/git4idea/tests/git4idea/test/RepoBuilder.kt index 7fce57766bf9..bd8975bcc6fd 100644 --- a/plugins/git4idea/tests/git4idea/test/RepoBuilder.kt +++ b/plugins/git4idea/tests/git4idea/test/RepoBuilder.kt @@ -1,18 +1,4 @@ -/* - * Copyright 2000-2016 JetBrains s.r.o. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +// Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. package git4idea.test import com.intellij.openapi.vcs.Executor.append @@ -51,7 +37,7 @@ class RepoBuilder(val repo: GitRepository) { } } else { - if (repo.git("branch").split("\n").map({ it.replace("*", "") }).contains(branch)) { + if (repo.git("branch").split("\n").map { it.replace("*", "").trim() }.contains(branch)) { repo.git("checkout $branch") } else {