@ghl-ai/aw 0.1.36-beta.71 → 0.1.36-beta.73

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/commands/pull.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // commands/pull.mjs — Pull content from registry using persistent git clone
2
2
 
3
- import { mkdirSync, existsSync, readdirSync, copyFileSync, unlinkSync, rmdirSync } from 'node:fs';
3
+ import { mkdirSync, existsSync, readdirSync, copyFileSync, unlinkSync, rmdirSync, lstatSync } from 'node:fs';
4
4
  import { join, relative, extname } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { exec as execCb } from 'node:child_process';
@@ -74,6 +74,16 @@ export async function pullCommand(args) {
74
74
  }
75
75
  }
76
76
 
77
+ // Guard: if a rebase or merge is already in progress on awHome, don't proceed
78
+ const awGitDir = join(AW_HOME, '.git');
79
+ if (existsSync(join(awGitDir, 'rebase-merge')) ||
80
+ existsSync(join(awGitDir, 'rebase-apply')) ||
81
+ existsSync(join(awGitDir, 'MERGE_HEAD'))) {
82
+ log.logWarn('Rebase paused — resolve conflicts in your IDE, then run `aw pull` again.');
83
+ if (!silent) fmt.outro(chalk.yellow('Pull skipped'));
84
+ return;
85
+ }
86
+
77
87
  // Fetch + merge latest
78
88
  const s = log.spinner();
79
89
  s.start('Fetching latest from registry...');
@@ -102,12 +112,26 @@ export async function pullCommand(args) {
102
112
  }
103
113
 
104
114
  if (fetchResult.conflicts.length > 0) {
115
+ if (!silent) {
116
+ // Interactive mode: abort the merge so the working tree is clean again
117
+ try { await exec(`git -C "${AW_HOME}" merge --abort`); } catch { /* best effort */ }
118
+ log.logWarn(`Merge conflict in: ${fetchResult.conflicts.join(', ')}`);
119
+ log.logWarn('Merge aborted — your branch is unchanged. Resolve conflicts and run `aw pull` again.');
120
+ return;
121
+ }
122
+ // Silent mode: leave conflict markers in place for IDE to show
105
123
  log.logWarn(`Conflicts in: ${fetchResult.conflicts.join(', ')}`);
106
124
  }
107
125
 
108
- // Always rebase project worktree's current branch onto origin/main
126
+ // Rebase project worktree branch onto origin/main — only for legacy git worktrees.
127
+ // In the symlink model, <project>/.aw IS ~/.aw (same repo), so fetchAndMerge already
128
+ // brought it up to date. Nothing to rebase.
109
129
  const localAw = cwd !== HOME ? findNearestWorktree(cwd, HOME) : null;
