Skip to content

Commit

Permalink
Detect checked out branch by reading git HEAD file (#2570)
Browse files Browse the repository at this point in the history
  • Loading branch information
unmultimedio authored Nov 14, 2023
1 parent 9aaf28d commit aaf2d2c
Show file tree
Hide file tree
Showing 5 changed files with 104 additions and 56 deletions.
14 changes: 6 additions & 8 deletions private/buf/bufsync/syncer.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ func (s *syncer) prepareSync(ctx context.Context) error {
branchesToSync = slicesextended.MapToSlice(allBranches)
} else {
// sync current branch, make sure it's present
currentBranch, err := s.repo.CurrentBranch(ctx)
currentBranch, err := s.repo.CheckedOutBranch()
if err != nil {
return fmt.Errorf("determine checked out branch")
}
Expand Down Expand Up @@ -399,7 +399,6 @@ func (s *syncer) branchSyncableCommits(
zap.String("expected sync point", expectedSyncPoint),
)
var commitsForSync []*syncableCommit
stopLoopErr := errors.New("stop loop")
eachCommitFunc := func(commit git.Commit) error {
commitHash := commit.Hash().Hex()
logger := logger.With(zap.String("commit", commitHash))
Expand Down Expand Up @@ -435,7 +434,7 @@ func (s *syncer) branchSyncableCommits(
// we reached the expected sync point for this branch, it's ok to stop
logger.Debug("expected sync point reached, stop looking back in branch")
}
return stopLoopErr
return git.ErrStopForEach
}
// git commit is not synced, attempt to read the module in the commit:moduleDir
builtModule, readErr := s.readModuleAt(
Expand All @@ -455,7 +454,7 @@ func (s *syncer) branchSyncableCommits(
return nil
case LookbackDecisionCodeStop:
logger.Debug("read module at commit failed, stop looking back in branch", zap.Error(readErr))
return stopLoopErr
return git.ErrStopForEach
case LookbackDecisionCodeOverride:
logger.Debug("read module at commit failed, overriding module identity in commit", zap.Error(readErr))
if builtModule == nil {
Expand All @@ -473,7 +472,7 @@ func (s *syncer) branchSyncableCommits(
branch,
git.ForEachCommitWithBranchStartPointWithRemote(s.gitRemoteName),
),
); err != nil && !errors.Is(err, stopLoopErr) {
); err != nil {
return nil, err
}
// if we have no commits to sync we can bail early
Expand Down Expand Up @@ -600,7 +599,6 @@ func (s *syncer) backfillTags(
var (
lookbackCommitsCount int
timeLimit = s.clock.Now().Add(-LookbackTimeLimit)
stopLoopErr = errors.New("stop loop")
logger = s.logger.With(
zap.String("branch", branch),
zap.String("module directory", moduleDir),
Expand All @@ -614,7 +612,7 @@ func (s *syncer) backfillTags(
// timespan) need to be met.
if lookbackCommitsCount > LookbackCommitsLimit &&
oldCommit.Committer().Timestamp().Before(timeLimit) {
return stopLoopErr
return git.ErrStopForEach
}
// Is there any tag in this commit to backfill?
tagsToBackfill := s.commitsToTags[oldCommit.Hash().Hex()]
Expand Down Expand Up @@ -647,7 +645,7 @@ func (s *syncer) backfillTags(
if err := s.repo.ForEachCommit(
forEachOldCommitFunc,
git.ForEachCommitWithHashStartPoint(syncStartHash.Hex()),
); err != nil && !errors.Is(err, stopLoopErr) {
); err != nil {
return fmt.Errorf("looking back past the start sync point: %w", err)
}
return nil
Expand Down
22 changes: 20 additions & 2 deletions private/pkg/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ var (
// ErrTreeNodeNotFound is an error found in the error chain when
// ObjectReader is unable to find the target object.
ErrObjectNotFound = errors.New("object not found")
// ErrStopForEach is provided for callers to use it when they want to gracefully stop a ForEach*
// function. It is not returned as an error by any function.
ErrStopForEach = errors.New("stop for each loop")
)

// ObjectMode is how to interpret a tree node's object. See the Mode* constants
Expand Down Expand Up @@ -271,8 +274,8 @@ type Repository interface {
// `.git/refs/remotes/origin/HEAD` (assuming the default branch has been already pushed to a
// remote named `origin`). It can be customized via the `OpenRepositoryWithDefaultBranch` option.
DefaultBranch() string
// CurrentBranch is the current checked out branch.
CurrentBranch(ctx context.Context) (string, error)
// CheckedOutBranch returns the current checked out branch.
CheckedOutBranch(options ...CheckedOutBranchOption) (string, error)
// ForEachBranch ranges over branches in the repository in an undefined order.
ForEachBranch(f func(branch string, headHash Hash) error, options ...ForEachBranchOption) error
// ForEachCommit ranges over commits in reverse topological order, going backwards in time always
Expand All @@ -296,6 +299,17 @@ type Repository interface {
Close() error
}

// CheckedOutBranchOption are options that can be passed to CheckedOutBranch.
type CheckedOutBranchOption func(*checkedOutBranchOpts)

// CheckedOutBranchWithRemote sets the function to only loop over branches present in the passed
// remote at their respective HEADs.
func CheckedOutBranchWithRemote(remoteName string) CheckedOutBranchOption {
return func(opts *checkedOutBranchOpts) {
opts.remote = remoteName
}
}

// ForEachBranchOption are options that can be passed to ForEachBranch.
type ForEachBranchOption func(*forEachBranchOpts)

Expand Down Expand Up @@ -384,6 +398,10 @@ func OpenRepositoryWithDefaultBranch(name string) OpenRepositoryOption {
}
}

type checkedOutBranchOpts struct {
remote string
}

type forEachBranchOpts struct {
remote string
}
Expand Down
7 changes: 3 additions & 4 deletions private/pkg/git/gittest/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
package gittest

import (
"context"
"testing"

"github.com/bufbuild/buf/private/pkg/command"
Expand Down Expand Up @@ -44,8 +43,8 @@ func newRepository(
func (r *repository) Close() error {
return r.inner.Close()
}
func (r *repository) CurrentBranch(ctx context.Context) (string, error) {
return r.inner.CurrentBranch(ctx)
func (r *repository) CheckedOutBranch(options ...git.CheckedOutBranchOption) (string, error) {
return r.inner.CheckedOutBranch(options...)
}
func (r *repository) DefaultBranch() string {
return r.inner.DefaultBranch()
Expand Down Expand Up @@ -95,7 +94,7 @@ func (r *repository) Tag(t *testing.T, name string, msg string) {
}
}
func (r *repository) Push(t *testing.T) {
currentBranch, err := r.CurrentBranch(context.Background())
currentBranch, err := r.CheckedOutBranch()
require.NoError(t, err)
runInDir(t, r.runner, r.repoDir, "git", "push", "--follow-tags", "origin", currentBranch)
}
Expand Down
113 changes: 73 additions & 40 deletions private/pkg/git/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"sync"

"github.com/bufbuild/buf/private/pkg/command"
Expand Down Expand Up @@ -130,6 +130,9 @@ func (r *repository) ForEachBranch(f func(string, Hash) error, options ...ForEac
unpackedBranches[branchName] = struct{}{}
return f(branchName, hash)
}); err != nil && !errors.Is(err, fs.ErrNotExist) {
if errors.Is(err, ErrStopForEach) {
return nil
}
return err
}
// Read packed branch refs that haven't been seen yet.
Expand All @@ -141,6 +144,9 @@ func (r *repository) ForEachBranch(f func(string, Hash) error, options ...ForEac
for branchName, hash := range remotePackedBranches {
if _, seen := unpackedBranches[branchName]; !seen {
if err := f(branchName, hash); err != nil {
if errors.Is(err, ErrStopForEach) {
return nil
}
return err
}
}
Expand All @@ -153,8 +159,63 @@ func (r *repository) DefaultBranch() string {
return r.defaultBranch
}

func (r *repository) CurrentBranch(ctx context.Context) (string, error) {
return detectCheckedOutBranch(ctx, r.gitDirPath, r.runner)
func (r *repository) CheckedOutBranch(options ...CheckedOutBranchOption) (string, error) {
var config checkedOutBranchOpts
for _, option := range options {
option(&config)
}
headBytes, err := os.ReadFile(filepath.Join(r.gitDirPath, "HEAD"))
if err != nil {
return "", fmt.Errorf("read HEAD bytes: %w", err)
}
headBytes = bytes.TrimSuffix(headBytes, []byte{'\n'})
// .git/HEAD could point to a named ref, or could be in a dettached state, ie pointing to a git
// hash. Possible values:
//
// "ref: refs/heads/somelocalbranch"
// "ref: refs/remotes/someremote/somebranch"
// "somegithash"
const refPrefix = "ref: refs/"
if strings.HasPrefix(string(headBytes), refPrefix) {
refRelDir := strings.TrimPrefix(string(headBytes), refPrefix)
if config.remote == "" {
// only match local branches
const localBranchPrefix = "heads/"
if !strings.HasPrefix(refRelDir, localBranchPrefix) {
return "", fmt.Errorf("git HEAD %s is not pointing to a local branch", string(headBytes))
}
return strings.TrimPrefix(refRelDir, localBranchPrefix), nil
}
// only match branches from the specific remote
remoteBranchPrefix := "remotes/" + config.remote + "/"
if !strings.HasPrefix(refRelDir, remoteBranchPrefix) {
return "", fmt.Errorf("git HEAD %s is not pointing to branch in remote %s", string(headBytes), config.remote)
}
return strings.TrimPrefix(refRelDir, remoteBranchPrefix), nil
}
// if HEAD is not a named ref, it could be a dettached HEAD, ie a git hash
headHash, err := parseHashFromHex(string(headBytes))
if err != nil {
return "", fmt.Errorf(".git/HEAD is not a named ref nor a git hash: %w", err)
}
// we can compare that hash with all repo branches' heads
var currentBranch string
if err := r.ForEachBranch(
func(branch string, branchHEAD Hash) error {
if headHash == branchHEAD {
currentBranch = branch
return ErrStopForEach
}
return nil
},
ForEachBranchWithRemote(config.remote),
); err != nil {
return "", fmt.Errorf("for each branch: %w", err)
}
if currentBranch == "" {
return "", errors.New("git HEAD is detached, no matches with any branch")
}
return currentBranch, nil
}

func (r *repository) ForEachCommit(f func(Commit) error, options ...ForEachCommitOption) error {
Expand All @@ -168,6 +229,9 @@ func (r *repository) ForEachCommit(f func(Commit) error, options ...ForEachCommi
}
for {
if err := f(currentCommit); err != nil {
if errors.Is(err, ErrStopForEach) {
return nil
}
return err
}
if len(currentCommit.Parents()) == 0 {
Expand Down Expand Up @@ -237,6 +301,9 @@ func (r *repository) ForEachTag(f func(string, Hash) error) error {
tagName,
)
}); err != nil {
if errors.Is(err, ErrStopForEach) {
return nil
}
return err
}
// Read packed tag refs that haven't been seen yet.
Expand All @@ -246,6 +313,9 @@ func (r *repository) ForEachTag(f func(string, Hash) error) error {
for tagName, commit := range r.packedTags {
if _, found := seen[tagName]; !found {
if err := f(tagName, commit); err != nil {
if errors.Is(err, ErrStopForEach) {
return nil
}
return err
}
}
Expand Down Expand Up @@ -375,43 +445,6 @@ func detectDefaultBranch(gitDirPath string) (string, error) {
return string(data), nil
}

func detectCheckedOutBranch(ctx context.Context, gitDirPath string, runner command.Runner) (string, error) {
var (
stdOutBuffer = bytes.NewBuffer(nil)
stdErrBuffer = bytes.NewBuffer(nil)
)
if err := runner.Run(
ctx,
"git",
command.RunWithArgs(
"rev-parse",
"--abbrev-ref",
"HEAD",
),
command.RunWithStdout(stdOutBuffer),
command.RunWithStderr(stdErrBuffer),
command.RunWithDir(gitDirPath), // exec command at the root of the git repo
); err != nil {
stdErrMsg, err := io.ReadAll(stdErrBuffer)
if err != nil {
stdErrMsg = []byte(fmt.Sprintf("read stderr: %s", err.Error()))
}
return "", fmt.Errorf("git rev-parse: %w (%s)", err, string(stdErrMsg))
}
stdOut, err := io.ReadAll(stdOutBuffer)
if err != nil {
return "", fmt.Errorf("read current branch: %w", err)
}
currentBranch := string(bytes.TrimSuffix(stdOut, []byte("\n")))
if currentBranch == "" {
return "", errors.New("empty current branch")
}
if currentBranch == "HEAD" {
return "", errors.New("no current branch, git HEAD is detached")
}
return currentBranch, nil
}

// validateDirPathExists returns a non-nil error if the given dirPath is not a valid directory path.
func validateDirPathExists(dirPath string) error {
var fileInfo os.FileInfo
Expand Down
4 changes: 2 additions & 2 deletions private/pkg/git/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ func TestForEachBranch(t *testing.T) {
t.Parallel()
repo := gittest.ScaffoldGitRepository(t)
writeModuleWithSampleCommits(t, context.Background(), repo)
currentBranch, err := repo.CurrentBranch(context.Background())
currentBranch, err := repo.CheckedOutBranch()
require.NoError(t, err)
assert.Equal(t, gittest.DefaultBranch, currentBranch)
branches := make(map[string]struct{})
Expand Down Expand Up @@ -305,7 +305,7 @@ func TestForEachBranch(t *testing.T) {
// │ └── buf.yaml
// └── randomBinary (+x)
func writeModuleWithSampleCommits(t *testing.T, ctx context.Context, repo gittest.Repository) {
currentBranch, err := repo.CurrentBranch(ctx)
currentBranch, err := repo.CheckedOutBranch()
require.NoError(t, err)

// (1) commit in main branch
Expand Down

0 comments on commit aaf2d2c

Please sign in to comment.