From f444113057f06b8f3d1664beaa6aeb9ce76816e9 Mon Sep 17 00:00:00 2001 From: Tanishq Dubey Date: Thu, 9 Oct 2025 18:50:51 -0400 Subject: [PATCH] Implement Phase 0 --- .gitignore | 32 +++++ Makefile | 16 +++ go.mod | 27 +++++ go.sum | 78 +++++++++++++ internal/core/interfaces.go | 74 ++++++++++++ internal/core/repository.go | 178 ++++++++++++++++++++++++++++ internal/git/objects.go | 205 ++++++++++++++++++++++++++++++++ internal/models/oplog.go | 173 +++++++++++++++++++++++++++ internal/models/workspace.go | 107 +++++++++++++++++ internal/models/workstream.go | 214 ++++++++++++++++++++++++++++++++++ 10 files changed, 1104 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 go.sum create mode 100644 internal/core/interfaces.go create mode 100644 internal/core/repository.go create mode 100644 internal/git/objects.go create mode 100644 internal/models/oplog.go create mode 100644 internal/models/workspace.go create mode 100644 internal/models/workstream.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aaadf73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# Editor/IDE +# .idea/ +# .vscode/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1fdbee2 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +# Makefile for Onyx +.PHONY: build test clean install + +build: + go build -o bin/onx ./cmd/onx + go build -o bin/onxd ./cmd/onxd + +test: + go test -v ./... + +install: + go install ./cmd/onx + go install ./cmd/onxd + +clean: + rm -rf bin/ diff --git a/go.mod b/go.mod index 6776152..0cf9b9d 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,30 @@ module git.dws.rip/DWS/onyx go 1.24.2 + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/go-git/go-git/v5 v5.16.3 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/spf13/cobra v1.10.1 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d4b635b --- /dev/null +++ b/go.sum @@ -0,0 +1,78 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git/v5 v5.16.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6Z8= +github.com/go-git/go-git/v5 v5.16.3/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/core/interfaces.go b/internal/core/interfaces.go new file mode 100644 index 0000000..1787940 --- /dev/null +++ b/internal/core/interfaces.go @@ -0,0 +1,74 @@ +package core + +import ( + "time" + + "github.com/go-git/go-git/v5" +) + +// Repository represents an Onyx repository with both Git and Onyx-specific metadata +type Repository interface { + // Init initializes a new Onyx repository at the given path + Init(path string) error + + // GetGitRepo returns the underlying Git repository + GetGitRepo() *git.Repository + + // GetOnyxMetadata returns Onyx-specific metadata + GetOnyxMetadata() *OnyxMetadata + + // Close releases any resources held by the repository + Close() error +} + +// GitBackend provides low-level Git object operations +type GitBackend interface { + // CreateCommit creates a new commit object + CreateCommit(tree, parent, message string) (string, error) + + // CreateTree creates a new tree object from the given entries + CreateTree(entries []TreeEntry) (string, error) + + // UpdateRef updates a Git reference to point to a new SHA + UpdateRef(name, sha string) error + + // GetRef retrieves the SHA that a reference points to + GetRef(name string) (string, error) + + // CreateBlob creates a new blob object from content + CreateBlob(content []byte) (string, error) + + // GetObject retrieves a Git object by its SHA + GetObject(sha string) (Object, error) +} + +// TreeEntry represents an entry in a Git tree object +type TreeEntry struct { + Mode int // File mode (e.g., 0100644 for regular file, 040000 for directory) + Name string // Entry name + SHA string // Object SHA-1 hash +} + +// Object represents a Git object (blob, tree, commit, or tag) +type Object interface { + // Type returns the type of the object (blob, tree, commit, tag) + Type() string + + // SHA returns the SHA-1 hash of the object + SHA() string + + // Size returns the size of the object in bytes + Size() int64 +} + +// OnyxMetadata holds Onyx-specific repository metadata +type OnyxMetadata struct { + // Version of the Onyx repository format + Version string + + // Created timestamp when the repository was initialized + Created time.Time + + // Path to the .onx directory + OnyxPath string +} diff --git a/internal/core/repository.go b/internal/core/repository.go new file mode 100644 index 0000000..53f6cdd --- /dev/null +++ b/internal/core/repository.go @@ -0,0 +1,178 @@ +package core + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/go-git/go-git/v5" +) + +// OnyxRepository implements the Repository interface +type OnyxRepository struct { + gitRepo *git.Repository + onyxPath string + gitPath string + metadata *OnyxMetadata +} + +// Open opens an existing Onyx repository at the given path +func Open(path string) (*OnyxRepository, error) { + // Resolve to absolute path + absPath, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("failed to resolve path: %w", err) + } + + // Check if .git directory exists + gitPath := filepath.Join(absPath, ".git") + if _, err := os.Stat(gitPath); os.IsNotExist(err) { + return nil, fmt.Errorf("not a git repository (no .git directory found)") + } + + // Check if .onx directory exists + onyxPath := filepath.Join(absPath, ".onx") + if _, err := os.Stat(onyxPath); os.IsNotExist(err) { + return nil, fmt.Errorf("not an onyx repository (no .onx directory found)") + } + + // Open the Git repository + gitRepo, err := git.PlainOpen(absPath) + if err != nil { + return nil, fmt.Errorf("failed to open git repository: %w", err) + } + + // Load Onyx metadata + metadata := &OnyxMetadata{ + Version: "1.0.0", + Created: time.Now(), // TODO: Load from .onx/metadata file + OnyxPath: onyxPath, + } + + return &OnyxRepository{ + gitRepo: gitRepo, + onyxPath: onyxPath, + gitPath: gitPath, + metadata: metadata, + }, nil +} + +// Init initializes a new Onyx repository at the given path +func (r *OnyxRepository) Init(path string) error { + // Resolve to absolute path + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("failed to resolve path: %w", err) + } + + // Check if directory exists, create if it doesn't + if _, err := os.Stat(absPath); os.IsNotExist(err) { + if err := os.MkdirAll(absPath, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + } + + // Initialize Git repository if it doesn't exist + gitPath := filepath.Join(absPath, ".git") + if _, err := os.Stat(gitPath); os.IsNotExist(err) { + _, err := git.PlainInit(absPath, false) + if err != nil { + return fmt.Errorf("failed to initialize git repository: %w", err) + } + } + + // Create .onx directory structure + onyxPath := filepath.Join(absPath, ".onx") + if err := os.MkdirAll(onyxPath, 0755); err != nil { + return fmt.Errorf("failed to create .onx directory: %w", err) + } + + // Create subdirectories + subdirs := []string{"rerere_cache"} + for _, subdir := range subdirs { + subdirPath := filepath.Join(onyxPath, subdir) + if err := os.MkdirAll(subdirPath, 0755); err != nil { + return fmt.Errorf("failed to create %s directory: %w", subdir, err) + } + } + + // Initialize oplog file + oplogPath := filepath.Join(onyxPath, "oplog") + if _, err := os.Stat(oplogPath); os.IsNotExist(err) { + if err := os.WriteFile(oplogPath, []byte{}, 0644); err != nil { + return fmt.Errorf("failed to create oplog file: %w", err) + } + } + + // Initialize workstreams.json + workstreamsPath := filepath.Join(onyxPath, "workstreams.json") + if _, err := os.Stat(workstreamsPath); os.IsNotExist(err) { + initialContent := []byte("{\"workstreams\":[]}\n") + if err := os.WriteFile(workstreamsPath, initialContent, 0644); err != nil { + return fmt.Errorf("failed to create workstreams.json: %w", err) + } + } + + // Open the repository + gitRepo, err := git.PlainOpen(absPath) + if err != nil { + return fmt.Errorf("failed to open git repository: %w", err) + } + + // Set up the repository instance + r.gitRepo = gitRepo + r.onyxPath = onyxPath + r.gitPath = gitPath + r.metadata = &OnyxMetadata{ + Version: "1.0.0", + Created: time.Now(), + OnyxPath: onyxPath, + } + + return nil +} + +// GetGitRepo returns the underlying Git repository +func (r *OnyxRepository) GetGitRepo() *git.Repository { + return r.gitRepo +} + +// GetOnyxMetadata returns Onyx-specific metadata +func (r *OnyxRepository) GetOnyxMetadata() *OnyxMetadata { + return r.metadata +} + +// Close releases any resources held by the repository +func (r *OnyxRepository) Close() error { + // Currently, go-git doesn't require explicit closing + // This method is here for future-proofing + return nil +} + +// IsOnyxRepo checks if the given path is an Onyx repository +func IsOnyxRepo(path string) bool { + absPath, err := filepath.Abs(path) + if err != nil { + return false + } + + // Check for both .git and .onx directories + gitPath := filepath.Join(absPath, ".git") + onyxPath := filepath.Join(absPath, ".onx") + + _, gitErr := os.Stat(gitPath) + _, onyxErr := os.Stat(onyxPath) + + return gitErr == nil && onyxErr == nil +} + +// GetOnyxPath returns the path to the .onx directory +func (r *OnyxRepository) GetOnyxPath() string { + return r.onyxPath +} + +// GetGitPath returns the path to the .git directory +func (r *OnyxRepository) GetGitPath() string { + return r.gitPath +} diff --git a/internal/git/objects.go b/internal/git/objects.go new file mode 100644 index 0000000..5d5a9db --- /dev/null +++ b/internal/git/objects.go @@ -0,0 +1,205 @@ +package git + +import ( + "fmt" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// GitBackend implements low-level Git object operations +type GitBackend struct { + repo *git.Repository +} + +// NewGitBackend creates a new GitBackend instance +func NewGitBackend(repo *git.Repository) *GitBackend { + return &GitBackend{repo: repo} +} + +// CreateBlob creates a new blob object from the given content +func (gb *GitBackend) CreateBlob(content []byte) (string, error) { + store := gb.repo.Storer + + // Create a blob object + blob := store.NewEncodedObject() + blob.SetType(plumbing.BlobObject) + blob.SetSize(int64(len(content))) + + writer, err := blob.Writer() + if err != nil { + return "", fmt.Errorf("failed to get blob writer: %w", err) + } + + _, err = writer.Write(content) + if err != nil { + writer.Close() + return "", fmt.Errorf("failed to write blob content: %w", err) + } + + if err := writer.Close(); err != nil { + return "", fmt.Errorf("failed to close blob writer: %w", err) + } + + // Store the blob + hash, err := store.SetEncodedObject(blob) + if err != nil { + return "", fmt.Errorf("failed to store blob: %w", err) + } + + return hash.String(), nil +} + +// TreeEntry represents an entry in a Git tree +type TreeEntry struct { + Mode filemode.FileMode + Name string + Hash plumbing.Hash +} + +// CreateTree creates a new tree object from the given entries +func (gb *GitBackend) CreateTree(entries []TreeEntry) (string, error) { + store := gb.repo.Storer + + // Create a new tree object + tree := &object.Tree{} + treeEntries := make([]object.TreeEntry, len(entries)) + + for i, entry := range entries { + treeEntries[i] = object.TreeEntry{ + Name: entry.Name, + Mode: entry.Mode, + Hash: entry.Hash, + } + } + + tree.Entries = treeEntries + + // Encode and store the tree + obj := store.NewEncodedObject() + if err := tree.Encode(obj); err != nil { + return "", fmt.Errorf("failed to encode tree: %w", err) + } + + hash, err := store.SetEncodedObject(obj) + if err != nil { + return "", fmt.Errorf("failed to store tree: %w", err) + } + + return hash.String(), nil +} + +// CreateCommit creates a new commit object +func (gb *GitBackend) CreateCommit(treeHash, parentHash, message, author string) (string, error) { + store := gb.repo.Storer + + // Parse hashes + tree := plumbing.NewHash(treeHash) + + var parents []plumbing.Hash + if parentHash != "" { + parents = []plumbing.Hash{plumbing.NewHash(parentHash)} + } + + // Create commit object + commit := &object.Commit{ + Author: object.Signature{ + Name: author, + Email: "onyx@local", + When: time.Now(), + }, + Committer: object.Signature{ + Name: author, + Email: "onyx@local", + When: time.Now(), + }, + Message: message, + TreeHash: tree, + } + + if len(parents) > 0 { + commit.ParentHashes = parents + } + + // Encode and store the commit + obj := store.NewEncodedObject() + if err := commit.Encode(obj); err != nil { + return "", fmt.Errorf("failed to encode commit: %w", err) + } + + hash, err := store.SetEncodedObject(obj) + if err != nil { + return "", fmt.Errorf("failed to store commit: %w", err) + } + + return hash.String(), nil +} + +// UpdateRef updates a Git reference to point to a new SHA +func (gb *GitBackend) UpdateRef(refName, sha string) error { + hash := plumbing.NewHash(sha) + ref := plumbing.NewHashReference(plumbing.ReferenceName(refName), hash) + + if err := gb.repo.Storer.SetReference(ref); err != nil { + return fmt.Errorf("failed to update reference %s: %w", refName, err) + } + + return nil +} + +// GetRef retrieves the SHA that a reference points to +func (gb *GitBackend) GetRef(refName string) (string, error) { + ref, err := gb.repo.Reference(plumbing.ReferenceName(refName), true) + if err != nil { + return "", fmt.Errorf("failed to get reference %s: %w", refName, err) + } + + return ref.Hash().String(), nil +} + +// GetObject retrieves a Git object by its SHA +func (gb *GitBackend) GetObject(sha string) (object.Object, error) { + hash := plumbing.NewHash(sha) + obj, err := gb.repo.Object(plumbing.AnyObject, hash) + if err != nil { + return nil, fmt.Errorf("failed to get object %s: %w", sha, err) + } + + return obj, nil +} + +// GetBlob retrieves a blob object by its SHA +func (gb *GitBackend) GetBlob(sha string) (*object.Blob, error) { + hash := plumbing.NewHash(sha) + blob, err := gb.repo.BlobObject(hash) + if err != nil { + return nil, fmt.Errorf("failed to get blob %s: %w", sha, err) + } + + return blob, nil +} + +// GetTree retrieves a tree object by its SHA +func (gb *GitBackend) GetTree(sha string) (*object.Tree, error) { + hash := plumbing.NewHash(sha) + tree, err := gb.repo.TreeObject(hash) + if err != nil { + return nil, fmt.Errorf("failed to get tree %s: %w", sha, err) + } + + return tree, nil +} + +// GetCommit retrieves a commit object by its SHA +func (gb *GitBackend) GetCommit(sha string) (*object.Commit, error) { + hash := plumbing.NewHash(sha) + commit, err := gb.repo.CommitObject(hash) + if err != nil { + return nil, fmt.Errorf("failed to get commit %s: %w", sha, err) + } + + return commit, nil +} diff --git a/internal/models/oplog.go b/internal/models/oplog.go new file mode 100644 index 0000000..2cc4647 --- /dev/null +++ b/internal/models/oplog.go @@ -0,0 +1,173 @@ +package models + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "time" +) + +// OplogEntry represents a single entry in the action log +type OplogEntry struct { + // ID is a monotonically increasing entry ID + ID uint64 + + // Timestamp when the operation was performed + Timestamp time.Time + + // Operation type (e.g., "save", "switch", "new", "sync") + Operation string + + // Description of the operation + Description string + + // StateBefore captures the state before the operation + StateBefore *RepositoryState + + // StateAfter captures the state after the operation + StateAfter *RepositoryState + + // Metadata contains operation-specific data + Metadata map[string]string +} + +// RepositoryState captures the state of the repository at a point in time +type RepositoryState struct { + // Refs maps reference names to their SHA-1 hashes + Refs map[string]string + + // CurrentWorkstream is the active workstream name + CurrentWorkstream string + + // WorkingTreeHash is the hash of the current working tree snapshot + WorkingTreeHash string + + // IndexHash is the hash of the staging area + IndexHash string +} + +// Serialize converts an OplogEntry to binary format +func (e *OplogEntry) Serialize() ([]byte, error) { + buf := new(bytes.Buffer) + + // Write entry ID (8 bytes) + if err := binary.Write(buf, binary.LittleEndian, e.ID); err != nil { + return nil, fmt.Errorf("failed to write ID: %w", err) + } + + // Write timestamp (8 bytes, Unix nano) + timestamp := e.Timestamp.UnixNano() + if err := binary.Write(buf, binary.LittleEndian, timestamp); err != nil { + return nil, fmt.Errorf("failed to write timestamp: %w", err) + } + + // Serialize the rest as JSON for flexibility + payload := struct { + Operation string `json:"operation"` + Description string `json:"description"` + StateBefore *RepositoryState `json:"state_before"` + StateAfter *RepositoryState `json:"state_after"` + Metadata map[string]string `json:"metadata"` + }{ + Operation: e.Operation, + Description: e.Description, + StateBefore: e.StateBefore, + StateAfter: e.StateAfter, + Metadata: e.Metadata, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal JSON: %w", err) + } + + // Write JSON length (4 bytes) + jsonLen := uint32(len(jsonData)) + if err := binary.Write(buf, binary.LittleEndian, jsonLen); err != nil { + return nil, fmt.Errorf("failed to write JSON length: %w", err) + } + + // Write JSON data + if _, err := buf.Write(jsonData); err != nil { + return nil, fmt.Errorf("failed to write JSON data: %w", err) + } + + return buf.Bytes(), nil +} + +// Deserialize converts binary data back to an OplogEntry +func DeserializeOplogEntry(data []byte) (*OplogEntry, error) { + buf := bytes.NewReader(data) + + entry := &OplogEntry{} + + // Read entry ID (8 bytes) + if err := binary.Read(buf, binary.LittleEndian, &entry.ID); err != nil { + return nil, fmt.Errorf("failed to read ID: %w", err) + } + + // Read timestamp (8 bytes) + var timestamp int64 + if err := binary.Read(buf, binary.LittleEndian, ×tamp); err != nil { + return nil, fmt.Errorf("failed to read timestamp: %w", err) + } + entry.Timestamp = time.Unix(0, timestamp) + + // Read JSON length (4 bytes) + var jsonLen uint32 + if err := binary.Read(buf, binary.LittleEndian, &jsonLen); err != nil { + return nil, fmt.Errorf("failed to read JSON length: %w", err) + } + + // Read JSON data + jsonData := make([]byte, jsonLen) + if _, err := io.ReadFull(buf, jsonData); err != nil { + return nil, fmt.Errorf("failed to read JSON data: %w", err) + } + + // Unmarshal JSON + payload := struct { + Operation string `json:"operation"` + Description string `json:"description"` + StateBefore *RepositoryState `json:"state_before"` + StateAfter *RepositoryState `json:"state_after"` + Metadata map[string]string `json:"metadata"` + }{} + + if err := json.Unmarshal(jsonData, &payload); err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON: %w", err) + } + + entry.Operation = payload.Operation + entry.Description = payload.Description + entry.StateBefore = payload.StateBefore + entry.StateAfter = payload.StateAfter + entry.Metadata = payload.Metadata + + return entry, nil +} + +// NewOplogEntry creates a new oplog entry +func NewOplogEntry(id uint64, operation, description string, before, after *RepositoryState) *OplogEntry { + return &OplogEntry{ + ID: id, + Timestamp: time.Now(), + Operation: operation, + Description: description, + StateBefore: before, + StateAfter: after, + Metadata: make(map[string]string), + } +} + +// NewRepositoryState creates a new repository state snapshot +func NewRepositoryState(refs map[string]string, currentWorkstream, workingTreeHash, indexHash string) *RepositoryState { + return &RepositoryState{ + Refs: refs, + CurrentWorkstream: currentWorkstream, + WorkingTreeHash: workingTreeHash, + IndexHash: indexHash, + } +} diff --git a/internal/models/workspace.go b/internal/models/workspace.go new file mode 100644 index 0000000..9d4d751 --- /dev/null +++ b/internal/models/workspace.go @@ -0,0 +1,107 @@ +package models + +import ( + "encoding/json" + "fmt" + "time" +) + +// WorkspaceState represents the current state of the workspace +type WorkspaceState struct { + // CurrentCommitSHA is the SHA of the current ephemeral commit + CurrentCommitSHA string `json:"current_commit_sha"` + + // WorkstreamName is the name of the active workstream + WorkstreamName string `json:"workstream_name"` + + // LastSnapshot is when the last automatic snapshot was created + LastSnapshot time.Time `json:"last_snapshot"` + + // IsDirty indicates if there are uncommitted changes + IsDirty bool `json:"is_dirty"` + + // TreeHash is the hash of the current working tree + TreeHash string `json:"tree_hash,omitempty"` + + // IndexHash is the hash of the staging area + IndexHash string `json:"index_hash,omitempty"` + + // Metadata contains additional workspace-specific data + Metadata map[string]string `json:"metadata,omitempty"` +} + +// NewWorkspaceState creates a new workspace state +func NewWorkspaceState(commitSHA, workstreamName string) *WorkspaceState { + return &WorkspaceState{ + CurrentCommitSHA: commitSHA, + WorkstreamName: workstreamName, + LastSnapshot: time.Now(), + IsDirty: false, + Metadata: make(map[string]string), + } +} + +// UpdateSnapshot updates the workspace state with a new snapshot +func (ws *WorkspaceState) UpdateSnapshot(commitSHA, treeHash, indexHash string, isDirty bool) { + ws.CurrentCommitSHA = commitSHA + ws.TreeHash = treeHash + ws.IndexHash = indexHash + ws.IsDirty = isDirty + ws.LastSnapshot = time.Now() +} + +// SetWorkstream changes the active workstream +func (ws *WorkspaceState) SetWorkstream(workstreamName string) { + ws.WorkstreamName = workstreamName +} + +// MarkDirty marks the workspace as having uncommitted changes +func (ws *WorkspaceState) MarkDirty() { + ws.IsDirty = true +} + +// MarkClean marks the workspace as clean (no uncommitted changes) +func (ws *WorkspaceState) MarkClean() { + ws.IsDirty = false +} + +// Serialize converts the workspace state to JSON +func (ws *WorkspaceState) Serialize() ([]byte, error) { + data, err := json.MarshalIndent(ws, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal workspace state: %w", err) + } + return data, nil +} + +// DeserializeWorkspaceState converts JSON data to a workspace state +func DeserializeWorkspaceState(data []byte) (*WorkspaceState, error) { + ws := &WorkspaceState{} + if err := json.Unmarshal(data, ws); err != nil { + return nil, fmt.Errorf("failed to unmarshal workspace state: %w", err) + } + return ws, nil +} + +// GetTimeSinceLastSnapshot returns the duration since the last snapshot +func (ws *WorkspaceState) GetTimeSinceLastSnapshot() time.Duration { + return time.Since(ws.LastSnapshot) +} + +// Clone creates a deep copy of the workspace state +func (ws *WorkspaceState) Clone() *WorkspaceState { + metadata := make(map[string]string, len(ws.Metadata)) + for k, v := range ws.Metadata { + metadata[k] = v + } + + return &WorkspaceState{ + CurrentCommitSHA: ws.CurrentCommitSHA, + WorkstreamName: ws.WorkstreamName, + LastSnapshot: ws.LastSnapshot, + IsDirty: ws.IsDirty, + TreeHash: ws.TreeHash, + IndexHash: ws.IndexHash, + Metadata: metadata, + } +} diff --git a/internal/models/workstream.go b/internal/models/workstream.go new file mode 100644 index 0000000..36d46e3 --- /dev/null +++ b/internal/models/workstream.go @@ -0,0 +1,214 @@ +package models + +import ( + "encoding/json" + "fmt" + "time" +) + +// Workstream represents a stacked-diff workflow +type Workstream struct { + // Name is the unique identifier for the workstream + Name string `json:"name"` + + // Description provides context about the workstream + Description string `json:"description"` + + // BaseBranch is the Git branch this workstream is based on + BaseBranch string `json:"base_branch"` + + // Commits is an ordered list of commits in this workstream + Commits []WorkstreamCommit `json:"commits"` + + // Created is when the workstream was created + Created time.Time `json:"created"` + + // Updated is when the workstream was last modified + Updated time.Time `json:"updated"` + + // Status indicates the current state (active, merged, abandoned) + Status WorkstreamStatus `json:"status"` + + // Metadata contains additional workstream-specific data + Metadata map[string]string `json:"metadata,omitempty"` +} + +// WorkstreamCommit represents a single commit in a workstream +type WorkstreamCommit struct { + // SHA is the Git commit hash + SHA string `json:"sha"` + + // Message is the commit message + Message string `json:"message"` + + // Author is the commit author + Author string `json:"author"` + + // Timestamp is when the commit was created + Timestamp time.Time `json:"timestamp"` + + // ParentSHA is the parent commit in the workstream (empty for first commit) + ParentSHA string `json:"parent_sha,omitempty"` + + // BaseSHA is the base commit from the base branch + BaseSHA string `json:"base_sha"` + + // BranchRef is the Git reference for this commit (e.g., refs/onyx/workstreams/name/commit-1) + BranchRef string `json:"branch_ref"` +} + +// WorkstreamStatus represents the state of a workstream +type WorkstreamStatus string + +const ( + // WorkstreamStatusActive indicates the workstream is being actively developed + WorkstreamStatusActive WorkstreamStatus = "active" + + // WorkstreamStatusMerged indicates the workstream has been merged + WorkstreamStatusMerged WorkstreamStatus = "merged" + + // WorkstreamStatusAbandoned indicates the workstream has been abandoned + WorkstreamStatusAbandoned WorkstreamStatus = "abandoned" + + // WorkstreamStatusArchived indicates the workstream has been archived + WorkstreamStatusArchived WorkstreamStatus = "archived" +) + +// WorkstreamCollection represents the collection of all workstreams +type WorkstreamCollection struct { + // Workstreams is a map of workstream name to Workstream + Workstreams map[string]*Workstream `json:"workstreams"` + + // CurrentWorkstream is the name of the active workstream + CurrentWorkstream string `json:"current_workstream,omitempty"` +} + +// NewWorkstream creates a new workstream +func NewWorkstream(name, description, baseBranch string) *Workstream { + now := time.Now() + return &Workstream{ + Name: name, + Description: description, + BaseBranch: baseBranch, + Commits: []WorkstreamCommit{}, + Created: now, + Updated: now, + Status: WorkstreamStatusActive, + Metadata: make(map[string]string), + } +} + +// AddCommit adds a commit to the workstream +func (w *Workstream) AddCommit(commit WorkstreamCommit) { + w.Commits = append(w.Commits, commit) + w.Updated = time.Now() +} + +// GetLatestCommit returns the latest commit in the workstream +func (w *Workstream) GetLatestCommit() (*WorkstreamCommit, error) { + if len(w.Commits) == 0 { + return nil, fmt.Errorf("workstream has no commits") + } + return &w.Commits[len(w.Commits)-1], nil +} + +// GetCommitCount returns the number of commits in the workstream +func (w *Workstream) GetCommitCount() int { + return len(w.Commits) +} + +// IsEmpty returns true if the workstream has no commits +func (w *Workstream) IsEmpty() bool { + return len(w.Commits) == 0 +} + +// NewWorkstreamCommit creates a new workstream commit +func NewWorkstreamCommit(sha, message, author, parentSHA, baseSHA, branchRef string) WorkstreamCommit { + return WorkstreamCommit{ + SHA: sha, + Message: message, + Author: author, + Timestamp: time.Now(), + ParentSHA: parentSHA, + BaseSHA: baseSHA, + BranchRef: branchRef, + } +} + +// NewWorkstreamCollection creates a new workstream collection +func NewWorkstreamCollection() *WorkstreamCollection { + return &WorkstreamCollection{ + Workstreams: make(map[string]*Workstream), + } +} + +// AddWorkstream adds a workstream to the collection +func (wc *WorkstreamCollection) AddWorkstream(workstream *Workstream) error { + if _, exists := wc.Workstreams[workstream.Name]; exists { + return fmt.Errorf("workstream '%s' already exists", workstream.Name) + } + wc.Workstreams[workstream.Name] = workstream + return nil +} + +// GetWorkstream retrieves a workstream by name +func (wc *WorkstreamCollection) GetWorkstream(name string) (*Workstream, error) { + workstream, exists := wc.Workstreams[name] + if !exists { + return nil, fmt.Errorf("workstream '%s' not found", name) + } + return workstream, nil +} + +// RemoveWorkstream removes a workstream from the collection +func (wc *WorkstreamCollection) RemoveWorkstream(name string) error { + if _, exists := wc.Workstreams[name]; !exists { + return fmt.Errorf("workstream '%s' not found", name) + } + delete(wc.Workstreams, name) + return nil +} + +// ListWorkstreams returns all workstreams +func (wc *WorkstreamCollection) ListWorkstreams() []*Workstream { + workstreams := make([]*Workstream, 0, len(wc.Workstreams)) + for _, ws := range wc.Workstreams { + workstreams = append(workstreams, ws) + } + return workstreams +} + +// SetCurrentWorkstream sets the active workstream +func (wc *WorkstreamCollection) SetCurrentWorkstream(name string) error { + if _, exists := wc.Workstreams[name]; !exists { + return fmt.Errorf("workstream '%s' not found", name) + } + wc.CurrentWorkstream = name + return nil +} + +// GetCurrentWorkstream returns the current workstream +func (wc *WorkstreamCollection) GetCurrentWorkstream() (*Workstream, error) { + if wc.CurrentWorkstream == "" { + return nil, fmt.Errorf("no current workstream set") + } + return wc.GetWorkstream(wc.CurrentWorkstream) +} + +// Serialize converts the workstream collection to JSON +func (wc *WorkstreamCollection) Serialize() ([]byte, error) { + data, err := json.MarshalIndent(wc, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal workstream collection: %w", err) + } + return data, nil +} + +// DeserializeWorkstreamCollection converts JSON data to a workstream collection +func DeserializeWorkstreamCollection(data []byte) (*WorkstreamCollection, error) { + wc := &WorkstreamCollection{} + if err := json.Unmarshal(data, wc); err != nil { + return nil, fmt.Errorf("failed to unmarshal workstream collection: %w", err) + } + return wc, nil +}