From cc880cf62ea8391f693f034858578cd4b693043b Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 18:36:42 +0100 Subject: feat(app): drop two sort orders, fix the proxy user name The Compose list shows folders and entries in separate groups, so the files first and type independent sort orders did nothing visible. They are removed; a stored one migrates to folders first, and any unknown value reads as folders first instead of throwing. The OpenKeychain SSH key ID, which nothing reads any more, is removed once. GitSettings wrote the proxy user name to the proxy preferences but read it from the main settings, so ProxyUtils never authenticated. Adds the pure parts of the settings port: proxy port parsing, HEAD status and the commit log. Co-Authored-By: Claude Opus 5.5 --- .../app/passwordstore/ui/settings/SettingsModel.kt | 79 +++++++++++++++++++ .../app/passwordstore/util/settings/GitSettings.kt | 2 +- .../app/passwordstore/util/settings/Migrations.kt | 19 +++++ .../util/settings/PasswordSortOrder.kt | 17 ++-- .../viewmodel/SearchableRepositoryViewModel.kt | 7 +- app/src/main/res/values-de/strings.xml | 2 - app/src/main/res/values-fr/strings.xml | 2 - app/src/main/res/values-gl/strings.xml | 2 - app/src/main/res/values-it/strings.xml | 2 - app/src/main/res/values-ko-rKR/strings.xml | 2 - app/src/main/res/values-pl-rPL/strings.xml | 2 - app/src/main/res/values-pt-rBR/strings.xml | 2 - app/src/main/res/values-ru/strings.xml | 2 - app/src/main/res/values-zh-rCN/strings.xml | 2 - app/src/main/res/values/arrays.xml | 4 - app/src/main/res/values/strings.xml | 2 - .../passwordstore/ui/settings/SettingsModelTest.kt | 90 ++++++++++++++++++++++ .../passwordstore/util/settings/GitSettingsTest.kt | 27 +++++++ .../passwordstore/util/settings/MigrationsTest.kt | 25 ++++++ .../util/settings/PasswordSortOrderTest.kt | 26 +++++++ 20 files changed, 274 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt create mode 100644 app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt create mode 100644 app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt create mode 100644 app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt (limited to 'app/src') diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt new file mode 100644 index 00000000..eca6770c --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import app.passwordstore.util.extensions.hash +import app.passwordstore.util.extensions.time +import app.passwordstore.util.git.GitCommit +import java.io.IOException +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.errors.GitAPIException +import org.eclipse.jgit.api.errors.JGitInternalException +import org.eclipse.jgit.lib.Constants +import org.eclipse.jgit.lib.Repository +import org.eclipse.jgit.lib.RepositoryState + +private const val TAG = "SettingsModel" + +/** + * The proxy port typed as [text]: -1 when empty (no port, as stored before any was set), null when + * it is not a port number from 1 to 65535. + */ +fun proxyPort(text: String): Int? { + val trimmed = text.trim() + if (trimmed.isEmpty()) return -1 + return trimmed.toIntOrNull()?.takeIf { it in 1..65535 } +} + +/** Where HEAD points, as the git config screen reports it. */ +sealed interface HeadStatus { + data class Branch(val name: String) : HeadStatus + + data class Detached(val hash: String) : HeadStatus + + data object Missing : HeadStatus +} + +/** [repo]'s HEAD: a branch (also one without commits yet), a detached commit, or missing. */ +fun headStatus(repo: Repository): HeadStatus = + try { + val head = repo.findRef(Constants.HEAD) + when { + head == null -> HeadStatus.Missing + head.isSymbolic -> HeadStatus.Branch(Repository.shortenRefName(head.target.name)) + else -> + head.objectId?.let { HeadStatus.Detached(it.abbreviate(8).name()) } ?: HeadStatus.Missing + } + } catch (e: IOException) { + logcat(TAG, ERROR) { e.asLog() } + HeadStatus.Missing + } + +/** Whether a rebase or a merge is in progress, so "Abort rebase" has something to abort. */ +fun needsAbort(repo: Repository): Boolean = + repo.repositoryState.let { it.isRebasing || it == RepositoryState.MERGING } + +/** + * The commits reachable from HEAD, newest first; empty without a repository or a commit. Blocking: + * call it off the main thread. + */ +// ponytail: the whole log at once, as the old screen did (tested there with 1000 commits); page it +// if a store's history ever makes this slow. +fun gitLog(repo: Repository?): List { + if (repo == null) return emptyList() + return try { + Git(repo).log().call().map { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) } + } catch (e: GitAPIException) { + logcat(TAG, ERROR) { e.asLog() } + emptyList() + } catch (e: JGitInternalException) { + logcat(TAG, ERROR) { e.asLog() } + emptyList() + } +} diff --git a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt index d315daf3..004028c4 100644 --- a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt +++ b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt @@ -108,7 +108,7 @@ constructor( } var proxyUsername - get() = settings.getString(PreferenceKeys.PROXY_USERNAME) + get() = proxySettings.getString(PreferenceKeys.PROXY_USERNAME) set(value) { proxySettings.edit { putString(PreferenceKeys.PROXY_USERNAME, value) } } diff --git a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt index 06075655..82847f02 100644 --- a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt +++ b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt @@ -32,6 +32,8 @@ fun runMigrations(filesDirPath: String, sharedPrefs: SharedPreferences, gitSetti migrateToDiceware(sharedPrefs) removeExternalStorageProperties(sharedPrefs) removeCurrentBranchValue(sharedPrefs) + migrateRemovedSortOrders(sharedPrefs) + removeOpenKeystoreKeyId(sharedPrefs) } /** @@ -194,3 +196,20 @@ private fun removeExternalStorageProperties(prefs: SharedPreferences) { } } } + +/** + * "Files first" and "type independent" were removed with the Compose list, which shows folders and + * entries in separate groups: a stored one becomes folders first, the list's default. + */ +private fun migrateRemovedSortOrders(sharedPrefs: SharedPreferences) { + val stored = sharedPrefs.getString(PreferenceKeys.SORT_ORDER) ?: return + if (stored != "FILE_FIRST" && stored != "INDEPENDENT") return + logcat(TAG, INFO) { "Replacing the removed sort order $stored" } + sharedPrefs.edit { putString(PreferenceKeys.SORT_ORDER, PasswordSortOrder.FOLDER_FIRST.name) } +} + +/** OpenKeychain SSH support is gone and nothing reads the remembered key ID any more. */ +private fun removeOpenKeystoreKeyId(sharedPrefs: SharedPreferences) { + if (!sharedPrefs.contains(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)) return + sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } +} diff --git a/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt b/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt index 224d9d66..921e9bc4 100644 --- a/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt +++ b/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt @@ -18,11 +18,6 @@ enum class PasswordSortOrder(val comparator: java.util.Comparator) (p1.type + p1.name).compareTo(p2.type + p2.name, ignoreCase = true) } ), - INDEPENDENT( - Comparator { p1: PasswordItem, p2: PasswordItem -> - p1.name.compareTo(p2.name, ignoreCase = true) - } - ), RECENTLY_USED( Comparator { p1: PasswordItem, p2: PasswordItem -> val recentHistory = @@ -36,18 +31,18 @@ enum class PasswordSortOrder(val comparator: java.util.Comparator) else -> p1.name.compareTo(p2.name, ignoreCase = true) } } - ), - FILE_FIRST( - Comparator { p1: PasswordItem, p2: PasswordItem -> - (p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true) - } ); companion object { + /** + * The stored sort order; anything else (the removed "files first" and "type independent", a + * value from another version) reads as [FOLDER_FIRST]. + */ @JvmStatic fun getSortOrder(settings: SharedPreferences): PasswordSortOrder { - return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name) + val stored = settings.getString(PreferenceKeys.SORT_ORDER) + return entries.firstOrNull { it.name == stored } ?: FOLDER_FIRST } } } diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt index ca570dc4..f8cbf1e4 100644 --- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt @@ -71,12 +71,7 @@ private fun PasswordItem.Companion.makeComparator( directoryStructure: DirectoryStructure, ): Comparator { return when (typeSortOrder) { - PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type } - // In order to let INDEPENDENT not distinguish between items based on their type, we - // simply - // declare them all equal at this stage. - PasswordSortOrder.INDEPENDENT -> Comparator { _, _ -> 0 } - PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type } + PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type } PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator } .then( diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 493391a8..618aac0d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -96,8 +96,6 @@ Findet Passwörter auch in Unterordnern. Passwortsortierung Ordner zuerst - Dateien zuerst - typunabhängig Zuletzt verwendet Automatisch ausfüllen Autofill aktivieren diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d2a469e7..4cc7d29d 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -94,8 +94,6 @@ Cherche le mot de passe dans tous les sous-répertoires du répertoire actuel. Ordre de tri des mots de passe Dossiers en premier - Fichiers en premier - Indifférent au type d\'entrée Récemment utilisé Saisie automatique Saisie automatique diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index ad4c663d..34b1ce6a 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -97,8 +97,6 @@ Organización dos contrasinais Orde para mostrar contrasinais Primeiro cartafoles - Primeiro ficheiros - Independentemente do tipo Usadas recentemente Completado automático Activar completado automático diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 8edc9d3d..9b67482c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -94,8 +94,6 @@ Trova ricorsivamente le password della directory corrente. Ordine password Prima le cartelle - Prima i file - Indipendente dal tipo Usato di recente Compilazione automatica Attiva la compilazione automatica diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index aaa438dd..06e0a555 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -95,8 +95,6 @@ 암허 파일 정리 암호 정렬 순서 폴더 우선 - 파일 우선 - 타입순 최신순 자동완성 자동완성 활성화 diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 815cf41c..6bdb966c 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -94,8 +94,6 @@ Organizacja plików haseł Kolejność sortowania haseł Najpierw foldery - Najpierw pliki - Niezależnie od typu Ostatnio używane Autouzupełnianie Włącz autouzupełnianie diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index a1d1a38c..8f40c831 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -96,8 +96,6 @@ Encontrar senhas do diretório corrente recursivamente. Ordenação da Senha Pastas primeiro - Arquivos primeiro - Tipo independente Usado recentemente Preenchimento Automático Ativar preenchimento automático diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d0934a4d..af3078bc 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -99,8 +99,6 @@ Рекурсивный поиск паролей в текущей директории Порядок сортировки паролей Сначала папки - Сначала файлы - Типонезависимый Недавно использованные Автозаполнение Включить автозаполнение diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index ddcdda1a..959d5fac 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -95,8 +95,6 @@ 递归式寻找当前目录的密码 密码排序顺序 目录优先 - 文件优先 - 类型无关 最近使用 自动填充 启用自动填充 diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 9f44710b..2891987d 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -6,14 +6,10 @@ @string/pref_folder_first_sort_order - @string/pref_file_first_sort_order - @string/pref_type_independent_sort_order @string/pref_recently_used_sort_order FOLDER_FIRST - FILE_FIRST - INDEPENDENT RECENTLY_USED diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 59ba74cb..dc9140f8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,8 +116,6 @@ Password file organization Password sort order Folders first - Files first - Type independent Recently used Autofill Enable Autofill diff --git a/app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt b/app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt new file mode 100644 index 00000000..693e07dd --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import app.passwordstore.util.git.GitCommit +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.lib.PersonIdent +import org.eclipse.jgit.revwalk.RevCommit +import org.eclipse.jgit.util.FS + +class SettingsModelTest { + + private val root = Files.createTempDirectory("aps-settings").toFile() + private val identity = PersonIdent("test", "test@example.org") + + init { + // Ignore the developer's ~/.gitconfig (default branch, commit signing). + FS.DETECTED.setUserHome(root) + } + + @AfterTest fun cleanUp() = root.deleteRecursively().let {} + + private fun repo(): Git = + Git.init().setInitialBranch("master").setDirectory(File(root, "store")).call() + + private fun Git.commitFile(name: String, message: String): RevCommit { + File(repository.workTree, name).writeText(message) + add().addFilepattern(name).call() + return commit() + .setMessage(message) + .setAuthor(identity) + .setCommitter(identity) + .setSign(false) + .call() + } + + @Test + fun proxyPorts() { + assertEquals(-1, proxyPort("")) + assertEquals(-1, proxyPort(" ")) + assertEquals(8080, proxyPort("8080")) + assertEquals(8080, proxyPort(" 8080 ")) + assertEquals(1, proxyPort("1")) + assertEquals(65535, proxyPort("65535")) + assertNull(proxyPort("0")) + assertNull(proxyPort("65536")) + assertNull(proxyPort("99999999999")) + assertNull(proxyPort("-1")) + assertNull(proxyPort("80a")) + } + + @Test + fun headStatusFollowsHead() { + repo().use { git -> + // A new repository: HEAD names a branch that has no commit yet. + assertEquals(HeadStatus.Branch("master"), headStatus(git.repository)) + val first = git.commitFile("a.gpg", "Add a") + git.commitFile("b.gpg", "Add b") + assertEquals(HeadStatus.Branch("master"), headStatus(git.repository)) + assertFalse(needsAbort(git.repository)) + git.checkout().setName(first.name).call() + assertEquals(HeadStatus.Detached(first.name.take(8)), headStatus(git.repository)) + } + } + + @Test + fun gitLogIsNewestFirst() { + assertEquals(emptyList(), gitLog(null)) + repo().use { git -> + // No commit yet: JGit's NoHeadException is an empty log, not a crash. + assertEquals(emptyList(), gitLog(git.repository)) + val first = git.commitFile("a.gpg", "Add a") + val second = git.commitFile("b.gpg", "Add b") + val log = gitLog(git.repository) + assertEquals(listOf("Add b", "Add a"), log.map(GitCommit::shortMessage)) + assertEquals(listOf(second.name, first.name), log.map(GitCommit::hash)) + assertEquals("test", log[0].authorName) + } + } +} diff --git a/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt b/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt new file mode 100644 index 00000000..6b30c248 --- /dev/null +++ b/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.settings + +import app.passwordstore.util.extensions.getString +import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class GitSettingsTest { + + @Test + fun proxyUsernameIsReadWhereItIsWritten() { + val settings = SPMockBuilder().createSharedPreferences() + val proxy = SPMockBuilder().createSharedPreferences() + val gitSettings = + GitSettings(settings, SPMockBuilder().createSharedPreferences(), proxy, "/nonexistent") + gitSettings.proxyUsername = "proxy-user" + assertEquals("proxy-user", gitSettings.proxyUsername) + assertEquals("proxy-user", proxy.getString(PreferenceKeys.PROXY_USERNAME)) + assertNull(settings.getString(PreferenceKeys.PROXY_USERNAME)) + } +} diff --git a/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt b/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt index 62109563..44bd12ba 100644 --- a/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt +++ b/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt @@ -184,4 +184,29 @@ class MigrationsTest { assertFalse { sharedPrefs.contains(PreferenceKeys.GIT_EXTERNAL_REPO) } assertTrue { sharedPrefs.getBoolean(PreferenceKeys.GIT_EXTERNAL_MIGRATED, false) } } + + @Test + fun removedSortOrdersFallBackToFoldersFirst() { + val gitSettings = + GitSettings(sharedPrefs, encryptedSharedPreferences, proxySharedPreferences, filesDir) + for (removed in listOf("FILE_FIRST", "INDEPENDENT")) { + sharedPrefs.edit { putString(PreferenceKeys.SORT_ORDER, removed) } + runMigrations(filesDir, sharedPrefs, gitSettings) + assertEquals("FOLDER_FIRST", sharedPrefs.getString(PreferenceKeys.SORT_ORDER)) + } + sharedPrefs.edit { putString(PreferenceKeys.SORT_ORDER, "RECENTLY_USED") } + runMigrations(filesDir, sharedPrefs, gitSettings) + assertEquals("RECENTLY_USED", sharedPrefs.getString(PreferenceKeys.SORT_ORDER)) + } + + @Test + fun staleOpenKeystoreKeyIdIsRemoved() { + sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, "0123456789ABCDEF") } + runMigrations( + filesDir, + sharedPrefs, + GitSettings(sharedPrefs, encryptedSharedPreferences, proxySharedPreferences, filesDir), + ) + assertFalse(sharedPrefs.contains(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)) + } } diff --git a/app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt b/app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt new file mode 100644 index 00000000..34348692 --- /dev/null +++ b/app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.settings + +import androidx.core.content.edit +import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder +import kotlin.test.Test +import kotlin.test.assertEquals + +class PasswordSortOrderTest { + + @Test + fun unknownAndRemovedValuesReadAsFoldersFirst() { + val prefs = SPMockBuilder().createSharedPreferences() + assertEquals(PasswordSortOrder.FOLDER_FIRST, PasswordSortOrder.getSortOrder(prefs)) + for (value in listOf("FILE_FIRST", "INDEPENDENT", "bogus")) { + prefs.edit { putString(PreferenceKeys.SORT_ORDER, value) } + assertEquals(PasswordSortOrder.FOLDER_FIRST, PasswordSortOrder.getSortOrder(prefs)) + } + prefs.edit { putString(PreferenceKeys.SORT_ORDER, "RECENTLY_USED") } + assertEquals(PasswordSortOrder.RECENTLY_USED, PasswordSortOrder.getSortOrder(prefs)) + } +} -- cgit v1.2.3