temp for tree extraction

This commit is contained in:
2025-10-15 19:19:52 -04:00
commit ffa434630f
51 changed files with 9036 additions and 0 deletions

141
internal/commands/clone.go Normal file
View File

@ -0,0 +1,141 @@
package commands
import (
"fmt"
"os"
"path/filepath"
"git.dws.rip/DWS/onyx/internal/core"
gogit "github.com/go-git/go-git/v5"
"github.com/spf13/cobra"
)
// NewCloneCmd creates the clone command
func NewCloneCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "clone <url> [directory]",
Short: "Clone a repository and initialize Onyx",
Long: `Clone a repository from a remote URL and initialize Onyx.
This command combines Git's clone functionality with automatic Onyx setup.
The cloned URL is automatically configured as the primary remote.
Examples:
onx clone https://github.com/user/repo.git
onx clone git@github.com:user/repo.git my-project`,
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
url := args[0]
var directory string
if len(args) == 2 {
directory = args[1]
}
return runClone(url, directory)
},
}
return cmd
}
// runClone executes the clone command
func runClone(url, directory string) error {
// If no directory specified, derive from URL
if directory == "" {
directory = deriveDirectoryFromURL(url)
}
// Check if directory already exists
if _, err := os.Stat(directory); err == nil {
return fmt.Errorf("directory '%s' already exists", directory)
}
// Clone the repository using go-git
fmt.Printf("Cloning into '%s'...\n", directory)
_, err := gogit.PlainClone(directory, false, &gogit.CloneOptions{
URL: url,
Progress: os.Stdout,
})
if err != nil {
return fmt.Errorf("failed to clone repository: %w", err)
}
fmt.Println("✓ Clone completed")
// Change to the cloned directory
originalDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
absDir, err := filepath.Abs(directory)
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
}
if err := os.Chdir(absDir); err != nil {
return fmt.Errorf("failed to change directory: %w", err)
}
defer os.Chdir(originalDir)
// Initialize Onyx in the cloned repository
fmt.Println("Initializing Onyx...")
// Check if .git exists (it should, since we just cloned)
gitDir := filepath.Join(absDir, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
return fmt.Errorf("no .git directory found after clone")
}
// Initialize Onyx repository structure (this will create .onx directory)
onyxRepo := &core.OnyxRepository{}
if err := onyxRepo.Init(absDir); err != nil {
return fmt.Errorf("failed to initialize Onyx: %w", err)
}
defer onyxRepo.Close()
// Create default workstream matching the cloned branch
wsManager := core.NewWorkstreamManager(onyxRepo)
if err := wsManager.CreateDefaultWorkstream(); err != nil {
// Don't fail clone if workstream creation fails, just warn
fmt.Fprintf(os.Stderr, "Warning: failed to create default workstream: %v\n", err)
}
// Get the workstream name to display to the user
currentWs, _ := wsManager.GetCurrentWorkstreamName()
if currentWs == "" {
currentWs = "main" // fallback
}
// The remote 'origin' is automatically configured by git clone
// We don't need to do anything extra - it's already the primary remote
fmt.Printf("\n✓ Successfully cloned and initialized Onyx repository in '%s'\n", directory)
fmt.Printf("\n✓ Created workstream '%s' tracking branch '%s'\n", currentWs, currentWs)
fmt.Printf("\nThe remote 'origin' (%s) is configured as your primary remote.\n", url)
fmt.Printf("\nNext steps:\n")
fmt.Printf(" cd %s\n", directory)
fmt.Printf(" onx save -m \"message\" # Save your work\n")
fmt.Printf(" onx new <name> # Create a new workstream\n")
return nil
}
// deriveDirectoryFromURL extracts a directory name from a Git URL
func deriveDirectoryFromURL(url string) string {
// Remove .git suffix if present
name := url
if filepath.Ext(name) == ".git" {
name = name[:len(name)-4]
}
// Extract the last component of the path
name = filepath.Base(name)
// Handle edge cases
if name == "/" || name == "." {
name = "repository"
}
return name
}

232
internal/commands/daemon.go Normal file
View File

@ -0,0 +1,232 @@
package commands
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
"git.dws.rip/DWS/onyx/internal/core"
"github.com/spf13/cobra"
)
// NewDaemonCmd creates the daemon command with start, stop, and status subcommands
func NewDaemonCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "daemon",
Short: "Manage the Onyx daemon for transparent versioning",
Long: `The daemon command controls the Onyx background daemon that monitors
your repository for changes and automatically creates snapshots.`,
}
cmd.AddCommand(newDaemonStartCmd())
cmd.AddCommand(newDaemonStopCmd())
cmd.AddCommand(newDaemonStatusCmd())
return cmd
}
// newDaemonStartCmd creates the daemon start subcommand
func newDaemonStartCmd() *cobra.Command {
return &cobra.Command{
Use: "start",
Short: "Start the Onyx daemon",
Long: `Starts the Onyx daemon in the background to monitor the repository.`,
RunE: runDaemonStart,
}
}
// newDaemonStopCmd creates the daemon stop subcommand
func newDaemonStopCmd() *cobra.Command {
return &cobra.Command{
Use: "stop",
Short: "Stop the Onyx daemon",
Long: `Gracefully stops the running Onyx daemon.`,
RunE: runDaemonStop,
}
}
// newDaemonStatusCmd creates the daemon status subcommand
func newDaemonStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Check the Onyx daemon status",
Long: `Checks if the Onyx daemon is running and displays its status.`,
RunE: runDaemonStatus,
}
}
// runDaemonStart starts the daemon in the background
func runDaemonStart(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 this is an Onyx repository
if !core.IsOnyxRepo(cwd) {
return fmt.Errorf("not an Onyx repository")
}
// Open repository to get .onx path
repo, err := core.Open(cwd)
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
}
defer repo.Close()
pidFile := filepath.Join(repo.GetOnyxPath(), "daemon.pid")
// Check if daemon is already running
if isDaemonRunning(pidFile) {
return fmt.Errorf("daemon is already running")
}
// Find the onxd binary
onxdPath, err := exec.LookPath("onxd")
if err != nil {
// Try to find it in the same directory as onx
onxPath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to locate onxd binary: %w", err)
}
onxdPath = filepath.Join(filepath.Dir(onxPath), "onxd")
if _, err := os.Stat(onxdPath); err != nil {
return fmt.Errorf("onxd binary not found. Please ensure it's installed")
}
}
// Start the daemon in the background
daemonCmd := exec.Command(onxdPath, "--repo", cwd)
daemonCmd.Stdout = nil
daemonCmd.Stderr = nil
daemonCmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true, // Create new session
}
if err := daemonCmd.Start(); err != nil {
return fmt.Errorf("failed to start daemon: %w", err)
}
// Detach the process
if err := daemonCmd.Process.Release(); err != nil {
return fmt.Errorf("failed to release daemon process: %w", err)
}
fmt.Println("Onyx daemon started successfully")
return nil
}
// runDaemonStop stops the running daemon
func runDaemonStop(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 this is an Onyx repository
if !core.IsOnyxRepo(cwd) {
return fmt.Errorf("not an Onyx repository")
}
// Open repository to get .onx path
repo, err := core.Open(cwd)
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
}
defer repo.Close()
pidFile := filepath.Join(repo.GetOnyxPath(), "daemon.pid")
// Read PID from file
pid, err := readPIDFile(pidFile)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("daemon is not running (no PID file found)")
}
return fmt.Errorf("failed to read PID file: %w", err)
}
// Check if process exists
process, err := os.FindProcess(pid)
if err != nil {
return fmt.Errorf("failed to find daemon process: %w", err)
}
// Send SIGTERM to gracefully stop the daemon
if err := process.Signal(syscall.SIGTERM); err != nil {
return fmt.Errorf("failed to stop daemon: %w", err)
}
fmt.Println("Onyx daemon stopped successfully")
return nil
}
// runDaemonStatus checks the daemon status
func runDaemonStatus(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 this is an Onyx repository
if !core.IsOnyxRepo(cwd) {
return fmt.Errorf("not an Onyx repository")
}
// Open repository to get .onx path
repo, err := core.Open(cwd)
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
}
defer repo.Close()
pidFile := filepath.Join(repo.GetOnyxPath(), "daemon.pid")
if isDaemonRunning(pidFile) {
pid, _ := readPIDFile(pidFile)
fmt.Printf("Onyx daemon is running (PID: %d)\n", pid)
} else {
fmt.Println("Onyx daemon is not running")
}
return nil
}
// isDaemonRunning checks if the daemon is running based on the PID file
func isDaemonRunning(pidFile string) bool {
pid, err := readPIDFile(pidFile)
if err != nil {
return false
}
// Check if process exists
process, err := os.FindProcess(pid)
if err != nil {
return false
}
// Send signal 0 to check if process is alive
err = process.Signal(syscall.Signal(0))
return err == nil
}
// readPIDFile reads the PID from a file
func readPIDFile(path string) (int, error) {
data, err := os.ReadFile(path)
if err != nil {
return 0, err
}
pid, err := strconv.Atoi(string(data[:len(data)-1])) // Remove trailing newline
if err != nil {
return 0, fmt.Errorf("invalid PID file: %w", err)
}
return pid, nil
}

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

@ -0,0 +1,231 @@
package commands
import (
"fmt"
"os"
"path/filepath"
"git.dws.rip/DWS/onyx/internal/core"
"git.dws.rip/DWS/onyx/internal/storage"
"github.com/go-git/go-git/v5/config"
"github.com/spf13/cobra"
)
// NewInitCmd creates the init command
func NewInitCmd() *cobra.Command {
var remoteURL string
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
- Optionally configure a remote repository
Example:
onx init
onx init --remote https://git.dws.rip/DWS/onyx.git`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runInit(cmd, args, remoteURL)
},
}
cmd.Flags().StringVarP(&remoteURL, "remote", "r", "", "Remote repository URL to configure as 'origin'")
return cmd
}
func runInit(cmd *cobra.Command, args []string, remoteURL 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 remote if specified
if remoteURL != "" {
gitRepo := repo.GetGitRepo()
_, err = gitRepo.CreateRemote(&config.RemoteConfig{
Name: "origin",
URLs: []string{remoteURL},
})
if err != nil {
// Don't fail the init, but warn the user
fmt.Fprintf(os.Stderr, "Warning: failed to add remote: %v\n", err)
} else {
fmt.Printf("Added remote 'origin': %s\n", remoteURL)
}
}
// Create default workstream matching the current Git branch
wsManager := core.NewWorkstreamManager(repo)
if err := wsManager.CreateDefaultWorkstream(); err != nil {
// Don't fail init if workstream creation fails, just warn
fmt.Fprintf(os.Stderr, "Warning: failed to create default workstream: %v\n", 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)
}
}
// Get the workstream name to display to the user
currentWs, _ := wsManager.GetCurrentWorkstreamName()
if currentWs == "" {
currentWs = "master" // fallback
}
fmt.Printf("Initialized empty Onyx repository in %s\n", filepath.Join(absPath, ".onx"))
fmt.Printf("\n✓ Created workstream '%s' tracking branch '%s'\n", currentWs, currentWs)
if remoteURL != "" {
fmt.Printf("\nYou can now:\n")
fmt.Printf(" onx save -m \"message\" # Save your work\n")
fmt.Printf(" onx push # Push to remote\n")
} else {
fmt.Printf("\nYou can now:\n")
fmt.Printf(" onx save -m \"message\" # Save your work\n")
fmt.Printf(" onx new <name> # Create a new workstream\n")
}
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)
}
})
}
}

191
internal/commands/list.go Normal file
View File

@ -0,0 +1,191 @@
package commands
import (
"fmt"
"os"
"sort"
"strings"
"git.dws.rip/DWS/onyx/internal/core"
"git.dws.rip/DWS/onyx/internal/models"
"github.com/spf13/cobra"
)
// ANSI color codes
const (
colorReset = "\033[0m"
colorGreen = "\033[32m"
colorYellow = "\033[33m"
colorBlue = "\033[34m"
colorGray = "\033[90m"
colorBold = "\033[1m"
)
// NewListCmd creates the list command
func NewListCmd() *cobra.Command {
var showAll bool
cmd := &cobra.Command{
Use: "list",
Short: "List all workstreams",
Long: `List all workstreams in the repository.
Shows the current workstream (marked with *), the number of commits in each
workstream, and the workstream status.
Status indicators:
* active - Currently being worked on (green)
* merged - Has been merged (gray)
* abandoned - No longer being worked on (gray)
* archived - Archived for historical purposes (gray)`,
Aliases: []string{"ls"},
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runList(showAll)
},
}
cmd.Flags().BoolVarP(&showAll, "all", "a", false, "Show all workstreams including merged and archived")
return cmd
}
// runList executes the list command
func runList(showAll bool) error {
// Get current directory
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
// Check if this is an Onyx repository
if !core.IsOnyxRepo(cwd) {
return fmt.Errorf("not an Onyx repository")
}
// Open the repository
repo, err := core.Open(cwd)
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
}
defer repo.Close()
// Create workstream manager
wsManager := core.NewWorkstreamManager(repo)
// Get all workstreams
workstreams, err := wsManager.ListWorkstreams()
if err != nil {
return fmt.Errorf("failed to list workstreams: %w", err)
}
// Get current workstream name
currentName, err := wsManager.GetCurrentWorkstreamName()
if err != nil {
currentName = "" // No current workstream
}
// Filter workstreams if not showing all
var displayWorkstreams []*models.Workstream
for _, ws := range workstreams {
if showAll || ws.Status == models.WorkstreamStatusActive {
displayWorkstreams = append(displayWorkstreams, ws)
}
}
// Check if there are any workstreams
if len(displayWorkstreams) == 0 {
if showAll {
fmt.Println("No workstreams found.")
} else {
fmt.Println("No active workstreams found.")
fmt.Println("Use 'onx new <name>' to create a new workstream.")
fmt.Println("Use 'onx list --all' to see all workstreams including merged and archived.")
}
return nil
}
// Sort workstreams by name for consistent output
sort.Slice(displayWorkstreams, func(i, j int) bool {
return displayWorkstreams[i].Name < displayWorkstreams[j].Name
})
// Display workstreams
fmt.Println("Workstreams:")
for _, ws := range displayWorkstreams {
displayWorkstream(ws, ws.Name == currentName)
}
// Show helpful footer
fmt.Println()
fmt.Printf("Use 'onx switch <name>' to switch to a different workstream\n")
if !showAll {
fmt.Printf("Use 'onx list --all' to see all workstreams\n")
}
return nil
}
// displayWorkstream displays a single workstream with formatting
func displayWorkstream(ws *models.Workstream, isCurrent bool) {
// Determine the indicator
indicator := " "
if isCurrent {
indicator = "*"
}
// Determine the color based on status
color := colorReset
switch ws.Status {
case models.WorkstreamStatusActive:
color = colorGreen
case models.WorkstreamStatusMerged:
color = colorGray
case models.WorkstreamStatusAbandoned:
color = colorGray
case models.WorkstreamStatusArchived:
color = colorGray
}
// Format the output
name := ws.Name
if isCurrent {
name = colorBold + name + colorReset
}
commitCount := ws.GetCommitCount()
commitText := "commit"
if commitCount != 1 {
commitText = "commits"
}
// Build status string
statusStr := string(ws.Status)
if ws.Status != models.WorkstreamStatusActive {
statusStr = colorGray + statusStr + colorReset
}
// Build the line
line := fmt.Sprintf("%s %s%s%s", indicator, color, name, colorReset)
// Add base branch info
baseBranchInfo := fmt.Sprintf(" (based on %s)", ws.BaseBranch)
line += colorGray + baseBranchInfo + colorReset
// Add commit count
commitInfo := fmt.Sprintf(" - %d %s", commitCount, commitText)
line += commitInfo
// Add status if not active
if ws.Status != models.WorkstreamStatusActive {
line += fmt.Sprintf(" [%s]", statusStr)
}
fmt.Println(line)
// Add description if present
if ws.Description != "" {
description := strings.TrimSpace(ws.Description)
fmt.Printf(" %s%s%s\n", colorGray, description, colorReset)
}
}

75
internal/commands/new.go Normal file
View File

@ -0,0 +1,75 @@
package commands
import (
"fmt"
"os"
"git.dws.rip/DWS/onyx/internal/core"
"github.com/spf13/cobra"
)
// NewNewCmd creates the new command
func NewNewCmd() *cobra.Command {
var baseBranch string
cmd := &cobra.Command{
Use: "new <name>",
Short: "Create a new workstream",
Long: `Create a new workstream for a feature or task.
A workstream is a logical unit of work that can contain multiple commits.
It's similar to creating a new branch in Git, but with better support for
stacked diffs and atomic operations.
The workstream will be based on the specified base branch (default: main).`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runNew(args[0], baseBranch)
},
}
cmd.Flags().StringVarP(&baseBranch, "base", "b", "main", "Base branch for the workstream")
return cmd
}
// runNew executes the new command
func runNew(name, baseBranch string) error {
// Get current directory
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
// Check if this is an Onyx repository
if !core.IsOnyxRepo(cwd) {
return fmt.Errorf("not an Onyx repository. Run 'onx init' first")
}
// Open the repository
repo, err := core.Open(cwd)
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
}
defer repo.Close()
// Create workstream manager
wsManager := core.NewWorkstreamManager(repo)
// Use ExecuteWithTransaction to capture state_before and state_after
err = core.ExecuteWithTransaction(repo, "new", fmt.Sprintf("Created workstream: %s", name), func() error {
return wsManager.CreateWorkstream(name, baseBranch)
})
if err != nil {
return err
}
fmt.Printf("Created workstream '%s' based on '%s'\n", name, baseBranch)
fmt.Printf("\nYou can now:\n")
fmt.Printf(" - Make changes to your files\n")
fmt.Printf(" - Save your work with 'onx save -m \"message\"'\n")
fmt.Printf(" - Switch to another workstream with 'onx switch <name>'\n")
return nil
}

