akm-cli 0.9.0-beta.40 → 0.9.0-beta.41

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/CHANGELOG.md CHANGED
@@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Fixed
10
+
11
+ - **Auto-sync no longer refuses to commit akm's own changes when unrelated
12
+ non-akm files are present in the stash working tree.** When a stash root is
13
+ shared with a project repo, stray files written into the stash root (e.g. a
14
+ `tasks.bak-…` backup dir or report artifacts like `data.js`,
15
+ `akm-health-report.html`, `reports/`) previously tripped the #476 safety
16
+ guard, which threw `refusing to push: … has uncommitted non-akm changes` on
17
+ **every** `akm improve` end-of-run auto-sync, `akm sync`, and `akm push`. In
18
+ one production incident this silently blocked all commits for ~1.5 days while
19
+ akm kept accepting proposals it never persisted. `saveGitStash` now **scopes
20
+ what it stages** instead of refusing: (1) an explicit modified-file list when
21
+ the caller passes `opts.paths`, else (2) the akm-managed pathspecs
22
+ (`TYPE_DIRS` values + `.akm`) that exist on disk — which by construction never
23
+ stages non-akm WIP, preserving the #476 protection without an all-or-nothing
24
+ refusal — and only as a last resort (3) `git add -A` when no managed pathspec
25
+ can be resolved. If nothing akm-managed is staged the run returns
26
+ `nothing to commit` (no empty commit, no throw). Unrelated non-akm files are
27
+ left untouched and uncommitted.
28
+
9
29
  ### Changed
10
30
 
11
31
  - **BEHAVIOR CHANGE — `akm init --dir <path>` no longer silently repoints your
