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
|
||||
}
|
@ -108,7 +108,7 @@ func (r *OnyxRepository) Init(path string) error {
|
||||
// Initialize workstreams.json
|
||||
workstreamsPath := filepath.Join(onyxPath, "workstreams.json")
|
||||
if _, err := os.Stat(workstreamsPath); os.IsNotExist(err) {
|
||||
initialContent := []byte("{\"workstreams\":[]}\n")
|
||||
initialContent := []byte("{\"workstreams\":{}}\n")
|
||||
if err := os.WriteFile(workstreamsPath, initialContent, 0644); err != nil {
|
||||
return fmt.Errorf("failed to create workstreams.json: %w", err)
|
||||
}
|
||||
|
@ -155,6 +155,25 @@ func (t *Transaction) Rollback(entryID uint64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Commit captures the final state and writes to oplog
|
||||
func (t *Transaction) Commit(operation, description string) error {
|
||||
// Capture state_after
|
||||
stateAfter, err := t.stateCapture.CaptureState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to capture state: %w", err)
|
||||
}
|
||||
|
||||
// Create oplog entry
|
||||
entry := models.NewOplogEntry(0, operation, description, nil, stateAfter)
|
||||
|
||||
// Write to oplog
|
||||
if err := t.oplogWriter.AppendEntry(entry); err != nil {
|
||||
return fmt.Errorf("failed to write to oplog: %w", err)
|
||||
}
|
||||
|
||||
return t.Close()
|
||||
}
|
||||
|
||||
// Helper function to execute a transaction on a repository
|
||||
func ExecuteWithTransaction(repo *OnyxRepository, operation, description string, fn func() error) error {
|
||||
txn, err := NewTransaction(repo)
|
||||
|
190
internal/daemon/daemon.go
Normal file
190
internal/daemon/daemon.go
Normal file
@ -0,0 +1,190 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.dws.rip/DWS/onyx/internal/core"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
// Daemon manages the filesystem watching and automatic snapshot creation
|
||||
type Daemon struct {
|
||||
repo *core.OnyxRepository
|
||||
watcher *fsnotify.Watcher
|
||||
ticker *time.Ticker
|
||||
debounce time.Duration
|
||||
shutdown chan bool
|
||||
mu sync.Mutex
|
||||
isRunning bool
|
||||
|
||||
// Debouncing state
|
||||
pendingChanges bool
|
||||
lastChangeTime time.Time
|
||||
}
|
||||
|
||||
// Config holds daemon configuration options
|
||||
type Config struct {
|
||||
// Debounce duration for filesystem events (default: 500ms)
|
||||
Debounce time.Duration
|
||||
|
||||
// Ticker interval for periodic checks (default: 1 second)
|
||||
TickerInterval time.Duration
|
||||
|
||||
// Repository root path
|
||||
RepoPath string
|
||||
}
|
||||
|
||||
// DefaultConfig returns the default daemon configuration
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Debounce: 500 * time.Millisecond,
|
||||
TickerInterval: 1 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// New creates a new Daemon instance
|
||||
func New(repo *core.OnyxRepository, config *Config) (*Daemon, error) {
|
||||
if config == nil {
|
||||
config = DefaultConfig()
|
||||
}
|
||||
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create filesystem watcher: %w", err)
|
||||
}
|
||||
|
||||
return &Daemon{
|
||||
repo: repo,
|
||||
watcher: watcher,
|
||||
ticker: time.NewTicker(config.TickerInterval),
|
||||
debounce: config.Debounce,
|
||||
shutdown: make(chan bool),
|
||||
isRunning: false,
|
||||
pendingChanges: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start begins the daemon's main loop
|
||||
func (d *Daemon) Start() error {
|
||||
d.mu.Lock()
|
||||
if d.isRunning {
|
||||
d.mu.Unlock()
|
||||
return fmt.Errorf("daemon is already running")
|
||||
}
|
||||
d.isRunning = true
|
||||
d.mu.Unlock()
|
||||
|
||||
// Set up filesystem watchers
|
||||
if err := d.setupWatchers(); err != nil {
|
||||
d.isRunning = false
|
||||
return fmt.Errorf("failed to setup watchers: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Onyx daemon started")
|
||||
|
||||
// Run the main event loop
|
||||
go d.run()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the daemon
|
||||
func (d *Daemon) Stop() error {
|
||||
d.mu.Lock()
|
||||
if !d.isRunning {
|
||||
d.mu.Unlock()
|
||||
return fmt.Errorf("daemon is not running")
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
log.Println("Stopping Onyx daemon...")
|
||||
|
||||
// Signal shutdown
|
||||
close(d.shutdown)
|
||||
|
||||
// Clean up resources
|
||||
d.ticker.Stop()
|
||||
if err := d.watcher.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close watcher: %w", err)
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
d.isRunning = false
|
||||
d.mu.Unlock()
|
||||
|
||||
log.Println("Onyx daemon stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsRunning returns whether the daemon is currently running
|
||||
func (d *Daemon) IsRunning() bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.isRunning
|
||||
}
|
||||
|
||||
// run is the main event loop for the daemon
|
||||
func (d *Daemon) run() {
|
||||
for {
|
||||
select {
|
||||
case <-d.shutdown:
|
||||
return
|
||||
|
||||
case event, ok := <-d.watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d.handleFileEvent(event)
|
||||
|
||||
case err, ok := <-d.watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Printf("Watcher error: %v", err)
|
||||
|
||||
case <-d.ticker.C:
|
||||
d.processDebounced()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleFileEvent processes a filesystem event
|
||||
func (d *Daemon) handleFileEvent(event fsnotify.Event) {
|
||||
// Ignore events for .git and .onx directories
|
||||
if shouldIgnorePath(event.Name) {
|
||||
return
|
||||
}
|
||||
|
||||
// Mark that we have pending changes
|
||||
d.mu.Lock()
|
||||
d.pendingChanges = true
|
||||
d.lastChangeTime = time.Now()
|
||||
d.mu.Unlock()
|
||||
|
||||
log.Printf("File change detected: %s [%s]", event.Name, event.Op)
|
||||
}
|
||||
|
||||
// processDebounced checks if enough time has passed since the last change
|
||||
// and creates a snapshot if needed
|
||||
func (d *Daemon) processDebounced() {
|
||||
d.mu.Lock()
|
||||
hasPending := d.pendingChanges
|
||||
timeSinceChange := time.Since(d.lastChangeTime)
|
||||
d.mu.Unlock()
|
||||
|
||||
if hasPending && timeSinceChange >= d.debounce {
|
||||
d.mu.Lock()
|
||||
d.pendingChanges = false
|
||||
d.mu.Unlock()
|
||||
|
||||
log.Println("Creating automatic snapshot...")
|
||||
if err := d.CreateSnapshot(); err != nil {
|
||||
log.Printf("Failed to create snapshot: %v", err)
|
||||
} else {
|
||||
log.Println("Snapshot created successfully")
|
||||
}
|
||||
}
|
||||
}
|
211
internal/daemon/snapshot.go
Normal file
211
internal/daemon/snapshot.go
Normal file
@ -0,0 +1,211 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.dws.rip/DWS/onyx/internal/git"
|
||||
"git.dws.rip/DWS/onyx/internal/models"
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing/filemode"
|
||||
)
|
||||
|
||||
const (
|
||||
// OnyxWorkspaceRef is the ref where ephemeral commits are stored
|
||||
OnyxWorkspaceRef = "refs/onyx/workspaces/current"
|
||||
)
|
||||
|
||||
// CreateSnapshot creates an ephemeral commit representing the current workspace state
|
||||
func (d *Daemon) CreateSnapshot() error {
|
||||
// 1. Read current workspace pointer
|
||||
workspaceState, err := d.readWorkspaceState()
|
||||
if err != nil {
|
||||
// If workspace doesn't exist, create a new one
|
||||
workspaceState = models.NewWorkspaceState("", "main")
|
||||
}
|
||||
|
||||
// 2. Create tree from working directory
|
||||
gitBackend := git.NewGitBackend(d.repo.GetGitRepo())
|
||||
repoRoot := filepath.Dir(d.repo.GetOnyxPath())
|
||||
|
||||
treeHash, err := d.createWorkspaceTree(repoRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create workspace tree: %w", err)
|
||||
}
|
||||
|
||||
// 3. Get the parent commit (if it exists)
|
||||
var parentHash string
|
||||
if workspaceState.CurrentCommitSHA != "" {
|
||||
parentHash = workspaceState.CurrentCommitSHA
|
||||
} else {
|
||||
// Try to get the current HEAD commit as parent
|
||||
head, err := d.repo.GetGitRepo().Head()
|
||||
if err == nil {
|
||||
parentHash = head.Hash().String()
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Create ephemeral commit
|
||||
message := fmt.Sprintf("[onyx-snapshot] Auto-save at %s", time.Now().Format("2006-01-02 15:04:05"))
|
||||
commitHash, err := gitBackend.CreateCommit(treeHash, parentHash, message, "Onyx Daemon")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create commit: %w", err)
|
||||
}
|
||||
|
||||
// 5. Update refs/onyx/workspaces/current
|
||||
if err := gitBackend.UpdateRef(OnyxWorkspaceRef, commitHash); err != nil {
|
||||
return fmt.Errorf("failed to update workspace ref: %w", err)
|
||||
}
|
||||
|
||||
// 6. Update .onx/workspace pointer
|
||||
workspaceState.UpdateSnapshot(commitHash, treeHash, "", false)
|
||||
if err := d.saveWorkspaceState(workspaceState); err != nil {
|
||||
return fmt.Errorf("failed to save workspace state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createWorkspaceTree creates a Git tree object from the current working directory
|
||||
func (d *Daemon) createWorkspaceTree(rootPath string) (string, error) {
|
||||
gitBackend := git.NewGitBackend(d.repo.GetGitRepo())
|
||||
|
||||
// Use the worktree to build the tree
|
||||
worktree, err := d.repo.GetGitRepo().Worktree()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get worktree: %w", err)
|
||||
}
|
||||
|
||||
// Create tree entries by walking the working directory
|
||||
entries := []git.TreeEntry{}
|
||||
|
||||
err = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip the root directory itself
|
||||
if path == rootPath {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get relative path
|
||||
relPath, err := filepath.Rel(rootPath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip .git and .onx directories
|
||||
if shouldIgnorePath(path) {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// For now, we'll use a simplified approach: hash the file content
|
||||
if !info.IsDir() {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read file %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Create blob for file content
|
||||
blobHash, err := gitBackend.CreateBlob(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create blob for %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Determine file mode
|
||||
mode := filemode.Regular
|
||||
if info.Mode()&0111 != 0 {
|
||||
mode = filemode.Executable
|
||||
}
|
||||
|
||||
entries = append(entries, git.TreeEntry{
|
||||
Name: relPath,
|
||||
Mode: mode,
|
||||
Hash: git.HashFromString(blobHash),
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to walk directory: %w", err)
|
||||
}
|
||||
|
||||
// For a proper implementation, we'd need to build a hierarchical tree
|
||||
// For now, we'll use the worktree's tree builder
|
||||
return d.buildTreeFromWorktree(worktree)
|
||||
}
|
||||
|
||||
// buildTreeFromWorktree builds a tree object from the current worktree state
|
||||
func (d *Daemon) buildTreeFromWorktree(worktree *gogit.Worktree) (string, error) {
|
||||
// Get the current index/staging area state
|
||||
// This is a simplified version - in production we'd want to properly handle
|
||||
// all files in the working directory
|
||||
|
||||
// For now, get the HEAD tree as a base
|
||||
head, err := d.repo.GetGitRepo().Head()
|
||||
if err != nil {
|
||||
// No HEAD yet (empty repo), return empty tree
|
||||
return d.createEmptyTree()
|
||||
}
|
||||
|
||||
commit, err := d.repo.GetGitRepo().CommitObject(head.Hash())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get HEAD commit: %w", err)
|
||||
}
|
||||
|
||||
tree, err := commit.Tree()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get commit tree: %w", err)
|
||||
}
|
||||
|
||||
// For now, just return the HEAD tree hash
|
||||
// In a full implementation, we'd modify this tree based on working directory changes
|
||||
return tree.Hash.String(), nil
|
||||
}
|
||||
|
||||
// createEmptyTree creates an empty Git tree object
|
||||
func (d *Daemon) createEmptyTree() (string, error) {
|
||||
gitBackend := git.NewGitBackend(d.repo.GetGitRepo())
|
||||
return gitBackend.CreateTree([]git.TreeEntry{})
|
||||
}
|
||||
|
||||
// readWorkspaceState reads the workspace state from .onx/workspace
|
||||
func (d *Daemon) readWorkspaceState() (*models.WorkspaceState, error) {
|
||||
workspacePath := filepath.Join(d.repo.GetOnyxPath(), "workspace")
|
||||
|
||||
data, err := os.ReadFile(workspacePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read workspace file: %w", err)
|
||||
}
|
||||
|
||||
state, err := models.DeserializeWorkspaceState(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize workspace state: %w", err)
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// saveWorkspaceState saves the workspace state to .onx/workspace
|
||||
func (d *Daemon) saveWorkspaceState(state *models.WorkspaceState) error {
|
||||
workspacePath := filepath.Join(d.repo.GetOnyxPath(), "workspace")
|
||||
|
||||
data, err := state.Serialize()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize workspace state: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(workspacePath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write workspace file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
112
internal/daemon/watcher.go
Normal file
112
internal/daemon/watcher.go
Normal file
@ -0,0 +1,112 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// setupWatchers initializes the filesystem watcher for the repository
|
||||
func (d *Daemon) setupWatchers() error {
|
||||
// Get the repository root
|
||||
repoRoot := filepath.Dir(d.repo.GetOnyxPath())
|
||||
|
||||
// Add the root directory to the watcher
|
||||
if err := d.addWatchRecursive(repoRoot); err != nil {
|
||||
return fmt.Errorf("failed to add watches: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Watching repository at: %s", repoRoot)
|
||||
return nil
|
||||
}
|
||||
|
||||
// addWatchRecursive adds watches for a directory and all its subdirectories
|
||||
func (d *Daemon) addWatchRecursive(path string) error {
|
||||
// Walk the directory tree
|
||||
return filepath.Walk(path, func(walkPath string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
// Skip directories we can't access
|
||||
log.Printf("Warning: cannot access %s: %v", walkPath, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip files, only watch directories
|
||||
if !info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip .git and .onx directories
|
||||
if shouldIgnorePath(walkPath) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
// Add watch for this directory
|
||||
if err := d.watcher.Add(walkPath); err != nil {
|
||||
log.Printf("Warning: cannot watch %s: %v", walkPath, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// shouldIgnorePath determines if a path should be ignored by the watcher
|
||||
func shouldIgnorePath(path string) bool {
|
||||
// Get the base name and check against ignored patterns
|
||||
base := filepath.Base(path)
|
||||
|
||||
// Ignore .git and .onx directories
|
||||
if base == ".git" || base == ".onx" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Ignore hidden directories starting with .
|
||||
if strings.HasPrefix(base, ".") && base != "." {
|
||||
return true
|
||||
}
|
||||
|
||||
// Ignore common build/dependency directories
|
||||
ignoredDirs := []string{
|
||||
"node_modules",
|
||||
"vendor",
|
||||
"target",
|
||||
"build",
|
||||
"dist",
|
||||
".vscode",
|
||||
".idea",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
}
|
||||
|
||||
for _, ignored := range ignoredDirs {
|
||||
if base == ignored {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore temporary and backup files
|
||||
if strings.HasSuffix(path, "~") ||
|
||||
strings.HasSuffix(path, ".swp") ||
|
||||
strings.HasSuffix(path, ".tmp") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// AddWatch adds a new directory to the watch list (useful for newly created directories)
|
||||
func (d *Daemon) AddWatch(path string) error {
|
||||
if shouldIgnorePath(path) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return d.watcher.Add(path)
|
||||
}
|
||||
|
||||
// RemoveWatch removes a directory from the watch list
|
||||
func (d *Daemon) RemoveWatch(path string) error {
|
||||
return d.watcher.Remove(path)
|
||||
}
|
@ -203,3 +203,8 @@ func (gb *GitBackend) GetCommit(sha string) (*object.Commit, error) {
|
||||
|
||||
return commit, nil
|
||||
}
|
||||
|
||||
// HashFromString converts a string SHA to a plumbing.Hash
|
||||
func HashFromString(sha string) plumbing.Hash {
|
||||
return plumbing.NewHash(sha)
|
||||
}
|
||||
|
Reference in New Issue
Block a user