cambria

Cambria Phase 1: Version Control Library Implementation
Login

Overview

Phase 1 implements the core version control library features, mapping Fossil SCM's C modules to Go packages. This phase builds on Phase 0's foundation (schema, hash, store, artifact packages) and implements the primary version control operations.

Phase 1 Scope: Version control library only (no CLI, no web UI)

Phase 1 Deliverables:

  1. Repository initialization and management
  2. Working directory tracking
  3. File addition and staging
  4. Commit operations with manifest generation
  5. Checkout operations
  6. Basic diff operations
  7. File status determination
  8. Comprehensive unit tests for all operations

C Module to Go Package Mapping

Based on analysis of /workspace/src/ and FOSSIL_VERSION_CONTROL.md:

Fossil C Module Cambria Go Package Responsibility
src/checkin.c pkg/vcs/checkin.go Commit creation, manifest generation
src/checkout.c pkg/vcs/checkout.go Checkout, update operations
src/add.c pkg/vcs/add.go File addition to version control
src/content.c pkg/store/blob.go Blob content retrieval (already in Phase 0)
src/delta.c pkg/delta/delta.go Delta compression (Phase 2)
src/diff.c pkg/vcs/diff.go Diff computation
src/manifest.c pkg/artifact/manifest.go Manifest parsing (Phase 0) + validation
src/vfile.c pkg/vcs/workdir.go Working directory state tracking
src/file.c internal/fileutil/paths.go Path utilities
src/glob.c internal/fileutil/glob.go Pattern matching
src/db.c pkg/store/db.go Database operations (Phase 0)

Not Implemented in Phase 1

Deferred to Phase 2+:

Feature Implementation Plan

F1: Repository Initialization

Go Package: pkg/vcs/repo.go

Fossil Equivalent: src/db.c (repository creation), src/configure.c (initial setup)

Functionality:

API Design:

package vcs

import "github.com/yourusername/cambria/pkg/store"

// Repository represents a Cambria repository
type Repository struct {
    path string
    db   *store.DB
}

// InitRepository creates a new repository at the given path
func InitRepository(path string) (*Repository, error)

// OpenRepository opens an existing repository
func OpenRepository(path string) (*Repository, error)

// Close closes the repository database connection
func (r *Repository) Close() error

// Path returns the repository file path
func (r *Repository) Path() string

// Config returns repository configuration
func (r *Repository) Config() *Config

Test Specification:

func TestInitRepository(t *testing.T) {
    tests := []struct {
        name    string
        path    string
        wantErr bool
    }{
        {"valid path", filepath.Join(t.TempDir(), "test.cambria"), false},
        {"existing file", "", true}, // setup creates existing file
        {"invalid path", "/invalid/\x00/path.cambria", true},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            repo, err := vcs.InitRepository(tt.path)
            if (err != nil) != tt.wantErr {
                t.Errorf("InitRepository() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if err != nil {
                return
            }
            defer repo.Close()
            
            // Verify repository is usable
            if repo.Path() != tt.path {
                t.Errorf("Path() = %v, want %v", repo.Path(), tt.path)
            }
        })
    }
}

func TestOpenRepository(t *testing.T) {
    // Create a repository
    repoPath := filepath.Join(t.TempDir(), "test.cambria")
    repo1, err := vcs.InitRepository(repoPath)
    if err != nil {
        t.Fatalf("InitRepository() error = %v", err)
    }
    repo1.Close()
    
    // Open it again
    repo2, err := vcs.OpenRepository(repoPath)
    if err != nil {
        t.Fatalf("OpenRepository() error = %v", err)
    }
    defer repo2.Close()
    
    // Should be functional
    if repo2.Path() != repoPath {
        t.Errorf("Path() = %v, want %v", repo2.Path(), repoPath)
    }
}

func TestOpenNonexistentRepository(t *testing.T) {
    _, err := vcs.OpenRepository("/nonexistent/repo.cambria")
    if err == nil {
        t.Error("OpenRepository() expected error for nonexistent file")
    }
}

F2: Working Directory State Tracking

Go Package: pkg/vcs/workdir.go

Fossil Equivalent: src/vfile.c

Functionality:

Data Structures:

// FileStatus represents the status of a file in the working directory
type FileStatus int

const (
    StatusUntracked FileStatus = iota  // Not in version control
    StatusAdded                         // Staged for addition
    StatusModified                      // Modified since checkout
    StatusDeleted                       // Removed from filesystem
    StatusClean                         // Unchanged
)

// WorkingFile represents a file in the working directory
type WorkingFile struct {
    Path         string
    Status       FileStatus
    ContentHash  string     // Current hash (if exists)
    BaselineHash string     // Hash from checked-out version
    Size         int64
}

// WorkDir manages working directory state
type WorkDir struct {
    root     string
    repo     *Repository
    baseline *Manifest  // Currently checked-out manifest
}

API Design:

// NewWorkDir creates a working directory tracker
func NewWorkDir(root string, repo *Repository) (*WorkDir, error)

// Scan scans the working directory and returns file states
func (w *WorkDir) Scan() ([]WorkingFile, error)

