Implement Milestone 1

This commit is contained in:
2025-10-09 19:19:31 -04:00
parent f7674cc2b0
commit 5e6ae2e429
12 changed files with 1671 additions and 5 deletions

179
internal/commands/init.go Normal file
View File

@ -0,0 +1,179 @@
package commands
import (
"fmt"
"os"
"path/filepath"
"git.dws.rip/DWS/onyx/internal/core"
"git.dws.rip/DWS/onyx/internal/storage"
"github.com/spf13/cobra"
)
// NewInitCmd creates the init command
func NewInitCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "init [path]",
Short: "Initialize a new Onyx repository",
Long: `Initialize a new Onyx repository in the specified directory.
If no path is provided, initializes in the current directory.
This command will:
- Create a Git repository (if one doesn't exist)
- Create the .onx directory structure
- Initialize the oplog file
- Create default workstreams.json
- Add .onx to .gitignore`,
Args: cobra.MaximumNArgs(1),
RunE: runInit,
}
return cmd
}
func runInit(cmd *cobra.Command, args []string) error {
// Determine the path
path := "."
if len(args) > 0 {
path = args[0]
}
// Resolve to absolute path
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("failed to resolve path: %w", err)
}
// Check if already an Onyx repository
if core.IsOnyxRepo(absPath) {
return fmt.Errorf("already an onyx repository: %s", absPath)
}
// Create and initialize repository
repo := &core.OnyxRepository{}
err = repo.Init(absPath)
if err != nil {
return fmt.Errorf("failed to initialize repository: %w", err)
}
// Add .onx to .gitignore
gitignorePath := filepath.Join(absPath, ".gitignore")
err = addToGitignore(gitignorePath, ".onx/")
if err != nil {
// Don't fail if we can't update .gitignore, just warn
fmt.Fprintf(os.Stderr, "Warning: failed to update .gitignore: %v\n", err)
}
// Log the init operation to oplog
txn, err := core.NewTransaction(repo)
if err != nil {
// Don't fail if we can't create transaction, repo is already initialized
fmt.Fprintf(os.Stderr, "Warning: failed to log init to oplog: %v\n", err)
} else {
defer txn.Close()
// Execute a no-op function just to log the init
err = txn.ExecuteWithTransaction("init", "Initialized Onyx repository", func() error {
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to log init: %v\n", err)
}
}
fmt.Printf("Initialized empty Onyx repository in %s\n", filepath.Join(absPath, ".onx"))
return nil
}
// addToGitignore adds an entry to .gitignore if it doesn't already exist
func addToGitignore(gitignorePath, entry string) error {
// Read existing .gitignore if it exists
var content []byte
if _, err := os.Stat(gitignorePath); err == nil {
content, err = os.ReadFile(gitignorePath)
if err != nil {
return fmt.Errorf("failed to read .gitignore: %w", err)
}
}
// Check if entry already exists
contentStr := string(content)
if len(contentStr) > 0 && contentStr[len(contentStr)-1] != '\n' {
contentStr += "\n"
}
// Add entry if it doesn't exist
needle := entry
if len(needle) > 0 && needle[len(needle)-1] != '\n' {
needle += "\n"
}
// Simple check - not perfect but good enough
if !containsLine(contentStr, entry) {
contentStr += needle
}
// Write back to .gitignore
err := os.WriteFile(gitignorePath, []byte(contentStr), 0644)
if err != nil {
return fmt.Errorf("failed to write .gitignore: %w", err)
}
return nil
}
// containsLine checks if a multi-line string contains a specific line
func containsLine(content, line string) bool {
// Simple implementation - just check if the line exists as a substring
// In the future, we might want to do line-by-line checking
target := line
if len(target) > 0 && target[len(target)-1] == '\n' {
target = target[:len(target)-1]
}
lines := splitLines(content)
for _, l := range lines {
if l == target {
return true
}
}
return false
}
// splitLines splits a string into lines
func splitLines(s string) []string {
if s == "" {
return []string{}
}
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
// Add the last line if it doesn't end with newline
if start < len(s) {
lines = append(lines, s[start:])
}
return lines
}
// GetOplogWriter creates an oplog writer for the repository at the given path
func GetOplogWriter(path string) (*storage.OplogWriter, error) {
absPath, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("failed to resolve path: %w", err)
}
if !core.IsOnyxRepo(absPath) {
return nil, fmt.Errorf("not an onyx repository: %s", absPath)
}
oplogPath := filepath.Join(absPath, ".onx", "oplog")
return storage.OpenOplog(oplogPath)
}