130
+ let isSymlinkWorktree = false;
110
131
  if (localAw) {
132
+ try { isSymlinkWorktree = lstatSync(localAw).isSymbolicLink(); } catch {}
133
+ }
134
+ if (localAw && !isSymlinkWorktree) {
111
135
  const rebaseSpinner = log.spinner();
112
136
  rebaseSpinner.start('Rebasing local branch onto latest...');
113
137
  try {
@@ -129,9 +153,7 @@ export async function pullCommand(args) {
129
153
  } catch { /* best effort */ }
130
154
 
131
155
  if (!silent) {
132
- // Interactive mode: abort and show clear terminal warning
133
156
  try { await exec(`git -C "${localAw}" rebase --abort`); } catch { /* best effort */ }
134
-
135
157
  log.logWarn('Rebase aborted — your branch is unchanged.');
136
158
  if (conflictedFiles.length > 0) {
137
159
  log.logWarn(`Conflicting file${conflictedFiles.length > 1 ? 's' : ''}:`);
package/commands/push.mjs CHANGED
@@ -427,15 +427,14 @@ export async function pushCommand(args) {
427
427
  const HOME = homedir();
428
428
  const globalAw = join(HOME, '.aw');
429
429
 
430
- // If the project has a local worktree (.aw/ with a .git file), use it so
431
- // changes made inside the project are correctly detected and committed.
432
- const localAw = findNearestWorktree(cwd, HOME);
433
- const hasWorktree = localAw !== null;
434
- const awHome = hasWorktree ? localAw : globalAw;
430
+ // Always push from the global clone (~/.aw), same as aw pull.
431
+ // All agent/skill edits via IDE symlinks land in ~/.aw/.aw_registry/ directly —
432
+ // project worktrees are just local views for IDE visibility, not the source of truth.
433
+ const awHome = globalAw;
435
434
  const registrySubDir = join(awHome, REGISTRY_DIR);
436
435
  const workspaceDir = getLocalRegistryDir(cwd, join(HOME, '.aw_registry'));
437
436
 
438
- const worktreeFlow = hasWorktree;
437
+ const worktreeFlow = false;
439
438
 
440
439
  fmt.intro('aw push');
441
440
 
@@ -5,7 +5,7 @@ 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 { detectChanges, getCurrentBranch, commitsAheadOfMain, isValidClone, findNearestWorktree } from '../git.mjs';
8
+ import { detectChanges, diffFromMain, getCurrentBranch, commitsAheadOfMain, isValidClone, findNearestWorktree } from '../git.mjs';
9
9
  import { REGISTRY_DIR, REGISTRY_REPO, REGISTRY_URL } from '../constants.mjs';
10
10
 
11
11
  export function statusCommand(args) {
@@ -51,54 +51,64 @@ export function statusCommand(args) {
51
51
  fmt.logInfo('No extra paths synced (platform/ always included).');
52
52
  }
53
53
 
54
- // Git-native change detection
55
- const changes = detectChanges(AW_HOME, REGISTRY_DIR);
54
+ // ── Drift from main (3-dot diff: captures committed+pushed changes too) ────
55
+ const drift = diffFromMain(AW_HOME, REGISTRY_DIR);
56
+ const driftTotal = drift.added.length + drift.modified.length + drift.deleted.length;
56
57
 
57
- const { modified, added, deleted, untracked } = changes;
58
- const allNew = [...added, ...untracked];
59
-
60
- // Summary line
61
- const summaryParts = [
62
- modified.length > 0 ? chalk.yellow(`${modified.length} modified`) : null,
63
- allNew.length > 0 ? chalk.green(`${allNew.length} new (unpushed)`) : null,
64
- deleted.length > 0 ? chalk.dim(`${deleted.length} deleted`) : null,
58
+ const driftParts = [
59
+ drift.added.length > 0 ? chalk.green(`${drift.added.length} added`) : null,
60
+ drift.modified.length > 0 ? chalk.yellow(`${drift.modified.length} modified`) : null,
61
+ drift.deleted.length > 0 ? chalk.dim(`${drift.deleted.length} deleted`) : null,
65
62
  ].filter(Boolean);
66
63
 
67
- if (summaryParts.length === 0) {
68
- fmt.logSuccess('Workspace is clean');
64
+ if (driftTotal === 0) {
65
+ fmt.logSuccess('No drift from main — workspace is in sync');
69
66
  } else {
70
- fmt.logInfo(summaryParts.join(chalk.dim(' · ')));
67
+ fmt.logInfo(`Drift from main: ${driftParts.join(chalk.dim(' · '))}`);
71
68
  }
72
69
 
73
- if (allNew.length > 0) {
70
+ if (drift.added.length > 0) {
74
71
  fmt.note(
75
- allNew.map(f => chalk.green(` ${f.registryPath}`)).join('\n'),
76
- chalk.green('New (unpushed)')
72
+ drift.added.map(f => chalk.green(` + ${f.registryPath}`)).join('\n'),
73
+ chalk.green('Added (vs main)')
77
74
  );
78
75
  }
79
76
 
80
- if (modified.length > 0) {
77
+ if (drift.modified.length > 0) {
81
78
  fmt.note(
82
- modified.map(f => chalk.yellow(` ${f.registryPath}`)).join('\n'),
83
- chalk.yellow('Modified')
79
+ drift.modified.map(f => chalk.yellow(` ~ ${f.registryPath}`)).join('\n'),
80
+ chalk.yellow('Modified (vs main)')
84
81
  );
85
82
  }
86
83
 
87
- if (deleted.length > 0) {
84
+ if (drift.deleted.length > 0) {
88
85
  fmt.note(
89
- deleted.map(f => chalk.dim(` ${f.registryPath}`)).join('\n'),
90
- chalk.dim('Deleted')
86
+ drift.deleted.map(f => chalk.dim(` - ${f.registryPath}`)).join('\n'),
87
+ chalk.dim('Deleted (vs main)')
91
88
  );
92
89
  }
93
90
 
91
+ // ── Uncommitted local changes (on top of drift) ───────────────────────────
92
+ const local = detectChanges(AW_HOME, REGISTRY_DIR);
93
+ const localDirty = [...local.modified, ...local.added, ...local.deleted, ...local.untracked];
94
+
95
+ if (localDirty.length > 0) {
96
+ const localParts = [
97
+ (local.modified.length > 0) ? chalk.yellow(`${local.modified.length} modified`) : null,
98
+ (local.added.length + local.untracked.length > 0) ? chalk.green(`${local.added.length + local.untracked.length} new`) : null,
99
+ (local.deleted.length > 0) ? chalk.dim(`${local.deleted.length} deleted`) : null,
100
+ ].filter(Boolean);
101
+ fmt.logWarn(`Uncommitted local changes: ${localParts.join(chalk.dim(' · '))} — run ${chalk.dim('aw push')} to commit & open PR`);
102
+ }
103
+
94
104
  if (!isOnMain) {
95
105
  const ahead = commitsAheadOfMain(AW_HOME);
96
- const aheadStr = ahead > 0 ? ` (${chalk.yellow(`${ahead} commit${ahead !== 1 ? 's' : ''} ahead`)})` : '';
106
+ const aheadStr = ahead > 0 ? ` (${chalk.yellow(`${ahead} commit${ahead !== 1 ? 's' : ''} ahead of main`)})` : '';
97
107
  fmt.logWarn(`On branch ${chalk.yellow(branch)}${aheadStr} — run ${chalk.dim('aw push')} to open a PR or ${chalk.dim('aw pull')} to sync`);
98
108
  }
99
109
 
100
110
  // Hints
101
- if (allNew.length > 0 || modified.length > 0 || deleted.length > 0) {
111
+ if (driftTotal > 0 || localDirty.length > 0) {
102
112
  fmt.logInfo(`Push changes: ${chalk.dim('aw push')} or ${chalk.dim('aw push <path>')}`);
103
113
  }
104
114
 
package/git.mjs CHANGED
@@ -1,7 +1,7 @@
1
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
- import { mkdtempSync, existsSync, lstatSync, rmSync, readFileSync } from 'node:fs';
4
+ import { mkdtempSync, existsSync, lstatSync, rmSync, readFileSync, symlinkSync, unlinkSync } from 'node:fs';
5
5
  import { join, basename, dirname } from 'node:path';
6
6
  import { homedir, tmpdir } from 'node:os';
7
7
  import { promisify } from 'node:util';
@@ -172,6 +172,8 @@ export function addToSparseCheckout(awHome, newPaths) {
172
172
  * Called after `addToSparseCheckout` so newly-pulled content appears in project/.aw/.
173
173
  */
174
174
  export function syncWorktreeSparseCheckout(awHome, worktreeDir) {
175
+ // Symlink model: worktreeDir IS awHome — sparse checkout is already shared, nothing to sync
176
+ try { if (lstatSync(worktreeDir).isSymbolicLink()) return; } catch { return; }
175
177
  try {
176
178
  const out = execSync('git sparse-checkout list', { cwd: awHome, stdio: 'pipe', encoding: 'utf8' });
177
179
  const paths = out.trim().split('\n').filter(Boolean);
@@ -323,7 +325,7 @@ export function commitToCurrentBranch(awHome, files, commitMsg, preStaged = fals
323
325
  if (!preStaged) {
324
326
  try {
325
327
  const quotedFiles = files.map(f => `"${f}"`).join(' ');
326
- execSync(`git -C "${awHome}" add --sparse ${quotedFiles}`, { stdio: 'pipe' });
328
+ execSync(`git -C "${awHome}" add ${quotedFiles}`, { stdio: 'pipe' });
327
329
  } catch (e) {
328
330
  throw new Error(`Failed to stage files: ${e.message}`);
329
331
  }
@@ -379,7 +381,7 @@ export async function createPushBranch(awHome, branchName, files, commitMsg, pre
379
381
  if (!preStaged) {
380
382
  try {
381
383
  const quotedFiles = files.map(f => `"${f}"`).join(' ');
382
- await exec(`git -C "${awHome}" add --sparse ${quotedFiles}`);
384
+ await exec(`git -C "${awHome}" add ${quotedFiles}`);
383
385
  } catch (e) {
384
386
  throw new Error(`Failed to stage files: ${e.message}`);
385
387
  }
@@ -416,6 +418,42 @@ export function commitsAheadOfMain(awHome) {
416
418
  }
417
419
  }
418
420
 
421
+ /**
422
+ * Get all files that differ from origin/main (or local main as fallback).
423
+ * Uses 3-dot diff (origin/main...HEAD) so it captures the full branch delta —
424
+ * including commits already pushed — not just uncommitted working-tree changes.
425
+ *
426
+ * Returns { added: [], modified: [], deleted: [] }
427
+ * Each entry: { path: 'relative/to/awHome', registryPath: 'relative/to/registryDir' }
428
+ */
429
+ export function diffFromMain(awHome, registryDir) {
430
+ for (const base of [`origin/${REGISTRY_BASE_BRANCH}`, REGISTRY_BASE_BRANCH]) {
431
+ try {
432
+ const out = execSync(
433
+ `git -C "${awHome}" diff ${base}...HEAD --name-status -- "${registryDir}/"`,
434
+ { stdio: 'pipe', encoding: 'utf8' },
435
+ );
436
+ const added = [], modified = [], deleted = [];
437
+ for (const line of out.trim().split('\n')) {
438
+ if (!line) continue;
439
+ const [status, filePath] = line.split('\t');
440
+ if (!filePath) continue;
441
+ const registryPath = filePath.startsWith(registryDir + '/')
442
+ ? filePath.slice(registryDir.length + 1)
443
+ : filePath;
444
+ const entry = { path: filePath, registryPath };
445
+ if (status === 'A') added.push(entry);
446
+ else if (status === 'D') deleted.push(entry);
447
+ else modified.push(entry); // M, R*, C*, T
448
+ }
449
+ return { added, modified, deleted };
450
+ } catch {
451
+ continue; // try next base ref
452
+ }
453
+ }
454
+ return { added: [], modified: [], deleted: [] };
455
+ }
456
+
419
457
  /**
420
458
  * Get one-line log of commits ahead of main.
421
459
  * Returns array of { hash, message } objects.
@@ -510,9 +548,17 @@ export function findNearestWorktree(startDir, stopDir) {
510
548
  }
511
549
 
512
550
  /**
513
- * Check if dir is a git linked worktree (has .git as a FILE, not a directory).
551
+ * Check if dir is a valid project .aw/ link either:
552
+ * (a) a symlink to awHome (new model — one shared repo for all workspaces), or
553
+ * (b) a legacy git linked worktree (.git is a FILE pointing to a valid gitdir).
514
554
  */
515
555
  export function isWorktree(dir) {
556
+ // (a) symlink model: <project>/.aw → ~/.aw
557
+ try {
558
+ if (lstatSync(dir).isSymbolicLink() && existsSync(dir)) return true;
559
+ } catch { /* doesn't exist */ }
560
+
561
+ // (b) legacy git worktree model
516
562
  const gitPath = join(dir, '.git');
517
563
  try {
518
564
  if (!lstatSync(gitPath).isFile()) return false;
@@ -527,11 +573,13 @@ export function isWorktree(dir) {
527
573
  }
528
574
 
529
575
  /**
530
- * Create project/.aw/ as a linked git worktree of awHome.
531
- * Sets up sparse checkout mirroring the main clone, then creates
532
- * project/.aw_registry → project/.aw/.aw_registry/ (relative symlink).
576
+ * Create project/.aw/ as a symlink to awHome (~/.aw).
533
577
  *
534
- * Safe to call multiple times skips if already set up.
578
+ * All IDE workspaces share the exact same git repo via this symlink — same
579
+ * branch, same content, always in sync. aw pull/push both operate on awHome
580
+ * directly, so there are no per-project diverging branches.
581
+ *
582
+ * Safe to call multiple times — skips if already set up correctly.
535
583
  */
536
584
  export function addProjectWorktree(awHome, projectDir) {
537
585
  const worktreeDir = join(projectDir, '.aw');
@@ -542,89 +590,47 @@ export function addProjectWorktree(awHome, projectDir) {
542
590
  if (lstatSync(staleSymlink).isSymbolicLink()) rmSync(staleSymlink);
543
591
  } catch { /* doesn't exist, fine */ }
544
592
 
545
- // Already set up and healthy
546
- if (isWorktree(worktreeDir)) return worktreeDir;
547
-
548
- // Stale .aw/ exists (broken git link — e.g. after aw nuke + re-init) — remove so git can recreate it
549
- if (existsSync(worktreeDir)) {
550
- try { execSync(`rm -rf "${worktreeDir}"`, { stdio: 'pipe' }); } catch { /* best effort */ }
551
- }
552
-
553
- // Prune stale worktree metadata from ~/.aw/.git/worktrees/ before re-adding.
554
- // Without this, `git worktree add` fails because the old metadata entry may still
555
- // reference the deleted path, causing a "already exists" or similar error.
556
- try { execSync(`git -C "${awHome}" worktree prune`, { stdio: 'pipe' }); } catch { /* best effort */ }
557
-
558
- const slug = basename(projectDir)
559
- .toLowerCase()
560
- .replace(/[^a-z0-9]/g, '-')
561
- .replace(/-+/g, '-')
562
- .replace(/^-|-$/g, '')
563
- .slice(0, 40) || 'project';
564
- const branchName = `worktree/${slug}`;
565
-
566
- // Try to create the worktree with a new branch
567
- let created = false;
593
+ // Already a valid symlink pointing to awHome → done
568
594
  try {
569
- execSync(`git -C "${awHome}" worktree add "${worktreeDir}" --no-checkout -b "${branchName}"`, { stdio: 'pipe' });
570
- created = true;
571
- } catch {
572
- // Branch already exists from a previous setup — reuse it
573
- try {
574
- execSync(`git -C "${awHome}" worktree add "${worktreeDir}" --no-checkout "${branchName}"`, { stdio: 'pipe' });
575
- created = true;
576
- } catch {
577
- // Last resort: unique branch name
578
- const unique = `${branchName}-${Date.now().toString(36).slice(-4)}`;
579
- execSync(`git -C "${awHome}" worktree add "${worktreeDir}" --no-checkout -b "${unique}"`, { stdio: 'pipe' });
580
- created = true;
581
- }
582
- }
595
+ if (lstatSync(worktreeDir).isSymbolicLink() && existsSync(worktreeDir)) return worktreeDir;
596
+ } catch { /* doesn't exist yet */ }
583
597
 
584
- if (!created) throw new Error(`Failed to create worktree at ${worktreeDir}`);
585
-
586
- // Set up sparse checkout in the worktree, mirroring the main clone's paths
598
+ // Remove old git worktree or stale directory/symlink so we can create a clean symlink
587
599
  try {
588
- execSync(`git -C "${worktreeDir}" sparse-checkout init --no-cone`, { stdio: 'pipe' });
589
-
590
- let sparsePaths = [];
591
- try {
592
- const out = execSync('git sparse-checkout list', { cwd: awHome, stdio: 'pipe', encoding: 'utf8' });
593
- sparsePaths = out.trim().split('\n').filter(Boolean);
594
- } catch { /* fallback below */ }
595
-
596
- if (sparsePaths.length === 0) {
597
- sparsePaths = [`${REGISTRY_DIR}/platform`, 'content', 'CODEOWNERS'];
600
+ const stat = lstatSync(worktreeDir);
601
+ if (stat.isSymbolicLink()) {
602
+ unlinkSync(worktreeDir); // dangling symlink — replace
603
+ } else if (stat.isDirectory()) {
604
+ // Legacy git worktree detach from git before removing
605
+ try { execSync(`git -C "${awHome}" worktree remove "${worktreeDir}" --force`, { stdio: 'pipe' }); } catch {}
606
+ try { execSync(`git -C "${awHome}" worktree prune`, { stdio: 'pipe' }); } catch {}
607
+ rmSync(worktreeDir, { recursive: true, force: true });
598
608
  }
609
+ } catch { /* nothing there — fine */ }
599
610
 
600
- execSync(`git -C "${worktreeDir}" sparse-checkout set ${sparsePaths.map(p => `"${p}"`).join(' ')}`, { stdio: 'pipe' });
601
- execSync(`git -C "${worktreeDir}" checkout`, { stdio: 'pipe' });
602
- } catch (e) {
603
- throw new Error(`Failed to configure worktree sparse checkout: ${e.message}`);
604
- }
605
-
611
+ // Create symlink: <project>/.aw ~/.aw
612
+ symlinkSync(awHome, worktreeDir);
606
613
  return worktreeDir;
607
614
  }
608
615
 
609
616
  /**
610
- * Remove a project worktree created by addProjectWorktree.
611
- * Removes the worktree directory and the .aw_registry symlink.
617
+ * Remove a project .aw/ link created by addProjectWorktree.
618
+ * For the new symlink model: just removes the symlink (never the target ~/.aw).
619
+ * For legacy git worktrees: removes via git worktree remove.
612
620
  */
613
621
  export function removeProjectWorktree(awHome, projectDir) {
614
622
  const worktreeDir = join(projectDir, '.aw');
615
-
616
- // Remove via git first
617
623
  try {
618
- execSync(`git -C "${awHome}" worktree remove "${worktreeDir}" --force`, { stdio: 'pipe' });
619
- } catch {
620
- // Manual cleanup if git command fails
621
- try {
622
- if (existsSync(worktreeDir)) {
623
- rmSync(worktreeDir, { recursive: true, force: true });
624
- }
625
- } catch { /* best effort */ }
626
- try { execSync(`git -C "${awHome}" worktree prune`, { stdio: 'pipe' }); } catch { /* best effort */ }
627
- }
624
+ const stat = lstatSync(worktreeDir);
625
+ if (stat.isSymbolicLink()) {
626
+ unlinkSync(worktreeDir); // remove symlink only never delete ~/.aw
627
+ } else if (stat.isDirectory()) {
628
+ // Legacy git worktree
629
+ try { execSync(`git -C "${awHome}" worktree remove "${worktreeDir}" --force`, { stdio: 'pipe' }); } catch {}
630
+ try { rmSync(worktreeDir, { recursive: true, force: true }); } catch {}
631
+ try { execSync(`git -C "${awHome}" worktree prune`, { stdio: 'pipe' }); } catch {}
632
+ }
633
+ } catch { /* doesn't exist — nothing to do */ }
628
634
  }
629
635
 
630
636
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.36-beta.71",
3
+ "version": "0.1.36-beta.73",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": "bin.js",