milestone 2 complete
This commit is contained in:
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
|
||||
}
|
230
internal/commands/save.go
Normal file
230
internal/commands/save.go
Normal file
@ -0,0 +1,230 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.dws.rip/DWS/onyx/internal/core"
|
||||
"git.dws.rip/DWS/onyx/internal/git"
|
||||
"git.dws.rip/DWS/onyx/internal/models"
|
||||
"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())
|
||||
|
||||
// 1. Read current ephemeral commit from workspace ref
|
||||
ephemeralCommitSHA, err := getEphemeralCommit(gitBackend)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get ephemeral commit: %w", err)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 3. Load workstream collection
|
||||
workstreams, err := loadWorkstreams(repo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load workstreams: %w", err)
|
||||
}
|
||||
|
||||
// 4. Get the current workstream
|
||||
currentWorkstream, err := workstreams.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 branch HEAD
|
||||
baseBranch := currentWorkstream.BaseBranch
|
||||
if baseBranch == "" {
|
||||
baseBranch = "main"
|
||||
}
|
||||
// Try to get the base branch reference
|
||||
branchRef := fmt.Sprintf("refs/heads/%s", baseBranch)
|
||||
sha, err := gitBackend.GetRef(branchRef)
|
||||
if err == nil {
|
||||
parentSHA = sha
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Create new commit with the user's message
|
||||
treeHash := ephemeralCommit.TreeHash.String()
|
||||
commitSHA, err := gitBackend.CreateCommit(treeHash, parentSHA, message, "User")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create commit: %w", err)
|
||||
}
|
||||
|
||||
// 7. Determine next branch number
|
||||
nextNumber := currentWorkstream.GetCommitCount() + 1
|
||||
|
||||
// 8. Create branch ref (e.g., refs/onyx/workstreams/feature-name/commit-1)
|
||||
branchRef := fmt.Sprintf("refs/onyx/workstreams/%s/commit-%d", currentWorkstream.Name, nextNumber)
|
||||
if err := gitBackend.UpdateRef(branchRef, commitSHA); err != nil {
|
||||
return fmt.Errorf("failed to create branch ref: %w", err)
|
||||
}
|
||||
|
||||
// 9. Add commit to workstream
|
||||
workstreamCommit := models.NewWorkstreamCommit(
|
||||
commitSHA,
|
||||
message,
|
||||
"User",
|
||||
parentSHA,
|
||||
currentWorkstream.BaseBranch,
|
||||
branchRef,
|
||||
)
|
||||
currentWorkstream.AddCommit(workstreamCommit)
|
||||
|
||||
// 10. Save updated workstreams
|
||||
if err := saveWorkstreams(repo, workstreams); err != nil {
|
||||
return fmt.Errorf("failed to save workstreams: %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
|
||||
}
|
||||
|
||||
// loadWorkstreams loads the workstream collection from .onx/workstreams.json
|
||||
func loadWorkstreams(repo *core.OnyxRepository) (*models.WorkstreamCollection, error) {
|
||||
workstreamsPath := filepath.Join(repo.GetOnyxPath(), "workstreams.json")
|
||||
|
||||
data, err := os.ReadFile(workstreamsPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read workstreams file: %w", err)
|
||||
}
|
||||
|
||||
workstreams, err := models.DeserializeWorkstreamCollection(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize workstreams: %w", err)
|
||||
}
|
||||
|
||||
return workstreams, nil
|
||||
}
|
||||
|
||||
// saveWorkstreams saves the workstream collection to .onx/workstreams.json
|
||||
func saveWorkstreams(repo *core.OnyxRepository, workstreams *models.WorkstreamCollection) error {
|
||||
workstreamsPath := filepath.Join(repo.GetOnyxPath(), "workstreams.json")
|
||||
|
||||
data, err := workstreams.Serialize()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize workstreams: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(workstreamsPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write workstreams file: %w", err)
|
||||
}
|
||||
|
||||
return 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
|
||||
}
|
Reference in New Issue
Block a user