View File

@ -0,0 +1,196 @@
package commands
import (
"os"
"path/filepath"
"testing"
"git.dws.rip/DWS/onyx/internal/core"
)
func TestInitCommand(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
// Create a repository instance
repo := &core.OnyxRepository{}
// Initialize the repository
err := repo.Init(tempDir)
if err != nil {
t.Fatalf("Failed to initialize repository: %v", err)
}
// Verify .git directory exists
gitPath := filepath.Join(tempDir, ".git")
if _, err := os.Stat(gitPath); os.IsNotExist(err) {
t.Errorf(".git directory was not created")
}
// Verify .onx directory exists
onyxPath := filepath.Join(tempDir, ".onx")
if _, err := os.Stat(onyxPath); os.IsNotExist(err) {
t.Errorf(".onx directory was not created")
}
// Verify oplog file exists
oplogPath := filepath.Join(onyxPath, "oplog")
if _, err := os.Stat(oplogPath); os.IsNotExist(err) {
t.Errorf("oplog file was not created")
}
// Verify workstreams.json exists
workstreamsPath := filepath.Join(onyxPath, "workstreams.json")
if _, err := os.Stat(workstreamsPath); os.IsNotExist(err) {
t.Errorf("workstreams.json was not created")
}
// Verify rerere_cache directory exists
rererePath := filepath.Join(onyxPath, "rerere_cache")
if _, err := os.Stat(rererePath); os.IsNotExist(err) {
t.Errorf("rerere_cache directory was not created")
}
}
func TestInitCommandInExistingRepo(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
// Initialize once
repo := &core.OnyxRepository{}
err := repo.Init(tempDir)
if err != nil {
t.Fatalf("Failed to initialize repository: %v", err)
}
// Verify it's an Onyx repo
if !core.IsOnyxRepo(tempDir) {
t.Errorf("IsOnyxRepo returned false for initialized repository")
}
}
func TestIsOnyxRepo(t *testing.T) {
tests := []struct {
name string
setup func(string) error
expected bool
}{
{
name: "empty directory",
setup: func(path string) error {
return nil
},
expected: false,
},
{
name: "initialized repository",
setup: func(path string) error {
repo := &core.OnyxRepository{}
return repo.Init(path)
},
expected: true,
},
{
name: "directory with only .git",
setup: func(path string) error {
return os.MkdirAll(filepath.Join(path, ".git"), 0755)
},
expected: false,
},
{
name: "directory with only .onx",
setup: func(path string) error {
return os.MkdirAll(filepath.Join(path, ".onx"), 0755)
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
err := tt.setup(tempDir)
if err != nil {
t.Fatalf("Setup failed: %v", err)
}
result := core.IsOnyxRepo(tempDir)
if result != tt.expected {
t.Errorf("IsOnyxRepo() = %v, expected %v", result, tt.expected)
}
})
}
}
func TestAddToGitignore(t *testing.T) {
tests := []struct {
name string
existingContent string
entryToAdd string
shouldContain string
}{
{
name: "add to empty gitignore",
existingContent: "",
entryToAdd: ".onx/",
shouldContain: ".onx/",
},
{
name: "add to existing gitignore",
existingContent: "node_modules/\n*.log\n",
entryToAdd: ".onx/",
shouldContain: ".onx/",
},
{
name: "don't duplicate existing entry",
existingContent: ".onx/\nnode_modules/\n",
entryToAdd: ".onx/",
shouldContain: ".onx/",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
gitignorePath := filepath.Join(tempDir, ".gitignore")
// Create existing content if specified
if tt.existingContent != "" {
err := os.WriteFile(gitignorePath, []byte(tt.existingContent), 0644)
if err != nil {
t.Fatalf("Failed to create test .gitignore: %v", err)
}
}
// Add the entry
err := addToGitignore(gitignorePath, tt.entryToAdd)
if err != nil {
t.Fatalf("addToGitignore failed: %v", err)
}
// Read the result
content, err := os.ReadFile(gitignorePath)
if err != nil {
t.Fatalf("Failed to read .gitignore: %v", err)
}
// Verify the entry is present
if !containsLine(string(content), tt.shouldContain) {
t.Errorf(".gitignore does not contain expected entry %q\nContent:\n%s", tt.shouldContain, string(content))
}
// Count occurrences (should not be duplicated)
lines := splitLines(string(content))
count := 0
for _, line := range lines {
if line == tt.shouldContain {
count++
}
}
if count > 1 {
t.Errorf("Entry %q appears %d times, expected 1", tt.shouldContain, count)
}
})
}
}

