temp for tree extraction

This commit is contained in:
2025-10-15 19:13:35 -04:00
commit 89fb7a30f7
50 changed files with 8769 additions and 0 deletions

190
internal/daemon/daemon.go Normal file
View 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
View 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
View 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)
}