Compare commits
2 Commits
onyx/works
...
milestone-
Author | SHA1 | Date | |
---|---|---|---|
98e51d2ecf | |||
c5c2ee9516 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -32,3 +32,4 @@ go.work.sum
|
||||
# .vscode/
|
||||
|
||||
bin/
|
||||
.onx/
|
||||
|
66
CLAUDE.md
66
CLAUDE.md
@ -105,8 +105,55 @@ Background process using fsnotify for continuous snapshot creation.
|
||||
| `onx switch <name>` | Switch workstreams | `git checkout` |
|
||||
| `onx sync` | Update with remote | `git pull --rebase` |
|
||||
| `onx push` | Push workstream | `git push` |
|
||||
| `onx push --stacked` | Push stacked diffs | N/A (advanced) |
|
||||
| `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
|
||||
|
||||
This is currently a planning/prototype phase. The codebase contains:
|
||||
@ -117,6 +164,25 @@ This is currently a planning/prototype phase. The codebase contains:
|
||||
|
||||
## 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
|
||||
- Follow Go conventions and idioms
|
||||
- Use structured logging (planned: zap or logrus)
|
||||
|
66
README.md
66
README.md
@ -66,6 +66,72 @@ onx push
|
||||
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
|
||||
|
||||
### Building
|
||||
|
@ -29,6 +29,8 @@ log for universal undo functionality.`,
|
||||
rootCmd.AddCommand(commands.NewNewCmd())
|
||||
rootCmd.AddCommand(commands.NewListCmd())
|
||||
rootCmd.AddCommand(commands.NewSwitchCmd())
|
||||
rootCmd.AddCommand(commands.NewSyncCmd())
|
||||
rootCmd.AddCommand(commands.NewPushCmd())
|
||||
|
||||
// Execute the root command
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
|
287
internal/commands/push.go
Normal file
287
internal/commands/push.go
Normal file
@ -0,0 +1,287 @@
|
||||
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
|
||||
}
|
222
internal/commands/sync.go
Normal file
222
internal/commands/sync.go
Normal file
@ -0,0 +1,222 @@
|
||||
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
|
||||
}
|
190
internal/git/auth.go
Normal file
190
internal/git/auth.go
Normal file
@ -0,0 +1,190 @@
|
||||
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)
|
||||
}
|
187
internal/git/conflicts.go
Normal file
187
internal/git/conflicts.go
Normal file
@ -0,0 +1,187 @@
|
||||
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
|
||||
}
|
207
internal/git/rebase.go
Normal file
207
internal/git/rebase.go
Normal file
@ -0,0 +1,207 @@
|
||||
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
|
||||
}
|
106
internal/git/remote.go
Normal file
106
internal/git/remote.go
Normal file
@ -0,0 +1,106 @@
|
||||
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
|
||||
}
|
286
internal/git/rerere.go
Normal file
286
internal/git/rerere.go
Normal file
@ -0,0 +1,286 @@
|
||||
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,54 +1,49 @@
|
||||
## Milestone 3: Workstreams ✓ COMPLETE
|
||||
## Milestone 4: Synchronization and Remote Interaction ✓ COMPLETE
|
||||
|
||||
### Workstream Data Model
|
||||
**Completion Date:** October 14, 2025
|
||||
**Status:** All features implemented and tested
|
||||
**Development Method:** Dogfooded using Onyx itself
|
||||
|
||||
28. ✓ **Implement workstream storage** (`internal/storage/workstreams.go`)
|
||||
- `LoadWorkstreams(path string) (*WorkstreamCollection, error)`
|
||||
- `SaveWorkstreams(path string, collection *WorkstreamCollection) error`
|
||||
### Rebase Engine ✓
|
||||
|
||||
29. ✓ **Create workstream manager** (`internal/core/workstream_manager.go`)
|
||||
- `CreateWorkstream(name string, base string) error`
|
||||
- `GetCurrentWorkstream() (*Workstream, error)`
|
||||
- `SwitchWorkstream(name string) error`
|
||||
- `ListWorkstreams() ([]*Workstream, error)`
|
||||
- `AddCommitToWorkstream(sha, message string) error`
|
||||
- `GetCurrentWorkstreamName() (string, error)`
|
||||
34. **Implement stacked rebase** (`internal/git/rebase.go`) ✓
|
||||
- Implemented RebaseStack function
|
||||
- Sequential rebase with conflict handling
|
||||
- Integration with rerere for automatic conflict resolution
|
||||
- Support for rebase continuation and abort
|
||||
|
||||
### Workstream Commands
|
||||
35. **Integrate rerere** (`internal/git/rerere.go`) ✓
|
||||
- Configured rerere cache location (.onx/rerere_cache)
|
||||
- Enabled rerere for rebase operations
|
||||
- Implemented conflict detection and recording
|
||||
- Apply recorded resolutions automatically
|
||||
|
||||
30. ✓ **Implement onx new** (`internal/commands/new.go`)
|
||||
- Validates workstream name
|
||||
- Creates workstream entry
|
||||
- Sets as current workstream
|
||||
- Updates workspace to base commit
|
||||
- Logs to oplog with transaction wrapper
|
||||
- Provides helpful next-step guidance
|
||||
36. **Create conflict resolution UI** (`internal/git/conflicts.go`) ✓
|
||||
- Detect merge conflicts via index stages
|
||||
- Present clear conflict markers with file paths
|
||||
- Guide user through resolution process
|
||||
- Record resolutions for rerere
|
||||
|
||||
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`
|
||||
### Sync and Push Commands ✓
|
||||
|
||||
32. ✓ **Implement onx switch** (`internal/commands/switch.go`)
|
||||
- Validates target workstream exists
|
||||
- Checkouts latest commit in target workstream
|
||||
- Updates current_workstream pointer
|
||||
- Logs to oplog with transaction wrapper
|
||||
- Shows helpful info about target workstream
|
||||
37. **Implement onx sync** (`internal/commands/sync.go`) ✓
|
||||
- Begin oplog transaction for undo support
|
||||
- Fetch from remote (origin by default)
|
||||
- Get workstream commit stack
|
||||
- Sequential rebase with rerere support
|
||||
- Handle and present conflicts clearly
|
||||
- Update workstreams.json with new SHAs
|
||||
- Finalize oplog transaction
|
||||
|
||||
33. ✓ **Add workstream validation**
|
||||
- Validates workstream names (alphanumeric, hyphens, underscores, slashes only)
|
||||
- Prevents duplicate workstream names
|
||||
- Prevents reserved names (HEAD, main, master, etc.)
|
||||
- Integrated in `ValidateWorkstreamName()` function
|
||||
|
||||
### Integration & Refactoring
|
||||
|
||||
- ✓ Refactored `onx save` to use WorkstreamManager
|
||||
- ✓ All commands properly wired in `cmd/onx/main.go`
|
||||
- ✓ All tests passing
|
||||
- ✓ Build successful
|
||||
38. **Implement onx push** (`internal/commands/push.go`) ✓
|
||||
- Get all branches in current workstream
|
||||
- Push each workstream branch to remote
|
||||
- Support for force push flag
|
||||
- Progress reporting for each branch
|
||||
- Clear summary of pushed branches
|
||||
|
||||
39. **Add remote configuration** (`internal/git/remote.go`) ✓
|
||||
- Read git remote configuration
|
||||
- Support multiple remotes
|
||||
- Default to "origin"
|
||||
- Validate remote existence and URLs
|
||||
|
@ -1,58 +1,3 @@
|
||||
## 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
|
||||
|
||||
40. **Create test utilities** (`internal/testutil/`)
|
||||
|
Reference in New Issue
Block a user