mintree 0.3.2 → 0.4.0

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.
@@ -10,7 +10,7 @@ import { resolveClaudeBinary } from "../lib/claude.js";
10
10
  import { tryExec } from "../lib/exec.js";
11
11
  import { getLatestVersion, isNewerVersion } from "../lib/version.js";
12
12
  import { ALLOWED_TYPES } from "../lib/branch.js";
13
- import { runCreate, runCreateDetached } from "../lib/worktreeCreate.js";
13
+ import { runCreate, runCreateDetached, } from "../lib/worktreeCreate.js";
14
14
  import { runRemove, runRemoveByPath } from "../lib/worktreeRemove.js";
15
15
  import { buildCreateMarkers, emitMarkers } from "../lib/markers.js";
16
16
  import { readMetadata } from "../lib/metadata.js";
@@ -186,7 +186,14 @@ function CreateOverlayView({ overlay, onDescChange, onPromptChange, }) {
186
186
  const dirPreview = isNewBranch
187
187
  ? `${overlay.issue.issue.id}-${overlay.desc}`
188
188
  : `${overlay.issue.issue.id}-${detachedDesc}`;
189
- return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Create worktree" }), _jsx(Text, { dimColor: true, children: ` for ${overlay.issue.issue.id}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "branchMode", children: overlay.field === "branchMode" ? "▸ Branch:" : " Branch:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "branchMode" ? "cyan" : undefined, bold: overlay.field === "branchMode", children: isNewBranch ? "new" : `current (${overlay.currentBranch ?? "?"})` }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "branchMode" && _jsx(Text, { dimColor: true, children: " (use ← / → to toggle)" })] }), isNewBranch && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "type", children: overlay.field === "type" ? "▸ Type:" : " Type:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "type" ? "cyan" : undefined, bold: overlay.field === "type", children: overlay.type }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "type" && _jsx(Text, { dimColor: true, children: " (use ← / → to cycle)" })] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "desc", children: overlay.field === "desc" ? "▸ Description:" : " Description:" }) }), _jsx(Box, { children: overlay.field === "desc" ? (_jsx(TextInput, { value: overlay.desc, onChange: onDescChange, placeholder: "kebab-case" })) : (_jsx(Text, { children: overlay.desc || "(empty)" })) })] })] })), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "prompt", children: overlay.field === "prompt" ? "▸ Prompt:" : " Prompt:" }) }), _jsx(Box, { children: overlay.field === "prompt" ? (_jsx(TextInput, { value: overlay.prompt, onChange: onPromptChange, placeholder: "(empty = no initial message)" })) : (_jsx(Text, { dimColor: true, children: overlay.prompt || "(empty — Claude starts with no message)" })) })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Checkout:" }) }), _jsx(Text, { color: "green", children: branchPreview })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Worktree:" }) }), _jsxs(Text, { dimColor: true, children: [".mintree/worktrees/", dirPreview] })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Mode:" }) }), _jsx(Text, { dimColor: true, children: "--work (Claude launches in the new worktree)" })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [isNewBranch ? (_jsxs(Text, { dimColor: true, children: ["Suggestion is a kebab of the title (capped at ", SUGGESTED_DESC_MAX_WORDS, " words). Edit it to match your repo's branch conventions."] })) : (_jsxs(Text, { dimColor: true, children: ["Detached HEAD at the tip of ", overlay.currentBranch ?? "the current branch", ". No new branch is created \u2014 commit on a new one with `git switch -c` when ready."] })), isNewBranch && overlay.conventionDoc && (_jsx(Text, { dimColor: true, children: `This repo has \`${overlay.conventionDoc}\` — review it before creating.` }))] }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) })), overlay.pending && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", overlay.pending] })] }))] }));
189
+ return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Create worktree" }), _jsx(Text, { dimColor: true, children: ` for ${overlay.issue.issue.id}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "branchMode", children: overlay.field === "branchMode" ? "▸ Branch:" : " Branch:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "branchMode" ? "cyan" : undefined, bold: overlay.field === "branchMode", children: isNewBranch ? "new" : `current (${overlay.currentBranch ?? "?"})` }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "branchMode" && _jsx(Text, { dimColor: true, children: " (use ← / → to toggle)" })] }), isNewBranch && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "type", children: overlay.field === "type" ? "▸ Type:" : " Type:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "type" ? "cyan" : undefined, bold: overlay.field === "type", children: overlay.type }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "type" && _jsx(Text, { dimColor: true, children: " (use ← / → to cycle)" })] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "desc", children: overlay.field === "desc" ? "▸ Description:" : " Description:" }) }), _jsx(Box, { children: overlay.field === "desc" ? (_jsx(TextInput, { value: overlay.desc, onChange: onDescChange, placeholder: "kebab-case" })) : (_jsx(Text, { children: overlay.desc || "(empty)" })) })] })] })), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "prompt", children: overlay.field === "prompt" ? "▸ Prompt:" : " Prompt:" }) }), _jsx(Box, { children: overlay.field === "prompt" ? (_jsx(TextInput, { value: overlay.prompt, onChange: onPromptChange, placeholder: "(empty = no initial message)" })) : (_jsx(Text, { dimColor: true, children: overlay.prompt || "(empty — Claude starts with no message)" })) })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Checkout:" }) }), _jsx(Text, { color: "green", children: branchPreview })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Worktree:" }) }), _jsxs(Text, { dimColor: true, children: [".mintree/worktrees/", dirPreview] })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Mode:" }) }), _jsx(Text, { dimColor: true, children: "--work (Claude launches in the new worktree)" })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [isNewBranch ? (_jsxs(Text, { dimColor: true, children: ["Suggestion is a kebab of the title (capped at ", SUGGESTED_DESC_MAX_WORDS, " words). Edit it to match your repo's branch conventions."] })) : (_jsxs(Text, { dimColor: true, children: ["Detached HEAD at the tip of ", overlay.currentBranch ?? "the current branch", ". No new branch is created \u2014 commit on a new one with `git switch -c` when ready."] })), isNewBranch && overlay.conventionDoc && (_jsx(Text, { dimColor: true, children: `This repo has \`${overlay.conventionDoc}\` — review it before creating.` }))] }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) })), overlay.steps.length > 0 && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: overlay.steps.map((step, i) => (_jsxs(Box, { children: [_jsx(CreateStepIcon, { kind: step.kind }), _jsx(Text, { children: " " }), _jsx(Text, { children: step.label }), step.detail && _jsxs(Text, { dimColor: true, children: [" (", step.detail, ")"] })] }, i))) })), overlay.pending && (_jsxs(Box, { marginTop: overlay.steps.length > 0 ? 0 : 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", overlay.pending] })] }))] }));
190
+ }
191
+ function CreateStepIcon({ kind }) {
192
+ if (kind === "ok")
193
+ return _jsx(Text, { color: "green", children: "\u2713" });
194
+ if (kind === "warn")
195
+ return _jsx(Text, { color: "yellow", children: "!" });
196
+ return _jsx(Text, { color: "cyan", children: "\u25CB" });
190
197
  }
191
198
  function IssueListRow({ d, selected, identifierWidth, rowWidth, }) {
192
199
  // Display the issue id raw (e.g. "FE-123", "100"). The `#` prefix is a
@@ -888,6 +895,7 @@ export default function Dashboard() {
888
895
  error: null,
889
896
  conventionDoc: root ? findBranchConventionDoc(root) : null,
890
897
  pending: null,
898
+ steps: [],
891
899
  },
892
900
  toast: null,
893
901
  });
@@ -985,22 +993,37 @@ export default function Dashboard() {
985
993
  });