296
internal/commands/push.go Normal file
View File

@ -0,0 +1,296 @@
package commands
import (
"fmt"
"os"
"strings"
"git.dws.rip/DWS/onyx/internal/core"
"git.dws.rip/DWS/onyx/internal/git"
gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/spf13/cobra"
)
// NewPushCmd creates the push command
func NewPushCmd() *cobra.Command {
var remoteName string
var force bool
var stacked bool
cmd := &cobra.Command{
Use: "push",
Short: "Push the current workstream to the remote repository",
Long: `Push the current workstream to the remote repository.
By default, pushes as a single branch (clean, traditional workflow).
Use --stacked to push each commit as a separate branch (advanced stacked diffs).
Single-branch mode (default):
- Pushes workstream as one branch with all commits
- Clean remote UI (1 branch per workstream)
- Perfect for traditional PR workflows
- Example: 'milestone-4' branch
Stacked mode (--stacked):
- Pushes each commit as a separate branch
- Enables stacked diff workflow (Meta/Google style)
- Each commit can have its own PR
- Example: 'onyx/workstreams/milestone-4/commit-1', 'commit-2', etc.`,
RunE: func(cmd *cobra.Command, args []string) error {
return runPush(remoteName, force, stacked)
},
}
cmd.Flags().StringVarP(&remoteName, "remote", "r", "origin", "Remote to push to")
cmd.Flags().BoolVarP(&force, "force", "f", false, "Force push (use with caution)")
cmd.Flags().BoolVar(&stacked, "stacked", false, "Push each commit as separate branch (stacked diffs)")
return cmd
}
// runPush executes the push command
func runPush(remoteName string, force, stacked bool) error {
// Get current directory
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
// Check if this is an Onyx repository
if !core.IsOnyxRepo(cwd) {
return fmt.Errorf("not an Onyx repository. Run 'onx init' first")
}
// Open the repository
repo, err := core.Open(cwd)
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
}
defer repo.Close()
// Use ExecuteWithTransaction to capture state
err = core.ExecuteWithTransaction(repo, "push", "Pushed to remote", func() error {
if stacked {
return executePushStacked(repo, remoteName, force)
}
return executePushSingleBranch(repo, remoteName, force)
})
if err != nil {
return err
}
fmt.Println("✓ Push completed successfully")
return nil
}
// executePushSingleBranch pushes the workstream as a single branch (default behavior)
func executePushSingleBranch(repo *core.OnyxRepository, remoteName string, force bool) error {
gitRepo := repo.GetGitRepo()
// 1. Validate remote exists
remoteHelper := git.NewRemoteHelper(gitRepo)
if err := remoteHelper.ValidateRemote(remoteName); err != nil {
return fmt.Errorf("remote validation failed: %w", err)
}
// 2. Get current workstream
wsManager := core.NewWorkstreamManager(repo)
currentWorkstream, err := wsManager.GetCurrentWorkstream()
if err != nil {
return fmt.Errorf("no active workstream: %w", err)
}
if currentWorkstream.IsEmpty() {
return fmt.Errorf("workstream has no commits to push")
}
// 3. Get the remote
remote, err := remoteHelper.GetRemote(remoteName)
if err != nil {
return fmt.Errorf("failed to get remote: %w", err)
}
// 4. Get the latest commit in the workstream
latestCommit, err := currentWorkstream.GetLatestCommit()
if err != nil {
return fmt.Errorf("failed to get latest commit: %w", err)
}
// 5. Build refspec to push the latest commit to a branch named after the workstream
branchName := currentWorkstream.Name
localRef := latestCommit.BranchRef
remoteRef := fmt.Sprintf("refs/heads/%s", branchName)
// Also create/update the local branch to point to the latest commit
// This ensures `refs/heads/master` (or whatever the workstream is) exists locally
gitBackend := git.NewGitBackend(gitRepo)
localBranchRef := fmt.Sprintf("refs/heads/%s", branchName)
if err := gitBackend.UpdateRef(localBranchRef, latestCommit.SHA); err != nil {
// Warn but don't fail - the push can still succeed
fmt.Fprintf(os.Stderr, "Warning: failed to update local branch %s: %v\n", branchName, err)
}
refSpec := config.RefSpec(fmt.Sprintf("%s:%s", localRef, remoteRef))
if force {
refSpec = config.RefSpec(fmt.Sprintf("+%s:%s", localRef, remoteRef))
}
// 6. Get authentication for the remote
remoteURL, err := remoteHelper.GetRemoteURL(remoteName)
if err != nil {
return fmt.Errorf("failed to get remote URL: %w", err)
}
authProvider := git.NewAuthProvider()
authMethod, err := authProvider.GetAuthMethod(remoteURL)
if err != nil {
// Log the error but continue - some remotes might not need auth
fmt.Fprintf(os.Stderr, "Warning: authentication not available: %v\n", err)
fmt.Fprintf(os.Stderr, "Attempting push without authentication...\n")
}
// 7. Push to remote
fmt.Printf("Pushing workstream '%s' to %s...\n", branchName, remoteName)
err = remote.Push(&gogit.PushOptions{
Auth: authMethod,
RefSpecs: []config.RefSpec{refSpec},
Progress: os.Stdout,
})
if err != nil {
if err == gogit.NoErrAlreadyUpToDate {
fmt.Println("Already up to date")
return nil
}
return fmt.Errorf("failed to push: %w", err)
}
fmt.Printf("✓ Pushed branch '%s' with %d commit(s)\n", branchName, len(currentWorkstream.Commits))
fmt.Printf("\nTo create a pull request:\n")
fmt.Printf(" gh pr create --base %s --head %s\n", currentWorkstream.BaseBranch, branchName)
return nil
}
// executePushStacked pushes each commit as a separate branch (stacked diffs)
func executePushStacked(repo *core.OnyxRepository, remoteName string, force bool) error {
gitRepo := repo.GetGitRepo()
// 1. Validate remote exists
remoteHelper := git.NewRemoteHelper(gitRepo)
if err := remoteHelper.ValidateRemote(remoteName); err != nil {
return fmt.Errorf("remote validation failed: %w", err)
}
// 2. Get current workstream
wsManager := core.NewWorkstreamManager(repo)
currentWorkstream, err := wsManager.GetCurrentWorkstream()
if err != nil {
return fmt.Errorf("no active workstream: %w", err)
}
if currentWorkstream.IsEmpty() {
return fmt.Errorf("workstream has no commits to push")
}
// 3. Get the remote
remote, err := remoteHelper.GetRemote(remoteName)
if err != nil {
return fmt.Errorf("failed to get remote: %w", err)
}
// 4. Build list of refspecs to push (all branches in the workstream)
refspecs := []config.RefSpec{}
// Also push the base branch if it exists locally
baseBranch := currentWorkstream.BaseBranch
if baseBranch != "" {
// Check if base branch exists locally
gitBackend := git.NewGitBackend(gitRepo)
baseRef := fmt.Sprintf("refs/heads/%s", baseBranch)
if _, err := gitBackend.GetRef(baseRef); err == nil {
refSpec := config.RefSpec(fmt.Sprintf("refs/heads/%s:refs/heads/%s", baseBranch, baseBranch))
if force {
refSpec = config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", baseBranch, baseBranch))
}
refspecs = append(refspecs, refSpec)
}
}
// Push each commit's branch ref
for i, commit := range currentWorkstream.Commits {
branchRef := commit.BranchRef
if branchRef == "" {
continue
}
// Extract branch name from ref (e.g., refs/onyx/workstreams/foo/commit-1 -> foo/commit-1)
// We'll push to refs/heads/onyx/workstreams/[workstream]/commit-[n]
remoteBranch := fmt.Sprintf("onyx/workstreams/%s/commit-%d", currentWorkstream.Name, i+1)
refSpec := config.RefSpec(fmt.Sprintf("%s:refs/heads/%s", branchRef, remoteBranch))
if force {
refSpec = config.RefSpec(fmt.Sprintf("+%s:refs/heads/%s", branchRef, remoteBranch))
}
refspecs = append(refspecs, refSpec)
}
if len(refspecs) == 0 {
return fmt.Errorf("no branches to push")
}
// 5. Get authentication for the remote
remoteURL, err := remoteHelper.GetRemoteURL(remoteName)
if err != nil {
return fmt.Errorf("failed to get remote URL: %w", err)
}
authProvider := git.NewAuthProvider()
authMethod, err := authProvider.GetAuthMethod(remoteURL)
if err != nil {
// Log the error but continue - some remotes might not need auth
fmt.Fprintf(os.Stderr, "Warning: authentication not available: %v\n", err)
fmt.Fprintf(os.Stderr, "Attempting push without authentication...\n")
}
// 6. Push to remote
fmt.Printf("Pushing %d branch(es) to %s...\n", len(refspecs), remoteName)
err = remote.Push(&gogit.PushOptions{
Auth: authMethod,
RefSpecs: refspecs,
Progress: os.Stdout,
})
if err != nil {
if err == gogit.NoErrAlreadyUpToDate {
fmt.Println("Already up to date")
return nil
}
return fmt.Errorf("failed to push: %w", err)
}
fmt.Printf("✓ Pushed %d branch(es) successfully\n", len(refspecs))
// 7. Print summary of pushed branches
fmt.Println("\nPushed branches (stacked diffs):")
if baseBranch != "" {
fmt.Printf(" - %s (base branch)\n", baseBranch)
}
for i, commit := range currentWorkstream.Commits {
remoteBranch := fmt.Sprintf("onyx/workstreams/%s/commit-%d", currentWorkstream.Name, i+1)
commitTitle := strings.Split(commit.Message, "\n")[0]
if len(commitTitle) > 60 {
commitTitle = commitTitle[:57] + "..."
}
fmt.Printf(" - %s: %s\n", remoteBranch, commitTitle)
}
fmt.Printf("\nTip: Each branch can have its own PR for incremental review\n")
return nil
}

225
internal/commands/save.go Normal file
View File

@ -0,0 +1,225 @@
package commands
import (
"fmt"
"os"
"strings"
"git.dws.rip/DWS/onyx/internal/core"
"git.dws.rip/DWS/onyx/internal/git"
gogit "github.com/go-git/go-git/v5"
"github.com/spf13/cobra"
)
const (
// OnyxWorkspaceRef is the ref where ephemeral commits are stored
OnyxWorkspaceRef = "refs/onyx/workspaces/current"
// MaxCommitTitleLength is the maximum length for a commit title
MaxCommitTitleLength = 72
)
// NewSaveCmd creates the save command
func NewSaveCmd() *cobra.Command {
var message string
cmd := &cobra.Command{
Use: "save",
Short: "Save the current work as a permanent commit",
Long: `Converts the current ephemeral snapshot into a permanent commit
in the active workstream. This is similar to 'git commit' but works
with Onyx's workstream model.`,
RunE: func(cmd *cobra.Command, args []string) error {
return runSave(message)
},
}
cmd.Flags().StringVarP(&message, "message", "m", "", "Commit message (required)")
cmd.MarkFlagRequired("message")
return cmd
}
// runSave executes the save command
func runSave(message string) error {
// Validate the commit message
if err := validateCommitMessage(message); err != nil {
return err
}
// Get current directory
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
// Check if this is an Onyx repository
if !core.IsOnyxRepo(cwd) {
return fmt.Errorf("not an Onyx repository")
}
// Open the repository
repo, err := core.Open(cwd)
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
}
defer repo.Close()
// Use ExecuteWithTransaction to capture state_before and state_after
err = core.ExecuteWithTransaction(repo, "save", message, func() error {
return executeSave(repo, message)
})
if err != nil {
return err
}
fmt.Printf("Successfully saved commit: %s\n", message)
return nil
}
// executeSave performs the actual save operation
func executeSave(repo *core.OnyxRepository, message string) error {
gitBackend := git.NewGitBackend(repo.GetGitRepo())
gitRepo := repo.GetGitRepo()
// 1. Try to read current ephemeral commit from workspace ref
ephemeralCommitSHA, err := getEphemeralCommit(gitBackend)
var treeHash string
if err != nil {
// No ephemeral commit yet - this is the first commit or daemon isn't running
// Use worktree to stage and get tree from working directory
worktree, err := gitRepo.Worktree()
if err != nil {
return fmt.Errorf("failed to get worktree: %w", err)
}
// Get all files including untracked ones
status, err := worktree.Status()
if err != nil {
return fmt.Errorf("failed to get status: %w", err)
}
// Add all files (including untracked) to the index
for file := range status {
_, err = worktree.Add(file)
if err != nil {
return fmt.Errorf("failed to add file %s: %w", file, err)
}
}
if len(status) == 0 {
return fmt.Errorf("no changes to commit")
}
// Create a temporary commit to get the tree hash
// This is a workaround since we need the tree object
tempCommitHash, err := worktree.Commit("temp for tree extraction", &gogit.CommitOptions{
All: true,
AllowEmptyCommits: false,
})
if err != nil {
return fmt.Errorf("failed to create temporary commit: %w", err)
}
// Get the tree hash from the temporary commit
tempCommit, err := gitRepo.CommitObject(tempCommitHash)
if err != nil {
return fmt.Errorf("failed to get temporary commit: %w", err)
}
treeHash = tempCommit.TreeHash.String()
// Note: We leave the temp commit in place - it will be orphaned
// when we create the real commit, and can be cleaned up by git gc
} else {
// 2. Get the commit object to extract the tree
ephemeralCommit, err := gitBackend.GetCommit(ephemeralCommitSHA)
if err != nil {
return fmt.Errorf("failed to get ephemeral commit object: %w", err)
}
treeHash = ephemeralCommit.TreeHash.String()
}
// 3. Create workstream manager
wsManager := core.NewWorkstreamManager(repo)
// 4. Get the current workstream
currentWorkstream, err := wsManager.GetCurrentWorkstream()
if err != nil {
return fmt.Errorf("no active workstream. Use 'onx new' to create one: %w", err)
}
// 5. Determine the parent commit
var parentSHA string
if !currentWorkstream.IsEmpty() {
latestCommit, err := currentWorkstream.GetLatestCommit()
if err != nil {
return err
}
parentSHA = latestCommit.SHA
} else {
// For the first commit in the workstream, use the base commit
baseCommitSHA := currentWorkstream.Metadata["base_commit"]
if baseCommitSHA == "" {
// Fallback to getting the base branch HEAD
baseBranch := currentWorkstream.BaseBranch
if baseBranch == "" {
baseBranch = "main"
}
branchRef := fmt.Sprintf("refs/heads/%s", baseBranch)
sha, err := gitBackend.GetRef(branchRef)
if err == nil {
parentSHA = sha
}
} else {
parentSHA = baseCommitSHA
}
}
// 6. Create new commit with the user's message
commitSHA, err := gitBackend.CreateCommit(treeHash, parentSHA, message, "User")
if err != nil {
return fmt.Errorf("failed to create commit: %w", err)
}
// 7. Add commit to workstream using the manager
if err := wsManager.AddCommitToWorkstream(commitSHA, message); err != nil {
return fmt.Errorf("failed to add commit to workstream: %w", err)
}
return nil
}
// getEphemeralCommit retrieves the current ephemeral commit SHA
func getEphemeralCommit(gitBackend *git.GitBackend) (string, error) {
sha, err := gitBackend.GetRef(OnyxWorkspaceRef)
if err != nil {
return "", fmt.Errorf("no ephemeral commit found. The daemon may not be running")
}
return sha, nil
}
// validateCommitMessage validates the commit message
func validateCommitMessage(message string) error {
// Check if message is empty
if strings.TrimSpace(message) == "" {
return fmt.Errorf("commit message cannot be empty")
}
// Split into lines
lines := strings.Split(message, "\n")
// Validate title (first line)
title := strings.TrimSpace(lines[0])
if title == "" {
return fmt.Errorf("commit message title cannot be empty")
}
if len(title) > MaxCommitTitleLength {
return fmt.Errorf("commit message title is too long (%d characters). Maximum is %d characters", len(title), MaxCommitTitleLength)
}
return nil
}

139
internal/commands/switch.go Normal file
View File

