feat(commit): added commit command and timeline

This commit is contained in:
darwincereska
2025-11-07 16:01:35 -05:00
parent 9b3c20cf2c
commit 673c9a3154
14 changed files with 400 additions and 23 deletions

1
.gitignore vendored
View File

@@ -28,3 +28,4 @@ Thumbs.db
# Other # Other
*.hprof *.hprof
.notevc/

View File

@@ -1 +1,11 @@
{"version":"1.0.0","created":"2025-11-06T22:16:55.863743Z","head":null} {
"version": "1.0.0",
"created": "2025-11-07T02:45:31.185947Z",
"head": "faa8ece0",
"lastCommit": {
"hash": "faa8ece0",
"message": "Updated readme",
"timestamp": "2025-11-07T19:12:43.784310Z",
"author": "darwin"
}
}

View File

@@ -1 +1,15 @@
[] [
{
"hash": "faa8ece0",
"message": "Updated readme",
"timestamp": "2025-11-07T19:12:43.784310Z",
"author": "darwin",
"parent": "00f585ce"
},
{
"hash": "00f585ce",
"message": "first",
"timestamp": "2025-11-07T02:45:41.270419Z",
"author": "darwin"
}
]

View File

@@ -41,11 +41,11 @@
## Commit command ## Commit command
- [ ] `notevc commit "message"` - Create snapshot - [x] `notevc commit "message"` - Create snapshot
- [ ] Validate commit message exists - [x] Validate commit message exists
- [ ] Store changed file contents - [x] Store changed file contents
- [ ] Create snapshot with metadata - [x] Create snapshot with metadata
- [ ] Update repository head pointer - [x] Update repository head pointer
## Log Command ## Log Command
@@ -111,3 +111,4 @@
- [ ] File watching for auto-commits - [ ] File watching for auto-commits
- [ ] Export/import functionality - [ ] Export/import functionality
- [ ] NeoVim Plugin - [ ] NeoVim Plugin

View File

