diff --git a/.changeset/disable-milestone-tags.md b/.changeset/disable-milestone-tags.md new file mode 100644 index 000000000..4f72e7905 --- /dev/null +++ b/.changeset/disable-milestone-tags.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 0 +--- +**Configurable milestone git-tag creation** — new `git.create_tag` boolean config (default `true`, backcompat) lets projects with their own release flow disable GSD's automatic `git tag -a v[X.Y]` on milestone completion. Set via `/gsd:settings` or `gsd config-set git.create_tag false`. Also adds tag-collision pre-check to prevent silent failure when re-running a milestone close. (#0) diff --git a/commands/gsd/complete-milestone.md b/commands/gsd/complete-milestone.md index fcd4e71bc..93ed2473f 100644 --- a/commands/gsd/complete-milestone.md +++ b/commands/gsd/complete-milestone.md @@ -126,7 +126,7 @@ Output: Milestone archived (roadmap + requirements), PROJECT.md evolved, git tag - `.planning/REQUIREMENTS.md` deleted (fresh for next milestone) - ROADMAP.md collapsed to one-line entry - PROJECT.md updated with current state -- Git tag v{{version}} created +- Git tag v{{version}} created (if `git.create_tag` enabled) - Commit successful - User knows next steps (including need for fresh requirements) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 73c8f38cf..581dcc293 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -542,6 +542,7 @@ All four fields are **optional and additive** — STATE.md files without them ke |---------|------|---------|-------------| | `git.branching_strategy` | enum | `none` | `none`, `phase`, or `milestone` | | `git.base_branch` | string | `main` | The integration branch that phase/milestone branches are created from and merged back into. Override when your repo uses `master` or a release branch | +| `git.create_tag` | boolean | `true` | Create a git tag (`v[X.Y]`) on milestone completion. Set to `false` for projects with their own release flow | | `git.phase_branch_template` | string | `gsd/phase-{phase}-{slug}` | Branch name template for phase strategy | | `git.milestone_branch_template` | string | `gsd/{milestone}-{slug}` | Branch name template for milestone strategy | | `git.quick_branch_template` | string or null | `null` | Optional branch name template for `/gsd-quick` tasks | diff --git a/get-shit-done/bin/lib/config-schema.cjs b/get-shit-done/bin/lib/config-schema.cjs index 92be21b4c..c002c251e 100644 --- a/get-shit-done/bin/lib/config-schema.cjs +++ b/get-shit-done/bin/lib/config-schema.cjs @@ -48,7 +48,7 @@ const VALID_CONFIG_KEYS = new Set([ 'code_quality.fallow.profile', 'code_quality.fallow.mcp', 'ship.pr_body_sections', - 'git.branching_strategy', 'git.base_branch', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template', + 'git.branching_strategy', 'git.base_branch', 'git.create_tag', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template', 'planning.commit_docs', 'planning.search_gitignored', 'planning.sub_repos', 'review.ollama_host', 'review.lm_studio_host', 'review.llama_cpp_host', 'review.default_reviewers', diff --git a/get-shit-done/bin/lib/config.cjs b/get-shit-done/bin/lib/config.cjs index b6bbeb1d7..4c843691c 100644 --- a/get-shit-done/bin/lib/config.cjs +++ b/get-shit-done/bin/lib/config.cjs @@ -432,6 +432,13 @@ function cmdConfigSet(cwd, keyPath, value, raw) { } } + // #3086 — git.create_tag: boolean only + if (keyPath === 'git.create_tag') { + if (typeof parsedValue !== 'boolean') { + error(`Invalid git.create_tag '${value}'. Must be a boolean (true or false).`); + } + } + if (keyPath === 'ship.pr_body_sections') { validateShipPrBodySections(parsedValue); } @@ -492,6 +499,7 @@ const SCHEMA_DEFAULTS = { 'context_window': 200000, 'executor.stall_detect_interval_minutes': 5, 'executor.stall_threshold_minutes': 10, + 'git.create_tag': true, }; function cmdConfigGet(cwd, keyPath, raw, defaultValue) { @@ -509,6 +517,10 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) { } else if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; + } else if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) { + const def = SCHEMA_DEFAULTS[keyPath]; + output(def, raw, String(def)); + return; } else { error('No config.json found at ' + configPath, ERROR_REASON.CONFIG_NO_FILE); } diff --git a/get-shit-done/references/planning-config.md b/get-shit-done/references/planning-config.md index fae155511..d58d29bd4 100644 --- a/get-shit-done/references/planning-config.md +++ b/get-shit-done/references/planning-config.md @@ -30,6 +30,7 @@ Configuration options for `.planning/` directory behavior. | `search_gitignored` | `false` | Add `--no-ignore` to broad rg searches | | `git.branching_strategy` | `"none"` | Git branching approach: `"none"`, `"phase"`, or `"milestone"` | | `git.base_branch` | `null` (auto-detect) | Target branch for PRs and merges (e.g. `"master"`, `"develop"`). When `null`, auto-detects from `git symbolic-ref refs/remotes/origin/HEAD`, falling back to `"main"`. | +| `git.create_tag` | `true` | Create git tags on milestone completion | | `git.phase_branch_template` | `"gsd/phase-{phase}-{slug}"` | Branch template for phase strategy | | `git.milestone_branch_template` | `"gsd/{milestone}-{slug}"` | Branch template for milestone strategy | | `git.quick_branch_template` | `null` | Optional branch template for quick-task runs | @@ -286,6 +287,7 @@ Set via `git.*` namespace (e.g., `"git": { "branching_strategy": "phase" }`). |-----|------|---------|----------------|-------------| | `git.branching_strategy` | string | `"none"` | `"none"`, `"phase"`, `"milestone"` | Git branching approach for phase/milestone isolation | | `git.base_branch` | string\|null | `null` (auto-detect) | Any branch name | Target branch for PRs and merges; auto-detects from `origin/HEAD` when `null` | +| `git.create_tag` | boolean | `true` | `true`, `false` | Create git tags on milestone completion | | `git.phase_branch_template` | string | `"gsd/phase-{phase}-{slug}"` | Template with `{phase}`, `{slug}` | Branch naming template for `phase` strategy | | `git.milestone_branch_template` | string | `"gsd/{milestone}-{slug}"` | Template with `{milestone}`, `{slug}` | Branch naming template for `milestone` strategy | | `git.quick_branch_template` | string\|null | `null` | Template with `{slug}` | Optional branch template for quick-task runs | diff --git a/get-shit-done/templates/config.json b/get-shit-done/templates/config.json index 49c50f9e5..4f4e0091b 100644 --- a/get-shit-done/templates/config.json +++ b/get-shit-done/templates/config.json @@ -28,6 +28,9 @@ "search_gitignored": false, "sub_repos": [] }, + "git": { + "create_tag": true + }, "parallelization": { "enabled": true, "plan_level": true, diff --git a/get-shit-done/workflows/complete-milestone.md b/get-shit-done/workflows/complete-milestone.md index 9a0c80324..7735b3154 100644 --- a/get-shit-done/workflows/complete-milestone.md +++ b/get-shit-done/workflows/complete-milestone.md @@ -724,9 +724,16 @@ fi + +Read `git.create_tag` via `gsd-sdk query config-get git.create_tag 2>/dev/null || echo "true"`. +If the result is `false` → skip this step entirely and proceed to `git_commit_milestone`. + + Create git tag: ```bash +# Pre-check: skip if tag already exists (prevents silent failure on retry) +if git rev-parse "v${milestone_version}" >/dev/null 2>&1; then echo "Tag v${milestone_version} already exists, skipping"; exit 0; fi git tag -a v[X.Y] -m "v[X.Y] [Name] Delivered: [One sentence] diff --git a/get-shit-done/workflows/settings.md b/get-shit-done/workflows/settings.md index 51642e375..f17726302 100644 --- a/get-shit-done/workflows/settings.md +++ b/get-shit-done/workflows/settings.md @@ -246,6 +246,15 @@ AskUserQuestion([ { label: "Per Milestone", description: "Create branch for entire milestone (gsd/{version}-{name})" } ] }, + { + question: "Create git tags on milestone completion?", + header: "Git Tagging", + multiSelect: false, + options: [ + { label: "Yes (Recommended)", description: "Tag releases with version (e.g., v1.0) on milestone completion" }, + { label: "No", description: "Skip git tagging — use if your project doesn't use tags or uses a different release convention" } + ] + }, { question: "Enable context window warnings? (injects advisory messages when context is getting full)", header: "Ctx Warnings", @@ -349,7 +358,8 @@ Merge new settings into existing config.json: }, "git": { "branching_strategy": "none" | "phase" | "milestone", - "quick_branch_template": + "quick_branch_template": , + "create_tag": true/false }, "hooks": { "context_warnings": true/false, @@ -450,6 +460,7 @@ Display: | UI Safety Gate | {On/Off} | | AI Integration Phase | {On/Off} | | Git Branching | {None/Per Phase/Per Milestone} | +| Git Tagging | {On/Off} | | Skip Discuss | {On/Off} | | Context Warnings | {On/Off} | | Saved as Defaults | {Yes/No} | @@ -470,7 +481,7 @@ Quick commands: - [ ] Current config read -- [ ] User presented with 22 settings (profile + workflow toggles + features + git branching + ctx warnings), grouped into six sections: Planning, Execution, Docs & Output, Features, Model & Pipeline, Misc. `code_review_depth` is conditional on `code_review=on`. +- [ ] User presented with 23 settings (profile + workflow toggles + features + git branching + git tagging + ctx warnings), grouped into six sections: Planning, Execution, Docs & Output, Features, Model & Pipeline, Misc. `code_review_depth` is conditional on `code_review=on`. - [ ] Config updated with model_profile, workflow, and git sections - [ ] User offered to save as global defaults (~/.gsd/defaults.json) - [ ] Changes confirmed to user diff --git a/sdk/src/query/config-schema.ts b/sdk/src/query/config-schema.ts index 022deb95b..c294c37e2 100644 --- a/sdk/src/query/config-schema.ts +++ b/sdk/src/query/config-schema.ts @@ -50,7 +50,7 @@ export const VALID_CONFIG_KEYS: ReadonlySet = new Set([ 'code_quality.fallow.profile', 'code_quality.fallow.mcp', 'ship.pr_body_sections', - 'git.branching_strategy', 'git.base_branch', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template', + 'git.branching_strategy', 'git.base_branch', 'git.create_tag', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template', 'planning.commit_docs', 'planning.search_gitignored', 'planning.sub_repos', 'review.default_reviewers', 'review.ollama_host', 'review.lm_studio_host', 'review.llama_cpp_host', diff --git a/tests/bug-3086-git-create-tag-config-gate.test.cjs b/tests/bug-3086-git-create-tag-config-gate.test.cjs new file mode 100644 index 000000000..793ad6274 --- /dev/null +++ b/tests/bug-3086-git-create-tag-config-gate.test.cjs @@ -0,0 +1,88 @@ +// allow-test-rule: workflow-markdown-is-the-runtime-contract +// Justification: complete-milestone.md IS the runtime — the agent reads and +// follows it directly. Asserting the block is present in the +// markdown is the only way to verify the gate is wired. Per CONTEXT.md L611. +'use strict'; + +/** + * #3086 — git.create_tag config gate for milestone tagging. + * + * Tests: + * A. Default value: fresh project returns `true` for git.create_tag + * B. config-set false → config-get returns false + * C. Invalid value (e.g. "maybe") is rejected by schema validator + * D. complete-milestone.md workflow contains the gate for git.create_tag + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); + +const WORKFLOW_PATH = path.join( + __dirname, + '..', + 'get-shit-done', + 'workflows', + 'complete-milestone.md', +); + +describe('#3086: git.create_tag config key', () => { + test('A. fresh project: config-get git.create_tag returns true (default)', (t) => { + const tmpDir = createTempProject('gsd-3086-default-'); + t.after(() => cleanup(tmpDir)); + + const result = runGsdTools(['config-get', 'git.create_tag'], tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `config-get git.create_tag failed:\n${result.error}`); + assert.strictEqual( + result.output.trim(), + 'true', + `Expected default value 'true', got: '${result.output.trim()}'`, + ); + }); + + test('B. config-set git.create_tag false → config-get returns false', (t) => { + const tmpDir = createTempProject('gsd-3086-set-false-'); + t.after(() => cleanup(tmpDir)); + + const setResult = runGsdTools(['config-set', 'git.create_tag', 'false'], tmpDir, { + HOME: tmpDir, + }); + assert.ok(setResult.success, `config-set git.create_tag false failed:\n${setResult.error}`); + + const getResult = runGsdTools(['config-get', 'git.create_tag'], tmpDir, { HOME: tmpDir }); + assert.ok(getResult.success, `config-get after set failed:\n${getResult.error}`); + assert.strictEqual( + getResult.output.trim(), + 'false', + `Expected 'false' after set, got: '${getResult.output.trim()}'`, + ); + }); + + test('C. config-set git.create_tag with invalid value "maybe" is rejected', (t) => { + const tmpDir = createTempProject('gsd-3086-invalid-'); + t.after(() => cleanup(tmpDir)); + + const result = runGsdTools(['config-set', 'git.create_tag', 'maybe'], tmpDir, { + HOME: tmpDir, + }); + assert.ok( + !result.success, + `Expected config-set to fail for invalid value "maybe", but it succeeded`, + ); + }); + + test('D. complete-milestone.md contains gate for git.create_tag', () => { + const content = fs.readFileSync(WORKFLOW_PATH, 'utf8'); + assert.ok( + content.includes('git.create_tag'), + 'complete-milestone.md must reference git.create_tag in a block', + ); + assert.ok( + content.includes(''), + 'complete-milestone.md must have a block in the git_tag step', + ); + }); +});