// Status returns the status of specific files
func (w *WorkDir) Status(paths ...string) ([]WorkingFile, error)

// HashFile computes the hash of a file in the working directory
func (w *WorkDir) HashFile(path string) (string, error)

// IsModified checks if a file has been modified
func (w *WorkDir) IsModified(path string) (bool, error)

// ListUntracked returns files not under version control
func (w *WorkDir) ListUntracked() ([]string, error)

// SetBaseline sets the current checked-out manifest
func (w *WorkDir) SetBaseline(manifestUUID string) error

// Baseline returns the current baseline manifest
func (w *WorkDir) Baseline() (*Manifest, error)

Test Specification:

func TestWorkDirScan(t *testing.T) {
    tempDir := t.TempDir()
    repo := setupTestRepo(t, tempDir)
    defer repo.Close()
    
    wd, err := vcs.NewWorkDir(tempDir, repo)
    if err != nil {
        t.Fatalf("NewWorkDir() error = %v", err)
    }
    
    // Create test files
    testFiles := map[string]string{
        "file1.txt": "content1",
        "file2.txt": "content2",
        "sub/file3.txt": "content3",
    }
    
    for path, content := range testFiles {
        fullPath := filepath.Join(tempDir, path)
        os.MkdirAll(filepath.Dir(fullPath), 0755)
        os.WriteFile(fullPath, []byte(content), 0644)
    }
    
    files, err := wd.Scan()
    if err != nil {
        t.Fatalf("Scan() error = %v", err)
    }
    
    // All files should be untracked initially
    if len(files) != len(testFiles) {
        t.Errorf("Scan() found %d files, want %d", len(files), len(testFiles))
    }
    
    for _, f := range files {
        if f.Status != vcs.StatusUntracked {
            t.Errorf("file %s status = %v, want StatusUntracked", f.Path, f.Status)
        }
    }
}

func TestWorkDirModifiedDetection(t *testing.T) {
    tempDir := t.TempDir()
    repo := setupTestRepo(t, tempDir)
    defer repo.Close()
    
    wd, err := vcs.NewWorkDir(tempDir, repo)
    if err != nil {
        t.Fatalf("NewWorkDir() error = %v", err)
    }
    
    // Simulate a checkout
    filePath := filepath.Join(tempDir, "test.txt")
    content := []byte("original content")
    os.WriteFile(filePath, content, 0644)
    
    // Store baseline hash
    hash := hash.ComputeSHA256(content)
    // (Assume we've set up baseline manifest with this file)
    
    // File should be clean initially
    modified, err := wd.IsModified("test.txt")
    if err != nil {
        t.Fatalf("IsModified() error = %v", err)
    }
    if modified {
        t.Error("IsModified() = true, want false for unchanged file")
    }
    
    // Modify the file
    os.WriteFile(filePath, []byte("modified content"), 0644)
    
    modified, err = wd.IsModified("test.txt")
    if err != nil {
        t.Fatalf("IsModified() error = %v", err)
    }
    if !modified {
        t.Error("IsModified() = false, want true for modified file")
    }
}

func TestWorkDirHashFile(t *testing.T) {
    tempDir := t.TempDir()
    repo := setupTestRepo(t, tempDir)
    defer repo.Close()
    
    wd, err := vcs.NewWorkDir(tempDir, repo)
    if err != nil {
        t.Fatalf("NewWorkDir() error = %v", err)
    }
    
    tests := []struct {
        name     string
        content  []byte
        wantHash string
    }{
        {"empty", []byte{}, hash.ComputeSHA256([]byte{})},
        {"small", []byte("hello"), hash.ComputeSHA256([]byte("hello"))},
        {"with newline", []byte("hello\n"), hash.ComputeSHA256([]byte("hello\n"))},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            filePath := filepath.Join(tempDir, tt.name+".txt")
            os.WriteFile(filePath, tt.content, 0644)
            
            gotHash, err := wd.HashFile(tt.name + ".txt")
            if err != nil {
                t.Fatalf("HashFile() error = %v", err)
            }
            if gotHash != tt.wantHash {
                t.Errorf("HashFile() = %v, want %v", gotHash, tt.wantHash)
            }
        })
    }
}

F3: File Addition

Go Package: pkg/vcs/add.go

Fossil Equivalent: src/add.c

Functionality:

API Design:

// Add adds files to version control
func (r *Repository) Add(workDir *WorkDir, paths ...string) error

// AddWithOptions adds files with specific options
func (r *Repository) AddWithOptions(workDir *WorkDir, opts AddOptions, paths ...string) error

// AddOptions controls file addition behavior
type AddOptions struct {
    Force      bool  // Force addition of ignored files
    Recursive  bool  // Recursively add directories
    DryRun     bool  // Don't actually add, just report
}

// IsIgnored checks if a path matches ignore patterns
func (r *Repository) IsIgnored(path string) (bool, error)

Test Specification:

