Phase 1: Milestone 1 #1

Merged
dubey merged 4 commits from milestone-1 into main 2025-10-09 20:22:23 -04:00
15 changed files with 1752 additions and 295 deletions

View File

@ -1,4 +1,4 @@
name: CI/CD Pipeline name: CI
on: on:
push: push:
@ -10,174 +10,50 @@ jobs:
test: test:
name: Test name: Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: golang:1.24.2-alpine
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24.2'
- name: Cache Go modules
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Download dependencies - name: Download dependencies
run: go mod download run: go mod download
- name: Verify dependencies
run: go mod verify
- name: Run tests - name: Run tests
run: go test -v -race -coverprofile=coverage.out ./... run: go test -v ./...
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
file: ./coverage.out
flags: unittests
name: codecov-umbrella
build: build:
name: Build name: Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test container: golang:1.24.2-alpine
strategy:
matrix:
goos: [linux, windows, darwin]
goarch: [amd64, arm64]
exclude:
- goos: windows
goarch: arm64
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24.2'
- name: Cache Go modules
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Download dependencies - name: Download dependencies
run: go mod download run: go mod download
- name: Build binaries - name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: | run: |
mkdir -p bin/${{ matrix.goos }}-${{ matrix.goarch }} go build -o bin/onx ./cmd/onx
go build -o bin/onxd ./cmd/onxd
# Build CLI
go build -ldflags="-s -w" -o bin/${{ matrix.goos }}-${{ matrix.goarch }}/onx${{ matrix.goos == 'windows' && '.exe' || '' }} ./cmd/onx
# Build daemon
go build -ldflags="-s -w" -o bin/${{ matrix.goos }}-${{ matrix.goarch }}/onxd${{ matrix.goos == 'windows' && '.exe' || '' }} ./cmd/onxd
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: onyx-${{ matrix.goos }}-${{ matrix.goarch }}
path: bin/${{ matrix.goos }}-${{ matrix.goarch }}/
security:
name: Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24.2'
- name: Run Gosec Security Scanner
uses: securecodewarrior/github-action-gosec@master
with:
args: './...'
- name: Run SAST with Gosec
run: |
go install github.com/securecodewarrior/gosec/v2/cmd/gosec@latest
gosec -fmt sarif -out gosec.sarif ./...
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: gosec.sarif
lint: lint:
name: Lint name: Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: golang:1.24.2-alpine
steps: steps:
- name: Install git
run: apk add --no-cache git
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Go - name: Install golangci-lint
uses: actions/setup-go@v5 run: |
with: wget -O- -nv https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.62.0
go-version: '1.24.2'
- name: Run golangci-lint - name: Run golangci-lint
uses: golangci/golangci-lint-action@v6 run: $(go env GOPATH)/bin/golangci-lint run --timeout=2m
with:
version: latest
args: --timeout=5m
release:
name: Release
runs-on: ubuntu-latest
needs: [test, build, security, lint]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts/
- name: Create release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ github.run_number }}
release_name: Release v${{ github.run_number }}
draft: false
prerelease: false
- name: Upload release assets
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: artifacts/
asset_name: onyx-binaries.zip
asset_content_type: application/zip

2
.gitignore vendored
View File

@ -30,3 +30,5 @@ go.work.sum
# Editor/IDE # Editor/IDE
# .idea/ # .idea/
# .vscode/ # .vscode/
bin/

33
cmd/onx/main.go Normal file
View File

@ -0,0 +1,33 @@
package main
import (
"fmt"
"os"
"git.dws.rip/DWS/onyx/internal/commands"
"github.com/spf13/cobra"
)
var version = "0.1.0"
func main() {
rootCmd := &cobra.Command{
Use: "onx",
Short: "Onyx - The iPhone of Version Control",
Long: `Onyx is a next-generation version control system that provides
a superior user experience layer on top of Git. It offers transparent
versioning, workstreams for stacked-diff management, and an action
log for universal undo functionality.`,
Version: version,
}
// Add commands
rootCmd.AddCommand(commands.NewInitCmd())
rootCmd.AddCommand(commands.NewUndoCmd())
// Execute the root command
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}

8
go.mod
View File

