From ec8c649150c414f6c78339f493b0051aa77566ff Mon Sep 17 00:00:00 2001 From: darwincereska Date: Sat, 13 Dec 2025 21:06:31 -0500 Subject: [PATCH] docs: added comments --- .../org/notevc/commands/CommitCommand.kt | 67 +++++- .../kotlin/org/notevc/core/BlockParser.kt | 146 ++++++++++--- src/main/kotlin/org/notevc/core/BlockStore.kt | 196 ++++++++++++++---- .../kotlin/org/notevc/core/ObjectStore.kt | 76 +++++-- src/main/kotlin/org/notevc/core/Repository.kt | 167 +++++++++++---- 5 files changed, 525 insertions(+), 127 deletions(-) diff --git a/src/main/kotlin/org/notevc/commands/CommitCommand.kt b/src/main/kotlin/org/notevc/commands/CommitCommand.kt index a6dcad4..04b5d1b 100644 --- a/src/main/kotlin/org/notevc/commands/CommitCommand.kt +++ b/src/main/kotlin/org/notevc/commands/CommitCommand.kt @@ -1,60 +1,106 @@ package org.notevc.commands +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.kargs.* import org.notevc.core.* import org.notevc.utils.FileUtils import org.notevc.utils.HashUtils -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import java.nio.file.Files import java.time.Instant -import kotlin.io.path.* -import org.kargs.* +import kotlin.io.path.exists +/** + * 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") + + /** Required commit message describing the changes */ val message by Argument(ArgType.String, name = "message", description = "Message for commit", required = true) + /** + * 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 = runCatching { + // Find the repository starting from current directory val repo = Repository.find() ?: throw Exception("Not in a notevc repository. Run `notevc init` first.") if (targetFile != null) { + // Commit only the specified file createSingleFileCommit(repo, targetFile.toString(), message!!) } else { + // Commit all changed files createChangedFilesCommit(repo, message!!) } } + // Display results with appropiate formatting result.onSuccess { message -> println(message) } result.onFailure { error -> println("${Colors.error("Error:")} ${error.message}") } } + /** + * Creates a commit for a single specified file. + * + * This method: + * 1. Validates the target file exists and is a markdown file + * 2. Parses the file into blocks + * 3. Checks if the file is enabled (not disabled in frontmatter) + * 4. Compares with the latest snapshot to detect changes + * 5. Creates a new snapshot if changes are found + * 6. Updates repository metadata + * + * @param repo The repository to commit to + * @param targetFile Path to the file to commit + * @param message Commit message + * @return Success message with commit details + */ private fun createSingleFileCommit(repo: Repository, targetFile: String, message: String): String { + // Initialize storage components val objectStore = ObjectStore(repo.path.resolve("${Repository.NOTEVC_DIR}/objects")) val blockStore = BlockStore(objectStore, repo.path.resolve("${Repository.NOTEVC_DIR}/blocks")) val blockParser = BlockParser() val timestamp = Instant.now() - // Resolve the target file path + // Validate and resolve target file path val filePath = repo.path.resolve(targetFile) if (!filePath.exists()) { throw Exception("File not found: $targetFile") } + // Only markdown files are supported if (!targetFile.toString().endsWith(".md")) { throw Exception("Only markdown files (.md) are supported") } + // Convert to relative path for storage val relativePath = repo.path.relativize(filePath).toString() val content = Files.readString(filePath) val parsedFile = blockParser.parseFile(content, relativePath) - // Check if file is disabled + // Respect the enabled flag in front matter if (parsedFile.frontMatter?.isEnabled == false) { 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) if (latestSnapshot != null) { val currentSnapshot = createCurrentSnapshot(parsedFile, objectStore, timestamp) @@ -65,13 +111,16 @@ class CommitCommand : Subcommand("commit", description = "Create a commit of cha } } - // Store blocks for this file + // Store the new snapshot val snapshot = blockStore.storeBlocks(parsedFile, timestamp) + + // Generate a short commit hash for display val commitHash = HashUtils.sha256("$timestamp:$message:$relativePath").take(8) - // Update repository metadata + // Update repository metadata with new commit updateRepositoryHead(repo, commitHash, timestamp, message) + // Return formatted success message return buildString { appendLine("${Colors.success("Created commit")} ${Colors.yellow(commitHash)}") appendLine("${Colors.bold("Message:")} $message") diff --git a/src/main/kotlin/org/notevc/core/BlockParser.kt b/src/main/kotlin/org/notevc/core/BlockParser.kt index 58e11af..8e727da 100644 --- a/src/main/kotlin/org/notevc/core/BlockParser.kt +++ b/src/main/kotlin/org/notevc/core/BlockParser.kt @@ -2,8 +2,27 @@ package org.notevc.core 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 { - // 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 { val lines = content.lines() val blocks = mutableListOf() @@ -13,6 +32,7 @@ class BlockParser { var currentHeading: String? = null var blockIndex = 0 + // Skip front matter lines if present val contentLines = if (frontMatter != null) { lines.drop(frontMatter.endLine + 1) } else lines @@ -20,7 +40,7 @@ class BlockParser { for (line in contentLines) { when { line.startsWith("#") -> { - // Save previous block if exists + // Save the previous block before starting a new one if (currentBlock != null && currentHeading != null) { blocks.add(Block( id = generateBlockId(filePath, currentHeading, blockIndex), @@ -31,17 +51,17 @@ class BlockParser { )) } - // Start new block + // Start a new block with this heading currentHeading = line currentBlock = mutableListOf(line) } else -> { - // Add to current block or create content-only block + // Add content to the current block if (currentBlock != null) currentBlock.add(line) else { - // Content before any heading + // Handle content that appears before any heading currentBlock = mutableListOf() - currentHeading = "" + currentHeading = "" // Special marker for content-only blocks 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): FrontMatter? { + // Front matter must start with --- on the first line if (lines.isEmpty() || lines[0] != "---") return null + // Find the closing --- delimiter val endIndex = lines.drop(1).indexOfFirst { it == "---"} if (endIndex == -1) return null + // Extract the YAML content between the delimiters val yamlLines = lines.subList(1, endIndex + 1) val properties = mutableMapOf() var currentKey: String? = null @@ -86,8 +123,8 @@ class BlockParser { arrayValues.add(value) } // Handle key-value pairs - line.contains(":") -> { - // Save previous array if exists + ":" in line -> { + // Save any accumulated array values from the previous key if (currentKey != null && arrayValues.isNotEmpty()) { properties[currentKey] = arrayValues.joinToString(", ") arrayValues.clear() @@ -101,6 +138,7 @@ class BlockParser { // This might be an array key currentKey = key } else { + // Simple key-value pair properties[key] = value currentKey = null } @@ -115,27 +153,49 @@ class BlockParser { return FrontMatter( 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 { + // Clean the heading by removing markdown syntax val cleanHeading = heading.replace(Regex("^#+\\s*"), "").trim() 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) } - // 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 { val result = StringBuilder() - // Add front matter if exists + // Reconstruct front matter if it exists parsedFile.frontMatter?.let { fm -> result.appendLine("---") fm.properties.forEach { (key, value) -> - // Handle tags as array - if (key == "tags" && value.contains(",")) { + // Special handling for tags - convert back to array format + if (key == "tags" && "," in value) { result.appendLine("$key:") value.split(",").forEach { tag -> result.appendLine(" - ${tag.trim()}") @@ -145,10 +205,10 @@ class BlockParser { } } 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 } sortedBlocks.forEachIndexed { index, block -> result.append(block.content) @@ -162,35 +222,63 @@ class BlockParser { } } +/** + * Represents a parsed markdown file with its constuitent blocks and metadata. + */ @Serializable data class ParsedFile( - val path: String, - val frontMatter: FrontMatter?, - val blocks: List + val path: String, // File path for identification + val frontMatter: FrontMatter?, // YAML front matter if present + val blocks: List // 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 data class Block( - val id: String, // Stable block identifier - val heading: String, // The heading text + val id: String, // Stable identifier for tracking + val heading: String, // The heading text (or special marker) val content: String, // Full block content including heading - val type: BlockType, - val order: Int // Order within file + val type: BlockType, // Type classification + 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 data class FrontMatter( - val properties: Map, - val endLine: Int + val properties: Map, // Key-value pairs from the YAML + 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" + + /** Whether this file should be processed automatically */ val isAutomatic: Boolean get() = properties["automatic"]?.lowercase() == "true" + + /** The title of the document */ val title: String? get() = properties["title"] + + /** List of tags associated with the document */ val tags: List get() = properties["tags"]?.split(",")?.map { it.trim() } ?: emptyList() } +/** + * Enumeration of different types of content blocks. + */ enum class BlockType { - HEADING_SECTION, // # Heading with content - CONTENT_ONLY // Content without heading + /** A section with a heading and associated content. */ + HEADING_SECTION, + + /** Content that appears without an associated heading */ + CONTENT_ONLY } diff --git a/src/main/kotlin/org/notevc/core/BlockStore.kt b/src/main/kotlin/org/notevc/core/BlockStore.kt index 35ce794..4c7db47 100644 --- a/src/main/kotlin/org/notevc/core/BlockStore.kt +++ b/src/main/kotlin/org/notevc/core/BlockStore.kt @@ -1,29 +1,53 @@ package org.notevc.core -import org.notevc.utils.HashUtils import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.nio.file.Files import java.nio.file.Path 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( - private val objectStore: ObjectStore, - private val blocksDir: Path + private val objectStore: ObjectStore, // For storing actual content + private val blocksDir: Path // Directory for block snapshots ) { 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 { + // Convert blocks to states with content hashes val blockStates = parsedFile.blocks.map { block -> + // Store the actual content and get its hash for deduplication val contentHash = objectStore.storeContent(block.content) BlockState( id = block.id, heading = block.heading, - contentHash = contentHash, + contentHash = contentHash, // Reference to content, not the content itself type = block.type, order = block.order ) @@ -36,28 +60,53 @@ class BlockStore( frontMatter = parsedFile.frontMatter ) - // Store block snapshot with time-based structure (yyyy/mm/dd) + // Store the snapshot in the time-based directory structure storeBlockSnapshot(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? { + // Retrieve the latest block snapshot before the given timestamp val snapshot = getLatestBlockSnapshotBefore(filePath, timestamp) + // Use let to reconstruct blocks only if is not null 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? { val snapshot = getLatestBlockSnapshot(filePath) 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 { val changes = mutableListOf() + // Create maps for efficient lookup by block ID val oldBlocks = oldSnapshot?.blocks?.associateBy { it.id } ?: emptyMap() val newBlocks = newSnapshot?.blocks?.associateBy { it.id } ?: emptyMap() @@ -66,6 +115,7 @@ class BlockStore( val oldBlock = oldBlocks[id] when { oldBlock == null -> { + // Block is new changes.add(BlockChange( blockId = id, type = BlockChangeType.ADDED, @@ -74,6 +124,7 @@ class BlockStore( )) } oldBlock.contentHash != newBlock.contentHash -> { + // Block content changed changes.add(BlockChange( blockId = id, type = BlockChangeType.MODIFIED, @@ -82,6 +133,7 @@ class BlockStore( newHash = newBlock.contentHash )) } + // If hashes are the same, no change needed } } @@ -100,20 +152,36 @@ class BlockStore( 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) { val datePath = getDatePath(Instant.parse(snapshot.timestamp)) val timeString: String = getTimeString(Instant.parse(snapshot.timestamp)) + // Ensure the date directory exists Files.createDirectories(blocksDir.resolve(datePath)) + // Create the filename with timestamp and sanitized file path val filename = "blocks-$timeString-${snapshot.filePath.replace("/","_")}.json" val snapshotPath: Path = blocksDir.resolve(datePath).resolve(filename) + // Write the snapshot as JSON 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 { return snapshot.blocks.map { blockState -> + // Retrieve the actual content using the hash val content = objectStore.getContent(blockState.contentHash) ?: 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? { - // Implementation to find latest snapshot for file - // Walk through time directories and find most recent if (!blocksDir.exists()) return null - // Walk through all time directories to find snapshot for this file val snapshots = mutableListOf>() + // Walk through all snapshot files Files.walk(blocksDir) .filter { it.isRegularFile() && it.fileName.toString().startsWith("blocks-") } .filter { filePath.replace("/","_") in it.fileName.toString()} @@ -146,15 +218,21 @@ class BlockStore( snapshots.add(snapshot to timestamp) } catch (e: Exception) { - // Skip corrupted snapshots + // Skip corrupted snapshots - they won't break the system } } + // Return the most recent snapshot return snapshots .maxByOrNull { it.second } ?.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? { if (!blocksDir.exists()) return null @@ -180,11 +258,15 @@ class BlockStore( // Return the most recent snapshot before the timestamp return snapshots - .maxByOrNull { it.second } - ?.first + .maxByOrNull { it.second } + ?.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 { if (!blocksDir.exists()) return emptyList() @@ -206,11 +288,15 @@ class BlockStore( } return snapshots - .sortedByDescending { it.second } + .sortedByDescending { it.second } // Most recent 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 { if (!blocksDir.exists()) return false @@ -219,7 +305,11 @@ class BlockStore( .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 { if (!blocksDir.exists()) return emptyList() @@ -241,43 +331,79 @@ class BlockStore( return files.toList() } + /** + * Converts a timestamp to a date-based directory path. + * Format: yyyy/mm/dd + */ private fun getDatePath(timestamp: Instant): String { 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')}" } + /** + * Converts a timestamp to a time-based filename component. + * Format: HH-MM-SS + */ private fun getTimeString(timestamp: Instant): String { 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')}" } } +/** + * 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 data class BlockSnapshot( - val filePath: String, - val timestamp: String, - val blocks: List, - val frontMatter: FrontMatter? + val filePath: String, // Path of the file this snapshot represents + val timestamp: String, // When this snapshot was created (ISO format) + val blocks: List, // The block states at this time + 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 data class BlockState( - val id: String, - val heading: String, - val contentHash: String, - val type: BlockType, - val order: Int + val id: String, // Stable block identifier + val heading: String, // The heading text + val contentHash: String, // Hash reference to content in object store + val type: BlockType, // Type of block + 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 data class BlockChange( - val blockId: String, - val type: BlockChangeType, - val heading: String, - val oldHash: String? = null, - val newHash: String? = null + val blockId: String, // Which block changed + val type: BlockChangeType, // Type of change + val heading: String, // Heading for display purposes + val oldHash: String? = null, // Previous content hash (for modified/deleted) + val newHash: String? = null // New content hash (for added/modified) ) +/** + * Types of changes that can occur to blocks + */ 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 } diff --git a/src/main/kotlin/org/notevc/core/ObjectStore.kt b/src/main/kotlin/org/notevc/core/ObjectStore.kt index e28f4ea..cab083a 100644 --- a/src/main/kotlin/org/notevc/core/ObjectStore.kt +++ b/src/main/kotlin/org/notevc/core/ObjectStore.kt @@ -1,34 +1,61 @@ package org.notevc.core -import java.nio.file.Files import org.notevc.utils.HashUtils -import java.nio.file.Path 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 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) { companion object { + /** Whether to enable GZIP compression for stored content */ private const val COMPRESSION_ENABLED = true + + /** Minimum size in bytes before compression is applied */ 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) - // Content is compressed if it exceeds MIN_COMPRESSION_SIZE + /** + * Stores content and returns its SHA-256 hash. + * + * 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 { val hash = HashUtils.sha256(content) 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()) { + // Ensure the parent directory exists Files.createDirectories(objectPath.parent) + // Compress large content to save space if (COMPRESSION_ENABLED && content.length > MIN_COMPRESSION_SIZE) { - val compressed = compressString(content) - Files.write(objectPath, compressed) + Files.write(objectPath, compressString(content)) } else { + // Store small content uncompressed for faster access Files.writeString(objectPath, content) } } @@ -36,7 +63,15 @@ class ObjectStore(private val objectsDir: Path) { 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? { val objectPath = getObjectPath(hash) if (!objectPath.exists()) return null @@ -44,23 +79,32 @@ class ObjectStore(private val objectsDir: Path) { return try { val bytes = Files.readAllBytes(objectPath) - // Try to decompress first, fall back to plain text + // Auto-detect compression by checking GZIP magic bytes try { if (COMPRESSION_ENABLED && bytes.size > 2 && bytes[0] == 0x1f.toByte() && bytes[1] == 0x8b.toByte()) { // This is a GZIP file (magic bytes: 0x1f 0x8b) decompressString(bytes) } else { + // Treat as plain text String(bytes, Charsets.UTF_8) } } 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) } } catch (e: Exception) { + // Return null for any IO errors - content is missing or corrupted null } } + /** + * Compress a string using GZIP compression. + * + * @param content The string to compress + * @return Compressed bytes + */ private fun compressString(content: String): ByteArray { val outputStream = ByteArrayOutputStream() GZIPOutputStream(outputStream).use { gzip -> @@ -69,6 +113,12 @@ class ObjectStore(private val objectsDir: Path) { 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 { return GZIPInputStream(compressed.inputStream()).use { gzip -> gzip.readBytes().toString(Charsets.UTF_8) diff --git a/src/main/kotlin/org/notevc/core/Repository.kt b/src/main/kotlin/org/notevc/core/Repository.kt index b121fe2..6bce408 100644 --- a/src/main/kotlin/org/notevc/core/Repository.kt +++ b/src/main/kotlin/org/notevc/core/Repository.kt @@ -1,25 +1,66 @@ package org.notevc.core -import java.nio.file.Path -import java.nio.file.Paths -import kotlin.io.path.* -import org.notevc.BuildConfig -import kotlinx.serialization.* -import kotlinx.serialization.json.Json +import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.notevc.BuildConfig import java.nio.file.Files +import java.nio.file.Path 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) { + /** The .notevc directory containing all version control data */ private val notevcDir = rootPath.resolve(NOTEVC_DIR) + + /** Object store for content-addressable storage */ private val objectStore = ObjectStore(notevcDir.resolve("objects")) + + /** The root path of this repository */ val path: Path get() = rootPath + + /** Whether this repository has been initialized */ val isInitialized: Boolean get() = notevcDir.exists() companion object { - // Factory methods - these create Repository instances - - // Create repository at a specified path + /** Name of the version control directory */ + const val NOTEVC_DIR = ".notevc" + + /** 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 { return try { 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()) - // 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? { var current = Path.of(System.getProperty("user.dir")).toAbsolutePath() while (current != null) { 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)" + /** + * 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 { return try { - // Check if already initialized + // Prevent double initialization 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.resolve("objects")) - // Create initial metadata + // Create initial repository metadata val metadata = RepoMetadata( version = VERSION, created = Instant.now().toString(), - head = null + head = null // No commits yet ) // Save metadata to .notevc/metadata.json val metadataFile = notevcDir.resolve("metadata.json") Files.writeString(metadataFile, Json.encodeToString(metadata)) - // Create empty timeline + // Create empty timeline for commit history val timelineFile = notevcDir.resolve("timeline.json") 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 data class RepoMetadata( - val version: String, - val created: String, - var head: String?, - val config: RepoConfig = RepoConfig(), - val lastCommit: CommitInfo? = null + val version: String, // NoteVC version that created this repo + val created: String, // ISO timestamp of creation + var head: String?, // Hash of the current HEAD commit + val config: RepoConfig = RepoConfig(), // Repository configuration + 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 data class CommitInfo( - val hash: String, - val message: String, - val timestamp: String, - val author: String + val hash: String, // Unique commit identifier + val message: String, // Commit message + val timestamp: String, // When the commit was made + val author: String // Who made the commit ) +/** + * Repository configuration options. + * + * These settings control how the repository behaves during + * various operations. + */ @Serializable data class RepoConfig( - val autoCommit: Boolean = false, - val compressionEnabled: Boolean = false, - val maxSnapshots: Int = 100 + val autoCommit: Boolean = false, // Whether to automatically commit changes + val compressionEnabled: Boolean = false, // Whether to compress stored content + 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 data class CommitEntry( - val hash: String, - val message: String, - val timestamp: String, - val author: String, - val parent: String? = null + val hash: String, // Unique commit identifier + val message: String, // Commit message describing changes + val timestamp: String, // ISO timestamp of when the commit was made + val author: String, // Author of the commit + val parent: String? = null // Hash of the parent commit (null for initial commit) )