feat(release): added diff and show command

This commit is contained in:
darwincereska
2025-11-10 20:18:23 -05:00
parent d2e51659e6
commit 375f5a52b2
13 changed files with 1168 additions and 91 deletions

View File

@@ -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
View File

@@ -1,84 +1,204 @@
# ![logo.png](./images/png/Color40X50.png) 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

View File

@@ -10,7 +10,7 @@ plugins {
}
group = "io.notevc"
version = "1.0.2"
version = "1.0.3"
buildConfig {
buildConfigField("String", "VERSION", "\"${project.version}\"")

View File

@@ -1,6 +0,0 @@
class Notevc < Formula
desc "Version control for markdown files"
homepage "https://github.com/darwincereska/notevc"
version "1.0.0"

View File

@@ -64,8 +64,30 @@ fun main(args: Array<String>) {
)
}
"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")
}
}
}

View File

@@ -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
)

View File

@@ -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) {
targetFile = args[i + 1]
showFiles = true
if (i + 1 < args.size && !args[i + 1].startsWith("-")) {
targetFile = args[i + 1]
i += 2
} else {
showFiles = true
i++
}
}

View File

@@ -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
)

View 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
)

View File

@@ -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("-")
}

View File

@@ -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 ->
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(":")
if (colonIndex != -1) {
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,8 +134,16 @@ class BlockParser {
parsedFile.frontMatter?.let { fm ->
result.appendLine("---")
fm.properties.forEach { (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 {

View File

@@ -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) ->

View File

@@ -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,8 +24,14 @@ class ObjectStore(private val objectsDir: Path) {
// Only store if it doesn't already exist
if (!objectPath.exists()) {
Files.createDirectories(objectPath.parent)
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