@ -2,6 +2,11 @@ module git.dws.rip/DWS/onyx
go 1.24.2 go 1.24.2
require (
github.com/go-git/go-git/v5 v5.16.3
github.com/spf13/cobra v1.10.1
)
require ( require (
dario.cat/mergo v1.0.0 // indirect dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
@ -9,10 +14,8 @@ require (
github.com/cloudflare/circl v1.6.1 // indirect github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/emirpasic/gods v1.18.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/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // 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/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
@ -20,7 +23,6 @@ require (
github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.1 // 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/spf13/pflag v1.0.9 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.37.0 // indirect golang.org/x/crypto v0.37.0 // indirect

36
go.sum
View File

@ -5,25 +5,36 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 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 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 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/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 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 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/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
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 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 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 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-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6Z8= 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/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 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
@ -31,12 +42,22 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 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/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/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 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/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 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 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
@ -50,11 +71,15 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 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= 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.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 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 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 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
@ -67,12 +92,19 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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 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/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 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.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

179
internal/commands/init.go Normal file
View File

@ -0,0 +1,179 @@
package commands
import (
"fmt"
"os"
"path/filepath"
"git.dws.rip/DWS/onyx/internal/core"
"git.dws.rip/DWS/onyx/internal/storage"
"github.com/spf13/cobra"
)
// NewInitCmd creates the init command
func NewInitCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "init [path]",
Short: "Initialize a new Onyx repository",
Long: `Initialize a new Onyx repository in the specified directory.
If no path is provided, initializes in the current directory.
This command will:
- Create a Git repository (if one doesn't exist)
- Create the .onx directory structure
- Initialize the oplog file
- Create default workstreams.json
- Add .onx to .gitignore`,
Args: cobra.MaximumNArgs(1),
RunE: runInit,
}
return cmd
}
func runInit(cmd *cobra.Command, args []string) error {
// Determine the path
path := "."
if len(args) > 0 {
path = args[0]
}
// Resolve to absolute path
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("failed to resolve path: %w", err)
}
// Check if already an Onyx repository
if core.IsOnyxRepo(absPath) {
return fmt.Errorf("already an onyx repository: %s", absPath)
}
// Create and initialize repository
repo := &core.OnyxRepository{}
err = repo.Init(absPath)
if err != nil {
return fmt.Errorf("failed to initialize repository: %w", err)
}
// Add .onx to .gitignore
gitignorePath := filepath.Join(absPath, ".gitignore")
err = addToGitignore(gitignorePath, ".onx/")
if err != nil {
// Don't fail if we can't update .gitignore, just warn
fmt.Fprintf(os.Stderr, "Warning: failed to update .gitignore: %v\n", err)
}
// Log the init operation to oplog
txn, err := core.NewTransaction(repo)
if err != nil {
// Don't fail if we can't create transaction, repo is already initialized
fmt.Fprintf(os.Stderr, "Warning: failed to log init to oplog: %v\n", err)
} else {
defer txn.Close()
// Execute a no-op function just to log the init
err = txn.ExecuteWithTransaction("init", "Initialized Onyx repository", func() error {
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to log init: %v\n", err)
}
}
fmt.Printf("Initialized empty Onyx repository in %s\n", filepath.Join(absPath, ".onx"))
return nil
}
// addToGitignore adds an entry to .gitignore if it doesn't already exist
func addToGitignore(gitignorePath, entry string) error {
// Read existing .gitignore if it exists
var content []byte
if _, err := os.Stat(gitignorePath); err == nil {
content, err = os.ReadFile(gitignorePath)
if err != nil {
return fmt.Errorf("failed to read .gitignore: %w", err)
}
}
// Check if entry already exists
contentStr := string(content)
if len(contentStr) > 0 && contentStr[len(contentStr)-1] != '\n' {
contentStr += "\n"
}
// Add entry if it doesn't exist
needle := entry
if len(needle) > 0 && needle[len(needle)-1] != '\n' {
needle += "\n"
}
// Simple check - not perfect but good enough
if !containsLine(contentStr, entry) {
contentStr += needle
}
// Write back to .gitignore
err := os.WriteFile(gitignorePath, []byte(contentStr), 0644)
if err != nil {
return fmt.Errorf("failed to write .gitignore: %w", err)
}
return nil
}
// containsLine checks if a multi-line string contains a specific line
func containsLine(content, line string) bool {
// Simple implementation - just check if the line exists as a substring
// In the future, we might want to do line-by-line checking
target := line
if len(target) > 0 && target[len(target)-1] == '\n' {
target = target[:len(target)-1]
}
lines := splitLines(content)
for _, l := range lines {
if l == target {
return true
}
}
return false
}
// splitLines splits a string into lines
func splitLines(s string) []string {
if s == "" {
return []string{}
}
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
// Add the last line if it doesn't end with newline
if start < len(s) {
lines = append(lines, s[start:])
}
return lines
}
// GetOplogWriter creates an oplog writer for the repository at the given path
func GetOplogWriter(path string) (*storage.OplogWriter, error) {
absPath, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("failed to resolve path: %w", err)
}
if !core.IsOnyxRepo(absPath) {
return nil, fmt.Errorf("not an onyx repository: %s", absPath)
}
oplogPath := filepath.Join(absPath, ".onx", "oplog")
return storage.OpenOplog(oplogPath)
}

View File

@ -0,0 +1,196 @@
package commands
import (
"os"
"path/filepath"
"testing"
"git.dws.rip/DWS/onyx/internal/core"
)
func TestInitCommand(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
// Create a repository instance
repo := &core.OnyxRepository{}
// Initialize the repository
err := repo.Init(tempDir)
if err != nil {
t.Fatalf("Failed to initialize repository: %v", err)
}
// Verify .git directory exists
gitPath := filepath.Join(tempDir, ".git")
if _, err := os.Stat(gitPath); os.IsNotExist(err) {
t.Errorf(".git directory was not created")
}
// Verify .onx directory exists
onyxPath := filepath.Join(tempDir, ".onx")
if _, err := os.Stat(onyxPath); os.IsNotExist(err) {
t.Errorf(".onx directory was not created")
}
// Verify oplog file exists
oplogPath := filepath.Join(onyxPath, "oplog")
if _, err := os.Stat(oplogPath); os.IsNotExist(err) {
t.Errorf("oplog file was not created")
}
// Verify workstreams.json exists
workstreamsPath := filepath.Join(onyxPath, "workstreams.json")
if _, err := os.Stat(workstreamsPath); os.IsNotExist(err) {
t.Errorf("workstreams.json was not created")
}
// Verify rerere_cache directory exists
rererePath := filepath.Join(onyxPath, "rerere_cache")
if _, err := os.Stat(rererePath); os.IsNotExist(err) {
t.Errorf("rerere_cache directory was not created")
}
}
func TestInitCommandInExistingRepo(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
// Initialize once
repo := &core.OnyxRepository{}
err := repo.Init(tempDir)
if err != nil {
t.Fatalf("Failed to initialize repository: %v", err)
}
// Verify it's an Onyx repo
if !core.IsOnyxRepo(tempDir) {
t.Errorf("IsOnyxRepo returned false for initialized repository")
}
}
func TestIsOnyxRepo(t *testing.T) {
tests := []struct {
name string
setup func(string) error
expected bool
}{
{
name: "empty directory",
setup: func(path string) error {
return nil
},
expected: false,
},
{
name: "initialized repository",
setup: func(path string) error {
repo := &core.OnyxRepository{}
return repo.Init(path)
},
expected: true,
},
{
name: "directory with only .git",
setup: func(path string) error {
return os.MkdirAll(filepath.Join(path, ".git"), 0755)
},
expected: false,
},
{
name: "directory with only .onx",
setup: func(path string) error {
return os.MkdirAll(filepath.Join(path, ".onx"), 0755)
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
err := tt.setup(tempDir)
if err != nil {
t.Fatalf("Setup failed: %v", err)
}
result := core.IsOnyxRepo(tempDir)
if result != tt.expected {
t.Errorf("IsOnyxRepo() = %v, expected %v", result, tt.expected)
}
})
}
}
func TestAddToGitignore(t *testing.T) {
tests := []struct {
name string
existingContent string
entryToAdd string
shouldContain string
}{
{
name: "add to empty gitignore",
existingContent: "",
entryToAdd: ".onx/",
shouldContain: ".onx/",
},
{
name: "add to existing gitignore",
existingContent: "node_modules/\n*.log\n",
entryToAdd: ".onx/",
shouldContain: ".onx/",
},
{
name: "don't duplicate existing entry",
existingContent: ".onx/\nnode_modules/\n",
entryToAdd: ".onx/",
shouldContain: ".onx/",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
gitignorePath := filepath.Join(tempDir, ".gitignore")
// Create existing content if specified
if tt.existingContent != "" {
err := os.WriteFile(gitignorePath, []byte(tt.existingContent), 0644)
if err != nil {
t.Fatalf("Failed to create test .gitignore: %v", err)
}
}
// Add the entry
err := addToGitignore(gitignorePath, tt.entryToAdd)
if err != nil {
t.Fatalf("addToGitignore failed: %v", err)
}
// Read the result
content, err := os.ReadFile(gitignorePath)
if err != nil {
t.Fatalf("Failed to read .gitignore: %v", err)
}
// Verify the entry is present
if !containsLine(string(content), tt.shouldContain) {
t.Errorf(".gitignore does not contain expected entry %q\nContent:\n%s", tt.shouldContain, string(content))
}
// Count occurrences (should not be duplicated)
lines := splitLines(string(content))
count := 0
for _, line := range lines {
if line == tt.shouldContain {
count++
}
}
if count > 1 {
t.Errorf("Entry %q appears %d times, expected 1", tt.shouldContain, count)
}
})
}
}

