diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index ae681168a5..1c6dc4e336 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index 87d70f071f..03df150470 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -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(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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/CodeHighlighter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/CodeHighlighter.kt new file mode 100644 index 0000000000..10d0e034b5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/CodeHighlighter.kt @@ -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) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt new file mode 100644 index 0000000000..096b1ec504 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt @@ -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(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, + 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)) } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt new file mode 100644 index 0000000000..bd5436c26b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt @@ -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?>(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" + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt new file mode 100644 index 0000000000..1f9e4d5fd7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt @@ -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(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? { + 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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt new file mode 100644 index 0000000000..a724320f67 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt @@ -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.Loading) + val state = _state.asStateFlow() + + private var cloneUrls: List = 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) { + 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 { + val out = LinkedHashSet() + 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 create(modelClass: Class): T = GitRepositoryBrowserViewModel(okHttpClient) as T + } + + companion object { + const val NO_CLONE_URL = "no-clone-url" + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index fde413a38a..07fbdda10c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2578,6 +2578,17 @@ Maintainers Topics Personal fork + Readme + Code + Loading repository… + Could not load the repository from its clone URL. + This repository announcement has no http(s) clone URL to browse. + This repository has no README file. + Could not load this file. + Binary file (%1$s) — preview not available. + This folder is empty. + root + Retry Git Repositories nSite: %1$s nApplet: %1$s diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index 79ccdd8713..9aeb9602e5 100644 Binary files a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf and b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf differ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index d5e253298d..168fe2f1b5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -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") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 05adc89478..bb2e78c405 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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"} diff --git a/quartz/plans/2026-06-28-git-smart-http-browser.md b/quartz/plans/2026-06-28-git-smart-http-browser.md new file mode 100644 index 0000000000..fb48f89393 --- /dev/null +++ b/quartz/plans/2026-06-28-git-smart-http-browser.md @@ -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 (` \0<20-byte oid>` tree entries; `tree ` + 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(" \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 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 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. diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitDelta.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitDelta.kt new file mode 100644 index 0000000000..6b88a633cf --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitDelta.kt @@ -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 { + 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 + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt new file mode 100644 index 0000000000..e7d5657eef --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt @@ -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>() + val blobs = HashMap() + 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, + 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>, + blobs: Map, + private val transport: GitSmartHttpTransport, + private val caps: GitCapabilities, +) { + private val blobs = HashMap(blobs) + private val blobMutex = Mutex() + + /** Entries at the repository root, folders first. */ + fun rootEntries(): List = 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): List? { + 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): 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): List = + entries.sortedWith( + compareByDescending { it.isFolder }.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name }, + ) +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt new file mode 100644 index 0000000000..d8349ba4b3 Binary files /dev/null and b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt differ diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitSmartHttpTransport.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitSmartHttpTransport.kt new file mode 100644 index 0000000000..24963345a0 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitSmartHttpTransport.kt @@ -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, +) { + /** 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 = + 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, + 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) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitUploadPackV2.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitUploadPackV2.kt new file mode 100644 index 0000000000..f4b02e5fc4 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitUploadPackV2.kt @@ -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, + 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): GitCapabilities { + var agent: String? = null + var objectFormat = "sha1" + var supportsLsRefs = false + var fetchFeatures = emptySet() + 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): List { + val refs = ArrayList() + 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): 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 + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/Packfile.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/Packfile.kt new file mode 100644 index 0000000000..a8b953f6fc --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/Packfile.kt @@ -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 { + 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(count) + val byOffset = HashMap(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, + byOffset: Map, + ): Map { + val memo = HashMap(raws.size * 2) + val oidToOffset = HashMap(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(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 { + 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() +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/PktLine.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/PktLine.kt new file mode 100644 index 0000000000..e531563d1a --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/PktLine.kt @@ -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 { + val result = ArrayList() + 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() +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitPackfileDeltaTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitPackfileDeltaTest.kt new file mode 100644 index 0000000000..1134b6e520 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitPackfileDeltaTest.kt @@ -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 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() } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitProtocolV2Test.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitProtocolV2Test.kt new file mode 100644 index 0000000000..60925b6bb5 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitProtocolV2Test.kt @@ -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()) + } +}