@ghl-ai/aw 0.1.89 → 0.1.92
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/cli.mjs +5 -1
- package/commands/init.mjs +19 -0
- package/commands/pull.mjs +178 -27
- package/commands/push.mjs +13 -6
- package/git.mjs +358 -11
- package/package.json +1 -1
package/cli.mjs
CHANGED
|
@@ -38,7 +38,7 @@ const COMMANDS = {
|
|
|
38
38
|
|
|
39
39
|
const COMMAND_NAMES = Object.keys(COMMANDS);
|
|
40
40
|
|
|
41
|
-
function parseArgs(argv) {
|
|
41
|
+
export function parseArgs(argv) {
|
|
42
42
|
const args = { _positional: [] };
|
|
43
43
|
let command = null;
|
|
44
44
|
let i = 0;
|
|
@@ -67,6 +67,9 @@ function parseArgs(argv) {
|
|
|
67
67
|
} else if (arg === '--yes' || arg === '--non-interactive' || arg === '--headless') {
|
|
68
68
|
args[arg] = true;
|
|
69
69
|
i++;
|
|
70
|
+
} else if (arg === '--discard-local') {
|
|
71
|
+
args['--discard-local'] = true;
|
|
72
|
+
i++;
|
|
70
73
|
} else if (arg === '--version') {
|
|
71
74
|
args['--version'] = true;
|
|
72
75
|
i++;
|
|
@@ -154,6 +157,7 @@ function printHelp() {
|
|
|
154
157
|
cmd('aw pull', 'Re-pull all synced paths (like git pull)'),
|
|
155
158
|
cmd('aw pull <path>', 'Pull agents, skills & more from registry'),
|
|
156
159
|
cmd('aw pull --dry-run <path>', 'Preview what would be downloaded'),
|
|
160
|
+
cmd('aw pull --discard-local', 'On registry conflict, take remote and continue (agent-safe)'),
|
|
157
161
|
|
|
158
162
|
sec('Upload'),
|
|
159
163
|
cmd('aw push', 'Push all modified files (creates one PR)'),
|
package/commands/init.mjs
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
fetchAndMerge,
|
|
44
44
|
addToSparseCheckout,
|
|
45
45
|
addProjectWorktree,
|
|
46
|
+
pruneAwSelfLinks,
|
|
46
47
|
isWorktree,
|
|
47
48
|
includeToSparsePaths,
|
|
48
49
|
sparseCheckoutAsync,
|
|
@@ -460,6 +461,24 @@ export async function initCommand(args) {
|
|
|
460
461
|
}
|
|
461
462
|
|
|
462
463
|
ensureAwGitignore(AW_HOME);
|
|
464
|
+
// Heal installs where an older `aw link` run from inside ~/.aw left a
|
|
465
|
+
// cycle-forming symlink, which makes recursive walkers die with
|
|
466
|
+
// ENAMETOOLONG. A fresh clone cannot have one, so only this path needs it.
|
|
467
|
+
const selfLinks = pruneAwSelfLinks(AW_HOME);
|
|
468
|
+
if (selfLinks.removed.length > 0 && !silent) {
|
|
469
|
+
const n = selfLinks.removed.length;
|
|
470
|
+
fmt.logStep(`Removed ${n} cycle-forming .aw symlink${n > 1 ? 's' : ''}`);
|
|
471
|
+
}
|
|
472
|
+
if (selfLinks.failed.length > 0 && !silent) {
|
|
473
|
+
// A cycle may still be in place — say so, with the paths, rather than
|
|
474
|
+
// letting the next `vite`/`tsc`/`eslint` run fail with ENAMETOOLONG and
|
|
475
|
+
// no explanation of why.
|
|
476
|
+
fmt.logWarn(
|
|
477
|
+
`Could not clear ${selfLinks.failed.length} cycle-forming .aw symlink(s) under ${AW_HOME}. `
|
|
478
|
+
+ 'Recursive tools may fail with ENAMETOOLONG until these are removed:\n'
|
|
479
|
+
+ selfLinks.failed.map(f => ` ${f.path} (${f.shape}: ${f.reason})`).join('\n')
|
|
480
|
+
);
|
|
481
|
+
}
|
|
463
482
|
const freshCfg = config.load(GLOBAL_AW_DIR);
|
|
464
483
|
syncRulesTargets(HOME);
|
|
465
484
|
if (cwd !== HOME) {
|
package/commands/pull.mjs
CHANGED
|
@@ -22,6 +22,10 @@ import {
|
|
|
22
22
|
isValidClone,
|
|
23
23
|
findNearestWorktree,
|
|
24
24
|
rebaseOntoOriginMain,
|
|
25
|
+
resolveRegistryConflicts,
|
|
26
|
+
continuePausedRebase,
|
|
27
|
+
pushForceWithLease,
|
|
28
|
+
getCurrentBranch,
|
|
25
29
|
} from '../git.mjs';
|
|
26
30
|
import {
|
|
27
31
|
REGISTRY_DIR,
|
|
@@ -57,10 +61,71 @@ function removeLegacyRegistryRules() {
|
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
function envConflictAction() {
|
|
65
|
+
const raw = String(process.env.AW_PULL_ON_CONFLICT || '').trim().toLowerCase();
|
|
66
|
+
if (raw === 'discard' || raw === 'abort' || raw === 'leave') return raw;
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isPushBranchName(branch) {
|
|
71
|
+
return /^(upload|sync|remove)\//.test(String(branch || ''));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function forcePushPushBranchIfNeeded(awHome, log) {
|
|
75
|
+
const branch = getCurrentBranch(awHome);
|
|
76
|
+
if (!isPushBranchName(branch)) return { ok: true, skipped: true };
|
|
77
|
+
try {
|
|
78
|
+
await pushForceWithLease(awHome, branch);
|
|
79
|
+
if (log?.logStep) log.logStep(`Synced push branch ${branch} after conflict discard`);
|
|
80
|
+
return { ok: true, skipped: false };
|
|
81
|
+
} catch (e) {
|
|
82
|
+
const msg = e?.message || 'push failed';
|
|
83
|
+
if (log?.logWarn) {
|
|
84
|
+
log.logWarn(`Could not sync push branch ${branch} after conflict recovery: ${msg}`);
|
|
85
|
+
}
|
|
86
|
+
return { ok: false, error: msg };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function printConflictHint(conflicts) {
|
|
91
|
+
const paths = (conflicts || []).filter(Boolean).join(',') || '(unknown)';
|
|
92
|
+
// Always emit — agents in --silent/CI must still parse this line.
|
|
93
|
+
console.error(`AW_PULL_CONFLICT paths=${paths} next=aw pull --discard-local`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function chooseConflictAction({ discardLocal, silent, headless, conflicts, log }) {
|
|
97
|
+
if (discardLocal) return 'discard';
|
|
98
|
+
const fromEnv = envConflictAction();
|
|
99
|
+
if (fromEnv) return fromEnv;
|
|
100
|
+
if (silent || headless) return 'abort';
|
|
101
|
+
|
|
102
|
+
const branch = getCurrentBranch(AW_HOME);
|
|
103
|
+
if (isPushBranchName(branch)) {
|
|
104
|
+
log.logWarn(`You are on push branch ${chalk.bold(branch)} — discard may drop unpushed registry commits.`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
log.logWarn(`Merge conflict in: ${conflicts.join(', ')}`);
|
|
108
|
+
const choice = await fmt.select({
|
|
109
|
+
message: 'How do you want to resolve the registry pull conflict?',
|
|
110
|
+
options: [
|
|
111
|
+
{ value: 'discard', label: 'Discard conflicting local changes and sync to remote', hint: 'recommended if you did not edit these files' },
|
|
112
|
+
{ value: 'abort', label: 'Cancel aw pull (leave local state unchanged)' },
|
|
113
|
+
{ value: 'leave', label: 'Keep local and resolve conflicts manually' },
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (fmt.isCancel(choice) || !choice) return 'abort';
|
|
118
|
+
return choice;
|
|
119
|
+
}
|
|
120
|
+
|
|
60
121
|
export async function pullCommand(args) {
|
|
61
122
|
const input = args._positional?.[0] || '';
|
|
62
123
|
const cwd = process.cwd();
|
|
63
|
-
const
|
|
124
|
+
const interaction = fmt.configureInteractionMode(args);
|
|
125
|
+
const silent = interaction.silent || args['--silent'] === true || args._silent === true;
|
|
126
|
+
const headless = interaction.headless || silent;
|
|
127
|
+
const discardLocal = args['--discard-local'] === true
|
|
128
|
+
|| String(process.env.AW_PULL_ON_CONFLICT || '').trim().toLowerCase() === 'discard';
|
|
64
129
|
|
|
65
130
|
const log = {
|
|
66
131
|
cancel: silent ? (msg) => { throw new fmt.CancelError(msg || 'silent cancel', { exitCode: 0 }); } : fmt.cancel,
|
|
@@ -135,32 +200,76 @@ export async function pullCommand(args) {
|
|
|
135
200
|
}
|
|
136
201
|
|
|
137
202
|
if (unresolved.length > 0) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
203
|
+
const action = await chooseConflictAction({
|
|
204
|
+
discardLocal,
|
|
205
|
+
silent,
|
|
206
|
+
headless,
|
|
207
|
+
conflicts: unresolved,
|
|
208
|
+
log,
|
|
209
|
+
});
|
|
210
|
+
const resolved = await resolveRegistryConflicts(AW_HOME, {
|
|
211
|
+
action,
|
|
212
|
+
conflicts: unresolved,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (action === 'discard') {
|
|
216
|
+
if (!resolved.ok) {
|
|
217
|
+
printConflictHint(unresolved);
|
|
218
|
+
throw new fmt.CancelError(
|
|
219
|
+
`Could not discard local conflicts: ${resolved.error || 'unknown error'}`,
|
|
220
|
+
{ exitCode: 1 },
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
if (!silent) log.logSuccess('Discarded conflicting local changes and synced to remote');
|
|
224
|
+
await forcePushPushBranchIfNeeded(AW_HOME, log);
|
|
225
|
+
// Fall through to continue the normal pull after a clean discard.
|
|
226
|
+
} else if (action === 'leave') {
|
|
227
|
+
if (!silent) {
|
|
228
|
+
log.logWarn(`Merge conflict in: ${unresolved.join(', ')}`);
|
|
229
|
+
log.logWarn('Rebase left in progress — resolve conflict markers, then run `aw pull` again.');
|
|
230
|
+
fmt.outro(chalk.yellow('Pull paused for manual conflict resolution'));
|
|
231
|
+
} else {
|
|
232
|
+
printConflictHint(unresolved);
|
|
153
233
|
}
|
|
234
|
+
return;
|
|
235
|
+
} else {
|
|
236
|
+
if (!silent) {
|
|
237
|
+
log.logWarn(`Merge conflict in: ${unresolved.join(', ')}`);
|
|
238
|
+
const kind = resolved.kind === 'merge' ? 'merge' : 'rebase';
|
|
239
|
+
log.logWarn(`Pull cancelled — local registry left unchanged (${kind} aborted).`);
|
|
240
|
+
printConflictHint(unresolved);
|
|
241
|
+
fmt.outro(chalk.yellow('Pull cancelled'));
|
|
242
|
+
} else {
|
|
243
|
+
printConflictHint(unresolved);
|
|
244
|
+
}
|
|
245
|
+
throw new fmt.CancelError('Registry pull conflict — unresolved', { exitCode: 2 });
|
|
154
246
|
}
|
|
155
|
-
}
|
|
247
|
+
} else if (mergeInProgress && !rebaseInProgress) {
|
|
156
248
|
try {
|
|
157
|
-
await exec(`git -C "${AW_HOME}"
|
|
158
|
-
log.logStep('
|
|
249
|
+
await exec(`git -C "${AW_HOME}" -c core.editor=true commit --no-edit`);
|
|
250
|
+
log.logStep('Merge continued after conflict resolution.');
|
|
159
251
|
} catch {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
252
|
+
printConflictHint(unresolved);
|
|
253
|
+
throw new fmt.CancelError(
|
|
254
|
+
'Merge continuation failed — resolve manually or run: git -C ~/.aw merge --abort',
|
|
255
|
+
{ exitCode: 2 },
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
const continued = await continuePausedRebase(AW_HOME);
|
|
260
|
+
if (!continued.ok) {
|
|
261
|
+
printConflictHint(continued.conflicts);
|
|
262
|
+
throw new fmt.CancelError(
|
|
263
|
+
'Rebase continuation failed — resolve manually in ~/.aw',
|
|
264
|
+
{ exitCode: 2 },
|
|
265
|
+
);
|
|
163
266
|
}
|
|
267
|
+
log.logStep(
|
|
268
|
+
continued.skipped
|
|
269
|
+
? 'Empty commit skipped during rebase continuation.'
|
|
270
|
+
: 'Rebase continued after conflict resolution.',
|
|
271
|
+
);
|
|
272
|
+
await forcePushPushBranchIfNeeded(AW_HOME, log);
|
|
164
273
|
}
|
|
165
274
|
}
|
|
166
275
|
|
|
@@ -168,7 +277,7 @@ export async function pullCommand(args) {
|
|
|
168
277
|
s.start('Fetching latest from registry...');
|
|
169
278
|
let fetchResult = { updated: false, conflicts: [] };
|
|
170
279
|
try {
|
|
171
|
-
fetchResult = await fetchAndMerge(AW_HOME, { silent });
|
|
280
|
+
fetchResult = await fetchAndMerge(AW_HOME, { silent, onConflict: 'leave' });
|
|
172
281
|
s.stop(fetchResult.updated ? 'Registry updated' : 'Already up to date');
|
|
173
282
|
} catch (e) {
|
|
174
283
|
s.stop(chalk.yellow('Fetch failed'));
|
|
@@ -190,12 +299,54 @@ export async function pullCommand(args) {
|
|
|
190
299
|
}
|
|
191
300
|
|
|
192
301
|
if (fetchResult.conflicts.length > 0) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
302
|
+
const action = await chooseConflictAction({
|
|
303
|
+
discardLocal,
|
|
304
|
+
silent,
|
|
305
|
+
headless,
|
|
306
|
+
conflicts: fetchResult.conflicts,
|
|
307
|
+
log,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const resolved = await resolveRegistryConflicts(AW_HOME, {
|
|
311
|
+
action,
|
|
312
|
+
conflicts: fetchResult.conflicts,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
if (action === 'discard') {
|
|
316
|
+
if (!resolved.ok) {
|
|
317
|
+
printConflictHint(fetchResult.conflicts);
|
|
318
|
+
throw new fmt.CancelError(
|
|
319
|
+
`Could not discard local conflicts: ${resolved.error || 'unknown error'}`,
|
|
320
|
+
{ exitCode: 1 },
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
if (!silent) log.logSuccess('Discarded conflicting local changes and synced to remote');
|
|
324
|
+
await forcePushPushBranchIfNeeded(AW_HOME, log);
|
|
325
|
+
} else if (action === 'leave') {
|
|
326
|
+
if (!silent) {
|
|
327
|
+
log.logWarn(`Merge conflict in: ${fetchResult.conflicts.join(', ')}`);
|
|
328
|
+
log.logWarn('Rebase left in progress — resolve conflict markers, then run `aw pull` again.');
|
|
329
|
+
log.logMessage(chalk.dim('Tip: grep -r "<<<<<<< " ~/.aw/.aw_registry/'));
|
|
330
|
+
fmt.outro(chalk.yellow('Pull paused for manual conflict resolution'));
|
|
331
|
+
} else {
|
|
332
|
+
printConflictHint(fetchResult.conflicts);
|
|
333
|
+
}
|
|
196
334
|
return;
|
|
335
|
+
} else {
|
|
336
|
+
// abort / cancel
|
|
337
|
+
if (!silent) {
|
|
338
|
+
log.logWarn(`Merge conflict in: ${fetchResult.conflicts.join(', ')}`);
|
|
339
|
+
const kind = resolved.kind === 'merge' ? 'merge' : 'rebase';
|
|
340
|
+
log.logWarn(`Pull cancelled — local registry left unchanged (${kind} aborted).`);
|
|
341
|
+
log.logMessage(chalk.dim('Tip: if you have intentional skill edits, run `aw push <path>` then `aw pull`.'));
|
|
342
|
+
log.logMessage(chalk.dim('Or discard with: aw pull --discard-local'));
|
|
343
|
+
printConflictHint(fetchResult.conflicts);
|
|
344
|
+
fmt.outro(chalk.yellow('Pull cancelled'));
|
|
345
|
+
} else {
|
|
346
|
+
printConflictHint(fetchResult.conflicts);
|
|
347
|
+
}
|
|
348
|
+
throw new fmt.CancelError('Registry pull conflict — unresolved', { exitCode: 2 });
|
|
197
349
|
}
|
|
198
|
-
log.logWarn(`Conflicts in: ${fetchResult.conflicts.join(', ')}`);
|
|
199
350
|
}
|
|
200
351
|
|
|
201
352
|
// Rebase project worktree branch onto origin/main — only for legacy git worktrees.
|
package/commands/push.mjs
CHANGED
|
@@ -155,10 +155,12 @@ function normalizeRelPath(value) {
|
|
|
155
155
|
|
|
156
156
|
// Root namespaces publish at the AW docs root (aw_docs/<ns>/...), mirroring the
|
|
157
157
|
// local .aw_docs/<ns>/... subtree 1:1 — NO per-workspace <repo>/<user> segment
|
|
158
|
-
// and full nested folders. Use for shared,
|
|
159
|
-
// reviews (pr-reviews/<owner>/<repo>/pr-<n>/<run>)
|
|
160
|
-
//
|
|
161
|
-
|
|
158
|
+
// and full nested folders. Use for shared, resource-keyed doc trees such as PR
|
|
159
|
+
// reviews (pr-reviews/<owner>/<repo>/pr-<n>/<run>), security audits
|
|
160
|
+
// (security-audits/<repo>/<date>), support ticket investigations
|
|
161
|
+
// (support-tickets/<ticketId>), and infra incident RCAs (incidents/<fingerprint>)
|
|
162
|
+
// so they do not land under the runner's workspace namespace as one mangled flat slug.
|
|
163
|
+
const AW_DOCS_ROOT_NAMESPACES = ['pr-reviews', 'security-audits', 'support-tickets', 'incidents'];
|
|
162
164
|
|
|
163
165
|
// Compound root prefixes (multi-segment). Longest match wins in matchRootPrefix().
|
|
164
166
|
const AW_DOCS_ROOT_PREFIX_RULES = [
|
|
@@ -174,9 +176,13 @@ const AW_DOCS_ROOT_PREFIX_RULES = [
|
|
|
174
176
|
// pr-reviews/<repo>/pr-<number>/<run> → 4 segments
|
|
175
177
|
// security-audits/<repo>/<date> → 3 segments
|
|
176
178
|
// security/audit/<repo>/<run-id> → 4 segments
|
|
179
|
+
// support-tickets/<ticketId> → 2 segments
|
|
180
|
+
// incidents/<fingerprint> → 2 segments
|
|
177
181
|
const AW_DOCS_ROOT_MIN_SEGMENTS = {
|
|
178
182
|
'pr-reviews': 4,
|
|
179
183
|
'security-audits': 3,
|
|
184
|
+
'support-tickets': 2,
|
|
185
|
+
'incidents': 2,
|
|
180
186
|
};
|
|
181
187
|
|
|
182
188
|
function normalizeAwDocsRootInput(stripped) {
|
|
@@ -214,7 +220,8 @@ function awDocsRootScope(relPath) {
|
|
|
214
220
|
if (!rule || segments.length < rule.minSegments) {
|
|
215
221
|
throw new Error(
|
|
216
222
|
'Root-scope docs publish must target pr-reviews/<repo>/pr-<number>/<run>, ' +
|
|
217
|
-
'security-audits/<repo>/<date>,
|
|
223
|
+
'security-audits/<repo>/<date>, security/audit/<repo>/<run-id>, ' +
|
|
224
|
+
'support-tickets/<ticketId>, or incidents/<fingerprint>, ' +
|
|
218
225
|
`got "${relPath}".`
|
|
219
226
|
);
|
|
220
227
|
}
|
|
@@ -237,7 +244,7 @@ function featureScopeFromInput(input) {
|
|
|
237
244
|
|
|
238
245
|
const match = value.match(/^(?:\.aw_docs\/)?features\/([^/]+)$/);
|
|
239
246
|
if (!match) {
|
|
240
|
-
throw new Error('Docs-only publish path must be .aw_docs/features/<feature-slug>, .aw_docs/pr-reviews/<...>, .aw_docs/security-audits/<...> (or legacy .aw_docs/security/audit/<...>), or use --feature <feature-slug>.');
|
|
247
|
+
throw new Error('Docs-only publish path must be .aw_docs/features/<feature-slug>, .aw_docs/pr-reviews/<...>, .aw_docs/security-audits/<...>, .aw_docs/support-tickets/<...>, .aw_docs/incidents/<...> (or legacy .aw_docs/security/audit/<...>), or use --feature <feature-slug>.');
|
|
241
248
|
}
|
|
242
249
|
return awDocsFeatureScope(match[1]);
|
|
243
250
|
}
|
package/git.mjs
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
// git.mjs — Git helpers: sparse checkout (temp) + persistent clone operations.
|
|
2
2
|
|
|
3
|
-
import { execSync, exec as execCb } from 'node:child_process';
|
|
4
|
-
import { mkdtempSync, existsSync, lstatSync, rmSync, readFileSync, symlinkSync, unlinkSync } from 'node:fs';
|
|
5
|
-
import { join, basename, dirname } from 'node:path';
|
|
3
|
+
import { execSync, exec as execCb, execFile as execFileCb } from 'node:child_process';
|
|
4
|
+
import { mkdtempSync, existsSync, lstatSync, readdirSync, realpathSync, rmSync, readFileSync, statSync, symlinkSync, unlinkSync } from 'node:fs';
|
|
5
|
+
import { join, basename, dirname, sep } from 'node:path';
|
|
6
6
|
import { homedir, tmpdir } from 'node:os';
|
|
7
7
|
import { promisify } from 'node:util';
|
|
8
8
|
import { REGISTRY_BASE_BRANCH, REGISTRY_DIR, DOCS_SOURCE_DIR, AW_DOCS_DIR, RULES_SOURCE_DIR } from './constants.mjs';
|
|
9
9
|
|
|
10
10
|
const exec = promisify(execCb);
|
|
11
|
+
const execFileAsync = promisify(execFileCb);
|
|
12
|
+
|
|
13
|
+
/** Run git with argv array (no shell) in awHome. */
|
|
14
|
+
async function gitIn(awHome, args, opts = {}) {
|
|
15
|
+
return execFileAsync('git', ['-C', awHome, ...args], { encoding: 'utf8', ...opts });
|
|
16
|
+
}
|
|
11
17
|
|
|
12
18
|
/**
|
|
13
19
|
* Env vars applied to every git command that touches the network.
|
|
@@ -306,15 +312,17 @@ export function removeFromSparseCheckout(awHome, removePaths) {
|
|
|
306
312
|
* 2. Fetch from origin.
|
|
307
313
|
* 3. Fast-forward if possible (clean case, no local commits).
|
|
308
314
|
* 4. Rebase local commits on top of remote if ff-only fails.
|
|
309
|
-
* 5. On rebase conflict
|
|
310
|
-
*
|
|
315
|
+
* 5. On rebase conflict: default policy is abort when silent (keep repo usable
|
|
316
|
+
* for init/doctor/daemon). Callers that need to discard/leave (aw pull)
|
|
317
|
+
* pass `{ onConflict: 'leave' }` so the rebase stays paused for policy.
|
|
311
318
|
* NEVER uses --no-edit merge — a merge commit on blob:none + sparse-checkout
|
|
312
319
|
* disables sparse checkout, emptying the working tree.
|
|
313
320
|
*
|
|
314
321
|
* @param {string} awHome
|
|
315
|
-
* @param {{ silent?: boolean }} opts
|
|
322
|
+
* @param {{ silent?: boolean, onConflict?: 'abort'|'leave' }} opts
|
|
316
323
|
*/
|
|
317
|
-
export async function fetchAndMerge(awHome, { silent = true } = {}) {
|
|
324
|
+
export async function fetchAndMerge(awHome, { silent = true, onConflict } = {}) {
|
|
325
|
+
const conflictPolicy = onConflict ?? (silent ? 'abort' : 'leave');
|
|
318
326
|
// ── 1. Branch classification ──────────────────────────────────────────────
|
|
319
327
|
// There are three possible states for ~/.aw:
|
|
320
328
|
//
|
|
@@ -417,16 +425,232 @@ export async function fetchAndMerge(awHome, { silent = true } = {}) {
|
|
|
417
425
|
conflicts.push(...stdout.trim().split('\n').filter(Boolean));
|
|
418
426
|
} catch { /* best effort */ }
|
|
419
427
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
428
|
+
// Default silent/init path aborts so ~/.aw stays usable. aw pull passes
|
|
429
|
+
// onConflict:'leave' so it can discard / menu / abort itself.
|
|
430
|
+
if (conflictPolicy === 'abort') {
|
|
431
|
+
try { await gitIn(awHome, ['rebase', '--abort']); } catch { /* best effort */ }
|
|
423
432
|
}
|
|
424
|
-
// Interactive: leave conflict markers for the user to resolve
|
|
425
433
|
}
|
|
426
434
|
|
|
427
435
|
return { updated, conflicts };
|
|
428
436
|
}
|
|
429
437
|
|
|
438
|
+
function rebaseInProgress(awHome) {
|
|
439
|
+
return existsSync(join(awHome, '.git', 'rebase-merge'))
|
|
440
|
+
|| existsSync(join(awHome, '.git', 'rebase-apply'));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function mergeInProgress(awHome) {
|
|
444
|
+
return existsSync(join(awHome, '.git', 'MERGE_HEAD'));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export async function listUnresolvedConflicts(awHome) {
|
|
448
|
+
try {
|
|
449
|
+
const { stdout } = await gitIn(awHome, ['diff', '--name-only', '--diff-filter=U']);
|
|
450
|
+
return stdout.trim().split('\n').filter(Boolean);
|
|
451
|
+
} catch {
|
|
452
|
+
return [];
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function gitErrorText(err) {
|
|
457
|
+
return [err?.stderr, err?.stdout, err?.message, err]
|
|
458
|
+
.filter(Boolean)
|
|
459
|
+
.map(String)
|
|
460
|
+
.join('\n')
|
|
461
|
+
.toLowerCase();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** True when git rebase --continue failed because the current patch is empty. */
|
|
465
|
+
export function isEmptyRebasePatchError(err) {
|
|
466
|
+
const msg = gitErrorText(err);
|
|
467
|
+
return (
|
|
468
|
+
msg.includes('nothing to commit')
|
|
469
|
+
|| msg.includes('no changes - did you forget')
|
|
470
|
+
|| msg.includes('no changes')
|
|
471
|
+
|| msg.includes('now empty')
|
|
472
|
+
|| msg.includes('you might want to skip this patch')
|
|
473
|
+
|| msg.includes('please use \'git rebase --skip\'')
|
|
474
|
+
|| msg.includes('please use "git rebase --skip"')
|
|
475
|
+
|| (msg.includes('empty') && (msg.includes('patch') || msg.includes('cherry-pick')))
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Force-push current branch tip with lease (no shell). Used after conflict discard /
|
|
481
|
+
* rebase continuation on upload|sync|remove branches.
|
|
482
|
+
*/
|
|
483
|
+
export async function pushForceWithLease(awHome, branch) {
|
|
484
|
+
await gitIn(awHome, ['push', '--force-with-lease', 'origin', branch], { env: GIT_NET_ENV });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Continue a paused rebase by one commit-step. After a failed continue, re-list
|
|
489
|
+
* conflicts and only `--skip` when Git indicates an empty patch. Never skip when
|
|
490
|
+
* continue stopped because a later commit conflicted or another non-empty error.
|
|
491
|
+
* Call again on the next `aw pull` after each new conflict is resolved.
|
|
492
|
+
*
|
|
493
|
+
* @returns {Promise<{ ok: boolean, skipped?: boolean, conflicts: string[], error?: string }>}
|
|
494
|
+
*/
|
|
495
|
+
export async function continuePausedRebase(awHome) {
|
|
496
|
+
try {
|
|
497
|
+
await gitIn(awHome, ['-c', 'core.editor=true', 'rebase', '--continue'], {
|
|
498
|
+
env: { ...process.env, GIT_EDITOR: 'true' },
|
|
499
|
+
});
|
|
500
|
+
return { ok: true, skipped: false, conflicts: [] };
|
|
501
|
+
} catch (continueErr) {
|
|
502
|
+
const conflicts = await listUnresolvedConflicts(awHome);
|
|
503
|
+
if (conflicts.length > 0) {
|
|
504
|
+
return {
|
|
505
|
+
ok: false,
|
|
506
|
+
skipped: false,
|
|
507
|
+
conflicts,
|
|
508
|
+
error: continueErr.message || 'Rebase continue stopped on conflicts',
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
if (!isEmptyRebasePatchError(continueErr)) {
|
|
512
|
+
return {
|
|
513
|
+
ok: false,
|
|
514
|
+
skipped: false,
|
|
515
|
+
conflicts: [],
|
|
516
|
+
error: continueErr.message || 'Rebase continue failed',
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
try {
|
|
520
|
+
await gitIn(awHome, ['rebase', '--skip']);
|
|
521
|
+
return { ok: true, skipped: true, conflicts: [] };
|
|
522
|
+
} catch (skipErr) {
|
|
523
|
+
return {
|
|
524
|
+
ok: false,
|
|
525
|
+
skipped: false,
|
|
526
|
+
conflicts: await listUnresolvedConflicts(awHome),
|
|
527
|
+
error: skipErr.message || continueErr.message || 'Rebase continue/skip failed',
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const REBASE_CONTINUE_LOOP_LIMIT = 50;
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Resolve an in-progress registry rebase or merge conflict in ~/.aw.
|
|
537
|
+
*
|
|
538
|
+
* During rebase onto origin/main, `--ours` is upstream (remote).
|
|
539
|
+
* During merge, `--theirs` is the incoming/remote side.
|
|
540
|
+
*
|
|
541
|
+
* @param {string} awHome
|
|
542
|
+
* @param {{ action?: 'discard'|'abort'|'leave', conflicts?: string[] }} opts
|
|
543
|
+
* @returns {Promise<{ ok: boolean, action: string, conflicts: string[], kind?: string, error?: string }>}
|
|
544
|
+
*/
|
|
545
|
+
export async function resolveRegistryConflicts(awHome, { action = 'abort', conflicts = [] } = {}) {
|
|
546
|
+
const initialConflicts = conflicts.length > 0 ? [...conflicts] : await listUnresolvedConflicts(awHome);
|
|
547
|
+
const isMerge = mergeInProgress(awHome);
|
|
548
|
+
const isRebase = rebaseInProgress(awHome);
|
|
549
|
+
const kind = isMerge && !isRebase ? 'merge' : 'rebase';
|
|
550
|
+
|
|
551
|
+
if (action === 'leave') {
|
|
552
|
+
return { ok: true, action: 'leave', conflicts: initialConflicts, kind };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (action === 'abort') {
|
|
556
|
+
try {
|
|
557
|
+
if (kind === 'merge') await gitIn(awHome, ['merge', '--abort']);
|
|
558
|
+
else await gitIn(awHome, ['rebase', '--abort']);
|
|
559
|
+
} catch { /* best effort */ }
|
|
560
|
+
return { ok: true, action: 'abort', conflicts: initialConflicts, kind };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (action !== 'discard') {
|
|
564
|
+
return { ok: false, action, conflicts: initialConflicts, kind, error: `Unknown conflict action: ${action}` };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
let pending = initialConflicts.length > 0 ? initialConflicts : await listUnresolvedConflicts(awHome);
|
|
568
|
+
if (pending.length === 0 && !isRebase && !isMerge) {
|
|
569
|
+
return { ok: true, action: 'discard', conflicts: [], kind };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Merge-in-progress: remote side is --theirs; finish with commit --no-edit.
|
|
573
|
+
if (kind === 'merge') {
|
|
574
|
+
for (const file of pending) {
|
|
575
|
+
try {
|
|
576
|
+
await gitIn(awHome, ['checkout', '--theirs', '--', file]);
|
|
577
|
+
await gitIn(awHome, ['add', '--', file]);
|
|
578
|
+
} catch (e) {
|
|
579
|
+
try { await gitIn(awHome, ['merge', '--abort']); } catch { /* best effort */ }
|
|
580
|
+
return { ok: false, action: 'discard', conflicts: pending, kind, error: e.message };
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
await gitIn(awHome, ['-c', 'core.editor=true', 'commit', '--no-edit']);
|
|
585
|
+
return { ok: true, action: 'discard', conflicts: initialConflicts, kind };
|
|
586
|
+
} catch (e) {
|
|
587
|
+
try { await gitIn(awHome, ['merge', '--abort']); } catch { /* best effort */ }
|
|
588
|
+
return { ok: false, action: 'discard', conflicts: initialConflicts, kind, error: e.message };
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Rebase-in-progress: remote/upstream is --ours.
|
|
593
|
+
for (let guard = 0; guard < REBASE_CONTINUE_LOOP_LIMIT; guard++) {
|
|
594
|
+
pending = pending.length > 0 ? pending : await listUnresolvedConflicts(awHome);
|
|
595
|
+
for (const file of pending) {
|
|
596
|
+
try {
|
|
597
|
+
await gitIn(awHome, ['checkout', '--ours', '--', file]);
|
|
598
|
+
await gitIn(awHome, ['add', '--', file]);
|
|
599
|
+
} catch (e) {
|
|
600
|
+
try { await gitIn(awHome, ['rebase', '--abort']); } catch { /* best effort */ }
|
|
601
|
+
return { ok: false, action: 'discard', conflicts: pending, kind, error: e.message };
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (!rebaseInProgress(awHome)) {
|
|
606
|
+
return { ok: true, action: 'discard', conflicts: initialConflicts, kind };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
try {
|
|
610
|
+
await gitIn(awHome, ['-c', 'core.editor=true', 'rebase', '--continue']);
|
|
611
|
+
if (!rebaseInProgress(awHome)) {
|
|
612
|
+
return { ok: true, action: 'discard', conflicts: initialConflicts, kind };
|
|
613
|
+
}
|
|
614
|
+
pending = await listUnresolvedConflicts(awHome);
|
|
615
|
+
} catch (e) {
|
|
616
|
+
pending = await listUnresolvedConflicts(awHome);
|
|
617
|
+
if (pending.length === 0 && isEmptyRebasePatchError(e)) {
|
|
618
|
+
try {
|
|
619
|
+
await gitIn(awHome, ['rebase', '--skip']);
|
|
620
|
+
if (!rebaseInProgress(awHome)) {
|
|
621
|
+
return { ok: true, action: 'discard', conflicts: initialConflicts, kind };
|
|
622
|
+
}
|
|
623
|
+
pending = await listUnresolvedConflicts(awHome);
|
|
624
|
+
continue;
|
|
625
|
+
} catch (skipErr) {
|
|
626
|
+
try { await gitIn(awHome, ['rebase', '--abort']); } catch { /* best effort */ }
|
|
627
|
+
return {
|
|
628
|
+
ok: false,
|
|
629
|
+
action: 'discard',
|
|
630
|
+
conflicts: initialConflicts,
|
|
631
|
+
kind,
|
|
632
|
+
error: skipErr.message || e.message,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (pending.length === 0) {
|
|
637
|
+
try { await gitIn(awHome, ['rebase', '--abort']); } catch { /* best effort */ }
|
|
638
|
+
return {
|
|
639
|
+
ok: false,
|
|
640
|
+
action: 'discard',
|
|
641
|
+
conflicts: initialConflicts,
|
|
642
|
+
kind,
|
|
643
|
+
error: e.message || 'Rebase continue failed with a non-empty patch',
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
// Later commit conflicted during continue — keep looping with new pending.
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
try { await gitIn(awHome, ['rebase', '--abort']); } catch { /* best effort */ }
|
|
651
|
+
return { ok: false, action: 'discard', conflicts: initialConflicts, kind, error: 'Discard exceeded rebase continue limit' };
|
|
652
|
+
}
|
|
653
|
+
|
|
430
654
|
/**
|
|
431
655
|
* Detect local changes in registryDir/ within awHome.
|
|
432
656
|
* Returns { modified: [], added: [], deleted: [], untracked: [] }
|
|
@@ -764,6 +988,119 @@ export function isWorktree(dir) {
|
|
|
764
988
|
}
|
|
765
989
|
}
|
|
766
990
|
|
|
991
|
+
/** Error message for reporting, without leaking a stack into CLI output. */
|
|
992
|
+
function errText(error) {
|
|
993
|
+
return error instanceof Error ? error.message : String(error);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/** Resolve to a real path, falling back to the input when it is not on disk. */
|
|
997
|
+
function realPathOrSelf(path) {
|
|
998
|
+
try {
|
|
999
|
+
return realpathSync(path);
|
|
1000
|
+
} catch (err) {
|
|
1001
|
+
void err; // not on disk (yet) — compare the literal path
|
|
1002
|
+
return path;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** True iff `candidate` is `root` itself or lives anywhere beneath it. */
|
|
1007
|
+
function isWithin(root, candidate) {
|
|
1008
|
+
return candidate === root || candidate.startsWith(root + sep);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* True iff `path` is awHome itself or lives anywhere inside it.
|
|
1013
|
+
*
|
|
1014
|
+
* Compares realpaths, which also covers a path that reaches inside awHome
|
|
1015
|
+
* through a symlink. A lexical comparison is deliberately not added on top:
|
|
1016
|
+
* callers pass `process.cwd()`, which the OS already returns resolved, so a
|
|
1017
|
+
* lexical form that differs from the resolved one never reaches this function.
|
|
1018
|
+
* The remaining cycle shape — an alias *under* awHome pointing at an already
|
|
1019
|
+
* linked project — cannot be seen from these two arguments at all and is
|
|
1020
|
+
* handled by pruneAwSelfLinks instead.
|
|
1021
|
+
*/
|
|
1022
|
+
function isInsideAwHome(awHome, path) {
|
|
1023
|
+
return isWithin(realPathOrSelf(awHome), realPathOrSelf(path));
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Remove symlinks under `awHome` that make its tree cyclic.
|
|
1028
|
+
*
|
|
1029
|
+
* Two shapes qualify:
|
|
1030
|
+
*
|
|
1031
|
+
* 1. A direct self-link — e.g. ~/.aw/.aw_registry/…/skills/.aw -> ~/.aw.
|
|
1032
|
+
* 2. An alias to a linked project — ~/.aw/external -> /path/to/project,
|
|
1033
|
+
* where /path/to/project/.aw -> ~/.aw. Walking ~/.aw/external/.aw then
|
|
1034
|
+
* re-enters ~/.aw. Here the *alias* is removed, never the project's own
|
|
1035
|
+
* .aw link, which is how every linked repo is legitimately wired.
|
|
1036
|
+
*
|
|
1037
|
+
* Either shape makes recursive walkers that follow symlinks and keep no
|
|
1038
|
+
* visited set — vite/chokidar, tsc, eslint — re-descend the same segments
|
|
1039
|
+
* until the path exceeds PATH_MAX and the walk dies with ENAMETOOLONG.
|
|
1040
|
+
*
|
|
1041
|
+
* Only descends real directories, never symlinks, so the prune cannot be
|
|
1042
|
+
* caught by the cycle it is removing.
|
|
1043
|
+
*
|
|
1044
|
+
* Returns `{ removed, failed }`. A non-empty `failed` means a cycle may still
|
|
1045
|
+
* be in place, so callers must surface it rather than assume the heal worked.
|
|
1046
|
+
*/
|
|
1047
|
+
export function pruneAwSelfLinks(awHome) {
|
|
1048
|
+
const root = realPathOrSelf(awHome);
|
|
1049
|
+
const removed = [];
|
|
1050
|
+
const failed = [];
|
|
1051
|
+
if (!existsSync(root)) return { removed, failed };
|
|
1052
|
+
|
|
1053
|
+
/** Remove one cycle-forming link, recording success or the reason it failed. */
|
|
1054
|
+
const drop = (linkPath, shape) => {
|
|
1055
|
+
try {
|
|
1056
|
+
unlinkSync(linkPath);
|
|
1057
|
+
removed.push(linkPath);
|
|
1058
|
+
} catch (error) {
|
|
1059
|
+
failed.push({ path: linkPath, shape, reason: errText(error) });
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
|
|
1063
|
+
const walk = (dir) => {
|
|
1064
|
+
let entries;
|
|
1065
|
+
try {
|
|
1066
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
// Cannot inspect this subtree — a cycle may be hiding in it, so report
|
|
1069
|
+
// rather than swallow.
|
|
1070
|
+
failed.push({ path: dir, shape: 'unreadable-directory', reason: errText(error) });
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
for (const entry of entries) {
|
|
1074
|
+
if (entry.name === '.git') continue; // never touch the registry's own git dir
|
|
1075
|
+
const full = join(dir, entry.name);
|
|
1076
|
+
if (entry.isSymbolicLink()) {
|
|
1077
|
+
let target;
|
|
1078
|
+
try {
|
|
1079
|
+
target = realpathSync(full);
|
|
1080
|
+
} catch (error) {
|
|
1081
|
+
void error; // dangling link — cannot form a cycle, leave it alone
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
if (target === root) {
|
|
1085
|
+
drop(full, 'self-link');
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
// Alias shape: does the target directory link back to awHome?
|
|
1089
|
+
try {
|
|
1090
|
+
if (!statSync(target).isDirectory()) continue;
|
|
1091
|
+
if (realpathSync(join(target, '.aw')) === root) drop(full, 'alias-cycle');
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
void error; // no .aw inside the target, or target vanished — not a cycle
|
|
1094
|
+
}
|
|
1095
|
+
} else if (entry.isDirectory()) {
|
|
1096
|
+
walk(full);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
};
|
|
1100
|
+
walk(root);
|
|
1101
|
+
return { removed, failed };
|
|
1102
|
+
}
|
|
1103
|
+
|
|
767
1104
|
/**
|
|
768
1105
|
* Create project/.aw/ as a symlink to awHome (~/.aw).
|
|
769
1106
|
*
|
|
@@ -774,6 +1111,16 @@ export function isWorktree(dir) {
|
|
|
774
1111
|
* Safe to call multiple times — skips if already set up correctly.
|
|
775
1112
|
*/
|
|
776
1113
|
export function addProjectWorktree(awHome, projectDir) {
|
|
1114
|
+
// Refuse to link awHome into itself or into anything inside it. Such a link
|
|
1115
|
+
// is a cycle (see pruneAwSelfLinks) that breaks every recursive walker with
|
|
1116
|
+
// ENAMETOOLONG. Compare realpaths, not strings: on macOS homedir() can report
|
|
1117
|
+
// /var/... while process.cwd() reports /private/var/... for the same dir.
|
|
1118
|
+
if (isInsideAwHome(awHome, projectDir)) {
|
|
1119
|
+
throw new Error(
|
|
1120
|
+
`Refusing to link ${projectDir}: it is inside ${awHome}. Run this from a project directory.`
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
777
1124
|
const worktreeDir = join(projectDir, '.aw');
|
|
778
1125
|
|
|
779
1126
|
// Clean up stale project-root .aw_registry symlink from old installs
|