204
internal/commands/undo.go Normal file
View File

@ -0,0 +1,204 @@
package commands
import (
"fmt"
"os"
"path/filepath"
"git.dws.rip/DWS/onyx/internal/core"
"git.dws.rip/DWS/onyx/internal/storage"
"github.com/spf13/cobra"
)
// NewUndoCmd creates the undo command
func NewUndoCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "undo",
Short: "Undo the last operation",
Long: `Undo the last operation by restoring the repository to its previous state.
This command reads the last entry from the oplog and restores all refs
and working directory state to what they were before the operation.`,
Args: cobra.NoArgs,
RunE: runUndo,
}
return cmd
}
func runUndo(cmd *cobra.Command, args []string) error {
// Get current directory
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
// Check if we're in an Onyx repository
if !core.IsOnyxRepo(cwd) {
return fmt.Errorf("not an onyx repository (or any parent up to mount point)")
}
// Open the repository
repo, err := core.Open(cwd)
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
}
defer repo.Close()
// Open oplog reader
oplogPath := filepath.Join(repo.GetOnyxPath(), "oplog")
reader := storage.NewOplogReader(oplogPath)
// Check if oplog is empty
isEmpty, err := reader.IsEmpty()
if err != nil {
return fmt.Errorf("failed to check oplog: %w", err)
}
if isEmpty {
return fmt.Errorf("nothing to undo")
}
// Read the last entry
lastEntry, err := reader.ReadLastEntry()
if err != nil {
return fmt.Errorf("failed to read last entry: %w", err)
}
// Check if we have state_before to restore
if lastEntry.StateBefore == nil {
return fmt.Errorf("cannot undo: last operation has no state_before")
}
// Show what we're undoing
fmt.Printf("Undoing: %s - %s\n", lastEntry.Operation, lastEntry.Description)
// Create state capture to restore the state
stateCapture := storage.NewStateCapture(repo.GetGitRepo())
// Restore the state
err = stateCapture.RestoreState(lastEntry.StateBefore)
if err != nil {
return fmt.Errorf("failed to restore state: %w", err)
}
// Log the undo operation
txn, err := core.NewTransaction(repo)
if err != nil {
// Don't fail if we can't create transaction, state is already restored
fmt.Fprintf(os.Stderr, "Warning: failed to log undo to oplog: %v\n", err)
} else {
defer txn.Close()
metadata := map[string]string{
"undone_entry_id": fmt.Sprintf("%d", lastEntry.ID),
"undone_operation": lastEntry.Operation,
}
err = txn.ExecuteWithTransactionAndMetadata(
"undo",
fmt.Sprintf("Undid operation: %s", lastEntry.Operation),
metadata,
func() error {
// The actual undo has already been performed above
// This function is just to capture the state after undo
return nil
},
)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to log undo: %v\n", err)
}
}
// Show what changed
stateAfter, _ := stateCapture.CaptureState()
if stateAfter != nil {
differences := stateCapture.CompareStates(stateAfter, lastEntry.StateBefore)
if len(differences) > 0 {
fmt.Println("\nChanges:")
for ref, change := range differences {
fmt.Printf(" %s: %s\n", ref, change)
}
}
}
fmt.Println("\nUndo complete!")
return nil
}
// UndoToEntry undoes to a specific entry ID
func UndoToEntry(repo *core.OnyxRepository, entryID uint64) error {
oplogPath := filepath.Join(repo.GetOnyxPath(), "oplog")
reader := storage.NewOplogReader(oplogPath)
// Read the target entry
entry, err := reader.ReadEntry(entryID)
if err != nil {
return fmt.Errorf("failed to read entry %d: %w", entryID, err)
}
if entry.StateBefore == nil {
return fmt.Errorf("entry %d has no state_before to restore", entryID)
}
// Restore the state
stateCapture := storage.NewStateCapture(repo.GetGitRepo())
err = stateCapture.RestoreState(entry.StateBefore)
if err != nil {
return fmt.Errorf("failed to restore state: %w", err)
}
// Log the undo operation
txn, err := core.NewTransaction(repo)
if err != nil {
return fmt.Errorf("failed to create transaction: %w", err)
}
defer txn.Close()
metadata := map[string]string{
"undone_to_entry_id": fmt.Sprintf("%d", entryID),
"undone_operation": entry.Operation,
}
err = txn.ExecuteWithTransactionAndMetadata(
"undo",
fmt.Sprintf("Undid to entry %d: %s", entryID, entry.Operation),
metadata,
func() error {
return nil
},
)
if err != nil {
return fmt.Errorf("failed to log undo: %w", err)
}
return nil
}
// ListUndoStack shows the undo stack
func ListUndoStack(repo *core.OnyxRepository) error {
oplogPath := filepath.Join(repo.GetOnyxPath(), "oplog")
reader := storage.NewOplogReader(oplogPath)
entries, err := reader.GetUndoStack()
if err != nil {
return fmt.Errorf("failed to get undo stack: %w", err)
}
if len(entries) == 0 {
fmt.Println("Nothing to undo")
return nil
}
fmt.Println("Undo stack (most recent first):")
for i, entry := range entries {
fmt.Printf("%d. [%d] %s: %s (%s)\n",
i+1,
entry.ID,
entry.Operation,
entry.Description,
entry.Timestamp.Format("2006-01-02 15:04:05"),
)
}
return nil
}

View File

