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"])
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user