ghorg/git/sync.go
Blair Hamilton 20f95aa25e
feat: Add --sync-default-branch CLI flag and refactor logging
- Add --sync-default-branch flag to clone command for enabling sync feature via CLI
- Add syncDefaultBranch variable and flag handler in cmd package
- Refactor git/sync.go to use colorlog consistently throughout
  - Convert all fmt.Printf debug statements to colorlog.PrintInfo/PrintSuccess/PrintError
  - Remove all GHORG_DEBUG environment variable checks
  - Return formatted errors with context using fmt.Errorf for better error messages
- Add comprehensive tests for sync-default-branch flag
  - Test default value is false
  - Test flag sets environment variable correctly
- All existing tests continue to pass
2025-12-15 14:36:14 -05:00

160 lines
5.8 KiB
Go

// Package git provides Git repository synchronization functionality for ghorg.
//
// For comprehensive documentation on sync functionality, safety philosophy,
// configuration options, and troubleshooting, see README.md in this directory.
package git
import (
"fmt"
"os"
"github.com/gabrie30/ghorg/colorlog"
"github.com/gabrie30/ghorg/scm"
)
// SyncDefaultBranch synchronizes the local default branch with the remote
// It checks for local changes and unpushed commits before performing the sync
func (g GitClient) SyncDefaultBranch(repo scm.Repo) error {
// Check if sync is disabled via configuration
// GHORG_SYNC_DEFAULT_BRANCH defaults to false (sync disabled by default)
syncEnabled := os.Getenv("GHORG_SYNC_DEFAULT_BRANCH")
if syncEnabled != "true" {
m := fmt.Sprintf("Skipping sync for %s: GHORG_SYNC_DEFAULT_BRANCH is not set to true\n", repo.Name)
colorlog.PrintInfo(m)
return nil
}
// First check if the remote exists and is accessible
_, err := g.GetRemoteURL(repo, "origin")
if err != nil {
// Remote doesn't exist or isn't accessible, skip sync
return nil
}
// Check if the working directory has any uncommitted changes
hasWorkingDirChanges, err := g.HasLocalChanges(repo)
if err != nil {
m := fmt.Sprintf("Failed to check working directory status for %s: %v", repo.Name, err)
colorlog.PrintError(m)
return fmt.Errorf("failed to check working directory status: %w", err)
}
// Check if the current branch has unpushed commits
hasUnpushedCommits, err := g.HasUnpushedCommits(repo)
if err != nil {
m := fmt.Sprintf("Failed to check for unpushed commits for %s: %v", repo.Name, err)
colorlog.PrintError(m)
return fmt.Errorf("failed to check for unpushed commits: %w", err)
}
// Check if we're on the correct branch
currentBranch, err := g.GetCurrentBranch(repo)
if err != nil {
m := fmt.Sprintf("Failed to get current branch for %s: %v", repo.Name, err)
colorlog.PrintError(m)
return fmt.Errorf("failed to get current branch: %w", err)
}
// Check if current branch has commits not on the default branch (divergent development)
hasCommitsNotOnDefault, err := g.HasCommitsNotOnDefaultBranch(repo, currentBranch)
if err != nil {
m := fmt.Sprintf("Failed to check for commits not on default branch for %s: %v", repo.Name, err)
colorlog.PrintError(m)
return fmt.Errorf("failed to check for commits not on default branch: %w", err)
}
// Check if the default branch is behind HEAD (missing commits from current branch)
isDefaultBehindHead, err := g.IsDefaultBranchBehindHead(repo, currentBranch)
if err != nil {
m := fmt.Sprintf("Failed to check if default branch is behind HEAD for %s: %v", repo.Name, err)
colorlog.PrintError(m)
return fmt.Errorf("failed to check if default branch is behind HEAD: %w", err)
}
// Only sync if:
// 1. Working directory is clean (no uncommitted changes)
// 2. No unpushed commits on the current branch
// 3. Either:
// a. No commits on current branch that aren't on the default branch, OR
// b. Default branch is behind HEAD (we can fast-forward merge)
// 4. We're on the target branch or can safely switch to it
if hasWorkingDirChanges {
m := fmt.Sprintf("Skipping sync for %s: working directory has uncommitted changes\n", repo.Name)
colorlog.PrintInfo(m)
return nil
}
if hasUnpushedCommits {
m := fmt.Sprintf("Skipping sync for %s: branch has unpushed commits\n", repo.Name)
colorlog.PrintInfo(m)
return nil
}
// Allow sync if default branch is behind HEAD (can fast-forward merge)
// or if there are no commits not on default branch
if hasCommitsNotOnDefault && !isDefaultBehindHead {
m := fmt.Sprintf("Skipping sync for %s: current branch has commits not on default branch and default is not behind\n", repo.Name)
colorlog.PrintInfo(m)
return nil
}
// Switch to the target branch if we're not already on it
if currentBranch != repo.CloneBranch {
err := g.Checkout(repo)
if err != nil {
m := fmt.Sprintf("Could not checkout %s for %s: %v", repo.CloneBranch, repo.Name, err)
colorlog.PrintError(m)
return nil // Don't fail, just skip sync
}
}
// Fetch the latest changes from the remote
err = g.FetchCloneBranch(repo)
if err != nil {
m := fmt.Sprintf("Failed to fetch default branch for %s: %v", repo.Name, err)
colorlog.PrintError(m)
return fmt.Errorf("failed to fetch default branch: %w", err)
}
// If the default branch is behind HEAD and we have commits to merge,
// perform a fast-forward merge
if isDefaultBehindHead && hasCommitsNotOnDefault {
m := fmt.Sprintf("Default branch is behind HEAD for %s, performing fast-forward merge", repo.Name)
colorlog.PrintInfo(m)
err = g.MergeIntoDefaultBranch(repo, currentBranch)
if err != nil {
m := fmt.Sprintf("Failed to merge into default branch for %s: %v", repo.Name, err)
colorlog.PrintError(m)
return fmt.Errorf("failed to merge into default branch: %w", err)
}
m = fmt.Sprintf("Successfully updated default branch %s by merging %s for %s", repo.CloneBranch, currentBranch, repo.Name)
colorlog.PrintSuccess(m)
return nil
}
// Update the local branch reference to match the remote
refName := fmt.Sprintf("refs/heads/%s", repo.CloneBranch)
commitRef := fmt.Sprintf("refs/remotes/origin/%s", repo.CloneBranch)
err = g.UpdateRef(repo, refName, commitRef)
if err != nil {
m := fmt.Sprintf("Failed to update branch reference for %s: %v", repo.Name, err)
colorlog.PrintError(m)
return fmt.Errorf("failed to update branch reference: %w", err)
}
// Reset the working directory to match the updated branch
err = g.Reset(repo)
if err != nil {
m := fmt.Sprintf("Failed to reset working directory to remote branch for %s: %v", repo.Name, err)
colorlog.PrintError(m)
return fmt.Errorf("failed to reset working directory to remote branch: %w", err)
}
m := fmt.Sprintf("Successfully updated default branch %s for %s", repo.CloneBranch, repo.Name)
colorlog.PrintSuccess(m)
return nil
}