@ -0,0 +1,300 @@
package commands
import (
"path/filepath"
"testing"
"git.dws.rip/DWS/onyx/internal/core"
"git.dws.rip/DWS/onyx/internal/storage"
)
func TestUndoWithEmptyOplog(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
// Initialize the repository
repo := &core.OnyxRepository{}
err := repo.Init(tempDir)
if err != nil {
t.Fatalf("Failed to initialize repository: %v", err)
}
// Open the repository
openedRepo, err := core.Open(tempDir)
if err != nil {
t.Fatalf("Failed to open repository: %v", err)
}
defer openedRepo.Close()
// Try to undo with empty oplog
oplogPath := filepath.Join(openedRepo.GetOnyxPath(), "oplog")
reader := storage.NewOplogReader(oplogPath)
isEmpty, err := reader.IsEmpty()
if err != nil {
t.Fatalf("Failed to check if oplog is empty: %v", err)
}
if !isEmpty {
t.Errorf("Expected oplog to be empty after init")
}
_, err = reader.ReadLastEntry()
if err == nil {
t.Errorf("Expected error when reading from empty oplog, got nil")
}
}
func TestUndoAfterOperation(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
// Initialize the repository
repo := &core.OnyxRepository{}
err := repo.Init(tempDir)
if err != nil {
t.Fatalf("Failed to initialize repository: %v", err)
}
// Open the repository
openedRepo, err := core.Open(tempDir)
if err != nil {
t.Fatalf("Failed to open repository: %v", err)
}
defer openedRepo.Close()
// Perform an operation with transaction
txn, err := core.NewTransaction(openedRepo)
if err != nil {
t.Fatalf("Failed to create transaction: %v", err)
}
err = txn.ExecuteWithTransaction("test_operation", "Test operation for undo", func() error {
// Simulate some operation
return nil
})
txn.Close()
if err != nil {
t.Fatalf("Failed to execute transaction: %v", err)
}
// Verify the oplog has an entry
oplogPath := filepath.Join(openedRepo.GetOnyxPath(), "oplog")
reader := storage.NewOplogReader(oplogPath)
isEmpty, err := reader.IsEmpty()
if err != nil {
t.Fatalf("Failed to check if oplog is empty: %v", err)
}
if isEmpty {
t.Errorf("Expected oplog to have entries after operation")
}
// Read the last entry
lastEntry, err := reader.ReadLastEntry()
if err != nil {
t.Fatalf("Failed to read last entry: %v", err)
}
if lastEntry.Operation != "test_operation" {
t.Errorf("Expected operation to be 'test_operation', got %q", lastEntry.Operation)
}
if lastEntry.Description != "Test operation for undo" {
t.Errorf("Expected description to be 'Test operation for undo', got %q", lastEntry.Description)
}
// Verify state_before and state_after are captured
if lastEntry.StateBefore == nil {
t.Errorf("Expected state_before to be captured")
}
if lastEntry.StateAfter == nil {
t.Errorf("Expected state_after to be captured")
}
}
func TestSequentialUndos(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
// Initialize the repository
repo := &core.OnyxRepository{}
err := repo.Init(tempDir)
if err != nil {
t.Fatalf("Failed to initialize repository: %v", err)
}
// Open the repository
openedRepo, err := core.Open(tempDir)
if err != nil {
t.Fatalf("Failed to open repository: %v", err)
}
defer openedRepo.Close()
// Perform multiple operations
operations := []string{"operation1", "operation2", "operation3"}
for _, op := range operations {
txn, err := core.NewTransaction(openedRepo)
if err != nil {
t.Fatalf("Failed to create transaction: %v", err)
}
err = txn.ExecuteWithTransaction(op, "Test "+op, func() error {
return nil
})
txn.Close()
if err != nil {
t.Fatalf("Failed to execute transaction for %s: %v", op, err)
}
}
// Verify we have 3 entries
oplogPath := filepath.Join(openedRepo.GetOnyxPath(), "oplog")
reader := storage.NewOplogReader(oplogPath)
count, err := reader.Count()
if err != nil {
t.Fatalf("Failed to count oplog entries: %v", err)
}
if count != 3 {
t.Errorf("Expected 3 oplog entries, got %d", count)
}
// Read all entries to verify order
entries, err := reader.ReadAllEntries()
if err != nil {
t.Fatalf("Failed to read all entries: %v", err)
}
for i, entry := range entries {
expectedOp := operations[i]
if entry.Operation != expectedOp {
t.Errorf("Entry %d: expected operation %q, got %q", i, expectedOp, entry.Operation)
}
}
}
func TestUndoStack(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
// Initialize the repository
repo := &core.OnyxRepository{}
err := repo.Init(tempDir)
if err != nil {
t.Fatalf("Failed to initialize repository: %v", err)
}
// Open the repository
openedRepo, err := core.Open(tempDir)
if err != nil {
t.Fatalf("Failed to open repository: %v", err)
}
defer openedRepo.Close()
// Perform multiple operations
operations := []string{"op1", "op2", "op3"}
for _, op := range operations {
txn, err := core.NewTransaction(openedRepo)
if err != nil {
t.Fatalf("Failed to create transaction: %v", err)
}
err = txn.ExecuteWithTransaction(op, "Test "+op, func() error {
return nil
})
txn.Close()
if err != nil {
t.Fatalf("Failed to execute transaction for %s: %v", op, err)
}
}
// Get the undo stack
oplogPath := filepath.Join(openedRepo.GetOnyxPath(), "oplog")
reader := storage.NewOplogReader(oplogPath)
undoStack, err := reader.GetUndoStack()
if err != nil {
t.Fatalf("Failed to get undo stack: %v", err)
}
if len(undoStack) != 3 {
t.Errorf("Expected undo stack size of 3, got %d", len(undoStack))
}
// Verify the stack is in reverse order (most recent first)
expectedOps := []string{"op3", "op2", "op1"}
for i, entry := range undoStack {
if entry.Operation != expectedOps[i] {
t.Errorf("Undo stack[%d]: expected operation %q, got %q", i, expectedOps[i], entry.Operation)
}
}
}
func TestOplogEntryMetadata(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
// Initialize the repository
repo := &core.OnyxRepository{}
err := repo.Init(tempDir)
if err != nil {
t.Fatalf("Failed to initialize repository: %v", err)
}
// Open the repository
openedRepo, err := core.Open(tempDir)
if err != nil {
t.Fatalf("Failed to open repository: %v", err)
}
defer openedRepo.Close()
// Perform an operation with metadata
txn, err := core.NewTransaction(openedRepo)
if err != nil {
t.Fatalf("Failed to create transaction: %v", err)
}
metadata := map[string]string{
"key1": "value1",
"key2": "value2",
}
err = txn.ExecuteWithTransactionAndMetadata("test_op", "Test with metadata", metadata, func() error {
return nil
})
txn.Close()
if err != nil {
t.Fatalf("Failed to execute transaction with metadata: %v", err)
}
// Read the entry and verify metadata
oplogPath := filepath.Join(openedRepo.GetOnyxPath(), "oplog")
reader := storage.NewOplogReader(oplogPath)
lastEntry, err := reader.ReadLastEntry()
if err != nil {
t.Fatalf("Failed to read last entry: %v", err)
}
if lastEntry.Metadata == nil {
t.Fatalf("Expected metadata to be present")
}
if lastEntry.Metadata["key1"] != "value1" {
t.Errorf("Expected metadata key1=value1, got %q", lastEntry.Metadata["key1"])
}
if lastEntry.Metadata["key2"] != "value2" {
t.Errorf("Expected metadata key2=value2, got %q", lastEntry.Metadata["key2"])
}
}

