From 37aa7d1c6ee56da3fc15fc0e238548be46fb791d Mon Sep 17 00:00:00 2001 From: Manuel Zubieta Date: Tue, 14 Apr 2026 07:51:51 -0400 Subject: [PATCH 1/6] Resolve cross-repo issue refs via registry prefix Teach the id resolver to consult the registry when a prefix doesn't belong to the current repo: - ec-42 in repo with prefix "bw" resolves to the repo registered under prefix "ec", as long as the prefix is unambiguous - Ambiguous prefixes (multiple repos claim it) produce a clear error pointing at both paths - -C now accepts either a path or a registered prefix/alias, so `bw -C ec list` works from anywhere --- cmd/bw/cross_repo.go | 160 ++++++++++++++++++ cmd/bw/main.go | 28 ++-- internal/registry/registry.go | 11 ++ test/acceptance_test.go | 298 +++++++++++++++++++++++++++++++++- 4 files changed, 478 insertions(+), 19 deletions(-) create mode 100644 cmd/bw/cross_repo.go diff --git a/cmd/bw/cross_repo.go b/cmd/bw/cross_repo.go new file mode 100644 index 00000000..f836689d --- /dev/null +++ b/cmd/bw/cross_repo.go @@ -0,0 +1,160 @@ +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 +} + +// isPrefixCandidate reports whether s looks like a beadwork prefix token. +func isPrefixCandidate(s string) bool { + if s == "" || len(s) > 16 { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z': + case c >= 'A' && c <= 'Z': + case c >= '0' && c <= '9': + case c == '_' || c == '-': + default: + return false + } + } + return true +} 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..7b05cd66 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,284 @@ 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. +func TestCFlagAcceptsAlias(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "legacy", "--id", "test-lg1") + env.bw("close", "test-lg1") + env.bw("config", "set", "prefix", "renamed") + + // The old prefix "test" is now an alias. -C test should still find it. + out := env.bw("-C", "test", "show", "test-lg1") + if !strings.Contains(out, "legacy") { + t.Errorf("-C did not resolve:\n%s", out) + } +} + +// 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") + + 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") + + 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) From b0b8384bd0abaa68ef87456dcab7da6745af9bca Mon Sep 17 00:00:00 2001 From: Manuel Zubieta Date: Tue, 14 Apr 2026 08:31:15 -0400 Subject: [PATCH 2/6] bw config set prefix: rename with alias tracking Let users rename a repo's prefix without losing history or breaking cross-repo references: - Open issues are renamed in place (filenames + content updated) - Closed issues keep their original IDs so git history stays intact - The old prefix is recorded as an alias in .bwconfig, so cross-repo lookups for old IDs continue to resolve - Aliases are skipped when another repo has since claimed the old prefix, avoiding ambiguity errors on rename --- cmd/bw/config.go | 8 +- cmd/bw/config_prefix.go | 315 ++++++++++++++++++++++++++++++++++ cmd/bw/cross_repo.go | 6 +- internal/registry/registry.go | 23 ++- internal/repo/repo.go | 35 ++++ test/acceptance_test.go | 200 +++++++++++++++++++++ 6 files changed, 578 insertions(+), 9 deletions(-) create mode 100644 cmd/bw/config_prefix.go diff --git a/cmd/bw/config.go b/cmd/bw/config.go index ce453c73..ff42c9ea 100644 --- a/cmd/bw/config.go +++ b/cmd/bw/config.go @@ -41,7 +41,7 @@ func parseConfigArgs(raw []string) (ConfigArgs, error) { return ca, nil } -func cmdConfig(store *issue.Store, args []string, w Writer, _ *config.Config) (*config.Config, error) { +func cmdConfig(store *issue.Store, args []string, w Writer, cfg *config.Config) (*config.Config, error) { ca, err := parseConfigArgs(args) if err != nil { return nil, err @@ -57,6 +57,12 @@ func cmdConfig(store *issue.Store, args []string, w Writer, _ *config.Config) (* fmt.Fprintln(w, val) case "set": + // Prefix changes are special: they atomically rename the active + // (open + in_progress) issues so cross-repo lookups stay consistent. + // Closed issues keep their old prefix as a historical record. + if ca.Key == "prefix" { + return renamePrefix(store, r, ca.Value, w, cfg) + } if err := r.SetConfig(ca.Key, ca.Value); err != nil { return nil, err } diff --git a/cmd/bw/config_prefix.go b/cmd/bw/config_prefix.go new file mode 100644 index 00000000..6229e7d1 --- /dev/null +++ b/cmd/bw/config_prefix.go @@ -0,0 +1,315 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "path/filepath" + + "github.com/jallum/beadwork/internal/config" + "github.com/jallum/beadwork/internal/issue" + "github.com/jallum/beadwork/internal/registry" + "github.com/jallum/beadwork/internal/repo" + "github.com/jallum/beadwork/internal/treefs" +) + +// renamePrefix changes the repo's prefix in .bwconfig and migrates +// open/in_progress issues (plus their descendant subtrees) to the new +// prefix. Closed issues keep their old prefix as a historical record; +// references to renamed issues from closed-issue JSON are updated. +// +// All changes land as a single commit. +func renamePrefix(store *issue.Store, r *repo.Repo, newPrefix string, w Writer, cfg *config.Config) (*config.Config, error) { + if err := repo.ValidatePrefix(newPrefix); err != nil { + return nil, err + } + if newPrefix == "" { + return nil, fmt.Errorf("prefix cannot be empty") + } + oldPrefix := r.Prefix + if oldPrefix == newPrefix { + return nil, fmt.Errorf("prefix is already %q", newPrefix) + } + + all, err := loadAllIssues(store, r.TreeFS()) + if err != nil { + return nil, err + } + + // Build rename map: top-level open/in_progress with the old prefix, + // plus their entire descendant subtrees (children's IDs structurally + // depend on the parent's ID). + renameMap := buildRenameMap(all, oldPrefix, newPrefix) + + // Update .bwconfig: new prefix + alias bookkeeping. + // + // The old prefix normally becomes an alias so closed issues with + // the old prefix remain reachable cross-repo. BUT if another repo + // already claims the old prefix (as its primary or an alias), + // adding an alias here would create or prolong a collision — and + // the user is often renaming specifically to escape one. Skip the + // alias in that case and surface a note. + collisionRepo := "" + if oldPrefix != "" { + collisionRepo = otherRepoClaiming(r, oldPrefix, cfg) + } + existingAliases := r.Aliases() + var newAliases []string + if collisionRepo != "" { + // Don't alias a prefix owned elsewhere. Existing aliases that + // don't conflict with newPrefix stay. + newAliases = mergeAliases(existingAliases, "", newPrefix) + fmt.Fprintf(os.Stderr, + "note: prefix %q is also used by %s — not adding as alias for this repo\n", + oldPrefix, collisionRepo) + } else { + newAliases = mergeAliases(existingAliases, oldPrefix, newPrefix) + } + if err := r.SetAliases(newAliases); err != nil { + return nil, err + } + if err := r.SetConfig("prefix", newPrefix); err != nil { + return nil, err + } + + if len(renameMap) == 0 { + intent := fmt.Sprintf("config prefix=%s", newPrefix) + if err := r.Commit(intent); err != nil { + return nil, fmt.Errorf("commit failed: %w", err) + } + fmt.Fprintf(w, "prefix=%s (no open issues to rename)\n", newPrefix) + return nil, nil + } + + fs := r.TreeFS() + + // 1. Rewrite issue JSONs. + for _, iss := range all { + newID, renamed := renameMap[iss.ID] + + // Update reference fields whether or not this issue is itself renamed. + dirty := false + if v, ok := renameMap[iss.Parent]; ok { + iss.Parent = v + dirty = true + } + for i, id := range iss.Blocks { + if v, ok := renameMap[id]; ok { + iss.Blocks[i] = v + dirty = true + } + } + for i, id := range iss.BlockedBy { + if v, ok := renameMap[id]; ok { + iss.BlockedBy[i] = v + dirty = true + } + } + + if renamed { + // Remove the old JSON file; write under the new ID. + fs.Remove("issues/" + iss.ID + ".json") + iss.ID = newID + if err := writeJSON(fs, "issues/"+newID+".json", iss); err != nil { + return nil, err + } + } else if dirty { + if err := writeJSON(fs, "issues/"+iss.ID+".json", iss); err != nil { + return nil, err + } + } + } + + // 2. Move status markers for renamed issues. + for _, status := range []string{"open", "in_progress", "closed", "deferred"} { + entries, _ := fs.ReadDir("status/" + status) + for _, e := range entries { + if e.Name() == ".gitkeep" { + continue + } + if newID, ok := renameMap[e.Name()]; ok { + fs.Remove("status/" + status + "/" + e.Name()) + fs.WriteFile("status/"+status+"/"+newID, []byte{}) + } + } + } + + // 3. Move label markers for renamed issues. + labelDirs, _ := fs.ReadDir("labels") + for _, ld := range labelDirs { + if !ld.IsDir() { + continue + } + entries, _ := fs.ReadDir("labels/" + ld.Name()) + for _, e := range entries { + if e.Name() == ".gitkeep" { + continue + } + if newID, ok := renameMap[e.Name()]; ok { + fs.Remove("labels/" + ld.Name() + "/" + e.Name()) + fs.WriteFile("labels/"+ld.Name()+"/"+newID, []byte{}) + } + } + } + + // 4. Move block edges. blocks// — either side may + // have been renamed. + blockerDirs, _ := fs.ReadDir("blocks") + for _, bd := range blockerDirs { + if !bd.IsDir() { + continue + } + blocker := bd.Name() + newBlocker, blockerRenamed := renameMap[blocker] + entries, _ := fs.ReadDir("blocks/" + blocker) + for _, e := range entries { + if e.Name() == ".gitkeep" { + continue + } + blocked := e.Name() + newBlocked, blockedRenamed := renameMap[blocked] + + if !blockerRenamed && !blockedRenamed { + continue + } + finalBlocker := blocker + if blockerRenamed { + finalBlocker = newBlocker + } + finalBlocked := blocked + if blockedRenamed { + finalBlocked = newBlocked + } + fs.Remove("blocks/" + blocker + "/" + blocked) + fs.MkdirAll("blocks/" + finalBlocker) + fs.WriteFile("blocks/"+finalBlocker+"/"+finalBlocked, []byte{}) + } + } + + // 5. Commit everything atomically — .bwconfig (prefix + aliases), + // renamed issue JSONs, status/label/block edge files. + intent := fmt.Sprintf("config prefix=%s (renamed %d issue(s))", newPrefix, len(renameMap)) + if err := r.Commit(intent); err != nil { + return nil, fmt.Errorf("commit failed: %w", err) + } + + if collisionRepo != "" { + fmt.Fprintf(w, "prefix=%s (renamed %d open/in_progress issue(s); old prefix %q NOT aliased — claimed by %s)\n", + newPrefix, len(renameMap), oldPrefix, collisionRepo) + } else { + fmt.Fprintf(w, "prefix=%s (renamed %d open/in_progress issue(s); %q kept as alias)\n", + newPrefix, len(renameMap), oldPrefix) + } + return nil, nil +} + +// otherRepoClaiming returns the path of another registered repo that +// already has `prefix` as its primary or one of its aliases, or "" if +// none does. Used by the renamer to decide whether to add `prefix` to +// this repo's alias list. +func otherRepoClaiming(r *repo.Repo, prefix string, cfg *config.Config) string { + selfPath, _ := filepath.EvalSymlinks(r.RepoDir()) + for _, path := range registry.ResolveAll(cfg, prefix) { + canon, _ := filepath.EvalSymlinks(path) + if canon != selfPath { + return path + } + } + return "" +} + +// mergeAliases produces the new alias list after a prefix rename. +// Adds oldPrefix unless it's already present or empty; removes newPrefix +// if it was previously an alias (rename back to a former prefix). +func mergeAliases(existing []string, oldPrefix, newPrefix string) []string { + seen := map[string]bool{} + var out []string + add := func(s string) { + if s == "" || s == newPrefix || seen[s] { + return + } + seen[s] = true + out = append(out, s) + } + for _, a := range existing { + add(a) + } + add(oldPrefix) + return out +} + +// buildRenameMap returns oldID → newID for every issue that should be +// renamed. The renamed set is: top-level (no parent, no '.') open or +// in_progress issues whose ID begins with oldPrefix+"-", plus all their +// descendants (regardless of status — IDs structurally include the +// parent's ID). +func buildRenameMap(all []*issue.Issue, oldPrefix, newPrefix string) map[string]string { + renameMap := make(map[string]string) + roots := []string{} + + for _, iss := range all { + if iss.Status != "open" && iss.Status != "in_progress" { + continue + } + if iss.Parent != "" { + continue + } + if strings.Contains(iss.ID, ".") { + continue + } + if !strings.HasPrefix(iss.ID, oldPrefix+"-") { + continue + } + newID := newPrefix + iss.ID[len(oldPrefix):] + renameMap[iss.ID] = newID + roots = append(roots, iss.ID) + } + + // Expand to descendants. Issue with ID like "." gets renamed. + for _, iss := range all { + for _, root := range roots { + if strings.HasPrefix(iss.ID, root+".") { + newID := renameMap[root] + iss.ID[len(root):] + renameMap[iss.ID] = newID + break + } + } + } + + return renameMap +} + +// loadAllIssues reads every issue JSON from issues/. +func loadAllIssues(_ *issue.Store, fs *treefs.TreeFS) ([]*issue.Issue, error) { + entries, err := fs.ReadDir("issues") + if err != nil { + return nil, err + } + var out []*issue.Issue + for _, e := range entries { + if e.IsDir() || e.Name() == ".gitkeep" { + continue + } + data, err := fs.ReadFile("issues/" + e.Name()) + if err != nil { + continue + } + var iss issue.Issue + if err := json.Unmarshal(data, &iss); err != nil { + continue + } + out = append(out, &iss) + } + return out, nil +} + +func writeJSON(fs *treefs.TreeFS, path string, v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return fs.WriteFile(path, data) +} diff --git a/cmd/bw/cross_repo.go b/cmd/bw/cross_repo.go index f836689d..cd53a68a 100644 --- a/cmd/bw/cross_repo.go +++ b/cmd/bw/cross_repo.go @@ -95,13 +95,17 @@ func resolveCrossRepo(cfg *config.Config, args []string) { } } -// knownPrefixes returns the set of live prefixes from registered repos. +// knownPrefixes scans registered repos and returns the set of live prefixes +// and aliases. 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 } + for _, a := range r.Aliases { + known[a] = true + } } return known } diff --git a/internal/registry/registry.go b/internal/registry/registry.go index cf7c818e..4e40aeec 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -19,14 +19,15 @@ func Paths(cfg *config.Config) []string { return cfg.StringSlice(key) } -// Repo pairs a filesystem path with the prefix read live from the repo. +// Repo pairs a filesystem path with the prefix and aliases read live from the repo. type Repo struct { - Path string - Prefix string + Path string + Prefix string + Aliases []string } -// Repos returns all registered repos with their prefixes. Entries that -// can't be opened or aren't initialized are silently skipped. +// Repos returns all registered repos with their prefixes and aliases. +// Entries that can't be opened or aren't initialized are silently skipped. func Repos(cfg *config.Config) []Repo { var out []Repo for _, p := range Paths(cfg) { @@ -34,7 +35,7 @@ func Repos(cfg *config.Config) []Repo { if err != nil || !r.IsInitialized() { continue } - out = append(out, Repo{Path: p, Prefix: r.Prefix}) + out = append(out, Repo{Path: p, Prefix: r.Prefix, Aliases: r.Aliases()}) } return out } @@ -50,12 +51,20 @@ func Resolve(cfg *config.Config, prefix string) (string, bool) { return "", false } -// ResolveAll returns all repo paths that share the given prefix. +// ResolveAll returns all repo paths whose current prefix or aliases +// match 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) + continue + } + for _, a := range r.Aliases { + if a == prefix { + paths = append(paths, r.Path) + break + } } } return paths diff --git a/internal/repo/repo.go b/internal/repo/repo.go index 4c71ccc6..2ff3c02d 100644 --- a/internal/repo/repo.go +++ b/internal/repo/repo.go @@ -322,6 +322,41 @@ func (r *Repo) SetConfig(key, value string) error { return r.tfs.WriteFile(".bwconfig", []byte(data)) } +// Aliases returns former prefixes this repo has used, read from the +// "aliases" key in .bwconfig (comma-separated). Returns nil if unset. +func (r *Repo) Aliases() []string { + raw, ok := r.GetConfig("aliases") + if !ok || raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// SetAliases writes the aliases list to .bwconfig. Pass an empty slice +// to clear aliases. Callers are expected to commit afterward. +func (r *Repo) SetAliases(aliases []string) error { + if len(aliases) == 0 { + // Clear by writing all config minus aliases. + cfg := r.ListConfig() + delete(cfg, "aliases") + var lines []string + for k, v := range cfg { + lines = append(lines, k+"="+v) + } + sort.Strings(lines) + data := strings.Join(lines, "\n") + "\n" + return r.tfs.WriteFile(".bwconfig", []byte(data)) + } + return r.SetConfig("aliases", strings.Join(aliases, ",")) +} + // ListConfig reads all key=value pairs from .bwconfig. func (r *Repo) ListConfig() map[string]string { cfg := make(map[string]string) diff --git a/test/acceptance_test.go b/test/acceptance_test.go index 7b05cd66..72947c42 100644 --- a/test/acceptance_test.go +++ b/test/acceptance_test.go @@ -1201,6 +1201,206 @@ func TestCrossRepoPrefixCollision(t *testing.T) { } } +// TestConfigSetPrefixRenamesOpen verifies `bw config set prefix` rewrites +// open/in_progress issue IDs while keeping closed ones as historical +// record. References from closed issues to renamed open issues update. +func TestConfigSetPrefixRenamesOpen(t *testing.T) { + env := newBwEnv(t) + + // 4 issues: 2 open (one with a child), 2 closed (one referencing the open one). + env.bw("create", "open root", "--id", "test-or1") + env.bw("create", "open child", "--id", "test-or1.1", "--parent", "test-or1") + env.bw("create", "another open", "--id", "test-or2") + env.bw("create", "closed one", "--id", "test-cl1") + env.bw("create", "closed referrer", "--id", "test-cl2") + + // test-or2 blocks test-cl2; test-cl2 will be closed. + env.bw("dep", "add", "test-or2", "blocks", "test-cl2") + + env.bw("close", "test-cl1") + env.bw("close", "test-cl2") + + // Now rename prefix. + out := env.bw("config", "set", "prefix", "newt") + if !strings.Contains(out, "renamed") { + t.Errorf("expected rename summary:\n%s", out) + } + + // Open issues moved to new prefix. + out2 := env.bw("show", "newt-or1") + if !strings.Contains(out2, "open root") { + t.Errorf("renamed open root not found:\n%s", out2) + } + out3 := env.bw("show", "newt-or1.1") + if !strings.Contains(out3, "open child") { + t.Errorf("renamed open child not found:\n%s", out3) + } + + // Old IDs for renamed issues are gone. + gone := env.bwFail("show", "test-or1") + if !strings.Contains(gone, "no issue found") { + t.Errorf("old open ID still resolves:\n%s", gone) + } + + // Closed issues keep their old prefix. + out4 := env.bw("show", "test-cl1") + if !strings.Contains(out4, "closed one") { + t.Errorf("closed issue prefix changed unexpectedly:\n%s", out4) + } + + // Closed-referrer's blocked_by should now point at the renamed ID. + out5 := env.bw("show", "test-cl2", "--json") + if strings.Contains(out5, "test-or2") || !strings.Contains(out5, "newt-or2") { + t.Errorf("blocked_by ref not updated in closed issue:\n%s", out5) + } + + // .bwconfig prefix updated. + out6 := env.bw("config", "get", "prefix") + if strings.TrimSpace(out6) != "newt" { + t.Errorf("config get prefix = %q, want newt", strings.TrimSpace(out6)) + } +} + +// TestConfigSetPrefixNoOpenIssues verifies the rename works on a repo +// with no open issues — just changes .bwconfig. +func TestConfigSetPrefixNoOpenIssues(t *testing.T) { + env := newBwEnv(t) + env.bw("create", "x", "--id", "test-x1") + env.bw("close", "test-x1") + + out := env.bw("config", "set", "prefix", "renamed") + if !strings.Contains(out, "no open issues to rename") { + t.Errorf("expected 'no open issues to rename' message:\n%s", out) + } + if !strings.Contains(out, "renamed") { + t.Errorf("expected new prefix in output:\n%s", out) + } + + // Closed issue still has old prefix. + out2 := env.bw("show", "test-x1") + if !strings.Contains(out2, "test-x1") { + t.Errorf("closed issue ID changed unexpectedly:\n%s", out2) + } +} + +// TestConfigSetPrefixAliasesOldForCrossRepo verifies that after a rename, +// the OLD prefix is registered as an alias so closed issues with the old +// prefix remain reachable cross-repo (and reopening them still routes +// correctly from other machines). +func TestConfigSetPrefixAliasesOldForCrossRepo(t *testing.T) { + env := newBwEnv(t) + + // One open + one closed before rename. Open will be migrated; closed + // keeps the old prefix. + env.bw("create", "open", "--id", "test-op1") + env.bw("create", "closed", "--id", "test-cl1") + env.bw("close", "test-cl1") + + env.bw("config", "set", "prefix", "newt") + + // Verify the repo's bwconfig shows the new prefix and old alias. + cfgOut := env.bw("config", "get", "prefix") + if !strings.Contains(cfgOut, "newt") { + t.Errorf("prefix not updated: %s", cfgOut) + } + aliasOut := env.bw("config", "get", "aliases") + if !strings.Contains(aliasOut, "test") { + t.Errorf("old prefix not registered as alias: %s", aliasOut) + } + + // From a non-beadwork dir, BOTH the new-prefix open issue and the + // old-prefix closed issue must resolve. + nonRepo := t.TempDir() + caller := &bwEnv{ + t: t, + dir: nonRepo, + cfgPath: env.cfgPath, + env: append(os.Environ(), + "BW_CLOCK="+fixedClock, + "NO_COLOR=1", + "BW_CONFIG="+env.cfgPath, + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + ), + } + if out := caller.bw("show", "newt-op1"); !strings.Contains(out, "open") { + t.Errorf("renamed open issue not reachable cross-repo:\n%s", out) + } + if out := caller.bw("show", "test-cl1"); !strings.Contains(out, "closed") { + t.Errorf("old-prefix closed issue not reachable cross-repo:\n%s", out) + } + + // Reopening a closed old-prefix issue should still route correctly. + caller.bw("reopen", "test-cl1") + if out := caller.bw("show", "test-cl1", "--json"); !strings.Contains(out, `"open"`) { + t.Errorf("reopen of old-prefix closed issue did not land:\n%s", out) + } +} + +// TestConfigSetPrefixSkipsAliasWhenCollides verifies that renaming one of +// two repos sharing a prefix does NOT add the old prefix as an alias on +// the renamed repo — otherwise the collision would persist. After the +// rename, the old prefix should unambiguously resolve to the *other* repo. +func TestConfigSetPrefixSkipsAliasWhenCollides(t *testing.T) { + envs := newMultiRepoEnv(t, 2) + envs[0].bw("list") + envs[1].bw("list") + + // Force the prefix collision: change envs[0]'s prefix to "r1" so + // both repos share the same prefix. + envs[0].bw("config", "set", "prefix", "r1") + envs[1].bw("create", "target", "--id", "r1-t1") + + // Rename envs[1] from r1 -> newr1. Should detect the collision + // with envs[0] and skip aliasing. + out := envs[1].bw("config", "set", "prefix", "newr1") + if !strings.Contains(out, "NOT aliased") { + t.Errorf("expected collision note in rename output:\n%s", out) + } + + // After rename, looking up the old prefix "r1" from a neutral dir + // should unambiguously point to envs[0] (the non-renamed repo) — + // envs[1] should NOT claim "r1" via alias anymore. + nonRepo := t.TempDir() + caller := &bwEnv{ + t: t, + dir: nonRepo, + cfgPath: envs[1].cfgPath, + env: append(os.Environ(), + "BW_CLOCK="+fixedClock, + "NO_COLOR=1", + "BW_CONFIG="+envs[1].cfgPath, + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + ), + } + + // envs[0] now has prefix r1 — so "r1-t1" should resolve there, + // not trigger a collision. + out2 := caller.bwFail("show", "r1-t1") + if strings.Contains(out2, "registered for 2 repositories") { + t.Errorf("collision should be resolved after rename:\n%s", out2) + } +} + +// TestConfigSetPrefixSameIsRejected verifies setting the same prefix errors. +func TestConfigSetPrefixSameIsRejected(t *testing.T) { + env := newBwEnv(t) + out := env.bwFail("config", "set", "prefix", "test") + if !strings.Contains(out, "already") { + t.Errorf("expected 'already' in error:\n%s", out) + } +} + +// TestConfigSetPrefixInvalidIsRejected verifies bad prefixes are rejected. +func TestConfigSetPrefixInvalidIsRejected(t *testing.T) { + env := newBwEnv(t) + out := env.bwFail("config", "set", "prefix", "has spaces") + if !strings.Contains(out, "invalid") && !strings.Contains(out, "must") { + t.Errorf("expected validation error:\n%s", out) + } +} + // TestCrossRepoUnknownPrefixFallsThrough verifies an unrecognized prefix // is not rewritten (command runs against current repo and fails normally). func TestCrossRepoUnknownPrefixFallsThrough(t *testing.T) { From 11de59002fced8fdb86234a915901f9f682cfa75 Mon Sep 17 00:00:00 2001 From: Manuel Zubieta Date: Thu, 16 Apr 2026 15:10:49 -0400 Subject: [PATCH 3/6] Remove unused treefs_test helpers and isPrefixCandidate Staticcheck flags containsStr, contains (treefs_test.go) and isPrefixCandidate (cross_repo.go) as U1000. The treefs helpers were orphaned when "Record unblocked events" swapped to errors.Is. The prefix-candidate check is no longer called from the -C path after the registry-backed resolver landed. --- cmd/bw/cross_repo.go | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/cmd/bw/cross_repo.go b/cmd/bw/cross_repo.go index cd53a68a..34645740 100644 --- a/cmd/bw/cross_repo.go +++ b/cmd/bw/cross_repo.go @@ -144,21 +144,3 @@ func extractPrefixCandidates(args []string, known map[string]bool) []string { return prefixes } -// isPrefixCandidate reports whether s looks like a beadwork prefix token. -func isPrefixCandidate(s string) bool { - if s == "" || len(s) > 16 { - return false - } - for i := 0; i < len(s); i++ { - c := s[i] - switch { - case c >= 'a' && c <= 'z': - case c >= 'A' && c <= 'Z': - case c >= '0' && c <= '9': - case c == '_' || c == '-': - default: - return false - } - } - return true -} From 4c35891bf11f95e0204c95957e274cf1934d09be Mon Sep 17 00:00:00 2001 From: Jason Allum <21417+jallum@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:59:03 -0400 Subject: [PATCH 4/6] renamePrefix returns error, not (*Config, error) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It never modifies the global config — the two-value return was unnecessary ceremony from the rebase. --- cmd/bw/config.go | 2 +- cmd/bw/config_prefix.go | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/cmd/bw/config.go b/cmd/bw/config.go index ff42c9ea..20edeaeb 100644 --- a/cmd/bw/config.go +++ b/cmd/bw/config.go @@ -61,7 +61,7 @@ func cmdConfig(store *issue.Store, args []string, w Writer, cfg *config.Config) // (open + in_progress) issues so cross-repo lookups stay consistent. // Closed issues keep their old prefix as a historical record. if ca.Key == "prefix" { - return renamePrefix(store, r, ca.Value, w, cfg) + return nil, renamePrefix(store, r, ca.Value, w, cfg) } if err := r.SetConfig(ca.Key, ca.Value); err != nil { return nil, err diff --git a/cmd/bw/config_prefix.go b/cmd/bw/config_prefix.go index 6229e7d1..29d182b3 100644 --- a/cmd/bw/config_prefix.go +++ b/cmd/bw/config_prefix.go @@ -21,21 +21,21 @@ import ( // references to renamed issues from closed-issue JSON are updated. // // All changes land as a single commit. -func renamePrefix(store *issue.Store, r *repo.Repo, newPrefix string, w Writer, cfg *config.Config) (*config.Config, error) { +func renamePrefix(store *issue.Store, r *repo.Repo, newPrefix string, w Writer, cfg *config.Config) error { if err := repo.ValidatePrefix(newPrefix); err != nil { - return nil, err + return err } if newPrefix == "" { - return nil, fmt.Errorf("prefix cannot be empty") + return fmt.Errorf("prefix cannot be empty") } oldPrefix := r.Prefix if oldPrefix == newPrefix { - return nil, fmt.Errorf("prefix is already %q", newPrefix) + return fmt.Errorf("prefix is already %q", newPrefix) } all, err := loadAllIssues(store, r.TreeFS()) if err != nil { - return nil, err + return err } // Build rename map: top-level open/in_progress with the old prefix, @@ -68,19 +68,19 @@ func renamePrefix(store *issue.Store, r *repo.Repo, newPrefix string, w Writer, newAliases = mergeAliases(existingAliases, oldPrefix, newPrefix) } if err := r.SetAliases(newAliases); err != nil { - return nil, err + return err } if err := r.SetConfig("prefix", newPrefix); err != nil { - return nil, err + return err } if len(renameMap) == 0 { intent := fmt.Sprintf("config prefix=%s", newPrefix) if err := r.Commit(intent); err != nil { - return nil, fmt.Errorf("commit failed: %w", err) + return fmt.Errorf("commit failed: %w", err) } fmt.Fprintf(w, "prefix=%s (no open issues to rename)\n", newPrefix) - return nil, nil + return nil } fs := r.TreeFS() @@ -113,11 +113,11 @@ func renamePrefix(store *issue.Store, r *repo.Repo, newPrefix string, w Writer, fs.Remove("issues/" + iss.ID + ".json") iss.ID = newID if err := writeJSON(fs, "issues/"+newID+".json", iss); err != nil { - return nil, err + return err } } else if dirty { if err := writeJSON(fs, "issues/"+iss.ID+".json", iss); err != nil { - return nil, err + return err } } } @@ -192,7 +192,7 @@ func renamePrefix(store *issue.Store, r *repo.Repo, newPrefix string, w Writer, // renamed issue JSONs, status/label/block edge files. intent := fmt.Sprintf("config prefix=%s (renamed %d issue(s))", newPrefix, len(renameMap)) if err := r.Commit(intent); err != nil { - return nil, fmt.Errorf("commit failed: %w", err) + return fmt.Errorf("commit failed: %w", err) } if collisionRepo != "" { @@ -202,7 +202,7 @@ func renamePrefix(store *issue.Store, r *repo.Repo, newPrefix string, w Writer, fmt.Fprintf(w, "prefix=%s (renamed %d open/in_progress issue(s); %q kept as alias)\n", newPrefix, len(renameMap), oldPrefix) } - return nil, nil + return nil } // otherRepoClaiming returns the path of another registered repo that From 4a22240118e8e2ba2285132fd93ddda5a7547dde Mon Sep 17 00:00:00 2001 From: Jason Allum <21417+jallum@users.noreply.github.com> Date: Sat, 25 Apr 2026 12:20:15 -0400 Subject: [PATCH 5/6] Drop prefix rename and alias machinery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config set prefix is now a plain SetConfig like any other key — no issue renaming, no alias bookkeeping, no collision detection for aliases. Removes config_prefix.go, Aliases/SetAliases from repo, and alias support from the registry and cross-repo resolver. --- cmd/bw/config.go | 8 +- cmd/bw/config_prefix.go | 315 ---------------------------------- cmd/bw/cross_repo.go | 6 +- internal/registry/registry.go | 23 +-- internal/repo/repo.go | 35 ---- test/acceptance_test.go | 215 +---------------------- 6 files changed, 11 insertions(+), 591 deletions(-) delete mode 100644 cmd/bw/config_prefix.go diff --git a/cmd/bw/config.go b/cmd/bw/config.go index 20edeaeb..ce453c73 100644 --- a/cmd/bw/config.go +++ b/cmd/bw/config.go @@ -41,7 +41,7 @@ func parseConfigArgs(raw []string) (ConfigArgs, error) { return ca, nil } -func cmdConfig(store *issue.Store, args []string, w Writer, cfg *config.Config) (*config.Config, error) { +func cmdConfig(store *issue.Store, args []string, w Writer, _ *config.Config) (*config.Config, error) { ca, err := parseConfigArgs(args) if err != nil { return nil, err @@ -57,12 +57,6 @@ func cmdConfig(store *issue.Store, args []string, w Writer, cfg *config.Config) fmt.Fprintln(w, val) case "set": - // Prefix changes are special: they atomically rename the active - // (open + in_progress) issues so cross-repo lookups stay consistent. - // Closed issues keep their old prefix as a historical record. - if ca.Key == "prefix" { - return nil, renamePrefix(store, r, ca.Value, w, cfg) - } if err := r.SetConfig(ca.Key, ca.Value); err != nil { return nil, err } diff --git a/cmd/bw/config_prefix.go b/cmd/bw/config_prefix.go deleted file mode 100644 index 29d182b3..00000000 --- a/cmd/bw/config_prefix.go +++ /dev/null @@ -1,315 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "strings" - - "path/filepath" - - "github.com/jallum/beadwork/internal/config" - "github.com/jallum/beadwork/internal/issue" - "github.com/jallum/beadwork/internal/registry" - "github.com/jallum/beadwork/internal/repo" - "github.com/jallum/beadwork/internal/treefs" -) - -// renamePrefix changes the repo's prefix in .bwconfig and migrates -// open/in_progress issues (plus their descendant subtrees) to the new -// prefix. Closed issues keep their old prefix as a historical record; -// references to renamed issues from closed-issue JSON are updated. -// -// All changes land as a single commit. -func renamePrefix(store *issue.Store, r *repo.Repo, newPrefix string, w Writer, cfg *config.Config) error { - if err := repo.ValidatePrefix(newPrefix); err != nil { - return err - } - if newPrefix == "" { - return fmt.Errorf("prefix cannot be empty") - } - oldPrefix := r.Prefix - if oldPrefix == newPrefix { - return fmt.Errorf("prefix is already %q", newPrefix) - } - - all, err := loadAllIssues(store, r.TreeFS()) - if err != nil { - return err - } - - // Build rename map: top-level open/in_progress with the old prefix, - // plus their entire descendant subtrees (children's IDs structurally - // depend on the parent's ID). - renameMap := buildRenameMap(all, oldPrefix, newPrefix) - - // Update .bwconfig: new prefix + alias bookkeeping. - // - // The old prefix normally becomes an alias so closed issues with - // the old prefix remain reachable cross-repo. BUT if another repo - // already claims the old prefix (as its primary or an alias), - // adding an alias here would create or prolong a collision — and - // the user is often renaming specifically to escape one. Skip the - // alias in that case and surface a note. - collisionRepo := "" - if oldPrefix != "" { - collisionRepo = otherRepoClaiming(r, oldPrefix, cfg) - } - existingAliases := r.Aliases() - var newAliases []string - if collisionRepo != "" { - // Don't alias a prefix owned elsewhere. Existing aliases that - // don't conflict with newPrefix stay. - newAliases = mergeAliases(existingAliases, "", newPrefix) - fmt.Fprintf(os.Stderr, - "note: prefix %q is also used by %s — not adding as alias for this repo\n", - oldPrefix, collisionRepo) - } else { - newAliases = mergeAliases(existingAliases, oldPrefix, newPrefix) - } - if err := r.SetAliases(newAliases); err != nil { - return err - } - if err := r.SetConfig("prefix", newPrefix); err != nil { - return err - } - - if len(renameMap) == 0 { - intent := fmt.Sprintf("config prefix=%s", newPrefix) - if err := r.Commit(intent); err != nil { - return fmt.Errorf("commit failed: %w", err) - } - fmt.Fprintf(w, "prefix=%s (no open issues to rename)\n", newPrefix) - return nil - } - - fs := r.TreeFS() - - // 1. Rewrite issue JSONs. - for _, iss := range all { - newID, renamed := renameMap[iss.ID] - - // Update reference fields whether or not this issue is itself renamed. - dirty := false - if v, ok := renameMap[iss.Parent]; ok { - iss.Parent = v - dirty = true - } - for i, id := range iss.Blocks { - if v, ok := renameMap[id]; ok { - iss.Blocks[i] = v - dirty = true - } - } - for i, id := range iss.BlockedBy { - if v, ok := renameMap[id]; ok { - iss.BlockedBy[i] = v - dirty = true - } - } - - if renamed { - // Remove the old JSON file; write under the new ID. - fs.Remove("issues/" + iss.ID + ".json") - iss.ID = newID - if err := writeJSON(fs, "issues/"+newID+".json", iss); err != nil { - return err - } - } else if dirty { - if err := writeJSON(fs, "issues/"+iss.ID+".json", iss); err != nil { - return err - } - } - } - - // 2. Move status markers for renamed issues. - for _, status := range []string{"open", "in_progress", "closed", "deferred"} { - entries, _ := fs.ReadDir("status/" + status) - for _, e := range entries { - if e.Name() == ".gitkeep" { - continue - } - if newID, ok := renameMap[e.Name()]; ok { - fs.Remove("status/" + status + "/" + e.Name()) - fs.WriteFile("status/"+status+"/"+newID, []byte{}) - } - } - } - - // 3. Move label markers for renamed issues. - labelDirs, _ := fs.ReadDir("labels") - for _, ld := range labelDirs { - if !ld.IsDir() { - continue - } - entries, _ := fs.ReadDir("labels/" + ld.Name()) - for _, e := range entries { - if e.Name() == ".gitkeep" { - continue - } - if newID, ok := renameMap[e.Name()]; ok { - fs.Remove("labels/" + ld.Name() + "/" + e.Name()) - fs.WriteFile("labels/"+ld.Name()+"/"+newID, []byte{}) - } - } - } - - // 4. Move block edges. blocks// — either side may - // have been renamed. - blockerDirs, _ := fs.ReadDir("blocks") - for _, bd := range blockerDirs { - if !bd.IsDir() { - continue - } - blocker := bd.Name() - newBlocker, blockerRenamed := renameMap[blocker] - entries, _ := fs.ReadDir("blocks/" + blocker) - for _, e := range entries { - if e.Name() == ".gitkeep" { - continue - } - blocked := e.Name() - newBlocked, blockedRenamed := renameMap[blocked] - - if !blockerRenamed && !blockedRenamed { - continue - } - finalBlocker := blocker - if blockerRenamed { - finalBlocker = newBlocker - } - finalBlocked := blocked - if blockedRenamed { - finalBlocked = newBlocked - } - fs.Remove("blocks/" + blocker + "/" + blocked) - fs.MkdirAll("blocks/" + finalBlocker) - fs.WriteFile("blocks/"+finalBlocker+"/"+finalBlocked, []byte{}) - } - } - - // 5. Commit everything atomically — .bwconfig (prefix + aliases), - // renamed issue JSONs, status/label/block edge files. - intent := fmt.Sprintf("config prefix=%s (renamed %d issue(s))", newPrefix, len(renameMap)) - if err := r.Commit(intent); err != nil { - return fmt.Errorf("commit failed: %w", err) - } - - if collisionRepo != "" { - fmt.Fprintf(w, "prefix=%s (renamed %d open/in_progress issue(s); old prefix %q NOT aliased — claimed by %s)\n", - newPrefix, len(renameMap), oldPrefix, collisionRepo) - } else { - fmt.Fprintf(w, "prefix=%s (renamed %d open/in_progress issue(s); %q kept as alias)\n", - newPrefix, len(renameMap), oldPrefix) - } - return nil -} - -// otherRepoClaiming returns the path of another registered repo that -// already has `prefix` as its primary or one of its aliases, or "" if -// none does. Used by the renamer to decide whether to add `prefix` to -// this repo's alias list. -func otherRepoClaiming(r *repo.Repo, prefix string, cfg *config.Config) string { - selfPath, _ := filepath.EvalSymlinks(r.RepoDir()) - for _, path := range registry.ResolveAll(cfg, prefix) { - canon, _ := filepath.EvalSymlinks(path) - if canon != selfPath { - return path - } - } - return "" -} - -// mergeAliases produces the new alias list after a prefix rename. -// Adds oldPrefix unless it's already present or empty; removes newPrefix -// if it was previously an alias (rename back to a former prefix). -func mergeAliases(existing []string, oldPrefix, newPrefix string) []string { - seen := map[string]bool{} - var out []string - add := func(s string) { - if s == "" || s == newPrefix || seen[s] { - return - } - seen[s] = true - out = append(out, s) - } - for _, a := range existing { - add(a) - } - add(oldPrefix) - return out -} - -// buildRenameMap returns oldID → newID for every issue that should be -// renamed. The renamed set is: top-level (no parent, no '.') open or -// in_progress issues whose ID begins with oldPrefix+"-", plus all their -// descendants (regardless of status — IDs structurally include the -// parent's ID). -func buildRenameMap(all []*issue.Issue, oldPrefix, newPrefix string) map[string]string { - renameMap := make(map[string]string) - roots := []string{} - - for _, iss := range all { - if iss.Status != "open" && iss.Status != "in_progress" { - continue - } - if iss.Parent != "" { - continue - } - if strings.Contains(iss.ID, ".") { - continue - } - if !strings.HasPrefix(iss.ID, oldPrefix+"-") { - continue - } - newID := newPrefix + iss.ID[len(oldPrefix):] - renameMap[iss.ID] = newID - roots = append(roots, iss.ID) - } - - // Expand to descendants. Issue with ID like "." gets renamed. - for _, iss := range all { - for _, root := range roots { - if strings.HasPrefix(iss.ID, root+".") { - newID := renameMap[root] + iss.ID[len(root):] - renameMap[iss.ID] = newID - break - } - } - } - - return renameMap -} - -// loadAllIssues reads every issue JSON from issues/. -func loadAllIssues(_ *issue.Store, fs *treefs.TreeFS) ([]*issue.Issue, error) { - entries, err := fs.ReadDir("issues") - if err != nil { - return nil, err - } - var out []*issue.Issue - for _, e := range entries { - if e.IsDir() || e.Name() == ".gitkeep" { - continue - } - data, err := fs.ReadFile("issues/" + e.Name()) - if err != nil { - continue - } - var iss issue.Issue - if err := json.Unmarshal(data, &iss); err != nil { - continue - } - out = append(out, &iss) - } - return out, nil -} - -func writeJSON(fs *treefs.TreeFS, path string, v any) error { - data, err := json.MarshalIndent(v, "", " ") - if err != nil { - return err - } - data = append(data, '\n') - return fs.WriteFile(path, data) -} diff --git a/cmd/bw/cross_repo.go b/cmd/bw/cross_repo.go index 34645740..836dd15f 100644 --- a/cmd/bw/cross_repo.go +++ b/cmd/bw/cross_repo.go @@ -95,17 +95,13 @@ func resolveCrossRepo(cfg *config.Config, args []string) { } } -// knownPrefixes scans registered repos and returns the set of live prefixes -// and aliases. +// 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 } - for _, a := range r.Aliases { - known[a] = true - } } return known } diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 4e40aeec..cf7c818e 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -19,15 +19,14 @@ func Paths(cfg *config.Config) []string { return cfg.StringSlice(key) } -// Repo pairs a filesystem path with the prefix and aliases read live from the repo. +// Repo pairs a filesystem path with the prefix read live from the repo. type Repo struct { - Path string - Prefix string - Aliases []string + Path string + Prefix string } -// Repos returns all registered repos with their prefixes and aliases. -// Entries that can't be opened or aren't initialized are silently skipped. +// Repos returns all registered repos with their prefixes. Entries that +// can't be opened or aren't initialized are silently skipped. func Repos(cfg *config.Config) []Repo { var out []Repo for _, p := range Paths(cfg) { @@ -35,7 +34,7 @@ func Repos(cfg *config.Config) []Repo { if err != nil || !r.IsInitialized() { continue } - out = append(out, Repo{Path: p, Prefix: r.Prefix, Aliases: r.Aliases()}) + out = append(out, Repo{Path: p, Prefix: r.Prefix}) } return out } @@ -51,20 +50,12 @@ func Resolve(cfg *config.Config, prefix string) (string, bool) { return "", false } -// ResolveAll returns all repo paths whose current prefix or aliases -// match the given prefix. +// 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) - continue - } - for _, a := range r.Aliases { - if a == prefix { - paths = append(paths, r.Path) - break - } } } return paths diff --git a/internal/repo/repo.go b/internal/repo/repo.go index 2ff3c02d..4c71ccc6 100644 --- a/internal/repo/repo.go +++ b/internal/repo/repo.go @@ -322,41 +322,6 @@ func (r *Repo) SetConfig(key, value string) error { return r.tfs.WriteFile(".bwconfig", []byte(data)) } -// Aliases returns former prefixes this repo has used, read from the -// "aliases" key in .bwconfig (comma-separated). Returns nil if unset. -func (r *Repo) Aliases() []string { - raw, ok := r.GetConfig("aliases") - if !ok || raw == "" { - return nil - } - parts := strings.Split(raw, ",") - out := make([]string, 0, len(parts)) - for _, p := range parts { - if p = strings.TrimSpace(p); p != "" { - out = append(out, p) - } - } - return out -} - -// SetAliases writes the aliases list to .bwconfig. Pass an empty slice -// to clear aliases. Callers are expected to commit afterward. -func (r *Repo) SetAliases(aliases []string) error { - if len(aliases) == 0 { - // Clear by writing all config minus aliases. - cfg := r.ListConfig() - delete(cfg, "aliases") - var lines []string - for k, v := range cfg { - lines = append(lines, k+"="+v) - } - sort.Strings(lines) - data := strings.Join(lines, "\n") + "\n" - return r.tfs.WriteFile(".bwconfig", []byte(data)) - } - return r.SetConfig("aliases", strings.Join(aliases, ",")) -} - // ListConfig reads all key=value pairs from .bwconfig. func (r *Repo) ListConfig() map[string]string { cfg := make(map[string]string) diff --git a/test/acceptance_test.go b/test/acceptance_test.go index 72947c42..57ba9e19 100644 --- a/test/acceptance_test.go +++ b/test/acceptance_test.go @@ -1092,19 +1092,6 @@ func TestCFlagAcceptsPrefix(t *testing.T) { } // TestCFlagAcceptsAlias verifies -C works with an alias after a rename. -func TestCFlagAcceptsAlias(t *testing.T) { - env := newBwEnv(t) - env.bw("create", "legacy", "--id", "test-lg1") - env.bw("close", "test-lg1") - env.bw("config", "set", "prefix", "renamed") - - // The old prefix "test" is now an alias. -C test should still find it. - out := env.bw("-C", "test", "show", "test-lg1") - if !strings.Contains(out, "legacy") { - t.Errorf("-C did not resolve:\n%s", out) - } -} - // TestCFlagPathFallsThroughWhenNotPrefix verifies that an explicit path // (absolute or relative) still works even if it isn't a registered prefix. func TestCFlagPathFallsThroughWhenNotPrefix(t *testing.T) { @@ -1141,6 +1128,7 @@ func TestCFlagCollisionErrors(t *testing.T) { 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") { @@ -1172,6 +1160,7 @@ func TestCrossRepoPrefixCollision(t *testing.T) { 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") @@ -1201,206 +1190,6 @@ func TestCrossRepoPrefixCollision(t *testing.T) { } } -// TestConfigSetPrefixRenamesOpen verifies `bw config set prefix` rewrites -// open/in_progress issue IDs while keeping closed ones as historical -// record. References from closed issues to renamed open issues update. -func TestConfigSetPrefixRenamesOpen(t *testing.T) { - env := newBwEnv(t) - - // 4 issues: 2 open (one with a child), 2 closed (one referencing the open one). - env.bw("create", "open root", "--id", "test-or1") - env.bw("create", "open child", "--id", "test-or1.1", "--parent", "test-or1") - env.bw("create", "another open", "--id", "test-or2") - env.bw("create", "closed one", "--id", "test-cl1") - env.bw("create", "closed referrer", "--id", "test-cl2") - - // test-or2 blocks test-cl2; test-cl2 will be closed. - env.bw("dep", "add", "test-or2", "blocks", "test-cl2") - - env.bw("close", "test-cl1") - env.bw("close", "test-cl2") - - // Now rename prefix. - out := env.bw("config", "set", "prefix", "newt") - if !strings.Contains(out, "renamed") { - t.Errorf("expected rename summary:\n%s", out) - } - - // Open issues moved to new prefix. - out2 := env.bw("show", "newt-or1") - if !strings.Contains(out2, "open root") { - t.Errorf("renamed open root not found:\n%s", out2) - } - out3 := env.bw("show", "newt-or1.1") - if !strings.Contains(out3, "open child") { - t.Errorf("renamed open child not found:\n%s", out3) - } - - // Old IDs for renamed issues are gone. - gone := env.bwFail("show", "test-or1") - if !strings.Contains(gone, "no issue found") { - t.Errorf("old open ID still resolves:\n%s", gone) - } - - // Closed issues keep their old prefix. - out4 := env.bw("show", "test-cl1") - if !strings.Contains(out4, "closed one") { - t.Errorf("closed issue prefix changed unexpectedly:\n%s", out4) - } - - // Closed-referrer's blocked_by should now point at the renamed ID. - out5 := env.bw("show", "test-cl2", "--json") - if strings.Contains(out5, "test-or2") || !strings.Contains(out5, "newt-or2") { - t.Errorf("blocked_by ref not updated in closed issue:\n%s", out5) - } - - // .bwconfig prefix updated. - out6 := env.bw("config", "get", "prefix") - if strings.TrimSpace(out6) != "newt" { - t.Errorf("config get prefix = %q, want newt", strings.TrimSpace(out6)) - } -} - -// TestConfigSetPrefixNoOpenIssues verifies the rename works on a repo -// with no open issues — just changes .bwconfig. -func TestConfigSetPrefixNoOpenIssues(t *testing.T) { - env := newBwEnv(t) - env.bw("create", "x", "--id", "test-x1") - env.bw("close", "test-x1") - - out := env.bw("config", "set", "prefix", "renamed") - if !strings.Contains(out, "no open issues to rename") { - t.Errorf("expected 'no open issues to rename' message:\n%s", out) - } - if !strings.Contains(out, "renamed") { - t.Errorf("expected new prefix in output:\n%s", out) - } - - // Closed issue still has old prefix. - out2 := env.bw("show", "test-x1") - if !strings.Contains(out2, "test-x1") { - t.Errorf("closed issue ID changed unexpectedly:\n%s", out2) - } -} - -// TestConfigSetPrefixAliasesOldForCrossRepo verifies that after a rename, -// the OLD prefix is registered as an alias so closed issues with the old -// prefix remain reachable cross-repo (and reopening them still routes -// correctly from other machines). -func TestConfigSetPrefixAliasesOldForCrossRepo(t *testing.T) { - env := newBwEnv(t) - - // One open + one closed before rename. Open will be migrated; closed - // keeps the old prefix. - env.bw("create", "open", "--id", "test-op1") - env.bw("create", "closed", "--id", "test-cl1") - env.bw("close", "test-cl1") - - env.bw("config", "set", "prefix", "newt") - - // Verify the repo's bwconfig shows the new prefix and old alias. - cfgOut := env.bw("config", "get", "prefix") - if !strings.Contains(cfgOut, "newt") { - t.Errorf("prefix not updated: %s", cfgOut) - } - aliasOut := env.bw("config", "get", "aliases") - if !strings.Contains(aliasOut, "test") { - t.Errorf("old prefix not registered as alias: %s", aliasOut) - } - - // From a non-beadwork dir, BOTH the new-prefix open issue and the - // old-prefix closed issue must resolve. - nonRepo := t.TempDir() - caller := &bwEnv{ - t: t, - dir: nonRepo, - cfgPath: env.cfgPath, - env: append(os.Environ(), - "BW_CLOCK="+fixedClock, - "NO_COLOR=1", - "BW_CONFIG="+env.cfgPath, - "GIT_CONFIG_GLOBAL=/dev/null", - "GIT_CONFIG_SYSTEM=/dev/null", - ), - } - if out := caller.bw("show", "newt-op1"); !strings.Contains(out, "open") { - t.Errorf("renamed open issue not reachable cross-repo:\n%s", out) - } - if out := caller.bw("show", "test-cl1"); !strings.Contains(out, "closed") { - t.Errorf("old-prefix closed issue not reachable cross-repo:\n%s", out) - } - - // Reopening a closed old-prefix issue should still route correctly. - caller.bw("reopen", "test-cl1") - if out := caller.bw("show", "test-cl1", "--json"); !strings.Contains(out, `"open"`) { - t.Errorf("reopen of old-prefix closed issue did not land:\n%s", out) - } -} - -// TestConfigSetPrefixSkipsAliasWhenCollides verifies that renaming one of -// two repos sharing a prefix does NOT add the old prefix as an alias on -// the renamed repo — otherwise the collision would persist. After the -// rename, the old prefix should unambiguously resolve to the *other* repo. -func TestConfigSetPrefixSkipsAliasWhenCollides(t *testing.T) { - envs := newMultiRepoEnv(t, 2) - envs[0].bw("list") - envs[1].bw("list") - - // Force the prefix collision: change envs[0]'s prefix to "r1" so - // both repos share the same prefix. - envs[0].bw("config", "set", "prefix", "r1") - envs[1].bw("create", "target", "--id", "r1-t1") - - // Rename envs[1] from r1 -> newr1. Should detect the collision - // with envs[0] and skip aliasing. - out := envs[1].bw("config", "set", "prefix", "newr1") - if !strings.Contains(out, "NOT aliased") { - t.Errorf("expected collision note in rename output:\n%s", out) - } - - // After rename, looking up the old prefix "r1" from a neutral dir - // should unambiguously point to envs[0] (the non-renamed repo) — - // envs[1] should NOT claim "r1" via alias anymore. - nonRepo := t.TempDir() - caller := &bwEnv{ - t: t, - dir: nonRepo, - cfgPath: envs[1].cfgPath, - env: append(os.Environ(), - "BW_CLOCK="+fixedClock, - "NO_COLOR=1", - "BW_CONFIG="+envs[1].cfgPath, - "GIT_CONFIG_GLOBAL=/dev/null", - "GIT_CONFIG_SYSTEM=/dev/null", - ), - } - - // envs[0] now has prefix r1 — so "r1-t1" should resolve there, - // not trigger a collision. - out2 := caller.bwFail("show", "r1-t1") - if strings.Contains(out2, "registered for 2 repositories") { - t.Errorf("collision should be resolved after rename:\n%s", out2) - } -} - -// TestConfigSetPrefixSameIsRejected verifies setting the same prefix errors. -func TestConfigSetPrefixSameIsRejected(t *testing.T) { - env := newBwEnv(t) - out := env.bwFail("config", "set", "prefix", "test") - if !strings.Contains(out, "already") { - t.Errorf("expected 'already' in error:\n%s", out) - } -} - -// TestConfigSetPrefixInvalidIsRejected verifies bad prefixes are rejected. -func TestConfigSetPrefixInvalidIsRejected(t *testing.T) { - env := newBwEnv(t) - out := env.bwFail("config", "set", "prefix", "has spaces") - if !strings.Contains(out, "invalid") && !strings.Contains(out, "must") { - t.Errorf("expected validation error:\n%s", out) - } -} - // TestCrossRepoUnknownPrefixFallsThrough verifies an unrecognized prefix // is not rewritten (command runs against current repo and fails normally). func TestCrossRepoUnknownPrefixFallsThrough(t *testing.T) { From caf23a43d4ebab0ab40a49523aa26d83b0d2106c Mon Sep 17 00:00:00 2001 From: Jason Allum <21417+jallum@users.noreply.github.com> Date: Sat, 9 May 2026 14:20:22 -0400 Subject: [PATCH 6/6] gofmt cmd/bw/cross_repo.go --- cmd/bw/cross_repo.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/bw/cross_repo.go b/cmd/bw/cross_repo.go index 836dd15f..aa9a5b09 100644 --- a/cmd/bw/cross_repo.go +++ b/cmd/bw/cross_repo.go @@ -139,4 +139,3 @@ func extractPrefixCandidates(args []string, known map[string]bool) []string { } return prefixes } -