diff --git a/plugins/git4idea/resources/META-INF/stage-plugin.xml b/plugins/git4idea/resources/META-INF/stage-plugin.xml index 2585f326ce58..4a63ed5dba0d 100644 --- a/plugins/git4idea/resources/META-INF/stage-plugin.xml +++ b/plugins/git4idea/resources/META-INF/stage-plugin.xml @@ -5,6 +5,10 @@ displayNameSupplierClassName="git4idea.index.GitStageDisplayNameSupplier"/> + + + + diff --git a/plugins/git4idea/src/git4idea/index/GitIndexUtil.java b/plugins/git4idea/src/git4idea/index/GitIndexUtil.java index 1310d6d29928..b2d34489f66d 100644 --- a/plugins/git4idea/src/git4idea/index/GitIndexUtil.java +++ b/plugins/git4idea/src/git4idea/index/GitIndexUtil.java @@ -59,11 +59,20 @@ public class GitIndexUtil { } @NotNull - public static List listStaged(@NotNull GitRepository repository, @NotNull Collection filePaths) throws VcsException { - List result = new ArrayList<>(); + public static List listStaged(@NotNull GitRepository repository, @NotNull Collection filePaths) + throws VcsException { + Project project = repository.getProject(); VirtualFile root = repository.getRoot(); - GitLineHandler h = new GitLineHandler(repository.getProject(), root, GitCommand.LS_FILES); + return listStaged(project, root, filePaths); + } + + @NotNull + public static List listStaged(@NotNull Project project, + @NotNull VirtualFile root, + @NotNull Collection filePaths) throws VcsException { + List result = new ArrayList<>(); + GitLineHandler h = new GitLineHandler(project, root, GitCommand.LS_FILES); h.addParameters("-s"); h.endOptions(); h.addRelativePaths(filePaths); @@ -176,20 +185,24 @@ public class GitIndexUtil { } @NotNull - public static Hash write(@NotNull GitRepository repository, - @NotNull FilePath filePath, - @NotNull InputStream content, + public static Hash write(@NotNull GitRepository repository, @NotNull FilePath filePath, @NotNull InputStream content, boolean executable) throws VcsException { - Hash hash = hashObject(repository, filePath, content); - updateIndex(repository, filePath, hash, executable); + return write(repository.getProject(), repository.getRoot(), filePath, content, executable); + } + + @NotNull + public static Hash write(@NotNull Project project, @NotNull VirtualFile root, + @NotNull FilePath filePath, @NotNull InputStream content, + boolean executable) throws VcsException { + Hash hash = hashObject(project, root, filePath, content); + updateIndex(project, root, filePath, hash, executable); return hash; } @NotNull - private static Hash hashObject(@NotNull GitRepository repository, - @NotNull FilePath filePath, + private static Hash hashObject(@NotNull Project project, @NotNull VirtualFile root, @NotNull FilePath filePath, @NotNull InputStream content) throws VcsException { - GitLineHandler h = new GitLineHandler(repository.getProject(), repository.getRoot(), GitCommand.HASH_OBJECT); + GitLineHandler h = new GitLineHandler(project, root, GitCommand.HASH_OBJECT); h.setSilent(true); h.addParameters("-w", "--stdin"); h.addParameters("--path"); @@ -204,11 +217,17 @@ public class GitIndexUtil { @NotNull FilePath filePath, @NotNull Hash blobHash, boolean isExecutable) throws VcsException { - String mode = isExecutable ? EXECUTABLE_MODE : DEFAULT_MODE; - String path = VcsFileUtil.relativePath(repository.getRoot(), filePath); + updateIndex(repository.getProject(), repository.getRoot(), filePath, blobHash, isExecutable); + } - GitLineHandler h = new GitLineHandler(repository.getProject(), repository.getRoot(), GitCommand.UPDATE_INDEX); - if (GitVersionSpecialty.CACHEINFO_SUPPORTS_SINGLE_PARAMETER_FORM.existsIn(repository)) { + public static void updateIndex(@NotNull Project project, @NotNull VirtualFile root, @NotNull FilePath filePath, + @NotNull Hash blobHash, + boolean isExecutable) throws VcsException { + String mode = isExecutable ? EXECUTABLE_MODE : DEFAULT_MODE; + String path = VcsFileUtil.relativePath(root, filePath); + + GitLineHandler h = new GitLineHandler(project, root, GitCommand.UPDATE_INDEX); + if (GitVersionSpecialty.CACHEINFO_SUPPORTS_SINGLE_PARAMETER_FORM.existsIn(project)) { h.addParameters("--cacheinfo", mode + "," + blobHash.asString() + "," + path); } else { diff --git a/plugins/git4idea/src/git4idea/index/vfs/GitIndexFileSystem.kt b/plugins/git4idea/src/git4idea/index/vfs/GitIndexFileSystem.kt new file mode 100644 index 000000000000..a5f54a2d7621 --- /dev/null +++ b/plugins/git4idea/src/git4idea/index/vfs/GitIndexFileSystem.kt @@ -0,0 +1,56 @@ +// Copyright 2000-2020 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.index.vfs + +import com.intellij.openapi.components.service +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileListener +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.openapi.vfs.VirtualFileSystem +import com.intellij.openapi.vfs.newvfs.VirtualFileFilteringListener +import com.intellij.util.containers.ContainerUtil + +private const val PROTOCOL = "gitIndexFs" + +class GitIndexFileSystem : VirtualFileSystem() { + private val listenerWrappers: MutableMap = ContainerUtil.newConcurrentMap() + + override fun getProtocol(): String = PROTOCOL + override fun isReadOnly(): Boolean = false + + override fun findFileByPath(path: String): VirtualFile? { + val (project, virtualFile, filePath) = GitIndexVirtualFile.decode(path) ?: return null + + project.service() // init service + return project.service().get(virtualFile, filePath) + } + + override fun refreshAndFindFileByPath(path: String): VirtualFile? = findFileByPath(path) + override fun refresh(asynchronous: Boolean) {} + + override fun extractPresentableUrl(path: String): String = "Staged: " + super.extractPresentableUrl(path) + + override fun addVirtualFileListener(listener: VirtualFileListener) { + val wrapper: VirtualFileListener = VirtualFileFilteringListener(listener, this) + VirtualFileManager.getInstance().addVirtualFileListener(wrapper) + listenerWrappers[listener] = wrapper + } + + override fun removeVirtualFileListener(listener: VirtualFileListener) { + listenerWrappers.remove(listener)?.let { + VirtualFileManager.getInstance().removeVirtualFileListener(it) + } + } + + override fun deleteFile(requestor: Any?, vFile: VirtualFile) = throw UnsupportedOperationException() + override fun moveFile(requestor: Any?, vFile: VirtualFile, newParent: VirtualFile) = throw UnsupportedOperationException() + override fun renameFile(requestor: Any?, vFile: VirtualFile, newName: String) = throw UnsupportedOperationException() + override fun createChildFile(requestor: Any?, vDir: VirtualFile, fileName: String): VirtualFile = throw UnsupportedOperationException() + override fun createChildDirectory(requestor: Any?, vDir: VirtualFile, dirName: String): VirtualFile = throw UnsupportedOperationException() + override fun copyFile(requestor: Any?, virtualFile: VirtualFile, newParent: VirtualFile, copyName: String): VirtualFile = throw UnsupportedOperationException() + + companion object { + @JvmStatic + val instance: GitIndexFileSystem + get() = VirtualFileManager.getInstance().getFileSystem(PROTOCOL) as GitIndexFileSystem + } +} \ No newline at end of file diff --git a/plugins/git4idea/src/git4idea/index/vfs/GitIndexFileSystemRefresher.kt b/plugins/git4idea/src/git4idea/index/vfs/GitIndexFileSystemRefresher.kt new file mode 100644 index 000000000000..7755826b0ca8 --- /dev/null +++ b/plugins/git4idea/src/git4idea/index/vfs/GitIndexFileSystemRefresher.kt @@ -0,0 +1,101 @@ +// Copyright 2000-2020 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.index.vfs + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.serviceIfCreated +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.openapi.vfs.newvfs.BulkFileListener +import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import com.intellij.util.concurrency.AppExecutorUtil +import com.intellij.util.messages.MessageBusConnection +import com.intellij.vcs.log.submitSafe +import git4idea.repo.GitRepositoryManager +import git4idea.repo.GitUntrackedFilesHolder +import org.jetbrains.annotations.CalledWithWriteLock + +class GitIndexFileSystemRefresher(private val project: Project) : Disposable { + private val executor = AppExecutorUtil.createBoundedApplicationPoolExecutor("Git Index Read/Write Thread for " + project.name, 1) + + init { + val connection: MessageBusConnection = project.messageBus.connect(this) + connection.subscribe(VirtualFileManager.VFS_CHANGES, object : BulkFileListener { + override fun after(events: List) { + val roots = GitRepositoryManager.getInstance(project).repositories.filter { repo -> + events.any { e -> GitUntrackedFilesHolder.indexChanged(repo, e.path) } + }.map { it.root } + if (roots.isNotEmpty()) { + LOG.debug("Scheduling refresh for ${roots.joinToString { it.name }}") + refresh(roots) + } + } + }) + } + + private fun refresh(roots: List) { + executor.submitSafe(LOG) { + val refreshRunnables = mutableListOf() + project.serviceIfCreated()?.forEachFile { file -> + if (roots.contains(file.root)) { + file.getRefresh()?.let { refreshRunnables.add(it) } + LOG.debug("Preparing refresh for $file") + } + } + if (refreshRunnables.isNotEmpty()) execute(refreshRunnables) + } + } + + internal fun execute(refreshList: List, postRunnable: Runnable? = null) { + val events = refreshList.map { it.event } + writeInEdt { + ApplicationManager.getApplication().messageBus.syncPublisher(VirtualFileManager.VFS_CHANGES).before(events) + refreshList.forEach { it.run() } + ApplicationManager.getApplication().messageBus.syncPublisher(VirtualFileManager.VFS_CHANGES).after(events) + postRunnable?.run() + } + } + + internal fun runTask(asynchronous: Boolean, task: () -> Unit) { + if (ApplicationManager.getApplication().isDispatchThread || asynchronous) { + executor.submitSafe(LOG, task) + } + else { + task() + } + } + + @CalledWithWriteLock + internal fun changeContent(file: GitIndexVirtualFile, requestor: Any?, modificationStamp: Long, writeCommand: () -> Unit) { + ApplicationManager.getApplication().assertWriteAccessAllowed() + + val event = VFileContentChangeEvent(requestor, file, file.modificationStamp, modificationStamp, false) + ApplicationManager.getApplication().messageBus.syncPublisher(VirtualFileManager.VFS_CHANGES).before(listOf(event)) + executor.submitSafe(LOG) { + try { + writeCommand() + } + finally { + writeInEdt { ApplicationManager.getApplication().messageBus.syncPublisher(VirtualFileManager.VFS_CHANGES).after(listOf(event)) } + } + } + } + + override fun dispose() { + } + + companion object { + private val LOG = Logger.getInstance(GitIndexFileSystemRefresher::class.java) + } +} + +fun writeInEdt(action: () -> Unit) { + ApplicationManager.getApplication().invokeLater { + ApplicationManager.getApplication().runWriteAction { + action() + } + } +} \ No newline at end of file diff --git a/plugins/git4idea/src/git4idea/index/vfs/GitIndexVirtualFile.kt b/plugins/git4idea/src/git4idea/index/vfs/GitIndexVirtualFile.kt new file mode 100644 index 000000000000..79e76b356064 --- /dev/null +++ b/plugins/git4idea/src/git4idea/index/vfs/GitIndexVirtualFile.kt @@ -0,0 +1,206 @@ +// Copyright 2000-2020 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.index.vfs + +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.util.text.StringUtil +import com.intellij.openapi.vcs.FilePath +import com.intellij.openapi.vcs.VcsException +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VfsUtilCore +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent +import com.intellij.util.LocalTimeCounter +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.commands.Git +import git4idea.commands.GitCommand +import git4idea.commands.GitLineHandler +import git4idea.index.GitIndexUtil +import git4idea.util.GitFileUtils +import java.io.* +import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.write + +class GitIndexVirtualFile(private val project: Project, + val root: VirtualFile, + val filePath: FilePath) : VirtualFile() { + private val lock = ReentrantReadWriteLock() + private val refresher: GitIndexFileSystemRefresher get() = project.service() + + private val cachedData: AtomicReference = AtomicReference() + + @Volatile + private var modificationStamp = LocalTimeCounter.currentTime() + + init { + refresher.runTask(false) { + cachedData.compareAndSet(null, readCachedData()) + } + } + + override fun getFileSystem(): GitIndexFileSystem = GitIndexFileSystem.instance + override fun getParent(): VirtualFile? = null + override fun getChildren(): Array = EMPTY_ARRAY + override fun isWritable(): Boolean = true + override fun isDirectory(): Boolean = false + override fun isValid(): Boolean = cachedData.get() != null + override fun getName(): String = filePath.name + override fun getPath(): String = encode(project, root, filePath) + override fun getLength(): Long = cachedData.get()?.length ?: 0 + override fun getTimeStamp(): Long = 0 + override fun getModificationStamp(): Long = modificationStamp + override fun refresh(asynchronous: Boolean, recursive: Boolean, postRunnable: Runnable?) { + refresher.runTask(asynchronous) { + getRefresh()?.also { refresher.execute(listOf(it), postRunnable) } ?: writeInEdt { postRunnable?.run() } + } + } + + internal fun getRefresh(): Refresh? { + val newCachedData = readCachedData() + val oldCachedData = cachedData.get() + if (oldCachedData != newCachedData) { + LOG.debug("Preparing refresh for $this") + return Refresh(oldCachedData, newCachedData, modificationStamp) + } + return null + } + + @Throws(IOException::class) + override fun getOutputStream(requestor: Any?, + newModificationStamp: Long, + newTimeStamp: Long): OutputStream { + val outputStream: ByteArrayOutputStream = object : ByteArrayOutputStream() { + override fun close() = write(requestor, toByteArray(), newModificationStamp) + } + return VfsUtilCore.outputStreamAddingBOM(outputStream, this) + } + + private fun write(requestor: Any?, newContent: ByteArray, newModificationStamp: Long) { + val newModStamp = if (newModificationStamp > 0) newModificationStamp else LocalTimeCounter.currentTime() + refresher.changeContent(this, requestor, modificationStamp) { + lock.write { + val oldCachedData = cachedData.get() + if (oldCachedData != readCachedData()) { + // TODO + LOG.warn("Skipping write for $this as it is not up to date") + return@write + } + + val isExecutable = oldCachedData?.isExecutable ?: false + val newHash = GitIndexUtil.write(project, root, filePath, ByteArrayInputStream(newContent), isExecutable) + LOG.debug("Written $this. newHash=$newHash") + + modificationStamp = newModStamp + if (oldCachedData?.hash != newHash) { + cachedData.compareAndSet(oldCachedData, CachedData(newHash, calculateLength(newHash.asString()), isExecutable)) + } + } + } + } + + @Throws(IOException::class) + override fun getInputStream(): InputStream { + return VfsUtilCore.byteStreamSkippingBOM(contentsToByteArray(), this) + } + + @Throws(IOException::class) + override fun contentsToByteArray(): ByteArray { + return try { + lock.read { + GitFileUtils.getFileContent(project, root, "", VcsFileUtil.relativePath(root, filePath)) + } + } + catch (e: VcsException) { + throw IOException(e) + } + } + + private fun readCachedData(): CachedData? { + lock.read { + val stagedFile = GitIndexUtil.listStaged(project, root, listOf(filePath)).singleOrNull() ?: return null + return CachedData(HashImpl.build(stagedFile.blobHash), calculateLength(stagedFile.blobHash), stagedFile.isExecutable) + } + } + + @Throws(VcsException::class) + private fun calculateLength(hash: String): Long { + val h = GitLineHandler(project, root, GitCommand.CAT_FILE) + h.setSilent(true) + h.addParameters("-s") + h.addParameters(hash) + h.endOptions() + val output = Git.getInstance().runCommand(h).getOutputOrThrow() + return java.lang.Long.valueOf(output.trim()) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as GitIndexVirtualFile + + if (root != other.root) return false + if (filePath != other.filePath) return false + + return true + } + + override fun hashCode(): Int { + var result = root.hashCode() + result = 31 * result + filePath.hashCode() + return result + } + + override fun toString(): String { + return "GitIndexVirtualFile: [${root.name}]/${VcsFileUtil.relativePath(root, filePath)}" + } + + internal inner class Refresh internal constructor(private val oldCachedData: CachedData?, + private val newCachedData: CachedData?, + oldModificationStamp: Long) { + val event: VFileContentChangeEvent = VFileContentChangeEvent(null, this@GitIndexVirtualFile, oldModificationStamp, -1, + 0, 0, + oldCachedData?.length ?: 0, newCachedData?.length ?: 0, true) + + fun run(): Boolean { + LOG.debug("Refreshing ${this@GitIndexVirtualFile}") + return cachedData.compareAndSet(oldCachedData, newCachedData) + } + + override fun toString(): String { + return "GitIndexVirtualFile.Refresh: ${this@GitIndexVirtualFile}" + } + } + + companion object { + private val LOG = Logger.getInstance(GitIndexVirtualFile::class.java) + const val SEPARATOR = ':' + + private fun encode(project: Project, root: VirtualFile, filePath: FilePath): String { + return StringUtil.escapeChar(project.locationHash, SEPARATOR) + SEPARATOR + + StringUtil.escapeChar(root.path, SEPARATOR) + SEPARATOR + + StringUtil.escapeChar(filePath.path, SEPARATOR) + } + + fun decode(path: String): Triple? { + val components = path.split(SEPARATOR) + if (components.size != 3) return null + + val locationHash = StringUtil.unescapeChar(components[0], SEPARATOR) + val project = ProjectManager.getInstance().openProjects.firstOrNull { it.locationHash == locationHash } ?: return null + val root = LocalFileSystem.getInstance().findFileByPath(StringUtil.unescapeChar(components[1], SEPARATOR)) ?: return null + val filePath = VcsUtil.getFilePath(StringUtil.unescapeChar(components[2], SEPARATOR)) + + return Triple(project, root, filePath) + } + } + + internal data class CachedData(val hash: Hash, val length: Long, val isExecutable: Boolean = false) +} \ No newline at end of file diff --git a/plugins/git4idea/src/git4idea/index/vfs/GitIndexVirtualFileCache.kt b/plugins/git4idea/src/git4idea/index/vfs/GitIndexVirtualFileCache.kt new file mode 100644 index 000000000000..d1a18b9c5b93 --- /dev/null +++ b/plugins/git4idea/src/git4idea/index/vfs/GitIndexVirtualFileCache.kt @@ -0,0 +1,29 @@ +// Copyright 2000-2020 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.index.vfs + +import com.google.common.cache.CacheBuilder +import com.google.common.cache.CacheLoader +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.FilePath +import com.intellij.openapi.vfs.VirtualFile + +class GitIndexVirtualFileCache(private val project: Project) : Disposable { + private val cache = CacheBuilder.newBuilder().weakValues().build(CacheLoader.from { key -> + GitIndexVirtualFile(project, key!!.root, key.filePath) + }) + + fun get(root: VirtualFile, filePath: FilePath): GitIndexVirtualFile { + return cache.get(Key(root, filePath)) + } + + fun forEachFile(function: (GitIndexVirtualFile) -> Unit) { + cache.asMap().forEach { (_, file) -> function(file) } + } + + override fun dispose() { + cache.invalidateAll() + } + + private data class Key(val root: VirtualFile, val filePath: FilePath) +} \ No newline at end of file diff --git a/plugins/git4idea/src/git4idea/repo/GitRepositoryFiles.java b/plugins/git4idea/src/git4idea/repo/GitRepositoryFiles.java index 610dc1cc741a..055f96a1c007 100644 --- a/plugins/git4idea/src/git4idea/repo/GitRepositoryFiles.java +++ b/plugins/git4idea/src/git4idea/repo/GitRepositoryFiles.java @@ -361,6 +361,14 @@ public class GitRepositoryFiles { VfsUtil.markDirtyAndRefresh(true, true, false, myMainDir, myWorktreeDir); } + /** + * Refresh .git/index asynchronously. + */ + public void refreshIndexFile() { + VirtualFile indexFilePath = LocalFileSystem.getInstance().refreshAndFindFileByPath(myIndexFilePath); + VfsUtil.markDirtyAndRefresh(true, false, false, indexFilePath); + } + /** * Refresh that part of .git repository files, which is not covered by {@link GitRepository#update()}, e.g. the {@code refs/tags/} dir. * diff --git a/plugins/git4idea/src/git4idea/repo/GitUntrackedFilesHolder.java b/plugins/git4idea/src/git4idea/repo/GitUntrackedFilesHolder.java index 801c3f642470..fde5f013ff26 100644 --- a/plugins/git4idea/src/git4idea/repo/GitUntrackedFilesHolder.java +++ b/plugins/git4idea/src/git4idea/repo/GitUntrackedFilesHolder.java @@ -279,7 +279,7 @@ public class GitUntrackedFilesHolder implements Disposable, AsyncVfsEventsListen return repository.getRepositoryFiles().isOrigHeadFile(path); } - private static boolean indexChanged(@NotNull GitRepository repository, @NotNull String path) { + public static boolean indexChanged(@NotNull GitRepository repository, @NotNull String path) { return repository.getRepositoryFiles().isIndexFile(path); }