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] Implement path resolution and validation
|
||||
- [x] Add file timestamp tracking
|
||||
- [ ] Create backup and restore mechanisms
|
||||
- [x] Create backup and restore mechanisms
|
||||
|
||||
|
||||
# Core Commands
|
||||
@@ -56,10 +56,10 @@
|
||||
|
||||
## Log Command
|
||||
|
||||
- [ ] `notevc log` - Show commit history
|
||||
- [ ] Display snapshots in reverse chronological order
|
||||
- [ ] Show commit hashes, messages, and timestamps
|
||||
- [ ] add `--since` time filtering option
|
||||
- [x] `notevc log` - Show commit history
|
||||
- [x] Display snapshots in reverse chronological order
|
||||
- [x] Show commit hashes, messages, and timestamps
|
||||
- [x] add `--since` time filtering option
|
||||
|
||||
|
||||
# Advanced Commands
|
||||
@@ -75,10 +75,10 @@
|
||||
|
||||
## Restore Command
|
||||
|
||||
- [ ] `notevc restore <commit>` - Restore entire state
|
||||
- [ ] `notevc restore <commit> <file>` - Restore specific file
|
||||
- [ ] Add conformation prompts for destructive operations
|
||||
- [ ] Handle file conflicts gracefully
|
||||
- [x] `notevc restore <commit>` - Restore entire state
|
||||
- [x] `notevc restore <commit> <file>` - Restore specific file
|
||||
- [x] Add conformation prompts for destructive operations
|
||||
- [x] Handle file conflicts gracefully
|
||||
|
||||
|
||||
## Show Command
|
||||
@@ -90,11 +90,11 @@
|
||||
|
||||
# Utilities and Polish
|
||||
|
||||
- [ ] Add colored output for better UX
|
||||
- [x] Add colored output for better UX
|
||||
- [ ] Implement proper error handling messages
|
||||
- [ ] Add input validation for all commands
|
||||
- [ ] Create help system (`notevc --help`)
|
||||
- [ ] Add version information (`notevc --version`)
|
||||
- [x] Add version information (`notevc --version`)
|
||||
- [ ] Implement configuration file support
|
||||
|
||||
|
||||
@@ -110,11 +110,11 @@
|
||||
|
||||
# Build and Distribution
|
||||
|
||||
- [ ] Create fat JAR for distribution
|
||||
- [ ] Add shell script wrapper for easy execution
|
||||
- [ ] Test on different operating systems
|
||||
- [ ] Create installation scripts
|
||||
- [ ] Add build automation (GitHub Actions)
|
||||
- [x] Create fat JAR for distribution
|
||||
- [x] Add shell script wrapper for easy execution
|
||||
- [x] Test on different operating systems
|
||||
- [x] Create installation scripts
|
||||
- [x] Add build automation (GitHub Actions)
|
||||
|
||||
|
||||
# Future Features
|
||||
|
||||
208
README.md
208
README.md
@@ -1,84 +1,204 @@
|
||||
#  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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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:
|
||||
```bash
|
||||
notevc checkout <commit-hash>
|
||||
```
|
||||
|
||||
|
||||
|
||||
# Utilities
|
||||
|
||||
Remove old snapshots:
|
||||
```bash
|
||||
notevc clean
|
||||
```
|
||||
|
||||
Garbage collect unused objects:
|
||||
```bash
|
||||
notevc gc
|
||||
```
|
||||
|
||||
Show/set configuration:
|
||||
```bash
|
||||
notevc config
|
||||
```
|
||||
**Frontmatter fields:**
|
||||
- `enabled`: Set to "false" to exclude file from commits (default: true)
|
||||
- `automatic`: Enable automatic commits (default: false)
|
||||
- `title`: Custom title for the note
|
||||
- `tags`: List of tags (array or comma-separated string)
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
NoteVC splits markdown files into blocks based on headings. Each block is:
|
||||
- Identified by a stable hash
|
||||
- Tracked independently
|
||||
- Versioned separately
|
||||
- **Compressed automatically** (GZIP) when content > 100 bytes
|
||||
|
||||
This means you can:
|
||||
- See which sections changed
|
||||
- Restore individual sections
|
||||
- Track content at a granular level
|
||||
- **Save disk space** with automatic compression
|
||||
|
||||
### 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"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
|
||||
buildConfig {
|
||||
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}") }
|
||||
)
|
||||
}
|
||||
|
||||
"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 -> {
|
||||
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
|
||||
|
||||
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) {
|
||||
when (args[i]) {
|
||||
"--max-count", "-n" -> {
|
||||
if (i + 1 < args.size) {
|
||||
if (i + 1 < args.size && !args[i + 1].startsWith("-")) {
|
||||
maxCount = args[i + 1].toIntOrNull()
|
||||
i += 2
|
||||
} else {
|
||||
@@ -44,7 +44,7 @@ class LogCommand {
|
||||
}
|
||||
}
|
||||
"--since" -> {
|
||||
if (i + 1 < args.size) {
|
||||
if (i + 1 < args.size && !args[i + 1].startsWith("-")) {
|
||||
since = args[i + 1]
|
||||
i += 2
|
||||
} else {
|
||||
@@ -56,12 +56,11 @@ class LogCommand {
|
||||
i++
|
||||
}
|
||||
"--file", "-f" -> {
|
||||
if (i + 1 < args.size) {
|
||||
showFiles = true
|
||||
if (i + 1 < args.size && !args[i + 1].startsWith("-")) {
|
||||
targetFile = args[i + 1]
|
||||
showFiles = true
|
||||
i += 2
|
||||
} else {
|
||||
showFiles = true
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,13 +127,16 @@ class RestoreCommand {
|
||||
|
||||
// Find the block snapshot for this file at the commit time
|
||||
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)}")
|
||||
|
||||
// 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(
|
||||
path = targetFile,
|
||||
frontMatter = null, // TODO: Get front matter from snapshot
|
||||
frontMatter = snapshot.frontMatter,
|
||||
blocks = blocks
|
||||
)
|
||||
|
||||
@@ -169,11 +172,12 @@ class RestoreCommand {
|
||||
var totalBlocks = 0
|
||||
|
||||
trackedFiles.forEach { filePath ->
|
||||
val snapshot = blockStore.getLatestBlockSnapshotBefore(filePath, commitTime)
|
||||
val blocks = blockStore.getBlocksAtTime(filePath, commitTime)
|
||||
if (blocks != null) {
|
||||
val parsedFile = ParsedFile(
|
||||
path = filePath,
|
||||
frontMatter = null, // TODO: Get front matter from snapshot
|
||||
frontMatter = snapshot?.frontMatter,
|
||||
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)}")
|
||||
fileStatus.blockChanges?.forEach { change ->
|
||||
val symbol = when (change.type) {
|
||||
BlockChangeType.MODIFIED -> ColorUtils.modified("M")
|
||||
BlockChangeType.MODIFIED -> ColorUtils.modified("~")
|
||||
BlockChangeType.ADDED -> ColorUtils.added("+")
|
||||
BlockChangeType.DELETED -> ColorUtils.deleted("-")
|
||||
}
|
||||
|
||||
@@ -75,15 +75,43 @@ class BlockParser {
|
||||
|
||||
val yamlLines = lines.subList(1, endIndex + 1)
|
||||
val properties = mutableMapOf<String, String>()
|
||||
var currentKey: String? = null
|
||||
val arrayValues = mutableListOf<String>()
|
||||
|
||||
yamlLines.forEach { line ->
|
||||
val colonIndex = line.indexOf(":")
|
||||
if (colonIndex != -1) {
|
||||
val key = line.take(colonIndex).trim()
|
||||
val value = line.substring(colonIndex + 1).trim().removeSurrounding("\"")
|
||||
properties[key] = value
|
||||
when {
|
||||
// Handle array items (lines starting with -)
|
||||
line.trim().startsWith("- ") && currentKey != null -> {
|
||||
val value = line.trim().substring(2).trim().removeSurrounding("\"")
|
||||
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(
|
||||
properties = properties,
|
||||
@@ -106,7 +134,15 @@ class BlockParser {
|
||||
parsedFile.frontMatter?.let { fm ->
|
||||
result.appendLine("---")
|
||||
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()
|
||||
@@ -147,8 +183,11 @@ data class FrontMatter(
|
||||
val properties: Map<String, String>,
|
||||
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 title: String? get() = properties["title"]
|
||||
val tags: List<String> get() = properties["tags"]?.split(",")?.map { it.trim() } ?: emptyList()
|
||||
}
|
||||
|
||||
enum class BlockType {
|
||||
|
||||
@@ -55,11 +55,11 @@ class BlockStore(
|
||||
}
|
||||
|
||||
// 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 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
|
||||
newBlocks.forEach { (id, newBlock) ->
|
||||
|
||||
@@ -3,11 +3,20 @@ package io.notevc.core
|
||||
import java.nio.file.Files
|
||||
import io.notevc.utils.HashUtils
|
||||
import java.nio.file.Path
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.zip.GZIPOutputStream
|
||||
import java.util.zip.GZIPInputStream
|
||||
import kotlin.io.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
|
||||
// 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 {
|
||||
val hash = HashUtils.sha256(content)
|
||||
val objectPath = getObjectPath(hash)
|
||||
@@ -15,7 +24,13 @@ class ObjectStore(private val objectsDir: Path) {
|
||||
// Only store if it doesn't already exist
|
||||
if (!objectPath.exists()) {
|
||||
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
|
||||
@@ -24,9 +39,40 @@ class ObjectStore(private val objectsDir: Path) {
|
||||
// Retrieve content by hash
|
||||
fun getContent(hash: String): String? {
|
||||
val objectPath = getObjectPath(hash)
|
||||
return if (objectPath.exists()) {
|
||||
Files.readString(objectPath)
|
||||
} else null
|
||||
if (!objectPath.exists()) return 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
|
||||
|
||||
Reference in New Issue
Block a user