@ghl-ai/aw 0.1.35 → 0.1.36-beta.2

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.
@@ -1,130 +1,100 @@
1
- // commands/status.mjs — Show workspace status
1
+ // commands/status.mjs — Show workspace status using native git
2
2
 
3
3
  import { join } from 'node:path';
4
- import { existsSync, readFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
5
  import * as config from '../config.mjs';
6
6
  import * as fmt from '../fmt.mjs';
7
7
  import { chalk } from '../fmt.mjs';
8
- import { load as loadManifest } from '../manifest.mjs';
9
- import { hashFile } from '../registry.mjs';
8
+ import { detectChanges, getCurrentBranch, isValidClone } from '../git.mjs';
9
+ import { REGISTRY_DIR, REGISTRY_REPO } from '../constants.mjs';
10
10
 
11
11
  export function statusCommand(args) {
12
- const cwd = process.cwd();
13
- const workspaceDir = join(cwd, '.aw_registry');
12
+ const HOME = homedir();
13
+ const AW_HOME = join(HOME, '.aw');
14
+ const GLOBAL_AW_DIR = join(HOME, '.aw_registry');
14
15
 
15
- const cfg = config.load(workspaceDir);
16
- if (!cfg) {
17
- fmt.cancel('No .sync-config.json found. Run: aw --init');
16
+ fmt.intro('aw status');
17
+
18
+ const repoUrl = `https://github.com/${REGISTRY_REPO}.git`;
19
+ if (!isValidClone(AW_HOME, repoUrl)) {
20
+ fmt.cancel('Registry not initialized. Run: aw init');
21
+ return;
18
22
  }
19
23
 
20
- const manifest = loadManifest(workspaceDir);
24
+ const cfg = config.load(GLOBAL_AW_DIR);
25
+ if (!cfg) {
26
+ fmt.cancel('No .sync-config.json found. Run: aw init');
27
+ return;
28
+ }
21
29
 
22
- fmt.intro(`aw status`);
30
+ const branch = getCurrentBranch(AW_HOME);
31
+ const isOnMain = branch === 'main';
23
32
 
24
33
  // Info note
25
34
  const infoLines = [
26
- `${chalk.dim('namespace:')} ${cfg.namespace || 'none'}`,
35
+ `${chalk.dim('source:')} ~/.aw/ (git clone)`,
36
+ `${chalk.dim('branch:')} ${isOnMain ? chalk.dim(branch) : chalk.yellow(branch)}`,
37
+ cfg.namespace ? `${chalk.dim('namespace:')} ${cfg.namespace}` : null,
27
38
  cfg.user ? `${chalk.dim('user:')} ${cfg.user}` : null,
28
- manifest.synced_at ? `${chalk.dim('last sync:')} ${manifest.synced_at}` : null,
29
39
  ].filter(Boolean).join('\n');
30
40
  fmt.note(infoLines, 'Workspace');
31
41
 
32
42
  // Show synced paths
33
- if (cfg.include.length > 0) {
43
+ if (cfg.include && cfg.include.length > 0) {
34
44
  const pathLines = cfg.include.map(p => ` ${chalk.cyan(p)}`).join('\n');
35
45
  fmt.note(pathLines, 'Synced Paths');
36
46
  } else {
37
- fmt.logInfo('No paths synced. Run: aw pull <path>');
47
+ fmt.logInfo('No extra paths synced (platform/ always included).');
38
48
  }
39
49
 
40
- const files = Object.entries(manifest.files || {});
41
- const modified = [];
42
- const unpushed = [];
43
- const missing = [];
44
- const conflicts = [];
45
-
46
- for (const [key, entry] of files) {
47
- const filePath = join(workspaceDir, key);
48
- if (!existsSync(filePath)) {
49
- missing.push(key);
50
- continue;
51
- }
52
-
53
- const currentHash = hashFile(filePath);
54
-
55
- // Template-derived files that were never pushed to the remote registry
56
- if (!entry.registry_sha256) {
57
- unpushed.push(key);
58
- continue;
59
- }
60
-
61
- if (currentHash !== entry.sha256) {
62
- const content = readFileSync(filePath, 'utf8');
63
- if (content.includes('<<<<<<<')) {
64
- conflicts.push(key);
65
- continue;
66
- }
67
- modified.push(key);
68
- }
69
- }
50
+ // Git-native change detection
51
+ const changes = detectChanges(AW_HOME, REGISTRY_DIR);
52
+
53
+ const { modified, added, deleted, untracked } = changes;
54
+ const allNew = [...added, ...untracked];
70
55
 
71
56
  // Summary line
72
57
  const summaryParts = [
73
- `${files.length} synced`,
74
- unpushed.length > 0 ? chalk.green(`${unpushed.length} new (unpushed)`) : null,
75
58
  modified.length > 0 ? chalk.yellow(`${modified.length} modified`) : null,
76
- conflicts.length > 0 ? chalk.red(`${conflicts.length} conflicts`) : null,
77
- missing.length > 0 ? chalk.dim(`${missing.length} missing`) : null,
78
- ].filter(Boolean).join(chalk.dim(' · '));
59
+ allNew.length > 0 ? chalk.green(`${allNew.length} new (unpushed)`) : null,
60
+ deleted.length > 0 ? chalk.dim(`${deleted.length} deleted`) : null,
61
+ ].filter(Boolean);
79
62
 
80
- fmt.logInfo(summaryParts);
63
+ if (summaryParts.length === 0) {
64
+ fmt.logSuccess('Workspace is clean');
65
+ } else {
66
+ fmt.logInfo(summaryParts.join(chalk.dim(' · ')));
67
+ }
81
68
 
82
- if (conflicts.length > 0) {
69
+ if (allNew.length > 0) {
83
70
  fmt.note(
84
- conflicts.map(c => chalk.red(c)).join('\n'),
85
- chalk.red('Unresolved Conflicts')
71
+ allNew.map(f => chalk.green(` ${f.registryPath}`)).join('\n'),
72
+ chalk.green('New (unpushed)')
86
73
  );
87
74
  }
88
75
 
89
- if (unpushed.length > 0) {
90
- // Group by namespace for cleaner display
91
- const nsCounts = {};
92
- for (const key of unpushed) {
93
- const ns = key.split('/').slice(0, 2).join('/');
94
- nsCounts[ns] = (nsCounts[ns] || 0) + 1;
95
- }
96
- const nsLines = Object.entries(nsCounts).map(([ns, count]) => chalk.green(` ${ns}: ${count} files`)).join('\n');
97
- fmt.note(nsLines, chalk.green('New (unpushed)'));
98
- }
99
-
100
76
  if (modified.length > 0) {
101
77
  fmt.note(
102
- modified.map(m => chalk.yellow(m)).join('\n'),
78
+ modified.map(f => chalk.yellow(` ${f.registryPath}`)).join('\n'),
103
79
  chalk.yellow('Modified')
104
80
  );
105
81
  }
106
82
 
107
- if (missing.length > 0) {
83
+ if (deleted.length > 0) {
108
84
  fmt.note(
109
- missing.map(m => chalk.dim(m)).join('\n'),
110
- chalk.dim('Missing (deleted locally)')
85
+ deleted.map(f => chalk.dim(` ${f.registryPath}`)).join('\n'),
86
+ chalk.dim('Deleted')
111
87
  );
112
88
  }
113
89
 
114
- if (modified.length === 0 && conflicts.length === 0 && missing.length === 0 && unpushed.length === 0) {
115
- fmt.logSuccess('Workspace is clean');
90
+ if (!isOnMain) {
91
+ fmt.logWarn(`On branch ${chalk.yellow(branch)} — run ${chalk.dim('aw pull')} to sync or checkout main first`);
116
92
  }
117
93
 
118
94
  // Hints
119
- if (conflicts.length > 0) {
120
- fmt.logWarn(`Fix conflicts: ${chalk.dim('grep -r "<<<<<<< " .aw_registry/')}`);
121
- }
122
- if (unpushed.length > 0) {
123
- fmt.logInfo(`Push new files: ${chalk.dim('aw push')} or ${chalk.dim('aw push <namespace>')}`);
124
- }
125
- if (modified.length > 0) {
126
- fmt.logInfo(`Push changes: ${chalk.dim('aw push <path>')}`);
95
+ if (allNew.length > 0 || modified.length > 0 || deleted.length > 0) {
96
+ fmt.logInfo(`Push changes: ${chalk.dim('aw push')} or ${chalk.dim('aw push <path>')}`);
127
97
  }
128
98
 
129
- fmt.outro(`${chalk.dim('aw pull <pattern>')} to pull latest`);
99
+ fmt.outro(`${chalk.dim('aw pull')} to fetch latest`);
130
100
  }
package/config.mjs CHANGED
@@ -48,7 +48,7 @@ export function create(workspaceDir, { namespace, user }) {
48
48
 
49
49
  export function addPattern(workspaceDir, pattern) {
50
50
  const config = load(workspaceDir);
51
- if (!config) throw new Error('No .sync-config.json found. Run: aw --init --namespace <name>');
51
+ if (!config) throw new Error('No .sync-config.json found. Run: aw init');
52
52
  // If a parent path already covers this, skip
53
53
  if (config.include.some(p => pattern === p || pattern.startsWith(p + '/'))) {
54
54
  return config;
@@ -62,7 +62,7 @@ export function addPattern(workspaceDir, pattern) {
62
62
 
63
63
  export function removePattern(workspaceDir, pattern) {
64
64
  const config = load(workspaceDir);
65
- if (!config) throw new Error('No .sync-config.json found. Run: aw --init --namespace <name>');
65
+ if (!config) throw new Error('No .sync-config.json found. Run: aw init');
66
66
  // Remove exact match + all children
67
67
  config.include = config.include.filter(p => p !== pattern && !p.startsWith(pattern + '/'));
68
68
  save(workspaceDir, config);
package/constants.mjs CHANGED
@@ -1,5 +1,8 @@
1
1
  // constants.mjs — Single source of truth for registry settings.
2
2
 
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
3
6
  /** Base branch for PRs and sync checkout */
4
7
  export const REGISTRY_BASE_BRANCH = 'main';
5
8
 
@@ -11,3 +14,6 @@ export const REGISTRY_DIR = '.aw_registry';
11
14
 
12
15
  /** Directory in platform-docs repo containing documentation (pulled into platform/docs/) */
13
16
  export const DOCS_SOURCE_DIR = 'content';
17
+
18
+ /** Persistent git clone root — ~/.aw/ */
19
+ export const AW_HOME = join(homedir(), '.aw');
package/ecc.mjs ADDED
@@ -0,0 +1,180 @@
1
+ import { execSync } from "node:child_process";
2
+ import {
3
+ existsSync, readFileSync, readdirSync,
4
+ mkdirSync, rmSync, writeFileSync,
5
+ } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { homedir } from "node:os";
8
+ import * as fmt from "./fmt.mjs";
9
+
10
+ const AW_ECC_REPO_SSH = "git@github.com:shreyansh-ghl/aw-ecc.git";
11
+ const AW_ECC_REPO_HTTPS = "https://github.com/shreyansh-ghl/aw-ecc.git";
12
+ const AW_ECC_TAG = "v1.2.2";
13
+
14
+ const MARKETPLACE_NAME = "aw-marketplace";
15
+ const PLUGIN_KEY = `aw@${MARKETPLACE_NAME}`;
16
+
17
+ function eccDir() { return join(homedir(), ".aw-ecc"); }
18
+
19
+ const FILE_COPY_TARGETS = ["cursor", "codex"];
20
+
21
+ const TARGET_STATE = {
22
+ cursor: { state: ".cursor/ecc-install-state.json" },
23
+ codex: { state: ".codex/ecc-install-state.json" },
24
+ };
25
+
26
+ function run(cmd, opts = {}) {
27
+ return execSync(cmd, { stdio: "pipe", ...opts });
28
+ }
29
+
30
+ function cloneOrUpdate(tag, dest) {
31
+ if (existsSync(join(dest, ".git"))) {
32
+ try {
33
+ run(`git -C ${dest} fetch --quiet --depth 1 origin tag ${tag}`);
34
+ run(`git -C ${dest} checkout --quiet ${tag}`);
35
+ return;
36
+ } catch { /* fall through to fresh clone */ }
37
+ }
38
+ if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
39
+ try {
40
+ run(`git clone --quiet --depth 1 --branch ${tag} ${AW_ECC_REPO_SSH} ${dest}`);
41
+ } catch {
42
+ run(`git clone --quiet --depth 1 --branch ${tag} ${AW_ECC_REPO_HTTPS} ${dest}`);
43
+ }
44
+ }
45
+
46
+ function installClaudePlugin(repoDir) {
47
+ try {
48
+ run(`claude plugin marketplace add ${repoDir} --scope user`);
49
+ } catch {
50
+ try { run(`claude plugin marketplace update ${MARKETPLACE_NAME}`); } catch { /* ok */ }
51
+ }
52
+ run(`claude plugin install ${PLUGIN_KEY} --scope user`);
53
+ }
54
+
55
+ function uninstallClaudePlugin() {
56
+ try { run(`claude plugin uninstall ${PLUGIN_KEY} --scope user`); } catch { /* not installed */ }
57
+ try { run(`claude plugin marketplace remove ${MARKETPLACE_NAME}`); } catch { /* not registered */ }
58
+ }
59
+
60
+ export async function installAwEcc(
61
+ cwd,
62
+ { targets = ["cursor", "claude", "codex"], silent = false } = {},
63
+ ) {
64
+ if (!silent) fmt.logStep("Installing aw-ecc engine...");
65
+
66
+ const repoDir = eccDir();
67
+
68
+ try {
69
+ cloneOrUpdate(AW_ECC_TAG, repoDir);
70
+
71
+ // Claude Code: plugin install via marketplace CLI (proper agent dispatch)
72
+ if (targets.includes("claude")) {
73
+ try {
74
+ installClaudePlugin(repoDir);
75
+ } catch (err) {
76
+ if (!silent) fmt.logWarn(`Claude plugin install skipped: ${err.message}`);
77
+ }
78
+ }
79
+
80
+ // Cursor + Codex: file-copy via install-apply.js
81
+ const fileCopyTargets = targets.filter((t) => FILE_COPY_TARGETS.includes(t));
82
+ if (fileCopyTargets.length > 0) {
83
+ run("npm install --no-audit --no-fund --ignore-scripts --loglevel=error", {
84
+ cwd: repoDir,
85
+ });
86
+ for (const target of fileCopyTargets) {
87
+ try {
88
+ run(
89
+ `node ${join(repoDir, "scripts/install-apply.js")} --target ${target} --profile full`,
90
+ { cwd },
91
+ );
92
+ } catch { /* target not supported — skip */ }
93
+ }
94
+ }
95
+
96
+ if (!silent) fmt.logSuccess("aw-ecc engine installed");
97
+ } catch (err) {
98
+ if (!silent) fmt.logWarn(`aw-ecc install failed: ${err.message}`);
99
+ }
100
+ }
101
+
102
+ export function uninstallAwEcc({ silent = false } = {}) {
103
+ const HOME = homedir();
104
+ let removed = 0;
105
+
106
+ // Claude Code: uninstall plugin + remove marketplace via CLI
107
+ try {
108
+ uninstallClaudePlugin();
109
+ removed++;
110
+ } catch { /* best effort */ }
111
+
112
+ // Cursor + Codex: remove file-copied content via install-state
113
+ for (const cfg of Object.values(TARGET_STATE)) {
114
+ const statePath = join(HOME, cfg.state);
115
+ if (!existsSync(statePath)) continue;
116
+
117
+ try {
118
+ const data = JSON.parse(readFileSync(statePath, "utf8"));
119
+ for (const op of data.operations || []) {
120
+ if (op.destinationPath && existsSync(op.destinationPath)) {
121
+ rmSync(op.destinationPath, { recursive: true, force: true });
122
+ removed++;
123
+ pruneEmptyParents(op.destinationPath, join(HOME, cfg.state.split("/")[0]));
124
+ }
125
+ }
126
+ rmSync(statePath, { force: true });
127
+ pruneEmptyParents(statePath, join(HOME, cfg.state.split("/")[0]));
128
+ } catch { /* corrupted state — skip */ }
129
+ }
130
+
131
+ // Clean leftover claude install-state from older file-copy versions
132
+ const claudeState = join(HOME, ".claude", "ecc", "install-state.json");
133
+ if (existsSync(claudeState)) {
134
+ try {
135
+ const data = JSON.parse(readFileSync(claudeState, "utf8"));
136
+ for (const op of data.operations || []) {
137
+ if (op.destinationPath && existsSync(op.destinationPath)) {
138
+ rmSync(op.destinationPath, { recursive: true, force: true });
139
+ removed++;
140
+ }
141
+ }
142
+ rmSync(claudeState, { force: true });
143
+ pruneEmptyParents(claudeState, join(HOME, ".claude"));
144
+ } catch { /* best effort */ }
145
+ }
146
+
147
+ // Clean leftover manual plugin cache from older versions
148
+ const oldCache = join(HOME, ".claude", "plugins", "cache", "aw");
149
+ if (existsSync(oldCache)) {
150
+ rmSync(oldCache, { recursive: true, force: true });
151
+ removed++;
152
+ }
153
+
154
+ // Remove permanent aw-ecc repo clone
155
+ const repoDir = eccDir();
156
+ if (existsSync(repoDir)) {
157
+ rmSync(repoDir, { recursive: true, force: true });
158
+ removed++;
159
+ }
160
+
161
+ if (!silent && removed > 0)
162
+ fmt.logStep(`Removed ${removed} aw-ecc file${removed > 1 ? "s" : ""}`);
163
+ return removed;
164
+ }
165
+
166
+ function pruneEmptyParents(filePath, stopAt) {
167
+ let dir = dirname(filePath);
168
+ while (dir !== stopAt && dir.startsWith(stopAt)) {
169
+ try {
170
+ if (readdirSync(dir).length === 0) {
171
+ rmSync(dir);
172
+ dir = dirname(dir);
173
+ } else {
174
+ break;
175
+ }
176
+ } catch {
177
+ break;
178
+ }
179
+ }
180
+ }
package/fmt.mjs CHANGED
@@ -36,6 +36,8 @@ export function banner(text, opts = {}) {
36
36
  export const intro = (msg) => p.intro(chalk.bgCyan.black(` ${msg} `));
37
37
  export const outro = (msg) => p.outro(chalk.green(msg));
38
38
  export const spinner = () => p.spinner();
39
+ export const select = p.select;
40
+ export const isCancel = p.isCancel;
39
41
 
40
42
  export function cancel(msg) {
41
43
  p.cancel(msg);
package/git.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // git.mjs — Git sparse checkout helpers. Zero dependencies.
1
+ // git.mjs — Git helpers: sparse checkout (temp) + persistent clone operations.
2
2
 
3
3
  import { execSync, exec as execCb } from 'node:child_process';
4
4
  import { mkdtempSync, existsSync } from 'node:fs';
@@ -9,6 +9,8 @@ import { REGISTRY_BASE_BRANCH, REGISTRY_DIR, DOCS_SOURCE_DIR } from './constants
9
9
 
10
10
  const exec = promisify(execCb);
11
11
 
12
+ // ── Backward-compat: temp-dir sparse checkout (used by search.mjs) ────────────
13
+
12
14
  /**
13
15
  * Sparse-checkout registry paths from GitHub (sync).
14
16
  * Returns the temp directory path containing the checkout.
@@ -92,3 +94,233 @@ export function includeToSparsePaths(paths) {
92
94
  }
93
95
  return [...result];
94
96
  }
97
+
98
+ // ── Persistent clone operations ────────────────────────────────────────────────
99
+
100
+ /**
101
+ * Check if awHome is already a valid git clone of the given registry repo.
102
+ * Returns boolean.
103
+ */
104
+ export function isValidClone(awHome, repoUrl) {
105
+ if (!existsSync(join(awHome, '.git'))) return false;
106
+ try {
107
+ const remote = execSync('git remote get-url origin', { cwd: awHome, stdio: 'pipe', encoding: 'utf8' }).trim();
108
+ return remote === repoUrl || remote === repoUrl.replace(/\.git$/, '') + '.git' || remote.replace(/\.git$/, '') === repoUrl.replace(/\.git$/, '');
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Initialize a persistent clone with sparse checkout at awHome.
116
+ * sparsePaths: array of repo-relative paths to include (e.g. ['.aw_registry/platform', 'content'])
117
+ */
118
+ export function initPersistentClone(repoUrl, awHome, sparsePaths) {
119
+ try {
120
+ execSync(`git clone --filter=blob:none --no-checkout "${repoUrl}" "${awHome}"`, { stdio: 'pipe' });
121
+ } catch (e) {
122
+ throw new Error(`Failed to clone ${repoUrl}: ${e.message}`);
123
+ }
124
+
125
+ try {
126
+ execSync('git sparse-checkout init --cone', { cwd: awHome, stdio: 'pipe' });
127
+ execSync(`git sparse-checkout set ${sparsePaths.map(p => `"${p}"`).join(' ')}`, { cwd: awHome, stdio: 'pipe' });
128
+ execSync(`git checkout ${REGISTRY_BASE_BRANCH}`, { cwd: awHome, stdio: 'pipe' });
129
+ } catch (e) {
130
+ throw new Error(`Failed to configure sparse checkout: ${e.message}`);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Add paths to an existing sparse checkout.
136
+ * Merges with currently checked-out paths.
137
+ */
138
+ export function addToSparseCheckout(awHome, newPaths) {
139
+ let existing = [];
140
+ try {
141
+ const out = execSync('git sparse-checkout list', { cwd: awHome, stdio: 'pipe', encoding: 'utf8' });
142
+ existing = out.trim().split('\n').filter(Boolean);
143
+ } catch {
144
+ // no sparse-checkout set yet — start fresh
145
+ }
146
+
147
+ const combined = [...new Set([...existing, ...newPaths])];
148
+ try {
149
+ execSync(`git sparse-checkout set ${combined.map(p => `"${p}"`).join(' ')}`, { cwd: awHome, stdio: 'pipe' });
150
+ execSync(`git checkout ${REGISTRY_BASE_BRANCH}`, { cwd: awHome, stdio: 'pipe' });
151
+ } catch (e) {
152
+ throw new Error(`Failed to add to sparse checkout: ${e.message}`);
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Remove paths from an existing sparse checkout.
158
+ * Remaining paths stay checked out.
159
+ */
160
+ export function removeFromSparseCheckout(awHome, removePaths) {
161
+ let existing = [];
162
+ try {
163
+ const out = execSync('git sparse-checkout list', { cwd: awHome, stdio: 'pipe', encoding: 'utf8' });
164
+ existing = out.trim().split('\n').filter(Boolean);
165
+ } catch {
166
+ return; // nothing to remove
167
+ }
168
+
169
+ const removeSet = new Set(removePaths);
170
+ const remaining = existing.filter(p => !removeSet.has(p));
171
+
172
+ try {
173
+ if (remaining.length > 0) {
174
+ execSync(`git sparse-checkout set ${remaining.map(p => `"${p}"`).join(' ')}`, { cwd: awHome, stdio: 'pipe' });
175
+ } else {
176
+ // Disable sparse checkout entirely
177
+ execSync('git sparse-checkout disable', { cwd: awHome, stdio: 'pipe' });
178
+ }
179
+ } catch (e) {
180
+ throw new Error(`Failed to remove from sparse checkout: ${e.message}`);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Fetch and merge latest from origin/main into the persistent clone.
186
+ * Returns { updated: boolean, conflicts: string[] }
187
+ */
188
+ export function fetchAndMerge(awHome) {
189
+ try {
190
+ execSync(`git -C "${awHome}" fetch origin ${REGISTRY_BASE_BRANCH}`, { stdio: 'pipe' });
191
+ } catch (e) {
192
+ throw new Error(`Failed to fetch from origin: ${e.message}`);
193
+ }
194
+
195
+ let updated = false;
196
+ const conflicts = [];
197
+
198
+ try {
199
+ const result = execSync(`git -C "${awHome}" merge origin/${REGISTRY_BASE_BRANCH} --ff-only`, {
200
+ stdio: 'pipe',
201
+ encoding: 'utf8',
202
+ });
203
+ updated = !result.includes('Already up to date');
204
+ } catch {
205
+ // ff-only failed — try no-edit merge
206
+ try {
207
+ execSync(`git -C "${awHome}" merge origin/${REGISTRY_BASE_BRANCH} --no-edit`, {
208
+ stdio: 'pipe',
209
+ encoding: 'utf8',
210
+ });
211
+ updated = true;
212
+ } catch (mergeErr) {
213
+ // Parse conflicts
214
+ try {
215
+ const statusOut = execSync(`git -C "${awHome}" diff --name-only --diff-filter=U`, {
216
+ stdio: 'pipe',
217
+ encoding: 'utf8',
218
+ });
219
+ conflicts.push(...statusOut.trim().split('\n').filter(Boolean));
220
+ } catch { /* best effort */ }
221
+ }
222
+ }
223
+
224
+ return { updated, conflicts };
225
+ }
226
+
227
+ /**
228
+ * Detect local changes in registryDir/ within awHome.
229
+ * Returns { modified: [], added: [], deleted: [], untracked: [] }
230
+ * Each entry: { path: 'relative/to/awHome', registryPath: 'relative/to/registryDir' }
231
+ */
232
+ export function detectChanges(awHome, registryDir) {
233
+ let statusOut = '';
234
+ try {
235
+ statusOut = execSync(`git -C "${awHome}" status --porcelain "${registryDir}/"`, {
236
+ stdio: 'pipe',
237
+ encoding: 'utf8',
238
+ });
239
+ } catch {
240
+ return { modified: [], added: [], deleted: [], untracked: [] };
241
+ }
242
+
243
+ const modified = [];
244
+ const added = [];
245
+ const deleted = [];
246
+ const untracked = [];
247
+
248
+ for (const line of statusOut.split('\n')) {
249
+ if (!line || line.length < 3) continue;
250
+ const xy = line.slice(0, 2);
251
+ const filePath = line.slice(3).trim();
252
+ const registryPath = filePath.startsWith(registryDir + '/') ? filePath.slice(registryDir.length + 1) : filePath;
253
+ const entry = { path: filePath, registryPath };
254
+
255
+ if (xy === '??') {
256
+ untracked.push(entry);
257
+ } else if (xy[0] === 'D' || xy[1] === 'D') {
258
+ deleted.push(entry);
259
+ } else if (xy[0] === 'A' || xy[1] === 'A') {
260
+ added.push(entry);
261
+ } else if (xy[0] === 'M' || xy[1] === 'M') {
262
+ modified.push(entry);
263
+ }
264
+ }
265
+
266
+ return { modified, added, deleted, untracked };
267
+ }
268
+
269
+ /**
270
+ * Create a branch, stage files, commit, push to origin.
271
+ * Branch stays on disk for iteration.
272
+ * Returns the branch name.
273
+ */
274
+ export function createPushBranch(awHome, branchName, files, commitMsg) {
275
+ try {
276
+ execSync(`git -C "${awHome}" checkout -b "${branchName}"`, { stdio: 'pipe' });
277
+ } catch (e) {
278
+ throw new Error(`Failed to create branch ${branchName}: ${e.message}`);
279
+ }
280
+
281
+ try {
282
+ const quotedFiles = files.map(f => `"${f}"`).join(' ');
283
+ execSync(`git -C "${awHome}" add ${quotedFiles}`, { stdio: 'pipe' });
284
+ } catch (e) {
285
+ throw new Error(`Failed to stage files: ${e.message}`);
286
+ }
287
+
288
+ try {
289
+ execSync(`git -C "${awHome}" commit -m "${commitMsg.replace(/"/g, '\\"')}"`, { stdio: 'pipe' });
290
+ } catch (e) {
291
+ throw new Error(`Failed to commit: ${e.message}`);
292
+ }
293
+
294
+ try {
295
+ execSync(`git -C "${awHome}" push -u origin "${branchName}"`, { stdio: 'pipe' });
296
+ } catch (e) {
297
+ throw new Error(`Failed to push branch: ${e.message}`);
298
+ }
299
+
300
+ return branchName;
301
+ }
302
+
303
+ /**
304
+ * Get the current branch name in awHome.
305
+ */
306
+ export function getCurrentBranch(awHome) {
307
+ try {
308
+ return execSync(`git -C "${awHome}" rev-parse --abbrev-ref HEAD`, {
309
+ stdio: 'pipe',
310
+ encoding: 'utf8',
311
+ }).trim();
312
+ } catch {
313
+ return null;
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Checkout the main branch in awHome.
319
+ */
320
+ export function checkoutMain(awHome) {
321
+ try {
322
+ execSync(`git -C "${awHome}" checkout ${REGISTRY_BASE_BRANCH}`, { stdio: 'pipe' });
323
+ } catch (e) {
324
+ throw new Error(`Failed to checkout ${REGISTRY_BASE_BRANCH}: ${e.message}`);
325
+ }
326
+ }