copperhead 0.6.0 → 0.8.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.
Files changed (118) hide show
  1. package/README.md +36 -4
  2. package/dist/agent/animate.js +76 -0
  3. package/dist/agent/animate.js.map +1 -0
  4. package/dist/agent/box.js +89 -0
  5. package/dist/agent/box.js.map +1 -0
  6. package/dist/agent/dock-renderer.js +173 -0
  7. package/dist/agent/dock-renderer.js.map +1 -0
  8. package/dist/agent/logo.js +21 -0
  9. package/dist/agent/logo.js.map +1 -0
  10. package/dist/agent/loop.js +130 -18
  11. package/dist/agent/loop.js.map +1 -1
  12. package/dist/agent/prompts.js +2 -1
  13. package/dist/agent/prompts.js.map +1 -1
  14. package/dist/agent/providers/claude-code.js +85 -116
  15. package/dist/agent/providers/claude-code.js.map +1 -1
  16. package/dist/agent/providers/cursor.js +317 -0
  17. package/dist/agent/providers/cursor.js.map +1 -0
  18. package/dist/agent/providers/tool-protocol.js +205 -0
  19. package/dist/agent/providers/tool-protocol.js.map +1 -0
  20. package/dist/agent/recovery.js +148 -0
  21. package/dist/agent/recovery.js.map +1 -0
  22. package/dist/agent/render.js +44 -12
  23. package/dist/agent/render.js.map +1 -1
  24. package/dist/agent/response-cache.js +81 -0
  25. package/dist/agent/response-cache.js.map +1 -0
  26. package/dist/agent/runmeta.js +4 -5
  27. package/dist/agent/runmeta.js.map +1 -1
  28. package/dist/agent/theme.js +84 -0
  29. package/dist/agent/theme.js.map +1 -0
  30. package/dist/agent/tools.js +61 -4
  31. package/dist/agent/tools.js.map +1 -1
  32. package/dist/agent/transcript.js.map +1 -1
  33. package/dist/cli.js +134 -13
  34. package/dist/cli.js.map +1 -1
  35. package/dist/commands/create.js +482 -38
  36. package/dist/commands/create.js.map +1 -1
  37. package/dist/commands/demo.js +146 -0
  38. package/dist/commands/demo.js.map +1 -0
  39. package/dist/commands/doctor.js +240 -0
  40. package/dist/commands/doctor.js.map +1 -0
  41. package/dist/commands/repl-inspect.js +342 -0
  42. package/dist/commands/repl-inspect.js.map +1 -0
  43. package/dist/commands/repl.js +618 -0
  44. package/dist/commands/repl.js.map +1 -0
  45. package/dist/config.js +24 -2
  46. package/dist/config.js.map +1 -1
  47. package/dist/kicad/bootstrap.js +166 -0
  48. package/dist/kicad/bootstrap.js.map +1 -0
  49. package/dist/kicad/cli.js +126 -6
  50. package/dist/kicad/cli.js.map +1 -1
  51. package/dist/kicad/spice.js +306 -0
  52. package/dist/kicad/spice.js.map +1 -0
  53. package/dist/kicad/symlib.js +228 -0
  54. package/dist/kicad/symlib.js.map +1 -0
  55. package/dist/memory/bom-table.js +193 -22
  56. package/dist/memory/bom-table.js.map +1 -1
  57. package/dist/memory/drift.js +33 -11
  58. package/dist/memory/drift.js.map +1 -1
  59. package/dist/util/cli-args.js +35 -0
  60. package/dist/util/cli-args.js.map +1 -0
  61. package/dist/util/dock.js +155 -0
  62. package/dist/util/dock.js.map +1 -0
  63. package/dist/util/git.js +165 -4
  64. package/dist/util/git.js.map +1 -1
  65. package/dist/util/live-prompt.js +542 -0
  66. package/dist/util/live-prompt.js.map +1 -0
  67. package/dist/util/paths.js +9 -0
  68. package/dist/util/paths.js.map +1 -1
  69. package/dist/util/preflight.js +37 -0
  70. package/dist/util/preflight.js.map +1 -1
  71. package/dist/util/retry.js +23 -0
  72. package/dist/util/retry.js.map +1 -1
  73. package/dist/util/select.js +172 -0
  74. package/dist/util/select.js.map +1 -0
  75. package/dist/util/tmp.js +119 -0
  76. package/dist/util/tmp.js.map +1 -0
  77. package/package.json +3 -2
  78. package/src/agent/animate.ts +90 -0
  79. package/src/agent/box.ts +99 -0
  80. package/src/agent/dock-renderer.ts +181 -0
  81. package/src/agent/logo.ts +23 -0
  82. package/src/agent/loop.ts +148 -18
  83. package/src/agent/prompts.ts +2 -1
  84. package/src/agent/providers/claude-code.ts +91 -122
  85. package/src/agent/providers/cursor.ts +364 -0
  86. package/src/agent/providers/tool-protocol.ts +212 -0
  87. package/src/agent/recovery.ts +162 -0
  88. package/src/agent/render.ts +56 -12
  89. package/src/agent/response-cache.ts +80 -0
  90. package/src/agent/runmeta.ts +6 -7
  91. package/src/agent/theme.ts +91 -0
  92. package/src/agent/tools.ts +62 -4
  93. package/src/agent/transcript.ts +1 -0
  94. package/src/agent/types.ts +17 -0
  95. package/src/cli.ts +139 -15
  96. package/src/commands/create.ts +581 -40
  97. package/src/commands/demo.ts +184 -0
  98. package/src/commands/doctor.ts +289 -0
  99. package/src/commands/repl-inspect.ts +353 -0
  100. package/src/commands/repl.ts +685 -0
  101. package/src/config.ts +40 -3
  102. package/src/kicad/bootstrap.ts +181 -0
  103. package/src/kicad/cli.ts +132 -7
  104. package/src/kicad/spice.ts +399 -0
  105. package/src/kicad/symlib.ts +248 -0
  106. package/src/layout/claude-ui-layout.md +72 -0
  107. package/src/layout/repl-ui-layout.md +139 -0
  108. package/src/memory/bom-table.ts +191 -20
  109. package/src/memory/drift.ts +42 -11
  110. package/src/util/cli-args.ts +42 -0
  111. package/src/util/dock.ts +161 -0
  112. package/src/util/git.ts +176 -4
  113. package/src/util/live-prompt.ts +595 -0
  114. package/src/util/paths.ts +10 -0
  115. package/src/util/preflight.ts +44 -0
  116. package/src/util/retry.ts +29 -0
  117. package/src/util/select.ts +192 -0
  118. package/src/util/tmp.ts +113 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Bottom-of-viewport dock built on DECSTBM scroll regions: content is fenced
