@navels/neal 0.1.0
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/LICENSE +21 -0
- package/README.md +527 -0
- package/SECURITY.md +91 -0
- package/config.yml +104 -0
- package/dist/neal/activity-footer.js +177 -0
- package/dist/neal/activity-reporting.js +1 -0
- package/dist/neal/adjudicator/artifacts.js +58 -0
- package/dist/neal/adjudicator/blocked-adjudicator.js +223 -0
- package/dist/neal/adjudicator/contracts.js +139 -0
- package/dist/neal/adjudicator/execute.js +611 -0
- package/dist/neal/adjudicator/final-completion.js +104 -0
- package/dist/neal/adjudicator/planning.js +145 -0
- package/dist/neal/adjudicator/specs.js +453 -0
- package/dist/neal/agents/prompts.js +120 -0
- package/dist/neal/agents/rounds.js +706 -0
- package/dist/neal/agents/schemas.js +832 -0
- package/dist/neal/agents/structured-coder.js +82 -0
- package/dist/neal/agents/structured-json.js +528 -0
- package/dist/neal/agents.js +4 -0
- package/dist/neal/atomic-write.js +18 -0
- package/dist/neal/blocked-guidance.js +406 -0
- package/dist/neal/cli.js +471 -0
- package/dist/neal/commands/check.js +401 -0
- package/dist/neal/commands/compat.js +807 -0
- package/dist/neal/commands/interactive-activity.js +57 -0
- package/dist/neal/commands/new-run.js +79 -0
- package/dist/neal/commands/plan-and-execute.js +44 -0
- package/dist/neal/commands/recovery-guidance.js +217 -0
- package/dist/neal/commands/resume-run.js +395 -0
- package/dist/neal/commands/review.js +21 -0
- package/dist/neal/commands/runtime.js +557 -0
- package/dist/neal/commands/setup.js +596 -0
- package/dist/neal/commands/squash.js +113 -0
- package/dist/neal/commands/status.js +33 -0
- package/dist/neal/commands/writer-exit-codes.js +42 -0
- package/dist/neal/commit-message.js +17 -0
- package/dist/neal/config.js +432 -0
- package/dist/neal/context/artifacts.js +140 -0
- package/dist/neal/context/context.js +324 -0
- package/dist/neal/context/inline-review-context.js +131 -0
- package/dist/neal/context/reviewer-context.js +166 -0
- package/dist/neal/context/shared.js +117 -0
- package/dist/neal/context/types.js +1 -0
- package/dist/neal/diagnostic.js +208 -0
- package/dist/neal/execute-finalization.js +5 -0
- package/dist/neal/final-completion-review.js +188 -0
- package/dist/neal/final-completion.js +229 -0
- package/dist/neal/git.js +339 -0
- package/dist/neal/index.js +135 -0
- package/dist/neal/interactive-controls.js +85 -0
- package/dist/neal/logger.js +102 -0
- package/dist/neal/manual-gates.js +121 -0
- package/dist/neal/orchestrator/artifacts.js +70 -0
- package/dist/neal/orchestrator/completion.js +531 -0
- package/dist/neal/orchestrator/failures.js +31 -0
- package/dist/neal/orchestrator/notifications.js +175 -0
- package/dist/neal/orchestrator/phases/coder.js +516 -0
- package/dist/neal/orchestrator/phases/planning.js +540 -0
- package/dist/neal/orchestrator/phases/recovery.js +798 -0
- package/dist/neal/orchestrator/phases/review.js +136 -0
- package/dist/neal/orchestrator/phases/shared.js +279 -0
- package/dist/neal/orchestrator/run-loop.js +113 -0
- package/dist/neal/orchestrator/split-plan.js +235 -0
- package/dist/neal/orchestrator/transitions.js +309 -0
- package/dist/neal/orchestrator.js +215 -0
- package/dist/neal/phase-display.js +27 -0
- package/dist/neal/plan-doc.js +154 -0
- package/dist/neal/plan-queue.js +1092 -0
- package/dist/neal/plan-refinement.js +39 -0
- package/dist/neal/plan-validation.js +525 -0
- package/dist/neal/progress.js +237 -0
- package/dist/neal/prompts/assert-builder.js +13 -0
- package/dist/neal/prompts/execute.js +290 -0
- package/dist/neal/prompts/guidance.js +70 -0
- package/dist/neal/prompts/planning.js +313 -0
- package/dist/neal/prompts/review-doctrine.js +142 -0
- package/dist/neal/prompts/shared.js +101 -0
- package/dist/neal/prompts/specialized.js +212 -0
- package/dist/neal/prompts/specs.js +572 -0
- package/dist/neal/providers/anthropic-claude.js +1599 -0
- package/dist/neal/providers/detection.js +139 -0
- package/dist/neal/providers/generic-agentic-tools.js +586 -0
- package/dist/neal/providers/generic-agentic.js +1238 -0
- package/dist/neal/providers/liveness.js +151 -0
- package/dist/neal/providers/openai-codex.js +1014 -0
- package/dist/neal/providers/openai-compatible.js +654 -0
- package/dist/neal/providers/registry.js +389 -0
- package/dist/neal/providers/telemetry.js +208 -0
- package/dist/neal/providers/types.js +21 -0
- package/dist/neal/recovery-artifacts.js +50 -0
- package/dist/neal/resume-decision.js +220 -0
- package/dist/neal/resume-planner.js +265 -0
- package/dist/neal/retrospective.js +391 -0
- package/dist/neal/review-debt.js +18 -0
- package/dist/neal/review-findings/artifacts.js +173 -0
- package/dist/neal/review-findings/prompts.js +172 -0
- package/dist/neal/review-findings/provider.js +330 -0
- package/dist/neal/review-findings/run.js +373 -0
- package/dist/neal/review-findings/types.js +1 -0
- package/dist/neal/review-mode.js +67 -0
- package/dist/neal/review.js +137 -0
- package/dist/neal/run-lock.js +334 -0
- package/dist/neal/run-metrics.js +355 -0
- package/dist/neal/run-narrative-types.js +1 -0
- package/dist/neal/run-narrative.js +1374 -0
- package/dist/neal/run-registry.js +218 -0
- package/dist/neal/run-status.js +25 -0
- package/dist/neal/scopes.js +451 -0
- package/dist/neal/sensitive-text.js +8 -0
- package/dist/neal/squash-message.js +379 -0
- package/dist/neal/squash.js +591 -0
- package/dist/neal/state-invariants.js +496 -0
- package/dist/neal/state-views.js +344 -0
- package/dist/neal/state.js +887 -0
- package/dist/neal/status-footer.js +258 -0
- package/dist/neal/status.js +1260 -0
- package/dist/neal/storage-paths.js +57 -0
- package/dist/neal/support.js +58 -0
- package/dist/neal/terminal-narrator.js +435 -0
- package/dist/neal/types.js +1 -0
- package/dist/neal/verification-events.js +81 -0
- package/dist/neal/version.js +37 -0
- package/dist/neal/worktree-status.js +137 -0
- package/dist/notifier.js +44 -0
- package/docs/ADJUDICATOR_INVENTORY.md +310 -0
- package/docs/PROMPT_SPECS.md +266 -0
- package/docs/README.md +22 -0
- package/docs/architecture.md +113 -0
- package/docs/assets/neal-execution-flow.png +0 -0
- package/docs/automation.md +65 -0
- package/docs/comparison.md +105 -0
- package/docs/compat.md +269 -0
- package/docs/compatible-models.md +135 -0
- package/docs/demo.md +55 -0
- package/docs/maintenance.md +64 -0
- package/docs/plan-format.md +213 -0
- package/docs/providers.md +751 -0
- package/docs/release.md +147 -0
- package/docs/state-machine.md +266 -0
- package/docs/storage.md +207 -0
- package/docs/troubleshooting.md +152 -0
- package/examples/compat/add-edit-verify/PLAN.md +29 -0
- package/examples/compat/add-edit-verify/broken.diff +8 -0
- package/examples/compat/add-edit-verify/good.diff +8 -0
- package/examples/compat/add-edit-verify/package.json +5 -0
- package/examples/compat/add-edit-verify/src/add.js +2 -0
- package/examples/compat/add-edit-verify/test/add.test.js +9 -0
- package/examples/compat/is-even-add-test/PLAN.md +30 -0
- package/examples/compat/is-even-add-test/broken.diff +11 -0
- package/examples/compat/is-even-add-test/good.diff +11 -0
- package/examples/compat/is-even-add-test/package.json +5 -0
- package/examples/compat/is-even-add-test/src/is-even.js +3 -0
- package/examples/compat/is-even-add-test/test/is-even.test.js +9 -0
- package/examples/compat/manifest.json +60 -0
- package/examples/compat/plan-greeting/ISSUE.md +25 -0
- package/examples/compat/plan-greeting/package.json +5 -0
- package/examples/compat/plan-greeting/src/greet.js +2 -0
- package/examples/compat/plan-greeting/test/greet.test.js +8 -0
- package/examples/compat/reverse-grep-edit/PLAN.md +32 -0
- package/examples/compat/reverse-grep-edit/broken.diff +12 -0
- package/examples/compat/reverse-grep-edit/good.diff +12 -0
- package/examples/compat/reverse-grep-edit/package.json +5 -0
- package/examples/compat/reverse-grep-edit/src/strings.js +10 -0
- package/examples/compat/reverse-grep-edit/test/strings.test.js +15 -0
- package/examples/issue-triage-js/PLAN.md +83 -0
- package/examples/issue-triage-js/README.md +76 -0
- package/examples/issue-triage-js/package.json +9 -0
- package/examples/issue-triage-js/src/issue-triage.js +87 -0
- package/examples/issue-triage-js/test/issue-triage.test.js +107 -0
- package/package.json +70 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { getFinalCompletionContinueExecutionMax } from './config.js';
|
|
4
|
+
import { getChangedFilesForRange, getCommitRange, getCommitSubjects, getDiffStatForRange, } from './git.js';
|
|
5
|
+
import { toResidualReviewDebt } from './review-debt.js';
|
|
6
|
+
import { buildScopeAccountingSummary, getCurrentScopeLabel } from './scopes.js';
|
|
7
|
+
import { getDerivedPlanView, getFinalCompletionView } from './state-views.js';
|
|
8
|
+
import { extractVerificationCommandResults, latestCommandResultPerCommand, summarizeVerificationCommandResults, } from './verification-events.js';
|
|
9
|
+
function getEventsPath(runDir) {
|
|
10
|
+
return join(runDir, 'events.ndjson');
|
|
11
|
+
}
|
|
12
|
+
function summarizeChangedFiles(files) {
|
|
13
|
+
if (files.length === 0) {
|
|
14
|
+
return 'none';
|
|
15
|
+
}
|
|
16
|
+
const displayFiles = files.slice(0, 12);
|
|
17
|
+
const lines = displayFiles.map((file) => `- ${file}`);
|
|
18
|
+
if (files.length > displayFiles.length) {
|
|
19
|
+
lines.push(`- ...and ${files.length - displayFiles.length} more`);
|
|
20
|
+
}
|
|
21
|
+
return lines.join('\n');
|
|
22
|
+
}
|
|
23
|
+
function renderCompletedScopeSummary(scopes) {
|
|
24
|
+
if (scopes.length === 0) {
|
|
25
|
+
return 'No completed scopes recorded.';
|
|
26
|
+
}
|
|
27
|
+
return scopes
|
|
28
|
+
.map((scope) => {
|
|
29
|
+
const changedFiles = scope.changedFiles.length > 0
|
|
30
|
+
? `${scope.changedFiles.length} file(s): ${scope.changedFiles.join(', ')}`
|
|
31
|
+
: 'no changed files';
|
|
32
|
+
const commit = scope.finalCommit ?? 'pending';
|
|
33
|
+
const parent = scope.derivedFromParentScope ? ` | parent ${scope.derivedFromParentScope}` : '';
|
|
34
|
+
const blocker = scope.blocker ? ` | blocker: ${scope.blocker}` : '';
|
|
35
|
+
const residualDebt = scope.residualReviewDebt?.length
|
|
36
|
+
? ` | residual non-blocking debt: ${scope.residualReviewDebt
|
|
37
|
+
.map((item) => `${item.id} ${item.status}: ${item.claim}`)
|
|
38
|
+
.join('; ')}`
|
|
39
|
+
: '';
|
|
40
|
+
return `- Scope ${scope.number}: ${scope.result} (${scope.marker}) | commit ${commit}${parent} | ${changedFiles}${blocker}${residualDebt}`;
|
|
41
|
+
})
|
|
42
|
+
.join('\n');
|
|
43
|
+
}
|
|
44
|
+
function uniqueFiles(files) {
|
|
45
|
+
return [...new Set(files)];
|
|
46
|
+
}
|
|
47
|
+
function buildTerminalScopeRecord(state, terminalScope) {
|
|
48
|
+
const marker = (terminalScope.marker ?? state.lastScopeMarker ?? 'AUTONOMY_DONE');
|
|
49
|
+
const derivedPlan = getDerivedPlanView(state);
|
|
50
|
+
return {
|
|
51
|
+
number: getCurrentScopeLabel(state),
|
|
52
|
+
marker,
|
|
53
|
+
result: 'accepted',
|
|
54
|
+
baseCommit: state.baseCommit,
|
|
55
|
+
finalCommit: terminalScope.finalCommit,
|
|
56
|
+
summary: state.currentScopeProgressJustification?.milestoneTargeted ?? null,
|
|
57
|
+
commitSubject: terminalScope.commitSubject,
|
|
58
|
+
changedFiles: [...terminalScope.changedFiles],
|
|
59
|
+
reviewRounds: state.rounds.length,
|
|
60
|
+
findings: state.findings.length,
|
|
61
|
+
residualReviewDebt: toResidualReviewDebt(state.findings),
|
|
62
|
+
archivedReviewPath: terminalScope.archivedReviewPath,
|
|
63
|
+
blocker: null,
|
|
64
|
+
derivedFromParentScope: derivedPlan?.parentScopeNumber !== null && derivedPlan?.parentScopeNumber !== undefined
|
|
65
|
+
? String(derivedPlan.parentScopeNumber)
|
|
66
|
+
: null,
|
|
67
|
+
replacedByDerivedPlanPath: null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function mergeCompletedScopesWithTerminalScope(state, terminalScope) {
|
|
71
|
+
if (!terminalScope) {
|
|
72
|
+
return state.completedScopes;
|
|
73
|
+
}
|
|
74
|
+
const terminalRecord = buildTerminalScopeRecord(state, terminalScope);
|
|
75
|
+
return [
|
|
76
|
+
...state.completedScopes.filter((scope) => scope.number !== terminalRecord.number),
|
|
77
|
+
terminalRecord,
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
function toReferenceScope(scope) {
|
|
81
|
+
return {
|
|
82
|
+
number: scope.number,
|
|
83
|
+
finalCommit: scope.finalCommit,
|
|
84
|
+
commitSubject: scope.commitSubject,
|
|
85
|
+
changedFiles: [...scope.changedFiles],
|
|
86
|
+
archivedReviewPath: scope.archivedReviewPath,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function findLastNonEmptyImplementationScope(effectiveScopes, terminalScope, state) {
|
|
90
|
+
if (terminalScope && terminalScope.changedFiles.length > 0) {
|
|
91
|
+
return toReferenceScope(buildTerminalScopeRecord(state, terminalScope));
|
|
92
|
+
}
|
|
93
|
+
for (let index = effectiveScopes.length - 1; index >= 0; index -= 1) {
|
|
94
|
+
const scope = effectiveScopes[index];
|
|
95
|
+
if (scope?.result === 'accepted' && scope.changedFiles.length > 0) {
|
|
96
|
+
return toReferenceScope(scope);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
async function loadVerificationCommandResults(runDir) {
|
|
102
|
+
try {
|
|
103
|
+
const content = await readFile(getEventsPath(runDir), 'utf8');
|
|
104
|
+
const events = content
|
|
105
|
+
.split('\n')
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
.map((line) => JSON.parse(line));
|
|
108
|
+
return extractVerificationCommandResults(events);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function collectResidualReviewDebt(scopes) {
|
|
115
|
+
return scopes.flatMap((scope) => scope.residualReviewDebt ?? []);
|
|
116
|
+
}
|
|
117
|
+
function summarizeResidualReviewDebt(scopes) {
|
|
118
|
+
const items = scopes.flatMap((scope) => (scope.residualReviewDebt ?? []).map((item) => ({
|
|
119
|
+
scope: scope.number,
|
|
120
|
+
...item,
|
|
121
|
+
})));
|
|
122
|
+
if (items.length === 0) {
|
|
123
|
+
return 'No unresolved non-blocking review debt was recorded for accepted scopes.';
|
|
124
|
+
}
|
|
125
|
+
return items
|
|
126
|
+
.map((item) => {
|
|
127
|
+
const files = item.files.length > 0 ? item.files.join(', ') : 'n/a';
|
|
128
|
+
const disposition = item.coderDisposition ? ` | coder disposition: ${item.coderDisposition}` : '';
|
|
129
|
+
return `- Scope ${item.scope} ${item.id} (${item.status}) | files: ${files} | ${item.claim} | required: ${item.requiredAction}${disposition}`;
|
|
130
|
+
})
|
|
131
|
+
.join('\n');
|
|
132
|
+
}
|
|
133
|
+
async function loadAggregateReviewContext(state, finalCommit) {
|
|
134
|
+
const baseCommit = state.initialBaseCommit;
|
|
135
|
+
const headCommit = finalCommit;
|
|
136
|
+
const range = baseCommit && headCommit ? `${baseCommit}..${headCommit}` : null;
|
|
137
|
+
if (!baseCommit) {
|
|
138
|
+
return {
|
|
139
|
+
baseCommit,
|
|
140
|
+
headCommit,
|
|
141
|
+
range,
|
|
142
|
+
commitSubjects: [],
|
|
143
|
+
diffStat: '',
|
|
144
|
+
changedFiles: [],
|
|
145
|
+
unavailableReason: 'initialBaseCommit is unavailable, so the aggregate completion range cannot be computed.',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (!headCommit) {
|
|
149
|
+
return {
|
|
150
|
+
baseCommit,
|
|
151
|
+
headCommit,
|
|
152
|
+
range,
|
|
153
|
+
commitSubjects: [],
|
|
154
|
+
diffStat: '',
|
|
155
|
+
changedFiles: [],
|
|
156
|
+
unavailableReason: 'finalCommit is unavailable, so the aggregate completion range cannot be computed.',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const commits = await getCommitRange(state.cwd, baseCommit, headCommit);
|
|
161
|
+
const [commitSubjects, diffStat, changedFiles] = await Promise.all([
|
|
162
|
+
getCommitSubjects(state.cwd, commits),
|
|
163
|
+
getDiffStatForRange(state.cwd, baseCommit, headCommit),
|
|
164
|
+
getChangedFilesForRange(state.cwd, baseCommit, headCommit),
|
|
165
|
+
]);
|
|
166
|
+
return {
|
|
167
|
+
baseCommit,
|
|
168
|
+
headCommit,
|
|
169
|
+
range,
|
|
170
|
+
commitSubjects,
|
|
171
|
+
diffStat,
|
|
172
|
+
changedFiles,
|
|
173
|
+
unavailableReason: null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
const reason = error instanceof Error && error.message.trim() ? error.message.trim() : 'unknown git error';
|
|
178
|
+
return {
|
|
179
|
+
baseCommit,
|
|
180
|
+
headCommit,
|
|
181
|
+
range,
|
|
182
|
+
commitSubjects: [],
|
|
183
|
+
diffStat: '',
|
|
184
|
+
changedFiles: [],
|
|
185
|
+
unavailableReason: `Unable to read aggregate completion git context for ${range}: ${reason}`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export async function buildFinalCompletionPacket(args) {
|
|
190
|
+
const terminalScope = args.terminalScope ?? null;
|
|
191
|
+
const finalCommit = terminalScope?.finalCommit ?? args.state.finalCommit;
|
|
192
|
+
const effectiveScopes = mergeCompletedScopesWithTerminalScope(args.state, terminalScope);
|
|
193
|
+
const allVerificationCommandResults = await loadVerificationCommandResults(args.state.runDir);
|
|
194
|
+
const verificationCommandResults = latestCommandResultPerCommand(allVerificationCommandResults);
|
|
195
|
+
const verificationCommands = verificationCommandResults.map((result) => result.command);
|
|
196
|
+
const scopeAccounting = buildScopeAccountingSummary(effectiveScopes);
|
|
197
|
+
const terminalChangedFiles = [...(terminalScope?.changedFiles ?? [])];
|
|
198
|
+
const planChangedFiles = uniqueFiles(effectiveScopes
|
|
199
|
+
.filter((scope) => scope.result === 'accepted')
|
|
200
|
+
.flatMap((scope) => scope.changedFiles));
|
|
201
|
+
const residualReviewDebt = collectResidualReviewDebt(effectiveScopes);
|
|
202
|
+
const aggregateReviewContext = await loadAggregateReviewContext(args.state, finalCommit);
|
|
203
|
+
const finalCompletion = getFinalCompletionView(args.state);
|
|
204
|
+
return {
|
|
205
|
+
planDoc: args.state.planDoc,
|
|
206
|
+
executionShape: args.state.executionShape,
|
|
207
|
+
currentScopeLabel: getCurrentScopeLabel(args.state),
|
|
208
|
+
finalCommit,
|
|
209
|
+
aggregateReviewContext,
|
|
210
|
+
completedScopeSummary: renderCompletedScopeSummary(effectiveScopes),
|
|
211
|
+
acceptedScopeCount: effectiveScopes.filter((scope) => scope.result === 'accepted').length,
|
|
212
|
+
blockedScopeCount: effectiveScopes.filter((scope) => scope.result === 'blocked').length,
|
|
213
|
+
scopeAccounting,
|
|
214
|
+
scopeAccountingSummary: scopeAccounting.summary,
|
|
215
|
+
verificationOnlyCompletion: terminalChangedFiles.length === 0 && args.state.createdCommits.length === 0,
|
|
216
|
+
terminalChangedFiles,
|
|
217
|
+
terminalChangedFilesSummary: summarizeChangedFiles(terminalChangedFiles),
|
|
218
|
+
planChangedFiles,
|
|
219
|
+
planChangedFilesSummary: summarizeChangedFiles(planChangedFiles),
|
|
220
|
+
residualReviewDebt,
|
|
221
|
+
residualReviewDebtSummary: summarizeResidualReviewDebt(effectiveScopes),
|
|
222
|
+
verificationCommands,
|
|
223
|
+
verificationCommandResults,
|
|
224
|
+
verificationSummary: summarizeVerificationCommandResults(verificationCommandResults),
|
|
225
|
+
lastNonEmptyImplementationScope: findLastNonEmptyImplementationScope(effectiveScopes, terminalScope, args.state),
|
|
226
|
+
continueExecutionCount: finalCompletion?.continueExecutionCount ?? 0,
|
|
227
|
+
continueExecutionMax: Math.max(0, getFinalCompletionContinueExecutionMax(args.state.cwd)),
|
|
228
|
+
};
|
|
229
|
+
}
|
package/dist/neal/git.js
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
function runGit(args, cwd) {
|
|
4
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
5
|
+
execFile('git', args, { cwd }, (error, stdout, stderr) => {
|
|
6
|
+
if (error) {
|
|
7
|
+
rejectPromise(new Error(stderr.trim() || error.message));
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
resolvePromise(stdout.trim());
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function runGitOptionalConfig(args, cwd) {
|
|
15
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
16
|
+
execFile('git', args, { cwd }, (error, stdout, stderr) => {
|
|
17
|
+
if (!error) {
|
|
18
|
+
resolvePromise(stdout.trim());
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : null;
|
|
22
|
+
if (code === 1 && stdout.trim() === '' && stderr.trim() === '') {
|
|
23
|
+
resolvePromise(null);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
rejectPromise(new Error(stderr.trim() || error.message));
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async function getBooleanGitConfig(cwd, key) {
|
|
31
|
+
const value = await runGitOptionalConfig(['config', '--bool', '--get', key], cwd);
|
|
32
|
+
if (value === null) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return value === 'true';
|
|
36
|
+
}
|
|
37
|
+
function runGitWithInput(args, cwd, input) {
|
|
38
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
39
|
+
const child = spawn('git', args, {
|
|
40
|
+
cwd,
|
|
41
|
+
shell: false,
|
|
42
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
43
|
+
});
|
|
44
|
+
let stdout = '';
|
|
45
|
+
let stderr = '';
|
|
46
|
+
child.stdout.on('data', (chunk) => {
|
|
47
|
+
stdout += chunk.toString();
|
|
48
|
+
});
|
|
49
|
+
child.stderr.on('data', (chunk) => {
|
|
50
|
+
stderr += chunk.toString();
|
|
51
|
+
});
|
|
52
|
+
child.on('error', (error) => {
|
|
53
|
+
rejectPromise(new Error(stderr.trim() || error.message));
|
|
54
|
+
});
|
|
55
|
+
child.on('exit', (code, signal) => {
|
|
56
|
+
if (signal) {
|
|
57
|
+
rejectPromise(new Error(`git ${args.join(' ')} terminated by signal ${signal}`));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (code === 0) {
|
|
61
|
+
resolvePromise(stdout.trim());
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
rejectPromise(new Error(stderr.trim() || `git ${args.join(' ')} exited with status ${code}`));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
child.stdin.end(input);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function runGitWithInputStatus(args, cwd, input) {
|
|
71
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
72
|
+
const child = spawn('git', args, {
|
|
73
|
+
cwd,
|
|
74
|
+
shell: false,
|
|
75
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
76
|
+
});
|
|
77
|
+
let stdout = '';
|
|
78
|
+
let stderr = '';
|
|
79
|
+
child.stdout.on('data', (chunk) => {
|
|
80
|
+
stdout += chunk.toString();
|
|
81
|
+
});
|
|
82
|
+
child.stderr.on('data', (chunk) => {
|
|
83
|
+
stderr += chunk.toString();
|
|
84
|
+
});
|
|
85
|
+
child.on('error', (error) => {
|
|
86
|
+
rejectPromise(new Error(stderr.trim() || error.message));
|
|
87
|
+
});
|
|
88
|
+
child.on('exit', (code, signal) => {
|
|
89
|
+
resolvePromise({
|
|
90
|
+
stdout,
|
|
91
|
+
stderr,
|
|
92
|
+
code: code ?? 0,
|
|
93
|
+
signal,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
child.stdin.end(input);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
export async function getHeadCommit(cwd) {
|
|
100
|
+
return runGit(['rev-parse', 'HEAD'], cwd);
|
|
101
|
+
}
|
|
102
|
+
export async function getRepositoryRoot(cwd) {
|
|
103
|
+
return runGit(['rev-parse', '--show-toplevel'], cwd);
|
|
104
|
+
}
|
|
105
|
+
export async function tryGetRepositoryRoot(cwd) {
|
|
106
|
+
try {
|
|
107
|
+
const insideWorkTree = await runGit(['rev-parse', '--is-inside-work-tree'], cwd);
|
|
108
|
+
if (insideWorkTree !== 'true') {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
return await getRepositoryRoot(cwd);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export async function getGitPath(cwd, path) {
|
|
118
|
+
const gitPath = await runGit(['rev-parse', '--git-path', path], cwd);
|
|
119
|
+
return isAbsolute(gitPath) ? gitPath : resolve(cwd, gitPath);
|
|
120
|
+
}
|
|
121
|
+
export async function getNealDirGitIgnoreStatus(cwd) {
|
|
122
|
+
const repoRoot = await tryGetRepositoryRoot(cwd);
|
|
123
|
+
if (repoRoot === null) {
|
|
124
|
+
return { kind: 'not_git' };
|
|
125
|
+
}
|
|
126
|
+
const ignoredPaths = await getIgnoredPaths(repoRoot, ['.neal/']);
|
|
127
|
+
if (ignoredPaths.includes('.neal/')) {
|
|
128
|
+
return { kind: 'ignored', repoRoot };
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
kind: 'not_ignored',
|
|
132
|
+
repoRoot,
|
|
133
|
+
excludePath: await getGitPath(repoRoot, 'info/exclude'),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
export async function assertGitRepositoryWithCommit(cwd, commandLabel) {
|
|
137
|
+
const repositoryMessage = `${commandLabel} requires a Git repository with at least one commit. ` +
|
|
138
|
+
'Run `git init` if needed, create an initial commit, then retry.';
|
|
139
|
+
const noInitialCommitMessage = `This repository has no commits yet. Create an initial commit before running \`${commandLabel}\`.`;
|
|
140
|
+
try {
|
|
141
|
+
const insideWorkTree = await runGit(['rev-parse', '--is-inside-work-tree'], cwd);
|
|
142
|
+
if (insideWorkTree !== 'true') {
|
|
143
|
+
throw new Error(repositoryMessage);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
throw new Error(repositoryMessage);
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
await runGit(['rev-parse', '--verify', 'HEAD^{commit}'], cwd);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
throw new Error(noInitialCommitMessage);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
export async function resolveCommitRef(cwd, ref) {
|
|
157
|
+
return runGit(['rev-parse', '--verify', `${ref}^{commit}`], cwd);
|
|
158
|
+
}
|
|
159
|
+
export async function isAncestorCommit(cwd, ancestor, descendant) {
|
|
160
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
161
|
+
execFile('git', ['merge-base', '--is-ancestor', ancestor, descendant], { cwd }, (error, _stdout, stderr) => {
|
|
162
|
+
if (!error) {
|
|
163
|
+
resolvePromise(true);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : null;
|
|
167
|
+
if (code === 1) {
|
|
168
|
+
resolvePromise(false);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
rejectPromise(new Error(stderr.trim() || error.message));
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
export async function getCommitRange(cwd, base, head) {
|
|
176
|
+
if (base === head) {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
const output = await runGit(['rev-list', '--reverse', `${base}..${head}`], cwd);
|
|
180
|
+
return output ? output.split('\n').filter(Boolean) : [];
|
|
181
|
+
}
|
|
182
|
+
export async function getDiffStatForRange(cwd, base, head) {
|
|
183
|
+
if (base === head) {
|
|
184
|
+
return '';
|
|
185
|
+
}
|
|
186
|
+
return runGit(['diff', '--stat', `${base}..${head}`], cwd);
|
|
187
|
+
}
|
|
188
|
+
export async function getDiffForRange(cwd, base, head) {
|
|
189
|
+
if (base === head) {
|
|
190
|
+
return '';
|
|
191
|
+
}
|
|
192
|
+
return runGit(['diff', '--find-renames', `${base}..${head}`], cwd);
|
|
193
|
+
}
|
|
194
|
+
export async function getChangedFilesForRange(cwd, base, head) {
|
|
195
|
+
if (base === head) {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
const output = await runGit(['diff', '--name-only', `${base}..${head}`], cwd);
|
|
199
|
+
return output ? output.split('\n').filter(Boolean) : [];
|
|
200
|
+
}
|
|
201
|
+
export async function commitTreePathExists(cwd, commit, path) {
|
|
202
|
+
const output = await runGit(['ls-tree', '--name-only', commit, '--', path], cwd);
|
|
203
|
+
return output.split('\n').filter(Boolean).includes(path);
|
|
204
|
+
}
|
|
205
|
+
export async function getIgnoredPaths(cwd, paths) {
|
|
206
|
+
if (paths.length === 0) {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
const result = await runGitWithInputStatus(['check-ignore', '--no-index', '--stdin', '-z'], cwd, paths.join('\0') + '\0');
|
|
210
|
+
if (result.signal) {
|
|
211
|
+
throw new Error(`git check-ignore terminated by signal ${result.signal}`);
|
|
212
|
+
}
|
|
213
|
+
if (result.code === 1) {
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
if (result.code !== 0) {
|
|
217
|
+
throw new Error(result.stderr.trim() || `git check-ignore exited with status ${result.code}`);
|
|
218
|
+
}
|
|
219
|
+
return result.stdout.split('\0').filter(Boolean);
|
|
220
|
+
}
|
|
221
|
+
export async function dropIgnoredPathsFromCurrentCommitRange(cwd, baseCommit, headCommit) {
|
|
222
|
+
const changedFiles = await getChangedFilesForRange(cwd, baseCommit, headCommit);
|
|
223
|
+
const ignoredFiles = await getIgnoredPaths(cwd, changedFiles);
|
|
224
|
+
if (ignoredFiles.length === 0) {
|
|
225
|
+
return {
|
|
226
|
+
ignoredFiles: [],
|
|
227
|
+
headCommit,
|
|
228
|
+
createdReplacementCommit: false,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const currentHead = await getHeadCommit(cwd);
|
|
232
|
+
if (currentHead !== headCommit) {
|
|
233
|
+
throw new Error(`Cannot drop ignored files from commit range because HEAD moved from ${headCommit} to ${currentHead}`);
|
|
234
|
+
}
|
|
235
|
+
const replacementMessage = await getCommitMessage(cwd, headCommit);
|
|
236
|
+
await runGit(['reset', '--soft', baseCommit], cwd);
|
|
237
|
+
await runGit(['reset', '--', ...ignoredFiles], cwd);
|
|
238
|
+
const stagedFiles = await runGit(['diff', '--cached', '--name-only'], cwd);
|
|
239
|
+
if (!stagedFiles) {
|
|
240
|
+
return {
|
|
241
|
+
ignoredFiles,
|
|
242
|
+
headCommit: baseCommit,
|
|
243
|
+
createdReplacementCommit: false,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
await runGitWithInput(['commit', '--no-verify', '-F', '-'], cwd, replacementMessage.endsWith('\n') ? replacementMessage : `${replacementMessage}\n`);
|
|
247
|
+
return {
|
|
248
|
+
ignoredFiles,
|
|
249
|
+
headCommit: await getHeadCommit(cwd),
|
|
250
|
+
createdReplacementCommit: true,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
export async function assertNoIgnoredChangedFiles(cwd, changedFiles, context) {
|
|
254
|
+
const ignoredFiles = await getIgnoredPaths(cwd, changedFiles);
|
|
255
|
+
if (ignoredFiles.length === 0) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
throw new Error([
|
|
259
|
+
`${context} would include ignored file(s), which Neal will not commit:`,
|
|
260
|
+
...ignoredFiles.map((file) => `- ${file}`),
|
|
261
|
+
'Remove those files from the commit or change the ignore rules intentionally before continuing.',
|
|
262
|
+
].join('\n'));
|
|
263
|
+
}
|
|
264
|
+
export async function getCommitSubjects(cwd, commits) {
|
|
265
|
+
if (commits.length === 0) {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
const output = await runGit(['show', '--quiet', '--format=%H %s', ...commits], cwd);
|
|
269
|
+
return output ? output.split('\n').filter(Boolean) : [];
|
|
270
|
+
}
|
|
271
|
+
export async function getCommitMessage(cwd, commit) {
|
|
272
|
+
return runGit(['show', '--quiet', '--format=%B', commit], cwd);
|
|
273
|
+
}
|
|
274
|
+
export async function getWorktreeStatus(cwd, options = {}) {
|
|
275
|
+
const args = ['status', '--short'];
|
|
276
|
+
if (options.untrackedFiles === 'all') {
|
|
277
|
+
args.push('--untracked-files=all');
|
|
278
|
+
}
|
|
279
|
+
return runGit(args, cwd);
|
|
280
|
+
}
|
|
281
|
+
export async function stagePath(cwd, path) {
|
|
282
|
+
await runGit(['add', '--', path], cwd);
|
|
283
|
+
}
|
|
284
|
+
export async function getStagedChangedFiles(cwd) {
|
|
285
|
+
const output = await runGit(['diff', '--cached', '--name-only'], cwd);
|
|
286
|
+
return output ? output.split('\n').filter(Boolean) : [];
|
|
287
|
+
}
|
|
288
|
+
export async function getStagedDiff(cwd) {
|
|
289
|
+
return runGit(['diff', '--cached', '--binary'], cwd);
|
|
290
|
+
}
|
|
291
|
+
export async function commitStagedChanges(cwd, message) {
|
|
292
|
+
await runGitWithInput(['commit', '--no-verify', '-F', '-'], cwd, message.endsWith('\n') ? message : `${message}\n`);
|
|
293
|
+
return getHeadCommit(cwd);
|
|
294
|
+
}
|
|
295
|
+
export async function getUnstagedDiff(cwd) {
|
|
296
|
+
return runGit(['diff', '--binary'], cwd);
|
|
297
|
+
}
|
|
298
|
+
export async function getUntrackedFiles(cwd) {
|
|
299
|
+
const output = await runGit(['ls-files', '--others', '--exclude-standard'], cwd);
|
|
300
|
+
return output ? output.split('\n').filter(Boolean) : [];
|
|
301
|
+
}
|
|
302
|
+
export async function resetHard(cwd, target) {
|
|
303
|
+
await runGit(['reset', '--hard', target], cwd);
|
|
304
|
+
}
|
|
305
|
+
export async function restoreWorktreePaths(cwd, paths) {
|
|
306
|
+
if (paths.length === 0) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
await runGit(['restore', '--staged', '--worktree', '--', ...paths], cwd);
|
|
310
|
+
}
|
|
311
|
+
export async function createSquashReplacementCommit(cwd, baseCommit, finalCommit, message) {
|
|
312
|
+
const args = ['commit-tree', `${finalCommit}^{tree}`, '-p', baseCommit];
|
|
313
|
+
if (await getBooleanGitConfig(cwd, 'commit.gpgsign')) {
|
|
314
|
+
args.push('-S');
|
|
315
|
+
}
|
|
316
|
+
args.push('-F', '-');
|
|
317
|
+
return runGitWithInput(args, cwd, message.endsWith('\n') ? message : `${message}\n`);
|
|
318
|
+
}
|
|
319
|
+
export async function rebaseCurrentBranchOnto(cwd, newBase, upstream) {
|
|
320
|
+
await runGit(['rebase', '--onto', newBase, upstream], cwd);
|
|
321
|
+
}
|
|
322
|
+
export async function cleanUntracked(cwd, excludedPaths = []) {
|
|
323
|
+
const args = ['clean', '-fd'];
|
|
324
|
+
for (const path of excludedPaths) {
|
|
325
|
+
args.push('-e', path);
|
|
326
|
+
}
|
|
327
|
+
await runGit(args, cwd);
|
|
328
|
+
}
|
|
329
|
+
export async function cleanUntrackedPaths(cwd, paths) {
|
|
330
|
+
if (paths.length === 0) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
await runGit(['clean', '-fd', '--', ...paths], cwd);
|
|
334
|
+
}
|
|
335
|
+
export async function squashCommits(cwd, baseCommit, message) {
|
|
336
|
+
await runGit(['reset', '--soft', baseCommit], cwd);
|
|
337
|
+
await runGitWithInput(['commit', '--no-verify', '-F', '-'], cwd, message.endsWith('\n') ? message : `${message}\n`);
|
|
338
|
+
return getHeadCommit(cwd);
|
|
339
|
+
}
|