diff --git a/.gitignore b/.gitignore index 0a28dde..8680464 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ Thumbs.db # Other *.hprof +.notevc/ diff --git a/.notevc/metadata.json b/.notevc/metadata.json index 0424d93..b6a5726 100644 --- a/.notevc/metadata.json +++ b/.notevc/metadata.json @@ -1 +1,11 @@ -{"version":"1.0.0","created":"2025-11-06T22:16:55.863743Z","head":null} \ No newline at end of file +{ + "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" + } +} \ No newline at end of file diff --git a/.notevc/timeline.json b/.notevc/timeline.json index 0637a08..0cbc69d 100644 --- a/.notevc/timeline.json +++ b/.notevc/timeline.json @@ -1 +1,15 @@ -[] \ No newline at end of file +[ + { + "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" + } +] \ No newline at end of file diff --git a/CHECKLIST.md b/CHECKLIST.md index ad9e24c..e7aa096 100644 --- a/CHECKLIST.md +++ b/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 + diff --git a/README.md b/README.md index b68f75e..b671d34 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,75 @@ -# NoteVC: Version Control for Markdown +# ![logo.png](images/png/Color40x50.png) 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 ] "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 # 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 +``` # Restoration -notevc restore [file] # Restore to specific version -notevc checkout # Restore entire repo state + +Restore to specific version: +```bash +notevc restore [file] +``` + +Restore to specific block: +```bash +notevc restore --block [file] +``` + +Restore entire repo state: +```bash +notevc checkout +``` # 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 +``` diff --git a/images/png/Color400X500.png b/images/png/Color400X500.png new file mode 100644 index 0000000..5d15b5d Binary files /dev/null and b/images/png/Color400X500.png differ diff --git a/images/png/Color40X50.png b/images/png/Color40X50.png new file mode 100644 index 0000000..a4bebd5 Binary files /dev/null and b/images/png/Color40X50.png differ diff --git a/images/png/Monochrome400X500.png b/images/png/Monochrome400X500.png new file mode 100644 index 0000000..2abd137 Binary files /dev/null and b/images/png/Monochrome400X500.png differ diff --git a/images/svg/Color.svg b/images/svg/Color.svg new file mode 100644 index 0000000..6d98552 --- /dev/null +++ b/images/svg/Color.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/images/svg/Monochrome.svg b/images/svg/Monochrome.svg new file mode 100644 index 0000000..c9bf4d6 --- /dev/null +++ b/images/svg/Monochrome.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/kotlin/io/notevc/NoteVC.kt b/src/main/kotlin/io/notevc/NoteVC.kt index 3e0c345..3600ce1 100644 --- a/src/main/kotlin/io/notevc/NoteVC.kt +++ b/src/main/kotlin/io/notevc/NoteVC.kt @@ -17,7 +17,14 @@ fun main(args: Array) { } "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" -> { diff --git a/src/main/kotlin/io/notevc/commands/CommitCommand.kt b/src/main/kotlin/io/notevc/commands/CommitCommand.kt index a1f7d85..d9ddf47 100644 --- a/src/main/kotlin/io/notevc/commands/CommitCommand.kt +++ b/src/main/kotlin/io/notevc/commands/CommitCommand.kt @@ -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): Result { + 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): Pair { + 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() + 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>(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(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)) + } +} diff --git a/src/main/kotlin/io/notevc/commands/StatusCommand.kt b/src/main/kotlin/io/notevc/commands/StatusCommand.kt index c9ff13b..7fb4d84 100644 --- a/src/main/kotlin/io/notevc/commands/StatusCommand.kt +++ b/src/main/kotlin/io/notevc/commands/StatusCommand.kt @@ -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" diff --git a/src/main/kotlin/io/notevc/core/Repository.kt b/src/main/kotlin/io/notevc/core/Repository.kt index 31bf56a..14fed74 100644 --- a/src/main/kotlin/io/notevc/core/Repository.kt +++ b/src/main/kotlin/io/notevc/core/Repository.kt @@ -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 +)