final using git
This commit is contained in:
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 switch <name>` | Switch workstreams | `git checkout` |
|
||||||
| `onx sync` | Update with remote | `git pull --rebase` |
|
| `onx sync` | Update with remote | `git pull --rebase` |
|
||||||
| `onx push` | Push workstream | `git push` |
|
| `onx push` | Push workstream | `git push` |
|
||||||
|
| `onx push --stacked` | Push stacked diffs | N/A (advanced) |
|
||||||
| `onx undo` | Undo last operation | `git reflog && reset` |
|
| `onx undo` | Undo last operation | `git reflog && reset` |
|
||||||
|
|
||||||
|
## Push Workflows
|
||||||
|
|
||||||
|
Onyx supports two push workflows to match different development styles:
|
||||||
|
|
||||||
|
### Single-Branch Mode (Default) - Recommended for AI Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
onx push
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
- Default for all standard feature development
|
||||||
|
- When creating traditional pull requests
|
||||||
|
- For AI-assisted development sessions
|
||||||
|
- When you want a clean remote repository UI
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
- Pushes workstream as ONE branch named after the workstream
|
||||||
|
- Example: `milestone-4` branch contains all commits
|
||||||
|
- Remote UI shows single branch per workstream (clean)
|
||||||
|
- Perfect for creating GitHub/Gitea pull requests
|
||||||
|
|
||||||
|
**AI Development Guidance:**
|
||||||
|
Use this mode by default. It provides the cleanest integration with standard Git workflows and PR creation tools.
|
||||||
|
|
||||||
|
### Stacked Diffs Mode (Advanced)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
onx push --stacked
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
- Large, complex features requiring incremental review
|
||||||
|
- When each commit needs independent review/approval
|
||||||
|
- Meta/Google-style stacked diff workflows
|
||||||
|
- When explicitly requested by the user
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
- Pushes EACH commit as a separate branch
|
||||||
|
- Example: `onyx/workstreams/milestone-4/commit-1`, `commit-2`, etc.
|
||||||
|
- Remote UI shows multiple branches (one per commit)
|
||||||
|
- Each branch can have its own pull request
|
||||||
|
|
||||||
|
**AI Development Guidance:**
|
||||||
|
Only use when specifically requested or when the feature is complex enough to warrant incremental review. The additional branches may clutter the remote UI.
|
||||||
|
|
||||||
## Implementation Status
|
## Implementation Status
|
||||||
|
|
||||||
This is currently a planning/prototype phase. The codebase contains:
|
This is currently a planning/prototype phase. The codebase contains:
|
||||||
@ -117,6 +164,25 @@ This is currently a planning/prototype phase. The codebase contains:
|
|||||||
|
|
||||||
## Development Guidelines
|
## Development Guidelines
|
||||||
|
|
||||||
|
### IMPORTANT: Dogfooding Policy
|
||||||
|
|
||||||
|
**This repository uses Onyx for its own development.** All development work MUST use Onyx commands exclusively:
|
||||||
|
|
||||||
|
- ✅ **Use `onx save -m "message"`** to commit changes (NOT `git commit`)
|
||||||
|
- ✅ **Use `onx new <name>`** to create feature branches (NOT `git checkout -b`)
|
||||||
|
- ✅ **Use `onx switch <name>`** to switch workstreams (NOT `git checkout`)
|
||||||
|
- ✅ **Use `onx sync`** to update from remote (NOT `git pull`)
|
||||||
|
- ✅ **Use `onx push`** to push to remote (NOT `git push`)
|
||||||
|
- ✅ **Use `onx undo`** to undo operations (NOT `git reset`)
|
||||||
|
- ✅ **Use `onx list`** to view workstreams (NOT `git branch`)
|
||||||
|
|
||||||
|
**Exception:** Only use `git` commands for:
|
||||||
|
- Initial remote setup (`git remote add`)
|
||||||
|
- Creating pull requests via GitHub CLI (`gh pr create`)
|
||||||
|
- Inspecting low-level Git state when debugging Onyx itself
|
||||||
|
|
||||||
|
This dogfooding validates our user experience and ensures Onyx works correctly for real-world development.
|
||||||
|
|
||||||
### Code Style
|
### Code Style
|
||||||
- Follow Go conventions and idioms
|
- Follow Go conventions and idioms
|
||||||
- Use structured logging (planned: zap or logrus)
|
- Use structured logging (planned: zap or logrus)
|
||||||
|
66
README.md
66
README.md
@ -66,6 +66,72 @@ onx push
|
|||||||
onx undo
|
onx undo
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Push Workflows: Single-Branch vs Stacked Diffs
|
||||||
|
|
||||||
|
Onyx supports two push workflows to match your team's needs:
|
||||||
|
|
||||||
|
### Single-Branch Mode (Default) - Recommended
|
||||||
|
|
||||||
|
**Perfect for:** Traditional teams, simple features, clean remote UI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
onx new add-login --base main
|
||||||
|
onx save -m "Add login form"
|
||||||
|
onx save -m "Add validation"
|
||||||
|
onx save -m "Add tests"
|
||||||
|
|
||||||
|
# Push creates ONE branch with all commits
|
||||||
|
onx push
|
||||||
|
|
||||||
|
# Result on remote:
|
||||||
|
# - Branch: add-login (contains all 3 commits)
|
||||||
|
# - Clean UI, easy PR workflow
|
||||||
|
```
|
||||||
|
|
||||||
|
**What you get:**
|
||||||
|
- ✅ One clean branch per workstream
|
||||||
|
- ✅ Perfect for traditional PR workflows
|
||||||
|
- ✅ All commits preserved locally for undo
|
||||||
|
- ✅ Clean remote repository UI
|
||||||
|
|
||||||
|
### Stacked Diffs Mode - Advanced
|
||||||
|
|
||||||
|
**Perfect for:** Complex features, incremental review, Meta/Google-style workflows
|
||||||
|
|
||||||
|
```bash
|
||||||
|
onx new big-refactor --base main
|
||||||
|
onx save -m "Step 1: Database schema"
|
||||||
|
onx save -m "Step 2: API endpoints"
|
||||||
|
onx save -m "Step 3: Frontend UI"
|
||||||
|
|
||||||
|
# Push creates MULTIPLE branches (one per commit)
|
||||||
|
onx push --stacked
|
||||||
|
|
||||||
|
# Result on remote:
|
||||||
|
# - Branch: onyx/workstreams/big-refactor/commit-1
|
||||||
|
# - Branch: onyx/workstreams/big-refactor/commit-2
|
||||||
|
# - Branch: onyx/workstreams/big-refactor/commit-3
|
||||||
|
# - Each can have its own PR for focused review
|
||||||
|
```
|
||||||
|
|
||||||
|
**What you get:**
|
||||||
|
- ✅ One branch per commit for incremental review
|
||||||
|
- ✅ PRs can be merged independently
|
||||||
|
- ✅ Better for large, complex changes
|
||||||
|
- ⚠️ More branches in remote UI
|
||||||
|
|
||||||
|
### Choosing Your Workflow
|
||||||
|
|
||||||
|
| Criterion | Single-Branch (`onx push`) | Stacked Diffs (`onx push --stacked`) |
|
||||||
|
|-----------|---------------------------|-------------------------------------|
|
||||||
|
| **Team Style** | Traditional Git workflow | Meta/Google stacked review |
|
||||||
|
| **Feature Size** | Any size | Large, complex features |
|
||||||
|
| **Review Style** | One big PR | Multiple small PRs |
|
||||||
|
| **Remote UI** | Clean (1 branch) | More branches (N commits) |
|
||||||
|
| **PR Creation** | `gh pr create --head feature` | Multiple PRs, stacked dependencies |
|
||||||
|
|
||||||
|
**Recommendation:** Start with default `onx push` (single-branch). Use `--stacked` only when you need incremental review of complex changes.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
@ -3,6 +3,7 @@ package commands
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.dws.rip/DWS/onyx/internal/core"
|
"git.dws.rip/DWS/onyx/internal/core"
|
||||||
"git.dws.rip/DWS/onyx/internal/git"
|
"git.dws.rip/DWS/onyx/internal/git"
|
||||||
@ -15,28 +16,41 @@ import (
|
|||||||
func NewPushCmd() *cobra.Command {
|
func NewPushCmd() *cobra.Command {
|
||||||
var remoteName string
|
var remoteName string
|
||||||
var force bool
|
var force bool
|
||||||
|
var stacked bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "push",
|
Use: "push",
|
||||||
Short: "Push the current workstream to the remote repository",
|
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,
|
By default, pushes as a single branch (clean, traditional workflow).
|
||||||
allowing you to share your stacked diff workflow with others or create
|
Use --stacked to push each commit as a separate branch (advanced stacked diffs).
|
||||||
pull requests for each commit in the stack.`,
|
|
||||||
|
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 {
|
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().StringVarP(&remoteName, "remote", "r", "origin", "Remote to push to")
|
||||||
cmd.Flags().BoolVarP(&force, "force", "f", false, "Force push (use with caution)")
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// runPush executes the push command
|
// runPush executes the push command
|
||||||
func runPush(remoteName string, force bool) error {
|
func runPush(remoteName string, force, stacked bool) error {
|
||||||
// Get current directory
|
// Get current directory
|
||||||
cwd, err := os.Getwd()
|
cwd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -57,7 +71,10 @@ func runPush(remoteName string, force bool) error {
|
|||||||
|
|
||||||
// Use ExecuteWithTransaction to capture state
|
// Use ExecuteWithTransaction to capture state
|
||||||
err = core.ExecuteWithTransaction(repo, "push", "Pushed to remote", func() error {
|
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 {
|
if err != nil {
|
||||||
@ -68,8 +85,89 @@ func runPush(remoteName string, force bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// executePush performs the actual push operation
|
// executePushSingleBranch pushes the workstream as a single branch (default behavior)
|
||||||
func executePush(repo *core.OnyxRepository, remoteName string, force bool) error {
|
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()
|
gitRepo := repo.GetGitRepo()
|
||||||
|
|
||||||
// 1. Validate remote exists
|
// 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")
|
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)
|
fmt.Printf("Pushing %d branch(es) to %s...\n", len(refspecs), remoteName)
|
||||||
|
|
||||||
err = remote.Push(&gogit.PushOptions{
|
err = remote.Push(&gogit.PushOptions{
|
||||||
|
Auth: authMethod,
|
||||||
RefSpecs: refspecs,
|
RefSpecs: refspecs,
|
||||||
Progress: os.Stdout,
|
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))
|
fmt.Printf("✓ Pushed %d branch(es) successfully\n", len(refspecs))
|
||||||
|
|
||||||
// 6. Print summary of pushed branches
|
// 7. Print summary of pushed branches
|
||||||
fmt.Println("\nPushed branches:")
|
fmt.Println("\nPushed branches (stacked diffs):")
|
||||||
if baseBranch != "" {
|
if baseBranch != "" {
|
||||||
fmt.Printf(" - %s (base branch)\n", baseBranch)
|
fmt.Printf(" - %s (base branch)\n", baseBranch)
|
||||||
}
|
}
|
||||||
for i, commit := range currentWorkstream.Commits {
|
for i, commit := range currentWorkstream.Commits {
|
||||||
remoteBranch := fmt.Sprintf("onyx/workstreams/%s/commit-%d", currentWorkstream.Name, i+1)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -96,7 +96,21 @@ func executeSync(repo *core.OnyxRepository, repoPath, remoteName string) error {
|
|||||||
return fmt.Errorf("workstream has no commits to sync")
|
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)
|
fmt.Printf("Fetching from %s...\n", remoteName)
|
||||||
remote, err := remoteHelper.GetRemote(remoteName)
|
remote, err := remoteHelper.GetRemote(remoteName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -104,6 +118,7 @@ func executeSync(repo *core.OnyxRepository, repoPath, remoteName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = remote.Fetch(&gogit.FetchOptions{
|
err = remote.Fetch(&gogit.FetchOptions{
|
||||||
|
Auth: authMethod,
|
||||||
RefSpecs: []config.RefSpec{
|
RefSpecs: []config.RefSpec{
|
||||||
config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/remotes/%s/%s",
|
config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/remotes/%s/%s",
|
||||||
currentWorkstream.BaseBranch, remoteName, currentWorkstream.BaseBranch)),
|
currentWorkstream.BaseBranch, remoteName, currentWorkstream.BaseBranch)),
|
||||||
@ -118,7 +133,7 @@ func executeSync(repo *core.OnyxRepository, repoPath, remoteName string) error {
|
|||||||
fmt.Println("Already up to date")
|
fmt.Println("Already up to date")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Get the updated base branch HEAD
|
// 5. Get the updated base branch HEAD
|
||||||
gitBackend := git.NewGitBackend(gitRepo)
|
gitBackend := git.NewGitBackend(gitRepo)
|
||||||
remoteRef := fmt.Sprintf("refs/remotes/%s/%s", remoteName, currentWorkstream.BaseBranch)
|
remoteRef := fmt.Sprintf("refs/remotes/%s/%s", remoteName, currentWorkstream.BaseBranch)
|
||||||
newBaseSHA, err := gitBackend.GetRef(remoteRef)
|
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)
|
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{}
|
stack := []string{}
|
||||||
for _, commit := range currentWorkstream.Commits {
|
for _, commit := range currentWorkstream.Commits {
|
||||||
stack = append(stack, commit.SHA)
|
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)
|
rebaseEngine := git.NewRebaseEngine(gitRepo, onyxPath, repoPath)
|
||||||
|
|
||||||
fmt.Printf("Rebasing %d commit(s) onto %s...\n", len(stack), newBaseSHA[:8])
|
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)
|
result, err := rebaseEngine.RebaseStack(stack, newBaseSHA)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("rebase failed: %w", err)
|
return fmt.Errorf("rebase failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Handle rebase result
|
// 9. Handle rebase result
|
||||||
if !result.Success {
|
if !result.Success {
|
||||||
if len(result.ConflictingFiles) > 0 {
|
if len(result.ConflictingFiles) > 0 {
|
||||||
// Present conflicts to user
|
// 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)
|
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 {
|
if err := updateWorkstreamCommits(repo, currentWorkstream, result.RebasedCommits); err != nil {
|
||||||
return fmt.Errorf("failed to update workstream: %w", err)
|
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
|
currentWorkstream.Metadata["base_commit"] = newBaseSHA
|
||||||
wsCollection, err := storage.LoadWorkstreams(filepath.Join(onyxPath, "workstreams.json"))
|
wsCollection, err := storage.LoadWorkstreams(filepath.Join(onyxPath, "workstreams.json"))
|
||||||
if err != nil {
|
if err != 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)
|
||||||
|
}
|
@ -1,5 +1,9 @@
|
|||||||
## Milestone 4: Synchronization and Remote Interaction ✓ COMPLETE
|
## 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 ✓
|
### Rebase Engine ✓
|
||||||
|
|
||||||
34. **Implement stacked rebase** (`internal/git/rebase.go`) ✓
|
34. **Implement stacked rebase** (`internal/git/rebase.go`) ✓
|
||||||
|
Reference in New Issue
Block a user