204
internal/commands/undo.go Normal file
View File

@ -0,0 +1,204 @@
package commands
import (
"fmt"
"os"
"path/filepath"
"git.dws.rip/DWS/onyx/internal/core"
"git.dws.rip/DWS/onyx/internal/storage"
"github.com/spf13/cobra"
)
// NewUndoCmd creates the undo command
func NewUndoCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "undo",
Short: "Undo the last operation",
Long: `Undo the last operation by restoring the repository to its previous state.
This command reads the last entry from the oplog and restores all refs
and working directory state to what they were before the operation.`,
Args: cobra.NoArgs,
RunE: runUndo,
}
return cmd
}
func runUndo(cmd *cobra.Command, args []string) error {
// Get current directory
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
// Check if we're in an Onyx repository
if !core.IsOnyxRepo(cwd) {
return fmt.Errorf("not an onyx repository (or any parent up to mount point)")
}
// Open the repository
repo, err := core.Open(cwd)
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
}
defer repo.Close()
// Open oplog reader
oplogPath := filepath.Join(repo.GetOnyxPath(), "oplog")
reader := storage.NewOplogReader(oplogPath)
// Check if oplog is empty
isEmpty, err := reader.IsEmpty()
if err != nil {
return fmt.Errorf("failed to check oplog: %w", err)
}
if isEmpty {
return fmt.Errorf("nothing to undo")
}
// Read the last entry
lastEntry, err := reader.ReadLastEntry()
if err != nil {
return fmt.Errorf("failed to read last entry: %w", err)
}
// Check if we have state_before to restore
if lastEntry.StateBefore == nil {
return fmt.Errorf("cannot undo: last operation has no state_before")
}
// Show what we're undoing
fmt.Printf("Undoing: %s - %s\n", lastEntry.Operation, lastEntry.Description)
// Create state capture to restore the state
stateCapture := storage.NewStateCapture(repo.GetGitRepo())
// Restore the state
err = stateCapture.RestoreState(lastEntry.StateBefore)
if err != nil {
return fmt.Errorf("failed to restore state: %w", err)
}
// Log the undo operation
txn, err := core.NewTransaction(repo)
if err != nil {
// Don't fail if we can't create transaction, state is already restored
fmt.Fprintf(os.Stderr, "Warning: failed to log undo to oplog: %v\n", err)
} else {
defer txn.Close()
metadata := map[string]string{
"undone_entry_id": fmt.Sprintf("%d", lastEntry.ID),
"undone_operation": lastEntry.Operation,
}
err = txn.ExecuteWithTransactionAndMetadata(
"undo",
fmt.Sprintf("Undid operation: %s", lastEntry.Operation),
metadata,
func() error {
// The actual undo has already been performed above
// This function is just to capture the state after undo
return nil
},
)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to log undo: %v\n", err)
}
}
// Show what changed
stateAfter, _ := stateCapture.CaptureState()
if stateAfter != nil {
differences := stateCapture.CompareStates(stateAfter, lastEntry.StateBefore)
if len(differences) > 0 {
fmt.Println("\nChanges:")
for ref, change := range differences {
fmt.Printf(" %s: %s\n", ref, change)
}
}
}
fmt.Println("\nUndo complete!")
return nil
}
// UndoToEntry undoes to a specific entry ID
func UndoToEntry(repo *core.OnyxRepository, entryID uint64) error {
oplogPath := filepath.Join(repo.GetOnyxPath(), "oplog")
reader := storage.NewOplogReader(oplogPath)
// Read the target entry
entry, err := reader.ReadEntry(entryID)
if err != nil {
return fmt.Errorf("failed to read entry %d: %w", entryID, err)
}
if entry.StateBefore == nil {
return fmt.Errorf("entry %d has no state_before to restore", entryID)
}
// Restore the state
stateCapture := storage.NewStateCapture(repo.GetGitRepo())
err = stateCapture.RestoreState(entry.StateBefore)
if err != nil {
return fmt.Errorf("failed to restore state: %w", err)
}
// Log the undo operation
txn, err := core.NewTransaction(repo)
if err != nil {
return fmt.Errorf("failed to create transaction: %w", err)
}
defer txn.Close()
metadata := map[string]string{
"undone_to_entry_id": fmt.Sprintf("%d", entryID),
"undone_operation": entry.Operation,
}
err = txn.ExecuteWithTransactionAndMetadata(
"undo",
fmt.Sprintf("Undid to entry %d: %s", entryID, entry.Operation),
metadata,
func() error {
return nil
},
)
if err != nil {
return fmt.Errorf("failed to log undo: %w", err)
}
return nil
}
// ListUndoStack shows the undo stack
func ListUndoStack(repo *core.OnyxRepository) error {
oplogPath := filepath.Join(repo.GetOnyxPath(), "oplog")
reader := storage.NewOplogReader(oplogPath)
entries, err := reader.GetUndoStack()
if err != nil {
return fmt.Errorf("failed to get undo stack: %w", err)
}
if len(entries) == 0 {
fmt.Println("Nothing to undo")
return nil
}
fmt.Println("Undo stack (most recent first):")
for i, entry := range entries {
fmt.Printf("%d. [%d] %s: %s (%s)\n",
i+1,
entry.ID,
entry.Operation,
entry.Description,
entry.Timestamp.Format("2006-01-02 15:04:05"),
)
}
return nil
}

