diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt index ffab9ab6e7..7bdc521e6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -141,27 +142,39 @@ fun RepoInfoBar( branches: List = emptyList(), tags: List = emptyList(), onSelectRef: ((String?) -> Unit)? = null, + onHistory: (() -> Unit)? = null, ) { Row( - modifier = - Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 12.dp, vertical = 8.dp), + modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - if (onSelectRef != null && (branches.size + tags.size) > 1) { - BranchSelector(branch, branches, tags, onSelectRef) - } else if (branch != null) { - InfoChip(symbol = MaterialSymbols.AltRoute, label = branch) + Row( + modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (onSelectRef != null && (branches.size + tags.size) > 1) { + BranchSelector(branch, branches, tags, onSelectRef) + } else if (branch != null) { + InfoChip(symbol = MaterialSymbols.AltRoute, label = branch) + } + InfoChip(symbol = MaterialSymbols.Commit, label = headCommit.take(7), monospace = true) + Text( + text = pluralStringResource(R.plurals.git_repo_item_count, entryCount, entryCount), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f), + ) + } + if (onHistory != null) { + IconButton(onClick = onHistory) { + Icon( + symbol = MaterialSymbols.History, + contentDescription = stringRes(R.string.git_repo_commits), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } } - InfoChip(symbol = MaterialSymbols.Commit, label = headCommit.take(7), monospace = true) - Text( - text = pluralStringResource(R.plurals.git_repo_item_count, entryCount, entryCount), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f), - ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt index d7469fb329..6523965452 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt @@ -105,6 +105,14 @@ private fun CodeBrowser( ) { var pathString by rememberSaveable(snapshot.headCommit) { mutableStateOf("") } var openFilePath by rememberSaveable(snapshot.headCommit) { mutableStateOf(null) } + var showHistory by rememberSaveable(snapshot.headCommit) { mutableStateOf(false) } + + if (showHistory) { + Column(Modifier.fillMaxSize().padding(scaffoldPaddingTop)) { + GitCommitLog(snapshot, viewModel, onBack = { showHistory = false }) + } + return + } val path = remember(pathString) { if (pathString.isEmpty()) emptyList() else pathString.split("/") } val openPath = openFilePath?.let { if (it.isEmpty()) emptyList() else it.split("/") } @@ -148,6 +156,7 @@ private fun CodeBrowser( branches = snapshot.branches, tags = snapshot.tags, onSelectRef = { viewModel.switchRef(it) }, + onHistory = { showHistory = true }, ) FileSearchField(query = query, onQueryChange = { query = it }) HorizontalDivider(thickness = 0.5.dp) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCommitLog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCommitLog.kt new file mode 100644 index 0000000000..90be83a102 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCommitLog.kt @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code + +import android.text.format.DateUtils +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.note.types.GitDiffView +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip34Git.git.GitCommit +import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot +import com.vitorpamplona.quartz.nip34Git.patch.ParsedPatch +import kotlin.coroutines.cancellation.CancellationException + +/** + * Commit history for the current branch: a `git log`-style list. Tapping a commit + * loads the diff it introduced (vs its first parent) and renders it with the + * shared [GitDiffView]. + */ +@Composable +fun GitCommitLog( + snapshot: GitRepoSnapshot, + viewModel: GitRepositoryBrowserViewModel, + onBack: () -> Unit, +) { + var openCommit by rememberSaveable(snapshot.headCommit) { mutableStateOf(null) } + + val history by + produceState>?>(null, snapshot.headCommit) { + value = + try { + Result.success(viewModel.loadHistory(snapshot)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } + } + + val commits = history?.getOrNull() + + if (openCommit != null) { + val commit = remember(openCommit, commits) { commits?.firstOrNull { it.oid == openCommit } } + BackHandler { openCommit = null } + Column(Modifier.fillMaxSize()) { + LogHeader(title = commit?.shortOid ?: "", onBack = { openCommit = null }) + HorizontalDivider(thickness = 0.5.dp) + if (commit == null) { + GitMessageBox(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_file_load_error)) + } else { + CommitDiff(snapshot, viewModel, commit) + } + } + return + } + + BackHandler { onBack() } + Column(Modifier.fillMaxSize()) { + LogHeader(title = stringRes(R.string.git_repo_commits), onBack = onBack) + HorizontalDivider(thickness = 0.5.dp) + when { + history == null -> GitLoadingBox(stringRes(R.string.git_repo_code_loading)) + history!!.isFailure -> GitMessageBox(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_code_error)) + commits.isNullOrEmpty() -> GitMessageBox(MaterialSymbols.History, stringRes(R.string.git_repo_no_commits)) + else -> + LazyColumn(Modifier.fillMaxSize()) { + items(commits, key = { it.oid }) { commit -> + CommitRow(commit) { openCommit = commit.oid } + HorizontalDivider( + modifier = Modifier.padding(start = 14.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f), + ) + } + } + } + } +} + +@Composable +private fun CommitDiff( + snapshot: GitRepoSnapshot, + viewModel: GitRepositoryBrowserViewModel, + commit: GitCommit, +) { + val result by + produceState?>(null, commit.oid) { + value = + try { + Result.success(viewModel.commitDiff(snapshot, commit)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } + } + + when (val r = result) { + null -> GitLoadingBox(stringRes(R.string.git_repo_code_loading)) + else -> + if (r.isFailure || r.getOrThrow().files.isEmpty()) { + GitMessageBox(MaterialSymbols.Code, stringRes(R.string.git_pr_no_changes)) + } else { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(12.dp), + ) { + if (commit.summary.isNotBlank()) { + Text(commit.summary, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Text( + text = "${commit.shortOid} · ${commit.authorName}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.padding(bottom = 8.dp), + ) + } + GitDiffView(r.getOrThrow(), Modifier.fillMaxWidth()) + } + } + } +} + +@Composable +private fun CommitRow( + commit: GitCommit, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + symbol = MaterialSymbols.Commit, + contentDescription = null, + modifier = Modifier.size(18.dp).padding(top = 2.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column(Modifier.weight(1f)) { + Text( + text = commit.summary.ifBlank { commit.shortOid }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = commit.shortOid, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f), + modifier = + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(horizontal = 5.dp, vertical = 1.dp), + ) + Text( + text = "${commit.authorName} · ${relativeTime(commit.authorTimeSec)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun LogHeader( + title: String, + onBack: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + IconButton(onClick = onBack) { ArrowBackIcon() } + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private fun relativeTime(epochSec: Long): String = + if (epochSec <= 0) { + "" + } else { + DateUtils.getRelativeTimeSpanString(epochSec * 1000L, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt index 70807beaf4..a4dbe7d846 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt @@ -23,12 +23,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import com.vitorpamplona.quartz.nip34Git.git.GitCommit import com.vitorpamplona.quartz.nip34Git.git.GitHttpClient import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot +import com.vitorpamplona.quartz.nip34Git.patch.ParsedPatch import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import kotlin.coroutines.cancellation.CancellationException @@ -116,6 +119,18 @@ class GitRepositoryBrowserViewModel( oid: String, ): ByteArray = snapshot.readBlob(oid) + /** Recent commits ending at the snapshot's tip, most recent first. */ + suspend fun loadHistory(snapshot: GitRepoSnapshot): List = withContext(Dispatchers.IO) { client.loadHistory(snapshot.cloneUrl, snapshot.headCommit) } + + /** The diff a commit introduced (commit vs its first parent). */ + suspend fun commitDiff( + snapshot: GitRepoSnapshot, + commit: GitCommit, + ): ParsedPatch = + withContext(Dispatchers.IO) { + client.computeDiff(snapshot.cloneUrl, commit.oid, commit.parents.firstOrNull()) + } + /** http(s) clone URLs to try, in order, including a `.git` variant when missing. */ private fun candidateUrls(): List { val out = LinkedHashSet() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1428329e41..1329ff6d49 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2594,6 +2594,8 @@ Tags Search files… No matching files. + Commits + No commit history available. Copy file contents Text diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt index 9515914903..e0369dd187 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip34Git.patch.ParsedPatch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.OkHttpClient +import java.util.PriorityQueue /** * High-level read-only browser for a git repository served over smart-HTTP. @@ -72,7 +73,7 @@ class GitHttpClient( deepen = 1, // When the server can't filter, a shallow fetch already pulls every blob, so the // snapshot is fully self-contained and no lazy per-file fetch is needed. - filterBlobNone = caps.supportsFilter, + filter = "blob:none", ) val objects = Packfile.parse(pack) @@ -106,6 +107,72 @@ class GitHttpClient( ) } + /** + * Loads up to [depth] commits of history starting at [startCommit] (or the + * server's HEAD), most recent first. Uses a shallow `tree:0` fetch so only the + * commit objects come down — no trees or blobs. The shallow boundary caps how + * far back the log goes. + */ + suspend fun loadHistory( + cloneUrl: String, + startCommit: String?, + depth: Int = 50, + ): List { + require(cloneUrl.startsWith("http://") || cloneUrl.startsWith("https://")) { + "unsupported git transport (only http/https): $cloneUrl" + } + val caps = transport.fetchCapabilities(cloneUrl) + if (!caps.supportsFetch) throw GitHttpException("server does not speak git protocol v2 (fetch)") + + val start = + startCommit?.takeIf { it.isNotBlank() } + ?: run { + if (!caps.supportsLsRefs) throw GitHttpException("server can't list refs") + val refs = transport.lsRefs(cloneUrl, caps) + selectHead(refs, null)?.oid ?: throw GitHttpException("could not resolve HEAD") + } + + val pack = + transport.fetchPack( + cloneUrl = cloneUrl, + caps = caps, + wants = listOf(start), + deepen = depth, + // tree:0 → commits only; if the server can't filter, blob:none still works (we ignore the trees). + filter = if (caps.supportsFilter) "tree:0" else "blob:none", + ) + val objects = Packfile.parse(pack) + val commits = HashMap() + for (obj in objects.values) { + if (obj.type == GitObjectType.COMMIT) { + val commit = GitObjectParser.parseCommit(obj.oid, obj.data) + commits[commit.oid] = commit + } + } + + // Walk parents most-recent-first (like `git log`), bounded by depth and the shallow set. + val result = ArrayList() + val visited = HashSet() + val frontier = PriorityQueue(compareByDescending { it.authorTimeSec }) + commits[start]?.let { + frontier.add(it) + visited.add(start) + } + while (frontier.isNotEmpty() && result.size < depth) { + val commit = frontier.poll() + result.add(commit) + for (parent in commit.parents) { + if (parent !in visited) { + commits[parent]?.let { + frontier.add(it) + visited.add(parent) + } + } + } + } + return result + } + /** * Computes the diff a pull request introduces: the changes between * [baseCommit] (or, when null, the server's HEAD) and [headCommit]. Fetches @@ -139,7 +206,7 @@ class GitHttpClient( caps = caps, wants = listOf(headCommit, base).distinct(), deepen = 1, - filterBlobNone = caps.supportsFilter, + filter = "blob:none", ) val objects = Packfile.parse(treePack) @@ -179,7 +246,7 @@ class GitHttpClient( val needed = changes.flatMap { listOfNotNull(it.oldOid, it.newOid) }.filter { it !in blobs }.distinct() if (needed.isNotEmpty() && caps.supportsFilter) { - val blobPack = transport.fetchPack(cloneUrl, caps, needed, deepen = null, filterBlobNone = true) + val blobPack = transport.fetchPack(cloneUrl, caps, needed, deepen = null, filter = "blob:none") for ((oid, obj) in Packfile.parse(blobPack)) { if (obj.type == GitObjectType.BLOB) blobs[oid] = obj.data } @@ -359,7 +426,7 @@ class GitRepoSnapshot( caps = caps, wants = listOf(oid), deepen = null, - filterBlobNone = caps.supportsFilter, + filter = "blob:none", ) val obj = Packfile.parse(pack)[oid] diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt index d8349ba4b3..e65b2ca487 100644 Binary files a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt and b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt differ diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitSmartHttpTransport.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitSmartHttpTransport.kt index 24963345a0..d8f63b8aaf 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitSmartHttpTransport.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitSmartHttpTransport.kt @@ -89,14 +89,16 @@ class GitSmartHttpTransport( * * @param wants object ids to request. * @param deepen shallow depth (1 = tip only). Null for a full fetch. - * @param filterBlobNone request `filter blob:none` (omit file contents). + * @param filter a partial-clone filter spec such as `blob:none` (omit file + * contents) or `tree:0` (omit trees and blobs). Applied only when the server + * advertises `filter`. */ suspend fun fetchPack( cloneUrl: String, caps: GitCapabilities, wants: List, deepen: Int?, - filterBlobNone: Boolean, + filter: String?, ): ByteArray = withContext(Dispatchers.IO) { require(wants.isNotEmpty()) { "fetch requires at least one want" } @@ -105,7 +107,7 @@ class GitSmartHttpTransport( objectFormat = caps.objectFormat, wants = wants, deepen = if (caps.supportsShallow) deepen else null, - filterBlobNone = filterBlobNone && caps.supportsFilter, + filter = filter?.takeIf { caps.supportsFilter }, ) GitUploadPackV2.extractPack(PktLineCodec.parse(postUploadPack(cloneUrl, body))) } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitUploadPackV2.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitUploadPackV2.kt index f4b02e5fc4..61e9ce1f92 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitUploadPackV2.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitUploadPackV2.kt @@ -45,7 +45,7 @@ object GitUploadPackV2 { objectFormat: String, wants: List, deepen: Int?, - filterBlobNone: Boolean, + filter: String?, ): ByteArray = PktLineCodec.build { write(PktLineCodec.dataLine("command=fetch\n")) @@ -54,7 +54,7 @@ object GitUploadPackV2 { write(PktLineCodec.dataLine("no-progress\n")) wants.forEach { write(PktLineCodec.dataLine("want $it\n")) } if (deepen != null) write(PktLineCodec.dataLine("deepen $deepen\n")) - if (filterBlobNone) write(PktLineCodec.dataLine("filter blob:none\n")) + if (filter != null) write(PktLineCodec.dataLine("filter $filter\n")) write(PktLineCodec.dataLine("done\n")) write(PktLineCodec.FLUSH) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitCommitParserTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitCommitParserTest.kt new file mode 100644 index 0000000000..f8e2c8074e --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitCommitParserTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.git + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.Base64 + +class GitCommitParserTest { + // A real, SSH-signed commit object (raw `git cat-file commit HEAD`), so the + // multi-line `gpgsig` header must be skipped without breaking parsing. + private val signedCommitB64 = + "dHJlZSA5YjZiM2Y5YmEzYjQxMjUxZTQzZGY0MDJiMjM0Njc4NzBiNDA4YTU4CnBhcmVudCBlNTc2ZjVmY2FmZjBkMDAwNGI2NmIzMjE2YmI2MTlhN2UyNjIwMTA2CmF1dGhvciBKYW5lIERldiA8YUBiLmNvbT4gMTc4MjY4MTU3NSArMDAwMApjb21taXR0ZXIgSmFuZSBEZXYgPGFAYi5jb20+IDE3ODI2ODE1NzUgKzAwMDAKZ3Bnc2lnIC0tLS0tQkVHSU4gU1NIIFNJR05BVFVSRS0tLS0tCiBVMU5JVTBsSEFBQUFBUUFBQURNQUFBQUxjM05vTFdWa01qVTFNVGtBQUFBZ3JMenNmRklTRjRieThRK0ZLejI3WXBrSzFVU3NCQittCiBhbXUxUWtKbmJEc0FBQUFEWjJsMEFBQUFBQUFBQUFaemFHRTFNVElBQUFCVEFBQUFDM056YUMxbFpESTFOVEU1QUFBQVFNRVUrNE1HCiAxU0tyWmVIUE5zeldQRGk1TzRIN3IyeU1mMkpWeGx1SG5xV2V4WTBHWHN1ZXBrSTBETXBOMldmWHZvMDc3cm9ac2Ivejhnb3RJSS92CiBCd009CiAtLS0tLUVORCBTU0ggU0lHTkFUVVJFLS0tLS0KCnNlY29uZCBjb21taXQKCndpdGggYSBib2R5IGxpbmUK" + + @Test + fun parsesSignedCommit() { + val data = Base64.getDecoder().decode(signedCommitB64) + val commit = GitObjectParser.parseCommit("20f90551a0e493306229a6b50990d3025b721cb0", data) + + assertEquals("9b6b3f9ba3b41251e43df402b23467870b408a58", commit.treeOid) + assertEquals(listOf("e576f5fcaff0d0004b66b3216bb619a7e2620106"), commit.parents) + assertEquals("Jane Dev", commit.authorName) + assertEquals("a@b.com", commit.authorEmail) + assertEquals(1782681575L, commit.authorTimeSec) + assertEquals("second commit", commit.summary) + assertEquals("20f9055", commit.shortOid) + // The signature block must not leak into the message. + assertEquals("second commit\n\nwith a body line\n", commit.message) + } +}