View File

@ -0,0 +1,167 @@
package core
import (
"fmt"
"path/filepath"
"git.dws.rip/DWS/onyx/internal/models"
"git.dws.rip/DWS/onyx/internal/storage"
)
// Transaction represents a transactional operation with oplog support
type Transaction struct {
repo *OnyxRepository
oplogWriter *storage.OplogWriter
stateCapture *storage.StateCapture
}
// NewTransaction creates a new transaction for the given repository
func NewTransaction(repo *OnyxRepository) (*Transaction, error) {
oplogPath := filepath.Join(repo.GetOnyxPath(), "oplog")
oplogWriter, err := storage.OpenOplog(oplogPath)
if err != nil {
return nil, fmt.Errorf("failed to open oplog: %w", err)
}
stateCapture := storage.NewStateCapture(repo.GetGitRepo())
return &Transaction{
repo: repo,
oplogWriter: oplogWriter,
stateCapture: stateCapture,
}, nil
}
// ExecuteWithTransaction executes a function within a transaction context
// It captures the state before and after the operation and logs it to the oplog
func (t *Transaction) ExecuteWithTransaction(operation, description string, fn func() error) error {
// 1. Capture state_before
stateBefore, err := t.stateCapture.CaptureState()
if err != nil {
return fmt.Errorf("failed to capture state before: %w", err)
}
// 2. Execute the function
err = fn()
if err != nil {
// On error, we don't log to oplog since the operation failed
return fmt.Errorf("operation failed: %w", err)
}
// 3. Capture state_after
stateAfter, err := t.stateCapture.CaptureState()
if err != nil {
// Even if we can't capture the after state, we should try to log what we can
// This is a warning situation rather than a failure
fmt.Printf("Warning: failed to capture state after: %v\n", err)
stateAfter = stateBefore // Use the before state as a fallback
}
// 4. Create oplog entry
entry := models.NewOplogEntry(0, operation, description, stateBefore, stateAfter)
// 5. Write to oplog
err = t.oplogWriter.AppendEntry(entry)
if err != nil {
return fmt.Errorf("failed to write to oplog: %w", err)
}
return nil
}
// Close closes the transaction and releases resources
func (t *Transaction) Close() error {
return t.oplogWriter.Close()
}
// ExecuteWithTransactionAndMetadata executes a function with custom metadata
func (t *Transaction) ExecuteWithTransactionAndMetadata(
operation, description string,
metadata map[string]string,
fn func() error,
) error {
// Capture state_before
stateBefore, err := t.stateCapture.CaptureState()
if err != nil {
return fmt.Errorf("failed to capture state before: %w", err)
}
// Execute the function
err = fn()
if err != nil {
return fmt.Errorf("operation failed: %w", err)
}
// Capture state_after
stateAfter, err := t.stateCapture.CaptureState()
if err != nil {
fmt.Printf("Warning: failed to capture state after: %v\n", err)
stateAfter = stateBefore
}
// Create oplog entry with metadata
entry := models.NewOplogEntry(0, operation, description, stateBefore, stateAfter)
entry.Metadata = metadata
// Write to oplog
err = t.oplogWriter.AppendEntry(entry)
if err != nil {
return fmt.Errorf("failed to write to oplog: %w", err)
}
return nil
}
// Rollback attempts to rollback to a previous state
func (t *Transaction) Rollback(entryID uint64) error {
// Read the oplog entry
oplogPath := filepath.Join(t.repo.GetOnyxPath(), "oplog")
reader := storage.NewOplogReader(oplogPath)
entry, err := reader.ReadEntry(entryID)
if err != nil {
return fmt.Errorf("failed to read entry %d: %w", entryID, err)
}
// Restore the state_before from that entry
if entry.StateBefore == nil {
return fmt.Errorf("entry %d has no state_before to restore", entryID)
}
err = t.stateCapture.RestoreState(entry.StateBefore)
if err != nil {
return fmt.Errorf("failed to restore state: %w", err)
}
// Log the rollback operation
stateAfter, _ := t.stateCapture.CaptureState()
rollbackEntry := models.NewOplogEntry(
0,
"rollback",
fmt.Sprintf("Rolled back to entry %d", entryID),
stateAfter, // The current state becomes the "before"
entry.StateBefore, // The restored state becomes the "after"
)
rollbackEntry.Metadata = map[string]string{
"rollback_to_entry_id": fmt.Sprintf("%d", entryID),
}
err = t.oplogWriter.AppendEntry(rollbackEntry)
if err != nil {
// Don't fail the rollback if we can't log it
fmt.Printf("Warning: failed to log rollback: %v\n", err)
}
return nil
}
// Helper function to execute a transaction on a repository
func ExecuteWithTransaction(repo *OnyxRepository, operation, description string, fn func() error) error {
txn, err := NewTransaction(repo)
if err != nil {
return err
}
defer txn.Close()
return txn.ExecuteWithTransaction(operation, description, fn)
}

View File

