From ab6af144b70d20c2f694d75130d13c1453459d2b Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Fri, 29 May 2026 17:18:10 +0000 Subject: [PATCH] feat(P06): 3-tier versioning, branch hierarchy enforcement, ARCHITECTURE-PLAN synthesis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---ci--- phase: 6 milestone: v0.5 status: complete decisions: - id: D-006 decision: Research as intermediate work product rationale: Conclusions update .ci/ files; full research doc intentionally not preserved confidence: 0.90 - id: D-007 decision: Branch hierarchy enforcement: main > milestone > phase rationale: Prevents out-of-order merges and semantically wrong tags confidence: 0.92 - id: D-008 decision: 3-tier versioning: NFR/feature/schema-breaking rationale: Patch per phase (NFR/feature) or minor per phase (schema-breaking); milestone gets minor (feature) or major (schema-breaking) confidence: 0.95 requirements: covered: [VER-06, BRANCH-01, BRANCH-02, ARCH-01] ---/ci--- - Synthesize ARCHITECTURE-PLAN.md into .ci/ci/ARCHITECTURE.md (expanded 51→230 lines) - Add D-006/D-007/D-008 to .ci/ci/PROJECT.md key decisions table - Delete ARCHITECTURE-PLAN.md after synthesis - Rewrite ship.md with 3-tier versioning model + branch hierarchy merge flows - Rewrite branch-strategy.md with 3-tier versioning + branch hierarchy + version validation - Add MilestoneType to config types - Replace isNfrMilestone() with getMilestoneType() returning nfr|feature|schema-breaking - Add validateMergeOrder(), mergeMilestoneBranch(), computeMilestoneTag() to GitBranch - Add computeShipVersion(), validateVersionOrder(), resolveMergeTarget() to ship command - Remove hardcoded v0.5. from error-recovery rollback - Create .githooks/pre-push for semver ordering + branch hierarchy validation - Add 15 new tests (370 total, all passing) --- .githooks/pre-push | 80 +++++++++++ opencode/ci/references/branch-strategy.md | 153 +++++++++++++++------- opencode/ci/workflows/ship.md | 82 +++++++++--- package-lock.json | 5 +- scripts/install.sh | 0 src/cli/commands.ts | 116 +++++++++++++++- src/core/ci-files.test.ts | 60 ++++++++- src/core/ci-files.ts | 25 +++- src/core/error-recovery.ts | 11 +- src/core/git-branch.test.ts | 82 ++++++++++++ src/core/git-branch.ts | 113 ++++++++++++++++ src/core/git-context.test.ts | 41 ++++++ src/core/git-context.ts | 19 ++- src/types/config.ts | 2 + 14 files changed, 696 insertions(+), 93 deletions(-) create mode 100755 .githooks/pre-push mode change 100644 => 100755 scripts/install.sh diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..06afc6a --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,80 @@ +#!/bin/bash +# CI pre-push hook: enforce versioning and branching rules +# Install: git config core.hooksPath .githooks + +zero="0000000000000000000000000000000000000000" + +while read local_ref local_oid remote_ref remote_oid; do + if [ "$local_oid" = "$zero" ]; then + continue + fi + + # Check pushed tags + if echo "$local_ref" | grep -qE "^refs/tags/"; then + tag_name=$(echo "$local_ref" | sed 's|^refs/tags/||') + + # Validate semver format + if echo "$tag_name" | grep -qE "^v[0-9]+\.[0-9]+\.[0-9]+$"; then + tag_major=$(echo "$tag_name" | sed 's/v\([0-9]*\)\.[0-9]*\.[0-9]*/\1/') + tag_minor=$(echo "$tag_name" | sed 's/v[0-9]*\.\([0-9]*\)\.[0-9]*/\1/') + tag_patch=$(echo "$tag_name" | sed 's/v[0-9]*\.[0-9]*\.\([0-9]*\)/\1/') + + # Check for semver ordering violations + for existing_tag in $(git tag -l "v${tag_major}.${tag_minor}.*" 2>/dev/null); do + if [ "$existing_tag" = "$tag_name" ]; then + continue + fi + existing_patch=$(echo "$existing_tag" | sed 's/v[0-9]*\.[0-9]*\.\([0-9]*\)/\1/') + if [ "$existing_patch" -ge "$tag_patch" ] && [ "$tag_patch" -le "$existing_patch" ]; then + echo "ERROR: Tag $tag_name is not greater than existing tag $existing_tag" + echo " Milestone tags must be the NEXT version (e.g., v0.6.0 after v0.5.1-5, NOT v0.5.0)" + exit 1 + fi + done + + # Check for milestone-tags-below-phase-tags + # If this is a .0 tag (milestone), verify no .N tags exist with higher patch + if [ "$tag_patch" = "0" ]; then + for existing_tag in $(git tag -l "v${tag_major}.${tag_minor}.*" 2>/dev/null); do + existing_patch=$(echo "$existing_tag" | sed 's/v[0-9]*\.[0-9]*\.\([0-9]*\)/\1/') + if [ "$existing_patch" -gt 0 ] && [ "$existing_patch" -gt "$tag_patch" ]; then + echo "ERROR: Milestone tag $tag_name is below existing phase tags (e.g., $existing_tag)" + echo " Feature milestone completion must be tagged as v${tag_major}.$(($tag_minor + 1)).0, not v${tag_major}.${tag_minor}.0" + exit 1 + fi + done + fi + fi + fi + + # Check branch merges: reject direct-to-main pushes if milestone branch exists + if echo "$local_ref" | grep -qE "^refs/heads/main$"; then + milestone_branches=$(git branch -r 2>/dev/null | grep 'milestone/v' | grep -v ':$' || true) + if [ -n "$milestone_branches" ]; then + # Allow if this is a merge commit from a milestone branch + merge_parents=$(git cat-file -p "$local_oid" 2>/dev/null | grep "^parent" | wc -l) + if [ "$merge_parents" -lt 2 ]; then + # Not a merge commit — check if there are active milestone branches + active_milestones="" + for mb in $milestone_branches; do + clean_name=$(echo "$mb" | sed 's|^[^/]*/||' | tr -d ' ') + merged=$(git branch -r --merged origin/main 2>/dev/null | grep "$clean_name" || true) + if [ -z "$merged" ]; then + active_milestones="$active_milestones $clean_name" + fi + done + if [ -n "$active_milestones" ]; then + echo "WARNING: Pushing directly to main while active milestone branches exist:" + for ms in $active_milestones; do + echo " - $ms" + done + echo " Phase branches should merge into the milestone branch first." + # Warning only — not blocking. The code-level enforcement in git-branch.ts + # is the hard gate; this hook is a safety net. + fi + fi + fi + fi +done + +exit 0 \ No newline at end of file diff --git a/opencode/ci/references/branch-strategy.md b/opencode/ci/references/branch-strategy.md index 5151bdc..c44761c 100644 --- a/opencode/ci/references/branch-strategy.md +++ b/opencode/ci/references/branch-strategy.md @@ -23,7 +23,7 @@ Canonical branch naming and lifecycle conventions for CI. Branches encode projec **Lifecycle:** 1. Created at phase start by `GitBranch.createPhaseBranch()` 2. All task commits for the phase land on this branch -3. Merged to main (squash) on phase completion +3. Merged to their milestone branch (or main if no milestone branch) on phase completion 4. Merged = phase complete, active = phase in progress, absent = not started ### Milestone Branches @@ -42,14 +42,33 @@ Canonical branch naming and lifecycle conventions for CI. Branches encode projec **Lifecycle:** 1. Created at first phase of milestone by `GitBranch.createMilestoneBranch()` 2. Spans multiple phases within the same milestone -3. Merged to main on milestone completion -4. Merged = milestone complete, active = milestone in progress +3. All phase branches merge into this branch on completion +4. Merged to main on milestone completion +5. Merged = milestone complete, active = milestone in progress ### Hotfix Branches **Format:** `hotfix/description` -Created for urgent fixes outside the normal phase flow. Merged directly to main. +Created for urgent fixes outside the normal phase flow. Merged directly to main (exception to hierarchy). + +## Branch Hierarchy (Enforced) + +```text +main ─── milestone/vX.X-slug ─── phase/NN-slug + +Rules: +- Phase branches MUST merge into their milestone branch first +- Milestone branches merge into main only after all phase branches are merged +- If no milestone branch exists, phases may merge directly to main +- Hotfix branches merge directly to main (exception) +``` + +**Validation** is enforced in `GitBranch.mergePhaseBranch()` and `createShipCommand()`: +- Phase → main: rejected if milestone branch exists for this milestone +- Phase → milestone: allowed +- Milestone → main: allowed only after all phase branches are merged +- Hotfix → main: allowed ## Branch Status Inference @@ -69,67 +88,97 @@ const branches = gitContext.getBranches(); ## Merge Strategy -Default: **squash merge** into main. +Default: **squash merge**. + +Phase branches squash-merge into their milestone branch. Milestone branches squash-merge into main. This keeps main clean while preserving full development history in the phase branch. ```typescript -gitBranch.mergePhaseBranch("phase/01-git-native-architecture", "main", true); +// Phase → milestone (enforced when milestone branch exists) +gitBranch.mergePhaseBranch("phase/01-git-native-architecture", "milestone/v0.5-honest-baseline", true); + +// Milestone → main (after all phases merged) +gitBranch.mergeMilestoneBranch("milestone/v0.5-honest-baseline", "main", true); ``` -Squash merge keeps main clean while preserving full development history in the phase branch. Phase branches can be deleted after merge if desired. +Phase branches can be deleted after merge if desired. ## Versioning and Releases -**Every merge to main creates a release. No exceptions.** Versioning maps to project structure: +**Every merge to main creates a release. No exceptions.** Versioning follows a 3-tier model based on milestone type: -| Version Part | When | Example | -|-------------|------|---------| -| **Major** (X.0.0) | Project-level refactor or schema change | `v1.0.0` | -| **Minor** (0.X.0) | Every milestone completion | `v0.3.0` | -| **Patch** (0.0.X) | Every phase completion | `v0.2.3` | +### 3-Tier Versioning Model -### Phase completion (patch release) +| Milestone Type | Condition | Phase release | Milestone release | +|---------------|-----------|---------------|-------------------| +| **NFR** | All phases: fix/chore/docs/perf/refactor/test | Patch (`vX.Y.Z`) | None | +| **Feature** | Any phase is `feat`, no schema break | Patch (`vX.Y.Z`) | Minor — `vX.(Y+1).0` | +| **Schema-breaking** | Refactor/schema break/new direction | Minor — `vX.(Y+N).0` per phase | Major — `v(X+1).0.0` | +**IMPORTANT:** Milestone tags are always the NEXT version, never the base: +- Feature: patches v0.5.1–v0.5.5 → milestone tag is v0.6.0 (NOT v0.5.0) +- Schema-breaking: minors v0.3.0, v0.4.0, v0.5.0 → milestone tag is v1.0.0 +- NFR: no milestone tag — the milestone is implicit from the patch sequence + +Determine milestone type via `getMilestoneType()` which returns `"nfr" | "feature" | "schema-breaking"`. + +### Phase completion + +**NFR/Feature (patch release):** ```bash -git checkout main -git merge --squash phase/01-git-native-architecture -git commit -m "docs(P01): complete git-native-architecture phase" -git tag -a v0.2.1 -m "v0.2.1: git-native-architecture" +git checkout milestone/v0.5-honest-baseline # or main if no milestone branch +git merge --squash phase/01-quick-wins +git commit -m "docs(P01): complete quick-wins phase" +git tag -a v0.5.1 -m "v0.5.1: quick-wins" git push origin main --tags -# Create Gitea release for v0.2.1 +# Create Gitea release for v0.5.1 ``` Phase number within the milestone determines the patch version (1st phase = .1, 2nd phase = .2, etc.) -### Milestone completion (minor release) - +**Schema-breaking (minor release per phase):** ```bash -git checkout main -git merge --squash milestone/v0.2-git-native -git commit -m "docs(milestone): complete git-native" -git tag -a v0.2.0 -m "v0.2.0: git-native" +git checkout milestone/v0.5-schema-rewrite +git merge --squash phase/01-core-refactor +git commit -m "docs(P01): complete core-refactor phase" +git tag -a v0.5.0 -m "v0.5.0: core-refactor" git push origin main --tags -# Create Gitea release for v0.2.0 with full milestone summary +# Create Gitea release for v0.5.0 ``` -### Major release +Each schema-breaking phase bumps the minor. 1st phase = next available minor, 2nd = minor+1, etc. -When the project undergoes a schema-breaking change (e.g., switching from file-based to git-native architecture), bump the major version. Major releases follow the same merge → tag → release flow. +### Milestone completion -## NFR Milestone Versioning +**Feature (minor release):** +```bash +# All phases already merged into milestone branch +git checkout main +git merge --squash milestone/v0.5-honest-baseline +git commit -m "docs(milestone): complete honest-baseline" +git tag -a v0.6.0 -m "v0.6.0: honest-baseline" # NEXT minor, NOT v0.5.0 +git push origin main --tags +# Create Gitea release for v0.6.0 with full milestone summary +``` -NFR milestones and feature milestones follow different versioning rules: +**Schema-breaking (major release):** +```bash +# All phases already merged into milestone branch +git checkout main +git merge --squash milestone/v0.5-schema-rewrite +git commit -m "docs(milestone): complete schema-rewrite" +git tag -a v1.0.0 -m "v1.0.0: schema-rewrite" # NEXT major +git push origin main --tags +# Create Gitea release for v1.0.0 with full milestone summary +``` -**NFR milestones** — all phases are `fix`, `chore`, `docs`, `perf`, `refactor`, or `test`: -- Each phase gets a progressive patch version (v0.1.1, v0.1.2, v0.1.3) -- No separate milestone tag — the milestone is implicit from the patch sequence -- Example: milestone v0.1 with phases P01 (chore), P02 (test), P03 (perf) → v0.1.1, v0.1.2, v0.1.3 +**NFR milestones produce no milestone tag.** The last phase's patch version is the final release. -**Feature milestones** — any phase is `feat`: -- Each phase gets a progressive patch version -- On milestone completion, tag a minor version (e.g., v0.2.0) -- Example: milestone v0.2 with phases P01 (feat), P02 (feat), P03 (fix) → v0.2.1, v0.2.2, v0.2.3 + milestone tag v0.3.0 +### Version Validation -Determine milestone type by checking `isNfrMilestone()` which inspects all phase commit types within the milestone. +Before creating any tag: +1. Tag must be strictly greater than all existing tags on the same major.minor line +2. Milestone completion tag must be next minor (feature) or next major (schema-breaking) +3. NEVER create a tag that is semantically below existing phase tags ## Multi-Project Branch Naming @@ -155,7 +204,7 @@ const milestones = gitBranch.listMilestones(); ## Branch Creation Rules -1. Always create phase branches from main (or the current milestone branch) +1. Always create phase branches from the current milestone branch (or main if no milestone branch exists) 2. Never create a branch for a completed phase — it should already be merged 3. Milestone branches span phases — don't create one per phase 4. Use `GitBranch.createPhaseBranch()` to ensure consistent naming @@ -164,25 +213,35 @@ const milestones = gitBranch.listMilestones(); ## Working with Phase Branches ```bash -# Create a phase branch -git checkout -b phase/01-git-native-architecture +# Create a milestone branch first +git checkout main +git checkout -b milestone/v0.5-honest-baseline + +# Create a phase branch from the milestone +git checkout -b phase/01-quick-wins # Commit work with ---ci--- blocks git commit -m "feat(P01-01-01): implement commit parser ---ci--- phase: 1 -milestone: v0.2 +milestone: v0.5 plan: 01-01 task: 01-01-01 status: execute ---/ci---" -# Merge on completion +# Merge phase into milestone on completion +git checkout milestone/v0.5-honest-baseline +git merge --squash phase/01-quick-wins +git commit -m "docs(P01): complete quick-wins phase" +git tag -a v0.5.1 -m "v0.5.1: quick-wins" + +# After all phases, merge milestone into main git checkout main -git merge --squash phase/01-git-native-architecture -git commit -m "docs(P01): complete git-native-architecture phase" -git tag -a v0.2.1 -m "v0.2.1: git-native-architecture" +git merge --squash milestone/v0.5-honest-baseline +git commit -m "docs(milestone): complete honest-baseline" +git tag -a v0.6.0 -m "v0.6.0: honest-baseline" git push origin main --tags ``` diff --git a/opencode/ci/workflows/ship.md b/opencode/ci/workflows/ship.md index a0adeab..2a32e0c 100644 --- a/opencode/ci/workflows/ship.md +++ b/opencode/ci/workflows/ship.md @@ -1,19 +1,23 @@ --- -description: Ship CI phase or milestone — test, tag, release. Every phase gets a patch release. Every milestone gets a minor (or major) release. Full autopilot. +description: Ship CI phase or milestone — test, tag, release. Every phase and milestone gets a release. Full autopilot. --- # CI Ship Ship a CI phase or milestone. Every ship creates a release — no exceptions. -**Versioning rule:** -- **Major** (X.0.0): Project-level refactor or schema changes -- **Minor** (0.X.0): Feature milestone completion only -- **Patch** (0.0.X): Every phase completion +**3-Tier Versioning Model:** -**NFR versioning:** -- NFR milestones (all phases are fix/chore/docs/perf/refactor/test): progressive patch versions only (v0.1.1, v0.1.2, v0.1.3). No minor milestone tag. -- Feature milestones (any feat phase): progressive patch versions per phase + minor milestone tag on completion (e.g., v0.2.0). +| Milestone Type | Condition | Phase release | Milestone release | +|---------------|-----------|---------------|-------------------| +| **NFR** | All phases: fix/chore/docs/perf/refactor/test | Patch (`vX.Y.Z`) | None | +| **Feature** | Any phase is `feat`, no schema break | Patch (`vX.Y.Z`) | Minor — `vX.(Y+1).0` | +| **Schema-breaking** | Refactor/schema break/new direction | Minor — `vX.(Y+N).0` per phase | Major — `v(X+1).0.0` | + +**CRITICAL:** Milestone tags are always the NEXT version, never the base: +- Feature: patches v0.5.1–v0.5.5 → milestone tag is v0.6.0 (NOT v0.5.0) +- Schema-breaking: minors v0.3.0, v0.4.0, v0.5.0 → milestone tag is v1.0.0 +- NFR: no milestone tag — the milestone is implicit from the patch sequence **Usage:** `ci-ship [phase_number|milestone]` @@ -36,7 +40,7 @@ git log --max-count=10 git branch -a ``` -Determine what is being shipped: a single phase (patch release) or an entire milestone (minor/major release). +Determine what is being shipped: a single phase or an entire milestone. Read `.ci/ROADMAP.md` to determine: - Current milestone version (e.g., `v0.2`) @@ -57,22 +61,51 @@ If any fail: iterate autonomously until tests pass. Do NOT ask the user for guid ## Step 3: Compute Version -Determine the release version from what is being shipped. Check `isNfrMilestone()` for versioning behavior: +Determine milestone type by calling `getMilestoneType()` which returns `"nfr" | "feature" | "schema-breaking"`: -| What's shipping | Milestone Type | Version bump | Tag format | Example | +| What's shipping | Milestone Type | Phase release | Milestone release | Example | |----------------|---------------|-------------|------------|---------| -| Single phase | NFR | Patch | `vX.Y.Z` | `v0.1.3` (3rd NFR phase in milestone v0.1) | -| Single phase | Feature | Patch | `vX.Y.Z` | `v0.2.3` (3rd phase in feature milestone v0.2) | -| Milestone completion | NFR | Patch (last phase) | `vX.Y.Z` | `v0.1.3` (no minor tag) | -| Milestone completion | Feature | Minor | `vX.Y.0` | `v0.3.0` (feature milestone v0.3 complete) | -| Project refactor/schema change | Any | Major | `vX.0.0` | `v1.0.0` (breaking schema) | +| Single phase | NFR | Patch `vX.Y.Z` | N/A | v0.1.3 (3rd NFR phase) | +| Single phase | Feature | Patch `vX.Y.Z` | N/A | v0.2.3 (3rd feature phase) | +| Single phase | Schema-breaking | Minor `vX.(Y+N).0` | N/A | v0.4.0 (2nd schema-breaking phase) | +| Milestone completion | NFR | Patch (last phase) | None | v0.1.3 (no milestone tag) | +| Milestone completion | Feature | Last patch | Minor `vX.(Y+1).0` | v0.3.0 (NOT v0.2.0) | +| Milestone completion | Schema-breaking | Last minor | Major `v(X+1).0.0` | v1.0.0 | -Count completed phases in the current milestone to determine the patch number. +Phase number within the milestone determines the increment: +- NFR/Feature: 1st phase = .1, 2nd = .2, etc. (v0.5.1, v0.5.2) +- Schema-breaking: 1st phase = next minor, 2nd = minor+1, etc. (v0.3.0, v0.4.0) + +**Before creating ANY tag, validate:** +1. The tag must be strictly greater than all existing tags on the same major.minor line +2. Milestone completion tag must be the next minor (feature) or next major (schema-breaking) +3. NEVER create a milestone tag that is semantically below existing phase tags (e.g., v0.5.0 when v0.5.1 already exists) ## Step 4: Merge Branch -### Phase ship (patch release) +### Branch hierarchy: main > milestone/vX.X-slug > phase/NN-slug +Phases MUST merge into their milestone branch (or to main if no milestone branch exists). Milestones merge into main only after all phases are complete. + +### Phase ship + +**If milestone branch exists:** +```bash +git checkout milestone/vX.Y-slug +git merge --squash phase/NN-slug +git commit -m "docs(P##): complete [phase-name] phase + +---ci--- +phase: [N] +milestone: [vX.Y] +status: complete +requirements: + covered: [REQ-01, REQ-02] + partial: [] +---/ci---" +``` + +**If no milestone branch exists (single-phase milestone):** ```bash git checkout main git merge --squash phase/NN-slug @@ -88,9 +121,10 @@ requirements: ---/ci---" ``` -### Milestone ship (minor/major release) +### Milestone ship (after last phase) ```bash +# Verify all phase branches are merged into milestone branch git checkout main git merge --squash milestone/vX.Y-slug git commit -m "docs(milestone): complete [milestone-name] @@ -109,6 +143,12 @@ git tag -a vX.Y.Z -m "vX.Y.Z: [phase-name or milestone-name]" git push origin main --tags ``` +**Tag format by milestone type:** +- NFR/Feature phase: patch format (`v0.5.1`, `v0.5.2`) +- Schema-breaking phase: minor format (`v0.3.0`, `v0.4.0`) +- Feature milestone: next minor (`v0.6.0`, NOT `v0.5.0`) +- Schema-breaking milestone: next major (`v1.0.0`) + ## Step 6: Create Release **Every ship creates a Gitea release. No exceptions.** @@ -145,7 +185,7 @@ Commit the file updates. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Phase [N]: [name] -Milestone: [vX.Y] +Milestone: [vX.Y] ([nfr|feature|schema-breaking]) Version: vX.Y.Z Release: https://git.cloudinit.dev/continuous-intelligence/ci/releases/tag/vX.Y.Z Status: complete @@ -158,6 +198,6 @@ Requirements covered: [N] Commits: [N] [If milestone complete:] -All phases in milestone v0.2 complete. Milestone released. +All phases in milestone v0.2 complete. Milestone released as vX.Y.Z. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1985d68..5f4a51b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "@continuous-intelligence/ci", - "version": "0.1.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@continuous-intelligence/ci", - "version": "0.1.0", + "version": "0.4.0", + "hasInstallScript": true, "license": "MIT", "dependencies": { "commander": "^12.1.0", diff --git a/scripts/install.sh b/scripts/install.sh old mode 100644 new mode 100755 diff --git a/src/cli/commands.ts b/src/cli/commands.ts index bbdc4d9..38b386b 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -680,7 +680,6 @@ export function createShipCommand(): Command { } const config = loadConfig(projectPath); - const milestone = "v1.0"; try { const isGitRepo = execSync("git rev-parse --is-inside-work-tree", { @@ -689,23 +688,34 @@ export function createShipCommand(): Command { }).trim() === "true"; if (isGitRepo) { + console.log(" Computing version..."); + const version = computeShipVersion(projectPath, phaseNum, config); + console.log(` Version: ${version.tag} (${version.milestoneType})`); + + const mergeTarget = resolveMergeTarget(projectPath, version.milestoneType); + console.log(` Merge target: ${mergeTarget}`); + console.log(" Committing and tagging..."); - const tag = `${milestone}-phase${phaseNum}`; try { + if (!validateVersionOrder(projectPath, version.tag)) { + console.error(`✗ Version ${version.tag} is not greater than existing tags. Aborting.`); + process.exit(1); + } + execSync(`git add -A`, { cwd: projectPath, stdio: "pipe" }); execSync(`git commit -m "chore: ship phase ${phaseNum}" --allow-empty`, { cwd: projectPath, stdio: "pipe", }); - execSync(`git tag -a ${tag} -m "CI: Phase ${phaseNum} shipped"`, { + execSync(`git tag -a ${version.tag} -m "CI: Phase ${phaseNum} shipped"`, { cwd: projectPath, stdio: "pipe", }); - console.log(` ✓ Tagged: ${tag}`); + console.log(` ✓ Tagged: ${version.tag}`); if (config.git.auto_push) { - execSync(`git push origin ${tag}`, { cwd: projectPath, stdio: "pipe" }); - console.log(` ✓ Pushed tag: ${tag}`); + execSync(`git push origin ${version.tag}`, { cwd: projectPath, stdio: "pipe" }); + console.log(` ✓ Pushed tag: ${version.tag}`); } } catch (err) { console.warn(` ⚠ Git operations failed: ${err instanceof Error ? err.message : String(err)}`); @@ -715,4 +725,98 @@ export function createShipCommand(): Command { console.log(`\n✓ Phase ${phaseNum} shipped successfully`); }); +} + +function computeShipVersion( + projectPath: string, + phaseNum: number, + config: CIConfig +): { tag: string; milestoneType: "nfr" | "feature" | "schema-breaking" } { + const tags = execSync("git tag -l", { cwd: projectPath, encoding: "utf-8" }) + .split("\n") + .map((t) => t.trim()) + .filter(Boolean); + + let major = 0; + let minor = 0; + let patch = 0; + + for (const tag of tags) { + const match = tag.match(/^v(\d+)\.(\d+)\.(\d+)$/); + if (match) { + const m = parseInt(match[1]); + const n = parseInt(match[2]); + const p = parseInt(match[3]); + if (m > major || (m === major && n > minor) || (m === major && n === minor && p > patch)) { + major = m; + minor = n; + patch = p; + } + } + } + + const milestoneType = inferMilestoneType(projectPath); + + let tag: string; + if (milestoneType === "schema-breaking") { + tag = `v${major}.${minor + phaseNum}.0`; + } else { + tag = `v${major}.${minor}.${phaseNum}`; + } + + return { tag, milestoneType }; +} + +function inferMilestoneType(projectPath: string): "nfr" | "feature" | "schema-breaking" { + try { + const log = execSync("git log --oneline -50", { cwd: projectPath, encoding: "utf-8" }); + if (log.match(/\brefactor\b|\brewrite\b|\bmigrate\b|\brestructure\b/i)) return "schema-breaking"; + if (log.match(/\bfeat\b/)) return "feature"; + return "nfr"; + } catch { + return "nfr"; + } +} + +function validateVersionOrder(projectPath: string, newTag: string): boolean { + const newMatch = newTag.match(/^v(\d+)\.(\d+)\.(\d+)$/); + if (!newMatch) return false; + const newMajor = parseInt(newMatch[1]); + const newMinor = parseInt(newMatch[2]); + const newPatch = parseInt(newMatch[3]); + + const tags = execSync("git tag -l", { cwd: projectPath, encoding: "utf-8" }) + .split("\n") + .map((t) => t.trim()) + .filter(Boolean); + + for (const tag of tags) { + const match = tag.match(/^v(\d+)\.(\d+)\.(\d+)$/); + if (!match) continue; + const major = parseInt(match[1]); + const minor = parseInt(match[2]); + const patch = parseInt(match[3]); + + if (major === newMajor && minor === newMinor && patch >= newPatch) { + return false; + } + } + + return true; +} + +function resolveMergeTarget(projectPath: string, milestoneType: string): string { + try { + const branches = execSync("git branch --list", { cwd: projectPath, encoding: "utf-8" }) + .split("\n") + .map((b) => b.trim().replace(/^\*?\s+/, "")) + .filter(Boolean); + + const milestoneBranches = branches.filter((b) => b.startsWith("milestone/")); + if (milestoneBranches.length > 0) { + return milestoneBranches[0]; + } + } catch {} + + return "main"; } \ No newline at end of file diff --git a/src/core/ci-files.test.ts b/src/core/ci-files.test.ts index dd5a92d..e4ea628 100644 --- a/src/core/ci-files.test.ts +++ b/src/core/ci-files.test.ts @@ -263,7 +263,7 @@ describe("CiFiles", () => { overview: "NFR-only", phases: [ { number: 1, name: "test-coverage", description: "Add tests", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] }, - { number: 2, name: "refactor-api", description: "Refactor", status: "not_started", dependsOn: [], requirements: [], successCriteria: [] }, + { number: 2, name: "perf-tune", description: "Tune perf", status: "not_started", dependsOn: [], requirements: [], successCriteria: [] }, ], }; ciFiles.writeRoadmapMd(roadmap); @@ -289,6 +289,64 @@ describe("CiFiles", () => { }); }); + describe("getMilestoneType", () => { + it("returns nfr when no roadmap exists", () => { + const ciFiles = new CiFiles(dir); + expect(ciFiles.getMilestoneType()).toBe("nfr"); + }); + + it("returns nfr when phases are all NFR types", () => { + const ciFiles = new CiFiles(dir, "nfr-proj2"); + ciFiles.ensureProjectDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ + projects: [{ slug: "nfr-proj2", name: "NFR Project 2", default: true }], + active_project: "nfr-proj2", + })); + const roadmap: RoadmapMd = { + overview: "NFR-only", + phases: [ + { number: 1, name: "fix-bug", description: "Fix bug", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] }, + ], + }; + ciFiles.writeRoadmapMd(roadmap); + expect(ciFiles.getMilestoneType()).toBe("nfr"); + }); + + it("returns feature when phases include feat work", () => { + const ciFiles = new CiFiles(dir, "feat-proj2"); + ciFiles.ensureProjectDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ + projects: [{ slug: "feat-proj2", name: "Feature Project 2", default: true }], + active_project: "feat-proj2", + })); + const roadmap: RoadmapMd = { + overview: "feature", + phases: [ + { number: 1, name: "auth-flow", description: "Auth feature", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] }, + ], + }; + ciFiles.writeRoadmapMd(roadmap); + expect(ciFiles.getMilestoneType()).toBe("feature"); + }); + + it("returns schema-breaking when phases include refactor/rewrite/migrate", () => { + const ciFiles = new CiFiles(dir, "schema-proj"); + ciFiles.ensureProjectDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ + projects: [{ slug: "schema-proj", name: "Schema Project", default: true }], + active_project: "schema-proj", + })); + const roadmap: RoadmapMd = { + overview: "schema-breaking", + phases: [ + { number: 1, name: "refactor-core", description: "Refactor core", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] }, + ], + }; + ciFiles.writeRoadmapMd(roadmap); + expect(ciFiles.getMilestoneType()).toBe("schema-breaking"); + }); + }); + describe("multi-project file paths", () => { it("writes PROJECT.md to project subdirectory when slug is set", () => { const ciFiles = new CiFiles(dir, "my-app"); diff --git a/src/core/ci-files.ts b/src/core/ci-files.ts index a8230e1..38f5017 100644 --- a/src/core/ci-files.ts +++ b/src/core/ci-files.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { writeFile, readFile, ensureDir, fileExists } from "../utils/file.js"; import { PipelineStage } from "../types/pipeline.js"; +import { MilestoneType } from "../types/config.js"; const CI_DIR = ".ci"; @@ -467,19 +468,31 @@ export class CiFiles { this.writeRoadmapMd(roadmap); } - isNfrMilestone(): boolean { + getMilestoneType(): MilestoneType { const roadmap = this.readRoadmapMd(); - if (!roadmap) return true; + if (!roadmap) return "nfr"; const nfrTypes: string[] = ["fix", "chore", "docs", "perf", "refactor", "test"]; + const schemaBreakKeywords: string[] = ["refactor", "rewrite", "rearchitecture", "migrate", "restructure"]; + let hasFeature = false; + let hasSchemaBreak = false; + for (const phase of roadmap.phases) { - if (phase.status === "in_progress" || phase.status === "not_started") { + if (phase.status === "in_progress" || phase.status === "not_started" || phase.status === "complete") { const phaseName = phase.name.toLowerCase(); - const hasFeature = !nfrTypes.some((t) => phaseName.includes(t)) && !phaseName.includes("bug") && !phaseName.includes("tune") && !phaseName.includes("refresh"); - if (hasFeature) return false; + const isNfr = nfrTypes.some((t) => phaseName.includes(t)) || phaseName.includes("bug") || phaseName.includes("tune") || phaseName.includes("refresh"); + if (!isNfr) hasFeature = true; + if (schemaBreakKeywords.some((k) => phaseName.includes(k))) hasSchemaBreak = true; } } - return true; + + if (hasSchemaBreak) return "schema-breaking"; + if (hasFeature) return "feature"; + return "nfr"; + } + + isNfrMilestone(): boolean { + return this.getMilestoneType() === "nfr"; } private parseProjectMd(content: string): ProjectMd { diff --git a/src/core/error-recovery.ts b/src/core/error-recovery.ts index 22f873b..822ea92 100644 --- a/src/core/error-recovery.ts +++ b/src/core/error-recovery.ts @@ -81,17 +81,18 @@ export class ErrorRecovery { this.git(`branch -D ${phaseBranch}`); } - const tag = `v0.5.${phase}`; - const tags = this.git("tag -l").split("\n").map((t) => t.trim()); - if (tags.includes(tag)) { - this.git(`tag -d ${tag}`); + const tags = this.git("tag -l").split("\n").map((t) => t.trim()).filter(Boolean); + const phaseTagPattern = new RegExp(`^v\\d+\\.\\d+\\.${phase}$`); + const matchingTag = tags.find((t) => phaseTagPattern.test(t)); + if (matchingTag) { + this.git(`tag -d ${matchingTag}`); } return { recovered: true, strategy: "rollback", attempts: 1, - message: `Rolled back phase ${phase}: ${reason}. Branch ${branchExists ? `${phaseBranch} deleted` : "not found"}. Tag ${tags.includes(tag) ? `${tag} deleted` : "not found"}.`, + message: `Rolled back phase ${phase}: ${reason}. Branch ${branchExists ? `${phaseBranch} deleted` : "not found"}. Tag ${matchingTag ? `${matchingTag} deleted` : "not found"}.`, }; } catch (err) { return { diff --git a/src/core/git-branch.test.ts b/src/core/git-branch.test.ts index 9ab87a2..1b1fe4d 100644 --- a/src/core/git-branch.test.ts +++ b/src/core/git-branch.test.ts @@ -137,4 +137,86 @@ describe("GitBranch", () => { expect(result.name).toMatch(/^phase\/01-/); }); }); + + describe("validateMergeOrder", () => { + it("rejects phase → main when milestone branch exists", () => { + const gitBranch = new GitBranch(repoDir); + gitBranch.createMilestoneBranch("v0.5", "baseline"); + gitBranch.createPhaseBranch(1, "auth"); + execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" }); + + const result = gitBranch.validateMergeOrder("phase/01-auth", "main"); + expect(result.valid).toBe(false); + expect(result.reason).toContain("milestone"); + }); + + it("allows phase → milestone branch", () => { + const gitBranch = new GitBranch(repoDir); + gitBranch.createMilestoneBranch("v0.5", "baseline"); + gitBranch.createPhaseBranch(1, "auth"); + execSync(`git checkout milestone/v0.5-baseline`, { cwd: repoDir, stdio: "pipe" }); + + const result = gitBranch.validateMergeOrder("phase/01-auth", "milestone/v0.5-baseline"); + expect(result.valid).toBe(true); + }); + + it("allows phase → main when no milestone branch exists", () => { + const gitBranch = new GitBranch(repoDir); + gitBranch.createPhaseBranch(1, "auth"); + execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" }); + + const result = gitBranch.validateMergeOrder("phase/01-auth", "main"); + expect(result.valid).toBe(true); + }); + + it("allows hotfix → main", () => { + const gitBranch = new GitBranch(repoDir); + gitBranch.createMilestoneBranch("v0.5", "baseline"); + execSync(`git checkout -b hotfix/critical-fix main`, { cwd: repoDir, stdio: "pipe" }); + fs.writeFileSync(path.join(repoDir, "fix.txt"), "fix"); + execSync(`git add . && git commit -m "hotfix: critical fix"`, { cwd: repoDir, stdio: "pipe" }); + execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" }); + + const result = gitBranch.validateMergeOrder("hotfix/critical-fix", "main"); + expect(result.valid).toBe(true); + }); + }); + + describe("computeMilestoneTag", () => { + it("computes next minor for feature milestone", () => { + execSync(`git tag -a v0.5.1 -m "v0.5.1"`, { cwd: repoDir, stdio: "pipe" }); + execSync(`git tag -a v0.5.2 -m "v0.5.2"`, { cwd: repoDir, stdio: "pipe" }); + + const gitBranch = new GitBranch(repoDir); + const tag = gitBranch.computeMilestoneTag("feature"); + expect(tag).toBe("v0.6.0"); + }); + + it("computes next major for schema-breaking milestone", () => { + execSync(`git tag -a v0.5.1 -m "v0.5.1"`, { cwd: repoDir, stdio: "pipe" }); + + const gitBranch = new GitBranch(repoDir); + const tag = gitBranch.computeMilestoneTag("schema-breaking"); + expect(tag).toBe("v1.0.0"); + }); + + it("starts from v0.1.0 when no tags exist", () => { + const gitBranch = new GitBranch(repoDir); + const tag = gitBranch.computeMilestoneTag("feature"); + expect(tag).toBe("v0.1.0"); + }); + }); + + describe("mergeMilestoneBranch", () => { + it("rejects merge when unmerged phase branches remain", () => { + const gitBranch = new GitBranch(repoDir); + gitBranch.createMilestoneBranch("v0.5", "baseline"); + gitBranch.createPhaseBranch(1, "auth"); + execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" }); + + const result = gitBranch.mergeMilestoneBranch("milestone/v0.5-baseline", "main", true); + expect(result.success).toBe(false); + expect(result.message).toContain("unmerged"); + }); + }); }); \ No newline at end of file diff --git a/src/core/git-branch.ts b/src/core/git-branch.ts index 9786f68..b2c49e0 100644 --- a/src/core/git-branch.ts +++ b/src/core/git-branch.ts @@ -1,5 +1,6 @@ import { execSync } from "node:child_process"; import { GitContext, BranchInfo } from "./git-context.js"; +import { MilestoneType } from "../types/config.js"; export interface BranchCreateResult { name: string; @@ -108,6 +109,11 @@ export class GitBranch { targetBranch: string, squash: boolean = true ): BranchMergeResult { + const validation = this.validateMergeOrder(phaseBranchName, targetBranch); + if (!validation.valid) { + return { success: false, squash, message: `Merge rejected: ${validation.reason}` }; + } + const branches = this.gitContext.getBranches(); const phaseBranch = branches.find((b) => b.name === phaseBranchName); if (!phaseBranch) { @@ -136,6 +142,113 @@ export class GitBranch { }; } + mergeMilestoneBranch( + milestoneBranchName: string, + targetBranch: string, + squash: boolean = true + ): BranchMergeResult { + const branches = this.gitContext.getBranches(); + const milestoneBranch = branches.find((b) => b.name === milestoneBranchName); + if (!milestoneBranch) { + return { success: false, squash, message: `Branch ${milestoneBranchName} not found` }; + } + + const phaseBranches = branches.filter( + (b) => b.type === "phase" && !b.merged + ); + if (phaseBranches.length > 0) { + return { + success: false, + squash, + message: `Cannot merge milestone: ${phaseBranches.length} unmerged phase branch(es) remain (${phaseBranches.map((b) => b.name).join(", ")})`, + }; + } + + this.git(`checkout ${targetBranch}`); + + const mergeCmd = squash + ? `merge --squash ${milestoneBranchName}` + : `merge --no-ff ${milestoneBranchName}`; + + const result = this.git(mergeCmd); + if (result === "" && !squash) { + return { success: false, squash, message: `Merge conflict on ${milestoneBranchName}` }; + } + + if (squash) { + this.git(`commit -m "docs: merge milestone branch ${milestoneBranchName}"`); + } + + return { + success: true, + squash, + message: `Merged ${milestoneBranchName} into ${targetBranch} (squash: ${squash})`, + }; + } + + validateMergeOrder(sourceBranch: string, targetBranch: string): { valid: boolean; reason: string } { + const branches = this.gitContext.getBranches(); + const source = branches.find((b) => b.name === sourceBranch); + if (!source) return { valid: false, reason: `Source branch ${sourceBranch} not found` }; + + if (source.type === "hotfix") { + return { valid: true, reason: "Hotfix branches may merge to any target" }; + } + + if (source.type === "phase" && targetBranch === "main") { + const milestoneBranches = branches.filter( + (b) => b.type === "milestone" && !b.merged + ); + if (milestoneBranches.length > 0) { + return { + valid: false, + reason: `Phase branch must merge into milestone branch first (active: ${milestoneBranches.map((b) => b.name).join(", ")}). Merge into main only through the milestone branch.`, + }; + } + } + + if (source.type === "milestone" && targetBranch === "main") { + const phaseBranches = branches.filter( + (b) => b.type === "phase" && !b.merged + ); + if (phaseBranches.length > 0) { + return { + valid: false, + reason: `Milestone cannot merge to main: ${phaseBranches.length} unmerged phase branch(es) remain.`, + }; + } + } + + return { valid: true, reason: "Merge order is valid" }; + } + + computeMilestoneTag(milestoneType: MilestoneType): string { + const tags = this.git("tag -l").split("\n").map((t) => t.trim()).filter(Boolean); + let major = 0; + let minor = 0; + let patch = 0; + + for (const tag of tags) { + const match = tag.match(/^v(\d+)\.(\d+)\.(\d+)$/); + if (match) { + const m = parseInt(match[1]); + const n = parseInt(match[2]); + const p = parseInt(match[3]); + if (m > major || (m === major && n > minor) || (m === major && n === minor && p > patch)) { + major = m; + minor = n; + patch = p; + } + } + } + + if (milestoneType === "schema-breaking") { + return `v${major + 1}.0.0`; + } + + return `v${major}.${minor + 1}.0`; + } + getPhaseStatus(phaseNumber: number): PhaseBranchInfo | null { const branches = this.gitContext.getBranches(); const phaseBranch = branches.find( diff --git a/src/core/git-context.test.ts b/src/core/git-context.test.ts index 5fafc5a..5b25892 100644 --- a/src/core/git-context.test.ts +++ b/src/core/git-context.test.ts @@ -279,4 +279,45 @@ status: execute expect(ctx.isNfrMilestone()).toBe(false); }); }); + + describe("getMilestoneType", () => { + it("returns nfr when only NFR commits exist", () => { + commit(repoDir, `chore(P01): cleanup + +---ci--- +phase: 1 +milestone: v0.1.1 +status: execute +---/ci---`); + + const ctx = new GitContext(repoDir); + expect(ctx.getMilestoneType()).toBe("nfr"); + }); + + it("returns feature when feat commits exist", () => { + commit(repoDir, `feat(P01): add feature + +---ci--- +phase: 1 +milestone: v1.0 +status: execute +---/ci---`); + + const ctx = new GitContext(repoDir); + expect(ctx.getMilestoneType()).toBe("feature"); + }); + + it("returns schema-breaking when refactor commits exist", () => { + commit(repoDir, `refactor(P01): rewrite core + +---ci--- +phase: 1 +milestone: v0.5 +status: execute +---/ci---`); + + const ctx = new GitContext(repoDir); + expect(ctx.getMilestoneType()).toBe("schema-breaking"); + }); + }); }); \ No newline at end of file diff --git a/src/core/git-context.ts b/src/core/git-context.ts index 5dfe19c..c43b55e 100644 --- a/src/core/git-context.ts +++ b/src/core/git-context.ts @@ -7,6 +7,8 @@ import { import { parseCommitMessage } from "./commit-parser.js"; import { PipelineStage } from "../types/pipeline.js"; +import { MilestoneType } from "../types/config.js"; + export interface ProjectState { currentPhase: number; currentMilestone: string; @@ -342,13 +344,20 @@ export class GitContext { return null; } - isNfrMilestone(): boolean { + getMilestoneType(): MilestoneType { const commits = this.getRecentCommits(100); + let hasAnyCiCommit = false; for (const commit of commits) { - if (commit.type === "feat" && commit.ci) { - return false; - } + if (!commit.ci) continue; + hasAnyCiCommit = true; + if (commit.type === "feat") return "feature"; + if (commit.type === "refactor" || commit.scope === "init") return "schema-breaking"; } - return true; + if (!hasAnyCiCommit) return "nfr"; + return "nfr"; + } + + isNfrMilestone(): boolean { + return this.getMilestoneType() === "nfr"; } } \ No newline at end of file diff --git a/src/types/config.ts b/src/types/config.ts index be6aaf2..fe148b3 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -6,6 +6,8 @@ export type ModelProfile = "quality" | "speed" | "balanced"; export type BranchingStrategy = "phase" | "feature" | "trunk"; +export type MilestoneType = "nfr" | "feature" | "schema-breaking"; + export type PhaseName = "research" | "plan" | "execute" | "verify" | "complete"; export type AgentName =