From 7a7eb3ad93e8ca601869a85a534dec8f8a8beffa Mon Sep 17 00:00:00 2001 From: Abdelilah El Aissaoui Date: Fri, 14 Jul 2023 14:28:29 +0200 Subject: [PATCH] Refactored repository watcher Refactored to use a rust implementation instead of the java impl, because the java impl has been unrelible in linux and macos --- .gitignore | 2 + build.gradle.kts | 75 ++++++++++- rs/Cargo.toml | 23 ++++ rs/build.rs | 3 + rs/src/lib.rs | 118 ++++++++++++++++++ rs/src/repository_watcher.udl | 20 +++ rs/uniffi-bindgen.rs | 3 + .../gitnuro/git/FileChangesWatcher.kt | 98 +++++---------- .../com/jetpackduba/gitnuro/git/TabState.kt | 8 +- .../git/workspace/GetIgnoreRulesUseCase.kt | 51 ++++++++ .../gitnuro/managers/ErrorsManager.kt | 20 ++- .../gitnuro/ui/dialogs/ErrorDialog.kt | 2 +- .../gitnuro/viewmodels/SettingsViewModel.kt | 3 +- .../gitnuro/viewmodels/TabViewModel.kt | 34 ++++- 14 files changed, 379 insertions(+), 81 deletions(-) create mode 100644 rs/Cargo.toml create mode 100644 rs/build.rs create mode 100644 rs/src/lib.rs create mode 100644 rs/src/repository_watcher.udl create mode 100644 rs/uniffi-bindgen.rs create mode 100644 src/main/kotlin/com/jetpackduba/gitnuro/git/workspace/GetIgnoreRulesUseCase.kt diff --git a/.gitignore b/.gitignore index a32b165..20c62d1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ build/ /captures .externalNativeBuild .cxx +rs/target/ +rs/Cargo.lock diff --git a/build.gradle.kts b/build.gradle.kts index ee3f3bf..60e3aa0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,9 +16,17 @@ plugins { val projectVersion = "1.2.1" val projectName = "Gitnuro" +val rustGeneratedSource = "${buildDir}/generated/source/uniffi/main/com/jetpackduba/gitnuro/java" + group = "com.jetpackduba" version = projectVersion +sourceSets.getByName("main") { + kotlin.sourceSets.main.get().kotlin.srcDir(rustGeneratedSource) +} + +sourceSets.main.get().java.srcDirs("src/main/resources").includes.addAll(arrayOf("**/*.*")) + repositories { mavenCentral() google() @@ -53,7 +61,7 @@ dependencies { implementation("io.github.oshai:kotlin-logging-jvm:4.0.0-beta-27") implementation("org.slf4j:slf4j-api:2.0.7") implementation("org.slf4j:slf4j-reload4j:2.0.7") - + implementation("io.arrow-kt:arrow-core:1.2.0") } fun currentOs(): OS { @@ -101,6 +109,14 @@ compose.desktop { application { mainClass = "com.jetpackduba.gitnuro.MainKt" + this@application.dependsOn("rust_generateKotlinFromUdl") + this@application.dependsOn("rust_build") + this@application.dependsOn("rust_copyBuild") + + sourceSets.forEach { + it.java.srcDir(rustGeneratedSource) + } + nativeDistributions { includeAllModules = true packageName = projectName @@ -142,4 +158,59 @@ task("fatJarLinux", type = Jar::class) { ) } with(tasks.jar.get() as CopySpec) -} \ No newline at end of file +} + + +task("rust_generateKotlinFromUdl", type = Exec::class) { + println("Generate Kotlin") + workingDir = File(project.projectDir, "rs") + commandLine = listOf( + "cargo", "run", "--features=uniffi/cli", + "--bin", "uniffi-bindgen", "generate", "src/repository_watcher.udl", + "--language", "kotlin", + "--out-dir", rustGeneratedSource + ) +} + +task("rust_build", type = Exec::class) { + println("Build rs called") + workingDir = File(project.projectDir, "rs") + commandLine = listOf( + "cargo", "build", "--release", "--features=uniffi/cli", + ) +} + +tasks.getByName("compileKotlin").dependsOn("rustTasks") +tasks.getByName("compileTestKotlin").dependsOn("rustTasks") + + +task("tasksList") { + println("Tasks") + tasks.forEach { + println("- ${it.name}") + } +} + +task("rustTasks") { + dependsOn("rust_build") + dependsOn("rust_generateKotlinFromUdl") + dependsOn("rust_copyBuild") +} + +task("rust_copyBuild", type = Exec::class) { + val outputDir = "${buildDir}/classes/kotlin/main" + println("Copy rs called") + workingDir = File(project.projectDir, "rs") + + val f = File(outputDir) + f.mkdirs() + + val lib = when (currentOs()) { + OS.LINUX -> "libuniffi_gitnuro.so" + OS.WINDOWS -> "libuniffi_gitnuro.dll" + OS.MAC -> "libuniffi_gitnuro.so" //TODO or is it a dylib? must be tested + } + commandLine = listOf( + "cp", "target/release/libgitnuro_rs.so", "$outputDir/$lib", + ) +} diff --git a/rs/Cargo.toml b/rs/Cargo.toml new file mode 100644 index 0000000..b72fb94 --- /dev/null +++ b/rs/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "gitnuro-rs" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib"] +name = "gitnuro_rs" + +[dependencies] +uniffi = { version = "0.24.1" } +notify = "6.0.1" +thiserror = "1.0.43" + +[build-dependencies] +uniffi = { version = "0.24.1", features = [ "build" ] } + +[[bin]] +# This can be whatever name makes sense for your project, but the rest of this tutorial assumes uniffi-bindgen. +name = "uniffi-bindgen" +path = "uniffi-bindgen.rs" diff --git a/rs/build.rs b/rs/build.rs new file mode 100644 index 0000000..b76debf --- /dev/null +++ b/rs/build.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::generate_scaffolding("src/repository_watcher.udl").unwrap(); +} diff --git a/rs/src/lib.rs b/rs/src/lib.rs new file mode 100644 index 0000000..777a9b3 --- /dev/null +++ b/rs/src/lib.rs @@ -0,0 +1,118 @@ +extern crate notify; + +use std::fmt::Debug; +use std::path::Path; +use std::sync::mpsc::{channel, RecvTimeoutError}; +use std::time::Duration; + +use notify::{Config, Error, ErrorKind, Event, RecommendedWatcher, RecursiveMode, Watcher}; + +uniffi::include_scaffolding!("repository_watcher"); + +fn watch_directory( + path: String, + notifier: Box, +) -> Result<(), WatcherInitError> { + // Create a channel to receive the events. + let (tx, rx) = channel(); + + // Create a watcher object, delivering debounced events. + // The notification back-end is selected based on the platform. + let config = Config::default(); + config.with_poll_interval(Duration::from_secs(3600)); + + let mut watcher = + RecommendedWatcher::new(tx, config).map_err(|err| err.kind.into_watcher_init_error())?; + + // Add a path to be watched. All files and directories at that path and + // below will be monitored for changes. + watcher + .watch(Path::new(path.as_str()), RecursiveMode::Recursive) + .map_err(|err| err.kind.into_watcher_init_error())?; + + while notifier.should_keep_looping() { + match rx.recv_timeout(Duration::from_secs(1)) { + Ok(e) => { + println!("{:?}", e); + + if let Some(paths) = get_paths_from_event_result(&e) { + notifier.detected_change(paths) + } + } + Err(e) => { + if e != RecvTimeoutError::Timeout { + println!("Watch error: {:?}", e) + } + } + } + } + + watcher + .unwatch(Path::new(path.as_str())) + .map_err(|err| err.kind.into_watcher_init_error())?; + + Ok(()) +} + +fn get_paths_from_event_result(event_result: &Result) -> Option> { + match event_result { + Ok(event) => { + let events: Vec = event + .paths + .clone() + .into_iter() + .filter_map(|path| path.into_os_string().into_string().ok()) + .collect(); + + if events.is_empty() { + None + } else { + Some(events) + } + } + Err(err) => { + println!("{:?}", err); + None + } + } +} + +pub trait WatchDirectoryNotifier: Send + Sync + Debug { + fn should_keep_looping(&self) -> bool; + fn detected_change(&self, paths: Vec); +} + +#[derive(Debug, thiserror::Error)] +pub enum WatcherInitError { + #[error("{error}")] + Generic { error: String }, + #[error("IO Error")] + Io { error: String }, + #[error("Path not found")] + PathNotFound, + #[error("Can not remove watch, it has not been found")] + WatchNotFound, + #[error("Invalid configuration")] + InvalidConfig, + #[error("Max files reached. Check the inotify limit")] + MaxFilesWatch, +} + +trait WatcherInitErrorConverter { + fn into_watcher_init_error(self) -> WatcherInitError; +} + +impl WatcherInitErrorConverter for ErrorKind { + fn into_watcher_init_error(self) -> WatcherInitError { + match self { + ErrorKind::Generic(err) => WatcherInitError::Generic { error: err }, + ErrorKind::Io(err) => WatcherInitError::Generic { + error: err.to_string(), + }, + ErrorKind::PathNotFound => WatcherInitError::PathNotFound, + ErrorKind::WatchNotFound => WatcherInitError::WatchNotFound, + ErrorKind::InvalidConfig(_) => WatcherInitError::InvalidConfig, + ErrorKind::MaxFilesWatch => WatcherInitError::MaxFilesWatch, + } + } +} diff --git a/rs/src/repository_watcher.udl b/rs/src/repository_watcher.udl new file mode 100644 index 0000000..163bab0 --- /dev/null +++ b/rs/src/repository_watcher.udl @@ -0,0 +1,20 @@ +namespace gitnuro { + [Throws=WatcherInitError] + void watch_directory(string path, WatchDirectoryNotifier checker); +}; + +callback interface WatchDirectoryNotifier { + boolean should_keep_looping(); + + void detected_change(sequence paths); +}; + +[Error] +interface WatcherInitError { + Generic(string error); + Io(string error); + PathNotFound(); + WatchNotFound(); + InvalidConfig(); + MaxFilesWatch(); +}; diff --git a/rs/uniffi-bindgen.rs b/rs/uniffi-bindgen.rs new file mode 100644 index 0000000..f6cff6c --- /dev/null +++ b/rs/uniffi-bindgen.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/git/FileChangesWatcher.kt b/src/main/kotlin/com/jetpackduba/gitnuro/git/FileChangesWatcher.kt index 29df27b..3c3494f 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/git/FileChangesWatcher.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/git/FileChangesWatcher.kt @@ -1,89 +1,59 @@ package com.jetpackduba.gitnuro.git +import com.jetpackduba.gitnuro.git.workspace.GetIgnoreRulesUseCase import com.jetpackduba.gitnuro.system.systemSeparator -import com.jetpackduba.gitnuro.logging.printLog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.launch +import kotlinx.coroutines.isActive +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -import java.io.File -import java.io.IOException -import java.nio.file.* -import java.nio.file.StandardWatchEventKinds.* -import java.nio.file.attribute.BasicFileAttributes +import org.eclipse.jgit.lib.Repository +import uniffi.gitnuro.WatchDirectoryNotifier +import uniffi.gitnuro.watchDirectory +import java.nio.file.Files +import java.nio.file.Paths import javax.inject.Inject private const val TAG = "FileChangesWatcher" -class FileChangesWatcher @Inject constructor() { - +class FileChangesWatcher @Inject constructor( + private val getIgnoreRulesUseCase: GetIgnoreRulesUseCase, +) { private val _changesNotifier = MutableSharedFlow() val changesNotifier: SharedFlow = _changesNotifier - val keys = mutableMapOf() - suspend fun watchDirectoryPath(pathStr: String, ignoredDirsPath: List) = withContext(Dispatchers.IO) { - val watchService = FileSystems.getDefault().newWatchService() - - val path = Paths.get(pathStr) - - path.register( - watchService, - ENTRY_CREATE, - ENTRY_DELETE, - ENTRY_MODIFY - ) - - // register directory and subdirectories but ignore dirs by gitignore - Files.walkFileTree(path, object : SimpleFileVisitor() { - @Throws(IOException::class) - override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult { - val isIgnoredDirectory = ignoredDirsPath.any { "$pathStr/$it" == dir.toString() } - - return if (!isIgnoredDirectory && !isGitDir(dir, pathStr)) { - val watchKey = dir.register(watchService, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY) - keys[watchKey] = dir - FileVisitResult.CONTINUE - } else { - FileVisitResult.SKIP_SUBTREE - } + suspend fun watchDirectoryPath( + repository: Repository, + pathStr: String + ) = withContext(Dispatchers.IO) { + var ignoreRules = getIgnoreRulesUseCase(repository) + val checker = object : WatchDirectoryNotifier { + override fun shouldKeepLooping(): Boolean { + return isActive } - }) - var key: WatchKey - while (watchService.take().also { key = it } != null) { - val events = key.pollEvents() + override fun detectedChange(paths: List) = runBlocking { + val hasGitIgnoreChanged = paths.any { it == "$pathStr$systemSeparator.gitignore" } - val dir = keys[key] ?: return@withContext + if (hasGitIgnoreChanged) { + ignoreRules = getIgnoreRulesUseCase(repository) + } - _changesNotifier.emit(false) - - // Check if new directories have been added to add them to the watchService - launch(Dispatchers.IO) { - for (event in events) { - if (event.kind() == ENTRY_CREATE) { - try { - val eventFile = File(dir.toAbsolutePath().toString() + systemSeparator + event.context()) - - if (eventFile.isDirectory) { - val eventPath = eventFile.toPath() - printLog(TAG, "New directory $eventFile detected, adding it to watchService") - val watchKey = - eventPath.register(watchService, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY) - keys[watchKey] = eventPath - } - } catch (ex: Exception) { - ex.printStackTrace() - } + val areAllPathsIgnored = paths.all { path -> + ignoreRules.any { rule -> + rule.isMatch(path, Files.isDirectory(Paths.get(path))) } } + + val hasGitDirChanged = paths.any { it == "$pathStr$systemSeparator.git" } + + if (!areAllPathsIgnored) { + _changesNotifier.emit(hasGitDirChanged) + } } - - key.reset() } - } - private fun isGitDir(dir: Path, pathStr: String): Boolean { - return dir.startsWith("$pathStr$systemSeparator.git$systemSeparator") + watchDirectory(pathStr, checker) } } \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/git/TabState.kt b/src/main/kotlin/com/jetpackduba/gitnuro/git/TabState.kt index f6af0f3..3fa6b4d 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/git/TabState.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/git/TabState.kt @@ -142,7 +142,7 @@ class TabState @Inject constructor( val containsCancellation = exceptionContainsCancellation(ex) if (showError && !containsCancellation) - errorsManager.addError(newErrorNow(ex, ex.message.orEmpty())) + errorsManager.addError(newErrorNow(ex, null, ex.message.orEmpty())) printError(TAG, ex.message.orEmpty(), ex) } finally { @@ -190,7 +190,7 @@ class TabState @Inject constructor( val containsCancellation = exceptionContainsCancellation(ex) if (showError && !containsCancellation) - errorsManager.addError(newErrorNow(ex, ex.localizedMessage)) + errorsManager.addError(newErrorNow(ex, null, ex.localizedMessage)) printError(TAG, ex.message.orEmpty(), ex) } finally { @@ -221,7 +221,7 @@ class TabState @Inject constructor( hasProcessFailed = true if (showError) - errorsManager.addError(newErrorNow(ex, ex.localizedMessage)) + errorsManager.addError(newErrorNow(ex, null, ex.localizedMessage)) printError(TAG, ex.message.orEmpty(), ex) } finally { @@ -284,7 +284,7 @@ class TabState @Inject constructor( callback(it) } catch (ex: Exception) { ex.printStackTrace() - errorsManager.addError(newErrorNow(ex, ex.localizedMessage)) + errorsManager.addError(newErrorNow(ex, null, ex.localizedMessage)) } } } diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/git/workspace/GetIgnoreRulesUseCase.kt b/src/main/kotlin/com/jetpackduba/gitnuro/git/workspace/GetIgnoreRulesUseCase.kt new file mode 100644 index 0000000..d0a7cda --- /dev/null +++ b/src/main/kotlin/com/jetpackduba/gitnuro/git/workspace/GetIgnoreRulesUseCase.kt @@ -0,0 +1,51 @@ +package com.jetpackduba.gitnuro.git.workspace + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.eclipse.jgit.ignore.FastIgnoreRule +import org.eclipse.jgit.lib.Config +import org.eclipse.jgit.lib.Repository +import java.io.File +import java.nio.file.FileSystems +import javax.inject.Inject + + +class GetIgnoreRulesUseCase @Inject constructor() { + suspend operator fun invoke(repository: Repository): List = withContext(Dispatchers.IO) { + val ignoreLines = mutableListOf() + + val repositoryExcludeFile = File(repository.directory, ".git/info/exclude") + val ignoreFile = File(repository.workTree, ".gitignore") + + repository.config.load() + val baseConfig: Config? = repository.config.baseConfig + + if (repositoryExcludeFile.exists() && repositoryExcludeFile.isFile) { + ignoreLines.addAll(repositoryExcludeFile.readLines()) + } + + if (ignoreFile.exists() && ignoreFile.isFile) { + ignoreLines.addAll(ignoreFile.readLines()) + } + + if (baseConfig != null) { + var excludesFilePath = baseConfig.getString("core", null, "excludesFile") + + if (excludesFilePath.startsWith("~")) { + excludesFilePath = excludesFilePath.replace("~", System.getProperty("user.home").orEmpty()) + } + + val excludesFile = FileSystems + .getDefault() + .getPath(excludesFilePath) + .normalize() + .toFile() + + if (excludesFile.exists() && excludesFile.isFile) { + ignoreLines.addAll(excludesFile.readLines()) + } + } + + ignoreLines.map { FastIgnoreRule(it) } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/managers/ErrorsManager.kt b/src/main/kotlin/com/jetpackduba/gitnuro/managers/ErrorsManager.kt index feeeb51..30c8134 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/managers/ErrorsManager.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/managers/ErrorsManager.kt @@ -34,6 +34,22 @@ class ErrorsManager @Inject constructor() { } -data class Error(val date: Long, val exception: Exception, val message: String) +data class Error( + val date: Long, + val exception: Exception, + val title: String?, + val message: String +) -fun newErrorNow(exception: Exception, message: String) = Error(System.currentTimeMillis(), exception, message) \ No newline at end of file +fun newErrorNow( + exception: Exception, + title: String?, + message: String, +): Error { + return Error( + date = System.currentTimeMillis(), + exception = exception, + title = title, + message = message + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/dialogs/ErrorDialog.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/dialogs/ErrorDialog.kt index 09065fd..d58b1f0 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/dialogs/ErrorDialog.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/dialogs/ErrorDialog.kt @@ -27,7 +27,7 @@ fun ErrorDialog( ) { Row { Text( - text = "Error", + text = error.title ?: "Error", fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colors.onBackground, diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/SettingsViewModel.kt b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/SettingsViewModel.kt index e2095a8..89b2f61 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/SettingsViewModel.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/SettingsViewModel.kt @@ -69,11 +69,10 @@ class SettingsViewModel @Inject constructor( null } catch (ex: Exception) { ex.printStackTrace() - newErrorNow(ex, "Failed to parse selected theme JSON. Please check if it's valid and try again.") + newErrorNow(ex, "Saving theme failed", "Failed to parse selected theme JSON. Please check if it's valid and try again.") } } - fun resetInfo() { commitsLimit = appSettings.commitsLimit } diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModel.kt b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModel.kt index a5de2fe..c947a83 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModel.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModel.kt @@ -36,6 +36,7 @@ import org.eclipse.jgit.blame.BlameResult import org.eclipse.jgit.lib.Repository import org.eclipse.jgit.lib.RepositoryState import org.eclipse.jgit.revwalk.RevCommit +import uniffi.gitnuro.WatcherInitException import java.awt.Desktop import java.io.File import javax.inject.Inject @@ -181,7 +182,7 @@ class TabViewModel @Inject constructor( } catch (ex: Exception) { onRepositoryChanged(null) ex.printStackTrace() - errorsManager.addError(newErrorNow(ex, ex.localizedMessage)) + errorsManager.addError(newErrorNow(ex, null, ex.localizedMessage)) _repositorySelectionStatus.value = RepositorySelectionStatus.None } } @@ -207,7 +208,6 @@ class TabViewModel @Inject constructor( } private suspend fun watchRepositoryChanges(git: Git) = tabScope.launch(Dispatchers.IO) { - val ignored = git.status().call().ignoredNotInIndex.toList() var asyncJob: Job? = null var lastNotify = 0L var hasGitDirChanged = false @@ -249,10 +249,32 @@ class TabViewModel @Inject constructor( } } } - fileChangesWatcher.watchDirectoryPath( - pathStr = git.repository.workTree.absolutePath, - ignoredDirsPath = ignored, - ) + + try { + fileChangesWatcher.watchDirectoryPath( + repository = git.repository, + pathStr = git.repository.workTree.absolutePath, + ) + } catch (ex: WatcherInitException) { + val message = when (ex) { + is WatcherInitException.Generic -> ex.error + is WatcherInitException.InvalidConfig -> "Invalid configuration" + is WatcherInitException.Io -> ex.error + is WatcherInitException.MaxFilesWatch -> "Reached the limit of files that can be watched. Please increase the system inotify limit to be able to detect the changes on this repository." + is WatcherInitException.PathNotFound -> "Path not found, check if your repository still exists" + is WatcherInitException.WatchNotFound -> null // This should never trigger as we don't unwatch files + } + + if(message != null) { + errorsManager.addError( + newErrorNow( + exception = ex, + title = "Repository changes detection has stopped working", + message = message, + ), + ) + } + } } suspend fun updateApp(hasGitDirChanged: Boolean) {