@link-assistant/hive-mind 2.0.11 → 2.0.13
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 +80 -0
- package/package.json +1 -1
- package/src/github-entity-validation.lib.mjs +152 -4
- package/src/lib.mjs +4 -1
- package/src/solve.branch-errors.lib.mjs +26 -1
- package/src/solve.branch.lib.mjs +8 -0
- package/src/solve.mjs +1 -1
- package/src/solve.repo-setup.lib.mjs +37 -2
- package/src/solve.repository.lib.mjs +59 -5
- package/src/telegram-bot.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,85 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.0.13
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- ca7c412: fix(branch): validate `--base-branch` existence up-front and stop misreporting a missing base branch as an empty repository (#1959)
|
|
8
|
+
|
|
9
|
+
A `/solve` run from the Telegram bot crashed with the unactionable message
|
|
10
|
+
`Branch operation failed`. Root cause: the user passed `--base-branch issue-375-8a4323e580780`
|
|
11
|
+
— a **one-character typo** of the real branch `issue-375-8a4323e58078`. The branch did not
|
|
12
|
+
exist, but nothing validated that before the run started. The solver cloned 72 MB, then
|
|
13
|
+
`git checkout -b … origin/issue-375-8a4323e580780` failed with
|
|
14
|
+
`fatal: 'origin/…' is not a commit`. Worse, the branch-creation error handler **misdiagnosed**
|
|
15
|
+
this as "the repository appears to be empty (no commits)" and suggested `--auto-init-repository`,
|
|
16
|
+
which is wrong for a non-empty repo. The top level then collapsed everything to the bare
|
|
17
|
+
`Branch operation failed` comment on GitHub.
|
|
18
|
+
|
|
19
|
+
Defense-in-depth fix applied across the codebase:
|
|
20
|
+
- `validateGitHubEntityExistence()` (`src/github-entity-validation.lib.mjs`) gains a new
|
|
21
|
+
base-branch step: when `--base-branch` is supplied, `checkBaseBranchExists()` verifies it
|
|
22
|
+
via `gh api repos/{owner}/{repo}/branches/{branch}` **before** cloning, in the same fail-fast
|
|
23
|
+
gate that already checks user/repo/issue/PR. A definitive 404 fails the run; transient
|
|
24
|
+
errors fail open so a network hiccup never blocks a valid run.
|
|
25
|
+
- New helpers `levenshteinDistance()` and `findClosestBranchName()` power a "did you mean
|
|
26
|
+
'<closest-existing-branch>'?" suggestion built from the repo's actual branch list, so the
|
|
27
|
+
exact real-world typo points the user straight at `issue-375-8a4323e58078`.
|
|
28
|
+
- Both entry points share the gate: `src/solve.mjs` (CLI + the GitHub comment path) and
|
|
29
|
+
`src/telegram-bot.mjs` (the bot pre-flight) now pass `baseBranch` in, so a missing base
|
|
30
|
+
branch fails immediately at every level — including in Telegram, before the solve command
|
|
31
|
+
is queued or spawned.
|
|
32
|
+
- `handleBranchCreationError()` (`src/solve.branch-errors.lib.mjs`) now receives `baseBranch`
|
|
33
|
+
and `branchSource`. When a custom base branch is the missing ref it reports the real root
|
|
34
|
+
cause instead of the bogus empty-repository advice; the genuine empty-repository path
|
|
35
|
+
(creating from the default branch) is preserved. `createOrCheckoutBranch()`
|
|
36
|
+
(`src/solve.branch.lib.mjs`) threads the base branch and its source into the handler.
|
|
37
|
+
|
|
38
|
+
Adds `tests/test-base-branch-existence.mjs` (17 offline assertions covering the helpers and
|
|
39
|
+
the misdiagnosis fix), `tests/test-base-branch-existence-integration.mjs`
|
|
40
|
+
(`@hive-mind-integration`, the real `gh` gate), and a deep case study in
|
|
41
|
+
`docs/case-studies/issue-1959/`.
|
|
42
|
+
|
|
43
|
+
## 2.0.12
|
|
44
|
+
|
|
45
|
+
### Patch Changes
|
|
46
|
+
|
|
47
|
+
- f17bac4: fix(clone): detect interrupted clones that exit 0, retry them, and explain the failure instead of the bare "Failed to get current branch" (#1957)
|
|
48
|
+
|
|
49
|
+
A `/solve` run crashed with the unactionable message `Failed to get current branch`.
|
|
50
|
+
Root cause: `gh repo clone` (and the `git clone` it wraps) **exited 0 even though the
|
|
51
|
+
transfer was interrupted** mid-stream (`fetch-pack: unexpected disconnect while reading
|
|
52
|
+
sideband packet`), leaving **no `.git` directory** (`size=0 B`). The solver trusted the
|
|
53
|
+
exit code, logged `✅ Cloned to:`, then every subsequent git command failed with
|
|
54
|
+
`fatal: not a git repository`; the first to propagate it (`git branch --show-current` in
|
|
55
|
+
`verifyDefaultBranchAndStatus`) threw the bare error with no clue about what went wrong
|
|
56
|
+
or how to recover.
|
|
57
|
+
|
|
58
|
+
Defense-in-depth fix applied across the codebase:
|
|
59
|
+
- `cloneRepository()` (`src/solve.repository.lib.mjs`) no longer trusts the exit code:
|
|
60
|
+
after `gh repo clone` it validates the result with `git rev-parse --is-inside-work-tree`
|
|
61
|
+
and only treats the clone as successful when the exit code is 0 **and** a real working
|
|
62
|
+
tree exists. In `--verbose` mode it logs why a 0-exit clone was rejected.
|
|
63
|
+
- New exported helper `cleanPartialClone()` empties the target directory before each
|
|
64
|
+
retry so a partial clone does not make `gh repo clone <dir>` fail with "directory
|
|
65
|
+
exists and is not empty".
|
|
66
|
+
- `classifyCloneError()` now classifies the interrupted-transfer vocabulary
|
|
67
|
+
(`unexpected disconnect`, `sideband`, `early eof`, `the remote end hung up`,
|
|
68
|
+
`rpc failed`, `fetch-pack`, `index-pack failed`, `transfer closed`) as a retryable
|
|
69
|
+
`NETWORK` error, so the existing 3× exponential-backoff retry loop recovers from it.
|
|
70
|
+
404 / ENOSPC / auth failures stay non-retryable.
|
|
71
|
+
- `isTransientNetworkError()` (`src/lib.mjs`, shared by many gh/git retry call sites)
|
|
72
|
+
gains the same vocabulary, so the fix propagates everywhere — not just the clone path.
|
|
73
|
+
- Both failure points now print concrete, root-cause-obvious guidance: the clone-failure
|
|
74
|
+
path adds NETWORK causes/fixes, and `verifyDefaultBranchAndStatus()`
|
|
75
|
+
(`src/solve.repo-setup.lib.mjs`) detects `not a git repository` and logs an
|
|
76
|
+
`INCOMPLETE CLONE DETECTED` block (What happened / Error details / How to fix:
|
|
77
|
+
check network·VPN·proxy, re-run — clones auto-retry, verify access, check GitHub
|
|
78
|
+
status) instead of the bare message.
|
|
79
|
+
|
|
80
|
+
Adds `tests/test-issue-1957-incomplete-clone.mjs` (26 assertions) and a deep case study
|
|
81
|
+
in `docs/case-studies/issue-1957/`.
|
|
82
|
+
|
|
3
83
|
## 2.0.11
|
|
4
84
|
|
|
5
85
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -10,8 +10,135 @@ import { ghCmdRetry } from './lib.mjs';
|
|
|
10
10
|
import { ghPrView, ghIssueView } from './github.lib.mjs';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* Compute the Levenshtein edit distance between two strings.
|
|
14
|
+
* Used to suggest the closest existing branch name when a requested base
|
|
15
|
+
* branch is not found (issue #1959 — typos like an extra trailing character).
|
|
16
|
+
*
|
|
17
|
+
* @param {string} a - First string
|
|
18
|
+
* @param {string} b - Second string
|
|
19
|
+
* @returns {number} The minimum number of single-character edits
|
|
20
|
+
*/
|
|
21
|
+
export function levenshteinDistance(a, b) {
|
|
22
|
+
a = String(a);
|
|
23
|
+
b = String(b);
|
|
24
|
+
if (a === b) return 0;
|
|
25
|
+
if (a.length === 0) return b.length;
|
|
26
|
+
if (b.length === 0) return a.length;
|
|
27
|
+
|
|
28
|
+
// Single-row dynamic programming to keep memory at O(min(len)).
|
|
29
|
+
let previousRow = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
30
|
+
for (let i = 0; i < a.length; i++) {
|
|
31
|
+
const currentRow = [i + 1];
|
|
32
|
+
for (let j = 0; j < b.length; j++) {
|
|
33
|
+
const insertCost = currentRow[j] + 1;
|
|
34
|
+
const deleteCost = previousRow[j + 1] + 1;
|
|
35
|
+
const replaceCost = previousRow[j] + (a[i] === b[j] ? 0 : 1);
|
|
36
|
+
currentRow.push(Math.min(insertCost, deleteCost, replaceCost));
|
|
37
|
+
}
|
|
38
|
+
previousRow = currentRow;
|
|
39
|
+
}
|
|
40
|
+
return previousRow[b.length];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Find the closest matching branch name to a (likely mistyped) target.
|
|
45
|
+
* Returns the nearest candidate only when it is close enough to be a plausible
|
|
46
|
+
* typo, so we never suggest an unrelated branch.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} target - The requested (not found) branch name
|
|
49
|
+
* @param {string[]} candidates - Existing branch names to compare against
|
|
50
|
+
* @returns {string|null} The closest branch name, or null if none is close enough
|
|
51
|
+
*/
|
|
52
|
+
export function findClosestBranchName(target, candidates) {
|
|
53
|
+
if (!target || !Array.isArray(candidates) || candidates.length === 0) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
// Allow a larger edit budget for longer names, but cap it so unrelated
|
|
57
|
+
// branches are never suggested (e.g. a 1-char typo on a 23-char branch).
|
|
58
|
+
const maxDistance = Math.max(2, Math.floor(target.length * 0.34));
|
|
59
|
+
let best = null;
|
|
60
|
+
let bestDistance = Infinity;
|
|
61
|
+
for (const candidate of candidates) {
|
|
62
|
+
if (!candidate || candidate === target) continue;
|
|
63
|
+
const distance = levenshteinDistance(target, candidate);
|
|
64
|
+
if (distance < bestDistance) {
|
|
65
|
+
bestDistance = distance;
|
|
66
|
+
best = candidate;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return bestDistance <= maxDistance ? best : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check whether a branch exists in the given repository using the GitHub API.
|
|
74
|
+
* Works without cloning the repo, so it can run during early validation
|
|
75
|
+
* (before the heavy clone/spawn work) for both the CLI and the Telegram bot.
|
|
76
|
+
*
|
|
77
|
+
* @param {Object} options
|
|
78
|
+
* @param {string} options.owner - Repository owner
|
|
79
|
+
* @param {string} options.repo - Repository name
|
|
80
|
+
* @param {string} options.baseBranch - Branch name to check
|
|
81
|
+
* @param {boolean} [options.verbose=false] - Verbose logging
|
|
82
|
+
* @returns {Promise<{exists: boolean, indeterminate?: boolean}>}
|
|
83
|
+
* - exists: true if the branch is present (or the check was inconclusive — fail open)
|
|
84
|
+
* - indeterminate: true when a non-404 error prevented a definitive answer
|
|
85
|
+
*/
|
|
86
|
+
export async function checkBaseBranchExists({ owner, repo, baseBranch, verbose = false }) {
|
|
87
|
+
try {
|
|
88
|
+
const result = await ghCmdRetry(() => $`gh api repos/${owner}/${repo}/branches/${baseBranch} --jq .name`, { label: `check branch ${baseBranch}` });
|
|
89
|
+
if (result.code === 0) {
|
|
90
|
+
return { exists: true };
|
|
91
|
+
}
|
|
92
|
+
const errorOutput = (result.stderr ? result.stderr.toString() : '') + (result.stdout ? result.stdout.toString() : '');
|
|
93
|
+
if (errorOutput.includes('404') || errorOutput.includes('Not Found') || errorOutput.includes('Branch not found')) {
|
|
94
|
+
return { exists: false };
|
|
95
|
+
}
|
|
96
|
+
// Non-404 errors (network, auth, rate limit): fail open so we don't block on transient issues.
|
|
97
|
+
verbose && console.log(`[VERBOSE] Entity check: Could not verify branch '${baseBranch}': ${errorOutput.trim()}`);
|
|
98
|
+
return { exists: true, indeterminate: true };
|
|
99
|
+
} catch (e) {
|
|
100
|
+
verbose && console.log(`[VERBOSE] Entity check: Branch check error for '${baseBranch}': ${e.message}`);
|
|
101
|
+
return { exists: true, indeterminate: true };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build a user-facing error message for a missing base branch, optionally
|
|
107
|
+
* including a "did you mean" suggestion derived from the repo's branches.
|
|
108
|
+
*
|
|
109
|
+
* @param {Object} options
|
|
110
|
+
* @param {string} options.owner
|
|
111
|
+
* @param {string} options.repo
|
|
112
|
+
* @param {string} options.baseBranch
|
|
113
|
+
* @param {boolean} [options.verbose=false]
|
|
114
|
+
* @returns {Promise<string>} The formatted error message
|
|
115
|
+
*/
|
|
116
|
+
export async function buildMissingBaseBranchErrorMessage({ owner, repo, baseBranch, verbose = false }) {
|
|
117
|
+
let suggestion = '';
|
|
118
|
+
try {
|
|
119
|
+
const listResult = await ghCmdRetry(() => $`gh api repos/${owner}/${repo}/branches --paginate --jq .[].name`, { label: `list branches ${owner}/${repo}` });
|
|
120
|
+
if (listResult.code === 0) {
|
|
121
|
+
const branches = listResult.stdout
|
|
122
|
+
.toString()
|
|
123
|
+
.split('\n')
|
|
124
|
+
.map(s => s.trim())
|
|
125
|
+
.filter(Boolean);
|
|
126
|
+
const closest = findClosestBranchName(baseBranch, branches);
|
|
127
|
+
if (closest) {
|
|
128
|
+
suggestion = `\n\n💡 Did you mean '${closest}'? (closest existing branch)`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch (e) {
|
|
132
|
+
verbose && console.log(`[VERBOSE] Entity check: Could not list branches for suggestion: ${e.message}`);
|
|
133
|
+
}
|
|
134
|
+
const listCmd = `gh api repos/${owner}/${repo}/branches --paginate --jq .[].name`;
|
|
135
|
+
return `Base branch '${baseBranch}' does not exist in ${owner}/${repo}.${suggestion}\n\n💡 Please check:\n• The branch name is spelled correctly\n• The branch has not been deleted or renamed\n• Omit --base-branch to use the repository's default branch\n• List existing branches: ${listCmd}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Validate existence of GitHub entities (user/org, repository, base branch, issue/PR)
|
|
140
|
+
* before executing a command. Checks each level in order, failing fast at the first
|
|
141
|
+
* missing entity: user/org → repository → base branch → issue/PR.
|
|
15
142
|
*
|
|
16
143
|
* When autoAcceptInvite is enabled, invitations should be accepted BEFORE calling this function,
|
|
17
144
|
* so that newly-accepted repos/orgs are visible to the API checks.
|
|
@@ -21,6 +148,9 @@ import { ghPrView, ghIssueView } from './github.lib.mjs';
|
|
|
21
148
|
* @param {string} options.repo - Repository name
|
|
22
149
|
* @param {number|string} [options.number] - Issue or PR number (if applicable)
|
|
23
150
|
* @param {string} [options.type] - URL type: 'issue' or 'pull'
|
|
151
|
+
* @param {string} [options.baseBranch] - Custom base branch (from --base-branch/--target-branch).
|
|
152
|
+
* When provided, its existence is verified up-front so a typo fails fast with a clear
|
|
153
|
+
* message instead of an opaque "Branch operation failed" after cloning (issue #1959).
|
|
24
154
|
* @param {boolean} [options.verbose=false] - Whether verbose logging is enabled
|
|
25
155
|
* @param {boolean} [options.autoAcceptInvite=false] - Whether the caller already passed
|
|
26
156
|
* `--auto-accept-invite`. When true, the repo-404 message omits the suggestion to
|
|
@@ -28,10 +158,10 @@ import { ghPrView, ghIssueView } from './github.lib.mjs';
|
|
|
28
158
|
* @returns {Promise<{valid: boolean, error?: string, level?: string, details?: string}>}
|
|
29
159
|
* - valid: true if all entities exist and are accessible
|
|
30
160
|
* - error: user-facing error message (when valid=false)
|
|
31
|
-
* - level: which entity level failed ('user', 'repo', 'issue', 'pull')
|
|
161
|
+
* - level: which entity level failed ('user', 'repo', 'branch', 'issue', 'pull')
|
|
32
162
|
* - details: additional context for verbose logging
|
|
33
163
|
*/
|
|
34
|
-
export async function validateGitHubEntityExistence({ owner, repo, number, type, verbose = false, autoAcceptInvite = false }) {
|
|
164
|
+
export async function validateGitHubEntityExistence({ owner, repo, number, type, baseBranch, verbose = false, autoAcceptInvite = false }) {
|
|
35
165
|
// Step 1: Check user/organization existence
|
|
36
166
|
try {
|
|
37
167
|
const userResult = await ghCmdRetry(() => $`gh api users/${owner} --jq .login`, { label: `check user ${owner}` });
|
|
@@ -73,6 +203,24 @@ export async function validateGitHubEntityExistence({ owner, repo, number, type,
|
|
|
73
203
|
verbose && console.log(`[VERBOSE] Entity check: Repo check error for '${owner}/${repo}': ${e.message}`);
|
|
74
204
|
}
|
|
75
205
|
|
|
206
|
+
// Step 2.5: Check base branch existence (issue #1959)
|
|
207
|
+
// When the user passes --base-branch/--target-branch, verify it exists in the
|
|
208
|
+
// canonical repository up-front. A typo (e.g. an extra trailing character) would
|
|
209
|
+
// otherwise surface only after cloning as an opaque "Branch operation failed",
|
|
210
|
+
// and was even misdiagnosed as an "empty repository". Failing here gives the
|
|
211
|
+
// same fast, explicit feedback we already provide for repo/issue/PR — in the CLI
|
|
212
|
+
// and in the GitHub comment, and BEFORE the Telegram bot starts the solve run.
|
|
213
|
+
if (baseBranch) {
|
|
214
|
+
const branchCheck = await checkBaseBranchExists({ owner, repo, baseBranch, verbose });
|
|
215
|
+
if (!branchCheck.exists) {
|
|
216
|
+
return {
|
|
217
|
+
valid: false,
|
|
218
|
+
error: await buildMissingBaseBranchErrorMessage({ owner, repo, baseBranch, verbose }),
|
|
219
|
+
level: 'branch',
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
76
224
|
// Step 3: Check issue or PR existence (if number is provided)
|
|
77
225
|
if (number) {
|
|
78
226
|
if (type === 'pull') {
|
package/src/lib.mjs
CHANGED
|
@@ -471,7 +471,10 @@ export const isTransientNetworkError = error => {
|
|
|
471
471
|
const combined = msg + ' ' + output;
|
|
472
472
|
|
|
473
473
|
// Issue #1536: added 'unexpected eof' — seen in gh CLI when connection drops mid-response
|
|
474
|
-
|
|
474
|
+
// Issue #1957: added git fetch-pack/sideband disconnect patterns — seen when a
|
|
475
|
+
// `gh repo clone` / `git clone` connection drops mid-transfer, leaving an incomplete
|
|
476
|
+
// (or missing) working tree even though the wrapper can exit 0.
|
|
477
|
+
const transientPatterns = ['i/o timeout', 'dial tcp', 'connection refused', 'connection reset', 'econnreset', 'etimedout', 'enotfound', 'ehostunreach', 'enetunreach', 'network is unreachable', 'temporary failure', 'http 502', 'http 503', 'http 504', 'bad gateway', 'service unavailable', 'gateway timeout', 'tls handshake timeout', 'ssl_error', 'socket hang up', 'unexpected eof', 'unexpected disconnect', 'sideband', 'early eof', 'the remote end hung up', 'rpc failed', 'fetch-pack', 'index-pack failed', 'remote end hung up unexpectedly', 'transfer closed'];
|
|
475
478
|
|
|
476
479
|
return transientPatterns.some(pattern => combined.includes(pattern));
|
|
477
480
|
};
|
|
@@ -219,7 +219,7 @@ export async function handleBranchCheckoutError({ branchName, prNumber, errorOut
|
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
-
export async function handleBranchCreationError({ branchName, errorOutput, tempDir, owner, repo, formatAligned, log }) {
|
|
222
|
+
export async function handleBranchCreationError({ branchName, errorOutput, tempDir, owner, repo, baseBranch, branchSource, formatAligned, log }) {
|
|
223
223
|
await log(`${formatAligned('❌', 'BRANCH CREATION FAILED', '')}`, { level: 'error' });
|
|
224
224
|
await log('');
|
|
225
225
|
await log(' 🔍 What happened:');
|
|
@@ -234,6 +234,31 @@ export async function handleBranchCreationError({ branchName, errorOutput, tempD
|
|
|
234
234
|
}
|
|
235
235
|
await log('');
|
|
236
236
|
|
|
237
|
+
// Issue #1959: A failure to create the branch from origin/<baseBranch> looks
|
|
238
|
+
// identical at the git level to an empty repository ("is not a commit"). Only
|
|
239
|
+
// treat it as "empty repo" when the failing ref is NOT a custom base branch.
|
|
240
|
+
// When a custom base branch is involved and the error references it, report the
|
|
241
|
+
// real root cause: the requested base branch does not exist on the remote.
|
|
242
|
+
const errorMentionsBaseRef = baseBranch && (errorOutput.includes(`origin/${baseBranch}`) || errorOutput.includes(`'${baseBranch}'`));
|
|
243
|
+
const looksLikeMissingRef = errorOutput.includes('is not a commit') || errorOutput.includes('not a valid object name') || errorOutput.includes('unknown revision') || errorOutput.includes('did not match any');
|
|
244
|
+
const isMissingBaseBranchError = branchSource === 'custom' && errorMentionsBaseRef && looksLikeMissingRef;
|
|
245
|
+
|
|
246
|
+
if (isMissingBaseBranchError) {
|
|
247
|
+
await log(' 💡 Root cause:');
|
|
248
|
+
await log(` The requested base branch '${baseBranch}' does not exist on the remote.`);
|
|
249
|
+
await log(' A branch cannot be created from a base branch that is not there.');
|
|
250
|
+
await log('');
|
|
251
|
+
await log(' 🔧 How to fix:');
|
|
252
|
+
await log(' • Check the branch name for typos (a single extra/missing character is enough)');
|
|
253
|
+
await log(' • Omit --base-branch to use the repository default branch');
|
|
254
|
+
if (owner && repo) {
|
|
255
|
+
await log(` • List existing branches: gh api repos/${owner}/${repo}/branches --paginate --jq .[].name`);
|
|
256
|
+
} else {
|
|
257
|
+
await log(' • List existing branches: git branch -r');
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
237
262
|
// Check if this is an empty repository error (no commits to branch from)
|
|
238
263
|
const isEmptyRepoError = errorOutput.includes('is not a commit') || errorOutput.includes('not a valid object name') || errorOutput.includes('unknown revision');
|
|
239
264
|
if (isEmptyRepoError) {
|
package/src/solve.branch.lib.mjs
CHANGED
|
@@ -308,6 +308,10 @@ export async function createOrCheckoutBranch({ isContinueMode, prBranch, issueNu
|
|
|
308
308
|
// Create a branch for the issue or checkout existing PR branch
|
|
309
309
|
let branchName;
|
|
310
310
|
let checkoutResult;
|
|
311
|
+
// Tracked across the create path so the error handler can report the real
|
|
312
|
+
// root cause when branch creation fails from a missing base branch (issue #1959).
|
|
313
|
+
let baseBranchForError = null;
|
|
314
|
+
let branchSourceForError = null;
|
|
311
315
|
|
|
312
316
|
if (isContinueMode && prBranch) {
|
|
313
317
|
// Continue mode: checkout existing PR branch
|
|
@@ -324,6 +328,8 @@ export async function createOrCheckoutBranch({ isContinueMode, prBranch, issueNu
|
|
|
324
328
|
// Use user-specified base branch if provided, otherwise use repository default
|
|
325
329
|
const baseBranch = argv.baseBranch || defaultBranch;
|
|
326
330
|
const branchSource = argv.baseBranch ? 'custom' : 'default';
|
|
331
|
+
baseBranchForError = baseBranch;
|
|
332
|
+
branchSourceForError = branchSource;
|
|
327
333
|
|
|
328
334
|
// Defense-in-depth: validate base branch name even if already validated at CLI parsing (issue #1482)
|
|
329
335
|
const baseBranchValidation = validateBranchName(baseBranch);
|
|
@@ -391,6 +397,8 @@ export async function createOrCheckoutBranch({ isContinueMode, prBranch, issueNu
|
|
|
391
397
|
tempDir,
|
|
392
398
|
owner,
|
|
393
399
|
repo,
|
|
400
|
+
baseBranch: baseBranchForError,
|
|
401
|
+
branchSource: branchSourceForError,
|
|
394
402
|
formatAligned,
|
|
395
403
|
log,
|
|
396
404
|
});
|
package/src/solve.mjs
CHANGED
|
@@ -289,7 +289,7 @@ if (!hasWriteAccess) {
|
|
|
289
289
|
}
|
|
290
290
|
|
|
291
291
|
// Issue #1552: Validate entity existence AFTER permissions (cascade: user/org → repo → issue/PR)
|
|
292
|
-
const entityCheck = await (await import('./github-entity-validation.lib.mjs')).validateGitHubEntityExistence({ owner, repo, number: urlNumber, type: isIssueUrl ? 'issue' : isPrUrl ? 'pull' : undefined, verbose: argv.verbose, autoAcceptInvite: !!argv.autoAcceptInvite });
|
|
292
|
+
const entityCheck = await (await import('./github-entity-validation.lib.mjs')).validateGitHubEntityExistence({ owner, repo, number: urlNumber, type: isIssueUrl ? 'issue' : isPrUrl ? 'pull' : undefined, baseBranch: argv.baseBranch, verbose: argv.verbose, autoAcceptInvite: !!argv.autoAcceptInvite });
|
|
293
293
|
if (!entityCheck.valid) {
|
|
294
294
|
await log(`\n❌ ${entityCheck.error}\n`, { level: 'error' });
|
|
295
295
|
await safeExit(1, `GitHub entity not found (${entityCheck.level})`);
|
|
@@ -65,9 +65,44 @@ export async function verifyDefaultBranchAndStatus({ tempDir, log, formatAligned
|
|
|
65
65
|
const defaultBranchResult = await $({ cwd: tempDir })`git branch --show-current`;
|
|
66
66
|
|
|
67
67
|
if (defaultBranchResult.code !== 0) {
|
|
68
|
+
// Issue #1957: the most common cause of this failure is an interrupted/incomplete
|
|
69
|
+
// clone — `git` reports "fatal: not a git repository" because there is no `.git`
|
|
70
|
+
// directory. The previous message ("Failed to get current branch") gave the user
|
|
71
|
+
// no idea what happened or what to do. Detect the incomplete-clone case and give
|
|
72
|
+
// concrete, actionable instructions instead.
|
|
73
|
+
const branchErr = ((defaultBranchResult.stderr?.toString() || '') + (defaultBranchResult.stdout?.toString() || '')).trim();
|
|
74
|
+
const isNotAGitRepo = /not a git repository/i.test(branchErr);
|
|
75
|
+
|
|
76
|
+
await log('');
|
|
77
|
+
await log(`${formatAligned('❌', isNotAGitRepo ? 'INCOMPLETE CLONE DETECTED' : 'FAILED TO READ CURRENT BRANCH', '')}`, { level: 'error' });
|
|
78
|
+
await log('');
|
|
79
|
+
await log(' 🔍 What happened:');
|
|
80
|
+
if (isNotAGitRepo) {
|
|
81
|
+
await log(` The working directory is not a valid git repository: ${tempDir}`);
|
|
82
|
+
await log(' This almost always means the clone was interrupted before it finished');
|
|
83
|
+
await log(' (e.g. "fetch-pack: unexpected disconnect while reading sideband packet"),');
|
|
84
|
+
await log(' so no .git directory was created.');
|
|
85
|
+
} else {
|
|
86
|
+
await log(` Could not determine the current branch in: ${tempDir}`);
|
|
87
|
+
}
|
|
88
|
+
await log('');
|
|
89
|
+
await log(' 📦 Error details:');
|
|
90
|
+
for (const line of (branchErr || 'Unknown error').split('\n')) {
|
|
91
|
+
if (line.trim()) await log(` ${line}`);
|
|
92
|
+
}
|
|
93
|
+
await log('');
|
|
94
|
+
await log(' 🔧 How to fix:');
|
|
95
|
+
await log(' 1. Check your network connection / VPN / proxy (interrupted transfers are usually transient)');
|
|
96
|
+
await log(' 2. Re-run the command — the solver retries clones automatically and a fresh run usually succeeds');
|
|
97
|
+
if (owner && repo) await log(` 3. Verify access to the repository: gh repo view ${owner}/${repo}`);
|
|
98
|
+
await log(` ${owner && repo ? '4' : '3'}. Check GitHub status if it keeps failing: https://www.githubstatus.com`);
|
|
99
|
+
await log('');
|
|
100
|
+
await log(' ℹ️ If this is reproducible, ask a Hive Mind administrator to inspect the solver terminal log for the clone error.');
|
|
101
|
+
await log('');
|
|
102
|
+
|
|
68
103
|
await log('Error: Failed to get current branch');
|
|
69
|
-
|
|
70
|
-
throw new Error('Failed to get current branch');
|
|
104
|
+
if (branchErr) await log(branchErr);
|
|
105
|
+
throw new Error(isNotAGitRepo ? 'Failed to get current branch - working directory is not a git repository (incomplete clone)' : 'Failed to get current branch');
|
|
71
106
|
}
|
|
72
107
|
|
|
73
108
|
let defaultBranch = defaultBranchResult.stdout.toString().trim();
|
|
@@ -922,8 +922,12 @@ export const classifyCloneError = errorOutput => {
|
|
|
922
922
|
}
|
|
923
923
|
|
|
924
924
|
// Network-related errors - typically retryable
|
|
925
|
-
|
|
926
|
-
|
|
925
|
+
// Issue #1957: git fetch-pack/sideband disconnects (e.g.
|
|
926
|
+
// "fetch-pack: unexpected disconnect while reading sideband packet",
|
|
927
|
+
// "early EOF", "the remote end hung up unexpectedly", "RPC failed",
|
|
928
|
+
// "index-pack failed") leave an incomplete or missing clone but are transient.
|
|
929
|
+
if (output.includes('connection refused') || output.includes('connection timed out') || output.includes('connection reset') || output.includes('unable to connect') || output.includes('network is unreachable') || output.includes('ssl error') || output.includes('unexpected disconnect') || output.includes('sideband') || output.includes('early eof') || output.includes('remote end hung up') || output.includes('rpc failed') || output.includes('fetch-pack') || output.includes('index-pack failed') || output.includes('transfer closed')) {
|
|
930
|
+
return { type: 'NETWORK', retryable: true, description: 'Network connectivity issue (interrupted transfer)' };
|
|
927
931
|
}
|
|
928
932
|
|
|
929
933
|
// Authentication/permission errors - not retryable
|
|
@@ -945,6 +949,24 @@ export const classifyCloneError = errorOutput => {
|
|
|
945
949
|
return { type: 'UNKNOWN', retryable: true, description: 'Unknown error' };
|
|
946
950
|
};
|
|
947
951
|
|
|
952
|
+
// Issue #1957: remove leftovers from an interrupted clone so a retry can start clean.
|
|
953
|
+
// We empty the directory in place (rather than removing it) because the path was
|
|
954
|
+
// created up-front by setupTempDirectory and may be the configured working directory.
|
|
955
|
+
export const cleanPartialClone = async tempDir => {
|
|
956
|
+
try {
|
|
957
|
+
const entries = await fs.readdir(tempDir);
|
|
958
|
+
for (const entry of entries) {
|
|
959
|
+
await fs.rm(path.join(tempDir, entry), { recursive: true, force: true });
|
|
960
|
+
}
|
|
961
|
+
} catch (error) {
|
|
962
|
+
// Directory may not exist yet, or be unreadable — non-fatal; the retry/clone
|
|
963
|
+
// will surface any real problem with a clearer message.
|
|
964
|
+
if (error?.code !== 'ENOENT') {
|
|
965
|
+
reportError(error, { context: 'clean_partial_clone', tempDir, operation: 'empty_directory' });
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
|
|
948
970
|
// Clone repository and set up remotes with retry mechanism
|
|
949
971
|
export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) => {
|
|
950
972
|
const maxRetries = 3;
|
|
@@ -959,9 +981,25 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
|
|
|
959
981
|
|
|
960
982
|
// Use 2>&1 to capture all output and filter "Cloning into" message
|
|
961
983
|
const cloneResult = await $`gh repo clone ${repoToClone} ${tempDir} 2>&1`;
|
|
962
|
-
|
|
963
|
-
|
|
984
|
+
const cloneOutput = (cloneResult.stdout || cloneResult.stderr || '').toString().trim();
|
|
985
|
+
|
|
986
|
+
// Issue #1957: `gh repo clone` (and the `git clone` it wraps) can exit 0 even when
|
|
987
|
+
// the underlying transfer was interrupted — e.g. "fetch-pack: unexpected disconnect
|
|
988
|
+
// while reading sideband packet" — leaving an incomplete or completely missing
|
|
989
|
+
// working tree (no `.git`). Trusting the exit code alone made the solver report
|
|
990
|
+
// "✅ Cloned to:" and then crash much later with the unhelpful "Failed to get
|
|
991
|
+
// current branch". Verify the clone actually produced a usable git repository.
|
|
992
|
+
let repoIsValid = false;
|
|
964
993
|
if (cloneResult.code === 0) {
|
|
994
|
+
const validityCheck = await $({ cwd: tempDir })`git rev-parse --is-inside-work-tree 2>&1`;
|
|
995
|
+
repoIsValid = validityCheck.code === 0 && validityCheck.stdout.toString().trim() === 'true';
|
|
996
|
+
if (!repoIsValid && argv.verbose) {
|
|
997
|
+
await log(`${formatAligned('🔧', 'Clone validation:', `git rev-parse failed despite exit 0 — ${(validityCheck.stdout || validityCheck.stderr || '').toString().trim().split('\n')[0]}`)}`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Verify clone was successful (exit code 0 AND a valid working tree exists)
|
|
1002
|
+
if (cloneResult.code === 0 && repoIsValid) {
|
|
965
1003
|
await log(`${formatAligned('✅', 'Cloned to:', tempDir)}`);
|
|
966
1004
|
|
|
967
1005
|
// Verify and fix remote configuration
|
|
@@ -975,7 +1013,15 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
|
|
|
975
1013
|
}
|
|
976
1014
|
|
|
977
1015
|
// Clone failed - analyze error and determine if retry is appropriate
|
|
978
|
-
|
|
1016
|
+
// Issue #1957: when the wrapper exited 0 but left no valid repo, surface the
|
|
1017
|
+
// interrupted-transfer output (which carries the real "unexpected disconnect"
|
|
1018
|
+
// reason) so it is classified as a retryable network error rather than UNKNOWN.
|
|
1019
|
+
const errorOutput = cloneResult.code === 0 && !repoIsValid ? cloneOutput || 'Clone exited 0 but no valid git repository was created (interrupted transfer / incomplete clone)' : (cloneResult.stderr || cloneResult.stdout || 'Unknown error').toString().trim();
|
|
1020
|
+
|
|
1021
|
+
// Issue #1957: a partial clone can leave stray files behind that make a retry of
|
|
1022
|
+
// `gh repo clone <dir>` fail with "directory exists and is not empty". Clean the
|
|
1023
|
+
// target directory before classifying/retrying so the next attempt starts fresh.
|
|
1024
|
+
await cleanPartialClone(tempDir);
|
|
979
1025
|
|
|
980
1026
|
const errorClassification = classifyCloneError(errorOutput);
|
|
981
1027
|
|
|
@@ -1015,6 +1061,9 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
|
|
|
1015
1061
|
await log(' • Network connectivity issues');
|
|
1016
1062
|
if (errorClassification.type === 'TRANSIENT') await log(' • GitHub server issues (temporary)');
|
|
1017
1063
|
if (errorClassification.type === 'RATE_LIMIT') await log(' • API rate limiting exceeded');
|
|
1064
|
+
// Issue #1957: the transfer started but was interrupted (e.g. the connection
|
|
1065
|
+
// dropped while reading the pack). The retries above were already exhausted.
|
|
1066
|
+
if (errorClassification.type === 'NETWORK') await log(' • Connection dropped mid-transfer (the clone was interrupted before completing)');
|
|
1018
1067
|
if (argv.fork) await log(' • Fork not ready yet (try again in a moment)');
|
|
1019
1068
|
await log('');
|
|
1020
1069
|
await log(' 🔧 How to fix:');
|
|
@@ -1024,6 +1073,11 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
|
|
|
1024
1073
|
if (argv.fork) await log(` 4. Check fork: gh repo view ${repoToClone}`);
|
|
1025
1074
|
if (errorClassification.type === 'TRANSIENT') await log(' 5. Wait and retry / check: https://www.githubstatus.com');
|
|
1026
1075
|
if (errorClassification.type === 'RATE_LIMIT') await log(' 5. Wait for rate limit to reset or use --token with different token');
|
|
1076
|
+
if (errorClassification.type === 'NETWORK') {
|
|
1077
|
+
await log(' 5. Check your network connection / VPN / proxy, then re-run the command');
|
|
1078
|
+
await log(' 6. On slow or unstable links, a shallower history transfers faster and is less');
|
|
1079
|
+
await log(' likely to be interrupted; ask a Hive Mind administrator if clone tuning is available');
|
|
1080
|
+
}
|
|
1027
1081
|
await log('');
|
|
1028
1082
|
}
|
|
1029
1083
|
await safeExit(1, 'Repository setup failed');
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -873,7 +873,7 @@ async function handleSolveCommand(ctx) {
|
|
|
873
873
|
}
|
|
874
874
|
// Issue #1714: read the parsed argv (default-on per #1694) instead of the raw args list,
|
|
875
875
|
// so the invite hint is suppressed on the default-on path where the literal flag is absent.
|
|
876
|
-
const entityCheck = await validateGitHubEntityExistence({ owner: validation.parsed.owner, repo: validation.parsed.repo, number: validation.parsed.number, type: validation.parsed.type, verbose: VERBOSE, autoAcceptInvite: !!parsedSolveArgs?.autoAcceptInvite });
|
|
876
|
+
const entityCheck = await validateGitHubEntityExistence({ owner: validation.parsed.owner, repo: validation.parsed.repo, number: validation.parsed.number, type: validation.parsed.type, baseBranch: parsedSolveArgs?.baseBranch, verbose: VERBOSE, autoAcceptInvite: !!parsedSolveArgs?.autoAcceptInvite });
|
|
877
877
|
if (!entityCheck.valid) {
|
|
878
878
|
await safeReply(ctx, `❌ ${escapeMarkdown(entityCheck.error)}`, { reply_to_message_id: ctx.message.message_id });
|
|
879
879
|
return;
|