gsd-pi 2.5.1 → 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.
@@ -26,8 +26,11 @@ export interface GitPreferences {
26
26
  snapshots?: boolean;
27
27
  pre_merge_check?: boolean | string;
28
28
  commit_type?: string;
29
+ main_branch?: string;
29
30
  }
30
31
 
32
+ export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/;
33
+
31
34
  export interface CommitOptions {
32
35
  message: string;
33
36
  allowEmpty?: boolean;
@@ -123,9 +126,11 @@ export class GitServiceImpl {
123
126
  /**
124
127
  * Smart staging: `git add -A` excluding GSD runtime paths via pathspec.
125
128
  * Falls back to plain `git add -A` if the exclusion pathspec fails.
129
+ * @param extraExclusions Additional pathspec exclusions beyond RUNTIME_EXCLUSION_PATHS.
126
130
  */
127
- private smartStage(): void {
128
- const excludes = RUNTIME_EXCLUSION_PATHS.map(p => `':(exclude)${p}'`);
131
+ private smartStage(extraExclusions: readonly string[] = []): void {
132
+ const allExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions];
133
+ const excludes = allExclusions.map(p => `':(exclude)${p}'`);
129
134
  const args = ["add", "-A", "--", ".", ...excludes];
130
135
  try {
131
136
  this.git(args);
@@ -157,13 +162,14 @@ export class GitServiceImpl {
157
162
  /**
158
163
  * Auto-commit dirty working tree with a conventional chore message.
159
164
  * Returns the commit message on success, or null if nothing to commit.
165
+ * @param extraExclusions Additional paths to exclude from staging (e.g. [".gsd/"] for pre-switch commits).
160
166
  */
161
- autoCommit(unitType: string, unitId: string): string | null {
167
+ autoCommit(unitType: string, unitId: string, extraExclusions: readonly string[] = []): string | null {
162
168
  // Quick check: is there anything dirty at all?
163
169
  const status = this.git(["status", "--short"], { allowFailure: true });
164
170
  if (!status) return null;
165
171
 
166
- this.smartStage();
172
+ this.smartStage(extraExclusions);
167
173
 
168
174
  // After smart staging, check if anything was actually staged
169
175
  // (all changes might have been runtime files that got excluded)
@@ -183,6 +189,11 @@ export class GitServiceImpl {
183
189
  * In the main tree: origin/HEAD symbolic-ref → main/master fallback → current branch.
184
190
  */
185
191
  getMainBranch(): string {
192
+ // Explicit preference takes priority (double-check validity as defense-in-depth)
193
+ if (this.prefs.main_branch && VALID_BRANCH_NAME.test(this.prefs.main_branch)) {
194
+ return this.prefs.main_branch;
195
+ }
196
+
186
197
  const wtName = detectWorktreeName(this.basePath);
187
198
  if (wtName) {
188
199
  const wtBranch = `worktree/${wtName}`;
@@ -297,8 +308,9 @@ export class GitServiceImpl {
297
308
  }
298
309
  }
299
310
 
300
- // Auto-commit dirty state via smart staging before checkout
301
- this.autoCommit("pre-switch", current);
311
+ // Auto-commit dirty state via smart staging before checkout.
312
+ // Exclude .gsd/ to prevent merge conflicts when both branches modify planning artifacts.
313
+ this.autoCommit("pre-switch", current, [".gsd/"]);
302
314
 
303
315
  this.git(["checkout", branch]);
304
316
  return created;
@@ -312,7 +324,8 @@ export class GitServiceImpl {
312
324
  const current = this.getCurrentBranch();
313
325
  if (current === mainBranch) return;
314
326
 
315
- this.autoCommit("pre-switch", current);
327
+ // Exclude .gsd/ to prevent merge conflicts when both branches modify planning artifacts.
328
+ this.autoCommit("pre-switch", current, [".gsd/"]);
316
329
 
317
330
  this.git(["checkout", mainBranch]);
318
331
  }
@@ -347,8 +360,36 @@ export class GitServiceImpl {
347
360
  * Stub: to be implemented in T03.
348
361
  */
349
362
  runPreMergeCheck(): PreMergeCheckResult {
350
- // TODO(S05/T03): implement pre-merge check
351
- return { passed: true, skipped: true };
363
+ if (this.prefs.pre_merge_check === false || this.prefs.pre_merge_check === undefined) {
364
+ return { passed: true, skipped: true };
365
+ }
366
+
367
+ // Determine command: explicit string or auto-detect from package.json
368
+ let command: string;
369
+ if (typeof this.prefs.pre_merge_check === "string") {
370
+ command = this.prefs.pre_merge_check;
371
+ } else {
372
+ // Auto-detect: look for package.json with a test script
373
+ try {
374
+ const pkg = execSync("cat package.json", { cwd: this.basePath, encoding: "utf-8" });
375
+ const parsed = JSON.parse(pkg);
376
+ if (parsed.scripts?.test) {
377
+ command = "npm test";
378
+ } else {
379
+ return { passed: true, skipped: true };
380
+ }
381
+ } catch {
382
+ return { passed: true, skipped: true };
383
+ }
384
+ }
385
+
386
+ try {
387
+ execSync(command, { cwd: this.basePath, stdio: "pipe", encoding: "utf-8" });
388
+ return { passed: true, skipped: false, command };
389
+ } catch (err) {
390
+ const msg = err instanceof Error ? err.message : String(err);
391
+ return { passed: false, skipped: false, command, error: msg };
392
+ }
352
393
  }
353
394
 
354
395
  // ─── Merge ─────────────────────────────────────────────────────────────
@@ -18,7 +18,7 @@
18
18
  * - Tool results: { role: "toolResult", toolCallId: "toolu_...", toolName: "bash", isError: bool, content: ... }
19
19
  */
20
20
 
21
- import { readFileSync, readdirSync, existsSync } from "node:fs";
21
+ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
22
22
  import { execSync } from "node:child_process";
23
23
  import { basename, join } from "node:path";
24
24
 
@@ -62,8 +62,16 @@ export interface RecoveryBriefing {
62
62
 
63
63
  // ─── JSONL Parsing ────────────────────────────────────────────────────────────
64
64
 
65
+ /** Max bytes to parse from a JSONL source. Prevents V8 OOM on bloated activity logs. */
66
+ const MAX_JSONL_BYTES = 10 * 1024 * 1024; // 10 MB
67
+
65
68
  function parseJSONL(raw: string): unknown[] {
66
- return raw.trim().split("\n").map(line => {
69
+ // If the file is enormous, only parse the tail (most recent entries).
70
+ // This prevents the OOM crash path: large file → split → map → parse → OOM.
71
+ const source = raw.length > MAX_JSONL_BYTES
72
+ ? raw.slice(-MAX_JSONL_BYTES)
73
+ : raw;
74
+ return source.trim().split("\n").map(line => {
67
75
  try { return JSON.parse(line); }
68
76
  catch { return null; }
69
77
  }).filter(Boolean) as unknown[];
@@ -239,10 +247,15 @@ export function synthesizeCrashRecovery(
239
247
 
240
248
  // Primary source: surviving pi session file
241
249
  if (sessionFile && existsSync(sessionFile)) {
242
- const raw = readFileSync(sessionFile, "utf-8");
243
- const allEntries = parseJSONL(raw);
244
- const sessionEntries = extractLastSession(allEntries);
245
- trace = extractTrace(sessionEntries);
250
+ const stat = statSync(sessionFile, { throwIfNoEntry: false });
251
+ const fileSize = stat?.size ?? 0;
252
+ // Skip files that would blow up memory; fall back to activity log
253
+ if (fileSize <= MAX_JSONL_BYTES * 2) {
254
+ const raw = readFileSync(sessionFile, "utf-8");
255
+ const allEntries = parseJSONL(raw);
256
+ const sessionEntries = extractLastSession(allEntries);
257
+ trace = extractTrace(sessionEntries);
258
+ }
246
259
  }
247
260
 
248
261
  // Fallback: last GSD activity log
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Integration tests for the secrets collection gate in startAuto().
3
+ *
4
+ * Exercises getManifestStatus() → collectSecretsFromManifest() composition
5
+ * end-to-end using real filesystem state. Proves the three gate paths:
6
+ * 1. No manifest exists — gate skips silently
7
+ * 2. Pending keys exist — gate triggers collection
8
+ * 3. No pending keys — gate skips silently
9
+ *
10
+ * Uses temp directories with real .gsd/milestones/M001/ structure, mirroring
11
+ * the pattern from manifest-status.test.ts.
12
+ */
13
+
14
+ import test from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { tmpdir } from 'node:os';
19
+ import { getManifestStatus } from '../files.ts';
20
+ import { collectSecretsFromManifest } from '../../get-secrets-from-user.ts';
21
+
22
+ function makeTempDir(prefix: string): string {
23
+ const dir = join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
24
+ mkdirSync(dir, { recursive: true });
25
+ return dir;
26
+ }
27
+
28
+ /** Create the .gsd/milestones/M001/ directory structure and write a secrets manifest. */
29
+ function writeManifest(base: string, content: string): void {
30
+ const mDir = join(base, '.gsd', 'milestones', 'M001');
31
+ mkdirSync(mDir, { recursive: true });
32
+ writeFileSync(join(mDir, 'M001-SECRETS.md'), content);
33
+ }
34
+
35
+ /** Stub ctx with hasUI: false — collectOneSecret returns null (skip), showSecretsSummary is a no-op. */
36
+ function makeNoUICtx(cwd: string) {
37
+ return {
38
+ ui: {},
39
+ hasUI: false,
40
+ cwd,
41
+ };
42
+ }
43
+
44
+ // ─── Scenario 1: No manifest exists ──────────────────────────────────────────
45
+
46
+ test('secrets gate: no manifest exists — getManifestStatus returns null', async () => {
47
+ const tmp = makeTempDir('gate-no-manifest');
48
+ try {
49
+ // No .gsd directory at all
50
+ const result = await getManifestStatus(tmp, 'M001');
51
+ assert.strictEqual(result, null, 'should return null when no manifest file exists');
52
+ } finally {
53
+ rmSync(tmp, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ // ─── Scenario 2: Pending keys exist ─────────────────────────────────────────
58
+
59
+ test('secrets gate: pending keys exist — gate triggers collection, manifest updated on disk', async () => {
60
+ const tmp = makeTempDir('gate-pending');
61
+ const savedA = process.env.GSD_GATE_TEST_EXISTING;
62
+ try {
63
+ // Simulate one key already in env
64
+ process.env.GSD_GATE_TEST_EXISTING = 'already-here';
65
+
66
+ // Ensure pending keys are NOT in env
67
+ delete process.env.GSD_GATE_TEST_PEND_A;
68
+ delete process.env.GSD_GATE_TEST_PEND_B;
69
+
70
+ writeManifest(tmp, `# Secrets Manifest
71
+
72
+ **Milestone:** M001
73
+ **Generated:** 2025-06-20T10:00:00Z
74
+
75
+ ### GSD_GATE_TEST_PEND_A
76
+
77
+ **Service:** ServiceA
78
+ **Status:** pending
79
+ **Destination:** dotenv
80
+
81
+ 1. Get key A from dashboard
82
+
83
+ ### GSD_GATE_TEST_PEND_B
84
+
85
+ **Service:** ServiceB
86
+ **Status:** pending
87
+ **Destination:** dotenv
88
+
89
+ 1. Get key B from dashboard
90
+
91
+ ### GSD_GATE_TEST_EXISTING
92
+
93
+ **Service:** ServiceC
94
+ **Status:** pending
95
+ **Destination:** dotenv
96
+
97
+ 1. Already in env
98
+ `);
99
+
100
+ // (a) Verify getManifestStatus shows pending keys
101
+ const status = await getManifestStatus(tmp, 'M001');
102
+ assert.notStrictEqual(status, null, 'manifest should exist');
103
+ assert.ok(status!.pending.length > 0, 'should have pending keys');
104
+ assert.deepStrictEqual(status!.pending, ['GSD_GATE_TEST_PEND_A', 'GSD_GATE_TEST_PEND_B']);
105
+ assert.deepStrictEqual(status!.existing, ['GSD_GATE_TEST_EXISTING']);
106
+
107
+ // (b) Call collectSecretsFromManifest with no-UI context
108
+ // With hasUI: false, collectOneSecret returns null → pending keys become "skipped"
109
+ const result = await collectSecretsFromManifest(tmp, 'M001', makeNoUICtx(tmp));
110
+
111
+ // (c) Verify return shape
112
+ assert.deepStrictEqual(result.applied, [], 'no keys applied (no UI to enter values)');
113
+ assert.ok(result.skipped.includes('GSD_GATE_TEST_PEND_A'), 'PEND_A should be skipped');
114
+ assert.ok(result.skipped.includes('GSD_GATE_TEST_PEND_B'), 'PEND_B should be skipped');
115
+ assert.deepStrictEqual(result.existingSkipped, ['GSD_GATE_TEST_EXISTING']);
116
+
117
+ // (d) Verify manifest on disk was updated — pending entries that went through
118
+ // collection are now "skipped". The existing-in-env entry retains its manifest
119
+ // status ("pending") because collectSecretsFromManifest only updates entries
120
+ // that flow through collectOneSecret. At runtime, getManifestStatus overrides
121
+ // env-present entries to "existing" regardless of manifest status.
122
+ const manifestPath = join(tmp, '.gsd', 'milestones', 'M001', 'M001-SECRETS.md');
123
+ const updatedContent = readFileSync(manifestPath, 'utf8');
124
+ assert.ok(
125
+ updatedContent.includes('**Status:** skipped'),
126
+ 'formerly-pending entries should now have status "skipped" in the manifest file',
127
+ );
128
+ // Count: PEND_A → skipped, PEND_B → skipped, EXISTING stays pending on disk
129
+ const skippedMatches = updatedContent.match(/\*\*Status:\*\* skipped/g);
130
+ assert.strictEqual(skippedMatches?.length, 2, 'two entries should have status "skipped"');
131
+ const pendingMatches = updatedContent.match(/\*\*Status:\*\* pending/g);
132
+ assert.strictEqual(pendingMatches?.length, 1, 'one entry (existing-in-env) retains pending on disk');
133
+
134
+ // (e) Verify getManifestStatus now shows no pending
135
+ const statusAfter = await getManifestStatus(tmp, 'M001');
136
+ assert.notStrictEqual(statusAfter, null);
137
+ assert.deepStrictEqual(statusAfter!.pending, [], 'no pending keys after collection');
138
+ } finally {
139
+ delete process.env.GSD_GATE_TEST_EXISTING;
140
+ if (savedA !== undefined) process.env.GSD_GATE_TEST_EXISTING = savedA;
141
+ delete process.env.GSD_GATE_TEST_PEND_A;
142
+ delete process.env.GSD_GATE_TEST_PEND_B;
143
+ rmSync(tmp, { recursive: true, force: true });
144
+ }
145
+ });
146
+
147
+ // ─── Scenario 3: No pending keys — all collected or in env ──────────────────
148
+
149
+ test('secrets gate: no pending keys — getManifestStatus shows pending.length === 0', async () => {
150
+ const tmp = makeTempDir('gate-no-pending');
151
+ const savedKey = process.env.GSD_GATE_TEST_ENVKEY;
152
+ try {
153
+ process.env.GSD_GATE_TEST_ENVKEY = 'some-value';
154
+
155
+ writeManifest(tmp, `# Secrets Manifest
156
+
157
+ **Milestone:** M001
158
+ **Generated:** 2025-06-20T10:00:00Z
159
+
160
+ ### ALREADY_COLLECTED
161
+
162
+ **Service:** ServiceX
163
+ **Status:** collected
164
+ **Destination:** dotenv
165
+
166
+ 1. Was collected previously
167
+
168
+ ### ALREADY_SKIPPED
169
+
170
+ **Service:** ServiceY
171
+ **Status:** skipped
172
+ **Destination:** dotenv
173
+
174
+ 1. Not needed
175
+
176
+ ### GSD_GATE_TEST_ENVKEY
177
+
178
+ **Service:** ServiceZ
179
+ **Status:** pending
180
+ **Destination:** dotenv
181
+
182
+ 1. In env already
183
+ `);
184
+
185
+ const result = await getManifestStatus(tmp, 'M001');
186
+ assert.notStrictEqual(result, null, 'manifest should exist');
187
+ assert.deepStrictEqual(result!.pending, [], 'no pending keys — gate would skip');
188
+ assert.deepStrictEqual(result!.collected, ['ALREADY_COLLECTED']);
189
+ assert.deepStrictEqual(result!.skipped, ['ALREADY_SKIPPED']);
190
+ assert.deepStrictEqual(result!.existing, ['GSD_GATE_TEST_ENVKEY']);
191
+ } finally {
192
+ delete process.env.GSD_GATE_TEST_ENVKEY;
193
+ if (savedKey !== undefined) process.env.GSD_GATE_TEST_ENVKEY = savedKey;
194
+ rmSync(tmp, { recursive: true, force: true });
195
+ }
196
+ });