Skip to content

Commit a628fb0

Browse files
loxmcncl
andauthored
fix: add git cli fallbacks for linked worktrees (#751)
* fix(git): fall back to git cli for worktree repos * fix: add git cli repo root fallback * Update internal/build/resolver/options/options_test.go Co-authored-by: Ben McNicholl <ben.mcnicholl@buildkite.com> * Update cmd/preflight/preflight.go Co-authored-by: Ben McNicholl <ben.mcnicholl@buildkite.com> * fix(test): import os in branch fallback test * refactor: simplify git remote fallback --------- Co-authored-by: Ben McNicholl <ben.mcnicholl@buildkite.com>
1 parent 0149f29 commit a628fb0

7 files changed

Lines changed: 247 additions & 14 deletions

File tree

cmd/preflight/preflight.go

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,17 @@ type PreflightCmd struct {
3333
JSON bool `help:"Emit one JSON object per event (JSONL)." xor:"output"`
3434
}
3535

36-
var notifyContext = signal.NotifyContext
36+
var (
37+
notifyContext = signal.NotifyContext
38+
newFactory = factory.New
39+
)
3740

3841
func (c *PreflightCmd) Help() string {
3942
return `Snapshots your working tree (uncommitted, staged, and untracked changes) and pushes it to a bk/preflight/<id> branch. If there are no local changes, pushes HEAD directly.`
4043
}
4144

4245
func (c *PreflightCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {
43-
f, err := factory.New(factory.WithDebug(globals.EnableDebug()))
46+
f, err := newFactory(factory.WithDebug(globals.EnableDebug()))
4447
if err != nil {
4548
return bkErrors.NewInternalError(err, "failed to initialize CLI", "This is likely a bug", "Report to Buildkite")
4649
}
@@ -51,19 +54,15 @@ func (c *PreflightCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error
5154
"the preflight command is under development and requires the 'preflight' experiment to opt in. Run: bk config set experiments preflight or set BUILDKITE_EXPERIMENTS=preflight")
5255
}
5356

54-
if f.GitRepository == nil {
57+
repoRoot, err := resolveRepositoryRoot(f, globals.EnableDebug())
58+
if err != nil {
5559
return bkErrors.NewValidationError(
56-
fmt.Errorf("not in a git repository"),
60+
fmt.Errorf("not in a git repository: %w", err),
5761
"preflight must be run from a git repository",
5862
"Run this command from inside a git repository",
5963
)
6064
}
6165

62-
wt, err := f.GitRepository.Worktree()
63-
if err != nil {
64-
return bkErrors.NewInternalError(err, "failed to get git worktree")
65-
}
66-
6766
preflightID, err := uuid.NewV7()
6867
if err != nil {
6968
return bkErrors.NewInternalError(err, "UUIDv7 generation failed")
@@ -97,7 +96,7 @@ func (c *PreflightCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error
9796
opts = append(opts, preflight.WithDebug())
9897
}
9998

100-
result, err := preflight.Snapshot(wt.Filesystem.Root(), preflightID, opts...)
99+
result, err := preflight.Snapshot(repoRoot, preflightID, opts...)
101100
if err != nil {
102101
return bkErrors.NewSnapshotError(err, "failed to create preflight snapshot",
103102
"Ensure you have uncommitted or committed changes to snapshot",
@@ -170,7 +169,7 @@ func (c *PreflightCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error
170169

171170
if !c.NoCleanup {
172171
_ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), PreflightID: preflightID.String(), Title: fmt.Sprintf("Cleaning up remote branch %s...", result.Branch)})
173-
if cleanupErr := preflight.Cleanup(wt.Filesystem.Root(), result.Ref, globals.EnableDebug()); cleanupErr != nil {
172+
if cleanupErr := preflight.Cleanup(repoRoot, result.Ref, globals.EnableDebug()); cleanupErr != nil {
174173
_ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), PreflightID: preflightID.String(), Title: fmt.Sprintf("Warning: failed to delete remote branch %s: %v", result.Ref, cleanupErr)})
175174
} else {
176175
_ = renderer.Render(Event{Type: EventOperation, Time: time.Now(), PreflightID: preflightID.String(), Title: fmt.Sprintf("Deleted remote branch %s", result.Branch)})
@@ -208,3 +207,14 @@ func (c *PreflightCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error
208207

209208
return finalErr
210209
}
210+
211+
func resolveRepositoryRoot(f *factory.Factory, debug bool) (string, error) {
212+
if f.GitRepository != nil {
213+
wt, err := f.GitRepository.Worktree()
214+
if err == nil {
215+
return wt.Filesystem.Root(), nil
216+
}
217+
}
218+
219+
return preflight.RepositoryRoot(".", debug)
220+
}

cmd/preflight/preflight_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ import (
1414
"testing"
1515
"time"
1616

17+
"github.com/buildkite/cli/v3/internal/config"
1718
buildkite "github.com/buildkite/go-buildkite/v4"
1819

1920
"github.com/buildkite/cli/v3/internal/build/watch"
2021
bkErrors "github.com/buildkite/cli/v3/internal/errors"
2122

2223
"github.com/buildkite/cli/v3/internal/cli"
24+
"github.com/buildkite/cli/v3/pkg/cmd/factory"
2325
)
2426

2527
type stubGlobals struct{}
@@ -125,6 +127,67 @@ func TestPreflightCmd_Run(t *testing.T) {
125127
}
126128
})
127129

130+
t.Run("falls back to git cli when factory cannot open repository", func(t *testing.T) {
131+
t.Setenv("BUILDKITE_EXPERIMENTS", "preflight")
132+
133+
originalNewFactory := newFactory
134+
t.Cleanup(func() { newFactory = originalNewFactory })
135+
136+
now := time.Now()
137+
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
138+
w.Header().Set("Content-Type", "application/json")
139+
switch {
140+
case r.Method == "POST" && strings.Contains(r.URL.Path, "/builds"):
141+
json.NewEncoder(w).Encode(buildkite.Build{
142+
Number: 1,
143+
State: "scheduled",
144+
WebURL: "https://buildkite.com/test-org/test-pipeline/builds/1",
145+
})
146+
return
147+
case r.Method == "GET" && strings.Contains(r.URL.Path, "/builds/1"):
148+
json.NewEncoder(w).Encode(buildkite.Build{
149+
Number: 1,
150+
State: "passed",
151+
FinishedAt: &buildkite.Timestamp{Time: now},
152+
})
153+
return
154+
}
155+
http.NotFound(w, r)
156+
}))
157+
defer s.Close()
158+
159+
newFactory = func(...factory.FactoryOpt) (*factory.Factory, error) {
160+
client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL))
161+
if err != nil {
162+
return nil, err
163+
}
164+
return &factory.Factory{
165+
Config: config.New(nil, nil),
166+
RestAPIClient: client,
167+
}, nil
168+
}
169+
170+
worktree := initTestRepo(t)
171+
subdir := filepath.Join(worktree, "nested", "dir")
172+
if err := os.MkdirAll(subdir, 0o755); err != nil {
173+
t.Fatal(err)
174+
}
175+
t.Chdir(subdir)
176+
if err := os.WriteFile(filepath.Join(subdir, "new.txt"), []byte("hello\n"), 0o644); err != nil {
177+
t.Fatal(err)
178+
}
179+
180+
cmd := &PreflightCmd{Pipeline: "test-org/test-pipeline", Watch: true, Interval: 0.01}
181+
if err := cmd.Run(nil, stubGlobals{}); err != nil {
182+
t.Fatalf("expected no error, got: %v", err)
183+
}
184+
185+
refs := runGit(t, worktree, "ls-remote", "--heads", "origin")
186+
if strings.Contains(refs, "bk/preflight/") {
187+
t.Errorf("expected preflight branch to be cleaned up, but found: %s", refs)
188+
}
189+
})
190+
128191
t.Run("watches build until completion and cleans up remote branch", func(t *testing.T) {
129192
t.Setenv("BUILDKITE_EXPERIMENTS", "preflight")
130193

internal/build/resolver/options/options.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ package options
22

33
import (
44
"context"
5+
"errors"
6+
"os/exec"
7+
"strings"
58

69
"github.com/buildkite/cli/v3/pkg/cmd/factory"
710
buildkite "github.com/buildkite/go-buildkite/v4"
@@ -33,19 +36,45 @@ func ResolveBranchFromFlag(branch string) OptionsFn {
3336
// ResolveBranchFromRepository returns a function that is used to add a branch filter to a build list options
3437
func ResolveBranchFromRepository(repo *git.Repository) OptionsFn {
3538
return func(options *buildkite.BuildsListOptions) error {
36-
var branch string
37-
if repo != nil && len(options.Branch) == 0 {
39+
if len(options.Branch) > 0 {
40+
return nil
41+
}
42+
43+
if repo != nil {
3844
head, err := repo.Head()
3945
if err != nil {
4046
return err
4147
}
42-
branch = head.Name().Short()
48+
options.Branch = append(options.Branch, head.Name().Short())
49+
return nil
50+
}
51+
52+
branch, err := getBranchFromGit()
53+
if err != nil {
54+
return err
55+
}
56+
if branch != "" {
4357
options.Branch = append(options.Branch, branch)
4458
}
4559
return nil
4660
}
4761
}
4862

63+
func getBranchFromGit() (string, error) {
64+
cmd := exec.Command("git", "symbolic-ref", "--quiet", "--short", "HEAD")
65+
output, err := cmd.Output()
66+
if err != nil {
67+
var exitErr *exec.ExitError
68+
var execErr *exec.Error
69+
if errors.As(err, &exitErr) || errors.As(err, &execErr) {
70+
return "", nil
71+
}
72+
return "", err
73+
}
74+
75+
return strings.TrimSpace(string(output)), nil
76+
}
77+
4978
// ResolveUserFromFlag returns a function that is used to add a user filter to a build list options
5079
func ResolveUserFromFlag(user string) OptionsFn {
5180
return func(options *buildkite.BuildsListOptions) error {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package options
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"testing"
8+
9+
buildkite "github.com/buildkite/go-buildkite/v4"
10+
git "github.com/go-git/go-git/v5"
11+
gitconfig "github.com/go-git/go-git/v5/config"
12+
)
13+
14+
func TestResolveBranchFromGitFallback(t *testing.T) {
15+
repo := testRepository(t, "https://github.com/buildkite/cli.git")
16+
wt, err := repo.Worktree()
17+
if err != nil {
18+
t.Fatalf("Worktree returned error: %v", err)
19+
}
20+
root := wt.Filesystem.Root()
21+
t.Chdir(root)
22+
23+
commitFile := filepath.Join(root, "README.md")
24+
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hello\n"), 0o644); err != nil {
25+
t.Fatalf("creating file returned error: %v", err)
26+
}
27+
if err := exec.Command("git", "add", filepath.Base(commitFile)).Run(); err != nil {
28+
t.Fatalf("git add returned error: %v", err)
29+
}
30+
if err := exec.Command("git", "-c", "user.name=Person Example", "-c", "user.email=person@example.com", "commit", "-m", "initial").Run(); err != nil {
31+
t.Fatalf("git commit returned error: %v", err)
32+
}
33+
if err := exec.Command("git", "checkout", "-b", "feature/test").Run(); err != nil {
34+
t.Fatalf("git checkout returned error: %v", err)
35+
}
36+
37+
options := &buildkite.BuildsListOptions{}
38+
err = ResolveBranchFromRepository(nil)(options)
39+
if err != nil {
40+
t.Fatalf("ResolveBranchFromRepository returned error: %v", err)
41+
}
42+
if len(options.Branch) != 1 {
43+
t.Fatalf("expected 1 branch, got %d", len(options.Branch))
44+
}
45+
if options.Branch[0] != "feature/test" {
46+
t.Fatalf("expected branch feature/test, got %q", options.Branch[0])
47+
}
48+
}
49+
50+
func testRepository(t *testing.T, remoteURLs ...string) *git.Repository {
51+
t.Helper()
52+
53+
repo, err := git.PlainInit(t.TempDir(), false)
54+
if err != nil {
55+
t.Fatalf("PlainInit returned error: %v", err)
56+
}
57+
if len(remoteURLs) == 0 {
58+
return repo
59+
}
60+
61+
_, err = repo.CreateRemote(&gitconfig.RemoteConfig{Name: "origin", URLs: remoteURLs})
62+
if err != nil {
63+
t.Fatalf("CreateRemote returned error: %v", err)
64+
}
65+
66+
return repo
67+
}

internal/pipeline/resolver/repository.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package resolver
22

33
import (
44
"context"
5+
"errors"
6+
"os/exec"
57
"strings"
68

79
bkIO "github.com/buildkite/cli/v3/internal/io"
@@ -52,6 +54,12 @@ func resolveFromRepository(ctx context.Context, f *factory.Factory, org string)
5254
if err != nil {
5355
return nil, err
5456
}
57+
if len(repos) == 0 {
58+
repos, err = getRepoURLsFromGit(ctx)
59+
if err != nil {
60+
return nil, err
61+
}
62+
}
5563
return filterPipelines(ctx, repos, org, f.RestAPIClient)
5664
}
5765

@@ -106,3 +114,27 @@ func getRepoURLs(r *git.Repository) ([]string, error) {
106114
}
107115
return c.Remotes["origin"].URLs, nil
108116
}
117+
118+
func getRepoURLsFromGit(ctx context.Context) ([]string, error) {
119+
cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "--all", "origin")
120+
output, err := cmd.Output()
121+
if err != nil {
122+
var exitErr *exec.ExitError
123+
var execErr *exec.Error
124+
if errors.As(err, &exitErr) || errors.As(err, &execErr) {
125+
return nil, nil
126+
}
127+
return nil, err
128+
}
129+
130+
var urls []string
131+
for _, line := range strings.Split(string(output), "\n") {
132+
url := strings.TrimSpace(line)
133+
if url == "" {
134+
continue
135+
}
136+
urls = append(urls, url)
137+
}
138+
139+
return urls, nil
140+
}

internal/pipeline/resolver/repository_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,33 @@ func TestResolvePipelinesFromPath(t *testing.T) {
9696
})
9797
}
9898

99+
func TestResolvePipelinesFromGitFallback(t *testing.T) {
100+
ctx := context.Background()
101+
const testOrg = "testOrg"
102+
103+
s := mockHTTPServer(`[{"slug": "cli-resolver-smoke", "repository": "git@github.com:buildkite/cli.git"}]`)
104+
t.Cleanup(s.Close)
105+
106+
repo := testRepository(t, "https://github.com/buildkite/cli.git")
107+
wt, err := repo.Worktree()
108+
if err != nil {
109+
t.Fatalf("Worktree returned error: %v", err)
110+
}
111+
t.Chdir(wt.Filesystem.Root())
112+
113+
f := testFactory(t, s.URL, testOrg, nil)
114+
pipelines, err := resolveFromRepository(ctx, f, testOrg)
115+
if err != nil {
116+
t.Errorf("Error: %s", err)
117+
}
118+
if len(pipelines) != 1 {
119+
t.Errorf("Expected 1 pipeline, got %d", len(pipelines))
120+
}
121+
if len(pipelines) == 1 && pipelines[0].Name != "cli-resolver-smoke" {
122+
t.Errorf("Expected cli-resolver-smoke pipeline, got %s", pipelines[0].Name)
123+
}
124+
}
125+
99126
func testRepository(t *testing.T, remoteURLs ...string) *git.Repository {
100127
t.Helper()
101128

internal/preflight/git.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,8 @@ func gitOutput(dir string, env []string, debug bool, args ...string) (string, er
4141
}
4242
return strings.TrimSpace(string(out)), nil
4343
}
44+
45+
// RepositoryRoot returns the top-level path for the git repository containing dir.
46+
func RepositoryRoot(dir string, debug bool) (string, error) {
47+
return gitOutput(dir, nil, debug, "rev-parse", "--show-toplevel")
48+
}

0 commit comments

Comments
 (0)