Add repository browsing domain foundation

This commit is contained in:
최민영 2026-08-25 21:52:40 +09:00
parent 75aa5f83e5
commit a2cc7d4ca3
19 changed files with 715 additions and 3 deletions

View File

@ -0,0 +1,26 @@
package com.ubisam.ubgitea.domain.model
@ConsistentCopyVisibility
data class RepositoryCoordinates private constructor(
val owner: String,
val name: String,
) {
companion object {
fun of(owner: String, name: String): RepositoryCoordinates {
return RepositoryCoordinates(
owner = owner.validatedComponent("owner"),
name = name.validatedComponent("name"),
)
}
private fun String.validatedComponent(label: String): String {
val normalized = trim()
require(normalized.isNotEmpty()) { "Repository $label cannot be blank" }
require('/' !in normalized) { "Repository $label must be one path component" }
require(normalized.none(Char::isISOControl)) {
"Repository $label cannot contain control characters"
}
return normalized
}
}
}

View File

@ -0,0 +1,81 @@
package com.ubisam.ubgitea.domain.model
import java.util.Collections
data class RepositorySummary(
val coordinates: RepositoryCoordinates,
val description: String?,
val defaultBranch: RepositoryRef,
val isPrivate: Boolean,
val isFork: Boolean,
)
data class BranchSummary(
val name: RepositoryRef,
val commitId: String,
val isProtected: Boolean,
)
enum class TreeEntryType {
DIRECTORY,
FILE,
SYMLINK,
SUBMODULE,
}
data class TreeEntry(
val name: String,
val path: RepositoryPath,
val type: TreeEntryType,
val sizeBytes: Long?,
)
data class SourceFile(
val path: RepositoryPath,
val ref: RepositoryRef,
val content: SourceContent,
val mediaType: String?,
)
class SourceContent private constructor(private val bytes: ByteArray) {
val sizeBytes: Int
get() = bytes.size
fun copyBytes(): ByteArray = bytes.copyOf()
override fun equals(other: Any?): Boolean =
other is SourceContent && bytes.contentEquals(other.bytes)
override fun hashCode(): Int {
return bytes.contentHashCode()
}
companion object {
fun from(bytes: ByteArray): SourceContent = SourceContent(bytes.copyOf())
}
}
data class CommitSummary(
val id: String,
val shortId: String,
val message: String,
val authorName: String,
val authoredAtEpochMillis: Long,
)
/**
* An immutable snapshot of one result page.
*
* The list structure is unmodifiable. Callers and adapters must also use immutable item types.
*/
class Page<T> private constructor(
val items: List<T>,
val nextPage: Int?,
) {
companion object {
fun <T> of(items: Collection<T>, nextPage: Int?): Page<T> {
require(nextPage == null || nextPage > 0) { "Next page must be positive" }
return Page(Collections.unmodifiableList(ArrayList(items)), nextPage)
}
}
}

View File

@ -0,0 +1,25 @@
package com.ubisam.ubgitea.domain.model
@JvmInline
value class RepositoryPath private constructor(val value: String) {
val isRoot: Boolean
get() = value.isEmpty()
companion object {
val Root = RepositoryPath("")
fun of(value: String): RepositoryPath {
val normalized = value.trimStart('/').trimEnd('/')
if (normalized.isEmpty()) return Root
require(normalized.none(Char::isISOControl)) {
"Repository path cannot contain control characters"
}
val segments = normalized.split('/')
require(segments.none { it.isEmpty() || it == "." || it == ".." }) {
"Repository path cannot contain empty or traversal segments"
}
return RepositoryPath(normalized)
}
}
}

View File

@ -0,0 +1,14 @@
package com.ubisam.ubgitea.domain.model
@JvmInline
value class RepositoryRef private constructor(val value: String) {
companion object {
fun of(value: String): RepositoryRef {
require(value.isNotBlank()) { "Repository ref cannot be blank" }
require(value.none(Char::isISOControl)) {
"Repository ref cannot contain control characters"
}
return RepositoryRef(value)
}
}
}

View File

