//go:build integration package main import ( "fmt" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/git-bug/git-bug/entities/bug" "github.com/git-bug/git-bug/entities/identity" "github.com/git-bug/git-bug/repository" ) // newBareRemote creates an empty bare git repository to act as "origin". func newBareRemote(t *testing.T) string { t.Helper() return localBareRemote(t) } // newRepoWithOrigin creates a fresh git repo whose origin remote points at remoteDir. // This mirrors a fresh checkout without needing a HEAD commit. func newRepoWithOrigin(t *testing.T, remoteDir string) string { t.Helper() dir := t.TempDir() initCmd := exec.Command("git", "init") initCmd.Dir = dir if out, err := initCmd.CombinedOutput(); err != nil { t.Fatalf("git init failed: %v: %s", err, out) } remoteCmd := exec.Command("git", "remote", "add", "origin", remoteDir) remoteCmd.Dir = dir if out, err := remoteCmd.CombinedOutput(); err != nil { t.Fatalf("git remote add failed: %v: %s", err, out) } return dir } // remoteRefCount counts refs on the remote matching prefix (e.g. "refs/bugs/"). func remoteRefCount(t *testing.T, remoteDir, prefix string) int { t.Helper() out, err := exec.Command("git", "ls-remote", remoteDir, prefix+"*").Output() if err != nil { t.Fatalf("git ls-remote failed: %v", err) } count := 0 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { if strings.TrimSpace(line) != "" { count++ } } return count } // countIdentitiesNamed returns how many local identities have the given name. func countIdentitiesNamed(t *testing.T, repoPath, name string) int { t.Helper() repo, err := repository.OpenGoGitRepo(repoPath, "", nil) if err != nil { t.Fatalf("failed to open repo: %v", err) } defer repo.Close() ids, err := identity.ListLocalIds(repo) if err != nil { t.Fatalf("ListLocalIds failed: %v", err) } count := 0 for _, id := range ids { i, err := identity.ReadLocal(repo, id) if err != nil { continue } if i.Name() == name { count++ } } return count } // countAgentIdentities returns how many local identities are named "agent". func countAgentIdentities(t *testing.T, repoPath string) int { t.Helper() return countIdentitiesNamed(t, repoPath, "agent") } // findBugByTitle returns the full ID and snapshot of the bug with the given title. func findBugByTitle(t *testing.T, repoPath, title string) (string, *bug.Snapshot) { t.Helper() repo, err := repository.OpenGoGitRepo(repoPath, "", nil) if err != nil { t.Fatalf("failed to open repo: %v", err) } defer repo.Close() for streamed := range bug.ReadAll(repo) { if streamed.Err != nil { t.Fatalf("failed to read bug: %v", streamed.Err) } snap := streamed.Entity.Compile() if snap.Title == title { return streamed.Entity.Id().String(), snap } } t.Fatalf("bug %q not found in %s", title, repoPath) return "", nil } // installGitShim puts a wrapper named "git" first on PATH that logs each // invocation and delegates to the real git binary. It returns the log path. // This lets tests assert that network operations go through the user's git // (honoring their SSH setup) rather than go-git's bundled transports. func installGitShim(t *testing.T) string { t.Helper() realGit, err := exec.LookPath("git") if err != nil { t.Fatalf("git not found: %v", err) } dir := t.TempDir() logFile := filepath.Join(dir, "git-invocations.log") script := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$*\" >> %q\nexec %q \"$@\"\n", logFile, realGit) shim := filepath.Join(dir, "git") if err := os.WriteFile(shim, []byte(script), 0o755); err != nil { t.Fatalf("failed to write git shim: %v", err) } t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) return logFile } func readGitInvocations(t *testing.T, logFile string) string { t.Helper() data, err := os.ReadFile(logFile) if err != nil { t.Fatalf("failed to read git invocation log: %v", err) } return string(data) } // TestSyncCommand_PushUsesGitBinary verifies push transport shells out to git // instead of go-git (whose SSH support requires SSH_AUTH_SOCK). func TestSyncCommand_PushUsesGitBinary(t *testing.T) { remote := newBareRemote(t) repo := newRepoWithOrigin(t, remote) if err := createIdentity(repo, "Alice", "alice@example.com", true); err != nil { t.Fatalf("createIdentity failed: %v", err) } if err := runNew(repo, "Bug", "desc", "", "", ""); err != nil { t.Fatalf("runNew failed: %v", err) } logFile := installGitShim(t) if err := runSync(repo, "origin", false, true); err != nil { t.Fatalf("runSync push failed: %v", err) } if invocations := readGitInvocations(t, logFile); !strings.Contains(invocations, "push") { t.Fatalf("push transport did not use the git binary; invocations:\n%s", invocations) } } // TestSyncCommand_PullUsesGitBinary verifies fetch transport shells out to git. func TestSyncCommand_PullUsesGitBinary(t *testing.T) { remote := newBareRemote(t) alice := newRepoWithOrigin(t, remote) if err := createIdentity(alice, "Alice", "alice@example.com", true); err != nil { t.Fatalf("createIdentity failed: %v", err) } if err := runNew(alice, "Remote bug", "desc", "", "", ""); err != nil { t.Fatalf("runNew failed: %v", err) } if err := runSync(alice, "origin", false, true); err != nil { t.Fatalf("alice push failed: %v", err) } bob := newRepoWithOrigin(t, remote) if err := createIdentity(bob, "Bob", "bob@example.com", true); err != nil { t.Fatalf("createIdentity failed: %v", err) } logFile := installGitShim(t) if err := runSync(bob, "origin", true, false); err != nil { t.Fatalf("runSync pull failed: %v", err) } if invocations := readGitInvocations(t, logFile); !strings.Contains(invocations, "fetch") { t.Fatalf("fetch transport did not use the git binary; invocations:\n%s", invocations) } } // TestInitCommand_UsesGitBinaryForFetch verifies init's remote check shells out to git. func TestInitCommand_UsesGitBinaryForFetch(t *testing.T) { remote := newBareRemote(t) alice := newRepoWithOrigin(t, remote) if err := runInitWithReader(alice, strings.NewReader("Alice\nalice@example.com\n")); err != nil { t.Fatalf("alice init failed: %v", err) } if err := runNew(alice, "Remote bug", "desc", "", "", ""); err != nil { t.Fatalf("runNew failed: %v", err) } if err := runSync(alice, "origin", false, true); err != nil { t.Fatalf("alice push failed: %v", err) } bob := newRepoWithOrigin(t, remote) logFile := installGitShim(t) if err := runInitWithReader(bob, strings.NewReader("Bob\nbob@example.com\n")); err != nil { t.Fatalf("bob init failed: %v", err) } if invocations := readGitInvocations(t, logFile); !strings.Contains(invocations, "fetch") { t.Fatalf("init remote check did not use the git binary; invocations:\n%s", invocations) } } // TestSyncCommand_PushesLocalBugsToRemote verifies push-only publishes bugs and identities. func TestSyncCommand_PushesLocalBugsToRemote(t *testing.T) { remote := newBareRemote(t) repo := newRepoWithOrigin(t, remote) if err := createIdentity(repo, "Alice", "alice@example.com", true); err != nil { t.Fatalf("createIdentity failed: %v", err) } if err := runNew(repo, "Bug one", "first", "", "", ""); err != nil { t.Fatalf("runNew failed: %v", err) } if err := runNew(repo, "Bug two", "second", "", "", ""); err != nil { t.Fatalf("runNew failed: %v", err) } if err := runSync(repo, "origin", false, true); err != nil { t.Fatalf("runSync push-only failed: %v", err) } if got := remoteRefCount(t, remote, "refs/bugs/"); got != 2 { t.Errorf("remote bug refs = %d, want 2", got) } if got := remoteRefCount(t, remote, "refs/identities/"); got < 1 { t.Errorf("remote identity refs = %d, want >= 1", got) } } // TestSyncCommand_PullOnlyMaterializesRemoteBugs verifies a fresh repo sees remote bugs // after a pull-only sync, including resolving the remote author identity. func TestSyncCommand_PullOnlyMaterializesRemoteBugs(t *testing.T) { remote := newBareRemote(t) alice := newRepoWithOrigin(t, remote) if err := createIdentity(alice, "Alice", "alice@example.com", true); err != nil { t.Fatalf("createIdentity failed: %v", err) } if err := runNew(alice, "Remote bug", "desc", "", "", ""); err != nil { t.Fatalf("runNew failed: %v", err) } if err := runSync(alice, "origin", false, true); err != nil { t.Fatalf("runSync push failed: %v", err) } bob := newRepoWithOrigin(t, remote) if err := createIdentity(bob, "Bob", "bob@example.com", true); err != nil { t.Fatalf("createIdentity failed: %v", err) } if err := runSync(bob, "origin", true, false); err != nil { t.Fatalf("runSync pull-only failed: %v", err) } issues, err := LoadBugs(bob) if err != nil { t.Fatalf("LoadBugs failed: %v", err) } if len(issues) != 1 || issues[0].Title != "Remote bug" { t.Fatalf("LoadBugs = %+v, want exactly the remote bug", issues) } } // TestSyncCommand_PullRequiresIdentity verifies a helpful error when no identity is set. func TestSyncCommand_PullRequiresIdentity(t *testing.T) { remote := newBareRemote(t) repo := newRepoWithOrigin(t, remote) err := runSync(repo, "origin", true, false) if err == nil { t.Fatal("expected error pulling without an identity, got nil") } if !strings.Contains(err.Error(), "bug init") { t.Errorf("error should point the user at 'bug init', got: %v", err) } } // TestSyncCommand_MergesConcurrentEdits verifies pull-before-push merges rather than clobbers. func TestSyncCommand_MergesConcurrentEdits(t *testing.T) { remote := newBareRemote(t) alice := newRepoWithOrigin(t, remote) if err := createIdentity(alice, "Alice", "alice@example.com", true); err != nil { t.Fatalf("createIdentity failed: %v", err) } if err := runNew(alice, "Shared", "first", "", "", ""); err != nil { t.Fatalf("runNew failed: %v", err) } if err := runSync(alice, "origin", false, true); err != nil { t.Fatalf("alice push failed: %v", err) } bob := newRepoWithOrigin(t, remote) if err := createIdentity(bob, "Bob", "bob@example.com", true); err != nil { t.Fatalf("createIdentity failed: %v", err) } if err := runSync(bob, "origin", true, false); err != nil { t.Fatalf("bob pull failed: %v", err) } aliceID, _ := findBugByTitle(t, alice, "Shared") bobID, _ := findBugByTitle(t, bob, "Shared") if err := runComment(alice, aliceID, "from A"); err != nil { t.Fatalf("alice comment failed: %v", err) } if err := runComment(bob, bobID, "from B"); err != nil { t.Fatalf("bob comment failed: %v", err) } if err := runSync(alice, "origin", false, true); err != nil { t.Fatalf("alice push failed: %v", err) } if err := runSync(bob, "origin", true, true); err != nil { t.Fatalf("bob sync failed: %v", err) } if err := runSync(alice, "origin", true, false); err != nil { t.Fatalf("alice pull failed: %v", err) } _, snap := findBugByTitle(t, alice, "Shared") messages := map[string]bool{} for _, c := range snap.Comments { messages[c.Message] = true } if !messages["from A"] || !messages["from B"] { t.Fatalf("merge lost concurrent edits, comments = %v", messages) } } // TestInitCommand_FreshRepoMergesRemoteAndAvoidsDuplicateAgent verifies that running // init on a fresh checkout pulls existing bugs and does not mint a duplicate agent identity. func TestInitCommand_FreshRepoMergesRemoteAndAvoidsDuplicateAgent(t *testing.T) { remote := newBareRemote(t) alice := newRepoWithOrigin(t, remote) if err := runInitWithReader(alice, strings.NewReader("Alice\nalice@example.com\n")); err != nil { t.Fatalf("alice init failed: %v", err) } if err := runNew(alice, "Remote bug one", "one", "", "", ""); err != nil { t.Fatalf("runNew failed: %v", err) } if err := runNew(alice, "Remote bug two", "two", "", "", ""); err != nil { t.Fatalf("runNew failed: %v", err) } if err := runSync(alice, "origin", false, true); err != nil { t.Fatalf("alice push failed: %v", err) } bob := newRepoWithOrigin(t, remote) if err := runInitWithReader(bob, strings.NewReader("Bob\nbob@example.com\n")); err != nil { t.Fatalf("bob init failed: %v", err) } issues, err := LoadBugs(bob) if err != nil { t.Fatalf("LoadBugs failed: %v", err) } if len(issues) != 2 { t.Fatalf("LoadBugs on fresh checkout = %d issues, want 2", len(issues)) } if got := countAgentIdentities(t, bob); got != 1 { t.Errorf("agent identities on fresh checkout = %d, want exactly 1", got) } } // TestInitCommand_AdoptsMatchingRemoteIdentity verifies that a second checkout by // the same user reuses the existing remote identity rather than duplicating it, // and that adopting works even though the identity is not yet local. func TestInitCommand_AdoptsMatchingRemoteIdentity(t *testing.T) { remote := newBareRemote(t) alice := newRepoWithOrigin(t, remote) if err := runInitWithReader(alice, strings.NewReader("Alice\nalice@example.com\n")); err != nil { t.Fatalf("alice init failed: %v", err) } if err := runNew(alice, "Shared bug", "desc", "", "", ""); err != nil { t.Fatalf("runNew failed: %v", err) } if err := runSync(alice, "origin", false, true); err != nil { t.Fatalf("alice push failed: %v", err) } // Same person, fresh checkout, same name and email. alice2 := newRepoWithOrigin(t, remote) if err := runInitWithReader(alice2, strings.NewReader("Alice\nalice@example.com\n")); err != nil { t.Fatalf("alice2 init failed: %v", err) } issues, err := LoadBugs(alice2) if err != nil { t.Fatalf("LoadBugs failed: %v", err) } if len(issues) != 1 { t.Fatalf("LoadBugs on second checkout = %d issues, want 1", len(issues)) } if got := countIdentitiesNamed(t, alice2, "Alice"); got != 1 { t.Errorf("Alice identities = %d, want exactly 1 (should adopt, not duplicate)", got) } }