Phase 1: Milestone 1 #1
@ -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
2
.gitignore
vendored
@ -30,3 +30,5 @@ go.work.sum
|
|||||||
# Editor/IDE
|
# Editor/IDE
|
||||||
# .idea/
|
# .idea/
|
||||||
# .vscode/
|
# .vscode/
|
||||||
|
|
||||||
|
bin/
|
||||||
|
33
cmd/onx/main.go
Normal file
33
cmd/onx/main.go
Normal 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
8
go.mod
@ -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
36
go.sum
@ -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
179
internal/commands/init.go
Normal 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)
|
||||||
|
}
|
196
internal/commands/init_test.go
Normal file
196
internal/commands/init_test.go
Normal 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
204
internal/commands/undo.go
Normal 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
|
||||||
|
}
|
300
internal/commands/undo_test.go
Normal file
300
internal/commands/undo_test.go
Normal 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"])
|
||||||
|
}
|
||||||
|
}
|
167
internal/core/transaction.go
Normal file
167
internal/core/transaction.go
Normal 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)
|
||||||
|
}
|
201
internal/storage/oplog_reader.go
Normal file
201
internal/storage/oplog_reader.go
Normal 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
|
||||||
|
}
|
163
internal/storage/oplog_writer.go
Normal file
163
internal/storage/oplog_writer.go
Normal 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
187
internal/storage/state.go
Normal 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
|
||||||
|
}
|
@ -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
|
|
||||||
|
@ -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
|
||||||
|
Reference in New Issue
Block a user