Compare commits
1 Commits
milestone-
...
onyx/works
Author | SHA1 | Date | |
---|---|---|---|
077643cca9 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -32,4 +32,3 @@ go.work.sum
|
|||||||
# .vscode/
|
# .vscode/
|
||||||
|
|
||||||
bin/
|
bin/
|
||||||
.onx/
|
|
||||||
|
66
CLAUDE.md
66
CLAUDE.md
@ -105,55 +105,8 @@ Background process using fsnotify for continuous snapshot creation.
|
|||||||
| `onx switch <name>` | Switch workstreams | `git checkout` |
|
| `onx switch <name>` | Switch workstreams | `git checkout` |
|
||||||
| `onx sync` | Update with remote | `git pull --rebase` |
|
| `onx sync` | Update with remote | `git pull --rebase` |
|
||||||
| `onx push` | Push workstream | `git push` |
|
| `onx push` | Push workstream | `git push` |
|
||||||
| `onx push --stacked` | Push stacked diffs | N/A (advanced) |
|
|
||||||
| `onx undo` | Undo last operation | `git reflog && reset` |
|
| `onx undo` | Undo last operation | `git reflog && reset` |
|
||||||
|
|
||||||
## Push Workflows
|
|
||||||
|
|
||||||
Onyx supports two push workflows to match different development styles:
|
|
||||||
|
|
||||||
### Single-Branch Mode (Default) - Recommended for AI Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
onx push
|
|
||||||
```
|
|
||||||
|
|
||||||
**When to use:**
|
|
||||||
- Default for all standard feature development
|
|
||||||
- When creating traditional pull requests
|
|
||||||
- For AI-assisted development sessions
|
|
||||||
- When you want a clean remote repository UI
|
|
||||||
|
|
||||||
**What happens:**
|
|
||||||
- Pushes workstream as ONE branch named after the workstream
|
|
||||||
- Example: `milestone-4` branch contains all commits
|
|
||||||
- Remote UI shows single branch per workstream (clean)
|
|
||||||
- Perfect for creating GitHub/Gitea pull requests
|
|
||||||
|
|
||||||
**AI Development Guidance:**
|
|
||||||
Use this mode by default. It provides the cleanest integration with standard Git workflows and PR creation tools.
|
|
||||||
|
|
||||||
### Stacked Diffs Mode (Advanced)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
onx push --stacked
|
|
||||||
```
|
|
||||||
|
|
||||||
**When to use:**
|
|
||||||
- Large, complex features requiring incremental review
|
|
||||||
- When each commit needs independent review/approval
|
|
||||||
- Meta/Google-style stacked diff workflows
|
|
||||||
- When explicitly requested by the user
|
|
||||||
|
|
||||||
**What happens:**
|
|
||||||
- Pushes EACH commit as a separate branch
|
|
||||||
- Example: `onyx/workstreams/milestone-4/commit-1`, `commit-2`, etc.
|
|
||||||
- Remote UI shows multiple branches (one per commit)
|
|
||||||
- Each branch can have its own pull request
|
|
||||||
|
|
||||||
**AI Development Guidance:**
|
|
||||||
Only use when specifically requested or when the feature is complex enough to warrant incremental review. The additional branches may clutter the remote UI.
|
|
||||||
|
|
||||||
## Implementation Status
|
## Implementation Status
|
||||||
|
|
||||||
This is currently a planning/prototype phase. The codebase contains:
|
This is currently a planning/prototype phase. The codebase contains:
|
||||||
@ -164,25 +117,6 @@ This is currently a planning/prototype phase. The codebase contains:
|
|||||||
|
|
||||||
## Development Guidelines
|
## Development Guidelines
|
||||||
|
|
||||||
### IMPORTANT: Dogfooding Policy
|
|
||||||
|
|
||||||
**This repository uses Onyx for its own development.** All development work MUST use Onyx commands exclusively:
|
|
||||||
|
|
||||||
- ✅ **Use `onx save -m "message"`** to commit changes (NOT `git commit`)
|
|
||||||
- ✅ **Use `onx new <name>`** to create feature branches (NOT `git checkout -b`)
|
|
||||||
- ✅ **Use `onx switch <name>`** to switch workstreams (NOT `git checkout`)
|
|
||||||
- ✅ **Use `onx sync`** to update from remote (NOT `git pull`)
|
|
||||||
- ✅ **Use `onx push`** to push to remote (NOT `git push`)
|
|
||||||
- ✅ **Use `onx undo`** to undo operations (NOT `git reset`)
|
|
||||||
- ✅ **Use `onx list`** to view workstreams (NOT `git branch`)
|
|
||||||
|
|
||||||
**Exception:** Only use `git` commands for:
|
|
||||||
- Initial remote setup (`git remote add`)
|
|
||||||
- Creating pull requests via GitHub CLI (`gh pr create`)
|
|
||||||
- Inspecting low-level Git state when debugging Onyx itself
|
|
||||||
|
|
||||||
This dogfooding validates our user experience and ensures Onyx works correctly for real-world development.
|
|
||||||
|
|
||||||
### Code Style
|
### Code Style
|
||||||
- Follow Go conventions and idioms
|
- Follow Go conventions and idioms
|
||||||
- Use structured logging (planned: zap or logrus)
|
- Use structured logging (planned: zap or logrus)
|
||||||
|
66
README.md
66
README.md
@ -66,72 +66,6 @@ onx push
|
|||||||
onx undo
|
onx undo
|
||||||
```
|
```
|
||||||
|
|
||||||
## Push Workflows: Single-Branch vs Stacked Diffs
|
|
||||||
|
|
||||||
Onyx supports two push workflows to match your team's needs:
|
|
||||||
|
|
||||||
### Single-Branch Mode (Default) - Recommended
|
|
||||||
|
|
||||||
**Perfect for:** Traditional teams, simple features, clean remote UI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
onx new add-login --base main
|
|
||||||
onx save -m "Add login form"
|
|
||||||
onx save -m "Add validation"
|
|
||||||
onx save -m "Add tests"
|
|
||||||
|
|
||||||
# Push creates ONE branch with all commits
|
|
||||||
onx push
|
|
||||||
|
|
||||||
# Result on remote:
|
|
||||||
# - Branch: add-login (contains all 3 commits)
|
|
||||||
# - Clean UI, easy PR workflow
|
|
||||||
```
|
|
||||||
|
|
||||||
**What you get:**
|
|
||||||
- ✅ One clean branch per workstream
|
|
||||||
- ✅ Perfect for traditional PR workflows
|
|
||||||
- ✅ All commits preserved locally for undo
|
|
||||||
- ✅ Clean remote repository UI
|
|
||||||
|
|
||||||
### Stacked Diffs Mode - Advanced
|
|
||||||
|
|
||||||
**Perfect for:** Complex features, incremental review, Meta/Google-style workflows
|
|
||||||
|
|
||||||
```bash
|
|
||||||
onx new big-refactor --base main
|
|
||||||
onx save -m "Step 1: Database schema"
|
|
||||||
onx save -m "Step 2: API endpoints"
|
|
||||||
onx save -m "Step 3: Frontend UI"
|
|
||||||
|
|
||||||
# Push creates MULTIPLE branches (one per commit)
|
|
||||||
onx push --stacked
|
|
||||||
|
|
||||||
# Result on remote:
|
|
||||||
# - Branch: onyx/workstreams/big-refactor/commit-1
|
|
||||||
# - Branch: onyx/workstreams/big-refactor/commit-2
|
|
||||||
# - Branch: onyx/workstreams/big-refactor/commit-3
|
|
||||||
# - Each can have its own PR for focused review
|
|
||||||
```
|
|
||||||
|
|
||||||
**What you get:**
|
|
||||||
- ✅ One branch per commit for incremental review
|
|
||||||
- ✅ PRs can be merged independently
|
|
||||||
- ✅ Better for large, complex changes
|
|
||||||
- ⚠️ More branches in remote UI
|
|
||||||
|
|
||||||
### Choosing Your Workflow
|
|
||||||
|
|
||||||
| Criterion | Single-Branch (`onx push`) | Stacked Diffs (`onx push --stacked`) |
|
|
||||||
|-----------|---------------------------|-------------------------------------|
|
|
||||||
| **Team Style** | Traditional Git workflow | Meta/Google stacked review |
|
|
||||||
| **Feature Size** | Any size | Large, complex features |
|
|
||||||
| **Review Style** | One big PR | Multiple small PRs |
|
|
||||||
| **Remote UI** | Clean (1 branch) | More branches (N commits) |
|
|
||||||
| **PR Creation** | `gh pr create --head feature` | Multiple PRs, stacked dependencies |
|
|
||||||
|
|
||||||
**Recommendation:** Start with default `onx push` (single-branch). Use `--stacked` only when you need incremental review of complex changes.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
@ -29,8 +29,6 @@ log for universal undo functionality.`,
|
|||||||
rootCmd.AddCommand(commands.NewNewCmd())
|
rootCmd.AddCommand(commands.NewNewCmd())
|
||||||
rootCmd.AddCommand(commands.NewListCmd())
|
rootCmd.AddCommand(commands.NewListCmd())
|
||||||
rootCmd.AddCommand(commands.NewSwitchCmd())
|
rootCmd.AddCommand(commands.NewSwitchCmd())
|
||||||
rootCmd.AddCommand(commands.NewSyncCmd())
|
|
||||||
rootCmd.AddCommand(commands.NewPushCmd())
|
|
||||||
|
|
||||||
// Execute the root command
|
// Execute the root command
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
@ -1,287 +0,0 @@
|
|||||||
package commands
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.dws.rip/DWS/onyx/internal/core"
|
|
||||||
"git.dws.rip/DWS/onyx/internal/git"
|
|
||||||
gogit "github.com/go-git/go-git/v5"
|
|
||||||
"github.com/go-git/go-git/v5/config"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewPushCmd creates the push command
|
|
||||||
func NewPushCmd() *cobra.Command {
|
|
||||||
var remoteName string
|
|
||||||
var force bool
|
|
||||||
var stacked bool
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "push",
|
|
||||||
Short: "Push the current workstream to the remote repository",
|
|
||||||
Long: `Push the current workstream to the remote repository.
|
|
||||||
|
|
||||||
By default, pushes as a single branch (clean, traditional workflow).
|
|
||||||
Use --stacked to push each commit as a separate branch (advanced stacked diffs).
|
|
||||||
|
|
||||||
Single-branch mode (default):
|
|
||||||
- Pushes workstream as one branch with all commits
|
|
||||||
- Clean remote UI (1 branch per workstream)
|
|
||||||
- Perfect for traditional PR workflows
|
|
||||||
- Example: 'milestone-4' branch
|
|
||||||
|
|
||||||
Stacked mode (--stacked):
|
|
||||||
- Pushes each commit as a separate branch
|
|
||||||
- Enables stacked diff workflow (Meta/Google style)
|
|
||||||
- Each commit can have its own PR
|
|
||||||
- Example: 'onyx/workstreams/milestone-4/commit-1', 'commit-2', etc.`,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return runPush(remoteName, force, stacked)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().StringVarP(&remoteName, "remote", "r", "origin", "Remote to push to")
|
|
||||||
cmd.Flags().BoolVarP(&force, "force", "f", false, "Force push (use with caution)")
|
|
||||||
cmd.Flags().BoolVar(&stacked, "stacked", false, "Push each commit as separate branch (stacked diffs)")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// runPush executes the push command
|
|
||||||
func runPush(remoteName string, force, stacked bool) error {
|
|
||||||
// Get current directory
|
|
||||||
cwd, err := os.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get current directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is an Onyx repository
|
|
||||||
if !core.IsOnyxRepo(cwd) {
|
|
||||||
return fmt.Errorf("not an Onyx repository. Run 'onx init' first")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the repository
|
|
||||||
repo, err := core.Open(cwd)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open repository: %w", err)
|
|
||||||
}
|
|
||||||
defer repo.Close()
|
|
||||||
|
|
||||||
// Use ExecuteWithTransaction to capture state
|
|
||||||
err = core.ExecuteWithTransaction(repo, "push", "Pushed to remote", func() error {
|
|
||||||
if stacked {
|
|
||||||
return executePushStacked(repo, remoteName, force)
|
|
||||||
}
|
|
||||||
return executePushSingleBranch(repo, remoteName, force)
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("✓ Push completed successfully")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// executePushSingleBranch pushes the workstream as a single branch (default behavior)
|
|
||||||
func executePushSingleBranch(repo *core.OnyxRepository, remoteName string, force bool) error {
|
|
||||||
gitRepo := repo.GetGitRepo()
|
|
||||||
|
|
||||||
// 1. Validate remote exists
|
|
||||||
remoteHelper := git.NewRemoteHelper(gitRepo)
|
|
||||||
if err := remoteHelper.ValidateRemote(remoteName); err != nil {
|
|
||||||
return fmt.Errorf("remote validation failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Get current workstream
|
|
||||||
wsManager := core.NewWorkstreamManager(repo)
|
|
||||||
currentWorkstream, err := wsManager.GetCurrentWorkstream()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("no active workstream: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentWorkstream.IsEmpty() {
|
|
||||||
return fmt.Errorf("workstream has no commits to push")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Get the remote
|
|
||||||
remote, err := remoteHelper.GetRemote(remoteName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get remote: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Get the latest commit in the workstream
|
|
||||||
latestCommit, err := currentWorkstream.GetLatestCommit()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get latest commit: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Build refspec to push the latest commit to a branch named after the workstream
|
|
||||||
branchName := currentWorkstream.Name
|
|
||||||
localRef := latestCommit.BranchRef
|
|
||||||
remoteRef := fmt.Sprintf("refs/heads/%s", branchName)
|
|
||||||
|
|
||||||
refSpec := config.RefSpec(fmt.Sprintf("%s:%s", localRef, remoteRef))
|
|
||||||
if force {
|
|
||||||
refSpec = config.RefSpec(fmt.Sprintf("+%s:%s", localRef, remoteRef))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Get authentication for the remote
|
|
||||||
remoteURL, err := remoteHelper.GetRemoteURL(remoteName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get remote URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
authProvider := git.NewAuthProvider()
|
|
||||||
authMethod, err := authProvider.GetAuthMethod(remoteURL)
|
|
||||||
if err != nil {
|
|
||||||
// Log the error but continue - some remotes might not need auth
|
|
||||||
fmt.Fprintf(os.Stderr, "Warning: authentication not available: %v\n", err)
|
|
||||||
fmt.Fprintf(os.Stderr, "Attempting push without authentication...\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Push to remote
|
|
||||||
fmt.Printf("Pushing workstream '%s' to %s...\n", branchName, remoteName)
|
|
||||||
|
|
||||||
err = remote.Push(&gogit.PushOptions{
|
|
||||||
Auth: authMethod,
|
|
||||||
RefSpecs: []config.RefSpec{refSpec},
|
|
||||||
Progress: os.Stdout,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if err == gogit.NoErrAlreadyUpToDate {
|
|
||||||
fmt.Println("Already up to date")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("failed to push: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("✓ Pushed branch '%s' with %d commit(s)\n", branchName, len(currentWorkstream.Commits))
|
|
||||||
fmt.Printf("\nTo create a pull request:\n")
|
|
||||||
fmt.Printf(" gh pr create --base %s --head %s\n", currentWorkstream.BaseBranch, branchName)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// executePushStacked pushes each commit as a separate branch (stacked diffs)
|
|
||||||
func executePushStacked(repo *core.OnyxRepository, remoteName string, force bool) error {
|
|
||||||
gitRepo := repo.GetGitRepo()
|
|
||||||
|
|
||||||
// 1. Validate remote exists
|
|
||||||
remoteHelper := git.NewRemoteHelper(gitRepo)
|
|
||||||
if err := remoteHelper.ValidateRemote(remoteName); err != nil {
|
|
||||||
return fmt.Errorf("remote validation failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Get current workstream
|
|
||||||
wsManager := core.NewWorkstreamManager(repo)
|
|
||||||
currentWorkstream, err := wsManager.GetCurrentWorkstream()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("no active workstream: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentWorkstream.IsEmpty() {
|
|
||||||
return fmt.Errorf("workstream has no commits to push")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Get the remote
|
|
||||||
remote, err := remoteHelper.GetRemote(remoteName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get remote: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Build list of refspecs to push (all branches in the workstream)
|
|
||||||
refspecs := []config.RefSpec{}
|
|
||||||
|
|
||||||
// Also push the base branch if it exists locally
|
|
||||||
baseBranch := currentWorkstream.BaseBranch
|
|
||||||
if baseBranch != "" {
|
|
||||||
// Check if base branch exists locally
|
|
||||||
gitBackend := git.NewGitBackend(gitRepo)
|
|
||||||
baseRef := fmt.Sprintf("refs/heads/%s", baseBranch)
|
|
||||||
if _, err := gitBackend.GetRef(baseRef); err == nil {
|
|
||||||
refSpec := config.RefSpec(fmt.Sprintf("refs/heads/%s:refs/heads/%s", baseBranch, baseBranch))
|
|
||||||
if force {
|
|
||||||
refSpec = config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", baseBranch, baseBranch))
|
|
||||||
}
|
|
||||||
refspecs = append(refspecs, refSpec)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push each commit's branch ref
|
|
||||||
for i, commit := range currentWorkstream.Commits {
|
|
||||||
branchRef := commit.BranchRef
|
|
||||||
if branchRef == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract branch name from ref (e.g., refs/onyx/workstreams/foo/commit-1 -> foo/commit-1)
|
|
||||||
// We'll push to refs/heads/onyx/workstreams/[workstream]/commit-[n]
|
|
||||||
remoteBranch := fmt.Sprintf("onyx/workstreams/%s/commit-%d", currentWorkstream.Name, i+1)
|
|
||||||
|
|
||||||
refSpec := config.RefSpec(fmt.Sprintf("%s:refs/heads/%s", branchRef, remoteBranch))
|
|
||||||
if force {
|
|
||||||
refSpec = config.RefSpec(fmt.Sprintf("+%s:refs/heads/%s", branchRef, remoteBranch))
|
|
||||||
}
|
|
||||||
|
|
||||||
refspecs = append(refspecs, refSpec)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(refspecs) == 0 {
|
|
||||||
return fmt.Errorf("no branches to push")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Get authentication for the remote
|
|
||||||
remoteURL, err := remoteHelper.GetRemoteURL(remoteName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get remote URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
authProvider := git.NewAuthProvider()
|
|
||||||
authMethod, err := authProvider.GetAuthMethod(remoteURL)
|
|
||||||
if err != nil {
|
|
||||||
// Log the error but continue - some remotes might not need auth
|
|
||||||
fmt.Fprintf(os.Stderr, "Warning: authentication not available: %v\n", err)
|
|
||||||
fmt.Fprintf(os.Stderr, "Attempting push without authentication...\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Push to remote
|
|
||||||
fmt.Printf("Pushing %d branch(es) to %s...\n", len(refspecs), remoteName)
|
|
||||||
|
|
||||||
err = remote.Push(&gogit.PushOptions{
|
|
||||||
Auth: authMethod,
|
|
||||||
RefSpecs: refspecs,
|
|
||||||
Progress: os.Stdout,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if err == gogit.NoErrAlreadyUpToDate {
|
|
||||||
fmt.Println("Already up to date")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("failed to push: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("✓ Pushed %d branch(es) successfully\n", len(refspecs))
|
|
||||||
|
|
||||||
// 7. Print summary of pushed branches
|
|
||||||
fmt.Println("\nPushed branches (stacked diffs):")
|
|
||||||
if baseBranch != "" {
|
|
||||||
fmt.Printf(" - %s (base branch)\n", baseBranch)
|
|
||||||
}
|
|
||||||
for i, commit := range currentWorkstream.Commits {
|
|
||||||
remoteBranch := fmt.Sprintf("onyx/workstreams/%s/commit-%d", currentWorkstream.Name, i+1)
|
|
||||||
commitTitle := strings.Split(commit.Message, "\n")[0]
|
|
||||||
if len(commitTitle) > 60 {
|
|
||||||
commitTitle = commitTitle[:57] + "..."
|
|
||||||
}
|
|
||||||
fmt.Printf(" - %s: %s\n", remoteBranch, commitTitle)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\nTip: Each branch can have its own PR for incremental review\n")
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
@ -1,222 +0,0 @@
|
|||||||
package commands
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"git.dws.rip/DWS/onyx/internal/core"
|
|
||||||
"git.dws.rip/DWS/onyx/internal/git"
|
|
||||||
"git.dws.rip/DWS/onyx/internal/models"
|
|
||||||
"git.dws.rip/DWS/onyx/internal/storage"
|
|
||||||
gogit "github.com/go-git/go-git/v5"
|
|
||||||
"github.com/go-git/go-git/v5/config"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewSyncCmd creates the sync command
|
|
||||||
func NewSyncCmd() *cobra.Command {
|
|
||||||
var remoteName string
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "sync",
|
|
||||||
Short: "Sync the current workstream with the remote base branch",
|
|
||||||
Long: `Synchronize the current workstream with the remote base branch.
|
|
||||||
|
|
||||||
This command will:
|
|
||||||
1. Fetch the latest changes from the remote
|
|
||||||
2. Rebase the workstream commits onto the updated base branch
|
|
||||||
3. Use rerere to automatically resolve known conflicts
|
|
||||||
4. Update all branch references in the workstream
|
|
||||||
|
|
||||||
If conflicts occur during the rebase, you will need to resolve them manually
|
|
||||||
and then continue the sync operation.`,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return runSync(remoteName)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().StringVarP(&remoteName, "remote", "r", "origin", "Remote to sync with")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// runSync executes the sync command
|
|
||||||
func runSync(remoteName string) error {
|
|
||||||
// Get current directory
|
|
||||||
cwd, err := os.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get current directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is an Onyx repository
|
|
||||||
if !core.IsOnyxRepo(cwd) {
|
|
||||||
return fmt.Errorf("not an Onyx repository. Run 'onx init' first")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the repository
|
|
||||||
repo, err := core.Open(cwd)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open repository: %w", err)
|
|
||||||
}
|
|
||||||
defer repo.Close()
|
|
||||||
|
|
||||||
// Use ExecuteWithTransaction to capture state
|
|
||||||
err = core.ExecuteWithTransaction(repo, "sync", "Synced with remote", func() error {
|
|
||||||
return executeSync(repo, cwd, remoteName)
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("✓ Sync completed successfully")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// executeSync performs the actual sync operation
|
|
||||||
func executeSync(repo *core.OnyxRepository, repoPath, remoteName string) error {
|
|
||||||
gitRepo := repo.GetGitRepo()
|
|
||||||
onyxPath := repo.GetOnyxPath()
|
|
||||||
|
|
||||||
// 1. Validate remote exists
|
|
||||||
remoteHelper := git.NewRemoteHelper(gitRepo)
|
|
||||||
if err := remoteHelper.ValidateRemote(remoteName); err != nil {
|
|
||||||
return fmt.Errorf("remote validation failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Get current workstream
|
|
||||||
wsManager := core.NewWorkstreamManager(repo)
|
|
||||||
currentWorkstream, err := wsManager.GetCurrentWorkstream()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("no active workstream: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentWorkstream.IsEmpty() {
|
|
||||||
return fmt.Errorf("workstream has no commits to sync")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Get authentication for the remote
|
|
||||||
remoteURL, err := remoteHelper.GetRemoteURL(remoteName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get remote URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
authProvider := git.NewAuthProvider()
|
|
||||||
authMethod, err := authProvider.GetAuthMethod(remoteURL)
|
|
||||||
if err != nil {
|
|
||||||
// Log the error but continue - some remotes might not need auth
|
|
||||||
fmt.Fprintf(os.Stderr, "Warning: authentication not available: %v\n", err)
|
|
||||||
fmt.Fprintf(os.Stderr, "Attempting fetch without authentication...\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Fetch from remote
|
|
||||||
fmt.Printf("Fetching from %s...\n", remoteName)
|
|
||||||
remote, err := remoteHelper.GetRemote(remoteName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get remote: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = remote.Fetch(&gogit.FetchOptions{
|
|
||||||
Auth: authMethod,
|
|
||||||
RefSpecs: []config.RefSpec{
|
|
||||||
config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/remotes/%s/%s",
|
|
||||||
currentWorkstream.BaseBranch, remoteName, currentWorkstream.BaseBranch)),
|
|
||||||
},
|
|
||||||
Progress: os.Stdout,
|
|
||||||
})
|
|
||||||
if err != nil && err != gogit.NoErrAlreadyUpToDate {
|
|
||||||
return fmt.Errorf("failed to fetch: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err == gogit.NoErrAlreadyUpToDate {
|
|
||||||
fmt.Println("Already up to date")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Get the updated base branch HEAD
|
|
||||||
gitBackend := git.NewGitBackend(gitRepo)
|
|
||||||
remoteRef := fmt.Sprintf("refs/remotes/%s/%s", remoteName, currentWorkstream.BaseBranch)
|
|
||||||
newBaseSHA, err := gitBackend.GetRef(remoteRef)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get remote base branch: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Build the commit stack from the workstream
|
|
||||||
stack := []string{}
|
|
||||||
for _, commit := range currentWorkstream.Commits {
|
|
||||||
stack = append(stack, commit.SHA)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Create rebase engine with rerere support
|
|
||||||
rebaseEngine := git.NewRebaseEngine(gitRepo, onyxPath, repoPath)
|
|
||||||
|
|
||||||
fmt.Printf("Rebasing %d commit(s) onto %s...\n", len(stack), newBaseSHA[:8])
|
|
||||||
|
|
||||||
// 8. Perform the rebase
|
|
||||||
result, err := rebaseEngine.RebaseStack(stack, newBaseSHA)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("rebase failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9. Handle rebase result
|
|
||||||
if !result.Success {
|
|
||||||
if len(result.ConflictingFiles) > 0 {
|
|
||||||
// Present conflicts to user
|
|
||||||
conflictResolver := rebaseEngine.GetConflictResolver()
|
|
||||||
conflictMsg := conflictResolver.PresentConflicts(result.ConflictingFiles)
|
|
||||||
fmt.Println(conflictMsg)
|
|
||||||
return fmt.Errorf("sync paused due to conflicts")
|
|
||||||
}
|
|
||||||
return fmt.Errorf("rebase failed: %s", result.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 10. Update workstream commits with new SHAs
|
|
||||||
if err := updateWorkstreamCommits(repo, currentWorkstream, result.RebasedCommits); err != nil {
|
|
||||||
return fmt.Errorf("failed to update workstream: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 11. Update the base commit metadata
|
|
||||||
currentWorkstream.Metadata["base_commit"] = newBaseSHA
|
|
||||||
wsCollection, err := storage.LoadWorkstreams(filepath.Join(onyxPath, "workstreams.json"))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load workstreams: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := storage.SaveWorkstreams(filepath.Join(onyxPath, "workstreams.json"), wsCollection); err != nil {
|
|
||||||
return fmt.Errorf("failed to save workstreams: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("✓ Rebased %d commit(s) successfully\n", len(result.RebasedCommits))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateWorkstreamCommits updates the workstream with new rebased commit SHAs
|
|
||||||
func updateWorkstreamCommits(repo *core.OnyxRepository, ws *models.Workstream, newSHAs []string) error {
|
|
||||||
if len(ws.Commits) != len(newSHAs) {
|
|
||||||
return fmt.Errorf("mismatch between old and new commit counts")
|
|
||||||
}
|
|
||||||
|
|
||||||
gitBackend := git.NewGitBackend(repo.GetGitRepo())
|
|
||||||
|
|
||||||
// Update each commit SHA and its branch ref
|
|
||||||
for i := range ws.Commits {
|
|
||||||
oldSHA := ws.Commits[i].SHA
|
|
||||||
newSHA := newSHAs[i]
|
|
||||||
|
|
||||||
// Update the commit SHA
|
|
||||||
ws.Commits[i].SHA = newSHA
|
|
||||||
|
|
||||||
// Update the branch ref to point to the new commit
|
|
||||||
branchRef := ws.Commits[i].BranchRef
|
|
||||||
if branchRef != "" {
|
|
||||||
if err := gitBackend.UpdateRef(branchRef, newSHA); err != nil {
|
|
||||||
return fmt.Errorf("failed to update ref %s: %w", branchRef, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf(" %s -> %s\n", oldSHA[:8], newSHA[:8])
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
@ -1,190 +0,0 @@
|
|||||||
package git
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/transport/ssh"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuthProvider handles authentication for Git operations
|
|
||||||
type AuthProvider struct {
|
|
||||||
cache map[string]transport.AuthMethod
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAuthProvider creates a new AuthProvider
|
|
||||||
func NewAuthProvider() *AuthProvider {
|
|
||||||
return &AuthProvider{
|
|
||||||
cache: make(map[string]transport.AuthMethod),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAuthMethod returns the appropriate authentication method for a URL
|
|
||||||
func (ap *AuthProvider) GetAuthMethod(url string) (transport.AuthMethod, error) {
|
|
||||||
// Check cache first
|
|
||||||
if auth, ok := ap.cache[url]; ok {
|
|
||||||
return auth, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var auth transport.AuthMethod
|
|
||||||
var err error
|
|
||||||
|
|
||||||
// Detect transport type from URL
|
|
||||||
if strings.HasPrefix(url, "git@") || strings.HasPrefix(url, "ssh://") {
|
|
||||||
// SSH authentication
|
|
||||||
auth, err = ap.getSSHAuth()
|
|
||||||
} else if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
|
|
||||||
// HTTPS authentication
|
|
||||||
auth, err = ap.getHTTPSAuth(url)
|
|
||||||
} else {
|
|
||||||
return nil, fmt.Errorf("unsupported URL scheme: %s", url)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the auth method
|
|
||||||
ap.cache[url] = auth
|
|
||||||
|
|
||||||
return auth, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getSSHAuth attempts to get SSH authentication
|
|
||||||
func (ap *AuthProvider) getSSHAuth() (transport.AuthMethod, error) {
|
|
||||||
// Try SSH agent first
|
|
||||||
auth, err := ssh.NewSSHAgentAuth("git")
|
|
||||||
if err == nil {
|
|
||||||
return auth, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to loading SSH keys from default locations
|
|
||||||
homeDir, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sshDir := filepath.Join(homeDir, ".ssh")
|
|
||||||
|
|
||||||
// Try common key files
|
|
||||||
keyFiles := []string{
|
|
||||||
"id_ed25519",
|
|
||||||
"id_rsa",
|
|
||||||
"id_ecdsa",
|
|
||||||
"id_dsa",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, keyFile := range keyFiles {
|
|
||||||
keyPath := filepath.Join(sshDir, keyFile)
|
|
||||||
if _, err := os.Stat(keyPath); err == nil {
|
|
||||||
// Try loading without passphrase first
|
|
||||||
auth, err := ssh.NewPublicKeysFromFile("git", keyPath, "")
|
|
||||||
if err == nil {
|
|
||||||
return auth, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// If that fails, it might need a passphrase
|
|
||||||
// For now, we'll skip passphrase-protected keys
|
|
||||||
// In the future, we could prompt for the passphrase
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("no SSH authentication method available (tried ssh-agent and ~/.ssh keys)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// getHTTPSAuth attempts to get HTTPS authentication
|
|
||||||
func (ap *AuthProvider) getHTTPSAuth(url string) (transport.AuthMethod, error) {
|
|
||||||
// Try git credential helper first
|
|
||||||
auth, err := ap.tryGitCredentialHelper(url)
|
|
||||||
if err == nil && auth != nil {
|
|
||||||
return auth, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try environment variables
|
|
||||||
username := os.Getenv("GIT_USERNAME")
|
|
||||||
password := os.Getenv("GIT_PASSWORD")
|
|
||||||
token := os.Getenv("GIT_TOKEN")
|
|
||||||
|
|
||||||
if token != "" {
|
|
||||||
// Use token as password (common for GitHub, GitLab, etc.)
|
|
||||||
return &http.BasicAuth{
|
|
||||||
Username: "git", // Token usually goes in password field
|
|
||||||
Password: token,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if username != "" && password != "" {
|
|
||||||
return &http.BasicAuth{
|
|
||||||
Username: username,
|
|
||||||
Password: password,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// No credentials available - return nil to let go-git try anonymous
|
|
||||||
// (this will fail for private repos but that's expected)
|
|
||||||
return nil, fmt.Errorf("no HTTPS credentials available (tried git credential helper and environment variables)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// tryGitCredentialHelper attempts to use git's credential helper
|
|
||||||
func (ap *AuthProvider) tryGitCredentialHelper(url string) (*http.BasicAuth, error) {
|
|
||||||
// Build the credential request
|
|
||||||
input := fmt.Sprintf("protocol=https\nhost=%s\n\n", extractHost(url))
|
|
||||||
|
|
||||||
// Call git credential fill
|
|
||||||
cmd := exec.Command("git", "credential", "fill")
|
|
||||||
cmd.Stdin = strings.NewReader(input)
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("git credential helper failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the output
|
|
||||||
lines := strings.Split(string(output), "\n")
|
|
||||||
auth := &http.BasicAuth{}
|
|
||||||
|
|
||||||
for _, line := range lines {
|
|
||||||
parts := strings.SplitN(line, "=", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
key := strings.TrimSpace(parts[0])
|
|
||||||
value := strings.TrimSpace(parts[1])
|
|
||||||
|
|
||||||
switch key {
|
|
||||||
case "username":
|
|
||||||
auth.Username = value
|
|
||||||
case "password":
|
|
||||||
auth.Password = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if auth.Username == "" || auth.Password == "" {
|
|
||||||
return nil, fmt.Errorf("git credential helper did not return username and password")
|
|
||||||
}
|
|
||||||
|
|
||||||
return auth, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractHost extracts the host from a URL
|
|
||||||
func extractHost(url string) string {
|
|
||||||
// Remove protocol
|
|
||||||
url = strings.TrimPrefix(url, "https://")
|
|
||||||
url = strings.TrimPrefix(url, "http://")
|
|
||||||
|
|
||||||
// Extract host (everything before the first /)
|
|
||||||
parts := strings.SplitN(url, "/", 2)
|
|
||||||
return parts[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearCache clears the authentication cache
|
|
||||||
func (ap *AuthProvider) ClearCache() {
|
|
||||||
ap.cache = make(map[string]transport.AuthMethod)
|
|
||||||
}
|
|
@ -1,187 +0,0 @@
|
|||||||
package git
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-git/go-git/v5"
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/format/index"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ConflictInfo represents information about a merge conflict
|
|
||||||
type ConflictInfo struct {
|
|
||||||
FilePath string
|
|
||||||
OursHash string
|
|
||||||
TheirsHash string
|
|
||||||
BaseHash string
|
|
||||||
HasConflict bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConflictResolver handles conflict detection and resolution guidance
|
|
||||||
type ConflictResolver struct {
|
|
||||||
repo *git.Repository
|
|
||||||
repoPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewConflictResolver creates a new ConflictResolver instance
|
|
||||||
func NewConflictResolver(repo *git.Repository, repoPath string) *ConflictResolver {
|
|
||||||
return &ConflictResolver{
|
|
||||||
repo: repo,
|
|
||||||
repoPath: repoPath,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DetectConflicts checks for merge conflicts in the working tree
|
|
||||||
func (cr *ConflictResolver) DetectConflicts() ([]ConflictInfo, error) {
|
|
||||||
idx, err := cr.repo.Storer.Index()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read index: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
conflicts := []ConflictInfo{}
|
|
||||||
|
|
||||||
// Check for conflicts in the index
|
|
||||||
for _, entry := range idx.Entries {
|
|
||||||
// Stage > 0 indicates a conflict
|
|
||||||
if entry.Stage != 0 {
|
|
||||||
// Find all stages for this file
|
|
||||||
conflict := cr.findConflictStages(idx, entry.Name)
|
|
||||||
if conflict.HasConflict {
|
|
||||||
conflicts = append(conflicts, conflict)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return conflicts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// findConflictStages finds all conflict stages for a file
|
|
||||||
func (cr *ConflictResolver) findConflictStages(idx *index.Index, path string) ConflictInfo {
|
|
||||||
conflict := ConflictInfo{
|
|
||||||
FilePath: path,
|
|
||||||
HasConflict: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entry := range idx.Entries {
|
|
||||||
if entry.Name == path {
|
|
||||||
switch entry.Stage {
|
|
||||||
case 1:
|
|
||||||
// Base/common ancestor
|
|
||||||
conflict.BaseHash = entry.Hash.String()
|
|
||||||
conflict.HasConflict = true
|
|
||||||
case 2:
|
|
||||||
// Ours (current branch)
|
|
||||||
conflict.OursHash = entry.Hash.String()
|
|
||||||
conflict.HasConflict = true
|
|
||||||
case 3:
|
|
||||||
// Theirs (incoming branch)
|
|
||||||
conflict.TheirsHash = entry.Hash.String()
|
|
||||||
conflict.HasConflict = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return conflict
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasConflicts checks if there are any conflicts in the working tree
|
|
||||||
func (cr *ConflictResolver) HasConflicts() (bool, error) {
|
|
||||||
conflicts, err := cr.DetectConflicts()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return len(conflicts) > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PresentConflicts presents conflicts to the user with clear guidance
|
|
||||||
func (cr *ConflictResolver) PresentConflicts(conflicts []ConflictInfo) string {
|
|
||||||
if len(conflicts) == 0 {
|
|
||||||
return "No conflicts detected."
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
|
|
||||||
sb.WriteString(fmt.Sprintf("\n%s\n", strings.Repeat("=", 70)))
|
|
||||||
sb.WriteString(fmt.Sprintf(" MERGE CONFLICTS DETECTED (%d file(s))\n", len(conflicts)))
|
|
||||||
sb.WriteString(fmt.Sprintf("%s\n\n", strings.Repeat("=", 70)))
|
|
||||||
|
|
||||||
for i, conflict := range conflicts {
|
|
||||||
sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, conflict.FilePath))
|
|
||||||
sb.WriteString(fmt.Sprintf(" Base: %s\n", conflict.BaseHash[:8]))
|
|
||||||
sb.WriteString(fmt.Sprintf(" Ours: %s\n", conflict.OursHash[:8]))
|
|
||||||
sb.WriteString(fmt.Sprintf(" Theirs: %s\n", conflict.TheirsHash[:8]))
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString("To resolve conflicts:\n")
|
|
||||||
sb.WriteString(" 1. Edit the conflicting files to resolve conflicts\n")
|
|
||||||
sb.WriteString(" 2. Look for conflict markers: <<<<<<<, =======, >>>>>>>\n")
|
|
||||||
sb.WriteString(" 3. Remove the conflict markers after resolving\n")
|
|
||||||
sb.WriteString(" 4. Stage the resolved files: git add <file>\n")
|
|
||||||
sb.WriteString(" 5. Continue the rebase: git rebase --continue\n")
|
|
||||||
sb.WriteString(fmt.Sprintf("%s\n", strings.Repeat("=", 70)))
|
|
||||||
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConflictMarkers reads a file and extracts conflict marker sections
|
|
||||||
func (cr *ConflictResolver) GetConflictMarkers(filePath string) ([]ConflictMarker, error) {
|
|
||||||
fullPath := filepath.Join(cr.repoPath, filePath)
|
|
||||||
file, err := os.Open(fullPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to open file: %w", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
markers := []ConflictMarker{}
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
lineNum := 0
|
|
||||||
var currentMarker *ConflictMarker
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
lineNum++
|
|
||||||
line := scanner.Text()
|
|
||||||
|
|
||||||
if strings.HasPrefix(line, "<<<<<<<") {
|
|
||||||
// Start of conflict
|
|
||||||
currentMarker = &ConflictMarker{
|
|
||||||
FilePath: filePath,
|
|
||||||
StartLine: lineNum,
|
|
||||||
}
|
|
||||||
} else if strings.HasPrefix(line, "=======") && currentMarker != nil {
|
|
||||||
currentMarker.SeparatorLine = lineNum
|
|
||||||
} else if strings.HasPrefix(line, ">>>>>>>") && currentMarker != nil {
|
|
||||||
currentMarker.EndLine = lineNum
|
|
||||||
markers = append(markers, *currentMarker)
|
|
||||||
currentMarker = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("error reading file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return markers, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConflictMarker represents a conflict marker section in a file
|
|
||||||
type ConflictMarker struct {
|
|
||||||
FilePath string
|
|
||||||
StartLine int
|
|
||||||
SeparatorLine int
|
|
||||||
EndLine int
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsFileConflicted checks if a specific file has conflict markers
|
|
||||||
func (cr *ConflictResolver) IsFileConflicted(filePath string) (bool, error) {
|
|
||||||
markers, err := cr.GetConflictMarkers(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return len(markers) > 0, nil
|
|
||||||
}
|
|
@ -1,207 +0,0 @@
|
|||||||
package git
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/go-git/go-git/v5"
|
|
||||||
"github.com/go-git/go-git/v5/plumbing"
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/object"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RebaseEngine handles stacked rebase operations with rerere support
|
|
||||||
type RebaseEngine struct {
|
|
||||||
repo *git.Repository
|
|
||||||
backend *GitBackend
|
|
||||||
rerere *RerereManager
|
|
||||||
conflictResolver *ConflictResolver
|
|
||||||
repoPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRebaseEngine creates a new RebaseEngine instance
|
|
||||||
func NewRebaseEngine(repo *git.Repository, onyxPath, repoPath string) *RebaseEngine {
|
|
||||||
return &RebaseEngine{
|
|
||||||
repo: repo,
|
|
||||||
backend: NewGitBackend(repo),
|
|
||||||
rerere: NewRerereManager(repo, onyxPath, repoPath),
|
|
||||||
conflictResolver: NewConflictResolver(repo, repoPath),
|
|
||||||
repoPath: repoPath,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RebaseStackResult contains the result of a stack rebase operation
|
|
||||||
type RebaseStackResult struct {
|
|
||||||
Success bool
|
|
||||||
RebasedCommits []string
|
|
||||||
FailedCommit string
|
|
||||||
ConflictingFiles []ConflictInfo
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
// RebaseStack rebases a stack of commits onto a new base
|
|
||||||
func (re *RebaseEngine) RebaseStack(stack []string, onto string) (*RebaseStackResult, error) {
|
|
||||||
result := &RebaseStackResult{
|
|
||||||
Success: true,
|
|
||||||
RebasedCommits: []string{},
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(stack) == 0 {
|
|
||||||
result.Message = "No commits to rebase"
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate onto commit exists
|
|
||||||
_, err := re.backend.GetCommit(onto)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid onto commit %s: %w", onto, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
currentBase := onto
|
|
||||||
|
|
||||||
// Rebase each commit in the stack sequentially
|
|
||||||
for i, commitSHA := range stack {
|
|
||||||
// Get the commit object
|
|
||||||
commit, err := re.backend.GetCommit(commitSHA)
|
|
||||||
if err != nil {
|
|
||||||
result.Success = false
|
|
||||||
result.FailedCommit = commitSHA
|
|
||||||
result.Message = fmt.Sprintf("Failed to get commit %s: %v", commitSHA, err)
|
|
||||||
return result, fmt.Errorf("failed to get commit: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rebase this commit onto the current base
|
|
||||||
newCommitSHA, err := re.rebaseSingleCommit(commit, currentBase)
|
|
||||||
if err != nil {
|
|
||||||
// Check if it's a conflict error
|
|
||||||
conflicts, detectErr := re.conflictResolver.DetectConflicts()
|
|
||||||
if detectErr == nil && len(conflicts) > 0 {
|
|
||||||
result.Success = false
|
|
||||||
result.FailedCommit = commitSHA
|
|
||||||
result.ConflictingFiles = conflicts
|
|
||||||
result.Message = fmt.Sprintf("Conflicts detected while rebasing commit %d/%d (%s)",
|
|
||||||
i+1, len(stack), commitSHA[:8])
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Success = false
|
|
||||||
result.FailedCommit = commitSHA
|
|
||||||
result.Message = fmt.Sprintf("Failed to rebase commit %s: %v", commitSHA, err)
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
result.RebasedCommits = append(result.RebasedCommits, newCommitSHA)
|
|
||||||
currentBase = newCommitSHA
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Message = fmt.Sprintf("Successfully rebased %d commit(s)", len(stack))
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// rebaseSingleCommit rebases a single commit onto a new parent
|
|
||||||
func (re *RebaseEngine) rebaseSingleCommit(commit *object.Commit, newParent string) (string, error) {
|
|
||||||
// Record conflicts before attempting rebase (for rerere)
|
|
||||||
if err := re.rerere.RecordConflicts(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Warning: failed to record conflicts: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the commit's tree
|
|
||||||
tree, err := commit.Tree()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get commit tree: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there are any changes between the trees
|
|
||||||
// For simplicity, we'll create a new commit with the same tree content
|
|
||||||
// In a more sophisticated implementation, we would perform a three-way merge
|
|
||||||
|
|
||||||
// Try to apply rerere resolutions first
|
|
||||||
if re.rerere.IsEnabled() {
|
|
||||||
applied, err := re.rerere.ApplyResolutions()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Warning: failed to apply rerere resolutions: %v\n", err)
|
|
||||||
} else if applied > 0 {
|
|
||||||
fmt.Printf("Applied %d rerere resolution(s)\n", applied)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform a simple rebase by creating a new commit with the same tree but new parent
|
|
||||||
// This is a simplified implementation - a full implementation would handle merges
|
|
||||||
newCommitSHA, err := re.backend.CreateCommit(
|
|
||||||
tree.Hash.String(),
|
|
||||||
newParent,
|
|
||||||
commit.Message,
|
|
||||||
commit.Author.Name,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create rebased commit: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newCommitSHA, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RebaseCommit rebases a single commit onto a new parent (public API)
|
|
||||||
func (re *RebaseEngine) RebaseCommit(commitSHA, newParent string) (string, error) {
|
|
||||||
commit, err := re.backend.GetCommit(commitSHA)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get commit: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return re.rebaseSingleCommit(commit, newParent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContinueRebase continues a rebase after conflict resolution
|
|
||||||
func (re *RebaseEngine) ContinueRebase(stack []string, fromIndex int, onto string) (*RebaseStackResult, error) {
|
|
||||||
// Record the resolution for rerere
|
|
||||||
if err := re.rerere.RecordResolution(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Warning: failed to record resolution: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if conflicts are resolved
|
|
||||||
hasConflicts, err := re.conflictResolver.HasConflicts()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to check for conflicts: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasConflicts {
|
|
||||||
return &RebaseStackResult{
|
|
||||||
Success: false,
|
|
||||||
Message: "Conflicts still exist. Please resolve all conflicts before continuing.",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continue rebasing from the next commit
|
|
||||||
remainingStack := stack[fromIndex:]
|
|
||||||
return re.RebaseStack(remainingStack, onto)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AbortRebase aborts a rebase operation and returns to the original state
|
|
||||||
func (re *RebaseEngine) AbortRebase(originalHead string) error {
|
|
||||||
// Update HEAD to original commit
|
|
||||||
hash := plumbing.NewHash(originalHead)
|
|
||||||
|
|
||||||
worktree, err := re.repo.Worktree()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get worktree: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Checkout the original HEAD
|
|
||||||
err = worktree.Checkout(&git.CheckoutOptions{
|
|
||||||
Hash: hash,
|
|
||||||
Force: true,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to checkout original HEAD: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRerereManager returns the rerere manager
|
|
||||||
func (re *RebaseEngine) GetRerereManager() *RerereManager {
|
|
||||||
return re.rerere
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConflictResolver returns the conflict resolver
|
|
||||||
func (re *RebaseEngine) GetConflictResolver() *ConflictResolver {
|
|
||||||
return re.conflictResolver
|
|
||||||
}
|
|
@ -1,106 +0,0 @@
|
|||||||
package git
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/go-git/go-git/v5"
|
|
||||||
"github.com/go-git/go-git/v5/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RemoteHelper provides utilities for working with Git remotes
|
|
||||||
type RemoteHelper struct {
|
|
||||||
repo *git.Repository
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRemoteHelper creates a new RemoteHelper instance
|
|
||||||
func NewRemoteHelper(repo *git.Repository) *RemoteHelper {
|
|
||||||
return &RemoteHelper{repo: repo}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRemote retrieves a remote by name, defaults to "origin" if name is empty
|
|
||||||
func (rh *RemoteHelper) GetRemote(name string) (*git.Remote, error) {
|
|
||||||
if name == "" {
|
|
||||||
name = "origin"
|
|
||||||
}
|
|
||||||
|
|
||||||
remote, err := rh.repo.Remote(name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("remote '%s' not found: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return remote, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListRemotes returns all configured remotes
|
|
||||||
func (rh *RemoteHelper) ListRemotes() ([]*git.Remote, error) {
|
|
||||||
remotes, err := rh.repo.Remotes()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to list remotes: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return remotes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateRemote checks if a remote exists and is properly configured
|
|
||||||
func (rh *RemoteHelper) ValidateRemote(name string) error {
|
|
||||||
if name == "" {
|
|
||||||
name = "origin"
|
|
||||||
}
|
|
||||||
|
|
||||||
remote, err := rh.GetRemote(name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if remote has URLs configured
|
|
||||||
cfg := remote.Config()
|
|
||||||
if len(cfg.URLs) == 0 {
|
|
||||||
return fmt.Errorf("remote '%s' has no URLs configured", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultRemoteName returns the default remote name (origin)
|
|
||||||
func (rh *RemoteHelper) GetDefaultRemoteName() string {
|
|
||||||
return "origin"
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRemoteURL returns the fetch URL for a remote
|
|
||||||
func (rh *RemoteHelper) GetRemoteURL(name string) (string, error) {
|
|
||||||
if name == "" {
|
|
||||||
name = "origin"
|
|
||||||
}
|
|
||||||
|
|
||||||
remote, err := rh.GetRemote(name)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := remote.Config()
|
|
||||||
if len(cfg.URLs) == 0 {
|
|
||||||
return "", fmt.Errorf("remote '%s' has no URLs configured", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg.URLs[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRemoteConfig returns the configuration for a remote
|
|
||||||
func (rh *RemoteHelper) GetRemoteConfig(name string) (*config.RemoteConfig, error) {
|
|
||||||
if name == "" {
|
|
||||||
name = "origin"
|
|
||||||
}
|
|
||||||
|
|
||||||
remote, err := rh.GetRemote(name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return remote.Config(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasRemote checks if a remote with the given name exists
|
|
||||||
func (rh *RemoteHelper) HasRemote(name string) bool {
|
|
||||||
_, err := rh.repo.Remote(name)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
@ -1,286 +0,0 @@
|
|||||||
package git
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha1"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-git/go-git/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RerereManager manages git rerere (reuse recorded resolution) functionality
|
|
||||||
type RerereManager struct {
|
|
||||||
repo *git.Repository
|
|
||||||
cachePath string
|
|
||||||
enabled bool
|
|
||||||
repoPath string
|
|
||||||
conflictResolver *ConflictResolver
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRerereManager creates a new RerereManager instance
|
|
||||||
func NewRerereManager(repo *git.Repository, onyxPath, repoPath string) *RerereManager {
|
|
||||||
cachePath := filepath.Join(onyxPath, "rerere_cache")
|
|
||||||
|
|
||||||
return &RerereManager{
|
|
||||||
repo: repo,
|
|
||||||
cachePath: cachePath,
|
|
||||||
enabled: true,
|
|
||||||
repoPath: repoPath,
|
|
||||||
conflictResolver: NewConflictResolver(repo, repoPath),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable enables rerere functionality
|
|
||||||
func (rm *RerereManager) Enable() {
|
|
||||||
rm.enabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable disables rerere functionality
|
|
||||||
func (rm *RerereManager) Disable() {
|
|
||||||
rm.enabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEnabled returns whether rerere is enabled
|
|
||||||
func (rm *RerereManager) IsEnabled() bool {
|
|
||||||
return rm.enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordConflicts records current conflicts for future resolution
|
|
||||||
func (rm *RerereManager) RecordConflicts() error {
|
|
||||||
if !rm.enabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
conflicts, err := rm.conflictResolver.DetectConflicts()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to detect conflicts: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(conflicts) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// For each conflict, create a cache entry
|
|
||||||
for _, conflict := range conflicts {
|
|
||||||
if err := rm.recordConflict(conflict); err != nil {
|
|
||||||
// Log error but continue with other conflicts
|
|
||||||
fmt.Fprintf(os.Stderr, "Warning: failed to record conflict for %s: %v\n", conflict.FilePath, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordConflict records a single conflict
|
|
||||||
func (rm *RerereManager) recordConflict(conflict ConflictInfo) error {
|
|
||||||
// Read the conflicted file
|
|
||||||
fullPath := filepath.Join(rm.repoPath, conflict.FilePath)
|
|
||||||
content, err := os.ReadFile(fullPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read conflicted file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a unique ID for this conflict pattern
|
|
||||||
conflictID := rm.generateConflictID(content)
|
|
||||||
|
|
||||||
// Create cache directory for this conflict
|
|
||||||
conflictDir := filepath.Join(rm.cachePath, conflictID)
|
|
||||||
if err := os.MkdirAll(conflictDir, 0755); err != nil {
|
|
||||||
return fmt.Errorf("failed to create conflict cache directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the preimage (conflict state)
|
|
||||||
preimagePath := filepath.Join(conflictDir, "preimage")
|
|
||||||
if err := os.WriteFile(preimagePath, content, 0644); err != nil {
|
|
||||||
return fmt.Errorf("failed to write preimage: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save metadata
|
|
||||||
metadataPath := filepath.Join(conflictDir, "metadata")
|
|
||||||
metadata := fmt.Sprintf("file=%s\nbase=%s\nours=%s\ntheirs=%s\n",
|
|
||||||
conflict.FilePath, conflict.BaseHash, conflict.OursHash, conflict.TheirsHash)
|
|
||||||
if err := os.WriteFile(metadataPath, []byte(metadata), 0644); err != nil {
|
|
||||||
return fmt.Errorf("failed to write metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordResolution records the resolution for previously recorded conflicts
|
|
||||||
func (rm *RerereManager) RecordResolution() error {
|
|
||||||
if !rm.enabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find all recorded conflicts
|
|
||||||
entries, err := os.ReadDir(rm.cachePath)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("failed to read rerere cache: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
conflictID := entry.Name()
|
|
||||||
if err := rm.recordResolutionForConflict(conflictID); err != nil {
|
|
||||||
// Log error but continue
|
|
||||||
fmt.Fprintf(os.Stderr, "Warning: failed to record resolution for %s: %v\n", conflictID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordResolutionForConflict records the resolution for a specific conflict
|
|
||||||
func (rm *RerereManager) recordResolutionForConflict(conflictID string) error {
|
|
||||||
conflictDir := filepath.Join(rm.cachePath, conflictID)
|
|
||||||
|
|
||||||
// Read metadata to get file path
|
|
||||||
metadataPath := filepath.Join(conflictDir, "metadata")
|
|
||||||
metadataContent, err := os.ReadFile(metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse file path from metadata
|
|
||||||
filePath := ""
|
|
||||||
for _, line := range strings.Split(string(metadataContent), "\n") {
|
|
||||||
if strings.HasPrefix(line, "file=") {
|
|
||||||
filePath = strings.TrimPrefix(line, "file=")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if filePath == "" {
|
|
||||||
return fmt.Errorf("file path not found in metadata")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if file still has conflicts
|
|
||||||
fullPath := filepath.Join(rm.repoPath, filePath)
|
|
||||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
|
||||||
// File was deleted or doesn't exist, skip
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
hasConflicts, err := rm.conflictResolver.IsFileConflicted(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check if file is conflicted: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasConflicts {
|
|
||||||
// Still has conflicts, not resolved yet
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the resolved content
|
|
||||||
resolvedContent, err := os.ReadFile(fullPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read resolved file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the postimage (resolved state)
|
|
||||||
postimagePath := filepath.Join(conflictDir, "postimage")
|
|
||||||
if err := os.WriteFile(postimagePath, resolvedContent, 0644); err != nil {
|
|
||||||
return fmt.Errorf("failed to write postimage: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyResolutions applies previously recorded resolutions to current conflicts
|
|
||||||
func (rm *RerereManager) ApplyResolutions() (int, error) {
|
|
||||||
if !rm.enabled {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
conflicts, err := rm.conflictResolver.DetectConflicts()
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to detect conflicts: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
appliedCount := 0
|
|
||||||
|
|
||||||
for _, conflict := range conflicts {
|
|
||||||
// Read the conflicted file
|
|
||||||
fullPath := filepath.Join(rm.repoPath, conflict.FilePath)
|
|
||||||
content, err := os.ReadFile(fullPath)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate conflict ID
|
|
||||||
conflictID := rm.generateConflictID(content)
|
|
||||||
|
|
||||||
// Check if we have a resolution for this conflict
|
|
||||||
postimagePath := filepath.Join(rm.cachePath, conflictID, "postimage")
|
|
||||||
if _, err := os.Stat(postimagePath); os.IsNotExist(err) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply the resolution
|
|
||||||
resolvedContent, err := os.ReadFile(postimagePath)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(fullPath, resolvedContent, 0644); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
appliedCount++
|
|
||||||
}
|
|
||||||
|
|
||||||
return appliedCount, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateConflictID generates a unique ID for a conflict pattern
|
|
||||||
func (rm *RerereManager) generateConflictID(content []byte) string {
|
|
||||||
// Normalize conflict content by removing variable parts
|
|
||||||
normalized := rm.normalizeConflict(content)
|
|
||||||
|
|
||||||
// Generate SHA1 hash
|
|
||||||
hash := sha1.New()
|
|
||||||
io.WriteString(hash, normalized)
|
|
||||||
return hex.EncodeToString(hash.Sum(nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeConflict normalizes conflict content for matching
|
|
||||||
func (rm *RerereManager) normalizeConflict(content []byte) string {
|
|
||||||
// Convert to string
|
|
||||||
str := string(content)
|
|
||||||
|
|
||||||
// Remove commit hashes from conflict markers (they vary)
|
|
||||||
lines := strings.Split(str, "\n")
|
|
||||||
var normalized []string
|
|
||||||
|
|
||||||
for _, line := range lines {
|
|
||||||
if strings.HasPrefix(line, "<<<<<<<") {
|
|
||||||
normalized = append(normalized, "<<<<<<<")
|
|
||||||
} else if strings.HasPrefix(line, ">>>>>>>") {
|
|
||||||
normalized = append(normalized, ">>>>>>>")
|
|
||||||
} else {
|
|
||||||
normalized = append(normalized, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(normalized, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearCache clears the rerere cache
|
|
||||||
func (rm *RerereManager) ClearCache() error {
|
|
||||||
return os.RemoveAll(rm.cachePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCachePath returns the path to the rerere cache
|
|
||||||
func (rm *RerereManager) GetCachePath() string {
|
|
||||||
return rm.cachePath
|
|
||||||
}
|
|
@ -1,49 +1,54 @@
|
|||||||
## Milestone 4: Synchronization and Remote Interaction ✓ COMPLETE
|
## Milestone 3: Workstreams ✓ COMPLETE
|
||||||
|
|
||||||
**Completion Date:** October 14, 2025
|
### Workstream Data Model
|
||||||
**Status:** All features implemented and tested
|
|
||||||
**Development Method:** Dogfooded using Onyx itself
|
|
||||||
|
|
||||||
### Rebase Engine ✓
|
28. ✓ **Implement workstream storage** (`internal/storage/workstreams.go`)
|
||||||
|
- `LoadWorkstreams(path string) (*WorkstreamCollection, error)`
|
||||||
|
- `SaveWorkstreams(path string, collection *WorkstreamCollection) error`
|
||||||
|
|
||||||
34. **Implement stacked rebase** (`internal/git/rebase.go`) ✓
|
29. ✓ **Create workstream manager** (`internal/core/workstream_manager.go`)
|
||||||
- Implemented RebaseStack function
|
- `CreateWorkstream(name string, base string) error`
|
||||||
- Sequential rebase with conflict handling
|
- `GetCurrentWorkstream() (*Workstream, error)`
|
||||||
- Integration with rerere for automatic conflict resolution
|
- `SwitchWorkstream(name string) error`
|
||||||
- Support for rebase continuation and abort
|
- `ListWorkstreams() ([]*Workstream, error)`
|
||||||
|
- `AddCommitToWorkstream(sha, message string) error`
|
||||||
|
- `GetCurrentWorkstreamName() (string, error)`
|
||||||
|
|
||||||
35. **Integrate rerere** (`internal/git/rerere.go`) ✓
|
### Workstream Commands
|
||||||
- Configured rerere cache location (.onx/rerere_cache)
|
|
||||||
- Enabled rerere for rebase operations
|
|
||||||
- Implemented conflict detection and recording
|
|
||||||
- Apply recorded resolutions automatically
|
|
||||||
|
|
||||||
36. **Create conflict resolution UI** (`internal/git/conflicts.go`) ✓
|
30. ✓ **Implement onx new** (`internal/commands/new.go`)
|
||||||
- Detect merge conflicts via index stages
|
- Validates workstream name
|
||||||
- Present clear conflict markers with file paths
|
- Creates workstream entry
|
||||||
- Guide user through resolution process
|
- Sets as current workstream
|
||||||
- Record resolutions for rerere
|
- Updates workspace to base commit
|
||||||
|
- Logs to oplog with transaction wrapper
|
||||||
|
- Provides helpful next-step guidance
|
||||||
|
|
||||||
### Sync and Push Commands ✓
|
31. ✓ **Implement onx list** (`internal/commands/list.go`)
|
||||||
|
- Reads workstreams.json via storage layer
|
||||||
|
- Formats output with current indicator (*)
|
||||||
|
- Shows commit count per workstream
|
||||||
|
- Color-coded output for status (active=green, merged/abandoned/archived=gray)
|
||||||
|
- `--all` flag to show non-active workstreams
|
||||||
|
- Alias: `ls`
|
||||||
|
|
||||||
37. **Implement onx sync** (`internal/commands/sync.go`) ✓
|
32. ✓ **Implement onx switch** (`internal/commands/switch.go`)
|
||||||
- Begin oplog transaction for undo support
|
- Validates target workstream exists
|
||||||
- Fetch from remote (origin by default)
|
- Checkouts latest commit in target workstream
|
||||||
- Get workstream commit stack
|
- Updates current_workstream pointer
|
||||||
- Sequential rebase with rerere support
|
- Logs to oplog with transaction wrapper
|
||||||
- Handle and present conflicts clearly
|
- Shows helpful info about target workstream
|
||||||
- Update workstreams.json with new SHAs
|
|
||||||
- Finalize oplog transaction
|
|
||||||
|
|
||||||
38. **Implement onx push** (`internal/commands/push.go`) ✓
|
33. ✓ **Add workstream validation**
|
||||||
- Get all branches in current workstream
|
- Validates workstream names (alphanumeric, hyphens, underscores, slashes only)
|
||||||
- Push each workstream branch to remote
|
- Prevents duplicate workstream names
|
||||||
- Support for force push flag
|
- Prevents reserved names (HEAD, main, master, etc.)
|
||||||
- Progress reporting for each branch
|
- Integrated in `ValidateWorkstreamName()` function
|
||||||
- Clear summary of pushed branches
|
|
||||||
|
### Integration & Refactoring
|
||||||
|
|
||||||
|
- ✓ Refactored `onx save` to use WorkstreamManager
|
||||||
|
- ✓ All commands properly wired in `cmd/onx/main.go`
|
||||||
|
- ✓ All tests passing
|
||||||
|
- ✓ Build successful
|
||||||
|
|
||||||
39. **Add remote configuration** (`internal/git/remote.go`) ✓
|
|
||||||
- Read git remote configuration
|
|
||||||
- Support multiple remotes
|
|
||||||
- Default to "origin"
|
|
||||||
- Validate remote existence and URLs
|
|
||||||
|
@ -1,3 +1,58 @@
|
|||||||
|
## Milestone 4: Synchronization and Remote Interaction
|
||||||
|
|
||||||
|
### Rebase Engine
|
||||||
|
|
||||||
|
34. **Implement stacked rebase** (`internal/git/rebase.go`)
|
||||||
|
```go
|
||||||
|
func RebaseStack(repo *Repository, stack []string,
|
||||||
|
onto string) error {
|
||||||
|
// 1. Rebase first commit onto target
|
||||||
|
// 2. For each subsequent commit:
|
||||||
|
// - Rebase onto previous
|
||||||
|
// - Handle conflicts
|
||||||
|
// 3. Update all branch refs
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
35. **Integrate rerere** (`internal/git/rerere.go`)
|
||||||
|
- Configure rerere cache location (.onx/rerere_cache)
|
||||||
|
- Enable rerere for rebase operations
|
||||||
|
- Implement conflict detection
|
||||||
|
- Apply recorded resolutions automatically
|
||||||
|
|
||||||
|
36. **Create conflict resolution UI** (`internal/git/conflicts.go`)
|
||||||
|
- Detect merge conflicts
|
||||||
|
- Present clear conflict markers
|
||||||
|
- Guide user through resolution
|
||||||
|
- Record resolution for rerere
|
||||||
|
|
||||||
|
### Sync and Push Commands
|
||||||
|
|
||||||
|
37. **Implement onx sync** (`internal/commands/sync.go`)
|
||||||
|
```go
|
||||||
|
func Sync(repo *Repository) error {
|
||||||
|
// 1. Begin oplog transaction
|
||||||
|
// 2. Fetch from origin
|
||||||
|
// 3. Get workstream stack
|
||||||
|
// 4. Sequential rebase with rerere
|
||||||
|
// 5. Handle conflicts
|
||||||
|
// 6. Update workstreams.json
|
||||||
|
// 7. Finalize oplog transaction
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
38. **Implement onx push** (`internal/commands/push.go`)
|
||||||
|
- Get all branches in current workstream
|
||||||
|
- Push each branch to remote
|
||||||
|
- Handle authentication (SSH/HTTPS)
|
||||||
|
- Report progress for each branch
|
||||||
|
|
||||||
|
39. **Add remote configuration**
|
||||||
|
- Read git remote configuration
|
||||||
|
- Support multiple remotes
|
||||||
|
- Default to "origin"
|
||||||
|
- Validate remote existence
|
||||||
|
|
||||||
## Testing Infrastructure
|
## Testing Infrastructure
|
||||||
|
|
||||||
40. **Create test utilities** (`internal/testutil/`)
|
40. **Create test utilities** (`internal/testutil/`)
|
||||||
|
Reference in New Issue
Block a user