Merge pull request #676 from adamdry/f-opt-out-of-worktrees
feat: add git.isolation "none" mode — no worktree, no milestone branch
This commit is contained in:
commit
2f1abf7aae
17 changed files with 369 additions and 37 deletions
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
|
|
@ -7,6 +7,17 @@ on:
|
|||
branches: [main]
|
||||
|
||||
jobs:
|
||||
no-gsd-dir:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Ensure .gsd/ is not checked in
|
||||
run: |
|
||||
if [ -d ".gsd" ]; then
|
||||
echo "::error::.gsd/ directory must not be checked in"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
|
|
|||
|
|
@ -41,9 +41,13 @@ The dispatch prompt is carefully constructed with:
|
|||
|
||||
The amount of context inlined is controlled by your [token profile](./token-optimization.md). Budget mode inlines minimal context; quality mode inlines everything.
|
||||
|
||||
### Git Worktree Isolation
|
||||
### Git Isolation
|
||||
|
||||
Each milestone runs in its own git worktree with a `milestone/<MID>` branch. All slice work commits sequentially — no branch switching, no merge conflicts mid-milestone. When the milestone completes, it's squash-merged to main as one clean commit.
|
||||
GSD isolates milestone work using one of three modes (configured via `git.isolation` in preferences):
|
||||
|
||||
- **`worktree`** (default): Each milestone runs in its own git worktree at `.gsd/worktrees/<MID>/` on a `milestone/<MID>` branch. All slice work commits sequentially — no branch switching, no merge conflicts mid-milestone. When the milestone completes, it's squash-merged to main as one clean commit.
|
||||
- **`branch`**: Work happens in the project root on a `milestone/<MID>` branch. Useful for submodule-heavy repos where worktrees don't work well.
|
||||
- **`none`**: Work happens directly on your current branch. No worktree, no milestone branch. Ideal for hot-reload workflows where file isolation breaks dev tooling.
|
||||
|
||||
See [Git Strategy](./git-strategy.md) for details.
|
||||
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ git:
|
|||
commit_type: feat # override conventional commit prefix
|
||||
main_branch: main # primary branch name
|
||||
merge_strategy: squash # how worktree branches merge: "squash" or "merge"
|
||||
isolation: worktree # git isolation: "worktree" or "branch"
|
||||
isolation: worktree # git isolation: "worktree", "branch", or "none"
|
||||
commit_docs: true # commit .gsd/ artifacts to git (set false to keep local)
|
||||
worktree_post_create: .gsd/hooks/post-worktree-create # script to run after worktree creation
|
||||
```
|
||||
|
|
@ -208,7 +208,7 @@ git:
|
|||
| `commit_type` | string | (inferred) | Override conventional commit prefix (`feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build`, `style`) |
|
||||
| `main_branch` | string | `"main"` | Primary branch name |
|
||||
| `merge_strategy` | string | `"squash"` | How worktree branches merge: `"squash"` (combine all commits) or `"merge"` (preserve individual commits) |
|
||||
| `isolation` | string | `"worktree"` | Auto-mode isolation: `"worktree"` (separate directory) or `"branch"` (work in project root — useful for submodule-heavy repos) |
|
||||
| `isolation` | string | `"worktree"` | Auto-mode isolation: `"worktree"` (separate directory), `"branch"` (work in project root — useful for submodule-heavy repos), or `"none"` (no isolation — commits on current branch, no worktree or milestone branch) |
|
||||
| `commit_docs` | boolean | `true` | Commit `.gsd/` planning artifacts to git. Set `false` to keep local-only |
|
||||
| `worktree_post_create` | string | (none) | Script to run after worktree creation. Receives `SOURCE_DIR` and `WORKTREE_DIR` env vars |
|
||||
|
||||
|
|
@ -429,7 +429,7 @@ auto_supervisor:
|
|||
git:
|
||||
auto_push: true
|
||||
merge_strategy: squash
|
||||
isolation: worktree
|
||||
isolation: worktree # "worktree", "branch", or "none"
|
||||
commit_docs: true
|
||||
|
||||
# Skills
|
||||
|
|
|
|||
|
|
@ -1,8 +1,36 @@
|
|||
# Git Strategy
|
||||
|
||||
GSD uses git worktrees for milestone isolation and sequential commits within each milestone. The strategy is fully automated — you don't need to manage branches manually.
|
||||
GSD uses git for milestone isolation and sequential commits within each milestone. You choose an **isolation mode** that controls where work happens. The strategy is fully automated — you don't need to manage branches manually.
|
||||
|
||||
## Branching Model
|
||||
## Isolation Modes
|
||||
|
||||
GSD supports three isolation modes, configured via the `git.isolation` preference:
|
||||
|
||||
| Mode | Working Directory | Branch | Best For |
|
||||
|------|-------------------|--------|----------|
|
||||
| `worktree` (default) | `.gsd/worktrees/<MID>/` | `milestone/<MID>` | Most projects — full file isolation between milestones |
|
||||
| `branch` | Project root | `milestone/<MID>` | Submodule-heavy repos where worktrees don't work well |
|
||||
| `none` | Project root | Current branch (no milestone branch) | Hot-reload workflows where file isolation breaks dev tooling |
|
||||
|
||||
### `worktree` Mode (Default)
|
||||
|
||||
Each milestone gets its own git worktree at `.gsd/worktrees/<MID>/` on a `milestone/<MID>` branch. All execution happens inside the worktree. On completion, the worktree is squash-merged to main as one clean commit. The worktree and branch are then cleaned up.
|
||||
|
||||
This provides full file isolation — changes in a milestone can't interfere with your main working copy.
|
||||
|
||||
### `branch` Mode
|
||||
|
||||
Work happens in the project root on a `milestone/<MID>` branch. No worktree is created. On completion, the branch is merged to main (squash or regular merge, per `merge_strategy`).
|
||||
|
||||
Use this when worktrees cause problems — submodule-heavy repos, repos with hardcoded paths, or environments where worktree symlinks don't behave.
|
||||
|
||||
### `none` Mode
|
||||
|
||||
Work happens directly on your current branch. No worktree, no milestone branch. GSD still commits sequentially with conventional commit messages, but there's no branch isolation.
|
||||
|
||||
Use this for hot-reload workflows where file isolation breaks dev tooling (e.g., file watchers that only see the project root), or for small projects where branch overhead isn't worth it.
|
||||
|
||||
## Branching Model (Worktree Mode)
|
||||
|
||||
```
|
||||
main ─────────────────────────────────────────────────────────
|
||||
|
|
@ -16,12 +44,14 @@ main ─────────────────────────
|
|||
→ squash-merged to main as single commit
|
||||
```
|
||||
|
||||
In **branch mode**, the flow is the same except work happens in the project root instead of a separate worktree directory.
|
||||
|
||||
In **none mode**, commits land directly on the current branch — no milestone branch is created, and no merge step is needed.
|
||||
|
||||
### Key Properties
|
||||
|
||||
- **One worktree per milestone** — all work happens in `.gsd/worktrees/<MID>/`
|
||||
- **Sequential commits on one branch** — no per-slice branches, no merge conflicts within a milestone
|
||||
- **Squash merge to main** — when the milestone completes, all commits are squashed into one clean commit on main
|
||||
- **Worktree teardown** — after merge, the worktree and branch are cleaned up
|
||||
- **Squash merge to main** — in worktree and branch modes, all commits are squashed into one clean commit on main (configurable via `merge_strategy`)
|
||||
|
||||
### Commit Format
|
||||
|
||||
|
|
@ -36,6 +66,8 @@ docs(M001/S04): workflow documentation
|
|||
|
||||
## Worktree Management
|
||||
|
||||
These features apply only in **worktree mode**.
|
||||
|
||||
### Automatic (Auto Mode)
|
||||
|
||||
Auto mode creates and manages worktrees automatically:
|
||||
|
|
@ -94,6 +126,7 @@ git:
|
|||
commit_type: feat # override commit type prefix
|
||||
main_branch: main # primary branch name
|
||||
commit_docs: true # commit .gsd/ to git
|
||||
isolation: worktree # "worktree", "branch", or "none"
|
||||
```
|
||||
|
||||
### `commit_docs: false`
|
||||
|
|
@ -106,7 +139,7 @@ GSD includes automatic recovery for common git issues:
|
|||
|
||||
- **Detached HEAD** — automatically reattaches to the correct branch
|
||||
- **Stale lock files** — removes `index.lock` files from crashed processes
|
||||
- **Orphaned worktrees** — detects and offers to clean up abandoned worktrees
|
||||
- **Orphaned worktrees** — detects and offers to clean up abandoned worktrees (worktree mode only)
|
||||
|
||||
Run `/gsd doctor` to check git health manually.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ It checks:
|
|||
- File structure and naming conventions
|
||||
- Roadmap ↔ slice ↔ task referential integrity
|
||||
- Completion state consistency
|
||||
- Git worktree health
|
||||
- Git worktree health (worktree and branch modes only — skipped in none mode)
|
||||
- Stale lock files and orphaned runtime records
|
||||
|
||||
## Common Issues
|
||||
|
|
@ -112,3 +112,4 @@ Doctor rebuilds `STATE.md` from plan and roadmap files on disk and fixes detecte
|
|||
- **GitHub Issues:** [github.com/gsd-build/GSD-2/issues](https://github.com/gsd-build/GSD-2/issues)
|
||||
- **Dashboard:** `Ctrl+Alt+G` or `/gsd status` for real-time diagnostics
|
||||
- **Session logs:** `.gsd/activity/` contains JSONL session dumps for crash forensics
|
||||
ctivity/` contains JSONL session dumps for crash forensics
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "gsd-pi",
|
||||
"version": "2.21.0",
|
||||
"version": "2.20.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gsd-pi",
|
||||
"version": "2.21.0",
|
||||
"version": "2.20.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ import {
|
|||
readUnitRuntimeRecord,
|
||||
writeUnitRuntimeRecord,
|
||||
} from "./unit-runtime.js";
|
||||
import { resolveAutoSupervisorConfig, resolveModelWithFallbacksForUnit, loadEffectiveGSDPreferences, resolveSkillDiscoveryMode, resolveDynamicRoutingConfig } from "./preferences.js";
|
||||
import { resolveAutoSupervisorConfig, resolveModelWithFallbacksForUnit, loadEffectiveGSDPreferences, resolveSkillDiscoveryMode, resolveDynamicRoutingConfig, getIsolationMode } from "./preferences.js";
|
||||
import { sendDesktopNotification } from "./notifications.js";
|
||||
import type { GSDPreferences } from "./preferences.js";
|
||||
import { classifyUnitComplexity, tierLabel } from "./complexity-classifier.js";
|
||||
|
|
@ -204,12 +204,15 @@ function checkResourcesStale(): string | null {
|
|||
|
||||
/**
|
||||
* Resolve whether auto-mode should use worktree isolation.
|
||||
* Returns true for worktree mode (default), false for branch mode.
|
||||
* Returns true for worktree mode (default), false for branch and none modes.
|
||||
* Branch mode works directly in the project root — useful for repos
|
||||
* with git submodules where worktrees don't work well (#531).
|
||||
* None mode skips all worktree and milestone-branch logic — commits
|
||||
* land on the current branch with no isolation (#M001-S02).
|
||||
*/
|
||||
function shouldUseWorktreeIsolation(): boolean {
|
||||
export function shouldUseWorktreeIsolation(): boolean {
|
||||
const prefs = loadEffectiveGSDPreferences()?.preferences?.git;
|
||||
if (prefs?.isolation === "none") return false;
|
||||
if (prefs?.isolation === "branch") return false;
|
||||
return true; // default: worktree
|
||||
}
|
||||
|
|
@ -675,7 +678,7 @@ export async function startAuto(
|
|||
|
||||
// ── Auto-worktree: re-enter worktree on resume if not already inside ──
|
||||
// Skip if already inside a worktree (manual /worktree) to prevent nesting.
|
||||
// Skip entirely in branch isolation mode (#531).
|
||||
// Skip entirely in branch or none isolation mode (#531).
|
||||
if (currentMilestoneId && shouldUseWorktreeIsolation() && originalBasePath && !isInAutoWorktree(basePath) && !detectWorktreeName(basePath) && !detectWorktreeName(originalBasePath)) {
|
||||
try {
|
||||
const existingWtPath = getAutoWorktreePath(originalBasePath, currentMilestoneId);
|
||||
|
|
@ -744,7 +747,7 @@ export async function startAuto(
|
|||
return;
|
||||
}
|
||||
|
||||
// Ensure git repo exists — GSD needs it for worktree isolation
|
||||
// Ensure git repo exists — GSD needs it for commits and state tracking
|
||||
if (!nativeIsRepo(base)) {
|
||||
const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main";
|
||||
nativeInit(base, mainBranch);
|
||||
|
|
@ -952,7 +955,9 @@ export async function startAuto(
|
|||
// of the repo's default (main/master). Idempotent when the branch is the
|
||||
// same; updates the record when started from a different branch (#300).
|
||||
if (currentMilestoneId) {
|
||||
captureIntegrationBranch(base, currentMilestoneId, { commitDocs });
|
||||
if (getIsolationMode() !== "none") {
|
||||
captureIntegrationBranch(base, currentMilestoneId, { commitDocs });
|
||||
}
|
||||
setActiveMilestoneId(base, currentMilestoneId);
|
||||
}
|
||||
|
||||
|
|
@ -1783,8 +1788,11 @@ async function dispatchNextUnit(
|
|||
}
|
||||
}
|
||||
} else {
|
||||
// Not in worktree — just capture integration branch for the new milestone
|
||||
captureIntegrationBranch(originalBasePath || basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
|
||||
// Not in worktree — capture integration branch for the new milestone (branch mode only).
|
||||
// In none mode there's no milestone branch to merge back to, so skip.
|
||||
if (getIsolationMode() !== "none") {
|
||||
captureIntegrationBranch(originalBasePath || basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
|
||||
}
|
||||
}
|
||||
|
||||
// Prune completed milestone from queue order file
|
||||
|
|
@ -1880,7 +1888,7 @@ async function dispatchNextUnit(
|
|||
try { process.chdir(basePath); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
} else if (currentMilestoneId && !isInAutoWorktree(basePath)) {
|
||||
} else if (currentMilestoneId && !isInAutoWorktree(basePath) && getIsolationMode() !== "none") {
|
||||
// Branch isolation mode (#603): no worktree, but we may be on a milestone/* branch.
|
||||
// Squash-merge back to the integration branch (or main) before stopping.
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -991,7 +991,7 @@ async function configureGit(ctx: ExtensionCommandContext, prefs: Record<string,
|
|||
const currentIsolation = git.isolation ? String(git.isolation) : "";
|
||||
const isolationChoice = await ctx.ui.select(
|
||||
`Git isolation strategy${currentIsolation ? ` (current: ${currentIsolation})` : " (default: worktree)"}:`,
|
||||
["worktree", "branch", "(keep current)"],
|
||||
["worktree", "branch", "none", "(keep current)"],
|
||||
);
|
||||
if (isolationChoice && isolationChoice !== "(keep current)") {
|
||||
git.isolation = isolationChoice;
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea
|
|||
- `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.
|
||||
- `main_branch`: string — the primary branch name for new git repos (e.g., `"main"`, `"master"`, `"trunk"`). Also used by `getMainBranch()` as the preferred branch when auto-detection is ambiguous. Default: `"main"`.
|
||||
- `merge_strategy`: `"squash"` or `"merge"` — controls how worktree branches are merged back. `"squash"` combines all commits into one; `"merge"` preserves individual commits. Default: `"squash"`.
|
||||
- `isolation`: `"worktree"` or `"branch"` — controls auto-mode git isolation strategy. `"worktree"` creates a milestone worktree for isolated work; `"branch"` works directly in the project root (useful for submodule-heavy repos). Default: `"worktree"`.
|
||||
- `isolation`: `"worktree"`, `"branch"`, or `"none"` — controls auto-mode git isolation strategy. `"worktree"` creates a milestone worktree for isolated work; `"branch"` works directly in the project root but creates a milestone branch (useful for submodule-heavy repos); `"none"` works directly on the current branch with no worktree or milestone branch (ideal for step-mode with hot reloads). Default: `"worktree"`.
|
||||
- `commit_docs`: boolean — when `false`, prevents GSD from committing `.gsd/` planning artifacts to git. The `.gsd/` folder is added to `.gitignore` and kept local-only. Useful for teams where only some members use GSD, or when company policy requires a clean repository. Default: `true`.
|
||||
- `worktree_post_create`: string — script to run after a worktree is created (both auto-mode and manual `/worktree`). Receives `SOURCE_DIR` and `WORKTREE_DIR` as environment variables. Can be absolute or relative to project root. Runs with 30-second timeout. Failure is non-fatal (logged as warning). Default: none.
|
||||
|
||||
|
|
|
|||
|
|
@ -474,6 +474,7 @@ async function checkGitHealth(
|
|||
issues: DoctorIssue[],
|
||||
fixesApplied: string[],
|
||||
shouldFix: (code: DoctorIssueCode) => boolean,
|
||||
isolationMode: "none" | "worktree" | "branch" = "worktree",
|
||||
): Promise<void> {
|
||||
// Degrade gracefully if not a git repo
|
||||
if (!nativeIsRepo(basePath)) {
|
||||
|
|
@ -482,7 +483,10 @@ async function checkGitHealth(
|
|||
|
||||
const gitDir = join(basePath, ".git");
|
||||
|
||||
// ── Orphaned auto-worktrees ──────────────────────────────────────────
|
||||
// ── Orphaned auto-worktrees & Stale milestone branches ────────────────
|
||||
// These checks only apply in worktree/branch modes — skip in none mode
|
||||
// where no milestone worktrees or branches are created.
|
||||
if (isolationMode !== "none") {
|
||||
try {
|
||||
const worktrees = listWorktrees(basePath);
|
||||
const milestoneWorktrees = worktrees.filter(wt => wt.branch.startsWith("milestone/"));
|
||||
|
|
@ -576,6 +580,7 @@ async function checkGitHealth(
|
|||
} catch {
|
||||
// listWorktrees or deriveState failed — skip worktree/branch checks
|
||||
}
|
||||
} // end isolationMode !== "none"
|
||||
|
||||
// ── Corrupt merge state ────────────────────────────────────────────────
|
||||
try {
|
||||
|
|
@ -976,7 +981,10 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|||
}
|
||||
|
||||
// Git health checks (orphaned worktrees, stale branches, corrupt merge state, tracked runtime files)
|
||||
await checkGitHealth(basePath, issues, fixesApplied, shouldFix);
|
||||
const isolationMode: "none" | "worktree" | "branch" =
|
||||
prefs?.preferences?.git?.isolation === "none" ? "none" :
|
||||
prefs?.preferences?.git?.isolation === "branch" ? "branch" : "worktree";
|
||||
await checkGitHealth(basePath, issues, fixesApplied, shouldFix, isolationMode);
|
||||
|
||||
// Runtime health checks (crash locks, completed-units, hook state, activity logs, STATE.md, gitignore)
|
||||
await checkRuntimeHealth(basePath, issues, fixesApplied, shouldFix);
|
||||
|
|
|
|||
|
|
@ -45,8 +45,9 @@ export interface GitPreferences {
|
|||
/** Controls auto-mode git isolation strategy.
|
||||
* - "worktree": (default) creates a milestone worktree for isolated work
|
||||
* - "branch": works directly in the project root (for submodule-heavy repos)
|
||||
* - "none": no git isolation — commits land on the user's current branch directly
|
||||
*/
|
||||
isolation?: "worktree" | "branch";
|
||||
isolation?: "worktree" | "branch" | "none";
|
||||
/** When false, prevents GSD from committing .gsd/ planning artifacts to git.
|
||||
* The .gsd/ folder is added to .gitignore and kept local-only.
|
||||
* Default: true (planning docs are tracked in git).
|
||||
|
|
|
|||
|
|
@ -525,6 +525,17 @@ export function resolveSkillStalenessDays(): number {
|
|||
return prefs?.preferences.skill_staleness_days ?? 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective git isolation mode from preferences.
|
||||
* Returns "worktree" (default), "branch", or "none".
|
||||
*/
|
||||
export function getIsolationMode(): "none" | "worktree" | "branch" {
|
||||
const prefs = loadEffectiveGSDPreferences()?.preferences?.git;
|
||||
if (prefs?.isolation === "none") return "none";
|
||||
if (prefs?.isolation === "branch") return "branch";
|
||||
return "worktree"; // default
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which model ID to use for a given auto-mode unit type.
|
||||
* Returns undefined if no model preference is set for this unit type.
|
||||
|
|
@ -1197,11 +1208,11 @@ export function validatePreferences(preferences: GSDPreferences): {
|
|||
}
|
||||
}
|
||||
if (g.isolation !== undefined) {
|
||||
const validIsolation = new Set(["worktree", "branch"]);
|
||||
const validIsolation = new Set(["worktree", "branch", "none"]);
|
||||
if (typeof g.isolation === "string" && validIsolation.has(g.isolation)) {
|
||||
git.isolation = g.isolation as "worktree" | "branch";
|
||||
git.isolation = g.isolation as "worktree" | "branch" | "none";
|
||||
} else {
|
||||
errors.push("git.isolation must be one of: worktree, branch");
|
||||
errors.push("git.isolation must be one of: worktree, branch, none");
|
||||
}
|
||||
}
|
||||
if (g.commit_docs !== undefined) {
|
||||
|
|
|
|||
|
|
@ -90,11 +90,17 @@ Titles live inside file content (headings, frontmatter), not in file or director
|
|||
T01-SUMMARY.md
|
||||
```
|
||||
|
||||
### Worktree Model
|
||||
### Isolation Model
|
||||
|
||||
All auto-mode work happens inside a worktree at `.gsd/worktrees/<MID>/`. This is a full git worktree on the `milestone/<MID>` branch — it has its own working copy of the project and its own `.gsd/` directory. Slices commit sequentially on this branch; there are no per-slice branches. When a milestone completes, the worktree is merged back to the integration branch.
|
||||
Auto-mode supports three isolation modes (configured in `.gsd/preferences.md` under `taskIsolation.mode`):
|
||||
|
||||
**If you are executing in auto-mode, your working directory is already set to the worktree.** Use relative paths or the path shown in the Working Directory section of your prompt. Do not navigate to any other copy of the project.
|
||||
- **worktree** (default): Work happens in `.gsd/worktrees/<MID>/`, a full git worktree on the `milestone/<MID>` branch. Each worktree has its own working copy and `.gsd/` directory. Squash-merged back to the integration branch on milestone completion.
|
||||
- **branch**: Work happens in the project root on a `milestone/<MID>` branch. No worktree directory — files are checked out in-place.
|
||||
- **none**: Work happens directly on the current branch. No worktree, no milestone branch. Commits land in-place.
|
||||
|
||||
In all modes, slices commit sequentially on the active branch; there are no per-slice branches.
|
||||
|
||||
**If you are executing in auto-mode, your working directory is shown in the Working Directory section of your prompt.** Use relative paths. Do not navigate to any other copy of the project.
|
||||
|
||||
### Conventions
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,27 @@ _None_
|
|||
return dir;
|
||||
}
|
||||
|
||||
/** Write a .gsd/preferences.md with the given git isolation mode. */
|
||||
function writePreferencesFile(dir: string, isolation: "none" | "worktree" | "branch"): void {
|
||||
const gsdDir = join(dir, ".gsd");
|
||||
mkdirSync(gsdDir, { recursive: true });
|
||||
writeFileSync(join(gsdDir, "preferences.md"), `---\ngit:\n isolation: "${isolation}"\n---\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write preferences to the test runner's cwd .gsd/preferences.md.
|
||||
* loadEffectiveGSDPreferences() resolves PROJECT_PREFERENCES_PATH at module
|
||||
* load time from process.cwd(), so we must write there — not to the temp dir.
|
||||
*/
|
||||
const RUNNER_PREFS_PATH = join(process.cwd(), ".gsd", "preferences.md");
|
||||
function writeRunnerPreferences(isolation: "none" | "worktree" | "branch"): void {
|
||||
mkdirSync(join(process.cwd(), ".gsd"), { recursive: true });
|
||||
writeFileSync(RUNNER_PREFS_PATH, `---\ngit:\n isolation: "${isolation}"\n---\n`);
|
||||
}
|
||||
function removeRunnerPreferences(): void {
|
||||
try { rmSync(RUNNER_PREFS_PATH); } catch { /* ignore if already gone */ }
|
||||
}
|
||||
|
||||
/** Create a repo with an in-progress milestone. */
|
||||
function createRepoWithActiveMilestone(): string {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-git-test-")));
|
||||
|
|
@ -252,6 +273,103 @@ async function main(): Promise<void> {
|
|||
console.log("\n=== active worktree safety (skipped on Windows) ===");
|
||||
}
|
||||
|
||||
// ─── Test 7: none-mode skips orphaned worktree check ───────────────
|
||||
// NOTE: loadEffectiveGSDPreferences() resolves PROJECT_PREFERENCES_PATH
|
||||
// at module load time from process.cwd(). We write the prefs file to
|
||||
// the test runner's cwd .gsd/preferences.md and clean up afterwards.
|
||||
if (process.platform !== "win32") {
|
||||
console.log("\n=== none-mode skips orphaned worktree ===");
|
||||
{
|
||||
const dir = createRepoWithCompletedMilestone();
|
||||
cleanups.push(dir);
|
||||
|
||||
// Create worktree with milestone/M001 branch under .gsd/worktrees/
|
||||
mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true });
|
||||
run("git worktree add -b milestone/M001 .gsd/worktrees/M001", dir);
|
||||
|
||||
// Write preferences to runner's cwd (where the module resolves project prefs)
|
||||
writeRunnerPreferences("none");
|
||||
try {
|
||||
const result = await runGSDDoctor(dir);
|
||||
const orphanIssues = result.issues.filter(i => i.code === "orphaned_auto_worktree");
|
||||
assertEq(orphanIssues.length, 0, "none-mode: orphaned worktree NOT detected");
|
||||
} finally {
|
||||
removeRunnerPreferences();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("\n=== none-mode skips orphaned worktree (skipped on Windows) ===");
|
||||
}
|
||||
|
||||
// ─── Test 8: none-mode skips stale branch check ────────────────────
|
||||
if (process.platform !== "win32") {
|
||||
console.log("\n=== none-mode skips stale branch ===");
|
||||
{
|
||||
const dir = createRepoWithCompletedMilestone();
|
||||
cleanups.push(dir);
|
||||
|
||||
// Create a milestone/M001 branch (no worktree)
|
||||
run("git branch milestone/M001", dir);
|
||||
|
||||
// Write preferences to runner's cwd
|
||||
writeRunnerPreferences("none");
|
||||
try {
|
||||
const result = await runGSDDoctor(dir);
|
||||
const staleIssues = result.issues.filter(i => i.code === "stale_milestone_branch");
|
||||
assertEq(staleIssues.length, 0, "none-mode: stale branch NOT detected");
|
||||
} finally {
|
||||
removeRunnerPreferences();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("\n=== none-mode skips stale branch (skipped on Windows) ===");
|
||||
}
|
||||
|
||||
// ─── Test 9: none-mode still detects corrupt merge state ───────────
|
||||
console.log("\n=== none-mode keeps corrupt merge state ===");
|
||||
{
|
||||
const dir = createRepoWithCompletedMilestone();
|
||||
cleanups.push(dir);
|
||||
|
||||
// Inject MERGE_HEAD into .git
|
||||
const headHash = run("git rev-parse HEAD", dir);
|
||||
writeFileSync(join(dir, ".git", "MERGE_HEAD"), headHash + "\n");
|
||||
|
||||
// Write preferences to runner's cwd
|
||||
writeRunnerPreferences("none");
|
||||
try {
|
||||
const result = await runGSDDoctor(dir);
|
||||
const mergeIssues = result.issues.filter(i => i.code === "corrupt_merge_state");
|
||||
assertTrue(mergeIssues.length > 0, "none-mode: corrupt merge state IS detected");
|
||||
} finally {
|
||||
removeRunnerPreferences();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 10: none-mode still detects tracked runtime files ────────
|
||||
console.log("\n=== none-mode keeps tracked runtime files ===");
|
||||
{
|
||||
const dir = createRepoWithCompletedMilestone();
|
||||
cleanups.push(dir);
|
||||
|
||||
// Force-add a runtime file
|
||||
const activityDir = join(dir, ".gsd", "activity");
|
||||
mkdirSync(activityDir, { recursive: true });
|
||||
writeFileSync(join(activityDir, "test.log"), "log data\n");
|
||||
run("git add -f .gsd/activity/test.log", dir);
|
||||
run("git commit -m \"track runtime file\"", dir);
|
||||
|
||||
// Write preferences to runner's cwd
|
||||
writeRunnerPreferences("none");
|
||||
try {
|
||||
const result = await runGSDDoctor(dir);
|
||||
const trackedIssues = result.issues.filter(i => i.code === "tracked_runtime_files");
|
||||
assertTrue(trackedIssues.length > 0, "none-mode: tracked runtime files IS detected");
|
||||
} finally {
|
||||
removeRunnerPreferences();
|
||||
}
|
||||
}
|
||||
|
||||
} finally {
|
||||
for (const dir of cleanups) {
|
||||
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
|
|
|
|||
105
src/resources/extensions/gsd/tests/none-mode-gates.test.ts
Normal file
105
src/resources/extensions/gsd/tests/none-mode-gates.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* none-mode-gates.test.ts — Tests for isolation-mode gate functions.
|
||||
*
|
||||
* Verifies that shouldUseWorktreeIsolation(), getIsolationMode(), and
|
||||
* getActiveAutoWorktreeContext() behave correctly across all three
|
||||
* isolation modes (none, branch, worktree) and at baseline (no prefs).
|
||||
*
|
||||
* Uses the writeRunnerPreferences pattern from doctor-git.test.ts:
|
||||
* PROJECT_PREFERENCES_PATH is a module-level constant frozen at import
|
||||
* time, so process.chdir() won't redirect preference loading. We write
|
||||
* prefs to the runner's cwd .gsd/preferences.md and clean up in finally.
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { shouldUseWorktreeIsolation } from "../auto.ts";
|
||||
import { getIsolationMode } from "../preferences.ts";
|
||||
import { getActiveAutoWorktreeContext } from "../auto-worktree.ts";
|
||||
import { invalidateAllCaches } from "../cache.ts";
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
// --- Preferences helpers (same pattern as doctor-git.test.ts K001) ---
|
||||
|
||||
const RUNNER_PREFS_PATH = join(process.cwd(), ".gsd", "preferences.md");
|
||||
|
||||
function writeRunnerPreferences(isolation: "none" | "worktree" | "branch"): void {
|
||||
mkdirSync(join(process.cwd(), ".gsd"), { recursive: true });
|
||||
writeFileSync(RUNNER_PREFS_PATH, `---\ngit:\n isolation: "${isolation}"\n---\n`);
|
||||
}
|
||||
|
||||
function removeRunnerPreferences(): void {
|
||||
try { rmSync(RUNNER_PREFS_PATH); } catch { /* ignore if already gone */ }
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
// Test 1: shouldUseWorktreeIsolation returns false for none
|
||||
console.log("Test 1: shouldUseWorktreeIsolation returns false for none");
|
||||
try {
|
||||
writeRunnerPreferences("none");
|
||||
invalidateAllCaches();
|
||||
assertEq(shouldUseWorktreeIsolation(), false, "shouldUseWorktreeIsolation() with none prefs");
|
||||
} finally {
|
||||
removeRunnerPreferences();
|
||||
invalidateAllCaches();
|
||||
}
|
||||
|
||||
// Test 2: shouldUseWorktreeIsolation returns false for branch
|
||||
console.log("Test 2: shouldUseWorktreeIsolation returns false for branch");
|
||||
try {
|
||||
writeRunnerPreferences("branch");
|
||||
invalidateAllCaches();
|
||||
assertEq(shouldUseWorktreeIsolation(), false, "shouldUseWorktreeIsolation() with branch prefs");
|
||||
} finally {
|
||||
removeRunnerPreferences();
|
||||
invalidateAllCaches();
|
||||
}
|
||||
|
||||
// Test 3: shouldUseWorktreeIsolation returns true for worktree
|
||||
console.log("Test 3: shouldUseWorktreeIsolation returns true for worktree");
|
||||
try {
|
||||
writeRunnerPreferences("worktree");
|
||||
invalidateAllCaches();
|
||||
assertEq(shouldUseWorktreeIsolation(), true, "shouldUseWorktreeIsolation() with worktree prefs");
|
||||
} finally {
|
||||
removeRunnerPreferences();
|
||||
invalidateAllCaches();
|
||||
}
|
||||
|
||||
// Test 4: shouldUseWorktreeIsolation returns true for no prefs (default)
|
||||
console.log("Test 4: shouldUseWorktreeIsolation returns true for no prefs (default)");
|
||||
try {
|
||||
removeRunnerPreferences(); // ensure no prefs file
|
||||
invalidateAllCaches();
|
||||
assertEq(shouldUseWorktreeIsolation(), true, "shouldUseWorktreeIsolation() with no prefs (default worktree)");
|
||||
} finally {
|
||||
invalidateAllCaches();
|
||||
}
|
||||
|
||||
// Test 5: getIsolationMode returns "none" with none prefs
|
||||
console.log("Test 5: getIsolationMode returns 'none' with none prefs");
|
||||
try {
|
||||
writeRunnerPreferences("none");
|
||||
invalidateAllCaches();
|
||||
assertEq(getIsolationMode(), "none", "getIsolationMode() with none prefs");
|
||||
} finally {
|
||||
removeRunnerPreferences();
|
||||
invalidateAllCaches();
|
||||
}
|
||||
|
||||
// Test 6: getActiveAutoWorktreeContext returns null at baseline
|
||||
console.log("Test 6: getActiveAutoWorktreeContext returns null at baseline");
|
||||
assertEq(getActiveAutoWorktreeContext(), null, "getActiveAutoWorktreeContext() returns null without enterAutoWorktree()");
|
||||
|
||||
// Test 7: System prompt worktree block absent without active worktree
|
||||
console.log("Test 7: System prompt worktree block absent without active worktree");
|
||||
{
|
||||
const ctx = getActiveAutoWorktreeContext();
|
||||
assertTrue(ctx === null, "getActiveAutoWorktreeContext() null confirms system prompt worktree block will not be injected");
|
||||
}
|
||||
|
||||
report();
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// GSD Git Preferences Tests — validates git.isolation and git.merge_to_main handling
|
||||
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
import { validatePreferences } from "../preferences.ts";
|
||||
import { validatePreferences, getIsolationMode } from "../preferences.ts";
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
|
|
@ -21,12 +21,18 @@ async function main(): Promise<void> {
|
|||
assertEq(warnings.length, 0, "isolation: branch — no warnings");
|
||||
assertEq(preferences.git?.isolation, "branch", "isolation: branch — value preserved");
|
||||
}
|
||||
{
|
||||
const { preferences, warnings, errors } = validatePreferences({ git: { isolation: "none" } });
|
||||
assertEq(errors.length, 0, "isolation: none — no errors");
|
||||
assertEq(warnings.length, 0, "isolation: none — no warnings");
|
||||
assertEq(preferences.git?.isolation, "none", "isolation: none — value preserved");
|
||||
}
|
||||
|
||||
// Invalid values produce errors
|
||||
{
|
||||
const { errors } = validatePreferences({ git: { isolation: "invalid" as any } });
|
||||
assertTrue(errors.length > 0, "isolation: invalid — produces error");
|
||||
assertTrue(errors[0].includes("worktree, branch"), "isolation: invalid — error mentions valid values");
|
||||
assertTrue(errors[0].includes("worktree, branch, none"), "isolation: invalid — error mentions valid values");
|
||||
}
|
||||
|
||||
// Undefined passes through without warning
|
||||
|
|
@ -95,6 +101,19 @@ async function main(): Promise<void> {
|
|||
assertEq(preferences.git?.commit_docs, undefined, "commit_docs: undefined — not set");
|
||||
}
|
||||
|
||||
console.log("\n=== getIsolationMode() ===");
|
||||
|
||||
// Returns "none" when set to "none"
|
||||
// Note: getIsolationMode() reads from disk via loadEffectiveGSDPreferences,
|
||||
// so we test it indirectly by verifying the function is exported and callable.
|
||||
// The validation tests above prove the preference value is stored correctly.
|
||||
// Direct mode tests require mocking the filesystem, so we test the function's
|
||||
// default return value (no preferences file in test context).
|
||||
{
|
||||
const mode = getIsolationMode();
|
||||
assertEq(mode, "worktree", "getIsolationMode: returns worktree as default when no prefs file");
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ async function main(): Promise<void> {
|
|||
|
||||
console.log("\n=== existing behavior preserved ===");
|
||||
|
||||
// git.isolation is a valid active setting (worktree | branch) — no warnings or errors
|
||||
// git.isolation is a valid active setting (worktree | branch | none) — no warnings or errors
|
||||
{
|
||||
const { warnings, errors, preferences } = validatePreferences({ git: { isolation: "worktree" } } as GSDPreferences);
|
||||
const unknownWarnings = warnings.filter(w => w.includes("unknown"));
|
||||
|
|
@ -163,6 +163,13 @@ async function main(): Promise<void> {
|
|||
assertEq(errors.length, 0, "valid git.isolation produces no errors");
|
||||
assertEq(preferences.git?.isolation, "worktree", "git.isolation value passes through");
|
||||
}
|
||||
{
|
||||
const { warnings, errors, preferences } = validatePreferences({ git: { isolation: "none" } } as GSDPreferences);
|
||||
const unknownWarnings = warnings.filter(w => w.includes("unknown"));
|
||||
assertEq(unknownWarnings.length, 0, "git.isolation none — no unknown-key warning");
|
||||
assertEq(errors.length, 0, "git.isolation none produces no errors");
|
||||
assertEq(preferences.git?.isolation, "none", "git.isolation none value passes through");
|
||||
}
|
||||
|
||||
// git.merge_to_main is deprecated — still produces deprecation warning
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue