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

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.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `pugi roster` command - α7.5 Tier 1 instantiation Phase 1.
3
+ *
4
+ * Lists the live Tier 1 personas with display name, role, and routing
5
+ * tag. The CLI walks two sources in order:
6
+ *
7
+ * 1. The local @pugi/personas roster (THE_TEN). Always succeeds; the
8
+ * ten brand-canonical personas are baked into the SDK.
9
+ * 2. The remote `GET /api/pugi/sessions/roster` endpoint when the
10
+ * operator has a valid credential. The remote response carries the
11
+ * server-side dispatch role + dispatchTag for each slug so the
12
+ * operator sees the actual routing decision the dispatcher will
13
+ * apply on a `pugi delegate <slug>` call.
14
+ *
15
+ * The command never fails if the network is unreachable - it falls back
16
+ * to local-only output with a one-line warning. This matches the
17
+ * local-first contract (ADR-0037): the operator can still see who is on
18
+ * the team without an API key.
19
+ *
20
+ * Output:
21
+ * - text default: a 3-column table (slug | name | role).
22
+ * - --json: a structured array of { slug, name, role, totem,
23
+ * dispatchTag } records, used by scripted callers.
24
+ */
25
+ import { THE_TEN } from '@pugi/personas';
26
+ import { fetchPersonaRoster, } from '@pugi/sdk';
27
+ /**
28
+ * Fallback role + tag table the CLI uses when the runtime is unreachable
29
+ * (no credentials, network error, older runtime without the
30
+ * /sessions/roster endpoint). Mirrors the server-side
31
+ * persona-dispatch.ts PERSONA_REGISTRY so a CLI that ran without
32
+ * credentials still shows the operator the right routing intent.
33
+ */
34
+ const FALLBACK_ROLE_BY_SLUG = Object.freeze({
35
+ main: { role: 'orchestrator', dispatchTag: 'reason' },
36
+ architect: { role: 'architect', dispatchTag: 'reason' },
37
+ dev: { role: 'coder', dispatchTag: 'codegen' },
38
+ qa: { role: 'verifier', dispatchTag: 'reason' },
39
+ pm: { role: 'release', dispatchTag: 'reason' },
40
+ devops: { role: 'devops', dispatchTag: 'reason' },
41
+ researcher: { role: 'researcher', dispatchTag: 'reason' },
42
+ analyst: { role: 'analyst', dispatchTag: 'summarize' },
43
+ designer: { role: 'design_qa', dispatchTag: 'reason' },
44
+ frontend: { role: 'frontend', dispatchTag: 'codegen' },
45
+ });
46
+ /**
47
+ * Build the roster rows by merging the local @pugi/personas brand
48
+ * roster with the remote dispatch metadata when a credential is
49
+ * available. Pure function so the runtime CLI command can unit-test it
50
+ * without standing up an Anvil endpoint.
51
+ */
52
+ export function mergeRoster(brandRoster, remote) {
53
+ const remoteIndex = new Map((remote ?? []).map((entry) => [entry.slug, entry]));
54
+ return brandRoster.map((persona) => {
55
+ const fromRemote = remoteIndex.get(persona.slug);
56
+ const fallback = FALLBACK_ROLE_BY_SLUG[persona.slug] ?? {
57
+ role: persona.role,
58
+ dispatchTag: 'reason',
59
+ };
60
+ return {
61
+ slug: persona.slug,
62
+ name: persona.name,
63
+ totem: persona.animal,
64
+ role: fromRemote?.role ?? fallback.role,
65
+ dispatchTag: fromRemote?.dispatchTag ?? fallback.dispatchTag,
66
+ oneLiner: persona.oneLiner,
67
+ source: fromRemote ? 'remote' : 'local-fallback',
68
+ };
69
+ });
70
+ }
71
+ /**
72
+ * Render a roster as a plain-text 3-column table the operator reads in
73
+ * the terminal. The column widths grow to fit the longest cell so a
74
+ * future displayName drift does not truncate silently.
75
+ */
76
+ export function renderRosterTable(rows) {
77
+ if (rows.length === 0)
78
+ return 'Roster is empty.';
79
+ const head = { slug: 'slug', name: 'name', totem: 'totem', role: 'role', dispatchTag: 'tag' };
80
+ const widths = {
81
+ slug: Math.max(head.slug.length, ...rows.map((r) => r.slug.length)),
82
+ name: Math.max(head.name.length, ...rows.map((r) => r.name.length)),
83
+ totem: Math.max(head.totem.length, ...rows.map((r) => r.totem.length)),
84
+ role: Math.max(head.role.length, ...rows.map((r) => r.role.length)),
85
+ dispatchTag: Math.max(head.dispatchTag.length, ...rows.map((r) => r.dispatchTag.length)),
86
+ };
87
+ const pad = (s, width) => s + ' '.repeat(Math.max(0, width - s.length));
88
+ const line = (r) => [pad(r.slug, widths.slug), pad(r.name, widths.name), pad(r.totem, widths.totem), pad(r.role, widths.role), pad(r.dispatchTag, widths.dispatchTag)].join(' ');
89
+ const header = line(head);
90
+ const sep = '-'.repeat(header.length);
91
+ return [header, sep, ...rows.map((r) => line(r))].join('\n');
92
+ }
93
+ /**
94
+ * Resolve the roster by walking remote + local sources. The CLI command
95
+ * is a thin wrapper around this function so unit tests can exercise the
96
+ * merge logic without hitting the runtime.
97
+ */
98
+ export async function resolveRoster(config) {
99
+ if (!config) {
100
+ return {
101
+ rows: mergeRoster(THE_TEN, null),
102
+ warning: 'no credential configured; showing local @pugi/personas roster only',
103
+ };
104
+ }
105
+ const result = await fetchPersonaRoster(config);
106
+ if (result.status === 'ok') {
107
+ return { rows: mergeRoster(THE_TEN, result.response.personas), warning: null };
108
+ }
109
+ const reason = result.status === 'endpoint_missing'
110
+ ? 'runtime does not expose /api/pugi/sessions/roster (upgrade admin-api to α7.5+)'
111
+ : result.message;
112
+ return {
113
+ rows: mergeRoster(THE_TEN, null),
114
+ warning: `roster fetch failed (${result.status}): ${reason}; showing local roster only`,
115
+ };
116
+ }
117
+ //# sourceMappingURL=roster.js.map
@@ -1,19 +1,8 @@
1
1
  const registry = [
2
- // α7.7: unified-diff patch apply. Routes through the same security
3
- // gate as Layer A/B/C, so the risk class matches `edit`/`write`
4
- // (medium — writes inside the workspace, never to protected files).
5
- { name: 'apply_patch', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
6
2
  { name: 'bash', permission: 'bash', risk: 'high', concurrencySafe: false, m1: true },
7
3
  { name: 'edit', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
8
4
  { name: 'glob', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
9
5
  { name: 'grep', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
10
- // α7.7: LSP read-only surface. Server runs locally, no Anvil
11
- // round-trip. Concurrency-safe because every operation reads
12
- // server state without mutating workspace files.
13
- { name: 'lsp_definition', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
14
- { name: 'lsp_diagnostics', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
15
- { name: 'lsp_hover', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
16
- { name: 'lsp_references', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
17
6
  { name: 'question', permission: 'none', risk: 'low', concurrencySafe: false, m1: true },
18
7
  { name: 'read', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
19
8
  { name: 'skill', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
@@ -22,13 +11,6 @@ const registry = [
22
11
  { name: 'task_list', permission: 'none', risk: 'low', concurrencySafe: true, m1: true },
23
12
  { name: 'task_update', permission: 'none', risk: 'low', concurrencySafe: false, m1: true },
24
13
  { name: 'web_fetch', permission: 'network', risk: 'medium', concurrencySafe: true, m1: true },
25
- // α7.7: scratch worktree management. `worktree_create` writes nothing
26
- // dangerous (a clone under `.pugi/worktrees/`); `worktree_promote`
27
- // applies a diff back to the main tree, so it shares the `edit`
28
- // risk class. `worktree_drop` is the cleanup primitive.
29
- { name: 'worktree_create', permission: 'edit', risk: 'low', concurrencySafe: false, m1: true },
30
- { name: 'worktree_drop', permission: 'edit', risk: 'low', concurrencySafe: false, m1: true },
31
- { name: 'worktree_promote', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
32
14
  { name: 'write', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
33
15
  ];
34
16
  export const toolRegistry = registry.sort((a, b) => a.name.localeCompare(b.name));
@@ -86,30 +86,14 @@ export async function renderRepl(options) {
86
86
  // Kick off the connect; the Repl renders the connecting state until
87
87
  // the session pushes `connection: 'on_watch'` from the SSE onOpen.
88
88
  void session.start();
89
- // α6.14.4 CEO dogfood 2026-05-25 (parity with Claude Code): enter
90
- // the terminal's alternate screen buffer so the REPL renders on a
91
- // fresh "screen" the operator cannot scroll above. On exit, leave
92
- // restores the previous terminal contents - the conversation does
93
- // not pollute the operator's shell history. Skipped under --no-tty
94
- // and when stdout is not a TTY (pipe/CI), where the escapes would
95
- // appear as literal characters.
96
- // ORDER MATTERS (beta.2 follow-up): alt-screen enter MUST happen
97
- // BEFORE the chafa mascot pre-print. Reversed, the alt-screen clear
98
- // wiped the freshly-painted pug and the operator saw nothing.
99
- const supportsAltScreen = process.stdout.isTTY === true;
100
- if (supportsAltScreen) {
101
- process.stdout.write('\x1b[?1049h');
102
- process.stdout.write('\x1b[H');
103
- }
104
89
  // α6.14.2 wave 5: paint the chafa-baked brand-pug ANSI render to
105
- // stdout BEFORE Ink mounts (but AFTER alt-screen enter). Ink's
106
- // layout engine would mis-measure the truecolor escape sequences,
107
- // so the pug must land verbatim. The flag is passed into <Repl />
108
- // so the splash component knows to skip its own hand-crafted
109
- // PUG_MASCOT column - otherwise the operator sees both the chafa
110
- // pug AND the ASCII fallback stacked. When skipSplash is true
111
- // (operator opted out via --no-splash), we suppress the pre-print
112
- // too so the boot stays silent.
90
+ // stdout BEFORE Ink mounts. Ink's layout engine would mis-measure
91
+ // the truecolor escape sequences, so the pug must land verbatim.
92
+ // The flag is passed into <Repl /> so the splash component knows to
93
+ // skip its own hand-crafted PUG_MASCOT column — otherwise the
94
+ // operator sees both the chafa pug AND the ASCII fallback stacked.
95
+ // When skipSplash is true (operator opted out via --no-splash), we
96
+ // suppress the pre-print too so the boot stays silent.
113
97
  const mascotPrePrinted = options.skipSplash === true ? false : printPugMascotPreInk(process.stdout);
114
98
  const instance = render(React.createElement(Repl, {
115
99
  session,
@@ -118,26 +102,10 @@ export async function renderRepl(options) {
118
102
  hideToolStream: options.hideToolStream === true,
119
103
  mascotPrePrinted,
120
104
  }));
121
- const restoreAltScreen = () => {
122
- if (supportsAltScreen) {
123
- try {
124
- process.stdout.write('\x1b[?1049l');
125
- }
126
- catch {
127
- /* shutdown race — terminal already detached */
128
- }
129
- }
130
- };
131
- // Make sure we leave the alt screen on abrupt exits too. Without
132
- // this the operator's shell stays "frozen" on the Pugi splash.
133
- process.once('exit', restoreAltScreen);
134
- process.once('SIGINT', restoreAltScreen);
135
- process.once('SIGTERM', restoreAltScreen);
136
105
  try {
137
106
  await instance.waitUntilExit();
138
107
  }
139
108
  finally {
140
- restoreAltScreen();
141
109
  session.close();
142
110
  if (store) {
143
111
  try {
package/dist/tui/repl.js CHANGED
@@ -178,14 +178,16 @@ export function Repl(props) {
178
178
  return undefined;
179
179
  return props.session.cancel();
180
180
  }, [props.session, modalActive]);
181
- // α6.14.4 CEO dogfood 2026-05-25: stretch to full alt-screen height
182
- // (parity with Claude Code which fills the entire viewport). Without
183
- // an explicit height the Box collapses to its natural content size
184
- // and leaves ~2/3 of the alt-screen blank below the input bar.
185
- // process.stdout.rows is the live terminal row count; the REPL
186
- // re-renders on SIGWINCH via Ink's built-in resize listener.
187
- const altScreenHeight = process.stdout.rows ?? 24;
188
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: altScreenHeight, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash, mascotPrePrinted: props.mascotPrePrinted === true })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow, hideToolStream: props.hideToolStream === true, toolStreamCollapsed: toolStreamCollapsed })) }), state.pendingAsk ? (_jsx(Box, { marginTop: 1, children: _jsx(AskModal, { tag: state.pendingAsk, onResolve: handleAskResolve }) })) : null, state.pendingPlanReview ? (_jsx(Box, { marginTop: 1, children: _jsx(PlanReviewModal, { tag: state.pendingPlanReview, onResolve: handlePlanReviewResolve }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' || modalActive ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, onCancel: handleCancel, now: props.now,
181
+ // α6.14.5 CEO dogfood 2026-05-25 (parity with Claude Code): the
182
+ // input box must pin to the BOTTOM of the alt-screen viewport, not
183
+ // float right under the conversation. Beta.3 attempt at full-height
184
+ // broke keystroke focus (raw echo at row 79). The right pattern is
185
+ // minHeight on the root + flexGrow on the conversation pane so the
186
+ // empty space sits ABOVE the input, not below it. The input then
187
+ // captures all keystrokes because it is the only Ink-focusable
188
+ // surface adjacent to the cursor row.
189
+ const altScreenRows = process.stdout.rows ?? 24;
190
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, minHeight: altScreenRows, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash, mascotPrePrinted: props.mascotPrePrinted === true })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, flexGrow: 1, children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow, hideToolStream: props.hideToolStream === true, toolStreamCollapsed: toolStreamCollapsed })) }), state.pendingAsk ? (_jsx(Box, { marginTop: 1, children: _jsx(AskModal, { tag: state.pendingAsk, onResolve: handleAskResolve }) })) : null, state.pendingPlanReview ? (_jsx(Box, { marginTop: 1, children: _jsx(PlanReviewModal, { tag: state.pendingPlanReview, onResolve: handlePlanReviewResolve }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' || modalActive ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, onCancel: handleCancel, now: props.now,
189
191
  // Slug from process.cwd() (full path) so two workspaces with
190
192
  // the same basename do not share history. state.workspaceLabel
191
193
  // is the basename only. Codex review P2.
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.4",
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.4"
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