Prohibit undo git reword if HEAD changes, or commit was pushed

Expire the notification if the repository has changed, and check when "Undo" is pressed as well.

The reworded commit changes its hash on reword, so we have to find the commit:
* Get the number of rebased commits: `git log parent..newHead` (parent didn't change)
* The last commit in the row is the one we started rebase from, i.e. the one which was reworded.
* Double-check it by comparing the commit message.

IDEA-173634
This commit is contained in:
Kirill Likhodedov
2017-06-14 16:42:01 +03:00
parent ef40b2c7ae
commit fe62334268
5 changed files with 163 additions and 15 deletions
@@ -26,8 +26,6 @@ import com.intellij.vcs.log.VcsLogDataKeys
import com.intellij.vcs.log.data.VcsLogData
import git4idea.GitUtil.HEAD
import git4idea.GitUtil.getRepositoryManager
import git4idea.config.GitSharedSettings
import git4idea.repo.GitRepository
/**
* Base class for Git action which is going to edit existing commits,
@@ -133,15 +131,6 @@ abstract class GitCommitEditingAction : DumbAwareAction() {
}, "Searching for branches containing the selected commit", true, data.project)
}
protected fun findProtectedRemoteBranch(repository: GitRepository, branches: Collection<String>): String? {
val settings = GitSharedSettings.getInstance(repository.project)
// protected branches hold patterns for branch names without remote names
return repository.branches.remoteBranches.
filter { settings.isBranchProtected(it.nameForRemoteOperations) }.
map { it.nameForLocalOperations }.
filter { branches.contains(it) }.firstOrNull()
}
private fun commitPushedToProtectedBranchError(protectedBranch: String)
= "The commit is already pushed to protected branch '$protectedBranch'"
}
}
@@ -0,0 +1,33 @@
/*
* Copyright 2000-2017 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.
*/
package git4idea.rebase
import git4idea.config.GitSharedSettings
import git4idea.repo.GitRepository
/**
* Checks if there is a protected remote branch among the given branches, and returns one of them, or `null` otherwise.
*
* `branches` are given in the "local" format, e.g. `origin/master`.
*/
fun findProtectedRemoteBranch(repository: GitRepository, branches: Collection<String>): String? {
val settings = GitSharedSettings.getInstance(repository.project)
// protected branches hold patterns for branch names without remote names
return repository.branches.remoteBranches.
filter { settings.isBranchProtected(it.nameForRemoteOperations) }.
map { it.nameForLocalOperations }.
filter { branches.contains(it) }.firstOrNull()
}
@@ -19,19 +19,26 @@ import com.intellij.notification.Notification
import com.intellij.notification.NotificationAction
import com.intellij.notification.NotificationType
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.progress.EmptyProgressIndicator
import com.intellij.openapi.progress.ProgressIndicator
import com.intellij.openapi.progress.ProgressManager
import com.intellij.openapi.progress.Task
import com.intellij.openapi.project.Project
import com.intellij.openapi.vcs.VcsNotifier
import com.intellij.openapi.vcs.VcsNotifier.STANDARD_NOTIFICATION
import com.intellij.util.containers.MultiMap
import com.intellij.vcs.log.Hash
import com.intellij.vcs.log.VcsCommitMetadata
import git4idea.branch.GitBranchUtil
import git4idea.branch.GitRebaseParams
import git4idea.commands.Git
import git4idea.history.GitHistoryUtils
import git4idea.rebase.GitRebaseEntry.Action.pick
import git4idea.rebase.GitRebaseEntry.Action.reword
import git4idea.repo.GitRepository
import git4idea.repo.GitRepositoryChangeListener
import git4idea.reset.GitResetMode
class GitRewordOperation(private val repository: GitRepository,
@@ -40,8 +47,14 @@ class GitRewordOperation(private val repository: GitRepository,
init {
repository.update()
}
private val LOG = logger<GitRewordOperation>()
private val project = repository.project
private val notifier = VcsNotifier.getInstance(project)
private val initialHeadPosition = repository.currentRevision!!
private var headAfterReword: String? = null
private var rewordedCommit: Hash? = null
fun execute() {
val rebaseEditor = GitAutomaticRebaseEditor(project, commit.root,
@@ -52,12 +65,26 @@ class GitRewordOperation(private val repository: GitRepository,
val indicator = ProgressManager.getInstance().progressIndicator ?: EmptyProgressIndicator()
val spec = GitRebaseSpec.forNewRebase(project, params, listOf(repository), indicator)
RewordProcess(spec).rebase()
headAfterReword = repository.currentRevision
rewordedCommit = findNewHashOfRewordedCommit(headAfterReword!!)
}
internal fun undo() {
val possibility = checkUndoPossibility(project)
val errorTitle = "Can't Undo Reword"
when (possibility) {
is UndoPossibility.HeadMoved -> notifier.notifyError(errorTitle, "Repository has already been changed")
is UndoPossibility.PushedToProtectedBranch ->
notifier.notifyError(errorTitle, "Commit has already been pushed to ${possibility.branch}")
is Error -> notifier.notifyError(errorTitle, "")
else -> doUndo()
}
}
private fun doUndo() {
val res = Git.getInstance().reset(repository, GitResetMode.KEEP, initialHeadPosition)
if (!res.success()) {
VcsNotifier.getInstance(project).notifyError("Undo Reword Failed", res.errorOutputAsHtmlString)
notifier.notifyError("Undo Reword Failed", res.errorOutputAsHtmlString)
}
}
@@ -78,6 +105,44 @@ class GitRewordOperation(private val repository: GitRepository,
}
}
private fun findNewHashOfRewordedCommit(newHead: String): Hash? {
val newCommitsRange = "${commit.parents.first().asString()}..$newHead"
val newCommits = GitHistoryUtils.loadMetadata(project, repository.root, newCommitsRange).commits
if (newCommits.isEmpty()) {
LOG.error("Couldn't find commits after reword in range $newCommitsRange")
return null
}
val newCommit = newCommits.last()
if (newCommit.fullMessage != newMessage) {
LOG.error("Couldn't find the reworded commit. Expected message: \n[$newMessage]\nActual message: \n[${newCommit.fullMessage}]")
return null
}
return newCommit.id
}
private fun checkUndoPossibility(project: Project): UndoPossibility {
repository.update()
if (repository.currentRevision != headAfterReword) {
return UndoPossibility.HeadMoved
}
if (rewordedCommit == null) {
LOG.error("Couldn't find the reworded commit")
return UndoPossibility.Error
}
val containingBranches = GitBranchUtil.getBranches(project, repository.root, false, true, rewordedCommit!!.asString())
val protectedBranch = findProtectedRemoteBranch(repository, containingBranches)
if (protectedBranch != null) return UndoPossibility.PushedToProtectedBranch(protectedBranch)
return UndoPossibility.Possible
}
private sealed class UndoPossibility {
object Possible : UndoPossibility()
object HeadMoved : UndoPossibility()
class PushedToProtectedBranch(val branch: String) : UndoPossibility()
object Error : UndoPossibility()
}
private inner class RewordProcess(spec: GitRebaseSpec) : GitRebaseProcess(project, spec, null) {
override fun notifySuccess(successful: MutableMap<GitRepository, GitSuccessfulRebase>,
skippedCommits: MultiMap<GitRepository, GitRebaseUtils.CommitInfo>) {
@@ -88,7 +153,16 @@ class GitRewordOperation(private val repository: GitRepository,
undoInBackground()
}
})
VcsNotifier.getInstance(project).notify(notification)
val connection = project.messageBus.connect()
notification.whenExpired { connection.disconnect() }
connection.subscribe(GitRepository.GIT_REPO_CHANGE, GitRepositoryChangeListener {
ApplicationManager.getApplication().executeOnPooledThread {
if (checkUndoPossibility(project) !is UndoPossibility.Possible) notification.expire()
}
})
notifier.notify(notification)
}
override fun shouldRefreshOnSuccess(successType: GitSuccessfulRebase.SuccessType) = false
@@ -16,6 +16,7 @@
package git4idea.rebase
import git4idea.test.GitSingleRepoTest
import git4idea.test.assertLatestHistory
import git4idea.test.file
import git4idea.test.git
@@ -50,4 +51,43 @@ class GitRewordTest : GitSingleRepoTest() {
assertEquals("Message reworded incorrectly", "Wrong message", git("log HEAD --no-walk --pretty=%B"))
}
fun `test undo is not possible if HEAD moved`() {
val commit = file("a").create("initial").addCommit("Wrong message").details()
val operation = GitRewordOperation(myRepo, commit, "Correct message")
operation.execute()
file("b").create().addCommit("New commit")
operation.undo()
myRepo.assertLatestHistory(
"New commit",
"Correct message"
)
assertErrorNotification("Can't Undo Reword", "Repository has already been changed")
}
fun `test undo is not possible if commit was pushed`() {
git("remote add origin http://example.git")
val file = file("a").create("initial")
file.append("First commit\n").addCommit("First commit")
val commit = file.append("To reword\n").addCommit("Wrong message").details()
file.append("Third commit").addCommit("Third commit")
val operation = GitRewordOperation(myRepo, commit, "Correct message")
operation.execute()
git("update-ref refs/remotes/origin/master HEAD")
operation.undo()
myRepo.assertLatestHistory(
"Third commit",
"Correct message",
"First commit"
)
assertErrorNotification("Can't Undo Reword", "Commit has already been pushed to origin/master")
}
}
@@ -17,7 +17,9 @@ package git4idea.test
import com.intellij.openapi.vcs.FilePath
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.testFramework.PlatformTestCase.assertOrderedEquals
import com.intellij.vcsUtil.VcsUtil.getFilePath
import git4idea.history.GitHistoryUtils
import git4idea.repo.GitRepository
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -35,4 +37,14 @@ fun GitRepository.assertStatus(file: File, status: Char) {
val actualStatus = git(this, "status --porcelain ${file.path}")
assertTrue("File status is not-changed: $actualStatus", !actualStatus.isEmpty())
assertEquals("File status is incorrect: $actualStatus", status, actualStatus[0])
}
}
/**
* Checks the latest part of git history by commit messages.
*/
fun GitRepository.assertLatestHistory(vararg expectedMessages: String) {
val actualMessages = GitHistoryUtils.loadMetadata(this.project, this.root).commits
.map { it.fullMessage }
.subList(0, expectedMessages.size)
assertOrderedEquals("History is incorrect", actualMessages, expectedMessages.asList())
}