View File

@ -0,0 +1,300 @@
package commands
import (
"path/filepath"
"testing"
"git.dws.rip/DWS/onyx/internal/core"
"git.dws.rip/DWS/onyx/internal/storage"
)
func TestUndoWithEmptyOplog(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
// Initialize the repository
repo := &core.OnyxRepository{}
err := repo.Init(tempDir)
if err != nil {
t.Fatalf("Failed to initialize repository: %v", err)
}
// Open the repository
openedRepo, err := core.Open(tempDir)
if err != nil {
t.Fatalf("Failed to open repository: %v", err)
}
defer openedRepo.Close()
// Try to undo with empty oplog
oplogPath := filepath.Join(openedRepo.GetOnyxPath(), "oplog")
reader := storage.NewOplogReader(oplogPath)
isEmpty, err := reader.IsEmpty()
if err != nil {
t.Fatalf("Failed to check if oplog is empty: %v", err)
}
if !isEmpty {
t.Errorf("Expected oplog to be empty after init")
}
_, err = reader.ReadLastEntry()
if err == nil {
t.Errorf("Expected error when reading from empty oplog, got nil")
}
}
func TestUndoAfterOperation(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
// Initialize the repository
repo := &core.OnyxRepository{}
err := repo.Init(tempDir)
if err != nil {
t.Fatalf("Failed to initialize repository: %v", err)
}
// Open the repository
openedRepo, err := core.Open(tempDir)
if err != nil {
t.Fatalf("Failed to open repository: %v", err)
}
defer openedRepo.Close()
// Perform an operation with transaction
txn, err := core.NewTransaction(openedRepo)
if err != nil {
t.Fatalf("Failed to create transaction: %v", err)
}
err = txn.ExecuteWithTransaction("test_operation", "Test operation for undo", func() error {
// Simulate some operation
return nil
})
txn.Close()
if err != nil {
t.Fatalf("Failed to execute transaction: %v", err)
}
// Verify the oplog has an entry
oplogPath := filepath.Join(openedRepo.GetOnyxPath(), "oplog")
reader := storage.NewOplogReader(oplogPath)
isEmpty, err := reader.IsEmpty()
if err != nil {
t.Fatalf("Failed to check if oplog is empty: %v", err)
}
if isEmpty {
t.Errorf("Expected oplog to have entries after operation")
}
// Read the last entry
lastEntry, err := reader.ReadLastEntry()
if err != nil {
t.Fatalf("Failed to read last entry: %v", err)
}
if lastEntry.Operation != "test_operation" {
t.Errorf("Expected operation to be 'test_operation', got %q", lastEntry.Operation)
}
if lastEntry.Description != "Test operation for undo" {
t.Errorf("Expected description to be 'Test operation for undo', got %q", lastEntry.Description)
}
// Verify state_before and state_after are captured
if lastEntry.StateBefore == nil {
t.Errorf("Expected state_before to be captured")
}
if lastEntry.StateAfter == nil {
t.Errorf("Expected state_after to be captured")
}
}
func TestSequentialUndos(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
// Initialize the repository
repo := &core.OnyxRepository{}
err := repo.Init(tempDir)
if err != nil {
t.Fatalf("Failed to initialize repository: %v", err)
}
// Open the repository
openedRepo, err := core.Open(tempDir)
if err != nil {
t.Fatalf("Failed to open repository: %v", err)
}
defer openedRepo.Close()
// Perform multiple operations
operations := []string{"operation1", "operation2", "operation3"}
for _, op := range operations {
txn, err := core.NewTransaction(openedRepo)
if err != nil {
t.Fatalf("Failed to create transaction: %v", err)
}
err = txn.ExecuteWithTransaction(op, "Test "+op, func() error {
return nil
})
txn.Close()
if err != nil {
t.Fatalf("Failed to execute transaction for %s: %v", op, err)
}
}
// Verify we have 3 entries
oplogPath := filepath.Join(openedRepo.GetOnyxPath(), "oplog")
reader := storage.NewOplogReader(oplogPath)
count, err := reader.Count()
if err != nil {
t.Fatalf("Failed to count oplog entries: %v", err)
}
if count != 3 {
t.Errorf("Expected 3 oplog entries, got %d", count)
}
// Read all entries to verify order
entries, err := reader.ReadAllEntries()
if err != nil {
t.Fatalf("Failed to read all entries: %v", err)
}
for i, entry := range entries {
expectedOp := operations[i]
if entry.Operation != expectedOp {
t.Errorf("Entry %d: expected operation %q, got %q", i, expectedOp, entry.Operation)
}
}
}
func TestUndoStack(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
// Initialize the repository
repo := &core.OnyxRepository{}
err := repo.Init(tempDir)
if err != nil {
t.Fatalf("Failed to initialize repository: %v", err)
}
// Open the repository
openedRepo, err := core.Open(tempDir)
if err != nil {
t.Fatalf("Failed to open repository: %v", err)
}
defer openedRepo.Close()
// Perform multiple operations
operations := []string{"op1", "op2", "op3"}
for _, op := range operations {
txn, err := core.NewTransaction(openedRepo)
if err != nil {
t.Fatalf("Failed to create transaction: %v", err)
}
err = txn.ExecuteWithTransaction(op, "Test "+op, func() error {
return nil
})
txn.Close()
if err != nil {
t.Fatalf("Failed to execute transaction for %s: %v", op, err)
}
}
// Get the undo stack
oplogPath := filepath.Join(openedRepo.GetOnyxPath(), "oplog")
reader := storage.NewOplogReader(oplogPath)
undoStack, err := reader.GetUndoStack()
if err != nil {
t.Fatalf("Failed to get undo stack: %v", err)
}
if len(undoStack) != 3 {
t.Errorf("Expected undo stack size of 3, got %d", len(undoStack))
}
// Verify the stack is in reverse order (most recent first)
expectedOps := []string{"op3", "op2", "op1"}
for i, entry := range undoStack {
if entry.Operation != expectedOps[i] {
t.Errorf("Undo stack[%d]: expected operation %q, got %q", i, expectedOps[i], entry.Operation)
}
}
}
func TestOplogEntryMetadata(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
// Initialize the repository
repo := &core.OnyxRepository{}
err := repo.Init(tempDir)
if err != nil {
t.Fatalf("Failed to initialize repository: %v", err)
}
// Open the repository
openedRepo, err := core.Open(tempDir)
if err != nil {
t.Fatalf("Failed to open repository: %v", err)
}
defer openedRepo.Close()
// Perform an operation with metadata
txn, err := core.NewTransaction(openedRepo)
if err != nil {
t.Fatalf("Failed to create transaction: %v", err)
}
metadata := map[string]string{
"key1": "value1",
"key2": "value2",
}
err = txn.ExecuteWithTransactionAndMetadata("test_op", "Test with metadata", metadata, func() error {
return nil
})
txn.Close()
if err != nil {
t.Fatalf("Failed to execute transaction with metadata: %v", err)
}
// Read the entry and verify metadata
oplogPath := filepath.Join(openedRepo.GetOnyxPath(), "oplog")
reader := storage.NewOplogReader(oplogPath)
lastEntry, err := reader.ReadLastEntry()
if err != nil {
t.Fatalf("Failed to read last entry: %v", err)
}
if lastEntry.Metadata == nil {
t.Fatalf("Expected metadata to be present")
}
if lastEntry.Metadata["key1"] != "value1" {
t.Errorf("Expected metadata key1=value1, got %q", lastEntry.Metadata["key1"])
}
if lastEntry.Metadata["key2"] != "value2" {
t.Errorf("Expected metadata key2=value2, got %q", lastEntry.Metadata["key2"])
}
}