feat(release): first release

This commit is contained in:
darwincereska
2025-11-10 11:36:50 -05:00
parent d87ba15ed1
commit cfa80de654
9 changed files with 676 additions and 22 deletions

View File

@@ -6,6 +6,7 @@ plugins {
application application
id("com.github.gmazzo.buildconfig") version "4.1.2" id("com.github.gmazzo.buildconfig") version "4.1.2"
id("com.gradleup.shadow") version "9.2.2" id("com.gradleup.shadow") version "9.2.2"
id("org.graalvm.buildtools.native") version "0.9.28"
} }
group = "io.notevc" group = "io.notevc"
@@ -34,10 +35,49 @@ application {
mainClass.set("io.notevc.NoteVCKt") 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<Test> { tasks.withType<Test> {
useJUnitPlatform() useJUnitPlatform()
} }
tasks.nativeCompile {
dependsOn(tasks.shadowJar)
}
tasks.build { tasks.build {
dependsOn(tasks.shadowJar) dependsOn(tasks.shadowJar)
} }
@@ -46,3 +86,6 @@ tasks.jar {
enabled = false enabled = false
} }
tasks.startScripts {
dependsOn(tasks.shadowJar)
}

6
notevc.rb Normal file
View File

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

View File

@@ -2,6 +2,7 @@ package io.notevc
import io.notevc.core.Repository import io.notevc.core.Repository
import io.notevc.commands.* import io.notevc.commands.*
import io.notevc.utils.ColorUtils
fun main(args: Array<String>) { fun main(args: Array<String>) {
// Args logic // Args logic
@@ -12,7 +13,18 @@ fun main(args: Array<String>) {
result.fold( result.fold(
onSuccess = { message -> println(message) }, 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<String>) {
result.fold( result.fold(
onSuccess = { output -> println(output) }, onSuccess = { output -> println(output) },
onFailure = { error -> println("Error: ${error.message}") } onFailure = { error -> println("${ColorUtils.error("Error:")} ${error.message}") }
) )
} }
@@ -33,7 +45,7 @@ fun main(args: Array<String>) {
result.fold( result.fold(
onSuccess = { output -> println(output) }, onSuccess = { output -> println(output) },
onFailure = { error -> println("Error: ${error.message}") } onFailure = { error -> println("${ColorUtils.error("Error:")} ${error.message}") }
) )
} }
@@ -41,6 +53,17 @@ fun main(args: Array<String>) {
println("notevc version ${Repository.VERSION}") 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 -> { else -> {
println("Usage: notevc init|commit|status|version") println("Usage: notevc init|commit|status|version")
} }

View File

@@ -7,6 +7,7 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.nio.file.Files import java.nio.file.Files
import java.time.Instant import java.time.Instant
import io.notevc.utils.ColorUtils
import kotlin.io.path.* import kotlin.io.path.*
class CommitCommand { class CommitCommand {
@@ -97,9 +98,9 @@ class CommitCommand {
updateRepositoryHead(repo, commitHash, timestamp, message) updateRepositoryHead(repo, commitHash, timestamp, message)
return buildString { return buildString {
appendLine("Created commit $commitHash") appendLine("${ColorUtils.success("Created commit")} ${ColorUtils.hash(commitHash)}")
appendLine("Message: $message") appendLine("${ColorUtils.bold("Message:")} $message")
appendLine("File: $relativePath (${snapshot.blocks.size} blocks)") appendLine("${ColorUtils.bold("File:")} ${ColorUtils.filename(relativePath)} ${ColorUtils.dim("(${snapshot.blocks.size} blocks)")}")
} }
} }
@@ -160,13 +161,16 @@ class CommitCommand {
updateRepositoryHead(repo, commitHash, timestamp, message) updateRepositoryHead(repo, commitHash, timestamp, message)
return buildString { return buildString {
appendLine("Created commit $commitHash") appendLine("${ColorUtils.success("Created commit")} ${ColorUtils.hash(commitHash)}")
appendLine("Message: $message") appendLine("${ColorUtils.bold("Message:")} $message")
appendLine("Files committed: ${changedFiles.size}") appendLine("${ColorUtils.bold("Files committed:")} ${changedFiles.size}")
appendLine("Total blocks: $totalBlocksStored") appendLine("${ColorUtils.bold("Total blocks:")} $totalBlocksStored")
appendLine() appendLine()
changedFiles.forEach { fileInfo -> 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)}")
} }
} }
} }