func TestAdd(t *testing.T) {
    tempDir := t.TempDir()
    repo := setupTestRepo(t, tempDir)
    defer repo.Close()
    wd := setupWorkDir(t, tempDir, repo)
    
    // Create test file
    filePath := filepath.Join(tempDir, "test.txt")
    os.WriteFile(filePath, []byte("content"), 0644)
    
    // Add the file
    err := repo.Add(wd, "test.txt")
    if err != nil {
        t.Fatalf("Add() error = %v", err)
    }
    
    // Verify file is now tracked
    files, err := wd.Status("test.txt")
    if err != nil {
        t.Fatalf("Status() error = %v", err)
    }
    
    if len(files) != 1 {
        t.Fatalf("Status() returned %d files, want 1", len(files))
    }
    
    if files[0].Status != vcs.StatusAdded {
        t.Errorf("file status = %v, want StatusAdded", files[0].Status)
    }
}

func TestAddNonexistentFile(t *testing.T) {
    tempDir := t.TempDir()
    repo := setupTestRepo(t, tempDir)
    defer repo.Close()
    wd := setupWorkDir(t, tempDir, repo)
    
    err := repo.Add(wd, "nonexistent.txt")
    if err == nil {
        t.Error("Add() expected error for nonexistent file")
    }
}

func TestAddRecursive(t *testing.T) {
    tempDir := t.TempDir()
    repo := setupTestRepo(t, tempDir)
    defer repo.Close()
    wd := setupWorkDir(t, tempDir, repo)
    
    // Create directory with files
    os.MkdirAll(filepath.Join(tempDir, "subdir"), 0755)
    os.WriteFile(filepath.Join(tempDir, "subdir", "file1.txt"), []byte("1"), 0644)
    os.WriteFile(filepath.Join(tempDir, "subdir", "file2.txt"), []byte("2"), 0644)
    
    opts := vcs.AddOptions{Recursive: true}
    err := repo.AddWithOptions(wd, opts, "subdir")
    if err != nil {
        t.Fatalf("AddWithOptions() error = %v", err)
    }
    
    // Verify both files added
    files, err := wd.Status("subdir/file1.txt", "subdir/file2.txt")
    if err != nil {
        t.Fatalf("Status() error = %v", err)
    }
    
    if len(files) != 2 {
        t.Errorf("Status() returned %d files, want 2", len(files))
    }
    
    for _, f := range files {
        if f.Status != vcs.StatusAdded {
            t.Errorf("file %s status = %v, want StatusAdded", f.Path, f.Status)
        }
    }
}

func TestAddIgnoredFile(t *testing.T) {
    tempDir := t.TempDir()
    repo := setupTestRepo(t, tempDir)
    defer repo.Close()
    wd := setupWorkDir(t, tempDir, repo)
    
    // Set up ignore pattern (e.g., *.log)
    // (Would need config mechanism)
    
    // Create ignored file
    filePath := filepath.Join(tempDir, "debug.log")
    os.WriteFile(filePath, []byte("logs"), 0644)
    
    // Add should fail or warn
    err := repo.Add(wd, "debug.log")
    if err == nil {
        t.Error("Add() expected error or warning for ignored file")
    }
    
    // Force add should succeed
    opts := vcs.AddOptions{Force: true}
    err = repo.AddWithOptions(wd, opts, "debug.log")
    if err != nil {
        t.Errorf("AddWithOptions(Force=true) error = %v", err)
    }
}

F4: Commit Operations

Go Package: pkg/vcs/checkin.go

Fossil Equivalent: src/checkin.c, src/manifest.c

Functionality:

API Design:

// CommitOptions specifies commit parameters
type CommitOptions struct {
    Message     string    // Commit message
    Author      string    // Author name
    Branch      string    // Branch name (optional)
    Tags        []string  // Tags to apply
    AllowEmpty  bool      // Allow commits with no changes
}

// Commit creates a new commit from working directory state
func (r *Repository) Commit(workDir *WorkDir, opts CommitOptions) (manifestUUID string, err error)

// ValidateCommit checks if a commit is valid before creating it
func (r *Repository) ValidateCommit(workDir *WorkDir) error

Implementation Steps:

  1. Scan working directory for changed/added files
  2. Compute hashes for all files
  3. Store file contents as blobs (if not already stored)
  4. Build manifest structure with F-lines (files) and P-lines (parents)
  5. Add C-line (comment) and T-lines (tags)
  6. Canonicalize and hash manifest
  7. Store manifest as blob
  8. Insert into manifest table
  9. Update mlink table (manifest-file linkage)
  10. Update plink table (parent-child linkage)
  11. Update label table (tags/branches)

Test Specification:

