Something went wrong. Try again.
An alternative CLI for git-bug that simplifies working with agents on issues
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425//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) }}