986
994
  return;
987
995
  }
988
- // Surface the spinner BEFORE the heavy sync work. runCreate /
989
- // runCreateDetached both block the event loop (execSync: git fetch +
990
- // worktree add + optional .mintree/init.sh — easily several seconds on
991
- // slow remotes or a repo with a real init script). Without yielding
992
- // here, Ink wouldn't get to paint the spinner before execSync blocks
993
- // the event loop, leaving the user staring at a frozen overlay.
994
- //
995
- // A single setImmediate isn't enough: React 19 commits the state on
996
- // the next microtask, then Ink schedules its stdout write on a later
997
- // macrotask. A ~32ms setTimeout (two frames at 60fps) covers both
998
- // phases reliably without being perceptible.
996
+ // Enter the live-setup view: clear input chrome, reset the step log,
997
+ // show a starting spinner. The actual progress updates come through
998
+ // the runCreate/runCreateDetached callbacks below.
999
999
  setState({
1000
1000
  ...state,
1001
- overlay: { ...overlay, error: null, pending: "Creating worktree..." },
1001
+ overlay: { ...overlay, error: null, pending: "Starting...", steps: [] },
1002
1002
  });
1003
1003
  await new Promise((resolve) => setTimeout(resolve, 32));
1004
+ // Progress callbacks invoked from inside runCreate/runCreateDetached.
1005
+ // Use functional setState so we don't clobber concurrent updates and
1006
+ // don't rely on the stale closure of `state`.
1007
+ const onStep = (step) => {
1008
+ setState((prev) => {
1009
+ if (prev.phase !== "ready" || prev.overlay?.kind !== "create")
1010
+ return prev;
1011
+ return {
1012
+ ...prev,
1013
+ overlay: { ...prev.overlay, steps: [...prev.overlay.steps, step] },
1014
+ };
1015
+ });
1016
+ };
1017
+ const onPending = (label) => {
1018
+ setState((prev) => {
1019
+ if (prev.phase !== "ready" || prev.overlay?.kind !== "create")
1020
+ return prev;
1021
+ return {
1022
+ ...prev,
1023
+ overlay: { ...prev.overlay, pending: label },
1024
+ };
1025
+ });
1026
+ };
1004
1027
  const prompt = overlay.prompt.trim();
