feat(release): added diff and show command
This commit is contained in:
32
CHECKLIST.md
32
CHECKLIST.md
@@ -23,7 +23,7 @@
|
|||||||
- [x] Add file content reading/writing utilities
|
- [x] Add file content reading/writing utilities
|
||||||
- [x] Implement path resolution and validation
|
- [x] Implement path resolution and validation
|
||||||
- [x] Add file timestamp tracking
|
- [x] Add file timestamp tracking
|
||||||
- [ ] Create backup and restore mechanisms
|
- [x] Create backup and restore mechanisms
|
||||||
|
|
||||||
|
|
||||||
# Core Commands
|
# Core Commands
|
||||||
@@ -56,10 +56,10 @@
|
|||||||
|
|
||||||
## Log Command
|
## Log Command
|
||||||
|
|
||||||
- [ ] `notevc log` - Show commit history
|
- [x] `notevc log` - Show commit history
|
||||||
- [ ] Display snapshots in reverse chronological order
|
- [x] Display snapshots in reverse chronological order
|
||||||
- [ ] Show commit hashes, messages, and timestamps
|
- [x] Show commit hashes, messages, and timestamps
|
||||||
- [ ] add `--since` time filtering option
|
- [x] add `--since` time filtering option
|
||||||
|
|
||||||
|
|
||||||
# Advanced Commands
|
# Advanced Commands
|
||||||
@@ -75,10 +75,10 @@
|
|||||||
|
|
||||||
## Restore Command
|
## Restore Command
|
||||||
|
|
||||||
- [ ] `notevc restore <commit>` - Restore entire state
|
- [x] `notevc restore <commit>` - Restore entire state
|
||||||
- [ ] `notevc restore <commit> <file>` - Restore specific file
|
- [x] `notevc restore <commit> <file>` - Restore specific file
|
||||||
- [ ] Add conformation prompts for destructive operations
|
- [x] Add conformation prompts for destructive operations
|
||||||
- [ ] Handle file conflicts gracefully
|
- [x] Handle file conflicts gracefully
|
||||||
|
|
||||||
|
|
||||||
## Show Command
|
## Show Command
|
||||||
@@ -90,11 +90,11 @@
|
|||||||
|
|
||||||
# Utilities and Polish
|
# Utilities and Polish
|
||||||
|
|
||||||
- [ ] Add colored output for better UX
|
- [x] Add colored output for better UX
|
||||||
- [ ] Implement proper error handling messages
|
- [ ] Implement proper error handling messages
|
||||||
- [ ] Add input validation for all commands
|
- [ ] Add input validation for all commands
|
||||||
- [ ] Create help system (`notevc --help`)
|
- [ ] Create help system (`notevc --help`)
|
||||||
- [ ] Add version information (`notevc --version`)
|
- [x] Add version information (`notevc --version`)
|
||||||
- [ ] Implement configuration file support
|
- [ ] Implement configuration file support
|
||||||
|
|
||||||
|
|
||||||
@@ -110,11 +110,11 @@
|
|||||||
|
|
||||||
# Build and Distribution
|
# Build and Distribution
|
||||||
|
|
||||||
- [ ] Create fat JAR for distribution
|
- [x] Create fat JAR for distribution
|
||||||
- [ ] Add shell script wrapper for easy execution
|
- [x] Add shell script wrapper for easy execution
|
||||||
- [ ] Test on different operating systems
|
- [x] Test on different operating systems
|
||||||
- [ ] Create installation scripts
|
- [x] Create installation scripts
|
||||||
- [ ] Add build automation (GitHub Actions)
|
- [x] Add build automation (GitHub Actions)
|
||||||
|
|
||||||
|
|
||||||
# Future Features
|
# Future Features
|
||||||
|
|||||||
208
README.md
208
README.md
@@ -1,84 +1,204 @@
|
|||||||
#  NoteVC: Version Control for Markdown
|
#  NoteVC: Version Control for Markdown
|
||||||
|
|
||||||
|
Block-level version control for markdown files. Track changes at the heading level, not just file level.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Block-level tracking**: Version control at heading granularity
|
||||||
|
- **Frontmatter support**: Control versioning with YAML frontmatter (tags, title, enabled flag)
|
||||||
|
- **Smart commits**: Only commits changed blocks
|
||||||
|
- **Block restoration**: Restore individual sections without affecting the entire file
|
||||||
|
- **Rich diffs**: See exactly which sections changed
|
||||||
|
|
||||||
# Repository management
|
---
|
||||||
|
|
||||||
Initialize notevc repo:
|
## Installation
|
||||||
|
|
||||||
|
Build from source:
|
||||||
```bash
|
```bash
|
||||||
notevc init [path]
|
gradle build
|
||||||
```
|
```
|
||||||
|
|
||||||
Show changed files:
|
The executable will be in `build/install/notevc/bin/notevc`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Command Reference
|
||||||
|
|
||||||
|
### Repository Management
|
||||||
|
|
||||||
|
#### `notevc init [path]`
|
||||||
|
Initialize a new notevc repository in the current or specified directory.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
notevc init # Initialize in current directory
|
||||||
|
notevc init ./notes # Initialize in specific directory
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `notevc status` or `notevc st`
|
||||||
|
Show the status of tracked files and which blocks have changed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
notevc status
|
notevc status
|
||||||
```
|
```
|
||||||
|
|
||||||
Create snapshot:
|
#### `notevc commit [options] "message"`
|
||||||
|
Create a commit (snapshot) of changed files.
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--file <file>`: Commit only a specific file
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
```bash
|
```bash
|
||||||
notevc commit [--file <file>] "message"
|
notevc commit "Added new features" # Commit all changed files
|
||||||
|
notevc commit --file notes.md "Updated notes" # Commit specific file
|
||||||
```
|
```
|
||||||
|
|
||||||
Show commit history:
|
---
|
||||||
|
|
||||||
|
### Viewing History
|
||||||
|
|
||||||
|
#### `notevc log [options]`
|
||||||
|
Show commit history with details.
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--max-count <n>` or `-n <n>`: Limit number of commits shown
|
||||||
|
- `--since <time>`: Show commits since specified time (e.g., "1h", "2d", "1w")
|
||||||
|
- `--oneline`: Show compact one-line format
|
||||||
|
- `--file` or `-f [file]`: Show file and block details for each commit
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
```bash
|
```bash
|
||||||
notevc log [--since=time]
|
notevc log # Show all commits
|
||||||
|
notevc log --max-count 5 # Show last 5 commits
|
||||||
|
notevc log --since 1d # Show commits from last day
|
||||||
|
notevc log --oneline # Compact format
|
||||||
|
notevc log --file # Show with file details
|
||||||
|
notevc log --file notes.md # Show commits affecting specific file
|
||||||
|
notevc log --file --oneline # Compact format with file info
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### `notevc show <commit-hash> [options]`
|
||||||
|
Show detailed information about a specific commit, including block/file contents.
|
||||||
|
|
||||||
# Viewing changes
|
**Options:**
|
||||||
|
- `--file <file>`: Show changes only for specific file
|
||||||
|
- `--block <block-hash>` or `-b <block-hash>`: Show specific block content
|
||||||
|
- `--content` or `-c`: Show full file content at commit
|
||||||
|
|
||||||
Show changes since last commit:
|
**Examples:**
|
||||||
```bash
|
```bash
|
||||||
notevc diff [file]
|
notevc show a1b2c3d4 # Show commit details
|
||||||
|
notevc show a1b2c3d4 --file notes.md # Show changes to specific file
|
||||||
|
notevc show a1b2c3d4 --file notes.md --content # Show full file content
|
||||||
|
notevc show a1b2c3d4 --file notes.md --block 1a2b3c # Show specific block
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare with previous commit:
|
---
|
||||||
|
|
||||||
|
### Viewing Changes
|
||||||
|
|
||||||
|
#### `notevc diff [commit1] [commit2] [options]`
|
||||||
|
Show differences between commits or working directory with enhanced visual formatting.
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--file <file>`: Show diff for specific file only
|
||||||
|
- `--block <block-hash>` or `-b <block-hash>`: Compare specific block only
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
```bash
|
```bash
|
||||||
notevc diff HEAD~1 [file]
|
notevc diff # Show uncommitted changes (enhanced view)
|
||||||
|
notevc diff a1b2c3d4 # Compare working dir to commit
|
||||||
|
notevc diff a1b2c3d4 b5c6d7e8 # Compare two commits
|
||||||
|
notevc diff --file notes.md # Diff specific file
|
||||||
|
notevc diff a1b2c3d4 --file notes.md # Compare specific file to commit
|
||||||
|
notevc diff --block 1a2b3c --file notes.md # Compare specific block
|
||||||
|
notevc diff a1b2c3d4 --block 1a2b3c --file notes.md # Compare block to commit
|
||||||
```
|
```
|
||||||
|
|
||||||
Show specific commit:
|
**Visual Enhancements:**
|
||||||
|
- Clear separators (+++/---/~~~) for ADDED/DELETED/MODIFIED blocks
|
||||||
|
- Line-by-line diffs showing exact changes (+/- prefixes)
|
||||||
|
- Context lines shown in gray for unchanged content
|
||||||
|
- Block-level comparison for targeted analysis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Restoration
|
||||||
|
|
||||||
|
#### `notevc restore <commit-hash> [file] [options]`
|
||||||
|
Restore files or blocks from a specific commit.
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--block <block-hash>` or `-b <block-hash>`: Restore specific block only
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
```bash
|
```bash
|
||||||
notevc show <commit-hash>
|
notevc restore a1b2c3d4 # Restore entire repository
|
||||||
|
notevc restore a1b2c3d4 notes.md # Restore specific file
|
||||||
|
notevc restore a1b2c3d4 notes.md --block 1a2b3c # Restore specific block
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Other Commands
|
||||||
|
|
||||||
# Restoration
|
#### `notevc version` or `notevc -v`
|
||||||
|
Show the version of notevc.
|
||||||
|
|
||||||
Restore to specific version:
|
|
||||||
```bash
|
```bash
|
||||||
notevc restore <commit-hash> [file]
|
notevc version
|
||||||
```
|
```
|
||||||
|
|
||||||
Restore to specific block:
|
---
|
||||||
```bash
|
|
||||||
notevc restore --block <commit-hash> [file]
|
## Frontmatter Support
|
||||||
|
|
||||||
|
Control versioning behavior with YAML frontmatter:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
enabled: "true" # Enable/disable tracking (default: true)
|
||||||
|
automatic: "true" # Auto-commit on changes (default: false)
|
||||||
|
title: "My Note" # Note title for sorting
|
||||||
|
tags: # Tags for categorization
|
||||||
|
- project
|
||||||
|
- important
|
||||||
|
---
|
||||||
|
|
||||||
|
# Your content here
|
||||||
```
|
```
|
||||||
|
|
||||||
Restore entire repo state:
|
**Frontmatter fields:**
|
||||||
```bash
|
- `enabled`: Set to "false" to exclude file from commits (default: true)
|
||||||
notevc checkout <commit-hash>
|
- `automatic`: Enable automatic commits (default: false)
|
||||||
```
|
- `title`: Custom title for the note
|
||||||
|
- `tags`: List of tags (array or comma-separated string)
|
||||||
|
|
||||||
|
---
|
||||||
# Utilities
|
|
||||||
|
## How It Works
|
||||||
Remove old snapshots:
|
|
||||||
```bash
|
NoteVC splits markdown files into blocks based on headings. Each block is:
|
||||||
notevc clean
|
- Identified by a stable hash
|
||||||
```
|
- Tracked independently
|
||||||
|
- Versioned separately
|
||||||
Garbage collect unused objects:
|
- **Compressed automatically** (GZIP) when content > 100 bytes
|
||||||
```bash
|
|
||||||
notevc gc
|
This means you can:
|
||||||
```
|
- See which sections changed
|
||||||
|
- Restore individual sections
|
||||||
Show/set configuration:
|
- Track content at a granular level
|
||||||
```bash
|
- **Save disk space** with automatic compression
|
||||||
notevc config
|
|
||||||
```
|
### Storage Optimization
|
||||||
|
|
||||||
|
NoteVC includes built-in compression for efficient storage:
|
||||||
|
- **Automatic GZIP compression** for block content over 100 bytes
|
||||||
|
- Typical compression ratio: 60-80% space savings for text
|
||||||
|
- Transparent decompression when reading
|
||||||
|
- No performance impact on small blocks
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group = "io.notevc"
|
group = "io.notevc"
|
||||||
version = "1.0.2"
|
version = "1.0.3"
|
||||||
|
|
||||||
buildConfig {
|
buildConfig {
|
||||||
buildConfigField("String", "VERSION", "\"${project.version}\"")
|
buildConfigField("String", "VERSION", "\"${project.version}\"")
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
class Notevc < Formula
|
|
||||||
desc "Version control for markdown files"
|
|
||||||
homepage "https://github.com/darwincereska/notevc"
|
|
||||||
version "1.0.0"
|
|
||||||
|
|
||||||
|
|
||||||
@@ -63,9 +63,31 @@ fun main(args: Array<String>) {
|
|||||||
onFailure = { error -> println("${ColorUtils.error("Error:")} ${error.message}") }
|
onFailure = { error -> println("${ColorUtils.error("Error:")} ${error.message}") }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"diff" -> {
|
||||||
|
val diffArgs = args.drop(1)
|
||||||
|
val diffCommand = DiffCommand()
|
||||||
|
val result = diffCommand.execute(diffArgs)
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { output -> println(output) },
|
||||||
|
onFailure = { error -> println("${ColorUtils.error("Error:")} ${error.message}") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"show" -> {
|
||||||
|
val showArgs = args.drop(1)
|
||||||
|
val showCommand = ShowCommand()
|
||||||
|
val result = showCommand.execute(showArgs)
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { output -> println(output) },
|
||||||
|
onFailure = { error -> println("${ColorUtils.error("Error:")} ${error.message}") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
println("Usage: notevc init|commit|status|version")
|
println("Usage: notevc init|commit|status|log|restore|diff|show|version")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,536 @@
|
|||||||
package io.notevc.commands
|
package io.notevc.commands
|
||||||
|
|
||||||
|
import io.notevc.core.*
|
||||||
|
import io.notevc.utils.ColorUtils
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.time.Instant
|
||||||
|
import kotlin.io.path.*
|
||||||
|
|
||||||
|
class DiffCommand {
|
||||||
|
|
||||||
|
fun execute(args: List<String>): Result<String> {
|
||||||
|
return try {
|
||||||
|
val options = parseArgs(args)
|
||||||
|
|
||||||
|
val repo = Repository.find()
|
||||||
|
?: return Result.failure(Exception("Not in a notevc repository. Run 'notevc init' first."))
|
||||||
|
|
||||||
|
val result = when {
|
||||||
|
options.blockHash != null -> {
|
||||||
|
// Compare specific block
|
||||||
|
compareSpecificBlock(repo, options.commitHash1, options.blockHash, options.targetFile)
|
||||||
|
}
|
||||||
|
options.commitHash1 != null && options.commitHash2 != null -> {
|
||||||
|
// Compare two commits
|
||||||
|
compareCommits(repo, options.commitHash1, options.commitHash2, options.targetFile)
|
||||||
|
}
|
||||||
|
options.commitHash1 != null -> {
|
||||||
|
// Compare working directory to a commit
|
||||||
|
compareWorkingDirectoryToCommit(repo, options.commitHash1, options.targetFile)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Show changes in working directory (not committed)
|
||||||
|
showWorkingDirectoryChanges(repo, options.targetFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success(result)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseArgs(args: List<String>): DiffOptions {
|
||||||
|
var commitHash1: String? = null
|
||||||
|
var commitHash2: String? = null
|
||||||
|
var targetFile: String? = null
|
||||||
|
var blockHash: String? = null
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < args.size) {
|
||||||
|
when {
|
||||||
|
args[i].startsWith("--file=") -> {
|
||||||
|
targetFile = args[i].substring(7)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
args[i] == "--file" && i + 1 < args.size -> {
|
||||||
|
targetFile = args[i + 1]
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
args[i] == "--block" || args[i] == "-b" -> {
|
||||||
|
if (i + 1 < args.size && !args[i + 1].startsWith("-")) {
|
||||||
|
blockHash = args[i + 1]
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!args[i].startsWith("-") -> {
|
||||||
|
// This is a commit hash
|
||||||
|
if (commitHash1 == null) {
|
||||||
|
commitHash1 = args[i]
|
||||||
|
} else if (commitHash2 == null) {
|
||||||
|
commitHash2 = args[i]
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
else -> i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DiffOptions(commitHash1, commitHash2, targetFile, blockHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compareSpecificBlock(repo: Repository, commitHash: String?, blockHash: String, targetFile: String?): String {
|
||||||
|
val objectStore = ObjectStore(repo.path.resolve("${Repository.NOTEVC_DIR}/objects"))
|
||||||
|
val blockStore = BlockStore(objectStore, repo.path.resolve("${Repository.NOTEVC_DIR}/blocks"))
|
||||||
|
val blockParser = BlockParser()
|
||||||
|
|
||||||
|
if (targetFile == null) {
|
||||||
|
throw Exception("--file is required when comparing a specific block")
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = StringBuilder()
|
||||||
|
result.appendLine("${ColorUtils.bold("Block comparison:")} ${ColorUtils.hash(blockHash.take(8))}")
|
||||||
|
result.appendLine()
|
||||||
|
|
||||||
|
// Get the commit snapshot if provided, otherwise use latest
|
||||||
|
val commitSnapshot = if (commitHash != null) {
|
||||||
|
val commit = findCommit(repo, commitHash)
|
||||||
|
?: throw Exception("Commit ${ColorUtils.hash(commitHash)} not found")
|
||||||
|
val commitTime = Instant.parse(commit.timestamp)
|
||||||
|
blockStore.getLatestBlockSnapshotBefore(targetFile, commitTime)
|
||||||
|
} else {
|
||||||
|
blockStore.getLatestBlockSnapshot(targetFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current snapshot
|
||||||
|
val filePath = repo.path.resolve(targetFile)
|
||||||
|
if (!filePath.exists()) {
|
||||||
|
throw Exception("File not found: $targetFile")
|
||||||
|
}
|
||||||
|
|
||||||
|
val content = Files.readString(filePath)
|
||||||
|
val parsedFile = blockParser.parseFile(content, targetFile)
|
||||||
|
val currentSnapshot = createCurrentSnapshot(parsedFile, objectStore)
|
||||||
|
|
||||||
|
// Find the specific block in both snapshots
|
||||||
|
val oldBlock = commitSnapshot?.blocks?.find { it.id.startsWith(blockHash) }
|
||||||
|
val newBlock = currentSnapshot.blocks.find { it.id.startsWith(blockHash) }
|
||||||
|
|
||||||
|
if (oldBlock == null && newBlock == null) {
|
||||||
|
throw Exception("Block ${ColorUtils.hash(blockHash)} not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
val headingText = (newBlock?.heading ?: oldBlock?.heading ?: "").replace(Regex("^#+\\s*"), "").trim()
|
||||||
|
|
||||||
|
result.appendLine("${ColorUtils.heading(headingText)} ${ColorUtils.dim("[${blockHash.take(8)}]")}")
|
||||||
|
result.appendLine("${ColorUtils.dim("─".repeat(70))}")
|
||||||
|
result.appendLine()
|
||||||
|
|
||||||
|
when {
|
||||||
|
oldBlock == null && newBlock != null -> {
|
||||||
|
result.appendLine("${ColorUtils.success("This block was ADDED")}")
|
||||||
|
result.appendLine()
|
||||||
|
val newContent = objectStore.getContent(newBlock.contentHash)
|
||||||
|
if (newContent != null) {
|
||||||
|
newContent.lines().forEach { line ->
|
||||||
|
result.appendLine("${ColorUtils.success("+ ")} $line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oldBlock != null && newBlock == null -> {
|
||||||
|
result.appendLine("${ColorUtils.error("This block was DELETED")}")
|
||||||
|
result.appendLine()
|
||||||
|
val oldContent = objectStore.getContent(oldBlock.contentHash)
|
||||||
|
if (oldContent != null) {
|
||||||
|
oldContent.lines().forEach { line ->
|
||||||
|
result.appendLine("${ColorUtils.error("- ")} $line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oldBlock != null && newBlock != null -> {
|
||||||
|
if (oldBlock.contentHash == newBlock.contentHash) {
|
||||||
|
result.appendLine("${ColorUtils.dim("No changes")}")
|
||||||
|
} else {
|
||||||
|
result.appendLine("${ColorUtils.warning("Block was MODIFIED")}")
|
||||||
|
result.appendLine()
|
||||||
|
val oldContent = objectStore.getContent(oldBlock.contentHash)
|
||||||
|
val newContent = objectStore.getContent(newBlock.contentHash)
|
||||||
|
|
||||||
|
if (oldContent != null && newContent != null) {
|
||||||
|
val diff = computeDetailedDiff(oldContent, newContent)
|
||||||
|
diff.forEach { line ->
|
||||||
|
result.appendLine(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compareCommits(repo: Repository, hash1: String, hash2: String, targetFile: String?): String {
|
||||||
|
val objectStore = ObjectStore(repo.path.resolve("${Repository.NOTEVC_DIR}/objects"))
|
||||||
|
val blockStore = BlockStore(objectStore, repo.path.resolve("${Repository.NOTEVC_DIR}/blocks"))
|
||||||
|
|
||||||
|
// Find commits
|
||||||
|
val commit1 = findCommit(repo, hash1)
|
||||||
|
?: throw Exception("Commit ${ColorUtils.hash(hash1)} not found")
|
||||||
|
val commit2 = findCommit(repo, hash2)
|
||||||
|
?: throw Exception("Commit ${ColorUtils.hash(hash2)} not found")
|
||||||
|
|
||||||
|
val time1 = Instant.parse(commit1.timestamp)
|
||||||
|
val time2 = Instant.parse(commit2.timestamp)
|
||||||
|
|
||||||
|
val filesToCompare = if (targetFile != null) {
|
||||||
|
listOf(targetFile)
|
||||||
|
} else {
|
||||||
|
getTrackedFilesAtTime(repo, time1, time2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filesToCompare.isEmpty()) {
|
||||||
|
return "No files to compare"
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = StringBuilder()
|
||||||
|
result.appendLine("${ColorUtils.bold("Comparing commits:")}")
|
||||||
|
result.appendLine(" ${ColorUtils.hash(hash1.take(8))} ${ColorUtils.dim(commit1.message)}")
|
||||||
|
result.appendLine(" ${ColorUtils.hash(hash2.take(8))} ${ColorUtils.dim(commit2.message)}")
|
||||||
|
result.appendLine()
|
||||||
|
|
||||||
|
var totalChanges = 0
|
||||||
|
|
||||||
|
filesToCompare.forEach { filePath ->
|
||||||
|
val snapshot1 = blockStore.getLatestBlockSnapshotBefore(filePath, time1)
|
||||||
|
val snapshot2 = blockStore.getLatestBlockSnapshotBefore(filePath, time2)
|
||||||
|
|
||||||
|
if (snapshot1 != null || snapshot2 != null) {
|
||||||
|
val changes = blockStore.compareBlocks(snapshot1, snapshot2)
|
||||||
|
if (changes.isNotEmpty()) {
|
||||||
|
result.appendLine("${ColorUtils.filename(filePath)}:")
|
||||||
|
result.append(formatBlockChanges(changes, objectStore))
|
||||||
|
result.appendLine()
|
||||||
|
totalChanges += changes.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalChanges == 0) {
|
||||||
|
result.appendLine("${ColorUtils.dim("No differences found")}")
|
||||||
|
} else {
|
||||||
|
result.appendLine("${ColorUtils.bold("Total changes:")} $totalChanges")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compareWorkingDirectoryToCommit(repo: Repository, hash: String, targetFile: String?): String {
|
||||||
|
val objectStore = ObjectStore(repo.path.resolve("${Repository.NOTEVC_DIR}/objects"))
|
||||||
|
val blockStore = BlockStore(objectStore, repo.path.resolve("${Repository.NOTEVC_DIR}/blocks"))
|
||||||
|
val blockParser = BlockParser()
|
||||||
|
|
||||||
|
// Find commit
|
||||||
|
val commit = findCommit(repo, hash)
|
||||||
|
?: throw Exception("Commit ${ColorUtils.hash(hash)} not found")
|
||||||
|
|
||||||
|
val commitTime = Instant.parse(commit.timestamp)
|
||||||
|
|
||||||
|
val filesToCompare = if (targetFile != null) {
|
||||||
|
val filePath = repo.path.resolve(targetFile)
|
||||||
|
if (!filePath.exists()) {
|
||||||
|
throw Exception("File not found: $targetFile")
|
||||||
|
}
|
||||||
|
listOf(targetFile)
|
||||||
|
} else {
|
||||||
|
io.notevc.utils.FileUtils.findMarkdownFiles(repo.path).map {
|
||||||
|
repo.path.relativize(it).toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = StringBuilder()
|
||||||
|
result.appendLine("${ColorUtils.bold("Comparing working directory to commit:")}")
|
||||||
|
result.appendLine(" ${ColorUtils.hash(hash.take(8))} ${ColorUtils.dim(commit.message)}")
|
||||||
|
result.appendLine()
|
||||||
|
|
||||||
|
var totalChanges = 0
|
||||||
|
|
||||||
|
filesToCompare.forEach { filePath ->
|
||||||
|
val fullPath = repo.path.resolve(filePath)
|
||||||
|
if (fullPath.exists()) {
|
||||||
|
val content = Files.readString(fullPath)
|
||||||
|
val parsedFile = blockParser.parseFile(content, filePath)
|
||||||
|
|
||||||
|
// Skip disabled files
|
||||||
|
if (parsedFile.frontMatter?.isEnabled == false) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
val commitSnapshot = blockStore.getLatestBlockSnapshotBefore(filePath, commitTime)
|
||||||
|
val currentSnapshot = createCurrentSnapshot(parsedFile, objectStore)
|
||||||
|
|
||||||
|
val changes = blockStore.compareBlocks(commitSnapshot, currentSnapshot)
|
||||||
|
if (changes.isNotEmpty()) {
|
||||||
|
result.appendLine("${ColorUtils.filename(filePath)}:")
|
||||||
|
result.append(formatBlockChanges(changes, objectStore))
|
||||||
|
result.appendLine()
|
||||||
|
totalChanges += changes.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalChanges == 0) {
|
||||||
|
result.appendLine("${ColorUtils.dim("No differences found")}")
|
||||||
|
} else {
|
||||||
|
result.appendLine("${ColorUtils.bold("Total changes:")} $totalChanges")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showWorkingDirectoryChanges(repo: Repository, targetFile: String?): String {
|
||||||
|
val objectStore = ObjectStore(repo.path.resolve("${Repository.NOTEVC_DIR}/objects"))
|
||||||
|
val blockStore = BlockStore(objectStore, repo.path.resolve("${Repository.NOTEVC_DIR}/blocks"))
|
||||||
|
val blockParser = BlockParser()
|
||||||
|
|
||||||
|
val filesToCheck = if (targetFile != null) {
|
||||||
|
val filePath = repo.path.resolve(targetFile)
|
||||||
|
if (!filePath.exists()) {
|
||||||
|
throw Exception("File not found: $targetFile")
|
||||||
|
}
|
||||||
|
listOf(targetFile)
|
||||||
|
} else {
|
||||||
|
io.notevc.utils.FileUtils.findMarkdownFiles(repo.path).map {
|
||||||
|
repo.path.relativize(it).toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = StringBuilder()
|
||||||
|
result.appendLine("${ColorUtils.bold("Changes in working directory:")}")
|
||||||
|
result.appendLine()
|
||||||
|
|
||||||
|
var totalChanges = 0
|
||||||
|
|
||||||
|
filesToCheck.forEach { filePath ->
|
||||||
|
val fullPath = repo.path.resolve(filePath)
|
||||||
|
if (fullPath.exists()) {
|
||||||
|
val content = Files.readString(fullPath)
|
||||||
|
val parsedFile = blockParser.parseFile(content, filePath)
|
||||||
|
|
||||||
|
// Skip disabled files
|
||||||
|
if (parsedFile.frontMatter?.isEnabled == false) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
val latestSnapshot = blockStore.getLatestBlockSnapshot(filePath)
|
||||||
|
val currentSnapshot = createCurrentSnapshot(parsedFile, objectStore)
|
||||||
|
|
||||||
|
val changes = blockStore.compareBlocks(latestSnapshot, currentSnapshot)
|
||||||
|
if (changes.isNotEmpty()) {
|
||||||
|
result.appendLine("${ColorUtils.filename(filePath)}:")
|
||||||
|
result.append(formatBlockChanges(changes, objectStore))
|
||||||
|
result.appendLine()
|
||||||
|
totalChanges += changes.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalChanges == 0) {
|
||||||
|
result.appendLine("${ColorUtils.dim("No changes detected - working directory clean")}")
|
||||||
|
} else {
|
||||||
|
result.appendLine("${ColorUtils.bold("Total changes:")} $totalChanges")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatBlockChanges(changes: List<BlockChange>, objectStore: ObjectStore): String {
|
||||||
|
val result = StringBuilder()
|
||||||
|
|
||||||
|
changes.sortedBy { it.blockId }.forEach { change ->
|
||||||
|
val headingText = change.heading.replace(Regex("^#+\\s*"), "").trim()
|
||||||
|
val blockId = change.blockId.take(8)
|
||||||
|
|
||||||
|
when (change.type) {
|
||||||
|
BlockChangeType.ADDED -> {
|
||||||
|
result.appendLine()
|
||||||
|
result.appendLine(" ${ColorUtils.success("+++")} ${ColorUtils.bold("ADDED")} ${ColorUtils.success("+++")} ${ColorUtils.heading(headingText)} ${ColorUtils.dim("[$blockId]")}")
|
||||||
|
result.appendLine(" ${ColorUtils.dim("─".repeat(60))}")
|
||||||
|
|
||||||
|
if (change.newHash != null) {
|
||||||
|
val content = objectStore.getContent(change.newHash)
|
||||||
|
if (content != null) {
|
||||||
|
content.lines().take(5).forEach { line ->
|
||||||
|
result.appendLine(" ${ColorUtils.success("+")} $line")
|
||||||
|
}
|
||||||
|
if (content.lines().size > 5) {
|
||||||
|
result.appendLine(" ${ColorUtils.dim(" ... ${content.lines().size - 5} more lines")}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BlockChangeType.DELETED -> {
|
||||||
|
result.appendLine()
|
||||||
|
result.appendLine(" ${ColorUtils.error("---")} ${ColorUtils.bold("DELETED")} ${ColorUtils.error("---")} ${ColorUtils.heading(headingText)} ${ColorUtils.dim("[$blockId]")}")
|
||||||
|
result.appendLine(" ${ColorUtils.dim("─".repeat(60))}")
|
||||||
|
|
||||||
|
if (change.oldHash != null) {
|
||||||
|
val content = objectStore.getContent(change.oldHash)
|
||||||
|
if (content != null) {
|
||||||
|
content.lines().take(5).forEach { line ->
|
||||||
|
result.appendLine(" ${ColorUtils.error("-")} $line")
|
||||||
|
}
|
||||||
|
if (content.lines().size > 5) {
|
||||||
|
result.appendLine(" ${ColorUtils.dim(" ... ${content.lines().size - 5} more lines")}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BlockChangeType.MODIFIED -> {
|
||||||
|
result.appendLine()
|
||||||
|
result.appendLine(" ${ColorUtils.warning("~~~")} ${ColorUtils.bold("MODIFIED")} ${ColorUtils.warning("~~~")} ${ColorUtils.heading(headingText)} ${ColorUtils.dim("[$blockId]")}")
|
||||||
|
result.appendLine(" ${ColorUtils.dim("─".repeat(60))}")
|
||||||
|
|
||||||
|
// Show detailed diff
|
||||||
|
if (change.oldHash != null && change.newHash != null) {
|
||||||
|
val oldContent = objectStore.getContent(change.oldHash)
|
||||||
|
val newContent = objectStore.getContent(change.newHash)
|
||||||
|
|
||||||
|
if (oldContent != null && newContent != null) {
|
||||||
|
val diff = computeDetailedDiff(oldContent, newContent)
|
||||||
|
diff.forEach { line ->
|
||||||
|
result.appendLine(" $line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeDetailedDiff(oldContent: String, newContent: String): List<String> {
|
||||||
|
val oldLines = oldContent.lines()
|
||||||
|
val newLines = newContent.lines()
|
||||||
|
val diff = mutableListOf<String>()
|
||||||
|
|
||||||
|
// Use a simple line-by-line diff algorithm
|
||||||
|
val maxLines = maxOf(oldLines.size, newLines.size)
|
||||||
|
var oldIndex = 0
|
||||||
|
var newIndex = 0
|
||||||
|
var displayedLines = 0
|
||||||
|
val maxDisplayLines = 15
|
||||||
|
|
||||||
|
while ((oldIndex < oldLines.size || newIndex < newLines.size) && displayedLines < maxDisplayLines) {
|
||||||
|
val oldLine = oldLines.getOrNull(oldIndex)
|
||||||
|
val newLine = newLines.getOrNull(newIndex)
|
||||||
|
|
||||||
|
when {
|
||||||
|
oldLine == null && newLine != null -> {
|
||||||
|
// Addition
|
||||||
|
diff.add("${ColorUtils.success("+ ")} $newLine")
|
||||||
|
newIndex++
|
||||||
|
displayedLines++
|
||||||
|
}
|
||||||
|
oldLine != null && newLine == null -> {
|
||||||
|
// Deletion
|
||||||
|
diff.add("${ColorUtils.error("- ")} $oldLine")
|
||||||
|
oldIndex++
|
||||||
|
displayedLines++
|
||||||
|
}
|
||||||
|
oldLine == newLine -> {
|
||||||
|
// Unchanged line (context)
|
||||||
|
diff.add("${ColorUtils.dim(" ")} ${ColorUtils.dim(oldLine ?: "")}")
|
||||||
|
oldIndex++
|
||||||
|
newIndex++
|
||||||
|
displayedLines++
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Modified line
|
||||||
|
diff.add("${ColorUtils.error("- ")} $oldLine")
|
||||||
|
diff.add("${ColorUtils.success("+ ")} $newLine")
|
||||||
|
oldIndex++
|
||||||
|
newIndex++
|
||||||
|
displayedLines += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val remainingLines = (oldLines.size - oldIndex) + (newLines.size - newIndex)
|
||||||
|
if (remainingLines > 0) {
|
||||||
|
diff.add("${ColorUtils.dim(" ... $remainingLines more lines")}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCurrentSnapshot(parsedFile: ParsedFile, objectStore: ObjectStore): BlockSnapshot {
|
||||||
|
return BlockSnapshot(
|
||||||
|
filePath = parsedFile.path,
|
||||||
|
timestamp = Instant.now().toString(),
|
||||||
|
blocks = parsedFile.blocks.map { block ->
|
||||||
|
BlockState(
|
||||||
|
id = block.id,
|
||||||
|
heading = block.heading,
|
||||||
|
contentHash = objectStore.storeContent(block.content),
|
||||||
|
type = block.type,
|
||||||
|
order = block.order
|
||||||
|
)
|
||||||
|
},
|
||||||
|
frontMatter = parsedFile.frontMatter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findCommit(repo: Repository, commitHash: String): CommitEntry? {
|
||||||
|
val timelineFile = repo.path.resolve("${Repository.NOTEVC_DIR}/timeline.json")
|
||||||
|
|
||||||
|
if (!timelineFile.exists()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val content = Files.readString(timelineFile)
|
||||||
|
if (content.trim() == "[]") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val commits = Json.decodeFromString<List<CommitEntry>>(content)
|
||||||
|
return commits.find { it.hash.startsWith(commitHash) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTrackedFilesAtTime(repo: Repository, time1: Instant, time2: Instant): List<String> {
|
||||||
|
val blocksDir = repo.path.resolve("${Repository.NOTEVC_DIR}/blocks")
|
||||||
|
if (!blocksDir.exists()) return emptyList()
|
||||||
|
|
||||||
|
val files = mutableSetOf<String>()
|
||||||
|
val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
Files.walk(blocksDir)
|
||||||
|
.filter { it.isRegularFile() && it.fileName.toString().startsWith("blocks-") }
|
||||||
|
.forEach { snapshotFile ->
|
||||||
|
try {
|
||||||
|
val content = Files.readString(snapshotFile)
|
||||||
|
val snapshot = json.decodeFromString<BlockSnapshot>(content)
|
||||||
|
val snapshotTime = Instant.parse(snapshot.timestamp)
|
||||||
|
|
||||||
|
// Include files that existed around either time
|
||||||
|
if (!snapshotTime.isAfter(time1) || !snapshotTime.isAfter(time2)) {
|
||||||
|
files.add(snapshot.filePath)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Skip corrupted snapshots
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files.toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DiffOptions(
|
||||||
|
val commitHash1: String?,
|
||||||
|
val commitHash2: String?,
|
||||||
|
val targetFile: String?,
|
||||||
|
val blockHash: String? = null
|
||||||
|
)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class LogCommand {
|
|||||||
while (i < args.size) {
|
while (i < args.size) {
|
||||||
when (args[i]) {
|
when (args[i]) {
|
||||||
"--max-count", "-n" -> {
|
"--max-count", "-n" -> {
|
||||||
if (i + 1 < args.size) {
|
if (i + 1 < args.size && !args[i + 1].startsWith("-")) {
|
||||||
maxCount = args[i + 1].toIntOrNull()
|
maxCount = args[i + 1].toIntOrNull()
|
||||||
i += 2
|
i += 2
|
||||||
} else {
|
} else {
|
||||||
@@ -44,7 +44,7 @@ class LogCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"--since" -> {
|
"--since" -> {
|
||||||
if (i + 1 < args.size) {
|
if (i + 1 < args.size && !args[i + 1].startsWith("-")) {
|
||||||
since = args[i + 1]
|
since = args[i + 1]
|
||||||
i += 2
|
i += 2
|
||||||
} else {
|
} else {
|
||||||
@@ -56,12 +56,11 @@ class LogCommand {
|
|||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
"--file", "-f" -> {
|
"--file", "-f" -> {
|
||||||
if (i + 1 < args.size) {
|
showFiles = true
|
||||||
|
if (i + 1 < args.size && !args[i + 1].startsWith("-")) {
|
||||||
targetFile = args[i + 1]
|
targetFile = args[i + 1]
|
||||||
showFiles = true
|
|
||||||
i += 2
|
i += 2
|
||||||
} else {
|
} else {
|
||||||
showFiles = true
|
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,13 +127,16 @@ class RestoreCommand {
|
|||||||
|
|
||||||
// Find the block snapshot for this file at the commit time
|
// Find the block snapshot for this file at the commit time
|
||||||
val commitTime = Instant.parse(commit.timestamp)
|
val commitTime = Instant.parse(commit.timestamp)
|
||||||
val blocks = blockStore.getBlocksAtTime(targetFile, commitTime)
|
val snapshot = blockStore.getLatestBlockSnapshotBefore(targetFile, commitTime)
|
||||||
?: throw Exception("No snapshot found for ${ColorUtils.filename(targetFile)} at commit ${ColorUtils.hash(commitHash)}")
|
?: throw Exception("No snapshot found for ${ColorUtils.filename(targetFile)} at commit ${ColorUtils.hash(commitHash)}")
|
||||||
|
|
||||||
// Reconstruct the file from blocks
|
val blocks = blockStore.getBlocksAtTime(targetFile, commitTime)
|
||||||
|
?: throw Exception("No blocks found for ${ColorUtils.filename(targetFile)} at commit ${ColorUtils.hash(commitHash)}")
|
||||||
|
|
||||||
|
// Reconstruct the file from blocks with frontmatter from snapshot
|
||||||
val parsedFile = ParsedFile(
|
val parsedFile = ParsedFile(
|
||||||
path = targetFile,
|
path = targetFile,
|
||||||
frontMatter = null, // TODO: Get front matter from snapshot
|
frontMatter = snapshot.frontMatter,
|
||||||
blocks = blocks
|
blocks = blocks
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -169,11 +172,12 @@ class RestoreCommand {
|
|||||||
var totalBlocks = 0
|
var totalBlocks = 0
|
||||||
|
|
||||||
trackedFiles.forEach { filePath ->
|
trackedFiles.forEach { filePath ->
|
||||||
|
val snapshot = blockStore.getLatestBlockSnapshotBefore(filePath, commitTime)
|
||||||
val blocks = blockStore.getBlocksAtTime(filePath, commitTime)
|
val blocks = blockStore.getBlocksAtTime(filePath, commitTime)
|
||||||
if (blocks != null) {
|
if (blocks != null) {
|
||||||
val parsedFile = ParsedFile(
|
val parsedFile = ParsedFile(
|
||||||
path = filePath,
|
path = filePath,
|
||||||
frontMatter = null, // TODO: Get front matter from snapshot
|
frontMatter = snapshot?.frontMatter,
|
||||||
blocks = blocks
|
blocks = blocks
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
318
src/main/kotlin/io/notevc/commands/ShowCommand.kt
Normal file
318
src/main/kotlin/io/notevc/commands/ShowCommand.kt
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
package io.notevc.commands
|
||||||
|
|
||||||
|
import io.notevc.core.*
|
||||||
|
import io.notevc.utils.ColorUtils
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlin.io.path.*
|
||||||
|
|
||||||
|
class ShowCommand {
|
||||||
|
|
||||||
|
fun execute(args: List<String>): Result<String> {
|
||||||
|
return try {
|
||||||
|
if (args.isEmpty()) {
|
||||||
|
return Result.failure(Exception("Commit hash is required. Usage: notevc show <commit-hash> [--file <file>] [--block <block>] [--content]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val options = parseArgs(args)
|
||||||
|
|
||||||
|
val repo = Repository.find()
|
||||||
|
?: return Result.failure(Exception("Not in a notevc repository. Run 'notevc init' first."))
|
||||||
|
|
||||||
|
val result = when {
|
||||||
|
options.blockHash != null -> showBlock(repo, options)
|
||||||
|
options.showContent -> showFileContent(repo, options)
|
||||||
|
else -> showCommit(repo, options.commitHash, options.targetFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success(result)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseArgs(args: List<String>): ShowOptions {
|
||||||
|
val commitHash = args[0]
|
||||||
|
var targetFile: String? = null
|
||||||
|
var blockHash: String? = null
|
||||||
|
var showContent = false
|
||||||
|
|
||||||
|
var i = 1
|
||||||
|
while (i < args.size) {
|
||||||
|
when {
|
||||||
|
args[i] == "--file" && i + 1 < args.size && !args[i + 1].startsWith("-") -> {
|
||||||
|
targetFile = args[i + 1]
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
args[i].startsWith("--file=") -> {
|
||||||
|
targetFile = args[i].substring(7)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
args[i] == "--block" || args[i] == "-b" -> {
|
||||||
|
if (i + 1 < args.size && !args[i + 1].startsWith("-")) {
|
||||||
|
blockHash = args[i + 1]
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args[i] == "--content" || args[i] == "-c" -> {
|
||||||
|
showContent = true
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
else -> i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ShowOptions(commitHash, targetFile, blockHash, showContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showCommit(repo: Repository, commitHash: String, targetFile: String?): String {
|
||||||
|
val objectStore = ObjectStore(repo.path.resolve("${Repository.NOTEVC_DIR}/objects"))
|
||||||
|
val blockStore = BlockStore(objectStore, repo.path.resolve("${Repository.NOTEVC_DIR}/blocks"))
|
||||||
|
|
||||||
|
// Find the commit
|
||||||
|
val commit = findCommit(repo, commitHash)
|
||||||
|
?: throw Exception("Commit ${ColorUtils.hash(commitHash)} not found")
|
||||||
|
|
||||||
|
val commitTime = Instant.parse(commit.timestamp)
|
||||||
|
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
|
.withZone(ZoneId.systemDefault())
|
||||||
|
|
||||||
|
val result = StringBuilder()
|
||||||
|
|
||||||
|
// Show commit header
|
||||||
|
result.appendLine("${ColorUtils.bold("Commit:")} ${ColorUtils.hash(commit.hash)}")
|
||||||
|
result.appendLine("${ColorUtils.bold("Author:")} ${commit.author}")
|
||||||
|
result.appendLine("${ColorUtils.bold("Date:")} ${formatter.format(commitTime)}")
|
||||||
|
if (commit.parent != null) {
|
||||||
|
result.appendLine("${ColorUtils.bold("Parent:")} ${ColorUtils.hash(commit.parent)}")
|
||||||
|
}
|
||||||
|
result.appendLine()
|
||||||
|
result.appendLine(" ${commit.message}")
|
||||||
|
result.appendLine()
|
||||||
|
|
||||||
|
// Get files at this commit
|
||||||
|
val filesToShow = if (targetFile != null) {
|
||||||
|
listOf(targetFile)
|
||||||
|
} else {
|
||||||
|
getTrackedFilesAtCommit(repo, commitTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filesToShow.isEmpty()) {
|
||||||
|
result.appendLine("${ColorUtils.dim("No files found at this commit")}")
|
||||||
|
return result.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show changes for each file
|
||||||
|
result.appendLine("${ColorUtils.bold("Changes:")}")
|
||||||
|
result.appendLine()
|
||||||
|
|
||||||
|
var totalAdded = 0
|
||||||
|
var totalModified = 0
|
||||||
|
var totalDeleted = 0
|
||||||
|
|
||||||
|
filesToShow.forEach { filePath ->
|
||||||
|
val currentSnapshot = blockStore.getLatestBlockSnapshotBefore(filePath, commitTime)
|
||||||
|
|
||||||
|
if (currentSnapshot != null) {
|
||||||
|
// Find parent commit to compare against
|
||||||
|
val parentSnapshot = if (commit.parent != null) {
|
||||||
|
val parentCommit = findCommit(repo, commit.parent)
|
||||||
|
if (parentCommit != null) {
|
||||||
|
val parentTime = Instant.parse(parentCommit.timestamp)
|
||||||
|
blockStore.getLatestBlockSnapshotBefore(filePath, parentTime)
|
||||||
|
} else null
|
||||||
|
} else null
|
||||||
|
|
||||||
|
val changes = blockStore.compareBlocks(parentSnapshot, currentSnapshot)
|
||||||
|
|
||||||
|
if (changes.isNotEmpty()) {
|
||||||
|
result.appendLine("${ColorUtils.filename(filePath)}:")
|
||||||
|
|
||||||
|
val added = changes.count { it.type == BlockChangeType.ADDED }
|
||||||
|
val modified = changes.count { it.type == BlockChangeType.MODIFIED }
|
||||||
|
val deleted = changes.count { it.type == BlockChangeType.DELETED }
|
||||||
|
|
||||||
|
totalAdded += added
|
||||||
|
totalModified += modified
|
||||||
|
totalDeleted += deleted
|
||||||
|
|
||||||
|
if (added > 0) result.appendLine(" ${ColorUtils.success("+")} $added ${if (added == 1) "block" else "blocks"} added")
|
||||||
|
if (modified > 0) result.appendLine(" ${ColorUtils.warning("~")} $modified ${if (modified == 1) "block" else "blocks"} modified")
|
||||||
|
if (deleted > 0) result.appendLine(" ${ColorUtils.error("-")} $deleted ${if (deleted == 1) "block" else "blocks"} deleted")
|
||||||
|
|
||||||
|
result.appendLine()
|
||||||
|
|
||||||
|
// Show detailed changes
|
||||||
|
changes.sortedBy { it.blockId }.forEach { change ->
|
||||||
|
val headingText = change.heading.replace(Regex("^#+\\s*"), "").trim()
|
||||||
|
val blockId = change.blockId.take(8)
|
||||||
|
|
||||||
|
when (change.type) {
|
||||||
|
BlockChangeType.ADDED -> {
|
||||||
|
result.appendLine(" ${ColorUtils.success("+")} ${ColorUtils.heading(headingText)} ${ColorUtils.dim("[$blockId]")}")
|
||||||
|
}
|
||||||
|
BlockChangeType.DELETED -> {
|
||||||
|
result.appendLine(" ${ColorUtils.error("-")} ${ColorUtils.heading(headingText)} ${ColorUtils.dim("[$blockId]")}")
|
||||||
|
}
|
||||||
|
BlockChangeType.MODIFIED -> {
|
||||||
|
result.appendLine(" ${ColorUtils.warning("~")} ${ColorUtils.heading(headingText)} ${ColorUtils.dim("[$blockId]")}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.appendLine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
if (totalAdded + totalModified + totalDeleted > 0) {
|
||||||
|
result.appendLine("${ColorUtils.bold("Summary:")}")
|
||||||
|
result.appendLine(" ${ColorUtils.success("+")} $totalAdded added, ${ColorUtils.warning("~")} $totalModified modified, ${ColorUtils.error("-")} $totalDeleted deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findCommit(repo: Repository, commitHash: String): CommitEntry? {
|
||||||
|
val timelineFile = repo.path.resolve("${Repository.NOTEVC_DIR}/timeline.json")
|
||||||
|
|
||||||
|
if (!timelineFile.exists()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val content = Files.readString(timelineFile)
|
||||||
|
if (content.trim() == "[]") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val commits = Json.decodeFromString<List<CommitEntry>>(content)
|
||||||
|
return commits.find { it.hash.startsWith(commitHash) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTrackedFilesAtCommit(repo: Repository, commitTime: Instant): List<String> {
|
||||||
|
val blocksDir = repo.path.resolve("${Repository.NOTEVC_DIR}/blocks")
|
||||||
|
if (!blocksDir.exists()) return emptyList()
|
||||||
|
|
||||||
|
val files = mutableSetOf<String>()
|
||||||
|
val json = Json { ignoreUnknownKeys = true }
|
||||||
|
val timeRange = 60L // seconds
|
||||||
|
|
||||||
|
Files.walk(blocksDir)
|
||||||
|
.filter { it.isRegularFile() && it.fileName.toString().startsWith("blocks-") }
|
||||||
|
.forEach { snapshotFile ->
|
||||||
|
try {
|
||||||
|
val content = Files.readString(snapshotFile)
|
||||||
|
val snapshot = json.decodeFromString<BlockSnapshot>(content)
|
||||||
|
val snapshotTime = Instant.parse(snapshot.timestamp)
|
||||||
|
|
||||||
|
val timeDiff = kotlin.math.abs(commitTime.epochSecond - snapshotTime.epochSecond)
|
||||||
|
if (timeDiff <= timeRange) {
|
||||||
|
files.add(snapshot.filePath)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Skip corrupted snapshots
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showBlock(repo: Repository, options: ShowOptions): String {
|
||||||
|
val objectStore = ObjectStore(repo.path.resolve("${Repository.NOTEVC_DIR}/objects"))
|
||||||
|
val blockStore = BlockStore(objectStore, repo.path.resolve("${Repository.NOTEVC_DIR}/blocks"))
|
||||||
|
|
||||||
|
val commit = findCommit(repo, options.commitHash)
|
||||||
|
?: throw Exception("Commit ${ColorUtils.hash(options.commitHash)} not found")
|
||||||
|
|
||||||
|
val commitTime = Instant.parse(commit.timestamp)
|
||||||
|
|
||||||
|
if (options.targetFile == null) {
|
||||||
|
throw Exception("--file is required when showing a specific block")
|
||||||
|
}
|
||||||
|
|
||||||
|
val snapshot = blockStore.getLatestBlockSnapshotBefore(options.targetFile, commitTime)
|
||||||
|
?: throw Exception("No snapshot found for ${options.targetFile} at commit ${options.commitHash}")
|
||||||
|
|
||||||
|
val block = snapshot.blocks.find { it.id.startsWith(options.blockHash!!) }
|
||||||
|
?: throw Exception("Block ${ColorUtils.hash(options.blockHash!!)} not found")
|
||||||
|
|
||||||
|
val content = objectStore.getContent(block.contentHash)
|
||||||
|
?: throw Exception("Content not found for block")
|
||||||
|
|
||||||
|
val headingText = block.heading.replace(Regex("^#+\\s*"), "").trim()
|
||||||
|
val result = StringBuilder()
|
||||||
|
|
||||||
|
result.appendLine("${ColorUtils.bold("Block:")} ${ColorUtils.hash(block.id.take(8))}")
|
||||||
|
result.appendLine("${ColorUtils.bold("Heading:")} ${ColorUtils.heading(headingText)}")
|
||||||
|
result.appendLine("${ColorUtils.bold("File:")} ${ColorUtils.filename(options.targetFile)}")
|
||||||
|
result.appendLine("${ColorUtils.bold("Commit:")} ${ColorUtils.hash(commit.hash)}")
|
||||||
|
result.appendLine()
|
||||||
|
result.appendLine("${ColorUtils.dim("─".repeat(70))}")
|
||||||
|
result.appendLine()
|
||||||
|
result.append(content)
|
||||||
|
|
||||||
|
return result.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showFileContent(repo: Repository, options: ShowOptions): String {
|
||||||
|
val objectStore = ObjectStore(repo.path.resolve("${Repository.NOTEVC_DIR}/objects"))
|
||||||
|
val blockStore = BlockStore(objectStore, repo.path.resolve("${Repository.NOTEVC_DIR}/blocks"))
|
||||||
|
val blockParser = BlockParser()
|
||||||
|
|
||||||
|
val commit = findCommit(repo, options.commitHash)
|
||||||
|
?: throw Exception("Commit ${ColorUtils.hash(options.commitHash)} not found")
|
||||||
|
|
||||||
|
val commitTime = Instant.parse(commit.timestamp)
|
||||||
|
|
||||||
|
if (options.targetFile == null) {
|
||||||
|
throw Exception("--file is required when showing file content")
|
||||||
|
}
|
||||||
|
|
||||||
|
val snapshot = blockStore.getLatestBlockSnapshotBefore(options.targetFile, commitTime)
|
||||||
|
?: throw Exception("No snapshot found for ${options.targetFile} at commit ${options.commitHash}")
|
||||||
|
|
||||||
|
val blocks = snapshot.blocks.map { blockState ->
|
||||||
|
val content = objectStore.getContent(blockState.contentHash)
|
||||||
|
?: throw IllegalStateException("Missing content for block ${blockState.id}")
|
||||||
|
|
||||||
|
Block(
|
||||||
|
id = blockState.id,
|
||||||
|
heading = blockState.heading,
|
||||||
|
content = content,
|
||||||
|
type = blockState.type,
|
||||||
|
order = blockState.order
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val parsedFile = ParsedFile(
|
||||||
|
path = options.targetFile,
|
||||||
|
frontMatter = snapshot.frontMatter,
|
||||||
|
blocks = blocks
|
||||||
|
)
|
||||||
|
|
||||||
|
val reconstructedContent = blockParser.reconstructFile(parsedFile)
|
||||||
|
|
||||||
|
val result = StringBuilder()
|
||||||
|
result.appendLine("${ColorUtils.bold("File:")} ${ColorUtils.filename(options.targetFile)}")
|
||||||
|
result.appendLine("${ColorUtils.bold("Commit:")} ${ColorUtils.hash(commit.hash)}")
|
||||||
|
result.appendLine("${ColorUtils.bold("Blocks:")} ${blocks.size}")
|
||||||
|
result.appendLine()
|
||||||
|
result.appendLine("${ColorUtils.dim("─".repeat(70))}")
|
||||||
|
result.appendLine()
|
||||||
|
result.append(reconstructedContent)
|
||||||
|
|
||||||
|
return result.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ShowOptions(
|
||||||
|
val commitHash: String,
|
||||||
|
val targetFile: String?,
|
||||||
|
val blockHash: String? = null,
|
||||||
|
val showContent: Boolean = false
|
||||||
|
)
|
||||||
@@ -114,7 +114,7 @@ class StatusCommand {
|
|||||||
output.appendLine(" ${ColorUtils.filename(fileStatus.path)}")
|
output.appendLine(" ${ColorUtils.filename(fileStatus.path)}")
|
||||||
fileStatus.blockChanges?.forEach { change ->
|
fileStatus.blockChanges?.forEach { change ->
|
||||||
val symbol = when (change.type) {
|
val symbol = when (change.type) {
|
||||||
BlockChangeType.MODIFIED -> ColorUtils.modified("M")
|
BlockChangeType.MODIFIED -> ColorUtils.modified("~")
|
||||||
BlockChangeType.ADDED -> ColorUtils.added("+")
|
BlockChangeType.ADDED -> ColorUtils.added("+")
|
||||||
BlockChangeType.DELETED -> ColorUtils.deleted("-")
|
BlockChangeType.DELETED -> ColorUtils.deleted("-")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,15 +75,43 @@ class BlockParser {
|
|||||||
|
|
||||||
val yamlLines = lines.subList(1, endIndex + 1)
|
val yamlLines = lines.subList(1, endIndex + 1)
|
||||||
val properties = mutableMapOf<String, String>()
|
val properties = mutableMapOf<String, String>()
|
||||||
|
var currentKey: String? = null
|
||||||
|
val arrayValues = mutableListOf<String>()
|
||||||
|
|
||||||
yamlLines.forEach { line ->
|
yamlLines.forEach { line ->
|
||||||
val colonIndex = line.indexOf(":")
|
when {
|
||||||
if (colonIndex != -1) {
|
// Handle array items (lines starting with -)
|
||||||
val key = line.take(colonIndex).trim()
|
line.trim().startsWith("- ") && currentKey != null -> {
|
||||||
val value = line.substring(colonIndex + 1).trim().removeSurrounding("\"")
|
val value = line.trim().substring(2).trim().removeSurrounding("\"")
|
||||||
properties[key] = value
|
arrayValues.add(value)
|
||||||
|
}
|
||||||
|
// Handle key-value pairs
|
||||||
|
line.contains(":") -> {
|
||||||
|
// Save previous array if exists
|
||||||
|
if (currentKey != null && arrayValues.isNotEmpty()) {
|
||||||
|
properties[currentKey] = arrayValues.joinToString(", ")
|
||||||
|
arrayValues.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
val colonIndex = line.indexOf(":")
|
||||||
|
val key = line.take(colonIndex).trim()
|
||||||
|
val value = line.substring(colonIndex + 1).trim().removeSurrounding("\"")
|
||||||
|
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
// This might be an array key
|
||||||
|
currentKey = key
|
||||||
|
} else {
|
||||||
|
properties[key] = value
|
||||||
|
currentKey = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save final array if exists
|
||||||
|
if (currentKey != null && arrayValues.isNotEmpty()) {
|
||||||
|
properties[currentKey] = arrayValues.joinToString(", ")
|
||||||
|
}
|
||||||
|
|
||||||
return FrontMatter(
|
return FrontMatter(
|
||||||
properties = properties,
|
properties = properties,
|
||||||
@@ -106,7 +134,15 @@ class BlockParser {
|
|||||||
parsedFile.frontMatter?.let { fm ->
|
parsedFile.frontMatter?.let { fm ->
|
||||||
result.appendLine("---")
|
result.appendLine("---")
|
||||||
fm.properties.forEach { (key, value) ->
|
fm.properties.forEach { (key, value) ->
|
||||||
result.appendLine("$key: \"$value\"")
|
// Handle tags as array
|
||||||
|
if (key == "tags" && value.contains(",")) {
|
||||||
|
result.appendLine("$key:")
|
||||||
|
value.split(",").forEach { tag ->
|
||||||
|
result.appendLine(" - ${tag.trim()}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.appendLine("$key: \"$value\"")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result.appendLine("---")
|
result.appendLine("---")
|
||||||
result.appendLine()
|
result.appendLine()
|
||||||
@@ -147,8 +183,11 @@ data class FrontMatter(
|
|||||||
val properties: Map<String, String>,
|
val properties: Map<String, String>,
|
||||||
val endLine: Int
|
val endLine: Int
|
||||||
) {
|
) {
|
||||||
val isEnabled: Boolean get() = properties["enabled"]?.lowercase() == "true"
|
// Default to true if not specified
|
||||||
|
val isEnabled: Boolean get() = properties["enabled"]?.lowercase() != "false"
|
||||||
val isAutomatic: Boolean get() = properties["automatic"]?.lowercase() == "true"
|
val isAutomatic: Boolean get() = properties["automatic"]?.lowercase() == "true"
|
||||||
|
val title: String? get() = properties["title"]
|
||||||
|
val tags: List<String> get() = properties["tags"]?.split(",")?.map { it.trim() } ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class BlockType {
|
enum class BlockType {
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ class BlockStore(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Compare blocks between two snapshots
|
// Compare blocks between two snapshots
|
||||||
fun compareBlocks(oldSnapshot: BlockSnapshot?, newSnapshot: BlockSnapshot): List<BlockChange> {
|
fun compareBlocks(oldSnapshot: BlockSnapshot?, newSnapshot: BlockSnapshot?): List<BlockChange> {
|
||||||
val changes = mutableListOf<BlockChange>()
|
val changes = mutableListOf<BlockChange>()
|
||||||
|
|
||||||
val oldBlocks = oldSnapshot?.blocks?.associateBy { it.id } ?: emptyMap()
|
val oldBlocks = oldSnapshot?.blocks?.associateBy { it.id } ?: emptyMap()
|
||||||
val newBlocks = newSnapshot.blocks.associateBy { it.id }
|
val newBlocks = newSnapshot?.blocks?.associateBy { it.id } ?: emptyMap()
|
||||||
|
|
||||||
// Find added and modified blocks
|
// Find added and modified blocks
|
||||||
newBlocks.forEach { (id, newBlock) ->
|
newBlocks.forEach { (id, newBlock) ->
|
||||||
|
|||||||
@@ -3,11 +3,20 @@ package io.notevc.core
|
|||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import io.notevc.utils.HashUtils
|
import io.notevc.utils.HashUtils
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.util.zip.GZIPOutputStream
|
||||||
|
import java.util.zip.GZIPInputStream
|
||||||
import kotlin.io.path.*
|
import kotlin.io.path.*
|
||||||
|
|
||||||
class ObjectStore(private val objectsDir: Path) {
|
class ObjectStore(private val objectsDir: Path) {
|
||||||
|
companion object {
|
||||||
|
private const val COMPRESSION_ENABLED = true
|
||||||
|
private const val MIN_COMPRESSION_SIZE = 100 // bytes
|
||||||
|
}
|
||||||
|
|
||||||
// Store content and return its hash
|
// Store content and return its hash
|
||||||
// Uses git-like storage: objects/ab/cdef123... (first 2 characters as directory)
|
// Uses git-like storage: objects/ab/cdef123... (first 2 characters as directory)
|
||||||
|
// Content is compressed if it exceeds MIN_COMPRESSION_SIZE
|
||||||
fun storeContent(content: String): String {
|
fun storeContent(content: String): String {
|
||||||
val hash = HashUtils.sha256(content)
|
val hash = HashUtils.sha256(content)
|
||||||
val objectPath = getObjectPath(hash)
|
val objectPath = getObjectPath(hash)
|
||||||
@@ -15,7 +24,13 @@ class ObjectStore(private val objectsDir: Path) {
|
|||||||
// Only store if it doesn't already exist
|
// Only store if it doesn't already exist
|
||||||
if (!objectPath.exists()) {
|
if (!objectPath.exists()) {
|
||||||
Files.createDirectories(objectPath.parent)
|
Files.createDirectories(objectPath.parent)
|
||||||
Files.writeString(objectPath, content)
|
|
||||||
|
if (COMPRESSION_ENABLED && content.length > MIN_COMPRESSION_SIZE) {
|
||||||
|
val compressed = compressString(content)
|
||||||
|
Files.write(objectPath, compressed)
|
||||||
|
} else {
|
||||||
|
Files.writeString(objectPath, content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return hash
|
return hash
|
||||||
@@ -24,9 +39,40 @@ class ObjectStore(private val objectsDir: Path) {
|
|||||||
// Retrieve content by hash
|
// Retrieve content by hash
|
||||||
fun getContent(hash: String): String? {
|
fun getContent(hash: String): String? {
|
||||||
val objectPath = getObjectPath(hash)
|
val objectPath = getObjectPath(hash)
|
||||||
return if (objectPath.exists()) {
|
if (!objectPath.exists()) return null
|
||||||
Files.readString(objectPath)
|
|
||||||
} else null
|
return try {
|
||||||
|
val bytes = Files.readAllBytes(objectPath)
|
||||||
|
|
||||||
|
// Try to decompress first, fall back to plain text
|
||||||
|
try {
|
||||||
|
if (COMPRESSION_ENABLED && bytes.size > 2 && bytes[0] == 0x1f.toByte() && bytes[1] == 0x8b.toByte()) {
|
||||||
|
// This is a GZIP file (magic bytes: 0x1f 0x8b)
|
||||||
|
decompressString(bytes)
|
||||||
|
} else {
|
||||||
|
String(bytes, Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// If decompression fails, try as plain text
|
||||||
|
String(bytes, Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compressString(content: String): ByteArray {
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
GZIPOutputStream(outputStream).use { gzip ->
|
||||||
|
gzip.write(content.toByteArray(Charsets.UTF_8))
|
||||||
|
}
|
||||||
|
return outputStream.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decompressString(compressed: ByteArray): String {
|
||||||
|
return GZIPInputStream(compressed.inputStream()).use { gzip ->
|
||||||
|
gzip.readBytes().toString(Charsets.UTF_8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if content exists
|
// Check if content exists
|
||||||
|
|||||||
Reference in New Issue
Block a user