@ -0,0 +1,201 @@
package storage
import (
"encoding/binary"
"fmt"
"io"
"os"
"git.dws.rip/DWS/onyx/internal/models"
)
// OplogReader handles reading entries from the oplog file
type OplogReader struct {
path string
}
// NewOplogReader creates a new oplog reader for the given file path
func NewOplogReader(path string) *OplogReader {
return &OplogReader{
path: path,
}
}
// ReadLastEntry reads the last (most recent) entry in the oplog
func (r *OplogReader) ReadLastEntry() (*models.OplogEntry, error) {
file, err := os.Open(r.path)
if err != nil {
return nil, fmt.Errorf("failed to open oplog file: %w", err)
}
defer file.Close()
var lastEntry *models.OplogEntry
// Read through all entries to find the last one
for {
// Read entry length (4 bytes)
var entryLen uint32
err := binary.Read(file, binary.LittleEndian, &entryLen)
if err != nil {
if err == io.EOF {
break
}
return nil, fmt.Errorf("failed to read entry length: %w", err)
}
// Read the entry data
entryData := make([]byte, entryLen)
n, err := file.Read(entryData)
if err != nil {
return nil, fmt.Errorf("failed to read entry data: %w", err)
}
if n != int(entryLen) {
return nil, fmt.Errorf("incomplete entry data read: expected %d bytes, got %d", entryLen, n)
}
// Deserialize the entry
entry, err := models.DeserializeOplogEntry(entryData)
if err != nil {
return nil, fmt.Errorf("failed to deserialize entry: %w", err)
}
lastEntry = entry
}
if lastEntry == nil {
return nil, fmt.Errorf("oplog is empty")
}
return lastEntry, nil
}
// ReadEntry reads a specific entry by ID
func (r *OplogReader) ReadEntry(id uint64) (*models.OplogEntry, error) {
file, err := os.Open(r.path)
if err != nil {
return nil, fmt.Errorf("failed to open oplog file: %w", err)
}
defer file.Close()
// Read through all entries to find the one with matching ID
for {
// Read entry length (4 bytes)
var entryLen uint32
err := binary.Read(file, binary.LittleEndian, &entryLen)
if err != nil {
if err == io.EOF {
break
}
return nil, fmt.Errorf("failed to read entry length: %w", err)
}
// Read the entry data
entryData := make([]byte, entryLen)
n, err := file.Read(entryData)
if err != nil {
return nil, fmt.Errorf("failed to read entry data: %w", err)
}
if n != int(entryLen) {
return nil, fmt.Errorf("incomplete entry data read: expected %d bytes, got %d", entryLen, n)
}
// Deserialize the entry
entry, err := models.DeserializeOplogEntry(entryData)
if err != nil {
return nil, fmt.Errorf("failed to deserialize entry: %w", err)
}
if entry.ID == id {
return entry, nil
}
}
return nil, fmt.Errorf("entry with ID %d not found", id)
}
// GetUndoStack returns a stack of entries that can be undone (in reverse order)
func (r *OplogReader) GetUndoStack() ([]*models.OplogEntry, error) {
entries, err := r.ReadAllEntries()
if err != nil {
return nil, err
}
// Filter out entries that have already been undone
// For now, we return all entries in reverse order
// In the future, we might track undone entries separately
var undoStack []*models.OplogEntry
for i := len(entries) - 1; i >= 0; i-- {
undoStack = append(undoStack, entries[i])
}
return undoStack, nil
}
// ReadAllEntries reads all entries from the oplog in order
func (r *OplogReader) ReadAllEntries() ([]*models.OplogEntry, error) {
file, err := os.Open(r.path)
if err != nil {
return nil, fmt.Errorf("failed to open oplog file: %w", err)
}
defer file.Close()
var entries []*models.OplogEntry
// Read through all entries
for {
// Read entry length (4 bytes)
var entryLen uint32
err := binary.Read(file, binary.LittleEndian, &entryLen)
if err != nil {
if err == io.EOF {
break
}
return nil, fmt.Errorf("failed to read entry length: %w", err)
}
// Read the entry data
entryData := make([]byte, entryLen)
n, err := file.Read(entryData)
if err != nil {
return nil, fmt.Errorf("failed to read entry data: %w", err)
}
if n != int(entryLen) {
return nil, fmt.Errorf("incomplete entry data read: expected %d bytes, got %d", entryLen, n)
}
// Deserialize the entry
entry, err := models.DeserializeOplogEntry(entryData)
if err != nil {
return nil, fmt.Errorf("failed to deserialize entry: %w", err)
}
entries = append(entries, entry)
}
return entries, nil
}
// Count returns the total number of entries in the oplog
func (r *OplogReader) Count() (int, error) {
entries, err := r.ReadAllEntries()
if err != nil {
return 0, err
}
return len(entries), nil
}
// IsEmpty checks if the oplog is empty
func (r *OplogReader) IsEmpty() (bool, error) {
file, err := os.Open(r.path)
if err != nil {
return false, fmt.Errorf("failed to open oplog file: %w", err)
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return false, fmt.Errorf("failed to stat file: %w", err)
}
return stat.Size() == 0, nil
}

View File

@ -0,0 +1,163 @@
package storage
import (
"encoding/binary"
"fmt"
"os"
"sync"
"git.dws.rip/DWS/onyx/internal/models"
)
// OplogWriter handles writing entries to the oplog file
type OplogWriter struct {
path string
file *os.File
mu sync.Mutex
nextID uint64
isClosed bool
}
// OpenOplog opens an existing oplog file or creates a new one
func OpenOplog(path string) (*OplogWriter, error) {
// Open file for append and read
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
return nil, fmt.Errorf("failed to open oplog file: %w", err)
}
writer := &OplogWriter{
path: path,
file: file,
nextID: 1,
}
// Calculate next ID by reading existing entries
if err := writer.calculateNextID(); err != nil {
file.Close()
return nil, fmt.Errorf("failed to calculate next ID: %w", err)
}
return writer, nil
}
// calculateNextID scans the oplog to determine the next entry ID
func (w *OplogWriter) calculateNextID() error {
// Seek to the beginning
if _, err := w.file.Seek(0, 0); err != nil {
return fmt.Errorf("failed to seek to beginning: %w", err)
}
var maxID uint64 = 0
// Read through all entries to find the max ID
for {
// Read entry length (4 bytes)
var entryLen uint32
err := binary.Read(w.file, binary.LittleEndian, &entryLen)
if err != nil {
// EOF is expected at the end
if err.Error() == "EOF" {
break
}
return fmt.Errorf("failed to read entry length: %w", err)
}
// Read the entry data
entryData := make([]byte, entryLen)
n, err := w.file.Read(entryData)
if err != nil || n != int(entryLen) {
return fmt.Errorf("failed to read entry data: %w", err)
}
// Deserialize to get the ID
entry, err := models.DeserializeOplogEntry(entryData)
if err != nil {
return fmt.Errorf("failed to deserialize entry: %w", err)
}
if entry.ID > maxID {
maxID = entry.ID
}
}
w.nextID = maxID + 1
// Seek to the end for appending
if _, err := w.file.Seek(0, 2); err != nil {
return fmt.Errorf("failed to seek to end: %w", err)
}
return nil
}
// AppendEntry appends a new entry to the oplog
func (w *OplogWriter) AppendEntry(entry *models.OplogEntry) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.isClosed {
return fmt.Errorf("oplog writer is closed")
}
// Assign ID if not set
if entry.ID == 0 {
entry.ID = w.nextID
w.nextID++
}
// Serialize the entry
data, err := entry.Serialize()
if err != nil {
return fmt.Errorf("failed to serialize entry: %w", err)
}
// Write entry length (4 bytes) followed by entry data
entryLen := uint32(len(data))
if err := binary.Write(w.file, binary.LittleEndian, entryLen); err != nil {
return fmt.Errorf("failed to write entry length: %w", err)
}
if _, err := w.file.Write(data); err != nil {
return fmt.Errorf("failed to write entry data: %w", err)
}
// Sync to disk for durability
if err := w.file.Sync(); err != nil {
return fmt.Errorf("failed to sync file: %w", err)
}
return nil
}
// GetNextID returns the next entry ID that will be assigned
func (w *OplogWriter) GetNextID() uint64 {
w.mu.Lock()
defer w.mu.Unlock()
return w.nextID
}
// Close closes the oplog file
func (w *OplogWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.isClosed {
return nil
}
w.isClosed = true
return w.file.Close()
}
// Flush ensures all buffered data is written to disk
func (w *OplogWriter) Flush() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.isClosed {
return fmt.Errorf("oplog writer is closed")
}
return w.file.Sync()
}

