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 NETWORK = "network.svg"
const val OPEN = "open.svg" const val OPEN = "open.svg"
const val PALETTE = "palette.svg" const val PALETTE = "palette.svg"
const val PASSWORD = "password.svg"
const val PASTE = "paste.svg" const val PASTE = "paste.svg"
const val PERSON = "person.svg" const val PERSON = "person.svg"
const val REFRESH = "refresh.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 package com.jetpackduba.gitnuro.credentials
import com.jetpackduba.gitnuro.git.remote_operations.CredentialsCache
import org.eclipse.jgit.transport.CredentialsProvider import org.eclipse.jgit.transport.CredentialsProvider
import org.eclipse.jgit.transport.RemoteSession import org.eclipse.jgit.transport.RemoteSession
import org.eclipse.jgit.transport.SshSessionFactory import org.eclipse.jgit.transport.SshSessionFactory
@ -9,26 +10,33 @@ import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
class GSessionManager @Inject constructor( class GSessionManager @Inject constructor(
private val sessionProvider: Provider<GRemoteSession> private val mySessionFactory: MySessionFactory,
) { ) {
fun generateSshSessionFactory(): SshSessionFactory { fun generateSshSessionFactory(): MySessionFactory {
return object : SshSessionFactory() { return mySessionFactory
override fun getSession( }
uri: URIish, }
credentialsProvider: CredentialsProvider?,
fs: FS?, class MySessionFactory @Inject constructor(
tms: Int private val sessionProvider: Provider<GRemoteSession>
): RemoteSession { ) : SshSessionFactory(), CredentialsCache {
val remoteSession = sessionProvider.get() override fun getSession(
remoteSession.setup(uri) uri: URIish,
credentialsProvider: CredentialsProvider?,
return remoteSession fs: FS?,
} tms: Int
): RemoteSession {
override fun getType(): String { val remoteSession = sessionProvider.get()
return "ssh" //TODO What should be the value of this? 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 package com.jetpackduba.gitnuro.credentials
import com.jetpackduba.gitnuro.exceptions.NotSupportedHelper import com.jetpackduba.gitnuro.exceptions.NotSupportedHelper
import com.jetpackduba.gitnuro.git.remote_operations.CredentialsCache
import com.jetpackduba.gitnuro.managers.IShellManager import com.jetpackduba.gitnuro.managers.IShellManager
import com.jetpackduba.gitnuro.preferences.AppSettings
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.Git
@ -18,8 +20,13 @@ private const val TIMEOUT_MIN = 1L
class HttpCredentialsProvider @AssistedInject constructor( class HttpCredentialsProvider @AssistedInject constructor(
private val credentialsStateManager: CredentialsStateManager, private val credentialsStateManager: CredentialsStateManager,
private val shellManager: IShellManager, private val shellManager: IShellManager,
private val appSettings: AppSettings,
private val credentialsCacheRepository: CredentialsCacheRepository,
@Assisted val git: Git?, @Assisted val git: Git?,
) : CredentialsProvider() { ) : CredentialsProvider(), CredentialsCache {
private var credentialsCached: CredentialsType.HttpCredentials? = null
override fun isInteractive(): Boolean { override fun isInteractive(): Boolean {
return true return true
} }
@ -45,15 +52,32 @@ class HttpCredentialsProvider @AssistedInject constructor(
val externalCredentialsHelper = getExternalCredentialsHelper(uri, git) val externalCredentialsHelper = getExternalCredentialsHelper(uri, git)
if (externalCredentialsHelper == null) { if (externalCredentialsHelper == null) {
val credentials = askForCredentials() val cachedCredentials = credentialsCacheRepository.getCachedHttpCredentials(uri.toString())
if (credentials is CredentialsAccepted.HttpCredentialsAccepted) { if (cachedCredentials == null) {
userItem.value = credentials.user val credentials = askForCredentials()
passwordItem.value = credentials.password.toCharArray()
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 return true
} else if (credentials is CredentialsState.CredentialsDenied) {
throw CancellationException("Credentials denied")
} }
return false return false
@ -153,26 +177,26 @@ class HttpCredentialsProvider @AssistedInject constructor(
} }
bufferedReader.use { bufferedReader.use {
var line: String var line: String?
while (bufferedReader.readLine().also { while (bufferedReader.readLine().also { line = it } != null && !(usernameSet && passwordSet)) {
line = checkNotNull(it) { "Cancelled authentication" } val safeLine = line ?: continue
} != null && !(usernameSet && passwordSet)) {
if (line.startsWith("username=")) { if (safeLine.startsWith("username=")) {
val split = line.split("=") val split = safeLine.split("=")
val userName = split.getOrNull(1) ?: return ExternalCredentialsRequestResult.CREDENTIALS_NOT_STORED 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) { if (userNameItem is CredentialItem.Username) {
userNameItem.value = userName userNameItem.value = userName
usernameSet = true usernameSet = true
} }
} else if (line.startsWith("password=")) { } else if (safeLine.startsWith("password=")) {
val split = line.split("=") val split = safeLine.split("=")
val password = split.getOrNull(1) ?: return ExternalCredentialsRequestResult.CREDENTIALS_NOT_STORED 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) { if (passwordItem is CredentialItem.Password) {
passwordItem.value = password.toCharArray() passwordItem.value = password.toCharArray()
@ -182,9 +206,9 @@ class HttpCredentialsProvider @AssistedInject constructor(
} }
} }
return if (usernameSet && passwordSet) return if (usernameSet && passwordSet) {
ExternalCredentialsRequestResult.SUCCESS ExternalCredentialsRequestResult.SUCCESS
else } else
ExternalCredentialsRequestResult.CREDENTIALS_NOT_STORED ExternalCredentialsRequestResult.CREDENTIALS_NOT_STORED
} }
@ -206,7 +230,7 @@ class HttpCredentialsProvider @AssistedInject constructor(
val genericCredentialHelper = config.getString("credential", null, "helper") val genericCredentialHelper = config.getString("credential", null, "helper")
val uriSpecificCredentialHelper = config.getString("credential", hostWithProtocol, "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") { if (credentialHelperPath == "cache" || credentialHelperPath == "store") {
throw NotSupportedHelper("Invalid credentials helper: \"$credentialHelperPath\" is not yet supported") throw NotSupportedHelper("Invalid credentials helper: \"$credentialHelperPath\" is not yet supported")
@ -225,6 +249,12 @@ class HttpCredentialsProvider @AssistedInject constructor(
return ExternalCredentialsHelper(credentialHelperPath, useHttpPath) return ExternalCredentialsHelper(credentialHelperPath, useHttpPath)
} }
override suspend fun cacheCredentialsIfNeeded() {
credentialsCached?.let {
credentialsCacheRepository.cacheHttpCredentials(it)
}
}
} }
data class ExternalCredentialsHelper( data class ExternalCredentialsHelper(

View File

@ -2,6 +2,7 @@ package com.jetpackduba.gitnuro.di
import com.jetpackduba.gitnuro.App import com.jetpackduba.gitnuro.App
import com.jetpackduba.gitnuro.AppEnvInfo import com.jetpackduba.gitnuro.AppEnvInfo
import com.jetpackduba.gitnuro.credentials.CredentialsCacheRepository
import com.jetpackduba.gitnuro.credentials.CredentialsStateManager import com.jetpackduba.gitnuro.credentials.CredentialsStateManager
import com.jetpackduba.gitnuro.di.modules.AppModule import com.jetpackduba.gitnuro.di.modules.AppModule
import com.jetpackduba.gitnuro.di.modules.NetworkModule import com.jetpackduba.gitnuro.di.modules.NetworkModule
@ -44,4 +45,6 @@ interface AppComponent {
fun tempFilesManager(): TempFilesManager fun tempFilesManager(): TempFilesManager
fun updatesRepository(): UpdatesRepository 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 { try {
ensureActive() ensureActive()
trySend(CloneState.Cloning("Starting...", progress, lastTotalWork)) 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() override fun beginTask(title: String?, totalWork: Int) {
.setDirectory(directory) println("ProgressMonitor Begin task with title: $title")
.setURI(url) lastTitle = title.orEmpty()
.setProgressMonitor( lastTotalWork = totalWork
object : ProgressMonitor { progress = 0
override fun start(totalTasks: Int) { trySend(CloneState.Cloning(lastTitle, progress, lastTotalWork))
printDebug(TAG, "ProgressMonitor Start with total tasks of: $totalTasks") }
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) { .setTransportConfigCallback { handleTransport(it) }
println("ProgressMonitor Begin task with title: $title") .setCloneSubmodules(cloneSubmodules)
lastTitle = title.orEmpty() .call()
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()
ensureActive() ensureActive()
trySend(CloneState.Completed(directory)) trySend(CloneState.Completed(directory))

View File

@ -25,35 +25,33 @@ class DeleteRemoteBranchUseCase @Inject constructor(
.setSource(null) .setSource(null)
.setDestination(branchName) .setDestination(branchName)
val pushResults = git.push() handleTransportUseCase(git) {
.setTransportConfigCallback { val pushResults = git.push()
handleTransportUseCase(it, git) .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) 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>>() val errors = mutableListOf<Pair<RemoteConfig, Exception>>()
for (remote in remotes) { for (remote in remotes) {
try { try {
git.fetch() handleTransportUseCase(git) {
.setRemote(remote.name) git.fetch()
.setRefSpecs(remote.fetchRefSpecs) .setRemote(remote.name)
.setRemoveDeletedRefs(true) .setRefSpecs(remote.fetchRefSpecs)
.setTransportConfigCallback { handleTransportUseCase(it, git) } .setRemoveDeletedRefs(true)
.setCredentialsProvider(CredentialsProvider.getDefault()) .setTransportConfigCallback { handleTransport(it) }
.setProgressMonitor(object : ProgressMonitor { .setCredentialsProvider(CredentialsProvider.getDefault())
override fun start(totalTasks: Int) {} .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) {} override fun showDuration(enabled: Boolean) {}
}) })
.call() .call()
}
} catch (ex: Exception) { } catch (ex: Exception) {
printError(TAG, "Fetch failed for remote ${remote.name} with error ${ex.message}", ex) 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 sessionManager: GSessionManager,
private val httpCredentialsProvider: HttpCredentialsFactory, private val httpCredentialsProvider: HttpCredentialsFactory,
) { ) {
operator fun invoke(transport: Transport?, git: Git?) { suspend operator fun invoke(git: Git?, block: suspend CredentialsHandler.() -> Unit) {
if (transport is SshTransport) { var cache: CredentialsCache? = null
transport.sshSessionFactory = sessionManager.generateSshSessionFactory()
} else if (transport is HttpTransport) { val credentialsHandler = object: CredentialsHandler {
transport.credentialsProvider = httpCredentialsProvider.create(git) 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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.Git
import org.eclipse.jgit.api.PullResult
import org.eclipse.jgit.api.RebaseResult import org.eclipse.jgit.api.RebaseResult
import org.eclipse.jgit.transport.CredentialsProvider import org.eclipse.jgit.transport.CredentialsProvider
import javax.inject.Inject import javax.inject.Inject
@ -19,25 +20,27 @@ class PullBranchUseCase @Inject constructor(
PullType.DEFAULT -> appSettings.pullRebase PullType.DEFAULT -> appSettings.pullRebase
} }
val pullResult = git handleTransportUseCase(git) {
.pull() val pullResult = git
.setTransportConfigCallback { handleTransportUseCase(it, git) } .pull()
.setRebase(pullWithRebase) .setTransportConfigCallback {this.handleTransport(it) }
.setCredentialsProvider(CredentialsProvider.getDefault()) .setRebase(pullWithRebase)
.call() .setCredentialsProvider(CredentialsProvider.getDefault())
.call()
if (!pullResult.isSuccessful) { if (!pullResult.isSuccessful) {
var message = "Pull failed" var message = "Pull failed"
if (pullWithRebase) { if (pullWithRebase) {
message = when (pullResult.rebaseResult.status) { message = when (pullResult.rebaseResult.status) {
RebaseResult.Status.UNCOMMITTED_CHANGES -> "The pull with rebase has failed because you have got uncommited changes" 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" RebaseResult.Status.CONFLICTS -> "Pull with rebase has conflicts, fix them to continue"
else -> message else -> message
}
} }
}
throw Exception(message) throw Exception(message)
}
} }
} }
} }

View File

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

View File

@ -1,6 +1,7 @@
package com.jetpackduba.gitnuro.git.remote_operations package com.jetpackduba.gitnuro.git.remote_operations
import com.jetpackduba.gitnuro.git.branches.GetTrackingBranchUseCase 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.isRejected
import com.jetpackduba.gitnuro.git.statusMessage import com.jetpackduba.gitnuro.git.statusMessage
import com.jetpackduba.gitnuro.preferences.AppSettings import com.jetpackduba.gitnuro.preferences.AppSettings
@ -27,7 +28,18 @@ class PushBranchUseCase @Inject constructor(
} else { } else {
currentBranch 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 val pushResult = git
.push() .push()
.setRefSpecs(RefSpec(refSpecStr)) .setRefSpecs(RefSpec(refSpecStr))
@ -66,7 +78,7 @@ class PushBranchUseCase @Inject constructor(
this this
} }
} }
.setTransportConfigCallback { handleTransportUseCase(it, git) } .setTransportConfigCallback { handleTransport(it) }
.setProgressMonitor(object : ProgressMonitor { .setProgressMonitor(object : ProgressMonitor {
override fun start(totalTasks: Int) {} override fun start(totalTasks: Int) {}
override fun beginTask(title: String?, totalWork: Int) {} override fun beginTask(title: String?, totalWork: Int) {}
@ -105,5 +117,4 @@ class PushBranchUseCase @Inject constructor(
throw Exception(error.toString()) throw Exception(error.toString())
} }
} }
} }

View File

@ -18,30 +18,32 @@ class PushToSpecificBranchUseCase @Inject constructor(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val currentBranchRefSpec = git.repository.fullBranch val currentBranchRefSpec = git.repository.fullBranch
val pushResult = git handleTransportUseCase(git) {
.push() val pushResult = git
.setRefSpecs(RefSpec("$currentBranchRefSpec:${remoteBranch.simpleName}")) .push()
.setRemote(remoteBranch.remoteName) .setRefSpecs(RefSpec("$currentBranchRefSpec:${remoteBranch.simpleName}"))
.setForce(force) .setRemote(remoteBranch.remoteName)
.apply { .setForce(force)
if (pushTags) .apply {
setPushTags() 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, private val handleTransportUseCase: HandleTransportUseCase,
) { ) {
suspend operator fun invoke(git: Git, name: String, path: String, uri: String): Unit = withContext(Dispatchers.IO) { suspend operator fun invoke(git: Git, name: String, path: String, uri: String): Unit = withContext(Dispatchers.IO) {
git.submoduleAdd() handleTransportUseCase(git) {
.setName(name) git.submoduleAdd()
.setPath(path) .setName(name)
.setURI(uri) .setPath(path)
.setTransportConfigCallback { handleTransportUseCase(it, git) } .setURI(uri)
.setCredentialsProvider(CredentialsProvider.getDefault()) .setTransportConfigCallback { handleTransport(it) }
.setProgressMonitor(object : ProgressMonitor { .setCredentialsProvider(CredentialsProvider.getDefault())
override fun start(totalTasks: Int) {} .setProgressMonitor(object : ProgressMonitor {
override fun beginTask(title: String?, totalWork: Int) {} override fun start(totalTasks: Int) {}
override fun update(completed: Int) {} override fun beginTask(title: String?, totalWork: Int) {}
override fun endTask() {} override fun update(completed: Int) {}
override fun showDuration(enabled: Boolean) {} 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, private val handleTransportUseCase: HandleTransportUseCase,
) { ) {
suspend operator fun invoke(git: Git, path: String) = withContext(Dispatchers.IO) { suspend operator fun invoke(git: Git, path: String) = withContext(Dispatchers.IO) {
git.submoduleUpdate() handleTransportUseCase(git) {
.addPath(path) git.submoduleUpdate()
.setCallback( .addPath(path)
object : CloneCommand.Callback { .setCallback(
override fun initializedSubmodules(submodules: MutableCollection<String>?) { 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?) { .setTransportConfigCallback { handleTransport(it) }
.setProgressMonitor(object : ProgressMonitor {
} override fun start(totalTasks: Int) {}
override fun beginTask(title: String?, totalWork: Int) {}
override fun checkingOut(commit: AnyObjectId?, path: String?) { override fun update(completed: Int) {}
override fun endTask() {}
} override fun isCancelled(): Boolean = !isActive
} override fun showDuration(enabled: Boolean) {}
) })
.setTransportConfigCallback { handleTransportUseCase(it, git) } .call()
.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_UI_SCALE = "ui_scale"
private const val PREF_DIFF_TYPE = "diffType" private const val PREF_DIFF_TYPE = "diffType"
private const val PREF_DIFF_FULL_FILE = "diffFullFile" 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_TERMINAL_PATH = "terminalPath"
private const val PREF_CACHE_CREDENTIALS_IN_MEMORY = "credentialsInMemory"
private const val PREF_GIT_FF_MERGE = "gitFFMerge" 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 = 1000
private const val DEFAULT_COMMITS_LIMIT_ENABLED = true 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 const val DEFAULT_UI_SCALE = -1f
@Singleton @Singleton
@ -51,8 +53,11 @@ class AppSettings @Inject constructor() {
private val _commitsLimitEnabledFlow = MutableStateFlow(commitsLimitEnabled) private val _commitsLimitEnabledFlow = MutableStateFlow(commitsLimitEnabled)
val commitsLimitEnabledFlow = _commitsLimitEnabledFlow.asStateFlow() val commitsLimitEnabledFlow = _commitsLimitEnabledFlow.asStateFlow()
private val _swapUncommitedChangesFlow = MutableStateFlow(swapUncommitedChanges) private val _swapUncommittedChangesFlow = MutableStateFlow(swapUncommittedChanges)
val swapUncommitedChangesFlow = _swapUncommitedChangesFlow.asStateFlow() val swapUncommittedChangesFlow = _swapUncommittedChangesFlow.asStateFlow()
private val _cacheCredentialsInMemoryFlow = MutableStateFlow(cacheCredentialsInMemory)
val cacheCredentialsInMemoryFlow = _cacheCredentialsInMemoryFlow.asStateFlow()
private val _ffMergeFlow = MutableStateFlow(ffMerge) private val _ffMergeFlow = MutableStateFlow(ffMerge)
val ffMergeFlow = _ffMergeFlow.asStateFlow() val ffMergeFlow = _ffMergeFlow.asStateFlow()
@ -123,13 +128,22 @@ class AppSettings @Inject constructor() {
_commitsLimitEnabledFlow.value = value _commitsLimitEnabledFlow.value = value
} }
var swapUncommitedChanges: Boolean var swapUncommittedChanges: Boolean
get() { 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) { set(value) {
preferences.putBoolean(PREF_SWAP_UNCOMMITED_CHANGES, value) preferences.putBoolean(PREF_SWAP_UNCOMMITTED_CHANGES, value)
_swapUncommitedChangesFlow.value = 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 var scaleUi: Float

View File

@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.Switch
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -17,7 +16,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.jetpackduba.gitnuro.AppIcons import com.jetpackduba.gitnuro.AppIcons
import com.jetpackduba.gitnuro.extensions.handMouseClickable import com.jetpackduba.gitnuro.extensions.handMouseClickable
import com.jetpackduba.gitnuro.extensions.handOnHover
import com.jetpackduba.gitnuro.managers.Error import com.jetpackduba.gitnuro.managers.Error
import com.jetpackduba.gitnuro.preferences.DEFAULT_UI_SCALE import com.jetpackduba.gitnuro.preferences.DEFAULT_UI_SCALE
import com.jetpackduba.gitnuro.theme.* import com.jetpackduba.gitnuro.theme.*
@ -48,6 +46,7 @@ val settings = listOf(
SettingsEntry.Section("Network"), SettingsEntry.Section("Network"),
SettingsEntry.Entry(AppIcons.NETWORK, "Proxy") { }, SettingsEntry.Entry(AppIcons.NETWORK, "Proxy") { },
SettingsEntry.Entry(AppIcons.PASSWORD, "Authentication") { Authentication(it) },
SettingsEntry.Section("Tools"), SettingsEntry.Section("Tools"),
SettingsEntry.Entry(AppIcons.TERMINAL, "Terminal") { Terminal(it) }, 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 @Composable
fun Terminal(settingsViewModel: SettingsViewModel) { fun Terminal(settingsViewModel: SettingsViewModel) {
var commitsLimit by remember { mutableStateOf(settingsViewModel.terminalPath) } var commitsLimit by remember { mutableStateOf(settingsViewModel.terminalPath) }
@ -272,7 +286,7 @@ private fun Branches(settingsViewModel: SettingsViewModel) {
@Composable @Composable
private fun Layout(settingsViewModel: SettingsViewModel) { private fun Layout(settingsViewModel: SettingsViewModel) {
val swapUncommitedChanges by settingsViewModel.swapUncommitedChangesFlow.collectAsState() val swapUncommitedChanges by settingsViewModel.swapUncommittedChangesFlow.collectAsState()
SettingToggle( SettingToggle(
title = "Swap position for staged/unstaged views", title = "Swap position for staged/unstaged views",

View File

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

View File

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

View File

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

View File

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