gsd-pi 2.13.0 → 2.14.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 +3 -3
- package/dist/cli.js +1 -0
- package/dist/loader.js +50 -6
- package/dist/resource-loader.d.ts +7 -6
- package/dist/resource-loader.js +15 -8
- package/dist/resources/extensions/gsd/auto-worktree.ts +29 -183
- package/dist/resources/extensions/gsd/auto.ts +252 -370
- package/dist/resources/extensions/gsd/commands.ts +118 -34
- package/dist/resources/extensions/gsd/doctor.ts +29 -4
- package/dist/resources/extensions/gsd/git-self-heal.ts +0 -71
- package/dist/resources/extensions/gsd/git-service.ts +8 -431
- package/dist/resources/extensions/gsd/gitignore.ts +11 -4
- package/dist/resources/extensions/gsd/guided-flow.ts +141 -5
- package/dist/resources/extensions/gsd/preferences.ts +18 -17
- package/dist/resources/extensions/gsd/prompts/discuss.md +35 -0
- package/dist/resources/extensions/gsd/prompts/queue.md +7 -1
- package/dist/resources/extensions/gsd/state.ts +26 -8
- package/dist/resources/extensions/gsd/templates/state.md +0 -1
- package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +3 -2
- package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +1 -1
- package/dist/resources/extensions/gsd/tests/derive-state.test.ts +35 -0
- package/dist/resources/extensions/gsd/tests/doctor-git.test.ts +22 -4
- package/dist/resources/extensions/gsd/tests/draft-promotion.test.ts +2 -1
- package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +8 -111
- package/dist/resources/extensions/gsd/tests/git-service.test.ts +11 -770
- package/dist/resources/extensions/gsd/tests/idle-recovery.test.ts +21 -113
- package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +16 -82
- package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +29 -50
- package/dist/resources/extensions/gsd/tests/worktree-e2e.test.ts +17 -91
- package/dist/resources/extensions/gsd/tests/worktree-integration.test.ts +28 -55
- package/dist/resources/extensions/gsd/tests/worktree.test.ts +1 -426
- package/dist/resources/extensions/gsd/types.ts +0 -1
- package/dist/resources/extensions/gsd/worktree-manager.ts +7 -3
- package/dist/resources/extensions/gsd/worktree.ts +7 -65
- package/dist/resources/extensions/search-the-web/command-search-provider.ts +3 -1
- package/package.json +1 -1
- package/packages/pi-ai/dist/providers/google.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/google.js +12 -4
- package/packages/pi-ai/dist/providers/google.js.map +1 -1
- package/packages/pi-ai/dist/providers/mistral.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/mistral.js +10 -2
- package/packages/pi-ai/dist/providers/mistral.js.map +1 -1
- package/packages/pi-ai/src/providers/google.ts +20 -8
- package/packages/pi-ai/src/providers/mistral.ts +14 -2
- package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts +3 -0
- package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/loader.js +10 -7
- package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js +4 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +12 -3
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/packages/pi-coding-agent/src/core/extensions/loader.ts +13 -9
- package/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts +4 -1
- package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +14 -3
- package/packages/pi-tui/dist/components/input.d.ts +1 -0
- package/packages/pi-tui/dist/components/input.d.ts.map +1 -1
- package/packages/pi-tui/dist/components/input.js +10 -0
- package/packages/pi-tui/dist/components/input.js.map +1 -1
- package/packages/pi-tui/src/components/input.ts +11 -0
- package/src/resources/extensions/gsd/auto-worktree.ts +29 -183
- package/src/resources/extensions/gsd/auto.ts +252 -370
- package/src/resources/extensions/gsd/commands.ts +118 -34
- package/src/resources/extensions/gsd/doctor.ts +29 -4
- package/src/resources/extensions/gsd/git-self-heal.ts +0 -71
- package/src/resources/extensions/gsd/git-service.ts +8 -431
- package/src/resources/extensions/gsd/gitignore.ts +11 -4
- package/src/resources/extensions/gsd/guided-flow.ts +141 -5
- package/src/resources/extensions/gsd/preferences.ts +18 -17
- package/src/resources/extensions/gsd/prompts/discuss.md +35 -0
- package/src/resources/extensions/gsd/prompts/queue.md +7 -1
- package/src/resources/extensions/gsd/state.ts +26 -8
- package/src/resources/extensions/gsd/templates/state.md +0 -1
- package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +3 -2
- package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/derive-state.test.ts +35 -0
- package/src/resources/extensions/gsd/tests/doctor-git.test.ts +22 -4
- package/src/resources/extensions/gsd/tests/draft-promotion.test.ts +2 -1
- package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +8 -111
- package/src/resources/extensions/gsd/tests/git-service.test.ts +11 -770
- package/src/resources/extensions/gsd/tests/idle-recovery.test.ts +21 -113
- package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +16 -82
- package/src/resources/extensions/gsd/tests/preferences-git.test.ts +29 -50
- package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +17 -91
- package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +28 -55
- package/src/resources/extensions/gsd/tests/worktree.test.ts +1 -426
- package/src/resources/extensions/gsd/types.ts +0 -1
- package/src/resources/extensions/gsd/worktree-manager.ts +7 -3
- package/src/resources/extensions/gsd/worktree.ts +7 -65
- package/src/resources/extensions/search-the-web/command-search-provider.ts +3 -1
- package/dist/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +0 -282
- package/dist/resources/extensions/gsd/tests/isolation-resolver.test.ts +0 -107
- package/dist/resources/extensions/gsd/tests/orphaned-branch.test.ts +0 -353
- package/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +0 -282
- package/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +0 -107
- package/src/resources/extensions/gsd/tests/orphaned-branch.test.ts +0 -353
|
@@ -14,16 +14,13 @@ import { join, sep } from "node:path";
|
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
16
|
detectWorktreeName,
|
|
17
|
-
getSliceBranchName,
|
|
18
17
|
SLICE_BRANCH_RE,
|
|
19
18
|
} from "./worktree.js";
|
|
20
19
|
import {
|
|
21
20
|
nativeGetCurrentBranch,
|
|
22
21
|
nativeDetectMainBranch,
|
|
23
22
|
nativeBranchExists,
|
|
24
|
-
nativeHasMergeConflicts,
|
|
25
23
|
nativeHasChanges,
|
|
26
|
-
nativeCommitCountBetween,
|
|
27
24
|
} from "./native-git-bridge.js";
|
|
28
25
|
|
|
29
26
|
// ─── Types ─────────────────────────────────────────────────────────────────
|
|
@@ -37,8 +34,6 @@ export interface GitPreferences {
|
|
|
37
34
|
commit_type?: string;
|
|
38
35
|
main_branch?: string;
|
|
39
36
|
merge_strategy?: "squash" | "merge";
|
|
40
|
-
isolation?: "worktree" | "branch";
|
|
41
|
-
merge_to_main?: "milestone" | "slice";
|
|
42
37
|
}
|
|
43
38
|
|
|
44
39
|
export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/;
|
|
@@ -48,12 +43,6 @@ export interface CommitOptions {
|
|
|
48
43
|
allowEmpty?: boolean;
|
|
49
44
|
}
|
|
50
45
|
|
|
51
|
-
export interface MergeSliceResult {
|
|
52
|
-
branch: string;
|
|
53
|
-
mergedCommitMessage: string;
|
|
54
|
-
deletedBranch: boolean;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
46
|
/**
|
|
58
47
|
* Thrown when a slice merge hits code conflicts in non-.gsd files.
|
|
59
48
|
* The working tree is left in a conflicted state (no reset) so the
|
|
@@ -106,22 +95,8 @@ export const RUNTIME_EXCLUSION_PATHS: readonly string[] = [
|
|
|
106
95
|
".gsd/metrics.json",
|
|
107
96
|
".gsd/completed-units.json",
|
|
108
97
|
".gsd/STATE.md",
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* GSD planning artifact paths that must be force-added even when .gsd/
|
|
113
|
-
* is in .gitignore. These are durable planning files that the agent writes
|
|
114
|
-
* and that must survive squash-merges to main.
|
|
115
|
-
*
|
|
116
|
-
* `git add --force` is a no-op when the path doesn't exist or has no
|
|
117
|
-
* changes, so this list is safe to apply unconditionally.
|
|
118
|
-
*/
|
|
119
|
-
const GSD_DURABLE_PATHS: readonly string[] = [
|
|
120
|
-
".gsd/milestones/",
|
|
121
|
-
".gsd/DECISIONS.md",
|
|
122
|
-
".gsd/QUEUE.md",
|
|
123
|
-
".gsd/PROJECT.md",
|
|
124
|
-
".gsd/REQUIREMENTS.md",
|
|
98
|
+
".gsd/gsd.db",
|
|
99
|
+
".gsd/DISCUSSION-MANIFEST.json",
|
|
125
100
|
];
|
|
126
101
|
|
|
127
102
|
// ─── Integration Branch Metadata ───────────────────────────────────────────
|
|
@@ -157,13 +132,11 @@ export function readIntegrationBranch(basePath: string, milestoneId: string): st
|
|
|
157
132
|
* Persist the integration branch for a milestone.
|
|
158
133
|
*
|
|
159
134
|
* Called when auto-mode starts on a milestone. Records the branch the user
|
|
160
|
-
* was on at that point, so
|
|
161
|
-
*
|
|
162
|
-
*
|
|
135
|
+
* was on at that point, so the milestone worktree merges back to the correct
|
|
136
|
+
* branch. Idempotent when the branch matches; updates the record when the
|
|
137
|
+
* user starts from a different branch.
|
|
163
138
|
*
|
|
164
|
-
* The file is committed immediately so
|
|
165
|
-
* pre-switch auto-commit excludes `.gsd/` to avoid merge conflicts, and
|
|
166
|
-
* uncommitted `.gsd/` files are discarded during checkout.
|
|
139
|
+
* The file is committed immediately so the metadata is persisted in git.
|
|
167
140
|
*/
|
|
168
141
|
export function writeIntegrationBranch(basePath: string, milestoneId: string, branch: string): void {
|
|
169
142
|
// Don't record slice branches as the integration target
|
|
@@ -190,12 +163,9 @@ export function writeIntegrationBranch(basePath: string, milestoneId: string, br
|
|
|
190
163
|
existing.integrationBranch = branch;
|
|
191
164
|
writeFileSync(metaFile, JSON.stringify(existing, null, 2) + "\n", "utf-8");
|
|
192
165
|
|
|
193
|
-
// Commit immediately
|
|
194
|
-
// (ensureSliceBranch excludes .gsd/ from pre-switch auto-commit and runs
|
|
195
|
-
// git checkout -- .gsd/ to prevent checkout conflicts). Without this
|
|
196
|
-
// commit, the metadata would be lost on the first branch switch.
|
|
166
|
+
// Commit immediately so the metadata is persisted in git.
|
|
197
167
|
try {
|
|
198
|
-
runGit(basePath, ["add",
|
|
168
|
+
runGit(basePath, ["add", metaFile]);
|
|
199
169
|
runGit(basePath, ["commit", "--no-verify", "-F", "-"], {
|
|
200
170
|
input: `chore(${milestoneId}): record integration branch`,
|
|
201
171
|
});
|
|
@@ -332,20 +302,6 @@ export class GitServiceImpl {
|
|
|
332
302
|
// error handling is needed per-path.
|
|
333
303
|
this.git(["add", "-A"]);
|
|
334
304
|
|
|
335
|
-
// Force-add GSD planning artifacts that live under .gsd/ but may be
|
|
336
|
-
// blocked by a .gsd/ gitignore pattern. `git add -A` respects .gitignore,
|
|
337
|
-
// so new files (CONTEXT.md, SUMMARY.md, PLAN.md, etc.) in gitignored
|
|
338
|
-
// directories are silently skipped. Without this force-add, planning
|
|
339
|
-
// artifacts are never committed — they exist on disk but not in git.
|
|
340
|
-
// Squash-merges then delete them on main because they appear as "removed
|
|
341
|
-
// relative to main" during the merge.
|
|
342
|
-
//
|
|
343
|
-
// Only force-add durable planning paths — runtime paths are excluded
|
|
344
|
-
// by the reset step below.
|
|
345
|
-
for (const durablePath of GSD_DURABLE_PATHS) {
|
|
346
|
-
this.git(["add", "--force", "--", durablePath], { allowFailure: true });
|
|
347
|
-
}
|
|
348
|
-
|
|
349
305
|
for (const exclusion of allExclusions) {
|
|
350
306
|
this.git(["reset", "HEAD", "--", exclusion], { allowFailure: true });
|
|
351
307
|
}
|
|
@@ -446,140 +402,8 @@ export class GitServiceImpl {
|
|
|
446
402
|
}
|
|
447
403
|
|
|
448
404
|
/** True if currently on a GSD slice branch. */
|
|
449
|
-
isOnSliceBranch(): boolean {
|
|
450
|
-
const current = this.getCurrentBranch();
|
|
451
|
-
return SLICE_BRANCH_RE.test(current);
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/** Returns the slice branch name if on one, null otherwise. */
|
|
455
|
-
getActiveSliceBranch(): string | null {
|
|
456
|
-
try {
|
|
457
|
-
const current = this.getCurrentBranch();
|
|
458
|
-
return SLICE_BRANCH_RE.test(current) ? current : null;
|
|
459
|
-
} catch {
|
|
460
|
-
return null;
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
405
|
// ─── Branch Lifecycle ──────────────────────────────────────────────────
|
|
465
406
|
|
|
466
|
-
/**
|
|
467
|
-
* Check if a local branch exists. Native libgit2 when available, execSync fallback.
|
|
468
|
-
*/
|
|
469
|
-
private branchExists(branch: string): boolean {
|
|
470
|
-
return nativeBranchExists(this.basePath, branch);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/**
|
|
474
|
-
* Ensure the slice branch exists and is checked out.
|
|
475
|
-
*
|
|
476
|
-
* Creates the branch from the current working branch if it's not a slice
|
|
477
|
-
* branch (preserves planning artifacts). Falls back to the integration
|
|
478
|
-
* branch when on another slice branch (avoids chaining slice branches).
|
|
479
|
-
*
|
|
480
|
-
* Auto-commits dirty state via smart staging before checkout so runtime
|
|
481
|
-
* files are never accidentally committed during branch switches.
|
|
482
|
-
*
|
|
483
|
-
* Returns true if the branch was newly created.
|
|
484
|
-
*/
|
|
485
|
-
ensureSliceBranch(milestoneId: string, sliceId: string): boolean {
|
|
486
|
-
const wtName = detectWorktreeName(this.basePath);
|
|
487
|
-
const branch = getSliceBranchName(milestoneId, sliceId, wtName);
|
|
488
|
-
const current = this.getCurrentBranch();
|
|
489
|
-
|
|
490
|
-
if (current === branch) return false;
|
|
491
|
-
|
|
492
|
-
let created = false;
|
|
493
|
-
|
|
494
|
-
if (!this.branchExists(branch)) {
|
|
495
|
-
// Fetch from remote before creating a new branch (best-effort).
|
|
496
|
-
const remotes = this.git(["remote"], { allowFailure: true });
|
|
497
|
-
if (remotes) {
|
|
498
|
-
const remote = this.prefs.remote ?? "origin";
|
|
499
|
-
const fetchResult = this.git(["fetch", "--prune", remote], { allowFailure: true });
|
|
500
|
-
if (fetchResult === "" && remotes.split("\n").includes(remote)) {
|
|
501
|
-
// Check if local is behind upstream (informational only)
|
|
502
|
-
const behind = this.git(
|
|
503
|
-
["rev-list", "--count", "HEAD..@{upstream}"],
|
|
504
|
-
{ allowFailure: true },
|
|
505
|
-
);
|
|
506
|
-
if (behind && parseInt(behind, 10) > 0) {
|
|
507
|
-
console.error(`GitService: local branch is ${behind} commit(s) behind upstream`);
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// Branch from current when it's a normal working branch (not a slice).
|
|
513
|
-
// If already on a slice branch, fall back to the integration branch to avoid chaining.
|
|
514
|
-
const mainBranch = this.getMainBranch();
|
|
515
|
-
const base = SLICE_BRANCH_RE.test(current) ? mainBranch : current;
|
|
516
|
-
this.git(["branch", branch, base]);
|
|
517
|
-
created = true;
|
|
518
|
-
} else {
|
|
519
|
-
// Branch exists — check it's not checked out in another worktree
|
|
520
|
-
const worktreeList = this.git(["worktree", "list", "--porcelain"]);
|
|
521
|
-
if (worktreeList.includes(`branch refs/heads/${branch}`)) {
|
|
522
|
-
throw new Error(
|
|
523
|
-
`Branch "${branch}" is already in use by another worktree. ` +
|
|
524
|
-
`Remove that worktree first, or switch it to a different branch.`,
|
|
525
|
-
);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// Auto-commit dirty state via smart staging before checkout.
|
|
530
|
-
// Exclude .gsd/ to prevent merge conflicts when both branches modify planning artifacts.
|
|
531
|
-
this.autoCommit("pre-switch", current, [".gsd/"]);
|
|
532
|
-
|
|
533
|
-
// Discard uncommitted .gsd/ changes so checkout doesn't fail.
|
|
534
|
-
// Two-step approach handles both tracked and untracked runtime files:
|
|
535
|
-
// 1. `checkout --` reverts tracked .gsd/ files to their HEAD versions.
|
|
536
|
-
// 2. `clean -fdx` removes untracked runtime files that the target branch has
|
|
537
|
-
// tracked — e.g., when a prior cleanup commit removed STATE.md from the
|
|
538
|
-
// current branch's HEAD but the target branch still has it committed.
|
|
539
|
-
this.git(["checkout", "--", ".gsd/"], { allowFailure: true });
|
|
540
|
-
this.discardUntrackedRuntimeFiles();
|
|
541
|
-
|
|
542
|
-
this.git(["checkout", branch]);
|
|
543
|
-
return created;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
/**
|
|
547
|
-
* Switch to the integration branch, auto-committing dirty state via smart staging first.
|
|
548
|
-
*/
|
|
549
|
-
switchToMain(): void {
|
|
550
|
-
const mainBranch = this.getMainBranch();
|
|
551
|
-
const current = this.getCurrentBranch();
|
|
552
|
-
if (current === mainBranch) return;
|
|
553
|
-
|
|
554
|
-
// Exclude .gsd/ to prevent merge conflicts when both branches modify planning artifacts.
|
|
555
|
-
this.autoCommit("pre-switch", current, [".gsd/"]);
|
|
556
|
-
|
|
557
|
-
// Discard uncommitted .gsd/ changes so checkout doesn't fail.
|
|
558
|
-
// Two-step approach handles both tracked and untracked runtime files.
|
|
559
|
-
this.git(["checkout", "--", ".gsd/"], { allowFailure: true });
|
|
560
|
-
this.discardUntrackedRuntimeFiles();
|
|
561
|
-
|
|
562
|
-
this.git(["checkout", mainBranch]);
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
/**
|
|
566
|
-
* Remove untracked runtime files from the working tree.
|
|
567
|
-
*
|
|
568
|
-
* Complements `git checkout -- .gsd/` (which only handles tracked files).
|
|
569
|
-
* Runtime files can end up untracked after a cleanup commit removes them
|
|
570
|
-
* from the current branch's HEAD — but the target branch may still have
|
|
571
|
-
* them committed. Without this step, `git checkout` fails with:
|
|
572
|
-
* "The following untracked working tree files would be overwritten by checkout"
|
|
573
|
-
*
|
|
574
|
-
* `git clean -fdx` is safe here because:
|
|
575
|
-
* - Only removes *untracked* files (tracked files are untouched)
|
|
576
|
-
* - Targets only the specific runtime paths listed in RUNTIME_EXCLUSION_PATHS
|
|
577
|
-
* - These files are always regenerated by GSD on the next run
|
|
578
|
-
*/
|
|
579
|
-
private discardUntrackedRuntimeFiles(): void {
|
|
580
|
-
this.git(["clean", "-fdx", "--", ...RUNTIME_EXCLUSION_PATHS], { allowFailure: true });
|
|
581
|
-
}
|
|
582
|
-
|
|
583
407
|
// ─── S05 Features ─────────────────────────────────────────────────────
|
|
584
408
|
|
|
585
409
|
/**
|
|
@@ -644,253 +468,6 @@ export class GitServiceImpl {
|
|
|
644
468
|
|
|
645
469
|
// ─── Merge ─────────────────────────────────────────────────────────────
|
|
646
470
|
|
|
647
|
-
/**
|
|
648
|
-
* Build a rich squash-commit message with a task list from branch commits.
|
|
649
|
-
*
|
|
650
|
-
* Format:
|
|
651
|
-
* type(scope): title
|
|
652
|
-
*
|
|
653
|
-
* Tasks:
|
|
654
|
-
* - commit subject 1
|
|
655
|
-
* - commit subject 2
|
|
656
|
-
*
|
|
657
|
-
* Branch: gsd/M001/S01
|
|
658
|
-
*/
|
|
659
|
-
private buildRichCommitMessage(
|
|
660
|
-
commitType: string,
|
|
661
|
-
milestoneId: string,
|
|
662
|
-
sliceId: string,
|
|
663
|
-
sliceTitle: string,
|
|
664
|
-
mainBranch: string,
|
|
665
|
-
branch: string,
|
|
666
|
-
): string {
|
|
667
|
-
const subject = `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`;
|
|
668
|
-
|
|
669
|
-
// Collect branch commit subjects
|
|
670
|
-
const logOutput = this.git(
|
|
671
|
-
["log", "--oneline", "--format=%s", `${mainBranch}..${branch}`],
|
|
672
|
-
{ allowFailure: true },
|
|
673
|
-
);
|
|
674
|
-
|
|
675
|
-
if (!logOutput) return subject;
|
|
676
|
-
|
|
677
|
-
const subjects = logOutput.split("\n").filter(Boolean);
|
|
678
|
-
const MAX_ENTRIES = 20;
|
|
679
|
-
const truncated = subjects.length > MAX_ENTRIES;
|
|
680
|
-
const displayed = truncated ? subjects.slice(0, MAX_ENTRIES) : subjects;
|
|
681
|
-
|
|
682
|
-
const taskLines = displayed.map(s => `- ${s}`).join("\n");
|
|
683
|
-
const truncationLine = truncated ? `\n- ... and ${subjects.length - MAX_ENTRIES} more` : "";
|
|
684
|
-
|
|
685
|
-
return `${subject}\n\nTasks:\n${taskLines}${truncationLine}\n\nBranch: ${branch}`;
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
/**
|
|
689
|
-
* Squash-merge a slice branch into the integration branch and delete it.
|
|
690
|
-
*
|
|
691
|
-
* The integration branch is resolved by getMainBranch() — this may be
|
|
692
|
-
* `main`, a feature branch, or a worktree branch depending on context.
|
|
693
|
-
*
|
|
694
|
-
* Flow: snapshot branch HEAD → squash merge → rich commit via stdin →
|
|
695
|
-
* auto-push (if enabled) → delete branch.
|
|
696
|
-
*
|
|
697
|
-
* Must be called from the integration branch. Uses `inferCommitType(sliceTitle)`
|
|
698
|
-
* for the conventional commit type instead of hardcoding `feat`.
|
|
699
|
-
*
|
|
700
|
-
* Throws when:
|
|
701
|
-
* - Not currently on the integration branch
|
|
702
|
-
* - The slice branch does not exist
|
|
703
|
-
* - The slice branch has no commits ahead of the integration branch
|
|
704
|
-
*/
|
|
705
|
-
mergeSliceToMain(milestoneId: string, sliceId: string, sliceTitle: string): MergeSliceResult {
|
|
706
|
-
const mainBranch = this.getMainBranch();
|
|
707
|
-
const current = this.getCurrentBranch();
|
|
708
|
-
|
|
709
|
-
if (current !== mainBranch) {
|
|
710
|
-
throw new Error(
|
|
711
|
-
`mergeSliceToMain must be called from the main branch ("${mainBranch}"), ` +
|
|
712
|
-
`but currently on "${current}"`,
|
|
713
|
-
);
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
const wtName = detectWorktreeName(this.basePath);
|
|
717
|
-
const branch = getSliceBranchName(milestoneId, sliceId, wtName);
|
|
718
|
-
|
|
719
|
-
if (!this.branchExists(branch)) {
|
|
720
|
-
throw new Error(
|
|
721
|
-
`Slice branch "${branch}" does not exist. Nothing to merge.`,
|
|
722
|
-
);
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// Check commits ahead — native libgit2 revwalk when available
|
|
726
|
-
const aheadCount = nativeCommitCountBetween(this.basePath, mainBranch, branch);
|
|
727
|
-
if (aheadCount === 0) {
|
|
728
|
-
throw new Error(
|
|
729
|
-
`Slice branch "${branch}" has no commits ahead of "${mainBranch}". Nothing to merge.`,
|
|
730
|
-
);
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
// Snapshot the branch HEAD before merge (gated on prefs)
|
|
734
|
-
// We need to save the ref while the branch still exists
|
|
735
|
-
this.createSnapshot(branch);
|
|
736
|
-
|
|
737
|
-
// Build rich commit message before squash (needs branch history)
|
|
738
|
-
const commitType = inferCommitType(sliceTitle);
|
|
739
|
-
const message = this.buildRichCommitMessage(
|
|
740
|
-
commitType, milestoneId, sliceId, sliceTitle, mainBranch, branch,
|
|
741
|
-
);
|
|
742
|
-
|
|
743
|
-
// Pull latest main before merging to avoid conflicts from remote changes
|
|
744
|
-
this.git(["pull", "--rebase", "origin", mainBranch], { allowFailure: true });
|
|
745
|
-
|
|
746
|
-
// Untrack runtime files that may have been manually committed (e.g. via `gsd queue`)
|
|
747
|
-
// to prevent merge conflicts on files that belong in .gitignore (#189)
|
|
748
|
-
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
|
|
749
|
-
this.git(["rm", "--cached", "-r", "--ignore-unmatch", exclusion], { allowFailure: true });
|
|
750
|
-
}
|
|
751
|
-
const untrackDiff = this.git(["diff", "--cached", "--stat"], { allowFailure: true });
|
|
752
|
-
if (untrackDiff && untrackDiff.trim()) {
|
|
753
|
-
this.git(["commit", "--no-verify", "-m", "chore: untrack .gsd/ runtime files before merge"], { allowFailure: true });
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
// Merge slice branch — strategy is configurable via git.merge_strategy
|
|
757
|
-
// preference. Default: "squash" (preserves existing behavior).
|
|
758
|
-
// "merge" uses --no-ff which is more resilient to conflicts from
|
|
759
|
-
// long-lived branches or frequently-changing .gsd/* artifacts.
|
|
760
|
-
const strategy = this.prefs.merge_strategy ?? "squash";
|
|
761
|
-
const mergeArgs = strategy === "merge"
|
|
762
|
-
? ["merge", "--no-ff", "-m", message, branch]
|
|
763
|
-
: ["merge", "--squash", branch];
|
|
764
|
-
|
|
765
|
-
try {
|
|
766
|
-
this.git(mergeArgs);
|
|
767
|
-
} catch (mergeError) {
|
|
768
|
-
// Check if conflicts can be auto-resolved (#189, #218)
|
|
769
|
-
//
|
|
770
|
-
// ─── BRANCH-MODE ONLY (D038) ────────────────────────────────────────
|
|
771
|
-
// The conflict resolution logic below applies ONLY when git.isolation = "branch".
|
|
772
|
-
// In worktree isolation mode, each milestone works in its own worktree directory
|
|
773
|
-
// so merge conflicts between slice branches and main are handled differently
|
|
774
|
-
// (worktree teardown merges via worktree-manager). This block is never reached
|
|
775
|
-
// in worktree mode because mergeSliceToMain is only called from the branch-mode
|
|
776
|
-
// code path. If you're modifying this logic, verify the isolation mode first.
|
|
777
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
778
|
-
const conflicted = this.git(["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
|
|
779
|
-
if (conflicted) {
|
|
780
|
-
const conflictedFiles = conflicted.split("\n").filter(Boolean);
|
|
781
|
-
const isRuntimeConflict = (f: string) =>
|
|
782
|
-
RUNTIME_EXCLUSION_PATHS.some(excl => f.startsWith(excl.replace(/\/$/, "")));
|
|
783
|
-
|
|
784
|
-
const runtimeConflicts = conflictedFiles.filter(isRuntimeConflict);
|
|
785
|
-
const gsdConflicts = conflictedFiles.filter(f => f.startsWith(".gsd/") && !isRuntimeConflict(f));
|
|
786
|
-
const otherConflicts = conflictedFiles.filter(
|
|
787
|
-
f => !isRuntimeConflict(f) && !f.startsWith(".gsd/"),
|
|
788
|
-
);
|
|
789
|
-
|
|
790
|
-
let resolvedAny = false;
|
|
791
|
-
|
|
792
|
-
if (runtimeConflicts.length > 0) {
|
|
793
|
-
// Runtime conflicts: take theirs and remove from index
|
|
794
|
-
for (const f of runtimeConflicts) {
|
|
795
|
-
this.git(["checkout", "--theirs", "--", f], { allowFailure: true });
|
|
796
|
-
this.git(["rm", "--cached", "--ignore-unmatch", f], { allowFailure: true });
|
|
797
|
-
}
|
|
798
|
-
resolvedAny = true;
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
if (gsdConflicts.length > 0) {
|
|
802
|
-
// Non-runtime .gsd/ conflicts (DECISIONS.md, REQUIREMENTS.md, ROADMAP.md, etc.):
|
|
803
|
-
// The slice branch has the authoritative .gsd/ state since the LLM just finished
|
|
804
|
-
// updating these artifacts during complete-slice. Take theirs (the slice branch).
|
|
805
|
-
for (const f of gsdConflicts) {
|
|
806
|
-
this.git(["checkout", "--theirs", "--", f], { allowFailure: true });
|
|
807
|
-
}
|
|
808
|
-
resolvedAny = true;
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
if (resolvedAny) {
|
|
812
|
-
this.git(["add", "-A"], { allowFailure: true });
|
|
813
|
-
|
|
814
|
-
// Re-check remaining conflicts after auto-resolving runtime and .gsd/ files
|
|
815
|
-
const remaining = this.git(["diff", "--name-only", "--diff-filter=U"], {
|
|
816
|
-
allowFailure: true,
|
|
817
|
-
});
|
|
818
|
-
if (remaining) {
|
|
819
|
-
const remainingFiles = remaining
|
|
820
|
-
.split("\n")
|
|
821
|
-
.filter(Boolean)
|
|
822
|
-
.filter(f => !isRuntimeConflict(f) && !f.startsWith(".gsd/"));
|
|
823
|
-
|
|
824
|
-
if (remainingFiles.length > 0) {
|
|
825
|
-
// Non-runtime, non-.gsd/ conflicts: leave working tree in conflicted state and throw
|
|
826
|
-
// MergeConflictError so the caller can dispatch a fix-merge session.
|
|
827
|
-
throw new MergeConflictError(remainingFiles, strategy, branch, mainBranch);
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
// No remaining non-runtime, non-.gsd/ conflicts — let the merge proceed
|
|
831
|
-
} else {
|
|
832
|
-
// No runtime or .gsd/ conflicts to auto-resolve; throw with original conflicted files
|
|
833
|
-
// so the caller can dispatch a fix-merge session.
|
|
834
|
-
throw new MergeConflictError(otherConflicts.length ? otherConflicts : conflictedFiles, strategy, branch, mainBranch);
|
|
835
|
-
}
|
|
836
|
-
} else {
|
|
837
|
-
// No conflicted files detected but merge still failed — reset and throw
|
|
838
|
-
this.git(["reset", "--hard", "HEAD"], { allowFailure: true });
|
|
839
|
-
const msg = mergeError instanceof Error ? mergeError.message : String(mergeError);
|
|
840
|
-
throw new Error(
|
|
841
|
-
`${strategy === "merge" ? "Merge" : "Squash-merge"} of "${branch}" into "${mainBranch}" failed. ` +
|
|
842
|
-
`Working tree has been reset to a clean state. ` +
|
|
843
|
-
`Resolve manually: git checkout ${mainBranch} && git merge ${strategy === "merge" ? "--no-ff" : "--squash"} ${branch}\n` +
|
|
844
|
-
`Original error: ${msg}`,
|
|
845
|
-
);
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
// Strip runtime files from the merge result before committing (#302).
|
|
850
|
-
// This replaces the old approach of checking out the slice branch to
|
|
851
|
-
// untrack runtime files pre-merge, which failed when the working tree
|
|
852
|
-
// had uncommitted .gsd/ changes that blocked the checkout.
|
|
853
|
-
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
|
|
854
|
-
this.git(["rm", "--cached", "-r", "--ignore-unmatch", exclusion], { allowFailure: true });
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
if (strategy === "squash") {
|
|
858
|
-
// After stripping runtime files, there may be nothing left to commit.
|
|
859
|
-
// This happens when the only changes in the slice were runtime artifacts.
|
|
860
|
-
const stagedDiff = this.git(["diff", "--cached", "--stat"], { allowFailure: true });
|
|
861
|
-
if (stagedDiff?.trim()) {
|
|
862
|
-
this.git(["commit", "--no-verify", "-F", "-"], { input: message });
|
|
863
|
-
} else {
|
|
864
|
-
// Nothing to commit — clean up the squash-merge state
|
|
865
|
-
this.git(["reset", "HEAD"], { allowFailure: true });
|
|
866
|
-
}
|
|
867
|
-
} else {
|
|
868
|
-
// --no-ff already committed; amend to include runtime file removal
|
|
869
|
-
const runtimeDiff = this.git(["diff", "--cached", "--stat"], { allowFailure: true });
|
|
870
|
-
if (runtimeDiff?.trim()) {
|
|
871
|
-
this.git(["commit", "--amend", "--no-edit", "--no-verify"]);
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// Delete the merged branch
|
|
876
|
-
this.git(["branch", "-D", branch]);
|
|
877
|
-
|
|
878
|
-
// Auto-push to remote if enabled
|
|
879
|
-
if (this.prefs.auto_push === true) {
|
|
880
|
-
const remote = this.prefs.remote ?? "origin";
|
|
881
|
-
const pushResult = this.git(["push", remote, mainBranch], { allowFailure: true });
|
|
882
|
-
if (pushResult === "") {
|
|
883
|
-
// push succeeded (empty stdout is normal) or failed silently
|
|
884
|
-
// Verify by checking if remote is reachable — the allowFailure handles errors
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
return {
|
|
889
|
-
branch,
|
|
890
|
-
mergedCommitMessage: `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`,
|
|
891
|
-
deletedBranch: true,
|
|
892
|
-
};
|
|
893
|
-
}
|
|
894
471
|
}
|
|
895
472
|
|
|
896
473
|
// ─── Commit Type Inference ─────────────────────────────────────────────────
|
|
@@ -14,8 +14,7 @@ import { execSync } from "node:child_process";
|
|
|
14
14
|
* Patterns that are always correct regardless of project type.
|
|
15
15
|
* No one ever wants these tracked.
|
|
16
16
|
*/
|
|
17
|
-
const
|
|
18
|
-
// ── GSD runtime (not source artifacts) ──
|
|
17
|
+
const GSD_RUNTIME_PATTERNS = [
|
|
19
18
|
".gsd/activity/",
|
|
20
19
|
".gsd/runtime/",
|
|
21
20
|
".gsd/worktrees/",
|
|
@@ -23,6 +22,15 @@ const BASELINE_PATTERNS = [
|
|
|
23
22
|
".gsd/metrics.json",
|
|
24
23
|
".gsd/completed-units.json",
|
|
25
24
|
".gsd/STATE.md",
|
|
25
|
+
".gsd/gsd.db",
|
|
26
|
+
".gsd/DISCUSSION-MANIFEST.json",
|
|
27
|
+
".gsd/milestones/**/*-CONTINUE.md",
|
|
28
|
+
".gsd/milestones/**/continue.md",
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
const BASELINE_PATTERNS = [
|
|
32
|
+
// ── GSD runtime (not source artifacts — planning files are tracked) ──
|
|
33
|
+
...GSD_RUNTIME_PATTERNS,
|
|
26
34
|
|
|
27
35
|
// ── OS junk ──
|
|
28
36
|
".DS_Store",
|
|
@@ -116,8 +124,7 @@ export function ensureGitignore(basePath: string): boolean {
|
|
|
116
124
|
* Only removes from the index (`--cached`), never from disk. Idempotent.
|
|
117
125
|
*/
|
|
118
126
|
export function untrackRuntimeFiles(basePath: string): void {
|
|
119
|
-
|
|
120
|
-
const runtimePaths = BASELINE_PATTERNS.slice(0, 7);
|
|
127
|
+
const runtimePaths = GSD_RUNTIME_PATTERNS;
|
|
121
128
|
|
|
122
129
|
for (const pattern of runtimePaths) {
|
|
123
130
|
// Use -r for directory patterns (trailing slash), strip the slash for the command
|