@ -0,0 +1,43 @@
package com.ubisam.ubgitea.domain.repository
import com.ubisam.ubgitea.domain.model.BranchSummary
import com.ubisam.ubgitea.domain.model.CommitSummary
import com.ubisam.ubgitea.domain.model.Page
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositoryPath
import com.ubisam.ubgitea.domain.model.RepositoryRef
import com.ubisam.ubgitea.domain.model.RepositorySummary
import com.ubisam.ubgitea.domain.model.SourceFile
import com.ubisam.ubgitea.domain.model.TreeEntry
interface GiteaRepositoryBrowser {
suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary>
suspend fun listBranches(
repository: RepositoryCoordinates,
page: Int,
pageSize: Int,
): Page<BranchSummary>
suspend fun listTree(
repository: RepositoryCoordinates,
ref: RepositoryRef,
path: RepositoryPath,
page: Int,
pageSize: Int,
): Page<TreeEntry>
suspend fun getFile(
repository: RepositoryCoordinates,
ref: RepositoryRef,
path: RepositoryPath,
): SourceFile
suspend fun listCommits(
repository: RepositoryCoordinates,
ref: RepositoryRef,
path: RepositoryPath?,
page: Int,
pageSize: Int,
): Page<CommitSummary>
}

View File

@ -0,0 +1,82 @@
package com.ubisam.ubgitea.domain.usecase
import com.ubisam.ubgitea.domain.model.BranchSummary
import com.ubisam.ubgitea.domain.model.CommitSummary
import com.ubisam.ubgitea.domain.model.Page
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositoryPath
import com.ubisam.ubgitea.domain.model.RepositoryRef
import com.ubisam.ubgitea.domain.model.RepositorySummary
import com.ubisam.ubgitea.domain.model.SourceFile
import com.ubisam.ubgitea.domain.model.TreeEntry
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
class ListRepositoriesUseCase(
private val repositoryBrowser: GiteaRepositoryBrowser,
) {
suspend operator fun invoke(page: Int, pageSize: Int): Page<RepositorySummary> {
validatePagination(page, pageSize)
return repositoryBrowser.listRepositories(page, pageSize)
}
}
class ListBranchesUseCase(
private val repositoryBrowser: GiteaRepositoryBrowser,
) {
suspend operator fun invoke(
repository: RepositoryCoordinates,
page: Int,
pageSize: Int,
): Page<BranchSummary> {
validatePagination(page, pageSize)
return repositoryBrowser.listBranches(repository, page, pageSize)
}
}
class BrowseTreeUseCase(
private val repositoryBrowser: GiteaRepositoryBrowser,
) {
suspend operator fun invoke(
repository: RepositoryCoordinates,
ref: RepositoryRef,
path: RepositoryPath,
page: Int,
pageSize: Int,
): Page<TreeEntry> {
validatePagination(page, pageSize)
return repositoryBrowser.listTree(repository, ref, path, page, pageSize)
}
}
class ListCommitsUseCase(
private val repositoryBrowser: GiteaRepositoryBrowser,
) {
suspend operator fun invoke(
repository: RepositoryCoordinates,
ref: RepositoryRef,
path: RepositoryPath?,
page: Int,
pageSize: Int,
): Page<CommitSummary> {
validatePagination(page, pageSize)
return repositoryBrowser.listCommits(repository, ref, path, page, pageSize)
}
}
private fun validatePagination(page: Int, pageSize: Int) {
require(page > 0) { "Page must be positive" }
require(pageSize in 1..100) { "Page size must be between 1 and 100" }
}
class LoadSourceFileUseCase(
private val repositoryBrowser: GiteaRepositoryBrowser,
) {
suspend operator fun invoke(
repository: RepositoryCoordinates,
ref: RepositoryRef,
path: RepositoryPath,
): SourceFile {
require(!path.isRoot) { "A source file path cannot be the repository root" }
return repositoryBrowser.getFile(repository, ref, path)
}
}

View File