187
internal/storage/state.go Normal file
View File

@ -0,0 +1,187 @@
package storage
import (
"fmt"
"git.dws.rip/DWS/onyx/internal/models"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
)
// StateCapture provides functionality to capture repository state
type StateCapture struct {
repo *git.Repository
}
// NewStateCapture creates a new StateCapture instance
func NewStateCapture(repo *git.Repository) *StateCapture {
return &StateCapture{
repo: repo,
}
}
// CaptureState captures the current state of the repository
func (s *StateCapture) CaptureState() (*models.RepositoryState, error) {
refs, err := s.captureRefs()
if err != nil {
return nil, fmt.Errorf("failed to capture refs: %w", err)
}
currentWorkstream, err := s.getCurrentWorkstream()
if err != nil {
// It's okay if there's no current workstream (e.g., in detached HEAD state)
currentWorkstream = ""
}
workingTreeHash, err := s.getWorkingTreeHash()
if err != nil {
// Working tree hash might not be available in a fresh repo
workingTreeHash = ""
}
indexHash, err := s.getIndexHash()
if err != nil {
// Index hash might not be available in a fresh repo
indexHash = ""
}
return models.NewRepositoryState(refs, currentWorkstream, workingTreeHash, indexHash), nil
}
// captureRefs captures all Git references (branches, tags, etc.)
func (s *StateCapture) captureRefs() (map[string]string, error) {
refs := make(map[string]string)
refIter, err := s.repo.References()
if err != nil {
return nil, fmt.Errorf("failed to get references: %w", err)
}
err = refIter.ForEach(func(ref *plumbing.Reference) error {
if ref.Type() == plumbing.HashReference {
refs[ref.Name().String()] = ref.Hash().String()
} else if ref.Type() == plumbing.SymbolicReference {
// For symbolic refs (like HEAD), store the target
refs[ref.Name().String()] = ref.Target().String()
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to iterate references: %w", err)
}
return refs, nil
}
// getCurrentWorkstream determines the current workstream (branch)
func (s *StateCapture) getCurrentWorkstream() (string, error) {
head, err := s.repo.Head()
if err != nil {
return "", fmt.Errorf("failed to get HEAD: %w", err)
}
if head.Name().IsBranch() {
return head.Name().Short(), nil
}
// In detached HEAD state
return "", fmt.Errorf("in detached HEAD state")
}
// getWorkingTreeHash gets a hash representing the current working tree
func (s *StateCapture) getWorkingTreeHash() (string, error) {
worktree, err := s.repo.Worktree()
if err != nil {
return "", fmt.Errorf("failed to get worktree: %w", err)
}
status, err := worktree.Status()
if err != nil {
return "", fmt.Errorf("failed to get status: %w", err)
}
// For now, we'll just check if the working tree is clean
// In the future, we might compute an actual hash
if status.IsClean() {
head, err := s.repo.Head()
if err == nil {
return head.Hash().String(), nil
}
}
return "dirty", nil
}
// getIndexHash gets a hash representing the current index (staging area)
func (s *StateCapture) getIndexHash() (string, error) {
// For now, this is a placeholder
// In the future, we might compute a proper hash of the index
return "", nil
}
// RestoreState restores the repository to a previously captured state
func (s *StateCapture) RestoreState(state *models.RepositoryState) error {
// Restore all refs
for refName, refHash := range state.Refs {
ref := plumbing.NewReferenceFromStrings(refName, refHash)
// Skip symbolic references for now
if ref.Type() == plumbing.SymbolicReference {
continue
}
err := s.repo.Storer.SetReference(ref)
if err != nil {
return fmt.Errorf("failed to restore ref %s: %w", refName, err)
}
}
// If there's a current workstream, check it out
if state.CurrentWorkstream != "" {
worktree, err := s.repo.Worktree()
if err != nil {
return fmt.Errorf("failed to get worktree: %w", err)
}
err = worktree.Checkout(&git.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName(state.CurrentWorkstream),
})
if err != nil {
// Don't fail if checkout fails, just log it
// The refs have been restored which is the most important part
fmt.Printf("Warning: failed to checkout branch %s: %v\n", state.CurrentWorkstream, err)
}
}
return nil
}
// CompareStates compares two repository states and returns the differences
func (s *StateCapture) CompareStates(before, after *models.RepositoryState) map[string]string {
differences := make(map[string]string)
// Check for changed/added refs
for refName, afterHash := range after.Refs {
beforeHash, exists := before.Refs[refName]
if !exists {
differences[refName] = fmt.Sprintf("added: %s", afterHash)
} else if beforeHash != afterHash {
differences[refName] = fmt.Sprintf("changed: %s -> %s", beforeHash, afterHash)
}
}
// Check for deleted refs
for refName := range before.Refs {
if _, exists := after.Refs[refName]; !exists {
differences[refName] = "deleted"
}
}
// Check workstream change
if before.CurrentWorkstream != after.CurrentWorkstream {
differences["current_workstream"] = fmt.Sprintf("changed: %s -> %s", before.CurrentWorkstream, after.CurrentWorkstream)
}
return differences
}

View File

@ -1,87 +1,78 @@
# Complete Implementation Plan for Onyx Phase 1 # Complete Implementation Plan for Onyx Phase 1
## Milestone 1: Action Log and onx init
## Milestone 0: Foundation and Core Abstractions ### Action Log Implementation
### Project Structure Setup 13. **Create oplog binary format** (`internal/storage/oplog.go`)
```go
type OplogEntry struct {
ID uint64
ParentID uint64
Timestamp int64
Command string
StateBefore map[string]string
StateAfter map[string]string
}
```
6. **Create directory structure** 14. **Implement oplog writer** (`internal/storage/oplog_writer.go`)
``` - `OpenOplog(path string) (*OplogWriter, error)`
onyx/ - `AppendEntry(entry *OplogEntry) error`
├── cmd/ - Use binary encoding (gob or protobuf)
│ ├── onx/ # CLI entry point - Implement file locking for concurrent access
│ └── onxd/ # Daemon entry point
├── internal/
│ ├── core/ # Core abstractions
│ ├── git/ # Git interaction layer
│ ├── models/ # Data models
│ ├── storage/ # .onx directory management
│ ├── commands/ # CLI command implementations
│ ├── daemon/ # Daemon implementation
│ └── utils/ # Utilities
├── pkg/ # Public APIs (if needed)
├── test/ # Integration tests
├── docs/ # Documentation
└── scripts/ # Build/deployment scripts
```
7. **Set up dependency management** 15. **Implement oplog reader** (`internal/storage/oplog_reader.go`)
```bash - `ReadLastEntry() (*OplogEntry, error)`
go get github.com/go-git/go-git/v5@latest - `ReadEntry(id uint64) (*OplogEntry, error)`
go get github.com/spf13/cobra@latest - `GetUndoStack() ([]*OplogEntry, error)`
go get github.com/fsnotify/fsnotify@latest
```
8. **Create Makefile** 16. **Create transactional wrapper** (`internal/core/transaction.go`)
```makefile ```go
# Makefile func ExecuteWithTransaction(repo *Repository, cmd string,
.PHONY: build test clean install fn func() error) error {
// 1. Capture state_before
build: // 2. Create oplog entry
go build -o bin/onx ./cmd/onx // 3. Execute fn()
go build -o bin/onxd ./cmd/onxd // 4. Capture state_after
// 5. Finalize oplog entry
test: // 6. Handle rollback on error
go test -v ./... }
```
install:
go install ./cmd/onx
go install ./cmd/onxd
clean:
rm -rf bin/
```
### Core Abstractions Implementation ### onx init Command
9. **Define core interfaces** (`internal/core/interfaces.go`) 17. **Implement init command** (`internal/commands/init.go`)
```go - Create .git directory (via go-git)
type Repository interface { - Create .onx directory structure
Init(path string) error - Initialize empty oplog file
GetGitRepo() *git.Repository - Create default workstreams.json
GetOnyxMetadata() *OnyxMetadata - Create workspace pointer file
} - Add .onx to .gitignore
type GitBackend interface {
CreateCommit(tree, parent, message string) (string, error)
CreateTree(entries []TreeEntry) (string, error)
UpdateRef(name, sha string) error
GetRef(name string) (string, error)
}
```
10. **Implement Repository struct** (`internal/core/repository.go`) 18. **Create CLI structure** (`cmd/onx/main.go`)
- Fields: gitRepo (*git.Repository), onyxPath (string), gitPath (string) ```go
- Methods: Open(), Close(), IsOnyxRepo() func main() {
- Error handling for missing .git or .onx directories rootCmd := &cobra.Command{
Use: "onx",
Short: "The iPhone of Version Control",
}
rootCmd.AddCommand(commands.InitCmd())
rootCmd.Execute()
}
```
11. **Implement Git object operations** (`internal/git/objects.go`) ### onx undo Command
- `CreateBlob(content []byte) (string, error)`
- `CreateTree(entries []TreeEntry) (string, error)` 19. **Implement undo logic** (`internal/commands/undo.go`)
- `CreateCommit(tree, parent, message, author string) (string, error)` - Read last oplog entry
- `GetObject(sha string) (Object, error)` - Restore all refs from state_before
- Update workspace pointer
- Mark entry as undone in oplog
- Perform git checkout to restore working directory
20. **Add undo tests** (`internal/commands/undo_test.go`)
- Test undo after init
- Test sequential undos
- Test undo with nothing to undo
12. **Create data models** (`internal/models/`)
- `oplog.go`: OplogEntry struct with serialization
- `workstream.go`: Workstream, WorkstreamCommit structs
- `workspace.go`: WorkspaceState struct

