feat(commit): added commit command and timeline
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,3 +28,4 @@ Thumbs.db
|
||||
|
||||
# Other
|
||||
*.hprof
|
||||
.notevc/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
11
CHECKLIST.md
11
CHECKLIST.md
@@ -41,11 +41,11 @@
|
||||
|
||||
## Commit command
|
||||
|
||||
- [ ] `notevc commit "message"` - Create snapshot
|
||||
- [ ] Validate commit message exists
|
||||
- [ ] Store changed file contents
|
||||
- [ ] Create snapshot with metadata
|
||||
- [ ] Update repository head pointer
|
||||
- [x] `notevc commit "message"` - Create snapshot
|
||||
- [x] Validate commit message exists
|
||||
- [x] Store changed file contents
|
||||
- [x] Create snapshot with metadata
|
||||
- [x] Update repository head pointer
|
||||
|
||||
## Log Command
|
||||
|
||||
@@ -111,3 +111,4 @@
|
||||
- [ ] File watching for auto-commits
|
||||
- [ ] Export/import functionality
|
||||
- [ ] NeoVim Plugin
|
||||
|
||||
|
||||
80
README.md
80
README.md
@@ -1,21 +1,75 @@
|
||||
# NoteVC: Version Control for Markdown
|
||||
#  NoteVC: Version Control for Markdown
|
||||
|
||||
|
||||
# Repository management
|
||||
notevc init [path] # Initialize notevc repo
|
||||
notevc status # Show changed files
|
||||
notevc commit "message" # Create snapshot
|
||||
notevc log [--since=time] # Show commit history
|
||||
|
||||
Initialize notevc repo:
|
||||
```bash
|
||||
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
|
||||
notevc diff [file] # Show changes since last commit
|
||||
notevc diff HEAD~1 [file] # Compare with previous commit
|
||||
notevc show <commit-hash> # Show specific commit
|
||||
|
||||
Show changes since last commit:
|
||||
```bash
|
||||
notevc diff [file]
|
||||
```
|
||||
|
||||
Compare with previous commit:
|
||||
```bash
|
||||
notevc diff HEAD~1 [file]
|
||||
```
|
||||
|
||||
Show specific commit:
|
||||
```bash
|
||||
notevc show <commit-hash>
|
||||
```
|
||||
|
||||
# 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
|
||||
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
BIN
images/png/Color400X500.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
images/png/Color40X50.png
Normal file
BIN
images/png/Color40X50.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
BIN
images/png/Monochrome400X500.png
Normal file
BIN
images/png/Monochrome400X500.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
19
images/svg/Color.svg
Normal file
19
images/svg/Color.svg
Normal 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 |
5
images/svg/Monochrome.svg
Normal file
5
images/svg/Monochrome.svg
Normal 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 |
@@ -17,7 +17,14 @@ fun main(args: Array<String>) {
|
||||
}
|
||||
|
||||
"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" -> {
|
||||
|
||||
@@ -1 +1,248 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ class StatusCommand {
|
||||
grouped[FileStatusType.MODIFIED]?.let { modifiedFiles ->
|
||||
output.appendLine("Modified files:")
|
||||
modifiedFiles.forEach { fileStatus ->
|
||||
output.appendLine(" ${fileStatus.path}")
|
||||
output.appendLine("― ${fileStatus.path}")
|
||||
fileStatus.blockChanges?.forEach { change ->
|
||||
val symbol = when (change.type) {
|
||||
BlockChangeType.MODIFIED -> "M"
|
||||
|
||||
@@ -93,7 +93,17 @@ class Repository private constructor(private val rootPath: Path) {
|
||||
data class RepoMetadata(
|
||||
val version: 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
|
||||
@@ -102,3 +112,12 @@ data class RepoConfig(
|
||||
val compressionEnabled: Boolean = false,
|
||||
val maxSnapshots: Int = 100
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CommitEntry(
|
||||
val hash: String,
|
||||
val message: String,
|
||||
val timestamp: String,
|
||||
val author: String,
|
||||
val parent: String? = null
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user