func TestCommitInitial(t *testing.T) {
    tempDir := t.TempDir()
    repo := setupTestRepo(t, tempDir)
    defer repo.Close()
    wd := setupWorkDir(t, tempDir, repo)
    
    // Add and commit a file
    filePath := filepath.Join(tempDir, "readme.txt")
    content := []byte("initial content")
    os.WriteFile(filePath, content, 0644)
    
    repo.Add(wd, "readme.txt")
    
    opts := vcs.CommitOptions{
        Message: "Initial commit",
        Author:  "Test User",
    }
    
    manifestUUID, err := repo.Commit(wd, opts)
    if err != nil {
        t.Fatalf("Commit() error = %v", err)
    }
    
    if manifestUUID == "" {
        t.Error("Commit() returned empty UUID")
    }
    
    // Verify manifest stored in database
    manifest, err := store.GetManifestByUUID(repo.db, manifestUUID)
    if err != nil {
        t.Fatalf("GetManifestByUUID() error = %v", err)
    }
    
    if manifest == nil {
        t.Error("Manifest not found in database")
    }
    
    // Verify mlink entries
    mlinks, err := store.GetMlinksByManifest(repo.db, manifest.RID)
    if err != nil {
        t.Fatalf("GetMlinksByManifest() error = %v", err)
    }
    
    if len(mlinks) != 1 {
        t.Errorf("Expected 1 mlink entry, got %d", len(mlinks))
    }
    
    if mlinks[0].Path != "readme.txt" {
        t.Errorf("mlink path = %v, want 'readme.txt'", mlinks[0].Path)
    }
}

func TestCommitWithParent(t *testing.T) {
    tempDir := t.TempDir()
    repo := setupTestRepo(t, tempDir)
    defer repo.Close()
    wd := setupWorkDir(t, tempDir, repo)
    
    // Create initial commit
    file1 := filepath.Join(tempDir, "file1.txt")
    os.WriteFile(file1, []byte("content1"), 0644)
    repo.Add(wd, "file1.txt")
    
    parent1UUID, err := repo.Commit(wd, vcs.CommitOptions{Message: "First"})
    if err != nil {
        t.Fatalf("Commit() error = %v", err)
    }
    
    // Create second commit
    file2 := filepath.Join(tempDir, "file2.txt")
    os.WriteFile(file2, []byte("content2"), 0644)
    repo.Add(wd, "file2.txt")
    
    child UUID, err := repo.Commit(wd, vcs.CommitOptions{Message: "Second"})
    if err != nil {
        t.Fatalf("Commit() error = %v", err)
    }
    
    // Verify parent-child relationship in plink
    plinks, err := store.GetPlinksByChild(repo.db, childRID)
    if err != nil {
        t.Fatalf("GetPlinksByChild() error = %v", err)
    }
    
    if len(plinks) != 1 {
        t.Errorf("Expected 1 parent, got %d", len(plinks))
    }
    
    // Verify parent UUID matches
    parentManifest, _ := store.GetManifestByRID(repo.db, plinks[0].ParentRID)
    if parentManifest.UUID != parent1UUID {
        t.Errorf("Parent UUID = %v, want %v", parentManifest.UUID, parent1UUID)
    }
}

func TestCommitEmptyChanges(t *testing.T) {
    tempDir := t.TempDir()
    repo := setupTestRepo(t, tempDir)
    defer repo.Close()
    wd := setupWorkDir(t, tempDir, repo)
    
    // Try to commit with no changes
    opts := vcs.CommitOptions{Message: "Empty"}
    _, err := repo.Commit(wd, opts)
    
    // Should fail unless AllowEmpty is set
    if err == nil {
        t.Error("Commit() expected error for empty changes")
    }
    
    // With AllowEmpty should succeed
    opts.AllowEmpty = true
    _, err = repo.Commit(wd, opts)
    if err != nil {
        t.Errorf("Commit(AllowEmpty=true) error = %v", err)
    }
}

