@celilo/cli 0.16.1 → 0.16.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -60,7 +60,7 @@
60
60
  "@celilo/capabilities": "^0.9.1",
61
61
  "@celilo/cli-display": "^0.1.9",
62
62
  "@celilo/core": "^0.3.0",
63
- "@celilo/event-bus": "^0.1.9",
63
+ "@celilo/event-bus": "^0.1.10",
64
64
  "@clack/prompts": "^1.1.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
@@ -1,5 +1,14 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { type GitCommandRunner, buildReleaseMetadata, collectGitInfo } from './release-metadata';
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import {
7
+ type GitCommandRunner,
8
+ buildReleaseMetadata,
9
+ collectGitInfo,
10
+ makeRealGitRunner,
11
+ } from './release-metadata';
3
12
 
4
13
  describe('buildReleaseMetadata', () => {
5
14
  test('produces a stable shape from injected inputs', () => {
@@ -77,7 +86,7 @@ describe('collectGitInfo', () => {
77
86
  expect(calls).toEqual([
78
87
  ['rev-parse', 'HEAD'],
79
88
  ['rev-parse', '--abbrev-ref', 'HEAD'],
80
- ['status', '--porcelain'],
89
+ ['status', '--porcelain', '--', '.'],
81
90
  ]);
82
91
  });
83
92
 
@@ -101,3 +110,68 @@ describe('collectGitInfo', () => {
101
110
  expect(collectGitInfo('/tmp/x', run).dirty).toBe(true);
102
111
  });
103
112
  });
113
+
114
+ /**
115
+ * Recurrence gate for #544, against a REAL git repo rather than a fake runner.
116
+ *
117
+ * The tests above pin the argv `collectGitInfo` constructs. That is what let the
118
+ * bug live: they asserted which command was built, never what it concluded, so
119
+ * they stayed green while the function reported the whole repository's dirt for
120
+ * every module. A fake `GitCommandRunner` cannot catch this at all — the defect
121
+ * IS git's real scoping behavior, which a stub by definition does not model.
122
+ *
123
+ * So this drives the real `makeRealGitRunner()` against a real checkout. Delete
124
+ * the `-- .` pathspec and the first test here fails; that is the whole point.
125
+ */
126
+ describe('collectGitInfo scopes dirt to sourceDir (#544, real git)', () => {
127
+ let repo: string;
128
+ let moduleDir: string;
129
+ const git = (args: string[], cwd: string) =>
130
+ execFileSync('git', args, { cwd, encoding: 'utf-8' });
131
+
132
+ beforeEach(() => {
133
+ repo = mkdtempSync(join(tmpdir(), 'celilo-gitinfo-'));
134
+ moduleDir = join(repo, 'modules', 'probe');
135
+ mkdirSync(moduleDir, { recursive: true });
136
+ writeFileSync(join(moduleDir, 'manifest.yml'), 'id: probe\n');
137
+ writeFileSync(join(repo, 'bun.lock'), 'lockfile v1\n');
138
+
139
+ git(['init', '-q'], repo);
140
+ git(['config', 'user.email', 'test@celilo.test'], repo);
141
+ git(['config', 'user.name', 'Test'], repo);
142
+ git(['add', '-A'], repo);
143
+ git(['commit', '-qm', 'init'], repo);
144
+ });
145
+
146
+ afterEach(() => rmSync(repo, { recursive: true, force: true }));
147
+
148
+ test('a tracked file rewritten OUTSIDE the module does not make it dirty', () => {
149
+ // Exactly what broke the release: `bun install` rewrites the tracked root
150
+ // `bun.lock` between module publishes. The module itself is untouched.
151
+ writeFileSync(join(repo, 'bun.lock'), 'lockfile v1\nrewritten by bun install\n');
152
+
153
+ expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(false);
154
+ });
155
+
156
+ test("a sibling module's dirt does not make this module dirty", () => {
157
+ const sibling = join(repo, 'modules', 'other');
158
+ mkdirSync(sibling, { recursive: true });
159
+ writeFileSync(join(sibling, 'manifest.yml'), 'id: other\n');
160
+
161
+ expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(false);
162
+ });
163
+
164
+ test("the module's OWN dirt still blocks the publish", () => {
165
+ // The control. Scoping must not have simply disabled the check — this is
166
+ // the case the guard exists for, and it must still fire.
167
+ writeFileSync(join(moduleDir, 'manifest.yml'), 'id: probe\nversion: 9.9.9\n');
168
+
169
+ expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(true);
170
+ });
171
+
172
+ test('an untracked file inside the module still blocks the publish', () => {
173
+ writeFileSync(join(moduleDir, 'stray.txt'), 'oops\n');
174
+
175
+ expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(true);
176
+ });
177
+ });
@@ -129,7 +129,17 @@ export function collectGitInfo(sourceDir: string, run: GitCommandRunner): GitInf
129
129
  // `git status --porcelain` prints one line per modified/untracked file.
130
130
  // Empty output = clean. Null (command failure) is treated as not-dirty
131
131
  // because we don't want to falsely block a publish.
132
- const status = run(['status', '--porcelain'], sourceDir);
132
+ //
133
+ // The `-- .` pathspec is load-bearing (#544). `git status` reports the WHOLE
134
+ // repository regardless of cwd, so passing `sourceDir` as cwd scoped nothing:
135
+ // every caller asks about one module, and got back the dirt of all of them
136
+ // plus the repo root. The release pipeline runs `bun install` between module
137
+ // publishes, which rewrites the tracked `bun.lock` at the root — so modules
138
+ // 1..N published fine and the next one failed with "Working tree at
139
+ // modules/<clean-module> has uncommitted changes", naming a directory that
140
+ // was clean and sending you to inspect it. Order-dependent, so it looked
141
+ // like a random module failing.
142
+ const status = run(['status', '--porcelain', '--', '.'], sourceDir);
133
143
  const dirty = status !== null && status.length > 0;
134
144
 
135
145
  return {