mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
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:
@@ -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)
|
||||
|
||||
+49
-6
@@ -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,
|
||||
|
||||
+95
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+288
@@ -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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+192
@@ -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"
|
||||
}
|
||||
+210
@@ -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()
|
||||
}
|
||||
+129
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
+1
@@ -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")
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
Binary file not shown.
+149
@@ -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)
|
||||
+143
@@ -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()
|
||||
}
|
||||
+129
@@ -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() }
|
||||
}
|
||||
+108
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user