@pugi/cli 0.1.0-beta.3 → 0.1.0-beta.5

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": "@pugi/cli",
3
- "version": "0.1.0-beta.3",
3
+ "version": "0.1.0-beta.5",
4
4
  "description": "Pugi CLI - terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -52,7 +52,7 @@
52
52
  "undici": "^8.3.0",
53
53
  "zod": "^3.23.0",
54
54
  "@pugi/personas": "0.1.2",
55
- "@pugi/sdk": "0.1.0-beta.3"
55
+ "@pugi/sdk": "0.1.0-beta.5"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/node": "^22.0.0",
@@ -1,229 +0,0 @@
1
- /**
2
- * Worktree isolation — α7.7 Phase 1.
3
- *
4
- * Wraps `git worktree add` so a long agent loop (build / consensus
5
- * review / multi-file refactor) can land its edits into a scratch
6
- * workspace, run the validators against THAT path, and only then promote
7
- * the resulting diff back to the operator's main working tree. The
8
- * primary win is safety: a half-applied refactor never corrupts the
9
- * operator's branch.
10
- *
11
- * Three operations:
12
- *
13
- * - `createWorktree(branch)` — spawns `git worktree add --detach`
14
- * under `.pugi/worktrees/<uuid>` based on the supplied branch (or
15
- * HEAD when omitted). Returns the absolute path + a `cleanup()`
16
- * callback. The dir lives under `.pugi/` so the existing `.gitignore`
17
- * for that subtree applies (no accidental commits of the scratch
18
- * state to the main repo).
19
- *
20
- * - `promoteWorktree(worktreePath, cwd)` — diffs the worktree against
21
- * its base commit and applies the diff to the main `cwd` via
22
- * `git apply`. Refuses if the main cwd has staged changes that
23
- * would conflict; the operator must commit or stash first.
24
- *
25
- * - `dropWorktree(worktreePath)` — removes the worktree both from
26
- * git's bookkeeping (`git worktree remove --force`) and from disk.
27
- * Idempotent; a partially-removed worktree (`git` already cleaned
28
- * up but dir survived) is handled.
29
- *
30
- * Brand voice: ASCII only, no emoji, no banned words.
31
- */
32
- import { spawnSync } from 'node:child_process';
33
- import { existsSync, mkdirSync, rmSync } from 'node:fs';
34
- import { randomUUID } from 'node:crypto';
35
- import { resolve, sep } from 'node:path';
36
- import { OperatorAbortedError } from '../../tools/file-tools.js';
37
- /**
38
- * Create a scratch worktree under `.pugi/worktrees/<uuid>`. The path is
39
- * guaranteed unique (uuid) so multiple agent loops can run in parallel
40
- * without collision.
41
- */
42
- export function createWorktree(opts) {
43
- if (opts.cancellation && opts.cancellation.isAborted) {
44
- return { ok: false, reason: 'operator_aborted', detail: 'createWorktree aborted' };
45
- }
46
- // Confirm we're inside a git repo. `git rev-parse --git-dir` is the
47
- // canonical check and avoids a misleading error message later when
48
- // `git worktree add` runs in a non-repo.
49
- const gitDir = runGit(['rev-parse', '--git-dir'], opts.cwd);
50
- if (gitDir.status !== 0) {
51
- return {
52
- ok: false,
53
- reason: 'not_a_git_repo',
54
- detail: `not a git repo: ${opts.cwd}`,
55
- };
56
- }
57
- // Resolve base SHA. When the operator named a branch we honor it; the
58
- // default is HEAD. We capture the SHA up-front so `promoteWorktree`
59
- // can `git diff <baseSha>..HEAD` deterministically even if the main
60
- // working tree has moved forward since.
61
- const baseRef = opts.branch ?? 'HEAD';
62
- const baseShaResult = runGit(['rev-parse', baseRef], opts.cwd);
63
- if (baseShaResult.status !== 0) {
64
- return {
65
- ok: false,
66
- reason: 'git_command_failed',
67
- detail: `cannot resolve base ref ${baseRef}: ${baseShaResult.stderr}`,
68
- };
69
- }
70
- const baseSha = baseShaResult.stdout.trim();
71
- const worktreeRoot = resolve(opts.cwd, '.pugi', 'worktrees');
72
- mkdirSync(worktreeRoot, { recursive: true });
73
- const worktreePath = resolve(worktreeRoot, randomUUID());
74
- // `--detach` keeps the worktree on a detached HEAD so we don't
75
- // collide with branch checkouts on the main tree. The worktree is
76
- // throwaway — there is no branch name to track.
77
- const create = runGit(['worktree', 'add', '--detach', worktreePath, baseSha], opts.cwd);
78
- if (create.status !== 0) {
79
- return {
80
- ok: false,
81
- reason: 'git_command_failed',
82
- detail: `git worktree add failed: ${create.stderr}`,
83
- };
84
- }
85
- const handle = {
86
- path: worktreePath,
87
- baseSha,
88
- cleanup: () => {
89
- const r = dropWorktree(worktreePath, opts.cwd);
90
- if (!r.ok && r.reason !== 'worktree_missing') {
91
- // Swallow non-fatal cleanup failures so the agent loop doesn't
92
- // hard-crash on the happy path. The diagnostic still surfaces
93
- // via the JSON output on the `pugi worktree drop` command.
94
- }
95
- },
96
- };
97
- return { ok: true, value: handle };
98
- }
99
- /**
100
- * Diff the worktree against its base and apply the diff to the main cwd.
101
- *
102
- * Implementation notes:
103
- *
104
- * - We run `git diff --binary <baseSha>` inside the worktree (NOT
105
- * `git diff <worktree>..HEAD` from the main tree — the worktree's
106
- * HEAD is detached at `baseSha`, so the meaningful diff is the
107
- * UNCOMMITTED changes the agent wrote into it).
108
- * - `--binary` ensures non-text files (assets, images) survive the
109
- * round-trip; without it `git apply` fails on any binary delta.
110
- * - We always run `git apply --check` first so a refusal does not
111
- * leave the main tree half-modified.
112
- */
113
- export function promoteWorktree(opts) {
114
- if (opts.cancellation && opts.cancellation.isAborted) {
115
- return { ok: false, reason: 'operator_aborted', detail: 'promoteWorktree aborted' };
116
- }
117
- if (!existsSync(opts.worktreePath)) {
118
- return {
119
- ok: false,
120
- reason: 'worktree_missing',
121
- detail: `worktree path does not exist: ${opts.worktreePath}`,
122
- };
123
- }
124
- // Capture the diff. Include both unstaged AND staged changes — the
125
- // agent loop typically stages nothing (it just writes files), but
126
- // honoring both is forward-compatible with hand-edits inside the
127
- // worktree.
128
- const diff = runGit(['diff', '--binary', opts.baseSha], opts.worktreePath);
129
- if (diff.status !== 0) {
130
- return {
131
- ok: false,
132
- reason: 'git_command_failed',
133
- detail: `git diff failed: ${diff.stderr}`,
134
- };
135
- }
136
- if (diff.stdout.trim().length === 0) {
137
- return { ok: true, value: { filesChanged: 0 } };
138
- }
139
- // `git apply --check` validates the diff against the main tree first.
140
- // Refuse early on conflict so the operator can resolve before we
141
- // touch any file.
142
- const check = runGit(['apply', '--check', '-'], opts.cwd, diff.stdout);
143
- if (check.status !== 0) {
144
- return {
145
- ok: false,
146
- reason: 'apply_conflict',
147
- detail: `git apply --check rejected: ${check.stderr}`,
148
- };
149
- }
150
- if (opts.dryRun) {
151
- return { ok: true, value: { filesChanged: countDiffFiles(diff.stdout) } };
152
- }
153
- const apply = runGit(['apply', '-'], opts.cwd, diff.stdout);
154
- if (apply.status !== 0) {
155
- return {
156
- ok: false,
157
- reason: 'apply_failed',
158
- detail: `git apply failed: ${apply.stderr}`,
159
- };
160
- }
161
- return { ok: true, value: { filesChanged: countDiffFiles(diff.stdout) } };
162
- }
163
- /**
164
- * Drop a worktree both from git's bookkeeping and from disk. Idempotent —
165
- * a missing path returns `worktree_missing` which the caller can ignore
166
- * on the cleanup-after-error path.
167
- */
168
- export function dropWorktree(worktreePath, cwd) {
169
- // `git worktree remove --force` cleans the metadata in `.git/worktrees`.
170
- // If the worktree was created by another process and already pruned,
171
- // git returns non-zero — we still try to `rmSync` the dir to leave the
172
- // filesystem consistent.
173
- const remove = runGit(['worktree', 'remove', '--force', worktreePath], cwd);
174
- const gitCleanFailed = remove.status !== 0;
175
- if (existsSync(worktreePath)) {
176
- try {
177
- rmSync(worktreePath, { recursive: true, force: true });
178
- }
179
- catch (error) {
180
- if (gitCleanFailed) {
181
- return {
182
- ok: false,
183
- reason: 'git_command_failed',
184
- detail: `git worktree remove failed AND rmSync failed: ${error instanceof Error ? error.message : String(error)}`,
185
- };
186
- }
187
- }
188
- }
189
- if (gitCleanFailed && !worktreePath.includes(`${sep}.pugi${sep}worktrees${sep}`)) {
190
- // A worktree that wasn't created by us (path is outside our naming
191
- // convention) is suspicious — surface the failure so the operator
192
- // can diagnose.
193
- return {
194
- ok: false,
195
- reason: 'git_command_failed',
196
- detail: `git worktree remove failed: ${remove.stderr}`,
197
- };
198
- }
199
- return { ok: true, value: undefined };
200
- }
201
- function countDiffFiles(diff) {
202
- // Count `diff --git a/... b/...` headers. Cheap and unambiguous.
203
- let count = 0;
204
- for (const line of diff.split('\n')) {
205
- if (line.startsWith('diff --git '))
206
- count += 1;
207
- }
208
- return count;
209
- }
210
- function runGit(args, cwd, stdin) {
211
- return spawnSync('git', args, {
212
- cwd,
213
- input: stdin,
214
- encoding: 'utf8',
215
- maxBuffer: 64 * 1024 * 1024,
216
- });
217
- }
218
- /**
219
- * Test-only helper exporting the internal git runner so specs can stub
220
- * the spawn surface when running on a CI host without a global git.
221
- */
222
- export const __test__ = { runGit, countDiffFiles };
223
- /**
224
- * Re-export the abort marker so the worktree CLI surface can fold the
225
- * exception into a clean exit code without needing to import from the
226
- * tools layer.
227
- */
228
- export { OperatorAbortedError };
229
- //# sourceMappingURL=worktree.js.map