@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.11",
3
+ "version": "2.0.13",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -10,8 +10,135 @@ import { ghCmdRetry } from './lib.mjs';
10
10
  import { ghPrView, ghIssueView } from './github.lib.mjs';
11
11
 
12
12
  /**
13
- * Validate existence of GitHub entities (user/org, repository, issue/PR) before executing a command.
14
- * Checks each level in order: user/org repository issue/PR, failing fast at the first missing entity.
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
- 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'];
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) {
@@ -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
- await log(defaultBranchResult.stderr ? defaultBranchResult.stderr.toString() : 'Unknown error');
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
- 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')) {
926
- return { type: 'NETWORK', retryable: true, description: 'Network connectivity issue' };
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
- // Verify clone was successful
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
- const errorOutput = (cloneResult.stderr || cloneResult.stdout || 'Unknown error').toString().trim();
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');
@@ -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;