@link-assistant/hive-mind 1.73.1 → 1.73.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.73.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 0af65ad: Handle the auto-PR placeholder being listed in the target repository's `.gitignore` without aborting the whole run (issue #1825). Previously `git add .gitkeep` exited non-zero and the solver threw `Failed to add .gitkeep` → `FATAL ERROR: PR creation failed`. Now, when the placeholder (`.gitkeep` or `CLAUDE.md`) is gitignored, the solver by default prints a clear, environment-agnostic root-cause explanation and stops cleanly instead of forcing the commit. Two opt-in flags are added (usable with both `solve` and `/solve`): `--remove-git-keep-from-git-ignore` removes the literal placeholder entry from `.gitignore` first and then commits normally, and `--force-git-keep-commit` commits the placeholder anyway with `git add -f`.
8
+
3
9
  ## 1.73.1
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.73.1",
3
+ "version": "1.73.2",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -5,6 +5,263 @@
5
5
  * the max-lines lint budget.
6
6
  */
7
7
 
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+
11
+ /**
12
+ * Decide whether a single .gitignore line is a literal entry for `fileName`.
13
+ *
14
+ * We only auto-remove exact placeholder entries (e.g. a line that is just
15
+ * `.gitkeep`, `/.gitkeep`, `.gitkeep/` or `/.gitkeep/`). Glob rules such as
16
+ * `.git*` are intentionally left untouched: removing them could un-ignore
17
+ * unrelated files, which the user did not ask for.
18
+ *
19
+ * @param {string} line - raw .gitignore line.
20
+ * @param {string} fileName - placeholder file name (e.g. `.gitkeep`).
21
+ * @returns {boolean}
22
+ */
23
+ function isLiteralIgnoreEntry(line, fileName) {
24
+ const trimmed = line.trim();
25
+ if (!trimmed || trimmed.startsWith('#')) {
26
+ return false;
27
+ }
28
+ // Normalize away an optional leading "/" (anchored) and trailing "/" (dir).
29
+ const normalized = trimmed.replace(/^\//, '').replace(/\/$/, '');
30
+ return normalized === fileName;
31
+ }
32
+
33
+ /**
34
+ * Parse a single `git check-ignore -v <file>` output line.
35
+ *
36
+ * Format (when the rule comes from a file):
37
+ * <source>:<linenum>:<pattern>\t<pathname>
38
+ *
39
+ * @param {string} output - raw stdout from `git check-ignore -v`.
40
+ * @returns {{source: string, lineNum: number, pattern: string} | null}
41
+ */
42
+ function parseCheckIgnoreVerbose(output) {
43
+ const firstLine = (output || '')
44
+ .split('\n')
45
+ .map(l => l.trim())
46
+ .find(Boolean);
47
+ if (!firstLine) {
48
+ return null;
49
+ }
50
+ // Split off the trailing "\t<pathname>" so colons in the pathname can't confuse us.
51
+ const meta = firstLine.split('\t')[0];
52
+ // Greedy source match lets us tolerate paths containing ":" (rare); the
53
+ // line number is the last ":<digits>:" group before the pattern.
54
+ const match = meta.match(/^(.*):(\d+):(.*)$/);
55
+ if (!match) {
56
+ return null;
57
+ }
58
+ return { source: match[1], lineNum: Number(match[2]), pattern: match[3] };
59
+ }
60
+
61
+ /**
62
+ * Remove the literal placeholder entry (e.g. `.gitkeep`) from whatever
63
+ * .gitignore file currently causes `fileName` to be ignored, used by the
64
+ * opt-in `--remove-git-keep-from-git-ignore` flow (issue #1825).
65
+ *
66
+ * Walks the ignore chain (`git check-ignore -v`) and strips each literal
67
+ * matching line until the placeholder is no longer ignored. Glob rules and
68
+ * ignore sources outside the working tree (global excludes file) are left
69
+ * untouched and cause the removal to report failure so the caller can fall
70
+ * back to a clear message instead of silently mangling the repo.
71
+ *
72
+ * @returns {Promise<{removed: boolean, reason?: string, modifiedFiles: string[], stagedFiles: string[]}>}
73
+ */
74
+ export async function removePlaceholderFromGitignore({ $, tempDir, fileName }) {
75
+ const repoRoot = path.resolve(tempDir);
76
+ const modifiedFiles = [];
77
+ const stagedFiles = [];
78
+
79
+ for (let i = 0; i < 50; i++) {
80
+ const stillIgnored = await $({ cwd: tempDir, silent: true })`git check-ignore ${fileName}`;
81
+ if (stillIgnored.code !== 0) {
82
+ // No longer ignored — done.
83
+ return { removed: true, modifiedFiles, stagedFiles };
84
+ }
85
+
86
+ const verbose = await $({ cwd: tempDir, silent: true })`git check-ignore -v ${fileName}`;
87
+ const parsed = parseCheckIgnoreVerbose(verbose.stdout ? verbose.stdout.toString() : '');
88
+ if (!parsed) {
89
+ return { removed: false, reason: 'could-not-locate-rule', modifiedFiles, stagedFiles };
90
+ }
91
+
92
+ // Only edit ignore files that live inside the working tree.
93
+ const sourcePath = path.resolve(repoRoot, parsed.source);
94
+ if (sourcePath !== repoRoot && !sourcePath.startsWith(repoRoot + path.sep)) {
95
+ return { removed: false, reason: 'rule-outside-worktree', modifiedFiles, stagedFiles };
96
+ }
97
+
98
+ let content;
99
+ try {
100
+ content = await fs.readFile(sourcePath, 'utf8');
101
+ } catch {
102
+ return { removed: false, reason: 'cannot-read-ignore-file', modifiedFiles, stagedFiles };
103
+ }
104
+
105
+ const lines = content.split('\n');
106
+ const targetLine = lines[parsed.lineNum - 1];
107
+ if (targetLine === undefined || !isLiteralIgnoreEntry(targetLine, fileName)) {
108
+ // The rule is a glob (e.g. ".git*") or otherwise not a literal entry we
109
+ // can safely remove — refuse rather than over-editing the user's config.
110
+ return { removed: false, reason: 'rule-not-literal', modifiedFiles, stagedFiles, pattern: parsed.pattern };
111
+ }
112
+
113
+ lines.splice(parsed.lineNum - 1, 1);
114
+ await fs.writeFile(sourcePath, lines.join('\n'));
115
+
116
+ const relSource = path.relative(repoRoot, sourcePath);
117
+ if (!modifiedFiles.includes(relSource)) {
118
+ modifiedFiles.push(relSource);
119
+ }
120
+
121
+ // Stage committable ignore files (.gitignore); skip non-committable sources
122
+ // such as .git/info/exclude which the un-ignore already takes effect for.
123
+ const insideGitDir = relSource.split(path.sep)[0] === '.git';
124
+ if (!insideGitDir) {
125
+ const addIgnore = await $({ cwd: tempDir, silent: true })`git add ${relSource}`;
126
+ if (addIgnore.code === 0 && !stagedFiles.includes(relSource)) {
127
+ stagedFiles.push(relSource);
128
+ }
129
+ }
130
+ }
131
+
132
+ return { removed: false, reason: 'too-many-rules', modifiedFiles, stagedFiles };
133
+ }
134
+
135
+ /**
136
+ * Convenience wrapper: stage the placeholder and, if it failed solely because
137
+ * the repository gitignores it, stop with a clear user-facing explanation
138
+ * (issue #1825). Keeps the auto-PR caller small (it is near the max-lines
139
+ * budget). Returns the {@link addPlaceholderFileToGit} result for any other
140
+ * outcome so the caller can handle genuine failures as before.
141
+ *
142
+ * @returns {Promise<{code: number, ignored: boolean, action: string, stderr: string, removal?: object}>}
143
+ */
144
+ export async function stagePlaceholderFileOrExplain(params) {
145
+ const addResult = await addPlaceholderFileToGit(params);
146
+ if (addResult.code !== 0 && addResult.ignored) {
147
+ await reportIgnoredPlaceholderAndThrow({
148
+ fileName: params.fileName,
149
+ issueUrl: params.issueUrl,
150
+ addResult,
151
+ log: params.log,
152
+ formatAligned: params.formatAligned,
153
+ });
154
+ }
155
+ return addResult;
156
+ }
157
+
158
+ /**
159
+ * Log a clear, friendly explanation of why the auto-PR placeholder could not be
160
+ * committed (it is listed in the repository's .gitignore) and then throw a
161
+ * user-facing error so the run stops without a scary stack trace.
162
+ *
163
+ * This is the default behaviour for issue #1825's follow-up: instead of forcing
164
+ * the commit through, we explain the root cause and let the user choose how to
165
+ * proceed (manual fix, or one of the two opt-in flags). The message deliberately
166
+ * stays environment-agnostic — it only mentions the `solve` / `/solve` options.
167
+ *
168
+ * @param {object} params
169
+ * @param {string} params.fileName - placeholder file name (e.g. `.gitkeep`).
170
+ * @param {string} params.issueUrl - issue URL, used to build copy-paste commands.
171
+ * @param {object} [params.addResult] - result from addPlaceholderFileToGit (for the remove-failed reason).
172
+ * @param {Function} params.log - async logger.
173
+ * @param {Function} params.formatAligned - log line formatter.
174
+ * @throws always — the thrown error carries `hiveMindUserFacingLogged = true`.
175
+ */
176
+ export async function reportIgnoredPlaceholderAndThrow({ fileName, issueUrl, addResult, log, formatAligned }) {
177
+ const url = issueUrl || '<issue-url>';
178
+ await log('');
179
+ await log(formatAligned('🛑', 'Cannot add placeholder:', `${fileName} is listed in .gitignore`), { level: 'error' });
180
+ await log('');
181
+ await log(' 🔍 Root cause:');
182
+ await log(` The repository's .gitignore matches the temporary placeholder file "${fileName}".`);
183
+ await log(' The placeholder is created only to seed the initial draft pull request and is');
184
+ await log(' removed automatically when the task completes — but git refuses to add an ignored');
185
+ await log(' file, so the initial commit cannot be created.');
186
+
187
+ if (addResult?.action === 'remove-failed') {
188
+ await log('');
189
+ await log(' ⚠️ The ignore rule is not a plain "' + fileName + '" entry, so it cannot be removed');
190
+ await log(' automatically (removing it might un-ignore unrelated files). Resolve it manually');
191
+ await log(' or use --force-git-keep-commit.');
192
+ }
193
+
194
+ await log('');
195
+ await log(' 💡 How to resolve (pick one):');
196
+ await log(` 1. Remove "${fileName}" from .gitignore in the repository, then re-run.`);
197
+ await log(' 2. Let the tool remove it for you before committing:');
198
+ await log(` solve ${url} --remove-git-keep-from-git-ignore`);
199
+ await log(` /solve ${url} --remove-git-keep-from-git-ignore`);
200
+ await log(' 3. Commit the placeholder anyway, ignoring the .gitignore rule:');
201
+ await log(` solve ${url} --force-git-keep-commit`);
202
+ await log(` /solve ${url} --force-git-keep-commit`);
203
+ await log('');
204
+
205
+ const error = new Error(`Placeholder "${fileName}" is listed in .gitignore; use --remove-git-keep-from-git-ignore or --force-git-keep-commit, or remove it from .gitignore manually.`);
206
+ error.hiveMindUserFacingLogged = true;
207
+ throw error;
208
+ }
209
+
210
+ /**
211
+ * Emit the verbose "git add staged nothing" troubleshooting report and throw.
212
+ *
213
+ * Reached by auto-PR creation when the placeholder file was written but git did
214
+ * not stage any change (e.g. identical content is already tracked, or the file
215
+ * is gitignored in .gitkeep mode). Extracted from solve.auto-pr.lib.mjs to keep
216
+ * that module under the max-lines budget.
217
+ *
218
+ * @param {object} params
219
+ * @param {string} params.fileName - placeholder file name.
220
+ * @param {boolean} params.useClaudeFile - true for CLAUDE.md mode, false for .gitkeep mode.
221
+ * @param {string} params.tempDir - repository working directory.
222
+ * @param {string} params.branchName - target branch (debug info).
223
+ * @param {boolean} params.existingContent - whether the file already existed.
224
+ * @param {Function} params.log - async logger.
225
+ * @param {Function} params.formatAligned - log line formatter.
226
+ * @throws always.
227
+ */
228
+ export async function explainNothingStagedAndThrow({ fileName, useClaudeFile, tempDir, branchName, existingContent, log, formatAligned }) {
229
+ await log('');
230
+ await log(formatAligned('❌', 'GIT ADD FAILED:', 'Nothing was staged'), { level: 'error' });
231
+ await log('');
232
+ await log(' 🔍 What happened:');
233
+ await log(` ${fileName} was created but git did not stage any changes.`);
234
+ await log('');
235
+ await log(' 💡 Possible causes:');
236
+ await log(` • ${fileName} already exists with identical content`);
237
+ await log(' • File system sync issue');
238
+ if (!useClaudeFile) {
239
+ await log(` • ${fileName} is in .gitignore`);
240
+ }
241
+ await log('');
242
+ await log(' 🔧 Troubleshooting steps:');
243
+ await log(` 1. Check file exists: ls -la "${tempDir}/${fileName}"`);
244
+ await log(` 2. Check git status: cd "${tempDir}" && git status`);
245
+ if (useClaudeFile) {
246
+ await log(` 3. Force add: cd "${tempDir}" && git add -f ${fileName}`);
247
+ } else {
248
+ await log(` 3. Check if ignored: cd "${tempDir}" && git check-ignore ${fileName}`);
249
+ await log(` 4. Force add: cd "${tempDir}" && git add -f ${fileName}`);
250
+ }
251
+ await log('');
252
+ await log(' 📂 Debug information:');
253
+ await log(` Working directory: ${tempDir}`);
254
+ await log(` Branch: ${branchName}`);
255
+ if (!useClaudeFile) {
256
+ await log(' Mode: .gitkeep');
257
+ }
258
+ if (existingContent) {
259
+ await log(` Note: ${fileName} already existed (attempted to update with timestamp)`);
260
+ }
261
+ await log('');
262
+ throw new Error(`Git add staged nothing - ${fileName} may be unchanged${useClaudeFile ? '' : ' or ignored'}`);
263
+ }
264
+
8
265
  /**
9
266
  * Stage the temporary placeholder file (CLAUDE.md or .gitkeep) used to seed the
10
267
  * initial auto-PR commit.
@@ -14,13 +271,18 @@
14
271
  * cleanupClaudeFile in solve.results.lib.mjs). When the target repository's
15
272
  * .gitignore matches the placeholder — issue #1825: e.g. rumaster/tg-games
16
273
  * ignores `.gitkeep` — a plain `git add <file>` exits non-zero with
17
- * "The following paths are ignored by one of your .gitignore files", which
18
- * previously aborted PR creation with a fatal "Failed to add .gitkeep".
274
+ * "The following paths are ignored by one of your .gitignore files".
275
+ *
276
+ * Behaviour when the placeholder is git-ignored (issue #1825 follow-up):
277
+ * - Default: do NOT force anything. Return `action: 'blocked'` so the caller
278
+ * can explain the root cause and offer the opt-in flags below.
279
+ * - `--remove-git-keep-from-git-ignore`: strip the literal placeholder entry
280
+ * from .gitignore, then add normally (`action: 'removed-from-gitignore'`).
281
+ * - `--force-git-keep-commit`: keep the previous behaviour and force-add with
282
+ * `git add -f` (`action: 'forced'`).
19
283
  *
20
- * Because the placeholder belongs to us and is short-lived, we confirm the path
21
- * is actually ignored with `git check-ignore` and then retry with
22
- * `git add -f`. Force-adding only happens for the ignored-placeholder case;
23
- * any other add failure is surfaced unchanged so genuine errors are not masked.
284
+ * Any add failure that is NOT caused by .gitignore is surfaced unchanged
285
+ * (`action: 'failed'`) so genuine errors are not masked.
24
286
  *
25
287
  * @param {object} params
26
288
  * @param {Function} params.$ - command-stream tagged-template runner.
@@ -29,15 +291,17 @@
29
291
  * @param {Function} [params.log] - async logger.
30
292
  * @param {Function} [params.formatAligned] - log line formatter.
31
293
  * @param {boolean} [params.verbose] - emit verbose diagnostics.
32
- * @returns {Promise<{code: number, forced: boolean, ignored: boolean, stderr: string}>}
294
+ * @param {boolean} [params.forceGitKeepCommit] - force-add even when ignored.
295
+ * @param {boolean} [params.removeGitKeepFromGitIgnore] - remove the .gitignore entry first.
296
+ * @returns {Promise<{code: number, ignored: boolean, action: string, stderr: string, removal?: object}>}
33
297
  */
34
- export async function addPlaceholderFileToGit({ $, tempDir, fileName, log, formatAligned, verbose = false }) {
298
+ export async function addPlaceholderFileToGit({ $, tempDir, fileName, log, formatAligned, verbose = false, forceGitKeepCommit = false, removeGitKeepFromGitIgnore = false }) {
35
299
  // Run silently: `git add` is quiet on success and only emits the noisy
36
300
  // "paths are ignored ... Use -f" hint on failure, which we capture in
37
301
  // `stderr` and re-surface from the caller only when the failure is genuine.
38
302
  const addResult = await $({ cwd: tempDir, silent: true })`git add ${fileName}`;
39
303
  if (addResult.code === 0) {
40
- return { code: 0, forced: false, ignored: false, stderr: '' };
304
+ return { code: 0, ignored: false, action: 'added', stderr: '' };
41
305
  }
42
306
 
43
307
  const stderr = addResult.stderr ? addResult.stderr.toString() : '';
@@ -50,21 +314,50 @@ export async function addPlaceholderFileToGit({ $, tempDir, fileName, log, forma
50
314
  if (!ignored) {
51
315
  // The failure was not caused by .gitignore — surface the original error so
52
316
  // genuine problems (permissions, corrupt index, ...) are not masked.
53
- return { code: addResult.code, forced: false, ignored: false, stderr };
317
+ return { code: addResult.code, ignored: false, action: 'failed', stderr };
54
318
  }
55
319
 
56
- if (log && formatAligned) {
57
- await log(formatAligned('ℹ️', `${fileName} is ignored:`, 'Force-adding placeholder (git add -f)'));
320
+ // The placeholder is ignored. Resolve based on the opt-in flags.
321
+ if (removeGitKeepFromGitIgnore) {
322
+ if (log && formatAligned) {
323
+ await log(formatAligned('ℹ️', `${fileName} is ignored:`, 'Removing it from .gitignore (--remove-git-keep-from-git-ignore)'));
324
+ }
325
+ const removal = await removePlaceholderFromGitignore({ $, tempDir, fileName });
326
+ if (!removal.removed) {
327
+ // Could not safely remove (glob rule, external source, ...). Block with
328
+ // detail so the caller can explain and suggest --force-git-keep-commit.
329
+ return { code: addResult.code, ignored: true, action: 'remove-failed', stderr, removal };
330
+ }
331
+ if (verbose && log) {
332
+ await log(` Removed ${fileName} from: ${removal.modifiedFiles.join(', ') || '(none)'}`, { verbose: true });
333
+ }
334
+ const retry = await $({ cwd: tempDir, silent: true })`git add ${fileName}`;
335
+ return {
336
+ code: retry.code,
337
+ ignored: true,
338
+ action: 'removed-from-gitignore',
339
+ stderr: retry.stderr ? retry.stderr.toString() : '',
340
+ removal,
341
+ };
58
342
  }
59
- if (verbose && log) {
60
- await log(` ${fileName} matched a .gitignore rule; retrying with: git add -f ${fileName}`, { verbose: true });
343
+
344
+ if (forceGitKeepCommit) {
345
+ if (log && formatAligned) {
346
+ await log(formatAligned('ℹ️', `${fileName} is ignored:`, 'Force-adding placeholder (--force-git-keep-commit)'));
347
+ }
348
+ if (verbose && log) {
349
+ await log(` ${fileName} matched a .gitignore rule; --force-git-keep-commit is set, retrying with: git add -f ${fileName}`, { verbose: true });
350
+ }
351
+ const forcedResult = await $({ cwd: tempDir, silent: true })`git add -f ${fileName}`;
352
+ return {
353
+ code: forcedResult.code,
354
+ ignored: true,
355
+ action: 'forced',
356
+ stderr: forcedResult.stderr ? forcedResult.stderr.toString() : '',
357
+ };
61
358
  }
62
359
 
63
- const forcedResult = await $({ cwd: tempDir, silent: true })`git add -f ${fileName}`;
64
- return {
65
- code: forcedResult.code,
66
- forced: true,
67
- ignored: true,
68
- stderr: forcedResult.stderr ? forcedResult.stderr.toString() : '',
69
- };
360
+ // Default: do not force through. Let the caller explain the root cause and
361
+ // offer the opt-in flags or a manual fix (issue #1825 follow-up).
362
+ return { code: addResult.code, ignored: true, action: 'blocked', stderr };
70
363
  }
@@ -8,7 +8,7 @@ import { handleRejectedPushForAutoPr, synchronizeExistingIssueBranchBeforeAutoPr
8
8
  import { emitForkAwareDiagnostic } from './solve.auto-pr-fork-diagnostic.lib.mjs';
9
9
 
10
10
  import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller. Issue #1756: execGhWithRetry retries on transient 5xx (504) too.
11
- import { addPlaceholderFileToGit } from './solve.auto-pr-placeholder.lib.mjs'; // Issue #1825: force-adds the seed placeholder when the target repo gitignores it.
11
+ import { stagePlaceholderFileOrExplain, explainNothingStagedAndThrow } from './solve.auto-pr-placeholder.lib.mjs'; // Issue #1825: handles the seed placeholder when the target repo gitignores it.
12
12
 
13
13
  export async function handleAutoPrCreation({ argv, tempDir, branchName, issueNumber, owner, repo, defaultBranch, forkedRepo, isContinueMode, prNumber, log, formatAligned, $, reportError, path, fs }) {
14
14
  // Skip auto-PR creation if:
@@ -167,9 +167,22 @@ Proceed.
167
167
  // Add and commit the file
168
168
  await log(formatAligned('📦', 'Adding file:', 'To git staging'));
169
169
 
170
- // Issue #1825: force-adds the placeholder when the target repo gitignores
171
- // it (e.g. ignores `.gitkeep`), so PR creation is no longer aborted.
172
- const addResult = await addPlaceholderFileToGit({ $, tempDir, fileName, log, formatAligned, verbose: argv.verbose });
170
+ // Issue #1825: by default we no longer force the placeholder through when
171
+ // the target repo gitignores it. stagePlaceholderFileOrExplain stops with a
172
+ // clear root-cause message unless --force-git-keep-commit /
173
+ // --remove-git-keep-from-git-ignore is set. Shared opts are reused by the
174
+ // .gitkeep fallback below.
175
+ const placeholderStageOpts = {
176
+ $,
177
+ tempDir,
178
+ log,
179
+ formatAligned,
180
+ verbose: argv.verbose,
181
+ issueUrl,
182
+ forceGitKeepCommit: argv.forceGitKeepCommit,
183
+ removeGitKeepFromGitIgnore: argv.removeGitKeepFromGitIgnore,
184
+ };
185
+ const addResult = await stagePlaceholderFileOrExplain({ ...placeholderStageOpts, fileName });
173
186
 
174
187
  if (addResult.code !== 0) {
175
188
  await log(`❌ Failed to add ${fileName}`, { level: 'error' });
@@ -214,14 +227,13 @@ Proceed.
214
227
  await fs.writeFile(gitkeepPath, gitkeepContent);
215
228
  await log(formatAligned('✅', 'Created:', '.gitkeep file'));
216
229
 
217
- // Try to add .gitkeep (force-added if it too is gitignored issue #1825)
218
- const gitkeepAddResult = await addPlaceholderFileToGit({ $, tempDir, fileName: '.gitkeep', log, formatAligned, verbose: argv.verbose });
230
+ // Try to add .gitkeep. If it too is gitignored, honor the opt-in
231
+ // flags or explain the root cause (issue #1825).
232
+ const gitkeepAddResult = await stagePlaceholderFileOrExplain({ ...placeholderStageOpts, fileName: '.gitkeep' });
219
233
 
220
234
  if (gitkeepAddResult.code !== 0) {
221
235
  await log('❌ Failed to add .gitkeep', { level: 'error' });
222
- await log(` Error: ${gitkeepAddResult.stderr || 'Unknown error'}`, {
223
- level: 'error',
224
- });
236
+ await log(` Error: ${gitkeepAddResult.stderr || 'Unknown error'}`, { level: 'error' });
225
237
  throw new Error('Failed to add .gitkeep');
226
238
  }
227
239
 
@@ -231,9 +243,7 @@ Proceed.
231
243
 
232
244
  if (!gitStatus || gitStatus.length === 0) {
233
245
  await log('');
234
- await log(formatAligned('❌', 'GIT ADD FAILED:', 'Neither CLAUDE.md nor .gitkeep could be staged'), {
235
- level: 'error',
236
- });
246
+ await log(formatAligned('❌', 'GIT ADD FAILED:', 'Neither CLAUDE.md nor .gitkeep could be staged'), { level: 'error' });
237
247
  await log('');
238
248
  await log(' 🔍 What happened:');
239
249
  await log(' Both CLAUDE.md and .gitkeep failed to stage.');
@@ -249,58 +259,11 @@ Proceed.
249
259
  commitFileName = '.gitkeep';
250
260
  await log(formatAligned('✅', 'File staged:', '.gitkeep'));
251
261
  } else {
252
- await log('');
253
- await log(formatAligned('❌', 'GIT ADD FAILED:', 'Nothing was staged'), { level: 'error' });
254
- await log('');
255
- await log(' 🔍 What happened:');
256
- await log(' CLAUDE.md was created but git did not stage any changes.');
257
- await log('');
258
- await log(' 💡 Possible causes:');
259
- await log(' • CLAUDE.md already exists with identical content');
260
- await log(' • File system sync issue');
261
- await log('');
262
- await log(' 🔧 Troubleshooting steps:');
263
- await log(` 1. Check file exists: ls -la "${tempDir}/CLAUDE.md"`);
264
- await log(` 2. Check git status: cd "${tempDir}" && git status`);
265
- await log(` 3. Force add: cd "${tempDir}" && git add -f CLAUDE.md`);
266
- await log('');
267
- await log(' 📂 Debug information:');
268
- await log(` Working directory: ${tempDir}`);
269
- await log(` Branch: ${branchName}`);
270
- if (existingContent) {
271
- await log(' Note: CLAUDE.md already existed (attempted to update with timestamp)');
272
- }
273
- await log('');
274
- throw new Error('Git add staged nothing - CLAUDE.md may be unchanged');
262
+ await explainNothingStagedAndThrow({ fileName: 'CLAUDE.md', useClaudeFile: true, tempDir, branchName, existingContent, log, formatAligned });
275
263
  }
276
264
  } else {
277
265
  // In --gitkeep-file mode, if .gitkeep couldn't be staged, this is an error
278
- await log('');
279
- await log(formatAligned('❌', 'GIT ADD FAILED:', 'Nothing was staged'), { level: 'error' });
280
- await log('');
281
- await log(' 🔍 What happened:');
282
- await log(` ${fileName} was created but git did not stage any changes.`);
283
- await log('');
284
- await log(' 💡 Possible causes:');
285
- await log(` • ${fileName} already exists with identical content`);
286
- await log(' • File system sync issue');
287
- await log(` • ${fileName} is in .gitignore`);
288
- await log('');
289
- await log(' 🔧 Troubleshooting steps:');
290
- await log(` 1. Check file exists: ls -la "${tempDir}/${fileName}"`);
291
- await log(` 2. Check git status: cd "${tempDir}" && git status`);
292
- await log(` 3. Check if ignored: cd "${tempDir}" && git check-ignore ${fileName}`);
293
- await log(` 4. Force add: cd "${tempDir}" && git add -f ${fileName}`);
294
- await log('');
295
- await log(' 📂 Debug information:');
296
- await log(` Working directory: ${tempDir}`);
297
- await log(` Branch: ${branchName}`);
298
- await log(` Mode: ${useClaudeFile ? 'CLAUDE.md' : '.gitkeep'}`);
299
- if (existingContent) {
300
- await log(` Note: ${fileName} already existed (attempted to update with timestamp)`);
301
- }
302
- await log('');
303
- throw new Error(`Git add staged nothing - ${fileName} may be unchanged or ignored`);
266
+ await explainNothingStagedAndThrow({ fileName, useClaudeFile: false, tempDir, branchName, existingContent, log, formatAligned });
304
267
  }
305
268
  }
306
269
 
@@ -419,9 +382,7 @@ Proceed.
419
382
 
420
383
  // Check for archived repository error
421
384
  if (errorOutput.includes('archived') && errorOutput.includes('read-only')) {
422
- await log(`\n${formatAligned('❌', 'REPOSITORY ARCHIVED:', 'Cannot push to archived repository')}`, {
423
- level: 'error',
424
- });
385
+ await log(`\n${formatAligned('❌', 'REPOSITORY ARCHIVED:', 'Cannot push to archived repository')}`, { level: 'error' });
425
386
  await log('');
426
387
  await log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
427
388
  await log('');
@@ -717,9 +678,7 @@ Proceed.
717
678
  } else if (parentRepo !== `${owner}/${repo}` && sourceRepo !== `${owner}/${repo}`) {
718
679
  // Repository IS a fork, but of a different repository
719
680
  await log('');
720
- await log(formatAligned('❌', 'WRONG FORK PARENT:', 'Fork is from different repository'), {
721
- level: 'error',
722
- });
681
+ await log(formatAligned('❌', 'WRONG FORK PARENT:', 'Fork is from different repository'), { level: 'error' });
723
682
  await log('');
724
683
  await log(' 🔍 What happened:');
725
684
  await log(` The repository ${forkedRepo} IS a GitHub fork,`);
@@ -110,6 +110,16 @@ export const SOLVE_OPTION_DEFINITIONS = {
110
110
  description: 'Automatically use .gitkeep if CLAUDE.md is in .gitignore (pre-checks before creating file)',
111
111
  default: true,
112
112
  },
113
+ 'force-git-keep-commit': {
114
+ type: 'boolean',
115
+ description: 'If the auto-PR placeholder (.gitkeep) is listed in .gitignore, commit it anyway with `git add -f` instead of stopping (issue #1825). Off by default.',
116
+ default: false,
117
+ },
118
+ 'remove-git-keep-from-git-ignore': {
119
+ type: 'boolean',
120
+ description: 'If the auto-PR placeholder (.gitkeep) is listed in .gitignore, remove that entry from .gitignore first, then commit normally (issue #1825). Off by default.',
121
+ default: false,
122
+ },
113
123
  'auto-support-agents-md-as-claude-md': {
114
124
  type: 'boolean',
115
125
  description: '[EXPERIMENTAL] Temporarily copy AGENTS.md/agents.md to CLAUDE.md while Claude runs, then remove the temporary copy',