@ -16,7 +16,10 @@ import com.ubisam.ubgitea.R
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
@Composable
fun HomeScreen(modifier: Modifier = Modifier) {
fun HomeScreen(
state: HomeUiState = HomeUiState.Ready,
modifier: Modifier = Modifier,
) {
Scaffold(modifier = modifier.fillMaxSize()) { innerPadding ->
Column(
modifier = Modifier
@ -30,13 +33,25 @@ fun HomeScreen(modifier: Modifier = Modifier) {
style = MaterialTheme.typography.headlineLarge,
)
Text(
text = stringResource(R.string.home_placeholder),
text = stringResource(state.messageResource),
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
data class HomeUiState(
val isReadOnly: Boolean,
val messageResource: Int,
) {
companion object {
val Ready = HomeUiState(
isReadOnly = true,
messageResource = R.string.home_read_only_placeholder,
)
}
}
@Preview(showBackground = true)
@Composable
private fun HomeScreenPreview() {

View File

@ -1,4 +1,4 @@
<resources>
<string name="app_name">UBGitea</string>
<string name="home_placeholder">회사 Gitea를 위한 Android 앱을 준비하고 있습니다.</string>
<string name="home_read_only_placeholder">저장소, 브랜치와 코드를 안전하게 둘러볼 읽기 전용 앱을 준비하고 있습니다.</string>
</resources>

View File

@ -0,0 +1,24 @@
package com.ubisam.ubgitea.domain.model
import org.junit.Assert.assertEquals
import org.junit.Test
class RepositoryCoordinatesTest {
@Test
fun trimsValidCoordinates() {
val coordinates = RepositoryCoordinates.of(" ubisam ", " mobile-app ")
assertEquals("ubisam", coordinates.owner)
assertEquals("mobile-app", coordinates.name)
}
@Test(expected = IllegalArgumentException::class)
fun rejectsBlankOwner() {
RepositoryCoordinates.of(" ", "mobile-app")
}
@Test(expected = IllegalArgumentException::class)
fun rejectsSlashInName() {
RepositoryCoordinates.of("ubisam", "mobile/app")
}
}

View File

@ -0,0 +1,41 @@
package com.ubisam.ubgitea.domain.model
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class RepositoryModelsTest {
@Test
fun sourceContentCopiesInputAndOutput() {
val input = byteArrayOf(1, 2, 3)
val content = SourceContent.from(input)
input[0] = 9
val output = content.copyBytes()
output[1] = 9
assertArrayEquals(byteArrayOf(1, 2, 3), content.copyBytes())
}
@Test
fun pageCopiesMutableInput() {
val input = mutableListOf("first")
val page = Page.of(input, nextPage = 2)
input += "second"
assertEquals(listOf("first"), page.items)
assertEquals(2, page.nextPage)
}
@Test
fun pageDoesNotExposeMutableListImplementation() {
val page = Page.of(listOf("first", "second"), nextPage = null)
assertThrows(UnsupportedOperationException::class.java) {
@Suppress("UNCHECKED_CAST")
(page.items as MutableList<String>).add("third")
}
assertEquals(listOf("first", "second"), page.items)
}
}

View File

@ -0,0 +1,40 @@
package com.ubisam.ubgitea.domain.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class RepositoryPathTest {
@Test
fun normalizesLeadingAndTrailingSlashes() {
assertEquals("src/main/App.kt", RepositoryPath.of("/src/main/App.kt/").value)
}
@Test
fun representsRootPath() {
assertTrue(RepositoryPath.of("/").isRoot)
}
@Test
fun preservesCharactersThatUrlLayerMustEncodeExactlyOnce() {
assertEquals(
"한글 경로/100%#?.kt",
RepositoryPath.of("한글 경로/100%#?.kt").value,
)
}
@Test(expected = IllegalArgumentException::class)
fun rejectsTraversalSegments() {
RepositoryPath.of("src/../secrets.txt")
}
@Test(expected = IllegalArgumentException::class)
fun rejectsEmptySegments() {
RepositoryPath.of("src//App.kt")
}
@Test(expected = IllegalArgumentException::class)
fun rejectsControlCharacters() {
RepositoryPath.of("src/App\u0000.kt")
}
}

View File

@ -0,0 +1,29 @@
package com.ubisam.ubgitea.domain.model
import org.junit.Assert.assertEquals
import org.junit.Test
class RepositoryRefTest {
@Test
fun acceptsNormalBranchTagAndCommitNames() {
assertEquals("feature/code-browser", RepositoryRef.of("feature/code-browser").value)
assertEquals("기능/코드-보기", RepositoryRef.of("기능/코드-보기").value)
assertEquals("v1.0.0", RepositoryRef.of("v1.0.0").value)
assertEquals("a1b2c3d", RepositoryRef.of("a1b2c3d").value)
}
@Test
fun doesNotSilentlyTrimRef() {
assertEquals(" feature ", RepositoryRef.of(" feature ").value)
}
@Test(expected = IllegalArgumentException::class)
fun rejectsBlankRef() {
RepositoryRef.of(" ")
}
@Test(expected = IllegalArgumentException::class)
fun rejectsControlCharacters() {
RepositoryRef.of("main\nother")
}
}

View File

@ -0,0 +1,142 @@
package com.ubisam.ubgitea.domain.usecase
import com.ubisam.ubgitea.domain.model.BranchSummary
import com.ubisam.ubgitea.domain.model.CommitSummary
import com.ubisam.ubgitea.domain.model.Page
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositoryPath
import com.ubisam.ubgitea.domain.model.RepositoryRef
import com.ubisam.ubgitea.domain.model.RepositorySummary
import com.ubisam.ubgitea.domain.model.SourceContent
import com.ubisam.ubgitea.domain.model.SourceFile
import com.ubisam.ubgitea.domain.model.TreeEntry
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
import kotlin.coroutines.Continuation
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.startCoroutine
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class RepositoryBrowseUseCasesTest {
private val coordinates = RepositoryCoordinates.of("ubisam", "mobile")
private val ref = RepositoryRef.of("feature/code-browser")
private val browser = RecordingBrowser()
@Test
fun validPagingDelegatesAcrossListUseCases() {
runSuspend { ListRepositoriesUseCase(browser)(1, 20) }
runSuspend { ListBranchesUseCase(browser)(coordinates, 2, 50) }
runSuspend {
BrowseTreeUseCase(browser)(coordinates, ref, RepositoryPath.Root, 3, 100)
}
runSuspend { ListCommitsUseCase(browser)(coordinates, ref, null, 4, 1) }
assertEquals(
listOf("repositories:1:20", "branches:2:50", "tree:3:100", "commits:4:1"),
browser.calls,
)
}
@Test
fun invalidPageDoesNotDelegate() {
assertThrows(IllegalArgumentException::class.java) {
runSuspend { ListBranchesUseCase(browser)(coordinates, 0, 20) }
}
assertEquals(emptyList<String>(), browser.calls)
}
@Test
fun invalidPageSizeDoesNotDelegate() {
assertThrows(IllegalArgumentException::class.java) {
runSuspend {
BrowseTreeUseCase(browser)(coordinates, ref, RepositoryPath.Root, 1, 101)
}
}
assertEquals(emptyList<String>(), browser.calls)
}
@Test
fun rootSourceFileDoesNotDelegate() {
assertThrows(IllegalArgumentException::class.java) {
runSuspend { LoadSourceFileUseCase(browser)(coordinates, ref, RepositoryPath.Root) }
}
assertEquals(emptyList<String>(), browser.calls)
}
@Test
fun validSourceFileDelegates() {
val path = RepositoryPath.of("src/App.kt")
val result = runSuspend { LoadSourceFileUseCase(browser)(coordinates, ref, path) }
assertEquals(path, result.path)
assertEquals(listOf("file:src/App.kt"), browser.calls)
}
private class RecordingBrowser : GiteaRepositoryBrowser {
val calls = mutableListOf<String>()
override suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary> {
calls += "repositories:$page:$pageSize"
return Page.of(emptyList(), null)
}
override suspend fun listBranches(
repository: RepositoryCoordinates,
page: Int,
pageSize: Int,
): Page<BranchSummary> {
calls += "branches:$page:$pageSize"
return Page.of(emptyList(), null)
}
override suspend fun listTree(
repository: RepositoryCoordinates,
ref: RepositoryRef,
path: RepositoryPath,
page: Int,
pageSize: Int,
): Page<TreeEntry> {
calls += "tree:$page:$pageSize"
return Page.of(emptyList(), null)
}
override suspend fun getFile(
repository: RepositoryCoordinates,
ref: RepositoryRef,
path: RepositoryPath,
): SourceFile {
calls += "file:${path.value}"
return SourceFile(path, ref, SourceContent.from(byteArrayOf()), "text/plain")
}
override suspend fun listCommits(
repository: RepositoryCoordinates,
ref: RepositoryRef,
path: RepositoryPath?,
page: Int,
pageSize: Int,
): Page<CommitSummary> {
calls += "commits:$page:$pageSize"
return Page.of(emptyList(), null)
}
}
}
private fun <T> runSuspend(block: suspend () -> T): T {
var outcome: Result<T>? = null
block.startCoroutine(
object : Continuation<T> {
override val context = EmptyCoroutineContext
override fun resumeWith(result: Result<T>) {
outcome = result
}
},
)
return checkNotNull(outcome).getOrThrow()
}

View File

@ -12,6 +12,20 @@ UBGitea gives employees a focused Android experience for the company Gitea insta
4. View issues, pull requests, and notifications.
5. Sign out and remove locally stored credentials.
## Repository browsing experience
Within the permissions of the signed-in Gitea account, the application must support:
- Listing organizations and repositories with pagination.
- Listing every branch and tag visible to the account.
- Browsing a repository tree at an explicit branch, tag, or commit.
- Viewing text source with the selected ref and path always visible.
- Viewing commit history and per-commit file changes.
- Comparing branches and commits without implying write access.
- Viewing pull requests, issues, reviews, comments, and notifications.
Binary files, Git LFS pointers, submodules, and files too large for safe mobile rendering require an explicit fallback rather than being decoded as ordinary source text.
The first release is read-oriented. Creating, merging, deleting, or administering resources is out of scope until explicitly approved.
## UX principles
@ -29,6 +43,7 @@ The first release is read-oriented. Creating, merging, deleting, or administerin
- Gitea Actions runner administration
- Package publishing
- Multiple Gitea instances
- Creating or editing files, pushing commits, merging, deleting branches, or administering repositories
## Product decisions

View File

@ -0,0 +1,55 @@
# TASK-003: Establish repository browsing foundations
Status: complete
Owner: Codex
Reviewer: Claude
## Outcome
Define the immutable domain contracts and presentation states needed to browse repositories, branches, directory entries, and source files without coupling the UI to Gitea DTOs or Android framework types.
## Scope
- Included: domain models, repository interface, validated repository location/ref/path value objects, browse use cases, immutable screen state, focused unit tests, and a placeholder home state that describes the read-only experience.
- Excluded: live HTTP calls, OAuth tokens, persistence, syntax highlighting, navigation between final screens, and write operations.
## Writable paths
- `app/src/main/java/com/ubisam/ubgitea/domain/`
- `app/src/main/java/com/ubisam/ubgitea/ui/home/`
- `app/src/main/res/values/strings.xml`
- `app/src/test/java/com/ubisam/ubgitea/`
- Documentation and task evidence required for this task
## Acceptance criteria
- [x] Repository, branch, tree-entry, source-file, and commit-summary models are immutable, defensively copy mutable input, and contain no network or Android types.
- [x] Repository coordinates reject blank owner/name values.
- [x] Refs reject blank values and control characters while permitting normal Gitea branch, tag, and commit names.
- [x] Repository paths normalize a leading slash, reject traversal segments and control characters, and preserve valid nested paths.
- [x] The repository interface is read-only and exposes paged repositories plus branch, tree, file, and commit queries.
- [x] Home UI uses an explicit immutable state and shows a Korean read-only placeholder using string resources.
- [x] Unit tests cover valid and invalid coordinates, refs, paths, defensive content copying, and paged-list copying.
- [x] No secret, token, network DTO, or Android framework dependency appears in the domain package.
## Required checks
```text
./gradlew assembleDebug
./gradlew testDebugUnitTest
./gradlew lintDebug
```
## Evidence
Recorded on 2026-08-25 using JDK 17 and Android SDK 36:
- `./gradlew assembleDebug` — passed.
- `./gradlew testDebugUnitTest` — passed; 22 tests across 6 suites, 0 failures, 0 errors.
- Focused domain/use-case tests — passed; page output mutation is rejected, all paged use cases share the same bounds, and invalid input does not reach the repository adapter.
- `./gradlew lintDebug` — passed.
- Sensitive-pattern repository scan excluding generated and Git metadata — no assigned secret, token, password, or private-key value found.
- `git diff --check` — passed.
- Actual device/emulator interaction was not required because this task establishes domain contracts and a placeholder state only.
- Independent read-only implementation review — initial P2 findings for page output mutability and incomplete use-case boundary tests were fixed; re-review found no remaining P0-P3 findings.
- Reviewer independently reran `assembleDebug`, `testDebugUnitTest`, and `lintDebug` successfully after the fixes.

View File

@ -0,0 +1,27 @@
# TASK-004: Implement the read-only Gitea API client
Status: planned — depends on TASK-003 and authentication contract
Owner: Codex
Reviewer: Claude
## Outcome
Connect the repository browsing domain to authenticated, paginated Gitea API calls while keeping DTOs and tokens out of domain and UI layers.
## Acceptance criteria
- [ ] Base URL is configurable with the approved production default.
- [ ] DTOs are separate from domain models and mapping is tested.
- [ ] Repository, branch, tree, file, and commit endpoints support pagination where provided.
- [ ] Authentication is injected without logging or exposing tokens.
- [ ] HTTP, authorization, rate-limit, parsing, cancellation, and offline failures map to typed domain failures.
- [ ] Branch, ref, owner, repository, and path values are safely encoded as individual URL components.
- [ ] Tests use a local fake server and never contact production.
## Required checks
```text
./gradlew assembleDebug
./gradlew testDebugUnitTest
./gradlew lintDebug
```

View File

@ -0,0 +1,17 @@
# TASK-005: Browse repositories and branches
Status: planned — depends on TASK-002 and TASK-004
Owner: Codex
Reviewer: Claude
## Outcome
A signed-in user can browse every repository and branch visible to their Gitea account with loading, pagination, refresh, empty, offline, expired-authentication, and server-error states.
## Acceptance criteria
- [ ] Repository and branch lists preserve pagination and refresh state.
- [ ] Organization/owner, visibility, default branch, and selected branch are unambiguous.
- [ ] Loading, empty, offline, authentication-expired, and server-error states are accessible and localized.
- [ ] Retry and pagination cannot create duplicate items or overlapping requests.
- [ ] UI and ViewModel tests cover primary states and selection behavior.

18
tasks/006-code-browser.md Normal file
View File

@ -0,0 +1,18 @@
# TASK-006: Browse trees and source code by ref
Status: planned — depends on TASK-005
Owner: Codex
Reviewer: Claude
## Outcome
A user can navigate directories and view source files at an explicit branch, tag, or commit without silently changing refs.
## Acceptance criteria
- [ ] Repository, selected ref, and current path remain visible.
- [ ] Directory navigation is deterministic and safely handles encoded names.
- [ ] Text files render with bounded memory use and copy support.
- [ ] Binary, Git LFS, submodule, unsupported encoding, and oversized-file states have explicit fallbacks.
- [ ] Loading, empty, offline, authentication-expired, not-found, and server-error states are localized and accessible.
- [ ] Tests cover ref preservation, path navigation, content classification, and size limits.

View File

@ -0,0 +1,18 @@
# TASK-007: View commit history and changes
Status: planned — depends on TASK-004 and TASK-006
Owner: Codex
Reviewer: Claude
## Outcome
A user can inspect commit history, changed files, per-commit patches, and comparisons between refs in a read-only interface.
## Acceptance criteria
- [ ] Commit history supports pagination and clearly identifies the active ref.
- [ ] Commit details show metadata and changed files without exposing private data in logs.
- [ ] Added, modified, renamed, and deleted files are distinguished.
- [ ] Large or truncated diffs are explicitly identified and bounded for mobile rendering.
- [ ] Ref comparisons validate and encode both refs independently.
- [ ] Tests cover change mapping, rename/delete behavior, pagination, truncation, and error states.