Compare commits

..

10 Commits

Author SHA1 Message Date
darwincereska
160a7eba06 feat: release action
Some checks failed
Compile and Release Binaries / build (macos-latest, macos-arm64) (push) Has been cancelled
Compile and Release Binaries / release (push) Has been cancelled
Compile and Release Binaries / build (ubuntu-latest, linux-x64) (push) Has been cancelled
2025-12-14 20:38:26 -05:00
8d22177084 Merge pull request 'docs: added comments' (#1) from comments into main
Reviewed-on: #1
2025-12-13 21:16:49 -05:00
darwincereska
ec8c649150 docs: added comments 2025-12-13 21:06:31 -05:00
Darwin Cereska
7d38aed1e0 Update issue templates 2025-11-24 12:13:55 -05:00
darwincereska
f15830e3e7 feat(colors): colored output; removed colorutils 2025-11-24 11:36:15 -05:00
Darwin Cereska
28f65d3886 merge(pr): pull request #1 from darwincereska
feat(help): added new cli parser and help menu
2025-11-20 16:02:07 -05:00
darwincereska
fa3aadb102 feat(help): added new cli parser and help menu 2025-11-20 15:59:39 -05:00
Darwin Cereska
e84ec0a5b5 Update release.yml 2025-11-13 19:05:43 -05:00
Darwin Cereska
6788299996 Update release.yml 2025-11-13 19:03:28 -05:00
Darwin Cereska
b551304d27 Update release.yml 2025-11-13 19:00:30 -05:00
23 changed files with 1217 additions and 740 deletions

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [MacOS, Linux]
- Arch: [arm64, x64]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -1,9 +1,10 @@
name: Build and Release Native Binaries name: Compile and Release Binaries
on: on:
push: push:
tags: tags:
- 'v*' - 'v*'
workflow_dispatch:
jobs: jobs:
build: build:
@@ -14,40 +15,40 @@ jobs:
platform: linux-x64 platform: linux-x64
- os: macos-latest - os: macos-latest
platform: macos-arm64 platform: macos-arm64
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install dependencies (Linux only)
if: matrix.os == 'ubuntu-latest'
run: "sudo apt-get update && sudo apt-get install -y libssl-dev"
- name: Setup GraalVM - name: Setup GraalVM
uses: graalvm/setup-graalvm@v1 uses: graalvm/setup-graalvm@v1
with: with:
java-version: '24' java-version: "24"
distribution: 'graalvm' distribution: "graalvm"
components: "native-image"
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Gradle - name: Build JAR
uses: gradle/actions/setup-gradle@v3 run: ./gradlew build
- name: Build project - name: Compile native binary
run: gradle build run: ./gradlew nativeCompile
- name: Build native binary - name: Install UPX (Linux only)
run: gradle nativeCompile
- name: Install UPX (Linux)
if: matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install -y upx run: sudo apt-get update && apt-get install -y upx
- name: Compress binary with UPX (Linux only) - name: Compress binary with UPX (Linux only)
if: matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-latest'
run: upx --best --lzma build/native/nativeCompile/notevc run: upx --best --lzma build/native/nativeCompile/notevc
- name: Rename binary for platform - name: Rename binary for platform
run: | run: mv build/native/nativeCompile/notevc notevc-${{ matrix.platform }}
mv build/native/nativeCompile/notevc notevc-${{ matrix.platform }}
- name: Upload binary as artifact - name: Upload binary as artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -67,8 +68,8 @@ jobs:
with: with:
path: binaries path: binaries
- name: Create Release - name: Create release
uses: softprops/action-gh-release@v1 uses: actions/gitea-release-action@v1
with: with:
files: binaries/*/* files: binaries/*/*
draft: false draft: false

4
.gitignore vendored
View File

@@ -29,3 +29,7 @@ Thumbs.db
# Other # Other
*.hprof *.hprof
**/.notevc/ **/.notevc/
!gradle/wrapper/
!gradle/wrapper/gradle-wrapper.jar
!gradle/wrapper/gradle-wrapper.properties

View File

@@ -91,9 +91,9 @@
# Utilities and Polish # Utilities and Polish
- [x] Add colored output for better UX - [x] Add colored output for better UX
- [ ] Implement proper error handling messages - [x] Implement proper error handling messages
- [ ] Add input validation for all commands - [x] Add input validation for all commands
- [ ] Create help system (`notevc --help`) - [x] Create help system (`notevc --help`)
- [x] Add version information (`notevc --version`) - [x] Add version information (`notevc --version`)
- [ ] Implement configuration file support - [ ] Implement configuration file support

View File

@@ -4,13 +4,13 @@ plugins {
kotlin("jvm") version "2.2.21" kotlin("jvm") version "2.2.21"
kotlin("plugin.serialization") version "2.2.21" kotlin("plugin.serialization") version "2.2.21"
application application
id("com.github.gmazzo.buildconfig") version "4.1.2" id("com.github.gmazzo.buildconfig") version "5.7.1"
id("com.gradleup.shadow") version "9.2.2" id("com.gradleup.shadow") version "9.2.2"
id("org.graalvm.buildtools.native") version "0.10.4" id("org.graalvm.buildtools.native") version "0.10.4"
} }
group = "org.notevc" group = "org.notevc"
version = "1.0.6" version = "1.0.8"
buildConfig { buildConfig {
buildConfigField("String", "VERSION", "\"${project.version}\"") buildConfigField("String", "VERSION", "\"${project.version}\"")
@@ -26,6 +26,7 @@ dependencies {
val junitVersion = "5.10.0" val junitVersion = "5.10.0"
implementation(kotlin("stdlib")) implementation(kotlin("stdlib"))
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("org.kargs:kargs:1.0.8")
testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion") testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junitVersion") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junitVersion")
@@ -50,16 +51,14 @@ graalvmNative {
buildArgs.addAll(listOf( buildArgs.addAll(listOf(
"--no-fallback", "--no-fallback",
"-H:+ReportExceptionStackTraces",
"-H:+UnlockExperimentalVMOptions", "-H:+UnlockExperimentalVMOptions",
"--initialize-at-build-time=kotlin", "--initialize-at-build-time=kotlin",
"--initialize-at-build-time=kotlinx", "--initialize-at-build-time=kotlinx",
"--initialize-at-build-time=io.notevc", "--initialize-at-build-time=org.notevc",
"-H:IncludeResources=.*\\.json", // "-H:IncludeResources=.*\\.json",
"-H:IncludeResources=.*\\.properties", // "-H:IncludeResources=.*\\.properties",
"-Ob", // Optimize for size "-Ob", // Optimize for size
"--gc=serial", // Use smaller GC (if you don't need G1) "--gc=epsilon", // Serial or epsilon
"--strict-image-heap"
)) ))
} }
} }
@@ -80,3 +79,4 @@ tasks.jar {
tasks.startScripts { tasks.startScripts {
dependsOn(tasks.shadowJar) dependsOn(tasks.shadowJar)
} }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
gradlew vendored Executable file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -2,104 +2,34 @@ package org.notevc
import org.notevc.core.Repository import org.notevc.core.Repository
import org.notevc.commands.* import org.notevc.commands.*
import org.notevc.utils.ColorUtils import org.kargs.*
fun main(args: Array<String>) { fun main(args: Array<String>) {
// Parse global flags first (like --no-color) val argsList = args.toMutableList()
val filteredArgs = args.filter { arg ->
when (arg) {
"--no-color" -> {
ColorUtils.disableColors()
false // Remove this arg from the list
}
else -> true // Keep this arg
}
}.toTypedArray()
// Args logic // Create argument parser
when (filteredArgs.firstOrNull()) { val parser = Parser("notevc", ParserConfig(programVersion = Repository.VERSION, colorsEnabled = true))
"init", "i" -> {
val initCommand = InitCommand()
val result = initCommand.execute(filteredArgs.getOrNull(1))
result.fold( if (argsList.remove("--no-color")) {
onSuccess = { message -> println(message) }, Colors.setGlobalColorsEnabled(false)
onFailure = { error -> println("${ColorUtils.error("Error:")} ${error.message}") } } else Colors.setGlobalColorsEnabled(true)
)
}
"log" -> { // Register subcommands
val logArgs = filteredArgs.drop(1) parser.subcommands(
val logCommand = LogCommand() StatusCommand(),
val result = logCommand.execute(logArgs) InitCommand(),
CommitCommand(),
LogCommand(),
DiffCommand(),
ShowCommand(),
RestoreCommand()
)
result.fold( // Parse arguments
onSuccess = { output -> println(output) }, try {
onFailure = { error -> println("${ColorUtils.error("Error:")} ${error.message}") } parser.parse(argsList.toTypedArray())
) } catch (e: Exception) {
} kotlin.system.exitProcess(1)
"commit" -> {
val commitArgs = filteredArgs.drop(1)
val commitCommand = CommitCommand()
val result = commitCommand.execute(commitArgs)
result.fold(
onSuccess = { output -> println(output) },
onFailure = { error -> println("${ColorUtils.error("Error:")} ${error.message}") }
)
}
"status", "st" -> {
val statusCommand = StatusCommand()
val result = statusCommand.execute()
result.fold(
onSuccess = { output -> println(output) },
onFailure = { error -> println("${ColorUtils.error("Error:")} ${error.message}") }
)
}
"version", "--version", "-v" -> {
println("notevc version ${Repository.VERSION}")
}
"restore" -> {
val restoreArgs = filteredArgs.drop(1)
val restoreCommand = RestoreCommand()
val result = restoreCommand.execute(restoreArgs)
result.fold(
onSuccess = { output -> println(output) },
onFailure = { error -> println("${ColorUtils.error("Error:")} ${error.message}") }
)
}
"diff" -> {
val diffArgs = filteredArgs.drop(1)
val diffCommand = DiffCommand()
val result = diffCommand.execute(diffArgs)
result.fold(
onSuccess = { output -> println(output) },
onFailure = { error -> println("${ColorUtils.error("Error:")} ${error.message}") }
)
}
"show" -> {
val showArgs = filteredArgs.drop(1)
val showCommand = ShowCommand()
val result = showCommand.execute(showArgs)
result.fold(
onSuccess = { output -> println(output) },
onFailure = { error -> println("${ColorUtils.error("Error:")} ${error.message}") }
)
}
else -> {
println("Usage: notevc [--no-color] init|commit|status|log|restore|diff|show|version")
println("For more info, visit: ${ColorUtils.bold("https://notevc.org")}")
}
} }
} }

View File

@@ -1 +0,0 @@
package org.notevc.cli

View File

@@ -1,85 +1,106 @@
package org.notevc.commands package org.notevc.commands
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.kargs.*
import org.notevc.core.* import org.notevc.core.*
import org.notevc.utils.FileUtils import org.notevc.utils.FileUtils
import org.notevc.utils.HashUtils import org.notevc.utils.HashUtils
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.nio.file.Files import java.nio.file.Files
import java.time.Instant import java.time.Instant
import org.notevc.utils.ColorUtils import kotlin.io.path.exists
import kotlin.io.path.*
class CommitCommand { /**
* Command for creating commits of changed files.
*
* The CommitCommand handles two main scenarios:
* 1. Committing a specific file (when --file is specified)
* 2. Committing all changed markdown files in the repository
*
* The commit process involves:
* - Parsing markdown files into blocks
* - Detecting changes by comparing with previous snapshots
* - Storing new snapshots for changed files
* - Creating a commit entry in the timeline
* - Updating repository metadata
*/
class CommitCommand : Subcommand("commit", description = "Create a commit of changed files") {
/** Optional target file to commit (if not specified, commits all changed files) */
val targetFile by Option(ArgType.readableFile(), longName = "file", shortName = "f", description = "Commit only a specific file")
fun execute(args: List<String>): Result<String> { /** Required commit message describing the changes */
return try { val message by Argument(ArgType.String, name = "message", description = "Message for commit", required = true)
val (targetFile, message) = parseArgs(args)
if (message.isBlank()) { /**
return Result.failure(Exception("Commit message cannot be empty")) * Main execution method for the commit command.
} *
* Determines whether to commit a single file or all changed files,
* then delagates to the appropiate method.
*/
override fun execute() {
val result: Result<String> = runCatching {
// Find the repository starting from current directory
val repo = Repository.find() ?: throw Exception("Not in a notevc repository. Run `notevc init` first.")
val repo = Repository.find() if (targetFile != null) {
?: return Result.failure(Exception("Not in a notevc repository. Run 'notevc init' first.")) // Commit only the specified file
createSingleFileCommit(repo, targetFile.toString(), message!!)
val commitResult = if (targetFile != null) {
createSingleFileCommit(repo, targetFile, message)
} else { } else {
createChangedFilesCommit(repo, message) // Commit all changed files
createChangedFilesCommit(repo, message!!)
} }
Result.success(commitResult)
} catch (e: Exception) {
Result.failure(e)
} }
// Display results with appropiate formatting
result.onSuccess { message -> println(message) }
result.onFailure { error -> println("${Colors.error("Error:")} ${error.message}") }
} }
private fun parseArgs(args: List<String>): Pair<String?, String> { /**
if (args.isEmpty()) { * Creates a commit for a single specified file.
return null to "" *
} * This method:
* 1. Validates the target file exists and is a markdown file
// Check for --file flag * 2. Parses the file into blocks
val fileIndex = args.indexOf("--file") * 3. Checks if the file is enabled (not disabled in frontmatter)
if (fileIndex != -1 && fileIndex + 1 < args.size) { * 4. Compares with the latest snapshot to detect changes
val targetFile = args[fileIndex + 1] * 5. Creates a new snapshot if changes are found
val messageArgs = args.filterIndexed { index, _ -> * 6. Updates repository metadata
index != fileIndex && index != fileIndex + 1 *
} * @param repo The repository to commit to
return targetFile to messageArgs.joinToString(" ") * @param targetFile Path to the file to commit
} * @param message Commit message
* @return Success message with commit details
// No --file flag, all args are the message */
return null to args.joinToString(" ")
}
private fun createSingleFileCommit(repo: Repository, targetFile: String, message: String): String { private fun createSingleFileCommit(repo: Repository, targetFile: String, message: String): String {
// Initialize storage components
val objectStore = ObjectStore(repo.path.resolve("${Repository.NOTEVC_DIR}/objects")) val objectStore = ObjectStore(repo.path.resolve("${Repository.NOTEVC_DIR}/objects"))
val blockStore = BlockStore(objectStore, repo.path.resolve("${Repository.NOTEVC_DIR}/blocks")) val blockStore = BlockStore(objectStore, repo.path.resolve("${Repository.NOTEVC_DIR}/blocks"))
val blockParser = BlockParser() val blockParser = BlockParser()
val timestamp = Instant.now() val timestamp = Instant.now()
// Resolve the target file path // Validate and resolve target file path
val filePath = repo.path.resolve(targetFile) val filePath = repo.path.resolve(targetFile)
if (!filePath.exists()) { if (!filePath.exists()) {
throw Exception("File not found: $targetFile") throw Exception("File not found: $targetFile")
} }
if (!targetFile.endsWith(".md")) { // Only markdown files are supported
if (!targetFile.toString().endsWith(".md")) {
throw Exception("Only markdown files (.md) are supported") throw Exception("Only markdown files (.md) are supported")
} }
// Convert to relative path for storage
val relativePath = repo.path.relativize(filePath).toString() val relativePath = repo.path.relativize(filePath).toString()
val content = Files.readString(filePath) val content = Files.readString(filePath)
val parsedFile = blockParser.parseFile(content, relativePath) val parsedFile = blockParser.parseFile(content, relativePath)
// Check if file is disabled // Respect the enabled flag in front matter
if (parsedFile.frontMatter?.isEnabled == false) { if (parsedFile.frontMatter?.isEnabled == false) {
throw Exception("File $targetFile is disabled (enabled: false in front matter)") throw Exception("File $targetFile is disabled (enabled: false in front matter)")
} }
// Check if file actually changed // Check if the file has actually changed since last commit
val latestSnapshot = blockStore.getLatestBlockSnapshot(relativePath) val latestSnapshot = blockStore.getLatestBlockSnapshot(relativePath)
if (latestSnapshot != null) { if (latestSnapshot != null) {
val currentSnapshot = createCurrentSnapshot(parsedFile, objectStore, timestamp) val currentSnapshot = createCurrentSnapshot(parsedFile, objectStore, timestamp)
@@ -90,17 +111,20 @@ class CommitCommand {
} }
} }
// Store blocks for this file // Store the new snapshot
val snapshot = blockStore.storeBlocks(parsedFile, timestamp) val snapshot = blockStore.storeBlocks(parsedFile, timestamp)
// Generate a short commit hash for display
val commitHash = HashUtils.sha256("$timestamp:$message:$relativePath").take(8) val commitHash = HashUtils.sha256("$timestamp:$message:$relativePath").take(8)
// Update repository metadata // Update repository metadata with new commit
updateRepositoryHead(repo, commitHash, timestamp, message) updateRepositoryHead(repo, commitHash, timestamp, message)
// Return formatted success message
return buildString { return buildString {
appendLine("${ColorUtils.success("Created commit")} ${ColorUtils.hash(commitHash)}") appendLine("${Colors.success("Created commit")} ${Colors.yellow(commitHash)}")
appendLine("${ColorUtils.bold("Message:")} $message") appendLine("${Colors.bold("Message:")} $message")
appendLine("${ColorUtils.bold("File:")} ${ColorUtils.filename(relativePath)} ${ColorUtils.dim("(${snapshot.blocks.size} blocks)")}") appendLine("${Colors.bold("File:")} ${Colors.filename(relativePath)} ${Colors.dim("(${snapshot.blocks.size} blocks)")}")
} }
} }
@@ -161,16 +185,16 @@ class CommitCommand {
updateRepositoryHead(repo, commitHash, timestamp, message) updateRepositoryHead(repo, commitHash, timestamp, message)
return buildString { return buildString {
appendLine("${ColorUtils.success("Created commit")} ${ColorUtils.hash(commitHash)}") appendLine("${Colors.success("Created commit")} ${Colors.yellow(commitHash)}")
appendLine("${ColorUtils.bold("Message:")} $message") appendLine("${Colors.bold("Message:")} $message")
appendLine("${ColorUtils.bold("Files committed:")} ${changedFiles.size}") appendLine("${Colors.bold("Files committed:")} ${changedFiles.size}")
appendLine("${ColorUtils.bold("Total blocks:")} $totalBlocksStored") appendLine("${Colors.bold("Total blocks:")} $totalBlocksStored")
appendLine() appendLine()
changedFiles.forEach { fileInfo -> changedFiles.forEach { fileInfo ->
val parts = fileInfo.split(" (") val parts = fileInfo.split(" (")
val filename = parts[0] val filename = parts[0]
val blockInfo = if (parts.size > 1) " (${parts[1]}" else "" val blockInfo = if (parts.size > 1) " (${parts[1]}" else ""
appendLine(" ${ColorUtils.filename(filename)}${ColorUtils.dim(blockInfo)}") appendLine(" ${Colors.filename(filename)}${Colors.dim(blockInfo)}")
} }
} }
} }

View File

@@ -1,85 +1,58 @@
package org.notevc.commands package org.notevc.commands
import org.notevc.core.* import org.notevc.core.*
import org.notevc.utils.ColorUtils
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 kotlin.io.path.* import kotlin.io.path.*
import org.kargs.*
class DiffCommand { class DiffCommand : Subcommand("diff", description = "Show differences between commits or working directory") {
val commitHash1 by Argument(ArgType.String, name = "commit1", description = "First commit", required = false)
val commitHash2 by Argument(ArgType.String, name = "commit2", description = "Second commit", required = false)
val targetFile by Option(ArgType.readableFile(), longName = "file", shortName = "f", description = "Show diff for specific file only")
val blockHash by Option(ArgType.String, longName = "block", shortName = "b", description = "Compare specific block only")
fun execute(args: List<String>): Result<String> { override fun execute() {
return try { val result: Result<String> = runCatching {
val options = parseArgs(args) val repo = Repository.find() ?: throw Exception("Not in a notevc repository. Run `notevc init` first.")
val repo = Repository.find() when {
?: return Result.failure(Exception("Not in a notevc repository. Run 'notevc init' first.")) blockHash != null && commitHash1 != null -> {
// Compare specific block to commit
val result = when { if (targetFile == null) {
options.blockHash != null -> { throw Exception("--file is required when using --block option")
// Compare specific block }
compareSpecificBlock(repo, options.commitHash1, options.blockHash, options.targetFile) compareSpecificBlock(repo, commitHash1, blockHash!!, targetFile!!.toString())
} }
options.commitHash1 != null && options.commitHash2 != null -> {
blockHash != null -> {
// Compare specific block in working directory
if (targetFile == null) {
throw Exception("--file is required when using --block option")
}
compareSpecificBlock(repo, null, blockHash!!, targetFile!!.toString())
}
commitHash1 != null && commitHash2 != null -> {
// Compare two commits // Compare two commits
compareCommits(repo, options.commitHash1, options.commitHash2, options.targetFile) compareCommits(repo, commitHash1!!, commitHash2!!, targetFile?.toString())
} }
options.commitHash1 != null -> {
commitHash1 != null -> {
// Compare working directory to a commit // Compare working directory to a commit
compareWorkingDirectoryToCommit(repo, options.commitHash1, options.targetFile) compareWorkingDirectoryToCommit(repo, commitHash1!!, targetFile?.toString())
} }
else -> { else -> {
// Show changes in working directory (not committed) // Show changes in working directory (not committed)
showWorkingDirectoryChanges(repo, options.targetFile) showWorkingDirectoryChanges(repo, targetFile?.toString())
} }
} }
Result.success(result)
} catch (e: Exception) {
Result.failure(e)
}
}
private fun parseArgs(args: List<String>): DiffOptions {
var commitHash1: String? = null
var commitHash2: String? = null
var targetFile: String? = null
var blockHash: String? = null
var i = 0
while (i < args.size) {
when {
args[i].startsWith("--file=") -> {
targetFile = args[i].substring(7)
i++
}
args[i] == "--file" && i + 1 < args.size -> {
targetFile = args[i + 1]
i += 2
}
args[i] == "--block" || args[i] == "-b" -> {
if (i + 1 < args.size && !args[i + 1].startsWith("-")) {
blockHash = args[i + 1]
i += 2
} else {
i++
}
}
!args[i].startsWith("-") -> {
// This is a commit hash
if (commitHash1 == null) {
commitHash1 = args[i]
} else if (commitHash2 == null) {
commitHash2 = args[i]
}
i++
}
else -> i++
}
} }
return DiffOptions(commitHash1, commitHash2, targetFile, blockHash) result.onSuccess { message -> println(message) }
result.onFailure { error -> println("${Colors.error("Error:")} ${error.message}") }
} }
private fun compareSpecificBlock(repo: Repository, commitHash: String?, blockHash: String, targetFile: String?): String { private fun compareSpecificBlock(repo: Repository, commitHash: String?, blockHash: String, targetFile: String?): String {
@@ -92,13 +65,13 @@ class DiffCommand {
} }
val result = StringBuilder() val result = StringBuilder()
result.appendLine("${ColorUtils.bold("Block comparison:")} ${ColorUtils.hash(blockHash.take(8))}") result.appendLine("${Colors.bold("Block comparison:")} ${Colors.yellow(blockHash.take(8))}")
result.appendLine() result.appendLine()
// Get the commit snapshot if provided, otherwise use latest // Get the commit snapshot if provided, otherwise use latest
val commitSnapshot = if (commitHash != null) { val commitSnapshot = if (commitHash != null) {
val commit = findCommit(repo, commitHash) val commit = findCommit(repo, commitHash)
?: throw Exception("Commit ${ColorUtils.hash(commitHash)} not found") ?: throw Exception("Commit ${Colors.yellow(commitHash)} not found")
val commitTime = Instant.parse(commit.timestamp) val commitTime = Instant.parse(commit.timestamp)
blockStore.getLatestBlockSnapshotBefore(targetFile, commitTime) blockStore.getLatestBlockSnapshotBefore(targetFile, commitTime)
} else { } else {
@@ -120,41 +93,41 @@ class DiffCommand {
val newBlock = currentSnapshot.blocks.find { it.id.startsWith(blockHash) } val newBlock = currentSnapshot.blocks.find { it.id.startsWith(blockHash) }
if (oldBlock == null && newBlock == null) { if (oldBlock == null && newBlock == null) {
throw Exception("Block ${ColorUtils.hash(blockHash)} not found") throw Exception("Block ${Colors.yellow(blockHash)} not found")
} }
val headingText = (newBlock?.heading ?: oldBlock?.heading ?: "").replace(Regex("^#+\\s*"), "").trim() val headingText = (newBlock?.heading ?: oldBlock?.heading ?: "").replace(Regex("^#+\\s*"), "").trim()
result.appendLine("${ColorUtils.heading(headingText)} ${ColorUtils.dim("[${blockHash.take(8)}]")}") result.appendLine("${Colors.heading(headingText)} ${Colors.dim("[${blockHash.take(8)}]")}")
result.appendLine("${ColorUtils.dim("─".repeat(70))}") result.appendLine(Colors.dim("".repeat(70)))
result.appendLine() result.appendLine()
when { when {
oldBlock == null && newBlock != null -> { oldBlock == null && newBlock != null -> {
result.appendLine("${ColorUtils.success("This block was ADDED")}") result.appendLine(Colors.green("This block was ADDED"))
result.appendLine() result.appendLine()
val newContent = objectStore.getContent(newBlock.contentHash) val newContent = objectStore.getContent(newBlock.contentHash)
if (newContent != null) { if (newContent != null) {
newContent.lines().forEach { line -> newContent.lines().forEach { line ->
result.appendLine("${ColorUtils.success("+ ")} $line") result.appendLine("${Colors.green("+ ")} $line")
} }
} }
} }
oldBlock != null && newBlock == null -> { oldBlock != null && newBlock == null -> {
result.appendLine("${ColorUtils.error("This block was DELETED")}") result.appendLine(Colors.error("This block was DELETED"))
result.appendLine() result.appendLine()
val oldContent = objectStore.getContent(oldBlock.contentHash) val oldContent = objectStore.getContent(oldBlock.contentHash)
if (oldContent != null) { if (oldContent != null) {
oldContent.lines().forEach { line -> oldContent.lines().forEach { line ->
result.appendLine("${ColorUtils.error("- ")} $line") result.appendLine("${Colors.error("- ")} $line")
} }
} }
} }
oldBlock != null && newBlock != null -> { oldBlock != null && newBlock != null -> {
if (oldBlock.contentHash == newBlock.contentHash) { if (oldBlock.contentHash == newBlock.contentHash) {
result.appendLine("${ColorUtils.dim("No changes")}") result.appendLine(Colors.dim("No changes"))
} else { } else {
result.appendLine("${ColorUtils.warning("Block was MODIFIED")}") result.appendLine(Colors.warn("Block was MODIFIED"))
result.appendLine() result.appendLine()
val oldContent = objectStore.getContent(oldBlock.contentHash) val oldContent = objectStore.getContent(oldBlock.contentHash)
val newContent = objectStore.getContent(newBlock.contentHash) val newContent = objectStore.getContent(newBlock.contentHash)
@@ -178,9 +151,9 @@ class DiffCommand {
// Find commits // Find commits
val commit1 = findCommit(repo, hash1) val commit1 = findCommit(repo, hash1)
?: throw Exception("Commit ${ColorUtils.hash(hash1)} not found") ?: throw Exception("Commit ${Colors.yellow(hash1)} not found")
val commit2 = findCommit(repo, hash2) val commit2 = findCommit(repo, hash2)
?: throw Exception("Commit ${ColorUtils.hash(hash2)} not found") ?: throw Exception("Commit ${Colors.yellow(hash2)} not found")
val time1 = Instant.parse(commit1.timestamp) val time1 = Instant.parse(commit1.timestamp)
val time2 = Instant.parse(commit2.timestamp) val time2 = Instant.parse(commit2.timestamp)
@@ -196,9 +169,9 @@ class DiffCommand {
} }
val result = StringBuilder() val result = StringBuilder()
result.appendLine("${ColorUtils.bold("Comparing commits:")}") result.appendLine(Colors.bold("Comparing commits:"))
result.appendLine(" ${ColorUtils.hash(hash1.take(8))} ${ColorUtils.dim(commit1.message)}") result.appendLine(" ${Colors.yellow(hash1.take(8))} ${Colors.dim(commit1.message)}")
result.appendLine(" ${ColorUtils.hash(hash2.take(8))} ${ColorUtils.dim(commit2.message)}") result.appendLine(" ${Colors.yellow(hash2.take(8))} ${Colors.dim(commit2.message)}")
result.appendLine() result.appendLine()
var totalChanges = 0 var totalChanges = 0
@@ -210,7 +183,7 @@ class DiffCommand {
if (snapshot1 != null || snapshot2 != null) { if (snapshot1 != null || snapshot2 != null) {
val changes = blockStore.compareBlocks(snapshot1, snapshot2) val changes = blockStore.compareBlocks(snapshot1, snapshot2)
if (changes.isNotEmpty()) { if (changes.isNotEmpty()) {
result.appendLine("${ColorUtils.filename(filePath)}:") result.appendLine("${Colors.filename(filePath)}:")
result.append(formatBlockChanges(changes, objectStore)) result.append(formatBlockChanges(changes, objectStore))
result.appendLine() result.appendLine()
totalChanges += changes.size totalChanges += changes.size
@@ -219,9 +192,9 @@ class DiffCommand {
} }
if (totalChanges == 0) { if (totalChanges == 0) {
result.appendLine("${ColorUtils.dim("No differences found")}") result.appendLine(Colors.dim("No differences found"))
} else { } else {
result.appendLine("${ColorUtils.bold("Total changes:")} $totalChanges") result.appendLine("${Colors.bold("Total changes:")} $totalChanges")
} }
return result.toString() return result.toString()
@@ -234,7 +207,7 @@ class DiffCommand {
// Find commit // Find commit
val commit = findCommit(repo, hash) val commit = findCommit(repo, hash)
?: throw Exception("Commit ${ColorUtils.hash(hash)} not found") ?: throw Exception("Commit ${Colors.yellow(hash)} not found")
val commitTime = Instant.parse(commit.timestamp) val commitTime = Instant.parse(commit.timestamp)
@@ -251,8 +224,8 @@ class DiffCommand {
} }
val result = StringBuilder() val result = StringBuilder()
result.appendLine("${ColorUtils.bold("Comparing working directory to commit:")}") result.appendLine(Colors.bold("Comparing working directory to commit:"))
result.appendLine(" ${ColorUtils.hash(hash.take(8))} ${ColorUtils.dim(commit.message)}") result.appendLine(" ${Colors.yellow(hash.take(8))} ${Colors.lightGray(commit.message)}")
result.appendLine() result.appendLine()
var totalChanges = 0 var totalChanges = 0
@@ -273,7 +246,7 @@ class DiffCommand {
val changes = blockStore.compareBlocks(commitSnapshot, currentSnapshot) val changes = blockStore.compareBlocks(commitSnapshot, currentSnapshot)
if (changes.isNotEmpty()) { if (changes.isNotEmpty()) {
result.appendLine("${ColorUtils.filename(filePath)}:") result.appendLine("${Colors.filename(filePath)}:")
result.append(formatBlockChanges(changes, objectStore)) result.append(formatBlockChanges(changes, objectStore))
result.appendLine() result.appendLine()
totalChanges += changes.size totalChanges += changes.size
@@ -282,9 +255,9 @@ class DiffCommand {
} }
if (totalChanges == 0) { if (totalChanges == 0) {
result.appendLine("${ColorUtils.dim("No differences found")}") result.appendLine(Colors.dim("No differences found"))
} else { } else {
result.appendLine("${ColorUtils.bold("Total changes:")} $totalChanges") result.appendLine("${Colors.bold("Total changes:")} $totalChanges")
} }
return result.toString() return result.toString()
@@ -308,7 +281,7 @@ class DiffCommand {
} }
val result = StringBuilder() val result = StringBuilder()
result.appendLine("${ColorUtils.bold("Changes in working directory:")}") result.appendLine(Colors.bold("Changes in working directory:"))
result.appendLine() result.appendLine()
var totalChanges = 0 var totalChanges = 0
@@ -320,16 +293,14 @@ class DiffCommand {
val parsedFile = blockParser.parseFile(content, filePath) val parsedFile = blockParser.parseFile(content, filePath)
// Skip disabled files // Skip disabled files
if (parsedFile.frontMatter?.isEnabled == false) { if (parsedFile.frontMatter?.isEnabled == false) return@forEach
return@forEach
}
val latestSnapshot = blockStore.getLatestBlockSnapshot(filePath) val latestSnapshot = blockStore.getLatestBlockSnapshot(filePath)
val currentSnapshot = createCurrentSnapshot(parsedFile, objectStore) val currentSnapshot = createCurrentSnapshot(parsedFile, objectStore)
val changes = blockStore.compareBlocks(latestSnapshot, currentSnapshot) val changes = blockStore.compareBlocks(latestSnapshot, currentSnapshot)
if (changes.isNotEmpty()) { if (changes.isNotEmpty()) {
result.appendLine("${ColorUtils.filename(filePath)}:") result.appendLine("${Colors.filename(filePath)}:")
result.append(formatBlockChanges(changes, objectStore)) result.append(formatBlockChanges(changes, objectStore))
result.appendLine() result.appendLine()
totalChanges += changes.size totalChanges += changes.size
@@ -338,9 +309,9 @@ class DiffCommand {
} }
if (totalChanges == 0) { if (totalChanges == 0) {
result.appendLine("${ColorUtils.dim("No changes detected - working directory clean")}") result.appendLine(Colors.lightGray("No changes detected - working directory clean"))
} else { } else {
result.appendLine("${ColorUtils.bold("Total changes:")} $totalChanges") result.appendLine("${Colors.bold("Total changes:")} $totalChanges")
} }
return result.toString() return result.toString()
@@ -356,42 +327,42 @@ class DiffCommand {
when (change.type) { when (change.type) {
BlockChangeType.ADDED -> { BlockChangeType.ADDED -> {
result.appendLine() result.appendLine()
result.appendLine(" ${ColorUtils.success("+++")} ${ColorUtils.bold("ADDED")} ${ColorUtils.success("+++")} ${ColorUtils.heading(headingText)} ${ColorUtils.dim("[$blockId]")}") result.appendLine(" ${Colors.green("+++")} ${Colors.bold("ADDED")} ${Colors.green("+++")} ${Colors.heading(headingText)} ${Colors.dim("[$blockId]")}")
result.appendLine(" ${ColorUtils.dim("─".repeat(60))}") result.appendLine(" ${Colors.dim("─".repeat(60))}")
if (change.newHash != null) { if (change.newHash != null) {
val content = objectStore.getContent(change.newHash) val content = objectStore.getContent(change.newHash)
if (content != null) { if (content != null) {
content.lines().take(5).forEach { line -> content.lines().take(5).forEach { line ->
result.appendLine(" ${ColorUtils.success("+")} $line") result.appendLine(" ${Colors.green("+")} $line")
} }
if (content.lines().size > 5) { if (content.lines().size > 5) {
result.appendLine(" ${ColorUtils.dim(" ... ${content.lines().size - 5} more lines")}") result.appendLine(" ${Colors.dim(" ... ${content.lines().size - 5} more lines")}")
} }
} }
} }
} }
BlockChangeType.DELETED -> { BlockChangeType.DELETED -> {
result.appendLine() result.appendLine()
result.appendLine(" ${ColorUtils.error("---")} ${ColorUtils.bold("DELETED")} ${ColorUtils.error("---")} ${ColorUtils.heading(headingText)} ${ColorUtils.dim("[$blockId]")}") result.appendLine(" ${Colors.error("---")} ${Colors.bold("DELETED")} ${Colors.error("---")} ${Colors.heading(headingText)} ${Colors.dim("[$blockId]")}")
result.appendLine(" ${ColorUtils.dim("─".repeat(60))}") result.appendLine(" ${Colors.dim("─".repeat(60))}")
if (change.oldHash != null) { if (change.oldHash != null) {
val content = objectStore.getContent(change.oldHash) val content = objectStore.getContent(change.oldHash)
if (content != null) { if (content != null) {
content.lines().take(5).forEach { line -> content.lines().take(5).forEach { line ->
result.appendLine(" ${ColorUtils.error("-")} $line") result.appendLine(" ${Colors.error("-")} $line")
} }
if (content.lines().size > 5) { if (content.lines().size > 5) {
result.appendLine(" ${ColorUtils.dim(" ... ${content.lines().size - 5} more lines")}") result.appendLine(" ${Colors.dim(" ... ${content.lines().size - 5} more lines")}")
} }
} }
} }
} }
BlockChangeType.MODIFIED -> { BlockChangeType.MODIFIED -> {
result.appendLine() result.appendLine()
result.appendLine(" ${ColorUtils.warning("~~~")} ${ColorUtils.bold("MODIFIED")} ${ColorUtils.warning("~~~")} ${ColorUtils.heading(headingText)} ${ColorUtils.dim("[$blockId]")}") result.appendLine(" ${Colors.warn("~~~")} ${Colors.bold("MODIFIED")} ${Colors.warn("~~~")} ${Colors.heading(headingText)} ${Colors.dim("[$blockId]")}")
result.appendLine(" ${ColorUtils.dim("─".repeat(60))}") result.appendLine(" ${Colors.dim("─".repeat(60))}")
// Show detailed diff // Show detailed diff
if (change.oldHash != null && change.newHash != null) { if (change.oldHash != null && change.newHash != null) {
@@ -431,27 +402,27 @@ class DiffCommand {
when { when {
oldLine == null && newLine != null -> { oldLine == null && newLine != null -> {
// Addition // Addition
diff.add("${ColorUtils.success("+ ")} $newLine") diff.add("${Colors.green("+ ")} $newLine")
newIndex++ newIndex++
displayedLines++ displayedLines++
} }
oldLine != null && newLine == null -> { oldLine != null && newLine == null -> {
// Deletion // Deletion
diff.add("${ColorUtils.error("- ")} $oldLine") diff.add("${Colors.boldRed("- ")} $oldLine")
oldIndex++ oldIndex++
displayedLines++ displayedLines++
} }
oldLine == newLine -> { oldLine == newLine -> {
// Unchanged line (context) // Unchanged line (context)
diff.add("${ColorUtils.dim(" ")} ${ColorUtils.dim(oldLine ?: "")}") diff.add("${Colors.dim(" ")} ${Colors.dimWhite(oldLine ?: "")}")
oldIndex++ oldIndex++
newIndex++ newIndex++
displayedLines++ displayedLines++
} }
else -> { else -> {
// Modified line // Modified line
diff.add("${ColorUtils.error("- ")} $oldLine") diff.add("${Colors.boldRed("- ")} $oldLine")
diff.add("${ColorUtils.success("+ ")} $newLine") diff.add("${Colors.green("+ ")} $newLine")
oldIndex++ oldIndex++
newIndex++ newIndex++
displayedLines += 2 displayedLines += 2
@@ -461,7 +432,7 @@ class DiffCommand {
val remainingLines = (oldLines.size - oldIndex) + (newLines.size - newIndex) val remainingLines = (oldLines.size - oldIndex) + (newLines.size - newIndex)
if (remainingLines > 0) { if (remainingLines > 0) {
diff.add("${ColorUtils.dim(" ... $remainingLines more lines")}") diff.add(Colors.dim(" ... $remainingLines more lines"))
} }
return diff return diff

View File

@@ -2,27 +2,27 @@ package org.notevc.commands
import org.notevc.core.Repository import org.notevc.core.Repository
import java.nio.file.Path import java.nio.file.Path
import org.notevc.utils.ColorUtils import org.kargs.*
class InitCommand { class InitCommand : Subcommand("init", description = "Initialize a repository", aliases = listOf("i")) {
fun execute(path: String?): Result<String> { val path by Argument(ArgType.existingDirectory(), "path", description = "Initialize in a specified directory", required = false)
return try {
override fun execute() {
val result: Result<String> = runCatching {
val repo = if (path != null) { val repo = if (path != null) {
Repository.at(path).getOrElse { return Result.failure(it) } Repository.at(path!!.toString()).getOrElse { throw Exception(it) }
} else Repository.current() } else Repository.current()
repo.init().fold( repo.init().fold(
onSuccess = { onSuccess = {
val absolutePath = repo.path.toAbsolutePath().toString() val absolutePath = repo.path.toAbsolutePath().toString()
Result.success("${ColorUtils.success("Initialized notevc repository")} in ${ColorUtils.filename(repo.path.toAbsolutePath().toString())}") "Initialized notevc repository in ${Colors.filename(absolutePath)}"
}, },
onFailure = { onFailure = { error -> throw Exception(error) }
error -> Result.failure(error)
}
) )
} }
catch (e: Exception) {
Result.failure(e) result.onSuccess { message -> println(message) }
} result.onFailure { error -> println("${Colors.error("Error:")} ${error.message}") }
} }
} }

View File

@@ -4,71 +4,29 @@ import org.notevc.core.*
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 org.notevc.utils.ColorUtils
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.io.path.* import kotlin.io.path.*
import org.kargs.*
class LogCommand { class LogCommand : Subcommand("log", description = "Show commit history with details") {
val maxCount by Option(ArgType.intRange(1, 100), longName = "max-count", shortName = "n", description = "Limit the number of commits shown")
val since by Option(ArgType.String, longName = "since", shortName = "s", description = "Show commits since specified time (e.g. 1h, 2d, 1w)")
val oneline by Flag(longName = "oneline", "o", description = "Show compact one-line format")
val file by OptionalOption(longName = "file", shortName = "f", description = "Show specific file and block details for each commit")
fun execute(args: List<String>): Result<String> { override fun execute() {
return try { val result: Result<String> = runCatching {
val repo = Repository.find() val repo = Repository.find() ?: throw Exception("Not in a notevc repository. Run `notevc init` first.")
?: return Result.failure(Exception("Not in a notevc repository. Run 'notevc init' first."))
val options = parseArgs(args) val targetFile: String? = if (file == "true") null else file
val logOutput = generateLog(repo, options)
Result.success(logOutput) val options = LogOptions(maxCount, since, oneline ?: false, file == "true" || targetFile != null, targetFile)
} catch (e: Exception) { generateLog(repo, options)
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 && !args[i + 1].startsWith("-")) {
maxCount = args[i + 1].toIntOrNull()
i += 2
} else {
i++
}
}
"--since" -> {
if (i + 1 < args.size && !args[i + 1].startsWith("-")) {
since = args[i + 1]
i += 2
} else {
i++
}
}
"--oneline" -> {
oneline = true
i++
}
"--file", "-f" -> {
showFiles = true
if (i + 1 < args.size && !args[i + 1].startsWith("-")) {
targetFile = args[i + 1]
i += 2
} else {
i++
}
}
else -> i++
}
} }
return LogOptions(maxCount, since, oneline, showFiles, targetFile) result.onSuccess { message -> println(message) }
result.onFailure { error -> println("${Colors.error("Error:")} ${error.message}") }
} }
private fun generateLog(repo: Repository, options: LogOptions): String { private fun generateLog(repo: Repository, options: LogOptions): String {
@@ -152,10 +110,10 @@ class LogCommand {
return commits.joinToString("\n") { commit -> return commits.joinToString("\n") { commit ->
val fileInfo = if (options.showFiles) { val fileInfo = if (options.showFiles) {
val stats = getCommitStats(repo, commit, options.targetFile) val stats = getCommitStats(repo, commit, options.targetFile)
ColorUtils.dim(" (${stats.filesChanged} files, ${stats.totalBlocks} blocks)") Colors.dim(" (${stats.filesChanged} files, ${stats.totalBlocks} blocks)")
} else "" } else ""
"${ColorUtils.hash(commit.hash)} ${commit.message}$fileInfo" "${Colors.yellow(commit.hash)} ${commit.message}$fileInfo"
} }
} }
@@ -168,22 +126,23 @@ class LogCommand {
val formattedDate = formatter.format(timestamp) val formattedDate = formatter.format(timestamp)
buildString { buildString {
appendLine("${ColorUtils.bold("commit")} ${ColorUtils.hash(commit.hash)}") appendLine("${Colors.bold("commit")} ${Colors.yellow(commit.hash)}")
appendLine("${ColorUtils.bold("Author:")} ${ColorUtils.author(commit.author)}") appendLine("${Colors.bold("Author:")} ${Colors.green(commit.author)}")
appendLine("${ColorUtils.bold("Date:")} ${ColorUtils.date(formattedDate)}") appendLine("${Colors.bold("Date:")} ${Colors.dim(formattedDate)}")
if (options.showFiles) { if (options.showFiles) {
val stats = getCommitStats(repo, commit, options.targetFile) val stats = getCommitStats(repo, commit, options.targetFile)
appendLine("${ColorUtils.info("Files changed:")} ${stats.filesChanged}, ${ColorUtils.info("Total blocks:")} ${stats.totalBlocks}") appendLine("${Colors.info("Files changed:")} ${stats.filesChanged}, ${Colors.info("Total blocks:")} ${stats.totalBlocks}")
if (stats.fileDetails.isNotEmpty()) { if (stats.fileDetails.isNotEmpty()) {
appendLine() appendLine()
stats.fileDetails.forEach { (file, blocks) -> stats.fileDetails.forEach { (file, blocks) ->
appendLine(" ${ColorUtils.filename(file)} ${ColorUtils.dim("(${blocks.size} blocks)")}") appendLine(" ${Colors.filename(file)} ${Colors.dim("(${blocks.size} blocks)")}")
blocks.forEach { block -> blocks.forEach { block ->
val heading = block.heading.replace(Regex("^#+\\s*"), "").trim() val heading = block.heading.replace(Regex("^#+\\s*"), "").trim()
appendLine(" - ${ColorUtils.hash(block.id.take(8))}: ${ColorUtils.heading(heading)}") appendLine(" - ${Colors.yellow(block.id.take(8))}: ${Colors.magenta(heading)}")
} }
appendLine()
} }
} }
} }

View File

@@ -1,72 +1,40 @@
package org.notevc.commands package org.notevc.commands
import org.notevc.core.* import org.notevc.core.*
import org.notevc.utils.ColorUtils
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 kotlin.io.path.* import kotlin.io.path.*
import org.kargs.*
class RestoreCommand { class RestoreCommand : Subcommand("restore", description = "Restore files or blocks from a specific commit") {
val blockHash by Option(ArgType.String, longName = "block", shortName = "b", description = "Restore specific block only")
val commitHash by Argument(ArgType.String, name = "commit-hash", description = "Commit to restore from", required = true)
val targetFile by Argument(ArgType.writableFile(), name = "file", description = "Specific file to restore to", required = false)
fun execute(args: List<String>): Result<String> { override fun execute() {
return try { val result: Result<String> = runCatching {
val options = parseArgs(args) val options = RestoreOptions(commitHash!!, blockHash, targetFile?.toString())
if (options.commitHash.isBlank()) { val repo = Repository.find() ?: throw Exception("Not in a notevc repository. Run `notevc init` first.")
return Result.failure(Exception("Commit hash is required"))
}
val repo = Repository.find() when {
?: return Result.failure(Exception("Not in a notevc repository. Run 'notevc init' first."))
val result = when {
options.blockHash != null && options.targetFile != null -> { options.blockHash != null && options.targetFile != null -> {
restoreSpecificBlock(repo, options.commitHash, options.blockHash, options.targetFile) restoreSpecificBlock(repo, options.blockHash, options.blockHash, options.targetFile)
} }
options.targetFile != null -> { options.targetFile != null -> {
restoreSpecificFile(repo, options.commitHash, options.targetFile) restoreSpecificFile(repo, options.commitHash, options.targetFile)
} }
else -> { else -> {
restoreEntireRepository(repo, options.commitHash) 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] result.onSuccess { message -> println(message) }
var blockHash: String? = null result.onFailure { error -> println("${Colors.error("Error:")} ${error.message}") }
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 { private fun restoreSpecificBlock(repo: Repository, commitHash: String, blockHash: String, targetFile: String): String {
@@ -76,21 +44,21 @@ class RestoreCommand {
// Find the commit // Find the commit
val commit = findCommit(repo, commitHash) val commit = findCommit(repo, commitHash)
?: throw Exception("Commit ${ColorUtils.hash(commitHash)} not found") ?: throw Exception("Commit ${Colors.yellow(commitHash)} not found")
// Find the block snapshot for this file at the commit time // Find the block snapshot for this file at the commit time
val commitTime = Instant.parse(commit.timestamp) val commitTime = Instant.parse(commit.timestamp)
val snapshot = blockStore.getBlocksAtTime(targetFile, commitTime) val snapshot = blockStore.getBlocksAtTime(targetFile, commitTime)
?: throw Exception("No snapshot found for ${ColorUtils.filename(targetFile)} at commit ${ColorUtils.hash(commitHash)}") ?: throw Exception("No snapshot found for ${Colors.filename(targetFile)} at commit ${Colors.yellow(commitHash)}")
// Find the specific block // Find the specific block
val targetBlock = snapshot.find { it.id.startsWith(blockHash) } 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)}") ?: throw Exception("Block ${Colors.yellow(blockHash)} not found in ${Colors.filename(targetFile)} at commit ${Colors.yellow(commitHash)}")
// Read current file // Read current file
val filePath = repo.path.resolve(targetFile) val filePath = repo.path.resolve(targetFile)
if (!filePath.exists()) { if (!filePath.exists()) {
throw Exception("File ${ColorUtils.filename(targetFile)} does not exist") throw Exception("File ${Colors.filename(targetFile)} does not exist")
} }
val currentContent = Files.readString(filePath) val currentContent = Files.readString(filePath)
@@ -99,7 +67,7 @@ class RestoreCommand {
// Find the block to replace in current file // Find the block to replace in current file
val currentBlockIndex = currentParsedFile.blocks.indexOfFirst { it.id.startsWith(blockHash) } val currentBlockIndex = currentParsedFile.blocks.indexOfFirst { it.id.startsWith(blockHash) }
if (currentBlockIndex == -1) { if (currentBlockIndex == -1) {
throw Exception("Block ${ColorUtils.hash(blockHash)} not found in current ${ColorUtils.filename(targetFile)}") throw Exception("Block ${Colors.yellow(blockHash)} not found in current ${Colors.filename(targetFile)}")
} }
// Replace the block // Replace the block
@@ -113,7 +81,7 @@ class RestoreCommand {
Files.writeString(filePath, restoredContent) Files.writeString(filePath, restoredContent)
val blockHeading = targetBlock.heading.replace(Regex("^#+\\s*"), "").trim() 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)}" return "${Colors.success("Restored block")} ${Colors.yellow(blockHash.take(8))} ${Colors.heading("\"$blockHeading\"")} in ${Colors.filename(targetFile)} from commit ${Colors.yellow(commitHash)}"
} }
private fun restoreSpecificFile(repo: Repository, commitHash: String, targetFile: String): String { private fun restoreSpecificFile(repo: Repository, commitHash: String, targetFile: String): String {
@@ -123,15 +91,15 @@ class RestoreCommand {
// Find the commit // Find the commit
val commit = findCommit(repo, commitHash) val commit = findCommit(repo, commitHash)
?: throw Exception("Commit ${ColorUtils.hash(commitHash)} not found") ?: throw Exception("Commit ${Colors.yellow(commitHash)} not found")
// Find the block snapshot for this file at the commit time // Find the block snapshot for this file at the commit time
val commitTime = Instant.parse(commit.timestamp) val commitTime = Instant.parse(commit.timestamp)
val snapshot = blockStore.getLatestBlockSnapshotBefore(targetFile, commitTime) val snapshot = blockStore.getLatestBlockSnapshotBefore(targetFile, commitTime)
?: throw Exception("No snapshot found for ${ColorUtils.filename(targetFile)} at commit ${ColorUtils.hash(commitHash)}") ?: throw Exception("No snapshot found for ${Colors.filename(targetFile)} at commit ${Colors.yellow(commitHash)}")
val blocks = blockStore.getBlocksAtTime(targetFile, commitTime) val blocks = blockStore.getBlocksAtTime(targetFile, commitTime)
?: throw Exception("No blocks found for ${ColorUtils.filename(targetFile)} at commit ${ColorUtils.hash(commitHash)}") ?: throw Exception("No blocks found for ${Colors.filename(targetFile)} at commit ${Colors.yellow(commitHash)}")
// Reconstruct the file from blocks with frontmatter from snapshot // Reconstruct the file from blocks with frontmatter from snapshot
val parsedFile = ParsedFile( val parsedFile = ParsedFile(
@@ -147,7 +115,7 @@ class RestoreCommand {
Files.createDirectories(filePath.parent) Files.createDirectories(filePath.parent)
Files.writeString(filePath, restoredContent) Files.writeString(filePath, restoredContent)
return "${ColorUtils.success("Restored file")} ${ColorUtils.filename(targetFile)} ${ColorUtils.dim("(${blocks.size} blocks)")} from commit ${ColorUtils.hash(commitHash)}" return "${Colors.success("Restored file")} ${Colors.filename(targetFile)} ${Colors.dim("(${blocks.size} blocks)")} from commit ${Colors.yellow(commitHash)}"
} }
private fun restoreEntireRepository(repo: Repository, commitHash: String): String { private fun restoreEntireRepository(repo: Repository, commitHash: String): String {
@@ -157,7 +125,7 @@ class RestoreCommand {
// Find the commit // Find the commit
val commit = findCommit(repo, commitHash) val commit = findCommit(repo, commitHash)
?: throw Exception("Commit ${ColorUtils.hash(commitHash)} not found") ?: throw Exception("Commit ${Colors.yellow(commitHash)} not found")
val commitTime = Instant.parse(commit.timestamp) val commitTime = Instant.parse(commit.timestamp)
@@ -165,7 +133,7 @@ class RestoreCommand {
val trackedFiles = getTrackedFilesAtCommit(repo, commitTime) val trackedFiles = getTrackedFilesAtCommit(repo, commitTime)
if (trackedFiles.isEmpty()) { if (trackedFiles.isEmpty()) {
throw Exception("No files found at commit ${ColorUtils.hash(commitHash)}") throw Exception("No files found at commit ${Colors.yellow(commitHash)}")
} }
var restoredFiles = 0 var restoredFiles = 0
@@ -193,10 +161,10 @@ class RestoreCommand {
} }
return buildString { return buildString {
appendLine("${ColorUtils.success("Restored repository")} to commit ${ColorUtils.hash(commitHash)}") appendLine("${Colors.success("Restored repository")} to commit ${Colors.yellow(commitHash)}")
appendLine("${ColorUtils.bold("Files restored:")} $restoredFiles") appendLine("${Colors.bold("Files restored:")} $restoredFiles")
appendLine("${ColorUtils.bold("Total blocks:")} $totalBlocks") appendLine("${Colors.bold("Total blocks:")} $totalBlocks")
appendLine("${ColorUtils.bold("Commit message:")} ${commit.message}") appendLine("${Colors.bold("Commit message:")} ${commit.message}")
} }
} }

View File

@@ -1,73 +1,35 @@
package org.notevc.commands package org.notevc.commands
import org.notevc.core.* import org.notevc.core.*
import org.notevc.utils.ColorUtils
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 java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.io.path.* import kotlin.io.path.*
import org.kargs.*
class ShowCommand { class ShowCommand : Subcommand("show", description = "Show detailed information about a specific commit", aliases = listOf("sh")) {
val commitHash by Argument(ArgType.String, "commit-hash", description = "Commit to show", required = true)
val targetFile by Option(ArgType.readableFile(), longName = "file", shortName = "f", description = "Show changes only for specific file")
val blockHash by Option(ArgType.String, longName = "block", shortName = "b", description = "Show specific block content")
val showContent by Flag(longName = "content", shortName = "c", description = "Show full file content at commit")
fun execute(args: List<String>): Result<String> { override fun execute() {
return try { val result: Result<String> = runCatching {
if (args.isEmpty()) { val repo = Repository.find() ?: throw Exception("Not in a notevc repository. Run `notevc init` first.")
return Result.failure(Exception("Commit hash is required. Usage: notevc show <commit-hash> [--file <file>] [--block <block>] [--content]"))
}
val options = parseArgs(args) val options = ShowOptions(commitHash!!, targetFile?.toString(), blockHash, showContent ?: false)
val repo = Repository.find() when {
?: return Result.failure(Exception("Not in a notevc repository. Run 'notevc init' first."))
val result = when {
options.blockHash != null -> showBlock(repo, options) options.blockHash != null -> showBlock(repo, options)
options.showContent -> showFileContent(repo, options) options.showContent -> showFileContent(repo, options)
else -> showCommit(repo, options.commitHash, options.targetFile) else -> showCommit(repo, options.commitHash, options.targetFile)
} }
Result.success(result)
} catch (e: Exception) {
Result.failure(e)
}
}
private fun parseArgs(args: List<String>): ShowOptions {
val commitHash = args[0]
var targetFile: String? = null
var blockHash: String? = null
var showContent = false
var i = 1
while (i < args.size) {
when {
args[i] == "--file" && i + 1 < args.size && !args[i + 1].startsWith("-") -> {
targetFile = args[i + 1]
i += 2
}
args[i].startsWith("--file=") -> {
targetFile = args[i].substring(7)
i++
}
args[i] == "--block" || args[i] == "-b" -> {
if (i + 1 < args.size && !args[i + 1].startsWith("-")) {
blockHash = args[i + 1]
i += 2
} else {
i++
}
}
args[i] == "--content" || args[i] == "-c" -> {
showContent = true
i++
}
else -> i++
}
} }
return ShowOptions(commitHash, targetFile, blockHash, showContent) result.onSuccess { message -> println(message) }
result.onFailure { error -> println("${Colors.error("Error:")} ${error.message}") }
} }
private fun showCommit(repo: Repository, commitHash: String, targetFile: String?): String { private fun showCommit(repo: Repository, commitHash: String, targetFile: String?): String {
@@ -76,7 +38,7 @@ class ShowCommand {
// Find the commit // Find the commit
val commit = findCommit(repo, commitHash) val commit = findCommit(repo, commitHash)
?: throw Exception("Commit ${ColorUtils.hash(commitHash)} not found") ?: throw Exception("Commit ${Colors.yellow(commitHash)} not found")
val commitTime = Instant.parse(commit.timestamp) val commitTime = Instant.parse(commit.timestamp)
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
@@ -85,11 +47,11 @@ class ShowCommand {
val result = StringBuilder() val result = StringBuilder()
// Show commit header // Show commit header
result.appendLine("${ColorUtils.bold("Commit:")} ${ColorUtils.hash(commit.hash)}") result.appendLine("${Colors.bold("Commit:")} ${Colors.yellow(commit.hash)}")
result.appendLine("${ColorUtils.bold("Author:")} ${commit.author}") result.appendLine("${Colors.bold("Author:")} ${commit.author}")
result.appendLine("${ColorUtils.bold("Date:")} ${formatter.format(commitTime)}") result.appendLine("${Colors.bold("Date:")} ${formatter.format(commitTime)}")
if (commit.parent != null) { if (commit.parent != null) {
result.appendLine("${ColorUtils.bold("Parent:")} ${ColorUtils.hash(commit.parent)}") result.appendLine("${Colors.bold("Parent:")} ${Colors.yellow(commit.parent)}")
} }
result.appendLine() result.appendLine()
result.appendLine(" ${commit.message}") result.appendLine(" ${commit.message}")
@@ -103,12 +65,12 @@ class ShowCommand {
} }
if (filesToShow.isEmpty()) { if (filesToShow.isEmpty()) {
result.appendLine("${ColorUtils.dim("No files found at this commit")}") result.appendLine("${Colors.dim("No files found at this commit")}")
return result.toString() return result.toString()
} }
// Show changes for each file // Show changes for each file
result.appendLine("${ColorUtils.bold("Changes:")}") result.appendLine("${Colors.bold("Changes:")}")
result.appendLine() result.appendLine()
var totalAdded = 0 var totalAdded = 0
@@ -131,7 +93,7 @@ class ShowCommand {
val changes = blockStore.compareBlocks(parentSnapshot, currentSnapshot) val changes = blockStore.compareBlocks(parentSnapshot, currentSnapshot)
if (changes.isNotEmpty()) { if (changes.isNotEmpty()) {
result.appendLine("${ColorUtils.filename(filePath)}:") result.appendLine("${Colors.filename(filePath)}:")
val added = changes.count { it.type == BlockChangeType.ADDED } val added = changes.count { it.type == BlockChangeType.ADDED }
val modified = changes.count { it.type == BlockChangeType.MODIFIED } val modified = changes.count { it.type == BlockChangeType.MODIFIED }
@@ -141,9 +103,9 @@ class ShowCommand {
totalModified += modified totalModified += modified
totalDeleted += deleted totalDeleted += deleted
if (added > 0) result.appendLine(" ${ColorUtils.success("+")} $added ${if (added == 1) "block" else "blocks"} added") if (added > 0) result.appendLine(" ${Colors.boldGreen("+")} $added ${if (added == 1) "block" else "blocks"} added")
if (modified > 0) result.appendLine(" ${ColorUtils.warning("~")} $modified ${if (modified == 1) "block" else "blocks"} modified") if (modified > 0) result.appendLine(" ${Colors.warn("~")} $modified ${if (modified == 1) "block" else "blocks"} modified")
if (deleted > 0) result.appendLine(" ${ColorUtils.error("-")} $deleted ${if (deleted == 1) "block" else "blocks"} deleted") if (deleted > 0) result.appendLine(" ${Colors.error("-")} $deleted ${if (deleted == 1) "block" else "blocks"} deleted")
result.appendLine() result.appendLine()
@@ -154,13 +116,13 @@ class ShowCommand {
when (change.type) { when (change.type) {
BlockChangeType.ADDED -> { BlockChangeType.ADDED -> {
result.appendLine(" ${ColorUtils.success("+")} ${ColorUtils.heading(headingText)} ${ColorUtils.dim("[$blockId]")}") result.appendLine(" ${Colors.boldGreen("+")} ${Colors.heading(headingText)} ${Colors.dim("[$blockId]")}")
} }
BlockChangeType.DELETED -> { BlockChangeType.DELETED -> {
result.appendLine(" ${ColorUtils.error("-")} ${ColorUtils.heading(headingText)} ${ColorUtils.dim("[$blockId]")}") result.appendLine(" ${Colors.error("-")} ${Colors.heading(headingText)} ${Colors.dim("[$blockId]")}")
} }
BlockChangeType.MODIFIED -> { BlockChangeType.MODIFIED -> {
result.appendLine(" ${ColorUtils.warning("~")} ${ColorUtils.heading(headingText)} ${ColorUtils.dim("[$blockId]")}") result.appendLine(" ${Colors.warn("~")} ${Colors.heading(headingText)} ${Colors.dim("[$blockId]")}")
} }
} }
} }
@@ -171,8 +133,8 @@ class ShowCommand {
// Summary // Summary
if (totalAdded + totalModified + totalDeleted > 0) { if (totalAdded + totalModified + totalDeleted > 0) {
result.appendLine("${ColorUtils.bold("Summary:")}") result.appendLine("${Colors.bold("Summary:")}")
result.appendLine(" ${ColorUtils.success("+")} $totalAdded added, ${ColorUtils.warning("~")} $totalModified modified, ${ColorUtils.error("-")} $totalDeleted deleted") result.appendLine(" ${Colors.boldGreen("+")} $totalAdded added, ${Colors.warn("~")} $totalModified modified, ${Colors.error("-")} $totalDeleted deleted")
} }
return result.toString() return result.toString()
@@ -227,7 +189,7 @@ class ShowCommand {
val blockStore = BlockStore(objectStore, repo.path.resolve("${Repository.NOTEVC_DIR}/blocks")) val blockStore = BlockStore(objectStore, repo.path.resolve("${Repository.NOTEVC_DIR}/blocks"))
val commit = findCommit(repo, options.commitHash) val commit = findCommit(repo, options.commitHash)
?: throw Exception("Commit ${ColorUtils.hash(options.commitHash)} not found") ?: throw Exception("Commit ${Colors.yellow(options.commitHash)} not found")
val commitTime = Instant.parse(commit.timestamp) val commitTime = Instant.parse(commit.timestamp)
@@ -239,7 +201,7 @@ class ShowCommand {
?: throw Exception("No snapshot found for ${options.targetFile} at commit ${options.commitHash}") ?: throw Exception("No snapshot found for ${options.targetFile} at commit ${options.commitHash}")
val block = snapshot.blocks.find { it.id.startsWith(options.blockHash!!) } val block = snapshot.blocks.find { it.id.startsWith(options.blockHash!!) }
?: throw Exception("Block ${ColorUtils.hash(options.blockHash!!)} not found") ?: throw Exception("Block ${Colors.yellow(options.blockHash!!)} not found")
val content = objectStore.getContent(block.contentHash) val content = objectStore.getContent(block.contentHash)
?: throw Exception("Content not found for block") ?: throw Exception("Content not found for block")
@@ -247,12 +209,12 @@ class ShowCommand {
val headingText = block.heading.replace(Regex("^#+\\s*"), "").trim() val headingText = block.heading.replace(Regex("^#+\\s*"), "").trim()
val result = StringBuilder() val result = StringBuilder()
result.appendLine("${ColorUtils.bold("Block:")} ${ColorUtils.hash(block.id.take(8))}") result.appendLine("${Colors.bold("Block:")} ${Colors.yellow(block.id.take(8))}")
result.appendLine("${ColorUtils.bold("Heading:")} ${ColorUtils.heading(headingText)}") result.appendLine("${Colors.bold("Heading:")} ${Colors.heading(headingText)}")
result.appendLine("${ColorUtils.bold("File:")} ${ColorUtils.filename(options.targetFile)}") result.appendLine("${Colors.bold("File:")} ${Colors.filename(options.targetFile)}")
result.appendLine("${ColorUtils.bold("Commit:")} ${ColorUtils.hash(commit.hash)}") result.appendLine("${Colors.bold("Commit:")} ${Colors.yellow(commit.hash)}")
result.appendLine() result.appendLine()
result.appendLine("${ColorUtils.dim("─".repeat(70))}") result.appendLine("${Colors.dim("─".repeat(70))}")
result.appendLine() result.appendLine()
result.append(content) result.append(content)
@@ -265,7 +227,7 @@ class ShowCommand {
val blockParser = BlockParser() val blockParser = BlockParser()
val commit = findCommit(repo, options.commitHash) val commit = findCommit(repo, options.commitHash)
?: throw Exception("Commit ${ColorUtils.hash(options.commitHash)} not found") ?: throw Exception("Commit ${Colors.yellow(options.commitHash)} not found")
val commitTime = Instant.parse(commit.timestamp) val commitTime = Instant.parse(commit.timestamp)
@@ -298,11 +260,11 @@ class ShowCommand {
val reconstructedContent = blockParser.reconstructFile(parsedFile) val reconstructedContent = blockParser.reconstructFile(parsedFile)
val result = StringBuilder() val result = StringBuilder()
result.appendLine("${ColorUtils.bold("File:")} ${ColorUtils.filename(options.targetFile)}") result.appendLine("${Colors.bold("File:")} ${Colors.filename(options.targetFile)}")
result.appendLine("${ColorUtils.bold("Commit:")} ${ColorUtils.hash(commit.hash)}") result.appendLine("${Colors.bold("Commit:")} ${Colors.yellow(commit.hash)}")
result.appendLine("${ColorUtils.bold("Blocks:")} ${blocks.size}") result.appendLine("${Colors.bold("Blocks:")} ${blocks.size}")
result.appendLine() result.appendLine()
result.appendLine("${ColorUtils.dim("─".repeat(70))}") result.appendLine("${Colors.dim("─".repeat(70))}")
result.appendLine() result.appendLine()
result.append(reconstructedContent) result.append(reconstructedContent)

View File

@@ -2,24 +2,25 @@ package org.notevc.commands
import org.notevc.core.* import org.notevc.core.*
import org.notevc.utils.FileUtils import org.notevc.utils.FileUtils
import org.notevc.utils.ColorUtils
import org.notevc.core.Repository.Companion.NOTEVC_DIR import org.notevc.core.Repository.Companion.NOTEVC_DIR
import java.time.Instant import java.time.Instant
import org.kargs.*
class StatusCommand { class StatusCommand : Subcommand("status", description = "Show status of tracked files", aliases = listOf("st")) {
fun execute(): Result<String> { override fun execute() {
return try { val result: Result<String> = runCatching {
val repo = Repository.find() try {
?: return Result.failure(Exception("Not in notevc repository. Run `notevc init` first.")) val repo = Repository.find() ?: throw Exception("Note in notevc repository. Run `notevc init` first.")
val status = getRepositoryStatus(repo) val status = getRepositoryStatus(repo)
val output = formatStatusOutput(status) formatStatusOutput(status)
} catch (e: Exception) {
Result.success(output) throw e
} }
catch (e: Exception) {
Result.failure(e)
} }
result.onSuccess { output -> println(output) }
result.onFailure { error -> println("${Colors.error("Error:")} ${error.message}") }
} }
private fun getRepositoryStatus(repo: Repository): RepositoryStatus { private fun getRepositoryStatus(repo: Repository): RepositoryStatus {
@@ -109,35 +110,35 @@ class StatusCommand {
// Modified files // Modified files
grouped[FileStatusType.MODIFIED]?.let { modifiedFiles -> grouped[FileStatusType.MODIFIED]?.let { modifiedFiles ->
output.appendLine(ColorUtils.bold("Modified files:")) output.appendLine(Colors.bold("Modified files:"))
modifiedFiles.forEach { fileStatus -> modifiedFiles.forEach { fileStatus ->
output.appendLine(" ${ColorUtils.filename(fileStatus.path)}") output.appendLine(" ${Colors.filename(fileStatus.path)}")
fileStatus.blockChanges?.forEach { change -> fileStatus.blockChanges?.forEach { change ->
val symbol = when (change.type) { val symbol = when (change.type) {
BlockChangeType.MODIFIED -> ColorUtils.modified("~") BlockChangeType.MODIFIED -> Colors.boldYellow("~")
BlockChangeType.ADDED -> ColorUtils.added("+") BlockChangeType.ADDED -> Colors.boldGreen("+")
BlockChangeType.DELETED -> ColorUtils.deleted("-") BlockChangeType.DELETED -> Colors.boldRed("-")
} }
val heading = change.heading.replace(Regex("^#+\\s*"), "").trim() val heading = change.heading.replace(Regex("^#+\\s*"), "").trim()
output.appendLine(" $symbol ${ColorUtils.heading(heading)}") output.appendLine(" $symbol ${Colors.heading(heading)}")
} }
} }
} }
// Untracked files // Untracked files
grouped[FileStatusType.UNTRACKED]?.let { untrackedFiles -> grouped[FileStatusType.UNTRACKED]?.let { untrackedFiles ->
output.appendLine(ColorUtils.bold("Untracked files:")) output.appendLine(Colors.bold("Untracked files:"))
untrackedFiles.forEach { fileStatus -> untrackedFiles.forEach { fileStatus ->
output.appendLine(" ${ColorUtils.untracked(fileStatus.path)} ${ColorUtils.dim("(${fileStatus.blockCount} blocks)")}") output.appendLine(" ${Colors.dimWhite(fileStatus.path)} ${Colors.dim("(${fileStatus.blockCount} blocks)")}")
} }
output.appendLine() output.appendLine()
} }
// Deleted files // Deleted files
grouped[FileStatusType.DELETED]?.let { deletedFiles -> grouped[FileStatusType.DELETED]?.let { deletedFiles ->
output.appendLine(ColorUtils.bold("Deleted files:")) output.appendLine(Colors.bold("Deleted files:"))
deletedFiles.forEach { fileStatus -> deletedFiles.forEach { fileStatus ->
output.appendLine(" ${ColorUtils.deleted(fileStatus.path)}") output.appendLine(" ${Colors.boldRed(fileStatus.path)}")
} }
output.appendLine() output.appendLine()
} }

View File

@@ -2,8 +2,27 @@ package org.notevc.core
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/**
* Parses a markdown file into structured blocks
*
* This class is responsible for breaking down markdown files into manageable blocks
* that can be individually tracked and versioned. Each block represents a section
* of content, typically around headings.
*/
class BlockParser { class BlockParser {
// Parse markdown file into blocks based on headings /**
* Parses a markdown file into structured blocks
*
* The parsing process:
* 1. Extracts YAML frontmatter if present
* 2. Splits content into blocks based on headings (lines starting with #)
* 3. Generates stable IDs for each block for tracking purposes
* 4. Handles content that appears before any heading
*
* @param content The raw markdown content to parse
* @param filePath The path of the file being parsed (used for ID generation)
* @return ParsedFile containing the structured blocks and metadata
*/
fun parseFile(content: String, filePath: String): ParsedFile { fun parseFile(content: String, filePath: String): ParsedFile {
val lines = content.lines() val lines = content.lines()
val blocks = mutableListOf<Block>() val blocks = mutableListOf<Block>()
@@ -13,6 +32,7 @@ class BlockParser {
var currentHeading: String? = null var currentHeading: String? = null
var blockIndex = 0 var blockIndex = 0
// Skip front matter lines if present
val contentLines = if (frontMatter != null) { val contentLines = if (frontMatter != null) {
lines.drop(frontMatter.endLine + 1) lines.drop(frontMatter.endLine + 1)
} else lines } else lines
@@ -20,7 +40,7 @@ class BlockParser {
for (line in contentLines) { for (line in contentLines) {
when { when {
line.startsWith("#") -> { line.startsWith("#") -> {
// Save previous block if exists // Save the previous block before starting a new one
if (currentBlock != null && currentHeading != null) { if (currentBlock != null && currentHeading != null) {
blocks.add(Block( blocks.add(Block(
id = generateBlockId(filePath, currentHeading, blockIndex), id = generateBlockId(filePath, currentHeading, blockIndex),
@@ -31,17 +51,17 @@ class BlockParser {
)) ))
} }
// Start new block // Start a new block with this heading
currentHeading = line currentHeading = line
currentBlock = mutableListOf(line) currentBlock = mutableListOf(line)
} }
else -> { else -> {
// Add to current block or create content-only block // Add content to the current block
if (currentBlock != null) currentBlock.add(line) if (currentBlock != null) currentBlock.add(line)
else { else {
// Content before any heading // Handle content that appears before any heading
currentBlock = mutableListOf() currentBlock = mutableListOf()
currentHeading = "<!-- Content -->" currentHeading = "<!-- Content -->" // Special marker for content-only blocks
currentBlock.add(line) currentBlock.add(line)
} }
} }
@@ -66,13 +86,30 @@ class BlockParser {
) )
} }
// Extract YAML front matter /**
* Extracts YAML frontmatter from the beginning of a markdown file.
*
* Front matter is expected to be in this format:
* ---
* key: value
* array_key:
* - item1
* - item2
* - item3
* ---
*
* @param lines The lines of the file to process
* @return FrontMatter object if found, null otherwise
*/
private fun extractFrontMatter(lines: List<String>): FrontMatter? { private fun extractFrontMatter(lines: List<String>): FrontMatter? {
// Front matter must start with --- on the first line
if (lines.isEmpty() || lines[0] != "---") return null if (lines.isEmpty() || lines[0] != "---") return null
// Find the closing --- delimiter
val endIndex = lines.drop(1).indexOfFirst { it == "---"} val endIndex = lines.drop(1).indexOfFirst { it == "---"}
if (endIndex == -1) return null if (endIndex == -1) return null
// Extract the YAML content between the delimiters
val yamlLines = lines.subList(1, endIndex + 1) val yamlLines = lines.subList(1, endIndex + 1)
val properties = mutableMapOf<String, String>() val properties = mutableMapOf<String, String>()
var currentKey: String? = null var currentKey: String? = null
@@ -86,8 +123,8 @@ class BlockParser {
arrayValues.add(value) arrayValues.add(value)
} }
// Handle key-value pairs // Handle key-value pairs
line.contains(":") -> { ":" in line -> {
// Save previous array if exists // Save any accumulated array values from the previous key
if (currentKey != null && arrayValues.isNotEmpty()) { if (currentKey != null && arrayValues.isNotEmpty()) {
properties[currentKey] = arrayValues.joinToString(", ") properties[currentKey] = arrayValues.joinToString(", ")
arrayValues.clear() arrayValues.clear()
@@ -101,6 +138,7 @@ class BlockParser {
// This might be an array key // This might be an array key
currentKey = key currentKey = key
} else { } else {
// Simple key-value pair
properties[key] = value properties[key] = value
currentKey = null currentKey = null
} }
@@ -115,27 +153,49 @@ class BlockParser {
return FrontMatter( return FrontMatter(
properties = properties, properties = properties,
endLine = endIndex + 1 endLine = endIndex + 1 // Track where front matter ends
) )
} }
// Generate stable block id /**
* Generates a stable, unique identifier for a block.
*
* This ID is based on the file path, heading content, and order within the file.
* This ensures that the same block will always get the same ID, enabling
* proper tracking across versions.
*
* @param filePath The path of the file containing the block
* @param heading The heading text of the block
* @param order The position of the block within the file
* @return A 12-character hash that uniquely identifies this block
*/
private fun generateBlockId(filePath: String, heading: String, order: Int): String { private fun generateBlockId(filePath: String, heading: String, order: Int): String {
// Clean the heading by removing markdown syntax
val cleanHeading = heading.replace(Regex("^#+\\s*"), "").trim() val cleanHeading = heading.replace(Regex("^#+\\s*"), "").trim()
val baseId = "$filePath:$cleanHeading:$order" val baseId = "$filePath:$cleanHeading:$order"
// Use the first 12 characters of SHA-256 hash for a compact but unique ID
return org.notevc.utils.HashUtils.sha256(baseId).take(12) return org.notevc.utils.HashUtils.sha256(baseId).take(12)
} }
// Reconstruct file from blocks /**
* Reconstructs a complete markdown file from parsed blocks.
*
* This is the inverse operation of parseFile(). It takes structured blocks
* and reassembles them into a valid markdown file, preserving the original
* format including the front matter.
*
* @param parsedFile The parsed file structure to reconstruct
* @return The complete markdown content as a string
*/
fun reconstructFile(parsedFile: ParsedFile): String { fun reconstructFile(parsedFile: ParsedFile): String {
val result = StringBuilder() val result = StringBuilder()
// Add front matter if exists // Reconstruct front matter if it exists
parsedFile.frontMatter?.let { fm -> parsedFile.frontMatter?.let { fm ->
result.appendLine("---") result.appendLine("---")
fm.properties.forEach { (key, value) -> fm.properties.forEach { (key, value) ->
// Handle tags as array // Special handling for tags - convert back to array format
if (key == "tags" && value.contains(",")) { if (key == "tags" && "," in value) {
result.appendLine("$key:") result.appendLine("$key:")
value.split(",").forEach { tag -> value.split(",").forEach { tag ->
result.appendLine(" - ${tag.trim()}") result.appendLine(" - ${tag.trim()}")
@@ -145,10 +205,10 @@ class BlockParser {
} }
} }
result.appendLine("---") result.appendLine("---")
result.appendLine() result.appendLine() // Empty line after the front matter
} }
// Add blocks in order // Reconstruct blocks in their original order
val sortedBlocks = parsedFile.blocks.sortedBy { it.order } val sortedBlocks = parsedFile.blocks.sortedBy { it.order }
sortedBlocks.forEachIndexed { index, block -> sortedBlocks.forEachIndexed { index, block ->
result.append(block.content) result.append(block.content)
@@ -162,35 +222,63 @@ class BlockParser {
} }
} }
/**
* Represents a parsed markdown file with its constuitent blocks and metadata.
*/
@Serializable @Serializable
data class ParsedFile( data class ParsedFile(
val path: String, val path: String, // File path for identification
val frontMatter: FrontMatter?, val frontMatter: FrontMatter?, // YAML front matter if present
val blocks: List<Block> val blocks: List<Block> // The content blocks
) )
/**
* Represents a single content block within a markdown file.
*
* Blocks are the fundamental unit of content tracking in the system.
* Each block corresponds to a section of the document, typically organized around headings.
*/
@Serializable @Serializable
data class Block( data class Block(
val id: String, // Stable block identifier val id: String, // Stable identifier for tracking
val heading: String, // The heading text val heading: String, // The heading text (or special marker)
val content: String, // Full block content including heading val content: String, // Full block content including heading
val type: BlockType, val type: BlockType, // Type classification
val order: Int // Order within file val order: Int // Position within the file
) )
/**
* Represents a YAML front matter found at the beginning of a markdown file.
*
* Front matter contains metadata about the document such as title, tags, and configuration options.
*/
@Serializable @Serializable
data class FrontMatter( data class FrontMatter(
val properties: Map<String, String>, val properties: Map<String, String>, // Key-value pairs from the YAML
val endLine: Int val endLine: Int // Line number where front matter ends
) { ) {
// Default to true if not specified // Convenience properties for common frontmatter fields
/** Whether this file is enabled for processing (defaults to true) */
val isEnabled: Boolean get() = properties["enabled"]?.lowercase() != "false" val isEnabled: Boolean get() = properties["enabled"]?.lowercase() != "false"
/** Whether this file should be processed automatically */
val isAutomatic: Boolean get() = properties["automatic"]?.lowercase() == "true" val isAutomatic: Boolean get() = properties["automatic"]?.lowercase() == "true"
/** The title of the document */
val title: String? get() = properties["title"] val title: String? get() = properties["title"]
/** List of tags associated with the document */
val tags: List<String> get() = properties["tags"]?.split(",")?.map { it.trim() } ?: emptyList() val tags: List<String> get() = properties["tags"]?.split(",")?.map { it.trim() } ?: emptyList()
} }
/**
* Enumeration of different types of content blocks.
*/
enum class BlockType { enum class BlockType {
HEADING_SECTION, // # Heading with content /** A section with a heading and associated content. */
CONTENT_ONLY // Content without heading HEADING_SECTION,
/** Content that appears without an associated heading */
CONTENT_ONLY
} }

View File

@@ -1,29 +1,53 @@
package org.notevc.core package org.notevc.core
import org.notevc.utils.HashUtils
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString 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.nio.file.Path import java.nio.file.Path
import java.time.Instant import java.time.Instant
import kotlin.io.path.* import kotlin.io.path.exists
import kotlin.io.path.isRegularFile
/**
* Manages storage and retrieval of block snapshots over time.
*
* The BlockStore is responsible for:
* - Storing snapshots of file blocks at specific points in time
* - Retrieving historical versions of blocks
* - Comparing blocks between different snapshots to detect changes
* - Organizing snapshots in a time-based directory structure
*
* Storage structure: .notevc/blocks/yyyy/mm/dd/blocks-HH-MM-SS-filename.json
*/
class BlockStore( class BlockStore(
private val objectStore: ObjectStore, private val objectStore: ObjectStore, // For storing actual content
private val blocksDir: Path private val blocksDir: Path // Directory for block snapshots
) { ) {
private val json = Json { prettyPrint = true } private val json = Json { prettyPrint = true }
// Store blocks from a parsed file and return block snapshot /**
* Stores blocks from a parsed file and creates a snapshot
*
* This method:
* 1. Stores the actual content of each block in the object store
* 2. Creates a snapshot containing metadata and content hashes
* 3. Saves the snapshot with a timestamp-based filename
*
* @param parsedFile The parsed file containing blocks to store
* @param timestamp When this snapshot was created
* @return BlockSnapshot representing the stored state
*/
fun storeBlocks(parsedFile: ParsedFile, timestamp: Instant): BlockSnapshot { fun storeBlocks(parsedFile: ParsedFile, timestamp: Instant): BlockSnapshot {
// Convert blocks to states with content hashes
val blockStates = parsedFile.blocks.map { block -> val blockStates = parsedFile.blocks.map { block ->
// Store the actual content and get its hash for deduplication
val contentHash = objectStore.storeContent(block.content) val contentHash = objectStore.storeContent(block.content)
BlockState( BlockState(
id = block.id, id = block.id,
heading = block.heading, heading = block.heading,
contentHash = contentHash, contentHash = contentHash, // Reference to content, not the content itself
type = block.type, type = block.type,
order = block.order order = block.order
) )
@@ -36,28 +60,53 @@ class BlockStore(
frontMatter = parsedFile.frontMatter frontMatter = parsedFile.frontMatter
) )
// Store block snapshot with time-based structure (yyyy/mm/dd) // Store the snapshot in the time-based directory structure
storeBlockSnapshot(snapshot) storeBlockSnapshot(snapshot)
return snapshot return snapshot
} }
// Get blocks for a file at a specific time /**
* Retrieves blocks for a file as they existed at a specific time
*
* @param filePath The path of the file to retrieve
* @param timestamp The point in time to retrieve blocks for
* @return List of blocks if found, null if no snapshot exists before that time
*/
fun getBlocksAtTime(filePath: String, timestamp: Instant): List<Block>? { fun getBlocksAtTime(filePath: String, timestamp: Instant): List<Block>? {
// Retrieve the latest block snapshot before the given timestamp
val snapshot = getLatestBlockSnapshotBefore(filePath, timestamp) val snapshot = getLatestBlockSnapshotBefore(filePath, timestamp)
// Use let to reconstruct blocks only if is not null
return snapshot?.let { reconstructBlocks(it) } return snapshot?.let { reconstructBlocks(it) }
} }
// Get current blocks for a file /**
* Retrieves the current (most recent) blocks for a file.
*
* @param filePath The path of the file to retrieve
* @return List of current blocks if found, null if file has no snapshots
*/
fun getCurrentBlocks(filePath: String): List<Block>? { fun getCurrentBlocks(filePath: String): List<Block>? {
val snapshot = getLatestBlockSnapshot(filePath) val snapshot = getLatestBlockSnapshot(filePath)
return snapshot?.let { reconstructBlocks(it) } return snapshot?.let { reconstructBlocks(it) }
} }
// Compare blocks between two snapshots /**
* Compares two block snapshots and identifies what changed.
*
* This method performs a detailed diff between snapshots, identifying:
* - Added blocks (present in new but not in old)
* - Modified blocks (content hash changed)
* - Deleted blocks (present in old but not new)
*
* @param oldSnapshot The previous snapshot(can be null for initial commit)
* @param newSnapshot The new snapshot to compare against
* @return List of changes between the snapshots
*/
fun compareBlocks(oldSnapshot: BlockSnapshot?, newSnapshot: BlockSnapshot?): List<BlockChange> { fun compareBlocks(oldSnapshot: BlockSnapshot?, newSnapshot: BlockSnapshot?): List<BlockChange> {
val changes = mutableListOf<BlockChange>() val changes = mutableListOf<BlockChange>()
// Create maps for efficient lookup by block ID
val oldBlocks = oldSnapshot?.blocks?.associateBy { it.id } ?: emptyMap() val oldBlocks = oldSnapshot?.blocks?.associateBy { it.id } ?: emptyMap()
val newBlocks = newSnapshot?.blocks?.associateBy { it.id } ?: emptyMap() val newBlocks = newSnapshot?.blocks?.associateBy { it.id } ?: emptyMap()
@@ -66,6 +115,7 @@ class BlockStore(
val oldBlock = oldBlocks[id] val oldBlock = oldBlocks[id]
when { when {
oldBlock == null -> { oldBlock == null -> {
// Block is new
changes.add(BlockChange( changes.add(BlockChange(
blockId = id, blockId = id,
type = BlockChangeType.ADDED, type = BlockChangeType.ADDED,
@@ -74,6 +124,7 @@ class BlockStore(
)) ))
} }
oldBlock.contentHash != newBlock.contentHash -> { oldBlock.contentHash != newBlock.contentHash -> {
// Block content changed
changes.add(BlockChange( changes.add(BlockChange(
blockId = id, blockId = id,
type = BlockChangeType.MODIFIED, type = BlockChangeType.MODIFIED,
@@ -82,6 +133,7 @@ class BlockStore(
newHash = newBlock.contentHash newHash = newBlock.contentHash
)) ))
} }
// If hashes are the same, no change needed
} }
} }
@@ -100,20 +152,36 @@ class BlockStore(
return changes return changes
} }
/**
* Stores a block snapshot to disk with time-based organization
*
* Creates directory structure: yyyy/mm/dd/blocks-HH-MM-SS-filename.json
* This organization makes it easy to find snapshots by date and time
*/
private fun storeBlockSnapshot(snapshot: BlockSnapshot) { private fun storeBlockSnapshot(snapshot: BlockSnapshot) {
val datePath = getDatePath(Instant.parse(snapshot.timestamp)) val datePath = getDatePath(Instant.parse(snapshot.timestamp))
val timeString: String = getTimeString(Instant.parse(snapshot.timestamp)) val timeString: String = getTimeString(Instant.parse(snapshot.timestamp))
// Ensure the date directory exists
Files.createDirectories(blocksDir.resolve(datePath)) Files.createDirectories(blocksDir.resolve(datePath))
// Create the filename with timestamp and sanitized file path
val filename = "blocks-$timeString-${snapshot.filePath.replace("/","_")}.json" val filename = "blocks-$timeString-${snapshot.filePath.replace("/","_")}.json"
val snapshotPath: Path = blocksDir.resolve(datePath).resolve(filename) val snapshotPath: Path = blocksDir.resolve(datePath).resolve(filename)
// Write the snapshot as JSON
Files.writeString(snapshotPath, json.encodeToString(snapshot)) Files.writeString(snapshotPath, json.encodeToString(snapshot))
} }
/**
* Reconstructs full Block objects from a BlockSnapshot
*
* This involves looking up the actual content from the object store
* using the content hashes stored in the snapshot
*/
private fun reconstructBlocks(snapshot: BlockSnapshot): List<Block> { private fun reconstructBlocks(snapshot: BlockSnapshot): List<Block> {
return snapshot.blocks.map { blockState -> return snapshot.blocks.map { blockState ->
// Retrieve the actual content using the hash
val content = objectStore.getContent(blockState.contentHash) val content = objectStore.getContent(blockState.contentHash)
?: throw IllegalStateException("Missing content for block ${blockState.id}") ?: throw IllegalStateException("Missing content for block ${blockState.id}")
@@ -127,14 +195,18 @@ class BlockStore(
} }
} }
/**
* Finds the most recent snapshot for a given file.
*
* Walks through all time directories to find the latest snapshot
* for the specified file.
*/
fun getLatestBlockSnapshot(filePath: String): BlockSnapshot? { fun getLatestBlockSnapshot(filePath: String): BlockSnapshot? {
// Implementation to find latest snapshot for file
// Walk through time directories and find most recent
if (!blocksDir.exists()) return null if (!blocksDir.exists()) return null
// Walk through all time directories to find snapshot for this file
val snapshots = mutableListOf<Pair<BlockSnapshot, Instant>>() val snapshots = mutableListOf<Pair<BlockSnapshot, Instant>>()
// Walk through all snapshot files
Files.walk(blocksDir) Files.walk(blocksDir)
.filter { it.isRegularFile() && it.fileName.toString().startsWith("blocks-") } .filter { it.isRegularFile() && it.fileName.toString().startsWith("blocks-") }
.filter { filePath.replace("/","_") in it.fileName.toString()} .filter { filePath.replace("/","_") in it.fileName.toString()}
@@ -146,15 +218,21 @@ class BlockStore(
snapshots.add(snapshot to timestamp) snapshots.add(snapshot to timestamp)
} }
catch (e: Exception) { catch (e: Exception) {
// Skip corrupted snapshots // Skip corrupted snapshots - they won't break the system
} }
} }
// Return the most recent snapshot // Return the most recent snapshot
return snapshots return snapshots
.maxByOrNull { it.second } .maxByOrNull { it.second }
?.first ?.first
} }
/**
* Finds the most recent snapshot for a file before a given timestamp.
*
* This is used for time-travel queries - "show me how this file looked at a specific point in time"
*/
fun getLatestBlockSnapshotBefore(filePath: String, timestamp: Instant): BlockSnapshot? { fun getLatestBlockSnapshotBefore(filePath: String, timestamp: Instant): BlockSnapshot? {
if (!blocksDir.exists()) return null if (!blocksDir.exists()) return null
@@ -180,11 +258,15 @@ class BlockStore(
// Return the most recent snapshot before the timestamp // Return the most recent snapshot before the timestamp
return snapshots return snapshots
.maxByOrNull { it.second } .maxByOrNull { it.second }
?.first ?.first
} }
// Get all snapshots for specific file /**
* Gets all snapshots for a specific file, ordered by recency.
*
* Useful for showing the complete history of a file.
*/
fun getSnapshotForFile(filePath: String): List<BlockSnapshot> { fun getSnapshotForFile(filePath: String): List<BlockSnapshot> {
if (!blocksDir.exists()) return emptyList() if (!blocksDir.exists()) return emptyList()
@@ -206,11 +288,15 @@ class BlockStore(
} }
return snapshots return snapshots
.sortedByDescending { it.second } .sortedByDescending { it.second } // Most recent first
.map { it.first } .map { it.first }
} }
// Check if any snapshots exist for a file /**
* Checks if any snapshots exist for a file
*
* Quick way to determine if a file is being tracked.
*/
fun hasSnapshots(filePath: String): Boolean { fun hasSnapshots(filePath: String): Boolean {
if (!blocksDir.exists()) return false if (!blocksDir.exists()) return false
@@ -219,7 +305,11 @@ class BlockStore(
.anyMatch { filePath.replace("/", "_") in it.fileName.toString() } .anyMatch { filePath.replace("/", "_") in it.fileName.toString() }
} }
// Get all files that have snapshots /**
* Gets a list of all files that have snapshots.
*
* Useful for showing what files are being tracked by the system.
*/
fun getTrackedFiles(): List<String> { fun getTrackedFiles(): List<String> {
if (!blocksDir.exists()) return emptyList() if (!blocksDir.exists()) return emptyList()
@@ -241,43 +331,79 @@ class BlockStore(
return files.toList() return files.toList()
} }
/**
* Converts a timestamp to a date-based directory path.
* Format: yyyy/mm/dd
*/
private fun getDatePath(timestamp: Instant): String { private fun getDatePath(timestamp: Instant): String {
val date = java.time.LocalDateTime.ofInstant(timestamp, java.time.ZoneId.systemDefault()) val date = java.time.LocalDateTime.ofInstant(timestamp, java.time.ZoneId.systemDefault())
return "${date.year}/${date.monthValue.toString().padStart(2, '0')}/${date.dayOfMonth.toString().padStart(2, '0')}" return "${date.year}/${date.monthValue.toString().padStart(2, '0')}/${date.dayOfMonth.toString().padStart(2, '0')}"
} }
/**
* Converts a timestamp to a time-based filename component.
* Format: HH-MM-SS
*/
private fun getTimeString(timestamp: Instant): String { private fun getTimeString(timestamp: Instant): String {
val time = java.time.LocalDateTime.ofInstant(timestamp, java.time.ZoneId.systemDefault()) val time = java.time.LocalDateTime.ofInstant(timestamp, java.time.ZoneId.systemDefault())
return "${time.hour.toString().padStart(2, '0')}-${time.minute.toString().padStart(2, '0')}-${time.second.toString().padStart(2, '0')}" return "${time.hour.toString().padStart(2, '0')}-${time.minute.toString().padStart(2, '0')}-${time.second.toString().padStart(2, '0')}"
} }
} }
/**
* Represents a snapshot of all blocks in a specific file at a specific point in time.
*
* This is the persistent format stored on disk. It contains metadata about
* the blocks but not their actual content (which is stored separately in
* the object store for deduplication)
*/
@Serializable @Serializable
data class BlockSnapshot( data class BlockSnapshot(
val filePath: String, val filePath: String, // Path of the file this snapshot represents
val timestamp: String, val timestamp: String, // When this snapshot was created (ISO format)
val blocks: List<BlockState>, val blocks: List<BlockState>, // The block states at this time
val frontMatter: FrontMatter? val frontMatter: FrontMatter? // Front matter if present
) )
/**
* Represents the state of a single block at a point in time
*
* This is a lightweight representation that stores metadata and a hash
* reference to the actual content, rather than deduplicating the content
*/
@Serializable @Serializable
data class BlockState( data class BlockState(
val id: String, val id: String, // Stable block identifier
val heading: String, val heading: String, // The heading text
val contentHash: String, val contentHash: String, // Hash reference to content in object store
val type: BlockType, val type: BlockType, // Type of block
val order: Int val order: Int // Position within file
) )
/**
* Represents a change to a block between two snapshots
*
* Used for generating diffs and understanding what changed between versions.
*/
@Serializable @Serializable
data class BlockChange( data class BlockChange(
val blockId: String, val blockId: String, // Which block changed
val type: BlockChangeType, val type: BlockChangeType, // Type of change
val heading: String, val heading: String, // Heading for display purposes
val oldHash: String? = null, val oldHash: String? = null, // Previous content hash (for modified/deleted)
val newHash: String? = null val newHash: String? = null // New content hash (for added/modified)
) )
/**
* Types of changes that can occur to blocks
*/
enum class BlockChangeType { enum class BlockChangeType {
ADDED, MODIFIED, DELETED /** Block was added in the new snapshot */
ADDED,
/** Block content was modified */
MODIFIED,
/** Block was removed in the new snapshot */
DELETED
} }

View File

@@ -1,34 +1,61 @@
package org.notevc.core package org.notevc.core
import java.nio.file.Files
import org.notevc.utils.HashUtils import org.notevc.utils.HashUtils
import java.nio.file.Path
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.util.zip.GZIPOutputStream import java.nio.file.Files
import java.nio.file.Path
import java.util.zip.GZIPInputStream import java.util.zip.GZIPInputStream
import kotlin.io.path.* import java.util.zip.GZIPOutputStream
import kotlin.io.path.exists
import kotlin.io.path.fileSize
import kotlin.io.path.isRegularFile
/**
* Content-addressable storage system for file content.
*
* The ObjectStore implements a Git-like storage system where:
* - Content is stored once and referenced by its SHA-256 hash
* - Identical content is automatically deduplicated
* - Large content is compressed to save space
* - Objects are organized in a two-level directory structure for performance
*
* Storage structure: objects/ab/cdef123... (first 2 chars as directory)
*/
class ObjectStore(private val objectsDir: Path) { class ObjectStore(private val objectsDir: Path) {
companion object { companion object {
/** Whether to enable GZIP compression for stored content */
private const val COMPRESSION_ENABLED = true private const val COMPRESSION_ENABLED = true
/** Minimum size in bytes before compression is applied */
private const val MIN_COMPRESSION_SIZE = 100 // bytes private const val MIN_COMPRESSION_SIZE = 100 // bytes
} }
// Store content and return its hash /**
// Uses git-like storage: objects/ab/cdef123... (first 2 characters as directory) * Stores content and returns its SHA-256 hash.
// Content is compressed if it exceeds MIN_COMPRESSION_SIZE *
* The storage process:
* 1. Calculate SHA-256 hash of the content
* 2. Check if content already exists (deduplication)
* 3. If new, compress if large enough and store in hash-based path
* 4. Return the hash for future reference
*
* @param content The string content to store
* @return SHA-256 hash that can be used to retrieve the content
*/
fun storeContent(content: String): String { fun storeContent(content: String): String {
val hash = HashUtils.sha256(content) val hash = HashUtils.sha256(content)
val objectPath = getObjectPath(hash) val objectPath = getObjectPath(hash)
// Only store if it doesn't already exist // Only store if it doesn't already exist (automatic deduplication)
if (!objectPath.exists()) { if (!objectPath.exists()) {
// Ensure the parent directory exists
Files.createDirectories(objectPath.parent) Files.createDirectories(objectPath.parent)
// Compress large content to save space
if (COMPRESSION_ENABLED && content.length > MIN_COMPRESSION_SIZE) { if (COMPRESSION_ENABLED && content.length > MIN_COMPRESSION_SIZE) {
val compressed = compressString(content) Files.write(objectPath, compressString(content))
Files.write(objectPath, compressed)
} else { } else {
// Store small content uncompressed for faster access
Files.writeString(objectPath, content) Files.writeString(objectPath, content)
} }
} }
@@ -36,7 +63,15 @@ class ObjectStore(private val objectsDir: Path) {
return hash return hash
} }
// Retrieve content by hash /**
* Retrieves content by its hash.
*
* Automatically detects whether content is compressed and handles
* decompression transparently.
*
* @param hash The SHA-256 hash of the content to retrieve
* @return The original content string, or null if not found
*/
fun getContent(hash: String): String? { fun getContent(hash: String): String? {
val objectPath = getObjectPath(hash) val objectPath = getObjectPath(hash)
if (!objectPath.exists()) return null if (!objectPath.exists()) return null
@@ -44,23 +79,32 @@ class ObjectStore(private val objectsDir: Path) {
return try { return try {
val bytes = Files.readAllBytes(objectPath) val bytes = Files.readAllBytes(objectPath)
// Try to decompress first, fall back to plain text // Auto-detect compression by checking GZIP magic bytes
try { try {
if (COMPRESSION_ENABLED && bytes.size > 2 && bytes[0] == 0x1f.toByte() && bytes[1] == 0x8b.toByte()) { if (COMPRESSION_ENABLED && bytes.size > 2 && bytes[0] == 0x1f.toByte() && bytes[1] == 0x8b.toByte()) {
// This is a GZIP file (magic bytes: 0x1f 0x8b) // This is a GZIP file (magic bytes: 0x1f 0x8b)
decompressString(bytes) decompressString(bytes)
} else { } else {
// Treat as plain text
String(bytes, Charsets.UTF_8) String(bytes, Charsets.UTF_8)
} }
} catch (e: Exception) { } catch (e: Exception) {
// If decompression fails, try as plain text // If decompression fails, fall back to plain text
// This handles edge cases and format changes gracefully
String(bytes, Charsets.UTF_8) String(bytes, Charsets.UTF_8)
} }
} catch (e: Exception) { } catch (e: Exception) {
// Return null for any IO errors - content is missing or corrupted
null null
} }
} }
/**
* Compress a string using GZIP compression.
*
* @param content The string to compress
* @return Compressed bytes
*/
private fun compressString(content: String): ByteArray { private fun compressString(content: String): ByteArray {
val outputStream = ByteArrayOutputStream() val outputStream = ByteArrayOutputStream()
GZIPOutputStream(outputStream).use { gzip -> GZIPOutputStream(outputStream).use { gzip ->
@@ -69,6 +113,12 @@ class ObjectStore(private val objectsDir: Path) {
return outputStream.toByteArray() return outputStream.toByteArray()
} }
/**
* Decompresses GZIP-compressed bytes back to a string.
*
* @param compressed The compressed bytes
* @return The original string content
*/
private fun decompressString(compressed: ByteArray): String { private fun decompressString(compressed: ByteArray): String {
return GZIPInputStream(compressed.inputStream()).use { gzip -> return GZIPInputStream(compressed.inputStream()).use { gzip ->
gzip.readBytes().toString(Charsets.UTF_8) gzip.readBytes().toString(Charsets.UTF_8)

View File

@@ -1,25 +1,66 @@
package org.notevc.core package org.notevc.core
import java.nio.file.Path import kotlinx.serialization.Serializable
import java.nio.file.Paths
import kotlin.io.path.*
import org.notevc.BuildConfig
import kotlinx.serialization.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.notevc.BuildConfig
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path
import java.time.Instant import java.time.Instant
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.isWritable
/**
* Represents a NoteVC repository and provides factory methods for creation and discovery.
*
* A Repository is the root container for all version control operations. It manages:
* - Repository initialization and discovery
* - Acess to the underlying storage systems (ObjectStore, BlockStore, etc.)
* - Repository metadata and configuration
*
* The repository structure:
* ```
* * project-root/
* └── .notevc/
* ├── metadata.json # Repository metadata
* ├── timeline.json # Commit history
* ├── objects/ # Content storage
* └── blocks/ # Block snapshots
* ```
*/
class Repository private constructor(private val rootPath: Path) { class Repository private constructor(private val rootPath: Path) {
/** The .notevc directory containing all version control data */
private val notevcDir = rootPath.resolve(NOTEVC_DIR) private val notevcDir = rootPath.resolve(NOTEVC_DIR)
/** Object store for content-addressable storage */
private val objectStore = ObjectStore(notevcDir.resolve("objects")) private val objectStore = ObjectStore(notevcDir.resolve("objects"))
/** The root path of this repository */
val path: Path get() = rootPath val path: Path get() = rootPath
/** Whether this repository has been initialized */
val isInitialized: Boolean get() = notevcDir.exists() val isInitialized: Boolean get() = notevcDir.exists()
companion object { companion object {
// Factory methods - these create Repository instances /** Name of the version control directory */
const val NOTEVC_DIR = ".notevc"
// Create repository at a specified path /** Current version from build configuration */
val VERSION = BuildConfig.VERSION
/** Build timestamp from build configuration */
val BUILD_TIME = BuildConfig.BUILD_TIME
/**
* Creates a repository instance at the specified path
*
* This method validates that the path exists, is a directory,
* and is writeable before creating the repository instance.
*
* @param path The directory path where the repository should be located
* @return Result containing the Repository or an error
*/
fun at(path: String): Result<Repository> { fun at(path: String): Result<Repository> {
return try { return try {
val absolutePath = Path.of(path).toAbsolutePath() val absolutePath = Path.of(path).toAbsolutePath()
@@ -35,49 +76,69 @@ class Repository private constructor(private val rootPath: Path) {
} }
} }
// Create repository at current directory /**
* Creates a repository instance in the current working directory.
*
* @return Repository instance for the current directory.
*/
fun current(): Repository = Repository(Path.of(System.getProperty("user.dir")).toAbsolutePath()) fun current(): Repository = Repository(Path.of(System.getProperty("user.dir")).toAbsolutePath())
// Find existing repository by walking up /**
* Finds an existing repository by walking up the directory tree.
*
* Starting from the current directory, this method walks up the
* directory hiearchy looking for a .notevc directory, similar
* to how Git finds repositories.
*
* @return Repository instance if found, null if no repository found
*/
fun find(): Repository? { fun find(): Repository? {
var current = Path.of(System.getProperty("user.dir")).toAbsolutePath() var current = Path.of(System.getProperty("user.dir")).toAbsolutePath()
while (current != null) { while (current != null) {
if (current.resolve(NOTEVC_DIR).exists()) return Repository(current) if (current.resolve(NOTEVC_DIR).exists()) return Repository(current)
current = current.parent // Go up one level current = current.parent // Walk up one level
} }
return null // No repository found return null // No repository found in any parent directory
} }
// Constants
const val NOTEVC_DIR = ".notevc"
val VERSION = BuildConfig.VERSION
val BUILD_TIME = BuildConfig.BUILD_TIME
} }
/**
* String representation showing the repository path and initialization status.
*/
override fun toString(): String = "Repository(path=${rootPath.toAbsolutePath()}, initialized=$isInitialized)" override fun toString(): String = "Repository(path=${rootPath.toAbsolutePath()}, initialized=$isInitialized)"
/**
* Initiaizes a new repository in the current directory.
*
* The initialization process:
* 1. Checks if repository is already initialized
* 2. Creates the .notevc directory structure
* 3. Creates initial metadata with version and creation time
* 4. Creates empty timeline for commit history
*
* @return Result indicating success or failure with error details
*/
fun init(): Result<Unit> { fun init(): Result<Unit> {
return try { return try {
// Check if already initialized // Prevent double initialization
if (isInitialized) return Result.failure(Exception("Repository already initialized at ${rootPath.toAbsolutePath()}")) if (isInitialized) return Result.failure(Exception("Repository already initialized at ${rootPath.toAbsolutePath()}"))
// Create .notevc directory structure // Create the directory structure
Files.createDirectories(notevcDir) Files.createDirectories(notevcDir)
Files.createDirectories(notevcDir.resolve("objects")) Files.createDirectories(notevcDir.resolve("objects"))
// Create initial metadata // Create initial repository metadata
val metadata = RepoMetadata( val metadata = RepoMetadata(
version = VERSION, version = VERSION,
created = Instant.now().toString(), created = Instant.now().toString(),
head = null head = null // No commits yet
) )
// Save metadata to .notevc/metadata.json // Save metadata to .notevc/metadata.json
val metadataFile = notevcDir.resolve("metadata.json") val metadataFile = notevcDir.resolve("metadata.json")
Files.writeString(metadataFile, Json.encodeToString(metadata)) Files.writeString(metadataFile, Json.encodeToString(metadata))
// Create empty timeline // Create empty timeline for commit history
val timelineFile = notevcDir.resolve("timeline.json") val timelineFile = notevcDir.resolve("timeline.json")
Files.writeString(timelineFile, "[]") Files.writeString(timelineFile, "[]")
@@ -89,35 +150,59 @@ class Repository private constructor(private val rootPath: Path) {
} }
} }
/**
* Repository metadata stored in .notevc/metadata.json
*
* Contains essential information about the repository including
* version, creation time, current HEAD, and configuration.
*/
@Serializable @Serializable
data class RepoMetadata( data class RepoMetadata(
val version: String, val version: String, // NoteVC version that created this repo
val created: String, val created: String, // ISO timestamp of creation
var head: String?, var head: String?, // Hash of the current HEAD commit
val config: RepoConfig = RepoConfig(), val config: RepoConfig = RepoConfig(), // Repository configuration
val lastCommit: CommitInfo? = null val lastCommit: CommitInfo? = null // Information about the last commit
) )
/**
* Information about a specific commit.
*
* Used in repository metadata to track the most recent commit
* without having to parse the entire timeline.
*/
@Serializable @Serializable
data class CommitInfo( data class CommitInfo(
val hash: String, val hash: String, // Unique commit identifier
val message: String, val message: String, // Commit message
val timestamp: String, val timestamp: String, // When the commit was made
val author: String val author: String // Who made the commit
) )
/**
* Repository configuration options.
*
* These settings control how the repository behaves during
* various operations.
*/
@Serializable @Serializable
data class RepoConfig( data class RepoConfig(
val autoCommit: Boolean = false, val autoCommit: Boolean = false, // Whether to automatically commit changes
val compressionEnabled: Boolean = false, val compressionEnabled: Boolean = false, // Whether to compress stored content
val maxSnapshots: Int = 100 val maxSnapshots: Int = 100 // Maximum number of snapshots to keep
) )
/**
* Represents a single commit in the repository timeline.
*
* Each commit represents a point in time when changes were
* recorded to the repository.
*/
@Serializable @Serializable
data class CommitEntry( data class CommitEntry(
val hash: String, val hash: String, // Unique commit identifier
val message: String, val message: String, // Commit message describing changes
val timestamp: String, val timestamp: String, // ISO timestamp of when the commit was made
val author: String, val author: String, // Author of the commit
val parent: String? = null val parent: String? = null // Hash of the parent commit (null for initial commit)
) )

View File

@@ -1,77 +0,0 @@
package org.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"
// Flag to force disable colors via --no-color flag
var forceDisableColors = false
// Check if colors should be enabled (disable in CI/pipes or via flag)
private val colorsEnabled: Boolean
get() = !forceDisableColors &&
System.getenv("NO_COLOR") == null &&
System.getenv("CI") == null &&
System.console() != null
// Function to disable colors programmatically
fun disableColors() {
forceDisableColors = true
}
// 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)
}