Added caching of HTTP Credentials

Fixed #75
This commit is contained in:
Abdelilah El Aissaoui 2023-08-12 15:42:21 +02:00
parent 0c3ced89b4
commit 9264d2cb7a
23 changed files with 489 additions and 241 deletions

View File

@ -40,6 +40,7 @@ object AppIcons {
const val NETWORK = "network.svg"
const val OPEN = "open.svg"
const val PALETTE = "palette.svg"
const val PASSWORD = "password.svg"
const val PASTE = "paste.svg"
const val PERSON = "person.svg"
const val REFRESH = "refresh.svg"

View File

@ -0,0 +1,105 @@
package com.jetpackduba.gitnuro.credentials
import com.jetpackduba.gitnuro.extensions.lockUse
import kotlinx.coroutines.sync.Mutex
import org.eclipse.jgit.util.Base64
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import javax.inject.Inject
import javax.inject.Singleton
private const val KEY_LENGTH = 16
@Singleton
class CredentialsCacheRepository @Inject constructor() {
private val credentialsCached = mutableListOf<CredentialsType>()
private val credentialsLock = Mutex(false)
// having a random key to encrypt the password may help in case of a memory dump attack
private val encryptionKey = getRandomKey()
fun getCachedHttpCredentials(url: String): CredentialsType.HttpCredentials? {
val credentials = credentialsCached.filterIsInstance<CredentialsType.HttpCredentials>().firstOrNull {
it.url == url
}
return credentials?.copy(password = credentials.password.cipherDecrypt())
}
suspend fun cacheHttpCredentials(credentials: CredentialsType.HttpCredentials) {
cacheHttpCredentials(credentials.url, credentials.userName, credentials.password)
}
suspend fun cacheHttpCredentials(url: String, userName: String, password: String) {
val passwordEncrypted = password.cipherEncrypt()
credentialsLock.lockUse {
val previouslyCached = credentialsCached.any {
it is CredentialsType.HttpCredentials && it.url == url
}
if (!previouslyCached) {
val credentials = CredentialsType.HttpCredentials(url, userName, passwordEncrypted)
credentialsCached.add(credentials)
}
}
}
suspend fun cacheSshCredentials(sshKey: String, password: String) {
credentialsLock.lockUse {
val previouslyCached = credentialsCached.any {
it is CredentialsType.SshCredentials && it.sshKey == sshKey
}
if (!previouslyCached) {
val credentials = CredentialsType.SshCredentials(sshKey, password)
credentialsCached.add(credentials)
}
}
}
private fun String.cipherEncrypt(): String {
val secretKeySpec = SecretKeySpec(encryptionKey.toByteArray(), "AES")
val iv = encryptionKey.toByteArray()
val ivParameterSpec = IvParameterSpec(iv)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec)
val encryptedValue = cipher.doFinal(this.toByteArray())
return Base64.encodeBytes(encryptedValue)
}
private fun String.cipherDecrypt(): String {
val secretKeySpec = SecretKeySpec(encryptionKey.toByteArray(), "AES")
val iv = encryptionKey.toByteArray()
val ivParameterSpec = IvParameterSpec(iv)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec)
val decodedValue = Base64.decode(this)
val decryptedValue = cipher.doFinal(decodedValue)
return String(decryptedValue)
}
private fun getRandomKey(): String {
val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9')
return (1..KEY_LENGTH)
.map { allowedChars.random() }
.joinToString("")
}
}
sealed interface CredentialsType {
data class SshCredentials(
val sshKey: String,
val password: String,
) : CredentialsType
data class HttpCredentials(
val url: String,
val userName: String,
val password: String,
) : CredentialsType
}

View File

