From 03f7da8dc931bd299bcf6aab4968782a7e1731ec Mon Sep 17 00:00:00 2001 From: Harsha Bharadwaj Date: Mon, 19 Oct 2026 17:48:54 +0530 Subject: [PATCH 1/2] feat: append missing ignore lines without --force Non-interactive sdd init on an existing project adds missing gitignore lines, including role-runs/, and leaves config and workflows alone. Issue #37 stays open. Closes #45 --- .../design.md | 23 ++++++++ .../feature.md | 44 +++++++++++++++ .../local-test-plan.md | 14 +++++ .../local-test-results.md | 22 ++++++++ .../meta.yaml | 43 +++++++++++++++ .../metrics.json | 50 +++++++++++++++++ .../tasks.md | 6 ++ docs/reference/agents.md | 2 +- docs/reference/cli.md | 2 +- docs/reference/layout.md | 2 +- .../cli/src/__tests__/cli.integration.test.ts | 25 ++++++--- packages/cli/src/init-flow.ts | 7 +++ .../core/src/__tests__/init-force.test.ts | 35 ++++++++++-- packages/core/src/init.ts | 55 ++++++++++++------- 14 files changed, 293 insertions(+), 37 deletions(-) create mode 100644 changes/2026-10-19-pick-up-new-gitignore-lines-without-force/design.md create mode 100644 changes/2026-10-19-pick-up-new-gitignore-lines-without-force/feature.md create mode 100644 changes/2026-10-19-pick-up-new-gitignore-lines-without-force/local-test-plan.md create mode 100644 changes/2026-10-19-pick-up-new-gitignore-lines-without-force/local-test-results.md create mode 100644 changes/2026-10-19-pick-up-new-gitignore-lines-without-force/meta.yaml create mode 100644 changes/2026-10-19-pick-up-new-gitignore-lines-without-force/metrics.json create mode 100644 changes/2026-10-19-pick-up-new-gitignore-lines-without-force/tasks.md diff --git a/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/design.md b/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/design.md new file mode 100644 index 0000000..ce67eb7 --- /dev/null +++ b/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/design.md @@ -0,0 +1,23 @@ +# Design + +> **Pick up new gitignore lines without --force** · `2026-10-19-pick-up-new-gitignore-lines-without-force` + +The sentences in `feature.md` are the contract. + +## Ignore repair + +`packages/core/src/init.ts` exports `repairRequiredIgnores(projectRoot)`. It loads the existing config and does not write it. It appends missing lines to `.sdd/.gitignore` and to `/.gitignore` through the current `ensureIgnoreEntries` helper. It returns true when a line was added. + +When `initProject` sees an existing config and `force` is false, it calls that function and returns `{ created, config, kept: [], warnings: [] }`. It does not merge defaults, save config, or reconcile workflows. + +## CLI + +`runSpeckitStyleInit` in `packages/cli/src/init-flow.ts`, when the project is initialized, `--force` is absent, and the session is not interactive (`--no-agent` counts as not interactive), calls `repairRequiredIgnores` and returns. It does not call `initProject` with `force: true`. Stdout contains `Added missing ignore lines.` or `Ignore lines are already present.` + +The interactive confirm path is unchanged. + +## Docs and tests + +Update the second-init sentences in `docs/reference/cli.md`, `docs/reference/layout.md`, and `docs/reference/agents.md`. + +Replace the core test that expects a throw and an unchanged ignore file. Update the CLI test that expects exit 1 on a second `sdd init --here --no-agent`. Keep the `--force` preservation test. diff --git a/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/feature.md b/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/feature.md new file mode 100644 index 0000000..f47f2f2 --- /dev/null +++ b/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/feature.md @@ -0,0 +1,44 @@ +# Feature + +> **Pick up new gitignore lines without --force** · `2026-10-19-pick-up-new-gitignore-lines-without-force` · 2026-10-19 + +Issue #45. Epic `issue-37`, story `S-001`. This story does not close #37. + +## Problem / opportunity + +A project that already has `.sdd/config.yaml` cannot pick up a new required ignore line. Non-interactive `sdd init` stops in `maybeConfirmNonEmpty` before `initProject` runs. `initProject` itself also throws at the existing-config check when `force` is false, and `ensureIgnoreEntries` (including `role-runs/`) runs only after that check. `--force` appends the line, and it also merges config defaults and refreshes unchanged generated workflows and templates. + +## Users & value + +A person who upgrades `sdd` in a repo that is already initialized. They should get new ignore lines, including `role-runs/`, without a full re-init. + +## Scope + +Non-interactive `sdd init` on an existing project, without `--force`, appends missing required lines and exits 0. Required lines are the `.sdd/.gitignore` list (`handoff.md`, `active-context.md`, `index.db`, `index.db-journal`, `index.db-wal`, `index.db-shm`, `models/`, `role-runs/`) and `/.gitignore` line `.active`. A missing ignore file is created. Existing lines and comments stay. The command does not rewrite `.sdd/config.yaml`, memory, workflows, templates, protocol, or other generated files. + +Stdout contains one sentence. When a line was added: `Added missing ignore lines.` When every required line is already present: `Ignore lines are already present.` The setup intro may also be printed. The command does not print the already-initialized error. + +`initProject` with `force: false` and an existing config does the same ignore update and returns. It does not throw `Already initialized`. + +`--force` stays the full refresh. An interactive re-init prompt is unchanged: declining writes nothing; confirming still refreshes unchanged generated files. + +`docs/reference/cli.md`, `docs/reference/layout.md`, and `docs/reference/agents.md` say that a second init without `--force` adds missing ignore lines and does not refresh workflows. The old sentence that a second init writes nothing is removed. + +## Non-goals + +- Do not change #36. Do not refresh an edited workflow or template without `--force`. +- Do not merge config defaults on this path. +- Do not close #37. +- Do not implement #46 or #47 in this story. + +## Success criteria + +- [x] A project whose `.sdd/.gitignore` ends before `role-runs/` gains that line from `sdd init --here --no-agent` with no `--force`. Exit code is 0. Config and an edited workflow file are byte-for-byte unchanged. +- [x] A second run, when every required line is present, exits 0 with `Ignore lines are already present.` and does not duplicate lines. +- [x] `initProject({ force: false })` on an existing project appends the missing line and does not throw. +- [x] `sdd init --force` still keeps an edited workflow and a custom `changes_path`. +- [x] Declining the interactive re-init prompt writes nothing. + +## Risks / open questions + +The old #15 test expects exit 1 and the already-initialized explanation. This story replaces that stop for the non-interactive path. The `--force` preservation test stays. diff --git a/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/local-test-plan.md b/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/local-test-plan.md new file mode 100644 index 0000000..c793535 --- /dev/null +++ b/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/local-test-plan.md @@ -0,0 +1,14 @@ +# Local test plan + +> **Pick up new gitignore lines without --force** · `2026-10-19-pick-up-new-gitignore-lines-without-force` + +Node v20.19.3. Branch `story/issue-37/S-001` from main `cb20443`. The `EBADENGINE` warning for `engines.node >= 24` is expected. + +| # | Check | Expected | +|---|--------|----------| +| 1 | `initProject` without `force` after `.sdd/.gitignore` is replaced with `extra` | File starts with `extra`, contains `role-runs/`, and a second call does not change it. Config and `feature.yaml` stay byte-identical. | +| 2 | Custom `changes_path: .sdd/changes` and a changes ignore of `extra` | That file becomes `extra` plus `.active`. Config stays the edited text. The default `changes/.gitignore` stays `.active`. | +| 3 | Second `sdd init --here --no-agent` | Exit 0. Stdout contains `Added missing ignore lines.` Config and an edited workflow stay byte-identical. A third run prints `Ignore lines are already present.` and does not change the ignore file. | +| 4 | `sdd init --here --no-agent --force` with a custom `changes_path` and edited workflow and template | Those edits stay. The command still lists the kept files. | +| 5 | Interactive decline | The decline branch in `maybeConfirmNonEmpty` is unchanged and exits before a write. No TTY test. | +| 6 | `pnpm test` | Pass. | diff --git a/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/local-test-results.md b/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/local-test-results.md new file mode 100644 index 0000000..24d91f7 --- /dev/null +++ b/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/local-test-results.md @@ -0,0 +1,22 @@ +# Local test results + +> **Pick up new gitignore lines without --force** · `2026-10-19-pick-up-new-gitignore-lines-without-force` + +| # | Result | +|---|--------| +| 1 | PASS. `init-force.test.ts` appends `role-runs/` after `extra` and leaves config and `feature.yaml` unchanged. A second call keeps the same ignore bytes. | +| 2 | PASS. Custom `changes_path: .sdd/changes` gains `.active` under `extra`. Config stays edited. Default `changes/.gitignore` stays `.active`. | +| 3 | PASS. CLI test `re-running init --no-agent adds missing ignore lines and leaves config alone (#45)` passed inside `pnpm test`. | +| 4 | PASS. `pnpm test` includes `init --force preserves custom config paths and lists edited workflows/templates (#15)` and it passed. | +| 5 | Waived. The decline branch still calls `p.cancel` and `process.exit(0)` before any write. This story did not edit that branch, and there is no TTY harness. | +| 6 | PASS. `sdd verify --no-agent` ran `pnpm test` and exited 0. | + +--- + +## Latest run (2026-10-19T12:14:39.495Z) + +> Overall: PASS +> Stage: local_verify + + +- **Workspace tests**: exit 0 (`pnpm test`) diff --git a/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/meta.yaml b/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/meta.yaml new file mode 100644 index 0000000..07ee11a --- /dev/null +++ b/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/meta.yaml @@ -0,0 +1,43 @@ +id: 2026-10-19-pick-up-new-gitignore-lines-without-force +title: Pick up new gitignore lines without --force +workflow: feature +created: 2026-10-19T11:52:36.779Z +updated: 2026-10-19T12:14:39.495Z +status: in_progress +stage: local_verify +branch: story/issue-37/S-001 +epic: issue-37 +story: S-001 +depends: [] +flags: {} +overrides: + skip_stages: + - clarify_intent + - brainstorm + - clarify_design + - clarify_tasks + gates: {} + extra_stages: [] +skipped: + - stage: clarify_intent + reason: the approved spec has no open product choice + at: 2026-10-19T12:00:27.871Z + - stage: brainstorm + reason: one approach is enough + at: 2026-10-19T12:00:28.613Z + - stage: clarify_design + reason: the approved design has no open product choice + at: 2026-10-19T12:00:30.171Z + - stage: clarify_tasks + reason: the approved design is the task list + at: 2026-10-19T12:00:31.738Z +gates: {} +verify_results: + local_verify: + ok: true + status: pass + head_sha: cb20443d5b0c143f2175d53c56cbcbe4f643a977 + at: 2026-10-19T12:14:39.495Z + results: + - name: Workspace tests + exitCode: 0 diff --git a/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/metrics.json b/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/metrics.json new file mode 100644 index 0000000..ac2eb18 --- /dev/null +++ b/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/metrics.json @@ -0,0 +1,50 @@ +{ + "version": 1, + "phases": [ + { + "phase": "intent", + "startedAt": "2026-10-19T11:52:36.779Z", + "endedAt": "2026-10-19T12:00:27.032Z" + }, + { + "phase": "clarify_intent", + "startedAt": "2026-10-19T12:00:27.032Z", + "endedAt": "2026-10-19T12:00:27.872Z" + }, + { + "phase": "brainstorm", + "startedAt": "2026-10-19T12:00:27.872Z", + "endedAt": "2026-10-19T12:00:28.614Z" + }, + { + "phase": "design", + "startedAt": "2026-10-19T12:00:28.614Z", + "endedAt": "2026-10-19T12:00:29.335Z" + }, + { + "phase": "clarify_design", + "startedAt": "2026-10-19T12:00:29.335Z", + "endedAt": "2026-10-19T12:00:30.172Z" + }, + { + "phase": "tasks", + "startedAt": "2026-10-19T12:00:30.172Z", + "endedAt": "2026-10-19T12:00:30.917Z" + }, + { + "phase": "clarify_tasks", + "startedAt": "2026-10-19T12:00:30.917Z", + "endedAt": "2026-10-19T12:00:31.739Z" + }, + { + "phase": "implement", + "startedAt": "2026-10-19T12:00:31.739Z", + "endedAt": "2026-10-19T12:13:21.018Z" + }, + { + "phase": "local_verify", + "startedAt": "2026-10-19T12:13:21.018Z" + } + ], + "requests": [] +} diff --git a/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/tasks.md b/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/tasks.md new file mode 100644 index 0000000..078b240 --- /dev/null +++ b/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +- [x] Add `repairRequiredIgnores` and return it from the non-force existing-config path in `initProject`. +- [x] Make non-interactive `sdd init` call that repair and return before the already-initialized error and before `force || already`. +- [x] Update the core and CLI tests for the new exit and the unchanged config and workflow. +- [x] Update `docs/reference/cli.md`, `layout.md`, and `agents.md`. diff --git a/docs/reference/agents.md b/docs/reference/agents.md index e166694..b92985c 100644 --- a/docs/reference/agents.md +++ b/docs/reference/agents.md @@ -66,7 +66,7 @@ This protection covers **`AGENTS.md`, host agent files and folders, `.sdd/protoc - If you already have an `AGENTS.md`, sdd leaves it alone and prints a short section you can paste into it. - Files from sdd versions older than this marker count as unmarked. To regenerate one, delete it and re-run. -**Project state under `.sdd/`:** If `.sdd/config.yaml` already exists, non-interactive `sdd init` without `--force` stops and writes nothing, so it does not repair gitignore. On a terminal, confirming the re-init prompt continues and behaves like `--force`. `--force` merges current defaults into that config. It refreshes a workflow, template, protocol, or README only while the file is still an unchanged sdd-generated file. Edited files, and files sdd did not generate, stay as they are even on a first init. `memory/` is never replaced. Host install rewrites `.sdd/agents.json` when the file is missing or when that install runs with force (the `--force` flag, or a confirmed terminal re-init). `sdd init --no-agent` does not write `.sdd/agents.json`. Init refreshes `.sdd/active-context.md`. A fresh init writes `.sdd/.gitignore` for `handoff.md` and `active-context.md`, and `/.gitignore` for `.active`. `--force` appends any of those lines that are missing and leaves the rest of the file in place. +**Project state under `.sdd/`:** If `.sdd/config.yaml` already exists, non-interactive `sdd init` without `--force` appends missing ignore lines and does not rewrite config, memory, or workflows. On a terminal, confirming the re-init prompt continues and behaves like `--force`. `--force` merges current defaults into that config. It refreshes a workflow, template, protocol, or README only while the file is still an unchanged sdd-generated file. Edited files, and files sdd did not generate, stay as they are even on a first init. `memory/` is never replaced. Host install rewrites `.sdd/agents.json` when the file is missing or when that install runs with force (the `--force` flag, or a confirmed terminal re-init). `sdd init --no-agent` does not write `.sdd/agents.json`. A fresh init, and a confirmed or `--force` re-init, refreshes `.sdd/active-context.md`. A fresh init writes `.sdd/.gitignore` for `handoff.md` and `active-context.md`, and `/.gitignore` for `.active`. `--force` appends any of those lines that are missing and leaves the rest of the file in place. ## Agent roles (stubs) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index b08df9f..341b283 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -10,7 +10,7 @@ Install: **[@structured-vibe-coding/cli on npm](https://www.npmjs.com/package/@s Learn by doing: [Start in 10 minutes](../tutorials/first-change). One command’s flags: `sdd --help`. -Commit `.sdd/config.yaml`, workflows, templates, `protocol.md`, `memory/`, and change packs. Ignore `handoff.md`, `active-context.md`, `index.db`, `index.db-journal`, `index.db-wal`, `index.db-shm`, `models/`, and `/.active`. `sdd init` writes those gitignore rules. On a project that already has `.sdd/config.yaml`, `sdd init --force` appends any missing ignore line and does not replace the file. `sdd status` prints the change path relative to the repo. +Commit `.sdd/config.yaml`, workflows, templates, `protocol.md`, `memory/`, and change packs. Ignore `handoff.md`, `active-context.md`, `index.db`, `index.db-journal`, `index.db-wal`, `index.db-shm`, `models/`, `role-runs/`, and `/.active`. `sdd init` writes those gitignore rules. On a project that already has `.sdd/config.yaml`, `sdd init` without `--force` appends any missing ignore line and does not rewrite config, memory, or workflows. `sdd init --force` still refreshes unchanged generated files. `sdd status` prints the change path relative to the repo. --- diff --git a/docs/reference/layout.md b/docs/reference/layout.md index 40345fc..4f23bb1 100644 --- a/docs/reference/layout.md +++ b/docs/reference/layout.md @@ -34,7 +34,7 @@ AGENTS.md ← tiny pointer for AIs Commit the shared process: `.sdd/config.yaml`, `.sdd/workflows/`, `.sdd/templates/`, `.sdd/protocol.md`, `memory/`, and each change pack under the configured changes directory. -Ignore per-machine files. Init writes `.sdd/.gitignore` for `handoff.md` and `active-context.md`, and `/.gitignore` for `.active` (by default `changes/.active`). `.active` is the live change pointer. `handoff.md` and `active-context.md` are a repo-relative handoff and task brief. Commands that move or launch work rewrite them; they do not change on every command. If git already tracks one of them, init warns and leaves the index alone. A second non-interactive `sdd init` without `--force` does not repair gitignore when `.sdd/config.yaml` already exists. Confirming the re-init prompt in a terminal behaves like `--force`. Run `sdd init --force` to append any missing ignore lines without replacing the rest of the file. `.sdd/prices.yaml` is committed project config, written by init when missing, and not regenerated over a user edit. +Ignore per-machine files. Init writes `.sdd/.gitignore` for `handoff.md` and `active-context.md`, and `/.gitignore` for `.active` (by default `changes/.active`). `.active` is the live change pointer. `handoff.md` and `active-context.md` are a repo-relative handoff and task brief. Commands that move or launch work rewrite them; they do not change on every command. If git already tracks one of them, init warns and leaves the index alone. A second non-interactive `sdd init` without `--force` appends missing ignore lines, including `role-runs/`, and does not rewrite config, memory, or workflows. Confirming the re-init prompt in a terminal still behaves like `--force`. `sdd init --force` appends any missing ignore lines and refreshes unchanged generated files. `.sdd/prices.yaml` is committed project config, written by init when missing, and not regenerated over a user edit. ## Keep project data together diff --git a/packages/cli/src/__tests__/cli.integration.test.ts b/packages/cli/src/__tests__/cli.integration.test.ts index fcd93b5..c8c07e3 100644 --- a/packages/cli/src/__tests__/cli.integration.test.ts +++ b/packages/cli/src/__tests__/cli.integration.test.ts @@ -675,17 +675,28 @@ stages: expect(await exists(join(root, "AGENTS.md"))).toBe(false); }); - it("re-running init --no-agent explains safe --force behavior (#15)", async () => { + it("re-running init --no-agent adds missing ignore lines and leaves config alone (#45)", async () => { const root = await mkdtemp(join(tmpdir(), "sdd-cli-noagent-reinit-")); temps.push(root); expect(runSdd(root, ["init", "--here", "--no-agent"]).status).toBe(0); + const configPath = join(root, ".sdd/config.yaml"); + const workflowPath = join(root, ".sdd/workflows/feature.yaml"); + const ignorePath = join(root, ".sdd/.gitignore"); + const configBefore = await readFile(configPath, "utf8"); + const workflowBefore = await readFile(workflowPath, "utf8"); + await writeFile(ignorePath, "extra\n"); const again = runSdd(root, ["init", "--here", "--no-agent"]); - expect(again.status).toBe(1); - const out = again.stderr + again.stdout; - expect(out).toContain("already initialized"); - expect(out).toContain("preserves supported config values"); - expect(out).toContain("refreshes only unchanged SDD-generated workflows/templates"); - expect(out).toContain("edited or untracked files are kept"); + expect(again.status, again.stderr + again.stdout).toBe(0); + expect(again.stdout).toContain("Added missing ignore lines."); + const ignore = await readFile(ignorePath, "utf8"); + expect(ignore).toContain("role-runs/"); + expect(ignore.startsWith("extra\n")).toBe(true); + expect(await readFile(configPath, "utf8")).toBe(configBefore); + expect(await readFile(workflowPath, "utf8")).toBe(workflowBefore); + const third = runSdd(root, ["init", "--here", "--no-agent"]); + expect(third.status, third.stderr + third.stdout).toBe(0); + expect(third.stdout).toContain("Ignore lines are already present."); + expect(await readFile(ignorePath, "utf8")).toBe(ignore); }); it("init --force preserves custom config paths and lists edited workflows/templates (#15)", async () => { diff --git a/packages/cli/src/init-flow.ts b/packages/cli/src/init-flow.ts index 11b495f..bec3a74 100644 --- a/packages/cli/src/init-flow.ts +++ b/packages/cli/src/init-flow.ts @@ -20,6 +20,7 @@ import { optionForTarget, parseIntegration, refreshActiveAgentContext, + repairRequiredIgnores, } from "@structured-vibe-coding/core"; import pc from "picocolors"; @@ -273,6 +274,12 @@ export async function runSpeckitStyleInit(args: InitCliArgs): Promise { const { projectRoot, here } = await resolveProjectPath(args); const force = Boolean(args.force); + if (!force && (await isInitialized(projectRoot)) && !isInteractive()) { + const added = await repairRequiredIgnores(projectRoot); + console.log(added ? "Added missing ignore lines." : "Ignore lines are already present."); + return; + } + await maybeConfirmNonEmpty(projectRoot, force, here); if (args.noAgent) { diff --git a/packages/core/src/__tests__/init-force.test.ts b/packages/core/src/__tests__/init-force.test.ts index 69f9f61..b81513d 100644 --- a/packages/core/src/__tests__/init-force.test.ts +++ b/packages/core/src/__tests__/init-force.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "pathe"; import { afterEach, describe, expect, it } from "vitest"; @@ -41,14 +41,37 @@ describe("per-machine gitignore (#11)", () => { expect(await readFile(sddIgnore, "utf8")).not.toContain("sdd:generated"); }); - it("does not repair gitignore lines when re-init lacks --force", async () => { + it("appends missing gitignore lines without --force and does not rewrite config", async () => { const root = await freshProject(); const sddIgnore = join(root, ".sdd/.gitignore"); + const configPath = join(root, ".sdd/config.yaml"); + const workflowPath = join(root, ".sdd/workflows/feature.yaml"); + const configBefore = await readFile(configPath, "utf8"); + const workflowBefore = await readFile(workflowPath, "utf8"); await writeFile(sddIgnore, "extra\n"); - await expect(initProject({ projectRoot: root, agents: false })).rejects.toThrow( - /Already initialized/, - ); - expect(await readFile(sddIgnore, "utf8")).toBe("extra\n"); + await initProject({ projectRoot: root, agents: false }); + const ignore = await readFile(sddIgnore, "utf8"); + expect(ignore.startsWith("extra\n")).toBe(true); + expect(ignore).toContain("role-runs/\n"); + expect(await readFile(configPath, "utf8")).toBe(configBefore); + expect(await readFile(workflowPath, "utf8")).toBe(workflowBefore); + await initProject({ projectRoot: root, agents: false }); + expect(await readFile(sddIgnore, "utf8")).toBe(ignore); + }); + + it("appends .active under a custom changes_path and does not rewrite config", async () => { + const root = await freshProject(); + const configPath = join(root, ".sdd/config.yaml"); + const original = await readFile(configPath, "utf8"); + const edited = original.replace(/^changes_path: changes$/m, "changes_path: .sdd/changes"); + await writeFile(configPath, edited); + const customIgnore = join(root, ".sdd/changes/.gitignore"); + await mkdir(join(root, ".sdd/changes"), { recursive: true }); + await writeFile(customIgnore, "extra\n"); + await initProject({ projectRoot: root, agents: false }); + expect(await readFile(configPath, "utf8")).toBe(edited); + expect(await readFile(customIgnore, "utf8")).toBe("extra\n.active\n"); + expect(await readFile(join(root, "changes/.gitignore"), "utf8")).toBe(".active\n"); }); it("warns when a per-machine file is already tracked and does not untrack it", async () => { diff --git a/packages/core/src/init.ts b/packages/core/src/init.ts index fad34dc..1ea41f1 100644 --- a/packages/core/src/init.ts +++ b/packages/core/src/init.ts @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import { lstat } from "node:fs/promises"; import { join } from "pathe"; import { type AgentTarget, assertAgentInstallable, installAgentIntegration } from "./agents.js"; -import { defaultConfig, mergeConfigDefaults, saveConfig } from "./config.js"; +import { defaultConfig, loadConfig, mergeConfigDefaults, saveConfig } from "./config.js"; import { defaultMcpYamlPath, defaultMemoryDir, @@ -80,9 +80,8 @@ export async function initProject(opts: InitOptions): Promise { const configFile = join(root, "config.yaml"); if ((await pathExists(configFile)) && !force) { - throw new Error( - `Already initialized (${configFile}). Use --force to merge defaults and refresh unchanged generated files.`, - ); + await repairRequiredIgnores(projectRoot); + return { created, config: await loadConfig(projectRoot), kept, warnings }; } // Check before writing anything, so a bad path cannot leave .sdd/ half-written. @@ -297,21 +296,10 @@ Active work: \`.sdd/active-context.md\` and the configured changes folder. const sddIgnore = join(root, ".gitignore"); const changesIgnore = join(changesDir(projectRoot, config), ".gitignore"); - if ( - await ensureIgnoreEntries(sddIgnore, [ - "handoff.md", - "active-context.md", - "index.db", - "index.db-journal", - "index.db-wal", - "index.db-shm", - "models/", - "role-runs/", - ]) - ) { + if ((await ensureIgnoreEntries(sddIgnore, SDD_IGNORE_LINES)).created) { created.push(sddIgnore); } - if (await ensureIgnoreEntries(changesIgnore, [".active"])) { + if ((await ensureIgnoreEntries(changesIgnore, [".active"])).created) { created.push(changesIgnore); } const perMachine = [ @@ -356,8 +344,33 @@ Active work: \`.sdd/active-context.md\` and the configured changes folder. return { created, config, kept, warnings, agents: agentsResult }; } -/** Append missing ignore lines. Returns true when the file did not exist before. */ -async function ensureIgnoreEntries(filePath: string, required: string[]): Promise { +const SDD_IGNORE_LINES = [ + "handoff.md", + "active-context.md", + "index.db", + "index.db-journal", + "index.db-wal", + "index.db-shm", + "models/", + "role-runs/", +]; + +/** Append missing required ignore lines. Does not rewrite config, memory, or workflows. */ +export async function repairRequiredIgnores(projectRoot: string): Promise { + const config = await loadConfig(projectRoot); + const root = sddRoot(projectRoot); + const sdd = await ensureIgnoreEntries(join(root, ".gitignore"), SDD_IGNORE_LINES); + const changes = await ensureIgnoreEntries(join(changesDir(projectRoot, config), ".gitignore"), [ + ".active", + ]); + return sdd.added || changes.added; +} + +/** Append missing ignore lines. `created` is true when the file did not exist before. */ +async function ensureIgnoreEntries( + filePath: string, + required: string[], +): Promise<{ created: boolean; added: boolean }> { const existed = await pathExists(filePath); const original = existed ? await readText(filePath) : ""; const present = new Set( @@ -367,12 +380,12 @@ async function ensureIgnoreEntries(filePath: string, required: string[]): Promis .filter((line) => line.length > 0 && !line.startsWith("#")), ); const missing = required.filter((line) => !present.has(line)); - if (existed && missing.length === 0) return false; + if (existed && missing.length === 0) return { created: false, added: false }; let next = original; if (next.length > 0 && !next.endsWith("\n")) next += "\n"; next += missing.map((line) => `${line}\n`).join(""); await writeText(filePath, next); - return !existed; + return { created: !existed, added: missing.length > 0 }; } function gitTrackedPaths(projectRoot: string, relativePaths: string[]): Promise { From 15d6044eba93df520b81294b0a6fae6c7ff54569 Mon Sep 17 00:00:00 2001 From: Harsha Bharadwaj Date: Mon, 19 Oct 2026 17:49:11 +0530 Subject: [PATCH 2/2] chore: sync the local verify SHA to this feature commit --- .../local-test-results.md | 2 +- .../meta.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/local-test-results.md b/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/local-test-results.md index 24d91f7..f4a385d 100644 --- a/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/local-test-results.md +++ b/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/local-test-results.md @@ -17,6 +17,6 @@ > Overall: PASS > Stage: local_verify - + - **Workspace tests**: exit 0 (`pnpm test`) diff --git a/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/meta.yaml b/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/meta.yaml index 07ee11a..770fded 100644 --- a/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/meta.yaml +++ b/changes/2026-10-19-pick-up-new-gitignore-lines-without-force/meta.yaml @@ -2,7 +2,7 @@ id: 2026-10-19-pick-up-new-gitignore-lines-without-force title: Pick up new gitignore lines without --force workflow: feature created: 2026-10-19T11:52:36.779Z -updated: 2026-10-19T12:14:39.495Z +updated: 2026-10-19T12:19:02.351Z status: in_progress stage: local_verify branch: story/issue-37/S-001 @@ -36,7 +36,7 @@ verify_results: local_verify: ok: true status: pass - head_sha: cb20443d5b0c143f2175d53c56cbcbe4f643a977 + head_sha: 03f7da8dc931bd299bcf6aab4968782a7e1731ec at: 2026-10-19T12:14:39.495Z results: - name: Workspace tests