3
+ * into rows [1, H - dockHeight] and the dock is painted with absolute cursor
4
+ * addressing below the scroll margin, so no amount of content output (or
5
+ * mouse wheel) can ever move it. Runs inside the alternate screen buffer the
6
+ * REPL enters at startup. This is the FrankenTUI/ratatui-inline technique;
7
+ * see also DECSTBM (CSI r).
8
+ */
9
+
10
+ import { truncateVisible } from '../agent/box.js';
11
+
12
+ const HIDE = '\x1b[?25l';
13
+ const SHOW = '\x1b[?25h';
14
+ /** Synchronized-output guards; terminals without support ignore them. */
15
+ const SYNC_ON = '\x1b[?2026h';
16
+ const SYNC_OFF = '\x1b[?2026l';
17
+ /** Reset the scroll region to the full screen. Homes the cursor (hence DECSC/DECRC around it). */
18
+ const REGION_RESET = '\x1b[r';
19
+ const SAVE = '\x1b7';
20
+ const RESTORE = '\x1b8';
21
+
22
+ export class TerminalDock {
23
+ /** Rows the dock currently occupies at the bottom of the screen. */
24
+ private dockH = 0;
25
+ /**
26
+ * True while the real cursor is parked at a caret position inside the dock
27
+ * (the DECSC slot then holds the content-region position to return to).
28
+ */
29
+ private parked = false;
30
+ /** Last set() payload, replayable after an external overdraw (animations). */
31
+ private last: { lines: string[]; caret?: { row: number; col: number } } | null = null;
32
+
33
+ constructor(private readonly out: NodeJS.WriteStream) {}
34
+
35
+ cols(): number {
36
+ return typeof this.out.columns === 'number' && this.out.columns ? this.out.columns : 80;
37
+ }
38
+
39
+ rows(): number {
40
+ return typeof this.out.rows === 'number' && this.out.rows ? this.out.rows : 24;
41
+ }
42
+
43
+ /**
44
+ * Replace the docked region. On a height change the content scroll region
45
+ * is re-fenced; same-height repaints are pure absolute-addressed writes and
46
+ * can never scroll anything. With `caret` (dock-relative 1-based row and
47
+ * column) the real terminal cursor is left parked there, Claude Code-style.
48
+ */
49
+ set(lines: string[], caret?: { row: number; col: number }): void {
50
+ if (!lines.length) {
51
+ this.release();
52
+ return;
53
+ }
54
+ const h = this.rows();
55
+ const shown = lines.slice(0, Math.max(1, h - 3)).map((l) => truncateVisible(l, this.cols() - 1));
56
+ const newH = shown.length;
57
+
58
+ let seq = SYNC_ON + HIDE;
59
+ if (this.parked) {
60
+ // Return to the content position first so the SAVE/RESTORE pairs below
61
+ // keep preserving it, not the old caret.
62
+ seq += RESTORE;
63
+ this.parked = false;
64
+ }
65
+ if (this.dockH === 0) {
66
+ // First activation: free the bottom rows by scrolling exactly as much
67
+ // as needed (preserves the banner above), then fence content into
68
+ // [1, h - newH]. DECSTBM homes the cursor; save/restore keeps it.
69
+ seq += SAVE + REGION_RESET + RESTORE;
70
+ seq += '\n'.repeat(newH) + `\x1b[${newH}A`;
71
+ seq += SAVE + `\x1b[1;${h - newH}r` + RESTORE;
72
+ } else if (newH !== this.dockH) {
73
+ // Height change while active (menu open/close): overlay mode. Re-fence
74
+ // and clear any rows freed by a shrink; never scroll content.
75
+ seq += SAVE + `\x1b[1;${h - newH}r`;
76
+ for (let r = h - Math.max(this.dockH, newH) + 1; r <= h - newH; r++) {
77
+ seq += `\x1b[${r};1H\x1b[2K`;
78
+ }
79
+ seq += RESTORE;
80
+ }
81
+ seq += SAVE;
82
+ for (let i = 0; i < newH; i++) {
83
+ seq += `\x1b[${h - newH + 1 + i};1H\x1b[2K${shown[i]!}`;
84
+ }
85
+ seq += RESTORE;
86
+ if (caret) {
87
+ const row = Math.min(h, h - newH + Math.max(1, caret.row));
88
+ const col = Math.max(1, Math.min(this.cols(), caret.col));
89
+ seq += SAVE + `\x1b[${row};${col}H`;
90
+ this.parked = true;
91
+ }
92
+ seq += SHOW + SYNC_OFF;
93
+ this.out.write(seq);
94
+ this.dockH = newH;
95
+ this.last = caret ? { lines, caret } : { lines };
96
+ }
97
+
98
+ /** Replay the last set() (e.g. after an animation overdrew part of the screen). */
99
+ repaint(): void {
100
+ if (this.last) this.set(this.last.lines, this.last.caret);
101
+ }
102
+
103
+ /** Rows available to content above the dock. */
104
+ contentRows(): number {
105
+ return Math.max(1, this.rows() - this.dockH);
106
+ }
107
+
108
+ /**
109
+ * Absolute-paint the whole content region (rows 1..contentRows) from the
110
+ * given lines — used by history scrolling to show any window of the session
111
+ * log. Does not touch the DECSC slot or the fence; callers repaint() the
112
+ * dock afterwards to restore the caret.
113
+ */
114
+ paintContent(lines: string[]): void {
115
+ const rowsN = this.contentRows();
116
+ const shown = lines.slice(-rowsN).map((l) => truncateVisible(l, this.cols() - 1));
117
+ let seq = SYNC_ON + HIDE;
118
+ for (let r = 1; r <= rowsN; r++) {
119
+ seq += `\x1b[${r};1H\x1b[2K${shown[r - 1] ?? ''}`;
120
+ }
121
+ seq += SYNC_OFF;
122
+ this.out.write(seq);
123
+ }
124
+
125
+ /**
126
+ * Write a scrollback line above the dock. With the fence in place a plain
127
+ * write cannot touch the dock rows; if the cursor is parked at the caret,
128
+ * hop back to the content region for the write and re-park after.
129
+ */
130
+ log(line: string): void {
131
+ if (!this.parked) {
132
+ this.out.write(line + '\n');
133
+ return;
134
+ }
135
+ this.out.write(SYNC_ON + RESTORE + line + '\n' + SAVE + SYNC_OFF);
136
+ // The slot now holds the advanced content position; the caret will be
137
+ // re-established by the next set() with a caret argument.
138
+ this.parked = false;
139
+ }
140
+
141
+ /** Drop the fence, clear the dock rows, and restore the cursor. */
142
+ release(): void {
143
+ if (!this.dockH) {
144
+ this.out.write(SHOW);
145
+ return;
146
+ }
147
+ const h = this.rows();
148
+ let seq = SYNC_ON;
149
+ if (this.parked) {
150
+ seq += RESTORE;
151
+ this.parked = false;
152
+ }
153
+ seq += SAVE + REGION_RESET + RESTORE;
154
+ seq += SAVE;
155
+ for (let r = h - this.dockH + 1; r <= h; r++) seq += `\x1b[${r};1H\x1b[2K`;
156
+ seq += RESTORE + SHOW + SYNC_OFF;
157
+ this.out.write(seq);
158
+ this.dockH = 0;
159
+ this.last = null;
160
+ }
161
+ }
package/src/util/git.ts CHANGED
@@ -1,13 +1,53 @@
1
1
  import { execa } from 'execa';
