diff --git a/.buildflags b/.buildflags index c68de5a21a..eef2683327 100644 --- a/.buildflags +++ b/.buildflags @@ -1,9 +1,24 @@ -# This file is sourced by scripts to provide standard build flags. -# Mirrors Makefile CGO configuration for consistency. +# Canonical build flags for beads. Source this in any script that invokes +# `go` to get the project-wide build settings. Scripts that intentionally +# exercise the ICU path (e.g. scripts/test-cgo.sh) do NOT source this. # -# NOTE: ICU flags are NOT set here. The gms_pure_go build tag (used by -# goreleaser and `make build`) tells go-mysql-server to use Go's stdlib -# regex, eliminating the ICU runtime dependency. Only test scripts that -# explicitly need ICU (test-cgo.sh) should set ICU flags. +# Rationale: go-mysql-server links ICU by default under cgo. beads never +# uses SQL REGEXP, so we always build with -tags=gms_pure_go. See +# docs/ICU-POLICY.md for the full policy. -export CGO_ENABLED=1 +# Embedded Dolt requires CGO; server mode and nocgo builds do not. Default +# to 1 for the embedded-capable build paths that sourced this, but respect +# a caller who explicitly set CGO_ENABLED=0 (e.g. a static/nocgo build). +: "${CGO_ENABLED:=1}" +export CGO_ENABLED + +# Build tag that tells go-mysql-server to use Go's stdlib regex instead of +# the ICU-backed go-icu-regex. Exported so callers can compose with extra +# tags: `go test -tags=regression,$BEADS_BUILD_TAGS ...` +export BEADS_BUILD_TAGS="gms_pure_go" + +# Propagate the tag via GOFLAGS so every subsequent bare `go` invocation +# picks it up automatically. Idempotent: re-sourcing is a no-op. +if [[ "${GOFLAGS:-}" != *gms_pure_go* ]]; then + export GOFLAGS="${GOFLAGS:+$GOFLAGS }-tags=${BEADS_BUILD_TAGS}" +fi diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c2c61e3d55..03bf29c965 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "beads", "source": "./claude-plugin", "description": "AI-supervised issue tracker for coding workflows", - "version": "1.0.0" + "version": "1.0.2" } ] } diff --git a/.claude/hooks/block-gh-watch.sh b/.claude/hooks/block-gh-watch.sh index fd7649ac0e..5bc7ee7172 100755 --- a/.claude/hooks/block-gh-watch.sh +++ b/.claude/hooks/block-gh-watch.sh @@ -17,30 +17,4 @@ if echo "$COMMAND" | grep -qE 'gh run watch|gh run list.*--watch'; then exit 0 fi -# Block PR creation for crew/maintainers. Crew workers push directly to main. -# External contributors (beads.role=contributor or fork origin) need PRs. -if echo "$COMMAND" | grep -qE 'gh pr create'; then - ROLE=$(git config --get beads.role 2>/dev/null || echo "") - ORIGIN=$(git config --get remote.origin.url 2>/dev/null || echo "") - - # Allow if role is explicitly contributor - if [ "$ROLE" = "contributor" ]; then - exit 0 - fi - - # Allow if origin points to a fork (not the upstream repo) - if [ -n "$ORIGIN" ] && echo "$ORIGIN" | grep -qvE 'steveyegge/beads'; then - exit 0 - fi - - jq -n '{ - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: "BLOCKED: Crew workers do NOT create PRs. Push directly to main with `git push`. PRs are for external contributors only. If you are reviewing an external PR, use fix-merge: checkout the PR, fix/rebase, merge to main, push, then close the PR." - } - }' - exit 0 -fi - exit 0 diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index 41092629ab..c28e6ff127 100644 --- a/.devcontainer/setup.sh +++ b/.devcontainer/setup.sh @@ -1,6 +1,10 @@ #!/bin/bash set -e +# Canonical build flags (GOFLAGS=-tags=gms_pure_go, CGO_ENABLED=1). +# shellcheck source=../.buildflags +source "$(dirname "$0")/../.buildflags" + echo "🔧 Building bd from source..." go build -o bd ./cmd/bd diff --git a/.github/scripts/embedded-test-shard.sh b/.github/scripts/embedded-test-shard.sh index 62e2610db4..ef2886663d 100755 --- a/.github/scripts/embedded-test-shard.sh +++ b/.github/scripts/embedded-test-shard.sh @@ -61,7 +61,7 @@ if [ -x "$CMD_BINARY" ]; then "$@" else echo "Warning: pre-built test binary not found at $CMD_BINARY, falling back to go test" - exec go test -v -race -count=1 -timeout 20m \ + exec go test -tags=gms_pure_go -v -race -count=1 -timeout 20m \ -run "$RUN_REGEX" \ "$@" \ ./cmd/bd/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d9af89f9d..11def9009d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,16 @@ concurrency: cancel-in-progress: true jobs: + # Fast check: every `go build|test|run|generate|install` invocation in + # tracked scripts/hooks/CI carries -tags=gms_pure_go. Prevents ICU-linkage + # regressions from re-entering the build (see docs/ICU-POLICY.md). + check-build-tags: + name: Check build-tag policy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - run: ./scripts/check-build-tags.sh + # Fast check to ensure all version files are in sync check-version-consistency: name: Check version consistency diff --git a/.github/workflows/cross-version-smoke.yml b/.github/workflows/cross-version-smoke.yml index c289085f0b..e39e436882 100644 --- a/.github/workflows/cross-version-smoke.yml +++ b/.github/workflows/cross-version-smoke.yml @@ -32,7 +32,7 @@ jobs: needs: versions steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v5 diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 3ee7079039..69bd14c213 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -84,7 +84,7 @@ jobs: website/build - name: Upload artifact - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@v5 with: path: website/build diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml new file mode 100644 index 0000000000..cd4217e780 --- /dev/null +++ b/.github/workflows/nix-build.yml @@ -0,0 +1,31 @@ +name: nix build + +on: + pull_request: + paths: + - 'go.mod' + - 'go.sum' + - 'default.nix' + - 'flake.nix' + - 'flake.lock' + push: + branches: [main] + paths: + - 'go.mod' + - 'go.sum' + - 'default.nix' + - 'flake.nix' + - 'flake.lock' + workflow_dispatch: + +jobs: + nix-build: + name: nix build .#default + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Install Nix + uses: DeterminateSystems/determinate-nix-action@v3 + - name: Build + run: nix build .#default --print-build-logs diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml index 74856b6878..bbbe07f8b3 100644 --- a/.github/workflows/regression.yml +++ b/.github/workflows/regression.yml @@ -32,4 +32,4 @@ jobs: key: regression-baseline-${{ hashFiles('tests/regression/BASELINE_VERSION') }} - name: Run regression tests - run: go test -tags=regression -timeout=10m -v ./tests/regression/... + run: go test -tags=regression,gms_pure_go -timeout=10m -v ./tests/regression/... diff --git a/.gitignore b/.gitignore index 0a95c8a570..d364b9fecc 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,10 @@ pkg/ .claude/settings.local.json .claude/worktrees/ .claude/*.log +.claude/*.lock + +# Codex CLI - local marker +.codex # OS diff --git a/AGENTS.md b/AGENTS.md index b17bc9cada..ad847038f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,12 +41,12 @@ echo 'Updated text' | bd update --description=- ## Testing Commands (No Ambiguity) - Default local test command: `make test` (or `./scripts/test.sh`). -- Full CGO-enabled suite: `make test-full-cgo` (or `./scripts/test-cgo.sh ./...`). -- On macOS, do **not** run raw `CGO_ENABLED=1 go test ./...` unless ICU flags are set; use the script/Make target above. -- If you need package- or test-scoped CGO runs: +- Opt-in ICU regex path: `make test-icu-path` (or `./scripts/test-icu-path.sh ./...`). +- This ICU path is maintainer-only and not part of normal validation; `make test-full-cgo` and `./scripts/test-cgo.sh` are deprecated aliases. +- For package- or test-scoped shipped-config CGO runs, prefer: ```bash -./scripts/test-cgo.sh ./cmd/bd/... -./scripts/test-cgo.sh -run '^TestName$' ./cmd/bd/... +CGO_ENABLED=1 go test -tags gms_pure_go ./cmd/bd/... +CGO_ENABLED=1 go test -tags gms_pure_go -run '^TestName$' ./cmd/bd/... ``` ## Non-Interactive Shell Commands diff --git a/AGENT_INSTRUCTIONS.md b/AGENT_INSTRUCTIONS.md index 15da646a3b..d93d4d7f2a 100644 --- a/AGENT_INSTRUCTIONS.md +++ b/AGENT_INSTRUCTIONS.md @@ -10,7 +10,7 @@ This document contains detailed operational instructions for AI agents working o - **Go version**: 1.24+ - **Linting**: `golangci-lint run ./...` (baseline warnings documented in [docs/LINTING.md](docs/LINTING.md)) -- **Testing**: All new features need tests (`make test` for local baseline, `make test-full-cgo` when validating full CGO paths) +- **Testing**: All new features need tests (`make test` for the normal local/CI path, `make test-icu-path` only when intentionally exercising the opt-in ICU regex path) - **Documentation**: Update relevant .md files ### File Organization @@ -66,7 +66,7 @@ into temp repos and produce flaky test behavior. ### Before Committing 1. **Run tests**: `make test` (or `./scripts/test.sh`) - - For full CGO validation: `make test-full-cgo` + - Only if intentionally exercising the ICU regex path: `make test-icu-path` 2. **Run linter**: `golangci-lint run ./...` (ignore baseline warnings) 3. **Update docs**: If you changed behavior, update README.md or other docs 4. **Commit**: With git hooks installed (`bd hooks install`), Dolt changes are auto-committed @@ -101,15 +101,17 @@ bd hooks install **Merge conflicts**: Rare with hash IDs. Dolt uses cell-level 3-way merge for conflict resolution. -## Git Workflow: Push to Main, Never PR +## Git Workflow: PR by Default -Crew workers push directly to main. **Never create pull requests.** +Crew workers use a PR-based workflow. Beads is a dependency of Gas City, so we +defer to the standard PR flow to keep changes reviewable. -- `git push` to main is the only way to land work -- `gh pr create` is forbidden — PRs are for external contributors, not crew -- Do not create feature branches for your own work — commit and push to main -- When handling external PRs, use fix-merge: checkout the PR branch locally, - fix/rebase onto main, merge locally, `git push`, then close the PR +- Work on a feature branch, push the branch, open a PR against `main` +- `gh pr create` is the normal path to land work +- Direct push to main is reserved for releases (tag + release commit) and + narrow operational fixes; prefer a PR when unsure +- When handling external contributor PRs, use fix-merge: checkout the PR + branch locally, fix/rebase onto main, merge via PR, then close the PR ### External Contributor PRs: Check Before You Build @@ -141,7 +143,7 @@ This is enforced by pre-use hooks. If you try `gh pr create`, it will be blocked 1. **File beads issues for any remaining work** that needs follow-up 2. **Ensure all quality gates pass** (only if code changes were made): - Run `make lint` or `golangci-lint run ./...` (if pre-commit installed: `pre-commit run --all-files`) - - Run `make test` (and `make test-full-cgo` when CGO-relevant code changed) + - Run `make test` (and `make test-icu-path` only if you intentionally need the ICU regex path) - File P0 issues if quality gates are broken 3. **Update beads issues** - close finished work, update status 4. **PUSH TO REMOTE - NON-NEGOTIABLE** - This step is MANDATORY. Execute ALL commands below: @@ -311,8 +313,8 @@ make install # Test (local baseline) make test -# Test with full CGO-enabled suite (local/CI parity) -make test-full-cgo +# Optional ICU regex path smoke (maintainer-only, not normal validation) +make test-icu-path # Coverage run go test -coverprofile=coverage.out ./... @@ -393,7 +395,7 @@ This handles the entire release workflow automatically, including waiting ~5 min 1. Bump version: `./scripts/bump-version.sh --commit` 2. Update CHANGELOG.md with release notes -3. Run tests: `make test` (and `make test-full-cgo` for CGO-related changes) +3. Run tests: `make test` (and `make test-icu-path` only if you intentionally need the ICU regex path) 4. Push version bump: `git push origin main` 5. Tag release: `git tag v && git push origin v` 6. Update Homebrew: `./scripts/update-homebrew.sh ` (waits for GitHub Actions) diff --git a/CHANGELOG.md b/CHANGELOG.md index f839c47da1..74318008a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,83 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.2] - 2026-04-15 + +### Fixed + +- **npm publish: provenance validation** — `npm-package/package.json` `repository.url`, `bugs.url`, and `homepage` updated from `steveyegge/beads` to `gastownhall/beads` so that npm's sigstore provenance validator accepts the published artifact. The repo move in v1.0.0 left these URLs stale, which caused npm publishes for v1.0.0 and v1.0.1 to be rejected after provenance check (E422). + +## [1.0.1] - 2026-04-15 + +### Added + +- **`bd batch`** — Atomic multi-operation transactions across create/update/close/dep. All ops commit together or none do. ([PR #3165](https://github.com/gastownhall/beads/pull/3165)) +- **`bd config drift` / `bd config apply`** — Detect and reconcile drift between yaml, git, and database config surfaces. ([PR #3086](https://github.com/gastownhall/beads/pull/3086)) +- **`bd config show`** — Unified provenance view showing where each effective config value comes from. ([PR #3084](https://github.com/gastownhall/beads/pull/3084), [bd-934](https://github.com/gastownhall/beads/issues/934)) +- **`started_at` on issues** — Timestamp recording when an issue first entered `in_progress`. ([PR #3206](https://github.com/gastownhall/beads/pull/3206), [GH#2796](https://github.com/gastownhall/beads/issues/2796)) +- **`beads_global` shared-server database** — Dedicated shared-server database for cross-project state. ([`fa87dd2b`](https://github.com/gastownhall/beads/commit/fa87dd2b)) +- **Pool metrics telemetry** — Shared-server connection pool metrics for diagnosing pool exhaustion. ([PR #3172](https://github.com/gastownhall/beads/pull/3172), [GH#3140](https://github.com/gastownhall/beads/issues/3140)) +- **`OpenBestAvailable` public API** — Library consumers can open the best available backend without hard-coding selection. ([PRs #3143, #3149](https://github.com/gastownhall/beads/pull/3149)) +- **Selective sync: `--issues` flag and `push`/`pull` subcommands** — Tracker sync accepts explicit issue ID lists; `--parent` ported across trackers. ([PR #2975](https://github.com/gastownhall/beads/pull/2975)) +- **`--label` / `--label-any` for `bd orphans`** — Filter orphan detection by label. ([PR #3026](https://github.com/gastownhall/beads/pull/3026)) +- **Cross-version smoke tests** — CI gate for upgrade compatibility across prior releases. ([GH#2968](https://github.com/gastownhall/beads/issues/2968)) +- **Migration test harness** — Cross-era upgrade fidelity tests. ([PR #3067](https://github.com/gastownhall/beads/pull/3067)) +- **Docusaurus versioning** — Site versioned per release; `llms-full.txt` aligned with snapshots. ([PR #3033](https://github.com/gastownhall/beads/pull/3033)) +- **Azure Blob Storage remotes** — `az://` recognized as a valid Dolt remote URL scheme. ([PR #3101](https://github.com/gastownhall/beads/pull/3101)) +- **Side-effect hints for `bd config set`/`unset`** — Shows what else changes when a config value is modified. ([PR #3089](https://github.com/gastownhall/beads/pull/3089)) +- **Dynamic config-list discovery** — `bd config list` auto-discovers yaml keys and env vars. ([PR #3088](https://github.com/gastownhall/beads/pull/3088)) +- **`BEADS_DOLT_READY_TIMEOUT`** — Override the 10s `waitForReady` timeout during `bd init --shared-server` for slower hardware. ([PR #3188](https://github.com/gastownhall/beads/pull/3188), [GH#3142](https://github.com/gastownhall/beads/issues/3142)) + ### Changed -- **Auto-export enabled by default** — `export.auto` defaults to `true` and the default `export.path` is now `issues.jsonl` (previously `export.jsonl`), with `export.git-add` on by default. `bd init` prompts interactively (default: keep enabled) and `--non-interactive` keeps it enabled. All `export.*` keys are now listed in `bd config --help`. ([GH#2973](https://github.com/steveyegge/beads/issues/2973)) +- **Auto-export enabled by default** — `export.auto` defaults to `true` and the default `export.path` is now `issues.jsonl` (previously `export.jsonl`), with `export.git-add` on by default. `bd init` prompts interactively (default: keep enabled); `--non-interactive` keeps it enabled. All `export.*` keys are listed in `bd config --help`. ([PR #3204](https://github.com/gastownhall/beads/pull/3204), [GH#2973](https://github.com/gastownhall/beads/issues/2973)) +- **`gms_pure_go` by default** — Test and install helpers default to the pure-Go build tag, dropping ICU linkage from CI. ([PRs #3240, #3259](https://github.com/gastownhall/beads/pull/3259)) +- **ICU runtime dependency removed from release binaries** — Release builds no longer link ICU, fixing portability issues. ([PR #3066](https://github.com/gastownhall/beads/pull/3066)) +- **Dolt connection pool lifetime configurable** — Longer default, configurable via env. ([PR #3163](https://github.com/gastownhall/beads/pull/3163)) +- **Dolt connection TLS explicitly disabled** — When TLS is not configured, connections now explicitly opt out rather than depending on driver defaults. ([PR #3107](https://github.com/gastownhall/beads/pull/3107)) +- **Dolt log noise reduced** — `NewConnection` spam silenced; `dolt-server.log` rotates at startup to cap size. ([PRs #3160, #3161](https://github.com/gastownhall/beads/pull/3161)) ### Fixed -- **`BEADS_DOLT_READY_TIMEOUT` env var** — `bd init --shared-server` now respects `BEADS_DOLT_READY_TIMEOUT` (positive integer seconds, default 10) so that slower hardware, where Dolt's first-run SQL engine bootstrap exceeds the 10-second budget, can opt into a longer `waitForReady` timeout. Default behavior is unchanged. ([GH#3142](https://github.com/gastownhall/beads/issues/3142)) +- **Schema migration conflict** — Removed a conflicting column on the schema migration table; migration issue patched with test coverage. ([commits `f079786b`, `65dbdbf5`, `9aa9b82f`](https://github.com/gastownhall/beads/commits/main)) +- **Wisp tables missing after bootstrap clone** — `bd bootstrap` now ensures wisp tables exist after cloning. ([commit `31d51232`](https://github.com/gastownhall/beads/commit/31d51232)) +- **CI test binary timeout on macOS** — `bd` test binary memoized to avoid 10-minute timeout. ([PR #3273](https://github.com/gastownhall/beads/pull/3273)) +- **Local-only state moved to Dolt-ignored tables** — Prevents local state from polluting shared history. ([commit `50f715b1`](https://github.com/gastownhall/beads/commit/50f715b1)) +- **Worktree awareness across commands** — Hooks path, fingerprint, doctor checks, config validate, preflight, reset, bootstrap path synthesis, `rename-prefix`, `countExistingIssues`, and formula search paths all resolve correctly when run inside a git worktree. ([PRs #3169, #3123, #3235](https://github.com/gastownhall/beads/pull/3169), plus numerous smaller fixes) +- **`bd bootstrap` hardening** — Commits `issue_prefix` to the config table; writes `metadata.json` and `config.yaml` after sync clone; uses parent workspace db name when local `.beads` missing; rejects empty `--database`; auto-start allowed for migration tests. ([PRs #3203, #3247, #3083, #3076](https://github.com/gastownhall/beads/pull/3247)) +- **Remote URL validation hardened at config parse time** — Security hardening for tracker and backup remotes. ([PR #3210](https://github.com/gastownhall/beads/pull/3210)) +- **`bd list` truncation hint in all output modes** — Truncation now indicated in JSON/compact/long formats. ([PR #3243](https://github.com/gastownhall/beads/pull/3243), [GH#3212](https://github.com/gastownhall/beads/issues/3212)) +- **`bd list --watch` hierarchy consistency** — Parent/child ordering stable across watch refreshes. ([PR #3236](https://github.com/gastownhall/beads/pull/3236)) +- **`bd update --defer` sets status=deferred** — Deferring an issue now transitions status correctly. ([PR #3241](https://github.com/gastownhall/beads/pull/3241), [GH#3233](https://github.com/gastownhall/beads/issues/3233)) +- **`bd mol bond` transitive cycle detection** — Cycles through transitive dependencies now caught. ([PR #3111](https://github.com/gastownhall/beads/pull/3111), [GH#2719](https://github.com/gastownhall/beads/issues/2719)) +- **`bd dep add/remove` allows cross-prefix targets** — No longer rejects dependencies that cross prefix boundaries. ([commit `7b02edda`](https://github.com/gastownhall/beads/commit/7b02edda)) +- **`bd dolt pull` nil-pointer panic in embedded mode** — Embedded backend no longer panics on pull. ([PR #3148](https://github.com/gastownhall/beads/pull/3148)) +- **Diverged-history guidance on auto-push failure** — Actionable recovery steps printed instead of raw error. ([PR #3138](https://github.com/gastownhall/beads/pull/3138)) +- **`bd config` error message hints** — Suggest `bd config set` (not `bd config`) in error messages. ([PR #3141](https://github.com/gastownhall/beads/pull/3141)) +- **JSONL export in pre-commit hook** — Atomic code+issues commits via pre-commit hook. ([PR #3121](https://github.com/gastownhall/beads/pull/3121)) +- **Auto-export / auto-backup errors surfaced to stderr** — Silent failures now visible. ([PR #3122](https://github.com/gastownhall/beads/pull/3122)) +- **`go install` on Windows** — Fixed ICU header dependency that broke `go install` on Windows. ([PR #3112](https://github.com/gastownhall/beads/pull/3112), [GH#3013](https://github.com/gastownhall/beads/issues/3013)) +- **Stepping-stone migration paths removed** — Unsupported intermediate migration versions cleaned up. ([PR #3110](https://github.com/gastownhall/beads/pull/3110)) +- **SQLite-era JSONL migration preserves dependencies and labels** — Migration from pre-Dolt exports no longer drops graph edges or labels. ([PR #3082](https://github.com/gastownhall/beads/pull/3082), [GH#3079](https://github.com/gastownhall/beads/issues/3079)) +- **MCP workspace discovery detects Dolt-backed projects** — MCP server no longer misses Dolt-native repos. ([PR #3207](https://github.com/gastownhall/beads/pull/3207), [GH#2997](https://github.com/gastownhall/beads/issues/2997)) +- **npm postinstall closes download streams** — Resource leak fixed. ([PR #3228](https://github.com/gastownhall/beads/pull/3228)) +- **Husky hooks sanitized when copied** — Husky-managed hooks no longer corrupt beads-managed hook directory. ([PR #3208](https://github.com/gastownhall/beads/pull/3208), [GH#3132](https://github.com/gastownhall/beads/issues/3132)) +- **GitLab issue link dependencies wired into sync pull** — Dependencies via GitLab links now import. ([PR #3202](https://github.com/gastownhall/beads/pull/3202), [GH#2645](https://github.com/gastownhall/beads/issues/2645)) +- **`.beads/` FS_NOCOW_FL on btrfs** — Prevents btrfs kworker thrashing on Dolt files. ([PR #3162](https://github.com/gastownhall/beads/pull/3162)) +- **Auto-commit config table writes** — `bd remember`, `bd forget`, and `bd config` commands now commit their writes. ([PR #3052](https://github.com/gastownhall/beads/pull/3052), [bd-g8p](https://github.com/gastownhall/beads/issues/g8p)) +- **Shared-server CLI dir resolved from shared root** — Corrects path resolution for shared-server layouts. ([PR #3223](https://github.com/gastownhall/beads/pull/3223)) +- **`.claude/` gitignore narrowed** — Blanket ignore replaced with specific patterns so project `.claude/` content is tracked. ([PR #3190](https://github.com/gastownhall/beads/pull/3190), [GH#3182](https://github.com/gastownhall/beads/issues/3182)) +- **Release archives extracting into subdirectory** — Install handles nested archive layouts. ([PR #3167](https://github.com/gastownhall/beads/pull/3167)) +- **`bd import` help text** — Removed references to nonexistent `-i` flag. ([PR #3200](https://github.com/gastownhall/beads/pull/3200)) + +### Docs + +- **ADR-0001: multi-remote approach decision** ([PR #3209](https://github.com/gastownhall/beads/pull/3209)) +- **ICU regex policy / build dependency guidance** ([GH-3126](https://github.com/gastownhall/beads/issues/3126)) +- **Contributor protection policy for AI agents** ([PR #3151](https://github.com/gastownhall/beads/pull/3151)) +- **Cross-era migration instructions** ([PR #3081](https://github.com/gastownhall/beads/pull/3081)) +- **Unified quick-start (site + stub)** ([PR #3032](https://github.com/gastownhall/beads/pull/3032)) +- **Hosted docs link** ([PR #3011](https://github.com/gastownhall/beads/pull/3011)) ## [1.0.0] - 2026-04-02 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b9ee88caf..4dd34b07c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -344,6 +344,8 @@ docker run --rm -v $(pwd):/workspace -w /workspace nixos/nix \ If the build fails with a `vendorHash` mismatch, update `default.nix` with the `got:` hash from the error message and rebuild. +The `nix build` CI job (`.github/workflows/nix-build.yml`) runs on any PR that touches `go.mod`, `go.sum`, `default.nix`, `flake.nix`, or `flake.lock`, so dependabot bumps that invalidate `vendorHash` fail loudly instead of silently breaking Nix users on main. + ### Debugging Use Go's built-in debugging tools: diff --git a/Makefile b/Makefile index 19bfba3f39..800a6dff2b 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ SHELL := $(subst cmd,bin,$(subst git.exe,bash.exe,$(GIT_BASH))) endif endif -.PHONY: all build test test-full-cgo test-regression test-upgrade test-cross-version test-migration bench bench-quick clean install install-force help check-up-to-date fmt fmt-check +.PHONY: all build test test-icu-path test-full-cgo test-regression test-upgrade test-cross-version test-migration bench bench-quick clean install install-force help check-up-to-date fmt fmt-check # Default target all: build @@ -43,7 +43,8 @@ endif # gms_pure_go tells go-mysql-server to use Go's stdlib regex instead of # ICU-backed go-icu-regex. This eliminates the ICU shared-library runtime # dependency, making release binaries portable across Linux distros. -# ICU flags are only needed for test-cgo.sh (which exercises the ICU path). +# ICU flags are only needed for scripts/test-icu-path.sh (which exercises the +# opt-in ICU regex path). BUILD_TAGS := gms_pure_go # Build the bd binary @@ -64,18 +65,25 @@ test: @echo "Running tests..." @TEST_COVER=1 ./scripts/test.sh -# Run full CGO-enabled test suite (no skip list). -# On macOS, auto-configures ICU include/link flags. +# Run the opt-in ICU regex path test suite (no skip list). +# This is a local developer workflow for intentionally exercising the leftover +# ICU path; it is not part of normal validation. +test-icu-path: + @echo "Running opt-in ICU regex path tests..." + @./scripts/test-icu-path.sh ./... + +# Deprecated compatibility alias. Keep forwarding so old local notes still work, +# but make the opt-in ICU nature explicit. test-full-cgo: - @echo "Running full CGO-enabled tests..." - @./scripts/test-cgo.sh ./... + @echo "WARNING: make test-full-cgo is deprecated; use make test-icu-path for the explicit ICU-only path." >&2 + @$(MAKE) test-icu-path # Run differential regression tests (baseline v0.49.6 vs current worktree). # Downloads baseline binary on first run; cached in ~/Library/Caches/beads-regression/. # Override baseline: BD_REGRESSION_BASELINE_BIN=/path/to/bd make test-regression test-regression: @echo "Running regression tests (baseline vs candidate)..." - go test -tags=regression -timeout=10m -v ./tests/regression/... + go test -tags=regression,$(BUILD_TAGS) -timeout=10m -v ./tests/regression/... # Run upgrade smoke tests (release stability gate). # Tests that upgrading from previous release preserves data, role, and mode. @@ -108,14 +116,14 @@ test-migration: build bench: @echo "Running performance benchmarks (Dolt backend)..." @echo "" - go test -bench=. -benchtime=1s -benchmem -run=^$$ ./internal/storage/dolt/ -timeout=30m + go test -tags "$(BUILD_TAGS)" -bench=. -benchtime=1s -benchmem -run=^$$ ./internal/storage/dolt/ -timeout=30m @echo "" @echo "Benchmark complete." # Run quick benchmarks (shorter benchtime for faster feedback) bench-quick: @echo "Running quick performance benchmarks..." - go test -bench=. -benchtime=100ms -benchmem -run=^$$ ./internal/storage/dolt/ -timeout=15m + go test -tags "$(BUILD_TAGS)" -bench=. -benchtime=100ms -benchmem -run=^$$ ./internal/storage/dolt/ -timeout=15m # Check that local branch is up to date with origin/main check-up-to-date: @@ -191,7 +199,8 @@ help: @echo "Beads Makefile targets:" @echo " make build - Build the bd binary" @echo " make test - Run all tests" - @echo " make test-full-cgo - Run full CGO-enabled test suite" + @echo " make test-icu-path - Run opt-in ICU regex path tests (maintainer-only)" + @echo " make test-full-cgo - Deprecated alias for make test-icu-path" @echo " make test-regression - Run differential regression tests (baseline vs candidate)" @echo " make test-upgrade - Run upgrade smoke tests (release stability gate)" @echo " make test-cross-version - Run cross-version smoke tests (last 30 tags)" diff --git a/claude-plugin/.claude-plugin/plugin.json b/claude-plugin/.claude-plugin/plugin.json index 1d881373f2..42eafa5ad6 100644 --- a/claude-plugin/.claude-plugin/plugin.json +++ b/claude-plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "beads", "description": "AI-supervised issue tracker for coding workflows. Manage tasks, discover work, and maintain context with simple CLI commands.", - "version": "1.0.0", + "version": "1.0.2", "author": { "name": "Steve Yegge", "url": "https://github.com/steveyegge" diff --git a/claude-plugin/skills/beads/resources/DEPENDENCIES.md b/claude-plugin/skills/beads/resources/DEPENDENCIES.md index 69e402e3e7..4f18f38848 100644 --- a/claude-plugin/skills/beads/resources/DEPENDENCIES.md +++ b/claude-plugin/skills/beads/resources/DEPENDENCIES.md @@ -360,7 +360,7 @@ blocks: Shows they must be done in order Epic with no ordering between children: All children show in bd ready immediately. Work on any child in any order. -Close epic when all children complete. +Close the epic explicitly once all children complete and the parent outcome is done. ``` **Pattern: Epic with Sequential Subtasks** diff --git a/cmd/bd/close.go b/cmd/bd/close.go index 611f8f0e3c..3b736c0b27 100644 --- a/cmd/bd/close.go +++ b/cmd/bd/close.go @@ -357,9 +357,10 @@ func checkGateSatisfaction(issue *types.Issue) error { return fmt.Errorf("gate condition not satisfied: %s (use --force to override)", reason) } -// autoCloseCompletedMolecule checks if closing a step completed a parent molecule, -// and if so, auto-closes the molecule root. This prevents stale wisps that are -// complete but never explicitly closed (e.g., deacon patrol wisps). +// autoCloseCompletedMolecule checks if closing a step completed an auto-closing +// parent molecule, and if so, closes the molecule root. Ordinary epics remain +// open when all children finish so they can become explicitly close-eligible +// instead of being closed as a side effect of the final child close. func autoCloseCompletedMolecule(ctx context.Context, s storage.DoltStorage, closedStepID, actorName, session string) { moleculeID := findParentMolecule(ctx, s, closedStepID) if moleculeID == "" { @@ -368,7 +369,7 @@ func autoCloseCompletedMolecule(ctx context.Context, s storage.DoltStorage, clos // Check if molecule root is already closed root, err := s.GetIssue(ctx, moleculeID) - if err != nil || root == nil || root.Status == types.StatusClosed { + if err != nil || root == nil || root.Status == types.StatusClosed || !shouldAutoCloseCompletedRoot(root) { return } @@ -393,6 +394,32 @@ func autoCloseCompletedMolecule(ctx context.Context, s storage.DoltStorage, clos } } +// shouldAutoCloseCompletedRoot returns true for molecule roots that should +// auto-close when their final step closes. Regular epics stay open and become +// explicit close-eligible work, while ephemeral wisps, template-driven +// molecules, and molecule-type coordination roots keep their cleanup behavior. +func shouldAutoCloseCompletedRoot(root *types.Issue) bool { + if root == nil { + return false + } + + if root.IssueType == types.TypeMolecule || root.Ephemeral { + return true + } + + if root.IssueType != types.TypeEpic { + return false + } + + for _, label := range root.Labels { + if label == BeadsTemplateLabel { + return true + } + } + + return false +} + // countEpicOpenChildren returns the number of open (non-closed) children for an epic. // Uses GetDependentsWithMetadata to find parent-child relationships. func countEpicOpenChildren(ctx context.Context, epicID string) int { diff --git a/cmd/bd/close_embedded_test.go b/cmd/bd/close_embedded_test.go index 940b80fdbc..1b73f60b41 100644 --- a/cmd/bd/close_embedded_test.go +++ b/cmd/bd/close_embedded_test.go @@ -264,6 +264,19 @@ func TestEmbeddedClose(t *testing.T) { _ = child }) + t.Run("close_last_child_keeps_regular_epic_open", func(t *testing.T) { + epic := bdCreate(t, bd, dir, "Epic stays open", "--type", "epic") + child := bdCreate(t, bd, dir, "Epic closing child", "--type", "task") + bdDepAdd(t, bd, dir, child.ID, epic.ID, "--type", "parent-child") + + bdClose(t, bd, dir, child.ID) + + got := bdShow(t, bd, dir, epic.ID) + if got.Status != types.StatusOpen { + t.Errorf("expected regular epic to stay open after its last child closes, got %s", got.Status) + } + }) + // ===== Blocker and Suggest-Next Behavior ===== t.Run("close_unblocks_dependent", func(t *testing.T) { diff --git a/cmd/bd/context_binding_integration_test.go b/cmd/bd/context_binding_integration_test.go new file mode 100644 index 0000000000..3ae4afe9b2 --- /dev/null +++ b/cmd/bd/context_binding_integration_test.go @@ -0,0 +1,143 @@ +//go:build cgo + +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/steveyegge/beads/internal/configfile" + "github.com/steveyegge/beads/internal/storage/dolt" + "github.com/steveyegge/beads/internal/types" +) + +func filteredEnvForContextBinding(keys ...string) []string { + strip := make(map[string]struct{}, len(keys)) + for _, key := range keys { + strip[key+"="] = struct{}{} + } + + env := os.Environ() + filtered := make([]string, 0, len(env)) + for _, entry := range env { + if strings.HasPrefix(entry, "BEADS_") || strings.HasPrefix(entry, "BD_") { + continue + } + trim := false + for prefix := range strip { + if strings.HasPrefix(entry, prefix) { + trim = true + break + } + } + if !trim { + filtered = append(filtered, entry) + } + } + return filtered +} + +func TestListExplicitDBPathRebindsTargetContext(t *testing.T) { + if testDoltServerPort == 0 { + t.Skip("Dolt test server not available, skipping") + } + + tmpDir := t.TempDir() + callerRepo := filepath.Join(tmpDir, "caller") + callerBeadsDir := filepath.Join(callerRepo, ".beads") + writeTestConfigYAML(t, callerBeadsDir, "dolt.auto-commit: invalid\nactor: caller-actor\n") + if err := os.WriteFile(filepath.Join(callerBeadsDir, ".env"), []byte("BEADS_DOLT_SERVER_PORT=1\n"), 0o600); err != nil { + t.Fatalf("write caller .env: %v", err) + } + + targetRepo := filepath.Join(tmpDir, "target") + targetBeadsDir := filepath.Join(targetRepo, ".beads") + writeTestConfigYAML(t, targetBeadsDir, "dolt.auto-commit: off\nactor: target-actor\n") + database := uniqueTestDBName(t) + if err := (&configfile.Config{ + Backend: configfile.BackendDolt, + DoltMode: configfile.DoltModeServer, + DoltServerHost: "127.0.0.1", + DoltServerPort: testDoltServerPort, + DoltDatabase: database, + }).Save(targetBeadsDir); err != nil { + t.Fatalf("save target metadata: %v", err) + } + + ctx := context.Background() + testStore, err := dolt.New(ctx, &dolt.Config{ + Path: filepath.Join(targetBeadsDir, "dolt"), + BeadsDir: targetBeadsDir, + ServerHost: "127.0.0.1", + ServerPort: testDoltServerPort, + Database: database, + CreateIfMissing: true, + }) + if err != nil { + t.Fatalf("create test store: %v", err) + } + defer func() { + _ = testStore.Close() + dropTestDatabase(database, testDoltServerPort) + }() + if err := testStore.SetConfig(ctx, "issue_prefix", "ctx"); err != nil { + t.Fatalf("set issue_prefix: %v", err) + } + now := time.Now() + nowIssue := &types.Issue{ + ID: "ctx-1", + Title: "Context binding proof", + Description: "Proves explicit --db commands use the target workspace config", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + CreatedAt: now, + UpdatedAt: now, + } + if err := testStore.CreateIssue(ctx, nowIssue, "test-user"); err != nil { + t.Fatalf("create issue: %v", err) + } + + binPath := filepath.Join(t.TempDir(), "bd-under-test") + packageDir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + buildCmd := exec.Command("go", "build", "-o", binPath, ".") + buildCmd.Dir = packageDir + buildOut, err := buildCmd.CombinedOutput() + if err != nil { + t.Fatalf("go build failed: %v\n%s", err, buildOut) + } + + listCmd := exec.Command(binPath, "list", "--db", filepath.Join(targetBeadsDir, "dolt"), "--json") + listCmd.Dir = callerRepo + listCmd.Env = append(filteredEnvForContextBinding("BEADS_DIR", "BEADS_DB", "BD_DB", "BEADS_DOLT_SERVER_PORT", "BEADS_DOLT_SERVER_DATABASE"), + "HOME="+t.TempDir(), + "XDG_CONFIG_HOME="+t.TempDir(), + "BEADS_TEST_MODE=1", + "BEADS_DIR="+callerBeadsDir, + "BEADS_DB=", + ) + output, err := listCmd.CombinedOutput() + if err != nil { + t.Fatalf("bd list failed: %v\n%s", err, output) + } + if !strings.Contains(string(output), "Context binding proof") { + t.Fatalf("expected list output to include target issue\n%s", output) + } + + if _, err := os.Stat(filepath.Join(callerBeadsDir, localVersionFile)); err == nil { + t.Fatalf("caller workspace unexpectedly created %s", filepath.Join(callerBeadsDir, localVersionFile)) + } else if !os.IsNotExist(err) { + t.Fatalf("stat caller %s: %v", localVersionFile, err) + } + if _, err := os.Stat(filepath.Join(targetBeadsDir, localVersionFile)); err != nil { + t.Fatalf("target workspace should create %s: %v", localVersionFile, err) + } +} diff --git a/cmd/bd/context_binding_test.go b/cmd/bd/context_binding_test.go new file mode 100644 index 0000000000..9302203a46 --- /dev/null +++ b/cmd/bd/context_binding_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/beads/internal/config" + "github.com/steveyegge/beads/internal/configfile" + "github.com/steveyegge/beads/internal/doltserver" +) + +func writeTestConfigYAML(t *testing.T, beadsDir, contents string) { + t.Helper() + if err := os.MkdirAll(beadsDir, 0o700); err != nil { + t.Fatalf("mkdir beads dir: %v", err) + } + if err := os.WriteFile(filepath.Join(beadsDir, "config.yaml"), []byte(contents), 0o600); err != nil { + t.Fatalf("write config.yaml: %v", err) + } +} + +type flagSnapshot struct { + value string + changed bool +} + +func snapshotRootFlagState() map[string]flagSnapshot { + state := map[string]flagSnapshot{} + for _, name := range []string{"db", "json", "format", "readonly", "actor", "dolt-auto-commit"} { + flag := rootCmd.PersistentFlags().Lookup(name) + if flag == nil { + continue + } + state[name] = flagSnapshot{value: flag.Value.String(), changed: flag.Changed} + } + return state +} + +func restoreRootFlagState(t *testing.T, state map[string]flagSnapshot) { + t.Helper() + for name, snapshot := range state { + flag := rootCmd.PersistentFlags().Lookup(name) + if flag == nil { + continue + } + if err := flag.Value.Set(snapshot.value); err != nil { + t.Fatalf("restore %s flag: %v", name, err) + } + flag.Changed = snapshot.changed + } +} + +func TestPrepareSelectedCommandContext_RebindsTargetConfig(t *testing.T) { + t.Setenv("BEADS_DOLT_SERVER_DATABASE", "") + t.Setenv("BEADS_DOLT_SERVER_PORT", "") + + callerDir := t.TempDir() + callerBeadsDir := filepath.Join(callerDir, ".beads") + writeTestConfigYAML(t, callerBeadsDir, "actor: caller-actor\ndolt.auto-start: true\ndolt.port: 1111\ndolt.auto-commit: on\n") + + targetDir := t.TempDir() + targetBeadsDir := filepath.Join(targetDir, ".beads") + writeTestConfigYAML(t, targetBeadsDir, "actor: target-actor\ndolt.auto-start: false\ndolt.port: 4242\ndolt.auto-commit: batch\njson: true\nreadonly: true\n") + if err := (&configfile.Config{ + Backend: configfile.BackendDolt, + DoltMode: configfile.DoltModeServer, + }).Save(targetBeadsDir); err != nil { + t.Fatalf("save target metadata: %v", err) + } + + t.Setenv("BEADS_DIR", callerBeadsDir) + config.ResetForTesting() + t.Cleanup(config.ResetForTesting) + if err := config.Initialize(); err != nil { + t.Fatalf("config.Initialize: %v", err) + } + + oldServerMode := serverMode + oldJSONOutput := jsonOutput + oldReadonlyMode := readonlyMode + oldActor := actor + oldDoltAutoCommit := doltAutoCommit + flagState := snapshotRootFlagState() + t.Cleanup(func() { + serverMode = oldServerMode + jsonOutput = oldJSONOutput + readonlyMode = oldReadonlyMode + actor = oldActor + doltAutoCommit = oldDoltAutoCommit + restoreRootFlagState(t, flagState) + }) + + serverMode = false + jsonOutput = false + readonlyMode = false + actor = "" + doltAutoCommit = "" + for _, name := range []string{"json", "format", "readonly", "actor", "dolt-auto-commit"} { + if flag := rootCmd.PersistentFlags().Lookup(name); flag != nil { + flag.Changed = false + } + } + + prepareSelectedCommandContext(targetBeadsDir, false) + refreshBoundCommandConfig(rootCmd) + + if got := os.Getenv("BEADS_DIR"); got != targetBeadsDir { + t.Fatalf("BEADS_DIR = %q, want %q", got, targetBeadsDir) + } + if !serverMode { + t.Fatal("serverMode should be true after rebinding to target metadata") + } + if !jsonOutput { + t.Fatal("jsonOutput should be rebound from target config") + } + if !readonlyMode { + t.Fatal("readonlyMode should be rebound from target config") + } + if actor != "target-actor" { + t.Fatalf("actor = %q, want %q", actor, "target-actor") + } + if doltAutoCommit != "batch" { + t.Fatalf("doltAutoCommit = %q, want %q", doltAutoCommit, "batch") + } + if !doltserver.IsAutoStartDisabled() { + t.Fatal("IsAutoStartDisabled should honor target config after rebinding") + } + if got := doltserver.DefaultConfig(targetBeadsDir).Port; got != 4242 { + t.Fatalf("DefaultConfig(target).Port = %d, want %d", got, 4242) + } +} + +func TestPrepareSelectedCommandContext_DoesNotMergeCallerConfigForUnsetKeys(t *testing.T) { + t.Setenv("BEADS_DOLT_SERVER_DATABASE", "") + t.Setenv("BEADS_DOLT_SERVER_PORT", "") + + root := t.TempDir() + callerDir := filepath.Join(root, "caller") + callerBeadsDir := filepath.Join(callerDir, ".beads") + writeTestConfigYAML(t, callerBeadsDir, "readonly: true\njson: true\n") + + targetDir := filepath.Join(root, "target") + targetBeadsDir := filepath.Join(targetDir, ".beads") + writeTestConfigYAML(t, targetBeadsDir, "actor: target-actor\n") + + t.Chdir(callerDir) + t.Setenv("BEADS_DIR", callerBeadsDir) + config.ResetForTesting() + t.Cleanup(config.ResetForTesting) + if err := config.Initialize(); err != nil { + t.Fatalf("config.Initialize: %v", err) + } + + oldJSONOutput := jsonOutput + oldReadonlyMode := readonlyMode + oldActor := actor + flagState := snapshotRootFlagState() + t.Cleanup(func() { + jsonOutput = oldJSONOutput + readonlyMode = oldReadonlyMode + actor = oldActor + restoreRootFlagState(t, flagState) + }) + + jsonOutput = false + readonlyMode = false + actor = "" + for _, name := range []string{"json", "format", "readonly", "actor"} { + if flag := rootCmd.PersistentFlags().Lookup(name); flag != nil { + flag.Changed = false + } + } + + prepareSelectedCommandContext(targetBeadsDir, false) + refreshBoundCommandConfig(rootCmd) + + if readonlyMode { + t.Fatal("readonlyMode should stay false when target config leaves readonly unset") + } + if jsonOutput { + t.Fatal("jsonOutput should stay false when target config leaves json unset") + } + if actor != "target-actor" { + t.Fatalf("actor = %q, want %q", actor, "target-actor") + } +} diff --git a/cmd/bd/context_cmd.go b/cmd/bd/context_cmd.go index 1c4a04a1b3..e2f3dc3c4c 100644 --- a/cmd/bd/context_cmd.go +++ b/cmd/bd/context_cmd.go @@ -51,7 +51,7 @@ Examples: } // Resolve repo context (works without DB open) - if selected := selectedNoDBBeadsDir(); selected != "" { + if selected := selectedNoDBBeadsDir(cmd); selected != "" { prepareSelectedNoDBContext(selected) } diff --git a/cmd/bd/create.go b/cmd/bd/create.go index df8c465195..6b56b6e27e 100644 --- a/cmd/bd/create.go +++ b/cmd/bd/create.go @@ -263,23 +263,18 @@ var createCmd = &cobra.Command{ // Handle --dry-run flag (before --rig to ensure it works with cross-rig creation) dryRun, _ := cmd.Flags().GetBool("dry-run") if dryRun { - // Build preview issue - var externalRefPtr *string - if externalRef != "" { - externalRefPtr = &externalRef - } - previewIssue := &types.Issue{ + previewIssue := buildCreateIssue(createIssueParams{ + ID: explicitID, Title: title, Description: description, Design: design, AcceptanceCriteria: acceptance, Notes: notes, SpecID: specID, - Status: types.StatusOpen, Priority: priority, IssueType: types.IssueType(issueType).Normalize(), Assignee: assignee, - ExternalRef: externalRefPtr, + ExternalRef: externalRef, Ephemeral: wisp, NoHistory: noHistory, CreatedBy: getActorWithGit(), @@ -289,44 +284,16 @@ var createCmd = &cobra.Command{ DueAt: dueAt, DeferUntil: deferUntil, Metadata: metadata, - // Event fields - EventKind: eventCategory, - Actor: eventActor, - Target: eventTarget, - Payload: eventPayload, - } - if explicitID != "" { - previewIssue.ID = explicitID - } + EventKind: eventCategory, + Actor: eventActor, + Target: eventTarget, + Payload: eventPayload, + }) if jsonOutput { outputJSON(previewIssue) } else { - idDisplay := previewIssue.ID - if idDisplay == "" { - idDisplay = "(will be generated)" - } - fmt.Printf("%s [DRY RUN] Would create issue:\n", ui.RenderWarn("âš ")) - fmt.Printf(" ID: %s\n", idDisplay) - fmt.Printf(" Title: %s\n", previewIssue.Title) - fmt.Printf(" Type: %s\n", previewIssue.IssueType) - fmt.Printf(" Priority: P%d\n", previewIssue.Priority) - fmt.Printf(" Status: %s\n", previewIssue.Status) - if previewIssue.Assignee != "" { - fmt.Printf(" Assignee: %s\n", previewIssue.Assignee) - } - if previewIssue.Description != "" { - fmt.Printf(" Description: %s\n", previewIssue.Description) - } - if len(labels) > 0 { - fmt.Printf(" Labels: %s\n", strings.Join(labels, ", ")) - } - if len(deps) > 0 { - fmt.Printf(" Dependencies: %s\n", strings.Join(deps, ", ")) - } - if eventCategory != "" { - fmt.Printf(" Event category: %s\n", eventCategory) - } + renderCreateDryRunPreview(previewIssue, labels, deps) } return } @@ -492,25 +459,18 @@ var createCmd = &cobra.Command{ } } - var externalRefPtr *string - if externalRef != "" { - externalRefPtr = &externalRef - } - - // Direct mode - issue := &types.Issue{ - ID: explicitID, // Set explicit ID if provided (empty string if not) + issue := buildCreateIssue(createIssueParams{ + ID: explicitID, Title: title, Description: description, Design: design, AcceptanceCriteria: acceptance, Notes: notes, SpecID: specID, - Status: types.StatusOpen, Priority: priority, IssueType: types.IssueType(issueType).Normalize(), Assignee: assignee, - ExternalRef: externalRefPtr, + ExternalRef: externalRef, EstimatedMinutes: estimatedMinutes, Ephemeral: wisp, NoHistory: noHistory, @@ -525,7 +485,7 @@ var createCmd = &cobra.Command{ DueAt: dueAt, DeferUntil: deferUntil, Metadata: metadata, - } + }) ctx := rootCtx @@ -747,6 +707,98 @@ var createCmd = &cobra.Command{ }, } +type createIssueParams struct { + ID string + Title string + Description string + Design string + AcceptanceCriteria string + Notes string + SpecID string + Priority int + IssueType types.IssueType + Assignee string + ExternalRef string + EstimatedMinutes *int + Ephemeral bool + NoHistory bool + CreatedBy string + Owner string + MolType types.MolType + WispType types.WispType + EventKind string + Actor string + Target string + Payload string + DueAt *time.Time + DeferUntil *time.Time + Metadata json.RawMessage +} + +func buildCreateIssue(params createIssueParams) *types.Issue { + var externalRefPtr *string + if params.ExternalRef != "" { + externalRefPtr = ¶ms.ExternalRef + } + + return &types.Issue{ + ID: params.ID, + Title: params.Title, + Description: params.Description, + Design: params.Design, + AcceptanceCriteria: params.AcceptanceCriteria, + Notes: params.Notes, + SpecID: params.SpecID, + Status: types.StatusOpen, + Priority: params.Priority, + IssueType: params.IssueType, + Assignee: params.Assignee, + ExternalRef: externalRefPtr, + EstimatedMinutes: params.EstimatedMinutes, + Ephemeral: params.Ephemeral, + NoHistory: params.NoHistory, + CreatedBy: params.CreatedBy, + Owner: params.Owner, + MolType: params.MolType, + WispType: params.WispType, + EventKind: params.EventKind, + Actor: params.Actor, + Target: params.Target, + Payload: params.Payload, + DueAt: params.DueAt, + DeferUntil: params.DeferUntil, + Metadata: params.Metadata, + } +} + +func renderCreateDryRunPreview(issue *types.Issue, labels, deps []string) { + idDisplay := issue.ID + if idDisplay == "" { + idDisplay = "(will be generated)" + } + fmt.Printf("%s [DRY RUN] Would create issue:\n", ui.RenderWarn("âš ")) + fmt.Printf(" ID: %s\n", idDisplay) + fmt.Printf(" Title: %s\n", issue.Title) + fmt.Printf(" Type: %s\n", issue.IssueType) + fmt.Printf(" Priority: P%d\n", issue.Priority) + fmt.Printf(" Status: %s\n", issue.Status) + if issue.Assignee != "" { + fmt.Printf(" Assignee: %s\n", issue.Assignee) + } + if issue.Description != "" { + fmt.Printf(" Description: %s\n", issue.Description) + } + if len(labels) > 0 { + fmt.Printf(" Labels: %s\n", strings.Join(labels, ", ")) + } + if len(deps) > 0 { + fmt.Printf(" Dependencies: %s\n", strings.Join(deps, ", ")) + } + if issue.EventKind != "" { + fmt.Printf(" Event category: %s\n", issue.EventKind) + } +} + func init() { createCmd.Flags().StringP("file", "f", "", "Create multiple issues from markdown file") createCmd.Flags().String("graph", "", "Create a graph of issues with dependencies from JSON plan file") diff --git a/cmd/bd/doctor/config_values.go b/cmd/bd/doctor/config_values.go index baba2a4c56..cbcdcdfe35 100644 --- a/cmd/bd/doctor/config_values.go +++ b/cmd/bd/doctor/config_values.go @@ -351,6 +351,21 @@ func checkMetadataConfigValues(repoPath string) []string { } } + // Validate dolt_database for embedded-mode compatibility (GH#3231). + // Hyphens and dots are allowed by server mode but rejected by the + // embedded Dolt engine because database names are interpolated into + // system variable identifiers (@@_head_ref) where only + // [a-zA-Z_][a-zA-Z0-9_]* is valid. + if cfg.DoltDatabase != "" && !cfg.IsDoltServerMode() { + sanitized := strings.ReplaceAll(cfg.DoltDatabase, "-", "_") + sanitized = strings.ReplaceAll(sanitized, ".", "_") + if sanitized != cfg.DoltDatabase { + issues = append(issues, fmt.Sprintf( + "metadata.json dolt_database: %q contains characters invalid in embedded mode — "+ + "replace with %q or set dolt_mode to \"server\" (GH#3231)", cfg.DoltDatabase, sanitized)) + } + } + // Validate deletions_retention_days if cfg.DeletionsRetentionDays < 0 { issues = append(issues, fmt.Sprintf("metadata.json deletions_retention_days: %d is invalid (must be >= 0)", cfg.DeletionsRetentionDays)) diff --git a/cmd/bd/doctor/config_values_test.go b/cmd/bd/doctor/config_values_test.go index 9e3ce4c345..028d424a9d 100644 --- a/cmd/bd/doctor/config_values_test.go +++ b/cmd/bd/doctor/config_values_test.go @@ -104,6 +104,96 @@ func TestCheckMetadataConfigValues(t *testing.T) { } }) + // GH#3231: hyphenated dolt_database in embedded mode + t.Run("hyphenated dolt_database embedded mode", func(t *testing.T) { + metadataContent := `{ + "database": "dolt", + "dolt_database": "my-cool-project", + "dolt_mode": "embedded" +}` + if err := os.WriteFile(filepath.Join(beadsDir, "metadata.json"), []byte(metadataContent), 0644); err != nil { + t.Fatalf("failed to write metadata.json: %v", err) + } + + issues := checkMetadataConfigValues(tmpDir) + found := false + for _, issue := range issues { + if contains(issue, "invalid in embedded") { + found = true + break + } + } + if !found { + t.Errorf("expected invalid-chars warning for embedded mode, got: %v", issues) + } + }) + + // GH#3231: dotted dolt_database in embedded mode + t.Run("dotted dolt_database embedded mode", func(t *testing.T) { + metadataContent := `{ + "database": "dolt", + "dolt_database": "my.project", + "dolt_mode": "embedded" +}` + if err := os.WriteFile(filepath.Join(beadsDir, "metadata.json"), []byte(metadataContent), 0644); err != nil { + t.Fatalf("failed to write metadata.json: %v", err) + } + + issues := checkMetadataConfigValues(tmpDir) + found := false + for _, issue := range issues { + if contains(issue, "invalid in embedded") { + found = true + break + } + } + if !found { + t.Errorf("expected invalid-chars warning for dotted name in embedded mode, got: %v", issues) + } + }) + + // GH#3231: hyphenated dolt_database is OK in server mode + t.Run("hyphenated dolt_database server mode", func(t *testing.T) { + metadataContent := `{ + "database": "dolt", + "dolt_database": "my-cool-project", + "dolt_mode": "server" +}` + if err := os.WriteFile(filepath.Join(beadsDir, "metadata.json"), []byte(metadataContent), 0644); err != nil { + t.Fatalf("failed to write metadata.json: %v", err) + } + + issues := checkMetadataConfigValues(tmpDir) + for _, issue := range issues { + if contains(issue, "invalid in embedded") { + t.Errorf("should not warn about invalid chars in server mode, got: %s", issue) + } + } + }) + + // GH#3231: no dolt_mode defaults to embedded, hyphens should warn + t.Run("hyphenated dolt_database default mode", func(t *testing.T) { + metadataContent := `{ + "database": "dolt", + "dolt_database": "my-project" +}` + if err := os.WriteFile(filepath.Join(beadsDir, "metadata.json"), []byte(metadataContent), 0644); err != nil { + t.Fatalf("failed to write metadata.json: %v", err) + } + + issues := checkMetadataConfigValues(tmpDir) + found := false + for _, issue := range issues { + if contains(issue, "invalid in embedded") { + found = true + break + } + } + if !found { + t.Errorf("expected invalid-chars warning when dolt_mode is unset (defaults to embedded), got: %v", issues) + } + }) + t.Run("valid dolt metadata", func(t *testing.T) { metadataContent := `{ "database": "dolt", diff --git a/cmd/bd/doctor/database.go b/cmd/bd/doctor/database.go index 403b020b30..c3478bf430 100644 --- a/cmd/bd/doctor/database.go +++ b/cmd/bd/doctor/database.go @@ -127,7 +127,7 @@ func CheckDatabaseVersionWithStore(ss *SharedStore, cliVersion string) DoctorChe func checkDatabaseVersionWithStore(store *dolt.DoltStore, cliVersion string) DoctorCheck { ctx := context.Background() - dbVersion, err := store.GetMetadata(ctx, "bd_version") + dbVersion, err := store.GetLocalMetadata(ctx, "bd_version") if err != nil { return DoctorCheck{ Name: "Database", @@ -138,12 +138,13 @@ func checkDatabaseVersionWithStore(store *dolt.DoltStore, cliVersion string) Doc } } if dbVersion == "" { + // bd_version is in local_metadata (dolt-ignored), so it's expected to be + // empty after a working-set reset. It self-heals on next startup. return DoctorCheck{ Name: "Database", - Status: StatusWarning, - Message: "Database missing version metadata", + Status: StatusOK, + Message: "bd_version not yet stamped (will self-heal on next startup)", Detail: "Storage: Dolt", - Fix: "Run 'bd doctor --fix' to repair metadata", } } @@ -280,7 +281,7 @@ func CheckDatabaseIntegrityWithStore(ss *SharedStore) DoctorCheck { func checkDatabaseIntegrityWithStore(store *dolt.DoltStore) DoctorCheck { ctx := context.Background() // Minimal checks: metadata + statistics. If these work, the store is at least readable. - if _, err := store.GetMetadata(ctx, "bd_version"); err != nil { + if _, err := store.GetLocalMetadata(ctx, "bd_version"); err != nil { return DoctorCheck{ Name: "Database Integrity", Status: StatusError, diff --git a/cmd/bd/doctor/dolt.go b/cmd/bd/doctor/dolt.go index 64ad25c5ec..077e8f9d36 100644 --- a/cmd/bd/doctor/dolt.go +++ b/cmd/bd/doctor/dolt.go @@ -280,24 +280,27 @@ func checkSchemaWithDB(conn *doltConn) DoctorCheck { } } - // Check dolt_ignore'd tables (wisps) — these only exist in the working - // set and must be recreated each server session. (GH#2271) - wispTables := []string{"wisps", "wisp_labels", "wisp_dependencies", "wisp_events", "wisp_comments"} - var missingWispTables []string - for _, table := range wispTables { + // Check dolt_ignore'd tables — these only exist in the working set and + // must be recreated each server session. (GH#2271) + ignoredTables := []string{ + "local_metadata", "repo_mtimes", + "wisps", "wisp_labels", "wisp_dependencies", "wisp_events", "wisp_comments", + } + var missingIgnoredTables []string + for _, table := range ignoredTables { var count int err := conn.db.QueryRowContext(ctx, fmt.Sprintf("SELECT COUNT(*) FROM %s LIMIT 1", table)).Scan(&count) if err != nil { - missingWispTables = append(missingWispTables, table) + missingIgnoredTables = append(missingIgnoredTables, table) } } - if len(missingWispTables) > 0 { + if len(missingIgnoredTables) > 0 { return DoctorCheck{ Name: "Dolt Schema", Status: StatusWarning, - Message: fmt.Sprintf("Missing ephemeral tables: %v (will be recreated on next bd command)", missingWispTables), - Detail: "Wisps tables are dolt_ignore'd and must be recreated each server session (GH#2271)", + Message: fmt.Sprintf("Missing dolt_ignore'd tables: %v (will be recreated on next bd command)", missingIgnoredTables), + Detail: "dolt_ignore'd tables live in the working set and must be recreated each server session", Fix: "Run any bd command to trigger automatic recreation, or restart the Dolt server", Category: CategoryCore, } @@ -397,10 +400,20 @@ func CheckDoltIssueCount(path string) DoctorCheck { return checkIssueCountWithDB(conn) } -// isWispTable returns true if the table name refers to a wisp (ephemeral) table. -// Wisp tables are expected to have uncommitted changes since they are excluded +// isIgnoredTable returns true if the table name refers to a dolt_ignore'd table. +// These tables are expected to have uncommitted changes since they are excluded // from Dolt version tracking via dolt_ignore. Reporting them as uncommitted // produces self-fulfilling warnings that can never be cleared. +func isIgnoredTable(tableName string) bool { + switch tableName { + case "wisps", "local_metadata", "repo_mtimes": + return true + } + return strings.HasPrefix(tableName, "wisp_") +} + +// isWispTable returns true if the table name refers to a wisp (ephemeral) table. +// Deprecated: use isIgnoredTable for broader coverage. func isWispTable(tableName string) bool { return tableName == "wisps" || strings.HasPrefix(tableName, "wisp_") } @@ -431,9 +444,9 @@ func checkStatusWithDB(conn *doltConn) DoctorCheck { if err := rows.Scan(&tableName, &staged, &status); err != nil { continue } - // Skip wisp tables — they are ephemeral and expected to have - // uncommitted changes (covered by dolt_ignore). - if isWispTable(tableName) { + // Skip dolt_ignore'd tables — they are ephemeral and expected to have + // uncommitted changes. + if isIgnoredTable(tableName) { continue } stageMark := "" diff --git a/cmd/bd/doctor/fix/metadata.go b/cmd/bd/doctor/fix/metadata.go index 81e6e05c82..4d952081d9 100644 --- a/cmd/bd/doctor/fix/metadata.go +++ b/cmd/bd/doctor/fix/metadata.go @@ -45,11 +45,11 @@ func FixMissingMetadata(path string, bdVersion string) error { var repaired []string - // Check and repair bd_version - if val, err := store.GetMetadata(ctx, "bd_version"); err == nil && val == "" { + // Check and repair bd_version (clone-local, dolt-ignored) + if val, err := store.GetLocalMetadata(ctx, "bd_version"); err == nil && val == "" { if bdVersion != "" { - if err := store.SetMetadata(ctx, "bd_version", bdVersion); err != nil { - return fmt.Errorf("failed to set bd_version metadata: %w", err) + if err := store.SetLocalMetadata(ctx, "bd_version", bdVersion); err != nil { + return fmt.Errorf("failed to set bd_version local metadata: %w", err) } repaired = append(repaired, "bd_version") } diff --git a/cmd/bd/doctor/fix/metadata_dolt_test.go b/cmd/bd/doctor/fix/metadata_dolt_test.go index d227e57a93..3b9a2b420f 100644 --- a/cmd/bd/doctor/fix/metadata_dolt_test.go +++ b/cmd/bd/doctor/fix/metadata_dolt_test.go @@ -88,9 +88,9 @@ func TestFixMissingMetadata_DoltRepair(t *testing.T) { defer func() { _ = store.Close() }() // Check bd_version - bdVersion, err := store.GetMetadata(ctx, "bd_version") + bdVersion, err := store.GetLocalMetadata(ctx, "bd_version") if err != nil { - t.Fatalf("GetMetadata(bd_version) error: %v", err) + t.Fatalf("GetLocalMetadata(bd_version) error: %v", err) } if bdVersion != "0.49.6" { t.Errorf("bd_version = %q, want %q", bdVersion, "0.49.6") @@ -133,7 +133,7 @@ func TestFixMissingMetadata_DoltIdempotent(t *testing.T) { if err != nil { t.Fatalf("failed to open store: %v", err) } - origVersion, _ := store.GetMetadata(ctx, "bd_version") + origVersion, _ := store.GetLocalMetadata(ctx, "bd_version") origRepoID, _ := store.GetMetadata(ctx, "repo_id") origCloneID, _ := store.GetMetadata(ctx, "clone_id") _ = store.Close() @@ -150,7 +150,7 @@ func TestFixMissingMetadata_DoltIdempotent(t *testing.T) { } defer func() { _ = store2.Close() }() - newVersion, _ := store2.GetMetadata(ctx, "bd_version") + newVersion, _ := store2.GetLocalMetadata(ctx, "bd_version") if newVersion != origVersion { t.Errorf("bd_version changed from %q to %q (should be idempotent)", origVersion, newVersion) } @@ -178,7 +178,7 @@ func TestFixMissingMetadata_DoltPartialRepair(t *testing.T) { if err != nil { t.Fatalf("failed to open store: %v", err) } - if err := store.SetMetadata(ctx, "bd_version", "0.48.0"); err != nil { + if err := store.SetLocalMetadata(ctx, "bd_version", "0.48.0"); err != nil { t.Fatalf("failed to pre-set bd_version: %v", err) } _ = store.Close() @@ -195,7 +195,7 @@ func TestFixMissingMetadata_DoltPartialRepair(t *testing.T) { } defer func() { _ = store2.Close() }() - bdVersion, _ := store2.GetMetadata(ctx, "bd_version") + bdVersion, _ := store2.GetLocalMetadata(ctx, "bd_version") if bdVersion != "0.48.0" { t.Errorf("bd_version = %q, want %q (should not overwrite existing)", bdVersion, "0.48.0") } diff --git a/cmd/bd/doctor/fix/migrate.go b/cmd/bd/doctor/fix/migrate.go index 43cf113cb3..37134986cd 100644 --- a/cmd/bd/doctor/fix/migrate.go +++ b/cmd/bd/doctor/fix/migrate.go @@ -69,7 +69,7 @@ func DatabaseVersionWithBdVersion(path string, bdVersion string) error { // Set version metadata if provided if bdVersion != "" { - if err := store.SetMetadata(ctx, "bd_version", bdVersion); err != nil { + if err := store.SetLocalMetadata(ctx, "bd_version", bdVersion); err != nil { fmt.Printf(" Warning: failed to set bd_version: %v\n", err) } } @@ -101,9 +101,9 @@ func DatabaseVersionWithBdVersion(path string, bdVersion string) error { } defer func() { _ = store.Close() }() - // Update bd_version if provided + // Update bd_version if provided (clone-local, dolt-ignored) if bdVersion != "" { - if err := store.SetMetadata(ctx, "bd_version", bdVersion); err != nil { + if err := store.SetLocalMetadata(ctx, "bd_version", bdVersion); err != nil { return fmt.Errorf("failed to set bd_version: %w", err) } } diff --git a/cmd/bd/doctor/server.go b/cmd/bd/doctor/server.go index 94e1d7fe8f..83e261d19b 100644 --- a/cmd/bd/doctor/server.go +++ b/cmd/bd/doctor/server.go @@ -519,7 +519,7 @@ func checkSchemaCompatible(db *sql.DB, database string) DoctorCheck { // Query metadata table for bd_version var bdVersion string - err = db.QueryRowContext(ctx, "SELECT value FROM metadata WHERE `key` = 'bd_version'").Scan(&bdVersion) + err = db.QueryRowContext(ctx, "SELECT value FROM local_metadata WHERE `key` = 'bd_version'").Scan(&bdVersion) if err != nil && err != sql.ErrNoRows { if strings.Contains(err.Error(), "doesn't exist") || strings.Contains(err.Error(), "Unknown table") { return DoctorCheck{ diff --git a/cmd/bd/doctor_context_test.go b/cmd/bd/doctor_context_test.go new file mode 100644 index 0000000000..baefd90caf --- /dev/null +++ b/cmd/bd/doctor_context_test.go @@ -0,0 +1,205 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/beads/internal/beads" + "github.com/steveyegge/beads/internal/config" + "github.com/steveyegge/beads/internal/configfile" + "github.com/steveyegge/beads/internal/utils" +) + +func savePersistentPreRunState(t *testing.T) { + t.Helper() + + oldServerMode := serverMode + oldCmdCtx := cmdCtx + oldDBPath := dbPath + oldActor := actor + oldJSONOutput := jsonOutput + oldReadonlyMode := readonlyMode + oldDoltAutoCommit := doltAutoCommit + flagState := snapshotRootFlagState() + t.Cleanup(func() { + serverMode = oldServerMode + cmdCtx = oldCmdCtx + dbPath = oldDBPath + actor = oldActor + jsonOutput = oldJSONOutput + readonlyMode = oldReadonlyMode + doltAutoCommit = oldDoltAutoCommit + restoreRootFlagState(t, flagState) + }) + + serverMode = false + cmdCtx = nil + dbPath = "" + actor = "" + jsonOutput = false + readonlyMode = false + doltAutoCommit = "" +} + +func writeMetadataConfig(t *testing.T, beadsDir string, doltMode string, database string) { + t.Helper() + + if err := (&configfile.Config{ + Backend: configfile.BackendDolt, + DoltMode: doltMode, + DoltDatabase: database, + }).Save(beadsDir); err != nil { + t.Fatalf("save metadata: %v", err) + } +} + +func TestDoctorPersistentPreRunLoadsServerModeForNoDBCommand(t *testing.T) { + repoDir := t.TempDir() + beadsDir := filepath.Join(repoDir, ".beads") + writeTestConfigYAML(t, beadsDir, "") + writeMetadataConfig(t, beadsDir, configfile.DoltModeServer, "doctor_ctx_test") + + t.Chdir(repoDir) + t.Setenv("BEADS_DIR", beadsDir) + t.Setenv("BEADS_DOLT_SHARED_SERVER", "") + t.Setenv("BEADS_DOLT_SERVER_DATABASE", "") + t.Setenv("BEADS_DOLT_SERVER_PORT", "") + + config.ResetForTesting() + t.Cleanup(config.ResetForTesting) + savePersistentPreRunState(t) + + if rootCmd.PersistentPreRun == nil { + t.Fatal("rootCmd.PersistentPreRun must be set") + } + rootCmd.PersistentPreRun(doctorCmd, nil) + + if !serverMode { + t.Fatal("doctor should load server mode before the no-store early return") + } +} + +func TestDoctorPersistentPreRunUsesExplicitDBTarget(t *testing.T) { + callerRepo := filepath.Join(t.TempDir(), "caller") + callerBeadsDir := filepath.Join(callerRepo, ".beads") + writeTestConfigYAML(t, callerBeadsDir, "") + writeMetadataConfig(t, callerBeadsDir, configfile.DoltModeEmbedded, "caller_ctx_test") + + targetRepo := filepath.Join(t.TempDir(), "target") + targetBeadsDir := filepath.Join(targetRepo, ".beads") + writeTestConfigYAML(t, targetBeadsDir, "") + writeMetadataConfig(t, targetBeadsDir, configfile.DoltModeServer, "target_ctx_test") + + t.Chdir(callerRepo) + t.Setenv("BEADS_DIR", callerBeadsDir) + t.Setenv("BEADS_DOLT_SHARED_SERVER", "") + t.Setenv("BEADS_DOLT_SERVER_DATABASE", "") + t.Setenv("BEADS_DOLT_SERVER_PORT", "") + + config.ResetForTesting() + t.Cleanup(config.ResetForTesting) + savePersistentPreRunState(t) + + targetDBPath := filepath.Join(targetBeadsDir, "dolt") + dbPath = targetDBPath + if flag := rootCmd.PersistentFlags().Lookup("db"); flag != nil { + flag.Changed = true + } + + rootCmd.PersistentPreRun(doctorCmd, nil) + + if got := os.Getenv("BEADS_DIR"); got != targetBeadsDir { + t.Fatalf("BEADS_DIR = %q, want %q", got, targetBeadsDir) + } + if !serverMode { + t.Fatal("doctor should use the explicit target repo's server mode") + } +} + +func TestBootstrapPersistentPreRunUsesExplicitDBTarget(t *testing.T) { + callerRepo := filepath.Join(t.TempDir(), "caller") + callerBeadsDir := filepath.Join(callerRepo, ".beads") + writeTestConfigYAML(t, callerBeadsDir, "") + writeMetadataConfig(t, callerBeadsDir, configfile.DoltModeEmbedded, "caller_bootstrap_test") + + targetRepo := filepath.Join(t.TempDir(), "target") + targetBeadsDir := filepath.Join(targetRepo, ".beads") + writeTestConfigYAML(t, targetBeadsDir, "") + writeMetadataConfig(t, targetBeadsDir, configfile.DoltModeServer, "target_bootstrap_test") + + t.Chdir(callerRepo) + t.Setenv("BEADS_DIR", callerBeadsDir) + t.Setenv("BEADS_DOLT_SHARED_SERVER", "") + t.Setenv("BEADS_DOLT_SERVER_DATABASE", "") + t.Setenv("BEADS_DOLT_SERVER_PORT", "") + + config.ResetForTesting() + t.Cleanup(config.ResetForTesting) + savePersistentPreRunState(t) + + targetDBPath := filepath.Join(targetBeadsDir, "dolt") + dbPath = targetDBPath + if flag := rootCmd.PersistentFlags().Lookup("db"); flag != nil { + flag.Changed = true + } + + rootCmd.PersistentPreRun(bootstrapCmd, nil) + + if got := os.Getenv("BEADS_DIR"); got != targetBeadsDir { + t.Fatalf("BEADS_DIR = %q, want %q", got, targetBeadsDir) + } +} + +func TestLoadSelectionEnvironmentUsesAmbientEnvFileForBEADSDB(t *testing.T) { + callerRepo := filepath.Join(t.TempDir(), "caller") + callerBeadsDir := filepath.Join(callerRepo, ".beads") + writeTestConfigYAML(t, callerBeadsDir, "") + + targetRepo := filepath.Join(t.TempDir(), "target") + targetBeadsDir := filepath.Join(targetRepo, ".beads") + writeTestConfigYAML(t, targetBeadsDir, "") + targetDBPath := filepath.Join(targetBeadsDir, "dolt") + if err := os.MkdirAll(targetDBPath, 0o700); err != nil { + t.Fatalf("mkdir target db dir: %v", err) + } + if err := os.WriteFile(filepath.Join(callerBeadsDir, ".env"), []byte("BEADS_DB="+targetDBPath+"\n"), 0o600); err != nil { + t.Fatalf("write caller .env: %v", err) + } + + t.Chdir(callerRepo) + t.Setenv("BEADS_DIR", "") + t.Setenv("BEADS_DB", "") + t.Setenv("BD_DB", "") + + loadSelectionEnvironment() + + if got := os.Getenv("BEADS_DB"); utils.CanonicalizePath(got) != utils.CanonicalizePath(targetDBPath) { + t.Fatalf("BEADS_DB = %q, want %q", got, targetDBPath) + } + if got := beads.FindDatabasePath(); utils.CanonicalizePath(got) != utils.CanonicalizePath(targetDBPath) { + t.Fatalf("FindDatabasePath() = %q, want %q", got, targetDBPath) + } +} + +func TestSelectedDoltBeadsDirUsesReboundBEADSDir(t *testing.T) { + callerRepo := filepath.Join(t.TempDir(), "caller") + callerBeadsDir := filepath.Join(callerRepo, ".beads") + writeTestConfigYAML(t, callerBeadsDir, "") + + targetRepo := filepath.Join(t.TempDir(), "target") + targetBeadsDir := filepath.Join(targetRepo, ".beads") + writeTestConfigYAML(t, targetBeadsDir, "") + targetDBPath := filepath.Join(targetBeadsDir, "dolt") + if err := os.MkdirAll(targetDBPath, 0o700); err != nil { + t.Fatalf("mkdir target db dir: %v", err) + } + + t.Chdir(callerRepo) + t.Setenv("BEADS_DIR", targetBeadsDir) + t.Setenv("BEADS_DB", filepath.Join(callerBeadsDir, "dolt")) + + if got := selectedDoltBeadsDir(); utils.CanonicalizePath(got) != utils.CanonicalizePath(targetBeadsDir) { + t.Fatalf("selectedDoltBeadsDir() = %q, want %q", got, targetBeadsDir) + } +} diff --git a/cmd/bd/doctor_health.go b/cmd/bd/doctor_health.go index 4e1190e2c4..64a844e681 100644 --- a/cmd/bd/doctor_health.go +++ b/cmd/bd/doctor_health.go @@ -228,7 +228,7 @@ func hintsDisabledDB(db *sql.DB) bool { // Uses an existing DB connection. func checkVersionMismatchDB(db *sql.DB) string { var dbVersion string - err := db.QueryRow("SELECT value FROM metadata WHERE `key` = 'bd_version'").Scan(&dbVersion) + err := db.QueryRow("SELECT value FROM local_metadata WHERE `key` = 'bd_version'").Scan(&dbVersion) if err != nil { return "" // Can't read version, skip } diff --git a/cmd/bd/doctor_health_integration_test.go b/cmd/bd/doctor_health_integration_test.go index 7c17b442f0..0dfe9599f7 100644 --- a/cmd/bd/doctor_health_integration_test.go +++ b/cmd/bd/doctor_health_integration_test.go @@ -58,7 +58,7 @@ func TestDoctorCheckHealthReportsVersionMismatchOnRepoLocalPort(t *testing.T) { t.Skip("derived repo-local port unexpectedly matched 3307; not exercising regression") } - sqlOut, sqlErr := runBDExecAllowErrorWithEnv(t, tmpDir, env, "sql", "UPDATE metadata SET value = '0.0.0' WHERE `key` = 'bd_version'") + sqlOut, sqlErr := runBDExecAllowErrorWithEnv(t, tmpDir, env, "sql", "UPDATE local_metadata SET value = '0.0.0' WHERE `key` = 'bd_version'") if sqlErr != nil { t.Fatalf("bd sql UPDATE failed: %v\n%s", sqlErr, sqlOut) } diff --git a/cmd/bd/doctor_health_test.go b/cmd/bd/doctor_health_test.go index 060c2e2bd6..e8749347f6 100644 --- a/cmd/bd/doctor_health_test.go +++ b/cmd/bd/doctor_health_test.go @@ -34,8 +34,8 @@ func TestCheckVersionMismatchDB_UsesQuotedMetadataKey(t *testing.T) { store := newTestStoreIsolatedDB(t, dbPath, "test") ctx := context.Background() - if err := store.SetMetadata(ctx, "bd_version", "0.0.0"); err != nil { - t.Fatalf("SetMetadata(bd_version): %v", err) + if err := store.SetLocalMetadata(ctx, "bd_version", "0.0.0"); err != nil { + t.Fatalf("SetLocalMetadata(bd_version): %v", err) } issue := checkVersionMismatchDB(store.DB()) diff --git a/cmd/bd/dolt.go b/cmd/bd/dolt.go index 6741f6e82a..5e52df79a9 100644 --- a/cmd/bd/dolt.go +++ b/cmd/bd/dolt.go @@ -14,6 +14,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/configfile" "github.com/steveyegge/beads/internal/doltserver" @@ -1023,7 +1024,7 @@ func init() { } func selectedDoltBeadsDir() string { - beadsDir := selectedNoDBBeadsDir() + beadsDir := beads.FindBeadsDir() if beadsDir == "" { return "" } diff --git a/cmd/bd/dolt_metadata_e2e_test.go b/cmd/bd/dolt_metadata_e2e_test.go index 192fb816a8..bac97b8d97 100644 --- a/cmd/bd/dolt_metadata_e2e_test.go +++ b/cmd/bd/dolt_metadata_e2e_test.go @@ -112,8 +112,9 @@ func TestE2E_DoctorFixMetadataRoundtrip(t *testing.T) { } // Delete metadata to simulate a pre-Phase-1 database + // bd_version is now in local_metadata (dolt-ignored), repo_id/clone_id remain in metadata sqlOut, sqlErr := runBDExecAllowErrorWithEnv(t, tmpDir, env, "sql", - "DELETE FROM metadata WHERE key IN ('bd_version', 'repo_id', 'clone_id')") + "DELETE FROM local_metadata WHERE `key` = 'bd_version'; DELETE FROM metadata WHERE `key` IN ('repo_id', 'clone_id')") if sqlErr != nil { t.Fatalf("bd sql DELETE failed: %v\n%s", sqlErr, sqlOut) } diff --git a/cmd/bd/duplicate.go b/cmd/bd/duplicate.go index eb9db06fd6..ab6099ddff 100644 --- a/cmd/bd/duplicate.go +++ b/cmd/bd/duplicate.go @@ -1,9 +1,7 @@ package main import ( - "encoding/json" "fmt" - "os" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/types" @@ -61,7 +59,9 @@ func init() { func runDuplicate(cmd *cobra.Command, args []string) error { CheckReadonly("duplicate") - ctx := rootCtx + ctx := getRootContext() + store := getStore() + actor := getActor() // Resolve partial IDs var duplicateID, canonicalID string @@ -111,15 +111,13 @@ func runDuplicate(cmd *cobra.Command, args []string) error { } } - if jsonOutput { - result := map[string]interface{}{ + if isJSONOutput() { + outputJSON(map[string]interface{}{ "duplicate": duplicateID, "canonical": canonicalID, "status": "closed", - } - encoder := json.NewEncoder(os.Stdout) - encoder.SetIndent("", " ") - return encoder.Encode(result) + }) + return nil } fmt.Printf("%s Marked %s as duplicate of %s (closed)\n", ui.RenderPass("✓"), duplicateID, canonicalID) @@ -129,7 +127,9 @@ func runDuplicate(cmd *cobra.Command, args []string) error { func runSupersede(cmd *cobra.Command, args []string) error { CheckReadonly("supersede") - ctx := rootCtx + ctx := getRootContext() + store := getStore() + actor := getActor() // Resolve partial IDs var oldID, newID string @@ -179,15 +179,13 @@ func runSupersede(cmd *cobra.Command, args []string) error { } } - if jsonOutput { - result := map[string]interface{}{ + if isJSONOutput() { + outputJSON(map[string]interface{}{ "superseded": oldID, "replacement": newID, "status": "closed", - } - encoder := json.NewEncoder(os.Stdout) - encoder.SetIndent("", " ") - return encoder.Encode(result) + }) + return nil } fmt.Printf("%s Marked %s as superseded by %s (closed)\n", ui.RenderPass("✓"), oldID, newID) diff --git a/cmd/bd/epic_embedded_test.go b/cmd/bd/epic_embedded_test.go index b8887cc431..b1f2efa730 100644 --- a/cmd/bd/epic_embedded_test.go +++ b/cmd/bd/epic_embedded_test.go @@ -10,6 +10,8 @@ import ( "strings" "sync" "testing" + + "github.com/steveyegge/beads/internal/types" ) // bdEpic runs "bd epic" with the given args and returns raw stdout. @@ -85,9 +87,7 @@ func TestEmbeddedEpic(t *testing.T) { }) t.Run("status_eligible_only", func(t *testing.T) { - // When all children are closed, the epic is auto-closed by bd close. - // So --eligible-only may find nothing if auto-close already happened. - // Just verify the flag doesn't crash and filters correctly. + // --eligible-only should only show epics with all children complete. out := bdEpic(t, bd, dir, "status", "--eligible-only") // epic1 has open children — should NOT appear if strings.Contains(out, epic1.ID) { @@ -122,25 +122,29 @@ func TestEmbeddedEpic(t *testing.T) { if strings.Contains(out, epic1.ID) { t.Errorf("epic1 (not eligible) should not appear in dry-run: %s", out) } - // Just verify no crash — auto-close may have already closed eligible epics }) t.Run("close_eligible_closes_epics", func(t *testing.T) { - // Note: bd close auto-closes the parent epic when the last child closes. - // So by the time we run close-eligible, the epic is already closed. - // Verify close-eligible handles this gracefully (no epics to close). dir3, _, _ := bdInit(t, bd, "--prefix", "ep3") e := bdCreate(t, bd, dir3, "Close me epic", "--type", "epic") ch := bdCreate(t, bd, dir3, "Close me child", "--type", "task") bdDep(t, bd, dir3, "add", ch.ID, e.ID, "--type", "parent-child") - bdClose(t, bd, dir3, ch.ID) // This auto-closes the epic + bdClose(t, bd, dir3, ch.ID) + + got := bdShow(t, bd, dir3, e.ID) + if got.Status != types.StatusOpen { + t.Fatalf("expected epic to remain open until close-eligible runs, got %s", got.Status) + } out := bdEpic(t, bd, dir3, "close-eligible") - // Epic already auto-closed — close-eligible finds nothing or reports already closed - _ = e if strings.Contains(out, "Error") { t.Errorf("unexpected error: %s", out) } + + got = bdShow(t, bd, dir3, e.ID) + if got.Status != types.StatusClosed { + t.Errorf("expected close-eligible to close the epic, got %s", got.Status) + } }) t.Run("close_eligible_json_output", func(t *testing.T) { @@ -148,7 +152,7 @@ func TestEmbeddedEpic(t *testing.T) { e := bdCreate(t, bd, dir4, "JSON close epic", "--type", "epic") ch := bdCreate(t, bd, dir4, "JSON close child", "--type", "task") bdDep(t, bd, dir4, "add", ch.ID, e.ID, "--type", "parent-child") - bdClose(t, bd, dir4, ch.ID) // auto-closes epic + bdClose(t, bd, dir4, ch.ID) _ = e fullArgs := []string{"epic", "close-eligible", "--json"} diff --git a/cmd/bd/epic_test.go b/cmd/bd/epic_test.go index ec3748b00f..2b6182db71 100644 --- a/cmd/bd/epic_test.go +++ b/cmd/bd/epic_test.go @@ -9,90 +9,101 @@ import ( "testing" "time" + "github.com/steveyegge/beads/internal/storage/dolt" "github.com/steveyegge/beads/internal/types" ) -func TestEpicCommand(t *testing.T) { +type epicTestHelper struct { + s *dolt.DoltStore + ctx context.Context +} + +func newEpicTestHelper(t *testing.T) *epicTestHelper { tmpDir := t.TempDir() testDB := filepath.Join(tmpDir, ".beads", "beads.db") - sqliteStore := newTestStore(t, testDB) - ctx := context.Background() - - // Create an epic with children - epic := &types.Issue{ - ID: "test-epic-1", - Title: "Test Epic", - Description: "Epic description", - Status: types.StatusOpen, - Priority: 1, - IssueType: types.TypeEpic, - CreatedAt: time.Now(), + return &epicTestHelper{ + s: newTestStore(t, testDB), + ctx: context.Background(), } +} - if err := sqliteStore.CreateIssue(ctx, epic, "test"); err != nil { +func (h *epicTestHelper) createIssue(t *testing.T, issue *types.Issue) { + t.Helper() + if err := h.s.CreateIssue(h.ctx, issue, "test"); err != nil { t.Fatal(err) } +} - // Create child tasks - child1 := &types.Issue{ - Title: "Child Task 1", - Status: types.StatusClosed, - Priority: 2, - IssueType: types.TypeTask, - CreatedAt: time.Now(), - ClosedAt: ptrTime(time.Now()), - } - - child2 := &types.Issue{ - Title: "Child Task 2", - Status: types.StatusOpen, - Priority: 2, - IssueType: types.TypeTask, - CreatedAt: time.Now(), - } - - if err := sqliteStore.CreateIssue(ctx, child1, "test"); err != nil { - t.Fatal(err) - } - if err := sqliteStore.CreateIssue(ctx, child2, "test"); err != nil { +func (h *epicTestHelper) addDependency(t *testing.T, dep *types.Dependency) { + t.Helper() + if err := h.s.AddDependency(h.ctx, dep, "test"); err != nil { t.Fatal(err) } +} - // Add parent-child dependencies - dep1 := &types.Dependency{ - IssueID: child1.ID, - DependsOnID: epic.ID, - Type: types.DepParentChild, - } - dep2 := &types.Dependency{ - IssueID: child2.ID, - DependsOnID: epic.ID, - Type: types.DepParentChild, +func (h *epicTestHelper) getEpicStatus(t *testing.T, epicID string) *types.EpicStatus { + t.Helper() + epics, err := h.s.GetEpicsEligibleForClosure(h.ctx) + if err != nil { + t.Fatalf("GetEpicsEligibleForClosure failed: %v", err) } - if err := sqliteStore.AddDependency(ctx, dep1, "test"); err != nil { - t.Fatal(err) - } - if err := sqliteStore.AddDependency(ctx, dep2, "test"); err != nil { - t.Fatal(err) + for _, epic := range epics { + if epic.Epic.ID == epicID { + return epic + } } + return nil +} - // Test GetEpicsEligibleForClosure - store = sqliteStore +func TestEpicSuite(t *testing.T) { + h := newEpicTestHelper(t) + + t.Run("MixedChildrenNotEligible", func(t *testing.T) { + epic := &types.Issue{ + ID: "test-epic-1", + Title: "Test Epic", + Description: "Epic description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeEpic, + CreatedAt: time.Now(), + } + h.createIssue(t, epic) - epics, err := sqliteStore.GetEpicsEligibleForClosure(ctx) - if err != nil { - t.Fatalf("GetEpicsEligibleForClosure failed: %v", err) - } + child1 := &types.Issue{ + Title: "Child Task 1", + Status: types.StatusClosed, + Priority: 2, + IssueType: types.TypeTask, + CreatedAt: time.Now(), + ClosedAt: ptrTime(time.Now()), + } + child2 := &types.Issue{ + Title: "Child Task 2", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + CreatedAt: time.Now(), + } + h.createIssue(t, child1) + h.createIssue(t, child2) - if len(epics) != 1 { - t.Errorf("Expected 1 epic, got %d", len(epics)) - } + h.addDependency(t, &types.Dependency{ + IssueID: child1.ID, + DependsOnID: epic.ID, + Type: types.DepParentChild, + }) + h.addDependency(t, &types.Dependency{ + IssueID: child2.ID, + DependsOnID: epic.ID, + Type: types.DepParentChild, + }) - if len(epics) > 0 { - epicStatus := epics[0] - if epicStatus.Epic.ID != "test-epic-1" { - t.Errorf("Expected epic ID test-epic-1, got %s", epicStatus.Epic.ID) + store = h.s + epicStatus := h.getEpicStatus(t, "test-epic-1") + if epicStatus == nil { + t.Fatal("Epic test-epic-1 not found in results") } if epicStatus.TotalChildren != 2 { t.Errorf("Expected 2 total children, got %d", epicStatus.TotalChildren) @@ -103,193 +114,127 @@ func TestEpicCommand(t *testing.T) { if epicStatus.EligibleForClose { t.Error("Epic should not be eligible for close with open children") } - } -} - -func TestEpicCommandInit(t *testing.T) { - if epicCmd == nil { - t.Fatal("epicCmd should be initialized") - } - - if epicCmd.Use != "epic" { - t.Errorf("Expected Use='epic', got %q", epicCmd.Use) - } - - // Check that subcommands exist - var hasStatusCmd bool - for _, cmd := range epicCmd.Commands() { - if cmd.Use == "status" { - hasStatusCmd = true - } - } - - if !hasStatusCmd { - t.Error("epic command should have status subcommand") - } -} - -func TestEpicEligibleForCloseWithWispChildren(t *testing.T) { - tmpDir := t.TempDir() - testDB := filepath.Join(tmpDir, ".beads", "beads.db") - sqliteStore := newTestStore(t, testDB) - ctx := context.Background() - - // Create an epic with one regular child and one wisp child. - epic := &types.Issue{ - ID: "test-epic-wisp", - Title: "Epic with wisp child", - Description: "Tests that wisp children are counted for closure eligibility", - Status: types.StatusOpen, - Priority: 1, - IssueType: types.TypeEpic, - CreatedAt: time.Now(), - } - if err := sqliteStore.CreateIssue(ctx, epic, "test"); err != nil { - t.Fatal(err) - } - - // Regular child (closed) - regularChild := &types.Issue{ - Title: "Regular child", - Status: types.StatusClosed, - Priority: 2, - IssueType: types.TypeTask, - CreatedAt: time.Now(), - ClosedAt: ptrTime(time.Now()), - } - if err := sqliteStore.CreateIssue(ctx, regularChild, "test"); err != nil { - t.Fatal(err) - } - - // Wisp child (still open) — stored in wisps table - wispChild := &types.Issue{ - Title: "Wisp child", - Status: types.StatusOpen, - Priority: 2, - IssueType: types.TypeTask, - Ephemeral: true, - CreatedAt: time.Now(), - } - if err := sqliteStore.CreateIssue(ctx, wispChild, "test"); err != nil { - t.Fatal(err) - } - - // Add parent-child dependencies - if err := sqliteStore.AddDependency(ctx, &types.Dependency{ - IssueID: regularChild.ID, - DependsOnID: epic.ID, - Type: types.DepParentChild, - }, "test"); err != nil { - t.Fatal(err) - } - if err := sqliteStore.AddDependency(ctx, &types.Dependency{ - IssueID: wispChild.ID, - DependsOnID: epic.ID, - Type: types.DepParentChild, - }, "test"); err != nil { - t.Fatal(err) - } - - // Epic should NOT be eligible — wisp child is still open - epics, err := sqliteStore.GetEpicsEligibleForClosure(ctx) - if err != nil { - t.Fatalf("GetEpicsEligibleForClosure failed: %v", err) - } - - var epicStatus *types.EpicStatus - for _, e := range epics { - if e.Epic.ID == "test-epic-wisp" { - epicStatus = e - break + }) + + t.Run("OpenWispChildNotEligible", func(t *testing.T) { + epic := &types.Issue{ + ID: "test-epic-wisp", + Title: "Epic with wisp child", + Description: "Tests that wisp children are counted for closure eligibility", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeEpic, + CreatedAt: time.Now(), } - } + h.createIssue(t, epic) - if epicStatus == nil { - t.Fatal("Epic test-epic-wisp not found in results") - } - if epicStatus.TotalChildren != 2 { - t.Errorf("Expected 2 total children (1 regular + 1 wisp), got %d", epicStatus.TotalChildren) - } - if epicStatus.ClosedChildren != 1 { - t.Errorf("Expected 1 closed child, got %d", epicStatus.ClosedChildren) - } - if epicStatus.EligibleForClose { - t.Error("Epic should NOT be eligible for close with open wisp child") - } -} - -func TestEpicEligibleForClose(t *testing.T) { - tmpDir := t.TempDir() - testDB := filepath.Join(tmpDir, ".beads", "beads.db") - sqliteStore := newTestStore(t, testDB) - ctx := context.Background() - - // Create an epic where all children are closed - epic := &types.Issue{ - ID: "test-epic-2", - Title: "Fully Completed Epic", - Description: "Epic description", - Status: types.StatusOpen, - Priority: 1, - IssueType: types.TypeEpic, - CreatedAt: time.Now(), - } - - if err := sqliteStore.CreateIssue(ctx, epic, "test"); err != nil { - t.Fatal(err) - } - - // Create all closed children - for i := 1; i <= 3; i++ { - child := &types.Issue{ - Title: fmt.Sprintf("Child Task %d", i), + regularChild := &types.Issue{ + Title: "Regular child", Status: types.StatusClosed, Priority: 2, IssueType: types.TypeTask, CreatedAt: time.Now(), ClosedAt: ptrTime(time.Now()), } - if err := sqliteStore.CreateIssue(ctx, child, "test"); err != nil { - t.Fatal(err) + wispChild := &types.Issue{ + Title: "Wisp child", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + Ephemeral: true, + CreatedAt: time.Now(), } + h.createIssue(t, regularChild) + h.createIssue(t, wispChild) - // Add parent-child dependency - dep := &types.Dependency{ - IssueID: child.ID, + h.addDependency(t, &types.Dependency{ + IssueID: regularChild.ID, DependsOnID: epic.ID, Type: types.DepParentChild, + }) + h.addDependency(t, &types.Dependency{ + IssueID: wispChild.ID, + DependsOnID: epic.ID, + Type: types.DepParentChild, + }) + + epicStatus := h.getEpicStatus(t, "test-epic-wisp") + if epicStatus == nil { + t.Fatal("Epic test-epic-wisp not found in results") } - if err := sqliteStore.AddDependency(ctx, dep, "test"); err != nil { - t.Fatal(err) + if epicStatus.TotalChildren != 2 { + t.Errorf("Expected 2 total children (1 regular + 1 wisp), got %d", epicStatus.TotalChildren) + } + if epicStatus.ClosedChildren != 1 { + t.Errorf("Expected 1 closed child, got %d", epicStatus.ClosedChildren) + } + if epicStatus.EligibleForClose { + t.Error("Epic should NOT be eligible for close with open wisp child") + } + }) + + t.Run("AllChildrenClosedEligible", func(t *testing.T) { + epic := &types.Issue{ + ID: "test-epic-2", + Title: "Fully Completed Epic", + Description: "Epic description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeEpic, + CreatedAt: time.Now(), + } + h.createIssue(t, epic) + + for i := 1; i <= 3; i++ { + child := &types.Issue{ + Title: fmt.Sprintf("Child Task %d", i), + Status: types.StatusClosed, + Priority: 2, + IssueType: types.TypeTask, + CreatedAt: time.Now(), + ClosedAt: ptrTime(time.Now()), + } + h.createIssue(t, child) + h.addDependency(t, &types.Dependency{ + IssueID: child.ID, + DependsOnID: epic.ID, + Type: types.DepParentChild, + }) } - } - - // Test GetEpicsEligibleForClosure - epics, err := sqliteStore.GetEpicsEligibleForClosure(ctx) - if err != nil { - t.Fatalf("GetEpicsEligibleForClosure failed: %v", err) - } - // Find our epic - var epicStatus *types.EpicStatus - for _, e := range epics { - if e.Epic.ID == "test-epic-2" { - epicStatus = e - break + epicStatus := h.getEpicStatus(t, "test-epic-2") + if epicStatus == nil { + t.Fatal("Epic test-epic-2 not found in results") } - } + if epicStatus.TotalChildren != 3 { + t.Errorf("Expected 3 total children, got %d", epicStatus.TotalChildren) + } + if epicStatus.ClosedChildren != 3 { + t.Errorf("Expected 3 closed children, got %d", epicStatus.ClosedChildren) + } + if !epicStatus.EligibleForClose { + t.Error("Epic should be eligible for close when all children are closed") + } + }) +} - if epicStatus == nil { - t.Fatal("Epic test-epic-2 not found in results") +func TestEpicCommandInit(t *testing.T) { + if epicCmd == nil { + t.Fatal("epicCmd should be initialized") } - if epicStatus.TotalChildren != 3 { - t.Errorf("Expected 3 total children, got %d", epicStatus.TotalChildren) + if epicCmd.Use != "epic" { + t.Errorf("Expected Use='epic', got %q", epicCmd.Use) } - if epicStatus.ClosedChildren != 3 { - t.Errorf("Expected 3 closed children, got %d", epicStatus.ClosedChildren) + + var hasStatusCmd bool + for _, cmd := range epicCmd.Commands() { + if cmd.Use == "status" { + hasStatusCmd = true + } } - if !epicStatus.EligibleForClose { - t.Error("Epic should be eligible for close when all children are closed") + + if !hasStatusCmd { + t.Error("epic command should have status subcommand") } } diff --git a/cmd/bd/info.go b/cmd/bd/info.go index 9ec572c01c..ad179e743e 100644 --- a/cmd/bd/info.go +++ b/cmd/bd/info.go @@ -82,7 +82,7 @@ Examples: ctx := rootCtx // Get schema version - schemaVersion, err := store.GetMetadata(ctx, "bd_version") + schemaVersion, err := store.GetLocalMetadata(ctx, "bd_version") if err != nil { schemaVersion = "unknown" } @@ -209,6 +209,42 @@ type VersionChange struct { // versionChanges contains agent-actionable changes for recent versions var versionChanges = []VersionChange{ + { + Version: "1.0.2", + Date: "2026-04-15", + Changes: []string{ + "FIX: npm publish — updated npm-package/package.json URLs (repository, bugs, homepage) from steveyegge/beads to gastownhall/beads so sigstore provenance validation accepts the artifact (v1.0.0 and v1.0.1 npm publishes failed E422 on provenance verification)", + }, + }, + { + Version: "1.0.1", + Date: "2026-04-15", + Changes: []string{ + "NEW: bd batch — atomic multi-operation transactions across create/update/close/dep", + "NEW: bd config drift / bd config apply — detect and reconcile config drift across yaml, git, and database", + "NEW: bd config show — unified provenance view for effective config values", + "NEW: started_at timestamp on issues (recorded when first entering in_progress)", + "NEW: Selective sync — --issues flag, push/pull subcommands, --parent port across trackers", + "NEW: Pool metrics telemetry for shared-server connection pool diagnosis", + "NEW: OpenBestAvailable public API for library consumers", + "NEW: az:// (Azure Blob Storage) recognized as Dolt remote URL scheme", + "NEW: BEADS_DOLT_READY_TIMEOUT env var to override 10s waitForReady timeout on slow hardware", + "CHANGE: Auto-export enabled by default — export.path defaults to issues.jsonl, git-add on", + "CHANGE: gms_pure_go by default — ICU linkage dropped from test/install helpers and release binaries", + "FIX: Worktree-aware path resolution across hooks, doctor, config validate, bootstrap, reset, rename-prefix, formula search", + "FIX: Schema migration conflict and wisp-tables-missing-after-bootstrap-clone", + "FIX: bd mol bond now detects transitive dependency cycles", + "FIX: bd dep add/remove allows cross-prefix dependency targets", + "FIX: bd dolt pull nil-pointer panic in embedded mode", + "FIX: bd list truncation hint shown in all output modes; --watch hierarchy ordering stable", + "FIX: bd update --defer correctly sets status=deferred", + "FIX: Remote URL validation hardened at config parse time (security)", + "FIX: GitLab issue link dependencies now imported during sync pull", + "FIX: SQLite-era JSONL migration preserves dependencies and labels", + "FIX: MCP workspace discovery detects Dolt-backed projects", + "FIX: go install on Windows (ICU header dependency removed)", + }, + }, { Version: "0.63.3", Date: "2026-03-29", diff --git a/cmd/bd/init.go b/cmd/bd/init.go index 225159b6ff..8800984e1b 100644 --- a/cmd/bd/init.go +++ b/cmd/bd/init.go @@ -104,7 +104,7 @@ Non-interactive mode (--non-interactive or BD_NON_INTERACTIVE=1): FatalError("unknown backend %q: only \"dolt\" is supported", backendFlag) } - // Validate --database early, before any side effects + // Validate --database format early, before any side effects. if database != "" { if err := dolt.ValidateDatabaseName(database); err != nil { FatalError("invalid database name %q: %v", database, err) @@ -164,6 +164,14 @@ Non-interactive mode (--non-interactive or BD_NON_INTERACTIVE=1): _ = os.Setenv("BEADS_DOLT_SHARED_SERVER", "1") } + // Reject hyphens in --database for embedded mode. Must run AFTER + // serverMode is set above — otherwise isEmbeddedMode() always returns + // true and incorrectly rejects server-mode names (GH#3231). + if database != "" && strings.ContainsRune(database, '-') && isEmbeddedMode() { + FatalError("database name %q contains hyphens which are invalid in embedded mode; use underscores instead (e.g. %q)", + database, sanitizeDBName(database)) + } + // Initialize config (PersistentPreRun doesn't run for init command) if err := config.Initialize(); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to initialize config: %v\n", err) @@ -662,8 +670,10 @@ Non-interactive mode (--non-interactive or BD_NON_INTERACTIVE=1): // but the system works without it. Failures here degrade gracefully - we warn but continue. // Belt-and-suspenders: write then verify read-back for each field. - // Store and verify the bd version (for version mismatch detection) - verifyMetadata(ctx, store, "bd_version", Version) + // Store bd version in clone-local metadata (dolt-ignored, no merge conflicts) + if err := store.SetLocalMetadata(ctx, "bd_version", Version); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to write bd_version local metadata: %v\n", err) + } // Compute and store repository fingerprint (FR-015) repoID, err := beads.ComputeRepoID() @@ -1346,9 +1356,10 @@ func checkExistingBeadsDataAt(beadsDir string, prefix string) error { // Check for existing Dolt database if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.GetBackend() == configfile.BackendDolt { // Embedded mode stores databases under `.beads/embeddeddolt//`. - // Treat any present embedded DB as "already initialized" (guard against - // accidental re-init / data loss). - if isEmbeddedMode() { + // Use the target workspace metadata rather than ambient process state so + // init guards remain deterministic even when another test or earlier + // command has rebound global server-mode state. + if !cfg.IsDoltServerMode() { embeddedRoot := filepath.Join(beadsDir, "embeddeddolt") entries, err := os.ReadDir(embeddedRoot) if err != nil { diff --git a/cmd/bd/init_embedded_test.go b/cmd/bd/init_embedded_test.go index 3bb404481a..49dbefe583 100644 --- a/cmd/bd/init_embedded_test.go +++ b/cmd/bd/init_embedded_test.go @@ -491,9 +491,19 @@ func TestEmbeddedInit(t *testing.T) { t.Run("metadata_written", func(t *testing.T) { _, beadsDir, _ := bdInit(t, bd, "--prefix", "meta") - if val := readBack(t, beadsDir, "meta", "bd_version", true); val == "" { - t.Error("bd_version metadata not set") - } + // bd_version is in local_metadata (dolt-ignored), not metadata + func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + store, err := embeddeddolt.New(ctx, beadsDir, "meta", "main") + if err != nil { + t.Fatalf("failed to open store for bd_version check: %v", err) + } + defer store.Close() + if val, err := store.GetLocalMetadata(ctx, "bd_version"); err != nil || val == "" { + t.Error("bd_version local metadata not set") + } + }() importTime := readBack(t, beadsDir, "meta", "last_import_time", true) if importTime == "" { t.Error("last_import_time metadata not set") diff --git a/cmd/bd/init_guard_test.go b/cmd/bd/init_guard_test.go index c67b703a3d..d3c51bbb14 100644 --- a/cmd/bd/init_guard_test.go +++ b/cmd/bd/init_guard_test.go @@ -279,6 +279,39 @@ func TestInitGuard_FreshCloneWithMetadataJSON(t *testing.T) { } }) + t.Run("embedded_metadata_ignores_ambient_shared_server_mode", func(t *testing.T) { + t.Setenv("BEADS_DOLT_SHARED_SERVER", "1") + + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + metadata := map[string]interface{}{ + "database": "dolt", + "backend": "dolt", + "dolt_mode": "embedded", + } + data, _ := json.Marshal(metadata) + if err := os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644); err != nil { + t.Fatal(err) + } + + dbDir := filepath.Join(beadsDir, "embeddeddolt", "beads", ".dolt") + if err := os.MkdirAll(dbDir, 0755); err != nil { + t.Fatal(err) + } + + err := checkExistingBeadsDataAt(beadsDir, "test") + if err == nil { + t.Error("existing embedded database should still block init when shared server mode is enabled elsewhere") + } + if err != nil && !strings.Contains(err.Error(), "already initialized") { + t.Errorf("expected 'already initialized' message, got: %v", err) + } + }) + t.Run("no_metadata_json_allows_init", func(t *testing.T) { tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") diff --git a/cmd/bd/init_test.go b/cmd/bd/init_test.go index 553f5df9db..75b962ae8a 100644 --- a/cmd/bd/init_test.go +++ b/cmd/bd/init_test.go @@ -1557,12 +1557,12 @@ func TestInitDoltMetadata(t *testing.T) { defer doltStore.Close() // FR-001: bd_version must be written - bdVersion, err := doltStore.GetMetadata(ctx, "bd_version") + bdVersion, err := doltStore.GetLocalMetadata(ctx, "bd_version") if err != nil { - t.Fatalf("GetMetadata(bd_version) failed: %v", err) + t.Fatalf("GetLocalMetadata(bd_version) failed: %v", err) } if bdVersion == "" { - t.Error("bd_version metadata was not written") + t.Error("bd_version local metadata was not written") } // FR-002: repo_id must be written (git repo with remote configured) diff --git a/cmd/bd/linear.go b/cmd/bd/linear.go index 83c025b5ac..2ed007b54a 100644 --- a/cmd/bd/linear.go +++ b/cmd/bd/linear.go @@ -191,9 +191,10 @@ func runLinearSync(cmd *cobra.Command, args []string) { ctx := rootCtx teamIDs := getLinearTeamIDs(ctx, cliTeams) + willPush := push || !pull // Require explicit --team for push when multiple teams are configured. - if push && len(teamIDs) > 1 && len(cliTeams) == 0 { + if willPush && len(teamIDs) > 1 && len(cliTeams) == 0 { FatalError("push requires explicit --team flag when multiple teams are configured\n" + "Use: bd linear sync --push --team ") } @@ -204,6 +205,11 @@ func runLinearSync(cmd *cobra.Command, args []string) { if err := lt.Init(ctx, store); err != nil { FatalError("initializing Linear tracker: %v", err) } + if willPush { + if err := lt.ValidatePushStateMappings(ctx); err != nil { + FatalError("%v", err) + } + } // Create the sync engine engine := tracker.NewEngine(lt, store, actor) @@ -213,9 +219,6 @@ func runLinearSync(cmd *cobra.Command, args []string) { // Set up Linear-specific pull hooks engine.PullHooks = buildLinearPullHooks(ctx) - // Set up Linear-specific push hooks - engine.PushHooks = buildLinearPushHooks(ctx, lt) - // Build sync options from CLI flags opts := tracker.SyncOptions{ Pull: pull, @@ -239,6 +242,10 @@ func runLinearSync(cmd *cobra.Command, args []string) { if err := applySelectiveSyncFlags(cmd, &opts, push); err != nil { FatalError("%v", err) } + allowProjectCreates := opts.ParentID != "" || len(opts.IssueIDs) > 0 + + // Set up Linear-specific push hooks + engine.PushHooks = buildLinearPushHooks(ctx, lt, allowProjectCreates) // Map conflict resolution if preferLocal { @@ -336,18 +343,22 @@ func buildLinearPullHooks(ctx context.Context) *tracker.PullHooks { } // buildLinearPushHooks creates PushHooks for Linear-specific push behavior. -func buildLinearPushHooks(ctx context.Context, lt *linear.Tracker) *tracker.PushHooks { +func buildLinearPushHooks(ctx context.Context, lt *linear.Tracker, allowProjectCreates bool) *tracker.PushHooks { + config := lt.MappingConfig() return &tracker.PushHooks{ FormatDescription: func(issue *types.Issue) string { return linear.BuildLinearDescription(issue) }, ContentEqual: func(local *types.Issue, remote *tracker.TrackerIssue) bool { - localComparable := linear.NormalizeIssueForLinearHash(local) + remoteIssue, ok := remote.Raw.(*linear.Issue) + if ok && remoteIssue != nil { + return linear.PushFieldsEqual(local, remoteIssue, config) + } remoteConv := lt.FieldMapper().IssueToBeads(remote) if remoteConv == nil || remoteConv.Issue == nil { return false } - return localComparable.ComputeContentHash() == remoteConv.Issue.ComputeContentHash() + return linear.PushFieldsEqualToBeads(local, remoteConv.Issue) }, BuildStateCache: func(ctx context.Context) (interface{}, error) { return linear.BuildStateCacheFromTracker(ctx, lt) @@ -361,6 +372,14 @@ func buildLinearPushHooks(ctx context.Context, lt *linear.Tracker) *tracker.Push return id, id != "" }, ShouldPush: func(issue *types.Issue) bool { + if projectID, _ := store.GetConfig(ctx, "linear.project_id"); projectID != "" { + if issue.ExternalRef == nil || strings.TrimSpace(*issue.ExternalRef) == "" { + if !allowProjectCreates { + return false + } + } + } + // Apply push prefix filtering if configured pushPrefix, _ := store.GetConfig(ctx, "linear.push_prefix") if pushPrefix == "" { diff --git a/cmd/bd/main.go b/cmd/bd/main.go index 26d0f72612..97e33b3b1f 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -136,6 +136,42 @@ func loadBeadsEnvFile(beadsDir string) { _ = gotenv.Load(envFile) } +// loadBeadsSelectionEnvFile loads only the selector keys needed for early +// workspace/database discovery. Unlike loadBeadsEnvFile, this intentionally +// limits itself to BEADS_DIR / BEADS_DB / BD_DB so caller credentials and +// runtime knobs do not leak into explicit-target commands before rebinding. +func loadBeadsSelectionEnvFile(beadsDir string) { + if beadsDir == "" { + return + } + envFile := filepath.Join(beadsDir, ".env") + pairs, err := gotenv.Read(envFile) + if err != nil { + return + } + for _, key := range []string{"BEADS_DIR", "BEADS_DB", "BD_DB"} { + if os.Getenv(key) != "" { + continue + } + if value, ok := pairs[key]; ok && strings.TrimSpace(value) != "" { + _ = os.Setenv(key, value) + } + } +} + +// loadSelectionEnvironment loads only the selector keys required to discover +// the target workspace/database before the store-init path runs. This preserves +// historical support for .beads/.env files that route commands via BEADS_DB or +// BEADS_DIR without importing the caller workspace's broader runtime settings. +func loadSelectionEnvironment() { + if os.Getenv("BEADS_DIR") != "" || os.Getenv("BEADS_DB") != "" || os.Getenv("BD_DB") != "" { + return + } + if beadsDir := beads.FindBeadsDir(); beadsDir != "" { + loadBeadsSelectionEnvFile(beadsDir) + } +} + // loadEnvironment runs the lightweight, always-needed environment setup that // must happen before the noDbCommands early return. This ensures commands like // "bd doctor --server" pick up per-project Dolt credentials from .beads/.env. @@ -177,11 +213,10 @@ func repairSharedServerEmbeddedMismatch(beadsDir string, cfg *configfile.Config) } } -// loadServerModeFromConfig loads the storage mode (embedded vs server) from -// metadata.json so that isEmbeddedMode() returns the correct value. Called -// for commands that skip full DB init but still need to know the mode. -func loadServerModeFromConfig() { - beadsDir := beads.FindBeadsDir() +// loadServerModeFromBeadsDir loads the storage mode (embedded vs server) from +// the given beads directory's metadata.json so that isEmbeddedMode() returns +// the correct value. +func loadServerModeFromBeadsDir(beadsDir string) { if beadsDir == "" { return } @@ -201,6 +236,13 @@ func loadServerModeFromConfig() { } } +// loadServerModeFromConfig loads the storage mode (embedded vs server) from +// metadata.json so that isEmbeddedMode() returns the correct value. Called +// for commands that skip full DB init but still need to know the mode. +func loadServerModeFromConfig() { + loadServerModeFromBeadsDir(beads.FindBeadsDir()) +} + func preserveRedirectSourceDatabase(beadsDir string) { if beadsDir == "" || os.Getenv("BEADS_DOLT_SERVER_DATABASE") != "" { return @@ -215,41 +257,35 @@ func preserveRedirectSourceDatabase(beadsDir string) { } } -func selectedNoDBBeadsDir() string { - selectedDBPath := "" - if rootCmd.PersistentFlags().Changed("db") && dbPath != "" { - selectedDBPath = dbPath +func selectedNoDBBeadsDir(cmd *cobra.Command) string { + if cmd != nil && cmd.Root() != nil && cmd.Root().PersistentFlags().Changed("db") && dbPath != "" { + if selectedBeadsDir := resolveCommandBeadsDir(dbPath); selectedBeadsDir != "" { + return selectedBeadsDir + } + } else if cmd != nil && cmd.PersistentFlags().Changed("db") && dbPath != "" { + if selectedBeadsDir := resolveCommandBeadsDir(dbPath); selectedBeadsDir != "" { + return selectedBeadsDir + } } else if envDB := os.Getenv("BEADS_DB"); envDB != "" { - selectedDBPath = envDB + if selectedBeadsDir := resolveCommandBeadsDir(envDB); selectedBeadsDir != "" { + return selectedBeadsDir + } } else if envDB := os.Getenv("BD_DB"); envDB != "" { - selectedDBPath = envDB - } else { - selectedDBPath = dbPath - } - if selectedDBPath != "" { - if selectedBeadsDir := resolveCommandBeadsDir(selectedDBPath); selectedBeadsDir != "" { + if selectedBeadsDir := resolveCommandBeadsDir(envDB); selectedBeadsDir != "" { return selectedBeadsDir } } - return beads.FindBeadsDir() -} - -func isSelectedNoDBCommand(cmd *cobra.Command) bool { - if cmd == nil { - return false - } - if cmd.Name() == "context" { - return true - } - if cmd.Parent() == nil || cmd.Parent().Name() != "dolt" { - return false + if os.Getenv("BEADS_DIR") != "" { + if selectedBeadsDir := beads.FindBeadsDir(); selectedBeadsDir != "" { + return selectedBeadsDir + } } - switch cmd.Name() { - case "push", "pull", "commit": - return false - default: - return true + if dbPath != "" { + if selectedBeadsDir := resolveCommandBeadsDir(dbPath); selectedBeadsDir != "" { + return selectedBeadsDir + } } + return beads.FindBeadsDir() } // configCommandCanRunWithoutStore returns true for config subcommands whose Run @@ -288,16 +324,50 @@ func configCommandCanRunWithoutStore(cmd *cobra.Command, args []string) bool { } } -func prepareSelectedNoDBContext(beadsDir string) { +func prepareSelectedCommandContext(beadsDir string, loadEnv bool) { if beadsDir == "" { return } _ = os.Setenv("BEADS_DIR", beadsDir) - loadBeadsEnvFile(beadsDir) + if loadEnv { + loadBeadsEnvFile(beadsDir) + } preserveRedirectSourceDatabase(beadsDir) if err := config.Initialize(); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to reinitialize config for selected beads dir: %v\n", err) } + config.CheckBeadsDirPermissions(beadsDir) + loadServerModeFromBeadsDir(beadsDir) +} + +func prepareSelectedNoDBContext(beadsDir string) { + prepareSelectedCommandContext(beadsDir, true) +} + +// refreshBoundCommandConfig reapplies config-backed defaults after the command +// context has been rebound to a resolved target beads directory. This keeps +// explicit flags authoritative while letting rerouted/explicit-db commands use +// the target repo's config rather than the caller's config. +func refreshBoundCommandConfig(cmd *cobra.Command) { + if cmd == nil { + return + } + root := cmd.Root() + if root == nil { + root = cmd + } + if !root.PersistentFlags().Changed("json") && !root.PersistentFlags().Changed("format") { + jsonOutput = config.GetBool("json") + } + if !root.PersistentFlags().Changed("readonly") { + readonlyMode = config.GetBool("readonly") + } + if !root.PersistentFlags().Changed("actor") { + actor = config.GetString("actor") + } + if !root.PersistentFlags().Changed("dolt-auto-commit") { + doltAutoCommit = config.GetString("dolt.auto-commit") + } } // resolveCommandBeadsDir maps a discovered Dolt data path back to the owning @@ -478,6 +548,8 @@ var rootCmd = &cobra.Command{ FatalError("%v", err) } + loadSelectionEnvironment() + // Apply viper configuration if flags weren't explicitly set // Priority: flags > viper (config file + env vars) > defaults // Do this BEFORE early-return so init/version/help respect config @@ -512,7 +584,8 @@ var rootCmd = &cobra.Command{ WasSet bool }{readonlyMode, true} } - if !cmd.Root().PersistentFlags().Changed("db") && dbPath == "" { + if !cmd.Root().PersistentFlags().Changed("db") && dbPath == "" && + os.Getenv("BEADS_DB") == "" && os.Getenv("BD_DB") == "" && os.Getenv("BEADS_DIR") == "" { dbPath = config.GetString("db") } else if cmd.Root().PersistentFlags().Changed("db") { flagOverrides["db"] = struct { @@ -545,22 +618,6 @@ var rootCmd = &cobra.Command{ } } - // Validate Dolt auto-commit mode early so all commands fail fast on invalid config. - if _, err := getDoltAutoCommitMode(); err != nil { - FatalError("%v", err) - } - - // GH#2677: Load .beads/.env before the noDbCommands early return so that - // commands like "bd doctor --server" pick up per-project Dolt credentials. - if !isSelectedNoDBCommand(cmd) { - loadEnvironment() - } - - // Load storage mode (embedded vs server) early so that isEmbeddedMode() - // returns the correct value for all commands, including those that skip - // full DB initialization (e.g., bd dolt status, bd doctor, bd bootstrap). - loadServerModeFromConfig() - // GH#1093: Check noDbCommands BEFORE expensive operations // to avoid spawning git subprocesses for simple commands // like "bd version" that don't need database access. @@ -603,6 +660,7 @@ var rootCmd = &cobra.Command{ // Check both the command name and parent command name for subcommands cmdName := cmd.Name() isSubcommand := cmd.Parent() != nil && cmd.Parent().Name() != "bd" + skipsStoreInit := false if cmd.Parent() != nil { parentName := cmd.Parent().Name() if parentName == "dolt" && slices.Contains(needsStoreDoltSubcommands, cmdName) { @@ -610,22 +668,42 @@ var rootCmd = &cobra.Command{ } else if slices.Contains(needsStoreDoltGrandchildren, parentName) { // GH#2224: dolt remote add/list/remove need the store — fall through to init } else if slices.Contains(noDbCommands, parentName) { - return + skipsStoreInit = true } } // Only skip for top-level commands in noDbCommands, not subcommands // that happen to share names (e.g., "bd backup init" vs "bd init"). if slices.Contains(noDbCommands, cmdName) && !isSubcommand { - return + skipsStoreInit = true } // Skip for root command with no subcommand (just shows help) if cmd.Parent() == nil && cmdName == cmd.Use { - return + skipsStoreInit = true } // Also skip for --version flag on root command (cmdName would be "bd") if v, _ := cmd.Flags().GetBool("version"); v { + skipsStoreInit = true + } + + // Commands that skip store initialization still need early config/env + // setup before they inspect server mode or per-project Dolt settings. + // Rebind them to the selected workspace so explicit --db / BEADS_DB + // targets behave consistently across doctor/bootstrap/context/dolt. + if skipsStoreInit { + prepareSelectedNoDBContext(selectedNoDBBeadsDir(cmd)) + refreshBoundCommandConfig(cmd) + if beadsDir := os.Getenv("BEADS_DIR"); beadsDir == "" { + loadEnvironment() + loadServerModeFromConfig() + } + if _, err := getDoltAutoCommitMode(); err != nil { + FatalError("%v", err) + } + } + + if skipsStoreInit { return } @@ -654,7 +732,9 @@ var rootCmd = &cobra.Command{ // When .beads/redirect points to a shared directory with a different // dolt_database, the source's database name would be lost. Capture it // early and set BEADS_DOLT_SERVER_DATABASE so all store opens use it. - preserveRedirectSourceDatabase(beads.GetRedirectInfo().LocalDir) + if dbPath == "" { + preserveRedirectSourceDatabase(beads.GetRedirectInfo().LocalDir) + } // Initialize database path if dbPath == "" { @@ -694,6 +774,13 @@ var rootCmd = &cobra.Command{ } } + beadsDir := resolveCommandBeadsDir(dbPath) + prepareSelectedCommandContext(beadsDir, true) + refreshBoundCommandConfig(cmd) + if _, err := getDoltAutoCommitMode(); err != nil { + FatalError("%v", err) + } + // Set actor for audit trail actor = getActorWithGit() // Attach actor to the command span now that we have it. @@ -715,7 +802,6 @@ var rootCmd = &cobra.Command{ // opens its own store connection, writes the version metadata, commits it, // and closes BEFORE the main store is opened. This ensures bd doctor and // read-only commands see the correct version after a CLI upgrade. - beadsDir := resolveCommandBeadsDir(dbPath) autoMigrateOnVersionBump(beadsDir) @@ -890,7 +976,7 @@ var rootCmd = &cobra.Command{ for tipID := range commandTipIDsShown { key := fmt.Sprintf("tip_%s_last_shown", tipID) value := time.Now().Format(time.RFC3339) - if err := store.SetMetadata(rootCtx, key, value); err != nil { + if err := store.SetLocalMetadata(rootCtx, key, value); err != nil { FatalError("dolt tip auto-commit failed: %v", err) } } diff --git a/cmd/bd/memory_embedded_test.go b/cmd/bd/memory_embedded_test.go index f1add87520..93e7fcdc02 100644 --- a/cmd/bd/memory_embedded_test.go +++ b/cmd/bd/memory_embedded_test.go @@ -220,6 +220,15 @@ func TestEmbeddedMemoryConcurrent(t *testing.T) { // flock contention, not export behavior. With export.auto=true // (the default since GH#2973), 8 concurrent writers also trigger // post-write read paths that race with in-flight commits. + // + // The underlying race is not flock-level (flock already serializes + // bd subprocesses) but engine-shutdown-level: Dolt's working-set + // persistence can lag behind flock release, so the next subprocess + // sometimes commits with a stale view and overwrites a prior forget. + // See GH#3260 for the investigation (PR #3269 was the CI probe). + // Fix would require synchronous flush on engine close or a long-lived + // engine per store; both are substantial work. Until then, this + // workaround keeps the test deterministic. disableAutoExport := exec.Command(bd, "config", "set", "export.auto", "false") disableAutoExport.Dir = dir disableAutoExport.Env = bdEnv(dir) diff --git a/cmd/bd/migrate.go b/cmd/bd/migrate.go index e0da836322..1459e1aca4 100644 --- a/cmd/bd/migrate.go +++ b/cmd/bd/migrate.go @@ -99,7 +99,7 @@ func handleDoltMetadataUpdate(cfg *configfile.Config, dryRun bool) { } // Check current state of all metadata fields - currentVersion, _ := store.GetMetadata(ctx, "bd_version") + currentVersion, _ := store.GetLocalMetadata(ctx, "bd_version") currentRepoID, _ := store.GetMetadata(ctx, "repo_id") currentCloneID, _ := store.GetMetadata(ctx, "clone_id") @@ -179,7 +179,7 @@ func handleDoltMetadataUpdate(cfg *configfile.Config, dryRun bool) { } // Update version metadata (fatal on failure — version is critical) - if err := store.SetMetadata(ctx, "bd_version", Version); err != nil { + if err := store.SetLocalMetadata(ctx, "bd_version", Version); err != nil { if jsonOutput { outputJSON(map[string]interface{}{ "error": "version_update_failed", @@ -448,7 +448,7 @@ func handleInspect() { ctx := rootCtx // Get current schema version - schemaVersion, err := store.GetMetadata(ctx, "bd_version") + schemaVersion, err := store.GetLocalMetadata(ctx, "bd_version") if err != nil { schemaVersion = "unknown" } diff --git a/cmd/bd/migrate_test.go b/cmd/bd/migrate_test.go index e457ddd1da..92eaa8e0c0 100644 --- a/cmd/bd/migrate_test.go +++ b/cmd/bd/migrate_test.go @@ -39,7 +39,7 @@ func TestMigrateRespectsConfigJSON(t *testing.T) { t.Skipf("skipping: Dolt server not available: %v", err) } ctx := context.Background() - if err := store.SetMetadata(ctx, "bd_version", "0.21.1"); err != nil { + if err := store.SetLocalMetadata(ctx, "bd_version", "0.21.1"); err != nil { t.Fatalf("Failed to set version: %v", err) } _ = store.Close() diff --git a/cmd/bd/store_factory.go b/cmd/bd/store_factory.go index c25d840dac..4e2546bd51 100644 --- a/cmd/bd/store_factory.go +++ b/cmd/bd/store_factory.go @@ -4,7 +4,10 @@ package main import ( "context" + "fmt" + "os" "path/filepath" + "strings" "github.com/steveyegge/beads/internal/configfile" "github.com/steveyegge/beads/internal/doltserver" @@ -60,6 +63,9 @@ func acquireEmbeddedLock(beadsDir string, serverMode bool) (embeddeddolt.Unlocke // newDoltStoreFromConfig creates a storage backend from the beads directory's // persisted metadata.json configuration. Uses embedded Dolt by default; // connects to dolt sql-server when dolt_mode is "server". +// +// For embedded mode, legacy hyphenated database names (pre-GH#2142) are +// auto-sanitized to underscores and the fix is persisted to metadata.json. func newDoltStoreFromConfig(ctx context.Context, beadsDir string) (storage.DoltStorage, error) { cfg, err := configfile.Load(beadsDir) if err == nil && cfg != nil && cfg.IsDoltServerMode() { @@ -69,16 +75,80 @@ func newDoltStoreFromConfig(ctx context.Context, beadsDir string) (storage.DoltS if cfg != nil { database = cfg.GetDoltDatabase() } + if sanitized := sanitizeDBName(database); sanitized != database { + if err := migrateHyphenatedDB(beadsDir, cfg, database, sanitized); err != nil { + return nil, fmt.Errorf("auto-sanitize database name %q → %q: %w", database, sanitized, err) + } + database = sanitized + } return embeddeddolt.New(ctx, beadsDir, database, "main") } +// sanitizeDBName replaces hyphens and dots with underscores for +// SQL-idiomatic embedded Dolt database names (GH#2142, GH#3231). +func sanitizeDBName(name string) string { + name = strings.ReplaceAll(name, "-", "_") + name = strings.ReplaceAll(name, ".", "_") + return name +} + +// migrateHyphenatedDB renames a legacy hyphenated database directory and +// persists the sanitized name to metadata.json so subsequent opens use it. +// This handles projects initialized before GH#2142 that upgrade to +// embedded-mode-default builds (GH#3231). +func migrateHyphenatedDB(beadsDir string, cfg *configfile.Config, oldName, newName string) error { + dataDir := filepath.Join(beadsDir, "embeddeddolt") + oldDir := filepath.Join(dataDir, oldName) + newDir := filepath.Join(dataDir, newName) + + oldExists := false + if info, err := os.Stat(oldDir); err == nil && info.IsDir() { + oldExists = true + } + + if oldExists { + _, newErr := os.Stat(newDir) + switch { + case newErr == nil: + return fmt.Errorf("cannot auto-migrate database: both %q and %q exist under %s; remove one manually and retry", + oldName, newName, dataDir) + case !os.IsNotExist(newErr): + return fmt.Errorf("checking target directory %q: %w", newDir, newErr) + default: + if err := os.Rename(oldDir, newDir); err != nil { + return fmt.Errorf("renaming database directory: %w", err) + } + fmt.Fprintf(os.Stderr, "bd: migrated database directory %q → %q (GH#3231)\n", oldName, newName) + } + } + + if cfg != nil && cfg.DoltDatabase != newName { + cfg.DoltDatabase = newName + if err := cfg.Save(beadsDir); err != nil { + return fmt.Errorf("persisting sanitized database name to metadata.json: %w", err) + } + fmt.Fprintf(os.Stderr, "bd: updated metadata.json dolt_database %q → %q (GH#3231)\n", oldName, newName) + } + return nil +} + // newReadOnlyStoreFromConfig creates a read-only storage backend from the beads // directory's persisted metadata.json configuration. +// +// For embedded mode, invalid characters (hyphens, dots) are sanitized in-memory +// only — no directory renames or metadata.json writes. This prevents cross-repo +// hydration from mutating foreign projects (GH#3231). func newReadOnlyStoreFromConfig(ctx context.Context, beadsDir string) (storage.DoltStorage, error) { cfg, err := configfile.Load(beadsDir) if err == nil && cfg != nil && cfg.IsDoltServerMode() { return dolt.NewFromConfigWithOptions(ctx, beadsDir, &dolt.Config{ReadOnly: true}) } - // Embedded dolt is single-process so read-only is not enforced at the engine level. - return newDoltStoreFromConfig(ctx, beadsDir) + database := configfile.DefaultDoltDatabase + if cfg != nil { + database = cfg.GetDoltDatabase() + } + if sanitized := sanitizeDBName(database); sanitized != database { + database = sanitized + } + return embeddeddolt.New(ctx, beadsDir, database, "main") } diff --git a/cmd/bd/store_factory_nocgo.go b/cmd/bd/store_factory_nocgo.go index 5d8d6e9589..ec1b9b57ed 100644 --- a/cmd/bd/store_factory_nocgo.go +++ b/cmd/bd/store_factory_nocgo.go @@ -22,7 +22,7 @@ func isEmbeddedMode() bool { // available without CGO. func newDoltStore(ctx context.Context, cfg *dolt.Config, _ ...embeddeddolt.Option) (storage.DoltStorage, error) { if !cfg.ServerMode { - return nil, fmt.Errorf("embedded Dolt requires CGO; use server mode (bd init --server)") + return nil, fmt.Errorf("%s", nocgoEmbeddedErrMsg) } return dolt.New(ctx, cfg) } @@ -38,7 +38,7 @@ func newDoltStoreFromConfig(ctx context.Context, beadsDir string) (storage.DoltS if err == nil && cfg != nil && cfg.IsDoltServerMode() { return dolt.NewFromConfig(ctx, beadsDir) } - return nil, fmt.Errorf("embedded Dolt requires CGO; use server mode (bd init --server)") + return nil, fmt.Errorf("%s", nocgoEmbeddedErrMsg) } // newReadOnlyStoreFromConfig creates a read-only server-mode storage backend. @@ -47,5 +47,23 @@ func newReadOnlyStoreFromConfig(ctx context.Context, beadsDir string) (storage.D if err == nil && cfg != nil && cfg.IsDoltServerMode() { return dolt.NewFromConfigWithOptions(ctx, beadsDir, &dolt.Config{ReadOnly: true}) } - return nil, fmt.Errorf("embedded Dolt requires CGO; use server mode (bd init --server)") + return nil, fmt.Errorf("%s", nocgoEmbeddedErrMsg) } + +// nocgoEmbeddedErrMsg guides the user either to server mode (no rebuild +// needed) or to an embedded-capable install path. It intentionally enumerates +// the canonical install paths so users don't have to hunt through docs. +const nocgoEmbeddedErrMsg = `embedded Dolt requires a CGO build, but this bd binary was built with CGO_ENABLED=0. + +Two options: + + 1. Use server mode (no reinstall needed): + bd init --server + Requires a running 'dolt sql-server'. See docs/DOLT.md. + + 2. Reinstall with embedded-mode support: + brew install beads # macOS / Linux + npm install -g @beads/bd # any platform with Node + curl -fsSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash + +See docs/INSTALLING.md for the full comparison.` diff --git a/cmd/bd/store_factory_test.go b/cmd/bd/store_factory_test.go index 6333801f21..2588515d2d 100644 --- a/cmd/bd/store_factory_test.go +++ b/cmd/bd/store_factory_test.go @@ -3,7 +3,9 @@ package main import ( + "encoding/json" "os" + "path/filepath" "strings" "testing" @@ -52,3 +54,217 @@ func TestEmbeddedNew_EmptyDatabaseRejected(t *testing.T) { t.Errorf("unexpected error: %v", err) } } + +// TestNewDoltStoreFromConfig_HyphenatedDBName verifies that +// newDoltStoreFromConfig auto-sanitizes hyphenated database names for embedded +// mode and persists the fix to metadata.json. +// Regression test for GH#3231: pre-#2142 projects break on embedded upgrade. +func TestNewDoltStoreFromConfig_HyphenatedDBName(t *testing.T) { + if os.Getenv("BEADS_TEST_EMBEDDED_DOLT") != "1" { + t.Skip("set BEADS_TEST_EMBEDDED_DOLT=1 to run embedded dolt tests") + } + + beadsDir := t.TempDir() + + cfg := &configfile.Config{ + Database: "dolt", + DoltDatabase: "my-cool-project", + DoltMode: configfile.DoltModeEmbedded, + } + if err := cfg.Save(beadsDir); err != nil { + t.Fatalf("failed to save config: %v", err) + } + + store, err := newDoltStoreFromConfig(t.Context(), beadsDir) + if err != nil { + t.Fatalf("newDoltStoreFromConfig failed (should have auto-sanitized): %v", err) + } + defer store.Close() + + reloaded, err := configfile.Load(beadsDir) + if err != nil { + t.Fatalf("failed to reload config: %v", err) + } + if reloaded.DoltDatabase != "my_cool_project" { + t.Errorf("expected dolt_database to be sanitized to %q, got %q", "my_cool_project", reloaded.DoltDatabase) + } +} + +// TestSanitizeDBName verifies the sanitization logic for database names. +func TestSanitizeDBName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"my-project", "my_project"}, + {"jtbot-core", "jtbot_core"}, + {"no-hyphens-here", "no_hyphens_here"}, + {"dots.and-hyphens", "dots_and_hyphens"}, + {"already_clean", "already_clean"}, + {"beads", "beads"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := sanitizeDBName(tt.input) + if got != tt.want { + t.Errorf("sanitizeDBName(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +// TestMigrateHyphenatedDB_PersistsToMetadata verifies that migrateHyphenatedDB +// updates metadata.json with the sanitized database name. +func TestMigrateHyphenatedDB_PersistsToMetadata(t *testing.T) { + beadsDir := t.TempDir() + + cfg := &configfile.Config{ + Database: "dolt", + DoltDatabase: "my-project", + } + if err := cfg.Save(beadsDir); err != nil { + t.Fatalf("failed to save config: %v", err) + } + + if err := migrateHyphenatedDB(beadsDir, cfg, "my-project", "my_project"); err != nil { + t.Fatalf("migrateHyphenatedDB failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(beadsDir, "metadata.json")) + if err != nil { + t.Fatalf("failed to read metadata.json: %v", err) + } + + var saved configfile.Config + if err := json.Unmarshal(data, &saved); err != nil { + t.Fatalf("failed to parse metadata.json: %v", err) + } + if saved.DoltDatabase != "my_project" { + t.Errorf("expected dolt_database %q in metadata.json, got %q", "my_project", saved.DoltDatabase) + } +} + +// TestMigrateHyphenatedDB_RenamesDirectory verifies that migrateHyphenatedDB +// renames the old hyphenated database directory to the sanitized name. +func TestMigrateHyphenatedDB_RenamesDirectory(t *testing.T) { + beadsDir := t.TempDir() + + dataDir := filepath.Join(beadsDir, "embeddeddolt") + oldDir := filepath.Join(dataDir, "my-project") + newDir := filepath.Join(dataDir, "my_project") + + if err := os.MkdirAll(oldDir, 0o755); err != nil { + t.Fatalf("failed to create old dir: %v", err) + } + sentinel := filepath.Join(oldDir, "sentinel.txt") + if err := os.WriteFile(sentinel, []byte("test"), 0o644); err != nil { + t.Fatalf("failed to write sentinel: %v", err) + } + + cfg := &configfile.Config{DoltDatabase: "my-project"} + if err := cfg.Save(beadsDir); err != nil { + t.Fatalf("failed to save config: %v", err) + } + + if err := migrateHyphenatedDB(beadsDir, cfg, "my-project", "my_project"); err != nil { + t.Fatalf("migrateHyphenatedDB failed: %v", err) + } + + if _, err := os.Stat(oldDir); !os.IsNotExist(err) { + t.Error("old directory should no longer exist after rename") + } + if _, err := os.Stat(filepath.Join(newDir, "sentinel.txt")); err != nil { + t.Error("sentinel file should exist in renamed directory") + } +} + +// TestMigrateHyphenatedDB_CollisionError verifies that migrateHyphenatedDB +// returns an error when both old and new directories exist (GH#3231). +func TestMigrateHyphenatedDB_CollisionError(t *testing.T) { + beadsDir := t.TempDir() + + dataDir := filepath.Join(beadsDir, "embeddeddolt") + oldDir := filepath.Join(dataDir, "my-project") + newDir := filepath.Join(dataDir, "my_project") + + if err := os.MkdirAll(oldDir, 0o755); err != nil { + t.Fatalf("failed to create old dir: %v", err) + } + if err := os.MkdirAll(newDir, 0o755); err != nil { + t.Fatalf("failed to create new dir: %v", err) + } + + cfg := &configfile.Config{DoltDatabase: "my-project"} + if err := cfg.Save(beadsDir); err != nil { + t.Fatalf("failed to save config: %v", err) + } + + err := migrateHyphenatedDB(beadsDir, cfg, "my-project", "my_project") + if err == nil { + t.Fatal("expected error when both directories exist, got nil") + } + if !strings.Contains(err.Error(), "both") { + t.Errorf("expected collision error message, got: %v", err) + } +} + +// TestMigrateHyphenatedDB_NoOldDir verifies that migrateHyphenatedDB still +// updates metadata.json even when the old directory doesn't exist (e.g., fresh +// project where only metadata.json has the bad name). +func TestMigrateHyphenatedDB_NoOldDir(t *testing.T) { + beadsDir := t.TempDir() + + cfg := &configfile.Config{DoltDatabase: "my-project"} + if err := cfg.Save(beadsDir); err != nil { + t.Fatalf("failed to save config: %v", err) + } + + if err := migrateHyphenatedDB(beadsDir, cfg, "my-project", "my_project"); err != nil { + t.Fatalf("migrateHyphenatedDB failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(beadsDir, "metadata.json")) + if err != nil { + t.Fatalf("failed to read metadata.json: %v", err) + } + var saved configfile.Config + if err := json.Unmarshal(data, &saved); err != nil { + t.Fatalf("failed to parse metadata.json: %v", err) + } + if saved.DoltDatabase != "my_project" { + t.Errorf("expected %q, got %q", "my_project", saved.DoltDatabase) + } +} + +// TestNewDoltStoreFromConfig_DottedDBName verifies that dots are also +// auto-sanitized, not just hyphens (GH#3231). +func TestNewDoltStoreFromConfig_DottedDBName(t *testing.T) { + if os.Getenv("BEADS_TEST_EMBEDDED_DOLT") != "1" { + t.Skip("set BEADS_TEST_EMBEDDED_DOLT=1 to run embedded dolt tests") + } + + beadsDir := t.TempDir() + + cfg := &configfile.Config{ + Database: "dolt", + DoltDatabase: "my.project", + DoltMode: configfile.DoltModeEmbedded, + } + if err := cfg.Save(beadsDir); err != nil { + t.Fatalf("failed to save config: %v", err) + } + + store, err := newDoltStoreFromConfig(t.Context(), beadsDir) + if err != nil { + t.Fatalf("newDoltStoreFromConfig failed (should have auto-sanitized dots): %v", err) + } + defer store.Close() + + reloaded, err := configfile.Load(beadsDir) + if err != nil { + t.Fatalf("failed to reload config: %v", err) + } + if reloaded.DoltDatabase != "my_project" { + t.Errorf("expected dolt_database %q, got %q", "my_project", reloaded.DoltDatabase) + } +} diff --git a/cmd/bd/sync_push_pull.go b/cmd/bd/sync_push_pull.go index 108542cb99..b5de0ac91c 100644 --- a/cmd/bd/sync_push_pull.go +++ b/cmd/bd/sync_push_pull.go @@ -395,17 +395,23 @@ func runLinearPush(cmd *cobra.Command, args []string) { ctx := rootCtx teamIDs := getLinearTeamIDs(ctx, nil) + if len(teamIDs) > 1 { + FatalError("linear push does not support multiple configured teams\nUse: bd linear sync --push --team ") + } lt := &linear.Tracker{} lt.SetTeamIDs(teamIDs) if err := lt.Init(ctx, store); err != nil { FatalError("initializing Linear tracker: %v", err) } + if err := lt.ValidatePushStateMappings(ctx); err != nil { + FatalError("%v", err) + } engine := tracker.NewEngine(lt, store, actor) engine.OnMessage = func(msg string) { fmt.Println(" " + msg) } engine.OnWarning = func(msg string) { fmt.Fprintf(os.Stderr, "Warning: %s\n", msg) } - engine.PushHooks = buildLinearPushHooks(ctx, lt) + engine.PushHooks = buildLinearPushHooks(ctx, lt, len(args) > 0) result, err := engine.Sync(ctx, tracker.SyncOptions{ Push: true, diff --git a/cmd/bd/tips.go b/cmd/bd/tips.go index 6f69a4b9f7..133d8705f7 100644 --- a/cmd/bd/tips.go +++ b/cmd/bd/tips.go @@ -136,7 +136,7 @@ func selectNextTip(store storage.DoltStorage) *Tip { // Returns zero time if never shown func getLastShown(store storage.DoltStorage, tipID string) time.Time { key := fmt.Sprintf("tip_%s_last_shown", tipID) - value, err := store.GetMetadata(context.Background(), key) + value, err := store.GetLocalMetadata(context.Background(), key) if err != nil || value == "" { return time.Time{} } @@ -173,7 +173,7 @@ func recordTipShown(store storage.DoltStorage, tipID string) { // Non-critical metadata, ok to fail silently. // If it succeeds, track the write for tip auto-commit behavior. - if err := store.SetMetadata(context.Background(), key, value); err == nil { + if err := store.SetLocalMetadata(context.Background(), key, value); err == nil { commandDidWriteTipMetadata = true if commandTipIDsShown == nil { commandTipIDsShown = make(map[string]struct{}) diff --git a/cmd/bd/version.go b/cmd/bd/version.go index f82bbdaaaa..c0a1e21310 100644 --- a/cmd/bd/version.go +++ b/cmd/bd/version.go @@ -15,7 +15,7 @@ import ( var ( // Version is the current version of bd (overridden by ldflags at build time) - Version = "1.0.0" + Version = "1.0.2" // Build can be set via ldflags at compile time Build = "dev" // Commit and branch the git revision the binary was built from (optional ldflag) diff --git a/cmd/bd/version_tracking.go b/cmd/bd/version_tracking.go index c93f6b80d8..a254940d82 100644 --- a/cmd/bd/version_tracking.go +++ b/cmd/bd/version_tracking.go @@ -199,8 +199,8 @@ func autoMigrateOnVersionBump(beadsDir string) { return } - // Get current database version - dbVersion, err := store.GetMetadata(ctx, "bd_version") + // Get current database version (clone-local, dolt-ignored) + dbVersion, err := store.GetLocalMetadata(ctx, "bd_version") if err != nil { // Failed to read version - skip migration debug.Logf("auto-migrate: failed to read database version: %v", err) @@ -217,7 +217,7 @@ func autoMigrateOnVersionBump(beadsDir string) { } // Check for downgrade: refuse to overwrite a newer version with an older one (gt-e3uiy) - maxVersion, _ := store.GetMetadata(ctx, "bd_version_max") + maxVersion, _ := store.GetLocalMetadata(ctx, "bd_version_max") if dbVersion != "" && doctor.CompareVersions(Version, dbVersion) < 0 { debug.Logf("auto-migrate: refusing downgrade from %s to %s", dbVersion, Version) _ = store.Close() // Best effort cleanup on error path @@ -231,7 +231,7 @@ func autoMigrateOnVersionBump(beadsDir string) { // Perform migration: update database version debug.Logf("auto-migrate: migrating database from %s to %s", dbVersion, Version) - if err := store.SetMetadata(ctx, "bd_version", Version); err != nil { + if err := store.SetLocalMetadata(ctx, "bd_version", Version); err != nil { // Migration failed - log and continue debug.Logf("auto-migrate: failed to update database version: %v", err) _ = store.Close() // Best effort cleanup on error path @@ -240,20 +240,13 @@ func autoMigrateOnVersionBump(beadsDir string) { // Update max version tracking if maxVersion == "" || doctor.CompareVersions(Version, maxVersion) > 0 { - if err := store.SetMetadata(ctx, "bd_version_max", Version); err != nil { + if err := store.SetLocalMetadata(ctx, "bd_version_max", Version); err != nil { debug.Logf("auto-migrate: failed to update max version: %v", err) } } - // Commit the version metadata update to Dolt (bd-jgxi). - // Without an explicit commit, the working set change is lost when the - // Dolt server restarts (common for standalone/embedded users). This - // ensures bd doctor and subsequent commands see the correct version. - commitMsg := fmt.Sprintf("auto-migrate: update bd_version %s → %s", dbVersion, Version) - if err := store.Commit(ctx, commitMsg); err != nil { - debug.Logf("auto-migrate: failed to commit version update: %v", err) - // Non-fatal: the working set still has the update for this session - } + // No Dolt commit needed — local_metadata is dolt-ignored and persists + // in the working set for the lifetime of the server session. // Close database if err := store.Close(); err != nil { diff --git a/cmd/bd/winres/manifest.xml b/cmd/bd/winres/manifest.xml index c5816494ae..ff58dd3713 100644 --- a/cmd/bd/winres/manifest.xml +++ b/cmd/bd/winres/manifest.xml @@ -3,7 +3,7 @@ beads - AI-supervised issue tracker diff --git a/cmd/bd/winres/winres.json b/cmd/bd/winres/winres.json index df698e1927..1cab292bae 100644 --- a/cmd/bd/winres/winres.json +++ b/cmd/bd/winres/winres.json @@ -9,8 +9,8 @@ "#1": { "0000": { "fixed": { - "file_version": "1.0.0", - "product_version": "1.0.0", + "file_version": "1.0.2", + "product_version": "1.0.2", "type": "App" }, "info": { @@ -18,12 +18,12 @@ "Comments": "AI-supervised issue tracker for coding workflows", "CompanyName": "Steve Yegge", "FileDescription": "beads - AI-supervised issue tracker", - "FileVersion": "1.0.0", + "FileVersion": "1.0.2", "InternalName": "bd", "LegalCopyright": "Copyright (c) 2024-2026 Steve Yegge. MIT License.", "OriginalFilename": "bd.exe", "ProductName": "beads", - "ProductVersion": "1.0.0" + "ProductVersion": "1.0.2" } } } diff --git a/default.nix b/default.nix index ccbd1765cf..ec8194acde 100644 --- a/default.nix +++ b/default.nix @@ -8,7 +8,7 @@ }: buildGoModule { pname = "beads"; - version = "1.0.0"; + version = "1.0.2"; src = self; @@ -16,25 +16,25 @@ buildGoModule { subPackages = [ "cmd/bd" ]; doCheck = false; - # Go module dependencies hash - if build fails with hash mismatch, update with the "got:" value - vendorHash = "sha256-7eb7u47f4/OCnK/T56Zd6b5XUyV6vkBmissryBxANBU="; + # gms_pure_go uses Go's stdlib regex instead of go-icu-regex + tags = [ "gms_pure_go" ]; + + # proxyVendor avoids the vendor/modules.txt consistency check that fails + # due to an upstream go.mod/vendor sync issue (needs go mod tidy upstream). + # The hash below is the go modules hash used to build the local proxy. + proxyVendor = true; + vendorHash = "sha256-2nQkAIxhAUVNC6SPwocjlfbgt6oG8WFapw/V+j2Pang="; # Relax go.mod version for Nix: nixpkgs Go may lag behind the latest # patch release, and GOTOOLCHAIN=auto can't download in the Nix sandbox. postPatch = '' goVer="$(go env GOVERSION | sed 's/^go//')" go mod edit -go="$goVer" - - env ''; # Allow patch-level toolchain upgrades when a dependency's minimum Go patch # version is newer than nixpkgs' bundled patch version. env.GOTOOLCHAIN = "auto"; - # Due to https://github.com/dolthub/go-icu-regex, which requires - # separate install of icu headers and library. - env.CGO_CPPFLAGS="-I${icu.dev}/include"; - env.CGO_LDFLAGS="-L${icu}/lib"; # Git is required for tests nativeBuildInputs = [ git ]; diff --git a/docs/ICU-POLICY.md b/docs/ICU-POLICY.md index b7602c6f0c..c1510d337e 100644 --- a/docs/ICU-POLICY.md +++ b/docs/ICU-POLICY.md @@ -57,14 +57,60 @@ Every build path that produces a binary for users must include `-tags gms_pure_g | Migration tests | `.github/workflows/migration-test.yml` | | Nightly tests | `.github/workflows/nightly.yml` | | Cross-version smoke | `.github/workflows/cross-version-smoke.yml` | +| Regression tests | `.github/workflows/regression.yml` | + +### Canonical pattern: source `.buildflags` + +The preferred way for a shell script to comply is to source `.buildflags` +at the top. That sets `CGO_ENABLED=1` **and** puts `-tags=gms_pure_go` +into `GOFLAGS`, so every subsequent bare `go` invocation in the script +picks it up automatically: + +```bash +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +# shellcheck source=../.buildflags +source "$REPO_ROOT/.buildflags" + +go build -o bd ./cmd/bd # -tags=gms_pure_go applied via GOFLAGS +``` + +Makefile targets use `-tags "$(BUILD_TAGS)"` directly, since make already +defines `BUILD_TAGS := gms_pure_go` at the top of the file. Workflow YAML +passes the tag inline (`-tags gms_pure_go`). + +### Source-time guard: `scripts/check-build-tags.sh` + +CI runs `scripts/check-build-tags.sh` on every PR (see `check-build-tags` +job in `.github/workflows/ci.yml`). It fails if any tracked shell script, +CI workflow, git hook, or the Makefile contains a +`go build|test|run|generate|install` invocation that: + +- does not carry `-tags=...gms_pure_go`, AND +- is not in a file that sources `.buildflags`, AND +- does not reference a file-level variable (e.g. `$(BUILD_TAGS)`) whose + value contains `gms_pure_go`, AND +- is not a third-party tool install (`go install X@version` / `go run X@version`). + +This is the source-time companion to `scripts/verify-cgo.sh` (runtime). +Between the two, an ICU regression cannot reach a release binary. + +To intentionally opt a file out (e.g. because it tests the ICU path), +add `# build-tags: allow-bare` within the first five lines of the file. +`scripts/test-cgo.sh` and `scripts/test-icu-path.sh` are exempt by name. ## Where `gms_pure_go` Is Intentionally Omitted -`scripts/test-cgo.sh` omits `gms_pure_go` as a local developer tool for -exercising the ICU code path in `go-mysql-server` on demand. CI no longer -does this: upstream confirmed (dolthub/go-mysql-server#3506) that -`-tags=gms_pure_go` is the sanctioned escape hatch, so we test the -configuration we ship. +`scripts/test-icu-path.sh` omits `gms_pure_go` as an explicit, opt-in local +developer tool for exercising the ICU code path in `go-mysql-server` on +demand. CI no longer does this: upstream confirmed +(dolthub/go-mysql-server#3506) that `-tags=gms_pure_go` is the sanctioned +escape hatch, so we test the configuration we ship. + +The older name `scripts/test-cgo.sh` is retained only as a deprecated shim +that warns and forwards to `scripts/test-icu-path.sh`. ## Post-Build Verification @@ -76,16 +122,24 @@ Release builds are verified to be ICU-free: If ICU linkage is detected, the release build fails. -## The Upstream Fork +## The Upstream Fork (historical) + +Beads used to carry a `replace github.com/dolthub/go-mysql-server => github.com/maphew/go-mysql-server ...` directive in `go.mod`, added in PR #3112 to try to make `go install` work on Windows without ICU headers. It was removed in PR #3306 (see GH#3303) after empirical testing confirmed that **`replace` directives are not honored by `go install pkg@version`** — the mechanism never worked for its stated purpose, and having the directive actively broke `go install` on every platform with a confusing error. + +Upstream PR (closed, declined): https://github.com/dolthub/go-mysql-server/pull/3504 +Upstream issue (closed, declined): https://github.com/dolthub/go-mysql-server/issues/3506 + +The dolthub maintainers have made clear the upstream default will not flip: *"We want our software to work as intended with the default settings. If users want to circumvent certain features with build tags or other build-time or run-time configuration, that's fine. Changing the default is not aligned with what we are actually trying to do."* + +### How `go install` is handled now + +Two supported modes, documented in [INSTALLING.md](INSTALLING.md): -`go.mod` has a `replace` directive pointing `go-mysql-server` to a fork -(`maphew/go-mysql-server`) that adds `!windows` to the CGO regex build -constraint. This ensures `go install` works on Windows without ICU headers. +1. **`CGO_ENABLED=0 go install github.com/steveyegge/beads/cmd/bd@latest`** produces a **server-mode-only** binary. Works on any Go-capable box with no C compiler. Users must run an external `dolt sql-server` and use `bd init --server`. -Upstream PR: https://github.com/dolthub/go-mysql-server/pull/3504 -Tracking issue: https://github.com/dolthub/go-mysql-server/issues/3506 +2. **`CGO_ENABLED=1 GOFLAGS=-tags=gms_pure_go go install ...`** produces an embedded-capable binary. Requires a C compiler but NOT libicu. -Once the upstream PR merges, remove the `replace` directive from `go.mod`. +No fork, no replace directive, no upstream patch required. The tradeoff is that `go install` users who want embedded mode have to pass an explicit `GOFLAGS`; those who don't care can use the shorter nocgo form. ## Common Mistakes to Avoid @@ -96,12 +150,17 @@ Once the upstream PR merges, remove the `replace` directive from `go.mod`. ICU linkage. The post-build checks will catch it, but don't do it. 3. **Installing `libicu-dev` in release or CI test workflows** -- only - needed for local, on-demand developer testing via `scripts/test-cgo.sh`. + needed for local, on-demand developer testing via + `scripts/test-icu-path.sh`. Neither release builds nor the CI test matrix link ICU; both must not depend on ICU being installed. -4. **Confusing CGO with ICU** -- CGO is required (for Dolt). ICU is not. - They are independent. `CGO_ENABLED=1` does not imply ICU. +4. **Confusing CGO with ICU** -- CGO is required for embedded Dolt mode + (NBS chunk compression via `gozstd`). ICU is independent. `CGO_ENABLED=1` + does not imply ICU linkage as long as `-tags gms_pure_go` is present. + beads also supports `CGO_ENABLED=0` builds via nocgo stubs: the binary + runs in server-mode only (no embedded Dolt backend), which is the + blessed `go install` path for users without a C toolchain. ## Trade-offs diff --git a/docs/INSTALLING.md b/docs/INSTALLING.md index 4e7dd78c98..accb08ce2b 100644 --- a/docs/INSTALLING.md +++ b/docs/INSTALLING.md @@ -98,12 +98,22 @@ BEADS_INSTALL_RESIGN_MACOS=1 curl -fsSL https://raw.githubusercontent.com/stevey | **npm** | JS/Node.js projects | `npm update -g @beads/bd` | Node.js | Convenient if npm is your ecosystem | | **bun** | JS/Bun.js projects | `bun install -g --trust @beads/bd` | Bun.js | Convenient if bun is your ecosystem | | **Install script** | Quick setup, CI/CD | Re-run script | curl, bash | Good for automation and one-liners | -| **go install** | Contributors / Go developers | Re-run command | Go 1.24+ | Builds from source, always latest | +| **go install (nocgo)** | Go developers, simplest install | Re-run command | Go 1.24+ | **Server-mode only** (no embedded Dolt) | +| **go install (cgo)** | Go developers wanting embedded mode | Re-run command | Go 1.24+, C compiler | Full embedded-Dolt support | | **From source** | Contributors only | `git pull && go build` | Go, git | Full control, can modify code | | **AUR (Arch)** | Arch Linux users | `yay -Syu` | yay/paru | Community-maintained | **TL;DR:** Use Homebrew if available. Use npm if you're in a Node.js environment. Use the script for quick one-off installs or CI. +### A note on `go install` capability + +`go install` supports **two build modes** that give different capabilities: + +- **Nocgo (simplest, default in this doc):** `CGO_ENABLED=0 go install ...`. Works on any machine with a Go toolchain, no C compiler needed. Produces a **server-mode-only** binary — you must run an external `dolt sql-server` and use `bd init --server`. See [DOLT.md](DOLT.md) for server-mode setup. +- **Cgo (embedded-capable):** `CGO_ENABLED=1 GOFLAGS=-tags=gms_pure_go go install ...`. Requires a C compiler (gcc/clang on Unix, MinGW on Windows). Produces a binary with the default embedded-Dolt backend — `bd init` Just Works. + +If you don't have a preference, `brew install beads` / `install.sh` give you the embedded-capable build with no fuss. + ## Platform-Specific Installation ### macOS @@ -113,16 +123,22 @@ BEADS_INSTALL_RESIGN_MACOS=1 curl -fsSL https://raw.githubusercontent.com/stevey brew install beads ``` -**Via go install**: +**Via go install** (server-mode only, simplest): ```bash -go install github.com/steveyegge/beads/cmd/bd@latest +CGO_ENABLED=0 go install github.com/steveyegge/beads/cmd/bd@latest +# Then: bd init --server (requires a running dolt sql-server) +``` + +**Via go install** (embedded-capable, needs Xcode CLI tools): +```bash +CGO_ENABLED=1 GOFLAGS=-tags=gms_pure_go go install github.com/steveyegge/beads/cmd/bd@latest ``` **From source**: ```bash git clone https://github.com/steveyegge/beads cd beads -go build -o bd ./cmd/bd +make build # uses gms_pure_go tag and CGO sudo mv bd /usr/local/bin/ ``` @@ -143,16 +159,22 @@ paru -S beads-git Thanks to [@v4rgas](https://github.com/v4rgas) for maintaining the AUR package! -**Via go install**: +**Via go install** (server-mode only, simplest): ```bash -go install github.com/steveyegge/beads/cmd/bd@latest +CGO_ENABLED=0 go install github.com/steveyegge/beads/cmd/bd@latest +# Then: bd init --server (requires a running dolt sql-server) +``` + +**Via go install** (embedded-capable, needs gcc): +```bash +CGO_ENABLED=1 GOFLAGS=-tags=gms_pure_go go install github.com/steveyegge/beads/cmd/bd@latest ``` **From source**: ```bash git clone https://github.com/steveyegge/beads cd beads -go build -o bd ./cmd/bd +make build # uses gms_pure_go tag and CGO sudo mv bd /usr/local/bin/ ``` @@ -163,9 +185,9 @@ sudo mv bd /usr/local/bin/ curl -fsSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash ``` -**Via go install**: +**Via go install** (server-mode only, simplest): ```bash -go install github.com/steveyegge/beads/cmd/bd@latest +CGO_ENABLED=0 go install github.com/steveyegge/beads/cmd/bd@latest ``` ### Windows 11 @@ -183,28 +205,33 @@ irm https://raw.githubusercontent.com/steveyegge/beads/main/install.ps1 | iex The script installs a prebuilt Windows release if available and verifies the downloaded ZIP checksum against release `checksums.txt`. Go is only required for `go install` or building from source. -**Dolt backend on Windows:** Supported via pure-Go regex backend. Windows builds automatically use Go's stdlib `regexp` instead of ICU regex to avoid CGO/header dependencies. If you need full ICU regex semantics, use Linux/macOS (or WSL) with ICU installed. +**Via go install** (server-mode only, simplest): +```pwsh +$env:CGO_ENABLED=0 +go install github.com/steveyegge/beads/cmd/bd@latest +# Then: bd init --server (requires a running dolt sql-server) +``` -**Via go install**: +This produces a server-mode-only binary with no C compiler requirement — the fastest path to a working `bd` on Windows. + +**Via go install** (embedded-capable, needs MinGW): ```pwsh +$env:CGO_ENABLED=1 +$env:GOFLAGS="-tags=gms_pure_go" go install github.com/steveyegge/beads/cmd/bd@latest ``` -ICU is **not required** on Windows. The regex backend uses pure Go automatically. +Requires MinGW-w64 gcc on your PATH. ICU is **not** required — `gms_pure_go` selects Go's stdlib `regexp`. **From source**: ```pwsh git clone https://github.com/steveyegge/beads cd beads -make build -# Or without Make: -go build -tags gms_pure_go -o bd.exe ./cmd/bd +make build # uses gms_pure_go tag and CGO Move-Item bd.exe $env:USERPROFILE\AppData\Local\Microsoft\WindowsApps\ ``` The `-tags gms_pure_go` flag tells go-mysql-server to use Go's stdlib regexp instead of ICU. -Additionally, the vendored go-icu-regex library has a Windows-specific pure-Go implementation -(`regex_windows.go`) that avoids ICU entirely. No C compiler or ICU libraries are needed. **Verify installation**: ```pwsh @@ -239,8 +266,9 @@ Linux (Fedora/RHEL): sudo dnf install -y libzstd-devel ``` -> **For CI / test contributors only:** If you need to run `scripts/test-cgo.sh` -> (which exercises the ICU code path), install ICU headers: +> **For maintainers only:** If you intentionally need to run +> `scripts/test-icu-path.sh` (which exercises the leftover ICU code path), +> install ICU headers: > `brew install icu4c` (macOS) or `sudo apt-get install -y libicu-dev` (Linux). > This is not needed for normal development. @@ -422,8 +450,8 @@ go list -f {{.Target}} github.com/steveyegge/beads/cmd/bd # Add Go bin to PATH (add to ~/.bashrc or ~/.zshrc) export PATH="$PATH:$(go env GOPATH)/bin" -# Or reinstall -go install github.com/steveyegge/beads/cmd/bd@latest +# Or reinstall (server-mode only, no C compiler needed) +CGO_ENABLED=0 go install github.com/steveyegge/beads/cmd/bd@latest ``` ### `zsh: killed bd` or crashes on macOS @@ -514,8 +542,14 @@ bun install -g --trust @beads/bd ### go install +Use whichever mode you installed with originally: + ```bash -go install github.com/steveyegge/beads/cmd/bd@latest +# Server-mode only +CGO_ENABLED=0 go install github.com/steveyegge/beads/cmd/bd@latest + +# Embedded-capable +CGO_ENABLED=1 GOFLAGS=-tags=gms_pure_go go install github.com/steveyegge/beads/cmd/bd@latest ``` ### From source diff --git a/docs/MOLECULES.md b/docs/MOLECULES.md index ab67288d60..af16a9d11f 100644 --- a/docs/MOLECULES.md +++ b/docs/MOLECULES.md @@ -115,6 +115,9 @@ bd mol bond A B --type conditional # B runs only if A fails This is how orchestrators run autonomous workflows - agents follow the dependency graph, handing off between sessions, until all work closes. +Ordinary epics stay open when the last child closes. They become close-eligible +work that can be closed explicitly once the parent outcome is actually done. + ## Phase Metaphor (Templates) For reusable workflows, beads uses a chemistry metaphor: diff --git a/docs/TESTING.md b/docs/TESTING.md index 1193ab23ae..740bb3aca3 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -22,8 +22,8 @@ make test # Or directly: ./scripts/test.sh -# Run full CGO-enabled suite (no skip list) -make test-full-cgo +# Run opt-in ICU regex path tests (maintainer-only, not normal validation) +make test-icu-path # Run specific package ./scripts/test.sh ./cmd/bd/... @@ -125,7 +125,7 @@ When running tests during development: - Automatically skips known broken tests - Uses appropriate timeouts - Consistent with CI/CD - - For full CGO validation, use `./scripts/test-cgo.sh` (or `make test-full-cgo`) + - Only if intentionally exercising the ICU regex path, use `./scripts/test-icu-path.sh` (or deprecated `make test-full-cgo`) 2. **Target specific tests when possible:** ```bash diff --git a/flake.nix b/flake.nix index f81a0273a7..51fd74b585 100644 --- a/flake.nix +++ b/flake.nix @@ -30,8 +30,7 @@ } ); in rec { - icu = nixpkgs.icu77; - packages = forAllSystems (args: import ./packages.nix (args // { inherit icu; })); + packages = forAllSystems (args: import ./packages.nix args); apps = forAllSystems ( { self, system, ... }: diff --git a/go.mod b/go.mod index b49d0b6805..a069116c19 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,10 @@ go 1.25.8 require ( charm.land/glamour/v2 v2.0.0 charm.land/huh/v2 v2.0.3 - charm.land/lipgloss/v2 v2.0.2 + charm.land/lipgloss/v2 v2.0.3 github.com/BurntSushi/toml v1.6.0 github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0 - github.com/anthropics/anthropic-sdk-go v1.34.0 + github.com/anthropics/anthropic-sdk-go v1.37.0 github.com/cenkalti/backoff/v4 v4.3.0 github.com/dolthub/driver v1.84.1 github.com/go-sql-driver/mysql v1.9.3 @@ -20,7 +20,7 @@ require ( github.com/testcontainers/testcontainers-go/modules/dolt v0.42.0 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 - go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 go.opentelemetry.io/otel/metric v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 @@ -28,7 +28,7 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.43.0 - golang.org/x/term v0.41.0 + golang.org/x/term v0.42.0 gopkg.in/yaml.v3 v3.0.1 rsc.io/script v0.0.2 ) @@ -98,9 +98,9 @@ require ( github.com/bcicen/jstream v1.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect @@ -170,12 +170,12 @@ require ( github.com/klauspost/cpuid/v2 v2.2.6 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lestrrat-go/strftime v1.0.6 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/microcosm-cc/bluemonday v1.0.27 github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -256,5 +256,3 @@ require ( gopkg.in/src-d/go-errors.v1 v1.0.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) - -replace github.com/dolthub/go-mysql-server => github.com/maphew/go-mysql-server v0.20.1-0.20260407202153-02d922453d4a diff --git a/go.sum b/go.sum index 67126e4830..ab00ad3e19 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U= charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w= charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= -charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= -charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -215,8 +215,8 @@ github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= -github.com/anthropics/anthropic-sdk-go v1.34.0 h1:IV+Wwxkwypit9Md8dr48zc626NS4o9PoQieESoNE0TE= -github.com/anthropics/anthropic-sdk-go v1.34.0/go.mod h1:dSIO7kSrOI7MA4fE6RRVaw8tyWP7HNQU5/H/KS4cax8= +github.com/anthropics/anthropic-sdk-go v1.37.0 h1:yBKUaBG3TCRb6das/Q5qNB9Fsafon09gu2yYVgvapKE= +github.com/anthropics/anthropic-sdk-go v1.37.0/go.mod h1:dSIO7kSrOI7MA4fE6RRVaw8tyWP7HNQU5/H/KS4cax8= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apache/arrow/go/arrow v0.0.0-20200730104253-651201b0f516/go.mod h1:QNYViu/X0HXDHw7m3KXzWSVXIbfUvJqBFe6Gj8/pYA0= github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40 h1:q4dksr6ICHXqG5hm0ZW5IHyeEJXoIJSOZeBLmWPNeIQ= @@ -337,12 +337,12 @@ github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghf github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= -github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= @@ -441,6 +441,8 @@ github.com/dolthub/fslock v0.0.3 h1:iLMpUIvJKMKm92+N1fmHVdxJP5NdyDK5bK7z7Ba2s2U= github.com/dolthub/fslock v0.0.3/go.mod h1:QWql+P17oAAMLnL4HGB5tiovtDuAjdDTPbuqx7bYfa0= github.com/dolthub/go-icu-regex v0.0.0-20250916051405-78a38d478790 h1:zxMsH7RLiG+dlZ/y0LgJHTV26XoiSJcuWq+em6t6VVc= github.com/dolthub/go-icu-regex v0.0.0-20250916051405-78a38d478790/go.mod h1:F3cnm+vMRK1HaU6+rNqQrOCyR03HHhR1GWG2gnPOqaE= +github.com/dolthub/go-mysql-server v0.20.1-0.20260325173633-83a7fba2790f h1:w94bo6kTYotpqmnp7NP+wwZAtlzIBJ8Mfu8bqmeSx84= +github.com/dolthub/go-mysql-server v0.20.1-0.20260325173633-83a7fba2790f/go.mod h1:ZPKRptEeXSk9Ytb9LhB+8tB4BkltxuNYiYDFvIqXD/4= github.com/dolthub/gozstd v0.0.0-20240423170813-23a2903bca63 h1:OAsXLAPL4du6tfbBgK0xXHZkOlos63RdKYS3Sgw/dfI= github.com/dolthub/gozstd v0.0.0-20240423170813-23a2903bca63/go.mod h1:lV7lUeuDhH5thVGDCKXbatwKy2KW80L4rMT46n+Y2/Q= github.com/dolthub/ishell v0.0.0-20240701202509-2b217167d718 h1:lT7hE5k+0nkBdj/1UOSFwjWpNxf+LCApbRHgnCA17XE= @@ -796,14 +798,12 @@ github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk= github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/maphew/go-mysql-server v0.20.1-0.20260407202153-02d922453d4a h1:sR0dZuqov/qka6zebQfOrnFzTByBNNwwFy9h+i/ngQU= -github.com/maphew/go-mysql-server v0.20.1-0.20260407202153-02d922453d4a/go.mod h1:SQws7iFFL5fNxjGbl6xyWNBz3DWTReGBegApANmnwhM= github.com/mark3labs/mcp-go v0.34.0 h1:eWy7WBGvhk6EyAAyVzivTCprE52iXJwNtvHV6Cv3bR0= github.com/mark3labs/mcp-go v0.34.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= @@ -817,8 +817,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= -github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.8 h1:gDp86IdQsN/xWjIEmr9MF6o9mpksUgh0fu+9ByFxzIU= github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -1057,8 +1057,8 @@ go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= @@ -1362,8 +1362,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/integrations/beads-mcp/pyproject.toml b/integrations/beads-mcp/pyproject.toml index 5371869ddc..ef30ae79e2 100644 --- a/integrations/beads-mcp/pyproject.toml +++ b/integrations/beads-mcp/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "beads-mcp" -version = "1.0.0" +version = "1.0.2" description = "MCP server for beads issue tracker." readme = "README.md" requires-python = ">=3.10" license = {text = "MIT"} dependencies = [ - "fastmcp==3.2.3", - "pydantic==2.12.5", + "fastmcp==3.2.4", + "pydantic==2.13.2", "pydantic-settings==2.13.1", ] authors = [ diff --git a/integrations/beads-mcp/src/beads_mcp/__init__.py b/integrations/beads-mcp/src/beads_mcp/__init__.py index 1a4bc40221..3d5a0695cf 100644 --- a/integrations/beads-mcp/src/beads_mcp/__init__.py +++ b/integrations/beads-mcp/src/beads_mcp/__init__.py @@ -4,4 +4,4 @@ beads (bd) issue tracker functionality to MCP Clients. """ -__version__ = "1.0.0" +__version__ = "1.0.2" diff --git a/internal/ado/client.go b/internal/ado/client.go index 9cc97f9c6d..ee7c1bb2f8 100644 --- a/internal/ado/client.go +++ b/internal/ado/client.go @@ -307,11 +307,11 @@ func escapeWIQL(s string) string { } // formatWIQLDate formats a time.Time for use in WIQL datetime literals. -// WIQL expects UTC ISO 8601 dates in single quotes: 'YYYY-MM-DDTHH:MM:SSZ'. -// The time is converted to UTC and formatted with time.RFC3339, which uses -// the proper Z07:00 timezone placeholder (outputs "Z" for UTC). +// Azure DevOps date-precision fields (e.g. System.ChangedDate) reject any +// time component, so we output date-only format: 'YYYY-MM-DD'. +// The time is converted to UTC before truncating to date. func formatWIQLDate(t time.Time) string { - return t.UTC().Format(time.RFC3339) + return t.UTC().Format("2006-01-02") } // buildPatchOps converts a field map into sorted JSON Patch operations. diff --git a/internal/ado/client_test.go b/internal/ado/client_test.go index 23839d7eb0..ed843b51cd 100644 --- a/internal/ado/client_test.go +++ b/internal/ado/client_test.go @@ -481,39 +481,39 @@ func TestFormatWIQLDate(t *testing.T) { want string }{ { - name: "UTC time", + name: "UTC time returns date only", time: time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC), - want: "2024-06-01T00:00:00Z", + want: "2024-06-01", }, { - name: "UTC time with seconds", + name: "UTC time with time component stripped", time: time.Date(2024, 6, 15, 14, 30, 45, 0, time.UTC), - want: "2024-06-15T14:30:45Z", + want: "2024-06-15", }, { - name: "non-UTC positive offset converts to UTC", + name: "non-UTC positive offset converts to UTC date", time: time.Date(2024, 6, 1, 12, 0, 0, 0, time.FixedZone("IST", 5*3600+30*60)), - want: "2024-06-01T06:30:00Z", + want: "2024-06-01", }, { - name: "non-UTC negative offset converts to UTC", + name: "non-UTC negative offset converts to UTC date", time: time.Date(2024, 6, 1, 10, 0, 0, 0, time.FixedZone("EST", -5*3600)), - want: "2024-06-01T15:00:00Z", + want: "2024-06-01", }, { - name: "nanoseconds truncated to seconds", + name: "nanoseconds irrelevant with date only", time: time.Date(2024, 6, 1, 10, 30, 45, 123456789, time.UTC), - want: "2024-06-01T10:30:45Z", + want: "2024-06-01", }, { - name: "midnight boundary from non-UTC", + name: "midnight boundary from non-UTC same date", time: time.Date(2024, 6, 2, 2, 0, 0, 0, time.FixedZone("CEST", 2*3600)), - want: "2024-06-02T00:00:00Z", + want: "2024-06-02", }, { - name: "date rollback across day boundary", + name: "date rollback across day boundary in UTC", time: time.Date(2024, 6, 1, 1, 0, 0, 0, time.FixedZone("JST", 9*3600)), - want: "2024-05-31T16:00:00Z", + want: "2024-05-31", }, } for _, tt := range tests { @@ -522,9 +522,9 @@ func TestFormatWIQLDate(t *testing.T) { if got != tt.want { t.Errorf("formatWIQLDate() = %q, want %q", got, tt.want) } - // Verify output always ends with Z (UTC indicator) - if !strings.HasSuffix(got, "Z") { - t.Errorf("formatWIQLDate() output %q must end with Z for WIQL compatibility", got) + // Verify output contains no time component + if strings.Contains(got, "T") || strings.Contains(got, "Z") { + t.Errorf("formatWIQLDate() output %q must be date-only (no time component) for ADO date-precision fields", got) } }) } @@ -728,7 +728,7 @@ func TestBuildPullWIQL(t *testing.T) { since: &since, filters: nil, contains: []string{ - "[System.ChangedDate] >= '2024-06-01T00:00:00Z'", + "[System.ChangedDate] >= '2024-06-01'", }, }, { @@ -736,7 +736,7 @@ func TestBuildPullWIQL(t *testing.T) { since: &sinceNonUTC, filters: nil, contains: []string{ - "[System.ChangedDate] >= '2024-06-01T06:30:00Z'", + "[System.ChangedDate] >= '2024-06-01'", }, }, { @@ -775,7 +775,7 @@ func TestBuildPullWIQL(t *testing.T) { contains: []string{ "[System.TeamProject] = 'testproject'", "[System.IsDeleted] = false", - "[System.ChangedDate] >= '2024-06-01T00:00:00Z'", + "[System.ChangedDate] >= '2024-06-01'", `[System.AreaPath] UNDER 'MyProject\\Backend'`, `[System.IterationPath] UNDER 'MyProject\\Sprint 1'`, "[System.WorkItemType] IN ('Bug')", diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 69ebf7882f..6010e5a877 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -1,7 +1,7 @@ package audit import ( - "bufio" + "bytes" "crypto/rand" "encoding/hex" "encoding/json" @@ -112,14 +112,17 @@ func Append(e *Entry) (string, error) { } defer func() { _ = f.Close() }() // Best effort: file close in defer after flush - bw := bufio.NewWriter(f) - enc := json.NewEncoder(bw) + // Marshal to a single byte slice and write atomically. + // Using bufio.NewWriter could split into multiple write() syscalls, + // which interleave under concurrent O_APPEND and corrupt lines. + var buf bytes.Buffer + enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) if err := enc.Encode(e); err != nil { - return "", fmt.Errorf("failed to write interactions log entry: %w", err) + return "", fmt.Errorf("failed to marshal interactions log entry: %w", err) } - if err := bw.Flush(); err != nil { - return "", fmt.Errorf("failed to flush interactions log: %w", err) + if _, err := f.Write(buf.Bytes()); err != nil { + return "", fmt.Errorf("failed to write interactions log entry: %w", err) } return e.ID, nil diff --git a/internal/config/config.go b/internal/config/config.go index d824ca0d67..589a650efd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -57,6 +57,11 @@ func Initialize() error { } // 1. Project: walk up from CWD to find .beads/config.yaml + beadsDirEnv := strings.TrimSpace(os.Getenv("BEADS_DIR")) + beadsEnvConfigPath := "" + if beadsDirEnv != "" { + beadsEnvConfigPath = filepath.Clean(filepath.Join(beadsDirEnv, "config.yaml")) + } cwd, err := os.Getwd() if err == nil { // In the beads repo, `.beads/config.yaml` is tracked and may set non-default config values. @@ -82,6 +87,12 @@ func Initialize() error { beadsDir := filepath.Join(dir, ".beads") p := filepath.Join(beadsDir, "config.yaml") if _, err := os.Stat(p); err == nil { + // When BEADS_DIR points at a different runtime workspace, do not + // merge the caller repo's config underneath it. That leaks caller + // settings like readonly/json/actor into explicit-target commands. + if beadsEnvConfigPath != "" && filepath.Clean(p) != beadsEnvConfigPath { + break + } if ignoreRepoConfig && moduleRoot != "" { // Only ignore the repo-local config (moduleRoot/.beads/config.yaml). wantIgnore := filepath.Clean(p) == filepath.Clean(filepath.Join(moduleRoot, ".beads", "config.yaml")) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 355a8ed2fa..808e9af4a7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1369,3 +1369,43 @@ func TestGetStringFromDir(t *testing.T) { } }) } + +func TestInitialize_ExternalBEADSDirDoesNotMergeCallerProjectConfig(t *testing.T) { + restore := envSnapshot(t) + defer restore() + + callerRepo := filepath.Join(t.TempDir(), "caller") + callerBeadsDir := filepath.Join(callerRepo, ".beads") + if err := os.MkdirAll(callerBeadsDir, 0o755); err != nil { + t.Fatalf("failed to create caller .beads: %v", err) + } + if err := os.WriteFile(filepath.Join(callerBeadsDir, "config.yaml"), []byte("readonly: true\njson: true\n"), 0o600); err != nil { + t.Fatalf("failed to write caller config: %v", err) + } + + targetBeadsDir := filepath.Join(t.TempDir(), "target", ".beads") + if err := os.MkdirAll(targetBeadsDir, 0o755); err != nil { + t.Fatalf("failed to create target .beads: %v", err) + } + if err := os.WriteFile(filepath.Join(targetBeadsDir, "config.yaml"), []byte("actor: target-user\n"), 0o600); err != nil { + t.Fatalf("failed to write target config: %v", err) + } + + t.Chdir(callerRepo) + t.Setenv("BEADS_DIR", targetBeadsDir) + + ResetForTesting() + if err := Initialize(); err != nil { + t.Fatalf("Initialize() returned error: %v", err) + } + + if got := GetString("actor"); got != "target-user" { + t.Fatalf("GetString(actor) = %q, want %q", got, "target-user") + } + if got := GetBool("readonly"); got { + t.Fatalf("GetBool(readonly) = %v, want false", got) + } + if got := GetBool("json"); got { + t.Fatalf("GetBool(json) = %v, want false", got) + } +} diff --git a/internal/doltserver/doltserver.go b/internal/doltserver/doltserver.go index 3c4ec8df1b..5e1a45cba0 100644 --- a/internal/doltserver/doltserver.go +++ b/internal/doltserver/doltserver.go @@ -711,103 +711,129 @@ func Start(beadsDir string) (*State, error) { return nil, fmt.Errorf("configuring dolt identity: %w", err) } - // Ensure dolt database directory is initialized - if err := ensureDoltInit(doltDir); err != nil { - return nil, fmt.Errorf("initializing dolt database: %w", err) - } - - // Rotate the log if it has grown past the configured ceiling. This is a - // startup-only check — dolt owns the fd directly once launched, so we can - // only intervene between runs. See logrotate.go for the caveat discussion. - maybeRotateLog(beadsDir) - - // Open log file - logFile, err := os.OpenFile(logPath(beadsDir), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) //nolint:gosec // G304: logPath derives from user-configured beadsDir - if err != nil { - return nil, fmt.Errorf("opening log file: %w", err) - } + // Launch dolt sql-server, retrying once after an automatic corrupt- + // manifest recovery (GH#3290). + var ( + pid int + actualPort int + lastErr error + attempts int + recoveryAttempted bool + ) +startupLoop: + for { + // Ensure dolt database directory is initialized + if err := ensureDoltInit(doltDir); err != nil { + return nil, fmt.Errorf("initializing dolt database: %w", err) + } - // Resolve the port to use. Explicit ports (env/config) go through - // reclaimPort for conflict detection. Port 0 means ephemeral — allocate - // a fresh port from the OS with retry for TOCTOU races. - actualPort := cfg.Port - explicitPort := actualPort > 0 + // Rotate the log if it has grown past the configured ceiling. This is a + // startup-only check — dolt owns the fd directly once launched, so we can + // only intervene between runs. See logrotate.go for the caveat discussion. + maybeRotateLog(beadsDir) - if explicitPort { - // Explicit port: check for conflicts and adopt existing servers. - adoptPID, reclaimErr := reclaimPort(cfg.Host, actualPort, beadsDir) - if reclaimErr != nil { - _ = logFile.Close() - return nil, fmt.Errorf("cannot start dolt server on port %d: %w", actualPort, reclaimErr) - } - if adoptPID > 0 { - _ = logFile.Close() - _ = os.WriteFile(pidPath(beadsDir), []byte(strconv.Itoa(adoptPID)), 0600) - _ = writePortFile(beadsDir, actualPort) - return &State{Running: true, PID: adoptPID, Port: actualPort, DataDir: doltDir}, nil + // Open log file + logFile, err := os.OpenFile(logPath(beadsDir), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) //nolint:gosec // G304: logPath derives from user-configured beadsDir + if err != nil { + return nil, fmt.Errorf("opening log file: %w", err) } - } - // Start dolt sql-server, with retry loop for ephemeral port TOCTOU. - var pid int - var lastErr error - attempts := 1 - if !explicitPort { - attempts = maxEphemeralPortAttempts - } - - for i := range attempts { - if !explicitPort { - p, allocErr := allocateEphemeralPort(cfg.Host) - if allocErr != nil { - lastErr = allocErr - continue + // Resolve the port to use. Explicit ports (env/config) go through + // reclaimPort for conflict detection. Port 0 means ephemeral — allocate + // a fresh port from the OS with retry for TOCTOU races. + actualPort = cfg.Port + explicitPort := actualPort > 0 + + if explicitPort { + // Explicit port: check for conflicts and adopt existing servers. + adoptPID, reclaimErr := reclaimPort(cfg.Host, actualPort, beadsDir) + if reclaimErr != nil { + _ = logFile.Close() + return nil, fmt.Errorf("cannot start dolt server on port %d: %w", actualPort, reclaimErr) + } + if adoptPID > 0 { + _ = logFile.Close() + _ = os.WriteFile(pidPath(beadsDir), []byte(strconv.Itoa(adoptPID)), 0600) + _ = writePortFile(beadsDir, actualPort) + return &State{Running: true, PID: adoptPID, Port: actualPort, DataDir: doltDir}, nil } - actualPort = p } - cmd := exec.Command(doltBin, buildDoltServerArgs(cfg.Host, actualPort)...) //nolint:gosec // doltBin is resolved from PATH, not user input - cmd.Dir = doltDir - cmd.Stdout = logFile - cmd.Stderr = logFile - cmd.Stdin = nil - cmd.SysProcAttr = procAttrDetached() - cmd.Env = os.Environ() + // Start dolt sql-server, with retry loop for ephemeral port TOCTOU. + pid = 0 + lastErr = nil + attempts = 1 + if !explicitPort { + attempts = maxEphemeralPortAttempts + } - if startErr := cmd.Start(); startErr != nil { - lastErr = startErr + for i := range attempts { if !explicitPort { - continue // retry with a new ephemeral port + p, allocErr := allocateEphemeralPort(cfg.Host) + if allocErr != nil { + lastErr = allocErr + continue + } + actualPort = p } - _ = logFile.Close() - return nil, fmt.Errorf("starting dolt sql-server: %w", startErr) - } - pid = cmd.Process.Pid - _ = cmd.Process.Release() + cmd := exec.Command(doltBin, buildDoltServerArgs(cfg.Host, actualPort)...) //nolint:gosec // doltBin is resolved from PATH, not user input + cmd.Dir = doltDir + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.Stdin = nil + cmd.SysProcAttr = procAttrDetached() + cmd.Env = os.Environ() + + if startErr := cmd.Start(); startErr != nil { + lastErr = startErr + if !explicitPort { + continue // retry with a new ephemeral port + } + break + } - // Quick check: did the process exit immediately (bind failure)? - // Give it a moment to fail on port bind before proceeding. - time.Sleep(200 * time.Millisecond) - if !isProcessAlive(pid) { - lastErr = fmt.Errorf("dolt sql-server exited immediately on port %d (attempt %d/%d)", actualPort, i+1, attempts) - pid = 0 - if !explicitPort { - continue + pid = cmd.Process.Pid + _ = cmd.Process.Release() + + // Quick check: did the process exit immediately (bind failure)? + // Give it a moment to fail on port bind before proceeding. + time.Sleep(200 * time.Millisecond) + if !isProcessAlive(pid) { + lastErr = fmt.Errorf("dolt sql-server exited immediately on port %d (attempt %d/%d)", actualPort, i+1, attempts) + pid = 0 + if !explicitPort { + continue + } + break } - _ = logFile.Close() - return nil, lastErr - } - lastErr = nil + lastErr = nil + break + } + _ = logFile.Close() + + if lastErr != nil { + // GH#3290: detect unclean-shutdown manifest corruption and auto- + // recover when the journal is empty (no data to lose). Recovery + // backs up the corrupt .dolt/ with a timestamped suffix and + // reinitializes in place, then the outer loop retries startup. + if !recoveryAttempted { + recoveryAttempted = true + if backups, recErr := recoverCorruptManifest(beadsDir, doltDir); recErr != nil { + fmt.Fprintf(os.Stderr, "Warning: corrupt manifest recovery failed: %v\n", recErr) + } else if len(backups) > 0 { + for _, b := range backups { + fmt.Fprintf(os.Stderr, "Info: backed up corrupt dolt database to %s and reinitialized (GH#3290)\n", filepath.Base(b)) + } + continue startupLoop + } + } + return nil, fmt.Errorf("failed to start dolt server after %d attempts: %w\nCheck logs: %s", + attempts, lastErr, logPath(beadsDir)) + } break } - _ = logFile.Close() - - if lastErr != nil { - return nil, fmt.Errorf("failed to start dolt server after %d attempts: %w\nCheck logs: %s", - attempts, lastErr, logPath(beadsDir)) - } // Write PID and port files if err := os.WriteFile(pidPath(beadsDir), []byte(strconv.Itoa(pid)), 0600); err != nil { diff --git a/internal/doltserver/doltserver_unix.go b/internal/doltserver/doltserver_unix.go index caee44e793..04b48d6232 100644 --- a/internal/doltserver/doltserver_unix.go +++ b/internal/doltserver/doltserver_unix.go @@ -83,7 +83,9 @@ func listDoltProcessPIDs() []int { // Uses lsof to look up the CWD, which is more reliable than checking command-line // args since dolt sql-server is started with cmd.Dir (not a --data-dir flag). func isProcessInDir(pid int, dir string) bool { - out, err := exec.Command("lsof", "-p", strconv.Itoa(pid), "-d", "cwd", "-Fn").Output() + // On macOS, lsof requires -a to AND selectors together; without it, + // "-p " and "-d cwd" can yield cwd entries from unrelated processes. + out, err := exec.Command("lsof", "-a", "-p", strconv.Itoa(pid), "-d", "cwd", "-Fn").Output() if err != nil { return false } diff --git a/internal/doltserver/manifest_recovery.go b/internal/doltserver/manifest_recovery.go new file mode 100644 index 0000000000..1ad2670e1f --- /dev/null +++ b/internal/doltserver/manifest_recovery.go @@ -0,0 +1,193 @@ +package doltserver + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +// corruptManifestSignature is the error emitted by dolt sql-server when its +// manifest references a root hash that was never flushed to disk (typically +// after an unclean shutdown). See GH#3290. +const corruptManifestSignature = "root hash doesn't exist" + +// logTailBytes is the size of the tail scanned when looking for the corrupt +// manifest signature in the dolt server log. 64 KiB comfortably covers the +// last few startup attempts without loading huge log files into memory. +const logTailBytes = 64 * 1024 + +// logHasCorruptManifestError returns true if the tail of the dolt server log +// contains the corrupt-manifest signature. +func logHasCorruptManifestError(logPath string) (bool, error) { + f, err := os.Open(logPath) //nolint:gosec // G304: path derived from beadsDir + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, err + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return false, err + } + + start := int64(0) + if info.Size() > logTailBytes { + start = info.Size() - logTailBytes + } + if _, err := f.Seek(start, io.SeekStart); err != nil { + return false, err + } + + buf, err := io.ReadAll(f) + if err != nil { + return false, err + } + return strings.Contains(string(buf), corruptManifestSignature), nil +} + +// findCorruptNomsDirs walks doltDir and returns the paths of every +// .dolt/noms/ directory whose contents look like a corrupt manifest with no +// recoverable data: journal.idx is empty (or missing), every journal file is +// at most a bare header, and oldgen/ holds no chunk data. +// +// The "no data to lose" guard is intentionally conservative: recovery only +// fires when we can prove there is nothing the user would want to keep. +func findCorruptNomsDirs(doltDir string) ([]string, error) { + var matches []string + err := filepath.Walk(doltDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // best-effort scan; skip unreadable subtrees + } + if !info.IsDir() || filepath.Base(path) != "noms" { + return nil + } + if filepath.Base(filepath.Dir(path)) != ".dolt" { + return nil + } + corrupt, checkErr := nomsDirLooksCorrupt(path) + if checkErr == nil && corrupt { + matches = append(matches, path) + } + return nil + }) + if err != nil { + return nil, err + } + return matches, nil +} + +// nomsDirLooksCorrupt returns true if the .dolt/noms directory has a +// manifest but no recoverable chunk data. See findCorruptNomsDirs for the +// exact conditions. +func nomsDirLooksCorrupt(nomsDir string) (bool, error) { + manifestPath := filepath.Join(nomsDir, "manifest") + if _, err := os.Stat(manifestPath); err != nil { + return false, err // no manifest = not the shape we're recovering + } + + if info, err := os.Stat(filepath.Join(nomsDir, "journal.idx")); err == nil { + if info.Size() > 0 { + return false, nil + } + } else if !errors.Is(err, os.ErrNotExist) { + return false, err + } + + entries, err := os.ReadDir(nomsDir) + if err != nil { + return false, err + } + for _, e := range entries { + if !e.Type().IsRegular() { + continue + } + // Dolt journal files have a 32-char name (all 'v' until rotated). + name := e.Name() + if len(name) == 32 && isLowerAlphaName(name) { + info, err := e.Info() + if err != nil { + return false, err + } + // Journal header is 40 bytes; anything larger may contain data. + if info.Size() > 64 { + return false, nil + } + } + } + + oldgen := filepath.Join(nomsDir, "oldgen") + if oldgenEntries, err := os.ReadDir(oldgen); err == nil { + for _, e := range oldgenEntries { + if e.Type().IsRegular() && e.Name() != "manifest" && e.Name() != "LOCK" { + if info, infoErr := e.Info(); infoErr == nil && info.Size() > 0 { + return false, nil + } + } + } + } else if !errors.Is(err, os.ErrNotExist) { + return false, err + } + + return true, nil +} + +func isLowerAlphaName(s string) bool { + for _, r := range s { + if r < 'a' || r > 'z' { + return false + } + } + return true +} + +// recoverCorruptManifest detects the GH#3290 corrupt-manifest condition by +// scanning the dolt server log for the "root hash doesn't exist" signature +// and confirming that every affected database has no recoverable data. If +// both conditions hold, each corrupt .dolt/ directory is backed up with a +// timestamped suffix and the database is reinitialized in place. +// +// Returns the list of backup paths created. If the preconditions do not +// hold, returns (nil, nil) so the caller can surface the original start +// failure. +func recoverCorruptManifest(beadsDir, doltDir string) ([]string, error) { + hasErr, err := logHasCorruptManifestError(logPath(beadsDir)) + if err != nil || !hasErr { + return nil, err + } + + nomsDirs, err := findCorruptNomsDirs(doltDir) + if err != nil { + return nil, err + } + if len(nomsDirs) == 0 { + return nil, nil + } + + ts := time.Now().UTC().Format("20060102T150405Z") + var backups []string + for _, nomsDir := range nomsDirs { + dotDolt := filepath.Dir(nomsDir) // .../X/.dolt + dbDir := filepath.Dir(dotDolt) // .../X + backupPath := dotDolt + "." + ts + ".corrupt.backup" + + if err := os.Rename(dotDolt, backupPath); err != nil { + return backups, fmt.Errorf("backing up corrupt dolt database at %s: %w", dotDolt, err) + } + backups = append(backups, backupPath) + + if err := ensureDoltInit(dbDir); err != nil { + // Best-effort restore so the user is no worse off than before. + _ = os.RemoveAll(dotDolt) + _ = os.Rename(backupPath, dotDolt) + return backups[:len(backups)-1], fmt.Errorf("reinitializing dolt database at %s: %w", dbDir, err) + } + } + return backups, nil +} diff --git a/internal/doltserver/manifest_recovery_test.go b/internal/doltserver/manifest_recovery_test.go new file mode 100644 index 0000000000..8c60a64503 --- /dev/null +++ b/internal/doltserver/manifest_recovery_test.go @@ -0,0 +1,143 @@ +package doltserver + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLogHasCorruptManifestError(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "dolt-server.log") + + // Missing log file is not an error. + got, err := logHasCorruptManifestError(logPath) + if err != nil || got { + t.Fatalf("missing log: got (%v, %v), want (false, nil)", got, err) + } + + // Log without the signature. + if err := os.WriteFile(logPath, []byte("starting server\nlistening on :3306\n"), 0600); err != nil { + t.Fatal(err) + } + got, err = logHasCorruptManifestError(logPath) + if err != nil || got { + t.Fatalf("clean log: got (%v, %v), want (false, nil)", got, err) + } + + // Log with the signature. + content := "starting\n" + strings.Repeat("noise\n", 100) + + "error: root hash doesn't exist: abc123\nexit\n" + if err := os.WriteFile(logPath, []byte(content), 0600); err != nil { + t.Fatal(err) + } + got, err = logHasCorruptManifestError(logPath) + if err != nil || !got { + t.Fatalf("corrupt log: got (%v, %v), want (true, nil)", got, err) + } +} + +// writeNomsDir creates a .dolt/noms/ shape under root and returns its path. +// If journalSize >= 0, a 32-char journal file is written with that size. +// If idxSize >= 0, a journal.idx file is written with that size. +// If oldgenChunkSize > 0, a chunk file is created in oldgen/. +func writeNomsDir(t *testing.T, root string, journalSize, idxSize, oldgenChunkSize int64) string { + t.Helper() + nomsDir := filepath.Join(root, ".dolt", "noms") + if err := os.MkdirAll(filepath.Join(nomsDir, "oldgen"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nomsDir, "manifest"), []byte("manifest-stub"), 0600); err != nil { + t.Fatal(err) + } + if idxSize >= 0 { + writeSized(t, filepath.Join(nomsDir, "journal.idx"), idxSize) + } + if journalSize >= 0 { + name := strings.Repeat("v", 32) + writeSized(t, filepath.Join(nomsDir, name), journalSize) + } + if oldgenChunkSize > 0 { + writeSized(t, filepath.Join(nomsDir, "oldgen", "chunk1"), oldgenChunkSize) + } + return nomsDir +} + +func writeSized(t *testing.T, path string, size int64) { + t.Helper() + f, err := os.Create(path) //nolint:gosec + if err != nil { + t.Fatal(err) + } + defer f.Close() + if size > 0 { + if err := f.Truncate(size); err != nil { + t.Fatal(err) + } + } +} + +func TestNomsDirLooksCorrupt(t *testing.T) { + tests := []struct { + name string + journalSize int64 + idxSize int64 + oldgenChunkSize int64 + want bool + }{ + {"empty journal + empty idx + empty oldgen", 40, 0, 0, true}, + {"no journal file, empty idx, empty oldgen", -1, 0, 0, true}, + {"journal has data", 8192, 0, 0, false}, + {"idx has data", 40, 4096, 0, false}, + {"oldgen has chunk", 40, 0, 2048, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + nomsDir := writeNomsDir(t, dir, tc.journalSize, tc.idxSize, tc.oldgenChunkSize) + got, err := nomsDirLooksCorrupt(nomsDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} + +func TestFindCorruptNomsDirs(t *testing.T) { + root := t.TempDir() + doltDir := filepath.Join(root, "dolt") + + // Corrupt database + writeNomsDir(t, filepath.Join(doltDir, "bd"), 40, 0, 0) + // Healthy database (has oldgen data) + writeNomsDir(t, filepath.Join(doltDir, "other"), 40, 0, 2048) + + matches, err := findCorruptNomsDirs(doltDir) + if err != nil { + t.Fatal(err) + } + if len(matches) != 1 { + t.Fatalf("got %d matches, want 1: %v", len(matches), matches) + } + if !strings.Contains(matches[0], filepath.Join("bd", ".dolt", "noms")) { + t.Errorf("unexpected match: %s", matches[0]) + } +} + +func TestRecoverCorruptManifest_NoLogSignatureNoop(t *testing.T) { + beadsDir := t.TempDir() + doltDir := filepath.Join(beadsDir, "dolt") + writeNomsDir(t, filepath.Join(doltDir, "bd"), 40, 0, 0) + + backups, err := recoverCorruptManifest(beadsDir, doltDir) + if err != nil { + t.Fatal(err) + } + if len(backups) != 0 { + t.Errorf("expected no backups without log signature, got %v", backups) + } +} diff --git a/internal/jira/tracker_test.go b/internal/jira/tracker_test.go index feb6fb9207..130be90722 100644 --- a/internal/jira/tracker_test.go +++ b/internal/jira/tracker_test.go @@ -523,7 +523,11 @@ func (s *configStore) GetAllConfig(_ context.Context) (map[string]string, error) } // Storage interface stubs — not exercised by Init(). -func (s *configStore) SetConfig(_ context.Context, _, _ string) error { return nil } +func (s *configStore) SetConfig(_ context.Context, _, _ string) error { return nil } +func (s *configStore) SetLocalMetadata(_ context.Context, _, _ string) error { return nil } +func (s *configStore) GetLocalMetadata(_ context.Context, _ string) (string, error) { + return "", nil +} func (s *configStore) CreateIssue(_ context.Context, _ *types.Issue, _ string) error { return nil } diff --git a/internal/linear/mapping.go b/internal/linear/mapping.go index 6362c0cc30..f816c2b593 100644 --- a/internal/linear/mapping.go +++ b/internal/linear/mapping.go @@ -16,6 +16,8 @@ type IDGenerationOptions struct { UsedIDs map[string]bool // Pre-populated set to avoid collisions (e.g., DB IDs) } +const missingExplicitStateMapMessage = "linear.state_map is not configured.\nRun 'bd linear link' to configure status mapping first." + // BuildLinearDescription formats a Beads issue for Linear's description field. // This mirrors the payload used during push to keep hash comparisons consistent. func BuildLinearDescription(issue *types.Issue) string { @@ -130,6 +132,11 @@ type MappingConfig struct { // Key is lowercase state type or name, value is Beads status string. StateMap map[string]string + // ExplicitStateMap contains only user-configured linear.state_map.* entries. + // Defaults are intentionally excluded so push can distinguish safe explicit + // mappings from type-based fallbacks. + ExplicitStateMap map[string]string + // LabelTypeMap maps Linear label names to Beads issue types. // Key is lowercase label name, value is Beads issue type. LabelTypeMap map[string]string @@ -159,6 +166,7 @@ func DefaultMappingConfig() *MappingConfig { "completed": "closed", "canceled": "closed", }, + ExplicitStateMap: make(map[string]string), // Label patterns for issue type inference LabelTypeMap: map[string]string{ "bug": "bug", @@ -220,6 +228,7 @@ func LoadMappingConfig(loader ConfigLoader) *MappingConfig { if strings.HasPrefix(key, "linear.state_map.") { stateKey := strings.ToLower(strings.TrimPrefix(key, "linear.state_map.")) config.StateMap[stateKey] = value + config.ExplicitStateMap[stateKey] = value } // Parse label-to-type mappings: linear.label_type_map. @@ -301,6 +310,69 @@ func StateToBeadsStatus(state *State, config *MappingConfig) types.Status { return types.StatusOpen } +func stateMapMatchesStatus(mapped string, status types.Status) bool { + normalizedMapped := strings.ToLower(strings.TrimSpace(mapped)) + normalizedStatus := strings.ToLower(strings.TrimSpace(string(status))) + if normalizedMapped == normalizedStatus { + return true + } + if status.IsValid() && ParseBeadsStatus(mapped) == status { + return true + } + return false +} + +// ResolveStateIDForBeadsStatus returns the unique Linear workflow state ID to +// use when pushing the given beads status. Push only trusts explicit +// linear.state_map.* entries; defaults are safe for pull but too ambiguous for +// mutation. +func ResolveStateIDForBeadsStatus(cache *StateCache, status types.Status, config *MappingConfig) (string, error) { + if cache == nil || len(cache.States) == 0 { + return "", fmt.Errorf("no workflow states found") + } + if config == nil || len(config.ExplicitStateMap) == 0 { + return "", fmt.Errorf("%s", missingExplicitStateMapMessage) + } + + var nameMatches []State + for _, state := range cache.States { + mapped, ok := config.ExplicitStateMap[strings.ToLower(strings.TrimSpace(state.Name))] + if ok && stateMapMatchesStatus(mapped, status) { + nameMatches = append(nameMatches, state) + } + } + if len(nameMatches) == 1 { + return nameMatches[0].ID, nil + } + if len(nameMatches) > 1 { + names := make([]string, 0, len(nameMatches)) + for _, state := range nameMatches { + names = append(names, state.Name) + } + return "", fmt.Errorf("linear.state_map maps beads status %q to multiple Linear states: %s", status, strings.Join(names, ", ")) + } + + var typeMatches []State + for _, state := range cache.States { + mapped, ok := config.ExplicitStateMap[strings.ToLower(strings.TrimSpace(state.Type))] + if ok && stateMapMatchesStatus(mapped, status) { + typeMatches = append(typeMatches, state) + } + } + if len(typeMatches) == 1 { + return typeMatches[0].ID, nil + } + if len(typeMatches) > 1 { + names := make([]string, 0, len(typeMatches)) + for _, state := range typeMatches { + names = append(names, state.Name) + } + return "", fmt.Errorf("linear.state_map type fallback is ambiguous for beads status %q across Linear states: %s", status, strings.Join(names, ", ")) + } + + return "", fmt.Errorf("linear.state_map has no configured Linear state for beads status %q", status) +} + // ParseBeadsStatus converts a status string to types.Status. func ParseBeadsStatus(s string) types.Status { switch strings.ToLower(s) { @@ -334,6 +406,43 @@ func StatusToLinearStateType(status types.Status) string { } } +// PushFieldsEqual compares only the fields that a Linear push can actually +// mutate. This avoids repeated updates caused by local-only fields such as +// issue type, metadata, or labels that are preserved elsewhere. +func PushFieldsEqual(local *types.Issue, remote *Issue, config *MappingConfig) bool { + if local == nil || remote == nil { + return false + } + if local.Title != remote.Title { + return false + } + if BuildLinearDescription(local) != remote.Description { + return false + } + if PriorityToLinear(local.Priority, config) != remote.Priority { + return false + } + return StateToBeadsStatus(remote.State, config) == local.Status +} + +// PushFieldsEqualToBeads is a fallback comparator for cases where Linear's raw +// payload is unavailable and only the normalized beads form remains. +func PushFieldsEqualToBeads(local, remote *types.Issue) bool { + if local == nil || remote == nil { + return false + } + if local.Title != remote.Title { + return false + } + if BuildLinearDescription(local) != remote.Description { + return false + } + if local.Priority != remote.Priority { + return false + } + return local.Status == remote.Status +} + // LabelToIssueType infers issue type from label names. // Uses configurable mapping from linear.label_type_map.* config. func LabelToIssueType(labels *Labels, config *MappingConfig) types.IssueType { diff --git a/internal/linear/mapping_test.go b/internal/linear/mapping_test.go index c0c21d5b00..f056d755f0 100644 --- a/internal/linear/mapping_test.go +++ b/internal/linear/mapping_test.go @@ -1,6 +1,7 @@ package linear import ( + "strings" "testing" "time" @@ -533,6 +534,9 @@ func TestLoadMappingConfig(t *testing.T) { if config.StateMap["custom"] != "in_progress" { t.Errorf("StateMap[custom] = %s, want in_progress", config.StateMap["custom"]) } + if config.ExplicitStateMap["custom"] != "in_progress" { + t.Errorf("ExplicitStateMap[custom] = %s, want in_progress", config.ExplicitStateMap["custom"]) + } // Check custom label type mapping if config.LabelTypeMap["story"] != "feature" { @@ -599,3 +603,104 @@ func TestBuildLinearDescription(t *testing.T) { }) } } + +func TestResolveStateIDForBeadsStatusRequiresExplicitMappings(t *testing.T) { + cache := &StateCache{ + States: []State{ + {ID: "state-1", Name: "Todo", Type: "unstarted"}, + }, + } + + _, err := ResolveStateIDForBeadsStatus(cache, types.StatusOpen, DefaultMappingConfig()) + if err == nil { + t.Fatal("expected missing explicit state map to fail") + } + if !strings.Contains(err.Error(), "linear.state_map is not configured") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResolveStateIDForBeadsStatusRejectsAmbiguousTypeFallback(t *testing.T) { + cache := &StateCache{ + States: []State{ + {ID: "state-1", Name: "Done", Type: "completed"}, + {ID: "state-2", Name: "Monitoring", Type: "completed"}, + }, + } + config := DefaultMappingConfig() + config.ExplicitStateMap["completed"] = "closed" + + _, err := ResolveStateIDForBeadsStatus(cache, types.StatusClosed, config) + if err == nil { + t.Fatal("expected ambiguous completed mapping to fail") + } + if got := err.Error(); !strings.Contains(got, "type fallback is ambiguous") || !strings.Contains(got, "Done, Monitoring") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResolveStateIDForBeadsStatusPrefersExplicitStateName(t *testing.T) { + cache := &StateCache{ + States: []State{ + {ID: "state-1", Name: "Done", Type: "completed"}, + {ID: "state-2", Name: "Monitoring", Type: "completed"}, + }, + } + config := DefaultMappingConfig() + config.ExplicitStateMap["done"] = "closed" + + got, err := ResolveStateIDForBeadsStatus(cache, types.StatusClosed, config) + if err != nil { + t.Fatalf("ResolveStateIDForBeadsStatus() error = %v", err) + } + if got != "state-1" { + t.Fatalf("ResolveStateIDForBeadsStatus() = %q, want state-1", got) + } +} + +func TestPushFieldsEqualIgnoresLocalOnlyDifferences(t *testing.T) { + config := DefaultMappingConfig() + local := &types.Issue{ + Title: "Ship the fix", + Description: "Main body", + Notes: "Local-only notes", + Status: types.StatusInProgress, + Priority: 1, + IssueType: types.TypeFeature, + Labels: []string{"customer-visible"}, + } + remote := &Issue{ + Title: "Ship the fix", + Description: "Main body\n\n## Notes\nLocal-only notes", + Priority: 2, + State: &State{ID: "state-3", Name: "In Progress", Type: "started"}, + } + + if !PushFieldsEqual(local, remote, config) { + t.Fatal("expected push fields to compare equal despite local-only issue type and labels") + } +} + +func TestPushFieldsEqualToBeads(t *testing.T) { + local := &types.Issue{ + Title: "Ship the fix", + Description: "Main body", + Notes: "Local-only notes", + Status: types.StatusInProgress, + Priority: 1, + IssueType: types.TypeFeature, + Labels: []string{"customer-visible"}, + } + remote := &types.Issue{ + Title: "Ship the fix", + Description: "Main body\n\n## Notes\nLocal-only notes", + Status: types.StatusInProgress, + Priority: 1, + IssueType: types.TypeTask, + Labels: []string{"ignored"}, + } + + if !PushFieldsEqualToBeads(local, remote) { + t.Fatal("expected beads-form fallback comparison to ignore local-only fields") + } +} diff --git a/internal/linear/tracker.go b/internal/linear/tracker.go index a18744446f..2b0a49fc81 100644 --- a/internal/linear/tracker.go +++ b/internal/linear/tracker.go @@ -4,8 +4,10 @@ import ( "context" "fmt" "os" + "strings" "time" + "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/tracker" "github.com/steveyegge/beads/internal/types" @@ -202,6 +204,11 @@ func (t *Tracker) FieldMapper() tracker.FieldMapper { return &linearFieldMapper{config: t.config} } +// MappingConfig returns the resolved Linear mapping configuration. +func (t *Tracker) MappingConfig() *MappingConfig { + return t.config +} + func (t *Tracker) IsExternalRef(ref string) bool { return IsLinearExternalRef(ref) } @@ -220,26 +227,44 @@ func (t *Tracker) BuildExternalRef(issue *tracker.TrackerIssue) string { return fmt.Sprintf("https://linear.app/issue/%s", issue.Identifier) } +// ValidatePushStateMappings ensures push has explicit, non-ambiguous status +// mappings for every configured team before any mutation occurs. +func (t *Tracker) ValidatePushStateMappings(ctx context.Context) error { + if t.config == nil || len(t.config.ExplicitStateMap) == 0 { + return fmt.Errorf("%s", missingExplicitStateMapMessage) + } + for _, teamID := range t.teamIDs { + client := t.clients[teamID] + if client == nil { + continue + } + cache, err := BuildStateCache(ctx, client) + if err != nil { + return fmt.Errorf("fetching workflow states for team %s: %w", teamID, err) + } + for _, status := range []types.Status{types.StatusOpen, types.StatusInProgress, types.StatusBlocked, types.StatusClosed} { + if _, err := ResolveStateIDForBeadsStatus(cache, status, t.config); err != nil { + // Only fail for statuses the config explicitly tries to map or when + // mappings are entirely absent. Missing blocked mappings are allowed + // until a blocked issue is actually pushed. + if status == types.StatusBlocked && strings.Contains(err.Error(), "has no configured Linear state") { + continue + } + return err + } + } + } + return nil +} + // findStateID looks up the Linear workflow state ID for a beads status // using the given per-team client. func (t *Tracker) findStateID(ctx context.Context, client *Client, status types.Status) (string, error) { - targetType := StatusToLinearStateType(status) - - states, err := client.GetTeamStates(ctx) + cache, err := BuildStateCache(ctx, client) if err != nil { return "", err } - - for _, s := range states { - if s.Type == targetType { - return s.ID, nil - } - } - - if len(states) > 0 { - return states[0].ID, nil - } - return "", fmt.Errorf("no workflow states found") + return ResolveStateIDForBeadsStatus(cache, status, t.config) } // primaryClient returns the client for the first configured team. @@ -284,7 +309,23 @@ func (t *Tracker) PrimaryClient() *Client { } // getConfig reads a config value from storage, falling back to env var. +// For yaml-only keys (e.g. linear.api_key), reads from config.yaml first +// to match the behavior of cmd/bd/linear.go:getLinearConfig(). func (t *Tracker) getConfig(ctx context.Context, key, envVar string) (string, error) { + // Secret keys are stored in config.yaml, not the Dolt database, + // to avoid leaking secrets when pushing to remotes. + if config.IsYamlOnlyKey(key) { + if val := config.GetString(key); val != "" { + return val, nil + } + if envVar != "" { + if envVal := os.Getenv(envVar); envVal != "" { + return envVal, nil + } + } + return "", nil + } + val, err := t.store.GetConfig(ctx, key) if err == nil && val != "" { return val, nil diff --git a/internal/storage/dolt/config.go b/internal/storage/dolt/config.go index 8aa8352161..452912a1c5 100644 --- a/internal/storage/dolt/config.go +++ b/internal/storage/dolt/config.go @@ -107,6 +107,26 @@ func (s *DoltStore) GetMetadata(ctx context.Context, key string) (string, error) return value, err } +// SetLocalMetadata sets a value in the dolt-ignored local_metadata table. +// Used for clone-local state that should not generate merge conflicts. +func (s *DoltStore) SetLocalMetadata(ctx context.Context, key, value string) error { + return s.withRetryTx(ctx, func(tx *sql.Tx) error { + return issueops.SetLocalMetadataInTx(ctx, tx, key, value) + }) +} + +// GetLocalMetadata retrieves a value from the dolt-ignored local_metadata table. +// Returns ("", nil) if the key does not exist. +func (s *DoltStore) GetLocalMetadata(ctx context.Context, key string) (string, error) { + var value string + err := s.withReadTx(ctx, func(tx *sql.Tx) error { + var err error + value, err = issueops.GetLocalMetadataInTx(ctx, tx, key) + return err + }) + return value, err +} + // GetCustomStatuses returns custom status name strings from config (backward-compatible API). // Callers that need category information should use GetCustomStatusesDetailed instead. func (s *DoltStore) GetCustomStatuses(ctx context.Context) ([]string, error) { diff --git a/internal/storage/dolt/migrations/004_wisps_table.go b/internal/storage/dolt/migrations/004_wisps_table.go index 579e8fbfd1..a5d97f1028 100644 --- a/internal/storage/dolt/migrations/004_wisps_table.go +++ b/internal/storage/dolt/migrations/004_wisps_table.go @@ -46,8 +46,8 @@ func MigrateWispsTable(db *sql.DB) error { return fmt.Errorf("failed to commit dolt_ignore changes: %w", err) } - // Step 2: Create wisps table using shared schema constant. - _, err = db.Exec(schema.WispsTableSchema) + // Step 2: Create wisps table using the embedded migration file. + _, err = db.Exec(schema.ReadMigrationSQL(20)) if err != nil { return fmt.Errorf("failed to create wisps table: %w", err) } diff --git a/internal/storage/dolt/migrations/005_wisp_auxiliary_tables.go b/internal/storage/dolt/migrations/005_wisp_auxiliary_tables.go index 87118226ff..39919785fb 100644 --- a/internal/storage/dolt/migrations/005_wisp_auxiliary_tables.go +++ b/internal/storage/dolt/migrations/005_wisp_auxiliary_tables.go @@ -12,18 +12,10 @@ import ( // tables but reference the wisps table instead of issues. They are covered // by the dolt_ignore pattern "wisp_%" added in migration 004. func MigrateWispAuxiliaryTables(db *sql.DB) error { - auxiliaryDDL := []string{ - schema.WispLabelsSchema, - schema.WispDependenciesSchema, - schema.WispEventsSchema, - schema.WispCommentsSchema, + // Migration 0021 is a multi-statement file that creates all four + // auxiliary tables. The Dolt/MySQL driver has multiStatements=true. + if _, err := db.Exec(schema.ReadMigrationSQL(21)); err != nil { + return fmt.Errorf("failed to create wisp auxiliary tables: %w", err) } - - for _, ddl := range auxiliaryDDL { - if _, err := db.Exec(ddl); err != nil { - return fmt.Errorf("failed to create wisp auxiliary table: %w", err) - } - } - return nil } diff --git a/internal/storage/dolt/store.go b/internal/storage/dolt/store.go index addc9e4415..1b2ae4eb8e 100644 --- a/internal/storage/dolt/store.go +++ b/internal/storage/dolt/store.go @@ -1075,6 +1075,14 @@ func newServerMode(ctx context.Context, cfg *Config) (*DoltStore, error) { // CREATE DATABASE, information_schema queries may fail transiently // even though Ping succeeded. This resolves within ~1s. if !cfg.ReadOnly { + // Ensure dolt_ignore'd tables exist BEFORE running migrations. + // Migrations may reference these tables (e.g. 0027 alters wisps, + // 0030 inserts into local_metadata). After a clone or server restart + // these tables don't exist yet since they're not in committed data. + if err := versioncontrolops.EnsureIgnoredTables(ctx, db); err != nil { + return nil, fmt.Errorf("failed to ensure ignored tables: %w", err) + } + schemaBO := backoff.NewExponentialBackOff() schemaBO.InitialInterval = 100 * time.Millisecond schemaBO.MaxElapsedTime = 5 * time.Second @@ -1090,13 +1098,6 @@ func newServerMode(ctx context.Context, cfg *Config) (*DoltStore, error) { }, backoff.WithContext(schemaBO, ctx)); err != nil { return nil, fmt.Errorf("failed to initialize schema: %w", err) } - - // Ensure dolt_ignore'd tables (wisps, wisp_*) exist in the working set. - // These tables are not persisted in commits, so they need recreation - // after a server restart. Short-circuits if they already exist (1 query). - if err := versioncontrolops.EnsureIgnoredTables(ctx, db); err != nil { - return nil, fmt.Errorf("failed to ensure ignored tables: %w", err) - } } // Initialize credential encryption key (loads from file or generates new random key). @@ -1423,7 +1424,7 @@ func initSchemaOnDB(ctx context.Context, db *sql.DB) error { "issues", "dependencies", "labels", "comments", "events", "config", "metadata", "child_counters", "issue_snapshots", "compaction_snapshots", - "repo_mtimes", "routes", "issue_counter", + "routes", "issue_counter", "interactions", "federation_peers", "custom_statuses", "custom_types", "dolt_ignore", "schema_migrations", diff --git a/internal/storage/dolt/transaction.go b/internal/storage/dolt/transaction.go index a7aaa33833..8cc1843943 100644 --- a/internal/storage/dolt/transaction.go +++ b/internal/storage/dolt/transaction.go @@ -818,6 +818,22 @@ func (t *doltTransaction) GetMetadata(ctx context.Context, key string) (string, return value, wrapQueryError("get metadata in tx", err) } +// SetLocalMetadata sets a value in the dolt-ignored local_metadata table within the transaction. +func (t *doltTransaction) SetLocalMetadata(ctx context.Context, key, value string) error { + _, err := t.tx.ExecContext(ctx, "REPLACE INTO local_metadata (`key`, value) VALUES (?, ?)", key, value) + return wrapExecError("set local metadata in tx", err) +} + +// GetLocalMetadata gets a value from the dolt-ignored local_metadata table within the transaction. +func (t *doltTransaction) GetLocalMetadata(ctx context.Context, key string) (string, error) { + var value string + err := t.tx.QueryRowContext(ctx, "SELECT value FROM local_metadata WHERE `key` = ?", key).Scan(&value) + if err == sql.ErrNoRows { + return "", nil + } + return value, wrapQueryError("get local metadata in tx", err) +} + func (t *doltTransaction) ImportIssueComment(ctx context.Context, issueID, author, text string, createdAt time.Time) (*types.Comment, error) { _, err := t.GetIssue(ctx, issueID) if err != nil { diff --git a/internal/storage/embeddeddolt/config_metadata.go b/internal/storage/embeddeddolt/config_metadata.go index a414356f38..ae2091e0d3 100644 --- a/internal/storage/embeddeddolt/config_metadata.go +++ b/internal/storage/embeddeddolt/config_metadata.go @@ -69,6 +69,22 @@ func (s *EmbeddedDoltStore) SetMetadata(ctx context.Context, key, value string) }) } +func (s *EmbeddedDoltStore) SetLocalMetadata(ctx context.Context, key, value string) error { + return s.withConn(ctx, true, func(tx *sql.Tx) error { + return issueops.SetLocalMetadataInTx(ctx, tx, key, value) + }) +} + +func (s *EmbeddedDoltStore) GetLocalMetadata(ctx context.Context, key string) (string, error) { + var value string + err := s.withConn(ctx, false, func(tx *sql.Tx) error { + var err error + value, err = issueops.GetLocalMetadataInTx(ctx, tx, key) + return err + }) + return value, err +} + // GetInfraTypes returns the set of infrastructure types that should be routed // to the wisps table. Reads from DB config "types.infra", falls back to YAML, // then to hardcoded defaults (agent, rig, role, message). diff --git a/internal/storage/embeddeddolt/open.go b/internal/storage/embeddeddolt/open.go index 7d19648872..df3ac33db7 100644 --- a/internal/storage/embeddeddolt/open.go +++ b/internal/storage/embeddeddolt/open.go @@ -76,8 +76,11 @@ func OpenSQL(ctx context.Context, dir, database, branch string) (*sql.DB, func() if strings.TrimSpace(database) != "" { if !validIdentifier.MatchString(database) { - return nil, nil, errors.Join( - fmt.Errorf("invalid database name: %q", database), cleanup()) + msg := fmt.Sprintf("invalid database name: %q", database) + if strings.ContainsRune(database, '-') { + msg += "; hyphens are not allowed in embedded mode — replace with underscores in .beads/metadata.json dolt_database field, or run 'bd doctor'" + } + return nil, nil, errors.Join(errors.New(msg), cleanup()) } if _, err := db.ExecContext(ctx, "USE `"+database+"`"); err != nil { return nil, nil, errors.Join(err, cleanup()) diff --git a/internal/storage/embeddeddolt/schema_test.go b/internal/storage/embeddeddolt/schema_test.go index fe928f85a7..6bf4325fb2 100644 --- a/internal/storage/embeddeddolt/schema_test.go +++ b/internal/storage/embeddeddolt/schema_test.go @@ -53,6 +53,7 @@ func TestSchemaAfterInit(t *testing.T) { "issue_counter", "interactions", "federation_peers", + "local_metadata", "wisps", "wisp_labels", "wisp_dependencies", diff --git a/internal/storage/embeddeddolt/store.go b/internal/storage/embeddeddolt/store.go index b135c17296..2e16fd174d 100644 --- a/internal/storage/embeddeddolt/store.go +++ b/internal/storage/embeddeddolt/store.go @@ -236,7 +236,11 @@ func (s *EmbeddedDoltStore) initSchema(ctx context.Context) error { return s.withRootConn(ctx, true, func(tx *sql.Tx) error { if s.database != "" { if !validIdentifier.MatchString(s.database) { - return fmt.Errorf("embeddeddolt: invalid database name: %q", s.database) + msg := fmt.Sprintf("embeddeddolt: invalid database name: %q", s.database) + if strings.ContainsRune(s.database, '-') { + msg += "; hyphens are not allowed in embedded mode — replace with underscores in .beads/metadata.json dolt_database field, or run 'bd doctor'" + } + return errors.New(msg) } if _, err := tx.ExecContext(ctx, "CREATE DATABASE IF NOT EXISTS `"+s.database+"`"); err != nil { return fmt.Errorf("embeddeddolt: creating database: %w", err) @@ -251,6 +255,13 @@ func (s *EmbeddedDoltStore) initSchema(ctx context.Context) error { } } + // Ensure dolt_ignore'd tables exist before migrations — some migrations + // reference these tables (e.g. 0027 alters wisps, 0030 inserts into + // local_metadata). After a clone they don't exist yet. + if err := schema.EnsureIgnoredTables(ctx, tx); err != nil { + return fmt.Errorf("ensure ignored tables before migration: %w", err) + } + applied, err := schema.MigrateUp(ctx, tx) if err != nil { return err diff --git a/internal/storage/embeddeddolt/transaction.go b/internal/storage/embeddeddolt/transaction.go index 126683c71a..858b65412a 100644 --- a/internal/storage/embeddeddolt/transaction.go +++ b/internal/storage/embeddeddolt/transaction.go @@ -158,6 +158,14 @@ func (t *embeddedTransaction) GetMetadata(ctx context.Context, key string) (stri return issueops.GetMetadataInTx(ctx, t.tx, key) } +func (t *embeddedTransaction) SetLocalMetadata(ctx context.Context, key, value string) error { + return issueops.SetLocalMetadataInTx(ctx, t.tx, key, value) +} + +func (t *embeddedTransaction) GetLocalMetadata(ctx context.Context, key string) (string, error) { + return issueops.GetLocalMetadataInTx(ctx, t.tx, key) +} + func (t *embeddedTransaction) AddComment(ctx context.Context, issueID, actor, comment string) error { return fmt.Errorf("embeddedTransaction: AddComment not implemented") } diff --git a/internal/storage/issueops/config_metadata.go b/internal/storage/issueops/config_metadata.go index d0bc647bd4..0b917c7e37 100644 --- a/internal/storage/issueops/config_metadata.go +++ b/internal/storage/issueops/config_metadata.go @@ -75,3 +75,28 @@ func GetMetadataInTx(ctx context.Context, tx *sql.Tx, key string) (string, error } return value, nil } + +// SetLocalMetadataInTx sets a value in the dolt-ignored local_metadata table +// within an existing transaction. Used for clone-local state that should not +// generate merge conflicts (tip timestamps, version stamps, sync cursors). +func SetLocalMetadataInTx(ctx context.Context, tx *sql.Tx, key, value string) error { + _, err := tx.ExecContext(ctx, "REPLACE INTO local_metadata (`key`, value) VALUES (?, ?)", key, value) + if err != nil { + return fmt.Errorf("set local metadata %s: %w", key, err) + } + return nil +} + +// GetLocalMetadataInTx retrieves a value from the dolt-ignored local_metadata +// table within an existing transaction. Returns ("", nil) if the key does not exist. +func GetLocalMetadataInTx(ctx context.Context, tx *sql.Tx, key string) (string, error) { + var value string + err := tx.QueryRowContext(ctx, "SELECT value FROM local_metadata WHERE `key` = ?", key).Scan(&value) + if err == sql.ErrNoRows { + return "", nil + } + if err != nil { + return "", fmt.Errorf("get local metadata %s: %w", key, err) + } + return value, nil +} diff --git a/internal/storage/schema/helpers.go b/internal/storage/schema/helpers.go index f73015d665..36688399f6 100644 --- a/internal/storage/schema/helpers.go +++ b/internal/storage/schema/helpers.go @@ -13,11 +13,15 @@ import ( // dolt_ignore entries are committed and persist across branches; only the // tables themselves (which live in the working set) need recreation. func EnsureIgnoredTables(ctx context.Context, db DBConn) error { - exists, err := TableExists(ctx, db, "wisps") + wispsOK, err := TableExists(ctx, db, "wisps") if err != nil { return fmt.Errorf("check wisps table: %w", err) } - if exists { + localOK, err := TableExists(ctx, db, "local_metadata") + if err != nil { + return fmt.Errorf("check local_metadata table: %w", err) + } + if wispsOK && localOK { return nil } return CreateIgnoredTables(ctx, db) @@ -30,9 +34,15 @@ func EnsureIgnoredTables(ctx context.Context, db DBConn) error { // This does NOT set up dolt_ignore entries or commit — those are migration // concerns handled separately during bd init. func CreateIgnoredTables(ctx context.Context, db DBConn) error { - for _, ddl := range IgnoredTableDDL { + for _, ddl := range IgnoredTableDDL() { if _, err := db.ExecContext(ctx, ddl); err != nil { - return fmt.Errorf("create ignored table: %w", err) + // Tolerate "already exists" / "duplicate column" errors: when + // wisps exists but local_metadata doesn't (or vice versa), the + // ALTER TABLE statements for existing tables may hit columns + // that are already present. + if !isConcurrentInitError(err) { + return fmt.Errorf("create ignored table: %w", err) + } } } return nil diff --git a/internal/storage/schema/ignored_tables.go b/internal/storage/schema/ignored_tables.go index 2761d82aed..5e54a60f79 100644 --- a/internal/storage/schema/ignored_tables.go +++ b/internal/storage/schema/ignored_tables.go @@ -1,119 +1,114 @@ package schema -// IgnoredTableDDL is the ordered list of CREATE TABLE IF NOT EXISTS statements -// for all dolt_ignore'd tables. This is the single source of truth for the -// wisp table schemas used by both DoltStore and EmbeddedDoltStore. -var IgnoredTableDDL = []string{ - WispsTableSchema, - WispLabelsSchema, - WispDependenciesSchema, - WispEventsSchema, - WispCommentsSchema, +import ( + "fmt" + "io/fs" + "strings" + "sync" +) + +// ignoredMigration identifies an embedded .up.sql migration (or a subset of +// its statements) that defines or alters a dolt-ignored table. +type ignoredMigration struct { + version int + // filter, if non-empty, selects only statements containing this substring + // (case-insensitive). When empty, the entire migration file is used. + filter string } -// WispsTableSchema mirrors the issues table schema exactly. -// This table is ignored by dolt_ignore and will not appear in Dolt commits. -const WispsTableSchema = `CREATE TABLE IF NOT EXISTS wisps ( - id VARCHAR(255) PRIMARY KEY, - content_hash VARCHAR(64), - title VARCHAR(500) NOT NULL, - description TEXT NOT NULL DEFAULT '', - design TEXT NOT NULL DEFAULT '', - acceptance_criteria TEXT NOT NULL DEFAULT '', - notes TEXT NOT NULL DEFAULT '', - status VARCHAR(32) NOT NULL DEFAULT 'open', - priority INT NOT NULL DEFAULT 2, - issue_type VARCHAR(32) NOT NULL DEFAULT 'task', - assignee VARCHAR(255), - estimated_minutes INT, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by VARCHAR(255) DEFAULT '', - owner VARCHAR(255) DEFAULT '', - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - closed_at DATETIME, - closed_by_session VARCHAR(255) DEFAULT '', - external_ref VARCHAR(255), - spec_id VARCHAR(1024), - compaction_level INT DEFAULT 0, - compacted_at DATETIME, - compacted_at_commit VARCHAR(64), - original_size INT, - sender VARCHAR(255) DEFAULT '', - ephemeral TINYINT(1) DEFAULT 0, - no_history TINYINT(1) DEFAULT 0, - wisp_type VARCHAR(32) DEFAULT '', - pinned TINYINT(1) DEFAULT 0, - is_template TINYINT(1) DEFAULT 0, - mol_type VARCHAR(32) DEFAULT '', - work_type VARCHAR(32) DEFAULT 'mutex', - source_system VARCHAR(255) DEFAULT '', - metadata JSON DEFAULT (JSON_OBJECT()), - source_repo VARCHAR(512) DEFAULT '', - close_reason TEXT DEFAULT '', - event_kind VARCHAR(32) DEFAULT '', - actor VARCHAR(255) DEFAULT '', - target VARCHAR(255) DEFAULT '', - payload TEXT DEFAULT '', - await_type VARCHAR(32) DEFAULT '', - await_id VARCHAR(255) DEFAULT '', - timeout_ns BIGINT DEFAULT 0, - waiters TEXT DEFAULT '', - hook_bead VARCHAR(255) DEFAULT '', - role_bead VARCHAR(255) DEFAULT '', - agent_state VARCHAR(32) DEFAULT '', - last_activity DATETIME, - role_type VARCHAR(32) DEFAULT '', - rig VARCHAR(255) DEFAULT '', - due_at DATETIME, - defer_until DATETIME, - INDEX idx_wisps_status (status), - INDEX idx_wisps_priority (priority), - INDEX idx_wisps_issue_type (issue_type), - INDEX idx_wisps_assignee (assignee), - INDEX idx_wisps_created_at (created_at), - INDEX idx_wisps_spec_id (spec_id), - INDEX idx_wisps_external_ref (external_ref) -)` +// ignoredMigrations lists the migrations that define or alter dolt-ignored +// tables, in the order they must be applied. This replaces the former +// hand-maintained Go constants — the .up.sql files are the single source +// of truth. +var ignoredMigrations = []ignoredMigration{ + {version: 29}, // CREATE TABLE local_metadata + {version: 11}, // CREATE TABLE repo_mtimes + {version: 20}, // CREATE TABLE wisps + {version: 21}, // CREATE TABLE wisp_labels, wisp_dependencies, wisp_events, wisp_comments + {version: 22}, // CREATE INDEX on wisp_dependencies + {version: 23, filter: "wisps"}, // ALTER TABLE wisps ADD COLUMN no_history (skip issues ALTER) + {version: 27, filter: "wisps"}, // ALTER TABLE wisps ADD COLUMN started_at (skip issues ALTER) + {version: 31}, // CREATE INDEX idx_wisp_events_created_at +} -const WispLabelsSchema = `CREATE TABLE IF NOT EXISTS wisp_labels ( - issue_id VARCHAR(255) NOT NULL, - label VARCHAR(255) NOT NULL, - PRIMARY KEY (issue_id, label), - INDEX idx_wisp_labels_label (label) -)` +var ( + ignoredDDLOnce sync.Once + ignoredDDLVal []string +) -const WispDependenciesSchema = `CREATE TABLE IF NOT EXISTS wisp_dependencies ( - issue_id VARCHAR(255) NOT NULL, - depends_on_id VARCHAR(255) NOT NULL, - type VARCHAR(32) NOT NULL DEFAULT 'blocks', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - created_by VARCHAR(255) DEFAULT '', - metadata JSON DEFAULT (JSON_OBJECT()), - thread_id VARCHAR(255) DEFAULT '', - PRIMARY KEY (issue_id, depends_on_id), - INDEX idx_wisp_dep_depends (depends_on_id), - INDEX idx_wisp_dep_type (type), - INDEX idx_wisp_dep_type_depends (type, depends_on_id) -)` +// IgnoredTableDDL returns the ordered list of SQL statements needed to +// recreate all dolt-ignored tables from scratch. Derived from embedded +// migration files at first call and cached thereafter. +func IgnoredTableDDL() []string { + ignoredDDLOnce.Do(func() { + ignoredDDLVal = buildIgnoredTableDDL() + }) + return ignoredDDLVal +} -const WispEventsSchema = `CREATE TABLE IF NOT EXISTS wisp_events ( - id CHAR(36) NOT NULL PRIMARY KEY DEFAULT (UUID()), - issue_id VARCHAR(255) NOT NULL, - event_type VARCHAR(32) NOT NULL, - actor VARCHAR(255) DEFAULT '', - old_value TEXT DEFAULT '', - new_value TEXT DEFAULT '', - comment TEXT DEFAULT '', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - INDEX idx_wisp_events_issue (issue_id), - INDEX idx_wisp_events_created_at (created_at) -)` +func buildIgnoredTableDDL() []string { + var result []string + for _, im := range ignoredMigrations { + raw := ReadMigrationSQL(im.version) + stmts := splitStatements(raw) + if im.filter != "" { + filterLower := strings.ToLower(im.filter) + for _, s := range stmts { + if strings.Contains(strings.ToLower(s), filterLower) { + result = append(result, s) + } + } + } else { + result = append(result, stmts...) + } + } + return result +} -const WispCommentsSchema = `CREATE TABLE IF NOT EXISTS wisp_comments ( - id CHAR(36) NOT NULL PRIMARY KEY DEFAULT (UUID()), - issue_id VARCHAR(255) NOT NULL, - author VARCHAR(255) DEFAULT '', - text TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - INDEX idx_wisp_comments_issue (issue_id) -)` +// ReadMigrationSQL reads the embedded .up.sql file for the given version number +// and returns its contents as a string. Panics if the migration is not found. +func ReadMigrationSQL(version int) string { + entries, err := fs.ReadDir(upMigrations, "migrations") + if err != nil { + panic(fmt.Sprintf("schema: reading migrations dir: %v", err)) + } + prefix := fmt.Sprintf("%04d_", version) + for _, e := range entries { + if strings.HasPrefix(e.Name(), prefix) && strings.HasSuffix(e.Name(), ".up.sql") { + data, err := upMigrations.ReadFile("migrations/" + e.Name()) + if err != nil { + panic(fmt.Sprintf("schema: reading migration %s: %v", e.Name(), err)) + } + return string(data) + } + } + panic(fmt.Sprintf("schema: migration %04d not found", version)) +} + +// splitStatements splits SQL text on semicolons into individual statements, +// stripping SQL comments and whitespace. Returns only non-empty statements. +func splitStatements(sql string) []string { + raw := strings.Split(sql, ";") + var out []string + for _, s := range raw { + s = stripSQLComments(s) + s = strings.TrimSpace(s) + if s != "" { + out = append(out, s) + } + } + return out +} + +// stripSQLComments removes lines starting with -- from SQL text. +func stripSQLComments(sql string) string { + var lines []string + for _, line := range strings.Split(sql, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "--") { + continue + } + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} diff --git a/internal/storage/schema/ignored_tables_test.go b/internal/storage/schema/ignored_tables_test.go new file mode 100644 index 0000000000..f04b7825e1 --- /dev/null +++ b/internal/storage/schema/ignored_tables_test.go @@ -0,0 +1,76 @@ +package schema + +import ( + "strings" + "testing" +) + +func TestIgnoredTableDDL(t *testing.T) { + ddl := IgnoredTableDDL() + if len(ddl) == 0 { + t.Fatal("IgnoredTableDDL returned no statements") + } + + combined := strings.Join(ddl, "\n") + + // Verify all expected tables are referenced. + for _, table := range []string{ + "local_metadata", "repo_mtimes", "wisps", + "wisp_labels", "wisp_dependencies", "wisp_events", "wisp_comments", + } { + if !strings.Contains(combined, table) { + t.Errorf("IgnoredTableDDL missing reference to table %q", table) + } + } + + // Verify columns added by later migrations are present (the bug that + // motivated this refactor: started_at was missing from the Go constant). + for _, col := range []string{"started_at", "no_history"} { + if !strings.Contains(combined, col) { + t.Errorf("IgnoredTableDDL missing column %q — migration not included?", col) + } + } + + // Verify the wisp_events created_at index is present. + if !strings.Contains(combined, "idx_wisp_events_created_at") { + t.Error("IgnoredTableDDL missing idx_wisp_events_created_at index") + } +} + +func TestReadMigrationSQL(t *testing.T) { + sql := ReadMigrationSQL(20) + if !strings.Contains(sql, "CREATE TABLE") { + t.Error("migration 0020 should contain CREATE TABLE") + } + if !strings.Contains(sql, "wisps") { + t.Error("migration 0020 should reference wisps table") + } +} + +func TestReadMigrationSQL_Panics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("expected panic for non-existent migration") + } + }() + ReadMigrationSQL(9999) +} + +func TestSplitStatements(t *testing.T) { + sql := "CREATE TABLE foo (id INT);\nALTER TABLE foo ADD COLUMN bar INT;\n" + stmts := splitStatements(sql) + if len(stmts) != 2 { + t.Fatalf("expected 2 statements, got %d: %v", len(stmts), stmts) + } +} + +func TestSplitStatements_StripsComments(t *testing.T) { + sql := "-- This is a comment\nCREATE TABLE foo (id INT);\n" + stmts := splitStatements(sql) + if len(stmts) != 1 { + t.Fatalf("expected 1 statement, got %d", len(stmts)) + } + if strings.Contains(stmts[0], "--") { + t.Error("comment not stripped from statement") + } +} diff --git a/internal/storage/schema/migrations/0028_local_state_dolt_ignore.down.sql b/internal/storage/schema/migrations/0028_local_state_dolt_ignore.down.sql new file mode 100644 index 0000000000..9fe77264e6 --- /dev/null +++ b/internal/storage/schema/migrations/0028_local_state_dolt_ignore.down.sql @@ -0,0 +1,4 @@ +-- Reverse migration 0028: remove dolt_ignore entries for local_metadata and repo_mtimes. +-- Note: repo_mtimes data in the working set will be lost; the committed table +-- would need to be re-created manually if a full rollback is needed. +DELETE FROM dolt_ignore WHERE pattern IN ('local_metadata', 'repo_mtimes'); diff --git a/internal/storage/schema/migrations/0028_local_state_dolt_ignore.up.sql b/internal/storage/schema/migrations/0028_local_state_dolt_ignore.up.sql new file mode 100644 index 0000000000..3d82026222 --- /dev/null +++ b/internal/storage/schema/migrations/0028_local_state_dolt_ignore.up.sql @@ -0,0 +1,41 @@ +-- Migration 0028: Move clone-local state to dolt-ignored tables. +-- +-- repo_mtimes and local_metadata contain clone-local state that generates +-- Dolt merge conflicts when two clones independently update the same rows. +-- Moving them to dolt_ignore eliminates these conflicts. +-- +-- repo_mtimes is an existing committed table that must be dropped, ignored, +-- and recreated. local_metadata is new and just needs an ignore entry before +-- creation (handled in migration 0029). + +-- Phase 1: Preserve repo_mtimes data in a temp table. +CREATE TABLE IF NOT EXISTS repo_mtimes_tmp ( + repo_path VARCHAR(512) PRIMARY KEY, + jsonl_path VARCHAR(512) NOT NULL, + mtime_ns BIGINT NOT NULL, + last_checked DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_repo_mtimes_tmp_checked (last_checked) +); +INSERT IGNORE INTO repo_mtimes_tmp SELECT * FROM repo_mtimes; + +-- Phase 2: Drop the committed repo_mtimes and register dolt_ignore patterns. +-- dolt_ignore entries must be committed BEFORE creating ignored tables. +DROP TABLE IF EXISTS repo_mtimes; +REPLACE INTO dolt_ignore VALUES ('local_metadata', true); +REPLACE INTO dolt_ignore VALUES ('repo_mtimes', true); +CALL DOLT_ADD('repo_mtimes', 'dolt_ignore'); +CALL DOLT_COMMIT('-m', 'chore: move repo_mtimes and local_metadata to dolt_ignore'); + +-- Phase 3: Recreate repo_mtimes in the working set (now dolt-ignored). +CREATE TABLE IF NOT EXISTS repo_mtimes ( + repo_path VARCHAR(512) PRIMARY KEY, + jsonl_path VARCHAR(512) NOT NULL, + mtime_ns BIGINT NOT NULL, + last_checked DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_repo_mtimes_checked (last_checked) +); + +-- Phase 4: Restore data and clean up temp table. +-- repo_mtimes_tmp was never committed, so dropping it leaves no tracked changes. +INSERT IGNORE INTO repo_mtimes SELECT * FROM repo_mtimes_tmp; +DROP TABLE IF EXISTS repo_mtimes_tmp; diff --git a/internal/storage/schema/migrations/0029_create_local_metadata.down.sql b/internal/storage/schema/migrations/0029_create_local_metadata.down.sql new file mode 100644 index 0000000000..3eaa4fce3f --- /dev/null +++ b/internal/storage/schema/migrations/0029_create_local_metadata.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS local_metadata; diff --git a/internal/storage/schema/migrations/0029_create_local_metadata.up.sql b/internal/storage/schema/migrations/0029_create_local_metadata.up.sql new file mode 100644 index 0000000000..fb81b8d63b --- /dev/null +++ b/internal/storage/schema/migrations/0029_create_local_metadata.up.sql @@ -0,0 +1,10 @@ +-- Migration 0029: Create the local_metadata table (dolt-ignored). +-- +-- This table stores clone-local key-value state that should not be replicated +-- across Dolt clones: tip display timestamps, bd version stamps, tracker sync +-- cursors, etc. It is dolt-ignored (see migration 0028) and will be recreated +-- empty by EnsureIgnoredTables after server restart, branch checkout, or clone. +CREATE TABLE IF NOT EXISTS local_metadata ( + `key` VARCHAR(255) PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' +); diff --git a/internal/storage/schema/migrations/0030_migrate_local_metadata_keys.down.sql b/internal/storage/schema/migrations/0030_migrate_local_metadata_keys.down.sql new file mode 100644 index 0000000000..664a00f6d8 --- /dev/null +++ b/internal/storage/schema/migrations/0030_migrate_local_metadata_keys.down.sql @@ -0,0 +1,8 @@ +-- Reverse migration 0030: copy keys back from local_metadata to committed tables. +-- Note: local_metadata may have been recreated empty, so this is best-effort. +INSERT IGNORE INTO metadata (`key`, value) + SELECT `key`, value FROM local_metadata WHERE `key` LIKE 'tip\_%' ESCAPE '\\'; +INSERT IGNORE INTO metadata (`key`, value) + SELECT `key`, value FROM local_metadata WHERE `key` IN ('bd_version', 'bd_version_max'); +INSERT IGNORE INTO config (`key`, value) + SELECT `key`, value FROM local_metadata WHERE `key` LIKE '%.last\_sync' ESCAPE '\\'; diff --git a/internal/storage/schema/migrations/0030_migrate_local_metadata_keys.up.sql b/internal/storage/schema/migrations/0030_migrate_local_metadata_keys.up.sql new file mode 100644 index 0000000000..3b07c32b49 --- /dev/null +++ b/internal/storage/schema/migrations/0030_migrate_local_metadata_keys.up.sql @@ -0,0 +1,27 @@ +-- Migration 0030: Migrate clone-local keys from committed tables to local_metadata. +-- +-- Copies tip timestamps, version stamps, and tracker sync cursors from the +-- committed metadata/config tables into the dolt-ignored local_metadata table, +-- then deletes the originals so they no longer generate merge conflicts. +-- +-- This is best-effort: local_metadata is ephemeral (recreated empty on working-set +-- reset), so all readers must handle "key not found" as the normal case. The copy +-- here just provides a smooth upgrade experience. + +-- Copy tip display timestamps +INSERT IGNORE INTO local_metadata (`key`, value) + SELECT `key`, value FROM metadata WHERE `key` LIKE 'tip\_%' ESCAPE '\\'; + +-- Copy version stamps +INSERT IGNORE INTO local_metadata (`key`, value) + SELECT `key`, value FROM metadata WHERE `key` IN ('bd_version', 'bd_version_max'); + +-- Copy tracker sync cursors +INSERT IGNORE INTO local_metadata (`key`, value) + SELECT `key`, value FROM config WHERE `key` LIKE '%.last\_sync' ESCAPE '\\'; + +-- Remove migrated keys from committed tables. +-- The post-migration commit in initSchemaOnDB stages and commits these deletions. +DELETE FROM metadata WHERE `key` LIKE 'tip\_%' ESCAPE '\\'; +DELETE FROM metadata WHERE `key` IN ('bd_version', 'bd_version_max'); +DELETE FROM config WHERE `key` LIKE '%.last\_sync' ESCAPE '\\'; diff --git a/internal/storage/schema/migrations/0031_wisp_events_created_at_index.down.sql b/internal/storage/schema/migrations/0031_wisp_events_created_at_index.down.sql new file mode 100644 index 0000000000..f03265b239 --- /dev/null +++ b/internal/storage/schema/migrations/0031_wisp_events_created_at_index.down.sql @@ -0,0 +1 @@ +DROP INDEX idx_wisp_events_created_at ON wisp_events; diff --git a/internal/storage/schema/migrations/0031_wisp_events_created_at_index.up.sql b/internal/storage/schema/migrations/0031_wisp_events_created_at_index.up.sql new file mode 100644 index 0000000000..b73839135d --- /dev/null +++ b/internal/storage/schema/migrations/0031_wisp_events_created_at_index.up.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_wisp_events_created_at ON wisp_events (created_at); diff --git a/internal/storage/schema/migrations/0032_drop_schema_migrations_applied_at.down.sql b/internal/storage/schema/migrations/0032_drop_schema_migrations_applied_at.down.sql new file mode 100644 index 0000000000..caf32ff618 --- /dev/null +++ b/internal/storage/schema/migrations/0032_drop_schema_migrations_applied_at.down.sql @@ -0,0 +1 @@ +ALTER TABLE schema_migrations ADD COLUMN applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/internal/storage/schema/migrations/0032_drop_schema_migrations_applied_at.up.sql b/internal/storage/schema/migrations/0032_drop_schema_migrations_applied_at.up.sql new file mode 100644 index 0000000000..e5f1afb995 --- /dev/null +++ b/internal/storage/schema/migrations/0032_drop_schema_migrations_applied_at.up.sql @@ -0,0 +1 @@ +ALTER TABLE schema_migrations DROP COLUMN applied_at; diff --git a/internal/storage/schema/schema.go b/internal/storage/schema/schema.go index fda59e60d2..21ae71fb54 100644 --- a/internal/storage/schema/schema.go +++ b/internal/storage/schema/schema.go @@ -106,6 +106,8 @@ func parseVersion(name string) (int, error) { // *sql.DB — the caller controls transaction boundaries. func MigrateUp(ctx context.Context, db DBConn) (int, error) { // Bootstrap the tracking table. + // Bootstrap with applied_at so migration 0032 can unconditionally drop it. + // After 0032 runs, the table has only the version column. if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( version INT PRIMARY KEY, applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP diff --git a/internal/storage/storage.go b/internal/storage/storage.go index f45c1f4198..ab8eb0d40a 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -84,6 +84,12 @@ type Storage interface { GetConfig(ctx context.Context, key string) (string, error) GetAllConfig(ctx context.Context) (map[string]string, error) + // Local metadata operations (dolt-ignored, clone-local state). + // Used for tip timestamps, version stamps, tracker sync cursors, etc. + // Data is ephemeral — callers must handle ("", nil) as the normal case. + SetLocalMetadata(ctx context.Context, key, value string) error + GetLocalMetadata(ctx context.Context, key string) (string, error) + // Transactions RunInTransaction(ctx context.Context, commitMsg string, fn func(tx Transaction) error) error @@ -265,6 +271,12 @@ type Transaction interface { SetMetadata(ctx context.Context, key, value string) error GetMetadata(ctx context.Context, key string) (string, error) + // Local metadata operations (dolt-ignored, clone-local state). + // Used for tip timestamps, version stamps, tracker sync cursors, etc. + // Data is ephemeral — callers must handle ("", nil) as the normal case. + SetLocalMetadata(ctx context.Context, key, value string) error + GetLocalMetadata(ctx context.Context, key string) (string, error) + // Comment operations AddComment(ctx context.Context, issueID, actor, comment string) error ImportIssueComment(ctx context.Context, issueID, author, text string, createdAt time.Time) (*types.Comment, error) diff --git a/internal/storage/versioncontrolops/remotes.go b/internal/storage/versioncontrolops/remotes.go index 91f0e7dd81..cbb965c010 100644 --- a/internal/storage/versioncontrolops/remotes.go +++ b/internal/storage/versioncontrolops/remotes.go @@ -36,8 +36,17 @@ func RemoveRemote(ctx context.Context, db DBConn, name string) error { } // Fetch fetches refs from a remote without merging. +// +// On failure, a best-effort GC is run to clean up any orphaned tmp_pack_* +// files that DOLT_FETCH may have left in the git-remote-cache. These files +// accumulate unboundedly across repeated failures and can consume hundreds of +// gigabytes over time. func Fetch(ctx context.Context, db DBConn, peer string) error { if _, err := db.ExecContext(ctx, "CALL DOLT_FETCH(?)", peer); err != nil { + // Best-effort: ignore GC errors — the original fetch error is what matters. + // DoltGC requires a non-transactional connection; if db is a tx it will + // fail silently here, which is acceptable. + _ = DoltGC(ctx, db) return fmt.Errorf("fetch from %s: %w", peer, err) } return nil diff --git a/internal/telemetry/storage.go b/internal/telemetry/storage.go index a27d22d9e6..a46bf659fa 100644 --- a/internal/telemetry/storage.go +++ b/internal/telemetry/storage.go @@ -411,6 +411,22 @@ func (s *InstrumentedStorage) GetAllConfig(ctx context.Context) (map[string]stri return v, err } +func (s *InstrumentedStorage) SetLocalMetadata(ctx context.Context, key, value string) error { + attrs := []attribute.KeyValue{attribute.String("bd.local_metadata.key", key)} + ctx, span, t := s.op(ctx, "SetLocalMetadata", attrs...) + err := s.inner.SetLocalMetadata(ctx, key, value) + s.done(ctx, span, t, err, attrs...) + return err +} + +func (s *InstrumentedStorage) GetLocalMetadata(ctx context.Context, key string) (string, error) { + attrs := []attribute.KeyValue{attribute.String("bd.local_metadata.key", key)} + ctx, span, t := s.op(ctx, "GetLocalMetadata", attrs...) + v, err := s.inner.GetLocalMetadata(ctx, key) + s.done(ctx, span, t, err, attrs...) + return v, err +} + // ── Transactions ───────────────────────────────────────────────────────────── func (s *InstrumentedStorage) RunInTransaction(ctx context.Context, commitMsg string, fn func(tx storage.Transaction) error) error { diff --git a/internal/tracker/engine.go b/internal/tracker/engine.go index 1f979530c5..71618e4cf0 100644 --- a/internal/tracker/engine.go +++ b/internal/tracker/engine.go @@ -188,7 +188,7 @@ func (e *Engine) Sync(ctx context.Context, opts SyncOptions) (*SyncResult, error if !opts.DryRun { lastSync := time.Now().UTC().Format(time.RFC3339Nano) key := e.Tracker.ConfigPrefix() + ".last_sync" - if err := e.Store.SetConfig(ctx, key, lastSync); err != nil { + if err := e.Store.SetLocalMetadata(ctx, key, lastSync); err != nil { e.warn("Failed to update last_sync: %v", err) } result.LastSync = lastSync @@ -208,7 +208,7 @@ func (e *Engine) DetectConflicts(ctx context.Context) ([]Conflict, error) { // Get last sync time key := e.Tracker.ConfigPrefix() + ".last_sync" - lastSyncStr, err := e.Store.GetConfig(ctx, key) + lastSyncStr, err := e.Store.GetLocalMetadata(ctx, key) if err != nil || lastSyncStr == "" { return nil, nil // No previous sync, no conflicts possible } @@ -280,7 +280,7 @@ func (e *Engine) doPull(ctx context.Context, opts SyncOptions, allowOverwriteIDs fetchOpts := FetchOptions{State: opts.State} var lastSync *time.Time key := e.Tracker.ConfigPrefix() + ".last_sync" - if lastSyncStr, err := e.Store.GetConfig(ctx, key); err == nil && lastSyncStr != "" { + if lastSyncStr, err := e.Store.GetLocalMetadata(ctx, key); err == nil && lastSyncStr != "" { if t, err := parseSyncTime(lastSyncStr); err == nil { fetchOpts.Since = &t lastSync = &t diff --git a/internal/tracker/engine_test.go b/internal/tracker/engine_test.go index b12d13d76e..886ad47462 100644 --- a/internal/tracker/engine_test.go +++ b/internal/tracker/engine_test.go @@ -1665,6 +1665,51 @@ func TestEnginePushWithParentFilterBasic(t *testing.T) { } } +func TestEnginePushWithParentFilterDoesNotUpdateOrphanExternalIssues(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + defer store.Close() + + parent := &types.Issue{ID: "bd-par-orphan", Title: "Parent", Status: types.StatusOpen, IssueType: types.TypeTask, Priority: 2} + child := &types.Issue{ID: "bd-child-orphan", Title: "Canceled upstream title", Status: types.StatusOpen, IssueType: types.TypeTask, Priority: 2} + for _, issue := range []*types.Issue{parent, child} { + if err := store.CreateIssue(ctx, issue, "test-actor"); err != nil { + t.Fatalf("CreateIssue(%s) error: %v", issue.ID, err) + } + } + dep := &types.Dependency{IssueID: "bd-child-orphan", DependsOnID: "bd-par-orphan", Type: types.DepParentChild} + if err := store.AddDependency(ctx, dep, "test-actor"); err != nil { + t.Fatalf("AddDependency error: %v", err) + } + + // Simulate an orphan external issue with an overlapping title. Current push + // must ignore it because no local Linear external_ref claims ownership. + tk := newMockTracker("linear") + tk.issues = []TrackerIssue{ + { + ID: "linear-1", + Identifier: "LIN-1", + Title: "Canceled upstream title", + UpdatedAt: time.Now().UTC(), + }, + } + + engine := NewEngine(tk, store, "test-actor") + result, err := engine.Sync(ctx, SyncOptions{Push: true, ParentID: "bd-par-orphan"}) + if err != nil { + t.Fatalf("Sync() error: %v", err) + } + if !result.Success { + t.Fatalf("Sync() not successful: %s", result.Error) + } + if len(tk.updated) != 0 { + t.Fatalf("updated %d external issues, want 0", len(tk.updated)) + } + if len(tk.created) != 2 { + t.Fatalf("created %d issues, want 2 (parent + child)", len(tk.created)) + } +} + func TestEnginePushWithParentFilterDeep(t *testing.T) { ctx := context.Background() store := newTestStore(t) diff --git a/issues.jsonl b/issues.jsonl new file mode 100644 index 0000000000..cf5da7554d --- /dev/null +++ b/issues.jsonl @@ -0,0 +1 @@ +{"id":"bd-main-idj","title":"Pattern-collapse pass: mechanical cruft inventory and reduction","description":"Quantify near-duplicate functions, dead code, single-call helpers, single-impl abstractions, redundant defensive checks, and orphaned config keys across the CLI. Execute easiest 10-20% mechanically, run tests, stage but do not merge.","status":"in_progress","priority":2,"issue_type":"chore","owner":"maphew@gmail.com","created_at":"2026-04-18T16:19:12Z","created_by":"matt wilkie","updated_at":"2026-04-18T16:30:16Z","started_at":"2026-04-18T16:30:16Z","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/npm-package/package.json b/npm-package/package.json index 312ca1a9a2..b521ab5d8d 100644 --- a/npm-package/package.json +++ b/npm-package/package.json @@ -1,6 +1,6 @@ { "name": "@beads/bd", - "version": "1.0.0", + "version": "1.0.2", "description": "Beads issue tracker - lightweight memory system for coding agents with native binary support", "main": "bin/bd.js", "bin": { @@ -26,13 +26,13 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/steveyegge/beads.git", + "url": "https://github.com/gastownhall/beads.git", "directory": "npm-package" }, "bugs": { - "url": "https://github.com/steveyegge/beads/issues" + "url": "https://github.com/gastownhall/beads/issues" }, - "homepage": "https://github.com/steveyegge/beads#readme", + "homepage": "https://github.com/gastownhall/beads#readme", "engines": { "node": ">=14.0.0" }, diff --git a/scripts/check-build-tags.sh b/scripts/check-build-tags.sh new file mode 100755 index 0000000000..7e03cabe72 --- /dev/null +++ b/scripts/check-build-tags.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# check-build-tags.sh — source-time guard for ICU regression. +# +# Scans tracked scripts, CI workflows, and git hooks. Fails when a +# `go build|test|run|generate|install` invocation neither: +# (a) carries -tags=...gms_pure_go itself, nor +# (b) appears in a file that sources .buildflags beforehand, nor +# (c) is an exempt third-party tool install (go install X@version). +# +# This is the source-time companion to scripts/verify-cgo.sh (which is a +# runtime check on release binaries). See docs/ICU-POLICY.md. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" + +# Candidate files: shell scripts, workflows, git hooks, the Makefile. +mapfile -t candidates < <( + git ls-files \ + '*.sh' \ + '.github/workflows/*.yml' \ + '.github/workflows/*.yaml' \ + '.github/scripts/*' \ + '.githooks/*' \ + 'Makefile' 2>/dev/null || true +) + +# Files that intentionally opt out of the policy. +opt_out_regex='^(scripts/test-cgo\.sh|scripts/test-icu-path\.sh|scripts/check-build-tags\.sh|examples/)' + +fail=0 +for f in "${candidates[@]}"; do + [[ -f "$f" ]] || continue + [[ "$f" =~ $opt_out_regex ]] && continue + + # Per-file opt-out marker for files that legitimately test the ICU path. + if head -n 5 "$f" | grep -q '^# build-tags: allow-bare'; then + continue + fi + + # Does the file source .buildflags before any `go` invocation? + # If so, GOFLAGS covers all bare `go` commands in the file. + sources_buildflags=no + if grep -Eq '(^|[[:space:]])(source|\.)[[:space:]]+[^#]*\.buildflags' "$f"; then + sources_buildflags=yes + fi + + # Does the file define a make/shell variable that carries the tag? + # e.g. `BUILD_TAGS := gms_pure_go` in the Makefile. If so, references + # like `-tags "$(BUILD_TAGS)"` count as tagged. + declare -a tag_vars=() + while IFS= read -r var; do + tag_vars+=("$var") + done < <(grep -E '^[[:space:]]*(export[[:space:]]+)?[A-Z_]+[[:space:]]*[:?+]?=[[:space:]]*["'"'"']*[^"'"'"']*gms_pure_go' "$f" \ + | sed -E 's/^[[:space:]]*(export[[:space:]]+)?([A-Z_]+).*/\2/' || true) + + while IFS= read -r hit; do + lineno="${hit%%:*}" + line="${hit#*:}" + + # Skip shell comments (allowing leading whitespace). + [[ "$line" =~ ^[[:space:]]*# ]] && continue + + stripped="$line" + + # Skip string literals that happen to mention `go `, e.g. + # log_error "go install failed" or echo "Run: go install ...". + # Heuristics: + # (a) `go ` immediately preceded by a quote (unlikely edge case) + # (b) line is a log/echo/printf call with a quoted argument. Any + # `go ` inside such a line is message text, not a command. + if [[ "$stripped" =~ [\"\']\ *go[[:space:]]+(build|test|run|install|generate) ]]; then + continue + fi + if [[ "$stripped" =~ (log_[a-z_]+|echo|printf)[[:space:]]+[\"\'] ]]; then + continue + fi + + verb="" + if [[ "$stripped" =~ (^|[^[:alnum:]_/.-])go[[:space:]]+(build|test|run|generate|install)($|[[:space:]]) ]]; then + verb="${BASH_REMATCH[2]}" + else + continue + fi + + # Allow third-party tool invocations pinned by version: + # `go install some/tool@version` + # `go run some/tool@version` + # These build their own module, not beads, so our tags don't apply. + if [[ "$verb" == "install" || "$verb" == "run" ]]; then + if [[ "$stripped" =~ @(latest|main|v[0-9]) ]]; then + continue + fi + fi + + # Allow if the tag is literal on this line. + if [[ "$stripped" == *gms_pure_go* ]]; then + continue + fi + + # Allow if the line references a file-defined variable that holds the tag. + matched_var=no + for v in "${tag_vars[@]}"; do + if [[ "$stripped" == *"\$($v)"* || \ + "$stripped" == *"\${$v}"* || \ + "$stripped" == *"\$$v"* ]]; then + matched_var=yes + break + fi + done + if [[ "$matched_var" == "yes" ]]; then + continue + fi + + # Allow if the file sources .buildflags. + if [[ "$sources_buildflags" == "yes" ]]; then + continue + fi + + printf 'error: %s:%s: bare `go %s` without -tags=gms_pure_go\n' "$f" "$lineno" "$verb" >&2 + printf ' %s\n' "$line" >&2 + fail=1 + done < <(grep -n -E '\bgo[[:space:]]+(build|test|run|generate|install)\b' "$f" 2>/dev/null || true) +done + +if [[ "$fail" -ne 0 ]]; then + cat >&2 <<'EOF' + +The beads project requires every `go build|test|run|generate|install` +invocation to build with -tags=gms_pure_go (see docs/ICU-POLICY.md). + +Fix by EITHER: + 1. Source .buildflags in the script (preferred, canonical): + # shellcheck source=../.buildflags + source "$PROJECT_ROOT/.buildflags" + 2. Pass -tags=gms_pure_go (or -tags=other,gms_pure_go) explicitly. + 3. Add a '# build-tags: allow-bare' marker in the top 5 lines of the + file if it intentionally exercises the ICU path. +EOF + exit 1 +fi + +echo "check-build-tags: ${#candidates[@]} file(s) scanned, all clear." diff --git a/scripts/cross-version-smoke-test.sh b/scripts/cross-version-smoke-test.sh index 8621cdeb25..7bb1653197 100755 --- a/scripts/cross-version-smoke-test.sh +++ b/scripts/cross-version-smoke-test.sh @@ -39,6 +39,10 @@ NC='\033[0m' SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +# Canonical build flags (GOFLAGS=-tags=gms_pure_go, CGO_ENABLED=1). +# shellcheck source=../.buildflags +source "$PROJECT_ROOT/.buildflags" + CACHE_DIR="${HOME}/.cache/beads-regression" mkdir -p "$CACHE_DIR" diff --git a/scripts/install.sh b/scripts/install.sh index 3083f7a6c7..59b5cc4607 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -465,49 +465,59 @@ verify_binary_has_cgo() { return 0 } -# Install using go install (fallback) +# Install using go install (fallback). +# +# Tries CGO_ENABLED=1 first for an embedded-capable binary. If that fails +# (host lacks C toolchain or transitive Dolt deps' headers), falls back to +# CGO_ENABLED=0 which yields a server-mode-only binary that still works on +# any Go-capable box. See docs/ICU-POLICY.md and docs/INSTALLING.md. install_with_go() { log_info "Installing bd using 'go install'..." + local gobin bin_dir + gobin=$(go env GOBIN 2>/dev/null || true) + if [ -n "$gobin" ]; then + bin_dir="$gobin" + else + bin_dir="$(go env GOPATH)/bin" + fi + if CGO_ENABLED=1 GOFLAGS="${GOFLAGS:+$GOFLAGS }-tags=gms_pure_go" go install github.com/gastownhall/beads/cmd/bd@latest; then - log_success "bd installed successfully via go install" - - # Record where we expect the binary to have been installed - # Prefer GOBIN if set, otherwise GOPATH/bin - local gobin - gobin=$(go env GOBIN 2>/dev/null || true) - if [ -n "$gobin" ]; then - bin_dir="$gobin" - else - bin_dir="$(go env GOPATH)/bin" - fi + log_success "bd installed via go install (embedded-capable)" LAST_INSTALL_PATH="$bin_dir/bd" if ! verify_binary_has_cgo "$LAST_INSTALL_PATH" "go install"; then return 1 fi + else + log_warning "go install with CGO failed; retrying without CGO (server-mode-only binary)" + if CGO_ENABLED=0 go install github.com/gastownhall/beads/cmd/bd@latest; then + log_success "bd installed via go install (CGO_ENABLED=0, server mode only)" + log_warning "This bd cannot use embedded Dolt. Run 'bd init --server' to use an external dolt sql-server, or reinstall with a C toolchain for embedded mode." + LAST_INSTALL_PATH="$bin_dir/bd" + else + log_error "go install failed both with and without CGO" + print_missing_build_deps_help + return 1 + fi + fi - # Optional local ad-hoc re-sign for macOS (off by default) - resign_for_macos "$bin_dir/bd" - - # Create 'beads' alias symlink - create_beads_alias "$bin_dir" + # Optional local ad-hoc re-sign for macOS (off by default) + resign_for_macos "$bin_dir/bd" - # Check if GOPATH/bin (or GOBIN) is in PATH - if [[ ":$PATH:" != *":$bin_dir:"* ]]; then - log_warning "$bin_dir is not in your PATH" - echo "" - echo "Add this to your shell profile (~/.bashrc, ~/.zshrc, etc.):" - echo " export PATH=\"\$PATH:$bin_dir\"" - echo "" - fi + # Create 'beads' alias symlink + create_beads_alias "$bin_dir" - return 0 - else - log_error "go install failed" - print_missing_build_deps_help - return 1 + # Check if GOPATH/bin (or GOBIN) is in PATH + if [[ ":$PATH:" != *":$bin_dir:"* ]]; then + log_warning "$bin_dir is not in your PATH" + echo "" + echo "Add this to your shell profile (~/.bashrc, ~/.zshrc, etc.):" + echo " export PATH=\"\$PATH:$bin_dir\"" + echo "" fi + + return 0 } # Build from source (last resort) diff --git a/scripts/test-cgo.sh b/scripts/test-cgo.sh index 541a0d6200..82525005a2 100755 --- a/scripts/test-cgo.sh +++ b/scripts/test-cgo.sh @@ -1,27 +1,9 @@ #!/usr/bin/env bash set -euo pipefail -# Run full CGO-enabled tests with platform-specific prerequisites. -# Use this instead of raw `CGO_ENABLED=1 go test ...` on macOS. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -export CGO_ENABLED=1 +echo "WARNING: scripts/test-cgo.sh is deprecated." >&2 +echo "WARNING: Use $SCRIPT_DIR/test-icu-path.sh for the explicit ICU-only path, or ./scripts/test.sh for normal validation." >&2 -if [[ "$(uname)" == "Darwin" ]]; then - ICU_PREFIX="$(brew --prefix icu4c 2>/dev/null || true)" - if [[ -z "$ICU_PREFIX" ]]; then - echo "ERROR: Homebrew icu4c not found." >&2 - echo "Install it with: brew install icu4c" >&2 - exit 1 - fi - - export CGO_CFLAGS="${CGO_CFLAGS:+$CGO_CFLAGS }-I${ICU_PREFIX}/include" - export CGO_CPPFLAGS="${CGO_CPPFLAGS:+$CGO_CPPFLAGS }-I${ICU_PREFIX}/include" - export CGO_LDFLAGS="${CGO_LDFLAGS:+$CGO_LDFLAGS }-L${ICU_PREFIX}/lib -Wl,-rpath,${ICU_PREFIX}/lib" -fi - -if [[ $# -eq 0 ]]; then - set -- ./... -fi - -echo "Running CGO tests: go test $*" >&2 -go test "$@" +exec "$SCRIPT_DIR/test-icu-path.sh" "$@" diff --git a/scripts/test-icu-path.sh b/scripts/test-icu-path.sh new file mode 100755 index 0000000000..4602f12e1e --- /dev/null +++ b/scripts/test-icu-path.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run the opt-in ICU regex path tests. +# This is intentionally NOT the normal validation path for beads. The shipped +# and CI-tested configuration uses -tags gms_pure_go instead. + +echo "WARNING: scripts/test-icu-path.sh intentionally exercises the ICU-only regex path." >&2 +echo "WARNING: This is maintainer-only and not part of normal validation; use ./scripts/test.sh or make test for the shipped path." >&2 + +export CGO_ENABLED=1 + +if [[ "$(uname)" == "Darwin" ]]; then + ICU_PREFIX="$(brew --prefix icu4c 2>/dev/null || true)" + if [[ -z "$ICU_PREFIX" ]]; then + echo "ERROR: Homebrew icu4c not found." >&2 + echo "Install it with: brew install icu4c" >&2 + exit 1 + fi + + export CGO_CFLAGS="${CGO_CFLAGS:+$CGO_CFLAGS }-I${ICU_PREFIX}/include" + export CGO_CPPFLAGS="${CGO_CPPFLAGS:+$CGO_CPPFLAGS }-I${ICU_PREFIX}/include" + export CGO_LDFLAGS="${CGO_LDFLAGS:+$CGO_LDFLAGS }-L${ICU_PREFIX}/lib -Wl,-rpath,${ICU_PREFIX}/lib" +fi + +if [[ $# -eq 0 ]]; then + set -- ./... +fi + +echo "Running ICU regex path tests: go test $*" >&2 +go test "$@" diff --git a/scripts/test.sh b/scripts/test.sh index 864a851304..dfc5ed71aa 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -7,9 +7,10 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" SKIP_FILE="$REPO_ROOT/.test-skip" -# Default test path uses the same pure-Go regex build as user-facing binaries. -# ICU coverage remains available via scripts/test-cgo.sh. -export GOFLAGS="${GOFLAGS:+$GOFLAGS }-tags=gms_pure_go" +# Canonical build flags (GOFLAGS=-tags=gms_pure_go, CGO_ENABLED=1). +# Opt-in ICU-path coverage remains available via scripts/test-icu-path.sh. +# shellcheck source=../.buildflags +source "$REPO_ROOT/.buildflags" # Build skip pattern from .test-skip file build_skip_pattern() { diff --git a/scripts/upgrade-smoke-test.sh b/scripts/upgrade-smoke-test.sh index c7543140f5..15977c3636 100755 --- a/scripts/upgrade-smoke-test.sh +++ b/scripts/upgrade-smoke-test.sh @@ -38,6 +38,10 @@ NC='\033[0m' SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +# Canonical build flags (GOFLAGS=-tags=gms_pure_go, CGO_ENABLED=1). +# shellcheck source=../.buildflags +source "$PROJECT_ROOT/.buildflags" + # --------------------------------------------------------------------------- # Multi-version mode: SMOKE_VERSIONS overrides single-version argument # ---------------------------------------------------------------------------