@@ -479,28 +479,32 @@ export function saveGitStash(name, message, writableOverride, options) {
479
479
  if (!statusResult.stdout.trim()) {
480
480
  return { committed: false, pushed: false, skipped: false, output: "nothing to commit, working tree clean" };
481
481
  }
482
- // Safety check (#476): when the stash dir is shared with a non-akm project
483
- // (stash root == project repo root), `git add -A` would stage every dirty
484
- // file in the user's working tree and push their unrelated WIP to the
485
- // stash's remote. Refuse if any dirty path is outside the known akm-
486
- // managed subtrees (TYPE_DIRS + `.akm/` state).
487
- const nonAkmDirty = collectNonAkmDirtyPaths(statusResult.stdout);
488
- if (nonAkmDirty.length > 0) {
489
- const sample = nonAkmDirty.slice(0, 10);
490
- const more = nonAkmDirty.length > sample.length ? `\n ...and ${nonAkmDirty.length - sample.length} more` : "";
491
- throw new Error(`refusing to push: stash repo at ${repoDir} has uncommitted non-akm changes:\n` +
492
- sample.map((p) => ` ${p}`).join("\n") +
493
- more +
494
- `\nCommit or stash these manually before running an akm push. ` +
495
- `Akm-managed paths are: ${Object.values(TYPE_DIRS).join(", ")}, .akm/`);
496
- }
497
- // Stage and commit — supply fallback identity so fresh environments without
482
+ // Scoped staging (#476 + the auto-sync incident): NEVER refuse akm's commit
483
+ // because unrelated non-akm files exist in the working tree. When the stash
484
+ // dir is shared with a non-akm project (stash root == project repo root), a
485
+ // blunt `git add -A` would sweep the user's unrelated WIP into the stash's
486
+ // remote. We avoid that by SCOPING what we stage, not by refusing the commit.
487
+ //
488
+ // Precedence:
489
+ // 1. Explicit modified-file list (`options.paths`) — stage exactly those.
490
+ // 2. Managed pathspecs (TYPE_DIRS values + `.akm`) that exist on disk
491
+ // stages everything akm owns and, by construction, never stages non-akm
492
+ // WIP. This preserves the #476 protection WITHOUT refusing.
493
+ // 3. Last-resort `git add -A` — ONLY when neither an explicit list nor any
494
+ // managed pathspec can be resolved. This is the maintainer-approved
495
+ // "or all files if we cannot determine the exact file list" fallback and
496
+ // is the one (rare) path that could include unrelated non-akm files.
497
+ if (!stageScopedChanges(repoDir, options?.paths)) {
498
+ throw new Error(`git add failed while staging akm changes in ${repoDir}`);
499
+ }
500
+ // Nothing actually staged → don't create an empty commit. This happens when
501
+ // only non-akm files were dirty (precedence 2 staged nothing).
502
+ const stagedResult = runGit(["-C", repoDir, "diff", "--cached", "--quiet"]);
503
+ if (stagedResult.status === 0) {
504
+ return { committed: false, pushed: false, skipped: false, output: "nothing to commit" };
505
+ }
506
+ // Commit — supply fallback identity so fresh environments without
498
507
  // user.name/user.email configured can always commit to the default stash.
499
- // `add -A` is safe here because nonAkmDirty was just verified empty.
500
- const addResult = runGit(["-C", repoDir, "add", "-A"]);
501
- if (addResult.status !== 0) {
502
- throw new Error(`git add failed: ${addResult.stderr?.trim() || "unknown error"}`);
503
- }
504
508
  const commitResult = runGit([
505
509
  "-C",
506
510
  repoDir,
@@ -535,6 +539,51 @@ export function saveGitStash(name, message, writableOverride, options) {
535
539
  output: (commitResult.stdout + pushResult.stdout).trim() || "changes committed and pushed",
536
540
  };
537
541
  }
542
+ /**
543
+ * Stage akm's changes in `repoDir` using the scoped-staging precedence
544
+ * documented at the call site (#476). Returns `false` only when a `git add`
545
+ * subprocess fails; returns `true` otherwise (including when nothing matched —
546
+ * the caller then detects "nothing staged" via `git diff --cached --quiet`).
547
+ *
548
+ * @param paths Optional explicit repo-relative paths akm wrote this run. When
549
+ * provided and non-empty, exactly those are staged (chunked to stay under
550
+ * argv length limits). Otherwise we fall back to the managed pathspecs, and
551
+ * finally to `git add -A` only if no managed pathspec exists on disk.
552
+ */
553
+ function stageScopedChanges(repoDir, paths) {
554
+ // Precedence 1: explicit modified-file list.
555
+ const explicit = (paths ?? []).filter((p) => typeof p === "string" && p.length > 0);
556
+ if (explicit.length > 0) {
557
+ return addPathspecsChunked(repoDir, explicit);
558
+ }
559
+ // Precedence 2: managed pathspecs that exist on disk (TYPE_DIRS + `.akm`).
560
+ const managed = [...Object.values(TYPE_DIRS), ".akm"].filter((dir) => fs.existsSync(path.join(repoDir, dir)));
561
+ if (managed.length > 0) {
562
+ return addPathspecsChunked(repoDir, managed);
563
+ }
564
+ // Precedence 3 (last resort): nothing akm-managed resolved on disk. Stage
565
+ // everything. This is the ONLY branch that can include unrelated non-akm
566
+ // files — it is the explicit, maintainer-approved "or all files if we cannot
567
+ // determine the exact file list" fallback and should be rare/never in
568
+ // practice (a git-backed stash always has at least one managed subtree once
569
+ // akm has written to it).
570
+ const addAll = runGit(["-C", repoDir, "add", "-A"]);
571
+ return addAll.status === 0;
572
+ }
573
+ /**
574
+ * Run `git add -- <pathspec>...` in chunks so a very large path list never
575
+ * exceeds the OS argv-length limit. Each chunk must succeed.
576
+ */
577
+ function addPathspecsChunked(repoDir, pathspecs) {
578
+ const CHUNK = 500;
579
+ for (let i = 0; i < pathspecs.length; i += CHUNK) {
580
+ const chunk = pathspecs.slice(i, i + CHUNK);
581
+ const result = runGit(["-C", repoDir, "add", "--", ...chunk]);
582
+ if (result.status !== 0)
583
+ return false;
584
+ }
585
+ return true;
586
+ }
538
587
  function findGitStashByTarget(stashes, target) {
539
588
  return stashes.find((stash) => matchesGitStashTarget(stash, target));
540
589
  }
@@ -620,45 +669,6 @@ export function classifyCloneFailure(url, stderr, spawnError) {
620
669
  const detail = raw || spawnMsg || "unknown error";
621
670
  return `Failed to clone ${url}: ${detail}`;
622
671
  }
623
- // ── Stash-safety helpers (#476) ──────────────────────────────────────────────
624
- /**
625
- * Inspect `git status --porcelain` output and return every dirty path that is
626
- * NOT inside an akm-managed subtree. Used by `runUpstreamPush` to refuse
627
- * pushing unrelated WIP when a writable stash shares its root with a project
628
- * repo.
629
- *
630
- * Porcelain v1 format: `XY <path>` or `XY <orig> -> <new>` for renames. We
631
- * key off the post-rename path (or the only path) — that is the working-tree
632
- * file at risk of being staged by `git add -A`.
633
- */
634
- function collectNonAkmDirtyPaths(porcelainOutput) {
635
- const akmDirs = new Set(Object.values(TYPE_DIRS));
636
- const result = [];
637
- for (const rawLine of porcelainOutput.split("\n")) {
638
- const line = rawLine.replace(/\r$/, "");
639
- if (line.length === 0)
640
- continue;
641
- // Skip the 2-char status code + 1 space.
642
- let p = line.length > 3 ? line.slice(3) : "";
643
- // Renames / copies: `from -> to`. Stage decision applies to `to`.
644
- const arrow = p.lastIndexOf(" -> ");
645
- if (arrow !== -1) {
646
- p = p.slice(arrow + 4);
647
- }
648
- // Strip surrounding quotes for paths with special chars.
649
- if (p.startsWith('"') && p.endsWith('"') && p.length >= 2) {
650
- p = p.slice(1, -1);
651
- }
652
- if (!p)
653
- continue;
654
- const segments = p.split("/");
655
- const top = segments[0];
656
- if (top === ".akm" || akmDirs.has(top))
657
- continue;
658
- result.push(p);
659
- }
660
- return result;
661
- }
662
672
  // ── Exports ─────────────────────────────────────────────────────────────────
663
- export { collectNonAkmDirtyPaths, ensureGitMirror, GitSourceProvider, getCachePaths, parseGitRepoUrl };
673
+ export { ensureGitMirror, GitSourceProvider, getCachePaths, parseGitRepoUrl };
664
674
  // resolveWritableOverride is exported at its declaration above.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.0-beta.40",
3
+ "version": "0.9.0-beta.41",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Management) — A package manager for AI agent skills, commands, tools, and knowledge. Works with Claude Code, OpenCode, Cursor, and any AI coding assistant.",
6
6
  "keywords": [