@@ -1,21 +1,75 @@
# NoteVC: Version Control for Markdown # ![logo.png](images/png/Color40x50.png) NoteVC: Version Control for Markdown
# Repository management # Repository management
notevc init [path] # Initialize notevc repo
notevc status # Show changed files Initialize notevc repo:
notevc commit "message" # Create snapshot ```bash
notevc log [--since=time] # Show commit history notevc init [path]
```
Show changed files:
```bash
notevc status
```
Create snapshot:
```bash
notevc commit [--file <file>] "message"
```
Show commit history:
```bash
notevc log [--since=time]
```
# Viewing changes # Viewing changes
notevc diff [file] # Show changes since last commit
notevc diff HEAD~1 [file] # Compare with previous commit Show changes since last commit:
notevc show <commit-hash> # Show specific commit ```bash
notevc diff [file]
```
Compare with previous commit:
```bash
notevc diff HEAD~1 [file]
```
Show specific commit:
```bash
notevc show <commit-hash>
```
# Restoration # Restoration
notevc restore <commit-hash> [file] # Restore to specific version
notevc checkout <commit-hash> # Restore entire repo state Restore to specific version:
```bash
notevc restore <commit-hash> [file]
```
Restore to specific block:
```bash
notevc restore --block <commit-hash> [file]
```
Restore entire repo state:
```bash
notevc checkout <commit-hash>
```
# Utilities # Utilities
notevc clean # Remove old snapshots
notevc gc # Garbage collect unused objects
notevc config # Show/set configuration
Remove old snapshots:
```bash
notevc clean
```
Garbage collect unused objects:
```bash
notevc gc
```
Show/set configuration:
```bash
notevc config
```

BIN
images/png/Color400X500.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
images/png/Color40X50.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

19
images/svg/Color.svg Normal file
View File

@@ -0,0 +1,19 @@
<svg width="400" height="500" viewBox="0 0 400 500" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M400 0V500L219 274L400 0Z" fill="url(#paint0_linear_4_24)"/>
<path d="M0 500V80L330.5 500H0Z" fill="url(#paint1_linear_4_24)"/>
<path d="M0 80L330 500H400L219.087 273.859L400 0H350L184.4 233L0 0V80Z" fill="url(#paint2_linear_4_24)"/>
<defs>
<linearGradient id="paint0_linear_4_24" x1="309.5" y1="0" x2="309.5" y2="500" gradientUnits="userSpaceOnUse">
<stop stop-color="#182057"/>
<stop offset="1" stop-color="#3F1536"/>
</linearGradient>
<linearGradient id="paint1_linear_4_24" x1="-3.38941e-05" y1="60" x2="350" y2="500" gradientUnits="userSpaceOnUse">
<stop stop-color="#182057"/>
<stop offset="1" stop-color="#3F1536"/>
</linearGradient>
<linearGradient id="paint2_linear_4_24" x1="200" y1="0" x2="200" y2="500" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFB700"/>
<stop offset="1" stop-color="#AE057B"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 957 B

View File

@@ -0,0 +1,5 @@
<svg width="400" height="500" viewBox="0 0 400 500" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M400 0V500L219 274L400 0Z" fill="white"/>
<path d="M0 500V80L330.5 500H0Z" fill="white"/>
<path d="M390.704 5L214.915 271.104L212.909 274.141L215.183 276.982L389.598 495H332.43L5 78.2705V14.374L180.479 236.103L184.615 241.328L188.476 235.896L352.58 5H390.704Z" stroke="white" stroke-width="10"/>
</svg>

After

Width:  |  Height:  |  Size: 412 B

View File

@@ -17,7 +17,14 @@ fun main(args: Array<String>) {
} }
"commit" -> { "commit" -> {
println("Not implemented yet") val commitArgs = args.drop(1)
val commitCommand = CommitCommand()
val result = commitCommand.execute(commitArgs)
result.fold(
onSuccess = { output -> println(output) },
onFailure = { error -> println("Error: ${error.message}") }
)
} }
"status", "st" -> { "status", "st" -> {

View File

@@ -1 +1,248 @@
package io.notevc.commands package io.notevc.commands
import io.notevc.core.*
import io.notevc.utils.FileUtils
import io.notevc.utils.HashUtils
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.nio.file.Files
import java.time.Instant
import kotlin.io.path.*
class CommitCommand {
fun execute(args: List<String>): Result<String> {
return try {
val (targetFile, message) = parseArgs(args)
if (message.isBlank()) {
return Result.failure(Exception("Commit message cannot be empty"))
}
val repo = Repository.find()
?: return Result.failure(Exception("Not in a notevc repository. Run 'notevc init' first."))
val commitResult = if (targetFile != null) {
createSingleFileCommit(repo, targetFile, message)
} else {
createChangedFilesCommit(repo, message)
}
Result.success(commitResult)
} catch (e: Exception) {
Result.failure(e)
}
}
private fun parseArgs(args: List<String>): Pair<String?, String> {
if (args.isEmpty()) {
return null to ""
}
// Check for --file flag
val fileIndex = args.indexOf("--file")
if (fileIndex != -1 && fileIndex + 1 < args.size) {
val targetFile = args[fileIndex + 1]
val messageArgs = args.filterIndexed { index, _ ->
index != fileIndex && index != fileIndex + 1
}
return targetFile to messageArgs.joinToString(" ")
}
// No --file flag, all args are the message
return null to args.joinToString(" ")
}
private fun createSingleFileCommit(repo: Repository, targetFile: String, message: 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 timestamp = Instant.now()
// Resolve the target file path
val filePath = repo.path.resolve(targetFile)
if (!filePath.exists()) {
throw Exception("File not found: $targetFile")
}
if (!targetFile.endsWith(".md")) {
throw Exception("Only markdown files (.md) are supported")
}
val relativePath = repo.path.relativize(filePath).toString()
val content = Files.readString(filePath)
val parsedFile = blockParser.parseFile(content, relativePath)
// Check if file is disabled
if (parsedFile.frontMatter?.isEnabled == false) {
throw Exception("File $targetFile is disabled (enabled: false in front matter)")
}
// Check if file actually changed
val latestSnapshot = blockStore.getLatestBlockSnapshot(relativePath)
if (latestSnapshot != null) {
val currentSnapshot = createCurrentSnapshot(parsedFile, objectStore, timestamp)
val changes = blockStore.compareBlocks(latestSnapshot, currentSnapshot)
if (changes.isEmpty()) {
return "No changes detected in $targetFile"
}
}
// Store blocks for this file
val snapshot = blockStore.storeBlocks(parsedFile, timestamp)
val commitHash = HashUtils.sha256("$timestamp:$message:$relativePath").take(8)
// Update repository metadata
updateRepositoryHead(repo, commitHash, timestamp, message)
return buildString {
appendLine("Created commit $commitHash")
appendLine("Message: $message")
appendLine("File: $relativePath (${snapshot.blocks.size} blocks)")
}
}
private fun createChangedFilesCommit(repo: Repository, message: 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 timestamp = Instant.now()
// Find all markdown files
val markdownFiles = FileUtils.findMarkdownFiles(repo.path)
if (markdownFiles.isEmpty()) {
throw Exception("No markdown files found to commit")
}
val changedFiles = mutableListOf<String>()
var totalBlocksStored = 0
// Process each file and check for changes
markdownFiles.forEach { filePath ->
val relativePath = repo.path.relativize(filePath).toString()
val content = Files.readString(filePath)
val parsedFile = blockParser.parseFile(content, relativePath)
// Skip files with front matter disabled
if (parsedFile.frontMatter?.isEnabled == false) {
return@forEach
}
// Check if file changed
val latestSnapshot = blockStore.getLatestBlockSnapshot(relativePath)
val hasChanges = if (latestSnapshot != null) {
val currentSnapshot = createCurrentSnapshot(parsedFile, objectStore, timestamp)
val changes = blockStore.compareBlocks(latestSnapshot, currentSnapshot)
changes.isNotEmpty()
} else {
// New file - always has changes
true
}
if (hasChanges) {
// Store blocks for this file
val snapshot = blockStore.storeBlocks(parsedFile, timestamp)
changedFiles.add("$relativePath (${snapshot.blocks.size} blocks)")
totalBlocksStored += snapshot.blocks.size
}
}
if (changedFiles.isEmpty()) {
return "No changes detected - working directory clean"
}
// Create commit hash from timestamp and message
val commitHash = HashUtils.sha256("$timestamp:$message").take(8)
// Update repository metadata
updateRepositoryHead(repo, commitHash, timestamp, message)
return buildString {
appendLine("Created commit $commitHash")
appendLine("Message: $message")
appendLine("Files committed: ${changedFiles.size}")
appendLine("Total blocks: $totalBlocksStored")
appendLine()
changedFiles.forEach { fileInfo ->
appendLine(" $fileInfo")
}
}
}
private fun createCurrentSnapshot(parsedFile: ParsedFile, objectStore: ObjectStore, timestamp: Instant): BlockSnapshot {
return BlockSnapshot(
filePath = parsedFile.path,
timestamp = timestamp.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 updateRepositoryHead(repo: Repository, commitHash: String, timestamp: Instant, message: String) {
val metadataFile = repo.path.resolve("${Repository.NOTEVC_DIR}/metadata.json")
val timelineFile = repo.path.resolve("${Repository.NOTEVC_DIR}/timeline.json")
// Read current timeline
val currentCommits = if (timelineFile.exists()) {
val content = Files.readString(timelineFile)
if (content.trim() == "[]") {
emptyList()
} else {
Json.decodeFromString<List<CommitEntry>>(content)
}
} else {
emptyList()
}
// Create new commit entry
val newCommit = CommitEntry(
hash = commitHash,
message = message,
timestamp = timestamp.toString(),
author = System.getProperty("user.name"),
parent = currentCommits.firstOrNull()?.hash // Previous commit
)
val updatedCommits = listOf(newCommit) + currentCommits
// Save timeline
val json = Json { prettyPrint = true }
Files.writeString(timelineFile, json.encodeToString(updatedCommits))
// Read current metadata
val currentMetadata = if (metadataFile.exists()) {
val content = Files.readString(metadataFile)
Json.decodeFromString<RepoMetadata>(content)
} else {
RepoMetadata(
version = Repository.VERSION,
created = timestamp.toString(),
head = null
)
}
// Update with new commit
val updatedMetadata = currentMetadata.copy(
head = commitHash,
lastCommit = CommitInfo(
hash = commitHash,
message = message,
timestamp = timestamp.toString(),
author = System.getProperty("user.name")
)
)
// Save updated metadata
Files.writeString(metadataFile, json.encodeToString(updatedMetadata))
}
}

View File

@@ -110,7 +110,7 @@ class StatusCommand {
grouped[FileStatusType.MODIFIED]?.let { modifiedFiles -> grouped[FileStatusType.MODIFIED]?.let { modifiedFiles ->
output.appendLine("Modified files:") output.appendLine("Modified files:")
modifiedFiles.forEach { fileStatus -> modifiedFiles.forEach { fileStatus ->
output.appendLine(" ${fileStatus.path}") output.appendLine(" ${fileStatus.path}")
fileStatus.blockChanges?.forEach { change -> fileStatus.blockChanges?.forEach { change ->
val symbol = when (change.type) { val symbol = when (change.type) {
BlockChangeType.MODIFIED -> "M" BlockChangeType.MODIFIED -> "M"

View File

@@ -93,7 +93,17 @@ class Repository private constructor(private val rootPath: Path) {
data class RepoMetadata( data class RepoMetadata(
val version: String, val version: String,
val created: String, val created: String,
var head: String? var head: String?,
val config: RepoConfig = RepoConfig(),
val lastCommit: CommitInfo? = null
)
@Serializable
data class CommitInfo(
val hash: String,
val message: String,
val timestamp: String,
val author: String
) )
@Serializable @Serializable
@@ -102,3 +112,12 @@ data class RepoConfig(
val compressionEnabled: Boolean = false, val compressionEnabled: Boolean = false,
val maxSnapshots: Int = 100 val maxSnapshots: Int = 100
) )
@Serializable
data class CommitEntry(
val hash: String,
val message: String,
val timestamp: String,
val author: String,
val parent: String? = null
)