func TestCommitValidation(t *testing.T) {
    tempDir := t.TempDir()
    repo := setupTestRepo(t, tempDir)
    defer repo.Close()
    wd := setupWorkDir(t, tempDir, repo)
    
    tests := []struct {
        name    string
        setup   func()
        wantErr bool
    }{
        {
            name: "valid",
            setup: func() {
                os.WriteFile(filepath.Join(tempDir, "valid.txt"), []byte("ok"), 0644)
                repo.Add(wd, "valid.txt")
            },
            wantErr: false,
        },
        {
            name: "binary file warning",
            setup: func() {
                // Create binary file (with NUL bytes)
                binary := append([]byte("binary"), 0x00, 0x01, 0x02)
                os.WriteFile(filepath.Join(tempDir, "binary.dat"), binary, 0644)
                repo.Add(wd, "binary.dat")
            },
            wantErr: false, // Warning, not error
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            tt.setup()
            err := repo.ValidateCommit(wd)
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateCommit() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

func TestCommitManifestFormat(t *testing.T) {
    tempDir := t.TempDir()
    repo := setupTestRepo(t, tempDir)
    defer repo.Close()
    wd := setupWorkDir(t, tempDir, repo)
    
    // Create multiple files
    files := map[string]string{
        "b.txt": "b content",
        "a.txt": "a content",
        "c.txt": "c content",
    }
    
    for name, content := range files {
        path := filepath.Join(tempDir, name)
        os.WriteFile(path, []byte(content), 0644)
        repo.Add(wd, name)
    }
    
    manifestUUID, err := repo.Commit(wd, vcs.CommitOptions{
        Message: "Test commit",
    })
    if err != nil {
        t.Fatalf("Commit() error = %v", err)
    }
    
    // Retrieve manifest content
    manifestBlob, err := store.GetBlobByUUID(repo.db, manifestUUID)
    if err != nil {
        t.Fatalf("GetBlobByUUID() error = %v", err)
    }
    
    manifestText := string(manifestBlob.Content)
    
    // Verify format
    if !strings.Contains(manifestText, "C Test commit") {
        t.Error("Manifest missing comment line")
    }
    
    // Verify files are sorted
    lines := strings.Split(manifestText, "\n")
    var filePaths []string
    for _, line := range lines {
        if strings.HasPrefix(line, "F ") {
            parts := strings.Fields(line)
            if len(parts) >= 2 {
                filePaths = append(filePaths, parts[1])
            }
        }
    }
    
    if !sort.StringsAreSorted(filePaths) {
        t.Errorf("Manifest files not sorted: %v", filePaths)
    }
}

F5: Checkout Operations

Go Package: pkg/vcs/checkout.go

Fossil Equivalent: src/checkout.c, src/update.c

Functionality:

API Design:

// CheckoutOptions controls checkout behavior
type CheckoutOptions struct {
    Force      bool  // Overwrite local modifications
    DryRun     bool  // Show what would be done
    Verbose    bool  // Detailed output
}

// Checkout materializes a manifest into the working directory
func (r *Repository) Checkout(workDir *WorkDir, manifestUUID string, opts CheckoutOptions) error

// CheckoutConflict represents a conflict during checkout
type CheckoutConflict struct {
    Path       string
    Type       ConflictType
    LocalHash  string
    RemoteHash string
}

type ConflictType int

const (
    ConflictModified ConflictType = iota  // Local modifications
    ConflictDeleted                        // File deleted locally
    ConflictExists                         // Untracked file exists
)

Test Specification:

func TestCheckoutInitial(t *testing.T) {
    tempDir := t.TempDir()
    repo := setupTestRepo(t, tempDir)
    defer repo.Close()
    wd := setupWorkDir(t, tempDir, repo)
    
    // Create and commit files
    manifestUUID := createTestCommit(t, repo, wd, map[string]string{
        "file1.txt": "content1",
        "file2.txt": "content2",
    })
    
    // Clear working directory
    os.RemoveAll(filepath.Join(tempDir, "file1.txt"))
    os.RemoveAll(filepath.Join(tempDir, "file2.txt"))
    
    // Checkout
    err := repo.Checkout(wd, manifestUUID, vcs.CheckoutOptions{})
    if err != nil {
        t.Fatalf("Checkout() error = %v", err)
    }
    
    // Verify files exist with correct content
    content1, err := os.ReadFile(filepath.Join(tempDir, "file1.txt"))
    if err != nil {
        t.Fatalf("ReadFile() error = %v", err)
    }
    if string(content1) != "content1" {
        t.Errorf("file1.txt content = %v, want 'content1'", string(content1))
    }
    
    content2, err := os.ReadFile(filepath.Join(tempDir, "file2.txt"))
    if err != nil {
        t.Fatalf("ReadFile() error = %v", err)
    }
    if string(content2) != "content2" {
        t.Errorf("file2.txt content = %v, want 'content2'", string(content2))
    }
}

func TestCheckoutWithLocalModifications(t *testing.T) {
    tempDir := t.TempDir()
    repo := setupTestRepo(t, tempDir)
    defer repo.Close()
    wd := setupWorkDir(t, tempDir, repo)
    
    // Create initial commit
    uuid1 := createTestCommit(t, repo, wd, map[string]string{
        "file.txt": "version1",
    })
    
    // Modify file locally
    os.WriteFile(filepath.Join(tempDir, "file.txt"), []byte("local changes"), 0644)
    
    // Create second commit
    createTestCommit(t, repo, wd, map[string]string{
        "file.txt": "version2",
    })
    
    // Try to checkout second version - should fail
    err := repo.Checkout(wd, uuid2, vcs.CheckoutOptions{})
    if err == nil {
        t.Error("Checkout() expected error for local modifications")
    }
    
    // With Force should succeed
    err = repo.Checkout(wd, uuid2, vcs.CheckoutOptions{Force: true})
    if err != nil {
        t.Errorf("Checkout(Force=true) error = %v", err)
    }
    
    // Verify file updated
    content, _ := os.ReadFile(filepath.Join(tempDir, "file.txt"))
    if string(content) != "version2" {
        t.Errorf("file content = %v, want 'version2'", string(content))
    }
}

func TestCheckoutWithUntracked File(t *testing.T) {
    tempDir := t.TempDir()
    repo := setupTestRepo(t, tempDir)
    defer repo.Close()
    wd := setupWorkDir(t, tempDir, repo)
    
    // Create commit with file
    manifestUUID := createTestCommit(t, repo, wd, map[string]string{
        "conflict.txt": "tracked content",
    })
    
    // Remove from working dir
    os.Remove(filepath.Join(tempDir, "conflict.txt"))
    
    // Create untracked file with same name
    os.WriteFile(filepath.Join(tempDir, "conflict.txt"), []byte("untracked"), 0644)
    
    // Checkout should detect conflict
    err := repo.Checkout(wd, manifestUUID, vcs.CheckoutOptions{})
    if err == nil {
        t.Error("Checkout() expected error for untracked file conflict")
    }
}

func TestCheckoutPermissions(t *testing.T) {
    if runtime.GOOS == "windows" {
        t.Skip("Skipping permission test on Windows")
    }
    
    tempDir := t.TempDir()
    repo := setupTestRepo(t, tempDir)
    defer repo.Close()
    wd := setupWorkDir(t, tempDir, repo)
    
    // Create executable file
    exePath := filepath.Join(tempDir, "script.sh")
    os.WriteFile(exePath, []byte("#!/bin/sh\necho test"), 0755)
    repo.Add(wd, "script.sh")
    
    manifestUUID, _ := repo.Commit(wd, vcs.CommitOptions{Message: "Add script"})
    
    // Remove file
    os.Remove(exePath)
    
    // Checkout
    repo.Checkout(wd, manifestUUID, vcs.CheckoutOptions{})
    
    // Verify executable bit preserved
    info, err := os.Stat(exePath)
    if err != nil {
        t.Fatalf("Stat() error = %v", err)
    }
    
    if info.Mode()&0111 == 0 {
        t.Error("Executable permission not preserved")
    }
}

F6: Diff Operations

Go Package: pkg/vcs/diff.go

Fossil Equivalent: src/diff.c, src/diffcmd.c

Functionality:

API Design:

// DiffOptions controls diff generation
type DiffOptions struct {
    Context    int   // Lines of context (default 3)
    Unified    bool  // Unified diff format (default true)
    IgnoreWS   bool  // Ignore whitespace changes
}

// Diff computes difference between two file versions
func Diff(original, modified []byte, opts DiffOptions) (string, error)

// DiffFiles computes diff between two files in repository
func (r *Repository) DiffFiles(uuid1, uuid2 string, opts DiffOptions) (string, error)

// DiffManifests computes diff between two manifests
func (r *Repository) DiffManifests(manifestUUID1, manifestUUID2 string, opts DiffOptions) ([]FileDiff, error)

// FileDiff represents changes to a single file
type FileDiff struct {
    Path    string
    Status  DiffStatus
    OldHash string
    NewHash string
    Diff    string  // Unified diff text
}

type DiffStatus int

const (
    DiffAdded DiffStatus = iota
    DiffModified
    DiffDeleted
    DiffRenamed
)

// IsBinary detects if content is binary
func IsBinary(data []byte) bool

Test Specification:

func TestDiff(t *testing.T) {
    tests := []struct {
        name     string
        original string
        modified string
        want     string  // Expected diff pattern
    }{
        {
            name:     "single line change",
            original: "line1\nline2\nline3\n",
            modified: "line1\nmodified\nline3\n",
            want:     "-line2\n+modified",
        },
        {
            name:     "addition",
            original: "line1\n",
            modified: "line1\nnew line\n",
            want:     "+new line",
        },
        {
            name:     "deletion",
            original: "line1\nline2\n",
            modified: "line1\n",
            want:     "-line2",
        },
        {
            name:     "no changes",
            original: "same\n",
            modified: "same\n",
            want:     "",
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            diff, err := vcs.Diff(
                []byte(tt.original),
                []byte(tt.modified),
                vcs.DiffOptions{Context: 1},
            )
            if err != nil {
                t.Fatalf("Diff() error = %v", err)
            }
            
            if tt.want == "" && diff != "" {
                t.Errorf("Diff() = %v, want empty", diff)
            }
            
            if tt.want != "" && !strings.Contains(diff, tt.want) {
                t.Errorf("Diff() = %v, want containing %v", diff, tt.want)
            }
        })
    }
}

func TestIsBinary(t *testing.T) {
    tests := []struct {
        name    string
        data    []byte
        want    bool
    }{
        {"text", []byte("hello world"), false},
        {"empty", []byte{}, false},
        {"nul byte", []byte{0x00}, true},
        {"mixed", []byte("text\x00binary"), true},
        {"utf-8", []byte("hello 世界"), false},
        {"long line", []byte(strings.Repeat("a", 10000)), false},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := vcs.IsBinary(tt.data)
            if got != tt.want {
                t.Errorf("IsBinary() = %v, want %v", got, tt.want)
            }
        })
    }
}