View File

@@ -2,6 +2,7 @@ package io.notevc.commands
import io.notevc.core.Repository import io.notevc.core.Repository
import java.nio.file.Path import java.nio.file.Path
import io.notevc.utils.ColorUtils
class InitCommand { class InitCommand {
fun execute(path: String?): Result<String> { fun execute(path: String?): Result<String> {
@@ -13,7 +14,7 @@ class InitCommand {
repo.init().fold( repo.init().fold(
onSuccess = { onSuccess = {
val absolutePath = repo.path.toAbsolutePath().toString() 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 = { onFailure = {
error -> Result.failure(error) error -> Result.failure(error)

View File

@@ -1 +1,262 @@
package io.notevc.commands 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<String>): Result<String> {
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<String>): 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<List<CommitEntry>>(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<CommitEntry>, options: LogOptions): List<CommitEntry> {
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<CommitEntry>, 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<CommitEntry>, 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<String, List<BlockState>>()
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<BlockSnapshot> {
val blocksDir = repo.path.resolve("${Repository.NOTEVC_DIR}/blocks")
if (!blocksDir.exists()) return emptyList()
val snapshots = mutableListOf<BlockSnapshot>()
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<BlockSnapshot>(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<String, List<BlockState>>
)

View File

@@ -1 +1,249 @@
package io.notevc.commands 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<String>): Result<String> {
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<String>): 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<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()
}
}
data class RestoreOptions(
val commitHash: String,
val blockHash: String?,
val targetFile: String?
)

View File

@@ -2,6 +2,7 @@ package io.notevc.commands
import io.notevc.core.* import io.notevc.core.*
import io.notevc.utils.FileUtils import io.notevc.utils.FileUtils
import io.notevc.utils.ColorUtils
import io.notevc.core.Repository.Companion.NOTEVC_DIR import io.notevc.core.Repository.Companion.NOTEVC_DIR
import java.time.Instant import java.time.Instant
@@ -108,35 +109,35 @@ class StatusCommand {
// Modified files // Modified files
grouped[FileStatusType.MODIFIED]?.let { modifiedFiles -> grouped[FileStatusType.MODIFIED]?.let { modifiedFiles ->
output.appendLine("Modified files:") output.appendLine(ColorUtils.bold("Modified files:"))
modifiedFiles.forEach { fileStatus -> modifiedFiles.forEach { fileStatus ->
output.appendLine(" ${fileStatus.path}") output.appendLine(" ${ColorUtils.filename(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 -> ColorUtils.modified("M")
BlockChangeType.ADDED -> "A" BlockChangeType.ADDED -> ColorUtils.added("+")
BlockChangeType.DELETED -> "D" BlockChangeType.DELETED -> ColorUtils.deleted("-")
} }
val heading = change.heading.replace(Regex("^#+\\s*"), "").trim() val heading = change.heading.replace(Regex("^#+\\s*"), "").trim()
output.appendLine("―― $symbol $heading") output.appendLine(" $symbol ${ColorUtils.heading(heading)}")
} }
} }
} }
// Untracked files // Untracked files
grouped[FileStatusType.UNTRACKED]?.let { untrackedFiles -> grouped[FileStatusType.UNTRACKED]?.let { untrackedFiles ->
output.appendLine("Untracked files:") output.appendLine(ColorUtils.bold("Untracked files:"))
untrackedFiles.forEach { fileStatus -> untrackedFiles.forEach { fileStatus ->
output.appendLine("―― ${fileStatus.path} (${fileStatus.blockCount} blocks)") output.appendLine(" ${ColorUtils.untracked(fileStatus.path)} ${ColorUtils.dim("${fileStatus.blockCount} blocks)")}")
} }
output.appendLine() output.appendLine()
} }
// Deleted files // Deleted files
grouped[FileStatusType.DELETED]?.let { deletedFiles -> grouped[FileStatusType.DELETED]?.let { deletedFiles ->
output.appendLine("Deleted files:") output.appendLine(ColorUtils.bold("Deleted files:"))
deletedFiles.forEach { fileStatus -> deletedFiles.forEach { fileStatus ->
output.appendLine("―― ${fileStatus.path}") output.appendLine(" ${ColorUtils.deleted(fileStatus.path)}")
} }
output.appendLine() output.appendLine()
} }

View File

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