diff --git a/CLAUDE.md b/CLAUDE.md index 1ad5fa7..1df3a46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,8 +105,55 @@ Background process using fsnotify for continuous snapshot creation. | `onx switch ` | 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 `** to create feature branches (NOT `git checkout -b`) +- ✅ **Use `onx switch `** 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) diff --git a/README.md b/README.md index 28a0248..5a88cec 100644 --- a/README.md +++ b/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 diff --git a/internal/commands/push.go b/internal/commands/push.go index 4385e72..09df859 100644 --- a/internal/commands/push.go +++ b/internal/commands/push.go @@ -3,6 +3,7 @@ package commands import ( "fmt" "os" + "strings" "git.dws.rip/DWS/onyx/internal/core" "git.dws.rip/DWS/onyx/internal/git" @@ -15,28 +16,41 @@ import ( 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 all branches in the current workstream to the remote repository. + Long: `Push 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.`, +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) + 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 bool) error { +func runPush(remoteName string, force, stacked bool) error { // Get current directory cwd, err := os.Getwd() if err != nil { @@ -57,7 +71,10 @@ func runPush(remoteName string, force bool) error { // Use ExecuteWithTransaction to capture state err = core.ExecuteWithTransaction(repo, "push", "Pushed to remote", func() error { - return executePush(repo, remoteName, force) + if stacked { + return executePushStacked(repo, remoteName, force) + } + return executePushSingleBranch(repo, remoteName, force) }) if err != nil { @@ -68,8 +85,89 @@ func runPush(remoteName string, force bool) error { return nil } -// executePush performs the actual push operation -func executePush(repo *core.OnyxRepository, remoteName string, force bool) error { +// 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 @@ -136,10 +234,25 @@ func executePush(repo *core.OnyxRepository, remoteName string, force bool) error return fmt.Errorf("no branches to push") } - // 5. Push to remote + // 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, }) @@ -154,15 +267,21 @@ func executePush(repo *core.OnyxRepository, remoteName string, force bool) error fmt.Printf("✓ Pushed %d branch(es) successfully\n", len(refspecs)) - // 6. Print summary of pushed branches - fmt.Println("\nPushed branches:") + // 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) - fmt.Printf(" - %s: %s\n", remoteBranch, commit.Message) + 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 } diff --git a/internal/commands/sync.go b/internal/commands/sync.go index 372f729..84d3e69 100644 --- a/internal/commands/sync.go +++ b/internal/commands/sync.go @@ -96,7 +96,21 @@ func executeSync(repo *core.OnyxRepository, repoPath, remoteName string) error { return fmt.Errorf("workstream has no commits to sync") } - // 3. Fetch from remote + // 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 { @@ -104,6 +118,7 @@ func executeSync(repo *core.OnyxRepository, repoPath, remoteName string) error { } 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)), @@ -118,7 +133,7 @@ func executeSync(repo *core.OnyxRepository, repoPath, remoteName string) error { fmt.Println("Already up to date") } - // 4. Get the updated base branch HEAD + // 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) @@ -126,24 +141,24 @@ func executeSync(repo *core.OnyxRepository, repoPath, remoteName string) error { return fmt.Errorf("failed to get remote base branch: %w", err) } - // 5. Build the commit stack from the workstream + // 6. 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 + // 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]) - // 7. Perform the rebase + // 8. Perform the rebase result, err := rebaseEngine.RebaseStack(stack, newBaseSHA) if err != nil { return fmt.Errorf("rebase failed: %w", err) } - // 8. Handle rebase result + // 9. Handle rebase result if !result.Success { if len(result.ConflictingFiles) > 0 { // Present conflicts to user @@ -155,12 +170,12 @@ func executeSync(repo *core.OnyxRepository, repoPath, remoteName string) error { return fmt.Errorf("rebase failed: %s", result.Message) } - // 9. Update workstream commits with new SHAs + // 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) } - // 10. Update the base commit metadata + // 11. Update the base commit metadata currentWorkstream.Metadata["base_commit"] = newBaseSHA wsCollection, err := storage.LoadWorkstreams(filepath.Join(onyxPath, "workstreams.json")) if err != nil { diff --git a/internal/git/auth.go b/internal/git/auth.go new file mode 100644 index 0000000..d3ec74f --- /dev/null +++ b/internal/git/auth.go @@ -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) +} diff --git a/notes/checklist.md b/notes/checklist.md index bf61726..0acfb50 100644 --- a/notes/checklist.md +++ b/notes/checklist.md @@ -1,5 +1,9 @@ ## Milestone 4: Synchronization and Remote Interaction ✓ COMPLETE +**Completion Date:** October 14, 2025 +**Status:** All features implemented and tested +**Development Method:** Dogfooded using Onyx itself + ### Rebase Engine ✓ 34. **Implement stacked rebase** (`internal/git/rebase.go`) ✓