diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 0000000..363ebae --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,51 @@ +# Dolt database (managed by Dolt, not git) +dolt/ +dolt-access.lock + +# Runtime files +bd.sock +bd.sock.startlock +sync-state.json +last-touched + +# Local version tracking (prevents upgrade notification spam after git ops) +.local_version + +# Worktree redirect file (contains relative path to main repo's .beads/) +# Must not be committed as paths would be wrong in other clones +redirect + +# Sync state (local-only, per-machine) +# These files are machine-specific and should not be shared across clones +.sync.lock +export-state/ + +# Ephemeral store (SQLite - wisps/molecules, intentionally not versioned) +ephemeral.sqlite3 +ephemeral.sqlite3-journal +ephemeral.sqlite3-wal +ephemeral.sqlite3-shm + +# Dolt server management (auto-started by bd) +dolt-server.pid +dolt-server.log +dolt-server.lock +dolt-server.port +dolt-server.activity +dolt-monitor.pid + +# Backup data (auto-exported JSONL, local-only) +backup/ + +# Legacy files (from pre-Dolt versions) +*.db +*.db?* +*.db-journal +*.db-wal +*.db-shm +db.sqlite +bd.db +# NOTE: Do NOT add negation patterns here. +# They would override fork protection in .git/info/exclude. +# Config files (metadata.json, config.yaml) are tracked by git by default +# since no pattern above ignores them. diff --git a/.beads/README.md b/.beads/README.md new file mode 100644 index 0000000..dbfe363 --- /dev/null +++ b/.beads/README.md @@ -0,0 +1,81 @@ +# Beads - AI-Native Issue Tracking + +Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. + +## What is Beads? + +Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. + +**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) + +## Quick Start + +### Essential Commands + +```bash +# Create new issues +bd create "Add user authentication" + +# View all issues +bd list + +# View issue details +bd show + +# Update issue status +bd update --claim +bd update --status done + +# Sync with Dolt remote +bd dolt push +``` + +### Working with Issues + +Issues in Beads are: +- **Git-native**: Stored in Dolt database with version control and branching +- **AI-friendly**: CLI-first design works perfectly with AI coding agents +- **Branch-aware**: Issues can follow your branch workflow +- **Always in sync**: Auto-syncs with your commits + +## Why Beads? + +✨ **AI-Native Design** +- Built specifically for AI-assisted development workflows +- CLI-first interface works seamlessly with AI coding agents +- No context switching to web UIs + +🚀 **Developer Focused** +- Issues live in your repo, right next to your code +- Works offline, syncs when you push +- Fast, lightweight, and stays out of your way + +🔧 **Git Integration** +- Automatic sync with git commits +- Branch-aware issue tracking +- Dolt-native three-way merge resolution + +## Get Started with Beads + +Try Beads in your own projects: + +```bash +# Install Beads +curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash + +# Initialize in your repo +bd init + +# Create your first issue +bd create "Try out Beads" +``` + +## Learn More + +- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) +- **Quick Start Guide**: Run `bd quickstart` +- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) + +--- + +*Beads: Issue tracking that moves at the speed of thought* ⚡ diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 0000000..e831a6b --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,54 @@ +# Beads Configuration File +# This file configures default behavior for all bd commands in this repository +# All settings can also be set via environment variables (BD_* prefix) +# or overridden with command-line flags + +# Issue prefix for this repository (used by bd init) +# If not set, bd init will auto-detect from directory name +# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. +# issue-prefix: "" + +# Use no-db mode: JSONL-only, no Dolt database +# When true, bd will use .beads/issues.jsonl as the source of truth +# no-db: false + +# Enable JSON output by default +# json: false + +# Feedback title formatting for mutating commands (create/update/close/dep/edit) +# 0 = hide titles, N > 0 = truncate to N characters +# output: +# title-length: 255 + +# Default actor for audit trails (overridden by BD_ACTOR or --actor) +# actor: "" + +# Export events (audit trail) to .beads/events.jsonl on each flush/sync +# When enabled, new events are appended incrementally using a high-water mark. +# Use 'bd export --events' to trigger manually regardless of this setting. +# events-export: false + +# Multi-repo configuration (experimental - bd-307) +# Allows hydrating from multiple repositories and routing writes to the correct database +# repos: +# primary: "." # Primary repo (where this database lives) +# additional: # Additional repos to hydrate from (read-only) +# - ~/beads-planning # Personal planning repo +# - ~/work-planning # Work planning repo + +# JSONL backup (periodic export for off-machine recovery) +# Auto-enabled when a git remote exists. Override explicitly: +# backup: +# enabled: false # Disable auto-backup entirely +# interval: 15m # Minimum time between auto-exports +# git-push: false # Disable git push (export locally only) +# git-repo: "" # Separate git repo for backups (default: project repo) + +# Integration settings (access with 'bd config get/set') +# These are stored in the database, not in this file: +# - jira.url +# - jira.project +# - linear.url +# - linear.api-key +# - github.org +# - github.repo diff --git a/.beads/dolt-monitor.pid.lock b/.beads/dolt-monitor.pid.lock new file mode 100644 index 0000000..e69de29 diff --git a/.beads/hooks/post-checkout b/.beads/hooks/post-checkout new file mode 100755 index 0000000..05cfb03 --- /dev/null +++ b/.beads/hooks/post-checkout @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v0.59.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + bd hooks run post-checkout "$@" + _bd_exit=$?; if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v0.59.0 --- diff --git a/.beads/hooks/post-merge b/.beads/hooks/post-merge new file mode 100755 index 0000000..88a5d7d --- /dev/null +++ b/.beads/hooks/post-merge @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v0.59.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + bd hooks run post-merge "$@" + _bd_exit=$?; if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v0.59.0 --- diff --git a/.beads/hooks/pre-commit b/.beads/hooks/pre-commit new file mode 100755 index 0000000..717ab65 --- /dev/null +++ b/.beads/hooks/pre-commit @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v0.59.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + bd hooks run pre-commit "$@" + _bd_exit=$?; if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v0.59.0 --- diff --git a/.beads/hooks/pre-push b/.beads/hooks/pre-push new file mode 100755 index 0000000..73a833c --- /dev/null +++ b/.beads/hooks/pre-push @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v0.59.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + bd hooks run pre-push "$@" + _bd_exit=$?; if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v0.59.0 --- diff --git a/.beads/hooks/prepare-commit-msg b/.beads/hooks/prepare-commit-msg new file mode 100755 index 0000000..9c82006 --- /dev/null +++ b/.beads/hooks/prepare-commit-msg @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v0.59.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + bd hooks run prepare-commit-msg "$@" + _bd_exit=$?; if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v0.59.0 --- diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 0000000..a4520c9 --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,7 @@ +{ + "database": "dolt", + "backend": "dolt", + "dolt_mode": "server", + "dolt_database": "ds", + "project_id": "83aca01a-ab4d-4513-ab0e-3962b218eeda" +} diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..ba0b2f0 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,26 @@ +{ + "hooks": { + "PreCompact": [ + { + "hooks": [ + { + "command": "bd prime", + "type": "command" + } + ], + "matcher": "" + } + ], + "SessionStart": [ + { + "hooks": [ + { + "command": "bd prime", + "type": "command" + } + ], + "matcher": "" + } + ] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d118cce..70e795c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,4 +18,10 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm checks - run: pnpm typecheck - - run: pnpm test + - run: pnpm test:coverage + - uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: coverage/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 4597e9d..bb6bf32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ node_modules dist +coverage .git/dubstack .worktrees + +# Dolt database files (added by bd init) +.dolt/ +*.db diff --git a/AGENTS.md b/AGENTS.md index eb40d12..d22b6e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,3 +101,116 @@ When implementing a task: When reviewing code: - Prioritize regressions in stack state handling, git command safety, submit flow, and conflict/recovery paths. + + +## Issue Tracking with bd (beads) + +**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods. + +### Why bd? + +- Dependency-aware: Track blockers and relationships between issues +- Git-friendly: Dolt-powered version control with native sync +- Agent-optimized: JSON output, ready work detection, discovered-from links +- Prevents duplicate tracking systems and confusion + +### Quick Start + +**Check for ready work:** + +```bash +bd ready --json +``` + +**Create new issues:** + +```bash +bd create "Issue title" --description="Detailed context" -t bug|feature|task -p 0-4 --json +bd create "Issue title" --description="What this issue is about" -p 1 --deps discovered-from:bd-123 --json +``` + +**Claim and update:** + +```bash +bd update --claim --json +bd update bd-42 --priority 1 --json +``` + +**Complete work:** + +```bash +bd close bd-42 --reason "Completed" --json +``` + +### Issue Types + +- `bug` - Something broken +- `feature` - New functionality +- `task` - Work item (tests, docs, refactoring) +- `epic` - Large feature with subtasks +- `chore` - Maintenance (dependencies, tooling) + +### Priorities + +- `0` - Critical (security, data loss, broken builds) +- `1` - High (major features, important bugs) +- `2` - Medium (default, nice-to-have) +- `3` - Low (polish, optimization) +- `4` - Backlog (future ideas) + +### Workflow for AI Agents + +1. **Check ready work**: `bd ready` shows unblocked issues +2. **Claim your task atomically**: `bd update --claim` +3. **Work on it**: Implement, test, document +4. **Discover new work?** Create linked issue: + - `bd create "Found bug" --description="Details about what was found" -p 1 --deps discovered-from:` +5. **Complete**: `bd close --reason "Done"` + +### Auto-Sync + +bd automatically syncs via Dolt: + +- Each write auto-commits to Dolt history +- Use `bd dolt push`/`bd dolt pull` for remote sync +- No manual export/import needed! + +### Important Rules + +- ✅ Use bd for ALL task tracking +- ✅ Always use `--json` flag for programmatic use +- ✅ Link discovered work with `discovered-from` dependencies +- ✅ Check `bd ready` before asking "what should I work on?" +- ❌ Do NOT create markdown TODO lists +- ❌ Do NOT use external issue trackers +- ❌ Do NOT duplicate tracking systems + +For more details, see README.md and docs/QUICKSTART.md. + +## Landing the Plane (Session Completion) + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd sync + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/package.json b/package.json index 19bae9b..a65511a 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "prepack": "pnpm build", "dev": "tsx src/index.ts", "test": "vitest run", + "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit", "checks": "biome check .", "checks:fix": "biome check --write .", @@ -50,6 +51,7 @@ "devDependencies": { "@biomejs/biome": "^2.4.2", "@types/node": "^25.2.3", + "@vitest/coverage-v8": "^4.0.18", "tsup": "^8.5.1", "tsx": "^4.21.0", "typescript": "^5.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4aa07c4..091e340 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@types/node': specifier: ^25.2.3 version: 25.2.3 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2)) tsup: specifier: ^8.5.1 version: 8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) @@ -76,6 +79,27 @@ packages: resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} engines: {node: '>=18'} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@biomejs/biome@2.4.2': resolution: {integrity: sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w==} engines: {node: '>=14.21.3'} @@ -534,6 +558,15 @@ packages: resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} engines: {node: '>= 20'} + '@vitest/coverage-v8@4.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + peerDependencies: + '@vitest/browser': 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} @@ -588,6 +621,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + balanced-match@4.0.3: resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} engines: {node: 20 || >=22} @@ -815,6 +851,13 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} @@ -863,10 +906,25 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -896,6 +954,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1173,6 +1238,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} @@ -1412,6 +1481,21 @@ snapshots: dependencies: json-schema: 0.4.0 + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + '@biomejs/biome@2.4.2': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.4.2 @@ -1696,6 +1780,20 @@ snapshots: '@vercel/oidc@3.1.0': {} + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.12 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -1755,6 +1853,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + balanced-match@4.0.3: {} base64-js@1.5.1: @@ -2002,6 +2106,10 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + human-signals@8.0.1: {} ieee754@1.2.1: {} @@ -2032,8 +2140,23 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + joycon@3.1.1: {} + js-tokens@10.0.0: {} + js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -2078,6 +2201,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + merge2@1.4.1: {} micromatch@4.0.8: @@ -2294,8 +2427,7 @@ snapshots: extend-shallow: 2.0.1 kind-of: 6.0.3 - semver@7.7.4: - optional: true + semver@7.7.4: {} shebang-command@2.0.0: dependencies: @@ -2361,6 +2493,10 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + tar-fs@2.1.4: dependencies: chownr: 1.1.4 diff --git a/src/commands/create.test.ts b/src/commands/create.test.ts index d1ff474..7213c32 100644 --- a/src/commands/create.test.ts +++ b/src/commands/create.test.ts @@ -3,7 +3,7 @@ import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createTestRepo, gitInRepo } from '../../test/helpers'; import { writeConfig } from '../lib/config'; -import { getCurrentBranch } from '../lib/git'; +import { getBranchTip, getCurrentBranch } from '../lib/git'; import { readState } from '../lib/state'; import { readUndoEntry } from '../lib/undo-log'; import { create } from './create'; @@ -96,6 +96,15 @@ describe('create', () => { expect(entry.createdBranches).toEqual(['feat/first']); expect(entry.previousState.stacks).toHaveLength(0); }); + + it('sets parent_revision to parent tip SHA on creation', async () => { + const parentTip = await getBranchTip('main', dir); + await create('feat/first', dir); + + const state = await readState(dir); + const child = state.stacks[0].branches.find((b) => b.name === 'feat/first'); + expect(child?.parent_revision).toBe(parentTip); + }); }); describe('create with -m', () => { diff --git a/src/commands/create.ts b/src/commands/create.ts index e9e7b9a..2a7fa9f 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -7,6 +7,7 @@ import { branchExists, commitStaged, createBranch, + getBranchTip, getCurrentBranch, getDiff, hasStagedChanges, @@ -165,8 +166,9 @@ export async function create( cwd, ); + const parentRevision = await getBranchTip(parent, cwd); await createBranch(branchName, cwd); - addBranchToStack(state, branchName, parent); + addBranchToStack(state, branchName, parent, parentRevision); await writeState(state, cwd); if (commitMessage) { diff --git a/src/commands/post-merge.test.ts b/src/commands/post-merge.test.ts index edf5567..9f382f7 100644 --- a/src/commands/post-merge.test.ts +++ b/src/commands/post-merge.test.ts @@ -134,6 +134,50 @@ describe('postMerge', () => { expect(featB?.parent).toBe('main'); }); + it('preserves parent_revision on reparented children', async () => { + const stateWithRevision: DubState = { + stacks: [ + { + id: 'stack-1', + branches: [ + { + name: 'main', + type: 'root', + parent: null, + pr_number: null, + pr_link: null, + }, + { + name: 'feat/a', + parent: 'main', + pr_number: 1, + pr_link: 'https://x/1', + }, + { + name: 'feat/b', + parent: 'feat/a', + parent_revision: 'a-tip-sha-original', + pr_number: 2, + pr_link: 'https://x/2', + }, + ], + }, + ], + }; + mockReadState.mockResolvedValue(stateWithRevision); + + const result = await postMerge('/repo', { + restack: false, + submit: false, + }); + + expect(result.cleaned).toEqual(['feat/a']); + const saved = mockWriteState.mock.calls[0][0] as DubState; + const featB = saved.stacks[0].branches.find((b) => b.name === 'feat/b'); + expect(featB?.parent).toBe('main'); + expect(featB?.parent_revision).toBe('a-tip-sha-original'); + }); + it('supports dry-run without mutating state', async () => { const result = await postMerge('/repo', { dryRun: true, diff --git a/src/commands/restack.test.ts b/src/commands/restack.test.ts index bd9159b..446aa2a 100644 --- a/src/commands/restack.test.ts +++ b/src/commands/restack.test.ts @@ -3,6 +3,7 @@ import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createTestRepo, gitInRepo } from '../../test/helpers'; import { getBranchTip, getCurrentBranch } from '../lib/git'; +import { readState, writeState } from '../lib/state'; import { readUndoEntry } from '../lib/undo-log'; import { create } from './create'; import { init } from './init'; @@ -157,6 +158,121 @@ describe('restack', () => { expect(await getCurrentBranch(dir)).toBe('feat/a'); }); + it('uses parent_revision as parentOldTip when set', async () => { + // create stores parent_revision automatically + await create('feat/a', dir); + fs.writeFileSync(path.join(dir, 'feat.txt'), 'feat'); + await gitInRepo(dir, ['add', '.']); + await gitInRepo(dir, ['commit', '-m', 'feat-commit']); + + // Record the parent_revision stored by create + const state = await readState(dir); + const branch = state.stacks[0].branches.find((b) => b.name === 'feat/a'); + expect(branch?.parent_revision).toBeTruthy(); + + // Add commit to main so restack has work to do + await gitInRepo(dir, ['checkout', 'main']); + fs.writeFileSync(path.join(dir, 'base.txt'), 'base'); + await gitInRepo(dir, ['add', '.']); + await gitInRepo(dir, ['commit', '-m', 'base-commit']); + + await gitInRepo(dir, ['checkout', 'feat/a']); + const result = await restack(dir); + + expect(result.status).toBe('success'); + expect(result.rebased).toContain('feat/a'); + expect(fs.existsSync(path.join(dir, 'base.txt'))).toBe(true); + expect(fs.existsSync(path.join(dir, 'feat.txt'))).toBe(true); + }); + + it('falls back to getMergeBase when parent_revision is absent', async () => { + await create('feat/a', dir); + fs.writeFileSync(path.join(dir, 'feat.txt'), 'feat'); + await gitInRepo(dir, ['add', '.']); + await gitInRepo(dir, ['commit', '-m', 'feat-commit']); + + // Clear parent_revision from state to force getMergeBase fallback + const state = await readState(dir); + const branch = state.stacks[0].branches.find((b) => b.name === 'feat/a'); + if (branch) { + branch.parent_revision = null; + } + await writeState(state, dir); + + // Add commit to main + await gitInRepo(dir, ['checkout', 'main']); + fs.writeFileSync(path.join(dir, 'base.txt'), 'base'); + await gitInRepo(dir, ['add', '.']); + await gitInRepo(dir, ['commit', '-m', 'base-commit']); + + await gitInRepo(dir, ['checkout', 'feat/a']); + const result = await restack(dir); + + expect(result.status).toBe('success'); + expect(result.rebased).toContain('feat/a'); + expect(fs.existsSync(path.join(dir, 'base.txt'))).toBe(true); + expect(fs.existsSync(path.join(dir, 'feat.txt'))).toBe(true); + }); + + it('updates parent_revision in state after successful restack', async () => { + await create('feat/a', dir); + fs.writeFileSync(path.join(dir, 'feat.txt'), 'feat'); + await gitInRepo(dir, ['add', '.']); + await gitInRepo(dir, ['commit', '-m', 'feat-commit']); + + // Add commit to main so restack has work to do + await gitInRepo(dir, ['checkout', 'main']); + fs.writeFileSync(path.join(dir, 'base.txt'), 'base'); + await gitInRepo(dir, ['add', '.']); + await gitInRepo(dir, ['commit', '-m', 'base-commit']); + + const mainTipAfterCommit = await getBranchTip('main', dir); + + await gitInRepo(dir, ['checkout', 'feat/a']); + const result = await restack(dir); + expect(result.status).toBe('success'); + + // parent_revision should now be main's new tip + const state = await readState(dir); + const branch = state.stacks[0].branches.find((b) => b.name === 'feat/a'); + expect(branch?.parent_revision).toBe(mainTipAfterCommit); + }); + + it('updates parent_revision for each branch in a chain after restack', async () => { + // Create main → feat/a → feat/b + await create('feat/a', dir); + fs.writeFileSync(path.join(dir, 'a.txt'), 'a'); + await gitInRepo(dir, ['add', '.']); + await gitInRepo(dir, ['commit', '-m', 'a-commit']); + + await create('feat/b', dir); + fs.writeFileSync(path.join(dir, 'b.txt'), 'b'); + await gitInRepo(dir, ['add', '.']); + await gitInRepo(dir, ['commit', '-m', 'b-commit']); + + // Add commit to main + await gitInRepo(dir, ['checkout', 'main']); + fs.writeFileSync(path.join(dir, 'main.txt'), 'main'); + await gitInRepo(dir, ['add', '.']); + await gitInRepo(dir, ['commit', '-m', 'main-commit']); + + const mainTip = await getBranchTip('main', dir); + + await gitInRepo(dir, ['checkout', 'feat/b']); + const result = await restack(dir); + expect(result.status).toBe('success'); + + const state = await readState(dir); + const branchA = state.stacks[0].branches.find((b) => b.name === 'feat/a'); + const branchB = state.stacks[0].branches.find((b) => b.name === 'feat/b'); + + // feat/a's parent_revision should be main's new tip + expect(branchA?.parent_revision).toBe(mainTip); + // feat/b's parent_revision should be feat/a's new tip (after rebase) + const featATip = await getBranchTip('feat/a', dir); + expect(branchB?.parent_revision).toBe(featATip); + }); + it('restacks all stacks when on root branch', async () => { // Create two separate stacks from main await create('feat/a', dir); @@ -185,6 +301,130 @@ describe('restack', () => { }); }); +describe('squash-merge-then-restack', () => { + it('produces no false conflicts after squash-merge', async () => { + // Create main → feat/a with a commit on file-a.txt + await create('feat/a', dir); + fs.writeFileSync(path.join(dir, 'file-a.txt'), 'feature a content'); + await gitInRepo(dir, ['add', '.']); + await gitInRepo(dir, ['commit', '-m', 'add file-a']); + + // Create feat/a → feat/b with a commit on file-b.txt (different file) + await create('feat/b', dir); + fs.writeFileSync(path.join(dir, 'file-b.txt'), 'feature b content'); + await gitInRepo(dir, ['add', '.']); + await gitInRepo(dir, ['commit', '-m', 'add file-b']); + + // Simulate squash-merge of feat/a into main + await gitInRepo(dir, ['checkout', 'main']); + await gitInRepo(dir, ['merge', '--squash', 'feat/a']); + await gitInRepo(dir, ['commit', '-m', 'squash A']); + await gitInRepo(dir, ['branch', '-D', 'feat/a']); + + // Post-merge cleanup: remove feat/a from state, reparenting feat/b to main + const state = await readState(dir); + const stack = state.stacks[0]; + const deletedBranch = stack.branches.find((b) => b.name === 'feat/a'); + expect(deletedBranch).toBeTruthy(); + const newParent = deletedBranch?.parent ?? null; + for (const branch of stack.branches) { + if (branch.parent === 'feat/a') { + branch.parent = newParent; + } + } + stack.branches = stack.branches.filter((b) => b.name !== 'feat/a'); + await writeState(state, dir); + + // Restack from main (feat/b is now a child of main) + const result = await restack(dir); + + expect(result.status).toBe('success'); + expect(result.rebased).toContain('feat/b'); + + // Verify feat/b only has its own commit(s) on top of main + const logOutput = ( + await gitInRepo(dir, ['log', '--oneline', 'main..feat/b']) + ).stdout.trim(); + const lines = logOutput.split('\n').filter((l) => l.length > 0); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('add file-b'); + + // Verify feat/b has both files (file-a from squash on main, file-b from its own commit) + await gitInRepo(dir, ['checkout', 'feat/b']); + expect(fs.existsSync(path.join(dir, 'file-a.txt'))).toBe(true); + expect(fs.existsSync(path.join(dir, 'file-b.txt'))).toBe(true); + }); + + it('backward compat — restack works when parent_revision is absent', async () => { + // Create main → feat/a → feat/b + await create('feat/a', dir); + fs.writeFileSync(path.join(dir, 'file-a.txt'), 'feature a content'); + await gitInRepo(dir, ['add', '.']); + await gitInRepo(dir, ['commit', '-m', 'add file-a']); + + await create('feat/b', dir); + fs.writeFileSync(path.join(dir, 'file-b.txt'), 'feature b content'); + await gitInRepo(dir, ['add', '.']); + await gitInRepo(dir, ['commit', '-m', 'add file-b']); + + // Simulate squash-merge of feat/a into main + await gitInRepo(dir, ['checkout', 'main']); + await gitInRepo(dir, ['merge', '--squash', 'feat/a']); + await gitInRepo(dir, ['commit', '-m', 'squash A']); + await gitInRepo(dir, ['branch', '-D', 'feat/a']); + + // Remove feat/a from state, reparent feat/b to main + const state = await readState(dir); + const stack = state.stacks[0]; + const deletedBranch = stack.branches.find((b) => b.name === 'feat/a'); + const newParent = deletedBranch?.parent ?? null; + for (const branch of stack.branches) { + if (branch.parent === 'feat/a') { + branch.parent = newParent; + } + } + stack.branches = stack.branches.filter((b) => b.name !== 'feat/a'); + + // Clear parent_revision to test backward compat fallback + const featB = stack.branches.find((b) => b.name === 'feat/b'); + if (featB) { + featB.parent_revision = undefined; + } + await writeState(state, dir); + + // Restack should not crash (falls back to getMergeBase) + await expect(restack(dir)).resolves.not.toThrow(); + }); + + it('normal restack updates parent_revision after rebasing', async () => { + // Create main → feat/a with parent_revision set + await create('feat/a', dir); + fs.writeFileSync(path.join(dir, 'feat.txt'), 'feat'); + await gitInRepo(dir, ['add', '.']); + await gitInRepo(dir, ['commit', '-m', 'feat-commit']); + + // Add new commit to main + await gitInRepo(dir, ['checkout', 'main']); + fs.writeFileSync(path.join(dir, 'main-new.txt'), 'new main content'); + await gitInRepo(dir, ['add', '.']); + await gitInRepo(dir, ['commit', '-m', 'main-new-commit']); + + const mainNewTip = await getBranchTip('main', dir); + + // Restack + await gitInRepo(dir, ['checkout', 'feat/a']); + const result = await restack(dir); + + expect(result.status).toBe('success'); + expect(result.rebased).toContain('feat/a'); + + // parent_revision should be updated to main's new tip + const state = await readState(dir); + const branch = state.stacks[0].branches.find((b) => b.name === 'feat/a'); + expect(branch?.parent_revision).toBe(mainNewTip); + }); +}); + describe('restackContinue', () => { it('throws when no restack is in progress', async () => { await expect(restackContinue(dir)).rejects.toThrow( diff --git a/src/commands/restack.ts b/src/commands/restack.ts index 2a2dbe5..6a51d91 100644 --- a/src/commands/restack.ts +++ b/src/commands/restack.ts @@ -16,6 +16,7 @@ import { readState, type Stack, topologicalOrder, + writeState, } from '../lib/state'; import { saveUndoEntry } from '../lib/undo-log'; @@ -23,6 +24,7 @@ interface RestackStep { branch: string; parent: string; parentOldTip: string; + parentNewTip?: string; status: 'pending' | 'done' | 'skipped' | 'conflicted'; } @@ -131,6 +133,12 @@ export async function restackContinue(cwd: string): Promise { const conflictedStep = progress.steps.find((s) => s.status === 'conflicted'); if (conflictedStep) { conflictedStep.status = 'done'; + const state = await readState(cwd); + const parentNewTip = + conflictedStep.parentNewTip ?? + (await getBranchTip(conflictedStep.parent, cwd)); + updateParentRevision(state, conflictedStep.branch, parentNewTip); + await writeState(state, cwd); } return executeRestackSteps(progress, cwd); @@ -141,6 +149,7 @@ async function executeRestackSteps( cwd: string, ): Promise { const rebased: string[] = []; + const state = await readState(cwd); for (const step of progress.steps) { if (step.status !== 'pending') { @@ -159,10 +168,12 @@ async function executeRestackSteps( await rebaseOnto(parentNewTip, step.parentOldTip, step.branch, cwd); step.status = 'done'; rebased.push(step.branch); + updateParentRevision(state, step.branch, parentNewTip); await writeProgress(progress, cwd); } catch (error) { if (error instanceof DubError && error.message.includes('Conflict')) { step.status = 'conflicted'; + step.parentNewTip = parentNewTip; await writeProgress(progress, cwd); return { status: 'conflict', rebased, conflictBranch: step.branch }; } @@ -170,6 +181,7 @@ async function executeRestackSteps( } } + await writeState(state, cwd); await clearProgress(cwd); await checkoutBranch(progress.originalBranch, cwd); @@ -182,6 +194,20 @@ async function executeRestackSteps( }; } +function updateParentRevision( + state: { stacks: Stack[] }, + branchName: string, + parentRevision: string, +): void { + for (const stack of state.stacks) { + const branch = stack.branches.find((b) => b.name === branchName); + if (branch) { + branch.parent_revision = parentRevision; + return; + } + } +} + function getTargetStacks(stacks: Stack[], currentBranch: string): Stack[] { // If current branch is a root of any stacks, restack all of them const rootStacks = stacks.filter((s) => @@ -206,11 +232,13 @@ async function buildRestackSteps( const ordered = topologicalOrder(stack); for (const branch of ordered) { if (branch.type === 'root' || !branch.parent) continue; - const mergeBase = await getMergeBase(branch.parent, branch.name, cwd); + const parentOldTip = + branch.parent_revision ?? + (await getMergeBase(branch.parent, branch.name, cwd)); steps.push({ branch: branch.name, parent: branch.parent, - parentOldTip: mergeBase, + parentOldTip, status: 'pending', }); } diff --git a/src/commands/submit.test.ts b/src/commands/submit.test.ts index 2569271..bacf285 100644 --- a/src/commands/submit.test.ts +++ b/src/commands/submit.test.ts @@ -56,7 +56,12 @@ const mockReadState = readState as ReturnType; const mockWriteState = writeState as ReturnType; function makeState( - branches: { name: string; parent: string | null; type?: 'root' }[], + branches: { + name: string; + parent: string | null; + type?: 'root'; + parent_revision?: string; + }[], ): DubState { return { stacks: [ @@ -264,4 +269,54 @@ describe('submit', () => { expect(featBranch?.sync_source).toBe('submit'); expect(featBranch?.last_synced_at).toBeTruthy(); }); + + it('sets parent_revision to base SHA on submit when not already set', async () => { + const state = makeState([ + { name: 'main', parent: null, type: 'root' }, + { name: 'feat/a', parent: 'main' }, + ]); + mockGetCurrentBranch.mockResolvedValue('feat/a'); + mockReadState.mockResolvedValue(state); + mockGetPr.mockResolvedValue({ + number: 10, + url: 'https://github.com/o/r/pull/10', + title: 'feat: thing', + body: '', + }); + + await submit('/repo', false); + + const savedState = mockWriteState.mock.calls[0][0] as DubState; + const featBranch = savedState.stacks[0].branches.find( + (b) => b.name === 'feat/a', + ); + expect(featBranch?.parent_revision).toBe('main-sha'); + }); + + it('preserves existing parent_revision on submit', async () => { + const state = makeState([ + { name: 'main', parent: null, type: 'root' }, + { + name: 'feat/a', + parent: 'main', + parent_revision: 'original-fork-sha', + }, + ]); + mockGetCurrentBranch.mockResolvedValue('feat/a'); + mockReadState.mockResolvedValue(state); + mockGetPr.mockResolvedValue({ + number: 10, + url: 'https://github.com/o/r/pull/10', + title: 'feat: thing', + body: '', + }); + + await submit('/repo', false); + + const savedState = mockWriteState.mock.calls[0][0] as DubState; + const featBranch = savedState.stacks[0].branches.find( + (b) => b.name === 'feat/a', + ); + expect(featBranch?.parent_revision).toBe('original-fork-sha'); + }); }); diff --git a/src/commands/submit.ts b/src/commands/submit.ts index ea31b67..be791bc 100644 --- a/src/commands/submit.ts +++ b/src/commands/submit.ts @@ -156,6 +156,9 @@ export async function submit( version_number: null, source: 'submit', }; + if (stateBranch.parent_revision == null) { + stateBranch.parent_revision = baseSha; + } stateBranch.last_synced_at = new Date().toISOString(); stateBranch.sync_source = 'submit'; } diff --git a/src/commands/sync.test.ts b/src/commands/sync.test.ts index 1c88c31..a2efbcd 100644 --- a/src/commands/sync.test.ts +++ b/src/commands/sync.test.ts @@ -376,6 +376,94 @@ describe('sync', () => { } }); + it('preserves parent_revision when auto-cleaning reparents children', async () => { + mockReadState.mockResolvedValue({ + stacks: [ + { + id: 'stack-1', + branches: [ + { + name: 'main', + parent: null, + type: 'root', + pr_number: null, + pr_link: null, + last_submitted_version: null, + last_synced_at: null, + sync_source: null, + }, + { + name: 'feat/a', + parent: 'main', + pr_number: null, + pr_link: null, + last_submitted_version: { + head_sha: 'feat/a-sha', + base_sha: 'main-sha', + base_branch: 'main', + version_number: null, + source: 'submit', + }, + last_synced_at: null, + sync_source: 'submit', + }, + { + name: 'feat/b', + parent: 'feat/a', + parent_revision: 'a-tip-sha-original', + pr_number: null, + pr_link: null, + last_submitted_version: { + head_sha: 'feat/b-sha', + base_sha: 'feat/a-sha', + base_branch: 'feat/a', + version_number: null, + source: 'submit', + }, + last_synced_at: null, + sync_source: 'submit', + }, + ], + }, + ], + }); + mockGetBranchPrLifecycleState.mockImplementation(async (branch: string) => + branch === 'feat/a' ? 'MERGED' : 'OPEN', + ); + + const result = await sync('/repo', { + interactive: false, + restack: false, + }); + + expect(result.cleaned).toContain('feat/a'); + const writtenState = mockWriteState.mock.calls.at(-1)?.[0] as DubState; + const featB = writtenState.stacks[0].branches.find( + (b) => b.name === 'feat/b', + ); + expect(featB?.parent).toBe('main'); + expect(featB?.parent_revision).toBe('a-tip-sha-original'); + }); + + it('updates parent_revision via markBranchSynced when base is ancestor', async () => { + mockReadState.mockResolvedValue( + makeState([ + { name: 'main', parent: null, type: 'root' }, + { name: 'feat/a', parent: 'main' }, + ]), + ); + mockGetRefSha.mockResolvedValue('same-sha'); + mockIsAncestor.mockResolvedValue(true); + + await sync('/repo', { interactive: false, restack: false }); + + const writtenState = mockWriteState.mock.calls.at(-1)?.[0] as DubState; + const featA = writtenState.stacks[0].branches.find( + (b) => b.name === 'feat/a', + ); + expect(featA?.parent_revision).toBe('same-sha'); + }); + it('handles parent-mismatch status in non-interactive mode by skipping', async () => { mockReadState.mockResolvedValue( makeState([ diff --git a/src/commands/sync.ts b/src/commands/sync.ts index a08cefa..a12d241 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -670,6 +670,13 @@ async function markBranchSynced( version_number: priorBaseline?.version_number ?? null, source: options.source, }; + try { + if (await isAncestor(resolvedBaseSha, headSha, cwd)) { + entry.parent_revision = resolvedBaseSha; + } + } catch { + // If ancestry check fails, keep existing parent_revision. + } entry.last_synced_at = new Date().toISOString(); entry.sync_source = options.source; } diff --git a/src/lib/state.test.ts b/src/lib/state.test.ts index 7eeabd2..b1d39d6 100644 --- a/src/lib/state.test.ts +++ b/src/lib/state.test.ts @@ -113,6 +113,36 @@ describe('writeState and readState roundtrip', () => { expect(loaded.stacks[0].branches[0].sync_source).toBeNull(); }); + it('roundtrips parent_revision correctly', async () => { + await initState(dir); + const state: DubState = { + stacks: [ + { + id: 'test-id', + branches: [ + { + name: 'main', + type: 'root', + parent: null, + pr_number: null, + pr_link: null, + }, + { + name: 'feat/a', + parent: 'main', + parent_revision: 'deadbeef', + pr_number: null, + pr_link: null, + }, + ], + }, + ], + }; + await writeState(state, dir); + const loaded = await readState(dir); + expect(loaded.stacks[0].branches[1].parent_revision).toBe('deadbeef'); + }); + it('creates parent directory if missing', async () => { const state: DubState = { stacks: [] }; await writeState(state, dir); @@ -238,6 +268,28 @@ describe('addBranchToStack', () => { }); }); + it('stores parent_revision when provided', () => { + const state: DubState = { stacks: [] }; + + addBranchToStack(state, 'feat/a', 'main', 'abc123'); + + expect(state.stacks[0].branches[1]).toMatchObject({ + name: 'feat/a', + parent: 'main', + parent_revision: 'abc123', + }); + }); + + it('omits parent_revision when not provided (backward compat)', () => { + const state: DubState = { stacks: [] }; + + addBranchToStack(state, 'feat/a', 'main'); + + const branch = state.stacks[0].branches[1]; + expect(branch.name).toBe('feat/a'); + expect(branch).not.toHaveProperty('parent_revision'); + }); + it('throws when child already exists in a stack', () => { const state: DubState = { stacks: [ diff --git a/src/lib/state.ts b/src/lib/state.ts index 960ae90..c9b3e14 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -12,6 +12,8 @@ export interface Branch { type?: 'root'; /** Name of the parent branch. `null` only for root branches. */ parent: string | null; + /** SHA of parent branch tip when this branch was created/last rebased */ + parent_revision?: string | null; /** GitHub PR number. Populated after `dub submit`. */ pr_number: number | null; /** GitHub PR URL. Populated after `dub submit`. */ @@ -174,12 +176,14 @@ export function getParent( * @param state - The state to mutate (modified in place) * @param child - Name of the new branch * @param parent - Name of the parent branch + * @param parentRevision - Optional SHA of the parent branch tip * @throws {DubError} If child branch already exists in state */ export function addBranchToStack( state: DubState, child: string, parent: string, + parentRevision?: string, ): void { if (findStackForBranch(state, child)) { throw new DubError(`Branch '${child}' is already tracked in a stack.`); @@ -188,6 +192,7 @@ export function addBranchToStack( const childBranch: Branch = { name: child, parent, + ...(parentRevision != null ? { parent_revision: parentRevision } : {}), pr_number: null, pr_link: null, last_submitted_version: null, diff --git a/vitest.config.ts b/vitest.config.ts index 0fd16e5..c834ecf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,5 +4,12 @@ export default defineConfig({ test: { include: ['src/**/*.test.ts', 'test/**/*.test.ts'], testTimeout: 15000, + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/index.ts'], + reporter: ['text', 'text-summary', 'json-summary', 'json'], + reportsDirectory: './coverage', + }, }, });