@link-assistant/hive-mind 1.73.1 → 1.73.3
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,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.73.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- a3eab04: Fix `solve` aborting with `GitHub compare API not ready - cannot create PR safely` when the compare/diff endpoint returns a transient HTTP 500 (`this diff is temporarily unavailable due to heavy server load`, code `not_available`). The auto-PR readiness gate polled `/repos/{owner}/{repo}/compare/{base}...{head}` to confirm the pushed commits were visible, but GitHub renders that diff lazily and returns 500 under load even though the branch and commits were already pushed and `gh pr create` (which does not render the full diff) would have succeeded. A new `isTransientCompareApiError` detector recognises the "heavy server load" / `not_available` 500 and the standard 5xx gateway codes (but NOT 404 fork mismatch or a literal `0`), and the gate now degrades gracefully — marking the compare ready and proceeding to PR creation, still guarded by branch verification and the local `git rev-list` commit check. The fork-404 mismatch and genuine 0-commits paths remain fatal.
|
|
8
|
+
|
|
9
|
+
## 1.73.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 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`.
|
|
14
|
+
|
|
3
15
|
## 1.73.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -305,6 +305,43 @@ const isTransientNetworkError = error => {
|
|
|
305
305
|
return TRANSIENT_NETWORK_PATTERNS.some(pattern => text.includes(pattern));
|
|
306
306
|
};
|
|
307
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Patterns that identify a *transient* failure of GitHub's compare/diff
|
|
310
|
+
* rendering endpoint (`/repos/{owner}/{repo}/compare/{base}...{head}`).
|
|
311
|
+
*
|
|
312
|
+
* Issue #1829: under heavy server load GitHub returns
|
|
313
|
+
* `HTTP 500: {"message":"...","errors":[{"code":"not_available",...}]}`
|
|
314
|
+
* with the body "this diff is temporarily unavailable due to heavy server
|
|
315
|
+
* load". This is NOT a "commits not indexed yet" condition — the branch and
|
|
316
|
+
* commits are already pushed and `gh pr create` (which does not render the
|
|
317
|
+
* full diff) would succeed. The readiness gate in `solve.auto-pr.lib.mjs`
|
|
318
|
+
* used to treat this as fatal and abort the whole session. These patterns let
|
|
319
|
+
* callers recognise the transient case and degrade gracefully instead.
|
|
320
|
+
*
|
|
321
|
+
* Note: HTTP 500 is deliberately matched here (and NOT in
|
|
322
|
+
* `TRANSIENT_NETWORK_PATTERNS`) because a bare 500 from arbitrary endpoints is
|
|
323
|
+
* too broad to retry blindly; it is only safe to treat as transient for the
|
|
324
|
+
* compare endpoint, alongside the explicit "not_available" / "heavy server
|
|
325
|
+
* load" markers.
|
|
326
|
+
*/
|
|
327
|
+
const TRANSIENT_COMPARE_API_PATTERNS = ['this diff is temporarily unavailable', 'temporarily unavailable due to heavy server load', 'heavy server load', 'not_available', 'http 500', 'http 502', 'http 503', 'http 504'];
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Detect whether `error` represents a transient failure of GitHub's
|
|
331
|
+
* compare/diff endpoint (issue #1829). Returns true for the documented
|
|
332
|
+
* "heavy server load" / `not_available` HTTP 500 response as well as the
|
|
333
|
+
* standard transient gateway codes (502/503/504), so the auto-PR readiness
|
|
334
|
+
* gate can fall through to PR creation rather than aborting.
|
|
335
|
+
*
|
|
336
|
+
* @param {unknown} error
|
|
337
|
+
* @returns {boolean}
|
|
338
|
+
*/
|
|
339
|
+
const isTransientCompareApiError = error => {
|
|
340
|
+
const text = collectErrorText(error).toLowerCase();
|
|
341
|
+
if (!text) return false;
|
|
342
|
+
return TRANSIENT_COMPARE_API_PATTERNS.some(pattern => text.includes(pattern));
|
|
343
|
+
};
|
|
344
|
+
|
|
308
345
|
/**
|
|
309
346
|
* Wrap `fn` so that GitHub rate-limit errors are converted into a sleep until
|
|
310
347
|
* (resetTime + bufferMs + jitterMs) followed by a retry. Transient network
|
|
@@ -438,11 +475,12 @@ export const wrapDollarWithGhRetry = (dollar, options = {}) => {
|
|
|
438
475
|
return wrapped;
|
|
439
476
|
};
|
|
440
477
|
|
|
441
|
-
export { isTransientNetworkError };
|
|
478
|
+
export { isTransientNetworkError, isTransientCompareApiError };
|
|
442
479
|
|
|
443
480
|
export default {
|
|
444
481
|
isRateLimitError,
|
|
445
482
|
isTransientNetworkError,
|
|
483
|
+
isTransientCompareApiError,
|
|
446
484
|
parseRateLimitReset,
|
|
447
485
|
fetchNextRateLimitReset,
|
|
448
486
|
fetchGitHubRateLimitUsage,
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compare-API readiness handler for the auto-PR pipeline.
|
|
3
|
+
*
|
|
4
|
+
* After a branch is pushed, the auto-PR flow polls GitHub's compare endpoint
|
|
5
|
+
* (`/repos/{owner}/{repo}/compare/{base}...{head}`) to confirm the pushed
|
|
6
|
+
* commits are visible before calling `gh pr create`. When that poll never
|
|
7
|
+
* reports commits ahead, this helper decides what the failure means:
|
|
8
|
+
*
|
|
9
|
+
* • HTTP 404 (fork mode) → repository mismatch. Investigate the fork
|
|
10
|
+
* relationship and abort with an actionable error (FATAL).
|
|
11
|
+
* • Issue #1829: a transient compare/diff failure (HTTP 500 "this diff is
|
|
12
|
+
* temporarily unavailable due to heavy server load" / code
|
|
13
|
+
* `not_available`, or a 5xx gateway error). The branch and commits were
|
|
14
|
+
* already pushed and `gh pr create` does not render the full diff, so this
|
|
15
|
+
* is a diff-RENDERING failure, NOT missing commits. Degrade gracefully and
|
|
16
|
+
* return `true` so the caller proceeds to PR creation — still guarded by
|
|
17
|
+
* branch verification and the LOCAL `git rev-list` commit check.
|
|
18
|
+
* • Anything else (genuinely 0 commits ahead / unknown failure) → abort with
|
|
19
|
+
* the original "GitHub compare API not ready" error (FATAL).
|
|
20
|
+
*
|
|
21
|
+
* Extracted from solve.auto-pr.lib.mjs to keep that file under the 1500-line
|
|
22
|
+
* CI cap.
|
|
23
|
+
*
|
|
24
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1829
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { isTransientCompareApiError } from './github-rate-limit.lib.mjs'; // Issue #1829: lets the compare-API readiness gate degrade gracefully on transient diff-render failures.
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Handle the case where the compare-API readiness poll never saw commits.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} params
|
|
33
|
+
* @param {object} params.argv
|
|
34
|
+
* @param {string|null} params.forkedRepo
|
|
35
|
+
* @param {string} params.owner
|
|
36
|
+
* @param {string} params.repo
|
|
37
|
+
* @param {string|number} params.issueNumber
|
|
38
|
+
* @param {string} params.branchName
|
|
39
|
+
* @param {string} params.targetBranchForCompare
|
|
40
|
+
* @param {number} params.maxCompareAttempts
|
|
41
|
+
* @param {object} params.compareResult - last command-stream compare result.
|
|
42
|
+
* @param {(msg: string, opts?: object) => Promise<void>} params.log
|
|
43
|
+
* @param {(symbol: string, label: string, value: string) => string} params.formatAligned
|
|
44
|
+
* @param {Function} params.$ - command-stream tagged function from solve.
|
|
45
|
+
* @returns {Promise<boolean>} `true` when the failure was transient and PR
|
|
46
|
+
* creation should proceed (degraded mode). Throws on fatal failures.
|
|
47
|
+
*/
|
|
48
|
+
export async function handleCompareApiNotReady({ argv, forkedRepo, owner, repo, issueNumber, branchName, targetBranchForCompare, maxCompareAttempts, compareResult, log, formatAligned, $ }) {
|
|
49
|
+
// Issue #1829: build the last compare-API output as a STRING. The
|
|
50
|
+
// command-stream result exposes stdout/stderr as Buffers, and both
|
|
51
|
+
// the 404 check below and isTransientCompareApiError expect a string
|
|
52
|
+
// (the rate-limit lib's collectErrorText returns '' for a raw Buffer).
|
|
53
|
+
const lastCompareOutput = `${compareResult?.stdout?.toString?.() ?? ''}${compareResult?.stderr?.toString?.() ?? ''}`;
|
|
54
|
+
|
|
55
|
+
// Check if this is a repository mismatch error (HTTP 404 from compare API)
|
|
56
|
+
let isRepositoryMismatch = false;
|
|
57
|
+
if (argv.fork && forkedRepo) {
|
|
58
|
+
// For fork mode, check the last compare API call result for 404
|
|
59
|
+
if (lastCompareOutput.includes('HTTP 404') || lastCompareOutput.includes('Not Found')) {
|
|
60
|
+
isRepositoryMismatch = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Issue #1829: GitHub's compare/diff endpoint can return a transient
|
|
65
|
+
// HTTP 500 ("this diff is temporarily unavailable due to heavy server
|
|
66
|
+
// load" / code "not_available") or a 5xx gateway error under load.
|
|
67
|
+
// That is a diff-RENDERING failure, NOT a "commits not indexed yet"
|
|
68
|
+
// condition: the branch and commits were already pushed, and
|
|
69
|
+
// `gh pr create` does not render the full diff, so it would still
|
|
70
|
+
// succeed. Aborting here used to kill the whole session needlessly.
|
|
71
|
+
// Treat a purely transient compare failure as non-fatal and fall
|
|
72
|
+
// through to PR creation — still guarded by the branch verification
|
|
73
|
+
// and the LOCAL `git rev-list` commit check below (and `gh pr create`
|
|
74
|
+
// itself retries transient 5xx via execGhWithRetry).
|
|
75
|
+
const compareFailedTransiently = !isRepositoryMismatch && isTransientCompareApiError(lastCompareOutput);
|
|
76
|
+
|
|
77
|
+
if (isRepositoryMismatch) {
|
|
78
|
+
// BEFORE showing any error, verify if the repository is actually a GitHub fork
|
|
79
|
+
await log('');
|
|
80
|
+
await log(formatAligned('🔍', 'Investigating:', 'Checking fork relationship...'));
|
|
81
|
+
|
|
82
|
+
const forkInfoResult = await $({
|
|
83
|
+
silent: true,
|
|
84
|
+
})`gh api repos/${forkedRepo} --jq '{fork: .fork, parent: .parent.full_name, source: .source.full_name}' 2>&1`;
|
|
85
|
+
|
|
86
|
+
let isFork = false;
|
|
87
|
+
let parentRepo = null;
|
|
88
|
+
let sourceRepo = null;
|
|
89
|
+
|
|
90
|
+
if (forkInfoResult.code === 0) {
|
|
91
|
+
try {
|
|
92
|
+
const forkInfo = JSON.parse(forkInfoResult.stdout.toString().trim());
|
|
93
|
+
isFork = forkInfo.fork === true;
|
|
94
|
+
parentRepo = forkInfo.parent || null;
|
|
95
|
+
sourceRepo = forkInfo.source || null;
|
|
96
|
+
} catch {
|
|
97
|
+
// Failed to parse fork info
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!isFork) {
|
|
102
|
+
// Repository is NOT a fork at all
|
|
103
|
+
await log('');
|
|
104
|
+
await log(formatAligned('❌', 'NOT A GITHUB FORK:', 'Repository is not a fork'), { level: 'error' });
|
|
105
|
+
await log('');
|
|
106
|
+
await log(' 🔍 What happened:');
|
|
107
|
+
await log(` The repository ${forkedRepo} is NOT a GitHub fork.`);
|
|
108
|
+
await log(' GitHub API reports: fork=false, parent=null');
|
|
109
|
+
await log('');
|
|
110
|
+
await log(' 💡 Why this happens:');
|
|
111
|
+
await log(' This repository was likely created by cloning and pushing (git clone + git push)');
|
|
112
|
+
await log(" instead of using GitHub's Fork button or API.");
|
|
113
|
+
await log('');
|
|
114
|
+
await log(' When a repository is created this way:');
|
|
115
|
+
await log(' • GitHub does not track it as a fork');
|
|
116
|
+
await log(' • It has no parent relationship with the original repository');
|
|
117
|
+
await log(' • Pull requests cannot be created to the original repository');
|
|
118
|
+
await log(' • Compare API returns 404 when comparing with unrelated repositories');
|
|
119
|
+
await log('');
|
|
120
|
+
await log(' 📦 Repository details:');
|
|
121
|
+
await log(' • Target repository: ' + `${owner}/${repo}`);
|
|
122
|
+
await log(' • Your repository: ' + forkedRepo);
|
|
123
|
+
await log(' • Fork status: false (NOT A FORK)');
|
|
124
|
+
await log('');
|
|
125
|
+
await log(' 🔧 How to fix:');
|
|
126
|
+
await log(' Option 1: Delete the non-fork repository and create a proper fork');
|
|
127
|
+
await log(` gh repo delete ${forkedRepo}`);
|
|
128
|
+
await log(` Then run this command again to create a proper GitHub fork of ${owner}/${repo}`);
|
|
129
|
+
await log('');
|
|
130
|
+
await log(' Option 2: Use --prefix-fork-name-with-owner-name to avoid name conflicts');
|
|
131
|
+
await log(` ./solve.mjs "https://github.com/${owner}/${repo}/issues/${issueNumber}" --prefix-fork-name-with-owner-name`);
|
|
132
|
+
await log(' This creates forks with names like "owner-repo" instead of just "repo"');
|
|
133
|
+
await log('');
|
|
134
|
+
await log(' Option 3: Work directly on the repository (if you have write access)');
|
|
135
|
+
await log(` ./solve.mjs "https://github.com/${owner}/${repo}/issues/${issueNumber}" --no-fork`);
|
|
136
|
+
await log('');
|
|
137
|
+
|
|
138
|
+
throw new Error('Repository is not a GitHub fork - cannot create PR to unrelated repository');
|
|
139
|
+
} else if (parentRepo !== `${owner}/${repo}` && sourceRepo !== `${owner}/${repo}`) {
|
|
140
|
+
// Repository IS a fork, but of a different repository
|
|
141
|
+
await log('');
|
|
142
|
+
await log(formatAligned('❌', 'WRONG FORK PARENT:', 'Fork is from different repository'), {
|
|
143
|
+
level: 'error',
|
|
144
|
+
});
|
|
145
|
+
await log('');
|
|
146
|
+
await log(' 🔍 What happened:');
|
|
147
|
+
await log(` The repository ${forkedRepo} IS a GitHub fork,`);
|
|
148
|
+
await log(` but it's a fork of a DIFFERENT repository than ${owner}/${repo}.`);
|
|
149
|
+
await log('');
|
|
150
|
+
await log(' 📦 Fork relationship:');
|
|
151
|
+
await log(' • Your fork: ' + forkedRepo);
|
|
152
|
+
await log(' • Fork parent: ' + (parentRepo || 'unknown'));
|
|
153
|
+
await log(' • Fork source: ' + (sourceRepo || 'unknown'));
|
|
154
|
+
await log(' • Target repository: ' + `${owner}/${repo}`);
|
|
155
|
+
await log('');
|
|
156
|
+
await log(' 💡 Why this happens:');
|
|
157
|
+
await log(' You have an existing fork from a different repository');
|
|
158
|
+
await log(' that shares the same name but is from a different source.');
|
|
159
|
+
await log(' GitHub treats forks hierarchically - each fork tracks its root repository.');
|
|
160
|
+
await log('');
|
|
161
|
+
await log(' 🔧 How to fix:');
|
|
162
|
+
await log(' Option 1: Delete the conflicting fork and create a new one');
|
|
163
|
+
await log(` gh repo delete ${forkedRepo}`);
|
|
164
|
+
await log(` Then run this command again to create a proper fork of ${owner}/${repo}`);
|
|
165
|
+
await log('');
|
|
166
|
+
await log(' Option 2: Use --prefix-fork-name-with-owner-name to avoid conflicts');
|
|
167
|
+
await log(` ./solve.mjs "https://github.com/${owner}/${repo}/issues/${issueNumber}" --prefix-fork-name-with-owner-name`);
|
|
168
|
+
await log(' This creates forks with names like "owner-repo" instead of just "repo"');
|
|
169
|
+
await log('');
|
|
170
|
+
await log(' Option 3: Work directly on the repository (if you have write access)');
|
|
171
|
+
await log(` ./solve.mjs "https://github.com/${owner}/${repo}/issues/${issueNumber}" --no-fork`);
|
|
172
|
+
await log('');
|
|
173
|
+
|
|
174
|
+
throw new Error('Fork parent mismatch - fork is from different repository tree');
|
|
175
|
+
} else {
|
|
176
|
+
// Repository is a fork of the correct parent, but compare API still failed
|
|
177
|
+
// This is unexpected - show detailed error
|
|
178
|
+
await log('');
|
|
179
|
+
await log(formatAligned('❌', 'COMPARE API ERROR:', 'Unexpected failure'), { level: 'error' });
|
|
180
|
+
await log('');
|
|
181
|
+
await log(' 🔍 What happened:');
|
|
182
|
+
await log(` The repository ${forkedRepo} is a valid fork of ${owner}/${repo},`);
|
|
183
|
+
await log(" but GitHub's compare API still returned an error.");
|
|
184
|
+
await log('');
|
|
185
|
+
await log(' 📦 Fork verification:');
|
|
186
|
+
await log(' • Your fork: ' + forkedRepo);
|
|
187
|
+
await log(' • Fork status: true (VALID FORK)');
|
|
188
|
+
await log(' • Fork parent: ' + (parentRepo || 'unknown'));
|
|
189
|
+
await log(' • Target repository: ' + `${owner}/${repo}`);
|
|
190
|
+
await log('');
|
|
191
|
+
await log(' 💡 This is unexpected:');
|
|
192
|
+
await log(' The fork relationship is correct, but the compare API failed.');
|
|
193
|
+
await log(' This might be a temporary GitHub API issue.');
|
|
194
|
+
await log('');
|
|
195
|
+
await log(' 🔧 How to fix:');
|
|
196
|
+
await log(' 1. Wait a minute and try creating the PR manually:');
|
|
197
|
+
if (argv.fork && forkedRepo) {
|
|
198
|
+
const forkUser = forkedRepo.split('/')[0];
|
|
199
|
+
await log(` gh pr create --draft --repo ${owner}/${repo} --base ${targetBranchForCompare} --head ${forkUser}:${branchName}`);
|
|
200
|
+
}
|
|
201
|
+
await log(' 2. Check if the issue persists - it might be a GitHub API outage');
|
|
202
|
+
await log('');
|
|
203
|
+
|
|
204
|
+
throw new Error('Compare API failed unexpectedly despite valid fork relationship');
|
|
205
|
+
}
|
|
206
|
+
} else if (compareFailedTransiently) {
|
|
207
|
+
// Issue #1829: the compare API failed only with a transient server
|
|
208
|
+
// error. Degrade gracefully — proceed to PR creation rather than
|
|
209
|
+
// aborting the whole session.
|
|
210
|
+
await log('');
|
|
211
|
+
await log(formatAligned('⚠️', 'COMPARE API DEGRADED:', 'Transient server error — proceeding'), {
|
|
212
|
+
level: 'warning',
|
|
213
|
+
});
|
|
214
|
+
await log('');
|
|
215
|
+
await log(' 🔍 What happened:');
|
|
216
|
+
await log(` GitHub's compare API failed with a transient server error after ${maxCompareAttempts} attempts`);
|
|
217
|
+
await log(' (e.g. HTTP 500 "this diff is temporarily unavailable due to heavy server');
|
|
218
|
+
await log(' load", or a 5xx gateway error).');
|
|
219
|
+
await log('');
|
|
220
|
+
await log(' 💡 Why this is safe to ignore:');
|
|
221
|
+
await log(' • The branch and commits were already pushed successfully.');
|
|
222
|
+
await log(' • This is a diff-RENDERING failure, not missing commits.');
|
|
223
|
+
await log(' • `gh pr create` does not render the full diff, so it can still succeed.');
|
|
224
|
+
await log(' • The branch is verified on GitHub and the local commit count is');
|
|
225
|
+
await log(' re-checked before PR creation; `gh pr create` retries 5xx errors too.');
|
|
226
|
+
await log('');
|
|
227
|
+
await log(' ➡️ Proceeding to PR creation despite the compare API error (issue #1829).');
|
|
228
|
+
await log('');
|
|
229
|
+
const firstLine =
|
|
230
|
+
lastCompareOutput
|
|
231
|
+
.split('\n')
|
|
232
|
+
.map(s => s.trim())
|
|
233
|
+
.filter(Boolean)[0] || 'unknown';
|
|
234
|
+
await log(` Last compare API output: ${firstLine}`, { verbose: true });
|
|
235
|
+
// Fall through to branch verification + local commit check + PR creation.
|
|
236
|
+
return true;
|
|
237
|
+
} else {
|
|
238
|
+
// Original timeout error for other cases
|
|
239
|
+
await log('');
|
|
240
|
+
await log(formatAligned('❌', 'GITHUB SYNC TIMEOUT:', 'Compare API not ready after retries'), {
|
|
241
|
+
level: 'error',
|
|
242
|
+
});
|
|
243
|
+
await log('');
|
|
244
|
+
await log(' 🔍 What happened:');
|
|
245
|
+
await log(` After ${maxCompareAttempts} attempts, GitHub's compare API still shows no commits`);
|
|
246
|
+
await log(` between ${targetBranchForCompare} and ${branchName}.`);
|
|
247
|
+
await log('');
|
|
248
|
+
await log(' 💡 This usually means:');
|
|
249
|
+
await log(" • GitHub's backend systems haven't finished indexing the push");
|
|
250
|
+
await log(" • There's a temporary issue with GitHub's API");
|
|
251
|
+
await log(' • The commits may not have been pushed correctly');
|
|
252
|
+
await log('');
|
|
253
|
+
await log(' 🔧 How to fix:');
|
|
254
|
+
await log(' 1. Wait a minute and try creating the PR manually:');
|
|
255
|
+
// For fork mode, use the correct head reference format
|
|
256
|
+
if (argv.fork && forkedRepo) {
|
|
257
|
+
const forkUser = forkedRepo.split('/')[0];
|
|
258
|
+
await log(` gh pr create --draft --repo ${owner}/${repo} --base ${targetBranchForCompare} --head ${forkUser}:${branchName}`);
|
|
259
|
+
} else {
|
|
260
|
+
await log(` gh pr create --draft --repo ${owner}/${repo} --base ${targetBranchForCompare} --head ${branchName}`);
|
|
261
|
+
}
|
|
262
|
+
await log(' 2. Check if the branch exists on GitHub:');
|
|
263
|
+
// Show the correct repository where the branch was pushed
|
|
264
|
+
const branchRepo = argv.fork && forkedRepo ? forkedRepo : `${owner}/${repo}`;
|
|
265
|
+
await log(` https://github.com/${branchRepo}/tree/${branchName}`);
|
|
266
|
+
await log(' 3. Check the commit is on GitHub:');
|
|
267
|
+
// Use the correct head reference for the compare API check
|
|
268
|
+
if (argv.fork && forkedRepo) {
|
|
269
|
+
const forkUser = forkedRepo.split('/')[0];
|
|
270
|
+
await log(` gh api repos/${owner}/${repo}/compare/${targetBranchForCompare}...${forkUser}:${branchName} --paginate`);
|
|
271
|
+
} else {
|
|
272
|
+
await log(` gh api repos/${owner}/${repo}/compare/${targetBranchForCompare}...${branchName} --paginate`);
|
|
273
|
+
}
|
|
274
|
+
await log('');
|
|
275
|
+
|
|
276
|
+
throw new Error('GitHub compare API not ready - cannot create PR safely');
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export default { handleCompareApiNotReady };
|
|
@@ -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"
|
|
18
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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
|
-
* @
|
|
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,
|
|
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,
|
|
317
|
+
return { code: addResult.code, ignored: false, action: 'failed', stderr };
|
|
54
318
|
}
|
|
55
319
|
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
}
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
import { closingIssueNumbersContain, parseClosingIssueNumbers } from './pr-issue-linking.lib.mjs';
|
|
7
7
|
import { handleRejectedPushForAutoPr, synchronizeExistingIssueBranchBeforeAutoPrCreation } from './solve.branch-divergence.lib.mjs';
|
|
8
8
|
import { emitForkAwareDiagnostic } from './solve.auto-pr-fork-diagnostic.lib.mjs';
|
|
9
|
+
import { handleCompareApiNotReady } from './solve.auto-pr-compare-readiness.lib.mjs'; // Issue #1829: decides whether a failed compare-API readiness poll is fatal (fork mismatch / 0 commits) or a transient diff-render failure to degrade past.
|
|
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 {
|
|
11
|
+
import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry, isTransientCompareApiError } 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. Issue #1829: isTransientCompareApiError lets the compare-API readiness gate degrade gracefully on transient diff-render failures.
|
|
12
|
+
import { stagePlaceholderFileOrExplain, explainNothingStagedAndThrow } from './solve.auto-pr-placeholder.lib.mjs'; // Issue #1825: handles the seed placeholder when the target repo gitignores it.
|
|
12
13
|
|
|
13
14
|
export async function handleAutoPrCreation({ argv, tempDir, branchName, issueNumber, owner, repo, defaultBranch, forkedRepo, isContinueMode, prNumber, log, formatAligned, $, reportError, path, fs }) {
|
|
14
15
|
// Skip auto-PR creation if:
|
|
@@ -167,9 +168,22 @@ Proceed.
|
|
|
167
168
|
// Add and commit the file
|
|
168
169
|
await log(formatAligned('📦', 'Adding file:', 'To git staging'));
|
|
169
170
|
|
|
170
|
-
// Issue #1825:
|
|
171
|
-
//
|
|
172
|
-
|
|
171
|
+
// Issue #1825: by default we no longer force the placeholder through when
|
|
172
|
+
// the target repo gitignores it. stagePlaceholderFileOrExplain stops with a
|
|
173
|
+
// clear root-cause message unless --force-git-keep-commit /
|
|
174
|
+
// --remove-git-keep-from-git-ignore is set. Shared opts are reused by the
|
|
175
|
+
// .gitkeep fallback below.
|
|
176
|
+
const placeholderStageOpts = {
|
|
177
|
+
$,
|
|
178
|
+
tempDir,
|
|
179
|
+
log,
|
|
180
|
+
formatAligned,
|
|
181
|
+
verbose: argv.verbose,
|
|
182
|
+
issueUrl,
|
|
183
|
+
forceGitKeepCommit: argv.forceGitKeepCommit,
|
|
184
|
+
removeGitKeepFromGitIgnore: argv.removeGitKeepFromGitIgnore,
|
|
185
|
+
};
|
|
186
|
+
const addResult = await stagePlaceholderFileOrExplain({ ...placeholderStageOpts, fileName });
|
|
173
187
|
|
|
174
188
|
if (addResult.code !== 0) {
|
|
175
189
|
await log(`❌ Failed to add ${fileName}`, { level: 'error' });
|
|
@@ -214,14 +228,13 @@ Proceed.
|
|
|
214
228
|
await fs.writeFile(gitkeepPath, gitkeepContent);
|
|
215
229
|
await log(formatAligned('✅', 'Created:', '.gitkeep file'));
|
|
216
230
|
|
|
217
|
-
// Try to add .gitkeep
|
|
218
|
-
|
|
231
|
+
// Try to add .gitkeep. If it too is gitignored, honor the opt-in
|
|
232
|
+
// flags or explain the root cause (issue #1825).
|
|
233
|
+
const gitkeepAddResult = await stagePlaceholderFileOrExplain({ ...placeholderStageOpts, fileName: '.gitkeep' });
|
|
219
234
|
|
|
220
235
|
if (gitkeepAddResult.code !== 0) {
|
|
221
236
|
await log('❌ Failed to add .gitkeep', { level: 'error' });
|
|
222
|
-
await log(` Error: ${gitkeepAddResult.stderr || 'Unknown error'}`, {
|
|
223
|
-
level: 'error',
|
|
224
|
-
});
|
|
237
|
+
await log(` Error: ${gitkeepAddResult.stderr || 'Unknown error'}`, { level: 'error' });
|
|
225
238
|
throw new Error('Failed to add .gitkeep');
|
|
226
239
|
}
|
|
227
240
|
|
|
@@ -231,9 +244,7 @@ Proceed.
|
|
|
231
244
|
|
|
232
245
|
if (!gitStatus || gitStatus.length === 0) {
|
|
233
246
|
await log('');
|
|
234
|
-
await log(formatAligned('❌', 'GIT ADD FAILED:', 'Neither CLAUDE.md nor .gitkeep could be staged'), {
|
|
235
|
-
level: 'error',
|
|
236
|
-
});
|
|
247
|
+
await log(formatAligned('❌', 'GIT ADD FAILED:', 'Neither CLAUDE.md nor .gitkeep could be staged'), { level: 'error' });
|
|
237
248
|
await log('');
|
|
238
249
|
await log(' 🔍 What happened:');
|
|
239
250
|
await log(' Both CLAUDE.md and .gitkeep failed to stage.');
|
|
@@ -249,58 +260,11 @@ Proceed.
|
|
|
249
260
|
commitFileName = '.gitkeep';
|
|
250
261
|
await log(formatAligned('✅', 'File staged:', '.gitkeep'));
|
|
251
262
|
} else {
|
|
252
|
-
await
|
|
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');
|
|
263
|
+
await explainNothingStagedAndThrow({ fileName: 'CLAUDE.md', useClaudeFile: true, tempDir, branchName, existingContent, log, formatAligned });
|
|
275
264
|
}
|
|
276
265
|
} else {
|
|
277
266
|
// 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`);
|
|
267
|
+
await explainNothingStagedAndThrow({ fileName, useClaudeFile: false, tempDir, branchName, existingContent, log, formatAligned });
|
|
304
268
|
}
|
|
305
269
|
}
|
|
306
270
|
|
|
@@ -419,9 +383,7 @@ Proceed.
|
|
|
419
383
|
|
|
420
384
|
// Check for archived repository error
|
|
421
385
|
if (errorOutput.includes('archived') && errorOutput.includes('read-only')) {
|
|
422
|
-
await log(`\n${formatAligned('❌', 'REPOSITORY ARCHIVED:', 'Cannot push to archived repository')}`, {
|
|
423
|
-
level: 'error',
|
|
424
|
-
});
|
|
386
|
+
await log(`\n${formatAligned('❌', 'REPOSITORY ARCHIVED:', 'Cannot push to archived repository')}`, { level: 'error' });
|
|
425
387
|
await log('');
|
|
426
388
|
await log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
427
389
|
await log('');
|
|
@@ -635,193 +597,40 @@ Proceed.
|
|
|
635
597
|
await log(` ⚠️ GitHub compare API shows 0 commits ahead (attempt ${compareAttempts}/${maxCompareAttempts})`, { level: 'warning' });
|
|
636
598
|
}
|
|
637
599
|
} else {
|
|
638
|
-
|
|
639
|
-
|
|
600
|
+
// Issue #1829: surface compare-API failures in normal output (not
|
|
601
|
+
// only verbose) so the degraded-mode decision below is explainable
|
|
602
|
+
// from the logs. Build the text as a STRING — the command-stream
|
|
603
|
+
// result exposes stdout/stderr as Buffers, and the transient
|
|
604
|
+
// detectors call String.prototype.toLowerCase().
|
|
605
|
+
const errorText = `${compareResult.stdout?.toString?.() ?? ''}${compareResult.stderr?.toString?.() ?? ''}`.trim();
|
|
606
|
+
const firstLine =
|
|
607
|
+
errorText
|
|
608
|
+
.split('\n')
|
|
609
|
+
.map(s => s.trim())
|
|
610
|
+
.filter(Boolean)[0] || 'unknown';
|
|
611
|
+
const transientNote = isTransientCompareApiError(errorText) ? ' (transient server error)' : '';
|
|
612
|
+
await log(` ⚠️ GitHub compare API error${transientNote} (attempt ${compareAttempts}/${maxCompareAttempts}): ${firstLine}`, { level: 'warning' });
|
|
613
|
+
if (argv.verbose && errorText) {
|
|
614
|
+
await log(` Compare API full output: ${errorText}`, { verbose: true });
|
|
640
615
|
}
|
|
641
616
|
}
|
|
642
617
|
}
|
|
643
618
|
|
|
644
619
|
if (!compareReady) {
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
const forkInfoResult = await $({
|
|
661
|
-
silent: true,
|
|
662
|
-
})`gh api repos/${forkedRepo} --jq '{fork: .fork, parent: .parent.full_name, source: .source.full_name}' 2>&1`;
|
|
663
|
-
|
|
664
|
-
let isFork = false;
|
|
665
|
-
let parentRepo = null;
|
|
666
|
-
let sourceRepo = null;
|
|
667
|
-
|
|
668
|
-
if (forkInfoResult.code === 0) {
|
|
669
|
-
try {
|
|
670
|
-
const forkInfo = JSON.parse(forkInfoResult.stdout.toString().trim());
|
|
671
|
-
isFork = forkInfo.fork === true;
|
|
672
|
-
parentRepo = forkInfo.parent || null;
|
|
673
|
-
sourceRepo = forkInfo.source || null;
|
|
674
|
-
} catch {
|
|
675
|
-
// Failed to parse fork info
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
if (!isFork) {
|
|
680
|
-
// Repository is NOT a fork at all
|
|
681
|
-
await log('');
|
|
682
|
-
await log(formatAligned('❌', 'NOT A GITHUB FORK:', 'Repository is not a fork'), { level: 'error' });
|
|
683
|
-
await log('');
|
|
684
|
-
await log(' 🔍 What happened:');
|
|
685
|
-
await log(` The repository ${forkedRepo} is NOT a GitHub fork.`);
|
|
686
|
-
await log(' GitHub API reports: fork=false, parent=null');
|
|
687
|
-
await log('');
|
|
688
|
-
await log(' 💡 Why this happens:');
|
|
689
|
-
await log(' This repository was likely created by cloning and pushing (git clone + git push)');
|
|
690
|
-
await log(" instead of using GitHub's Fork button or API.");
|
|
691
|
-
await log('');
|
|
692
|
-
await log(' When a repository is created this way:');
|
|
693
|
-
await log(' • GitHub does not track it as a fork');
|
|
694
|
-
await log(' • It has no parent relationship with the original repository');
|
|
695
|
-
await log(' • Pull requests cannot be created to the original repository');
|
|
696
|
-
await log(' • Compare API returns 404 when comparing with unrelated repositories');
|
|
697
|
-
await log('');
|
|
698
|
-
await log(' 📦 Repository details:');
|
|
699
|
-
await log(' • Target repository: ' + `${owner}/${repo}`);
|
|
700
|
-
await log(' • Your repository: ' + forkedRepo);
|
|
701
|
-
await log(' • Fork status: false (NOT A FORK)');
|
|
702
|
-
await log('');
|
|
703
|
-
await log(' 🔧 How to fix:');
|
|
704
|
-
await log(' Option 1: Delete the non-fork repository and create a proper fork');
|
|
705
|
-
await log(` gh repo delete ${forkedRepo}`);
|
|
706
|
-
await log(` Then run this command again to create a proper GitHub fork of ${owner}/${repo}`);
|
|
707
|
-
await log('');
|
|
708
|
-
await log(' Option 2: Use --prefix-fork-name-with-owner-name to avoid name conflicts');
|
|
709
|
-
await log(` ./solve.mjs "https://github.com/${owner}/${repo}/issues/${issueNumber}" --prefix-fork-name-with-owner-name`);
|
|
710
|
-
await log(' This creates forks with names like "owner-repo" instead of just "repo"');
|
|
711
|
-
await log('');
|
|
712
|
-
await log(' Option 3: Work directly on the repository (if you have write access)');
|
|
713
|
-
await log(` ./solve.mjs "https://github.com/${owner}/${repo}/issues/${issueNumber}" --no-fork`);
|
|
714
|
-
await log('');
|
|
715
|
-
|
|
716
|
-
throw new Error('Repository is not a GitHub fork - cannot create PR to unrelated repository');
|
|
717
|
-
} else if (parentRepo !== `${owner}/${repo}` && sourceRepo !== `${owner}/${repo}`) {
|
|
718
|
-
// Repository IS a fork, but of a different repository
|
|
719
|
-
await log('');
|
|
720
|
-
await log(formatAligned('❌', 'WRONG FORK PARENT:', 'Fork is from different repository'), {
|
|
721
|
-
level: 'error',
|
|
722
|
-
});
|
|
723
|
-
await log('');
|
|
724
|
-
await log(' 🔍 What happened:');
|
|
725
|
-
await log(` The repository ${forkedRepo} IS a GitHub fork,`);
|
|
726
|
-
await log(` but it's a fork of a DIFFERENT repository than ${owner}/${repo}.`);
|
|
727
|
-
await log('');
|
|
728
|
-
await log(' 📦 Fork relationship:');
|
|
729
|
-
await log(' • Your fork: ' + forkedRepo);
|
|
730
|
-
await log(' • Fork parent: ' + (parentRepo || 'unknown'));
|
|
731
|
-
await log(' • Fork source: ' + (sourceRepo || 'unknown'));
|
|
732
|
-
await log(' • Target repository: ' + `${owner}/${repo}`);
|
|
733
|
-
await log('');
|
|
734
|
-
await log(' 💡 Why this happens:');
|
|
735
|
-
await log(' You have an existing fork from a different repository');
|
|
736
|
-
await log(' that shares the same name but is from a different source.');
|
|
737
|
-
await log(' GitHub treats forks hierarchically - each fork tracks its root repository.');
|
|
738
|
-
await log('');
|
|
739
|
-
await log(' 🔧 How to fix:');
|
|
740
|
-
await log(' Option 1: Delete the conflicting fork and create a new one');
|
|
741
|
-
await log(` gh repo delete ${forkedRepo}`);
|
|
742
|
-
await log(` Then run this command again to create a proper fork of ${owner}/${repo}`);
|
|
743
|
-
await log('');
|
|
744
|
-
await log(' Option 2: Use --prefix-fork-name-with-owner-name to avoid conflicts');
|
|
745
|
-
await log(` ./solve.mjs "https://github.com/${owner}/${repo}/issues/${issueNumber}" --prefix-fork-name-with-owner-name`);
|
|
746
|
-
await log(' This creates forks with names like "owner-repo" instead of just "repo"');
|
|
747
|
-
await log('');
|
|
748
|
-
await log(' Option 3: Work directly on the repository (if you have write access)');
|
|
749
|
-
await log(` ./solve.mjs "https://github.com/${owner}/${repo}/issues/${issueNumber}" --no-fork`);
|
|
750
|
-
await log('');
|
|
751
|
-
|
|
752
|
-
throw new Error('Fork parent mismatch - fork is from different repository tree');
|
|
753
|
-
} else {
|
|
754
|
-
// Repository is a fork of the correct parent, but compare API still failed
|
|
755
|
-
// This is unexpected - show detailed error
|
|
756
|
-
await log('');
|
|
757
|
-
await log(formatAligned('❌', 'COMPARE API ERROR:', 'Unexpected failure'), { level: 'error' });
|
|
758
|
-
await log('');
|
|
759
|
-
await log(' 🔍 What happened:');
|
|
760
|
-
await log(` The repository ${forkedRepo} is a valid fork of ${owner}/${repo},`);
|
|
761
|
-
await log(" but GitHub's compare API still returned an error.");
|
|
762
|
-
await log('');
|
|
763
|
-
await log(' 📦 Fork verification:');
|
|
764
|
-
await log(' • Your fork: ' + forkedRepo);
|
|
765
|
-
await log(' • Fork status: true (VALID FORK)');
|
|
766
|
-
await log(' • Fork parent: ' + (parentRepo || 'unknown'));
|
|
767
|
-
await log(' • Target repository: ' + `${owner}/${repo}`);
|
|
768
|
-
await log('');
|
|
769
|
-
await log(' 💡 This is unexpected:');
|
|
770
|
-
await log(' The fork relationship is correct, but the compare API failed.');
|
|
771
|
-
await log(' This might be a temporary GitHub API issue.');
|
|
772
|
-
await log('');
|
|
773
|
-
await log(' 🔧 How to fix:');
|
|
774
|
-
await log(' 1. Wait a minute and try creating the PR manually:');
|
|
775
|
-
if (argv.fork && forkedRepo) {
|
|
776
|
-
const forkUser = forkedRepo.split('/')[0];
|
|
777
|
-
await log(` gh pr create --draft --repo ${owner}/${repo} --base ${targetBranchForCompare} --head ${forkUser}:${branchName}`);
|
|
778
|
-
}
|
|
779
|
-
await log(' 2. Check if the issue persists - it might be a GitHub API outage');
|
|
780
|
-
await log('');
|
|
781
|
-
|
|
782
|
-
throw new Error('Compare API failed unexpectedly despite valid fork relationship');
|
|
783
|
-
}
|
|
784
|
-
} else {
|
|
785
|
-
// Original timeout error for other cases
|
|
786
|
-
await log('');
|
|
787
|
-
await log(formatAligned('❌', 'GITHUB SYNC TIMEOUT:', 'Compare API not ready after retries'), {
|
|
788
|
-
level: 'error',
|
|
789
|
-
});
|
|
790
|
-
await log('');
|
|
791
|
-
await log(' 🔍 What happened:');
|
|
792
|
-
await log(` After ${maxCompareAttempts} attempts, GitHub's compare API still shows no commits`);
|
|
793
|
-
await log(` between ${targetBranchForCompare} and ${branchName}.`);
|
|
794
|
-
await log('');
|
|
795
|
-
await log(' 💡 This usually means:');
|
|
796
|
-
await log(" • GitHub's backend systems haven't finished indexing the push");
|
|
797
|
-
await log(" • There's a temporary issue with GitHub's API");
|
|
798
|
-
await log(' • The commits may not have been pushed correctly');
|
|
799
|
-
await log('');
|
|
800
|
-
await log(' 🔧 How to fix:');
|
|
801
|
-
await log(' 1. Wait a minute and try creating the PR manually:');
|
|
802
|
-
// For fork mode, use the correct head reference format
|
|
803
|
-
if (argv.fork && forkedRepo) {
|
|
804
|
-
const forkUser = forkedRepo.split('/')[0];
|
|
805
|
-
await log(` gh pr create --draft --repo ${owner}/${repo} --base ${targetBranchForCompare} --head ${forkUser}:${branchName}`);
|
|
806
|
-
} else {
|
|
807
|
-
await log(` gh pr create --draft --repo ${owner}/${repo} --base ${targetBranchForCompare} --head ${branchName}`);
|
|
808
|
-
}
|
|
809
|
-
await log(' 2. Check if the branch exists on GitHub:');
|
|
810
|
-
// Show the correct repository where the branch was pushed
|
|
811
|
-
const branchRepo = argv.fork && forkedRepo ? forkedRepo : `${owner}/${repo}`;
|
|
812
|
-
await log(` https://github.com/${branchRepo}/tree/${branchName}`);
|
|
813
|
-
await log(' 3. Check the commit is on GitHub:');
|
|
814
|
-
// Use the correct head reference for the compare API check
|
|
815
|
-
if (argv.fork && forkedRepo) {
|
|
816
|
-
const forkUser = forkedRepo.split('/')[0];
|
|
817
|
-
await log(` gh api repos/${owner}/${repo}/compare/${targetBranchForCompare}...${forkUser}:${branchName} --paginate`);
|
|
818
|
-
} else {
|
|
819
|
-
await log(` gh api repos/${owner}/${repo}/compare/${targetBranchForCompare}...${branchName} --paginate`);
|
|
820
|
-
}
|
|
821
|
-
await log('');
|
|
822
|
-
|
|
823
|
-
throw new Error('GitHub compare API not ready - cannot create PR safely');
|
|
824
|
-
}
|
|
620
|
+
compareReady = await handleCompareApiNotReady({
|
|
621
|
+
argv,
|
|
622
|
+
forkedRepo,
|
|
623
|
+
owner,
|
|
624
|
+
repo,
|
|
625
|
+
issueNumber,
|
|
626
|
+
branchName,
|
|
627
|
+
targetBranchForCompare,
|
|
628
|
+
maxCompareAttempts,
|
|
629
|
+
compareResult,
|
|
630
|
+
log,
|
|
631
|
+
formatAligned,
|
|
632
|
+
$,
|
|
633
|
+
});
|
|
825
634
|
}
|
|
826
635
|
|
|
827
636
|
// Verify the push actually worked by checking GitHub API
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -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',
|