From cfa80de6545f3a34151179b6b5a181220e411b68 Mon Sep 17 00:00:00 2001 From: darwincereska Date: Mon, 10 Nov 2025 11:36:50 -0500 Subject: [PATCH] feat(release): first release --- build.gradle.kts | 43 +++ notevc.rb | 6 + src/main/kotlin/io/notevc/NoteVC.kt | 29 +- .../io/notevc/commands/CommitCommand.kt | 20 +- .../kotlin/io/notevc/commands/InitCommand.kt | 3 +- .../kotlin/io/notevc/commands/LogCommand.kt | 261 ++++++++++++++++++ .../io/notevc/commands/RestoreCommand.kt | 248 +++++++++++++++++ .../io/notevc/commands/StatusCommand.kt | 21 +- src/main/kotlin/io/notevc/utils/ColorUtils.kt | 67 +++++ 9 files changed, 676 insertions(+), 22 deletions(-) create mode 100644 notevc.rb create mode 100644 src/main/kotlin/io/notevc/utils/ColorUtils.kt diff --git a/build.gradle.kts b/build.gradle.kts index e82b497..38aa6f4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,7 @@ plugins { application id("com.github.gmazzo.buildconfig") version "4.1.2" id("com.gradleup.shadow") version "9.2.2" + id("org.graalvm.buildtools.native") version "0.9.28" } group = "io.notevc" @@ -34,10 +35,49 @@ application { mainClass.set("io.notevc.NoteVCKt") } + + +tasks.shadowJar { + archiveClassifier.set("") // This should remove the -all suffix + manifest { + attributes(mapOf("Main-Class" to "io.notevc.NoteVCKt")) + } +} + +graalvmNative { + binaries { + named("main") { + imageName.set("notevc") + mainClass.set("io.notevc.NoteVCKt") + + // Force it to use the shadowJar + classpath.from(tasks.shadowJar.get().outputs.files) + + buildArgs.addAll(listOf( + "--no-fallback", + "-H:+ReportExceptionStackTraces", + "-H:+UnlockExperimentalVMOptions", + + "--initialize-at-build-time=kotlin", + "--initialize-at-build-time=kotlinx", + "--initialize-at-build-time=io.notevc", + + "--enable-monitoring=heapdump,jfr", + "-H:IncludeResources=.*\\.json", + "-H:IncludeResources=.*\\.properties" + )) + } + } +} + tasks.withType { useJUnitPlatform() } +tasks.nativeCompile { + dependsOn(tasks.shadowJar) +} + tasks.build { dependsOn(tasks.shadowJar) } @@ -46,3 +86,6 @@ tasks.jar { enabled = false } +tasks.startScripts { + dependsOn(tasks.shadowJar) +} diff --git a/notevc.rb b/notevc.rb new file mode 100644 index 0000000..f9a1645 --- /dev/null +++ b/notevc.rb @@ -0,0 +1,6 @@ +class Notevc < Formula + desc "Version control for markdown files" + homepage "https://github.com/darwincereska/notevc" + version "1.0.0" + + diff --git a/src/main/kotlin/io/notevc/NoteVC.kt b/src/main/kotlin/io/notevc/NoteVC.kt index 3600ce1..f6c13e2 100644 --- a/src/main/kotlin/io/notevc/NoteVC.kt +++ b/src/main/kotlin/io/notevc/NoteVC.kt @@ -2,6 +2,7 @@ package io.notevc import io.notevc.core.Repository import io.notevc.commands.* +import io.notevc.utils.ColorUtils fun main(args: Array) { // Args logic @@ -12,7 +13,18 @@ fun main(args: Array) { result.fold( onSuccess = { message -> println(message) }, - onFailure = { error -> println("Error: ${error.message}") } + onFailure = { error -> println("${ColorUtils.error("Error:")} ${error.message}") } + ) + } + + "log" -> { + val logArgs = args.drop(1) + val logCommand = LogCommand() + val result = logCommand.execute(logArgs) + + result.fold( + onSuccess = { output -> println(output) }, + onFailure = { error -> println("${ColorUtils.error("Error:")} ${error.message}") } ) } @@ -23,7 +35,7 @@ fun main(args: Array) { result.fold( onSuccess = { output -> println(output) }, - onFailure = { error -> println("Error: ${error.message}") } + onFailure = { error -> println("${ColorUtils.error("Error:")} ${error.message}") } ) } @@ -33,13 +45,24 @@ fun main(args: Array) { result.fold( onSuccess = { output -> println(output) }, - onFailure = { error -> println("Error: ${error.message}") } + onFailure = { error -> println("${ColorUtils.error("Error:")} ${error.message}") } ) } "version", "--version", "-v" -> { println("notevc version ${Repository.VERSION}") } + + "restore" -> { + val restoreArgs = args.drop(1) + val restoreCommand = RestoreCommand() + val result = restoreCommand.execute(restoreArgs) + + result.fold( + onSuccess = { output -> println(output) }, + onFailure = { error -> println("${ColorUtils.error("Error:")} ${error.message}") } + ) + } else -> { println("Usage: notevc init|commit|status|version") diff --git a/src/main/kotlin/io/notevc/commands/CommitCommand.kt b/src/main/kotlin/io/notevc/commands/CommitCommand.kt index d9ddf47..d44a262 100644 --- a/src/main/kotlin/io/notevc/commands/CommitCommand.kt +++ b/src/main/kotlin/io/notevc/commands/CommitCommand.kt @@ -7,6 +7,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.nio.file.Files import java.time.Instant +import io.notevc.utils.ColorUtils import kotlin.io.path.* class CommitCommand { @@ -97,9 +98,9 @@ class CommitCommand { updateRepositoryHead(repo, commitHash, timestamp, message) return buildString { - appendLine("Created commit $commitHash") - appendLine("Message: $message") - appendLine("File: $relativePath (${snapshot.blocks.size} blocks)") + appendLine("${ColorUtils.success("Created commit")} ${ColorUtils.hash(commitHash)}") + appendLine("${ColorUtils.bold("Message:")} $message") + appendLine("${ColorUtils.bold("File:")} ${ColorUtils.filename(relativePath)} ${ColorUtils.dim("(${snapshot.blocks.size} blocks)")}") } } @@ -160,13 +161,16 @@ class CommitCommand { updateRepositoryHead(repo, commitHash, timestamp, message) return buildString { - appendLine("Created commit $commitHash") - appendLine("Message: $message") - appendLine("Files committed: ${changedFiles.size}") - appendLine("Total blocks: $totalBlocksStored") + appendLine("${ColorUtils.success("Created commit")} ${ColorUtils.hash(commitHash)}") + appendLine("${ColorUtils.bold("Message:")} $message") + appendLine("${ColorUtils.bold("Files committed:")} ${changedFiles.size}") + appendLine("${ColorUtils.bold("Total blocks:")} $totalBlocksStored") appendLine() changedFiles.forEach { fileInfo -> - appendLine(" $fileInfo") + val parts = fileInfo.split(" (") + val filename = parts[0] + val blockInfo = if (parts.size > 1) " (${parts[1]}" else "" + appendLine(" ${ColorUtils.filename(filename)}${ColorUtils.dim(blockInfo)}") } } } diff --git a/src/main/kotlin/io/notevc/commands/InitCommand.kt b/src/main/kotlin/io/notevc/commands/InitCommand.kt index 68cea60..c5cde77 100644 --- a/src/main/kotlin/io/notevc/commands/InitCommand.kt +++ b/src/main/kotlin/io/notevc/commands/InitCommand.kt @@ -2,6 +2,7 @@ package io.notevc.commands import io.notevc.core.Repository import java.nio.file.Path +import io.notevc.utils.ColorUtils class InitCommand { fun execute(path: String?): Result { @@ -13,7 +14,7 @@ class InitCommand { repo.init().fold( onSuccess = { val absolutePath = repo.path.toAbsolutePath().toString() - Result.success("Initialized notevc repository in $absolutePath") + Result.success("${ColorUtils.success("Initialized notevc repository")} in ${ColorUtils.filename(repo.path.toAbsolutePath().toString())}") }, onFailure = { error -> Result.failure(error) diff --git a/src/main/kotlin/io/notevc/commands/LogCommand.kt b/src/main/kotlin/io/notevc/commands/LogCommand.kt index a1f7d85..4a85169 100644 --- a/src/main/kotlin/io/notevc/commands/LogCommand.kt +++ b/src/main/kotlin/io/notevc/commands/LogCommand.kt @@ -1 +1,262 @@ package io.notevc.commands + +import io.notevc.core.* +import kotlinx.serialization.json.Json +import java.nio.file.Files +import java.time.Instant +import io.notevc.utils.ColorUtils +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.io.path.* + +class LogCommand { + + fun execute(args: List): Result { + return try { + val repo = Repository.find() + ?: return Result.failure(Exception("Not in a notevc repository. Run 'notevc init' first.")) + + val options = parseArgs(args) + val logOutput = generateLog(repo, options) + + Result.success(logOutput) + } catch (e: Exception) { + Result.failure(e) + } + } + + private fun parseArgs(args: List): LogOptions { + var maxCount: Int? = null + var since: String? = null + var oneline = false + var showFiles = false + var targetFile: String? = null + + var i = 0 + while (i < args.size) { + when (args[i]) { + "--max-count", "-n" -> { + if (i + 1 < args.size) { + maxCount = args[i + 1].toIntOrNull() + i += 2 + } else { + i++ + } + } + "--since" -> { + if (i + 1 < args.size) { + since = args[i + 1] + i += 2 + } else { + i++ + } + } + "--oneline" -> { + oneline = true + i++ + } + "--file", "-f" -> { + if (i + 1 < args.size) { + targetFile = args[i + 1] + showFiles = true + i += 2 + } else { + showFiles = true + i++ + } + } + else -> i++ + } + } + + return LogOptions(maxCount, since, oneline, showFiles, targetFile) + } + + private fun generateLog(repo: Repository, options: LogOptions): String { + val timelineFile = repo.path.resolve("${Repository.NOTEVC_DIR}/timeline.json") + + if (!timelineFile.exists()) { + return "No commits yet" + } + + val content = Files.readString(timelineFile) + if (content.trim() == "[]") { + return "No commits yet" + } + + val commits = Json.decodeFromString>(content) + + // Filter commits based on options + val filteredCommits = filterCommits(commits, options) + + return if (options.oneline) { + formatOnelineLog(filteredCommits, repo, options) + } else { + formatDetailedLog(filteredCommits, repo, options) + } + } + + private fun filterCommits(commits: List, options: LogOptions): List { + var filtered = commits + + // Filter by date if --since is provided + options.since?.let { sinceStr -> + val sinceTime = parseSinceTime(sinceStr) + filtered = filtered.filter { commit -> + val commitTime = Instant.parse(commit.timestamp) + commitTime.isAfter(sinceTime) + } + } + + // Limit count if --max-count is provided + options.maxCount?.let { count -> + filtered = filtered.take(count) + } + + return filtered + } + + private fun parseSinceTime(since: String): Instant { + return when { + since.endsWith("h") -> { + val hours = since.dropLast(1).toLongOrNull() ?: 1 + Instant.now().minusSeconds(hours * 3600) + } + since.endsWith("d") -> { + val days = since.dropLast(1).toLongOrNull() ?: 1 + Instant.now().minusSeconds(days * 24 * 3600) + } + since.endsWith("w") -> { + val weeks = since.dropLast(1).toLongOrNull() ?: 1 + Instant.now().minusSeconds(weeks * 7 * 24 * 3600) + } + else -> { + try { + Instant.parse(since) + } catch (e: Exception) { + Instant.now().minusSeconds(24 * 3600) + } + } + } + } + + private fun formatOnelineLog(commits: List, repo: Repository, options: LogOptions): String { + return commits.joinToString("\n") { commit -> + val fileInfo = if (options.showFiles) { + val stats = getCommitStats(repo, commit, options.targetFile) + ColorUtils.dim(" (${stats.filesChanged} files, ${stats.totalBlocks} blocks)") + } else "" + + "${ColorUtils.hash(commit.hash)} ${commit.message}$fileInfo" + } + } + + private fun formatDetailedLog(commits: List, repo: Repository, options: LogOptions): String { + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()) + + return commits.joinToString("\n\n") { commit -> + val timestamp = Instant.parse(commit.timestamp) + val formattedDate = formatter.format(timestamp) + + buildString { + appendLine("${ColorUtils.bold("commit")} ${ColorUtils.hash(commit.hash)}") + appendLine("${ColorUtils.bold("Author:")} ${ColorUtils.author(commit.author)}") + appendLine("${ColorUtils.bold("Date:")} ${ColorUtils.date(formattedDate)}") + + if (options.showFiles) { + val stats = getCommitStats(repo, commit, options.targetFile) + appendLine("${ColorUtils.info("Files changed:")} ${stats.filesChanged}, ${ColorUtils.info("Total blocks:")} ${stats.totalBlocks}") + + if (stats.fileDetails.isNotEmpty()) { + appendLine() + stats.fileDetails.forEach { (file, blocks) -> + appendLine(" ${ColorUtils.filename(file)} ${ColorUtils.dim("(${blocks.size} blocks)")}") + blocks.forEach { block -> + val heading = block.heading.replace(Regex("^#+\\s*"), "").trim() + appendLine(" - ${ColorUtils.hash(block.id.take(8))}: ${ColorUtils.heading(heading)}") + } + } + } + } + + appendLine() + appendLine(" ${commit.message}") + } + } + } + + private fun getCommitStats(repo: Repository, commit: CommitEntry, targetFile: String?): CommitStats { + val blockStore = BlockStore( + ObjectStore(repo.path.resolve("${Repository.NOTEVC_DIR}/objects")), + repo.path.resolve("${Repository.NOTEVC_DIR}/blocks") + ) + + // Find all block snapshots for this commit timestamp + val commitTime = Instant.parse(commit.timestamp) + val snapshots = findSnapshotsForCommit(repo, commitTime, targetFile) + + val fileDetails = mutableMapOf>() + var totalBlocks = 0 + + snapshots.forEach { snapshot -> + if (targetFile == null || snapshot.filePath == targetFile) { + fileDetails[snapshot.filePath] = snapshot.blocks + totalBlocks += snapshot.blocks.size + } + } + + return CommitStats( + filesChanged = fileDetails.size, + totalBlocks = totalBlocks, + fileDetails = fileDetails + ) + } + + private fun findSnapshotsForCommit(repo: Repository, commitTime: Instant, targetFile: String?): List { + val blocksDir = repo.path.resolve("${Repository.NOTEVC_DIR}/blocks") + if (!blocksDir.exists()) return emptyList() + + val snapshots = mutableListOf() + val json = Json { ignoreUnknownKeys = true } + + // Look for snapshots around the commit time (within 1 minute) + 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(content) + val snapshotTime = Instant.parse(snapshot.timestamp) + + // Check if snapshot is within time range of commit + val timeDiff = kotlin.math.abs(commitTime.epochSecond - snapshotTime.epochSecond) + if (timeDiff <= timeRange) { + if (targetFile == null || snapshot.filePath == targetFile) { + snapshots.add(snapshot) + } + } + } catch (e: Exception) { + // Skip corrupted snapshots + } + } + + return snapshots + } +} + +data class LogOptions( + val maxCount: Int? = null, + val since: String? = null, + val oneline: Boolean = false, + val showFiles: Boolean = false, + val targetFile: String? = null +) + +data class CommitStats( + val filesChanged: Int, + val totalBlocks: Int, + val fileDetails: Map> +) diff --git a/src/main/kotlin/io/notevc/commands/RestoreCommand.kt b/src/main/kotlin/io/notevc/commands/RestoreCommand.kt index a1f7d85..65b0a1a 100644 --- a/src/main/kotlin/io/notevc/commands/RestoreCommand.kt +++ b/src/main/kotlin/io/notevc/commands/RestoreCommand.kt @@ -1 +1,249 @@ 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 RestoreCommand { + + fun execute(args: List): Result { + return try { + val options = parseArgs(args) + + if (options.commitHash.isBlank()) { + return Result.failure(Exception("Commit hash is required")) + } + + val repo = Repository.find() + ?: return Result.failure(Exception("Not in a notevc repository. Run 'notevc init' first.")) + + val result = when { + options.blockHash != null && options.targetFile != null -> { + restoreSpecificBlock(repo, options.commitHash, options.blockHash, options.targetFile) + } + options.targetFile != null -> { + restoreSpecificFile(repo, options.commitHash, options.targetFile) + } + else -> { + restoreEntireRepository(repo, options.commitHash) + } + } + + Result.success(result) + } catch (e: Exception) { + Result.failure(e) + } + } + + private fun parseArgs(args: List): RestoreOptions { + if (args.isEmpty()) { + return RestoreOptions("", null, null) + } + + val commitHash = args[0] + var blockHash: String? = null + var targetFile: String? = null + + var i = 1 + while (i < args.size) { + when (args[i]) { + "--block", "-b" -> { + if (i + 1 < args.size) { + blockHash = args[i + 1] + i += 2 + } else { + i++ + } + } + else -> { + // Assume it's the target file + targetFile = args[i] + i++ + } + } + } + + return RestoreOptions(commitHash, blockHash, targetFile) + } + + private fun restoreSpecificBlock(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() + + // Find the commit + val commit = findCommit(repo, commitHash) + ?: throw Exception("Commit ${ColorUtils.hash(commitHash)} not found") + + // Find the block snapshot for this file at the commit time + val commitTime = Instant.parse(commit.timestamp) + val snapshot = blockStore.getBlocksAtTime(targetFile, commitTime) + ?: throw Exception("No snapshot found for ${ColorUtils.filename(targetFile)} at commit ${ColorUtils.hash(commitHash)}") + + // Find the specific block + val targetBlock = snapshot.find { it.id.startsWith(blockHash) } + ?: throw Exception("Block ${ColorUtils.hash(blockHash)} not found in ${ColorUtils.filename(targetFile)} at commit ${ColorUtils.hash(commitHash)}") + + // Read current file + val filePath = repo.path.resolve(targetFile) + if (!filePath.exists()) { + throw Exception("File ${ColorUtils.filename(targetFile)} does not exist") + } + + val currentContent = Files.readString(filePath) + val currentParsedFile = blockParser.parseFile(currentContent, targetFile) + + // Find the block to replace in current file + val currentBlockIndex = currentParsedFile.blocks.indexOfFirst { it.id.startsWith(blockHash) } + if (currentBlockIndex == -1) { + throw Exception("Block ${ColorUtils.hash(blockHash)} not found in current ${ColorUtils.filename(targetFile)}") + } + + // Replace the block + val updatedBlocks = currentParsedFile.blocks.toMutableList() + updatedBlocks[currentBlockIndex] = targetBlock + + val updatedParsedFile = currentParsedFile.copy(blocks = updatedBlocks) + val restoredContent = blockParser.reconstructFile(updatedParsedFile) + + // Write the updated file + Files.writeString(filePath, restoredContent) + + val blockHeading = targetBlock.heading.replace(Regex("^#+\\s*"), "").trim() + return "${ColorUtils.success("Restored block")} ${ColorUtils.hash(blockHash.take(8))} ${ColorUtils.heading("\"$blockHeading\"")} in ${ColorUtils.filename(targetFile)} from commit ${ColorUtils.hash(commitHash)}" + } + + private fun restoreSpecificFile(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")) + val blockParser = BlockParser() + + // Find the commit + val commit = findCommit(repo, commitHash) + ?: throw Exception("Commit ${ColorUtils.hash(commitHash)} not found") + + // Find the block snapshot for this file at the commit time + val commitTime = Instant.parse(commit.timestamp) + val blocks = blockStore.getBlocksAtTime(targetFile, commitTime) + ?: throw Exception("No snapshot found for ${ColorUtils.filename(targetFile)} at commit ${ColorUtils.hash(commitHash)}") + + // Reconstruct the file from blocks + val parsedFile = ParsedFile( + path = targetFile, + frontMatter = null, // TODO: Get front matter from snapshot + blocks = blocks + ) + + val restoredContent = blockParser.reconstructFile(parsedFile) + + // Write the restored file + val filePath = repo.path.resolve(targetFile) + Files.createDirectories(filePath.parent) + Files.writeString(filePath, restoredContent) + + return "${ColorUtils.success("Restored file")} ${ColorUtils.filename(targetFile)} ${ColorUtils.dim("(${blocks.size} blocks)")} from commit ${ColorUtils.hash(commitHash)}" + } + + private fun restoreEntireRepository(repo: Repository, commitHash: 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 the commit + val commit = findCommit(repo, commitHash) + ?: throw Exception("Commit ${ColorUtils.hash(commitHash)} not found") + + val commitTime = Instant.parse(commit.timestamp) + + // Get all files that were tracked at this commit + val trackedFiles = getTrackedFilesAtCommit(repo, commitTime) + + if (trackedFiles.isEmpty()) { + throw Exception("No files found at commit ${ColorUtils.hash(commitHash)}") + } + + var restoredFiles = 0 + var totalBlocks = 0 + + trackedFiles.forEach { filePath -> + val blocks = blockStore.getBlocksAtTime(filePath, commitTime) + if (blocks != null) { + val parsedFile = ParsedFile( + path = filePath, + frontMatter = null, // TODO: Get front matter from snapshot + blocks = blocks + ) + + val restoredContent = blockParser.reconstructFile(parsedFile) + val fullPath = repo.path.resolve(filePath) + + Files.createDirectories(fullPath.parent) + Files.writeString(fullPath, restoredContent) + + restoredFiles++ + totalBlocks += blocks.size + } + } + + return buildString { + appendLine("${ColorUtils.success("Restored repository")} to commit ${ColorUtils.hash(commitHash)}") + appendLine("${ColorUtils.bold("Files restored:")} $restoredFiles") + appendLine("${ColorUtils.bold("Total blocks:")} $totalBlocks") + appendLine("${ColorUtils.bold("Commit message:")} ${commit.message}") + } + } + + 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>(content) + return commits.find { it.hash.startsWith(commitHash) } + } + + private fun getTrackedFilesAtCommit(repo: Repository, commitTime: Instant): List { + val blocksDir = repo.path.resolve("${Repository.NOTEVC_DIR}/blocks") + if (!blocksDir.exists()) return emptyList() + + val files = mutableSetOf() + 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(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() + } +} + +data class RestoreOptions( + val commitHash: String, + val blockHash: String?, + val targetFile: String? +) + diff --git a/src/main/kotlin/io/notevc/commands/StatusCommand.kt b/src/main/kotlin/io/notevc/commands/StatusCommand.kt index 7fb4d84..0dc8467 100644 --- a/src/main/kotlin/io/notevc/commands/StatusCommand.kt +++ b/src/main/kotlin/io/notevc/commands/StatusCommand.kt @@ -2,6 +2,7 @@ package io.notevc.commands import io.notevc.core.* import io.notevc.utils.FileUtils +import io.notevc.utils.ColorUtils import io.notevc.core.Repository.Companion.NOTEVC_DIR import java.time.Instant @@ -108,35 +109,35 @@ class StatusCommand { // Modified files grouped[FileStatusType.MODIFIED]?.let { modifiedFiles -> - output.appendLine("Modified files:") + output.appendLine(ColorUtils.bold("Modified files:")) modifiedFiles.forEach { fileStatus -> - output.appendLine("― ${fileStatus.path}") + output.appendLine(" ${ColorUtils.filename(fileStatus.path)}") fileStatus.blockChanges?.forEach { change -> val symbol = when (change.type) { - BlockChangeType.MODIFIED -> "M" - BlockChangeType.ADDED -> "A" - BlockChangeType.DELETED -> "D" + BlockChangeType.MODIFIED -> ColorUtils.modified("M") + BlockChangeType.ADDED -> ColorUtils.added("+") + BlockChangeType.DELETED -> ColorUtils.deleted("-") } val heading = change.heading.replace(Regex("^#+\\s*"), "").trim() - output.appendLine("―― $symbol $heading") + output.appendLine(" $symbol ${ColorUtils.heading(heading)}") } } } // Untracked files grouped[FileStatusType.UNTRACKED]?.let { untrackedFiles -> - output.appendLine("Untracked files:") + output.appendLine(ColorUtils.bold("Untracked files:")) untrackedFiles.forEach { fileStatus -> - output.appendLine("―― ${fileStatus.path} (${fileStatus.blockCount} blocks)") + output.appendLine(" ${ColorUtils.untracked(fileStatus.path)} ${ColorUtils.dim("${fileStatus.blockCount} blocks)")}") } output.appendLine() } // Deleted files grouped[FileStatusType.DELETED]?.let { deletedFiles -> - output.appendLine("Deleted files:") + output.appendLine(ColorUtils.bold("Deleted files:")) deletedFiles.forEach { fileStatus -> - output.appendLine("―― ${fileStatus.path}") + output.appendLine(" ${ColorUtils.deleted(fileStatus.path)}") } output.appendLine() } diff --git a/src/main/kotlin/io/notevc/utils/ColorUtils.kt b/src/main/kotlin/io/notevc/utils/ColorUtils.kt new file mode 100644 index 0000000..9f49c7f --- /dev/null +++ b/src/main/kotlin/io/notevc/utils/ColorUtils.kt @@ -0,0 +1,67 @@ +package io.notevc.utils + +object ColorUtils { + // ANSI color codes + private const val RESET = "\u001B[0m" + private const val BOLD = "\u001B[1m" + private const val DIM = "\u001B[2m" + + // Colors + private const val BLACK = "\u001B[30m" + private const val RED = "\u001B[31m" + private const val GREEN = "\u001B[32m" + private const val YELLOW = "\u001B[33m" + private const val BLUE = "\u001B[34m" + private const val MAGENTA = "\u001B[35m" + private const val CYAN = "\u001B[36m" + private const val WHITE = "\u001B[37m" + + // Bright colors + private const val BRIGHT_RED = "\u001B[91m" + private const val BRIGHT_GREEN = "\u001B[92m" + private const val BRIGHT_YELLOW = "\u001B[93m" + private const val BRIGHT_BLUE = "\u001B[94m" + private const val BRIGHT_MAGENTA = "\u001B[95m" + private const val BRIGHT_CYAN = "\u001B[96m" + + // Check if colors should be enabled (disable in CI/pipes) + private val colorsEnabled = System.getenv("NO_COLOR") == null && + System.getenv("CI") == null && + System.console() != null + + // Public color functions + fun red(text: String): String = if (colorsEnabled) "$RED$text$RESET" else text + fun green(text: String): String = if (colorsEnabled) "$GREEN$text$RESET" else text + fun yellow(text: String): String = if (colorsEnabled) "$YELLOW$text$RESET" else text + fun blue(text: String): String = if (colorsEnabled) "$BLUE$text$RESET" else text + fun magenta(text: String): String = if (colorsEnabled) "$MAGENTA$text$RESET" else text + fun cyan(text: String): String = if (colorsEnabled) "$CYAN$text$RESET" else text + + fun brightRed(text: String): String = if (colorsEnabled) "$BRIGHT_RED$text$RESET" else text + fun brightGreen(text: String): String = if (colorsEnabled) "$BRIGHT_GREEN$text$RESET" else text + fun brightYellow(text: String): String = if (colorsEnabled) "$BRIGHT_YELLOW$text$RESET" else text + fun brightBlue(text: String): String = if (colorsEnabled) "$BRIGHT_BLUE$text$RESET" else text + fun brightMagenta(text: String): String = if (colorsEnabled) "$BRIGHT_MAGENTA$text$RESET" else text + fun brightCyan(text: String): String = if (colorsEnabled) "$BRIGHT_CYAN$text$RESET" else text + + fun bold(text: String): String = if (colorsEnabled) "$BOLD$text$RESET" else text + fun dim(text: String): String = if (colorsEnabled) "$DIM$text$RESET" else text + + // Semantic colors for version control + fun success(text: String): String = brightGreen(text) + fun error(text: String): String = brightRed(text) + fun warning(text: String): String = brightYellow(text) + fun info(text: String): String = brightBlue(text) + fun hash(text: String): String = yellow(text) + fun filename(text: String): String = cyan(text) + fun heading(text: String): String = brightMagenta(text) + fun author(text: String): String = green(text) + fun date(text: String): String = dim(text) + + // Status-specific colors + fun added(text: String): String = brightGreen(text) + fun modified(text: String): String = brightYellow(text) + fun deleted(text: String): String = brightRed(text) + fun untracked(text: String): String = red(text) +} +