diff --git a/cmd/bw/cross_repo.go b/cmd/bw/cross_repo.go new file mode 100644 index 00000000..aa9a5b09 --- /dev/null +++ b/cmd/bw/cross_repo.go @@ -0,0 +1,141 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/jallum/beadwork/internal/config" + "github.com/jallum/beadwork/internal/registry" + "github.com/jallum/beadwork/internal/repo" +) + +// resolveCFlag expands the value of -C: tries the registry first +// (prefix), falls through to a filesystem path if no match. +func resolveCFlag(cfg *config.Config, arg string) string { + paths := registry.ResolveAll(cfg, arg) + if len(paths) > 1 { + fatal(fmt.Sprintf("-C %s: prefix registered for %d repositories; use -C to disambiguate:\n %s", + arg, len(paths), strings.Join(paths, "\n "))) + } + if len(paths) == 1 { + return paths[0] + } + abs, err := filepath.Abs(arg) + if err != nil { + fatal(fmt.Sprintf("-C %s: %s", arg, err)) + } + return abs +} + +// resolveCrossRepo rewrites repoDir if the first positional argument +// references an issue in a different beadwork-enabled repository, using +// the host-local registry as a prefix → path lookup. +// +// If the prefix is unknown, we do nothing (let the downstream command +// fail with its usual "not found" error). If multiple positionals +// reference conflicting prefixes, the call fails loudly. +// +// The resolver runs AFTER -C extraction and BEFORE command dispatch, so +// an explicit -C wins. +func resolveCrossRepo(cfg *config.Config, args []string) { + if repoDir != "" { + return + } + + localPrefix := "" + if r, err := repo.FindRepoAt(""); err == nil && r.IsInitialized() { + localPrefix = r.Prefix + } + + known := knownPrefixes(cfg) + if localPrefix != "" { + known[localPrefix] = true + } + + prefixes := extractPrefixCandidates(args, known) + if len(prefixes) == 0 { + return + } + + var foreign string + localSeen := false + for _, p := range prefixes { + if p == localPrefix { + localSeen = true + continue + } + if foreign == "" { + foreign = p + continue + } + if foreign != p { + fatal(fmt.Sprintf("cross-repo references mixing prefixes %q and %q are not supported", foreign, p)) + } + } + if foreign == "" { + return + } + if localSeen { + fatal(fmt.Sprintf("cross-repo references mixing local prefix %q with %q are not supported", localPrefix, foreign)) + } + + paths := registry.ResolveAll(cfg, foreign) + if len(paths) > 1 { + fatal(fmt.Sprintf("prefix %q registered for %d repositories; use -C to disambiguate:\n %s", + foreign, len(paths), strings.Join(paths, "\n "))) + } + if len(paths) == 0 { + return + } + repoDir = paths[0] + if os.Getenv("BW_DEBUG") != "" { + fmt.Fprintf(os.Stderr, "[cross-repo] %q → %s\n", foreign, paths[0]) + } +} + +// knownPrefixes returns the set of live prefixes from registered repos. +func knownPrefixes(cfg *config.Config) map[string]bool { + known := map[string]bool{} + for _, r := range registry.Repos(cfg) { + if r.Prefix != "" { + known[r.Prefix] = true + } + } + return known +} + +// extractPrefixCandidates returns the prefixes of ID-shaped positional +// arguments that are also in the `known` set. +func extractPrefixCandidates(args []string, known map[string]bool) []string { + var prefixes []string + seen := map[string]bool{} + + skipNext := false + for _, a := range args { + if skipNext { + skipNext = false + continue + } + if strings.HasPrefix(a, "-") { + if !strings.Contains(a, "=") { + skipNext = true + } + continue + } + idx := strings.IndexByte(a, '-') + if idx <= 0 { + continue + } + p := a[:idx] + if !known[p] { + continue + } + if !seen[p] { + seen[p] = true + prefixes = append(prefixes, p) + } + } + return prefixes +} diff --git a/cmd/bw/main.go b/cmd/bw/main.go index 0f2cf6f5..872f1f7b 100644 --- a/cmd/bw/main.go +++ b/cmd/bw/main.go @@ -3,7 +3,6 @@ package main import ( "fmt" "os" - "path/filepath" "time" "github.com/jallum/beadwork/internal/config" @@ -55,8 +54,13 @@ func main() { w = PlainWriter(os.Stdout) } + cfg, err := config.Load(config.DefaultPath()) + if err != nil { + fatal(err.Error()) + } + allArgs := os.Args[1:] - allArgs = extractDirFlag(allArgs) + allArgs = extractDirFlag(cfg, allArgs) if len(allArgs) < 1 { printUsage(w) @@ -100,6 +104,11 @@ func main() { return } + // Cross-repo routing: if the first ID-shaped positional references a + // different registered prefix, rewrite repoDir so the command targets + // that repo. Never overrides an explicit -C. + resolveCrossRepo(cfg, args) + var store *issue.Store if c.NeedsStore { var err error @@ -111,11 +120,6 @@ func main() { maybeCheckForUpgrade(store, w) } - cfg, err := config.Load(config.DefaultPath()) - if err != nil { - fatal(err.Error()) - } - originalCfg := cfg newCfg, err := c.Run(store, args, w, cfg) @@ -151,19 +155,15 @@ func bwNow() time.Time { } // extractDirFlag removes all -C pairs from args and sets repoDir. -func extractDirFlag(args []string) []string { +func extractDirFlag(cfg *config.Config, args []string) []string { out := make([]string, 0, len(args)) for i := 0; i < len(args); i++ { if args[i] == "-C" { if i+1 >= len(args) { fatal("-C requires an argument") } - abs, err := filepath.Abs(args[i+1]) - if err != nil { - fatal(fmt.Sprintf("-C %s: %s", args[i+1], err)) - } - repoDir = abs - i++ // skip value + repoDir = resolveCFlag(cfg, args[i+1]) + i++ } else { out = append(out, args[i]) } diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 826ad358..cf7c818e 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -50,6 +50,17 @@ func Resolve(cfg *config.Config, prefix string) (string, bool) { return "", false } +// ResolveAll returns all repo paths that share the given prefix. +func ResolveAll(cfg *config.Config, prefix string) []string { + var paths []string + for _, r := range Repos(cfg) { + if r.Prefix == prefix { + paths = append(paths, r.Path) + } + } + return paths +} + // Register returns a new config with path added to the registry. If the // path is already registered, returns cfg unchanged (same pointer). func Register(cfg *config.Config, path string) *config.Config { diff --git a/test/acceptance_test.go b/test/acceptance_test.go index 5cb2e58b..57ba9e19 100644 --- a/test/acceptance_test.go +++ b/test/acceptance_test.go @@ -60,6 +60,8 @@ func newBwEnv(t *testing.T) *bwEnv { "GIT_AUTHOR_DATE="+fixedClock, "GIT_COMMITTER_DATE="+fixedClock, "NO_COLOR=1", + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", ), } @@ -245,6 +247,8 @@ func newMultiRepoEnv(t *testing.T, n int) []*bwEnv { "GIT_AUTHOR_DATE="+fixedClock, "GIT_COMMITTER_DATE="+fixedClock, "NO_COLOR=1", + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", ), } env.git("init") @@ -455,6 +459,8 @@ func TestAutoRegistrationSilentFailure(t *testing.T) { "GIT_AUTHOR_DATE="+fixedClock, "GIT_COMMITTER_DATE="+fixedClock, "NO_COLOR=1", + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", ), } env.git("init") @@ -707,14 +713,16 @@ func TestRecapTodayLocalTimezone(t *testing.T) { envLateYesterdayLocal := "2026-01-14T23:30:00-05:00" dir := t.TempDir() - cfgPathTZ := filepath.Join(t.TempDir(), ".bw") + cfgPath := filepath.Join(t.TempDir(), ".bw") baseEnv := append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@test.com", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@test.com", "NO_COLOR=1", - "BW_CONFIG="+cfgPathTZ, + "BW_CONFIG="+cfgPath, + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", ) run := func(clock string, args ...string) string { @@ -892,12 +900,14 @@ func TestRecapFromSubdir(t *testing.T) { // TestRecapNotInRepo verifies error when not in a git repo. func TestRecapNotInRepo(t *testing.T) { dir := t.TempDir() + cfgPath := filepath.Join(t.TempDir(), ".bw") env := &bwEnv{ - t: t, - dir: dir, + t: t, + dir: dir, + cfgPath: cfgPath, env: append(os.Environ(), "BW_CLOCK="+fixedClock, - "BW_CONFIG="+filepath.Join(t.TempDir(), ".bw"), + "BW_CONFIG="+cfgPath, "NO_COLOR=1", ), } @@ -927,6 +937,273 @@ func TestRecapInBwHelp(t *testing.T) { } } +// TestCrossRepoShow verifies `bw show -` routes to the +// repo registered for that prefix. +func TestCrossRepoShow(t *testing.T) { + envs := newMultiRepoEnv(t, 2) + envs[0].bw("list") + envs[1].bw("list") + envs[1].bw("create", "Foreign task", "--id", "r1-x1") + + // From envs[0] (prefix r0), run `bw show r1-x1` — should resolve to envs[1]. + out := envs[0].bw("show", "r1-x1") + if !strings.Contains(out, "Foreign task") { + t.Errorf("cross-repo show failed to resolve r1-x1:\n%s", out) + } +} + +// TestCrossRepoCloseAndComment verifies mutations route cross-repo. +func TestCrossRepoCloseAndComment(t *testing.T) { + envs := newMultiRepoEnv(t, 2) + envs[0].bw("list") + envs[1].bw("list") + envs[1].bw("create", "To close", "--id", "r1-x9") + + // From envs[0], comment + close on the other repo's issue. + envs[0].bw("comment", "r1-x9", "from other repo") + envs[0].bw("close", "r1-x9") + + // Verify state landed in envs[1]. + out := envs[1].bw("show", "r1-x9", "--json") + if !strings.Contains(out, `"status": "closed"`) && !strings.Contains(out, `"status":"closed"`) { + t.Errorf("cross-repo close did not land:\n%s", out) + } + if !strings.Contains(out, "from other repo") { + t.Errorf("cross-repo comment did not land:\n%s", out) + } +} + +// TestCrossRepoExplicitCWins verifies -C is honored and cross-repo resolver +// does not override it. +func TestCrossRepoExplicitCWins(t *testing.T) { + envs := newMultiRepoEnv(t, 2) + envs[0].bw("list") + envs[1].bw("list") + envs[1].bw("create", "target", "--id", "r1-x5") + + // Pass -C to envs[0] AND an id that belongs to envs[1]. -C should win, + // which means the command fails because r1-x5 doesn't exist in envs[0]. + out := envs[0].bwFail("-C", envs[0].dir, "show", "r1-x5") + if !strings.Contains(out, "no issue found") && !strings.Contains(out, "not found") { + t.Errorf("expected not-found error with -C:\n%s", out) + } +} + +// TestCrossRepoMixedPrefixesRejected verifies mixing prefixes in a single +// command (e.g. dep add linking across repos) fails loudly. +func TestCrossRepoMixedPrefixesRejected(t *testing.T) { + envs := newMultiRepoEnv(t, 2) + envs[0].bw("list") + envs[1].bw("list") + + envs[0].bw("create", "local", "--id", "r0-l1") + envs[1].bw("create", "remote", "--id", "r1-r1") + + // Try to link a local and remote issue: should error. + out := envs[0].bwFail("dep", "add", "r0-l1", "blocks", "r1-r1") + if !strings.Contains(out, "cross-repo") && !strings.Contains(out, "prefixes") { + t.Errorf("expected cross-repo rejection:\n%s", out) + } +} + +// TestCrossRepoFromNonBeadworkDir verifies that cross-repo commands work +// from a directory that isn't a beadwork repo (or even a git repo). The +// prefix alone is enough to route. +func TestCrossRepoFromNonBeadworkDir(t *testing.T) { + envs := newMultiRepoEnv(t, 1) + envs[0].bw("list") + envs[0].bw("create", "remote task", "--id", "r0-nb1") + + // Use a plain temp dir (no git, no beadwork). + nonRepo := t.TempDir() + caller := &bwEnv{ + t: t, + dir: nonRepo, + cfgPath: envs[0].cfgPath, + env: append(os.Environ(), + "BW_CLOCK="+fixedClock, + "GIT_AUTHOR_DATE="+fixedClock, + "GIT_COMMITTER_DATE="+fixedClock, + "NO_COLOR=1", + "BW_CONFIG="+envs[0].cfgPath, + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + ), + } + + // From a non-beadwork dir, show should resolve via the registry. + out := caller.bw("show", "r0-nb1") + if !strings.Contains(out, "remote task") { + t.Errorf("cross-repo show from non-beadwork dir failed:\n%s", out) + } + + // And mutations should work too. + caller.bw("close", "r0-nb1") + out2 := envs[0].bw("show", "r0-nb1", "--json") + if !strings.Contains(out2, `"closed"`) { + t.Errorf("cross-repo close from non-beadwork dir did not land:\n%s", out2) + } +} + +// TestCrossRepoRecapAllFromNonBeadworkDir verifies --all works from +// anywhere, not just inside a beadwork repo. +func TestCrossRepoRecapAllFromNonBeadworkDir(t *testing.T) { + envs := newMultiRepoEnv(t, 2) + envs[0].bw("create", "a", "--id", "r0-a1") + envs[1].bw("create", "b", "--id", "r1-b1") + + nonRepo := t.TempDir() + caller := &bwEnv{ + t: t, + dir: nonRepo, + cfgPath: envs[0].cfgPath, + env: append(os.Environ(), + "BW_CLOCK="+fixedClock, + "GIT_AUTHOR_DATE="+fixedClock, + "GIT_COMMITTER_DATE="+fixedClock, + "NO_COLOR=1", + "BW_CONFIG="+envs[0].cfgPath, + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + ), + } + + out := caller.bw("recap", "today", "--all") + for _, id := range []string{"r0-a1", "r1-b1"} { + if !strings.Contains(out, id) { + t.Errorf("recap --all from non-beadwork dir missing %s:\n%s", id, out) + } + } +} + +// TestCFlagAcceptsPrefix verifies that `bw -C ` resolves through +// the registry to the repo's real path. +func TestCFlagAcceptsPrefix(t *testing.T) { + envs := newMultiRepoEnv(t, 2) + envs[0].bw("list") + envs[1].bw("list") + envs[1].bw("create", "via prefix", "--id", "r1-p1") + + // From envs[0], use -C r1 (the prefix of envs[1]) to show r1-p1. + out := envs[0].bw("-C", "r1", "show", "r1-p1") + if !strings.Contains(out, "via prefix") { + t.Errorf("-C did not expand correctly:\n%s", out) + } +} + +// TestCFlagAcceptsAlias verifies -C works with an alias after a rename. +// TestCFlagPathFallsThroughWhenNotPrefix verifies that an explicit path +// (absolute or relative) still works even if it isn't a registered prefix. +func TestCFlagPathFallsThroughWhenNotPrefix(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "here", "--id", "test-p1") + + // Absolute path — has '/' so can't be a prefix, must fall through. + out := env.bw("-C", env.dir, "show", "test-p1") + if !strings.Contains(out, "here") { + t.Errorf("-C did not work:\n%s", out) + } +} + +// TestCFlagCollisionErrors verifies -C errors when a prefix resolves to +// multiple repos. +func TestCFlagCollisionErrors(t *testing.T) { + envs := newMultiRepoEnv(t, 1) + envs[0].bw("list") + + // Inject a duplicate prefix in the registry by creating a second repo + // with the same prefix and registering it. + otherPath := filepath.Join(filepath.Dir(envs[0].dir), "twin") + twin := &bwEnv{ + t: t, + dir: otherPath, + cfgPath: envs[0].cfgPath, + env: envs[0].env, + } + os.MkdirAll(otherPath, 0755) + twin.git("init") + twin.git("config", "user.email", "test@beadwork.dev") + twin.git("config", "user.name", "Test User") + os.WriteFile(filepath.Join(otherPath, "README"), []byte("twin"), 0644) + twin.git("add", ".") + twin.git("commit", "-m", "initial") + twin.bw("init", "--prefix", "r0") + twin.bw("list") // triggers auto-registration + + out := envs[0].bwFail("-C", "r0", "list") + if !strings.Contains(out, "registered for 2 repositories") { + t.Errorf("expected -C collision error:\n%s", out) + } +} + +// TestCrossRepoPrefixCollision verifies that when two repos share the +// same prefix, the resolver fails with a clear error listing both paths +// and pointing the user at -C, instead of silently picking one. +func TestCrossRepoPrefixCollision(t *testing.T) { + envs := newMultiRepoEnv(t, 1) + envs[0].bw("list") + + // Create a second repo with the same prefix as envs[0] ("r0") and + // register it, so the prefix resolver sees a collision. + otherPath := filepath.Join(filepath.Dir(envs[0].dir), "twin") + twin := &bwEnv{ + t: t, + dir: otherPath, + cfgPath: envs[0].cfgPath, + env: envs[0].env, + } + os.MkdirAll(otherPath, 0755) + twin.git("init") + twin.git("config", "user.email", "test@beadwork.dev") + twin.git("config", "user.name", "Test User") + os.WriteFile(filepath.Join(otherPath, "README"), []byte("twin"), 0644) + twin.git("add", ".") + twin.git("commit", "-m", "initial") + twin.bw("init", "--prefix", "r0") + twin.bw("list") // triggers auto-registration + + envs[0].bw("create", "duped", "--id", "r0-d1") + + // Use a non-beadwork dir to force the resolver to look up the prefix + // (when called from within envs[0], the local prefix matches and + // resolver short-circuits — no cross-repo lookup happens). + nonRepo := t.TempDir() + caller := &bwEnv{ + t: t, + dir: nonRepo, + cfgPath: envs[0].cfgPath, + env: append(os.Environ(), + "BW_CLOCK="+fixedClock, + "NO_COLOR=1", + "BW_CONFIG="+envs[0].cfgPath, + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + ), + } + + out := caller.bwFail("show", "r0-d1") + if !strings.Contains(out, "registered for 2 repositories") { + t.Errorf("expected collision error mentioning both repos:\n%s", out) + } + if !strings.Contains(out, "use -C ") { + t.Errorf("collision error should suggest -C:\n%s", out) + } +} + +// TestCrossRepoUnknownPrefixFallsThrough verifies an unrecognized prefix +// is not rewritten (command runs against current repo and fails normally). +func TestCrossRepoUnknownPrefixFallsThrough(t *testing.T) { + env := newBwEnv(t) + env.bw("list") + + // "bogus-1" has a prefix that's not registered anywhere. The command + // should just run locally and fail with the local repo's normal error. + out := env.bwFail("show", "bogus-1") + if strings.Contains(out, "cross-repo") { + t.Errorf("unexpected cross-repo path taken:\n%s", out) + } +} + // TestRecapAllThreeHealthy verifies cross-repo recap over 3 healthy repos. func TestRecapAllThreeHealthy(t *testing.T) { envs := newMultiRepoEnv(t, 3)