func TestDiffManifests(t *testing.T) {
    tempDir := t.TempDir()
    repo := setupTestRepo(t, tempDir)
    defer repo.Close()
    wd := setupWorkDir(t, tempDir, repo)
    
    // Create initial commit
    uuid1 := createTestCommit(t, repo, wd, map[string]string{
        "file1.txt": "version1",
        "file2.txt": "unchanged",
    })
    
    // Modify and create new commit
    os.WriteFile(filepath.Join(tempDir, "file1.txt"), []byte("version2"), 0644)
    os.WriteFile(filepath.Join(tempDir, "file3.txt"), []byte("new file"), 0644)
    repo.Add(wd, "file3.txt")
    
    uuid2, _ := repo.Commit(wd, vcs.CommitOptions{Message: "Changes"})
    
    // Compute diff
    diffs, err := repo.DiffManifests(uuid1, uuid2, vcs.DiffOptions{})
    if err != nil {
        t.Fatalf("DiffManifests() error = %v", err)
    }
    
    // Verify results
    statusMap := make(map[string]vcs.DiffStatus)
    for _, d := range diffs {
        statusMap[d.Path] = d.Status
    }
    
    if statusMap["file1.txt"] != vcs.DiffModified {
        t.Errorf("file1.txt status = %v, want DiffModified", statusMap["file1.txt"])
    }
    
    if statusMap["file3.txt"] != vcs.DiffAdded {
        t.Errorf("file3.txt status = %v, want DiffAdded", statusMap["file3.txt"])
    }
    
    // file2.txt should not appear (unchanged)
    if _, exists := statusMap["file2.txt"]; exists {
        t.Error("file2.txt should not appear in diff (unchanged)")
    }
}

