temp for tree extraction
This commit is contained in:
141
internal/commands/clone.go
Normal file
141
internal/commands/clone.go
Normal 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
232
internal/commands/daemon.go
Normal 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
231
internal/commands/init.go
Normal 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)
|
||||
}
|
196
internal/commands/init_test.go
Normal file
196
internal/commands/init_test.go
Normal 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
191
internal/commands/list.go
Normal 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
75
internal/commands/new.go
Normal 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
296
internal/commands/push.go
Normal 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
225
internal/commands/save.go
Normal 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
139
internal/commands/switch.go
Normal 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
222
internal/commands/sync.go
Normal 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
204
internal/commands/undo.go
Normal 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
|
||||
}
|
300
internal/commands/undo_test.go
Normal file
300
internal/commands/undo_test.go
Normal 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"])
|
||||
}
|
||||
}
|
74
internal/core/interfaces.go
Normal file
74
internal/core/interfaces.go
Normal 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
178
internal/core/repository.go
Normal 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
|
||||
}
|
186
internal/core/transaction.go
Normal file
186
internal/core/transaction.go
Normal 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)
|
||||
}
|
389
internal/core/workstream_manager.go
Normal file
389
internal/core/workstream_manager.go
Normal 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
190
internal/daemon/daemon.go
Normal 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
211
internal/daemon/snapshot.go
Normal 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
112
internal/daemon/watcher.go
Normal 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
190
internal/git/auth.go
Normal 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
187
internal/git/conflicts.go
Normal 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
210
internal/git/objects.go
Normal 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
207
internal/git/rebase.go
Normal 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
106
internal/git/remote.go
Normal 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
286
internal/git/rerere.go
Normal 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
173
internal/models/oplog.go
Normal 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, ×tamp); 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,
|
||||
}
|
||||
}
|
107
internal/models/workspace.go
Normal file
107
internal/models/workspace.go
Normal 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,
|
||||
}
|
||||
}
|
214
internal/models/workstream.go
Normal file
214
internal/models/workstream.go
Normal 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
|
||||
}
|
201
internal/storage/oplog_reader.go
Normal file
201
internal/storage/oplog_reader.go
Normal 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
|
||||
}
|
163
internal/storage/oplog_writer.go
Normal file
163
internal/storage/oplog_writer.go
Normal 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
187
internal/storage/state.go
Normal 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
|
||||
}
|
47
internal/storage/workstreams.go
Normal file
47
internal/storage/workstreams.go
Normal 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
|
||||
}
|
Reference in New Issue
Block a user