mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
feat: commit history in the git repository code browser
Add a History action to the Code tab that shows a git-log-style list of the branch's recent commits, and opens the diff a commit introduced (vs its first parent) with the shared diff viewer. quartz: GitCommit + a commit-object parser (skips multi-line headers like a signed gpgsig), and GitHttpClient.loadHistory() — a shallow tree:0 fetch (commits only) walked most-recent-first. The fetch filter is generalized from a blob:none boolean to a filter spec so tree:0 can be requested. Unit-tested against a real SSH-signed commit object. amethyst: GitCommitLog (log list + per-commit diff), a History button on the repo header, and ViewModel hooks (loadHistory / commitDiff). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
This commit is contained in:
+29
-16
@@ -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<String> = emptyList(),
|
||||
tags: List<String> = 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
@@ -105,6 +105,14 @@ private fun CodeBrowser(
|
||||
) {
|
||||
var pathString by rememberSaveable(snapshot.headCommit) { mutableStateOf("") }
|
||||
var openFilePath by rememberSaveable(snapshot.headCommit) { mutableStateOf<String?>(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)
|
||||
|
||||
+259
@@ -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<String?>(null) }
|
||||
|
||||
val history by
|
||||
produceState<Result<List<GitCommit>>?>(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<Result<ParsedPatch>?>(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()
|
||||
}
|
||||
+15
@@ -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<GitCommit> = 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<String> {
|
||||
val out = LinkedHashSet<String>()
|
||||
|
||||
@@ -2594,6 +2594,8 @@
|
||||
<string name="git_repo_tags">Tags</string>
|
||||
<string name="git_repo_search_files">Search files…</string>
|
||||
<string name="git_repo_no_search_results">No matching files.</string>
|
||||
<string name="git_repo_commits">Commits</string>
|
||||
<string name="git_repo_no_commits">No commit history available.</string>
|
||||
<string name="git_repo_copy_file">Copy file contents</string>
|
||||
<string name="git_repo_plain_text">Text</string>
|
||||
<plurals name="git_repo_item_count">
|
||||
|
||||
+71
-4
@@ -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<GitCommit> {
|
||||
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<String, GitCommit>()
|
||||
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<GitCommit>()
|
||||
val visited = HashSet<String>()
|
||||
val frontier = PriorityQueue<GitCommit>(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]
|
||||
|
||||
Binary file not shown.
+5
-3
@@ -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<String>,
|
||||
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)))
|
||||
}
|
||||
|
||||
+2
-2
@@ -45,7 +45,7 @@ object GitUploadPackV2 {
|
||||
objectFormat: String,
|
||||
wants: List<String>,
|
||||
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)
|
||||
}
|
||||
|
||||
+48
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user