@ -0,0 +1,139 @@
package commands
import (
"fmt"
"os"
"git.dws.rip/DWS/onyx/internal/core"
"github.com/spf13/cobra"
)
// NewSwitchCmd creates the switch command
func NewSwitchCmd() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "switch <name>",
Short: "Switch to a different workstream",
Long: `Switch to a different workstream.
This command will:
1. Check for uncommitted changes (unless --force is used)
2. Load the target workstream
3. Checkout the latest commit in the target workstream
4. Update the current workstream pointer
⚠️ Warning: Switching discards uncommitted changes in your working directory.
Use 'onx save' to commit your work before switching, or use --force to bypass the safety check.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runSwitch(args[0], force)
},
}
cmd.Flags().BoolVarP(&force, "force", "f", false, "Force switch even with uncommitted changes")
return cmd
}
// runSwitch executes the switch command
func runSwitch(name string, force bool) error {
// Get current directory
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
// Check if this is an Onyx repository
if !core.IsOnyxRepo(cwd) {
return fmt.Errorf("not an Onyx repository")
}
// Open the repository
repo, err := core.Open(cwd)
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
}
defer repo.Close()
// Create workstream manager
wsManager := core.NewWorkstreamManager(repo)
// Get current workstream name before switching
currentName, err := wsManager.GetCurrentWorkstreamName()
if err != nil {
currentName = "none"
}
// Check if we're already on the target workstream
if currentName == name {
fmt.Printf("Already on workstream '%s'\n", name)
return nil
}
// Check for uncommitted changes unless force is enabled
if !force {
gitRepo := repo.GetGitRepo()
worktree, err := gitRepo.Worktree()
if err != nil {
return fmt.Errorf("failed to get worktree: %w", err)
}
status, err := worktree.Status()
if err != nil {
return fmt.Errorf("failed to check status: %w", err)
}
if !status.IsClean() {
// Show which files have changes
fmt.Println("Error: You have uncommitted changes:")
for file, fileStatus := range status {
if fileStatus.Worktree != ' ' || fileStatus.Staging != ' ' {
fmt.Printf(" %c%c %s\n", fileStatus.Staging, fileStatus.Worktree, file)
}
}
fmt.Println("\nPlease commit your changes or use --force to discard them:")
fmt.Printf(" onx save -m \"WIP\" # Save your work\n")
fmt.Printf(" onx switch %s --force # Discard changes and switch\n", name)
return fmt.Errorf("uncommitted changes present")
}
}
// Use ExecuteWithTransaction to capture state_before and state_after
err = core.ExecuteWithTransaction(repo, "switch", fmt.Sprintf("Switched from '%s' to '%s'", currentName, name), func() error {
return wsManager.SwitchWorkstream(name)
})
if err != nil {
return err
}
// Get the workstream we just switched to
targetWorkstream, err := wsManager.GetCurrentWorkstream()
if err != nil {
return fmt.Errorf("failed to get workstream after switch: %w", err)
}
// Display success message
fmt.Printf("Switched to workstream '%s'\n", name)
// Show workstream info
commitCount := targetWorkstream.GetCommitCount()
if commitCount == 0 {
fmt.Printf("\nThis is a new workstream based on '%s' with no commits yet.\n", targetWorkstream.BaseBranch)
fmt.Printf("Make changes and save them with 'onx save -m \"message\"'\n")
} else {
commitText := "commit"
if commitCount != 1 {
commitText = "commits"
}
fmt.Printf("\nThis workstream has %d %s.\n", commitCount, commitText)
// Show the latest commit
if latestCommit, err := targetWorkstream.GetLatestCommit(); err == nil {
fmt.Printf("Latest commit: %s\n", latestCommit.Message)
}
}
return nil
}

222
internal/commands/sync.go Normal file
View File

@ -0,0 +1,222 @@
package commands
import (
"fmt"
"os"
"path/filepath"
"git.dws.rip/DWS/onyx/internal/core"
"git.dws.rip/DWS/onyx/internal/git"
"git.dws.rip/DWS/onyx/internal/models"
"git.dws.rip/DWS/onyx/internal/storage"
gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/spf13/cobra"
)
// NewSyncCmd creates the sync command
func NewSyncCmd() *cobra.Command {
var remoteName string
cmd := &cobra.Command{
Use: "sync",
Short: "Sync the current workstream with the remote base branch",
Long: `Synchronize the current workstream with the remote base branch.
This command will:
1. Fetch the latest changes from the remote
2. Rebase the workstream commits onto the updated base branch
3. Use rerere to automatically resolve known conflicts
4. Update all branch references in the workstream
If conflicts occur during the rebase, you will need to resolve them manually
and then continue the sync operation.`,
RunE: func(cmd *cobra.Command, args []string) error {
return runSync(remoteName)
},
}
cmd.Flags().StringVarP(&remoteName, "remote", "r", "origin", "Remote to sync with")
return cmd
}
// runSync executes the sync command
func runSync(remoteName string) error {
// Get current directory
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
// Check if this is an Onyx repository
if !core.IsOnyxRepo(cwd) {
return fmt.Errorf("not an Onyx repository. Run 'onx init' first")
}
// Open the repository
repo, err := core.Open(cwd)
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
}
defer repo.Close()
// Use ExecuteWithTransaction to capture state
err = core.ExecuteWithTransaction(repo, "sync", "Synced with remote", func() error {
return executeSync(repo, cwd, remoteName)
})
if err != nil {
return err
}
fmt.Println("✓ Sync completed successfully")
return nil
}
// executeSync performs the actual sync operation
func executeSync(repo *core.OnyxRepository, repoPath, remoteName string) error {
gitRepo := repo.GetGitRepo()
onyxPath := repo.GetOnyxPath()
// 1. Validate remote exists
remoteHelper := git.NewRemoteHelper(gitRepo)
if err := remoteHelper.ValidateRemote(remoteName); err != nil {
return fmt.Errorf("remote validation failed: %w", err)
}
// 2. Get current workstream
wsManager := core.NewWorkstreamManager(repo)
currentWorkstream, err := wsManager.GetCurrentWorkstream()
if err != nil {
return fmt.Errorf("no active workstream: %w", err)
}
if currentWorkstream.IsEmpty() {
return fmt.Errorf("workstream has no commits to sync")
}
// 3. Get authentication for the remote
remoteURL, err := remoteHelper.GetRemoteURL(remoteName)
if err != nil {
return fmt.Errorf("failed to get remote URL: %w", err)
}
authProvider := git.NewAuthProvider()
authMethod, err := authProvider.GetAuthMethod(remoteURL)
if err != nil {
// Log the error but continue - some remotes might not need auth
fmt.Fprintf(os.Stderr, "Warning: authentication not available: %v\n", err)
fmt.Fprintf(os.Stderr, "Attempting fetch without authentication...\n")
}
// 4. Fetch from remote
fmt.Printf("Fetching from %s...\n", remoteName)
remote, err := remoteHelper.GetRemote(remoteName)
if err != nil {
return fmt.Errorf("failed to get remote: %w", err)
}
err = remote.Fetch(&gogit.FetchOptions{
Auth: authMethod,
RefSpecs: []config.RefSpec{
config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/remotes/%s/%s",
currentWorkstream.BaseBranch, remoteName, currentWorkstream.BaseBranch)),
},
Progress: os.Stdout,
})
if err != nil && err != gogit.NoErrAlreadyUpToDate {
return fmt.Errorf("failed to fetch: %w", err)
}
if err == gogit.NoErrAlreadyUpToDate {
fmt.Println("Already up to date")
}
// 5. Get the updated base branch HEAD
gitBackend := git.NewGitBackend(gitRepo)
remoteRef := fmt.Sprintf("refs/remotes/%s/%s", remoteName, currentWorkstream.BaseBranch)
newBaseSHA, err := gitBackend.GetRef(remoteRef)
if err != nil {
return fmt.Errorf("failed to get remote base branch: %w", err)
}
// 6. Build the commit stack from the workstream
stack := []string{}
for _, commit := range currentWorkstream.Commits {
stack = append(stack, commit.SHA)
}
// 7. Create rebase engine with rerere support
rebaseEngine := git.NewRebaseEngine(gitRepo, onyxPath, repoPath)
fmt.Printf("Rebasing %d commit(s) onto %s...\n", len(stack), newBaseSHA[:8])
// 8. Perform the rebase
result, err := rebaseEngine.RebaseStack(stack, newBaseSHA)
if err != nil {
return fmt.Errorf("rebase failed: %w", err)
}
// 9. Handle rebase result
if !result.Success {
if len(result.ConflictingFiles) > 0 {
// Present conflicts to user
conflictResolver := rebaseEngine.GetConflictResolver()
conflictMsg := conflictResolver.PresentConflicts(result.ConflictingFiles)
fmt.Println(conflictMsg)
return fmt.Errorf("sync paused due to conflicts")
}
return fmt.Errorf("rebase failed: %s", result.Message)
}
// 10. Update workstream commits with new SHAs
if err := updateWorkstreamCommits(repo, currentWorkstream, result.RebasedCommits); err != nil {
return fmt.Errorf("failed to update workstream: %w", err)
}
// 11. Update the base commit metadata
currentWorkstream.Metadata["base_commit"] = newBaseSHA
wsCollection, err := storage.LoadWorkstreams(filepath.Join(onyxPath, "workstreams.json"))
if err != nil {
return fmt.Errorf("failed to load workstreams: %w", err)
}
if err := storage.SaveWorkstreams(filepath.Join(onyxPath, "workstreams.json"), wsCollection); err != nil {
return fmt.Errorf("failed to save workstreams: %w", err)
}
fmt.Printf("✓ Rebased %d commit(s) successfully\n", len(result.RebasedCommits))
return nil
}
// updateWorkstreamCommits updates the workstream with new rebased commit SHAs
func updateWorkstreamCommits(repo *core.OnyxRepository, ws *models.Workstream, newSHAs []string) error {
if len(ws.Commits) != len(newSHAs) {
return fmt.Errorf("mismatch between old and new commit counts")
}
gitBackend := git.NewGitBackend(repo.GetGitRepo())
// Update each commit SHA and its branch ref
for i := range ws.Commits {
oldSHA := ws.Commits[i].SHA
newSHA := newSHAs[i]
// Update the commit SHA
ws.Commits[i].SHA = newSHA
// Update the branch ref to point to the new commit
branchRef := ws.Commits[i].BranchRef
if branchRef != "" {
if err := gitBackend.UpdateRef(branchRef, newSHA); err != nil {
return fmt.Errorf("failed to update ref %s: %w", branchRef, err)
}
}
fmt.Printf(" %s -> %s\n", oldSHA[:8], newSHA[:8])
}
return nil
}

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"])
}
}

View File

@ -0,0 +1,74 @@
package core
import (
"time"
gogit "github.com/go-git/go-git/v5"
)
// Repository represents an Onyx repository with both Git and Onyx-specific metadata
type Repository interface {
// Init initializes a new Onyx repository at the given path
Init(path string) error
// GetGitRepo returns the underlying Git repository
GetGitRepo() *gogit.Repository
// GetOnyxMetadata returns Onyx-specific metadata
GetOnyxMetadata() *OnyxMetadata
// Close releases any resources held by the repository
Close() error
}
// GitBackend provides low-level Git object operations
type GitBackend interface {
// CreateCommit creates a new commit object
CreateCommit(tree, parent, message string) (string, error)
// CreateTree creates a new tree object from the given entries
CreateTree(entries []TreeEntry) (string, error)
// UpdateRef updates a Git reference to point to a new SHA
UpdateRef(name, sha string) error
// GetRef retrieves the SHA that a reference points to
GetRef(name string) (string, error)
// CreateBlob creates a new blob object from content
CreateBlob(content []byte) (string, error)
// GetObject retrieves a Git object by its SHA
GetObject(sha string) (Object, error)
}
// TreeEntry represents an entry in a Git tree object
type TreeEntry struct {
Mode int // File mode (e.g., 0100644 for regular file, 040000 for directory)
Name string // Entry name
SHA string // Object SHA-1 hash
}
// Object represents a Git object (blob, tree, commit, or tag)
type Object interface {
// Type returns the type of the object (blob, tree, commit, tag)
Type() string
// SHA returns the SHA-1 hash of the object
SHA() string
// Size returns the size of the object in bytes
Size() int64
}
// OnyxMetadata holds Onyx-specific repository metadata
type OnyxMetadata struct {
// Version of the Onyx repository format
Version string
// Created timestamp when the repository was initialized
Created time.Time
// Path to the .onx directory
OnyxPath string
}

178
internal/core/repository.go Normal file
View File

@ -0,0 +1,178 @@
package core
import (
"fmt"
"os"
"path/filepath"
"time"
gogit "github.com/go-git/go-git/v5"
)
// OnyxRepository implements the Repository interface
type OnyxRepository struct {
gitRepo *gogit.Repository
onyxPath string
gitPath string
metadata *OnyxMetadata
}
// Open opens an existing Onyx repository at the given path
func Open(path string) (*OnyxRepository, error) {
// Resolve to absolute path
absPath, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("failed to resolve path: %w", err)
}
// Check if .git directory exists
gitPath := filepath.Join(absPath, ".git")
if _, err := os.Stat(gitPath); os.IsNotExist(err) {
return nil, fmt.Errorf("not a git repository (no .git directory found)")
}
// Check if .onx directory exists
onyxPath := filepath.Join(absPath, ".onx")
if _, err := os.Stat(onyxPath); os.IsNotExist(err) {
return nil, fmt.Errorf("not an onyx repository (no .onx directory found)")
}
// Open the Git repository
gitRepo, err := gogit.PlainOpen(absPath)
if err != nil {
return nil, fmt.Errorf("failed to open git repository: %w", err)
}
// Load Onyx metadata
metadata := &OnyxMetadata{
Version: "1.0.0",
Created: time.Now(), // TODO: Load from .onx/metadata file
OnyxPath: onyxPath,
}
return &OnyxRepository{
gitRepo: gitRepo,
onyxPath: onyxPath,
gitPath: gitPath,
metadata: metadata,
}, nil
}
// Init initializes a new Onyx repository at the given path
func (r *OnyxRepository) Init(path string) error {
// Resolve to absolute path
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("failed to resolve path: %w", err)
}
// Check if directory exists, create if it doesn't
if _, err := os.Stat(absPath); os.IsNotExist(err) {
if err := os.MkdirAll(absPath, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
}
// Initialize Git repository if it doesn't exist
gitPath := filepath.Join(absPath, ".git")
if _, err := os.Stat(gitPath); os.IsNotExist(err) {
_, err := gogit.PlainInit(absPath, false)
if err != nil {
return fmt.Errorf("failed to initialize git repository: %w", err)
}
}
// Create .onx directory structure
onyxPath := filepath.Join(absPath, ".onx")
if err := os.MkdirAll(onyxPath, 0755); err != nil {
return fmt.Errorf("failed to create .onx directory: %w", err)
}
// Create subdirectories
subdirs := []string{"rerere_cache"}
for _, subdir := range subdirs {
subdirPath := filepath.Join(onyxPath, subdir)
if err := os.MkdirAll(subdirPath, 0755); err != nil {
return fmt.Errorf("failed to create %s directory: %w", subdir, err)
}
}
// Initialize oplog file
oplogPath := filepath.Join(onyxPath, "oplog")
if _, err := os.Stat(oplogPath); os.IsNotExist(err) {
if err := os.WriteFile(oplogPath, []byte{}, 0644); err != nil {
return fmt.Errorf("failed to create oplog file: %w", err)
}
}
// Initialize workstreams.json
workstreamsPath := filepath.Join(onyxPath, "workstreams.json")
if _, err := os.Stat(workstreamsPath); os.IsNotExist(err) {
initialContent := []byte("{\"workstreams\":{}}\n")
if err := os.WriteFile(workstreamsPath, initialContent, 0644); err != nil {
return fmt.Errorf("failed to create workstreams.json: %w", err)
}
}
// Open the repository
gitRepo, err := gogit.PlainOpen(absPath)
if err != nil {
return fmt.Errorf("failed to open git repository: %w", err)
}
// Set up the repository instance
r.gitRepo = gitRepo
r.onyxPath = onyxPath
r.gitPath = gitPath
r.metadata = &OnyxMetadata{
Version: "1.0.0",
Created: time.Now(),
OnyxPath: onyxPath,
}
return nil
}
// GetGitRepo returns the underlying Git repository
func (r *OnyxRepository) GetGitRepo() *gogit.Repository {
return r.gitRepo
}
// GetOnyxMetadata returns Onyx-specific metadata
func (r *OnyxRepository) GetOnyxMetadata() *OnyxMetadata {
return r.metadata
}
// Close releases any resources held by the repository
func (r *OnyxRepository) Close() error {
// Currently, go-git doesn't require explicit closing
// This method is here for future-proofing
return nil
}
// IsOnyxRepo checks if the given path is an Onyx repository
func IsOnyxRepo(path string) bool {
absPath, err := filepath.Abs(path)
if err != nil {
return false
}
// Check for both .git and .onx directories
gitPath := filepath.Join(absPath, ".git")
onyxPath := filepath.Join(absPath, ".onx")
_, gitErr := os.Stat(gitPath)
_, onyxErr := os.Stat(onyxPath)
return gitErr == nil && onyxErr == nil
}
// GetOnyxPath returns the path to the .onx directory
func (r *OnyxRepository) GetOnyxPath() string {
return r.onyxPath
}
// GetGitPath returns the path to the .git directory
func (r *OnyxRepository) GetGitPath() string {
return r.gitPath
}

View File

@ -0,0 +1,186 @@
package core
import (
"fmt"
"path/filepath"
"git.dws.rip/DWS/onyx/internal/models"
"git.dws.rip/DWS/onyx/internal/storage"
)
// Transaction represents a transactional operation with oplog support
type Transaction struct {
repo *OnyxRepository
oplogWriter *storage.OplogWriter
stateCapture *storage.StateCapture
}
// NewTransaction creates a new transaction for the given repository
func NewTransaction(repo *OnyxRepository) (*Transaction, error) {
oplogPath := filepath.Join(repo.GetOnyxPath(), "oplog")
oplogWriter, err := storage.OpenOplog(oplogPath)
if err != nil {
return nil, fmt.Errorf("failed to open oplog: %w", err)
}
stateCapture := storage.NewStateCapture(repo.GetGitRepo())
return &Transaction{
repo: repo,
oplogWriter: oplogWriter,
stateCapture: stateCapture,
}, nil
}
// ExecuteWithTransaction executes a function within a transaction context
// It captures the state before and after the operation and logs it to the oplog
func (t *Transaction) ExecuteWithTransaction(operation, description string, fn func() error) error {
// 1. Capture state_before
stateBefore, err := t.stateCapture.CaptureState()
if err != nil {
return fmt.Errorf("failed to capture state before: %w", err)
}
// 2. Execute the function
err = fn()
if err != nil {
// On error, we don't log to oplog since the operation failed
return fmt.Errorf("operation failed: %w", err)
}
// 3. Capture state_after
stateAfter, err := t.stateCapture.CaptureState()
if err != nil {
// Even if we can't capture the after state, we should try to log what we can
// This is a warning situation rather than a failure
fmt.Printf("Warning: failed to capture state after: %v\n", err)
stateAfter = stateBefore // Use the before state as a fallback
}
// 4. Create oplog entry
entry := models.NewOplogEntry(0, operation, description, stateBefore, stateAfter)
// 5. Write to oplog
err = t.oplogWriter.AppendEntry(entry)
if err != nil {
return fmt.Errorf("failed to write to oplog: %w", err)
}
return nil
}
// Close closes the transaction and releases resources
func (t *Transaction) Close() error {
return t.oplogWriter.Close()
}
// ExecuteWithTransactionAndMetadata executes a function with custom metadata
func (t *Transaction) ExecuteWithTransactionAndMetadata(
operation, description string,
metadata map[string]string,
fn func() error,
) error {
// Capture state_before
stateBefore, err := t.stateCapture.CaptureState()
if err != nil {
return fmt.Errorf("failed to capture state before: %w", err)
}
// Execute the function
err = fn()
if err != nil {
return fmt.Errorf("operation failed: %w", err)
}
// Capture state_after
stateAfter, err := t.stateCapture.CaptureState()
if err != nil {
fmt.Printf("Warning: failed to capture state after: %v\n", err)
stateAfter = stateBefore
}
// Create oplog entry with metadata
entry := models.NewOplogEntry(0, operation, description, stateBefore, stateAfter)
entry.Metadata = metadata
// Write to oplog
err = t.oplogWriter.AppendEntry(entry)
if err != nil {
return fmt.Errorf("failed to write to oplog: %w", err)
}
return nil
}
// Rollback attempts to rollback to a previous state
func (t *Transaction) Rollback(entryID uint64) error {
// Read the oplog entry
oplogPath := filepath.Join(t.repo.GetOnyxPath(), "oplog")
reader := storage.NewOplogReader(oplogPath)
entry, err := reader.ReadEntry(entryID)
if err != nil {
return fmt.Errorf("failed to read entry %d: %w", entryID, err)
}
// Restore the state_before from that entry
if entry.StateBefore == nil {
return fmt.Errorf("entry %d has no state_before to restore", entryID)
}
err = t.stateCapture.RestoreState(entry.StateBefore)
if err != nil {
return fmt.Errorf("failed to restore state: %w", err)
}
// Log the rollback operation
stateAfter, _ := t.stateCapture.CaptureState()
rollbackEntry := models.NewOplogEntry(
0,
"rollback",
fmt.Sprintf("Rolled back to entry %d", entryID),
stateAfter, // The current state becomes the "before"
entry.StateBefore, // The restored state becomes the "after"
)
rollbackEntry.Metadata = map[string]string{
"rollback_to_entry_id": fmt.Sprintf("%d", entryID),
}
err = t.oplogWriter.AppendEntry(rollbackEntry)
if err != nil {
// Don't fail the rollback if we can't log it
fmt.Printf("Warning: failed to log rollback: %v\n", err)
}
return nil
}
// Commit captures the final state and writes to oplog
func (t *Transaction) Commit(operation, description string) error {
// Capture state_after
stateAfter, err := t.stateCapture.CaptureState()
if err != nil {
return fmt.Errorf("failed to capture state: %w", err)
}
// Create oplog entry
entry := models.NewOplogEntry(0, operation, description, nil, stateAfter)
// Write to oplog
if err := t.oplogWriter.AppendEntry(entry); err != nil {
return fmt.Errorf("failed to write to oplog: %w", err)
}
return t.Close()
}
// Helper function to execute a transaction on a repository
func ExecuteWithTransaction(repo *OnyxRepository, operation, description string, fn func() error) error {
txn, err := NewTransaction(repo)
if err != nil {
return err
}
defer txn.Close()
return txn.ExecuteWithTransaction(operation, description, fn)
}

View File

@ -0,0 +1,389 @@
package core
import (
"fmt"
"path/filepath"
"regexp"
"strings"
"git.dws.rip/DWS/onyx/internal/git"
"git.dws.rip/DWS/onyx/internal/models"
"git.dws.rip/DWS/onyx/internal/storage"
gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
)
// WorkstreamManager manages workstream operations
type WorkstreamManager struct {
repo *OnyxRepository
gitBackend *git.GitBackend
workstreamsPath string
}
// NewWorkstreamManager creates a new workstream manager
func NewWorkstreamManager(repo *OnyxRepository) *WorkstreamManager {
return &WorkstreamManager{
repo: repo,
gitBackend: git.NewGitBackend(repo.GetGitRepo()),
workstreamsPath: filepath.Join(repo.GetOnyxPath(), "workstreams.json"),
}
}
// ValidateWorkstreamName validates a workstream name
func ValidateWorkstreamName(name string) error {
if name == "" {
return fmt.Errorf("workstream name cannot be empty")
}
// Only allow alphanumeric characters, hyphens, underscores, and slashes
validName := regexp.MustCompile(`^[a-zA-Z0-9_/-]+$`)
if !validName.MatchString(name) {
return fmt.Errorf("workstream name '%s' contains invalid characters. Only alphanumeric, hyphens, underscores, and slashes are allowed", name)
}
// Prevent names that could cause issues
reserved := []string{"HEAD", ".", ".."}
for _, r := range reserved {
if strings.EqualFold(name, r) {
return fmt.Errorf("workstream name '%s' is reserved", name)
}
}
return nil
}
// CreateWorkstream creates a new workstream
func (wm *WorkstreamManager) CreateWorkstream(name, baseBranch string) error {
// Validate the name
if err := ValidateWorkstreamName(name); err != nil {
return err
}
// Load existing workstreams
collection, err := storage.LoadWorkstreams(wm.workstreamsPath)
if err != nil {
return fmt.Errorf("failed to load workstreams: %w", err)
}
// Check if workstream already exists
if _, exists := collection.Workstreams[name]; exists {
return fmt.Errorf("workstream '%s' already exists", name)
}
// Default to main if no base branch specified
if baseBranch == "" {
baseBranch = "main"
}
// Try to fetch latest from remote base branch
// We'll attempt this but won't fail if it doesn't work (might be a local-only repo)
remoteBranch := fmt.Sprintf("origin/%s", baseBranch)
_ = wm.fetchRemoteBranch(remoteBranch)
// Get the base commit SHA
baseCommitSHA, err := wm.getBaseBranchHead(baseBranch)
if err != nil {
return fmt.Errorf("failed to get base branch HEAD: %w", err)
}
// Create the workstream
workstream := models.NewWorkstream(name, "", baseBranch)
// Add the base commit SHA to metadata for reference
workstream.Metadata["base_commit"] = baseCommitSHA
// Add to collection
if err := collection.AddWorkstream(workstream); err != nil {
return fmt.Errorf("failed to add workstream: %w", err)
}
// Set as current workstream
collection.CurrentWorkstream = name
// Save the collection
if err := storage.SaveWorkstreams(wm.workstreamsPath, collection); err != nil {
return fmt.Errorf("failed to save workstreams: %w", err)
}
// Update workspace to point to base commit (only if we have a valid base commit)
if baseCommitSHA != "" {
workspaceRef := "refs/onyx/workspaces/current"
if err := wm.gitBackend.UpdateRef(workspaceRef, baseCommitSHA); err != nil {
// This is non-fatal - the daemon will create a new ephemeral commit
// We'll just log a warning
fmt.Printf("Warning: failed to update workspace ref: %v\n", err)
}
}
return nil
}
// GetCurrentWorkstream returns the current active workstream
func (wm *WorkstreamManager) GetCurrentWorkstream() (*models.Workstream, error) {
collection, err := storage.LoadWorkstreams(wm.workstreamsPath)
if err != nil {
return nil, fmt.Errorf("failed to load workstreams: %w", err)
}
return collection.GetCurrentWorkstream()
}
// SwitchWorkstream switches to a different workstream
func (wm *WorkstreamManager) SwitchWorkstream(name string) error {
// Load workstreams
collection, err := storage.LoadWorkstreams(wm.workstreamsPath)
if err != nil {
return fmt.Errorf("failed to load workstreams: %w", err)
}
// Check if target workstream exists
targetWorkstream, err := collection.GetWorkstream(name)
if err != nil {
return fmt.Errorf("workstream '%s' not found", name)
}
// Get the commit to checkout
var checkoutSHA string
if !targetWorkstream.IsEmpty() {
// Checkout the latest commit in the workstream
latestCommit, err := targetWorkstream.GetLatestCommit()
if err != nil {
return fmt.Errorf("failed to get latest commit: %w", err)
}
checkoutSHA = latestCommit.SHA
} else {
// Checkout the base commit
baseCommitSHA := targetWorkstream.Metadata["base_commit"]
if baseCommitSHA == "" {
// Fallback to getting the base branch HEAD
baseCommitSHA, err = wm.getBaseBranchHead(targetWorkstream.BaseBranch)
if err != nil {
return fmt.Errorf("failed to get base branch HEAD: %w", err)
}
}
checkoutSHA = baseCommitSHA
}
// Update the working directory to the target commit
worktree, err := wm.repo.GetGitRepo().Worktree()
if err != nil {
return fmt.Errorf("failed to get worktree: %w", err)
}
// Checkout the commit
err = worktree.Checkout(&gogit.CheckoutOptions{
Hash: plumbing.NewHash(checkoutSHA),
Force: true,
})
if err != nil {
return fmt.Errorf("failed to checkout commit: %w", err)
}
// Update current workstream
if err := collection.SetCurrentWorkstream(name); err != nil {
return fmt.Errorf("failed to set current workstream: %w", err)
}
// Save the collection
if err := storage.SaveWorkstreams(wm.workstreamsPath, collection); err != nil {
return fmt.Errorf("failed to save workstreams: %w", err)
}
// Update workspace ref to point to the checked out commit (only if we have a valid commit)
if checkoutSHA != "" {
workspaceRef := "refs/onyx/workspaces/current"
if err := wm.gitBackend.UpdateRef(workspaceRef, checkoutSHA); err != nil {
fmt.Printf("Warning: failed to update workspace ref: %v\n", err)
}
}
return nil
}
// ListWorkstreams returns all workstreams
func (wm *WorkstreamManager) ListWorkstreams() ([]*models.Workstream, error) {
collection, err := storage.LoadWorkstreams(wm.workstreamsPath)
if err != nil {
return nil, fmt.Errorf("failed to load workstreams: %w", err)
}
return collection.ListWorkstreams(), nil
}
// GetCurrentWorkstreamName returns the name of the current workstream
func (wm *WorkstreamManager) GetCurrentWorkstreamName() (string, error) {
collection, err := storage.LoadWorkstreams(wm.workstreamsPath)
if err != nil {
return "", fmt.Errorf("failed to load workstreams: %w", err)
}
if collection.CurrentWorkstream == "" {
return "", fmt.Errorf("no current workstream set")
}
return collection.CurrentWorkstream, nil
}
// AddCommitToWorkstream adds a commit to the current workstream
func (wm *WorkstreamManager) AddCommitToWorkstream(sha, message string) error {
// Load workstreams
collection, err := storage.LoadWorkstreams(wm.workstreamsPath)
if err != nil {
return fmt.Errorf("failed to load workstreams: %w", err)
}
// Get current workstream
currentWorkstream, err := collection.GetCurrentWorkstream()
if err != nil {
return fmt.Errorf("no active workstream: %w", err)
}
// Determine parent SHA
var parentSHA string
if !currentWorkstream.IsEmpty() {
latestCommit, err := currentWorkstream.GetLatestCommit()
if err != nil {
return fmt.Errorf("failed to get latest commit: %w", err)
}
parentSHA = latestCommit.SHA
} else {
// For the first commit, use the base commit
baseCommitSHA := currentWorkstream.Metadata["base_commit"]
if baseCommitSHA == "" {
baseCommitSHA, err = wm.getBaseBranchHead(currentWorkstream.BaseBranch)
if err != nil {
return fmt.Errorf("failed to get base branch HEAD: %w", err)
}
}
parentSHA = baseCommitSHA
}
// Get the base commit SHA
baseSHA := currentWorkstream.Metadata["base_commit"]
if baseSHA == "" {
baseSHA, err = wm.getBaseBranchHead(currentWorkstream.BaseBranch)
if err != nil {
return fmt.Errorf("failed to get base branch HEAD: %w", err)
}
}
// Determine the branch ref
nextNumber := currentWorkstream.GetCommitCount() + 1
branchRef := fmt.Sprintf("refs/onyx/workstreams/%s/commit-%d", currentWorkstream.Name, nextNumber)
// Create the workstream commit
workstreamCommit := models.NewWorkstreamCommit(
sha,
message,
"User", // TODO: Get actual user from git config
parentSHA,
baseSHA,
branchRef,
)
// Add commit to workstream
currentWorkstream.AddCommit(workstreamCommit)
// Update the branch ref to point to this commit
if err := wm.gitBackend.UpdateRef(branchRef, sha); err != nil {
return fmt.Errorf("failed to create branch ref: %w", err)
}
// Save the collection
if err := storage.SaveWorkstreams(wm.workstreamsPath, collection); err != nil {
return fmt.Errorf("failed to save workstreams: %w", err)
}
return nil
}
// fetchRemoteBranch attempts to fetch the latest from a remote branch
func (wm *WorkstreamManager) fetchRemoteBranch(remoteBranch string) error {
// This is a best-effort operation
// We use the underlying git command for now
// In the future, we could use go-git's fetch capabilities
// For now, we'll just return nil as this is optional
// The real implementation would use go-git's Fetch method
return nil
}
// getBaseBranchHead gets the HEAD commit SHA of a base branch
func (wm *WorkstreamManager) getBaseBranchHead(baseBranch string) (string, error) {
// Try local branch first
refName := fmt.Sprintf("refs/heads/%s", baseBranch)
sha, err := wm.gitBackend.GetRef(refName)
if err == nil {
return sha, nil
}
// Try remote branch
remoteRefName := fmt.Sprintf("refs/remotes/origin/%s", baseBranch)
sha, err = wm.gitBackend.GetRef(remoteRefName)
if err == nil {
return sha, nil
}
// If we still can't find it, try HEAD
head, err := wm.repo.GetGitRepo().Head()
if err != nil {
// Empty repository with no commits - return empty string
// This is a valid state for a brand new repository
return "", nil
}
return head.Hash().String(), nil
}
// getCurrentBranchName gets the name of the current Git branch
func (wm *WorkstreamManager) getCurrentBranchName() (string, error) {
// Try to get HEAD reference
head, err := wm.repo.GetGitRepo().Head()
if err != nil {
// No HEAD yet (empty repo) - check the symbolic ref manually
ref, err := wm.repo.GetGitRepo().Reference(plumbing.HEAD, false)
if err != nil {
// Can't determine - default to "main"
return "main", nil
}
// Extract branch name from refs/heads/branch-name
if ref.Target().IsBranch() {
return ref.Target().Short(), nil
}
return "main", nil
}
// Check if we're on a branch (not detached HEAD)
if !head.Name().IsBranch() {
// Detached HEAD - default to "main"
return "main", nil
}
// Extract branch name from refs/heads/branch-name
branchName := head.Name().Short()
return branchName, nil
}
// CreateDefaultWorkstream creates a workstream matching the current Git branch
func (wm *WorkstreamManager) CreateDefaultWorkstream() error {
// Get the current Git branch name
branchName, err := wm.getCurrentBranchName()
if err != nil {
return fmt.Errorf("failed to get current branch: %w", err)
}
// Load existing workstreams to check if one already exists
collection, err := storage.LoadWorkstreams(wm.workstreamsPath)
if err != nil {
return fmt.Errorf("failed to load workstreams: %w", err)
}
// If a workstream already exists, don't create another one
if len(collection.Workstreams) > 0 {
return nil
}
// Create workstream with the same name as the branch
// This workstream tracks the branch it's named after
return wm.CreateWorkstream(branchName, branchName)
}

190
internal/daemon/daemon.go Normal file
View File

@ -0,0 +1,190 @@
package daemon
import (
"fmt"
"log"
"sync"
"time"
"git.dws.rip/DWS/onyx/internal/core"
"github.com/fsnotify/fsnotify"
)
// Daemon manages the filesystem watching and automatic snapshot creation
type Daemon struct {
repo *core.OnyxRepository
watcher *fsnotify.Watcher
ticker *time.Ticker
debounce time.Duration
shutdown chan bool
mu sync.Mutex
isRunning bool
// Debouncing state
pendingChanges bool
lastChangeTime time.Time
}
// Config holds daemon configuration options
type Config struct {
// Debounce duration for filesystem events (default: 500ms)
Debounce time.Duration
// Ticker interval for periodic checks (default: 1 second)
TickerInterval time.Duration
// Repository root path
RepoPath string
}
// DefaultConfig returns the default daemon configuration
func DefaultConfig() *Config {
return &Config{
Debounce: 500 * time.Millisecond,
TickerInterval: 1 * time.Second,
}
}
// New creates a new Daemon instance
func New(repo *core.OnyxRepository, config *Config) (*Daemon, error) {
if config == nil {
config = DefaultConfig()
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("failed to create filesystem watcher: %w", err)
}
return &Daemon{
repo: repo,
watcher: watcher,
ticker: time.NewTicker(config.TickerInterval),
debounce: config.Debounce,
shutdown: make(chan bool),
isRunning: false,
pendingChanges: false,
}, nil
}
// Start begins the daemon's main loop
func (d *Daemon) Start() error {
d.mu.Lock()
if d.isRunning {
d.mu.Unlock()
return fmt.Errorf("daemon is already running")
}
d.isRunning = true
d.mu.Unlock()
// Set up filesystem watchers
if err := d.setupWatchers(); err != nil {
d.isRunning = false
return fmt.Errorf("failed to setup watchers: %w", err)
}
log.Println("Onyx daemon started")
// Run the main event loop
go d.run()
return nil
}
// Stop gracefully shuts down the daemon
func (d *Daemon) Stop() error {
d.mu.Lock()
if !d.isRunning {
d.mu.Unlock()
return fmt.Errorf("daemon is not running")
}
d.mu.Unlock()
log.Println("Stopping Onyx daemon...")
// Signal shutdown
close(d.shutdown)
// Clean up resources
d.ticker.Stop()
if err := d.watcher.Close(); err != nil {
return fmt.Errorf("failed to close watcher: %w", err)
}
d.mu.Lock()
d.isRunning = false
d.mu.Unlock()
log.Println("Onyx daemon stopped")
return nil
}
// IsRunning returns whether the daemon is currently running
func (d *Daemon) IsRunning() bool {
d.mu.Lock()
defer d.mu.Unlock()
return d.isRunning
}
// run is the main event loop for the daemon
func (d *Daemon) run() {
for {
select {
case <-d.shutdown:
return
case event, ok := <-d.watcher.Events:
if !ok {
return
}
d.handleFileEvent(event)
case err, ok := <-d.watcher.Errors:
if !ok {
return
}
log.Printf("Watcher error: %v", err)
case <-d.ticker.C:
d.processDebounced()
}
}
}
// handleFileEvent processes a filesystem event
func (d *Daemon) handleFileEvent(event fsnotify.Event) {
// Ignore events for .git and .onx directories
if shouldIgnorePath(event.Name) {
return
}
// Mark that we have pending changes
d.mu.Lock()
d.pendingChanges = true
d.lastChangeTime = time.Now()
d.mu.Unlock()
log.Printf("File change detected: %s [%s]", event.Name, event.Op)
}
// processDebounced checks if enough time has passed since the last change
// and creates a snapshot if needed
func (d *Daemon) processDebounced() {
d.mu.Lock()
hasPending := d.pendingChanges
timeSinceChange := time.Since(d.lastChangeTime)
d.mu.Unlock()
if hasPending && timeSinceChange >= d.debounce {
d.mu.Lock()
d.pendingChanges = false
d.mu.Unlock()
log.Println("Creating automatic snapshot...")
if err := d.CreateSnapshot(); err != nil {
log.Printf("Failed to create snapshot: %v", err)
} else {
log.Println("Snapshot created successfully")
}
}
}

211
internal/daemon/snapshot.go Normal file
View File

@ -0,0 +1,211 @@
package daemon
import (
"fmt"
"os"
"path/filepath"
"time"
"git.dws.rip/DWS/onyx/internal/git"
"git.dws.rip/DWS/onyx/internal/models"
gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/filemode"
)
const (
// OnyxWorkspaceRef is the ref where ephemeral commits are stored
OnyxWorkspaceRef = "refs/onyx/workspaces/current"
)
// CreateSnapshot creates an ephemeral commit representing the current workspace state
func (d *Daemon) CreateSnapshot() error {
// 1. Read current workspace pointer
workspaceState, err := d.readWorkspaceState()
if err != nil {
// If workspace doesn't exist, create a new one
workspaceState = models.NewWorkspaceState("", "main")
}
// 2. Create tree from working directory
gitBackend := git.NewGitBackend(d.repo.GetGitRepo())
repoRoot := filepath.Dir(d.repo.GetOnyxPath())
treeHash, err := d.createWorkspaceTree(repoRoot)
if err != nil {
return fmt.Errorf("failed to create workspace tree: %w", err)
}
// 3. Get the parent commit (if it exists)
var parentHash string
if workspaceState.CurrentCommitSHA != "" {
parentHash = workspaceState.CurrentCommitSHA
} else {
// Try to get the current HEAD commit as parent
head, err := d.repo.GetGitRepo().Head()
if err == nil {
parentHash = head.Hash().String()
}
}
// 4. Create ephemeral commit
message := fmt.Sprintf("[onyx-snapshot] Auto-save at %s", time.Now().Format("2006-01-02 15:04:05"))
commitHash, err := gitBackend.CreateCommit(treeHash, parentHash, message, "Onyx Daemon")
if err != nil {
return fmt.Errorf("failed to create commit: %w", err)
}
// 5. Update refs/onyx/workspaces/current
if err := gitBackend.UpdateRef(OnyxWorkspaceRef, commitHash); err != nil {
return fmt.Errorf("failed to update workspace ref: %w", err)
}
// 6. Update .onx/workspace pointer
workspaceState.UpdateSnapshot(commitHash, treeHash, "", false)
if err := d.saveWorkspaceState(workspaceState); err != nil {
return fmt.Errorf("failed to save workspace state: %w", err)
}
return nil
}
// createWorkspaceTree creates a Git tree object from the current working directory
func (d *Daemon) createWorkspaceTree(rootPath string) (string, error) {
gitBackend := git.NewGitBackend(d.repo.GetGitRepo())
// Use the worktree to build the tree
worktree, err := d.repo.GetGitRepo().Worktree()
if err != nil {
return "", fmt.Errorf("failed to get worktree: %w", err)
}
// Create tree entries by walking the working directory
entries := []git.TreeEntry{}
err = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip the root directory itself
if path == rootPath {
return nil
}
// Get relative path
relPath, err := filepath.Rel(rootPath, path)
if err != nil {
return err
}
// Skip .git and .onx directories
if shouldIgnorePath(path) {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
// For now, we'll use a simplified approach: hash the file content
if !info.IsDir() {
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read file %s: %w", path, err)
}
// Create blob for file content
blobHash, err := gitBackend.CreateBlob(content)
if err != nil {
return fmt.Errorf("failed to create blob for %s: %w", path, err)
}
// Determine file mode
mode := filemode.Regular
if info.Mode()&0111 != 0 {
mode = filemode.Executable
}
entries = append(entries, git.TreeEntry{
Name: relPath,
Mode: mode,
Hash: git.HashFromString(blobHash),
})
}
return nil
})
if err != nil {
return "", fmt.Errorf("failed to walk directory: %w", err)
}
// For a proper implementation, we'd need to build a hierarchical tree
// For now, we'll use the worktree's tree builder
return d.buildTreeFromWorktree(worktree)
}
// buildTreeFromWorktree builds a tree object from the current worktree state
func (d *Daemon) buildTreeFromWorktree(worktree *gogit.Worktree) (string, error) {
// Get the current index/staging area state
// This is a simplified version - in production we'd want to properly handle
// all files in the working directory
// For now, get the HEAD tree as a base
head, err := d.repo.GetGitRepo().Head()
if err != nil {
// No HEAD yet (empty repo), return empty tree
return d.createEmptyTree()
}
commit, err := d.repo.GetGitRepo().CommitObject(head.Hash())
if err != nil {
return "", fmt.Errorf("failed to get HEAD commit: %w", err)
}
tree, err := commit.Tree()
if err != nil {
return "", fmt.Errorf("failed to get commit tree: %w", err)
}
// For now, just return the HEAD tree hash
// In a full implementation, we'd modify this tree based on working directory changes
return tree.Hash.String(), nil
}
// createEmptyTree creates an empty Git tree object
func (d *Daemon) createEmptyTree() (string, error) {
gitBackend := git.NewGitBackend(d.repo.GetGitRepo())
return gitBackend.CreateTree([]git.TreeEntry{})
}
// readWorkspaceState reads the workspace state from .onx/workspace
func (d *Daemon) readWorkspaceState() (*models.WorkspaceState, error) {
workspacePath := filepath.Join(d.repo.GetOnyxPath(), "workspace")
data, err := os.ReadFile(workspacePath)
if err != nil {
return nil, fmt.Errorf("failed to read workspace file: %w", err)
}
state, err := models.DeserializeWorkspaceState(data)
if err != nil {
return nil, fmt.Errorf("failed to deserialize workspace state: %w", err)
}
return state, nil
}
// saveWorkspaceState saves the workspace state to .onx/workspace
func (d *Daemon) saveWorkspaceState(state *models.WorkspaceState) error {
workspacePath := filepath.Join(d.repo.GetOnyxPath(), "workspace")
data, err := state.Serialize()
if err != nil {
return fmt.Errorf("failed to serialize workspace state: %w", err)
}
if err := os.WriteFile(workspacePath, data, 0644); err != nil {
return fmt.Errorf("failed to write workspace file: %w", err)
}
return nil
}

112
internal/daemon/watcher.go Normal file
View File

@ -0,0 +1,112 @@
package daemon
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
)
// setupWatchers initializes the filesystem watcher for the repository
func (d *Daemon) setupWatchers() error {
// Get the repository root
repoRoot := filepath.Dir(d.repo.GetOnyxPath())
// Add the root directory to the watcher
if err := d.addWatchRecursive(repoRoot); err != nil {
return fmt.Errorf("failed to add watches: %w", err)
}
log.Printf("Watching repository at: %s", repoRoot)
return nil
}
// addWatchRecursive adds watches for a directory and all its subdirectories
func (d *Daemon) addWatchRecursive(path string) error {
// Walk the directory tree
return filepath.Walk(path, func(walkPath string, info os.FileInfo, err error) error {
if err != nil {
// Skip directories we can't access
log.Printf("Warning: cannot access %s: %v", walkPath, err)
return nil
}
// Skip files, only watch directories
if !info.IsDir() {
return nil
}
// Skip .git and .onx directories
if shouldIgnorePath(walkPath) {
return filepath.SkipDir
}
// Add watch for this directory
if err := d.watcher.Add(walkPath); err != nil {
log.Printf("Warning: cannot watch %s: %v", walkPath, err)
return nil
}
return nil
})
}
// shouldIgnorePath determines if a path should be ignored by the watcher
func shouldIgnorePath(path string) bool {
// Get the base name and check against ignored patterns
base := filepath.Base(path)
// Ignore .git and .onx directories
if base == ".git" || base == ".onx" {
return true
}
// Ignore hidden directories starting with .
if strings.HasPrefix(base, ".") && base != "." {
return true
}
// Ignore common build/dependency directories
ignoredDirs := []string{
"node_modules",
"vendor",
"target",
"build",
"dist",
".vscode",
".idea",
"__pycache__",
".pytest_cache",
".mypy_cache",
}
for _, ignored := range ignoredDirs {
if base == ignored {
return true
}
}
// Ignore temporary and backup files
if strings.HasSuffix(path, "~") ||
strings.HasSuffix(path, ".swp") ||
strings.HasSuffix(path, ".tmp") {
return true
}
return false
}
// AddWatch adds a new directory to the watch list (useful for newly created directories)
func (d *Daemon) AddWatch(path string) error {
if shouldIgnorePath(path) {
return nil
}
return d.watcher.Add(path)
}
// RemoveWatch removes a directory from the watch list
func (d *Daemon) RemoveWatch(path string) error {
return d.watcher.Remove(path)
}

190
internal/git/auth.go Normal file
View File

@ -0,0 +1,190 @@
package git
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/go-git/go-git/v5/plumbing/transport/ssh"
)
// AuthProvider handles authentication for Git operations
type AuthProvider struct {
cache map[string]transport.AuthMethod
}
// NewAuthProvider creates a new AuthProvider
func NewAuthProvider() *AuthProvider {
return &AuthProvider{
cache: make(map[string]transport.AuthMethod),
}
}
// GetAuthMethod returns the appropriate authentication method for a URL
func (ap *AuthProvider) GetAuthMethod(url string) (transport.AuthMethod, error) {
// Check cache first
if auth, ok := ap.cache[url]; ok {
return auth, nil
}
var auth transport.AuthMethod
var err error
// Detect transport type from URL
if strings.HasPrefix(url, "git@") || strings.HasPrefix(url, "ssh://") {
// SSH authentication
auth, err = ap.getSSHAuth()
} else if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
// HTTPS authentication
auth, err = ap.getHTTPSAuth(url)
} else {
return nil, fmt.Errorf("unsupported URL scheme: %s", url)
}
if err != nil {
return nil, err
}
// Cache the auth method
ap.cache[url] = auth
return auth, nil
}
// getSSHAuth attempts to get SSH authentication
func (ap *AuthProvider) getSSHAuth() (transport.AuthMethod, error) {
// Try SSH agent first
auth, err := ssh.NewSSHAgentAuth("git")
if err == nil {
return auth, nil
}
// Fallback to loading SSH keys from default locations
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err)
}
sshDir := filepath.Join(homeDir, ".ssh")
// Try common key files
keyFiles := []string{
"id_ed25519",
"id_rsa",
"id_ecdsa",
"id_dsa",
}
for _, keyFile := range keyFiles {
keyPath := filepath.Join(sshDir, keyFile)
if _, err := os.Stat(keyPath); err == nil {
// Try loading without passphrase first
auth, err := ssh.NewPublicKeysFromFile("git", keyPath, "")
if err == nil {
return auth, nil
}
// If that fails, it might need a passphrase
// For now, we'll skip passphrase-protected keys
// In the future, we could prompt for the passphrase
continue
}
}
return nil, fmt.Errorf("no SSH authentication method available (tried ssh-agent and ~/.ssh keys)")
}
// getHTTPSAuth attempts to get HTTPS authentication
func (ap *AuthProvider) getHTTPSAuth(url string) (transport.AuthMethod, error) {
// Try git credential helper first
auth, err := ap.tryGitCredentialHelper(url)
if err == nil && auth != nil {
return auth, nil
}
// Try environment variables
username := os.Getenv("GIT_USERNAME")
password := os.Getenv("GIT_PASSWORD")
token := os.Getenv("GIT_TOKEN")
if token != "" {
// Use token as password (common for GitHub, GitLab, etc.)
return &http.BasicAuth{
Username: "git", // Token usually goes in password field
Password: token,
}, nil
}
if username != "" && password != "" {
return &http.BasicAuth{
Username: username,
Password: password,
}, nil
}
// No credentials available - return nil to let go-git try anonymous
// (this will fail for private repos but that's expected)
return nil, fmt.Errorf("no HTTPS credentials available (tried git credential helper and environment variables)")
}
// tryGitCredentialHelper attempts to use git's credential helper
func (ap *AuthProvider) tryGitCredentialHelper(url string) (*http.BasicAuth, error) {
// Build the credential request
input := fmt.Sprintf("protocol=https\nhost=%s\n\n", extractHost(url))
// Call git credential fill
cmd := exec.Command("git", "credential", "fill")
cmd.Stdin = strings.NewReader(input)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("git credential helper failed: %w", err)
}
// Parse the output
lines := strings.Split(string(output), "\n")
auth := &http.BasicAuth{}
for _, line := range lines {
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "username":
auth.Username = value
case "password":
auth.Password = value
}
}
if auth.Username == "" || auth.Password == "" {
return nil, fmt.Errorf("git credential helper did not return username and password")
}
return auth, nil
}
// extractHost extracts the host from a URL
func extractHost(url string) string {
// Remove protocol
url = strings.TrimPrefix(url, "https://")
url = strings.TrimPrefix(url, "http://")
// Extract host (everything before the first /)
parts := strings.SplitN(url, "/", 2)
return parts[0]
}
// ClearCache clears the authentication cache
func (ap *AuthProvider) ClearCache() {
ap.cache = make(map[string]transport.AuthMethod)
}

187
internal/git/conflicts.go Normal file
View File

@ -0,0 +1,187 @@
package git
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/format/index"
)
// ConflictInfo represents information about a merge conflict
type ConflictInfo struct {
FilePath string
OursHash string
TheirsHash string
BaseHash string
HasConflict bool
}
// ConflictResolver handles conflict detection and resolution guidance
type ConflictResolver struct {
repo *gogit.Repository
repoPath string
}
// NewConflictResolver creates a new ConflictResolver instance
func NewConflictResolver(repo *gogit.Repository, repoPath string) *ConflictResolver {
return &ConflictResolver{
repo: repo,
repoPath: repoPath,
}
}
// DetectConflicts checks for merge conflicts in the working tree
func (cr *ConflictResolver) DetectConflicts() ([]ConflictInfo, error) {
idx, err := cr.repo.Storer.Index()
if err != nil {
return nil, fmt.Errorf("failed to read index: %w", err)
}
conflicts := []ConflictInfo{}
// Check for conflicts in the index
for _, entry := range idx.Entries {
// Stage > 0 indicates a conflict
if entry.Stage != 0 {
// Find all stages for this file
conflict := cr.findConflictStages(idx, entry.Name)
if conflict.HasConflict {
conflicts = append(conflicts, conflict)
}
}
}
return conflicts, nil
}
// findConflictStages finds all conflict stages for a file
func (cr *ConflictResolver) findConflictStages(idx *index.Index, path string) ConflictInfo {
conflict := ConflictInfo{
FilePath: path,
HasConflict: false,
}
for _, entry := range idx.Entries {
if entry.Name == path {
switch entry.Stage {
case 1:
// Base/common ancestor
conflict.BaseHash = entry.Hash.String()
conflict.HasConflict = true
case 2:
// Ours (current branch)
conflict.OursHash = entry.Hash.String()
conflict.HasConflict = true
case 3:
// Theirs (incoming branch)
conflict.TheirsHash = entry.Hash.String()
conflict.HasConflict = true
}
}
}
return conflict
}
// HasConflicts checks if there are any conflicts in the working tree
func (cr *ConflictResolver) HasConflicts() (bool, error) {
conflicts, err := cr.DetectConflicts()
if err != nil {
return false, err
}
return len(conflicts) > 0, nil
}
// PresentConflicts presents conflicts to the user with clear guidance
func (cr *ConflictResolver) PresentConflicts(conflicts []ConflictInfo) string {
if len(conflicts) == 0 {
return "No conflicts detected."
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("\n%s\n", strings.Repeat("=", 70)))
sb.WriteString(fmt.Sprintf(" MERGE CONFLICTS DETECTED (%d file(s))\n", len(conflicts)))
sb.WriteString(fmt.Sprintf("%s\n\n", strings.Repeat("=", 70)))
for i, conflict := range conflicts {
sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, conflict.FilePath))
sb.WriteString(fmt.Sprintf(" Base: %s\n", conflict.BaseHash[:8]))
sb.WriteString(fmt.Sprintf(" Ours: %s\n", conflict.OursHash[:8]))
sb.WriteString(fmt.Sprintf(" Theirs: %s\n", conflict.TheirsHash[:8]))
sb.WriteString("\n")
}
sb.WriteString("To resolve conflicts:\n")
sb.WriteString(" 1. Edit the conflicting files to resolve conflicts\n")
sb.WriteString(" 2. Look for conflict markers: <<<<<<<, =======, >>>>>>>\n")
sb.WriteString(" 3. Remove the conflict markers after resolving\n")
sb.WriteString(" 4. Stage the resolved files: git add <file>\n")
sb.WriteString(" 5. Continue the rebase: git rebase --continue\n")
sb.WriteString(fmt.Sprintf("%s\n", strings.Repeat("=", 70)))
return sb.String()
}
// GetConflictMarkers reads a file and extracts conflict marker sections
func (cr *ConflictResolver) GetConflictMarkers(filePath string) ([]ConflictMarker, error) {
fullPath := filepath.Join(cr.repoPath, filePath)
file, err := os.Open(fullPath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
markers := []ConflictMarker{}
scanner := bufio.NewScanner(file)
lineNum := 0
var currentMarker *ConflictMarker
for scanner.Scan() {
lineNum++
line := scanner.Text()
if strings.HasPrefix(line, "<<<<<<<") {
// Start of conflict
currentMarker = &ConflictMarker{
FilePath: filePath,
StartLine: lineNum,
}
} else if strings.HasPrefix(line, "=======") && currentMarker != nil {
currentMarker.SeparatorLine = lineNum
} else if strings.HasPrefix(line, ">>>>>>>") && currentMarker != nil {
currentMarker.EndLine = lineNum
markers = append(markers, *currentMarker)
currentMarker = nil
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading file: %w", err)
}
return markers, nil
}
// ConflictMarker represents a conflict marker section in a file
type ConflictMarker struct {
FilePath string
StartLine int
SeparatorLine int
EndLine int
}
// IsFileConflicted checks if a specific file has conflict markers
func (cr *ConflictResolver) IsFileConflicted(filePath string) (bool, error) {
markers, err := cr.GetConflictMarkers(filePath)
if err != nil {
return false, err
}
return len(markers) > 0, nil
}

210
internal/git/objects.go Normal file
View File

@ -0,0 +1,210 @@
package git
import (
"fmt"
"time"
gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
)
// GitBackend implements low-level Git object operations
type GitBackend struct {
repo *gogit.Repository
}
// NewGitBackend creates a new GitBackend instance
func NewGitBackend(repo *gogit.Repository) *GitBackend {
return &GitBackend{repo: repo}
}
// CreateBlob creates a new blob object from the given content
func (gb *GitBackend) CreateBlob(content []byte) (string, error) {
store := gb.repo.Storer
// Create a blob object
blob := store.NewEncodedObject()
blob.SetType(plumbing.BlobObject)
blob.SetSize(int64(len(content)))
writer, err := blob.Writer()
if err != nil {
return "", fmt.Errorf("failed to get blob writer: %w", err)
}
_, err = writer.Write(content)
if err != nil {
writer.Close()
return "", fmt.Errorf("failed to write blob content: %w", err)
}
if err := writer.Close(); err != nil {
return "", fmt.Errorf("failed to close blob writer: %w", err)
}
// Store the blob
hash, err := store.SetEncodedObject(blob)
if err != nil {
return "", fmt.Errorf("failed to store blob: %w", err)
}
return hash.String(), nil
}
// TreeEntry represents an entry in a Git tree
type TreeEntry struct {
Mode filemode.FileMode
Name string
Hash plumbing.Hash
}
// CreateTree creates a new tree object from the given entries
func (gb *GitBackend) CreateTree(entries []TreeEntry) (string, error) {
store := gb.repo.Storer
// Create a new tree object
tree := &object.Tree{}
treeEntries := make([]object.TreeEntry, len(entries))
for i, entry := range entries {
treeEntries[i] = object.TreeEntry{
Name: entry.Name,
Mode: entry.Mode,
Hash: entry.Hash,
}
}
tree.Entries = treeEntries
// Encode and store the tree
obj := store.NewEncodedObject()
if err := tree.Encode(obj); err != nil {
return "", fmt.Errorf("failed to encode tree: %w", err)
}
hash, err := store.SetEncodedObject(obj)
if err != nil {
return "", fmt.Errorf("failed to store tree: %w", err)
}
return hash.String(), nil
}
// CreateCommit creates a new commit object
func (gb *GitBackend) CreateCommit(treeHash, parentHash, message, author string) (string, error) {
store := gb.repo.Storer
// Parse hashes
tree := plumbing.NewHash(treeHash)
var parents []plumbing.Hash
if parentHash != "" {
parents = []plumbing.Hash{plumbing.NewHash(parentHash)}
}
// Create commit object
commit := &object.Commit{
Author: object.Signature{
Name: author,
Email: "onyx@local",
When: time.Now(),
},
Committer: object.Signature{
Name: author,
Email: "onyx@local",
When: time.Now(),
},
Message: message,
TreeHash: tree,
}
if len(parents) > 0 {
commit.ParentHashes = parents
}
// Encode and store the commit
obj := store.NewEncodedObject()
if err := commit.Encode(obj); err != nil {
return "", fmt.Errorf("failed to encode commit: %w", err)
}
hash, err := store.SetEncodedObject(obj)
if err != nil {
return "", fmt.Errorf("failed to store commit: %w", err)
}
return hash.String(), nil
}
// UpdateRef updates a Git reference to point to a new SHA
func (gb *GitBackend) UpdateRef(refName, sha string) error {
hash := plumbing.NewHash(sha)
ref := plumbing.NewHashReference(plumbing.ReferenceName(refName), hash)
if err := gb.repo.Storer.SetReference(ref); err != nil {
return fmt.Errorf("failed to update reference %s: %w", refName, err)
}
return nil
}
// GetRef retrieves the SHA that a reference points to
func (gb *GitBackend) GetRef(refName string) (string, error) {
ref, err := gb.repo.Reference(plumbing.ReferenceName(refName), true)
if err != nil {
return "", fmt.Errorf("failed to get reference %s: %w", refName, err)
}
return ref.Hash().String(), nil
}
// GetObject retrieves a Git object by its SHA
func (gb *GitBackend) GetObject(sha string) (object.Object, error) {
hash := plumbing.NewHash(sha)
obj, err := gb.repo.Object(plumbing.AnyObject, hash)
if err != nil {
return nil, fmt.Errorf("failed to get object %s: %w", sha, err)
}
return obj, nil
}
// GetBlob retrieves a blob object by its SHA
func (gb *GitBackend) GetBlob(sha string) (*object.Blob, error) {
hash := plumbing.NewHash(sha)
blob, err := gb.repo.BlobObject(hash)
if err != nil {
return nil, fmt.Errorf("failed to get blob %s: %w", sha, err)
}
return blob, nil
}
// GetTree retrieves a tree object by its SHA
func (gb *GitBackend) GetTree(sha string) (*object.Tree, error) {
hash := plumbing.NewHash(sha)
tree, err := gb.repo.TreeObject(hash)
if err != nil {
return nil, fmt.Errorf("failed to get tree %s: %w", sha, err)
}
return tree, nil
}
// GetCommit retrieves a commit object by its SHA
func (gb *GitBackend) GetCommit(sha string) (*object.Commit, error) {
hash := plumbing.NewHash(sha)
commit, err := gb.repo.CommitObject(hash)
if err != nil {
return nil, fmt.Errorf("failed to get commit %s: %w", sha, err)
}
return commit, nil
}
// HashFromString converts a string SHA to a plumbing.Hash
func HashFromString(sha string) plumbing.Hash {
return plumbing.NewHash(sha)
}

207
internal/git/rebase.go Normal file
View File

@ -0,0 +1,207 @@
package git
import (
"fmt"
"os"
gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
)
// RebaseEngine handles stacked rebase operations with rerere support
type RebaseEngine struct {
repo *gogit.Repository
backend *GitBackend
rerere *RerereManager
conflictResolver *ConflictResolver
repoPath string
}
// NewRebaseEngine creates a new RebaseEngine instance
func NewRebaseEngine(repo *gogit.Repository, onyxPath, repoPath string) *RebaseEngine {
return &RebaseEngine{
repo: repo,
backend: NewGitBackend(repo),
rerere: NewRerereManager(repo, onyxPath, repoPath),
conflictResolver: NewConflictResolver(repo, repoPath),
repoPath: repoPath,
}
}
// RebaseStackResult contains the result of a stack rebase operation
type RebaseStackResult struct {
Success bool
RebasedCommits []string
FailedCommit string
ConflictingFiles []ConflictInfo
Message string
}
// RebaseStack rebases a stack of commits onto a new base
func (re *RebaseEngine) RebaseStack(stack []string, onto string) (*RebaseStackResult, error) {
result := &RebaseStackResult{
Success: true,
RebasedCommits: []string{},
}
if len(stack) == 0 {
result.Message = "No commits to rebase"
return result, nil
}
// Validate onto commit exists
_, err := re.backend.GetCommit(onto)
if err != nil {
return nil, fmt.Errorf("invalid onto commit %s: %w", onto, err)
}
currentBase := onto
// Rebase each commit in the stack sequentially
for i, commitSHA := range stack {
// Get the commit object
commit, err := re.backend.GetCommit(commitSHA)
if err != nil {
result.Success = false
result.FailedCommit = commitSHA
result.Message = fmt.Sprintf("Failed to get commit %s: %v", commitSHA, err)
return result, fmt.Errorf("failed to get commit: %w", err)
}
// Rebase this commit onto the current base
newCommitSHA, err := re.rebaseSingleCommit(commit, currentBase)
if err != nil {
// Check if it's a conflict error
conflicts, detectErr := re.conflictResolver.DetectConflicts()
if detectErr == nil && len(conflicts) > 0 {
result.Success = false
result.FailedCommit = commitSHA
result.ConflictingFiles = conflicts
result.Message = fmt.Sprintf("Conflicts detected while rebasing commit %d/%d (%s)",
i+1, len(stack), commitSHA[:8])
return result, nil
}
result.Success = false
result.FailedCommit = commitSHA
result.Message = fmt.Sprintf("Failed to rebase commit %s: %v", commitSHA, err)
return result, err
}
result.RebasedCommits = append(result.RebasedCommits, newCommitSHA)
currentBase = newCommitSHA
}
result.Message = fmt.Sprintf("Successfully rebased %d commit(s)", len(stack))
return result, nil
}
// rebaseSingleCommit rebases a single commit onto a new parent
func (re *RebaseEngine) rebaseSingleCommit(commit *object.Commit, newParent string) (string, error) {
// Record conflicts before attempting rebase (for rerere)
if err := re.rerere.RecordConflicts(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to record conflicts: %v\n", err)
}
// Get the commit's tree
tree, err := commit.Tree()
if err != nil {
return "", fmt.Errorf("failed to get commit tree: %w", err)
}
// Check if there are any changes between the trees
// For simplicity, we'll create a new commit with the same tree content
// In a more sophisticated implementation, we would perform a three-way merge
// Try to apply rerere resolutions first
if re.rerere.IsEnabled() {
applied, err := re.rerere.ApplyResolutions()
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to apply rerere resolutions: %v\n", err)
} else if applied > 0 {
fmt.Printf("Applied %d rerere resolution(s)\n", applied)
}
}
// Perform a simple rebase by creating a new commit with the same tree but new parent
// This is a simplified implementation - a full implementation would handle merges
newCommitSHA, err := re.backend.CreateCommit(
tree.Hash.String(),
newParent,
commit.Message,
commit.Author.Name,
)
if err != nil {
return "", fmt.Errorf("failed to create rebased commit: %w", err)
}
return newCommitSHA, nil
}
// RebaseCommit rebases a single commit onto a new parent (public API)
func (re *RebaseEngine) RebaseCommit(commitSHA, newParent string) (string, error) {
commit, err := re.backend.GetCommit(commitSHA)
if err != nil {
return "", fmt.Errorf("failed to get commit: %w", err)
}
return re.rebaseSingleCommit(commit, newParent)
}
// ContinueRebase continues a rebase after conflict resolution
func (re *RebaseEngine) ContinueRebase(stack []string, fromIndex int, onto string) (*RebaseStackResult, error) {
// Record the resolution for rerere
if err := re.rerere.RecordResolution(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to record resolution: %v\n", err)
}
// Check if conflicts are resolved
hasConflicts, err := re.conflictResolver.HasConflicts()
if err != nil {
return nil, fmt.Errorf("failed to check for conflicts: %w", err)
}
if hasConflicts {
return &RebaseStackResult{
Success: false,
Message: "Conflicts still exist. Please resolve all conflicts before continuing.",
}, nil
}
// Continue rebasing from the next commit
remainingStack := stack[fromIndex:]
return re.RebaseStack(remainingStack, onto)
}
// AbortRebase aborts a rebase operation and returns to the original state
func (re *RebaseEngine) AbortRebase(originalHead string) error {
// Update HEAD to original commit
hash := plumbing.NewHash(originalHead)
worktree, err := re.repo.Worktree()
if err != nil {
return fmt.Errorf("failed to get worktree: %w", err)
}
// Checkout the original HEAD
err = worktree.Checkout(&gogit.CheckoutOptions{
Hash: hash,
Force: true,
})
if err != nil {
return fmt.Errorf("failed to checkout original HEAD: %w", err)
}
return nil
}
// GetRerereManager returns the rerere manager
func (re *RebaseEngine) GetRerereManager() *RerereManager {
return re.rerere
}
// GetConflictResolver returns the conflict resolver
func (re *RebaseEngine) GetConflictResolver() *ConflictResolver {
return re.conflictResolver
}

106
internal/git/remote.go Normal file
View File

@ -0,0 +1,106 @@
package git
import (
"fmt"
gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
)
// RemoteHelper provides utilities for working with Git remotes
type RemoteHelper struct {
repo *gogit.Repository
}
// NewRemoteHelper creates a new RemoteHelper instance
func NewRemoteHelper(repo *gogit.Repository) *RemoteHelper {
return &RemoteHelper{repo: repo}
}
// GetRemote retrieves a remote by name, defaults to "origin" if name is empty
func (rh *RemoteHelper) GetRemote(name string) (*gogit.Remote, error) {
if name == "" {
name = "origin"
}
remote, err := rh.repo.Remote(name)
if err != nil {
return nil, fmt.Errorf("remote '%s' not found: %w", name, err)
}
return remote, nil
}
// ListRemotes returns all configured remotes
func (rh *RemoteHelper) ListRemotes() ([]*gogit.Remote, error) {
remotes, err := rh.repo.Remotes()
if err != nil {
return nil, fmt.Errorf("failed to list remotes: %w", err)
}
return remotes, nil
}
// ValidateRemote checks if a remote exists and is properly configured
func (rh *RemoteHelper) ValidateRemote(name string) error {
if name == "" {
name = "origin"
}
remote, err := rh.GetRemote(name)
if err != nil {
return err
}
// Check if remote has URLs configured
cfg := remote.Config()
if len(cfg.URLs) == 0 {
return fmt.Errorf("remote '%s' has no URLs configured", name)
}
return nil
}
// GetDefaultRemoteName returns the default remote name (origin)
func (rh *RemoteHelper) GetDefaultRemoteName() string {
return "origin"
}
// GetRemoteURL returns the fetch URL for a remote
func (rh *RemoteHelper) GetRemoteURL(name string) (string, error) {
if name == "" {
name = "origin"
}
remote, err := rh.GetRemote(name)
if err != nil {
return "", err
}
cfg := remote.Config()
if len(cfg.URLs) == 0 {
return "", fmt.Errorf("remote '%s' has no URLs configured", name)
}
return cfg.URLs[0], nil
}
// GetRemoteConfig returns the configuration for a remote
func (rh *RemoteHelper) GetRemoteConfig(name string) (*config.RemoteConfig, error) {
if name == "" {
name = "origin"
}
remote, err := rh.GetRemote(name)
if err != nil {
return nil, err
}
return remote.Config(), nil
}
// HasRemote checks if a remote with the given name exists
func (rh *RemoteHelper) HasRemote(name string) bool {
_, err := rh.repo.Remote(name)
return err == nil
}

286
internal/git/rerere.go Normal file
View File

@ -0,0 +1,286 @@
package git
import (
"crypto/sha1"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
gogit "github.com/go-git/go-git/v5"
)
// RerereManager manages git rerere (reuse recorded resolution) functionality
type RerereManager struct {
repo *gogit.Repository
cachePath string
enabled bool
repoPath string
conflictResolver *ConflictResolver
}
// NewRerereManager creates a new RerereManager instance
func NewRerereManager(repo *gogit.Repository, onyxPath, repoPath string) *RerereManager {
cachePath := filepath.Join(onyxPath, "rerere_cache")
return &RerereManager{
repo: repo,
cachePath: cachePath,
enabled: true,
repoPath: repoPath,
conflictResolver: NewConflictResolver(repo, repoPath),
}
}
// Enable enables rerere functionality
func (rm *RerereManager) Enable() {
rm.enabled = true
}
// Disable disables rerere functionality
func (rm *RerereManager) Disable() {
rm.enabled = false
}
// IsEnabled returns whether rerere is enabled
func (rm *RerereManager) IsEnabled() bool {
return rm.enabled
}
// RecordConflicts records current conflicts for future resolution
func (rm *RerereManager) RecordConflicts() error {
if !rm.enabled {
return nil
}
conflicts, err := rm.conflictResolver.DetectConflicts()
if err != nil {
return fmt.Errorf("failed to detect conflicts: %w", err)
}
if len(conflicts) == 0 {
return nil
}
// For each conflict, create a cache entry
for _, conflict := range conflicts {
if err := rm.recordConflict(conflict); err != nil {
// Log error but continue with other conflicts
fmt.Fprintf(os.Stderr, "Warning: failed to record conflict for %s: %v\n", conflict.FilePath, err)
}
}
return nil
}
// recordConflict records a single conflict
func (rm *RerereManager) recordConflict(conflict ConflictInfo) error {
// Read the conflicted file
fullPath := filepath.Join(rm.repoPath, conflict.FilePath)
content, err := os.ReadFile(fullPath)
if err != nil {
return fmt.Errorf("failed to read conflicted file: %w", err)
}
// Generate a unique ID for this conflict pattern
conflictID := rm.generateConflictID(content)
// Create cache directory for this conflict
conflictDir := filepath.Join(rm.cachePath, conflictID)
if err := os.MkdirAll(conflictDir, 0755); err != nil {
return fmt.Errorf("failed to create conflict cache directory: %w", err)
}
// Save the preimage (conflict state)
preimagePath := filepath.Join(conflictDir, "preimage")
if err := os.WriteFile(preimagePath, content, 0644); err != nil {
return fmt.Errorf("failed to write preimage: %w", err)
}
// Save metadata
metadataPath := filepath.Join(conflictDir, "metadata")
metadata := fmt.Sprintf("file=%s\nbase=%s\nours=%s\ntheirs=%s\n",
conflict.FilePath, conflict.BaseHash, conflict.OursHash, conflict.TheirsHash)
if err := os.WriteFile(metadataPath, []byte(metadata), 0644); err != nil {
return fmt.Errorf("failed to write metadata: %w", err)
}
return nil
}
// RecordResolution records the resolution for previously recorded conflicts
func (rm *RerereManager) RecordResolution() error {
if !rm.enabled {
return nil
}
// Find all recorded conflicts
entries, err := os.ReadDir(rm.cachePath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to read rerere cache: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
conflictID := entry.Name()
if err := rm.recordResolutionForConflict(conflictID); err != nil {
// Log error but continue
fmt.Fprintf(os.Stderr, "Warning: failed to record resolution for %s: %v\n", conflictID, err)
}
}
return nil
}
// recordResolutionForConflict records the resolution for a specific conflict
func (rm *RerereManager) recordResolutionForConflict(conflictID string) error {
conflictDir := filepath.Join(rm.cachePath, conflictID)
// Read metadata to get file path
metadataPath := filepath.Join(conflictDir, "metadata")
metadataContent, err := os.ReadFile(metadataPath)
if err != nil {
return fmt.Errorf("failed to read metadata: %w", err)
}
// Parse file path from metadata
filePath := ""
for _, line := range strings.Split(string(metadataContent), "\n") {
if strings.HasPrefix(line, "file=") {
filePath = strings.TrimPrefix(line, "file=")
break
}
}
if filePath == "" {
return fmt.Errorf("file path not found in metadata")
}
// Check if file still has conflicts
fullPath := filepath.Join(rm.repoPath, filePath)
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
// File was deleted or doesn't exist, skip
return nil
}
hasConflicts, err := rm.conflictResolver.IsFileConflicted(filePath)
if err != nil {
return fmt.Errorf("failed to check if file is conflicted: %w", err)
}
if hasConflicts {
// Still has conflicts, not resolved yet
return nil
}
// Read the resolved content
resolvedContent, err := os.ReadFile(fullPath)
if err != nil {
return fmt.Errorf("failed to read resolved file: %w", err)
}
// Save the postimage (resolved state)
postimagePath := filepath.Join(conflictDir, "postimage")
if err := os.WriteFile(postimagePath, resolvedContent, 0644); err != nil {
return fmt.Errorf("failed to write postimage: %w", err)
}
return nil
}
// ApplyResolutions applies previously recorded resolutions to current conflicts
func (rm *RerereManager) ApplyResolutions() (int, error) {
if !rm.enabled {
return 0, nil
}
conflicts, err := rm.conflictResolver.DetectConflicts()
if err != nil {
return 0, fmt.Errorf("failed to detect conflicts: %w", err)
}
appliedCount := 0
for _, conflict := range conflicts {
// Read the conflicted file
fullPath := filepath.Join(rm.repoPath, conflict.FilePath)
content, err := os.ReadFile(fullPath)
if err != nil {
continue
}
// Generate conflict ID
conflictID := rm.generateConflictID(content)
// Check if we have a resolution for this conflict
postimagePath := filepath.Join(rm.cachePath, conflictID, "postimage")
if _, err := os.Stat(postimagePath); os.IsNotExist(err) {
continue
}
// Apply the resolution
resolvedContent, err := os.ReadFile(postimagePath)
if err != nil {
continue
}
if err := os.WriteFile(fullPath, resolvedContent, 0644); err != nil {
continue
}
appliedCount++
}
return appliedCount, nil
}
// generateConflictID generates a unique ID for a conflict pattern
func (rm *RerereManager) generateConflictID(content []byte) string {
// Normalize conflict content by removing variable parts
normalized := rm.normalizeConflict(content)
// Generate SHA1 hash
hash := sha1.New()
io.WriteString(hash, normalized)
return hex.EncodeToString(hash.Sum(nil))
}
// normalizeConflict normalizes conflict content for matching
func (rm *RerereManager) normalizeConflict(content []byte) string {
// Convert to string
str := string(content)
// Remove commit hashes from conflict markers (they vary)
lines := strings.Split(str, "\n")
var normalized []string
for _, line := range lines {
if strings.HasPrefix(line, "<<<<<<<") {
normalized = append(normalized, "<<<<<<<")
} else if strings.HasPrefix(line, ">>>>>>>") {
normalized = append(normalized, ">>>>>>>")
} else {
normalized = append(normalized, line)
}
}
return strings.Join(normalized, "\n")
}
// ClearCache clears the rerere cache
func (rm *RerereManager) ClearCache() error {
return os.RemoveAll(rm.cachePath)
}
// GetCachePath returns the path to the rerere cache
func (rm *RerereManager) GetCachePath() string {
return rm.cachePath
}

173
internal/models/oplog.go Normal file
View File

@ -0,0 +1,173 @@
package models
import (
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"time"
)
// OplogEntry represents a single entry in the action log
type OplogEntry struct {
// ID is a monotonically increasing entry ID
ID uint64
// Timestamp when the operation was performed
Timestamp time.Time
// Operation type (e.g., "save", "switch", "new", "sync")
Operation string
// Description of the operation
Description string
// StateBefore captures the state before the operation
StateBefore *RepositoryState
// StateAfter captures the state after the operation
StateAfter *RepositoryState
// Metadata contains operation-specific data
Metadata map[string]string
}
// RepositoryState captures the state of the repository at a point in time
type RepositoryState struct {
// Refs maps reference names to their SHA-1 hashes
Refs map[string]string
// CurrentWorkstream is the active workstream name
CurrentWorkstream string
// WorkingTreeHash is the hash of the current working tree snapshot
WorkingTreeHash string
// IndexHash is the hash of the staging area
IndexHash string
}
// Serialize converts an OplogEntry to binary format
func (e *OplogEntry) Serialize() ([]byte, error) {
buf := new(bytes.Buffer)
// Write entry ID (8 bytes)
if err := binary.Write(buf, binary.LittleEndian, e.ID); err != nil {
return nil, fmt.Errorf("failed to write ID: %w", err)
}
// Write timestamp (8 bytes, Unix nano)
timestamp := e.Timestamp.UnixNano()
if err := binary.Write(buf, binary.LittleEndian, timestamp); err != nil {
return nil, fmt.Errorf("failed to write timestamp: %w", err)
}
// Serialize the rest as JSON for flexibility
payload := struct {
Operation string `json:"operation"`
Description string `json:"description"`
StateBefore *RepositoryState `json:"state_before"`
StateAfter *RepositoryState `json:"state_after"`
Metadata map[string]string `json:"metadata"`
}{
Operation: e.Operation,
Description: e.Description,
StateBefore: e.StateBefore,
StateAfter: e.StateAfter,
Metadata: e.Metadata,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal JSON: %w", err)
}
// Write JSON length (4 bytes)
jsonLen := uint32(len(jsonData))
if err := binary.Write(buf, binary.LittleEndian, jsonLen); err != nil {
return nil, fmt.Errorf("failed to write JSON length: %w", err)
}
// Write JSON data
if _, err := buf.Write(jsonData); err != nil {
return nil, fmt.Errorf("failed to write JSON data: %w", err)
}
return buf.Bytes(), nil
}
// Deserialize converts binary data back to an OplogEntry
func DeserializeOplogEntry(data []byte) (*OplogEntry, error) {
buf := bytes.NewReader(data)
entry := &OplogEntry{}
// Read entry ID (8 bytes)
if err := binary.Read(buf, binary.LittleEndian, &entry.ID); err != nil {
return nil, fmt.Errorf("failed to read ID: %w", err)
}
// Read timestamp (8 bytes)
var timestamp int64
if err := binary.Read(buf, binary.LittleEndian, &timestamp); err != nil {
return nil, fmt.Errorf("failed to read timestamp: %w", err)
}
entry.Timestamp = time.Unix(0, timestamp)
// Read JSON length (4 bytes)
var jsonLen uint32
if err := binary.Read(buf, binary.LittleEndian, &jsonLen); err != nil {
return nil, fmt.Errorf("failed to read JSON length: %w", err)
}
// Read JSON data
jsonData := make([]byte, jsonLen)
if _, err := io.ReadFull(buf, jsonData); err != nil {
return nil, fmt.Errorf("failed to read JSON data: %w", err)
}
// Unmarshal JSON
payload := struct {
Operation string `json:"operation"`
Description string `json:"description"`
StateBefore *RepositoryState `json:"state_before"`
StateAfter *RepositoryState `json:"state_after"`
Metadata map[string]string `json:"metadata"`
}{}
if err := json.Unmarshal(jsonData, &payload); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
}
entry.Operation = payload.Operation
entry.Description = payload.Description
entry.StateBefore = payload.StateBefore
entry.StateAfter = payload.StateAfter
entry.Metadata = payload.Metadata
return entry, nil
}
// NewOplogEntry creates a new oplog entry
func NewOplogEntry(id uint64, operation, description string, before, after *RepositoryState) *OplogEntry {
return &OplogEntry{
ID: id,
Timestamp: time.Now(),
Operation: operation,
Description: description,
StateBefore: before,
StateAfter: after,
Metadata: make(map[string]string),
}
}
// NewRepositoryState creates a new repository state snapshot
func NewRepositoryState(refs map[string]string, currentWorkstream, workingTreeHash, indexHash string) *RepositoryState {
return &RepositoryState{
Refs: refs,
CurrentWorkstream: currentWorkstream,
WorkingTreeHash: workingTreeHash,
IndexHash: indexHash,
}
}

View File

@ -0,0 +1,107 @@
package models
import (
"encoding/json"
"fmt"
"time"
)
// WorkspaceState represents the current state of the workspace
type WorkspaceState struct {
// CurrentCommitSHA is the SHA of the current ephemeral commit
CurrentCommitSHA string `json:"current_commit_sha"`
// WorkstreamName is the name of the active workstream
WorkstreamName string `json:"workstream_name"`
// LastSnapshot is when the last automatic snapshot was created
LastSnapshot time.Time `json:"last_snapshot"`
// IsDirty indicates if there are uncommitted changes
IsDirty bool `json:"is_dirty"`
// TreeHash is the hash of the current working tree
TreeHash string `json:"tree_hash,omitempty"`
// IndexHash is the hash of the staging area
IndexHash string `json:"index_hash,omitempty"`
// Metadata contains additional workspace-specific data
Metadata map[string]string `json:"metadata,omitempty"`
}
// NewWorkspaceState creates a new workspace state
func NewWorkspaceState(commitSHA, workstreamName string) *WorkspaceState {
return &WorkspaceState{
CurrentCommitSHA: commitSHA,
WorkstreamName: workstreamName,
LastSnapshot: time.Now(),
IsDirty: false,
Metadata: make(map[string]string),
}
}
// UpdateSnapshot updates the workspace state with a new snapshot
func (ws *WorkspaceState) UpdateSnapshot(commitSHA, treeHash, indexHash string, isDirty bool) {
ws.CurrentCommitSHA = commitSHA
ws.TreeHash = treeHash
ws.IndexHash = indexHash
ws.IsDirty = isDirty
ws.LastSnapshot = time.Now()
}
// SetWorkstream changes the active workstream
func (ws *WorkspaceState) SetWorkstream(workstreamName string) {
ws.WorkstreamName = workstreamName
}
// MarkDirty marks the workspace as having uncommitted changes
func (ws *WorkspaceState) MarkDirty() {
ws.IsDirty = true
}
// MarkClean marks the workspace as clean (no uncommitted changes)
func (ws *WorkspaceState) MarkClean() {
ws.IsDirty = false
}
// Serialize converts the workspace state to JSON
func (ws *WorkspaceState) Serialize() ([]byte, error) {
data, err := json.MarshalIndent(ws, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal workspace state: %w", err)
}
return data, nil
}
// DeserializeWorkspaceState converts JSON data to a workspace state
func DeserializeWorkspaceState(data []byte) (*WorkspaceState, error) {
ws := &WorkspaceState{}
if err := json.Unmarshal(data, ws); err != nil {
return nil, fmt.Errorf("failed to unmarshal workspace state: %w", err)
}
return ws, nil
}
// GetTimeSinceLastSnapshot returns the duration since the last snapshot
func (ws *WorkspaceState) GetTimeSinceLastSnapshot() time.Duration {
return time.Since(ws.LastSnapshot)
}
// Clone creates a deep copy of the workspace state
func (ws *WorkspaceState) Clone() *WorkspaceState {
metadata := make(map[string]string, len(ws.Metadata))
for k, v := range ws.Metadata {
metadata[k] = v
}
return &WorkspaceState{
CurrentCommitSHA: ws.CurrentCommitSHA,
WorkstreamName: ws.WorkstreamName,
LastSnapshot: ws.LastSnapshot,
IsDirty: ws.IsDirty,
TreeHash: ws.TreeHash,
IndexHash: ws.IndexHash,
Metadata: metadata,
}
}

View File

@ -0,0 +1,214 @@
package models
import (
"encoding/json"
"fmt"
"time"
)
// Workstream represents a stacked-diff workflow
type Workstream struct {
// Name is the unique identifier for the workstream
Name string `json:"name"`
// Description provides context about the workstream
Description string `json:"description"`
// BaseBranch is the Git branch this workstream is based on
BaseBranch string `json:"base_branch"`
// Commits is an ordered list of commits in this workstream
Commits []WorkstreamCommit `json:"commits"`
// Created is when the workstream was created
Created time.Time `json:"created"`
// Updated is when the workstream was last modified
Updated time.Time `json:"updated"`
// Status indicates the current state (active, merged, abandoned)
Status WorkstreamStatus `json:"status"`
// Metadata contains additional workstream-specific data
Metadata map[string]string `json:"metadata,omitempty"`
}
// WorkstreamCommit represents a single commit in a workstream
type WorkstreamCommit struct {
// SHA is the Git commit hash
SHA string `json:"sha"`
// Message is the commit message
Message string `json:"message"`
// Author is the commit author
Author string `json:"author"`
// Timestamp is when the commit was created
Timestamp time.Time `json:"timestamp"`
// ParentSHA is the parent commit in the workstream (empty for first commit)
ParentSHA string `json:"parent_sha,omitempty"`
// BaseSHA is the base commit from the base branch
BaseSHA string `json:"base_sha"`
// BranchRef is the Git reference for this commit (e.g., refs/onyx/workstreams/name/commit-1)
BranchRef string `json:"branch_ref"`
}
// WorkstreamStatus represents the state of a workstream
type WorkstreamStatus string
const (
// WorkstreamStatusActive indicates the workstream is being actively developed
WorkstreamStatusActive WorkstreamStatus = "active"
// WorkstreamStatusMerged indicates the workstream has been merged
WorkstreamStatusMerged WorkstreamStatus = "merged"
// WorkstreamStatusAbandoned indicates the workstream has been abandoned
WorkstreamStatusAbandoned WorkstreamStatus = "abandoned"
// WorkstreamStatusArchived indicates the workstream has been archived
WorkstreamStatusArchived WorkstreamStatus = "archived"
)
// WorkstreamCollection represents the collection of all workstreams
type WorkstreamCollection struct {
// Workstreams is a map of workstream name to Workstream
Workstreams map[string]*Workstream `json:"workstreams"`
// CurrentWorkstream is the name of the active workstream
CurrentWorkstream string `json:"current_workstream,omitempty"`
}
// NewWorkstream creates a new workstream
func NewWorkstream(name, description, baseBranch string) *Workstream {
now := time.Now()
return &Workstream{
Name: name,
Description: description,
BaseBranch: baseBranch,
Commits: []WorkstreamCommit{},
Created: now,
Updated: now,
Status: WorkstreamStatusActive,
Metadata: make(map[string]string),
}
}
// AddCommit adds a commit to the workstream
func (w *Workstream) AddCommit(commit WorkstreamCommit) {
w.Commits = append(w.Commits, commit)
w.Updated = time.Now()
}
// GetLatestCommit returns the latest commit in the workstream
func (w *Workstream) GetLatestCommit() (*WorkstreamCommit, error) {
if len(w.Commits) == 0 {
return nil, fmt.Errorf("workstream has no commits")
}
return &w.Commits[len(w.Commits)-1], nil
}
// GetCommitCount returns the number of commits in the workstream
func (w *Workstream) GetCommitCount() int {
return len(w.Commits)
}
// IsEmpty returns true if the workstream has no commits
func (w *Workstream) IsEmpty() bool {
return len(w.Commits) == 0
}
// NewWorkstreamCommit creates a new workstream commit
func NewWorkstreamCommit(sha, message, author, parentSHA, baseSHA, branchRef string) WorkstreamCommit {
return WorkstreamCommit{
SHA: sha,
Message: message,
Author: author,
Timestamp: time.Now(),
ParentSHA: parentSHA,
BaseSHA: baseSHA,
BranchRef: branchRef,
}
}
// NewWorkstreamCollection creates a new workstream collection
func NewWorkstreamCollection() *WorkstreamCollection {
return &WorkstreamCollection{
Workstreams: make(map[string]*Workstream),
}
}
// AddWorkstream adds a workstream to the collection
func (wc *WorkstreamCollection) AddWorkstream(workstream *Workstream) error {
if _, exists := wc.Workstreams[workstream.Name]; exists {
return fmt.Errorf("workstream '%s' already exists", workstream.Name)
}
wc.Workstreams[workstream.Name] = workstream
return nil
}
// GetWorkstream retrieves a workstream by name
func (wc *WorkstreamCollection) GetWorkstream(name string) (*Workstream, error) {
workstream, exists := wc.Workstreams[name]
if !exists {
return nil, fmt.Errorf("workstream '%s' not found", name)
}
return workstream, nil
}
// RemoveWorkstream removes a workstream from the collection
func (wc *WorkstreamCollection) RemoveWorkstream(name string) error {
if _, exists := wc.Workstreams[name]; !exists {
return fmt.Errorf("workstream '%s' not found", name)
}
delete(wc.Workstreams, name)
return nil
}
// ListWorkstreams returns all workstreams
func (wc *WorkstreamCollection) ListWorkstreams() []*Workstream {
workstreams := make([]*Workstream, 0, len(wc.Workstreams))
for _, ws := range wc.Workstreams {
workstreams = append(workstreams, ws)
}
return workstreams
}
// SetCurrentWorkstream sets the active workstream
func (wc *WorkstreamCollection) SetCurrentWorkstream(name string) error {
if _, exists := wc.Workstreams[name]; !exists {
return fmt.Errorf("workstream '%s' not found", name)
}
wc.CurrentWorkstream = name
return nil
}
// GetCurrentWorkstream returns the current workstream
func (wc *WorkstreamCollection) GetCurrentWorkstream() (*Workstream, error) {
if wc.CurrentWorkstream == "" {
return nil, fmt.Errorf("no current workstream set")
}
return wc.GetWorkstream(wc.CurrentWorkstream)
}
// Serialize converts the workstream collection to JSON
func (wc *WorkstreamCollection) Serialize() ([]byte, error) {
data, err := json.MarshalIndent(wc, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal workstream collection: %w", err)
}
return data, nil
}
// DeserializeWorkstreamCollection converts JSON data to a workstream collection
func DeserializeWorkstreamCollection(data []byte) (*WorkstreamCollection, error) {
wc := &WorkstreamCollection{}
if err := json.Unmarshal(data, wc); err != nil {
return nil, fmt.Errorf("failed to unmarshal workstream collection: %w", err)
}
return wc, nil
}

View File

@ -0,0 +1,201 @@
package storage
import (
"encoding/binary"
"fmt"
"io"
"os"
"git.dws.rip/DWS/onyx/internal/models"
)
// OplogReader handles reading entries from the oplog file
type OplogReader struct {
path string
}
// NewOplogReader creates a new oplog reader for the given file path
func NewOplogReader(path string) *OplogReader {
return &OplogReader{
path: path,
}
}
// ReadLastEntry reads the last (most recent) entry in the oplog
func (r *OplogReader) ReadLastEntry() (*models.OplogEntry, error) {
file, err := os.Open(r.path)
if err != nil {
return nil, fmt.Errorf("failed to open oplog file: %w", err)
}
defer file.Close()
var lastEntry *models.OplogEntry
// Read through all entries to find the last one
for {
// Read entry length (4 bytes)
var entryLen uint32
err := binary.Read(file, binary.LittleEndian, &entryLen)
if err != nil {
if err == io.EOF {
break
}
return nil, fmt.Errorf("failed to read entry length: %w", err)
}
// Read the entry data
entryData := make([]byte, entryLen)
n, err := file.Read(entryData)
if err != nil {
return nil, fmt.Errorf("failed to read entry data: %w", err)
}
if n != int(entryLen) {
return nil, fmt.Errorf("incomplete entry data read: expected %d bytes, got %d", entryLen, n)
}
// Deserialize the entry
entry, err := models.DeserializeOplogEntry(entryData)
if err != nil {
return nil, fmt.Errorf("failed to deserialize entry: %w", err)
}
lastEntry = entry
}
if lastEntry == nil {
return nil, fmt.Errorf("oplog is empty")
}
return lastEntry, nil
}
// ReadEntry reads a specific entry by ID
func (r *OplogReader) ReadEntry(id uint64) (*models.OplogEntry, error) {
file, err := os.Open(r.path)
if err != nil {
return nil, fmt.Errorf("failed to open oplog file: %w", err)
}
defer file.Close()
// Read through all entries to find the one with matching ID
for {
// Read entry length (4 bytes)
var entryLen uint32
err := binary.Read(file, binary.LittleEndian, &entryLen)
if err != nil {
if err == io.EOF {
break
}
return nil, fmt.Errorf("failed to read entry length: %w", err)
}
// Read the entry data
entryData := make([]byte, entryLen)
n, err := file.Read(entryData)
if err != nil {
return nil, fmt.Errorf("failed to read entry data: %w", err)
}
if n != int(entryLen) {
return nil, fmt.Errorf("incomplete entry data read: expected %d bytes, got %d", entryLen, n)
}
// Deserialize the entry
entry, err := models.DeserializeOplogEntry(entryData)
if err != nil {
return nil, fmt.Errorf("failed to deserialize entry: %w", err)
}
if entry.ID == id {
return entry, nil
}
}
return nil, fmt.Errorf("entry with ID %d not found", id)
}
// GetUndoStack returns a stack of entries that can be undone (in reverse order)
func (r *OplogReader) GetUndoStack() ([]*models.OplogEntry, error) {
entries, err := r.ReadAllEntries()
if err != nil {
return nil, err
}
// Filter out entries that have already been undone
// For now, we return all entries in reverse order
// In the future, we might track undone entries separately
var undoStack []*models.OplogEntry
for i := len(entries) - 1; i >= 0; i-- {
undoStack = append(undoStack, entries[i])
}
return undoStack, nil
}
// ReadAllEntries reads all entries from the oplog in order
func (r *OplogReader) ReadAllEntries() ([]*models.OplogEntry, error) {
file, err := os.Open(r.path)
if err != nil {
return nil, fmt.Errorf("failed to open oplog file: %w", err)
}
defer file.Close()
var entries []*models.OplogEntry
// Read through all entries
for {
// Read entry length (4 bytes)
var entryLen uint32
err := binary.Read(file, binary.LittleEndian, &entryLen)
if err != nil {
if err == io.EOF {
break
}
return nil, fmt.Errorf("failed to read entry length: %w", err)
}
// Read the entry data
entryData := make([]byte, entryLen)
n, err := file.Read(entryData)
if err != nil {
return nil, fmt.Errorf("failed to read entry data: %w", err)
}
if n != int(entryLen) {
return nil, fmt.Errorf("incomplete entry data read: expected %d bytes, got %d", entryLen, n)
}
// Deserialize the entry
entry, err := models.DeserializeOplogEntry(entryData)
if err != nil {
return nil, fmt.Errorf("failed to deserialize entry: %w", err)
}
entries = append(entries, entry)
}
return entries, nil
}
// Count returns the total number of entries in the oplog
func (r *OplogReader) Count() (int, error) {
entries, err := r.ReadAllEntries()
if err != nil {
return 0, err
}
return len(entries), nil
}
// IsEmpty checks if the oplog is empty
func (r *OplogReader) IsEmpty() (bool, error) {
file, err := os.Open(r.path)
if err != nil {
return false, fmt.Errorf("failed to open oplog file: %w", err)
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return false, fmt.Errorf("failed to stat file: %w", err)
}
return stat.Size() == 0, nil
}

View File

@ -0,0 +1,163 @@
package storage
import (
"encoding/binary"
"fmt"
"os"
"sync"
"git.dws.rip/DWS/onyx/internal/models"
)
// OplogWriter handles writing entries to the oplog file
type OplogWriter struct {
path string
file *os.File
mu sync.Mutex
nextID uint64
isClosed bool
}
// OpenOplog opens an existing oplog file or creates a new one
func OpenOplog(path string) (*OplogWriter, error) {
// Open file for append and read
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
return nil, fmt.Errorf("failed to open oplog file: %w", err)
}
writer := &OplogWriter{
path: path,
file: file,
nextID: 1,
}
// Calculate next ID by reading existing entries
if err := writer.calculateNextID(); err != nil {
file.Close()
return nil, fmt.Errorf("failed to calculate next ID: %w", err)
}
return writer, nil
}
// calculateNextID scans the oplog to determine the next entry ID
func (w *OplogWriter) calculateNextID() error {
// Seek to the beginning
if _, err := w.file.Seek(0, 0); err != nil {
return fmt.Errorf("failed to seek to beginning: %w", err)
}
var maxID uint64 = 0
// Read through all entries to find the max ID
for {
// Read entry length (4 bytes)
var entryLen uint32
err := binary.Read(w.file, binary.LittleEndian, &entryLen)
if err != nil {
// EOF is expected at the end
if err.Error() == "EOF" {
break
}
return fmt.Errorf("failed to read entry length: %w", err)
}
// Read the entry data
entryData := make([]byte, entryLen)
n, err := w.file.Read(entryData)
if err != nil || n != int(entryLen) {
return fmt.Errorf("failed to read entry data: %w", err)
}
// Deserialize to get the ID
entry, err := models.DeserializeOplogEntry(entryData)
if err != nil {
return fmt.Errorf("failed to deserialize entry: %w", err)
}
if entry.ID > maxID {
maxID = entry.ID
}
}
w.nextID = maxID + 1
// Seek to the end for appending
if _, err := w.file.Seek(0, 2); err != nil {
return fmt.Errorf("failed to seek to end: %w", err)
}
return nil
}
// AppendEntry appends a new entry to the oplog
func (w *OplogWriter) AppendEntry(entry *models.OplogEntry) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.isClosed {
return fmt.Errorf("oplog writer is closed")
}
// Assign ID if not set
if entry.ID == 0 {
entry.ID = w.nextID
w.nextID++
}
// Serialize the entry
data, err := entry.Serialize()
if err != nil {
return fmt.Errorf("failed to serialize entry: %w", err)
}
// Write entry length (4 bytes) followed by entry data
entryLen := uint32(len(data))
if err := binary.Write(w.file, binary.LittleEndian, entryLen); err != nil {
return fmt.Errorf("failed to write entry length: %w", err)
}
if _, err := w.file.Write(data); err != nil {
return fmt.Errorf("failed to write entry data: %w", err)
}
// Sync to disk for durability
if err := w.file.Sync(); err != nil {
return fmt.Errorf("failed to sync file: %w", err)
}
return nil
}
// GetNextID returns the next entry ID that will be assigned
func (w *OplogWriter) GetNextID() uint64 {
w.mu.Lock()
defer w.mu.Unlock()
return w.nextID
}
// Close closes the oplog file
func (w *OplogWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.isClosed {
return nil
}
w.isClosed = true
return w.file.Close()
}
// Flush ensures all buffered data is written to disk
func (w *OplogWriter) Flush() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.isClosed {
return fmt.Errorf("oplog writer is closed")
}
return w.file.Sync()
}

187
internal/storage/state.go Normal file
View File

@ -0,0 +1,187 @@
package storage
import (
"fmt"
"git.dws.rip/DWS/onyx/internal/models"
gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
)
// StateCapture provides functionality to capture repository state
type StateCapture struct {
repo *gogit.Repository
}
// NewStateCapture creates a new StateCapture instance
func NewStateCapture(repo *gogit.Repository) *StateCapture {
return &StateCapture{
repo: repo,
}
}
// CaptureState captures the current state of the repository
func (s *StateCapture) CaptureState() (*models.RepositoryState, error) {
refs, err := s.captureRefs()
if err != nil {
return nil, fmt.Errorf("failed to capture refs: %w", err)
}
currentWorkstream, err := s.getCurrentWorkstream()
if err != nil {
// It's okay if there's no current workstream (e.g., in detached HEAD state)
currentWorkstream = ""
}
workingTreeHash, err := s.getWorkingTreeHash()
if err != nil {
// Working tree hash might not be available in a fresh repo
workingTreeHash = ""
}
indexHash, err := s.getIndexHash()
if err != nil {
// Index hash might not be available in a fresh repo
indexHash = ""
}
return models.NewRepositoryState(refs, currentWorkstream, workingTreeHash, indexHash), nil
}
// captureRefs captures all Git references (branches, tags, etc.)
func (s *StateCapture) captureRefs() (map[string]string, error) {
refs := make(map[string]string)
refIter, err := s.repo.References()
if err != nil {
return nil, fmt.Errorf("failed to get references: %w", err)
}
err = refIter.ForEach(func(ref *plumbing.Reference) error {
if ref.Type() == plumbing.HashReference {
refs[ref.Name().String()] = ref.Hash().String()
} else if ref.Type() == plumbing.SymbolicReference {
// For symbolic refs (like HEAD), store the target
refs[ref.Name().String()] = ref.Target().String()
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to iterate references: %w", err)
}
return refs, nil
}
// getCurrentWorkstream determines the current workstream (branch)
func (s *StateCapture) getCurrentWorkstream() (string, error) {
head, err := s.repo.Head()
if err != nil {
return "", fmt.Errorf("failed to get HEAD: %w", err)
}
if head.Name().IsBranch() {
return head.Name().Short(), nil
}
// In detached HEAD state
return "", fmt.Errorf("in detached HEAD state")
}
// getWorkingTreeHash gets a hash representing the current working tree
func (s *StateCapture) getWorkingTreeHash() (string, error) {
worktree, err := s.repo.Worktree()
if err != nil {
return "", fmt.Errorf("failed to get worktree: %w", err)
}
status, err := worktree.Status()
if err != nil {
return "", fmt.Errorf("failed to get status: %w", err)
}
// For now, we'll just check if the working tree is clean
// In the future, we might compute an actual hash
if status.IsClean() {
head, err := s.repo.Head()
if err == nil {
return head.Hash().String(), nil
}
}
return "dirty", nil
}
// getIndexHash gets a hash representing the current index (staging area)
func (s *StateCapture) getIndexHash() (string, error) {
// For now, this is a placeholder
// In the future, we might compute a proper hash of the index
return "", nil
}
// RestoreState restores the repository to a previously captured state
func (s *StateCapture) RestoreState(state *models.RepositoryState) error {
// Restore all refs
for refName, refHash := range state.Refs {
ref := plumbing.NewReferenceFromStrings(refName, refHash)
// Skip symbolic references for now
if ref.Type() == plumbing.SymbolicReference {
continue
}
err := s.repo.Storer.SetReference(ref)
if err != nil {
return fmt.Errorf("failed to restore ref %s: %w", refName, err)
}
}
// If there's a current workstream, check it out
if state.CurrentWorkstream != "" {
worktree, err := s.repo.Worktree()
if err != nil {
return fmt.Errorf("failed to get worktree: %w", err)
}
err = worktree.Checkout(&gogit.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName(state.CurrentWorkstream),
})
if err != nil {
// Don't fail if checkout fails, just log it
// The refs have been restored which is the most important part
fmt.Printf("Warning: failed to checkout branch %s: %v\n", state.CurrentWorkstream, err)
}
}
return nil
}
// CompareStates compares two repository states and returns the differences
func (s *StateCapture) CompareStates(before, after *models.RepositoryState) map[string]string {
differences := make(map[string]string)
// Check for changed/added refs
for refName, afterHash := range after.Refs {
beforeHash, exists := before.Refs[refName]
if !exists {
differences[refName] = fmt.Sprintf("added: %s", afterHash)
} else if beforeHash != afterHash {
differences[refName] = fmt.Sprintf("changed: %s -> %s", beforeHash, afterHash)
}
}
// Check for deleted refs
for refName := range before.Refs {
if _, exists := after.Refs[refName]; !exists {
differences[refName] = "deleted"
}
}
// Check workstream change
if before.CurrentWorkstream != after.CurrentWorkstream {
differences["current_workstream"] = fmt.Sprintf("changed: %s -> %s", before.CurrentWorkstream, after.CurrentWorkstream)
}
return differences
}

View File

@ -0,0 +1,47 @@
package storage
import (
"fmt"
"os"
"git.dws.rip/DWS/onyx/internal/models"
)
// LoadWorkstreams loads the workstream collection from the workstreams.json file
func LoadWorkstreams(path string) (*models.WorkstreamCollection, error) {
// Check if file exists
if _, err := os.Stat(path); os.IsNotExist(err) {
// Return empty collection if file doesn't exist
return models.NewWorkstreamCollection(), nil
}
// Read the file
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read workstreams file: %w", err)
}
// Deserialize the workstream collection
collection, err := models.DeserializeWorkstreamCollection(data)
if err != nil {
return nil, fmt.Errorf("failed to deserialize workstreams: %w", err)
}
return collection, nil
}
// SaveWorkstreams saves the workstream collection to the workstreams.json file
func SaveWorkstreams(path string, collection *models.WorkstreamCollection) error {
// Serialize the collection
data, err := collection.Serialize()
if err != nil {
return fmt.Errorf("failed to serialize workstreams: %w", err)
}
// Write to file
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("failed to write workstreams file: %w", err)
}
return nil
}