feat: add README and Code tabs to the git repository screen

Render the repository README in the first tab and add a Code tab that
browses the repo's file tree and renders source files (syntax-highlighted),
reading directly from the NIP-34 clone URL over the git smart-HTTP v2
protocol (works with GRASP/ngit bare servers as well as GitHub/GitLab).

quartz (jvmAndroid): a from-scratch git smart-HTTP v2 client — pkt-line
codec, packfile parser with OFS/REF delta resolution and SHA-1 oids,
tree/commit parsers, and a high-level browser that fetches a shallow
filter=blob:none snapshot (one request for the whole tree) and lazily
pulls file blobs on demand. Offline tests run against real captured
GitHub wire bytes plus a git-generated OFS-delta pack.

amethyst: README tab (rich markdown), Code tab (folders-first browser
with breadcrumb navigation + a file viewer that renders markdown or
syntax-highlighted source), a browser ViewModel, new UI strings, and a
Folder material symbol (font subset regenerated). Syntax highlighting
uses dev.snipme:highlights (Apache-2.0, permissive).

Tabs are now: README, Code, Overview, Issues, Patches & PRs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
This commit is contained in:
Claude
2026-06-28 14:46:32 +00:00
parent 82f40e0ed1
commit 291fda5728
21 changed files with 2249 additions and 6 deletions
+3
View File
@@ -457,6 +457,9 @@ dependencies {
implementation(libs.markdown.ui.material3)
implementation(libs.markdown.commonmark)
// Syntax highlighting for the git repository code browser (Apache-2.0)
implementation(libs.highlights)
// LaTeX math rendering ($...$ and $$...$$ inline equations)
implementation(libs.jlatexmath.android)
implementation(libs.jlatexmath.font.greek)
@@ -39,6 +39,7 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
@@ -49,6 +50,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
@@ -64,6 +66,9 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code.GitCodeTab
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code.GitReadmeTab
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code.GitRepositoryBrowserViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal.RepositoryIssuesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal.RepositoryPatchesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource.RepositoryFilterAssemblerSubscription
@@ -120,12 +125,19 @@ private fun PrepareGitRepositoryScreen(
factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true),
)
val browserViewModel: GitRepositoryBrowserViewModel =
viewModel(
key = note.idHex + "GitRepoBrowser",
factory = GitRepositoryBrowserViewModel.Factory(accountViewModel.httpClientBuilder::okHttpClientForPreview),
)
GitRepositoryScreen(
note = note,
openIssuesViewModel = openIssuesViewModel,
closedIssuesViewModel = closedIssuesViewModel,
openPatchesViewModel = openPatchesViewModel,
closedPatchesViewModel = closedPatchesViewModel,
browserViewModel = browserViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -139,6 +151,7 @@ private fun GitRepositoryScreen(
closedIssuesViewModel: RepositoryIssuesFeedViewModel,
openPatchesViewModel: RepositoryPatchesFeedViewModel,
closedPatchesViewModel: RepositoryPatchesFeedViewModel,
browserViewModel: GitRepositoryBrowserViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -149,6 +162,13 @@ private fun GitRepositoryScreen(
val event by observeNoteEvent<GitRepositoryEvent>(note, accountViewModel)
// Start the smart-HTTP browser as soon as the repository announcement arrives,
// so the README and Code tabs can fetch from its clone URL.
LaunchedEffect(event) {
event?.let { browserViewModel.loadOnce(it.clones()) }
}
val browserState by browserViewModel.state.collectAsStateWithLifecycle()
// RepositoryContentSubAssembler.updateFilter reads note.event and bails out if it isn't
// a GitRepositoryEvent yet. The compose subscription manager doesn't re-run updateFilter
// when note.event later mutates, so subscribing before the event has arrived (cold-start
@@ -159,7 +179,7 @@ private fun GitRepositoryScreen(
RepositoryFilterAssemblerSubscription(note, accountViewModel.dataSources().gitRepository)
}
val pagerState = rememberForeverPagerState(note.idHex + "GitRepoScreenPagerState") { 3 }
val pagerState = rememberForeverPagerState(note.idHex + "GitRepoScreenPagerState") { 5 }
DisappearingScaffold(
isInvertedLayout = false,
@@ -185,19 +205,29 @@ private fun GitRepositoryScreen(
val coroutineScope = rememberCoroutineScope()
Tab(
selected = pagerState.currentPage == 0,
text = { Text(stringRes(R.string.git_repo_tab_overview)) },
text = { Text(stringRes(R.string.git_repo_tab_readme)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
)
Tab(
selected = pagerState.currentPage == 1,
text = { Text(stringRes(R.string.git_repo_tab_issues)) },
text = { Text(stringRes(R.string.git_repo_tab_code)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
)
Tab(
selected = pagerState.currentPage == 2,
text = { Text(stringRes(R.string.git_repo_tab_patches)) },
text = { Text(stringRes(R.string.git_repo_tab_overview)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } },
)
Tab(
selected = pagerState.currentPage == 3,
text = { Text(stringRes(R.string.git_repo_tab_issues)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(3) } },
)
Tab(
selected = pagerState.currentPage == 4,
text = { Text(stringRes(R.string.git_repo_tab_patches)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(4) } },
)
}
}
},
@@ -211,13 +241,26 @@ private fun GitRepositoryScreen(
0 -> {
val currentEvent = event
if (currentEvent != null) {
GitRepositoryOverview(currentEvent, accountViewModel, nav)
GitReadmeTab(browserState, browserViewModel, currentEvent, accountViewModel, nav)
} else {
EmptyMessage(stringRes(R.string.loading_feed))
}
}
1 -> {
GitCodeTab(browserState, browserViewModel, accountViewModel, nav)
}
2 -> {
val currentEvent = event
if (currentEvent != null) {
GitRepositoryOverview(currentEvent, accountViewModel, nav)
} else {
EmptyMessage(stringRes(R.string.loading_feed))
}
}
3 -> {
StatusSplitFeed(
persistKey = note.idHex + "GitRepoIssuesStatus",
openViewModel = openIssuesViewModel,
@@ -227,7 +270,7 @@ private fun GitRepositoryScreen(
)
}
2 -> {
4 -> {
StatusSplitFeed(
persistKey = note.idHex + "GitRepoPatchesStatus",
openViewModel = openPatchesViewModel,
@@ -0,0 +1,95 @@
/*
* 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 androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.BoldHighlight
import dev.snipme.highlights.model.ColorHighlight
import dev.snipme.highlights.model.SyntaxLanguage
import dev.snipme.highlights.model.SyntaxThemes
/**
* Builds a syntax-highlighted [AnnotatedString] for a source file using the
* `dev.snipme:highlights` KMP tokenizer (Apache-2.0). Highlighting is CPU work,
* so callers should invoke [highlight] off the main thread.
*/
object CodeHighlighter {
/** Maps a file name to a supported language, or null when no highlighter fits. */
fun languageForFile(name: String): SyntaxLanguage? {
val ext = name.substringAfterLast('.', "").lowercase()
return when (ext) {
"kt", "kts" -> SyntaxLanguage.KOTLIN
"java" -> SyntaxLanguage.JAVA
"js", "mjs", "cjs", "jsx" -> SyntaxLanguage.JAVASCRIPT
"ts", "tsx" -> SyntaxLanguage.TYPESCRIPT
"py" -> SyntaxLanguage.PYTHON
"rb" -> SyntaxLanguage.RUBY
"rs" -> SyntaxLanguage.RUST
"go" -> SyntaxLanguage.GO
"c", "h" -> SyntaxLanguage.C
"cpp", "cc", "cxx", "hpp", "hh", "hxx" -> SyntaxLanguage.CPP
"cs" -> SyntaxLanguage.CSHARP
"swift" -> SyntaxLanguage.SWIFT
"php" -> SyntaxLanguage.PHP
"pl", "pm" -> SyntaxLanguage.PERL
"dart" -> SyntaxLanguage.DART
"coffee" -> SyntaxLanguage.COFFEESCRIPT
"sh", "bash", "zsh", "ksh" -> SyntaxLanguage.SHELL
else -> null
}
}
fun highlight(
code: String,
language: SyntaxLanguage,
darkMode: Boolean,
): AnnotatedString {
val highlights =
Highlights
.Builder()
.code(code)
.language(language)
.theme(SyntaxThemes.darcula(darkMode = darkMode))
.build()
.getHighlights()
return buildAnnotatedString {
append(code)
val len = code.length
highlights.forEach { highlight ->
val start = highlight.location.start.coerceIn(0, len)
val end = highlight.location.end.coerceIn(start, len)
if (start == end) return@forEach
when (highlight) {
is ColorHighlight ->
addStyle(SpanStyle(color = Color(0xFF000000L or (highlight.rgb.toLong() and 0xFFFFFF))), start, end)
is BoldHighlight ->
addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end)
}
}
}
}
}
@@ -0,0 +1,288 @@
/*
* 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 androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.material3.Button
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.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.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.commons.ui.layouts.LocalDisappearingScaffoldPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot
import com.vitorpamplona.quartz.nip34Git.git.GitTreeEntry
@Composable
fun GitCodeTab(
state: GitBrowseState,
viewModel: GitRepositoryBrowserViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val scaffoldPadding = LocalDisappearingScaffoldPadding.current
when (state) {
is GitBrowseState.Loading ->
CenteredStatus(stringRes(R.string.git_repo_code_loading), Modifier.padding(scaffoldPadding))
is GitBrowseState.Error ->
CenteredStatus(
text =
if (state.message == GitRepositoryBrowserViewModel.NO_CLONE_URL) {
stringRes(R.string.git_repo_no_clone_url)
} else {
stringRes(R.string.git_repo_code_error)
},
modifier = Modifier.padding(scaffoldPadding),
onRetry = if (state.message == GitRepositoryBrowserViewModel.NO_CLONE_URL) null else viewModel::reload,
)
is GitBrowseState.Loaded ->
CodeBrowser(state.snapshot, viewModel, accountViewModel, nav, scaffoldPaddingTop = scaffoldPadding)
}
}
@Composable
private fun CodeBrowser(
snapshot: GitRepoSnapshot,
viewModel: GitRepositoryBrowserViewModel,
accountViewModel: AccountViewModel,
nav: INav,
scaffoldPaddingTop: PaddingValues,
) {
var pathString by rememberSaveable(snapshot.headCommit) { mutableStateOf("") }
var openFilePath by rememberSaveable(snapshot.headCommit) { mutableStateOf<String?>(null) }
val path = remember(pathString) { if (pathString.isEmpty()) emptyList() else pathString.split("/") }
val openPath = openFilePath?.let { if (it.isEmpty()) emptyList() else it.split("/") }
if (openPath != null) {
val entry = remember(openFilePath) { snapshot.entryAt(openPath) }
BackHandler { openFilePath = null }
Column(Modifier.fillMaxSize().padding(scaffoldPaddingTop)) {
FileHeader(name = openPath.lastOrNull() ?: "", onBack = { openFilePath = null })
HorizontalDivider(thickness = 0.5.dp)
if (entry == null) {
CenteredStatus(stringRes(R.string.git_repo_file_load_error), Modifier)
} else {
GitFileViewer(
snapshot = snapshot,
viewModel = viewModel,
entry = entry,
accountViewModel = accountViewModel,
nav = nav,
modifier = Modifier.fillMaxSize(),
)
}
}
return
}
BackHandler(enabled = path.isNotEmpty()) { pathString = path.dropLast(1).joinToString("/") }
val entries = remember(snapshot, pathString) { snapshot.entriesAt(path).orEmpty() }
Column(Modifier.fillMaxSize().padding(scaffoldPaddingTop)) {
Breadcrumb(
path = path,
onNavigate = { depth -> pathString = path.take(depth).joinToString("/") },
)
HorizontalDivider(thickness = 0.5.dp)
if (entries.isEmpty()) {
CenteredStatus(stringRes(R.string.git_repo_empty_folder), Modifier)
} else {
LazyColumn(Modifier.fillMaxSize()) {
items(entries, key = { it.name }) { entry ->
EntryRow(
entry = entry,
onClick = {
val child = (path + entry.name).joinToString("/")
if (entry.isFolder) pathString = child else openFilePath = child
},
)
}
}
}
}
}
@Composable
private fun Breadcrumb(
path: List<String>,
onNavigate: (depth: Int) -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Crumb(label = stringRes(R.string.git_repo_root), enabled = path.isNotEmpty()) { onNavigate(0) }
path.forEachIndexed { index, segment ->
Icon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = null,
modifier = Modifier.size(16.dp).padding(horizontal = 2.dp),
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.4f),
)
Crumb(label = segment, enabled = index < path.lastIndex) { onNavigate(index + 1) }
}
}
}
@Composable
private fun Crumb(
label: String,
enabled: Boolean,
onClick: () -> Unit,
) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
fontWeight = if (enabled) FontWeight.Normal else FontWeight.SemiBold,
color =
if (enabled) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onBackground
},
maxLines = 1,
modifier = if (enabled) Modifier.clickable(onClick = onClick) else Modifier,
)
}
@Composable
private fun EntryRow(
entry: GitTreeEntry,
onClick: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
val symbol =
when {
entry.isFolder -> MaterialSymbols.Folder
entry.isSubmodule -> MaterialSymbols.Code
else -> MaterialSymbols.Description
}
Icon(
symbol = symbol,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint =
if (entry.isFolder) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
},
)
Text(
text = entry.name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun FileHeader(
name: 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 = name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun CenteredStatus(
text: String,
modifier: Modifier,
onRetry: (() -> Unit)? = null,
) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(24.dp),
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
)
if (onRetry != null) {
Button(onClick = onRetry) { Text(stringRes(R.string.git_repo_retry)) }
}
}
}
}
@@ -0,0 +1,192 @@
/*
* 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 androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot
import com.vitorpamplona.quartz.nip34Git.git.GitTreeEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
/**
* Renders a single file from the repository. Markdown files render as rich text,
* text files render with syntax highlighting, and binary files show a notice.
*/
@Composable
fun GitFileViewer(
snapshot: GitRepoSnapshot,
viewModel: GitRepositoryBrowserViewModel,
entry: GitTreeEntry,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
val result by
produceState<Result<ByteArray>?>(null, entry.oid) {
value =
try {
Result.success(viewModel.readBlob(snapshot, entry.oid))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
val bytes = result
when {
bytes == null -> CenteredMessage(stringRes(R.string.git_repo_code_loading), modifier)
bytes.isFailure -> CenteredMessage(stringRes(R.string.git_repo_file_load_error), modifier)
else -> {
val data = bytes.getOrThrow()
when {
isProbablyBinary(data) ->
CenteredMessage(stringRes(R.string.git_repo_binary_file, humanSize(data.size)), modifier)
isMarkdownFile(entry.name) ->
MarkdownFile(data.decodeToString(), accountViewModel, nav, modifier)
else ->
HighlightedCode(data.decodeToString(), entry.name, modifier)
}
}
}
}
@Composable
private fun MarkdownFile(
content: String,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier,
) {
val background = MaterialTheme.colorScheme.background
val backgroundColor = remember { mutableStateOf(background) }
Column(
modifier =
modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
RichTextViewer(
content = content,
canPreview = true,
quotesLeft = 1,
modifier = Modifier.fillMaxWidth(),
tags = EmptyTagList,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
private fun HighlightedCode(
code: String,
fileName: String,
modifier: Modifier,
) {
val darkMode = isSystemInDarkTheme()
val annotated by
produceState(AnnotatedString(code), code, fileName, darkMode) {
value =
withContext(Dispatchers.Default) {
val language = CodeHighlighter.languageForFile(fileName)
if (language == null) AnnotatedString(code) else CodeHighlighter.highlight(code, language, darkMode)
}
}
Text(
text = annotated,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
lineHeight = 18.sp,
softWrap = false,
color = MaterialTheme.colorScheme.onBackground,
modifier =
modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
@Composable
private fun CenteredMessage(
text: String,
modifier: Modifier,
) {
Column(
modifier = modifier.fillMaxSize().padding(24.dp),
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
)
}
}
private fun isMarkdownFile(name: String): Boolean {
val ext = name.substringAfterLast('.', "").lowercase()
return ext == "md" || ext == "markdown" || ext == "mdown" || ext == "mkd"
}
/** Heuristic: a NUL byte in the first chunk means the content isn't text. */
private fun isProbablyBinary(data: ByteArray): Boolean {
val limit = minOf(data.size, 8000)
for (i in 0 until limit) if (data[i].toInt() == 0) return true
return false
}
private fun humanSize(bytes: Int): String =
when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
else -> "${bytes / (1024 * 1024)} MB"
}
@@ -0,0 +1,210 @@
/*
* 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 androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot
import com.vitorpamplona.quartz.nip34Git.git.GitTreeEntry
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import kotlin.coroutines.cancellation.CancellationException
/**
* First tab of the repository screen. Renders the repository's README as rich
* markdown when it can be fetched, otherwise falls back to the announcement's
* own description so the tab is never empty.
*/
@Composable
fun GitReadmeTab(
state: GitBrowseState,
viewModel: GitRepositoryBrowserViewModel,
event: GitRepositoryEvent,
accountViewModel: AccountViewModel,
nav: INav,
) {
val scaffoldPadding = LocalDisappearingScaffoldPadding.current
val snapshot = (state as? GitBrowseState.Loaded)?.snapshot
val readme = remember(snapshot) { snapshot?.let { findReadme(it.rootEntries()) } }
if (snapshot != null && readme != null) {
ReadmeContent(snapshot, viewModel, readme, accountViewModel, nav, scaffoldPaddingTop = scaffoldPadding)
} else {
ReadmeFallback(
event = event,
loading = state is GitBrowseState.Loading,
accountViewModel = accountViewModel,
nav = nav,
scaffoldPaddingTop = scaffoldPadding,
)
}
}
@Composable
private fun ReadmeContent(
snapshot: GitRepoSnapshot,
viewModel: GitRepositoryBrowserViewModel,
readme: GitTreeEntry,
accountViewModel: AccountViewModel,
nav: INav,
scaffoldPaddingTop: PaddingValues,
) {
val content by
produceState<String?>(null, readme.oid) {
value =
try {
viewModel.readBlob(snapshot, readme.oid).decodeToString()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
""
}
}
when (val text = content) {
null -> StatusText(stringRes(R.string.git_repo_code_loading), scaffoldPaddingTop)
"" -> StatusText(stringRes(R.string.git_repo_file_load_error), scaffoldPaddingTop)
else -> {
val background = MaterialTheme.colorScheme.background
val backgroundColor = remember { mutableStateOf(background) }
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(scaffoldPaddingTop)
.padding(horizontal = 12.dp, vertical = 12.dp),
) {
RichTextViewer(
content = text,
canPreview = true,
quotesLeft = 1,
modifier = Modifier.fillMaxWidth(),
tags = EmptyTagList,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@Composable
private fun ReadmeFallback(
event: GitRepositoryEvent,
loading: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
scaffoldPaddingTop: PaddingValues,
) {
val description = event.description()?.takeIf { it.isNotBlank() }
if (description == null) {
StatusText(
text = if (loading) stringRes(R.string.git_repo_code_loading) else stringRes(R.string.git_repo_readme_missing),
scaffoldPaddingTop = scaffoldPaddingTop,
)
return
}
val background = MaterialTheme.colorScheme.background
val backgroundColor = remember { mutableStateOf(background) }
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(scaffoldPaddingTop)
.padding(horizontal = 12.dp, vertical = 12.dp),
) {
Text(
text = event.name() ?: event.dTag(),
style = MaterialTheme.typography.headlineSmall,
)
RichTextViewer(
content = description,
canPreview = true,
quotesLeft = 1,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
tags = EmptyTagList,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
if (loading) {
Text(
text = stringRes(R.string.git_repo_code_loading),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f),
modifier = Modifier.padding(top = 16.dp),
)
}
}
}
@Composable
private fun StatusText(
text: String,
scaffoldPaddingTop: PaddingValues,
) {
Column(
modifier = Modifier.fillMaxSize().padding(scaffoldPaddingTop).padding(24.dp),
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
)
}
}
/** Picks the most README-like file in the root, preferring markdown. */
private fun findReadme(entries: List<GitTreeEntry>): GitTreeEntry? {
val files = entries.filter { !it.isFolder }
val readmes = files.filter { it.name.substringBeforeLast('.').equals("readme", ignoreCase = true) || it.name.equals("readme", ignoreCase = true) }
if (readmes.isEmpty()) return null
val byPreference = listOf("readme.md", "readme.markdown", "readme.mdown", "readme", "readme.txt", "readme.rst")
for (preferred in byPreference) {
readmes.firstOrNull { it.name.equals(preferred, ignoreCase = true) }?.let { return it }
}
return readmes.first()
}
@@ -0,0 +1,129 @@
/*
* 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 androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.quartz.nip34Git.git.GitHttpClient
import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import kotlin.coroutines.cancellation.CancellationException
/** UI state for the git repository code/README browser. */
sealed interface GitBrowseState {
object Loading : GitBrowseState
class Loaded(
val snapshot: GitRepoSnapshot,
) : GitBrowseState
class Error(
val message: String,
) : GitBrowseState
}
/**
* Loads a shallow snapshot of a NIP-34 repository over git smart-HTTP and serves
* both the README tab and the Code browser. The snapshot lets the UI walk
* directories offline; [readBlob] lazily fetches file contents on demand.
*/
class GitRepositoryBrowserViewModel(
private val okHttpClient: (String) -> OkHttpClient,
) : ViewModel() {
private val client = GitHttpClient(okHttpClient)
private val _state = MutableStateFlow<GitBrowseState>(GitBrowseState.Loading)
val state = _state.asStateFlow()
private var cloneUrls: List<String> = emptyList()
private var started = false
/**
* Loads the repository once, using the announcement's clone URLs. Safe to call
* on every recomposition; only the first call (after the event has arrived) runs.
*/
fun loadOnce(cloneUrls: List<String>) {
if (started) return
started = true
this.cloneUrls = cloneUrls
reload()
}
fun reload() {
_state.value = GitBrowseState.Loading
viewModelScope.launch(Dispatchers.IO) {
val candidates = candidateUrls()
if (candidates.isEmpty()) {
_state.value = GitBrowseState.Error(NO_CLONE_URL)
return@launch
}
val errors = StringBuilder()
for (url in candidates) {
try {
val snapshot = client.open(url)
_state.value = GitBrowseState.Loaded(snapshot)
return@launch
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
errors
.append(url)
.append("")
.append(e.message ?: e.toString())
.append('\n')
}
}
_state.value = GitBrowseState.Error(errors.toString().trim())
}
}
suspend fun readBlob(
snapshot: GitRepoSnapshot,
oid: String,
): ByteArray = snapshot.readBlob(oid)
/** http(s) clone URLs to try, in order, including a `.git` variant when missing. */
private fun candidateUrls(): List<String> {
val out = LinkedHashSet<String>()
for (raw in cloneUrls) {
val url = raw.trim()
if (!url.startsWith("http://") && !url.startsWith("https://")) continue
out.add(url)
if (!url.removeSuffix("/").endsWith(".git")) out.add(url.removeSuffix("/") + ".git")
}
return out.toList()
}
class Factory(
private val okHttpClient: (String) -> OkHttpClient,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T = GitRepositoryBrowserViewModel(okHttpClient) as T
}
companion object {
const val NO_CLONE_URL = "no-clone-url"
}
}
+11
View File
@@ -2578,6 +2578,17 @@
<string name="git_repo_section_maintainers">Maintainers</string>
<string name="git_repo_section_topics">Topics</string>
<string name="git_repo_personal_fork">Personal fork</string>
<string name="git_repo_tab_readme">Readme</string>
<string name="git_repo_tab_code">Code</string>
<string name="git_repo_code_loading">Loading repository…</string>
<string name="git_repo_code_error">Could not load the repository from its clone URL.</string>
<string name="git_repo_no_clone_url">This repository announcement has no http(s) clone URL to browse.</string>
<string name="git_repo_readme_missing">This repository has no README file.</string>
<string name="git_repo_file_load_error">Could not load this file.</string>
<string name="git_repo_binary_file">Binary file (%1$s) — preview not available.</string>
<string name="git_repo_empty_folder">This folder is empty.</string>
<string name="git_repo_root">root</string>
<string name="git_repo_retry">Retry</string>
<string name="git_repositories">Git Repositories</string>
<string name="nsite_title">nSite: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
@@ -115,6 +115,7 @@ object MaterialSymbols {
val FileOpen = MaterialSymbol("\uEAF3")
val FilterAlt = MaterialSymbol("\uEF4F")
val FitnessCenter = MaterialSymbol("\uEB43")
val Folder = MaterialSymbol("\uE2C7")
val FolderZip = MaterialSymbol("\uEB2C")
val FormatBold = MaterialSymbol("\uE238")
val FormatItalic = MaterialSymbol("\uE23F")
+2
View File
@@ -47,6 +47,7 @@ lifecycleRuntimeKtx = "2.11.0"
lightcompressor-enhanced = "2.2.1"
jlatexmath = "1.4"
markdown = "f92ef49c9d"
highlights = "1.1.0"
material3 = "1.9.0"
media3 = "1.10.1"
mockk = "1.14.11"
@@ -191,6 +192,7 @@ jlatexmath-font-cyrillic = { module = "com.github.rikkahub.jlatexmath-android:jl
markdown-commonmark = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-commonmark", version.ref = "markdown" }
markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" }
markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui-material3", version.ref = "markdown" }
highlights = { group = "dev.snipme", name = "highlights", version.ref = "highlights" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"}
@@ -0,0 +1,87 @@
# Git smart-HTTP browser for NIP-34 repositories
Date: 2026-06-28
Module: `quartz` (protocol client) + `amethyst` (UI)
## Goal
On the git repository screen, render the repo's `README` in the first tab and add
a **Code** tab that browses the repository's file tree and renders source files
(syntax-highlighted), reading directly from the repo's git `clone` URL.
NIP-34 (`GitRepositoryEvent`, kind 30617) only guarantees a git `clone` URL — a
plain git-over-HTTP(S) endpoint. To work with **every** server in the Nostr git
ecosystem (GRASP / `ngit` bare servers as well as GitHub/GitLab/Gitea), we read
files via the **git smart-HTTP protocol v2** rather than host-specific web APIs.
## Survey (what already exists — reused, not duplicated)
- `GitRepositoryEvent.clones()` / `webs()` — quartz, kept as-is. Source of the
endpoint URLs.
- `OkHttpClientFactory` + the `(String) -> OkHttpClient` provider already injected
into quartz fetchers (`OkHttpNip05Fetcher`, `OkHttpLnurlEndpointResolver`). The
git client takes the same lambda, so it inherits Tor/proxy routing and the
onion-rewrite interceptors for free.
- `RenderContentAsMarkdown` (amethyst) — renders the `README.md` rich-text.
- `GitRepositoryScreen` HorizontalPager + `SecondaryTabRow` — the tab host we
extend.
Genuinely new: the git smart-HTTP client (no git/pack code existed anywhere) and
a syntax-highlighted code viewer.
## Protocol client (`quartz`, `jvmAndroid` source set)
Placed in `jvmAndroid` (shared by Android + Desktop JVM) so it can use
`java.util.zip.Inflater`, `java.security.MessageDigest`, and OkHttp directly with
no expect/actual. Not needed on iOS/native.
Package `com.vitorpamplona.quartz.nip34Git.git`:
- `PktLine` — git pkt-line frame reader/writer (flush `0000`, delim `0001`,
response-end `0002`, data otherwise).
- `GitObjectType`, `GitTreeEntry`, `GitTree`/commit parsers — pure byte parsing of
loose object payloads (`<mode> <name>\0<20-byte oid>` tree entries; `tree <oid>`
from a commit).
- `Packfile` — parses a v2 packfile: object headers, zlib inflate per object,
`OBJ_OFS_DELTA` / `OBJ_REF_DELTA` resolution, and SHA-1 oid computation
(`sha1("<type> <len>\0" + content)`).
- `GitDelta` — copy/insert delta instruction decoder.
- `GitSmartHttpTransport` — the three HTTP exchanges:
1. `GET {clone}/info/refs?service=git-upload-pack` (`Git-Protocol: version=2`)
→ capability advertisement (we read `fetch` features incl. `filter`, and
`object-format`).
2. `POST {clone}/git-upload-pack` `command=ls-refs` → HEAD oid + default branch.
3. `POST {clone}/git-upload-pack` `command=fetch` → sideband-framed packfile.
- `GitHttpRepository` / `GitHttpClient` — high level:
- `loadRepository(cloneUrl)``fetch want <HEAD> deepen 1 filter blob:none`,
yielding the commit + **all** trees in one shallow request. Builds
`treeOid -> entries` and a navigable snapshot (`entriesAt(path)`).
- `loadBlob(oid)` → lazy partial-clone fetch `want <oid> filter blob:none`
(cached). Falls back to a full `deepen 1` (no filter) snapshot for servers
that don't advertise `filter`, in which case all blobs arrive up-front.
### Why this shape
`filter blob:none` + `deepen 1` is exactly how a git partial clone browses a tip
without downloading file contents; lazy blob-by-oid fetch is the same mechanism
git uses to backfill missing blobs, so any server that advertises `filter`
supports both. We do **not** request `thin-pack`, so packs are self-contained and
deltas are `OFS_DELTA` (offset based) — no cross-pack base lookups.
Validated end-to-end against `github.com/octocat/Hello-World.git`; the captured
wire bytes are checked in as offline test fixtures (CI-safe, no network).
## UI (`amethyst`)
- Tabs become: **README**, **Code**, Overview, Issues, Patches.
- `GitReadmeTab` — fetches `README(.md/.markdown/...)` from the root tree, renders
via `RenderContentAsMarkdown`; falls back to the repo description / overview.
- `GitCodeTab` — file browser (breadcrumb + folders-first listing) backed by a
`GitRepositoryBrowserViewModel`; tapping a file opens `GitFileViewer`.
- `GitFileViewer` — markdown for `.md`, otherwise monospace + syntax highlighting
via the `dev.snipme:highlights` KMP library (Apache-2.0 — permissive, OK).
## Out of scope (future)
- Writing/committing, branch switching beyond HEAD, history/blame, large-binary
preview, sha256 object-format repos.
@@ -0,0 +1,94 @@
/*
* 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
/**
* Applies a git delta ([delta]) against a [base] object payload, reconstructing
* the target object. This is the format used by `OBJ_OFS_DELTA` / `OBJ_REF_DELTA`
* packfile entries.
*
* Layout: a base-size varint, a target-size varint, then a stream of
* instructions copy (high bit set, offset/size assembled from the selected
* following bytes) or insert (a literal run of `cmd` bytes).
*/
object GitDelta {
fun apply(
base: ByteArray,
delta: ByteArray,
): ByteArray {
var pos = 0
val baseSize = readVarInt(delta) { pos }.also { pos = it.second }.first
require(baseSize == base.size) {
"delta base size mismatch: header says $baseSize, base is ${base.size}"
}
val targetSize = readVarInt(delta) { pos }.also { pos = it.second }.first
val out = ByteArray(targetSize)
var outPos = 0
while (pos < delta.size) {
val cmd = delta[pos++].toInt() and 0xFF
if (cmd and 0x80 != 0) {
// copy from base
var copyOffset = 0
var copySize = 0
if (cmd and 0x01 != 0) copyOffset = copyOffset or (delta[pos++].toInt() and 0xFF)
if (cmd and 0x02 != 0) copyOffset = copyOffset or ((delta[pos++].toInt() and 0xFF) shl 8)
if (cmd and 0x04 != 0) copyOffset = copyOffset or ((delta[pos++].toInt() and 0xFF) shl 16)
if (cmd and 0x08 != 0) copyOffset = copyOffset or ((delta[pos++].toInt() and 0xFF) shl 24)
if (cmd and 0x10 != 0) copySize = copySize or (delta[pos++].toInt() and 0xFF)
if (cmd and 0x20 != 0) copySize = copySize or ((delta[pos++].toInt() and 0xFF) shl 8)
if (cmd and 0x40 != 0) copySize = copySize or ((delta[pos++].toInt() and 0xFF) shl 16)
if (copySize == 0) copySize = 0x10000
base.copyInto(out, outPos, copyOffset, copyOffset + copySize)
outPos += copySize
} else if (cmd != 0) {
// insert literal: the next `cmd` bytes
delta.copyInto(out, outPos, pos, pos + cmd)
outPos += cmd
pos += cmd
} else {
throw IllegalArgumentException("invalid delta opcode 0x00")
}
}
require(outPos == targetSize) { "delta produced $outPos bytes, expected $targetSize" }
return out
}
/** Little-endian base-128 varint as used in delta size headers. */
private inline fun readVarInt(
data: ByteArray,
startPos: () -> Int,
): Pair<Int, Int> {
var pos = startPos()
var value = 0
var shift = 0
while (true) {
val b = data[pos++].toInt() and 0xFF
value = value or ((b and 0x7F) shl shift)
if (b and 0x80 == 0) break
shift += 7
}
return value to pos
}
}
@@ -0,0 +1,182 @@
/*
* 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 kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
/**
* High-level read-only browser for a git repository served over smart-HTTP.
*
* [open] downloads a shallow, blob-less snapshot of the default branch tip (one
* request fetches the commit and every tree), returning a [GitRepoSnapshot] that
* can walk directories offline and lazily fetch individual file blobs on demand.
*/
class GitHttpClient(
okHttpClient: (String) -> OkHttpClient,
) {
private val transport = GitSmartHttpTransport(okHttpClient)
/**
* Connects to [cloneUrl] and loads the tree of [ref] (default: the server's
* HEAD branch). Only `http(s)` git endpoints are supported.
*/
suspend fun open(
cloneUrl: String,
ref: String? = null,
): GitRepoSnapshot {
require(cloneUrl.startsWith("http://") || cloneUrl.startsWith("https://")) {
"unsupported git transport (only http/https): $cloneUrl"
}
val caps = transport.fetchCapabilities(cloneUrl)
if (!caps.supportsLsRefs || !caps.supportsFetch) {
throw GitHttpException("server does not speak git protocol v2 (ls-refs/fetch)")
}
val refs = transport.lsRefs(cloneUrl, caps)
val head = selectHead(refs, ref) ?: throw GitHttpException("could not resolve a branch to browse")
val branch = head.symrefTarget?.removePrefix("refs/heads/") ?: ref?.removePrefix("refs/heads/")
val pack =
transport.fetchPack(
cloneUrl = cloneUrl,
caps = caps,
wants = listOf(head.oid),
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,
)
val objects = Packfile.parse(pack)
val commit = objects[head.oid] ?: throw GitHttpException("tip commit ${head.oid} missing from pack")
if (commit.type != GitObjectType.COMMIT) throw GitHttpException("tip ${head.oid} is not a commit")
val rootTreeOid =
GitObjectParser.parseCommitTree(commit.data)
?: throw GitHttpException("commit ${head.oid} has no tree")
val trees = HashMap<String, List<GitTreeEntry>>()
val blobs = HashMap<String, ByteArray>()
for ((oid, obj) in objects) {
when (obj.type) {
GitObjectType.TREE -> trees[oid] = GitObjectParser.parseTree(obj.data)
GitObjectType.BLOB -> blobs[oid] = obj.data
else -> {}
}
}
return GitRepoSnapshot(
cloneUrl = cloneUrl,
headCommit = head.oid,
branch = branch,
rootTreeOid = rootTreeOid,
trees = trees,
blobs = blobs,
transport = transport,
caps = caps,
)
}
/** Picks the ref to browse: an explicit [ref] if given, otherwise HEAD. */
private fun selectHead(
refs: List<GitRef>,
ref: String?,
): GitRef? {
if (ref != null) {
return refs.firstOrNull { it.name == ref } ?: refs.firstOrNull { it.name == "refs/heads/$ref" }
}
refs.firstOrNull { it.name == "HEAD" }?.let { return it }
return refs.firstOrNull { it.name == "refs/heads/main" }
?: refs.firstOrNull { it.name == "refs/heads/master" }
?: refs.firstOrNull { it.name.startsWith("refs/heads/") }
}
}
/**
* An offline-navigable snapshot of a repository tree plus lazy blob access.
* Directory listings are resolved from the in-memory tree map; file contents are
* fetched on demand (and cached) unless they were already pulled up-front.
*/
class GitRepoSnapshot(
val cloneUrl: String,
val headCommit: String,
val branch: String?,
private val rootTreeOid: String,
private val trees: Map<String, List<GitTreeEntry>>,
blobs: Map<String, ByteArray>,
private val transport: GitSmartHttpTransport,
private val caps: GitCapabilities,
) {
private val blobs = HashMap<String, ByteArray>(blobs)
private val blobMutex = Mutex()
/** Entries at the repository root, folders first. */
fun rootEntries(): List<GitTreeEntry> = sortForDisplay(trees[rootTreeOid].orEmpty())
/**
* Entries inside the directory at [path] (a list of path segments). Returns
* null if the path doesn't resolve to a directory.
*/
fun entriesAt(path: List<String>): List<GitTreeEntry>? {
var treeOid = rootTreeOid
for (segment in path) {
val entry = trees[treeOid]?.firstOrNull { it.name == segment && it.isFolder } ?: return null
treeOid = entry.oid
}
return trees[treeOid]?.let { sortForDisplay(it) }
}
/** Resolves the entry of a file (or folder) at the full [path], or null. */
fun entryAt(path: List<String>): GitTreeEntry? {
if (path.isEmpty()) return null
val parent = entriesAt(path.dropLast(1)) ?: return null
return parent.firstOrNull { it.name == path.last() }
}
fun hasBlob(oid: String): Boolean = blobs.containsKey(oid)
/** Returns the bytes of a blob, fetching (and caching) it if not already present. */
suspend fun readBlob(oid: String): ByteArray {
blobs[oid]?.let { return it }
return blobMutex.withLock {
blobs[oid]?.let { return it }
val pack =
transport.fetchPack(
cloneUrl = cloneUrl,
caps = caps,
wants = listOf(oid),
deepen = null,
filterBlobNone = caps.supportsFilter,
)
val obj =
Packfile.parse(pack)[oid]
?: throw GitHttpException("blob $oid missing from fetch response")
obj.data.also { blobs[oid] = it }
}
}
private fun sortForDisplay(entries: List<GitTreeEntry>): List<GitTreeEntry> =
entries.sortedWith(
compareByDescending<GitTreeEntry> { it.isFolder }.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
)
}
@@ -0,0 +1,149 @@
/*
* 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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.coroutines.executeAsync
/** A ref returned by `ls-refs`: an oid, its full name, and (for symrefs) its target. */
class GitRef(
val oid: String,
val name: String,
val symrefTarget: String? = null,
)
/** Parsed protocol-v2 server capabilities. */
class GitCapabilities(
val agent: String?,
val objectFormat: String,
val supportsLsRefs: Boolean,
val fetchFeatures: Set<String>,
) {
/** Some servers advertise `fetch` with no feature list; track that we saw the command. */
var rawHadFetch: Boolean = false
val supportsFetch: Boolean get() = fetchFeatures.isNotEmpty() || rawHadFetch
val supportsFilter: Boolean get() = fetchFeatures.contains("filter")
val supportsShallow: Boolean get() = fetchFeatures.contains("shallow")
}
/**
* Low-level git smart-HTTP **protocol v2** transport (`git-upload-pack` only;
* we never write). The three exchanges are `info/refs` (capabilities),
* `ls-refs`, and `fetch`. Wire encoding/decoding lives in [GitUploadPackV2].
*
* [okHttpClient] is the shared per-URL client provider, so every request inherits
* the app's proxy / Tor routing and onion-rewrite interceptors.
*/
class GitSmartHttpTransport(
private val okHttpClient: (String) -> OkHttpClient,
) {
suspend fun fetchCapabilities(cloneUrl: String): GitCapabilities =
withContext(Dispatchers.IO) {
val url = "${base(cloneUrl)}/info/refs?service=git-upload-pack"
val request =
Request
.Builder()
.url(url)
.header("Git-Protocol", "version=2")
.header("Accept", "*/*")
.get()
.build()
GitUploadPackV2.parseCapabilities(PktLineCodec.parse(execute(url, request)))
}
suspend fun lsRefs(
cloneUrl: String,
caps: GitCapabilities,
): List<GitRef> =
withContext(Dispatchers.IO) {
val body = GitUploadPackV2.lsRefsRequest(caps.objectFormat)
GitUploadPackV2.parseRefs(PktLineCodec.parse(postUploadPack(cloneUrl, body)))
}
/**
* Runs a `fetch` and returns the raw packfile bytes (sideband demuxed).
*
* @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).
*/
suspend fun fetchPack(
cloneUrl: String,
caps: GitCapabilities,
wants: List<String>,
deepen: Int?,
filterBlobNone: Boolean,
): ByteArray =
withContext(Dispatchers.IO) {
require(wants.isNotEmpty()) { "fetch requires at least one want" }
val body =
GitUploadPackV2.fetchRequest(
objectFormat = caps.objectFormat,
wants = wants,
deepen = if (caps.supportsShallow) deepen else null,
filterBlobNone = filterBlobNone && caps.supportsFilter,
)
GitUploadPackV2.extractPack(PktLineCodec.parse(postUploadPack(cloneUrl, body)))
}
private suspend fun postUploadPack(
cloneUrl: String,
body: ByteArray,
): ByteArray {
val url = "${base(cloneUrl)}/git-upload-pack"
val request =
Request
.Builder()
.url(url)
.header("Git-Protocol", "version=2")
.header("Accept", "application/x-git-upload-pack-result")
.post(body.toRequestBody(GIT_UPLOAD_PACK_REQUEST))
.build()
return execute(url, request)
}
private suspend fun execute(
url: String,
request: Request,
): ByteArray =
okHttpClient(url).newCall(request).executeAsync().use { response ->
if (!response.isSuccessful) {
throw GitHttpException("HTTP ${response.code} from $url")
}
response.body.bytes()
}
private fun base(cloneUrl: String): String = cloneUrl.trim().trimEnd('/')
companion object {
private val GIT_UPLOAD_PACK_REQUEST = "application/x-git-upload-pack-request".toMediaType()
}
}
class GitHttpException(
message: String,
) : Exception(message)
@@ -0,0 +1,143 @@
/*
* 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 java.io.ByteArrayOutputStream
/**
* Pure (transport-free) encoders and decoders for the git `git-upload-pack`
* protocol-v2 exchanges. Kept separate from [GitSmartHttpTransport] so the wire
* formats can be unit-tested against captured server bytes without any network.
*/
object GitUploadPackV2 {
fun lsRefsRequest(objectFormat: String): ByteArray =
PktLineCodec.build {
write(PktLineCodec.dataLine("command=ls-refs\n"))
write(PktLineCodec.dataLine("object-format=$objectFormat\n"))
write(PktLineCodec.DELIM)
write(PktLineCodec.dataLine("symrefs\n"))
write(PktLineCodec.dataLine("peel\n"))
write(PktLineCodec.dataLine("ref-prefix HEAD\n"))
write(PktLineCodec.dataLine("ref-prefix refs/heads/\n"))
write(PktLineCodec.dataLine("ref-prefix refs/tags/\n"))
write(PktLineCodec.FLUSH)
}
fun fetchRequest(
objectFormat: String,
wants: List<String>,
deepen: Int?,
filterBlobNone: Boolean,
): ByteArray =
PktLineCodec.build {
write(PktLineCodec.dataLine("command=fetch\n"))
write(PktLineCodec.dataLine("object-format=$objectFormat\n"))
write(PktLineCodec.DELIM)
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"))
write(PktLineCodec.dataLine("done\n"))
write(PktLineCodec.FLUSH)
}
fun parseCapabilities(lines: List<PktLine>): GitCapabilities {
var agent: String? = null
var objectFormat = "sha1"
var supportsLsRefs = false
var fetchFeatures = emptySet<String>()
var hadFetch = false
for (line in lines) {
if (line !is PktLine.Data) continue
val text = line.text()
if (text.startsWith("# service=")) continue
val eq = text.indexOf('=')
val key = if (eq >= 0) text.substring(0, eq) else text
val value = if (eq >= 0) text.substring(eq + 1) else ""
when (key) {
"agent" -> agent = value
"object-format" -> if (value.isNotBlank()) objectFormat = value.trim()
"ls-refs" -> supportsLsRefs = true
"fetch" -> {
hadFetch = true
fetchFeatures =
value
.split(' ')
.map { it.trim() }
.filter { it.isNotEmpty() }
.toSet()
}
}
}
return GitCapabilities(agent, objectFormat, supportsLsRefs, fetchFeatures).also { it.rawHadFetch = hadFetch }
}
fun parseRefs(lines: List<PktLine>): List<GitRef> {
val refs = ArrayList<GitRef>()
for (line in lines) {
if (line !is PktLine.Data) continue
val text = line.text()
if (text.isEmpty()) continue
val parts = text.split(' ')
if (parts.size < 2) continue
var symref: String? = null
for (i in 2 until parts.size) {
val attr = parts[i]
if (attr.startsWith("symref-target:")) symref = attr.substringAfter("symref-target:")
}
refs.add(GitRef(parts[0], parts[1], symref))
}
return refs
}
/** Demuxes the sideband-64k `packfile` section of a fetch response into raw pack bytes. */
fun extractPack(lines: List<PktLine>): ByteArray {
val pack = ByteArrayOutputStream()
val errors = StringBuilder()
var inPack = false
for (line in lines) {
when (line) {
is PktLine.Flush -> if (inPack) break
is PktLine.Delim -> {} // section separator
is PktLine.ResponseEnd -> if (inPack) break
is PktLine.Data -> {
if (!inPack) {
if (line.text() == "packfile") inPack = true
continue
}
val payload = line.payload
if (payload.isEmpty()) continue
when (payload[0].toInt() and 0xFF) {
1 -> pack.write(payload, 1, payload.size - 1) // pack data
2 -> {} // progress
3 -> errors.append(payload.decodeToString(1, payload.size)) // fatal
}
}
}
}
if (errors.isNotEmpty()) throw GitHttpException("git server error: $errors")
val bytes = pack.toByteArray()
if (bytes.size < 4) throw GitHttpException("empty packfile in fetch response")
return bytes
}
}
@@ -0,0 +1,243 @@
/*
* 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 java.security.MessageDigest
import java.util.zip.Inflater
/**
* Parser for a git version-2 packfile (`PACK` magic). Inflates each object,
* resolves `OBJ_OFS_DELTA` / `OBJ_REF_DELTA` chains, and computes each object's
* SHA-1 oid so callers can look objects up by id.
*
* We never request thin packs, so every delta base is present inside the same
* pack; `OFS_DELTA` bases always precede their delta, and `REF_DELTA` bases are
* resolved through a pre-built oid index.
*/
object Packfile {
private const val OBJ_COMMIT = 1
private const val OBJ_TREE = 2
private const val OBJ_BLOB = 3
private const val OBJ_TAG = 4
private const val OBJ_OFS_DELTA = 6
private const val OBJ_REF_DELTA = 7
private class Raw(
val offset: Int,
val type: Int,
val data: ByteArray,
val baseOffset: Int,
val baseOid: String?,
)
/** Parses [pack] and returns the resolved objects indexed by their oid (hex). */
fun parse(pack: ByteArray): Map<String, GitObject> {
require(pack.size >= 12) { "packfile too short" }
require(pack[0] == 'P'.code.toByte() && pack[1] == 'A'.code.toByte() && pack[2] == 'C'.code.toByte() && pack[3] == 'K'.code.toByte()) {
"missing PACK signature"
}
val version = readUInt32(pack, 4)
require(version == 2L || version == 3L) { "unsupported packfile version $version" }
val count = readUInt32(pack, 8).toInt()
val raws = ArrayList<Raw>(count)
val byOffset = HashMap<Int, Raw>(count * 2)
var p = 12
repeat(count) {
val start = p
var b = pack[p++].toInt() and 0xFF
val type = (b ushr 4) and 0x07
var size = (b and 0x0F).toLong()
var shift = 4
while (b and 0x80 != 0) {
b = pack[p++].toInt() and 0xFF
size = size or ((b and 0x7F).toLong() shl shift)
shift += 7
}
var baseOffset = -1
var baseOid: String? = null
when (type) {
OBJ_OFS_DELTA -> {
b = pack[p++].toInt() and 0xFF
var rel = (b and 0x7F).toLong()
while (b and 0x80 != 0) {
b = pack[p++].toInt() and 0xFF
rel = ((rel + 1) shl 7) or (b and 0x7F).toLong()
}
baseOffset = (start - rel).toInt()
}
OBJ_REF_DELTA -> {
baseOid = toHex(pack, p, 20)
p += 20
}
}
val (data, consumed) = inflate(pack, p, size.toInt())
p += consumed
val raw = Raw(start, type, data, baseOffset, baseOid)
raws.add(raw)
byOffset[start] = raw
}
return resolve(raws, byOffset)
}
private fun resolve(
raws: List<Raw>,
byOffset: Map<Int, Raw>,
): Map<String, GitObject> {
val memo = HashMap<Int, GitObject>(raws.size * 2)
val oidToOffset = HashMap<String, Int>(raws.size * 2)
// Pre-index oids of non-delta objects so REF_DELTA bases can be found
// regardless of pack ordering.
for (r in raws) {
if (r.type != OBJ_OFS_DELTA && r.type != OBJ_REF_DELTA) {
oidToOffset[oidOf(baseType(r.type), r.data)] = r.offset
}
}
fun resolveAt(offset: Int): GitObject {
memo[offset]?.let { return it }
val r = byOffset[offset] ?: throw IllegalStateException("delta base offset $offset not in pack")
val obj =
when (r.type) {
OBJ_OFS_DELTA -> {
val base = resolveAt(r.baseOffset)
val data = GitDelta.apply(base.data, r.data)
GitObject(base.type, data, oidOf(base.type, data))
}
OBJ_REF_DELTA -> {
val baseOffset =
oidToOffset[r.baseOid]
?: throw IllegalStateException("REF_DELTA base ${r.baseOid} missing from pack")
val base = resolveAt(baseOffset)
val data = GitDelta.apply(base.data, r.data)
GitObject(base.type, data, oidOf(base.type, data))
}
else -> GitObject(baseType(r.type), r.data, oidOf(baseType(r.type), r.data))
}
memo[offset] = obj
oidToOffset[obj.oid] = offset
return obj
}
val result = HashMap<String, GitObject>(raws.size * 2)
for (r in raws) {
val obj = resolveAt(r.offset)
result[obj.oid] = obj
}
return result
}
private fun baseType(type: Int): GitObjectType =
when (type) {
OBJ_COMMIT -> GitObjectType.COMMIT
OBJ_TREE -> GitObjectType.TREE
OBJ_BLOB -> GitObjectType.BLOB
OBJ_TAG -> GitObjectType.TAG
else -> throw IllegalArgumentException("not a base object type: $type")
}
private fun oidOf(
type: GitObjectType,
data: ByteArray,
): String {
val digest = MessageDigest.getInstance("SHA-1")
digest.update("${type.header} ${data.size}".encodeToByteArray())
digest.update(0)
digest.update(data)
return digest.digest().toHex()
}
private fun inflate(
src: ByteArray,
offset: Int,
expectedSize: Int,
): Pair<ByteArray, Int> {
val inflater = Inflater()
try {
inflater.setInput(src, offset, src.size - offset)
val out = ByteArray(expectedSize)
var got = 0
// Inflate until the stream is fully consumed (not just until we have all
// output bytes): the trailing adler32 checksum must be read too, otherwise
// bytesRead() is short and the next packed object's offset is misaligned.
val scratch = ByteArray(64)
while (!inflater.finished()) {
if (got < expectedSize) {
val n = inflater.inflate(out, got, expectedSize - got)
if (n == 0) {
if (inflater.needsInput() || inflater.needsDictionary()) break
} else {
got += n
}
} else {
val n = inflater.inflate(scratch, 0, scratch.size)
if (n == 0 && (inflater.needsInput() || inflater.needsDictionary())) break
}
}
if (got != expectedSize) {
throw IllegalStateException("inflate short read: $got of $expectedSize")
}
return out to inflater.bytesRead.toInt()
} finally {
inflater.end()
}
}
private fun readUInt32(
data: ByteArray,
offset: Int,
): Long {
var v = 0L
for (i in 0 until 4) v = (v shl 8) or (data[offset + i].toLong() and 0xFF)
return v
}
private fun toHex(
data: ByteArray,
offset: Int,
len: Int,
): String {
val sb = StringBuilder(len * 2)
for (i in 0 until len) {
val v = data[offset + i].toInt() and 0xFF
sb.append(HEX[v ushr 4])
sb.append(HEX[v and 0x0F])
}
return sb.toString()
}
private fun ByteArray.toHex(): String {
val sb = StringBuilder(size * 2)
for (b in this) {
val v = b.toInt() and 0xFF
sb.append(HEX[v ushr 4])
sb.append(HEX[v and 0x0F])
}
return sb.toString()
}
private val HEX = "0123456789abcdef".toCharArray()
}
@@ -0,0 +1,134 @@
/*
* 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 java.io.ByteArrayOutputStream
/**
* git `pkt-line` framing used by the smart-HTTP transport.
*
* Each frame is a 4-byte ASCII hexadecimal length prefix followed by that many
* bytes (the length count includes the 4 prefix bytes). Three lengths are
* reserved as control frames:
*
* - `0000` flush-pkt
* - `0001` delim-pkt (protocol v2 section separator)
* - `0002` response-end-pkt
*
* See `gitprotocol-common(5)` and `gitprotocol-v2(5)`.
*/
sealed interface PktLine {
object Flush : PktLine
object Delim : PktLine
object ResponseEnd : PktLine
/** A data frame. [payload] excludes the length prefix. */
class Data(
val payload: ByteArray,
) : PktLine {
/** Convenience for text frames, trailing `\n` stripped. */
fun text(): String = payload.decodeToString().trimEnd('\n')
}
}
object PktLineCodec {
/** Encodes a text payload as a single data frame (a trailing `\n` is the git convention). */
fun dataLine(text: String): ByteArray = dataLine(text.encodeToByteArray())
/** Encodes a binary payload as a single data frame. */
fun dataLine(payload: ByteArray): ByteArray {
val len = payload.size + 4
require(len <= 0xFFFF) { "pkt-line payload too large: ${payload.size}" }
val out = ByteArray(len)
writeLengthPrefix(out, len)
payload.copyInto(out, 4)
return out
}
val FLUSH: ByteArray = "0000".encodeToByteArray()
val DELIM: ByteArray = "0001".encodeToByteArray()
private fun writeLengthPrefix(
out: ByteArray,
len: Int,
) {
val hex = len.toString(16).padStart(4, '0')
for (i in 0 until 4) out[i] = hex[i].code.toByte()
}
/**
* Parses a full pkt-line stream into frames. The smart-HTTP responses we
* consume are small enough to read fully into memory before decoding.
*/
fun parse(bytes: ByteArray): List<PktLine> {
val result = ArrayList<PktLine>()
var i = 0
while (i + 4 <= bytes.size) {
val len = parseHex4(bytes, i)
when (len) {
0 -> {
result.add(PktLine.Flush)
i += 4
}
1 -> {
result.add(PktLine.Delim)
i += 4
}
2 -> {
result.add(PktLine.ResponseEnd)
i += 4
}
else -> {
require(len >= 4) { "invalid pkt-line length $len at offset $i" }
val end = i + len
require(end <= bytes.size) { "truncated pkt-line: need $end have ${bytes.size}" }
result.add(PktLine.Data(bytes.copyOfRange(i + 4, end)))
i = end
}
}
}
return result
}
private fun parseHex4(
bytes: ByteArray,
offset: Int,
): Int {
var value = 0
for (i in 0 until 4) {
val c = bytes[offset + i].toInt().toChar()
val digit =
when (c) {
in '0'..'9' -> c - '0'
in 'a'..'f' -> c - 'a' + 10
in 'A'..'F' -> c - 'A' + 10
else -> throw IllegalArgumentException("invalid pkt-line length char '$c' at offset ${offset + i}")
}
value = (value shl 4) or digit
}
return value
}
/** Builds a request body by concatenating pre-encoded frames. */
fun build(block: ByteArrayOutputStream.() -> Unit): ByteArray = ByteArrayOutputStream().apply(block).toByteArray()
}
@@ -0,0 +1,129 @@
/*
* 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.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
import java.util.Base64
class GitPackfileDeltaTest {
// A real pack of two big.txt versions produced with:
// git pack-objects --stdout --delta-base-offset --window=10 --depth=10
// It contains one full blob and one OBJ_OFS_DELTA against it.
private val deltaPack =
"UEFDSwAAAAIAAAACt4sDeJxl2LGOHFUURdHcX9Gf0Peequ6ukMBIlpADQCK2wAGSPQ5A4vdBZGc5fE68Zc+cVe/9cPv4/rfbjx9+/uXX208fPr5/9/u3r1+/vd2+/Pn2+Xav09Rp65Q6HXU66/So07NOrzpd/bcT0zXTOdM900HTRdNJ003TUdNV21XLv1FXbVdtV21XbVdtV21XbVelq9JV4b+uq9JV6ap0VboqXZWuOrrq6Kqjqw5+orrq6Kqjq46uOrrq6Kqzq86uOrvq7KqTH/SuOrvq7Kqzq86uenTVo6seXfXoqkdXPfj966pHVz266tFVz656dtWzq55d9eyqZ1c9mYWuenbVs6teXfXqqldXvbrq1VWvrnp11Yu16qpXV11ddXXV1VVXV11ddXXV1VVXV12MqCvKjN7Z0TtDemdJ70zpnS29M6Z31vTOnN7p+27m6XPoXXqn3q137F175569HwZ/VofoY/OH0R9Wf5j9YfeH4R+Wf5j+YfsnQkkf8z/s/wDAIMBAwGDAgMCgwMDAHEpOHxIMFAwWDBgMGgwcDB4MIAwizOmnBn2gMKgwsDC4MMAwyDDQMNgw4DAPv4Xow4cBiEGIgYjBiAGJQYmBicGJefqxRh9UDFYMWAxaDFwMXgxgDGIMZMzLr0n6UGNgY3BjgGOQY6BjsGPAY9BjLj93/d7lgxc/Fj8WPxY/Fj8WPxY/Fj8WP3b8IKcPPxY/Fj8WPxY/Fj8WP9b7gheG724M9Hln8NLgrcFrg/cGLw74sfix+LHxSkMffix+LH4sfix+LH4sfix+LH7s4Z2LPvxY/Fj8WPxY/Fj8WPxY/Fj82NNLIX34sfix+LH4sfix+LH4sfix+LEPb6304cfix+LH4sfix+LH4sfix+LHPr1W04cfix+LH4sfix+LH4sfix+LH/vy3k8ffix+LH4sfix+LH4sfix+LH7s5cOELxM8TeBH8CP4EfwIfgQ/gh/Bj+BHxqcT+vAj+BH8CH4EP4IfwY/gR/Aj69sOffgR/Ah+BD+CH8GP+PLk05NvT989PtHn85PvTz5A+QLlExR+BD+CH8GPHL6O0YcfwY/gR/Aj+BH8CH4EP4IfOX2+ow8/gh/Bj+BH8CP4EfwIfgQ/gh/Bj+BH8CP4EfwIfgQ/gh/Bj+BH8CP4EfwIfgQ/gh/Bj+BH8CP4EfwIfgQ/gh/Bj+BH8CP4EfwIfgQ/gh/Bj/znx6e3P26fbm+f/7l9+fTX3///+bt/ARJv6GvuAYYkeJzbbrjCUDQnMy9VIT9NoSS1okQhrzQ3KbVIwWCjwGQJAKvrCr5mknNvxZN0+vEf6iRSfsYDsdzxuw=="
private fun bigTxtCommon() = (0..399).joinToString("") { "common line $it\n" }
@Test
fun resolvesOfsDeltaAndComputesOids() {
val pack = Base64.getDecoder().decode(deltaPack)
val objects = Packfile.parse(pack)
val common = bigTxtCommon()
val expectedHead = "A NEW FIRST LINE\n" + common + "and a new last line\n"
val expectedPrev = "line of text number 0\n" + common
val head = objects["e873ccdfb69d504f8ce2d51255da3c530d191f21"]
assertNotNull("full base blob present", head)
assertEquals(GitObjectType.BLOB, head!!.type)
assertEquals(expectedHead, head.data.decodeToString())
val prev = objects["eb06ed9c0291ae7fd14aec388d470a1bff363f1e"]
assertNotNull("delta blob resolved", prev)
assertEquals(GitObjectType.BLOB, prev!!.type)
// The oid is recomputed from the reconstructed bytes, so a correct oid key
// proves both the delta application and the SHA-1 hashing are right.
assertEquals(expectedPrev, prev.data.decodeToString())
}
@Test
fun appliesHandcraftedDelta() {
val base = "the quick brown fox".encodeToByteArray()
// delta: copy "the quick " (offset 0, size 10), insert "red ", copy "fox" (offset 16, size 3)
val delta =
byteArrayOf(
base.size.toByte(), // base size varint (19)
("the quick ".length + "red ".length + "fox".length).toByte(), // target size (17)
// copy op: cmd 0x90 = copy, offset 0 (no offset bytes), size from one byte (0x0A = 10)
0x90.toByte(),
0x0A,
// insert op: literal of 4 bytes
0x04,
'r'.code.toByte(),
'e'.code.toByte(),
'd'.code.toByte(),
' '.code.toByte(),
// copy op: cmd 0x91 = copy, offset from one byte (0x10 = 16), size from one byte (0x03)
0x91.toByte(),
0x10,
0x03,
)
val result = GitDelta.apply(base, delta)
assertEquals("the quick red fox", result.decodeToString())
}
@Test
fun parsesTreeEntries() {
// Build a tree payload: "100644 README\0<20 bytes>" + "40000 src\0<20 bytes>"
val out = java.io.ByteArrayOutputStream()
out.write("100644 README".encodeToByteArray())
out.write(0)
out.write(hex("980a0d5f19a64b4b30a87d4206aade58726b60e3"))
out.write("40000 src".encodeToByteArray())
out.write(0)
out.write(hex("b4eecafa9be2f2006ce1b709d6857b07069b4608"))
val entries = GitObjectParser.parseTree(out.toByteArray())
assertEquals(2, entries.size)
assertEquals("README", entries[0].name)
assertEquals("980a0d5f19a64b4b30a87d4206aade58726b60e3", entries[0].oid)
assertEquals(false, entries[0].isFolder)
assertEquals("src", entries[1].name)
assertEquals(true, entries[1].isFolder)
}
@Test
fun parsesCommitTreeHeader() {
val commit =
(
"tree b4eecafa9be2f2006ce1b709d6857b07069b4608\n" +
"parent 0000000000000000000000000000000000000000\n" +
"author Someone <a@b.c> 1 +0000\n\nmessage\n"
).encodeToByteArray()
assertEquals("b4eecafa9be2f2006ce1b709d6857b07069b4608", GitObjectParser.parseCommitTree(commit))
}
@Test
fun roundTripsPktLine() {
val frame = PktLineCodec.dataLine("want abc\n")
assertArrayEquals("000dwant abc\n".encodeToByteArray(), frame)
val parsed = PktLineCodec.parse(frame + PktLineCodec.FLUSH + PktLineCodec.DELIM)
assertEquals(3, parsed.size)
assertEquals("want abc", (parsed[0] as PktLine.Data).text())
assertEquals(PktLine.Flush, parsed[1])
assertEquals(PktLine.Delim, parsed[2])
}
private fun hex(s: String): ByteArray = ByteArray(s.length / 2) { ((s[it * 2].digitToInt(16) shl 4) or s[it * 2 + 1].digitToInt(16)).toByte() }
}
@@ -0,0 +1,108 @@
/*
* 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.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Base64
/**
* Verifies the protocol-v2 parsers against real bytes captured from
* `github.com/octocat/Hello-World.git`. These fixtures are static so the test
* runs offline.
*/
class GitProtocolV2Test {
private fun decode(b64: String): ByteArray = Base64.getDecoder().decode(b64)
// GET /info/refs?service=git-upload-pack with Git-Protocol: version=2
private val infoRefs =
"MDAxZSMgc2VydmljZT1naXQtdXBsb2FkLXBhY2sKMDAwMDAwMGV2ZXJzaW9uIDIKMDAyOGFnZW50PWdpdC9naXRodWItZTBiNThhZGU0OTgwLUxpbnV4CjAwMTNscy1yZWZzPXVuYm9ybgowMDI3ZmV0Y2g9c2hhbGxvdyB3YWl0LWZvci1kb25lIGZpbHRlcgowMDEyc2VydmVyLW9wdGlvbgowMDE3b2JqZWN0LWZvcm1hdD1zaGExCjAwMDA="
// POST git-upload-pack command=ls-refs response
private val lsRefs =
"MDA1MjdmZDFhNjBiMDFmOTFiMzE0ZjU5OTU1YTRlNGQ0ZTgwZDhlZGYxMWQgSEVBRCBzeW1yZWYtdGFyZ2V0OnJlZnMvaGVhZHMvbWFzdGVyCjAwM2Y3ZmQxYTYwYjAxZjkxYjMxNGY1OTk1NWE0ZTRkNGU4MGQ4ZWRmMTFkIHJlZnMvaGVhZHMvbWFzdGVyCjAwNDhiMWIzZjk3MjM4MzExNDFhMzFhMWE3MjUyYTIxM2UyMTZlYTc2ZTU2IHJlZnMvaGVhZHMvb2N0b2NhdC1wYXRjaC0xCjAwM2RiM2NiZDViYmQ3ZTgxNDM2ZDJlZWUwNDUzN2VhMmI0YzBjYWQ0Y2RmIHJlZnMvaGVhZHMvdGVzdAowMDAw"
// POST git-upload-pack command=fetch (want HEAD, deepen 1, filter blob:none)
private val fetchResp =
"MDAxMXNoYWxsb3ctaW5mbwowMDM0c2hhbGxvdyA3ZmQxYTYwYjAxZjkxYjMxNGY1OTk1NWE0ZTRkNGU4MGQ4ZWRmMTFkMDAwMTAwMGRwYWNrZmlsZQowMTMwAVBBQ0sAAAACAAAAAp0UeJydjUtqwzAURedexYOOkz79LQilG2g7aDegz1VssC1XUcj2ayjdQGeXwz2c3gCKGkihBB8hi2S2CSI69tmOxkV2bH3UlsdhDw1bJ2NUkuxcYeSkssnJZOGklcWGoP0Ie6hlxN/fWem1UGIEhIXxOcQYUhRRs/ZHWsrCWbEawr1PtdHXBPpIvabQ6VJ/x+tWHxMazqmuLySUEuyMFEwnHpmHg65z7/iXPLyhXUH7fVmo4fuOW6cnS6XVlT73kHCd6q0/76Gn6SSG4R0PWuYNdCSwZaqFyrzg/ANUZmZ/ogJ4nDM0MDAzMVEIcnV08XVlmMHFGy+5zNvbYEWtE9uqexFF2QmPAZo4Cv5JlwtXPkO1ygyeE+5XX4ICt6XpMDAwNgF9MDAwMA=="
// POST git-upload-pack command=fetch (want the README blob by oid)
private val blobResp =
"MDAwZHBhY2tmaWxlCjAwM2EBUEFDSwAAAAIAAAABPXic80jNyclXCM8vyklR5AIAIJEESLzcQ5zr061K/FSjSFUFPLVK/b0wMDA2AQgwMDAw"
@Test
fun parsesCapabilities() {
val caps = GitUploadPackV2.parseCapabilities(PktLineCodec.parse(decode(infoRefs)))
assertEquals("sha1", caps.objectFormat)
assertTrue(caps.supportsLsRefs)
assertTrue(caps.supportsFetch)
assertTrue("server advertised filter", caps.supportsFilter)
assertTrue("server advertised shallow", caps.supportsShallow)
assertEquals("git/github-e0b58ade4980-Linux", caps.agent)
}
@Test
fun parsesLsRefs() {
val refs = GitUploadPackV2.parseRefs(PktLineCodec.parse(decode(lsRefs)))
val head = refs.first { it.name == "HEAD" }
assertEquals("7fd1a60b01f91b314f59955a4e4d4e80d8edf11d", head.oid)
assertEquals("refs/heads/master", head.symrefTarget)
assertNotNull(refs.firstOrNull { it.name == "refs/heads/master" })
assertNotNull(refs.firstOrNull { it.name == "refs/heads/test" })
}
@Test
fun demuxesAndParsesShallowPack() {
val pack = GitUploadPackV2.extractPack(PktLineCodec.parse(decode(fetchResp)))
val objects = Packfile.parse(pack)
// commit + root tree, blobs filtered out
val commit = objects["7fd1a60b01f91b314f59955a4e4d4e80d8edf11d"]
assertNotNull(commit)
assertEquals(GitObjectType.COMMIT, commit!!.type)
val rootTreeOid = GitObjectParser.parseCommitTree(commit.data)
assertEquals("b4eecafa9be2f2006ce1b709d6857b07069b4608", rootTreeOid)
val tree = objects[rootTreeOid]!!
assertEquals(GitObjectType.TREE, tree.type)
val entries = GitObjectParser.parseTree(tree.data)
val readme = entries.first { it.name == "README" }
assertEquals("980a0d5f19a64b4b30a87d4206aade58726b60e3", readme.oid)
assertFalse(readme.isFolder)
// blob:none means the README content is not in this pack
assertFalse(objects.containsKey(readme.oid))
}
@Test
fun parsesSingleBlobFetch() {
val pack = GitUploadPackV2.extractPack(PktLineCodec.parse(decode(blobResp)))
val objects = Packfile.parse(pack)
val blob = objects["980a0d5f19a64b4b30a87d4206aade58726b60e3"]
assertNotNull(blob)
assertEquals(GitObjectType.BLOB, blob!!.type)
assertEquals("Hello World!\n", blob.data.decodeToString())
}
}