@link-assistant/hive-mind 2.0.11 → 2.0.12
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 +40 -0
- package/package.json +1 -1
- package/src/lib.mjs +4 -1
- package/src/solve.repo-setup.lib.mjs +37 -2
- package/src/solve.repository.lib.mjs +59 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.0.12
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 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)
|
|
8
|
+
|
|
9
|
+
A `/solve` run crashed with the unactionable message `Failed to get current branch`.
|
|
10
|
+
Root cause: `gh repo clone` (and the `git clone` it wraps) **exited 0 even though the
|
|
11
|
+
transfer was interrupted** mid-stream (`fetch-pack: unexpected disconnect while reading
|
|
12
|
+
sideband packet`), leaving **no `.git` directory** (`size=0 B`). The solver trusted the
|
|
13
|
+
exit code, logged `✅ Cloned to:`, then every subsequent git command failed with
|
|
14
|
+
`fatal: not a git repository`; the first to propagate it (`git branch --show-current` in
|
|
15
|
+
`verifyDefaultBranchAndStatus`) threw the bare error with no clue about what went wrong
|
|
16
|
+
or how to recover.
|
|
17
|
+
|
|
18
|
+
Defense-in-depth fix applied across the codebase:
|
|
19
|
+
- `cloneRepository()` (`src/solve.repository.lib.mjs`) no longer trusts the exit code:
|
|
20
|
+
after `gh repo clone` it validates the result with `git rev-parse --is-inside-work-tree`
|
|
21
|
+
and only treats the clone as successful when the exit code is 0 **and** a real working
|
|
22
|
+
tree exists. In `--verbose` mode it logs why a 0-exit clone was rejected.
|
|
23
|
+
- New exported helper `cleanPartialClone()` empties the target directory before each
|
|
24
|
+
retry so a partial clone does not make `gh repo clone <dir>` fail with "directory
|
|
25
|
+
exists and is not empty".
|
|
26
|
+
- `classifyCloneError()` now classifies the interrupted-transfer vocabulary
|
|
27
|
+
(`unexpected disconnect`, `sideband`, `early eof`, `the remote end hung up`,
|
|
28
|
+
`rpc failed`, `fetch-pack`, `index-pack failed`, `transfer closed`) as a retryable
|
|
29
|
+
`NETWORK` error, so the existing 3× exponential-backoff retry loop recovers from it.
|
|
30
|
+
404 / ENOSPC / auth failures stay non-retryable.
|
|
31
|
+
- `isTransientNetworkError()` (`src/lib.mjs`, shared by many gh/git retry call sites)
|
|
32
|
+
gains the same vocabulary, so the fix propagates everywhere — not just the clone path.
|
|
33
|
+
- Both failure points now print concrete, root-cause-obvious guidance: the clone-failure
|
|
34
|
+
path adds NETWORK causes/fixes, and `verifyDefaultBranchAndStatus()`
|
|
35
|
+
(`src/solve.repo-setup.lib.mjs`) detects `not a git repository` and logs an
|
|
36
|
+
`INCOMPLETE CLONE DETECTED` block (What happened / Error details / How to fix:
|
|
37
|
+
check network·VPN·proxy, re-run — clones auto-retry, verify access, check GitHub
|
|
38
|
+
status) instead of the bare message.
|
|
39
|
+
|
|
40
|
+
Adds `tests/test-issue-1957-incomplete-clone.mjs` (26 assertions) and a deep case study
|
|
41
|
+
in `docs/case-studies/issue-1957/`.
|
|
42
|
+
|
|
3
43
|
## 2.0.11
|
|
4
44
|
|
|
5
45
|
### Patch Changes
|
package/package.json
CHANGED
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
|
};
|
|
@@ -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');
|