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