@ghl-ai/aw 0.1.81 → 0.1.83

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.
Files changed (2) hide show
  1. package/commands/push.mjs +99 -15
  2. package/package.json +1 -1
package/commands/push.mjs CHANGED
@@ -431,6 +431,10 @@ async function removeTrackedAwSymlink(cloneDir) {
431
431
  return false;
432
432
  }
433
433
 
434
+ // `.aw` is always kept inside the sparse-checkout cone (see AW_DOCS_CACHE_PATH
435
+ // in ensureAwDocsRepoClone) so this remove works even on blobless/sparse
436
+ // clones. Without that, git rejects the rm with "outside sparse-checkout
437
+ // definition" against repos that track a real .aw/ tree (e.g. ghl-aw-docs).
434
438
  await execFile('git', ['rm', '-f', '.aw'], {
435
439
  cwd: cloneDir,
436
440
  encoding: 'utf8',
@@ -572,32 +576,97 @@ async function getProjectSourceRepo(projectRoot) {
572
576
  return `local/${basename(projectRoot)}`;
573
577
  }
574
578
 
575
- async function ensureAwDocsRepoClone(home, publishConfig) {
576
- const cloneDir = process.env.AW_DOCS_WORKTREE || join(home, '.aw-ghl-aw-docs');
577
-
578
- if (!existsSync(join(cloneDir, '.git'))) {
579
- await execFile('git', ['clone', publishConfig.repoUrl, cloneDir], {
579
+ // Network git ops against the docs repo always disable interactive credential
580
+ // prompts (so a missing/incorrect credential fails fast instead of hanging
581
+ // forever) and are time-bounded (so a stalled connection aborts instead of
582
+ // blocking the whole publish). Override the caps with AW_DOCS_GIT_TIMEOUT_MS.
583
+ const AW_DOCS_GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' };
584
+ const AW_DOCS_CLONE_TIMEOUT_MS = Number(process.env.AW_DOCS_GIT_TIMEOUT_MS) || 180000;
585
+ const AW_DOCS_NET_TIMEOUT_MS = Number(process.env.AW_DOCS_GIT_TIMEOUT_MS) || 120000;
586
+ // `.aw` is always kept in the sparse-checkout cone. The docs repo may track a
587
+ // real `.aw/` tree (e.g. ghl-aw-docs tracks `.aw/.aw_rules/**`), and the
588
+ // cache-management code (removeTrackedAwSymlink, selfHeal*) runs write git ops
589
+ // on `.aw`. Excluding it from the cone makes those ops fail with "outside
590
+ // sparse-checkout definition" and breaks every publish against such a repo.
591
+ const AW_DOCS_CACHE_PATH = '.aw';
592
+
593
+ async function awDocsSparseEnabled(cloneDir) {
594
+ try {
595
+ const { stdout } = await execFile('git', ['config', '--get', 'core.sparseCheckout'], {
596
+ cwd: cloneDir,
580
597
  encoding: 'utf8',
581
598
  });
599
+ return stdout.trim() === 'true';
600
+ } catch {
601
+ return false;
582
602
  }
603
+ }
583
604
 
584
- let status = await getGitStatus(cloneDir);
585
- if (status.trim() && await selfHealManagedAwCacheDirtiness(cloneDir, status)) {
586
- status = await getGitStatus(cloneDir);
605
+ async function ensureAwDocsRepoClone(home, publishConfig, sparseIncludePaths = []) {
606
+ const cloneDir = process.env.AW_DOCS_WORKTREE || join(home, '.aw-ghl-aw-docs');
607
+ const wantSparse = sparseIncludePaths.length > 0;
608
+ const freshClone = !existsSync(join(cloneDir, '.git'));
609
+
610
+ if (freshClone) {
611
+ // A full clone of ghl-aw-docs pulls the entire org's published docs
612
+ // (hundreds of MB / ~170k objects) and is the dominant cause of
613
+ // first-publish timeouts. Clone blobless (no file contents up front) and,
614
+ // when we know which subtree this publish touches, defer checkout and
615
+ // sparse-checkout only that path — turning hundreds of MB into a few MB.
616
+ // Degrades gracefully: if the remote does not support partial clone git
617
+ // ignores the filter, and if sparse-checkout is unavailable the branch
618
+ // checkout below still materialises the full tree.
619
+ const cloneArgs = ['clone', '--filter=blob:none'];
620
+ if (wantSparse) cloneArgs.push('--no-checkout');
621
+ cloneArgs.push(publishConfig.repoUrl, cloneDir);
622
+ await execFile('git', cloneArgs, {
623
+ encoding: 'utf8',
624
+ env: AW_DOCS_GIT_ENV,
625
+ timeout: AW_DOCS_CLONE_TIMEOUT_MS,
626
+ });
627
+ if (wantSparse) {
628
+ await execFile('git', ['sparse-checkout', 'set', '--cone', ...sparseIncludePaths, AW_DOCS_CACHE_PATH], {
629
+ cwd: cloneDir,
630
+ encoding: 'utf8',
631
+ }).catch(() => { /* sparse unsupported → full checkout below */ });
632
+ }
587
633
  }
588
- if (status.trim()) {
589
- throw new Error([
590
- `AW docs repo worktree is dirty: ${cloneDir}`,
591
- status.trim(),
592
- 'Clean the cached docs repo or set AW_DOCS_WORKTREE to a clean clone before publishing.',
593
- ].join('\n'));
634
+
635
+ // Only validate cleanliness for a REUSED clone. A fresh blobless
636
+ // --no-checkout clone intentionally has no working tree yet (it is
637
+ // materialised by the branch checkout below), which would otherwise
638
+ // register as "dirty".
639
+ if (!freshClone) {
640
+ let status = await getGitStatus(cloneDir);
641
+ if (status.trim() && await selfHealManagedAwCacheDirtiness(cloneDir, status)) {
642
+ status = await getGitStatus(cloneDir);
643
+ }
644
+ if (status.trim()) {
645
+ throw new Error([
646
+ `AW docs repo worktree is dirty: ${cloneDir}`,
647
+ status.trim(),
648
+ 'Clean the cached docs repo or set AW_DOCS_WORKTREE to a clean clone before publishing.',
649
+ ].join('\n'));
650
+ }
594
651
  }
595
652
 
596
653
  await execFile('git', ['fetch', 'origin'], {
597
654
  cwd: cloneDir,
598
655
  encoding: 'utf8',
656
+ env: AW_DOCS_GIT_ENV,
657
+ timeout: AW_DOCS_NET_TIMEOUT_MS,
599
658
  });
600
659
 
660
+ // Keep the current publish's subtree inside the sparse cone. Only applies to
661
+ // clones that already use sparse-checkout (i.e. the blobless clones we
662
+ // created) — legacy full clones are left untouched so there is no regression.
663
+ if (wantSparse && await awDocsSparseEnabled(cloneDir)) {
664
+ await execFile('git', ['sparse-checkout', 'add', ...sparseIncludePaths, AW_DOCS_CACHE_PATH], {
665
+ cwd: cloneDir,
666
+ encoding: 'utf8',
667
+ }).catch(() => { /* best effort — checkout below still works */ });
668
+ }
669
+
601
670
  const hasPublishBranch = await remoteBranchExists(cloneDir, publishConfig.branch);
602
671
  const checkoutStart = hasPublishBranch
603
672
  ? `origin/${publishConfig.branch}`
@@ -608,9 +677,13 @@ async function ensureAwDocsRepoClone(home, publishConfig) {
608
677
  }
609
678
 
610
679
  try {
680
+ // With a blobless clone, checkout lazily fetches the missing blobs for the
681
+ // sparse cone — a network op, so bound it like the other remote calls.
611
682
  await execFile('git', ['checkout', '-B', publishConfig.branch, checkoutStart], {
612
683
  cwd: cloneDir,
613
684
  encoding: 'utf8',
685
+ env: AW_DOCS_GIT_ENV,
686
+ timeout: AW_DOCS_NET_TIMEOUT_MS,
614
687
  });
615
688
  } catch (e) {
616
689
  const healedPath = isCheckoutOverwrittenByAwPathError(e)
@@ -622,6 +695,8 @@ async function ensureAwDocsRepoClone(home, publishConfig) {
622
695
  await execFile('git', ['checkout', '-B', publishConfig.branch, checkoutStart], {
623
696
  cwd: cloneDir,
624
697
  encoding: 'utf8',
698
+ env: AW_DOCS_GIT_ENV,
699
+ timeout: AW_DOCS_NET_TIMEOUT_MS,
625
700
  });
626
701
  }
627
702
 
@@ -629,6 +704,8 @@ async function ensureAwDocsRepoClone(home, publishConfig) {
629
704
  await execFile('git', ['pull', '--ff-only', 'origin', publishConfig.branch], {
630
705
  cwd: cloneDir,
631
706
  encoding: 'utf8',
707
+ env: AW_DOCS_GIT_ENV,
708
+ timeout: AW_DOCS_NET_TIMEOUT_MS,
632
709
  });
633
710
  }
634
711
  await removeTrackedAwSymlink(cloneDir);
@@ -729,6 +806,8 @@ async function commitAndPushAwDocsRepo(docsRepoDir, { message, branch }) {
729
806
  await execFile('git', ['push', '-u', 'origin', `HEAD:${branch}`], {
730
807
  cwd: docsRepoDir,
731
808
  encoding: 'utf8',
809
+ env: AW_DOCS_GIT_ENV,
810
+ timeout: AW_DOCS_NET_TIMEOUT_MS,
732
811
  });
733
812
  }
734
813
 
@@ -823,7 +902,12 @@ async function publishProjectAwDocs(cwd, home, dryRun, scope = null) {
823
902
  const s = fmt.spinner();
824
903
  s.start(`Publishing ${files.length} AW doc${files.length > 1 ? 's' : ''} to ${publishConfig.repo}...`);
825
904
  try {
826
- const docsRepoDir = await ensureAwDocsRepoClone(home, publishConfig);
905
+ // Scope the (blobless) docs clone to only the subtree this publish writes,
906
+ // so a fresh clone fetches a few MB instead of the whole org's docs.
907
+ const sparseIncludePaths = isRootScope
908
+ ? [`${publishConfig.dest}/${scope.relPrefix}`]
909
+ : [`${publishConfig.dest}/${repoSlug}/${githubUsername}`];
910
+ const docsRepoDir = await ensureAwDocsRepoClone(home, publishConfig, sparseIncludePaths);
827
911
  const deleteTarget = isRootScope
828
912
  ? join(docsRepoDir, publishConfig.dest, scope.relPrefix)
829
913
  : scope?.relPrefix
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.81",
3
+ "version": "0.1.83",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {