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
This commit is contained in:
Abdelilah El Aissaoui 2023-07-14 14:28:29 +02:00
parent 70597aa16a
commit 7a7eb3ad93
No known key found for this signature in database
GPG Key ID: 7587FC860F594869
14 changed files with 379 additions and 81 deletions

2
.gitignore vendored
View File

@ -13,3 +13,5 @@ build/
/captures
.externalNativeBuild
.cxx
rs/target/
rs/Cargo.lock

View File

@ -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
@ -143,3 +159,58 @@ task("fatJarLinux", type = Jar::class) {
}
with(tasks.jar.get() as CopySpec)
}
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",
)
}

23
rs/Cargo.toml Normal file
View File

@ -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"

3
rs/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
uniffi::generate_scaffolding("src/repository_watcher.udl").unwrap();
}

118
rs/src/lib.rs Normal file
View File

@ -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<dyn WatchDirectoryNotifier>,
) -> 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<Event, Error>) -> Option<Vec<String>> {
match event_result {
Ok(event) => {
let events: Vec<String> = 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<String>);
}
#[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,
}
}
}

View File

@ -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<string> paths);
};
[Error]
interface WatcherInitError {
Generic(string error);
Io(string error);
PathNotFound();
WatchNotFound();
InvalidConfig();
MaxFilesWatch();
};

3
rs/uniffi-bindgen.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
uniffi::uniffi_bindgen_main()
}

View File

@ -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<Boolean>()
val changesNotifier: SharedFlow<Boolean> = _changesNotifier
val keys = mutableMapOf<WatchKey, Path>()
suspend fun watchDirectoryPath(pathStr: String, ignoredDirsPath: List<String>) = 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<Path>() {
@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
}
override fun detectedChange(paths: List<String>) = runBlocking {
val hasGitIgnoreChanged = paths.any { it == "$pathStr$systemSeparator.gitignore" }
if (hasGitIgnoreChanged) {
ignoreRules = getIgnoreRulesUseCase(repository)
}
})
var key: WatchKey
while (watchService.take().also { key = it } != null) {
val events = key.pollEvents()
val dir = keys[key] ?: return@withContext
_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)))
}
}
key.reset()
val hasGitDirChanged = paths.any { it == "$pathStr$systemSeparator.git" }
if (!areAllPathsIgnored) {
_changesNotifier.emit(hasGitDirChanged)
}
}
}
private fun isGitDir(dir: Path, pathStr: String): Boolean {
return dir.startsWith("$pathStr$systemSeparator.git$systemSeparator")
watchDirectory(pathStr, checker)
}
}

View File

@ -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))
}
}
}

View File

@ -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<FastIgnoreRule> = withContext(Dispatchers.IO) {
val ignoreLines = mutableListOf<String>()
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) }
}
}

View File

@ -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)
fun newErrorNow(
exception: Exception,
title: String?,
message: String,
): Error {
return Error(
date = System.currentTimeMillis(),
exception = exception,
title = title,
message = message
)
}

View File

@ -27,7 +27,7 @@ fun ErrorDialog(
) {
Row {
Text(
text = "Error",
text = error.title ?: "Error",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colors.onBackground,

View File

@ -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
}

View File

@ -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(
}
}
}
try {
fileChangesWatcher.watchDirectoryPath(
repository = git.repository,
pathStr = git.repository.workTree.absolutePath,
ignoredDirsPath = ignored,
)
} 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) {