gsd-pi 2.4.0 → 2.5.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/README.md +4 -3
- package/package.json +2 -2
- package/src/resources/GSD-WORKFLOW.md +7 -7
- package/src/resources/extensions/gsd/auto.ts +78 -23
- package/src/resources/extensions/gsd/docs/preferences-reference.md +27 -0
- package/src/resources/extensions/gsd/git-service.ts +230 -11
- package/src/resources/extensions/gsd/preferences.ts +51 -0
- package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
- package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -1
- package/src/resources/extensions/gsd/prompts/execute-task.md +1 -1
- package/src/resources/extensions/gsd/prompts/replan-slice.md +1 -1
- package/src/resources/extensions/gsd/templates/preferences.md +7 -0
- package/src/resources/extensions/gsd/tests/git-service.test.ts +358 -0
- package/src/resources/extensions/gsd/tests/replan-slice.test.ts +0 -2
- package/src/resources/extensions/gsd/worktree-command.ts +48 -6
- package/src/resources/extensions/gsd/worktree.ts +40 -147
- package/src/resources/extensions/search-the-web/index.ts +16 -25
- package/src/resources/extensions/search-the-web/native-search.ts +157 -0
package/README.md
CHANGED
|
@@ -252,17 +252,18 @@ Branch-per-slice with squash merge. Fully automated.
|
|
|
252
252
|
|
|
253
253
|
```
|
|
254
254
|
main:
|
|
255
|
-
|
|
255
|
+
docs(M001/S04): workflow documentation and examples
|
|
256
|
+
fix(M001/S03): bug fixes and doc corrections
|
|
256
257
|
feat(M001/S02): API endpoints and middleware
|
|
257
258
|
feat(M001/S01): data model and type system
|
|
258
259
|
|
|
259
|
-
gsd/M001/S01 (
|
|
260
|
+
gsd/M001/S01 (deleted after merge):
|
|
260
261
|
feat(S01/T03): file writer with round-trip fidelity
|
|
261
262
|
feat(S01/T02): markdown parser for plan files
|
|
262
263
|
feat(S01/T01): core types and interfaces
|
|
263
264
|
```
|
|
264
265
|
|
|
265
|
-
One commit per slice on main.
|
|
266
|
+
One commit per slice on main. Squash commits are the permanent record — branches are deleted after merge. Git bisect works. Individual slices are revertable.
|
|
266
267
|
|
|
267
268
|
### Verification
|
|
268
269
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gsd-pi",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "GSD — Get Shit Done coding agent",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"scripts": {
|
|
36
36
|
"build": "tsc && npm run copy-themes",
|
|
37
37
|
"copy-themes": "node -e \"const{mkdirSync,cpSync}=require('fs');const{resolve}=require('path');const src=resolve(__dirname,'node_modules/@mariozechner/pi-coding-agent/dist/modes/interactive/theme');mkdirSync('pkg/dist/modes/interactive/theme',{recursive:true});cpSync(src,'pkg/dist/modes/interactive/theme',{recursive:true})\"",
|
|
38
|
-
"test": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test
|
|
38
|
+
"test": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts",
|
|
39
39
|
"dev": "tsc --watch",
|
|
40
40
|
"postinstall": "node scripts/postinstall.js",
|
|
41
41
|
"pi:install-global": "node scripts/install-pi-global.js",
|
|
@@ -548,7 +548,7 @@ If files disagree, **pause and surface to the user**:
|
|
|
548
548
|
1. **Slice starts** → create branch `gsd/M001/S01` from main
|
|
549
549
|
2. **Per-task commits** on the branch — atomic, descriptive, bisectable
|
|
550
550
|
3. **Slice completes** → squash merge to main as one clean commit
|
|
551
|
-
4. **Branch
|
|
551
|
+
4. **Branch deleted** — squash commit on main is the permanent record
|
|
552
552
|
|
|
553
553
|
### What Main Looks Like
|
|
554
554
|
|
|
@@ -566,21 +566,21 @@ One commit per slice. Individually revertable. Reads like a changelog.
|
|
|
566
566
|
gsd/M001/S01:
|
|
567
567
|
test(S01): round-trip tests passing
|
|
568
568
|
feat(S01/T03): file writer with round-trip fidelity
|
|
569
|
-
|
|
569
|
+
chore(S01/T03): auto-commit after task
|
|
570
570
|
feat(S01/T02): markdown parser for plan files
|
|
571
|
-
|
|
571
|
+
chore(S01/T02): auto-commit after task
|
|
572
572
|
feat(S01/T01): core types and interfaces
|
|
573
|
-
|
|
573
|
+
chore(S01/T01): auto-commit after task
|
|
574
574
|
```
|
|
575
575
|
|
|
576
576
|
### Commit Conventions
|
|
577
577
|
|
|
578
578
|
| When | Format | Example |
|
|
579
579
|
|------|--------|---------|
|
|
580
|
-
|
|
|
580
|
+
| Auto-commit (dirty state) | `chore(S01/T02): auto-commit after task` | Automatic save of work in progress |
|
|
581
581
|
| After task verified | `feat(S01/T02): <what was built>` | The real work |
|
|
582
582
|
| Plan/docs committed | `docs(S01): add slice plan` | Bundled with first task |
|
|
583
|
-
| Slice squash to main | `
|
|
583
|
+
| Slice squash to main | `type(M001/S01): <slice title>` | Type inferred from title (`feat`, `fix`, `docs`, etc.) |
|
|
584
584
|
|
|
585
585
|
Commit types: `feat`, `fix`, `test`, `refactor`, `docs`, `chore`
|
|
586
586
|
|
|
@@ -601,7 +601,7 @@ Tasks completed:
|
|
|
601
601
|
|
|
602
602
|
| Problem | Fix |
|
|
603
603
|
|---------|-----|
|
|
604
|
-
| Bad task | `git reset --hard` to
|
|
604
|
+
| Bad task | `git reset --hard HEAD~1` to previous commit on the branch |
|
|
605
605
|
| Bad slice | `git revert <squash commit>` on main |
|
|
606
606
|
| UAT failure after merge | Fix tasks on `gsd/M001/S01-fix` branch, squash as `fix(M001/S01): <fix>` |
|
|
607
607
|
|
|
@@ -62,11 +62,12 @@ import {
|
|
|
62
62
|
ensureSliceBranch,
|
|
63
63
|
getCurrentBranch,
|
|
64
64
|
getMainBranch,
|
|
65
|
-
getSliceBranchName,
|
|
66
65
|
parseSliceBranch,
|
|
67
66
|
switchToMain,
|
|
68
67
|
mergeSliceToMain,
|
|
69
68
|
} from "./worktree.ts";
|
|
69
|
+
import { GitServiceImpl } from "./git-service.ts";
|
|
70
|
+
import type { GitPreferences } from "./git-service.ts";
|
|
70
71
|
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
71
72
|
import { makeUI, GLYPH, INDENT } from "../shared/ui.js";
|
|
72
73
|
import { showNextAction } from "../shared/next-action-ui.js";
|
|
@@ -93,6 +94,18 @@ function persistCompletedKey(base: string, key: string): void {
|
|
|
93
94
|
}
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
/** Remove a stale completed unit key from disk. */
|
|
98
|
+
function removePersistedKey(base: string, key: string): void {
|
|
99
|
+
const file = completedKeysPath(base);
|
|
100
|
+
try {
|
|
101
|
+
if (existsSync(file)) {
|
|
102
|
+
let keys: string[] = JSON.parse(readFileSync(file, "utf-8"));
|
|
103
|
+
keys = keys.filter(k => k !== key);
|
|
104
|
+
writeFileSync(file, JSON.stringify(keys), "utf-8");
|
|
105
|
+
}
|
|
106
|
+
} catch { /* non-fatal */ }
|
|
107
|
+
}
|
|
108
|
+
|
|
96
109
|
/** Load all completed unit keys from disk into the in-memory set. */
|
|
97
110
|
function loadPersistedKeys(base: string, target: Set<string>): void {
|
|
98
111
|
const file = completedKeysPath(base);
|
|
@@ -112,6 +125,7 @@ let stepMode = false;
|
|
|
112
125
|
let verbose = false;
|
|
113
126
|
let cmdCtx: ExtensionCommandContext | null = null;
|
|
114
127
|
let basePath = "";
|
|
128
|
+
let gitService: GitServiceImpl | null = null;
|
|
115
129
|
|
|
116
130
|
/** Track total dispatches per unit to detect stuck loops (catches A→B→A→B patterns) */
|
|
117
131
|
const unitDispatchCount = new Map<string, number>();
|
|
@@ -376,6 +390,9 @@ export async function startAuto(
|
|
|
376
390
|
} catch { /* nothing to commit */ }
|
|
377
391
|
}
|
|
378
392
|
|
|
393
|
+
// Initialize GitServiceImpl — basePath is set and git repo confirmed
|
|
394
|
+
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
395
|
+
|
|
379
396
|
// Check for crash from previous session
|
|
380
397
|
const crashLock = readCrashLock(base);
|
|
381
398
|
if (crashLock) {
|
|
@@ -1190,15 +1207,27 @@ async function dispatchNextUnit(
|
|
|
1190
1207
|
// Idempotency: skip units already completed in a prior session.
|
|
1191
1208
|
const idempotencyKey = `${unitType}/${unitId}`;
|
|
1192
1209
|
if (completedKeySet.has(idempotencyKey)) {
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1210
|
+
// Cross-validate: does the expected artifact actually exist?
|
|
1211
|
+
const artifactExists = verifyExpectedArtifact(unitType, unitId, basePath);
|
|
1212
|
+
if (artifactExists) {
|
|
1213
|
+
ctx.ui.notify(
|
|
1214
|
+
`Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`,
|
|
1215
|
+
"info",
|
|
1216
|
+
);
|
|
1217
|
+
// Yield to the event loop before re-dispatching to avoid tight recursion
|
|
1218
|
+
// when many units are already completed (e.g., after crash recovery).
|
|
1219
|
+
await new Promise(r => setImmediate(r));
|
|
1220
|
+
await dispatchNextUnit(ctx, pi);
|
|
1221
|
+
return;
|
|
1222
|
+
} else {
|
|
1223
|
+
// Stale completion record — artifact missing. Remove and re-run.
|
|
1224
|
+
completedKeySet.delete(idempotencyKey);
|
|
1225
|
+
removePersistedKey(basePath, idempotencyKey);
|
|
1226
|
+
ctx.ui.notify(
|
|
1227
|
+
`Re-running ${unitType} ${unitId} — marked complete but expected artifact missing.`,
|
|
1228
|
+
"warning",
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1202
1231
|
}
|
|
1203
1232
|
|
|
1204
1233
|
// Stuck detection — tracks total dispatches per unit (not just consecutive repeats).
|
|
@@ -1234,20 +1263,26 @@ async function dispatchNextUnit(
|
|
|
1234
1263
|
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
|
1235
1264
|
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
1236
1265
|
|
|
1237
|
-
//
|
|
1266
|
+
// Only mark the previous unit as completed if:
|
|
1267
|
+
// 1. We're not about to re-dispatch the same unit (retry scenario)
|
|
1268
|
+
// 2. The expected artifact actually exists on disk
|
|
1238
1269
|
const closeoutKey = `${currentUnit.type}/${currentUnit.id}`;
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1270
|
+
const incomingKey = `${unitType}/${unitId}`;
|
|
1271
|
+
const artifactVerified = verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath);
|
|
1272
|
+
if (closeoutKey !== incomingKey && artifactVerified) {
|
|
1273
|
+
persistCompletedKey(basePath, closeoutKey);
|
|
1274
|
+
completedKeySet.add(closeoutKey);
|
|
1275
|
+
|
|
1276
|
+
completedUnits.push({
|
|
1277
|
+
type: currentUnit.type,
|
|
1278
|
+
id: currentUnit.id,
|
|
1279
|
+
startedAt: currentUnit.startedAt,
|
|
1280
|
+
finishedAt: Date.now(),
|
|
1281
|
+
});
|
|
1282
|
+
clearUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id);
|
|
1283
|
+
unitDispatchCount.delete(`${currentUnit.type}/${currentUnit.id}`);
|
|
1284
|
+
unitRecoveryCount.delete(`${currentUnit.type}/${currentUnit.id}`);
|
|
1285
|
+
}
|
|
1251
1286
|
}
|
|
1252
1287
|
currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
|
|
1253
1288
|
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
@@ -2587,6 +2622,15 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba
|
|
|
2587
2622
|
const dir = resolveSlicePath(base, mid, sid!);
|
|
2588
2623
|
return dir ? join(dir, buildSliceFileName(sid!, "UAT-RESULT")) : null;
|
|
2589
2624
|
}
|
|
2625
|
+
case "execute-task": {
|
|
2626
|
+
const tid = parts[2];
|
|
2627
|
+
const dir = resolveSlicePath(base, mid, sid!);
|
|
2628
|
+
return dir && tid ? join(dir, "tasks", buildTaskFileName(tid, "SUMMARY")) : null;
|
|
2629
|
+
}
|
|
2630
|
+
case "complete-slice": {
|
|
2631
|
+
const dir = resolveSlicePath(base, mid, sid!);
|
|
2632
|
+
return dir ? join(dir, buildSliceFileName(sid!, "SUMMARY")) : null;
|
|
2633
|
+
}
|
|
2590
2634
|
case "complete-milestone": {
|
|
2591
2635
|
const dir = resolveMilestonePath(base, mid);
|
|
2592
2636
|
return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null;
|
|
@@ -2596,6 +2640,17 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba
|
|
|
2596
2640
|
}
|
|
2597
2641
|
}
|
|
2598
2642
|
|
|
2643
|
+
/**
|
|
2644
|
+
* Check whether the expected artifact for a unit exists on disk.
|
|
2645
|
+
* Returns true if the artifact file exists, or if the unit type has no
|
|
2646
|
+
* single verifiable artifact (e.g., replan-slice).
|
|
2647
|
+
*/
|
|
2648
|
+
function verifyExpectedArtifact(unitType: string, unitId: string, base: string): boolean {
|
|
2649
|
+
const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
|
|
2650
|
+
if (!absPath) return true;
|
|
2651
|
+
return existsSync(absPath);
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2599
2654
|
/**
|
|
2600
2655
|
* Write a placeholder artifact so the pipeline can advance past a stuck unit.
|
|
2601
2656
|
* Returns the relative path written, or null if the path couldn't be resolved.
|
|
@@ -40,6 +40,14 @@ Full documentation for `~/.gsd/preferences.md` (global) and `.gsd/preferences.md
|
|
|
40
40
|
- `idle_timeout_minutes`: minutes of inactivity before the supervisor intervenes (default: 10).
|
|
41
41
|
- `hard_timeout_minutes`: minutes before the supervisor forces termination (default: 30).
|
|
42
42
|
|
|
43
|
+
- `git`: configures GSD's git behavior. All fields are optional — omit any to use defaults. Keys:
|
|
44
|
+
- `auto_push`: boolean — automatically push commits to the remote after committing. Default: `false`.
|
|
45
|
+
- `push_branches`: boolean — push newly created slice branches to the remote. Default: `false`.
|
|
46
|
+
- `remote`: string — git remote name to push to. Default: `"origin"`.
|
|
47
|
+
- `snapshots`: boolean — create snapshot commits (WIP saves) during long-running tasks. Default: `false`.
|
|
48
|
+
- `pre_merge_check`: boolean or `"auto"` — run pre-merge checks before merging a slice branch. `true` always runs, `false` never runs, `"auto"` runs when CI is detected. Default: `false`.
|
|
49
|
+
- `commit_type`: string — override the conventional commit type prefix. Must be one of: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build`, `style`. Default: inferred from diff content.
|
|
50
|
+
|
|
43
51
|
---
|
|
44
52
|
|
|
45
53
|
## Best Practices
|
|
@@ -101,3 +109,22 @@ skill_rules:
|
|
|
101
109
|
- find-skills
|
|
102
110
|
---
|
|
103
111
|
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Git Preferences Example
|
|
116
|
+
|
|
117
|
+
```yaml
|
|
118
|
+
---
|
|
119
|
+
version: 1
|
|
120
|
+
git:
|
|
121
|
+
auto_push: true
|
|
122
|
+
push_branches: true
|
|
123
|
+
remote: origin
|
|
124
|
+
snapshots: true
|
|
125
|
+
pre_merge_check: auto
|
|
126
|
+
commit_type: feat
|
|
127
|
+
---
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
All git fields are optional. Omit any field to use the default behavior. Project-level preferences override global preferences on a per-field basis.
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { execSync } from "node:child_process";
|
|
12
|
-
import {
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join, sep } from "node:path";
|
|
13
14
|
|
|
14
15
|
import {
|
|
15
16
|
detectWorktreeName,
|
|
@@ -39,6 +40,13 @@ export interface MergeSliceResult {
|
|
|
39
40
|
deletedBranch: boolean;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
export interface PreMergeCheckResult {
|
|
44
|
+
passed: boolean;
|
|
45
|
+
skipped?: boolean;
|
|
46
|
+
command?: string;
|
|
47
|
+
error?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
42
50
|
// ─── Constants ─────────────────────────────────────────────────────────────
|
|
43
51
|
|
|
44
52
|
/**
|
|
@@ -61,13 +69,15 @@ export const RUNTIME_EXCLUSION_PATHS: readonly string[] = [
|
|
|
61
69
|
/**
|
|
62
70
|
* Run a git command in the given directory.
|
|
63
71
|
* Returns trimmed stdout. Throws on non-zero exit unless allowFailure is set.
|
|
72
|
+
* When `input` is provided, it is piped to stdin.
|
|
64
73
|
*/
|
|
65
|
-
export function runGit(basePath: string, args: string[], options: { allowFailure?: boolean } = {}): string {
|
|
74
|
+
export function runGit(basePath: string, args: string[], options: { allowFailure?: boolean; input?: string } = {}): string {
|
|
66
75
|
try {
|
|
67
76
|
return execSync(`git ${args.join(" ")}`, {
|
|
68
77
|
cwd: basePath,
|
|
69
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
78
|
+
stdio: [options.input != null ? "pipe" : "ignore", "pipe", "pipe"],
|
|
70
79
|
encoding: "utf-8",
|
|
80
|
+
...(options.input != null ? { input: options.input } : {}),
|
|
71
81
|
}).trim();
|
|
72
82
|
} catch (error) {
|
|
73
83
|
if (options.allowFailure) return "";
|
|
@@ -107,7 +117,7 @@ export class GitServiceImpl {
|
|
|
107
117
|
}
|
|
108
118
|
|
|
109
119
|
/** Convenience wrapper: run git in this repo's basePath. */
|
|
110
|
-
private git(args: string[], options: { allowFailure?: boolean } = {}): string {
|
|
120
|
+
private git(args: string[], options: { allowFailure?: boolean; input?: string } = {}): string {
|
|
111
121
|
return runGit(this.basePath, args, options);
|
|
112
122
|
}
|
|
113
123
|
|
|
@@ -129,6 +139,7 @@ export class GitServiceImpl {
|
|
|
129
139
|
/**
|
|
130
140
|
* Stage files (smart staging) and commit.
|
|
131
141
|
* Returns the commit message string on success, or null if nothing to commit.
|
|
142
|
+
* Uses `git commit -F -` with stdin pipe for safe multi-line message handling.
|
|
132
143
|
*/
|
|
133
144
|
commit(opts: CommitOptions): string | null {
|
|
134
145
|
this.smartStage();
|
|
@@ -137,7 +148,10 @@ export class GitServiceImpl {
|
|
|
137
148
|
const staged = this.git(["diff", "--cached", "--stat"], { allowFailure: true });
|
|
138
149
|
if (!staged && !opts.allowEmpty) return null;
|
|
139
150
|
|
|
140
|
-
this.git(
|
|
151
|
+
this.git(
|
|
152
|
+
["commit", "-F", "-", ...(opts.allowEmpty ? ["--allow-empty"] : [])],
|
|
153
|
+
{ input: opts.message },
|
|
154
|
+
);
|
|
141
155
|
return opts.message;
|
|
142
156
|
}
|
|
143
157
|
|
|
@@ -158,7 +172,7 @@ export class GitServiceImpl {
|
|
|
158
172
|
if (!staged) return null;
|
|
159
173
|
|
|
160
174
|
const message = `chore(${unitId}): auto-commit after ${unitType}`;
|
|
161
|
-
this.git(["commit", "-
|
|
175
|
+
this.git(["commit", "-F", "-"], { input: message });
|
|
162
176
|
return message;
|
|
163
177
|
}
|
|
164
178
|
|
|
@@ -235,6 +249,9 @@ export class GitServiceImpl {
|
|
|
235
249
|
* branch (preserves planning artifacts). Falls back to main when on another
|
|
236
250
|
* slice branch (avoids chaining slice branches).
|
|
237
251
|
*
|
|
252
|
+
* When creating a new branch, fetches from remote first (best-effort) to
|
|
253
|
+
* ensure the local main is up-to-date.
|
|
254
|
+
*
|
|
238
255
|
* Auto-commits dirty state via smart staging before checkout so runtime
|
|
239
256
|
* files are never accidentally committed during branch switches.
|
|
240
257
|
*
|
|
@@ -250,6 +267,24 @@ export class GitServiceImpl {
|
|
|
250
267
|
let created = false;
|
|
251
268
|
|
|
252
269
|
if (!this.branchExists(branch)) {
|
|
270
|
+
// Fetch from remote before creating a new branch (best-effort).
|
|
271
|
+
const remotes = this.git(["remote"], { allowFailure: true });
|
|
272
|
+
if (remotes) {
|
|
273
|
+
const remote = this.prefs.remote ?? "origin";
|
|
274
|
+
const fetchResult = this.git(["fetch", "--prune", remote], { allowFailure: true });
|
|
275
|
+
// fetchResult is empty string on both success and allowFailure-caught error.
|
|
276
|
+
// Check if local is behind upstream (informational only).
|
|
277
|
+
if (remotes.split("\n").includes(remote)) {
|
|
278
|
+
const behind = this.git(
|
|
279
|
+
["rev-list", "--count", "HEAD..@{upstream}"],
|
|
280
|
+
{ allowFailure: true },
|
|
281
|
+
);
|
|
282
|
+
if (behind && parseInt(behind, 10) > 0) {
|
|
283
|
+
console.error(`GitService: local branch is ${behind} commit(s) behind upstream`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
253
288
|
// Branch from current when it's a normal working branch (not a slice).
|
|
254
289
|
// If already on a slice branch, fall back to main to avoid chaining.
|
|
255
290
|
const mainBranch = this.getMainBranch();
|
|
@@ -287,11 +322,170 @@ export class GitServiceImpl {
|
|
|
287
322
|
this.git(["checkout", mainBranch]);
|
|
288
323
|
}
|
|
289
324
|
|
|
325
|
+
// ─── S05 Features ─────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Create a snapshot ref for the given label (typically a slice branch name).
|
|
329
|
+
* Gated on prefs.snapshots === true. Ref path: refs/gsd/snapshots/<label>/<timestamp>
|
|
330
|
+
* The ref points at HEAD, capturing the current commit before destructive operations.
|
|
331
|
+
*/
|
|
332
|
+
createSnapshot(label: string): void {
|
|
333
|
+
if (this.prefs.snapshots !== true) return;
|
|
334
|
+
|
|
335
|
+
const now = new Date();
|
|
336
|
+
const ts = now.getFullYear().toString()
|
|
337
|
+
+ String(now.getMonth() + 1).padStart(2, "0")
|
|
338
|
+
+ String(now.getDate()).padStart(2, "0")
|
|
339
|
+
+ "-"
|
|
340
|
+
+ String(now.getHours()).padStart(2, "0")
|
|
341
|
+
+ String(now.getMinutes()).padStart(2, "0")
|
|
342
|
+
+ String(now.getSeconds()).padStart(2, "0");
|
|
343
|
+
|
|
344
|
+
const refPath = `refs/gsd/snapshots/${label}/${ts}`;
|
|
345
|
+
this.git(["update-ref", refPath, "HEAD"]);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Run pre-merge verification check. Auto-detects test runner from project
|
|
350
|
+
* files, or uses custom command from prefs.pre_merge_check.
|
|
351
|
+
*
|
|
352
|
+
* Gating:
|
|
353
|
+
* - `false` → skip (return passed:true, skipped:true)
|
|
354
|
+
* - non-empty string (not "auto") → use as custom command
|
|
355
|
+
* - `true`, `"auto"`, or `undefined` → auto-detect from project files
|
|
356
|
+
*
|
|
357
|
+
* Auto-detection order:
|
|
358
|
+
* package.json scripts.test → npm test
|
|
359
|
+
* package.json scripts.build (only if no test) → npm run build
|
|
360
|
+
* Cargo.toml → cargo test
|
|
361
|
+
* Makefile with test: target → make test
|
|
362
|
+
* pyproject.toml → python -m pytest
|
|
363
|
+
*
|
|
364
|
+
* If no runner detected in auto mode, returns passed:true (don't block).
|
|
365
|
+
*/
|
|
366
|
+
runPreMergeCheck(): PreMergeCheckResult {
|
|
367
|
+
const pref = this.prefs.pre_merge_check;
|
|
368
|
+
|
|
369
|
+
// Explicitly disabled
|
|
370
|
+
if (pref === false) {
|
|
371
|
+
return { passed: true, skipped: true };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
let command: string | null = null;
|
|
375
|
+
|
|
376
|
+
// Custom string command (not "auto")
|
|
377
|
+
if (typeof pref === "string" && pref !== "auto" && pref.trim() !== "") {
|
|
378
|
+
command = pref.trim();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Auto-detect (true, "auto", or undefined)
|
|
382
|
+
if (command === null) {
|
|
383
|
+
command = this.detectTestRunner();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (command === null) {
|
|
387
|
+
return { passed: true, command: "none", error: "no test runner detected" };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Execute the command
|
|
391
|
+
try {
|
|
392
|
+
execSync(command, {
|
|
393
|
+
cwd: this.basePath,
|
|
394
|
+
timeout: 300_000,
|
|
395
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
396
|
+
encoding: "utf-8",
|
|
397
|
+
});
|
|
398
|
+
return { passed: true, command };
|
|
399
|
+
} catch (err) {
|
|
400
|
+
const stderr = err instanceof Error && "stderr" in err
|
|
401
|
+
? String((err as { stderr: unknown }).stderr).slice(0, 2000)
|
|
402
|
+
: String(err).slice(0, 2000);
|
|
403
|
+
return { passed: false, command, error: stderr };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Detect a test/build runner from project files in basePath.
|
|
409
|
+
* Returns the command string or null if nothing detected.
|
|
410
|
+
*/
|
|
411
|
+
private detectTestRunner(): string | null {
|
|
412
|
+
const pkgPath = join(this.basePath, "package.json");
|
|
413
|
+
if (existsSync(pkgPath)) {
|
|
414
|
+
try {
|
|
415
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
416
|
+
if (pkg?.scripts?.test) return "npm test";
|
|
417
|
+
if (pkg?.scripts?.build) return "npm run build";
|
|
418
|
+
} catch { /* invalid JSON — skip */ }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (existsSync(join(this.basePath, "Cargo.toml"))) {
|
|
422
|
+
return "cargo test";
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const makefilePath = join(this.basePath, "Makefile");
|
|
426
|
+
if (existsSync(makefilePath)) {
|
|
427
|
+
try {
|
|
428
|
+
const content = readFileSync(makefilePath, "utf-8");
|
|
429
|
+
if (/^test\s*:/m.test(content)) return "make test";
|
|
430
|
+
} catch { /* skip */ }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (existsSync(join(this.basePath, "pyproject.toml"))) {
|
|
434
|
+
return "python -m pytest";
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
|
|
290
440
|
// ─── Merge ─────────────────────────────────────────────────────────────
|
|
291
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Build a rich squash-commit message with a task list from branch commits.
|
|
444
|
+
*
|
|
445
|
+
* Format:
|
|
446
|
+
* type(scope): title
|
|
447
|
+
*
|
|
448
|
+
* Tasks:
|
|
449
|
+
* - commit subject 1
|
|
450
|
+
* - commit subject 2
|
|
451
|
+
*
|
|
452
|
+
* Branch: gsd/M001/S01
|
|
453
|
+
*/
|
|
454
|
+
private buildRichCommitMessage(
|
|
455
|
+
commitType: string,
|
|
456
|
+
milestoneId: string,
|
|
457
|
+
sliceId: string,
|
|
458
|
+
sliceTitle: string,
|
|
459
|
+
mainBranch: string,
|
|
460
|
+
branch: string,
|
|
461
|
+
): string {
|
|
462
|
+
const subject = `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`;
|
|
463
|
+
|
|
464
|
+
// Collect branch commit subjects
|
|
465
|
+
const logOutput = this.git(
|
|
466
|
+
["log", "--oneline", "--format=%s", `${mainBranch}..${branch}`],
|
|
467
|
+
{ allowFailure: true },
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
if (!logOutput) return subject;
|
|
471
|
+
|
|
472
|
+
const subjects = logOutput.split("\n").filter(Boolean);
|
|
473
|
+
const MAX_ENTRIES = 20;
|
|
474
|
+
const truncated = subjects.length > MAX_ENTRIES;
|
|
475
|
+
const displayed = truncated ? subjects.slice(0, MAX_ENTRIES) : subjects;
|
|
476
|
+
|
|
477
|
+
const taskLines = displayed.map(s => `- ${s}`).join("\n");
|
|
478
|
+
const truncationLine = truncated ? `\n- ... and ${subjects.length - MAX_ENTRIES} more` : "";
|
|
479
|
+
|
|
480
|
+
return `${subject}\n\nTasks:\n${taskLines}${truncationLine}\n\nBranch: ${branch}`;
|
|
481
|
+
}
|
|
482
|
+
|
|
292
483
|
/**
|
|
293
484
|
* Squash-merge a slice branch into main and delete it.
|
|
294
485
|
*
|
|
486
|
+
* Flow: snapshot branch HEAD → squash merge → rich commit via stdin →
|
|
487
|
+
* auto-push (if enabled) → delete branch.
|
|
488
|
+
*
|
|
295
489
|
* Must be called from the main branch. Uses `inferCommitType(sliceTitle)`
|
|
296
490
|
* for the conventional commit type instead of hardcoding `feat`.
|
|
297
491
|
*
|
|
@@ -328,20 +522,45 @@ export class GitServiceImpl {
|
|
|
328
522
|
);
|
|
329
523
|
}
|
|
330
524
|
|
|
525
|
+
// Snapshot the branch HEAD before merge (gated on prefs.snapshots)
|
|
526
|
+
this.createSnapshot(branch);
|
|
527
|
+
|
|
528
|
+
// Build rich commit message before squash (needs branch history)
|
|
529
|
+
const commitType = inferCommitType(sliceTitle);
|
|
530
|
+
const message = this.buildRichCommitMessage(
|
|
531
|
+
commitType, milestoneId, sliceId, sliceTitle, mainBranch, branch,
|
|
532
|
+
);
|
|
533
|
+
|
|
331
534
|
// Squash merge
|
|
332
535
|
this.git(["merge", "--squash", branch]);
|
|
333
536
|
|
|
334
|
-
//
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
537
|
+
// Pre-merge check: run after squash (tests merged result), reset on failure
|
|
538
|
+
const checkResult = this.runPreMergeCheck();
|
|
539
|
+
if (!checkResult.passed && !checkResult.skipped) {
|
|
540
|
+
// Undo the squash merge — nothing committed yet, reset staging area
|
|
541
|
+
this.git(["reset", "--hard", "HEAD"]);
|
|
542
|
+
const cmdInfo = checkResult.command ? ` (command: ${checkResult.command})` : "";
|
|
543
|
+
const errInfo = checkResult.error ? `\n${checkResult.error}` : "";
|
|
544
|
+
throw new Error(
|
|
545
|
+
`Pre-merge check failed${cmdInfo}. Merge aborted.${errInfo}`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Commit with rich message via stdin pipe
|
|
550
|
+
this.git(["commit", "-F", "-"], { input: message });
|
|
338
551
|
|
|
339
552
|
// Delete the merged branch
|
|
340
553
|
this.git(["branch", "-D", branch]);
|
|
341
554
|
|
|
555
|
+
// Auto-push to remote if enabled
|
|
556
|
+
if (this.prefs.auto_push === true) {
|
|
557
|
+
const remote = this.prefs.remote ?? "origin";
|
|
558
|
+
this.git(["push", remote, mainBranch], { allowFailure: true });
|
|
559
|
+
}
|
|
560
|
+
|
|
342
561
|
return {
|
|
343
562
|
branch,
|
|
344
|
-
mergedCommitMessage:
|
|
563
|
+
mergedCommitMessage: `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`,
|
|
345
564
|
deletedBranch: true,
|
|
346
565
|
};
|
|
347
566
|
}
|