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:
- Repository initialization and management
- Working directory tracking
- File addition and staging
- Commit operations with manifest generation
- Checkout operations
- Basic diff operations
- File status determination
- 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+:
src/merge.c- Merge operations (Phase 2)src/branch.c- Branch management (Phase 2)src/tag.c- Tag operations (Phase 2)src/sync.c,src/xfer.c- Synchronization (Phase 3)src/stash.c,src/undo.c- State management (Phase 2)src/mv.c,src/rm.c- Move/remove operations (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:
- Create new repository database file
- Initialize schema (using
pkg/store/schema.go) - Set initial configuration values
- Validate repository file format
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:
- Track checked-out files and their states
- Compute file hashes for change detection
- Identify added, modified, removed, and untracked files
- Support for working directory scanning
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:
- Mark files for inclusion in version control
- Validate file paths
- Detect binary files
- Handle glob patterns
- Respect ignore patterns
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:
- Create commit from working directory changes
- Generate manifest artifact
- Store manifest and update linkage tables
- Support commit messages and metadata
- Handle initial (parent-less) commits
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:
- Scan working directory for changed/added files
- Compute hashes for all files
- Store file contents as blobs (if not already stored)
- Build manifest structure with F-lines (files) and P-lines (parents)
- Add C-line (comment) and T-lines (tags)
- Canonicalize and hash manifest
- Store manifest as blob
- Insert into manifest table
- Update mlink table (manifest-file linkage)
- Update plink table (parent-child linkage)
- 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:
- Materialize files from a manifest to working directory
- Update working directory to a different version
- Handle file permissions
- Detect and handle conflicts
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:
- Compute differences between file versions
- Unified diff format output
- Binary file detection
- Line-by-line comparison
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:
- Path simplification and canonicalization
- Relative path computation
- Cross-platform path handling
- Working directory traversal
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:
- Glob pattern matching for file filtering
- Support for
*,?,[...]patterns - Path-aware matching
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:
Week 1: Repository + WorkDir
- F1: Repository initialization
- F2: Working directory tracking
- Tests for both
Week 2: Add + Commit (Part 1)
- F3: File addition
- F4: Basic commit (no parent linking yet)
- Tests for both
Week 3: Commit (Part 2) + Diff
- F4: Complete commit with parent/child linking
- F6: Diff operations
- Tests for both
Week 4: Checkout + Utilities
- F5: Checkout operations
- F7: File path utilities
- F8: Glob matching
- Integration tests
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:
- ✅ All features (F1-F8) implemented
- ✅ All unit tests passing
- ✅ Integration tests passing
- ✅ Test coverage meets goals (>85% average)
- ✅ No race conditions:
go test -race ./... - ✅ Benchmarks defined for critical paths
- ✅ Documentation complete (godoc comments)
- ✅ Code review passed
Next Steps (Phase 2 Preview)
Phase 2 will add:
- Branch and tag management
- Merge operations (three-way merge)
- Move and remove operations
- Stash/revert operations
- Delta compression
- CLI commands (using urfave/cli)
See CAMBRIA_PHASE_2.md (future document) for details.
References
- FOSSIL_VERSION_CONTROL.md: C module responsibilities
- FOSSIL_VERSION_CONTROL_TEST.md: Test organization and requirements
- CAMBRIA_DATA_MODEL_DESIGN.md: Database schema specification
- Fossil source:
/workspace/src/for reference implementation