1005
1028
  const issueId = overlay.issue.issue.id;
1006
1029
  let result;
@@ -1009,29 +1032,35 @@ export default function Dashboard() {
1009
1032
  // from the issue title (kebabized), not user input — keeping the
1010
1033
  // "current branch" flow as low-friction as possible.
1011
1034
  const descKebab = kebabize(overlay.issue.issue.title) || `issue-${issueId}`;
1012
- result = runCreateDetached({
1035
+ result = await runCreateDetached({
1013
1036
  issueId,
1014
1037
  descKebab,
1015
1038
  work: true,
1039
+ progress: { onStep, onPending },
1016
1040
  ...(prompt.length > 0 ? { prompt } : {}),
1017
1041
  });
1018
1042
  }
1019
1043
  else {
1020
1044
  const desc = overlay.desc.trim();
1021
1045
  const branch = `${overlay.type}/${issueId}-${desc}`;
1022
- result = runCreate(branch, {
1046
+ result = await runCreate(branch, {
1023
1047
  work: true,
1048
+ progress: { onStep, onPending },
1024
1049
  ...(prompt.length > 0 ? { prompt } : {}),
1025
1050
  });
1026
1051
  }
1027
1052
  if (!result.ok) {
1028
- setState({
1029
- ...state,
1030
- overlay: {
1031
- ...overlay,
1032
- pending: null,
1033
- error: result.message + (result.hint ? ` — ${result.hint}` : ""),
1034
- },
1053
+ setState((prev) => {
1054
+ if (prev.phase !== "ready" || prev.overlay?.kind !== "create")
1055
+ return prev;
1056
+ return {
1057
+ ...prev,
1058
+ overlay: {
1059
+ ...prev.overlay,
1060
+ pending: null,
1061
+ error: result.ok ? null : result.message + (result.hint ? ` — ${result.hint}` : ""),
1062
+ },
1063
+ };
1035
1064
  });
1036
1065
  return;
1037
1066
  }
@@ -1039,9 +1068,15 @@ export default function Dashboard() {
1039
1068
  // to In Progress on its project. Errors from the GraphQL call don't
1040
1069
  // block the worktree hand-off; we swallow them and let `mintree doctor`
1041
1070
  // surface persistent issues (missing `project` scope, etc.).
1042
- setState({
1043
- ...state,
1044
- overlay: { ...overlay, error: null, pending: "Updating issue status..." },
1071
+ // Functional update preserves the accumulated `steps` list from the
1072
+ // progress callbacks; using the stale `overlay` closure would wipe it.
1073
+ setState((prev) => {
1074
+ if (prev.phase !== "ready" || prev.overlay?.kind !== "create")
1075
+ return prev;
1076
+ return {
1077
+ ...prev,
1078
+ overlay: { ...prev.overlay, error: null, pending: "Updating issue status..." },
1079
+ };
1045
1080
  });
1046
1081
  const repoRoot = findMainRepoRoot();
1047
1082
  if (repoRoot) {
@@ -55,19 +55,20 @@ export default function Create({ args, options }) {
55
55
  const [result, setResult] = useState(null);
56
56
  const [transition, setTransition] = useState("idle");
57
57
  useEffect(() => {
58
- setTimeout(() => {
58
+ (async () => {
59
59
  try {
60
- setResult(runCreate(branch, {
60
+ const r = await runCreate(branch, {
61
61
  base: options.base,
62
62
  work: options.work,
63
63
  prompt: options.prompt,
64
64
  permissionMode: options.permissionMode,
65
- }));
65
+ });
66
+ setResult(r);
66
67
  }
67
68
  catch (err) {
68
69
  setResult({ ok: false, message: err instanceof Error ? err.message : String(err) });
69
70
  }
70
- }, 0);
71
+ })();
71
72
  }, [branch, options.base, options.work, options.prompt, options.permissionMode]);
72
73
  // Kick the Project v2 transition once the worktree is in place. Only when
73
74
  // --work was on — non-work creates leave status untouched. Errors from the
@@ -5,11 +5,24 @@ export type CreateStep = {
5
5
  label: string;
6
6
  detail?: string;
7
7
  };
8
+ /**
9
+ * Optional progress callbacks used by the dashboard overlay to render a
10
+ * live setup log (santree-style). `onPending(label)` highlights the
11
+ * currently running blocking operation (rendered with a spinner); call
12
+ * `onPending(null)` when it ends. `onStep(step)` appends a completed step
13
+ * to the log. Between every emission the implementation yields the event
14
+ * loop for one frame so Ink can paint before the next blocking section.
15
+ */
16
+ export type ProgressCallbacks = {
17
+ onStep?: (step: CreateStep) => void;
18
+ onPending?: (label: string | null) => void;
19
+ };
8
20
  export type CreateOpts = {
9
21
  base?: string;
10
22
  work: boolean;
11
23
  prompt?: string;
12
24
  permissionMode?: PermissionMode;
25
+ progress?: ProgressCallbacks;
13
26
  };
14
27
  export type CreateResult = {
15
28
  ok: true;
@@ -32,14 +45,18 @@ export type CreateResult = {
32
45
  * resolves a base branch, runs `git worktree add`, persists metadata, runs
33
46
  * the optional `.mintree/init.sh`, and stages the --prompt to a temp file
34
47
  * for the work hand-off when relevant.
48
+ *
49
+ * Async only because progress callbacks need event-loop yields between
50
+ * blocking sections; without them the dashboard overlay would freeze.
35
51
  */
36
- export declare function runCreate(branchArg: string, opts: CreateOpts): CreateResult;
52
+ export declare function runCreate(branchArg: string, opts: CreateOpts): Promise<CreateResult>;
37
53
  export type CreateDetachedOpts = {
38
54
  issueId: string;
39
55
  descKebab: string;
40
56
  work: boolean;
41
57
  prompt?: string;
42
58
  permissionMode?: PermissionMode;
59
+ progress?: ProgressCallbacks;
43
60
  };
44
61
  /**
45
62
  * Variant of `runCreate` that doesn't create a new branch — the worktree is
@@ -53,4 +70,4 @@ export type CreateDetachedOpts = {
53
70
  * branch-based flow so `worktree work` can still recover the issueId from
54
71
  * the dir name (where it can't read it from the branch).
55
72
  */
56
- export declare function runCreateDetached(opts: CreateDetachedOpts): CreateResult;
73
+ export declare function runCreateDetached(opts: CreateDetachedOpts): Promise<CreateResult>;
@@ -37,14 +37,26 @@ function writePromptFile(prompt) {
37
37
  fs.writeFileSync(filePath, prompt);
38
38
  return filePath;
39
39
  }
40
+ // Wait one frame (~16ms) so Ink has time to commit + paint the latest
41
+ // state before the next blocking execSync. No-op when no progress
42
+ // callbacks are set — CLI invocations skip the cost entirely.
43
+ function nextFrame(progress) {
44
+ if (!progress || (!progress.onStep && !progress.onPending))
45
+ return Promise.resolve();
46
+ return new Promise((resolve) => setTimeout(resolve, 16));
47
+ }
40
48
  /**
41
49
  * The whole `worktree create` flow as a pure function — same code path used
42
50
  * by the CLI command and by the dashboard's `w` overlay. Validates input,
43
51
  * resolves a base branch, runs `git worktree add`, persists metadata, runs
44
52
  * the optional `.mintree/init.sh`, and stages the --prompt to a temp file
45
53
  * for the work hand-off when relevant.
54
+ *
55
+ * Async only because progress callbacks need event-loop yields between
56
+ * blocking sections; without them the dashboard overlay would freeze.
46
57
  */
47
- export function runCreate(branchArg, opts) {
58
+ export async function runCreate(branchArg, opts) {
59
+ const progress = opts.progress;
48
60
  const root = findMainRepoRoot();
49
61
  if (!root) {
50
62
  return {
@@ -81,18 +93,27 @@ export function runCreate(branchArg, opts) {
81
93
  };
82
94
  }
83
95
  const steps = [];
84
- steps.push({
96
+ const pushStep = (step) => {
97
+ steps.push(step);
98
+ progress?.onStep?.(step);
99
+ };
100
+ pushStep({
85
101
  kind: "ok",
86
102
  label: "parsed branch",
87
103
  detail: `type=${parsed.type}, issue=${parsed.issueId}, desc=${parsed.desc}`,
88
104
  });
105
+ await nextFrame(progress);
89
106
  // Fetch before resolving refs so the worktree forks from fresh code, not a
90
107
  // stale local checkout. Best-effort: offline / no-remote just warns and we
91
108
  // fall back to whatever is already local.
109
+ progress?.onPending?.("Fetching origin...");
110
+ await nextFrame(progress);
92
111
  const fetch = fetchRemote(root);
93
- steps.push(fetch.ok
112
+ progress?.onPending?.(null);
113
+ pushStep(fetch.ok
94
114
  ? { kind: "ok", label: "fetched origin", detail: "refs up to date" }
95
115
  : { kind: "warn", label: "skipped git fetch", detail: fetch.reason });
116
+ await nextFrame(progress);
96
117
  const existence = branchExists(root, parsed.branch);
97
118
  let base;
98
119
  if (existence === null) {
@@ -119,10 +140,13 @@ export function runCreate(branchArg, opts) {
119
140
  if (existence === null && base && fetch.ok && remoteBranchExists(root, base)) {
120
141
  baseRef = `origin/${base}`;
121
142
  }
143
+ progress?.onPending?.("Creating worktree...");
144
+ await nextFrame(progress);
122
145
  try {
123
146
  addWorktree({ repoRoot: root, branch: parsed.branch, worktreePath, base: baseRef });
124
147
  }
125
148
  catch (err) {
149
+ progress?.onPending?.(null);
126
150
  const stderr = err && typeof err === "object" && "stderr" in err
127
151
  ? String(err.stderr).trim()
128
152
  : err instanceof Error
@@ -130,48 +154,58 @@ export function runCreate(branchArg, opts) {
130
154
  : String(err);
131
155
  return { ok: false, message: `git worktree add failed: ${stderr}` };
132
156
  }
157
+ progress?.onPending?.(null);
133
158
  if (existence === "remote") {
134
- steps.push({
159
+ pushStep({
135
160
  kind: "ok",
136
161
  label: "checked out tracking branch",
137
162
  detail: `from origin/${parsed.branch}`,
138
163
  });
139
164
  }
140
165
  else if (existence === "local") {
141
- steps.push({
166
+ pushStep({
142
167
  kind: "ok",
143
168
  label: "checked out existing local branch",
144
169
  detail: parsed.branch,
145
170
  });
146
171
  }
147
172
  else {
148
- steps.push({
173
+ pushStep({
149
174
  kind: "ok",
150
175
  label: "created new branch",
151
176
  detail: `${parsed.branch} (from ${baseRef})`,
152
177
  });
153
178
  }
154
- steps.push({ kind: "ok", label: "worktree created", detail: worktreePath });
179
+ await nextFrame(progress);
180
+ pushStep({ kind: "ok", label: "worktree created", detail: worktreePath });
181
+ await nextFrame(progress);
155
182
  upsertIssue(root, parsed.issueId, base ? { base_branch: base } : {});
156
- steps.push({ kind: "ok", label: "metadata updated", detail: `issue ${parsed.issueId}` });
183
+ pushStep({ kind: "ok", label: "metadata updated", detail: `issue ${parsed.issueId}` });
184
+ await nextFrame(progress);
157
185
  const initShPath = getInitScriptPath(root);
186
+ if (pathExists(initShPath)) {
187
+ progress?.onPending?.("Running .mintree/init.sh...");
188
+ await nextFrame(progress);
189
+ }
158
190
  const initResult = tryRunInitScript(initShPath, worktreePath, root);
191
+ progress?.onPending?.(null);
159
192
  if (initResult.ran) {
160
- steps.push({ kind: "ok", label: "ran .mintree/init.sh", detail: worktreePath });
193
+ pushStep({ kind: "ok", label: "ran .mintree/init.sh", detail: worktreePath });
161
194
  }
162
195
  else if (initResult.error) {
163
- steps.push({ kind: "warn", label: "init.sh failed", detail: initResult.error });
196
+ pushStep({ kind: "warn", label: "init.sh failed", detail: initResult.error });
164
197
  }
165
198
  else if (!pathExists(initShPath)) {
166
- steps.push({ kind: "skip", label: "no init.sh (skipping post-create hook)" });
199
+ pushStep({ kind: "skip", label: "no init.sh (skipping post-create hook)" });
167
200
  }
201
+ await nextFrame(progress);
168
202
  let promptFile;
169
203
  if (opts.work && opts.prompt && opts.prompt.length > 0) {
170
204
  try {
171
205
  promptFile = writePromptFile(opts.prompt);
172
206
  }
173
207
  catch (err) {
174
- steps.push({
208
+ pushStep({
175
209
  kind: "warn",
176
210
  label: "failed to stage --prompt for hand-off",
177
211
  detail: err instanceof Error ? err.message : String(err),
@@ -179,7 +213,7 @@ export function runCreate(branchArg, opts) {
179
213
  }
180
214
  }
181
215
  if (!opts.work && (opts.prompt || opts.permissionMode)) {
182
- steps.push({
216
+ pushStep({
183
217
  kind: "warn",
184
218
  label: "ignoring --prompt / --permission-mode (only meaningful with --work)",
185
219
  });
@@ -208,7 +242,8 @@ export function runCreate(branchArg, opts) {
208
242
  * branch-based flow so `worktree work` can still recover the issueId from
209
243
  * the dir name (where it can't read it from the branch).
210
244
  */
211
- export function runCreateDetached(opts) {
245
+ export async function runCreateDetached(opts) {
246
+ const progress = opts.progress;
212
247
  const root = findMainRepoRoot();
213
248
  if (!root) {
214
249
  return {
@@ -255,22 +290,34 @@ export function runCreateDetached(opts) {
255
290
  };
256
291
  }
257
292
  const steps = [];
258
- steps.push({
293
+ const pushStep = (step) => {
294
+ steps.push(step);
295
+ progress?.onStep?.(step);
296
+ };
297
+ pushStep({
259
298
  kind: "ok",
260
299
  label: "detached worktree",
261
300
  detail: `issue=${opts.issueId}, base=${currentBranch}`,
262
301
  });
302
+ await nextFrame(progress);
263
303
  // Fetch so the detached worktree forks from the fresh remote tip of the
264
304
  // current branch instead of a stale local checkout. Best-effort.
305
+ progress?.onPending?.("Fetching origin...");
306
+ await nextFrame(progress);
265
307
  const fetch = fetchRemote(root);
266
- steps.push(fetch.ok
308
+ progress?.onPending?.(null);
309
+ pushStep(fetch.ok
267
310
  ? { kind: "ok", label: "fetched origin", detail: "refs up to date" }
268
311
  : { kind: "warn", label: "skipped git fetch", detail: fetch.reason });
312
+ await nextFrame(progress);
269
313
  const baseRef = fetch.ok && remoteBranchExists(root, currentBranch) ? `origin/${currentBranch}` : currentBranch;
314
+ progress?.onPending?.("Creating worktree...");
315
+ await nextFrame(progress);
270
316
  try {
271
317
  execSync(`git worktree add --detach '${worktreePath.replace(/'/g, `'\\''`)}' '${baseRef.replace(/'/g, `'\\''`)}'`, { cwd: root, stdio: ["ignore", "pipe", "pipe"] });
272
318
  }
273
319
  catch (err) {
320
+ progress?.onPending?.(null);
274
321
  const stderr = err && typeof err === "object" && "stderr" in err
275
322
  ? String(err.stderr).trim()
276
323
  : err instanceof Error
@@ -278,32 +325,42 @@ export function runCreateDetached(opts) {
278
325
  : String(err);
279
326
  return { ok: false, message: `git worktree add --detach failed: ${stderr}` };
280
327
  }
281
- steps.push({
328
+ progress?.onPending?.(null);
329
+ pushStep({
282
330
  kind: "ok",
283
331
  label: "checked out detached HEAD",
284
332
  detail: `at tip of ${baseRef}`,
285
333
  });
286
- steps.push({ kind: "ok", label: "worktree created", detail: worktreePath });
334
+ await nextFrame(progress);
335
+ pushStep({ kind: "ok", label: "worktree created", detail: worktreePath });
336
+ await nextFrame(progress);
287
337
  upsertIssue(root, opts.issueId, { base_branch: currentBranch });
288
- steps.push({ kind: "ok", label: "metadata updated", detail: `issue ${opts.issueId}` });
338
+ pushStep({ kind: "ok", label: "metadata updated", detail: `issue ${opts.issueId}` });
339
+ await nextFrame(progress);
289
340
  const initShPath = getInitScriptPath(root);
341
+ if (pathExists(initShPath)) {
342
+ progress?.onPending?.("Running .mintree/init.sh...");
343
+ await nextFrame(progress);
344
+ }
290
345
  const initResult = tryRunInitScript(initShPath, worktreePath, root);
346
+ progress?.onPending?.(null);
291
347
  if (initResult.ran) {
292
- steps.push({ kind: "ok", label: "ran .mintree/init.sh", detail: worktreePath });
348
+ pushStep({ kind: "ok", label: "ran .mintree/init.sh", detail: worktreePath });
293
349
  }
294
350
  else if (initResult.error) {
295
- steps.push({ kind: "warn", label: "init.sh failed", detail: initResult.error });
351
+ pushStep({ kind: "warn", label: "init.sh failed", detail: initResult.error });
296
352
  }
297
353
  else if (!pathExists(initShPath)) {
298
- steps.push({ kind: "skip", label: "no init.sh (skipping post-create hook)" });
354
+ pushStep({ kind: "skip", label: "no init.sh (skipping post-create hook)" });
299
355
  }
356
+ await nextFrame(progress);
300
357
  let promptFile;
301
358
  if (opts.work && opts.prompt && opts.prompt.length > 0) {
302
359
  try {
303
360
  promptFile = writePromptFile(opts.prompt);
304
361
  }
305
362
  catch (err) {
306
- steps.push({
363
+ pushStep({
307
364
  kind: "warn",
308
365
  label: "failed to stage --prompt for hand-off",
309
366
  detail: err instanceof Error ? err.message : String(err),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mintree",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Issue-driven git worktrees + Claude Code sessions for repos with an opinionated SDD+TDD flow.",
5
5
  "license": "MIT",
6
6
  "author": "Martin Mineo <mmineo@canarytechnologies.com>",