2
- import { cp, mkdir, mkdtemp, rm } from 'node:fs/promises';
2
+ import { access, constants, cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { PreflightError } from './preflight.js';
7
7
 
8
+ /**
9
+ * Paths copperhead must keep out of `git add -A`. KiCad ≥9 writes a
10
+ * git-backed local-history directory (`.history/`, complete with its own nested
11
+ * `.git`) into the project the first time kicad-cli touches it. Left untracked,
12
+ * that nested repo has an unborn HEAD, so a plain `git add -A` in the parent
13
+ * aborts with `error: '.history/' does not have a commit checked out` (exit
14
+ * 128) — which fails the commit at the end of every KiCad-touching stage
15
+ * (schematic, layout, outputs). Ignoring it is both correct (local history is
16
+ * never a project artifact) and the fix for that abort. Kept as a list so other
17
+ * KiCad transients can join it if they surface.
18
+ */
19
+ const GIT_ADD_EXCLUDES = ['.history/'];
20
+
21
+ /**
22
+ * Ensure the repo's root .gitignore lists each entry, appending only the
23
+ * missing ones. Idempotent and best-effort: a failure here must never block a
24
+ * commit, so it swallows its own errors. Run before any `git add -A` so a
25
+ * git-backed KiCad `.history/` (or similar nested repo) is skipped instead of
26
+ * aborting the add.
27
+ */
28
+ export async function ensureIgnored(repo: string, entries: string[]): Promise<void> {
29
+ try {
30
+ const p = path.join(repo, '.gitignore');
31
+ const text = existsSync(p) ? await readFile(p, 'utf8') : '';
32
+ const present = new Set(text.split('\n').map((l) => l.trim()));
33
+ const missing = entries.filter((e) => !present.has(e));
34
+ if (!missing.length) return;
35
+ const prefix = text.length && !text.endsWith('\n') ? '\n' : '';
36
+ await writeFile(p, text + prefix + missing.join('\n') + '\n', 'utf8');
37
+ } catch {
38
+ // best-effort: .gitignore maintenance must never be the thing that fails a run
39
+ }
40
+ }
41
+
8
42
  export interface GitSnapshot {
9
43
  head: string;
10
44
  stash: string | null;
45
+ /**
46
+ * Tree object holding the untracked-but-not-ignored files that `git stash
47
+ * create` cannot capture. Without it a rollback's `git clean -fd` deletes
48
+ * them for good; see snapshotUntracked().
49
+ */
50
+ untracked: string | null;
11
51
  }
12
52
 
13
53
  async function git(repo: string, args: string[]): Promise<string> {
@@ -15,6 +55,121 @@ async function git(repo: string, args: string[]): Promise<string> {
15
55
  return stdout.trim();
16
56
  }
17
57
 
58
+ /** Same as git(), with a scratch index so the repo's real index is untouched. */
59
+ async function gitWithIndex(repo: string, indexFile: string, args: string[]): Promise<string> {
60
+ const { stdout } = await execa('git', args, {
61
+ cwd: repo,
62
+ env: { GIT_INDEX_FILE: indexFile },
63
+ });
64
+ return stdout.trim();
65
+ }
66
+
67
+ /** Monotonic suffix so two scratch indexes in one process never collide. */
68
+ let scratchIndexSeq = 0;
69
+
70
+ /**
71
+ * Path for a throwaway index, inside the repo's own git dir rather than
72
+ * TMPDIR: git guarantees that directory exists and is writable, so a hostile
73
+ * or missing temp dir cannot make a snapshot fail and block a run from
74
+ * starting. Absolute, because git resolves GIT_INDEX_FILE against cwd.
75
+ */
76
+ async function scratchIndexPath(repo: string): Promise<string> {
77
+ const gitDir = await git(repo, ['rev-parse', '--absolute-git-dir']);
78
+ return path.join(gitDir, `copperhead-index-${process.pid}-${scratchIndexSeq++}`);
79
+ }
80
+
81
+ /**
82
+ * Write the untracked-but-not-ignored files to a tree object and return its
83
+ * sha, or null when there are none.
84
+ *
85
+ * `git stash create` only ever captures tracked changes, so on its own it
86
+ * leaves every new file a run (or the user) has not added yet outside the
87
+ * snapshot — and restore()'s `git clean -fd` then deletes exactly those. The
88
+ * two sets line up: `--exclude-standard` skips ignored paths and plain
89
+ * `clean -fd` (no -x) leaves them alone, so what is captured here is precisely
90
+ * what the rollback would otherwise destroy.
91
+ *
92
+ * Built through a scratch GIT_INDEX_FILE rather than `git add -A`, because
93
+ * this runs at the *start* of a run: staging the user's files would outlive a
94
+ * successful run and silently rewrite their staged/unstaged split.
95
+ */
96
+ async function snapshotUntracked(repo: string): Promise<string | null> {
97
+ const listed = await git(repo, ['ls-files', '--others', '--exclude-standard', '-z']);
98
+ const paths = listed.split('\0').filter(Boolean);
99
+ if (!paths.length) return null;
100
+ const usable = await readableOnly(repo, paths);
101
+ if (!usable.length) return null;
102
+ const indexFile = await scratchIndexPath(repo);
103
+ try {
104
+ await execa('git', ['update-index', '-z', '--add', '--stdin'], {
105
+ cwd: repo,
106
+ env: { GIT_INDEX_FILE: indexFile },
107
+ input: usable.join('\0') + '\0',
108
+ });
109
+ return (await gitWithIndex(repo, indexFile, ['write-tree'])) || null;
110
+ } catch (err) {
111
+ // A path that passed the readability check above and still failed here
112
+ // lost the race (permissions or existence changed in between). Same
113
+ // refusal, since the same file would be destroyed by the rollback.
114
+ throw unsnapshottable(String((err as Error).message).split('\n')[0] ?? 'unknown path');
115
+ } finally {
116
+ await rm(indexFile, { force: true }).catch(() => {});
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Drop untracked paths that no longer exist, and refuse on any that exist but
122
+ * cannot be read.
123
+ *
124
+ * `git update-index` aborts the whole batch with exit 128 on the first path it
125
+ * cannot open, and this runs before the first turn, so one stray root-owned or
126
+ * mode-000 file would otherwise refuse every `--allow-dirty` run with a bare
127
+ * `fatal: Unable to process path …`. A vanished path is dropped rather than
128
+ * refused: a file that no longer exists cannot be lost. One that exists but is
129
+ * unreadable is refused deliberately rather than skipped, because `restore()`'s
130
+ * `git clean -fd` deletes it either way, and skipping would quietly reinstate
131
+ * exactly the data loss the untracked snapshot exists to prevent.
132
+ */
133
+ async function readableOnly(repo: string, paths: string[]): Promise<string[]> {
134
+ const usable: string[] = [];
135
+ for (const p of paths) {
136
+ try {
137
+ await access(path.join(repo, p), constants.R_OK);
138
+ usable.push(p);
139
+ } catch (err) {
140
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue;
141
+ throw unsnapshottable(p);
142
+ }
143
+ }
144
+ return usable;
145
+ }
146
+
147
+ function unsnapshottable(what: string): PreflightError {
148
+ return new PreflightError(
149
+ `cannot read untracked file: ${what}`,
150
+ 'a run started with --allow-dirty promises your uncommitted work survives a failed run, but a file copperhead cannot read cannot be snapshotted, and the rollback would delete it with nothing to restore it from',
151
+ [
152
+ `make it readable: chmod +r "${what}"`,
153
+ 'or delete it, or add it to .gitignore so the rollback leaves it alone',
154
+ 'or commit your work and rerun without --allow-dirty',
155
+ ],
156
+ );
157
+ }
158
+
159
+ /**
160
+ * Re-materialize the untracked files captured by snapshotUntracked(). Runs
161
+ * after `stash apply` so tracked restores win any path collision.
162
+ */
163
+ async function restoreUntracked(repo: string, tree: string): Promise<void> {
164
+ const indexFile = await scratchIndexPath(repo);
165
+ try {
166
+ await gitWithIndex(repo, indexFile, ['read-tree', tree]);
167
+ await gitWithIndex(repo, indexFile, ['checkout-index', '-a', '-f']);
168
+ } finally {
169
+ await rm(indexFile, { force: true }).catch(() => {});
170
+ }
171
+ }
172
+
18
173
  export async function isGitRepo(repo: string): Promise<boolean> {
19
174
  try {
20
175
  await git(repo, ['rev-parse', '--git-dir']);
@@ -74,16 +229,19 @@ export async function gitPreflight(repo: string, opts: { allowDirty?: boolean }
74
229
 
75
230
  /**
76
231
  * Snapshot the working tree before a run. On a clean tree HEAD is enough;
77
- * with --allow-dirty we keep a `git stash create` object so uncommitted work
78
- * survives a rollback (SPEC §7).
232
+ * with --allow-dirty we keep a `git stash create` object for tracked changes
233
+ * plus a tree of the untracked files it cannot see, so uncommitted work
234
+ * survives a rollback intact (SPEC §7).
79
235
  */
80
236
  export async function snapshot(repo: string): Promise<GitSnapshot> {
81
237
  const head = await git(repo, ['rev-parse', 'HEAD']);
82
238
  let stash: string | null = null;
239
+ let untracked: string | null = null;
83
240
  if (await isDirty(repo)) {
84
241
  stash = (await git(repo, ['stash', 'create'])) || null;
242
+ untracked = await snapshotUntracked(repo);
85
243
  }
86
- return { head, stash };
244
+ return { head, stash, untracked };
87
245
  }
88
246
 
89
247
  /**
@@ -114,6 +272,18 @@ export async function restore(repo: string, snap: GitSnapshot): Promise<void> {
114
272
  if (snap.stash) {
115
273
  await git(repo, ['stash', 'apply', snap.stash]);
116
274
  }
275
+ // The clean above deleted every untracked file; put back the ones that
276
+ // were there before the run. Never fatal: a rollback that restored the
277
+ // tracked state is still better than one that threw halfway.
278
+ if (snap.untracked) {
279
+ try {
280
+ await restoreUntracked(repo, snap.untracked);
281
+ } catch (err) {
282
+ console.warn(
283
+ `warning: could not restore untracked files after rollback: ${(err as Error).message}`,
284
+ );
285
+ }
286
+ }
117
287
  } finally {
118
288
  if (backup && existsSync(backup)) {
119
289
  try {
@@ -147,6 +317,7 @@ export async function restore(repo: string, snap: GitSnapshot): Promise<void> {
147
317
  export async function preserveFailedRun(repo: string, runId: string): Promise<string | null> {
148
318
  try {
149
319
  if (!(await isDirty(repo))) return null;
320
+ await ensureIgnored(repo, GIT_ADD_EXCLUDES);
150
321
  // Never leave the audit trail staged: a staged-but-not-in-HEAD path is
151
322
  // deleted by restore()'s `reset --hard`, which silently defeats its
152
323
  // `clean -e .copperhead/runs` protection (that flag only spares untracked
@@ -181,6 +352,7 @@ export async function uncommittedCount(repo: string): Promise<number> {
181
352
  }
182
353
 
183
354
  export async function commitAll(repo: string, message: string): Promise<string> {
355
+ await ensureIgnored(repo, GIT_ADD_EXCLUDES);
184
356
  await git(repo, ['add', '-A']);
185
357
  await git(repo, ['commit', '-m', message]);
186
358
  return git(repo, ['rev-parse', 'HEAD']);