Skip to content

Commit 82a59e8

Browse files
nvandesselclaude
andcommitted
test: add sandbox integration tests for Graphite driver
Build a fakegt test double (env-var-controlled) via TestMain and prepend it to PATH, then test the Graphite driver end-to-end in isolation without needing the real gt CLI or GitHub. Tests cover: NewGraphite, CreateBranch (with real temp git repo), Push (created/updated/existing PR paths), --title/--description/ --draft flag forwarding, Push failure, Rebase success, and Rebase conflict detection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d3dd8be commit 82a59e8

File tree

2 files changed

+352
-0
lines changed

2 files changed

+352
-0
lines changed

internal/driver/graphite_test.go

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,94 @@
11
package driver
22

33
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
410
"strings"
511
"testing"
612
)
713

14+
// fakeBinDir holds the directory containing the built fakegt binary.
15+
// Set by TestMain before any tests run.
16+
var fakeBinDir string
17+
18+
func TestMain(m *testing.M) {
19+
// Build fakegt and prepend its directory to PATH so that
20+
// exec.LookPath("gt") and runGT find our test double.
21+
dir, err := os.MkdirTemp("", "fakegt-*")
22+
if err != nil {
23+
fmt.Fprintf(os.Stderr, "creating temp dir: %v\n", err)
24+
os.Exit(1)
25+
}
26+
defer os.RemoveAll(dir)
27+
28+
gtBin := filepath.Join(dir, "gt")
29+
cmd := exec.Command("go", "build", "-o", gtBin, "./testdata/fakegt")
30+
cmd.Stderr = os.Stderr
31+
if err := cmd.Run(); err != nil {
32+
fmt.Fprintf(os.Stderr, "building fakegt: %v\n", err)
33+
os.Exit(1)
34+
}
35+
36+
fakeBinDir = dir
37+
os.Setenv("PATH", dir+":"+os.Getenv("PATH"))
38+
39+
os.Exit(m.Run())
40+
}
41+
42+
// initGitRepo creates a temp git repo with an initial commit, chdir's into it,
43+
// and restores the original directory on cleanup. This is needed because the
44+
// git package operates on the current working directory.
45+
func initGitRepo(t *testing.T) (dir string, ctx context.Context) {
46+
t.Helper()
47+
dir = t.TempDir()
48+
ctx = context.Background()
49+
50+
gitEnv := []string{
51+
"GIT_AUTHOR_NAME=Test User",
52+
"GIT_AUTHOR_EMAIL=test@example.com",
53+
"GIT_COMMITTER_NAME=Test User",
54+
"GIT_COMMITTER_EMAIL=test@example.com",
55+
"GIT_CONFIG_NOSYSTEM=1",
56+
"HOME=" + dir,
57+
}
58+
59+
gitCmd := func(args ...string) {
60+
t.Helper()
61+
cmd := exec.Command("git", args...)
62+
cmd.Dir = dir
63+
cmd.Env = append(os.Environ(), gitEnv...)
64+
out, err := cmd.CombinedOutput()
65+
if err != nil {
66+
t.Fatalf("setup git %s: %s\n%s", strings.Join(args, " "), err, out)
67+
}
68+
}
69+
70+
gitCmd("init", "-b", "main")
71+
gitCmd("commit", "--allow-empty", "-m", "init")
72+
73+
origDir, err := os.Getwd()
74+
if err != nil {
75+
t.Fatal(err)
76+
}
77+
if err := os.Chdir(dir); err != nil {
78+
t.Fatal(err)
79+
}
80+
t.Cleanup(func() { _ = os.Chdir(origDir) })
81+
82+
for _, e := range gitEnv {
83+
parts := strings.SplitN(e, "=", 2)
84+
t.Setenv(parts[0], parts[1])
85+
}
86+
87+
return dir, ctx
88+
}
89+
90+
// --- Unit tests for parseSubmitResult ---
91+
892
func TestParseSubmitResult(t *testing.T) {
993
tests := []struct {
1094
name string
@@ -81,3 +165,208 @@ pp--06-14-part_3: https://app.graphite.com/github/pr/withgraphite/repo/102 (crea
81165
})
82166
}
83167
}
168+
169+
// --- Integration tests using fakegt + temp git repos ---
170+
171+
func TestNewGraphiteWithFakeGT(t *testing.T) {
172+
// fakegt is on PATH via TestMain, so NewGraphite should succeed.
173+
g, err := NewGraphite()
174+
if err != nil {
175+
t.Fatalf("NewGraphite() with fakegt on PATH: %v", err)
176+
}
177+
if g.Name() != "graphite" {
178+
t.Errorf("Name() = %q, want %q", g.Name(), "graphite")
179+
}
180+
}
181+
182+
func TestGraphiteCreateBranch(t *testing.T) {
183+
_, ctx := initGitRepo(t)
184+
185+
g := &Graphite{}
186+
187+
// CreateBranch checks out the parent (real git) then calls gt create (fakegt).
188+
if err := g.CreateBranch(ctx, "my-feature", "main"); err != nil {
189+
t.Fatalf("CreateBranch: %v", err)
190+
}
191+
}
192+
193+
func TestGraphitePush(t *testing.T) {
194+
tests := []struct {
195+
name string
196+
submitOut string
197+
branch string
198+
opts PushOpts
199+
wantPR int
200+
wantCreated bool
201+
wantErr string
202+
}{
203+
{
204+
name: "new PR created",
205+
submitOut: "feat-a: https://app.graphite.com/github/pr/owner/repo/77 (created)",
206+
branch: "feat-a",
207+
opts: PushOpts{
208+
Branch: "feat-a",
209+
Base: "main",
210+
Title: "Add feature A",
211+
},
212+
wantPR: 77,
213+
wantCreated: true,
214+
},
215+
{
216+
name: "existing PR updated by gt",
217+
submitOut: "feat-b: https://app.graphite.com/github/pr/owner/repo/88 (updated)",
218+
branch: "feat-b",
219+
opts: PushOpts{
220+
Branch: "feat-b",
221+
Base: "main",
222+
},
223+
wantPR: 88,
224+
wantCreated: false,
225+
},
226+
{
227+
name: "existing PR via ExistingPR field",
228+
submitOut: "feat-c: https://app.graphite.com/github/pr/owner/repo/99 (updated)",
229+
branch: "feat-c",
230+
opts: PushOpts{
231+
Branch: "feat-c",
232+
Base: "main",
233+
ExistingPR: intPtr(55),
234+
},
235+
wantPR: 55,
236+
wantCreated: false,
237+
},
238+
}
239+
240+
for _, tt := range tests {
241+
t.Run(tt.name, func(t *testing.T) {
242+
t.Setenv("FAKEGT_SUBMIT_OUTPUT", tt.submitOut)
243+
ctx := context.Background()
244+
g := &Graphite{}
245+
246+
result, err := g.Push(ctx, tt.opts)
247+
if tt.wantErr != "" {
248+
if err == nil {
249+
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
250+
}
251+
if !strings.Contains(err.Error(), tt.wantErr) {
252+
t.Errorf("error = %q, want containing %q", err.Error(), tt.wantErr)
253+
}
254+
return
255+
}
256+
if err != nil {
257+
t.Fatalf("Push: %v", err)
258+
}
259+
if result.PRNumber != tt.wantPR {
260+
t.Errorf("PRNumber = %d, want %d", result.PRNumber, tt.wantPR)
261+
}
262+
if result.Created != tt.wantCreated {
263+
t.Errorf("Created = %v, want %v", result.Created, tt.wantCreated)
264+
}
265+
})
266+
}
267+
}
268+
269+
func TestGraphitePushPassesBodyAndTitle(t *testing.T) {
270+
// Verify that --title and --description flags are passed to gt submit.
271+
recordFile := filepath.Join(t.TempDir(), "record.txt")
272+
t.Setenv("FAKEGT_RECORD", recordFile)
273+
t.Setenv("FAKEGT_SUBMIT_OUTPUT", "my-feat: https://app.graphite.com/github/pr/o/r/1 (created)")
274+
275+
ctx := context.Background()
276+
g := &Graphite{}
277+
278+
_, err := g.Push(ctx, PushOpts{
279+
Branch: "my-feat",
280+
Base: "main",
281+
Title: "My title",
282+
Body: "My description",
283+
})
284+
if err != nil {
285+
t.Fatalf("Push: %v", err)
286+
}
287+
288+
recorded, err := os.ReadFile(recordFile)
289+
if err != nil {
290+
t.Fatalf("reading record file: %v", err)
291+
}
292+
args := string(recorded)
293+
294+
if !strings.Contains(args, "--title My title") {
295+
t.Errorf("expected --title in args, got: %s", args)
296+
}
297+
if !strings.Contains(args, "--description My description") {
298+
t.Errorf("expected --description in args, got: %s", args)
299+
}
300+
}
301+
302+
func TestGraphitePushDraft(t *testing.T) {
303+
recordFile := filepath.Join(t.TempDir(), "record.txt")
304+
t.Setenv("FAKEGT_RECORD", recordFile)
305+
t.Setenv("FAKEGT_SUBMIT_OUTPUT", "my-feat: https://app.graphite.com/github/pr/o/r/1 (created)")
306+
307+
ctx := context.Background()
308+
g := &Graphite{}
309+
310+
_, err := g.Push(ctx, PushOpts{
311+
Branch: "my-feat",
312+
Base: "main",
313+
Title: "Draft PR",
314+
Draft: true,
315+
})
316+
if err != nil {
317+
t.Fatalf("Push: %v", err)
318+
}
319+
320+
recorded, err := os.ReadFile(recordFile)
321+
if err != nil {
322+
t.Fatalf("reading record file: %v", err)
323+
}
324+
if !strings.Contains(string(recorded), "--draft") {
325+
t.Errorf("expected --draft in args, got: %s", recorded)
326+
}
327+
}
328+
329+
func TestGraphitePushFailure(t *testing.T) {
330+
t.Setenv("FAKEGT_FAIL", "1")
331+
ctx := context.Background()
332+
g := &Graphite{}
333+
334+
_, err := g.Push(ctx, PushOpts{Branch: "feat", Base: "main"})
335+
if err == nil {
336+
t.Fatal("expected error when gt submit fails")
337+
}
338+
if !strings.Contains(err.Error(), "gt submit") {
339+
t.Errorf("error = %q, want containing 'gt submit'", err.Error())
340+
}
341+
}
342+
343+
func TestGraphiteRebase(t *testing.T) {
344+
ctx := context.Background()
345+
g := &Graphite{}
346+
347+
// Normal restack succeeds.
348+
if err := g.Rebase(ctx, "main", "feature"); err != nil {
349+
t.Fatalf("Rebase: %v", err)
350+
}
351+
}
352+
353+
func TestGraphiteRebaseConflict(t *testing.T) {
354+
t.Setenv("FAKEGT_CONFLICT", "1")
355+
ctx := context.Background()
356+
g := &Graphite{}
357+
358+
err := g.Rebase(ctx, "main", "feature")
359+
if err == nil {
360+
t.Fatal("expected error on conflict")
361+
}
362+
363+
var conflictErr *RebaseConflictError
364+
if !errors.As(err, &conflictErr) {
365+
t.Fatalf("expected RebaseConflictError, got %T: %v", err, err)
366+
}
367+
if !strings.Contains(conflictErr.Detail, "CONFLICT") {
368+
t.Errorf("Detail = %q, want containing 'CONFLICT'", conflictErr.Detail)
369+
}
370+
}
371+
372+
func intPtr(n int) *int { return &n }
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Command fakegt is a test double for the Graphite CLI (gt).
2+
// Behavior is controlled via environment variables:
3+
//
4+
// - FAKEGT_FAIL: if set, exit 1 with error message
5+
// - FAKEGT_CONFLICT: if set, exit 1 with CONFLICT output (for restack)
6+
// - FAKEGT_SUBMIT_OUTPUT: custom stdout for "submit" command
7+
// - FAKEGT_RECORD: if set to a file path, append each invocation's args
8+
package main
9+
10+
import (
11+
"fmt"
12+
"os"
13+
"strings"
14+
)
15+
16+
func main() {
17+
args := os.Args[1:]
18+
19+
// Record invocations for test assertions.
20+
if recordFile := os.Getenv("FAKEGT_RECORD"); recordFile != "" {
21+
f, err := os.OpenFile(recordFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
22+
if err == nil {
23+
fmt.Fprintln(f, strings.Join(args, " "))
24+
f.Close()
25+
}
26+
}
27+
28+
// Unconditional failure mode.
29+
if os.Getenv("FAKEGT_FAIL") != "" {
30+
fmt.Fprintln(os.Stderr, "fatal: something went wrong")
31+
os.Exit(1)
32+
}
33+
34+
if len(args) == 0 {
35+
os.Exit(0)
36+
}
37+
38+
switch args[0] {
39+
case "create":
40+
// gt create <name> — no output on success.
41+
case "submit":
42+
// Conflict mode for submit.
43+
if os.Getenv("FAKEGT_CONFLICT") != "" {
44+
fmt.Fprintln(os.Stderr, "CONFLICT (content): Merge conflict in file.go")
45+
os.Exit(1)
46+
}
47+
// Custom output or default.
48+
if out := os.Getenv("FAKEGT_SUBMIT_OUTPUT"); out != "" {
49+
fmt.Println(out)
50+
} else {
51+
fmt.Println("default-branch: https://app.graphite.com/github/pr/owner/repo/1 (created)")
52+
}
53+
case "restack":
54+
if os.Getenv("FAKEGT_CONFLICT") != "" {
55+
fmt.Println("CONFLICT (content): Merge conflict in file.go")
56+
fmt.Fprintln(os.Stderr, "could not apply abc1234... commit message")
57+
os.Exit(1)
58+
}
59+
fmt.Println("Restacked")
60+
default:
61+
// Unknown commands succeed silently.
62+
}
63+
}

0 commit comments

Comments
 (0)