F7: File Path Utilities

Go Package: internal/fileutil/paths.go

Fossil Equivalent: src/file.c

Functionality:

API Design:

package fileutil

// SimplifyPath removes redundant elements from path
func SimplifyPath(path string) string

// RelPath computes relative path from base to target
func RelPath(base, target string) (string, error)

// IsSubPath checks if child is under parent directory
func IsSubPath(parent, child string) bool

// WalkFiles walks directory tree with filtering
func WalkFiles(root string, ignore []string, fn func(path string, info os.FileInfo) error) error

Test Specification:

func TestSimplifyPath(t *testing.T) {
    tests := []struct {
        input string
        want  string
    }{
        {"a/b/../c", "a/c"},
        {"a/./b", "a/b"},
        {"a//b", "a/b"},
        {"./a", "a"},
        {"../a", "../a"},
        {"/a/b/../c", "/a/c"},
    }
    
    for _, tt := range tests {
        t.Run(tt.input, func(t *testing.T) {
            got := fileutil.SimplifyPath(tt.input)
            if got != tt.want {
                t.Errorf("SimplifyPath(%v) = %v, want %v", tt.input, got, tt.want)
            }
        })
    }
}

func TestRelPath(t *testing.T) {
    tests := []struct {
        base    string
        target  string
        want    string
        wantErr bool
    }{
        {"/a/b", "/a/b/c", "c", false},
        {"/a/b", "/a/c", "../c", false},
        {"/a/b/c", "/a/d/e", "../../d/e", false},
        {"/a", "/a", ".", false},
    }
    
    for _, tt := range tests {
        t.Run(tt.base+"->"+tt.target, func(t *testing.T) {
            got, err := fileutil.RelPath(tt.base, tt.target)
            if (err != nil) != tt.wantErr {
                t.Errorf("RelPath() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if got != tt.want {
                t.Errorf("RelPath() = %v, want %v", got, tt.want)
            }
        })
    }
}

func TestWalkFiles(t *testing.T) {
    tempDir := t.TempDir()
    
    // Create test structure
    os.MkdirAll(filepath.Join(tempDir, "dir1"), 0755)
    os.MkdirAll(filepath.Join(tempDir, "dir2", "subdir"), 0755)
    os.WriteFile(filepath.Join(tempDir, "file1.txt"), []byte("1"), 0644)
    os.WriteFile(filepath.Join(tempDir, "dir1", "file2.txt"), []byte("2"), 0644)
    os.WriteFile(filepath.Join(tempDir, "dir2", "file3.txt"), []byte("3"), 0644)
    os.WriteFile(filepath.Join(tempDir, "dir2", "subdir", "file4.txt"), []byte("4"), 0644)
    
    var foundFiles []string
    err := fileutil.WalkFiles(tempDir, nil, func(path string, info os.FileInfo) error {
        if !info.IsDir() {
            rel, _ := filepath.Rel(tempDir, path)
            foundFiles = append(foundFiles, rel)
        }
        return nil
    })
    
    if err != nil {
        t.Fatalf("WalkFiles() error = %v", err)
    }
    
    if len(foundFiles) != 4 {
        t.Errorf("WalkFiles() found %d files, want 4", len(foundFiles))
    }
}

F8: Glob Pattern Matching

Go Package: internal/fileutil/glob.go

Fossil Equivalent: src/glob.c

Functionality:

API Design:

package fileutil

// Match reports whether path matches the glob pattern
func Match(pattern, path string) (bool, error)

// MatchAny reports whether path matches any of the patterns
func MatchAny(patterns []string, path string) bool

Test Specification:

func TestMatch(t *testing.T) {
    tests := []struct {
        pattern string
        path    string
        want    bool
    }{
        {"*.txt", "file.txt", true},
        {"*.txt", "file.go", false},
        {"test?", "test1", true},
        {"test?", "test12", false},
        {"src/*.go", "src/main.go", true},
        {"src/*.go", "src/pkg/util.go", false},
        {"src/**/*.go", "src/pkg/util.go", true},
        {"[abc].txt", "a.txt", true},
        {"[abc].txt", "d.txt", false},
    }
    
    for _, tt := range tests {
        t.Run(tt.pattern+":"+tt.path, func(t *testing.T) {
            got, err := fileutil.Match(tt.pattern, tt.path)
            if err != nil {
                t.Fatalf("Match() error = %v", err)
            }
            if got != tt.want {
                t.Errorf("Match() = %v, want %v", got, tt.want)
            }
        })
    }
}

Integration Tests

Beyond unit tests, create integration tests that exercise complete workflows:

func TestWorkflowInitAddCommit(t *testing.T) {
    tempDir := t.TempDir()
    
    // Initialize repository
    repoPath := filepath.Join(tempDir, "test.cambria")
    repo, err := vcs.InitRepository(repoPath)
    if err != nil {
        t.Fatalf("InitRepository() error = %v", err)
    }
    defer repo.Close()
    
    workDir := filepath.Join(tempDir, "work")
    os.MkdirAll(workDir, 0755)
    
    wd, err := vcs.NewWorkDir(workDir, repo)
    if err != nil {
        t.Fatalf("NewWorkDir() error = %v", err)
    }
    
    // Add files
    os.WriteFile(filepath.Join(workDir, "readme.md"), []byte("# Project"), 0644)
    os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main"), 0644)
    
    repo.Add(wd, "readme.md", "main.go")
    
    // Commit
    uuid, err := repo.Commit(wd, vcs.CommitOptions{
        Message: "Initial commit",
        Author:  "Test User",
    })
    if err != nil {
        t.Fatalf("Commit() error = %v", err)
    }
    
    // Verify commit created
    if uuid == "" {
        t.Error("Expected non-empty commit UUID")
    }
    
    // Verify clean status
    files, _ := wd.Status()
    for _, f := range files {
        if f.Status != vcs.StatusClean {
            t.Errorf("Expected all files clean, got %v for %s", f.Status, f.Path)
        }
    }
}

func TestWorkflowCommitCheckoutModify(t *testing.T) {
    tempDir := t.TempDir()
    repoPath := filepath.Join(tempDir, "test.cambria")
    repo, _ := vcs.InitRepository(repoPath)
    defer repo.Close()
    
    workDir := filepath.Join(tempDir, "work")
    os.MkdirAll(workDir, 0755)
    wd, _ := vcs.NewWorkDir(workDir, repo)
    
    // Create version 1
    os.WriteFile(filepath.Join(workDir, "file.txt"), []byte("version 1"), 0644)
    repo.Add(wd, "file.txt")
    uuid1, _ := repo.Commit(wd, vcs.CommitOptions{Message: "v1"})
    
    // Create version 2
    os.WriteFile(filepath.Join(workDir, "file.txt"), []byte("version 2"), 0644)
    uuid2, _ := repo.Commit(wd, vcs.CommitOptions{Message: "v2"})
    
    // Checkout version 1
    repo.Checkout(wd, uuid1, vcs.CheckoutOptions{})
    
    // Verify content
    content, _ := os.ReadFile(filepath.Join(workDir, "file.txt"))
    if string(content) != "version 1" {
        t.Errorf("Expected 'version 1', got %v", string(content))
    }
    
    // Checkout version 2
    repo.Checkout(wd, uuid2, vcs.CheckoutOptions{})
    
    // Verify content
    content, _ = os.ReadFile(filepath.Join(workDir, "file.txt"))
    if string(content) != "version 2" {
        t.Errorf("Expected 'version 2', got %v", string(content))
    }
}

Test Coverage Goals

Package Target Coverage Critical Areas
pkg/vcs/repo.go 90%+ Init, open, config
pkg/vcs/workdir.go 85%+ Scan, hash, status detection
pkg/vcs/add.go 85%+ Add, ignore patterns
pkg/vcs/checkin.go 90%+ Commit, manifest generation, validation
pkg/vcs/checkout.go 85%+ Checkout, conflicts, permissions
pkg/vcs/diff.go 80%+ Diff algorithm, binary detection
internal/fileutil/ 90%+ Path operations, glob matching

Implementation Order

Recommended implementation sequence:

  1. Week 1: Repository + WorkDir

    • F1: Repository initialization
    • F2: Working directory tracking
    • Tests for both
  2. Week 2: Add + Commit (Part 1)

    • F3: File addition
    • F4: Basic commit (no parent linking yet)
    • Tests for both
  3. Week 3: Commit (Part 2) + Diff

    • F4: Complete commit with parent/child linking
    • F6: Diff operations
    • Tests for both
  4. Week 4: Checkout + Utilities

    • F5: Checkout operations
    • F7: File path utilities
    • F8: Glob matching
    • Integration tests
  5. Week 5: Polish and Documentation

    • Achieve coverage goals
    • Documentation
    • Performance benchmarks
    • Bug fixes

Total: ~5 weeks for Phase 1 completion.

Success Criteria

Phase 1 is complete when:

  1. ✅ All features (F1-F8) implemented
  2. ✅ All unit tests passing
  3. ✅ Integration tests passing
  4. ✅ Test coverage meets goals (>85% average)
  5. ✅ No race conditions: go test -race ./...
  6. ✅ Benchmarks defined for critical paths
  7. ✅ Documentation complete (godoc comments)
  8. ✅ Code review passed

Next Steps (Phase 2 Preview)

Phase 2 will add:

See CAMBRIA_PHASE_2.md (future document) for details.

References