@ -1,5 +1,6 @@
package com.jetpackduba.gitnuro.credentials
import com.jetpackduba.gitnuro.git.remote_operations.CredentialsCache
import org.eclipse.jgit.transport.CredentialsProvider
import org.eclipse.jgit.transport.RemoteSession
import org.eclipse.jgit.transport.SshSessionFactory
@ -9,26 +10,33 @@ import javax.inject.Inject
import javax.inject.Provider
class GSessionManager @Inject constructor(
private val sessionProvider: Provider<GRemoteSession>
private val mySessionFactory: MySessionFactory,
) {
fun generateSshSessionFactory(): SshSessionFactory {
return object : SshSessionFactory() {
override fun getSession(
uri: URIish,
credentialsProvider: CredentialsProvider?,
fs: FS?,
tms: Int
): RemoteSession {
val remoteSession = sessionProvider.get()
remoteSession.setup(uri)
return remoteSession
}
override fun getType(): String {
return "ssh" //TODO What should be the value of this?
}
}
fun generateSshSessionFactory(): MySessionFactory {
return mySessionFactory
}
}
class MySessionFactory @Inject constructor(
private val sessionProvider: Provider<GRemoteSession>
) : SshSessionFactory(), CredentialsCache {
override fun getSession(
uri: URIish,
credentialsProvider: CredentialsProvider?,
fs: FS?,
tms: Int
): RemoteSession {
val remoteSession = sessionProvider.get()
remoteSession.setup(uri)
return remoteSession
}
override fun getType(): String {
return "ssh" //TODO What should be the value of this?
}
override suspend fun cacheCredentialsIfNeeded() {
// Nothing to do until we add some kind of password cache for SSHKeys
}
}

View File

@ -1,7 +1,9 @@
package com.jetpackduba.gitnuro.credentials
import com.jetpackduba.gitnuro.exceptions.NotSupportedHelper
import com.jetpackduba.gitnuro.git.remote_operations.CredentialsCache
import com.jetpackduba.gitnuro.managers.IShellManager
import com.jetpackduba.gitnuro.preferences.AppSettings
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.eclipse.jgit.api.Git
@ -18,8 +20,13 @@ private const val TIMEOUT_MIN = 1L
class HttpCredentialsProvider @AssistedInject constructor(
private val credentialsStateManager: CredentialsStateManager,
private val shellManager: IShellManager,
private val appSettings: AppSettings,
private val credentialsCacheRepository: CredentialsCacheRepository,
@Assisted val git: Git?,
) : CredentialsProvider() {
) : CredentialsProvider(), CredentialsCache {
private var credentialsCached: CredentialsType.HttpCredentials? = null
override fun isInteractive(): Boolean {
return true
}
@ -45,15 +52,32 @@ class HttpCredentialsProvider @AssistedInject constructor(
val externalCredentialsHelper = getExternalCredentialsHelper(uri, git)
if (externalCredentialsHelper == null) {
val credentials = askForCredentials()
val cachedCredentials = credentialsCacheRepository.getCachedHttpCredentials(uri.toString())
if (credentials is CredentialsAccepted.HttpCredentialsAccepted) {
userItem.value = credentials.user
passwordItem.value = credentials.password.toCharArray()
if (cachedCredentials == null) {
val credentials = askForCredentials()
if (credentials is CredentialsAccepted.HttpCredentialsAccepted) {
userItem.value = credentials.user
passwordItem.value = credentials.password.toCharArray()
if (appSettings.cacheCredentialsInMemory) {
credentialsCached = CredentialsType.HttpCredentials(
url = uri.toString(),
userName = credentials.user,
password = credentials.password,
)
}
return true
} else if (credentials is CredentialsState.CredentialsDenied) {
throw CancellationException("Credentials denied")
}
} else {
userItem.value = cachedCredentials.userName
passwordItem.value = cachedCredentials.password.toCharArray()
return true
} else if (credentials is CredentialsState.CredentialsDenied) {
throw CancellationException("Credentials denied")
}
return false
@ -153,26 +177,26 @@ class HttpCredentialsProvider @AssistedInject constructor(
}
bufferedReader.use {
var line: String
while (bufferedReader.readLine().also {
line = checkNotNull(it) { "Cancelled authentication" }
} != null && !(usernameSet && passwordSet)) {
if (line.startsWith("username=")) {
val split = line.split("=")
var line: String?
while (bufferedReader.readLine().also { line = it } != null && !(usernameSet && passwordSet)) {
val safeLine = line ?: continue
if (safeLine.startsWith("username=")) {
val split = safeLine.split("=")
val userName = split.getOrNull(1) ?: return ExternalCredentialsRequestResult.CREDENTIALS_NOT_STORED
val userNameItem = items.firstOrNull { it.promptText == "Username" }
val userNameItem = items.firstOrNull { it is CredentialItem.Username }
if (userNameItem is CredentialItem.Username) {
userNameItem.value = userName
usernameSet = true
}
} else if (line.startsWith("password=")) {
val split = line.split("=")
} else if (safeLine.startsWith("password=")) {
val split = safeLine.split("=")
val password = split.getOrNull(1) ?: return ExternalCredentialsRequestResult.CREDENTIALS_NOT_STORED
val passwordItem = items.firstOrNull { it.promptText == "Password" }
val passwordItem = items.firstOrNull { it is CredentialItem.Password }
if (passwordItem is CredentialItem.Password) {
passwordItem.value = password.toCharArray()
@ -182,9 +206,9 @@ class HttpCredentialsProvider @AssistedInject constructor(
}
}
return if (usernameSet && passwordSet)
return if (usernameSet && passwordSet) {
ExternalCredentialsRequestResult.SUCCESS
else
} else
ExternalCredentialsRequestResult.CREDENTIALS_NOT_STORED
}
@ -206,7 +230,7 @@ class HttpCredentialsProvider @AssistedInject constructor(
val genericCredentialHelper = config.getString("credential", null, "helper")
val uriSpecificCredentialHelper = config.getString("credential", hostWithProtocol, "helper")
var credentialHelperPath = uriSpecificCredentialHelper ?: genericCredentialHelper ?: return null
val credentialHelperPath = uriSpecificCredentialHelper ?: genericCredentialHelper ?: return null
if (credentialHelperPath == "cache" || credentialHelperPath == "store") {
throw NotSupportedHelper("Invalid credentials helper: \"$credentialHelperPath\" is not yet supported")
@ -225,6 +249,12 @@ class HttpCredentialsProvider @AssistedInject constructor(
return ExternalCredentialsHelper(credentialHelperPath, useHttpPath)
}
override suspend fun cacheCredentialsIfNeeded() {
credentialsCached?.let {
credentialsCacheRepository.cacheHttpCredentials(it)
}
}
}
data class ExternalCredentialsHelper(

View File

@ -2,6 +2,7 @@ package com.jetpackduba.gitnuro.di
import com.jetpackduba.gitnuro.App
import com.jetpackduba.gitnuro.AppEnvInfo
import com.jetpackduba.gitnuro.credentials.CredentialsCacheRepository
import com.jetpackduba.gitnuro.credentials.CredentialsStateManager
import com.jetpackduba.gitnuro.di.modules.AppModule
import com.jetpackduba.gitnuro.di.modules.NetworkModule
@ -44,4 +45,6 @@ interface AppComponent {
fun tempFilesManager(): TempFilesManager
fun updatesRepository(): UpdatesRepository
fun credentialsCacheRepository(): CredentialsCacheRepository
}

View File

@ -0,0 +1,13 @@
package com.jetpackduba.gitnuro.extensions
import kotlinx.coroutines.sync.Mutex
suspend fun <T> Mutex.lockUse(block: () -> T): T {
this.lock()
try {
return block()
} finally {
this.unlock()
}
}

View File

@ -26,46 +26,47 @@ class CloneRepositoryUseCase @Inject constructor(
try {
ensureActive()
trySend(CloneState.Cloning("Starting...", progress, lastTotalWork))
handleTransportUseCase(null) {
Git.cloneRepository()
.setDirectory(directory)
.setURI(url)
.setProgressMonitor(
object : ProgressMonitor {
override fun start(totalTasks: Int) {
printDebug(TAG, "ProgressMonitor Start with total tasks of: $totalTasks")
}
Git.cloneRepository()
.setDirectory(directory)
.setURI(url)
.setProgressMonitor(
object : ProgressMonitor {
override fun start(totalTasks: Int) {
printDebug(TAG, "ProgressMonitor Start with total tasks of: $totalTasks")
override fun beginTask(title: String?, totalWork: Int) {
println("ProgressMonitor Begin task with title: $title")
lastTitle = title.orEmpty()
lastTotalWork = totalWork
progress = 0
trySend(CloneState.Cloning(lastTitle, progress, lastTotalWork))
}
override fun update(completed: Int) {
printDebug(TAG, "ProgressMonitor Update $completed")
ensureActive()
progress += completed
trySend(CloneState.Cloning(lastTitle, progress, lastTotalWork))
}
override fun endTask() {
printDebug(TAG, "ProgressMonitor End task")
}
override fun isCancelled(): Boolean {
return !isActive
}
override fun showDuration(enabled: Boolean) {}
}
override fun beginTask(title: String?, totalWork: Int) {
println("ProgressMonitor Begin task with title: $title")
lastTitle = title.orEmpty()
lastTotalWork = totalWork
progress = 0
trySend(CloneState.Cloning(lastTitle, progress, lastTotalWork))
}
override fun update(completed: Int) {
printDebug(TAG, "ProgressMonitor Update $completed")
ensureActive()
progress += completed
trySend(CloneState.Cloning(lastTitle, progress, lastTotalWork))
}
override fun endTask() {
printDebug(TAG, "ProgressMonitor End task")
}
override fun isCancelled(): Boolean {
return !isActive
}
override fun showDuration(enabled: Boolean) {}
}
)
.setTransportConfigCallback { handleTransportUseCase(it, null) }
.setCloneSubmodules(cloneSubmodules)
.call()
)
.setTransportConfigCallback { handleTransport(it) }
.setCloneSubmodules(cloneSubmodules)
.call()
}
ensureActive()
trySend(CloneState.Completed(directory))

View File

@ -25,35 +25,33 @@ class DeleteRemoteBranchUseCase @Inject constructor(
.setSource(null)
.setDestination(branchName)
val pushResults = git.push()
.setTransportConfigCallback {
handleTransportUseCase(it, git)
handleTransportUseCase(git) {
val pushResults = git.push()
.setTransportConfigCallback {
handleTransport(it)
}
.setRefSpecs(refSpec)
.setRemote(remoteName)
.call()
val results = pushResults.map { pushResult ->
pushResult.remoteUpdates.filter { remoteRefUpdate ->
remoteRefUpdate.status.isRejected
}
}.flatten()
if (results.isNotEmpty()) {
val error = StringBuilder()
results.forEach { result ->
error.append(result.statusMessage)
error.append("\n")
}
throw Exception(error.toString())
}
.setRefSpecs(refSpec)
.setRemote(remoteName)
.call()
val results = pushResults.map { pushResult ->
pushResult.remoteUpdates.filter { remoteRefUpdate ->
remoteRefUpdate.status.isRejected
}
}.flatten()
if (results.isNotEmpty()) {
val error = StringBuilder()
results.forEach { result ->
error.append(result.statusMessage)
error.append("\n")
}
throw Exception(error.toString())
}
deleteBranchUseCase(git, ref)
// git
// .branchDelete()
// .setBranchNames(ref.name)
// .call()
}
}

View File

@ -23,26 +23,28 @@ class FetchAllBranchesUseCase @Inject constructor(
val errors = mutableListOf<Pair<RemoteConfig, Exception>>()
for (remote in remotes) {
try {
git.fetch()
.setRemote(remote.name)
.setRefSpecs(remote.fetchRefSpecs)
.setRemoveDeletedRefs(true)
.setTransportConfigCallback { handleTransportUseCase(it, git) }
.setCredentialsProvider(CredentialsProvider.getDefault())
.setProgressMonitor(object : ProgressMonitor {
override fun start(totalTasks: Int) {}
handleTransportUseCase(git) {
git.fetch()
.setRemote(remote.name)
.setRefSpecs(remote.fetchRefSpecs)
.setRemoveDeletedRefs(true)
.setTransportConfigCallback { handleTransport(it) }
.setCredentialsProvider(CredentialsProvider.getDefault())
.setProgressMonitor(object : ProgressMonitor {
override fun start(totalTasks: Int) {}
override fun beginTask(title: String?, totalWork: Int) {}
override fun beginTask(title: String?, totalWork: Int) {}
override fun update(completed: Int) {}
override fun update(completed: Int) {}
override fun endTask() {}
override fun endTask() {}
override fun isCancelled(): Boolean = isActive
override fun isCancelled(): Boolean = isActive
override fun showDuration(enabled: Boolean) {}
})
.call()
override fun showDuration(enabled: Boolean) {}
})
.call()
}
} catch (ex: Exception) {
printError(TAG, "Fetch failed for remote ${remote.name} with error ${ex.message}", ex)

View File

@ -12,11 +12,39 @@ class HandleTransportUseCase @Inject constructor(
private val sessionManager: GSessionManager,
private val httpCredentialsProvider: HttpCredentialsFactory,
) {
operator fun invoke(transport: Transport?, git: Git?) {
if (transport is SshTransport) {
transport.sshSessionFactory = sessionManager.generateSshSessionFactory()
} else if (transport is HttpTransport) {
transport.credentialsProvider = httpCredentialsProvider.create(git)
suspend operator fun invoke(git: Git?, block: suspend CredentialsHandler.() -> Unit) {
var cache: CredentialsCache? = null
val credentialsHandler = object: CredentialsHandler {
override fun handleTransport(transport: Transport?) {
cache = when (transport) {
is SshTransport -> {
val sshSessionFactory = sessionManager.generateSshSessionFactory()
transport.sshSessionFactory = sshSessionFactory
sshSessionFactory
}
is HttpTransport -> {
val httpCredentials = httpCredentialsProvider.create(git)
transport.credentialsProvider = httpCredentials
httpCredentials
}
else -> {
null
}
}
}
}
credentialsHandler.block()
cache?.cacheCredentialsIfNeeded()
}
}
interface CredentialsCache {
suspend fun cacheCredentialsIfNeeded()
}
interface CredentialsHandler {
fun handleTransport(transport: Transport?)
}

View File

@ -4,6 +4,7 @@ import com.jetpackduba.gitnuro.preferences.AppSettings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.api.PullResult
import org.eclipse.jgit.api.RebaseResult
import org.eclipse.jgit.transport.CredentialsProvider
import javax.inject.Inject
@ -19,25 +20,27 @@ class PullBranchUseCase @Inject constructor(
PullType.DEFAULT -> appSettings.pullRebase
}
val pullResult = git
.pull()
.setTransportConfigCallback { handleTransportUseCase(it, git) }
.setRebase(pullWithRebase)
.setCredentialsProvider(CredentialsProvider.getDefault())
.call()
handleTransportUseCase(git) {
val pullResult = git
.pull()
.setTransportConfigCallback {this.handleTransport(it) }
.setRebase(pullWithRebase)
.setCredentialsProvider(CredentialsProvider.getDefault())
.call()
if (!pullResult.isSuccessful) {
var message = "Pull failed"
if (!pullResult.isSuccessful) {
var message = "Pull failed"
if (pullWithRebase) {
message = when (pullResult.rebaseResult.status) {
RebaseResult.Status.UNCOMMITTED_CHANGES -> "The pull with rebase has failed because you have got uncommited changes"
RebaseResult.Status.CONFLICTS -> "Pull with rebase has conflicts, fix them to continue"
else -> message
if (pullWithRebase) {
message = when (pullResult.rebaseResult.status) {
RebaseResult.Status.UNCOMMITTED_CHANGES -> "The pull with rebase has failed because you have got uncommited changes"
RebaseResult.Status.CONFLICTS -> "Pull with rebase has conflicts, fix them to continue"
else -> message
}
}
}
throw Exception(message)
throw Exception(message)
}
}
}
}

View File

@ -14,27 +14,30 @@ class PullFromSpecificBranchUseCase @Inject constructor(
private val handleTransportUseCase: HandleTransportUseCase,
) {
suspend operator fun invoke(git: Git, rebase: Boolean, remoteBranch: Ref) = withContext(Dispatchers.IO) {
val pullResult = git
.pull()
.setTransportConfigCallback { handleTransportUseCase(it, git) }
.setRemote(remoteBranch.remoteName)
.setRemoteBranchName(remoteBranch.simpleName)
.setRebase(rebase)
.setCredentialsProvider(CredentialsProvider.getDefault())
.call()
handleTransportUseCase(git) {
val pullResult = git
.pull()
.setTransportConfigCallback { handleTransport(it) }
.setRemote(remoteBranch.remoteName)
.setRemoteBranchName(remoteBranch.simpleName)
.setRebase(rebase)
.setCredentialsProvider(CredentialsProvider.getDefault())
.call()
if (!pullResult.isSuccessful) {
var message = "Pull failed" // TODO Remove messages from here and pass the result to a custom exception type
if (!pullResult.isSuccessful) {
var message =
"Pull failed" // TODO Remove messages from here and pass the result to a custom exception type
if (rebase) {
message = when (pullResult.rebaseResult.status) {
RebaseResult.Status.UNCOMMITTED_CHANGES -> "The pull with rebase has failed because you have got uncommited changes"
RebaseResult.Status.CONFLICTS -> "Pull with rebase has conflicts, fix them to continue"
else -> message
if (rebase) {
message = when (pullResult.rebaseResult.status) {
RebaseResult.Status.UNCOMMITTED_CHANGES -> "The pull with rebase has failed because you have got uncommited changes"
RebaseResult.Status.CONFLICTS -> "Pull with rebase has conflicts, fix them to continue"
else -> message
}
}
}
throw Exception(message)
throw Exception(message)
}
}
}
}

View File

@ -1,6 +1,7 @@
package com.jetpackduba.gitnuro.git.remote_operations
import com.jetpackduba.gitnuro.git.branches.GetTrackingBranchUseCase
import com.jetpackduba.gitnuro.git.branches.TrackingBranch
import com.jetpackduba.gitnuro.git.isRejected
import com.jetpackduba.gitnuro.git.statusMessage
import com.jetpackduba.gitnuro.preferences.AppSettings
@ -27,7 +28,18 @@ class PushBranchUseCase @Inject constructor(
} else {
currentBranch
}
handleTransportUseCase(git) {
push(git, tracking, refSpecStr, force, pushTags)
}
}
private suspend fun CredentialsHandler.push(
git: Git,
tracking: TrackingBranch?,
refSpecStr: String?,
force: Boolean,
pushTags: Boolean
) = withContext(Dispatchers.IO) {
val pushResult = git
.push()
.setRefSpecs(RefSpec(refSpecStr))
@ -66,7 +78,7 @@ class PushBranchUseCase @Inject constructor(
this
}
}
.setTransportConfigCallback { handleTransportUseCase(it, git) }
.setTransportConfigCallback { handleTransport(it) }
.setProgressMonitor(object : ProgressMonitor {
override fun start(totalTasks: Int) {}
override fun beginTask(title: String?, totalWork: Int) {}
@ -105,5 +117,4 @@ class PushBranchUseCase @Inject constructor(
throw Exception(error.toString())
}
}
}

View File

@ -18,30 +18,32 @@ class PushToSpecificBranchUseCase @Inject constructor(
withContext(Dispatchers.IO) {
val currentBranchRefSpec = git.repository.fullBranch
val pushResult = git
.push()
.setRefSpecs(RefSpec("$currentBranchRefSpec:${remoteBranch.simpleName}"))
.setRemote(remoteBranch.remoteName)
.setForce(force)
.apply {
if (pushTags)
setPushTags()
handleTransportUseCase(git) {
val pushResult = git
.push()
.setRefSpecs(RefSpec("$currentBranchRefSpec:${remoteBranch.simpleName}"))
.setRemote(remoteBranch.remoteName)
.setForce(force)
.apply {
if (pushTags)
setPushTags()
}
.setTransportConfigCallback { handleTransport(it) }
.call()
val results =
pushResult.map { it.remoteUpdates.filter { remoteRefUpdate -> remoteRefUpdate.status.isRejected } }
.flatten()
if (results.isNotEmpty()) {
val error = StringBuilder()
results.forEach { result ->
error.append(result.statusMessage)
error.append("\n")
}
throw Exception(error.toString())
}
.setTransportConfigCallback { handleTransportUseCase(it, git) }
.call()
val results =
pushResult.map { it.remoteUpdates.filter { remoteRefUpdate -> remoteRefUpdate.status.isRejected } }
.flatten()
if (results.isNotEmpty()) {
val error = StringBuilder()
results.forEach { result ->
error.append(result.statusMessage)
error.append("\n")
}
throw Exception(error.toString())
}
}
}

View File

@ -13,22 +13,24 @@ class AddSubmoduleUseCase @Inject constructor(
private val handleTransportUseCase: HandleTransportUseCase,
) {
suspend operator fun invoke(git: Git, name: String, path: String, uri: String): Unit = withContext(Dispatchers.IO) {
git.submoduleAdd()
.setName(name)
.setPath(path)
.setURI(uri)
.setTransportConfigCallback { handleTransportUseCase(it, git) }
.setCredentialsProvider(CredentialsProvider.getDefault())
.setProgressMonitor(object : ProgressMonitor {
override fun start(totalTasks: Int) {}
override fun beginTask(title: String?, totalWork: Int) {}
override fun update(completed: Int) {}
override fun endTask() {}
override fun showDuration(enabled: Boolean) {}
handleTransportUseCase(git) {
git.submoduleAdd()
.setName(name)
.setPath(path)
.setURI(uri)
.setTransportConfigCallback { handleTransport(it) }
.setCredentialsProvider(CredentialsProvider.getDefault())
.setProgressMonitor(object : ProgressMonitor {
override fun start(totalTasks: Int) {}
override fun beginTask(title: String?, totalWork: Int) {}
override fun update(completed: Int) {}
override fun endTask() {}
override fun showDuration(enabled: Boolean) {}
override fun isCancelled() = !isActive
override fun isCancelled() = !isActive
})
.call()
})
.call()
}
}
}

View File

@ -16,32 +16,34 @@ class UpdateSubmoduleUseCase @Inject constructor(
private val handleTransportUseCase: HandleTransportUseCase,
) {
suspend operator fun invoke(git: Git, path: String) = withContext(Dispatchers.IO) {
git.submoduleUpdate()
.addPath(path)
.setCallback(
object : CloneCommand.Callback {
override fun initializedSubmodules(submodules: MutableCollection<String>?) {
handleTransportUseCase(git) {
git.submoduleUpdate()
.addPath(path)
.setCallback(
object : CloneCommand.Callback {
override fun initializedSubmodules(submodules: MutableCollection<String>?) {
}
override fun cloningSubmodule(path: String?) {
}
override fun checkingOut(commit: AnyObjectId?, path: String?) {
}
}
override fun cloningSubmodule(path: String?) {
}
override fun checkingOut(commit: AnyObjectId?, path: String?) {
}
}
)
.setTransportConfigCallback { handleTransportUseCase(it, git) }
.setProgressMonitor(object : ProgressMonitor {
override fun start(totalTasks: Int) {}
override fun beginTask(title: String?, totalWork: Int) {}
override fun update(completed: Int) {}
override fun endTask() {}
override fun isCancelled(): Boolean = !isActive
override fun showDuration(enabled: Boolean) {}
})
.call()
)
.setTransportConfigCallback { handleTransport(it) }
.setProgressMonitor(object : ProgressMonitor {
override fun start(totalTasks: Int) {}
override fun beginTask(title: String?, totalWork: Int) {}
override fun update(completed: Int) {}
override fun endTask() {}
override fun isCancelled(): Boolean = !isActive
override fun showDuration(enabled: Boolean) {}
})
.call()
}
}
}

View File

@ -28,8 +28,9 @@ private const val PREF_CUSTOM_THEME = "customTheme"
private const val PREF_UI_SCALE = "ui_scale"
private const val PREF_DIFF_TYPE = "diffType"
private const val PREF_DIFF_FULL_FILE = "diffFullFile"
private const val PREF_SWAP_UNCOMMITED_CHANGES = "inverseUncommitedChanges"
private const val PREF_SWAP_UNCOMMITTED_CHANGES = "inverseUncommittedChanges"
private const val PREF_TERMINAL_PATH = "terminalPath"
private const val PREF_CACHE_CREDENTIALS_IN_MEMORY = "credentialsInMemory"
private const val PREF_GIT_FF_MERGE = "gitFFMerge"
@ -38,7 +39,8 @@ private const val PREF_GIT_PUSH_WITH_LEASE = "gitPushWithLease"
private const val DEFAULT_COMMITS_LIMIT = 1000
private const val DEFAULT_COMMITS_LIMIT_ENABLED = true
private const val DEFAULT_SWAP_UNCOMMITED_CHANGES = false
private const val DEFAULT_SWAP_UNCOMMITTED_CHANGES = false
private const val DEFAULT_CACHE_CREDENTIALS_IN_MEMORY = true
const val DEFAULT_UI_SCALE = -1f
@Singleton
@ -51,8 +53,11 @@ class AppSettings @Inject constructor() {
private val _commitsLimitEnabledFlow = MutableStateFlow(commitsLimitEnabled)
val commitsLimitEnabledFlow = _commitsLimitEnabledFlow.asStateFlow()
private val _swapUncommitedChangesFlow = MutableStateFlow(swapUncommitedChanges)
val swapUncommitedChangesFlow = _swapUncommitedChangesFlow.asStateFlow()
private val _swapUncommittedChangesFlow = MutableStateFlow(swapUncommittedChanges)
val swapUncommittedChangesFlow = _swapUncommittedChangesFlow.asStateFlow()
private val _cacheCredentialsInMemoryFlow = MutableStateFlow(cacheCredentialsInMemory)
val cacheCredentialsInMemoryFlow = _cacheCredentialsInMemoryFlow.asStateFlow()
private val _ffMergeFlow = MutableStateFlow(ffMerge)
val ffMergeFlow = _ffMergeFlow.asStateFlow()
@ -123,13 +128,22 @@ class AppSettings @Inject constructor() {
_commitsLimitEnabledFlow.value = value
}
var swapUncommitedChanges: Boolean
var swapUncommittedChanges: Boolean
get() {
return preferences.getBoolean(PREF_SWAP_UNCOMMITED_CHANGES, DEFAULT_SWAP_UNCOMMITED_CHANGES)
return preferences.getBoolean(PREF_SWAP_UNCOMMITTED_CHANGES, DEFAULT_SWAP_UNCOMMITTED_CHANGES)
}
set(value) {
preferences.putBoolean(PREF_SWAP_UNCOMMITED_CHANGES, value)
_swapUncommitedChangesFlow.value = value
preferences.putBoolean(PREF_SWAP_UNCOMMITTED_CHANGES, value)
_swapUncommittedChangesFlow.value = value
}
var cacheCredentialsInMemory: Boolean
get() {
return preferences.getBoolean(PREF_CACHE_CREDENTIALS_IN_MEMORY, DEFAULT_CACHE_CREDENTIALS_IN_MEMORY)
}
set(value) {
preferences.putBoolean(PREF_CACHE_CREDENTIALS_IN_MEMORY, value)
_cacheCredentialsInMemoryFlow.value = value
}
var scaleUi: Float

View File

@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.Switch
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -17,7 +16,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.jetpackduba.gitnuro.AppIcons
import com.jetpackduba.gitnuro.extensions.handMouseClickable
import com.jetpackduba.gitnuro.extensions.handOnHover
import com.jetpackduba.gitnuro.managers.Error
import com.jetpackduba.gitnuro.preferences.DEFAULT_UI_SCALE
import com.jetpackduba.gitnuro.theme.*
@ -48,6 +46,7 @@ val settings = listOf(
SettingsEntry.Section("Network"),
SettingsEntry.Entry(AppIcons.NETWORK, "Proxy") { },
SettingsEntry.Entry(AppIcons.PASSWORD, "Authentication") { Authentication(it) },
SettingsEntry.Section("Tools"),
SettingsEntry.Entry(AppIcons.TERMINAL, "Terminal") { Terminal(it) },
@ -240,6 +239,21 @@ private fun RemoteActions(settingsViewModel: SettingsViewModel) {
)
}
@Composable
private fun Authentication(settingsViewModel: SettingsViewModel) {
val cacheCredentialsInMemory by settingsViewModel.cacheCredentialsInMemoryFlow.collectAsState()
SettingToggle(
title = "Cache HTTP credentials in memory",
subtitle = "If active, HTTP Credentials will be remember until Gitnuro is closed",
value = cacheCredentialsInMemory,
onValueChanged = { value ->
settingsViewModel.cacheCredentialsInMemory = value
}
)
}
@Composable
fun Terminal(settingsViewModel: SettingsViewModel) {
var commitsLimit by remember { mutableStateOf(settingsViewModel.terminalPath) }
@ -272,7 +286,7 @@ private fun Branches(settingsViewModel: SettingsViewModel) {
@Composable
private fun Layout(settingsViewModel: SettingsViewModel) {
val swapUncommitedChanges by settingsViewModel.swapUncommitedChangesFlow.collectAsState()
val swapUncommitedChanges by settingsViewModel.swapUncommittedChangesFlow.collectAsState()
SettingToggle(
title = "Swap position for staged/unstaged views",

View File

@ -129,7 +129,7 @@ private fun LogLoaded(
repositoryState: RepositoryState
) {
val scope = rememberCoroutineScope()
val hasUncommitedChanges = logStatus.hasUncommittedChanges
val hasUncommittedChanges = logStatus.hasUncommittedChanges
val commitList = logStatus.plotCommitList
val verticalScrollState by logViewModel.verticalListState.collectAsState()
val horizontalScrollState by logViewModel.horizontalListState.collectAsState()
@ -219,7 +219,7 @@ private fun LogLoaded(
MessagesList(
scrollState = verticalScrollState,
horizontalScrollState = horizontalScrollState,
hasUncommitedChanges = hasUncommitedChanges,
hasUncommittedChanges = hasUncommittedChanges,
searchFilter = if (searchFilterValue is LogSearch.SearchResults) searchFilterValue.commits else null,
selectedCommit = selectedCommit,
logStatus = logStatus,
@ -427,7 +427,7 @@ fun SearchFilter(
@Composable
fun MessagesList(
scrollState: LazyListState,
hasUncommitedChanges: Boolean,
hasUncommittedChanges: Boolean,
searchFilter: List<GraphNode>?,
selectedCommit: RevCommit?,
logStatus: LogStatus.Loaded,
@ -447,7 +447,7 @@ fun MessagesList(
modifier = Modifier.fillMaxSize(),
) {
if (
hasUncommitedChanges ||
hasUncommittedChanges ||
repositoryState.isMerging ||
repositoryState.isRebasing ||
repositoryState.isCherryPicking

View File

@ -285,7 +285,7 @@ class LogViewModel @Inject constructor(
if (previousLogStatusValue is LogStatus.Loaded) {
val newLogStatusValue = LogStatus.Loaded(
hasUncommitedChanges = hasUncommitedChanges,
hasUncommittedChanges = hasUncommitedChanges,
plotCommitList = previousLogStatusValue.plotCommitList,
currentBranch = currentBranch,
statusSummary = statsSummary,
@ -447,7 +447,7 @@ class LogViewModel @Inject constructor(
sealed class LogStatus {
object Loading : LogStatus()
class Loaded(
val hasUncommitedChanges: Boolean,
val hasUncommittedChanges: Boolean,
val plotCommitList: GraphCommitList,
val currentBranch: Ref?,
val statusSummary: StatusSummary,

View File

@ -26,7 +26,8 @@ class SettingsViewModel @Inject constructor(
val pullRebaseFlow = appSettings.pullRebaseFlow
val pushWithLeaseFlow = appSettings.pushWithLeaseFlow
val commitsLimitEnabledFlow = appSettings.commitsLimitEnabledFlow
val swapUncommitedChangesFlow = appSettings.swapUncommitedChangesFlow
val swapUncommittedChangesFlow = appSettings.swapUncommittedChangesFlow
val cacheCredentialsInMemoryFlow = appSettings.cacheCredentialsInMemoryFlow
val terminalPathFlow = appSettings.terminalPathFlow
var scaleUi: Float
@ -42,9 +43,9 @@ class SettingsViewModel @Inject constructor(
}
var swapUncommitedChanges: Boolean
get() = appSettings.swapUncommitedChanges
get() = appSettings.swapUncommittedChanges
set(value) {
appSettings.swapUncommitedChanges = value
appSettings.swapUncommittedChanges = value
}
var ffMerge: Boolean
@ -53,6 +54,12 @@ class SettingsViewModel @Inject constructor(
appSettings.ffMerge = value
}
var cacheCredentialsInMemory: Boolean
get() = appSettings.cacheCredentialsInMemory
set(value) {
appSettings.cacheCredentialsInMemory = value
}
var pullRebase: Boolean
get() = appSettings.pullRebase
set(value) {

View File

@ -72,7 +72,7 @@ class StatusViewModel @Inject constructor(
private val _searchFilterStaged = MutableStateFlow(TextFieldValue(""))
val searchFilterStaged: StateFlow<TextFieldValue> = _searchFilterStaged
val swapUncommitedChanges = appSettings.swapUncommitedChangesFlow
val swapUncommitedChanges = appSettings.swapUncommittedChangesFlow
val rebaseInteractiveState = sharedRepositoryStateManager.rebaseInteractiveState
private val _stageState = MutableStateFlow<StageState>(StageState.Loading)

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M80-200v-61h800v61H80Zm38-254-40-22 40-68H40v-45h78l-40-68 40-22 38 67 38-67 40 22-40 68h78v45h-78l40 68-40 22-38-67-38 67Zm324 0-40-24 40-68h-78v-45h78l-40-68 40-22 38 67 38-67 40 22-40 68h78v45h-78l40 68-40 24-38-67-38 67Zm324 0-40-24 40-68h-78v-45h78l-40-68 40-22 38 67 38-67 40 22-40 68h78v45h-78l40 68-40 24-38-67-38 67Z"/></svg>

After

Width:  |  Height:  |  Size: 431 B