@tuan_son.dinh/gsd 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +453 -0
- package/dist/app-paths.d.ts +4 -0
- package/dist/app-paths.js +6 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +269 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +70 -0
- package/dist/logo.d.ts +16 -0
- package/dist/logo.js +25 -0
- package/dist/onboarding.d.ts +43 -0
- package/dist/onboarding.js +418 -0
- package/dist/pi-migration.d.ts +14 -0
- package/dist/pi-migration.js +57 -0
- package/dist/resource-loader.d.ts +22 -0
- package/dist/resource-loader.js +60 -0
- package/dist/tool-bootstrap.d.ts +4 -0
- package/dist/tool-bootstrap.js +74 -0
- package/dist/wizard.d.ts +7 -0
- package/dist/wizard.js +25 -0
- package/package.json +60 -0
- package/patches/@mariozechner+pi-coding-agent+0.57.1.patch +108 -0
- package/patches/@mariozechner+pi-tui+0.57.1.patch +47 -0
- package/pkg/dist/modes/interactive/theme/dark.json +85 -0
- package/pkg/dist/modes/interactive/theme/light.json +84 -0
- package/pkg/dist/modes/interactive/theme/theme-schema.json +335 -0
- package/pkg/dist/modes/interactive/theme/theme.d.ts +78 -0
- package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -0
- package/pkg/dist/modes/interactive/theme/theme.js +949 -0
- package/pkg/dist/modes/interactive/theme/theme.js.map +1 -0
- package/pkg/package.json +8 -0
- package/scripts/postinstall.js +127 -0
- package/src/resources/GSD-WORKFLOW.md +661 -0
- package/src/resources/agents/researcher.md +29 -0
- package/src/resources/agents/scout.md +56 -0
- package/src/resources/agents/worker.md +31 -0
- package/src/resources/extensions/ask-user-questions.ts +249 -0
- package/src/resources/extensions/bg-shell/index.ts +2808 -0
- package/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
- package/src/resources/extensions/browser-tools/core.js +1057 -0
- package/src/resources/extensions/browser-tools/index.ts +4989 -0
- package/src/resources/extensions/browser-tools/package.json +20 -0
- package/src/resources/extensions/context7/index.ts +428 -0
- package/src/resources/extensions/context7/package.json +11 -0
- package/src/resources/extensions/get-secrets-from-user.ts +352 -0
- package/src/resources/extensions/google-search/index.ts +323 -0
- package/src/resources/extensions/google-search/package.json +9 -0
- package/src/resources/extensions/gsd/activity-log.ts +69 -0
- package/src/resources/extensions/gsd/auto.ts +2744 -0
- package/src/resources/extensions/gsd/commands.ts +313 -0
- package/src/resources/extensions/gsd/crash-recovery.ts +85 -0
- package/src/resources/extensions/gsd/dashboard-overlay.ts +521 -0
- package/src/resources/extensions/gsd/docs/preferences-reference.md +176 -0
- package/src/resources/extensions/gsd/doctor.ts +690 -0
- package/src/resources/extensions/gsd/files.ts +732 -0
- package/src/resources/extensions/gsd/git-service.ts +597 -0
- package/src/resources/extensions/gsd/gitignore.ts +168 -0
- package/src/resources/extensions/gsd/guided-flow.ts +817 -0
- package/src/resources/extensions/gsd/index.ts +558 -0
- package/src/resources/extensions/gsd/metrics.ts +374 -0
- package/src/resources/extensions/gsd/migrate/command.ts +218 -0
- package/src/resources/extensions/gsd/migrate/index.ts +42 -0
- package/src/resources/extensions/gsd/migrate/parser.ts +323 -0
- package/src/resources/extensions/gsd/migrate/parsers.ts +624 -0
- package/src/resources/extensions/gsd/migrate/preview.ts +48 -0
- package/src/resources/extensions/gsd/migrate/transformer.ts +346 -0
- package/src/resources/extensions/gsd/migrate/types.ts +370 -0
- package/src/resources/extensions/gsd/migrate/validator.ts +55 -0
- package/src/resources/extensions/gsd/migrate/writer.ts +539 -0
- package/src/resources/extensions/gsd/observability-validator.ts +408 -0
- package/src/resources/extensions/gsd/package.json +11 -0
- package/src/resources/extensions/gsd/paths.ts +308 -0
- package/src/resources/extensions/gsd/preferences.ts +757 -0
- package/src/resources/extensions/gsd/prompt-loader.ts +50 -0
- package/src/resources/extensions/gsd/prompts/complete-milestone.md +25 -0
- package/src/resources/extensions/gsd/prompts/complete-slice.md +29 -0
- package/src/resources/extensions/gsd/prompts/discuss.md +189 -0
- package/src/resources/extensions/gsd/prompts/doctor-heal.md +29 -0
- package/src/resources/extensions/gsd/prompts/execute-task.md +61 -0
- package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -0
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -0
- package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +59 -0
- package/src/resources/extensions/gsd/prompts/guided-execute-task.md +1 -0
- package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +23 -0
- package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -0
- package/src/resources/extensions/gsd/prompts/guided-research-slice.md +11 -0
- package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -0
- package/src/resources/extensions/gsd/prompts/plan-milestone.md +65 -0
- package/src/resources/extensions/gsd/prompts/plan-slice.md +51 -0
- package/src/resources/extensions/gsd/prompts/queue.md +85 -0
- package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +48 -0
- package/src/resources/extensions/gsd/prompts/replan-slice.md +39 -0
- package/src/resources/extensions/gsd/prompts/research-milestone.md +37 -0
- package/src/resources/extensions/gsd/prompts/research-slice.md +28 -0
- package/src/resources/extensions/gsd/prompts/review-migration.md +66 -0
- package/src/resources/extensions/gsd/prompts/run-uat.md +109 -0
- package/src/resources/extensions/gsd/prompts/system.md +187 -0
- package/src/resources/extensions/gsd/prompts/worktree-merge.md +123 -0
- package/src/resources/extensions/gsd/session-forensics.ts +487 -0
- package/src/resources/extensions/gsd/skill-discovery.ts +137 -0
- package/src/resources/extensions/gsd/state.ts +460 -0
- package/src/resources/extensions/gsd/templates/context.md +76 -0
- package/src/resources/extensions/gsd/templates/decisions.md +8 -0
- package/src/resources/extensions/gsd/templates/milestone-summary.md +73 -0
- package/src/resources/extensions/gsd/templates/plan.md +131 -0
- package/src/resources/extensions/gsd/templates/preferences.md +24 -0
- package/src/resources/extensions/gsd/templates/project.md +31 -0
- package/src/resources/extensions/gsd/templates/reassessment.md +28 -0
- package/src/resources/extensions/gsd/templates/requirements.md +81 -0
- package/src/resources/extensions/gsd/templates/research.md +46 -0
- package/src/resources/extensions/gsd/templates/roadmap.md +118 -0
- package/src/resources/extensions/gsd/templates/slice-context.md +58 -0
- package/src/resources/extensions/gsd/templates/slice-summary.md +99 -0
- package/src/resources/extensions/gsd/templates/state.md +19 -0
- package/src/resources/extensions/gsd/templates/task-plan.md +52 -0
- package/src/resources/extensions/gsd/templates/task-summary.md +57 -0
- package/src/resources/extensions/gsd/templates/uat.md +54 -0
- package/src/resources/extensions/gsd/tests/activity-log-prune.test.ts +327 -0
- package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +56 -0
- package/src/resources/extensions/gsd/tests/auto-supervisor.test.mjs +53 -0
- package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +225 -0
- package/src/resources/extensions/gsd/tests/cost-projection.test.ts +160 -0
- package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +341 -0
- package/src/resources/extensions/gsd/tests/derive-state.test.ts +689 -0
- package/src/resources/extensions/gsd/tests/discuss-prompt.test.ts +38 -0
- package/src/resources/extensions/gsd/tests/doctor.test.ts +505 -0
- package/src/resources/extensions/gsd/tests/git-service.test.ts +1313 -0
- package/src/resources/extensions/gsd/tests/idle-recovery.test.ts +308 -0
- package/src/resources/extensions/gsd/tests/metrics-io.test.ts +201 -0
- package/src/resources/extensions/gsd/tests/metrics.test.ts +217 -0
- package/src/resources/extensions/gsd/tests/migrate-command.test.ts +390 -0
- package/src/resources/extensions/gsd/tests/migrate-parser.test.ts +786 -0
- package/src/resources/extensions/gsd/tests/migrate-transformer.test.ts +657 -0
- package/src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts +443 -0
- package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +318 -0
- package/src/resources/extensions/gsd/tests/migrate-writer.test.ts +420 -0
- package/src/resources/extensions/gsd/tests/must-have-parser.test.ts +309 -0
- package/src/resources/extensions/gsd/tests/parsers.test.ts +1351 -0
- package/src/resources/extensions/gsd/tests/plan-milestone.test.ts +163 -0
- package/src/resources/extensions/gsd/tests/plan-quality-validator.test.ts +386 -0
- package/src/resources/extensions/gsd/tests/reassess-prompt.test.ts +171 -0
- package/src/resources/extensions/gsd/tests/remote-questions.test.ts +155 -0
- package/src/resources/extensions/gsd/tests/remote-status.test.ts +99 -0
- package/src/resources/extensions/gsd/tests/replan-slice.test.ts +521 -0
- package/src/resources/extensions/gsd/tests/requirements.test.ts +125 -0
- package/src/resources/extensions/gsd/tests/resolve-ts-hooks.mjs +34 -0
- package/src/resources/extensions/gsd/tests/resolve-ts.mjs +11 -0
- package/src/resources/extensions/gsd/tests/run-uat.test.ts +348 -0
- package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +247 -0
- package/src/resources/extensions/gsd/tests/workflow-config.test.mjs +53 -0
- package/src/resources/extensions/gsd/tests/workspace-index.test.ts +94 -0
- package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +253 -0
- package/src/resources/extensions/gsd/tests/worktree-manager.test.ts +160 -0
- package/src/resources/extensions/gsd/tests/worktree.test.ts +264 -0
- package/src/resources/extensions/gsd/types.ts +159 -0
- package/src/resources/extensions/gsd/unit-runtime.ts +184 -0
- package/src/resources/extensions/gsd/workspace-index.ts +203 -0
- package/src/resources/extensions/gsd/worktree-command.ts +845 -0
- package/src/resources/extensions/gsd/worktree-manager.ts +392 -0
- package/src/resources/extensions/gsd/worktree.ts +183 -0
- package/src/resources/extensions/mac-tools/index.ts +852 -0
- package/src/resources/extensions/mac-tools/swift-cli/Package.swift +22 -0
- package/src/resources/extensions/mac-tools/swift-cli/Sources/main.swift +1318 -0
- package/src/resources/extensions/mcporter/index.ts +429 -0
- package/src/resources/extensions/remote-questions/config.ts +81 -0
- package/src/resources/extensions/remote-questions/discord-adapter.ts +128 -0
- package/src/resources/extensions/remote-questions/format.ts +163 -0
- package/src/resources/extensions/remote-questions/manager.ts +192 -0
- package/src/resources/extensions/remote-questions/remote-command.ts +307 -0
- package/src/resources/extensions/remote-questions/slack-adapter.ts +92 -0
- package/src/resources/extensions/remote-questions/status.ts +31 -0
- package/src/resources/extensions/remote-questions/store.ts +77 -0
- package/src/resources/extensions/remote-questions/types.ts +75 -0
- package/src/resources/extensions/search-the-web/cache.ts +78 -0
- package/src/resources/extensions/search-the-web/command-search-provider.ts +95 -0
- package/src/resources/extensions/search-the-web/format.ts +258 -0
- package/src/resources/extensions/search-the-web/http.ts +238 -0
- package/src/resources/extensions/search-the-web/index.ts +65 -0
- package/src/resources/extensions/search-the-web/native-search.ts +157 -0
- package/src/resources/extensions/search-the-web/provider.ts +118 -0
- package/src/resources/extensions/search-the-web/tavily.ts +116 -0
- package/src/resources/extensions/search-the-web/tool-fetch-page.ts +519 -0
- package/src/resources/extensions/search-the-web/tool-llm-context.ts +561 -0
- package/src/resources/extensions/search-the-web/tool-search.ts +576 -0
- package/src/resources/extensions/search-the-web/url-utils.ts +91 -0
- package/src/resources/extensions/shared/confirm-ui.ts +126 -0
- package/src/resources/extensions/shared/interview-ui.ts +613 -0
- package/src/resources/extensions/shared/next-action-ui.ts +197 -0
- package/src/resources/extensions/shared/progress-widget.ts +282 -0
- package/src/resources/extensions/shared/terminal.ts +23 -0
- package/src/resources/extensions/shared/thinking-widget.ts +107 -0
- package/src/resources/extensions/shared/ui.ts +400 -0
- package/src/resources/extensions/shared/wizard-ui.ts +551 -0
- package/src/resources/extensions/slash-commands/audit.ts +88 -0
- package/src/resources/extensions/slash-commands/clear.ts +10 -0
- package/src/resources/extensions/slash-commands/create-extension.ts +297 -0
- package/src/resources/extensions/slash-commands/create-slash-command.ts +234 -0
- package/src/resources/extensions/slash-commands/index.ts +12 -0
- package/src/resources/extensions/subagent/agents.ts +126 -0
- package/src/resources/extensions/subagent/index.ts +1020 -0
- package/src/resources/extensions/voice/index.ts +195 -0
- package/src/resources/extensions/voice/speech-recognizer.swift +154 -0
- package/src/resources/skills/debug-like-expert/SKILL.md +231 -0
- package/src/resources/skills/debug-like-expert/references/debugging-mindset.md +253 -0
- package/src/resources/skills/debug-like-expert/references/hypothesis-testing.md +373 -0
- package/src/resources/skills/debug-like-expert/references/investigation-techniques.md +337 -0
- package/src/resources/skills/debug-like-expert/references/verification-patterns.md +425 -0
- package/src/resources/skills/debug-like-expert/references/when-to-research.md +361 -0
- package/src/resources/skills/frontend-design/SKILL.md +45 -0
- package/src/resources/skills/swiftui/SKILL.md +208 -0
- package/src/resources/skills/swiftui/references/animations.md +921 -0
- package/src/resources/skills/swiftui/references/architecture.md +1561 -0
- package/src/resources/skills/swiftui/references/layout-system.md +1186 -0
- package/src/resources/skills/swiftui/references/navigation.md +1492 -0
- package/src/resources/skills/swiftui/references/networking-async.md +214 -0
- package/src/resources/skills/swiftui/references/performance.md +1706 -0
- package/src/resources/skills/swiftui/references/platform-integration.md +204 -0
- package/src/resources/skills/swiftui/references/state-management.md +1443 -0
- package/src/resources/skills/swiftui/references/swiftdata.md +297 -0
- package/src/resources/skills/swiftui/references/testing-debugging.md +247 -0
- package/src/resources/skills/swiftui/references/uikit-appkit-interop.md +218 -0
- package/src/resources/skills/swiftui/workflows/add-feature.md +191 -0
- package/src/resources/skills/swiftui/workflows/build-new-app.md +311 -0
- package/src/resources/skills/swiftui/workflows/debug-swiftui.md +192 -0
- package/src/resources/skills/swiftui/workflows/optimize-performance.md +197 -0
- package/src/resources/skills/swiftui/workflows/ship-app.md +203 -0
- package/src/resources/skills/swiftui/workflows/write-tests.md +235 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSD Git Service
|
|
3
|
+
*
|
|
4
|
+
* Core git operations for GSD: types, constants, and pure helpers.
|
|
5
|
+
* Higher-level operations (commit, staging, branching) build on these.
|
|
6
|
+
*
|
|
7
|
+
* This module centralizes the GitPreferences interface, runtime exclusion
|
|
8
|
+
* paths, commit type inference, and the runGit shell helper.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execSync } from "node:child_process";
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join, sep } from "node:path";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
detectWorktreeName,
|
|
17
|
+
getSliceBranchName,
|
|
18
|
+
SLICE_BRANCH_RE,
|
|
19
|
+
} from "./worktree.ts";
|
|
20
|
+
|
|
21
|
+
// ─── Types ─────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export interface GitPreferences {
|
|
24
|
+
auto_push?: boolean;
|
|
25
|
+
push_branches?: boolean;
|
|
26
|
+
remote?: string;
|
|
27
|
+
snapshots?: boolean;
|
|
28
|
+
pre_merge_check?: boolean | string;
|
|
29
|
+
commit_type?: string;
|
|
30
|
+
main_branch?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/;
|
|
34
|
+
|
|
35
|
+
export interface CommitOptions {
|
|
36
|
+
message: string;
|
|
37
|
+
allowEmpty?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MergeSliceResult {
|
|
41
|
+
branch: string;
|
|
42
|
+
mergedCommitMessage: string;
|
|
43
|
+
deletedBranch: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PreMergeCheckResult {
|
|
47
|
+
passed: boolean;
|
|
48
|
+
skipped?: boolean;
|
|
49
|
+
command?: string;
|
|
50
|
+
error?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Constants ─────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* GSD runtime paths that should be excluded from smart staging.
|
|
57
|
+
* These are transient/generated artifacts that should never be committed.
|
|
58
|
+
* Matches the union of SKIP_PATHS + SKIP_EXACT in worktree-manager.ts
|
|
59
|
+
* and the first 6 entries in gitignore.ts BASELINE_PATTERNS.
|
|
60
|
+
*/
|
|
61
|
+
export const RUNTIME_EXCLUSION_PATHS: readonly string[] = [
|
|
62
|
+
".gsd/activity/",
|
|
63
|
+
".gsd/runtime/",
|
|
64
|
+
".gsd/worktrees/",
|
|
65
|
+
".gsd/auto.lock",
|
|
66
|
+
".gsd/metrics.json",
|
|
67
|
+
".gsd/STATE.md",
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
// ─── Git Helper ────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run a git command in the given directory.
|
|
74
|
+
* Returns trimmed stdout. Throws on non-zero exit unless allowFailure is set.
|
|
75
|
+
* When `input` is provided, it is piped to stdin.
|
|
76
|
+
*/
|
|
77
|
+
export function runGit(basePath: string, args: string[], options: { allowFailure?: boolean; input?: string } = {}): string {
|
|
78
|
+
try {
|
|
79
|
+
return execSync(`git ${args.join(" ")}`, {
|
|
80
|
+
cwd: basePath,
|
|
81
|
+
stdio: [options.input != null ? "pipe" : "ignore", "pipe", "pipe"],
|
|
82
|
+
encoding: "utf-8",
|
|
83
|
+
...(options.input != null ? { input: options.input } : {}),
|
|
84
|
+
}).trim();
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (options.allowFailure) return "";
|
|
87
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
88
|
+
throw new Error(`git ${args.join(" ")} failed in ${basePath}: ${message}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Commit Type Inference ─────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Keyword-to-commit-type mapping. Order matters — first match wins.
|
|
96
|
+
* Each entry: [keywords[], commitType]
|
|
97
|
+
*/
|
|
98
|
+
const COMMIT_TYPE_RULES: [string[], string][] = [
|
|
99
|
+
[["fix", "bug", "patch", "hotfix"], "fix"],
|
|
100
|
+
[["refactor", "restructure", "reorganize"], "refactor"],
|
|
101
|
+
[["doc", "docs", "documentation"], "docs"],
|
|
102
|
+
[["test", "tests", "testing"], "test"],
|
|
103
|
+
[["chore", "cleanup", "clean up", "archive", "remove", "delete"], "chore"],
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Infer a conventional commit type from a slice title.
|
|
108
|
+
* Uses case-insensitive word-boundary matching against known keywords.
|
|
109
|
+
* Returns "feat" when no keywords match.
|
|
110
|
+
*/
|
|
111
|
+
// ─── GitServiceImpl ────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
export class GitServiceImpl {
|
|
114
|
+
readonly basePath: string;
|
|
115
|
+
readonly prefs: GitPreferences;
|
|
116
|
+
|
|
117
|
+
constructor(basePath: string, prefs: GitPreferences = {}) {
|
|
118
|
+
this.basePath = basePath;
|
|
119
|
+
this.prefs = prefs;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Convenience wrapper: run git in this repo's basePath. */
|
|
123
|
+
private git(args: string[], options: { allowFailure?: boolean; input?: string } = {}): string {
|
|
124
|
+
return runGit(this.basePath, args, options);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Smart staging: `git add -A` excluding GSD runtime paths via pathspec.
|
|
129
|
+
* Falls back to plain `git add -A` if the exclusion pathspec fails.
|
|
130
|
+
*/
|
|
131
|
+
private smartStage(): void {
|
|
132
|
+
const excludes = RUNTIME_EXCLUSION_PATHS.map(p => `':(exclude)${p}'`);
|
|
133
|
+
const args = ["add", "-A", "--", ".", ...excludes];
|
|
134
|
+
try {
|
|
135
|
+
this.git(args);
|
|
136
|
+
} catch {
|
|
137
|
+
console.error("GitService: smart staging failed, falling back to git add -A");
|
|
138
|
+
this.git(["add", "-A"]);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Stage files (smart staging) and commit.
|
|
144
|
+
* Returns the commit message string on success, or null if nothing to commit.
|
|
145
|
+
* Uses `git commit -F -` with stdin pipe for safe multi-line message handling.
|
|
146
|
+
*/
|
|
147
|
+
commit(opts: CommitOptions): string | null {
|
|
148
|
+
this.smartStage();
|
|
149
|
+
|
|
150
|
+
// Check if anything was actually staged
|
|
151
|
+
const staged = this.git(["diff", "--cached", "--stat"], { allowFailure: true });
|
|
152
|
+
if (!staged && !opts.allowEmpty) return null;
|
|
153
|
+
|
|
154
|
+
this.git(
|
|
155
|
+
["commit", "-F", "-", ...(opts.allowEmpty ? ["--allow-empty"] : [])],
|
|
156
|
+
{ input: opts.message },
|
|
157
|
+
);
|
|
158
|
+
return opts.message;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Auto-commit dirty working tree with a conventional chore message.
|
|
163
|
+
* Returns the commit message on success, or null if nothing to commit.
|
|
164
|
+
*/
|
|
165
|
+
autoCommit(unitType: string, unitId: string): string | null {
|
|
166
|
+
// Quick check: is there anything dirty at all?
|
|
167
|
+
const status = this.git(["status", "--short"], { allowFailure: true });
|
|
168
|
+
if (!status) return null;
|
|
169
|
+
|
|
170
|
+
this.smartStage();
|
|
171
|
+
|
|
172
|
+
// After smart staging, check if anything was actually staged
|
|
173
|
+
// (all changes might have been runtime files that got excluded)
|
|
174
|
+
const staged = this.git(["diff", "--cached", "--stat"], { allowFailure: true });
|
|
175
|
+
if (!staged) return null;
|
|
176
|
+
|
|
177
|
+
const message = `chore(${unitId}): auto-commit after ${unitType}`;
|
|
178
|
+
this.git(["commit", "-F", "-"], { input: message });
|
|
179
|
+
return message;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── Branch Queries ────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get the "main" branch for this repo.
|
|
186
|
+
* In a worktree: returns worktree/<name> (the worktree's base branch).
|
|
187
|
+
* In the main tree: origin/HEAD symbolic-ref → main/master fallback → current branch.
|
|
188
|
+
*/
|
|
189
|
+
getMainBranch(): string {
|
|
190
|
+
const wtName = detectWorktreeName(this.basePath);
|
|
191
|
+
if (wtName) {
|
|
192
|
+
const wtBranch = `worktree/${wtName}`;
|
|
193
|
+
const exists = this.git(["show-ref", "--verify", `refs/heads/${wtBranch}`], { allowFailure: true });
|
|
194
|
+
if (exists) return wtBranch;
|
|
195
|
+
return this.git(["branch", "--show-current"]);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Explicit preference takes priority over auto-detection
|
|
199
|
+
const configured = this.prefs.main_branch;
|
|
200
|
+
if (configured && VALID_BRANCH_NAME.test(configured)) {
|
|
201
|
+
return configured;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const symbolic = this.git(["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFailure: true });
|
|
205
|
+
if (symbolic) {
|
|
206
|
+
const match = symbolic.match(/refs\/remotes\/origin\/(.+)$/);
|
|
207
|
+
if (match) return match[1]!;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const mainExists = this.git(["show-ref", "--verify", "refs/heads/main"], { allowFailure: true });
|
|
211
|
+
if (mainExists) return "main";
|
|
212
|
+
|
|
213
|
+
const masterExists = this.git(["show-ref", "--verify", "refs/heads/master"], { allowFailure: true });
|
|
214
|
+
if (masterExists) return "master";
|
|
215
|
+
|
|
216
|
+
return this.git(["branch", "--show-current"]);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Get the current branch name. */
|
|
220
|
+
getCurrentBranch(): string {
|
|
221
|
+
return this.git(["branch", "--show-current"]);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** True if currently on a GSD slice branch. */
|
|
225
|
+
isOnSliceBranch(): boolean {
|
|
226
|
+
const current = this.getCurrentBranch();
|
|
227
|
+
return SLICE_BRANCH_RE.test(current);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Returns the slice branch name if on one, null otherwise. */
|
|
231
|
+
getActiveSliceBranch(): string | null {
|
|
232
|
+
try {
|
|
233
|
+
const current = this.getCurrentBranch();
|
|
234
|
+
return SLICE_BRANCH_RE.test(current) ? current : null;
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── Branch Lifecycle ──────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Check if a local branch exists.
|
|
244
|
+
*/
|
|
245
|
+
private branchExists(branch: string): boolean {
|
|
246
|
+
try {
|
|
247
|
+
this.git(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
248
|
+
return true;
|
|
249
|
+
} catch {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Ensure the slice branch exists and is checked out.
|
|
256
|
+
*
|
|
257
|
+
* Creates the branch from the current working branch if it's not a slice
|
|
258
|
+
* branch (preserves planning artifacts). Falls back to main when on another
|
|
259
|
+
* slice branch (avoids chaining slice branches).
|
|
260
|
+
*
|
|
261
|
+
* When creating a new branch, fetches from remote first (best-effort) to
|
|
262
|
+
* ensure the local main is up-to-date.
|
|
263
|
+
*
|
|
264
|
+
* Auto-commits dirty state via smart staging before checkout so runtime
|
|
265
|
+
* files are never accidentally committed during branch switches.
|
|
266
|
+
*
|
|
267
|
+
* Returns true if the branch was newly created.
|
|
268
|
+
*/
|
|
269
|
+
ensureSliceBranch(milestoneId: string, sliceId: string): boolean {
|
|
270
|
+
const wtName = detectWorktreeName(this.basePath);
|
|
271
|
+
const branch = getSliceBranchName(milestoneId, sliceId, wtName);
|
|
272
|
+
const current = this.getCurrentBranch();
|
|
273
|
+
|
|
274
|
+
if (current === branch) return false;
|
|
275
|
+
|
|
276
|
+
let created = false;
|
|
277
|
+
|
|
278
|
+
if (!this.branchExists(branch)) {
|
|
279
|
+
// Fetch from remote before creating a new branch (best-effort).
|
|
280
|
+
const remotes = this.git(["remote"], { allowFailure: true });
|
|
281
|
+
if (remotes) {
|
|
282
|
+
const remote = this.prefs.remote ?? "origin";
|
|
283
|
+
const fetchResult = this.git(["fetch", "--prune", remote], { allowFailure: true });
|
|
284
|
+
// fetchResult is empty string on both success and allowFailure-caught error.
|
|
285
|
+
// Check if local is behind upstream (informational only).
|
|
286
|
+
if (remotes.split("\n").includes(remote)) {
|
|
287
|
+
const behind = this.git(
|
|
288
|
+
["rev-list", "--count", "HEAD..@{upstream}"],
|
|
289
|
+
{ allowFailure: true },
|
|
290
|
+
);
|
|
291
|
+
if (behind && parseInt(behind, 10) > 0) {
|
|
292
|
+
console.error(`GitService: local branch is ${behind} commit(s) behind upstream`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Branch from current when it's a normal working branch (not a slice).
|
|
298
|
+
// If already on a slice branch, fall back to main to avoid chaining.
|
|
299
|
+
const mainBranch = this.getMainBranch();
|
|
300
|
+
const base = SLICE_BRANCH_RE.test(current) ? mainBranch : current;
|
|
301
|
+
this.git(["branch", branch, base]);
|
|
302
|
+
created = true;
|
|
303
|
+
} else {
|
|
304
|
+
// Branch exists — check it's not checked out in another worktree
|
|
305
|
+
const worktreeList = this.git(["worktree", "list", "--porcelain"]);
|
|
306
|
+
if (worktreeList.includes(`branch refs/heads/${branch}`)) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
`Branch "${branch}" is already in use by another worktree. ` +
|
|
309
|
+
`Remove that worktree first, or switch it to a different branch.`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Auto-commit dirty state via smart staging before checkout
|
|
315
|
+
this.autoCommit("pre-switch", current);
|
|
316
|
+
|
|
317
|
+
this.git(["checkout", branch]);
|
|
318
|
+
return created;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Switch to main, auto-committing dirty state via smart staging first.
|
|
323
|
+
*/
|
|
324
|
+
switchToMain(): void {
|
|
325
|
+
const mainBranch = this.getMainBranch();
|
|
326
|
+
const current = this.getCurrentBranch();
|
|
327
|
+
if (current === mainBranch) return;
|
|
328
|
+
|
|
329
|
+
this.autoCommit("pre-switch", current);
|
|
330
|
+
|
|
331
|
+
this.git(["checkout", mainBranch]);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ─── S05 Features ─────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Create a snapshot ref for the given label (typically a slice branch name).
|
|
338
|
+
* Gated on prefs.snapshots === true. Ref path: refs/gsd/snapshots/<label>/<timestamp>
|
|
339
|
+
* The ref points at HEAD, capturing the current commit before destructive operations.
|
|
340
|
+
*/
|
|
341
|
+
createSnapshot(label: string): void {
|
|
342
|
+
if (this.prefs.snapshots !== true) return;
|
|
343
|
+
|
|
344
|
+
const now = new Date();
|
|
345
|
+
const ts = now.getFullYear().toString()
|
|
346
|
+
+ String(now.getMonth() + 1).padStart(2, "0")
|
|
347
|
+
+ String(now.getDate()).padStart(2, "0")
|
|
348
|
+
+ "-"
|
|
349
|
+
+ String(now.getHours()).padStart(2, "0")
|
|
350
|
+
+ String(now.getMinutes()).padStart(2, "0")
|
|
351
|
+
+ String(now.getSeconds()).padStart(2, "0");
|
|
352
|
+
|
|
353
|
+
const refPath = `refs/gsd/snapshots/${label}/${ts}`;
|
|
354
|
+
this.git(["update-ref", refPath, "HEAD"]);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Run pre-merge verification check. Auto-detects test runner from project
|
|
359
|
+
* files, or uses custom command from prefs.pre_merge_check.
|
|
360
|
+
*
|
|
361
|
+
* Gating:
|
|
362
|
+
* - `false` → skip (return passed:true, skipped:true)
|
|
363
|
+
* - non-empty string (not "auto") → use as custom command
|
|
364
|
+
* - `true`, `"auto"`, or `undefined` → auto-detect from project files
|
|
365
|
+
*
|
|
366
|
+
* Auto-detection order:
|
|
367
|
+
* package.json scripts.test → npm test
|
|
368
|
+
* package.json scripts.build (only if no test) → npm run build
|
|
369
|
+
* Cargo.toml → cargo test
|
|
370
|
+
* Makefile with test: target → make test
|
|
371
|
+
* pyproject.toml → python -m pytest
|
|
372
|
+
*
|
|
373
|
+
* If no runner detected in auto mode, returns passed:true (don't block).
|
|
374
|
+
*/
|
|
375
|
+
runPreMergeCheck(): PreMergeCheckResult {
|
|
376
|
+
const pref = this.prefs.pre_merge_check;
|
|
377
|
+
|
|
378
|
+
// Explicitly disabled
|
|
379
|
+
if (pref === false) {
|
|
380
|
+
return { passed: true, skipped: true };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let command: string | null = null;
|
|
384
|
+
|
|
385
|
+
// Custom string command (not "auto")
|
|
386
|
+
if (typeof pref === "string" && pref !== "auto" && pref.trim() !== "") {
|
|
387
|
+
command = pref.trim();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Auto-detect (true, "auto", or undefined)
|
|
391
|
+
if (command === null) {
|
|
392
|
+
command = this.detectTestRunner();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (command === null) {
|
|
396
|
+
return { passed: true, command: "none", error: "no test runner detected" };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Execute the command
|
|
400
|
+
try {
|
|
401
|
+
execSync(command, {
|
|
402
|
+
cwd: this.basePath,
|
|
403
|
+
timeout: 300_000,
|
|
404
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
405
|
+
encoding: "utf-8",
|
|
406
|
+
});
|
|
407
|
+
return { passed: true, command };
|
|
408
|
+
} catch (err) {
|
|
409
|
+
const stderr = err instanceof Error && "stderr" in err
|
|
410
|
+
? String((err as { stderr: unknown }).stderr).slice(0, 2000)
|
|
411
|
+
: String(err).slice(0, 2000);
|
|
412
|
+
return { passed: false, command, error: stderr };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Detect a test/build runner from project files in basePath.
|
|
418
|
+
* Returns the command string or null if nothing detected.
|
|
419
|
+
*/
|
|
420
|
+
private detectTestRunner(): string | null {
|
|
421
|
+
const pkgPath = join(this.basePath, "package.json");
|
|
422
|
+
if (existsSync(pkgPath)) {
|
|
423
|
+
try {
|
|
424
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
425
|
+
if (pkg?.scripts?.test) return "npm test";
|
|
426
|
+
if (pkg?.scripts?.build) return "npm run build";
|
|
427
|
+
} catch { /* invalid JSON — skip */ }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (existsSync(join(this.basePath, "Cargo.toml"))) {
|
|
431
|
+
return "cargo test";
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const makefilePath = join(this.basePath, "Makefile");
|
|
435
|
+
if (existsSync(makefilePath)) {
|
|
436
|
+
try {
|
|
437
|
+
const content = readFileSync(makefilePath, "utf-8");
|
|
438
|
+
if (/^test\s*:/m.test(content)) return "make test";
|
|
439
|
+
} catch { /* skip */ }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (existsSync(join(this.basePath, "pyproject.toml"))) {
|
|
443
|
+
return "python -m pytest";
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ─── Merge ─────────────────────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Build a rich squash-commit message with a task list from branch commits.
|
|
453
|
+
*
|
|
454
|
+
* Format:
|
|
455
|
+
* type(scope): title
|
|
456
|
+
*
|
|
457
|
+
* Tasks:
|
|
458
|
+
* - commit subject 1
|
|
459
|
+
* - commit subject 2
|
|
460
|
+
*
|
|
461
|
+
* Branch: gsd/M001/S01
|
|
462
|
+
*/
|
|
463
|
+
private buildRichCommitMessage(
|
|
464
|
+
commitType: string,
|
|
465
|
+
milestoneId: string,
|
|
466
|
+
sliceId: string,
|
|
467
|
+
sliceTitle: string,
|
|
468
|
+
mainBranch: string,
|
|
469
|
+
branch: string,
|
|
470
|
+
): string {
|
|
471
|
+
const subject = `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`;
|
|
472
|
+
|
|
473
|
+
// Collect branch commit subjects
|
|
474
|
+
const logOutput = this.git(
|
|
475
|
+
["log", "--oneline", "--format=%s", `${mainBranch}..${branch}`],
|
|
476
|
+
{ allowFailure: true },
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
if (!logOutput) return subject;
|
|
480
|
+
|
|
481
|
+
const subjects = logOutput.split("\n").filter(Boolean);
|
|
482
|
+
const MAX_ENTRIES = 20;
|
|
483
|
+
const truncated = subjects.length > MAX_ENTRIES;
|
|
484
|
+
const displayed = truncated ? subjects.slice(0, MAX_ENTRIES) : subjects;
|
|
485
|
+
|
|
486
|
+
const taskLines = displayed.map(s => `- ${s}`).join("\n");
|
|
487
|
+
const truncationLine = truncated ? `\n- ... and ${subjects.length - MAX_ENTRIES} more` : "";
|
|
488
|
+
|
|
489
|
+
return `${subject}\n\nTasks:\n${taskLines}${truncationLine}\n\nBranch: ${branch}`;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Squash-merge a slice branch into main and delete it.
|
|
494
|
+
*
|
|
495
|
+
* Flow: snapshot branch HEAD → squash merge → rich commit via stdin →
|
|
496
|
+
* auto-push (if enabled) → delete branch.
|
|
497
|
+
*
|
|
498
|
+
* Must be called from the main branch. Uses `inferCommitType(sliceTitle)`
|
|
499
|
+
* for the conventional commit type instead of hardcoding `feat`.
|
|
500
|
+
*
|
|
501
|
+
* Throws when:
|
|
502
|
+
* - Not currently on the main branch
|
|
503
|
+
* - The slice branch does not exist
|
|
504
|
+
* - The slice branch has no commits ahead of main
|
|
505
|
+
*/
|
|
506
|
+
mergeSliceToMain(milestoneId: string, sliceId: string, sliceTitle: string): MergeSliceResult {
|
|
507
|
+
const mainBranch = this.getMainBranch();
|
|
508
|
+
const current = this.getCurrentBranch();
|
|
509
|
+
|
|
510
|
+
if (current !== mainBranch) {
|
|
511
|
+
throw new Error(
|
|
512
|
+
`mergeSliceToMain must be called from the main branch ("${mainBranch}"), ` +
|
|
513
|
+
`but currently on "${current}"`,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const wtName = detectWorktreeName(this.basePath);
|
|
518
|
+
const branch = getSliceBranchName(milestoneId, sliceId, wtName);
|
|
519
|
+
|
|
520
|
+
if (!this.branchExists(branch)) {
|
|
521
|
+
throw new Error(
|
|
522
|
+
`Slice branch "${branch}" does not exist. Nothing to merge.`,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Check commits ahead
|
|
527
|
+
const aheadCount = this.git(["rev-list", "--count", `${mainBranch}..${branch}`]);
|
|
528
|
+
if (aheadCount === "0") {
|
|
529
|
+
throw new Error(
|
|
530
|
+
`Slice branch "${branch}" has no commits ahead of "${mainBranch}". Nothing to merge.`,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Snapshot the branch HEAD before merge (gated on prefs.snapshots)
|
|
535
|
+
this.createSnapshot(branch);
|
|
536
|
+
|
|
537
|
+
// Build rich commit message before squash (needs branch history)
|
|
538
|
+
const commitType = inferCommitType(sliceTitle);
|
|
539
|
+
const message = this.buildRichCommitMessage(
|
|
540
|
+
commitType, milestoneId, sliceId, sliceTitle, mainBranch, branch,
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
// Squash merge
|
|
544
|
+
this.git(["merge", "--squash", branch]);
|
|
545
|
+
|
|
546
|
+
// Pre-merge check: run after squash (tests merged result), reset on failure
|
|
547
|
+
const checkResult = this.runPreMergeCheck();
|
|
548
|
+
if (!checkResult.passed && !checkResult.skipped) {
|
|
549
|
+
// Undo the squash merge — nothing committed yet, reset staging area
|
|
550
|
+
this.git(["reset", "--hard", "HEAD"]);
|
|
551
|
+
const cmdInfo = checkResult.command ? ` (command: ${checkResult.command})` : "";
|
|
552
|
+
const errInfo = checkResult.error ? `\n${checkResult.error}` : "";
|
|
553
|
+
throw new Error(
|
|
554
|
+
`Pre-merge check failed${cmdInfo}. Merge aborted.${errInfo}`,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Commit with rich message via stdin pipe
|
|
559
|
+
this.git(["commit", "-F", "-"], { input: message });
|
|
560
|
+
|
|
561
|
+
// Delete the merged branch
|
|
562
|
+
this.git(["branch", "-D", branch]);
|
|
563
|
+
|
|
564
|
+
// Auto-push to remote if enabled
|
|
565
|
+
if (this.prefs.auto_push === true) {
|
|
566
|
+
const remote = this.prefs.remote ?? "origin";
|
|
567
|
+
this.git(["push", remote, mainBranch], { allowFailure: true });
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
branch,
|
|
572
|
+
mergedCommitMessage: `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`,
|
|
573
|
+
deletedBranch: true,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ─── Commit Type Inference ─────────────────────────────────────────────────
|
|
579
|
+
|
|
580
|
+
export function inferCommitType(sliceTitle: string): string {
|
|
581
|
+
const lower = sliceTitle.toLowerCase();
|
|
582
|
+
|
|
583
|
+
for (const [keywords, commitType] of COMMIT_TYPE_RULES) {
|
|
584
|
+
for (const keyword of keywords) {
|
|
585
|
+
// "clean up" is multi-word — use indexOf for it
|
|
586
|
+
if (keyword.includes(" ")) {
|
|
587
|
+
if (lower.includes(keyword)) return commitType;
|
|
588
|
+
} else {
|
|
589
|
+
// Word boundary match: keyword must not be surrounded by word chars
|
|
590
|
+
const re = new RegExp(`\\b${keyword}\\b`, "i");
|
|
591
|
+
if (re.test(lower)) return commitType;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return "feat";
|
|
597
|
+
}
|