@link-assistant/hive-mind 2.1.7 → 2.1.9
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 +12 -0
- package/package.json +1 -1
- package/src/argument-normalization.lib.mjs +23 -0
- package/src/cli-arguments.lib.mjs +3 -1
- package/src/hive.mjs +5 -5
- package/src/solve.config.lib.mjs +3 -3
- package/src/solve.pre-pr-failure-notifier.lib.mjs +2 -1
- package/src/solve.repository-recovery-message.lib.mjs +25 -5
- package/src/solve.repository-safety.lib.mjs +163 -0
- package/src/solve.repository.lib.mjs +49 -6
- package/src/telegram-solve-command.lib.mjs +4 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.1.9
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5559c5e: Check all replacement repository branch tips before deleting a non-fork fork replacement, and report concrete branch-only commits when deletion is unsafe.
|
|
8
|
+
|
|
9
|
+
## 2.1.8
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 11dab3c: Handle Telegram and CLI commands where a GitHub issue or pull request URL is immediately followed by a long option marker.
|
|
14
|
+
|
|
3
15
|
## 2.1.7
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const TYPOGRAPHIC_LONG_OPTION_DASHES = /[\u2013\u2014]/g;
|
|
2
|
+
|
|
3
|
+
const GITHUB_ISSUE_OR_PR_WITH_JOINED_OPTION = new RegExp(['^(', '(?:https?://)?', '(?:www\\.)?', '(?:github\\.com/)?', '[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/(?:issues|pull)/\\d+', ')', '(--[A-Za-z][A-Za-z0-9-]*(?:=.*)?)', '$'].join(''));
|
|
4
|
+
|
|
5
|
+
export const normalizeTypographicOptionDashes = value => {
|
|
6
|
+
if (typeof value !== 'string') return value;
|
|
7
|
+
return value.replace(TYPOGRAPHIC_LONG_OPTION_DASHES, '--');
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const splitJoinedGitHubLongOptionArg = value => {
|
|
11
|
+
const normalized = normalizeTypographicOptionDashes(value);
|
|
12
|
+
if (typeof normalized !== 'string') return [normalized];
|
|
13
|
+
|
|
14
|
+
const match = normalized.match(GITHUB_ISSUE_OR_PR_WITH_JOINED_OPTION);
|
|
15
|
+
if (!match) return [normalized];
|
|
16
|
+
|
|
17
|
+
return [match[1], match[2]];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const normalizeCliArgs = args => {
|
|
21
|
+
if (!Array.isArray(args)) return [];
|
|
22
|
+
return args.flatMap(splitJoinedGitHubLongOptionArg);
|
|
23
|
+
};
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { getenv, makeConfig, yargs as linoYargs } from 'lino-arguments';
|
|
2
2
|
|
|
3
|
+
import { normalizeCliArgs } from './argument-normalization.lib.mjs';
|
|
3
4
|
import { enhanceUnknownArgumentError } from './option-suggestions.lib.mjs';
|
|
4
5
|
|
|
5
6
|
export { getenv };
|
|
7
|
+
export { normalizeCliArgs, normalizeTypographicOptionDashes, splitJoinedGitHubLongOptionArg } from './argument-normalization.lib.mjs';
|
|
6
8
|
|
|
7
9
|
export const hideBin = argv => argv.slice(2);
|
|
8
10
|
|
|
@@ -53,7 +55,7 @@ export function addCliCompatibilityAliases(parsed, { positionalAliases = [] } =
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
export function parseCliArgumentsWithLino({ argv = process.argv, commandName = 'cli', createYargsConfig, positionalAliases = [], lenv = { enabled: true }, env = { enabled: false }, getenv: getenvOptions = { enabled: true } } = {}) {
|
|
56
|
-
const fullArgv = ensureFullArgv(argv, commandName);
|
|
58
|
+
const fullArgv = normalizeCliArgs(ensureFullArgv(argv, commandName));
|
|
57
59
|
let configuredParser = null;
|
|
58
60
|
let parsed;
|
|
59
61
|
|
package/src/hive.mjs
CHANGED
|
@@ -18,9 +18,9 @@ if (earlyArgs.includes('--version')) {
|
|
|
18
18
|
if (earlyArgs.includes('--help') || earlyArgs.includes('-h')) {
|
|
19
19
|
try {
|
|
20
20
|
// Load minimal modules needed for help
|
|
21
|
-
const { getLinoYargsFactory, hideBin } = await import('./cli-arguments.lib.mjs');
|
|
21
|
+
const { getLinoYargsFactory, hideBin, normalizeCliArgs } = await import('./cli-arguments.lib.mjs');
|
|
22
22
|
const yargs = getLinoYargsFactory();
|
|
23
|
-
const rawArgs = hideBin(process.argv);
|
|
23
|
+
const rawArgs = normalizeCliArgs(hideBin(process.argv));
|
|
24
24
|
// Reuse createYargsConfig from shared module to avoid duplication
|
|
25
25
|
const { createYargsConfig } = await import('./hive.config.lib.mjs');
|
|
26
26
|
const helpYargs = createYargsConfig(yargs(rawArgs)).version(false);
|
|
@@ -62,7 +62,7 @@ if (isRunningDirectly) {
|
|
|
62
62
|
30000, // 30 second timeout
|
|
63
63
|
'loading command-stream'
|
|
64
64
|
);
|
|
65
|
-
const { parseCliArgumentsWithLino, hideBin } = await import('./cli-arguments.lib.mjs');
|
|
65
|
+
const { parseCliArgumentsWithLino, hideBin, normalizeCliArgs } = await import('./cli-arguments.lib.mjs');
|
|
66
66
|
const path = (await withTimeout(use('path'), 30000, 'loading path')).default;
|
|
67
67
|
const fs = (await withTimeout(use('fs'), 30000, 'loading fs')).promises;
|
|
68
68
|
// Import shared library functions
|
|
@@ -225,7 +225,7 @@ if (isRunningDirectly) {
|
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
// Configure command line arguments - GitHub URL as positional argument
|
|
228
|
-
const rawArgs = hideBin(process.argv);
|
|
228
|
+
const rawArgs = normalizeCliArgs(hideBin(process.argv));
|
|
229
229
|
// Use .parse() instead of .argv to ensure .strict() mode works correctly
|
|
230
230
|
// When you use .argv, strict mode doesn't trigger properly
|
|
231
231
|
// See: https://github.com/yargs/yargs/issues - .strict() only works with .parse()
|
|
@@ -248,7 +248,7 @@ if (isRunningDirectly) {
|
|
|
248
248
|
|
|
249
249
|
try {
|
|
250
250
|
argv = parseCliArgumentsWithLino({
|
|
251
|
-
argv:
|
|
251
|
+
argv: ['node', 'hive', ...rawArgs],
|
|
252
252
|
commandName: 'hive',
|
|
253
253
|
createYargsConfig,
|
|
254
254
|
positionalAliases: ['github-url'],
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { enhanceErrorMessage, detectMalformedFlags } from './option-suggestions.
|
|
|
11
11
|
import { defaultModels, buildModelOptionDescription, resolveDefaultFallbackModel, resolveRuntimeDefaultModel } from './models/index.mjs';
|
|
12
12
|
import { validateBranchName } from './solve.branch.lib.mjs';
|
|
13
13
|
import { resolveEscalationConfig, isEscalateEnabled, DEFAULT_ESCALATE_RANGE } from './solve.escalate.lib.mjs';
|
|
14
|
-
import { getLinoYargsFactory, hideBin, parseCliArgumentsWithLino } from './cli-arguments.lib.mjs';
|
|
14
|
+
import { getLinoYargsFactory, hideBin, normalizeCliArgs, parseCliArgumentsWithLino } from './cli-arguments.lib.mjs';
|
|
15
15
|
|
|
16
16
|
// Re-export for use by telegram-bot.mjs (avoids extra import lines there)
|
|
17
17
|
export { detectMalformedFlags };
|
|
@@ -737,7 +737,7 @@ export const createYargsConfig = yargsInstance => {
|
|
|
737
737
|
|
|
738
738
|
// Parse command line arguments - now needs yargs and hideBin passed in
|
|
739
739
|
export const parseArguments = async (yargs = getLinoYargsFactory(), hideBinFn = hideBin) => {
|
|
740
|
-
const rawArgs = hideBinFn(process.argv);
|
|
740
|
+
const rawArgs = normalizeCliArgs(hideBinFn(process.argv));
|
|
741
741
|
|
|
742
742
|
// Issue #1092: Detect malformed flag patterns BEFORE yargs parsing
|
|
743
743
|
// This catches cases like "-- model" which yargs silently treats as positional arguments
|
|
@@ -776,7 +776,7 @@ export const parseArguments = async (yargs = getLinoYargsFactory(), hideBinFn =
|
|
|
776
776
|
try {
|
|
777
777
|
yargsInstance = createYargsConfig(yargs());
|
|
778
778
|
argv = parseCliArgumentsWithLino({
|
|
779
|
-
argv:
|
|
779
|
+
argv: ['node', 'solve', ...rawArgs],
|
|
780
780
|
commandName: 'solve',
|
|
781
781
|
createYargsConfig,
|
|
782
782
|
positionalAliases: ['issue-url'],
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getTrackedToolCommentIds, postTrackedComment, SOLUTION_DRAFT_FAILED_MARKER } from './tool-comments.lib.mjs';
|
|
2
|
-
import { extractForkReplacementBlockedDetails, isForkReplacementBlockedReason } from './solve.repository-recovery-message.lib.mjs';
|
|
2
|
+
import { extractForkReplacementBlockedDetails, isForkReplacementBlockedReason, GITHUB_FORK_SUPPORT_URL } from './solve.repository-recovery-message.lib.mjs';
|
|
3
3
|
import { FORK_DIVERGENCE_RESOLUTION_OPTION, buildForkDivergenceFailureActionSection } from './solve.branch-divergence.lib.mjs';
|
|
4
4
|
|
|
5
5
|
export { FORK_DIVERGENCE_RESOLUTION_OPTION };
|
|
@@ -41,6 +41,7 @@ export function buildPrePullRequestFailureActionSection(reason = '') {
|
|
|
41
41
|
- It could not prove whether the existing repository has unique commits, so it did not delete it automatically.
|
|
42
42
|
|
|
43
43
|
### What you can do
|
|
44
|
+
- Recover the fork without deleting: if \`${repository}\` was a fork of \`${upstream}\` that GitHub detached (for example after a private/public visibility change), ask GitHub Support at ${GITHUB_FORK_SUPPORT_URL} ("Attach, detach or reroute forks") to re-attach it. Detachment from a visibility change is documented as permanent, so this is not guaranteed, and while detached the repository cannot open a cross-repository pull request to \`${upstream}\`.
|
|
44
45
|
- Repository owner path: back up any needed work, then delete, rename, archive, or repair \`${repository}\` in GitHub and rerun the solver.
|
|
45
46
|
- External-owner path: ask the repository owner or a Hive Mind administrator to clean it up and rerun the solver.
|
|
46
47
|
- Use \`--allow-force-non-fork-repository-deletion\` only after confirming that deleting \`${repository}\` is acceptable and any unique commits can be lost.`;
|
|
@@ -26,27 +26,47 @@ export function buildForkReplacementSafetyCheckDescription({ aheadBy = null, com
|
|
|
26
26
|
return 'Hive Mind could not prove the repository has no unique commits.';
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// GitHub Support's self-service fork request workflow. See
|
|
30
|
+
// https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/detaching-a-fork
|
|
31
|
+
export const GITHUB_FORK_SUPPORT_URL = 'https://support.github.com/request/fork';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Explain how to recover the GitHub fork relationship without deleting the
|
|
35
|
+
* existing repository. GitHub detaches a fork from its upstream network when the
|
|
36
|
+
* repository's visibility is toggled (for example private -> public), and that
|
|
37
|
+
* detachment is documented as permanent with no API/self-service reattachment.
|
|
38
|
+
* The only non-deletion path is a GitHub Support request. See issue #2019.
|
|
39
|
+
*/
|
|
40
|
+
export function buildDetachedForkRecoveryGuidance({ existingRepository, expectedUpstream } = {}) {
|
|
41
|
+
const repository = fallback(existingRepository, 'the existing repository');
|
|
42
|
+
const upstream = fallback(expectedUpstream, 'the expected upstream repository');
|
|
43
|
+
|
|
44
|
+
return `Recover the fork link WITHOUT deleting ${repository}: if ${repository} was once a GitHub fork of ${upstream} that got detached (for example after its visibility was switched to private and then back to public), the only path that keeps the repository is a GitHub Support request. Open ${GITHUB_FORK_SUPPORT_URL}, choose the "Attach, detach or reroute forks" flow, and ask to re-attach ${repository} to ${upstream}. GitHub documents visibility-change detachment as permanent, so reattachment is not guaranteed. While detached, ${repository} cannot open a cross-repository pull request to ${upstream}, so the solver needs either a reattached fork or a fresh fork to continue.`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildForkReplacementBlockedReason({ existingRepository, expectedUpstream, relationshipDescription, safetyCheckDescription, likelyDetachedFork = false } = {}) {
|
|
30
48
|
const repository = fallback(existingRepository, 'the existing repository');
|
|
31
49
|
const upstream = fallback(expectedUpstream, 'the expected upstream repository');
|
|
32
50
|
const relationship = fallback(relationshipDescription, `${repository} is not a confirmed GitHub fork of ${upstream}.`);
|
|
33
51
|
const safetyCheck = fallback(safetyCheckDescription, buildForkReplacementSafetyCheckDescription());
|
|
52
|
+
const detachedForkNote = likelyDetachedFork ? `\n- Likely cause: ${repository} shares history with ${upstream} but GitHub reports it as a non-fork, which matches a fork that was detached from its network (commonly after a private/public visibility change).` : '';
|
|
34
53
|
|
|
35
54
|
return `${FORK_REPLACEMENT_BLOCKED_REASON_PREFIX}
|
|
36
55
|
|
|
37
56
|
What happened:
|
|
38
57
|
- Expected fork or replacement repository: ${repository}
|
|
39
58
|
- Expected upstream: ${upstream}
|
|
40
|
-
- Actual state: ${relationship}
|
|
59
|
+
- Actual state: ${relationship}${detachedForkNote}
|
|
41
60
|
- Safety check: ${safetyCheck}
|
|
42
61
|
|
|
43
62
|
Why it stopped:
|
|
44
63
|
Hive Mind did not delete ${repository} because the repository may contain commits that would be lost.
|
|
45
64
|
|
|
46
65
|
Options:
|
|
47
|
-
1.
|
|
48
|
-
2.
|
|
49
|
-
3.
|
|
66
|
+
1. ${buildDetachedForkRecoveryGuidance({ existingRepository: repository, expectedUpstream: upstream })}
|
|
67
|
+
2. Back up any needed work in ${repository}, then delete, rename, archive, or repair that repository in GitHub and rerun the solver.
|
|
68
|
+
3. If you do not control ${repository}, ask the repository owner or a Hive Mind administrator to clean it up and rerun the solver.
|
|
69
|
+
4. Rerun with --allow-force-non-fork-repository-deletion only after confirming that deleting ${repository} is acceptable.`;
|
|
50
70
|
}
|
|
51
71
|
|
|
52
72
|
export function isForkReplacementBlockedReason(reason = '') {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { summarizeGitHubCompareFailure } from './solve.repository-recovery-message.lib.mjs';
|
|
6
|
+
|
|
7
|
+
const resultText = result => `${result?.stderr?.toString?.() || ''}${result?.stdout?.toString?.() || ''}`.trim();
|
|
8
|
+
const resultStdout = result => result?.stdout?.toString?.().trim() || '';
|
|
9
|
+
const shortSha = sha => String(sha || '').slice(0, 12);
|
|
10
|
+
|
|
11
|
+
const branchLabel = branch => {
|
|
12
|
+
const subject = branch.subject ? ` ${branch.subject}` : '';
|
|
13
|
+
return `${branch.ref} (${branch.uniqueCommitCount} commit(s), ${shortSha(branch.sha)}${subject})`;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function buildReplacementBranchSafetyDescription({ uniqueBranches = [], branchCount = 0, failureOutput = '' } = {}) {
|
|
17
|
+
if (failureOutput) {
|
|
18
|
+
return `Local Git branch reachability check failed (${summarizeGitHubCompareFailure(failureOutput)}), so Hive Mind could not prove all replacement repository branches are preserved upstream.`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (uniqueBranches.length > 0) {
|
|
22
|
+
const examples = uniqueBranches.slice(0, 3).map(branchLabel).join('; ');
|
|
23
|
+
const remaining = uniqueBranches.length > 3 ? `; and ${uniqueBranches.length - 3} more` : '';
|
|
24
|
+
return `Local Git branch reachability found ${uniqueBranches.length} replacement branch tip(s) with commits not reachable from upstream branches or PR refs: ${examples}${remaining}.`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return `Local Git branch reachability found all ${branchCount} replacement branch tip(s) reachable from upstream branches or PR refs.`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function runStep(commandPromise, failurePrefix) {
|
|
31
|
+
const result = await commandPromise;
|
|
32
|
+
if (result.code !== 0) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
output: `${failurePrefix}: ${resultText(result) || `exit ${result.code}`}`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return { ok: true, result };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const parseReplacementRefs = output =>
|
|
42
|
+
String(output || '')
|
|
43
|
+
.split('\n')
|
|
44
|
+
.map(line => line.trim())
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.map(line => {
|
|
47
|
+
const [ref, sha] = line.split(/\s+/, 2);
|
|
48
|
+
return { ref, sha };
|
|
49
|
+
})
|
|
50
|
+
.filter(item => item.ref && item.sha);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Verify that deleting a non-fork replacement repository will not remove branch
|
|
54
|
+
* tips that are absent from the upstream repository. This is intentionally used
|
|
55
|
+
* only in the rare fork-replacement recovery path.
|
|
56
|
+
*/
|
|
57
|
+
export async function checkReplacementRepositoryBranchSafety({ $, owner, repo, existingRepository, tempRoot = os.tmpdir() }) {
|
|
58
|
+
const tempDir = await fs.mkdtemp(path.join(tempRoot, 'hive-mind-fork-replacement-'));
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const init = await runStep($({ cwd: tempDir })`git init -q 2>&1`, 'git init failed');
|
|
62
|
+
if (!init.ok) {
|
|
63
|
+
return {
|
|
64
|
+
safeToDelete: false,
|
|
65
|
+
branchCount: 0,
|
|
66
|
+
uniqueBranches: [],
|
|
67
|
+
safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: init.output }),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const upstreamUrl = `https://github.com/${owner}/${repo}.git`;
|
|
72
|
+
const replacementUrl = `https://github.com/${existingRepository}.git`;
|
|
73
|
+
|
|
74
|
+
const upstreamFetch = await runStep($({ cwd: tempDir })`git fetch --filter=blob:none ${upstreamUrl} '+refs/heads/*:refs/remotes/upstream/*' '+refs/pull/*/head:refs/remotes/upstream-pr/*' 2>&1`, 'upstream fetch failed');
|
|
75
|
+
if (!upstreamFetch.ok) {
|
|
76
|
+
return {
|
|
77
|
+
safeToDelete: false,
|
|
78
|
+
branchCount: 0,
|
|
79
|
+
uniqueBranches: [],
|
|
80
|
+
safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: upstreamFetch.output }),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const replacementFetch = await runStep($({ cwd: tempDir })`git fetch --filter=blob:none ${replacementUrl} '+refs/heads/*:refs/remotes/replacement/*' 2>&1`, 'replacement fetch failed');
|
|
85
|
+
if (!replacementFetch.ok) {
|
|
86
|
+
return {
|
|
87
|
+
safeToDelete: false,
|
|
88
|
+
branchCount: 0,
|
|
89
|
+
uniqueBranches: [],
|
|
90
|
+
safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: replacementFetch.output }),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const refsResult = await runStep($({ cwd: tempDir })`git for-each-ref --format='%(refname:short) %(objectname)' refs/remotes/replacement 2>&1`, 'replacement ref listing failed');
|
|
95
|
+
if (!refsResult.ok) {
|
|
96
|
+
return {
|
|
97
|
+
safeToDelete: false,
|
|
98
|
+
branchCount: 0,
|
|
99
|
+
uniqueBranches: [],
|
|
100
|
+
safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: refsResult.output }),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const replacementRefs = parseReplacementRefs(resultStdout(refsResult.result));
|
|
105
|
+
const uniqueBranches = [];
|
|
106
|
+
|
|
107
|
+
for (const { ref, sha } of replacementRefs) {
|
|
108
|
+
const countResult = await runStep($({ cwd: tempDir })`git rev-list --count ${ref} --not --remotes=upstream --remotes=upstream-pr 2>&1`, `unique commit count failed for ${ref}`);
|
|
109
|
+
if (!countResult.ok) {
|
|
110
|
+
return {
|
|
111
|
+
safeToDelete: false,
|
|
112
|
+
branchCount: replacementRefs.length,
|
|
113
|
+
uniqueBranches,
|
|
114
|
+
safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: countResult.output }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const uniqueCommitCount = Number.parseInt(resultStdout(countResult.result), 10);
|
|
119
|
+
if (!Number.isFinite(uniqueCommitCount)) {
|
|
120
|
+
return {
|
|
121
|
+
safeToDelete: false,
|
|
122
|
+
branchCount: replacementRefs.length,
|
|
123
|
+
uniqueBranches,
|
|
124
|
+
safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: `invalid unique commit count for ${ref}: ${resultStdout(countResult.result)}` }),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (uniqueCommitCount > 0) {
|
|
129
|
+
const subjectResult = await $({ cwd: tempDir })`git log -1 --format=%s ${ref} 2>&1`;
|
|
130
|
+
uniqueBranches.push({
|
|
131
|
+
ref: ref.replace(/^replacement\//, ''),
|
|
132
|
+
sha,
|
|
133
|
+
uniqueCommitCount,
|
|
134
|
+
subject: subjectResult.code === 0 ? resultStdout(subjectResult) : '',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const reachableBranchCount = replacementRefs.length - uniqueBranches.length;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
safeToDelete: uniqueBranches.length === 0,
|
|
143
|
+
branchCount: replacementRefs.length,
|
|
144
|
+
reachableBranchCount,
|
|
145
|
+
// At least one branch tip is fully reachable from upstream refs, so the
|
|
146
|
+
// replacement shares history with upstream. Combined with GitHub reporting
|
|
147
|
+
// it as a non-fork, that matches a fork detached from its network (see
|
|
148
|
+
// issue #2019), typically after a private/public visibility change.
|
|
149
|
+
likelyDetachedFork: replacementRefs.length > 0 && reachableBranchCount > 0,
|
|
150
|
+
uniqueBranches,
|
|
151
|
+
safetyCheckDescription: buildReplacementBranchSafetyDescription({ uniqueBranches, branchCount: replacementRefs.length }),
|
|
152
|
+
};
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return {
|
|
155
|
+
safeToDelete: false,
|
|
156
|
+
branchCount: 0,
|
|
157
|
+
uniqueBranches: [],
|
|
158
|
+
safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: error.message }),
|
|
159
|
+
};
|
|
160
|
+
} finally {
|
|
161
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -30,6 +30,7 @@ const { log, formatAligned } = lib;
|
|
|
30
30
|
// Import exit handler
|
|
31
31
|
import { safeExit } from './exit-handler.lib.mjs';
|
|
32
32
|
import { parseForkFullNameFromGhOutput } from './github-repository-names.lib.mjs';
|
|
33
|
+
import { checkReplacementRepositoryBranchSafety } from './solve.repository-safety.lib.mjs';
|
|
33
34
|
import { buildForkReplacementBlockedReason, buildForkReplacementSafetyCheckDescription } from './solve.repository-recovery-message.lib.mjs';
|
|
34
35
|
|
|
35
36
|
// Import GitHub utilities for permission checks
|
|
@@ -525,34 +526,75 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
|
|
|
525
526
|
const relationshipDescription = !forkValidation.isFork ? `${existingForkName} is not a GitHub fork of ${owner}/${repo}.` : `${existingForkName} is a GitHub fork of ${forkValidation.parent || 'unknown parent'}, not ${owner}/${repo}.`;
|
|
526
527
|
await log(`${formatAligned('', '', detail)}`);
|
|
527
528
|
await log(`${formatAligned('', '', `Fork parent: ${forkValidation.parent || 'N/A (not a fork)'}, source: ${forkValidation.source || 'N/A'}, expected: ${owner}/${repo}`)}`);
|
|
528
|
-
// Safety check: compare commits before deleting to avoid data loss
|
|
529
|
-
|
|
529
|
+
// Safety check: compare commits before deleting to avoid data loss.
|
|
530
|
+
// GitHub compare only answers for fork-network refs, so a local Git
|
|
531
|
+
// fallback checks every replacement branch tip before deletion.
|
|
532
|
+
await log(`${formatAligned('🔍', 'Safety check:', 'Comparing default branch against upstream...')}`);
|
|
530
533
|
let safeToDelete = false;
|
|
531
534
|
let safetyCheckDescription = buildForkReplacementSafetyCheckDescription();
|
|
535
|
+
let shouldRunBranchReachabilityCheck = false;
|
|
536
|
+
let likelyDetachedFork = false;
|
|
532
537
|
try {
|
|
533
538
|
const cmp = await $`gh api repos/${owner}/${repo}/compare/${owner}:HEAD...${existingForkName.split('/')[0]}:HEAD --paginate --jq '.ahead_by' 2>&1`;
|
|
534
539
|
const aheadBy = parseInt(cmp.stdout.toString().trim(), 10);
|
|
535
540
|
if (cmp.code === 0 && aheadBy === 0) {
|
|
536
|
-
await log(`${formatAligned('✅', '
|
|
537
|
-
|
|
541
|
+
await log(`${formatAligned('✅', 'Default branch:', 'No additional commits in replacement repository')}`);
|
|
542
|
+
shouldRunBranchReachabilityCheck = true;
|
|
538
543
|
} else if (cmp.code === 0) {
|
|
539
544
|
safetyCheckDescription = buildForkReplacementSafetyCheckDescription({ aheadBy });
|
|
540
|
-
|
|
541
|
-
|
|
545
|
+
if (Number.isFinite(aheadBy)) {
|
|
546
|
+
await log(`${formatAligned('⚠️', 'UNSAFE:', `Default branch has ${aheadBy} commit(s) ahead of upstream that would be lost`)}`, { level: 'warning' });
|
|
547
|
+
} else {
|
|
548
|
+
await log(`${formatAligned('⚠️', 'Compare unclear:', 'GitHub compare did not return a numeric ahead_by value')}`, { level: 'warning' });
|
|
549
|
+
shouldRunBranchReachabilityCheck = true;
|
|
550
|
+
}
|
|
542
551
|
} else {
|
|
543
552
|
const compareOutput = (cmp.stderr?.toString() || '') + (cmp.stdout?.toString() || '');
|
|
544
553
|
safetyCheckDescription = buildForkReplacementSafetyCheckDescription({ compareFailureOutput: compareOutput });
|
|
545
554
|
await log(`${formatAligned('⚠️', 'Compare failed:', compareOutput.split('\n')[0])}`, { level: 'warning' });
|
|
555
|
+
shouldRunBranchReachabilityCheck = true;
|
|
546
556
|
}
|
|
547
557
|
} catch (e) {
|
|
548
558
|
safetyCheckDescription = buildForkReplacementSafetyCheckDescription({ compareFailureOutput: e.message });
|
|
549
559
|
await log(`${formatAligned('⚠️', 'Compare error:', e.message)}`, { level: 'warning' });
|
|
560
|
+
shouldRunBranchReachabilityCheck = true;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (shouldRunBranchReachabilityCheck) {
|
|
564
|
+
await log(`${formatAligned('🔍', 'Safety check:', 'Checking all replacement branches against upstream refs...')}`);
|
|
565
|
+
const branchSafety = await checkReplacementRepositoryBranchSafety({
|
|
566
|
+
$,
|
|
567
|
+
owner,
|
|
568
|
+
repo,
|
|
569
|
+
existingRepository: existingForkName,
|
|
570
|
+
});
|
|
571
|
+
safetyCheckDescription = branchSafety.safetyCheckDescription;
|
|
572
|
+
likelyDetachedFork = Boolean(branchSafety.likelyDetachedFork);
|
|
573
|
+
|
|
574
|
+
if (likelyDetachedFork) {
|
|
575
|
+
await log(`${formatAligned('🔗', 'Detached fork:', `${existingForkName} shares history with ${owner}/${repo} but is not a GitHub fork — this matches a fork detached by a private/public visibility change`)}`);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (branchSafety.safeToDelete) {
|
|
579
|
+
await log(`${formatAligned('✅', 'Safe to delete:', `All ${branchSafety.branchCount} replacement branch tip(s) are reachable from upstream`)}`);
|
|
580
|
+
safeToDelete = true;
|
|
581
|
+
} else if (branchSafety.uniqueBranches.length > 0) {
|
|
582
|
+
await log(`${formatAligned('⚠️', 'UNSAFE:', `${branchSafety.uniqueBranches.length} replacement branch tip(s) have commits not reachable from upstream`)}`, { level: 'warning' });
|
|
583
|
+
for (const branch of branchSafety.uniqueBranches.slice(0, 3)) {
|
|
584
|
+
await log(`${formatAligned('', '', `${branch.ref}: ${branch.uniqueCommitCount} commit(s), ${branch.sha.slice(0, 12)} ${branch.subject || ''}`)}`, { level: 'warning' });
|
|
585
|
+
}
|
|
586
|
+
} else {
|
|
587
|
+
await log(`${formatAligned('⚠️', 'Branch check failed:', safetyCheckDescription)}`, { level: 'warning' });
|
|
588
|
+
}
|
|
550
589
|
}
|
|
551
590
|
if (!safeToDelete) {
|
|
552
591
|
if (argv.allowForceNonForkRepositoryDeletion) {
|
|
553
592
|
// Force flag set — proceed with deletion despite the failed safety check.
|
|
554
593
|
await log(`${formatAligned('⚠️', 'Force deletion ENABLED:', '--allow-force-non-fork-repository-deletion — proceeding despite potential data loss')}`, { level: 'warning' });
|
|
555
594
|
} else {
|
|
595
|
+
if (likelyDetachedFork) {
|
|
596
|
+
await log(` 🔗 Recover without deletion: ask GitHub Support to re-attach ${existingForkName} to ${owner}/${repo} at https://support.github.com/request/fork ("Attach, detach or reroute forks")`);
|
|
597
|
+
}
|
|
556
598
|
await log(` 💡 Manual fix required: back up work, then: gh repo delete ${existingForkName} --yes`);
|
|
557
599
|
await log(` Then run this command again to create a proper fork of ${owner}/${repo}`);
|
|
558
600
|
await log(` 🔧 Or force deletion (DANGEROUS): solve ${argv.url || argv['issue-url'] || argv._[0] || '<issue-url>'} --allow-force-non-fork-repository-deletion`);
|
|
@@ -563,6 +605,7 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
|
|
|
563
605
|
expectedUpstream: `${owner}/${repo}`,
|
|
564
606
|
relationshipDescription,
|
|
565
607
|
safetyCheckDescription,
|
|
608
|
+
likelyDetachedFork,
|
|
566
609
|
})
|
|
567
610
|
);
|
|
568
611
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* @see https://github.com/link-assistant/hive-mind/issues/1618
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { normalizeCliArgs } from './argument-normalization.lib.mjs';
|
|
11
12
|
import { enhanceUnknownArgumentError } from './option-suggestions.lib.mjs';
|
|
12
13
|
|
|
13
14
|
export const TOOL_SOLVE_COMMAND_ALIASES = Object.freeze({
|
|
@@ -29,16 +30,13 @@ export function parseCommandArgs(text) {
|
|
|
29
30
|
return [];
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
// Replace em-dash with double-dash to fix Telegram auto-replacement.
|
|
33
|
-
const normalizedArgsText = argsText.replace(/—/g, '--');
|
|
34
|
-
|
|
35
33
|
const args = [];
|
|
36
34
|
let currentArg = '';
|
|
37
35
|
let inQuotes = false;
|
|
38
36
|
let quoteChar = null;
|
|
39
37
|
|
|
40
|
-
for (let i = 0; i <
|
|
41
|
-
const char =
|
|
38
|
+
for (let i = 0; i < argsText.length; i++) {
|
|
39
|
+
const char = argsText[i];
|
|
42
40
|
|
|
43
41
|
if ((char === '"' || char === "'") && !inQuotes) {
|
|
44
42
|
inQuotes = true;
|
|
@@ -60,7 +58,7 @@ export function parseCommandArgs(text) {
|
|
|
60
58
|
args.push(currentArg);
|
|
61
59
|
}
|
|
62
60
|
|
|
63
|
-
return args;
|
|
61
|
+
return normalizeCliArgs(args);
|
|
64
62
|
}
|
|
65
63
|
|
|
66
64
|
function toCamelCaseOptionName(name) {
|