Implement Milestone 4: Synchronization and Remote Interaction
This milestone adds comprehensive remote synchronization and stacked-diff publishing capabilities to Onyx. ## New Features ### Rebase Engine (internal/git/rebase.go) - Stacked rebase with sequential commit rebasing - Conflict detection and handling - Integration with rerere for automatic conflict resolution - Support for rebase continuation and abort operations ### Rerere Integration (internal/git/rerere.go) - Conflict resolution recording and replay - Cache location: .onx/rerere_cache - Automatic application of previous conflict resolutions - Normalized conflict pattern matching via SHA1 hashing ### Conflict Resolution UI (internal/git/conflicts.go) - Index-based conflict detection (stage 1/2/3) - Clear conflict presentation with file paths and hashes - User-friendly resolution guidance - Conflict marker extraction and analysis ### Remote Commands #### onx sync (internal/commands/sync.go) - Fetch latest changes from remote - Rebase workstream stack onto updated base branch - Automatic rerere conflict resolution - Transaction-based with full undo support - Progress reporting and clear error messages #### onx push (internal/commands/push.go) - Push all workstream branches to remote - Support for force push operations - Per-branch progress reporting - Clear summary of pushed branches ### Remote Helpers (internal/git/remote.go) - Remote validation and configuration - Support for multiple remotes with origin default - URL retrieval and remote existence checking ## Implementation Details - All operations wrapped in oplog transactions for undo support - Comprehensive error handling and user feedback - Integration with existing workstream management - CLI commands registered in cmd/onx/main.go ## Status Milestone 4 is now complete. All core synchronization and remote interaction features are implemented and tested. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
168
internal/commands/push.go
Normal file
168
internal/commands/push.go
Normal file
@ -0,0 +1,168 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"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
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "push",
|
||||
Short: "Push the current workstream to the remote repository",
|
||||
Long: `Push all branches in the current workstream to the remote repository.
|
||||
|
||||
This command will push each commit's branch reference to the remote,
|
||||
allowing you to share your stacked diff workflow with others or create
|
||||
pull requests for each commit in the stack.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runPush(remoteName, force)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&remoteName, "remote", "r", "origin", "Remote to push to")
|
||||
cmd.Flags().BoolVarP(&force, "force", "f", false, "Force push (use with caution)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runPush executes the push command
|
||||
func runPush(remoteName string, force 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 {
|
||||
return executePush(repo, remoteName, force)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("✓ Push completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// executePush performs the actual push operation
|
||||
func executePush(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. Push to remote
|
||||
fmt.Printf("Pushing %d branch(es) to %s...\n", len(refspecs), remoteName)
|
||||
|
||||
err = remote.Push(&gogit.PushOptions{
|
||||
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))
|
||||
|
||||
// 6. Print summary of pushed branches
|
||||
fmt.Println("\nPushed branches:")
|
||||
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)
|
||||
fmt.Printf(" - %s: %s\n", remoteBranch, commit.Message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
207
internal/commands/sync.go
Normal file
207
internal/commands/sync.go
Normal file
@ -0,0 +1,207 @@
|
||||
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. 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{
|
||||
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")
|
||||
}
|
||||
|
||||
// 4. 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)
|
||||
}
|
||||
|
||||
// 5. Build the commit stack from the workstream
|
||||
stack := []string{}
|
||||
for _, commit := range currentWorkstream.Commits {
|
||||
stack = append(stack, commit.SHA)
|
||||
}
|
||||
|
||||
// 6. 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])
|
||||
|
||||
// 7. Perform the rebase
|
||||
result, err := rebaseEngine.RebaseStack(stack, newBaseSHA)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rebase failed: %w", err)
|
||||
}
|
||||
|
||||
// 8. 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)
|
||||
}
|
||||
|
||||
// 9. Update workstream commits with new SHAs
|
||||
if err := updateWorkstreamCommits(repo, currentWorkstream, result.RebasedCommits); err != nil {
|
||||
return fmt.Errorf("failed to update workstream: %w", err)
|
||||
}
|
||||
|
||||
// 10. 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
|
||||
}
|
Reference in New Issue
Block a user