View File

@ -1,79 +1,3 @@
## Milestone 1: Action Log and onx init
### Action Log Implementation
13. **Create oplog binary format** (`internal/storage/oplog.go`)
```go
type OplogEntry struct {
ID uint64
ParentID uint64
Timestamp int64
Command string
StateBefore map[string]string
StateAfter map[string]string
}
```
14. **Implement oplog writer** (`internal/storage/oplog_writer.go`)
- `OpenOplog(path string) (*OplogWriter, error)`
- `AppendEntry(entry *OplogEntry) error`
- Use binary encoding (gob or protobuf)
- Implement file locking for concurrent access
15. **Implement oplog reader** (`internal/storage/oplog_reader.go`)
- `ReadLastEntry() (*OplogEntry, error)`
- `ReadEntry(id uint64) (*OplogEntry, error)`
- `GetUndoStack() ([]*OplogEntry, error)`
16. **Create transactional wrapper** (`internal/core/transaction.go`)
```go
func ExecuteWithTransaction(repo *Repository, cmd string,
fn func() error) error {
// 1. Capture state_before
// 2. Create oplog entry
// 3. Execute fn()
// 4. Capture state_after
// 5. Finalize oplog entry
// 6. Handle rollback on error
}
```
### onx init Command
17. **Implement init command** (`internal/commands/init.go`)
- Create .git directory (via go-git)
- Create .onx directory structure
- Initialize empty oplog file
- Create default workstreams.json
- Create workspace pointer file
- Add .onx to .gitignore
18. **Create CLI structure** (`cmd/onx/main.go`)
```go
func main() {
rootCmd := &cobra.Command{
Use: "onx",
Short: "The iPhone of Version Control",
}
rootCmd.AddCommand(commands.InitCmd())
rootCmd.Execute()
}
```
### onx undo Command
19. **Implement undo logic** (`internal/commands/undo.go`)
- Read last oplog entry
- Restore all refs from state_before
- Update workspace pointer
- Mark entry as undone in oplog
- Perform git checkout to restore working directory
20. **Add undo tests** (`internal/commands/undo_test.go`)
- Test undo after init
- Test sequential undos
- Test undo with nothing to undo
## Milestone 2: Transparent Versioning and onx save ## Milestone 2: Transparent Versioning and onx save
### Filesystem Daemon Implementation ### Filesystem Daemon Implementation