santree 0.0.14 → 0.0.16

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/README.md CHANGED
@@ -81,6 +81,7 @@ santree clean
81
81
  | `santree sync` | Sync current worktree with base branch |
82
82
  | `santree setup` | Run the init script (`.santree/init.sh`) |
83
83
  | `santree work` | Launch Claude AI to work on the current ticket |
84
+ | `santree pr` | Create a GitHub pull request (opens in browser) |
84
85
  | `santree clean` | Remove worktrees with merged/closed PRs |
85
86
  | `santree doctor` | Check system requirements and integrations |
86
87
  | `santree editor` | Open workspace file in VSCode or Cursor |
@@ -179,6 +180,13 @@ Shows worktrees with merged/closed PRs and prompts for confirmation before remov
179
180
  |--------|-------------|
180
181
  | `--editor <cmd>` | Editor command to use (default: `code`). Also configurable via `SANTREE_EDITOR` env var |
181
182
 
183
+ ### pr
184
+ | Option | Description |
185
+ |--------|-------------|
186
+ | `--fill` | Use AI to fill the PR template before opening |
187
+
188
+ Automatically pushes, detects existing PRs, and uses the first commit message as the title. If a closed PR exists for the branch, prompts before creating a new one.
189
+
182
190
  ### work
183
191
  | Option | Description |
184
192
  |--------|-------------|
@@ -3,10 +3,10 @@ import { useEffect, useState } from "react";
3
3
  import { Text, Box } from "ink";
4
4
  import Spinner from "ink-spinner";
5
5
  import { z } from "zod";
6
- import { spawn } from "child_process";
6
+ import { execSync } from "child_process";
7
7
  import * as fs from "fs";
8
8
  import { createWorktree, findMainRepoRoot, getDefaultBranch, pullLatest, hasInitScript, getInitScriptPath, extractTicketId, } from "../lib/git.js";
9
- import { execSync } from "child_process";
9
+ import { spawnAsync } from "../lib/exec.js";
10
10
  export const description = "Create a new worktree from a branch";
11
11
  export const options = z.object({
12
12
  base: z.string().optional().describe("Base branch to create from"),
@@ -136,27 +136,18 @@ export default function Create({ options, args }) {
136
136
  finalize(result.path, branch);
137
137
  return;
138
138
  }
139
- const child = spawn(initScript, [], {
139
+ const initResult = await spawnAsync(initScript, [], {
140
140
  cwd: result.path,
141
- stdio: "pipe",
142
141
  env: {
143
142
  ...process.env,
144
143
  SANTREE_WORKTREE_PATH: result.path,
145
144
  SANTREE_REPO_ROOT: mainRepo,
146
145
  },
147
146
  });
148
- // Capture output but don't display (to avoid conflicting with Ink)
149
- child.stdout?.on("data", () => { });
150
- child.stderr?.on("data", () => { });
151
- child.on("error", (err) => {
152
- setMessage(`Warning: Init script failed: ${err.message}`);
153
- });
154
- child.on("close", (code) => {
155
- if (code !== 0) {
156
- setMessage(`Warning: Init script exited with code ${code}`);
157
- }
158
- finalize(result.path, branch);
159
- });
147
+ if (initResult.code !== 0) {
148
+ setMessage(`Warning: Init script exited with code ${initResult.code}`);
149
+ }
150
+ finalize(result.path, branch);
160
151
  }
161
152
  else {
162
153
  finalize(result.path, branch);
@@ -2,10 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import Spinner from "ink-spinner";
4
4
  import { useEffect, useState } from "react";
5
- import { exec } from "child_process";
5
+ import { exec, execSync } from "child_process";
6
6
  import { promisify } from "util";
7
7
  import * as fs from "fs";
8
8
  import * as path from "path";
9
+ import { findMainRepoRoot, getSantreeDir, getInitScriptPath, } from "../lib/git.js";
9
10
  const execAsync = promisify(exec);
10
11
  export const description = "Check system requirements and integrations";
11
12
  /**
@@ -138,7 +139,9 @@ async function checkLinearMcp() {
138
139
  configured: true,
139
140
  url: urlMatch?.[1],
140
141
  status,
141
- hint: isConnected ? undefined : "Open Linear MCP URL in browser to authenticate",
142
+ hint: isConnected
143
+ ? undefined
144
+ : "Open Linear MCP URL in browser to authenticate",
142
145
  };
143
146
  }
144
147
  return {
@@ -177,7 +180,8 @@ async function checkStatusline() {
177
180
  if (settings.statusLine?.command) {
178
181
  currentCommand = String(settings.statusLine.command);
179
182
  // Check if it points to santree statusline
180
- claudeSettingsConfigured = currentCommand.includes("santree statusline");
183
+ claudeSettingsConfigured =
184
+ currentCommand.includes("santree statusline");
181
185
  }
182
186
  }
183
187
  }
@@ -186,7 +190,8 @@ async function checkStatusline() {
186
190
  }
187
191
  let hint;
188
192
  if (!claudeSettingsConfigured) {
189
- hint = 'Add to ~/.claude/settings.json: "statusLine": { "type": "command", "command": "santree statusline" }';
193
+ hint =
194
+ 'Add to ~/.claude/settings.json: "statusLine": { "type": "command", "command": "santree statusline" }';
190
195
  }
191
196
  return {
192
197
  claudeSettingsConfigured,
@@ -194,6 +199,78 @@ async function checkStatusline() {
194
199
  hint,
195
200
  };
196
201
  }
202
+ /**
203
+ * Checks if a path is gitignored (via .gitignore or .git/info/exclude).
204
+ */
205
+ function isGitIgnored(filePath, cwd) {
206
+ try {
207
+ execSync(`git check-ignore -q "${filePath}"`, { cwd, stdio: "ignore" });
208
+ return true; // exit 0 = ignored
209
+ }
210
+ catch {
211
+ return false; // exit 1 = not ignored
212
+ }
213
+ }
214
+ /**
215
+ * Checks if the current directory is a git repo and if .santree/init.sh exists and is executable.
216
+ */
217
+ function checkSantreeSetup() {
218
+ const mainRepoRoot = findMainRepoRoot();
219
+ if (!mainRepoRoot) {
220
+ return {
221
+ isGitRepo: false,
222
+ santreeFolderExists: false,
223
+ initShExists: false,
224
+ initShExecutable: false,
225
+ worktreesIgnored: false,
226
+ metadataIgnored: false,
227
+ hints: ["Not in a git repository"],
228
+ };
229
+ }
230
+ const santreeDir = getSantreeDir(mainRepoRoot);
231
+ const initShPath = getInitScriptPath(mainRepoRoot);
232
+ const santreeFolderExists = fs.existsSync(santreeDir);
233
+ const initShExists = fs.existsSync(initShPath);
234
+ let initShExecutable = false;
235
+ if (initShExists) {
236
+ try {
237
+ fs.accessSync(initShPath, fs.constants.X_OK);
238
+ initShExecutable = true;
239
+ }
240
+ catch {
241
+ initShExecutable = false;
242
+ }
243
+ }
244
+ // Check gitignore status (use relative paths for git check-ignore)
245
+ const worktreesIgnored = isGitIgnored(".santree/worktrees", mainRepoRoot);
246
+ const metadataIgnored = isGitIgnored(".santree/metadata.json", mainRepoRoot);
247
+ const hints = [];
248
+ if (!santreeFolderExists) {
249
+ hints.push(`Create .santree folder: mkdir ${santreeDir}`);
250
+ }
251
+ else if (!initShExists) {
252
+ hints.push(`Create init.sh: touch ${initShPath} && chmod +x ${initShPath}`);
253
+ }
254
+ else if (!initShExecutable) {
255
+ hints.push(`Make init.sh executable: chmod +x ${initShPath}`);
256
+ }
257
+ if (!worktreesIgnored) {
258
+ hints.push("Add .santree/worktrees to .gitignore");
259
+ }
260
+ if (!metadataIgnored) {
261
+ hints.push("Add .santree/metadata.json to .gitignore");
262
+ }
263
+ return {
264
+ isGitRepo: true,
265
+ mainRepoRoot,
266
+ santreeFolderExists,
267
+ initShExists,
268
+ initShExecutable,
269
+ worktreesIgnored,
270
+ metadataIgnored,
271
+ hints,
272
+ };
273
+ }
197
274
  function StatusIcon({ ok, required }) {
198
275
  if (ok) {
199
276
  return _jsx(Text, { color: "green", children: "\u2713" });
@@ -204,7 +281,8 @@ function ToolRow({ tool }) {
204
281
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: tool.installed && !tool.hint, required: tool.required }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: tool.name }), _jsxs(Text, { dimColor: true, children: [" - ", tool.description] }), !tool.required && _jsx(Text, { dimColor: true, children: " (optional)" })] }), tool.installed ? (_jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Version: ", tool.version] }), _jsxs(Text, { dimColor: true, children: ["Path: ", tool.path] }), tool.authStatus && _jsxs(Text, { dimColor: true, children: ["Auth: ", tool.authStatus] }), tool.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.hint] })] })) : (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.hint] }) }))] }));
205
282
  }
206
283
  function McpRow({ mcp }) {
207
- const isOk = mcp.configured && Boolean(mcp.status?.includes("✓") || mcp.status?.includes("Connected"));
284
+ const isOk = mcp.configured &&
285
+ Boolean(mcp.status?.includes("✓") || mcp.status?.includes("Connected"));
208
286
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: isOk, required: true }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: mcp.name }), _jsx(Text, { dimColor: true, children: " - Linear ticket integration for Claude" })] }), mcp.configured ? (_jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [mcp.url && _jsxs(Text, { dimColor: true, children: ["URL: ", mcp.url] }), mcp.status && _jsxs(Text, { dimColor: true, children: ["Status: ", mcp.status] }), mcp.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", mcp.hint] })] })) : (mcp.hint && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", mcp.hint] }) })))] }));
209
287
  }
210
288
  function ShellRow({ configured, shell, }) {
@@ -213,11 +291,27 @@ function ShellRow({ configured, shell, }) {
213
291
  function StatuslineRow({ status }) {
214
292
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: status.claudeSettingsConfigured, required: false }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Claude Statusline" }), _jsx(Text, { dimColor: true, children: " - Custom statusline in Claude Code" }), _jsx(Text, { dimColor: true, children: " (optional)" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [status.currentCommand ? (_jsxs(Text, { dimColor: true, children: ["Command: ", status.currentCommand] })) : (_jsx(Text, { dimColor: true, children: "Command: not configured" })), status.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", status.hint] })] })] }));
215
293
  }
294
+ function SantreeSetupRow({ status }) {
295
+ const isOk = status.santreeFolderExists &&
296
+ status.initShExists &&
297
+ status.initShExecutable &&
298
+ status.worktreesIgnored &&
299
+ status.metadataIgnored;
300
+ if (!status.isGitRepo) {
301
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: false, required: false }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Repository Setup" }), _jsx(Text, { dimColor: true, children: " - .santree configuration" }), _jsx(Text, { dimColor: true, children: " (optional)" })] }), _jsx(Box, { marginLeft: 2, children: _jsx(Text, { dimColor: true, children: "Not in a git repository" }) })] }));
302
+ }
303
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: isOk, required: false }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Repository Setup" }), _jsx(Text, { dimColor: true, children: " - .santree configuration" }), _jsx(Text, { dimColor: true, children: " (optional)" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Main repo: ", status.mainRepoRoot] }), _jsxs(Text, { dimColor: true, children: [".santree folder: ", status.santreeFolderExists ? "exists" : "missing"] }), status.santreeFolderExists && (_jsxs(Text, { dimColor: true, children: ["init.sh:", " ", status.initShExists
304
+ ? status.initShExecutable
305
+ ? "executable"
306
+ : "not executable"
307
+ : "missing"] })), _jsxs(Text, { dimColor: true, children: [".santree/worktrees ignored: ", status.worktreesIgnored ? "yes" : "no"] }), _jsxs(Text, { dimColor: true, children: [".santree/metadata.json ignored:", " ", status.metadataIgnored ? "yes" : "no"] }), status.hints.map((hint, i) => (_jsxs(Text, { color: "yellow", children: ["\u21B3 ", hint] }, i)))] })] }));
308
+ }
216
309
  export default function Doctor() {
217
310
  const [tools, setTools] = useState([]);
218
311
  const [mcp, setMcp] = useState(null);
219
312
  const [shellStatus, setShellStatus] = useState(null);
220
313
  const [statusline, setStatusline] = useState(null);
314
+ const [santreeSetup, setSantreeSetup] = useState(null);
221
315
  const [loading, setLoading] = useState(true);
222
316
  useEffect(() => {
223
317
  async function runChecks() {
@@ -254,6 +348,7 @@ export default function Doctor() {
254
348
  setMcp(mcpResult);
255
349
  setShellStatus(checkShellIntegration());
256
350
  setStatusline(statuslineResult);
351
+ setSantreeSetup(checkSantreeSetup());
257
352
  setLoading(false);
258
353
  }
259
354
  runChecks();
@@ -263,9 +358,10 @@ export default function Doctor() {
263
358
  }
264
359
  const requiredMissing = tools.filter((t) => t.required && (!t.installed || t.hint));
265
360
  const optionalMissing = tools.filter((t) => !t.required && !t.installed);
266
- const mcpOk = mcp?.configured && (mcp?.status?.includes("✓") || mcp?.status?.includes("Connected"));
361
+ const mcpOk = mcp?.configured &&
362
+ (mcp?.status?.includes("✓") || mcp?.status?.includes("Connected"));
267
363
  const allRequired = requiredMissing.length === 0 && mcpOk && shellStatus?.configured;
268
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Santree Doctor" }) }), _jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "CLI Tools" }) }), tools.map((tool) => (_jsx(ToolRow, { tool: tool }, tool.name))), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Integrations" }) }), mcp && _jsx(McpRow, { mcp: mcp }), shellStatus && (_jsx(ShellRow, { configured: shellStatus.configured, shell: shellStatus.shell })), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Aesthetics" }) }), statusline && _jsx(StatuslineRow, { status: statusline }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: allRequired ? "green" : "yellow", paddingX: 2, children: allRequired ? (_jsx(Text, { color: "green", children: "All requirements satisfied! Santree is ready to use." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: [requiredMissing.length +
364
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Santree Doctor" }) }), _jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "CLI Tools" }) }), tools.map((tool) => (_jsx(ToolRow, { tool: tool }, tool.name))), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Integrations" }) }), mcp && _jsx(McpRow, { mcp: mcp }), shellStatus && (_jsx(ShellRow, { configured: shellStatus.configured, shell: shellStatus.shell })), santreeSetup && _jsx(SantreeSetupRow, { status: santreeSetup }), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Aesthetics" }) }), statusline && _jsx(StatuslineRow, { status: statusline }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: allRequired ? "green" : "yellow", paddingX: 2, children: allRequired ? (_jsx(Text, { color: "green", children: "All requirements satisfied! Santree is ready to use." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: [requiredMissing.length +
269
365
  (mcpOk ? 0 : 1) +
270
366
  (shellStatus?.configured ? 0 : 1), " ", "required item(s) need attention"] }), optionalMissing.length > 0 && (_jsxs(Text, { dimColor: true, children: [optionalMissing.length, " optional item(s) not installed"] }))] })) })] }));
271
367
  }
@@ -4,7 +4,7 @@ import { Text, Box } from "ink";
4
4
  import Spinner from "ink-spinner";
5
5
  import { exec } from "child_process";
6
6
  import { promisify } from "util";
7
- import { listWorktrees, getWorktreeMetadata, isWorktreePath, } from "../lib/git.js";
7
+ import { listWorktrees, getBaseBranch, isWorktreePath } from "../lib/git.js";
8
8
  import { getPRInfoAsync } from "../lib/github.js";
9
9
  export const description = "List all worktrees with status information";
10
10
  const execAsync = promisify(exec);
@@ -48,13 +48,10 @@ export default function List() {
48
48
  let prState = "";
49
49
  let status = "-";
50
50
  if (!isMain) {
51
- const metadata = getWorktreeMetadata(wt.path);
52
- if (metadata?.base_branch) {
53
- base = metadata.base_branch;
54
- }
51
+ base = wt.branch ? getBaseBranch(wt.branch) : base;
55
52
  // Run async operations in parallel
56
53
  const [aheadResult, dirtyResult, prInfo] = await Promise.all([
57
- base !== "-" ? getCommitsAhead(wt.path, base) : Promise.resolve(-1),
54
+ getCommitsAhead(wt.path, base),
58
55
  isDirty(wt.path),
59
56
  wt.branch ? getPRInfoAsync(wt.branch) : Promise.resolve(null),
60
57
  ]);
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  export declare const description = "Create a GitHub pull request";
3
3
  export declare const options: z.ZodObject<{
4
- draft: z.ZodOptional<z.ZodBoolean>;
4
+ fill: z.ZodOptional<z.ZodBoolean>;
5
5
  }, z.core.$strip>;
6
6
  type Props = {
7
7
  options: z.infer<typeof options>;
@@ -1,17 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from "react";
3
- import { Text, Box, useApp } from "ink";
4
- import TextInput from "ink-text-input";
3
+ import { Text, Box, useInput, useApp } from "ink";
5
4
  import Spinner from "ink-spinner";
6
5
  import { z } from "zod";
7
- import { exec } from "child_process";
6
+ import { exec, spawnSync } from "child_process";
8
7
  import { promisify } from "util";
9
- import { findMainRepoRoot, findRepoRoot, getCurrentBranch, getDefaultBranch, getWorktreeMetadata, hasUncommittedChanges, getCommitsAhead, remoteBranchExists, getUnpushedCommits, extractTicketId, isInWorktree, getLatestCommitMessage, } from "../lib/git.js";
10
- import { ghCliAvailable, getPRInfoAsync, pushBranch, createPR, } from "../lib/github.js";
8
+ import { join } from "path";
9
+ import { writeFileSync } from "fs";
10
+ import { tmpdir } from "os";
11
+ import { findMainRepoRoot, findRepoRoot, getCurrentBranch, getBaseBranch, hasUncommittedChanges, getCommitsAhead, remoteBranchExists, getUnpushedCommits, extractTicketId, isInWorktree, getFirstCommitMessage, getCommitLog, getDiffStat, getDiffContent, } from "../lib/git.js";
12
+ import { ghCliAvailable, getPRInfoAsync, pushBranch, createPR, getPRTemplate, } from "../lib/github.js";
13
+ import { renderPrompt } from "../lib/prompts.js";
11
14
  const execAsync = promisify(exec);
12
15
  export const description = "Create a GitHub pull request";
13
16
  export const options = z.object({
14
- draft: z.boolean().optional().describe("Create as draft PR"),
17
+ fill: z.boolean().optional().describe("Use AI to fill the PR template"),
15
18
  });
16
19
  export default function PR({ options }) {
17
20
  const { exit } = useApp();
@@ -20,27 +23,78 @@ export default function PR({ options }) {
20
23
  const [branch, setBranch] = useState(null);
21
24
  const [baseBranch, setBaseBranch] = useState(null);
22
25
  const [issueId, setIssueId] = useState(null);
23
- const [titleInput, setTitleInput] = useState("");
24
- async function handleTitleSubmit(value) {
25
- const finalTitle = value.trim();
26
- if (!finalTitle) {
26
+ const [closedPrInfo, setClosedPrInfo] = useState(null);
27
+ const [pendingCreate, setPendingCreate] = useState(false);
28
+ useInput((input, key) => {
29
+ if (status !== "confirm-reopen")
30
+ return;
31
+ if (input === "y" || input === "Y") {
32
+ setClosedPrInfo(null);
33
+ setPendingCreate(true);
34
+ }
35
+ else if (input === "n" || input === "N" || key.escape) {
27
36
  setStatus("error");
28
- setMessage("PR title is required");
37
+ setMessage("Cancelled");
29
38
  setTimeout(() => exit(), 100);
30
- return;
31
39
  }
40
+ });
41
+ useEffect(() => {
42
+ if (!pendingCreate || !branch || !baseBranch)
43
+ return;
44
+ setPendingCreate(false);
45
+ openPR();
46
+ }, [pendingCreate]);
47
+ function openPR() {
32
48
  if (!branch || !baseBranch)
33
49
  return;
50
+ const title = getFirstCommitMessage(baseBranch) ?? branch;
51
+ let bodyFile;
52
+ if (options.fill) {
53
+ setStatus("filling");
54
+ setMessage("Filling PR template with AI...");
55
+ const prTemplate = getPRTemplate();
56
+ if (!prTemplate) {
57
+ setStatus("error");
58
+ setMessage("No PR template found at .github/pull_request_template.md");
59
+ setTimeout(() => exit(), 100);
60
+ return;
61
+ }
62
+ const commitLog = getCommitLog(baseBranch) ?? "";
63
+ const diffStat = getDiffStat(baseBranch) ?? "";
64
+ const diff = getDiffContent(baseBranch) ?? "";
65
+ const ticketId = extractTicketId(branch);
66
+ const prompt = renderPrompt("fill-pr", {
67
+ pr_template: prTemplate,
68
+ commit_log: commitLog,
69
+ diff_stat: diffStat,
70
+ diff,
71
+ ticket_id: ticketId ?? "",
72
+ branch_name: branch,
73
+ });
74
+ const result = spawnSync("happy", ["-p", prompt, "--output-format", "text"], {
75
+ encoding: "utf-8",
76
+ maxBuffer: 10 * 1024 * 1024,
77
+ });
78
+ if (result.status !== 0) {
79
+ setStatus("error");
80
+ setMessage("Failed to generate PR body with Claude");
81
+ setTimeout(() => exit(), 100);
82
+ return;
83
+ }
84
+ const body = result.stdout.trim();
85
+ bodyFile = join(tmpdir(), `santree-pr-${Date.now()}.md`);
86
+ writeFileSync(bodyFile, body);
87
+ }
34
88
  setStatus("creating");
35
- setMessage("Creating PR...");
36
- const result = createPR(finalTitle, baseBranch, branch, options.draft ?? false);
89
+ setMessage("Opening PR in browser...");
90
+ const result = createPR(title, baseBranch, branch, bodyFile);
37
91
  if (result === 0) {
38
92
  setStatus("done");
39
93
  setMessage("Opened PR creation page in browser");
40
94
  }
41
95
  else {
42
96
  setStatus("error");
43
- setMessage("Failed to create PR");
97
+ setMessage("Failed to open PR page");
44
98
  }
45
99
  setTimeout(() => exit(), 100);
46
100
  }
@@ -83,20 +137,19 @@ export default function PR({ options }) {
83
137
  // Check for uncommitted changes
84
138
  if (hasUncommittedChanges()) {
85
139
  setStatus("error");
86
- setMessage("You have uncommitted changes. Please commit your changes before creating a PR.");
140
+ setMessage("You have uncommitted changes. Please commit before creating a PR.");
87
141
  return;
88
142
  }
89
143
  // Yield to let spinner animate
90
144
  await new Promise((r) => setTimeout(r, 10));
91
145
  // Get base branch from metadata
92
- const metadata = getWorktreeMetadata(currentRepo);
93
- const base = metadata?.base_branch ?? getDefaultBranch();
146
+ const base = getBaseBranch(branchName);
94
147
  setBaseBranch(base);
95
148
  // Check commits ahead
96
149
  const commitsAhead = getCommitsAhead(base);
97
150
  if (commitsAhead === 0) {
98
151
  setStatus("error");
99
- setMessage(`No commits ahead of ${base}. You need to make commits before creating a PR.`);
152
+ setMessage(`No commits ahead of ${base}. Make commits before creating a PR.`);
100
153
  return;
101
154
  }
102
155
  // Yield to let spinner animate
@@ -118,6 +171,12 @@ export default function PR({ options }) {
118
171
  // Check if PR already exists
119
172
  const existingPr = await getPRInfoAsync(branchName);
120
173
  if (existingPr) {
174
+ if (existingPr.state === "CLOSED") {
175
+ // Closed PR — let user decide to create a new one
176
+ setClosedPrInfo(existingPr);
177
+ setStatus("confirm-reopen");
178
+ return;
179
+ }
121
180
  setStatus("existing");
122
181
  setMessage(`PR already exists (#${existingPr.number}) - ${existingPr.state}`);
123
182
  if (existingPr.url) {
@@ -131,25 +190,26 @@ export default function PR({ options }) {
131
190
  setTimeout(() => exit(), 100);
132
191
  return;
133
192
  }
134
- // Get the latest commit message for the PR title
135
- const latestCommit = getLatestCommitMessage();
136
- let suggestedTitle = latestCommit ?? "";
137
193
  // Extract ticket ID from branch name to display in UI
138
194
  const ticket = extractTicketId(branchName);
139
195
  if (ticket) {
140
196
  setIssueId(ticket);
141
197
  }
142
- setTitleInput(suggestedTitle);
143
- setStatus("awaiting-title");
144
198
  }
145
199
  run();
146
- }, [options.draft]);
147
- const isLoading = status === "checking" || status === "pushing" || status === "creating";
200
+ }, [options.fill]);
201
+ // Once branch and baseBranch are set and we're still checking, go straight to PR
202
+ useEffect(() => {
203
+ if (status === "checking" && branch && baseBranch && !closedPrInfo) {
204
+ openPR();
205
+ }
206
+ }, [status, branch, baseBranch]);
207
+ const isLoading = status === "checking" || status === "pushing" || status === "filling" || status === "creating";
148
208
  return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83D\uDD17 Pull Request" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error"
149
209
  ? "red"
150
210
  : status === "done"
151
211
  ? "green"
152
212
  : status === "existing"
153
213
  ? "yellow"
154
- : "blue", paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), baseBranch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "base:" }), _jsx(Text, { color: "blue", children: baseBranch })] })), issueId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "issue:" }), _jsx(Text, { color: "blue", bold: true, children: issueId })] })), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "type:" }), _jsx(Text, { backgroundColor: options.draft ? "yellow" : "green", color: "black", children: options.draft ? " draft " : " ready " })] })] }), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message || "Checking..."] })] })), status === "awaiting-title" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "PR Title: " }), _jsx(TextInput, { value: titleInput, onChange: setTitleInput, onSubmit: handleTitleSubmit })] })), status === "done" && (_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] })), status === "existing" && (_jsxs(Text, { color: "yellow", bold: true, children: ["\u26A0 ", message] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
214
+ : "blue", paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), baseBranch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "base:" }), _jsx(Text, { color: "blue", children: baseBranch })] })), issueId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "issue:" }), _jsx(Text, { color: "blue", bold: true, children: issueId })] }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message || "Checking..."] })] })), status === "confirm-reopen" && closedPrInfo && (_jsxs(Box, { children: [_jsxs(Text, { color: "yellow", children: ["PR #", closedPrInfo.number, " was closed. Create a new one? "] }), _jsx(Text, { color: "green", bold: true, children: "[y]" }), _jsx(Text, { children: " / " }), _jsx(Text, { color: "red", bold: true, children: "[n]" })] })), status === "done" && (_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] })), status === "existing" && (_jsxs(Text, { color: "yellow", bold: true, children: ["\u26A0 ", message] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
155
215
  }
@@ -2,10 +2,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from "react";
3
3
  import { Text, Box } from "ink";
4
4
  import Spinner from "ink-spinner";
5
- import { spawn } from "child_process";
6
5
  import * as path from "path";
7
6
  import * as fs from "fs";
8
7
  import { findMainRepoRoot, getSantreeDir, isInWorktree } from "../lib/git.js";
8
+ import { spawnAsync } from "../lib/exec.js";
9
9
  export const description = "Run init script in current worktree";
10
10
  export default function Setup() {
11
11
  const [status, setStatus] = useState("checking");
@@ -48,40 +48,23 @@ export default function Setup() {
48
48
  setWorktreePath(cwd);
49
49
  setStatus("running");
50
50
  // Run script and capture output
51
- const exitCode = await new Promise((resolve) => {
52
- const child = spawn(initScript, [], {
53
- cwd,
54
- stdio: "pipe",
55
- env: {
56
- ...process.env,
57
- SANTREE_WORKTREE_PATH: cwd,
58
- SANTREE_REPO_ROOT: mainRepo,
59
- },
60
- });
61
- let scriptOutput = "";
62
- child.stdout?.on("data", (data) => {
63
- scriptOutput += data.toString();
64
- setOutput(scriptOutput);
65
- });
66
- child.stderr?.on("data", (data) => {
67
- scriptOutput += data.toString();
68
- setOutput(scriptOutput);
69
- });
70
- child.on("close", (code) => {
71
- resolve(code ?? 1);
72
- });
73
- child.on("error", (err) => {
74
- setOutput(err.message);
75
- resolve(1);
76
- });
51
+ const result = await spawnAsync(initScript, [], {
52
+ cwd,
53
+ env: {
54
+ ...process.env,
55
+ SANTREE_WORKTREE_PATH: cwd,
56
+ SANTREE_REPO_ROOT: mainRepo,
57
+ },
58
+ onOutput: setOutput,
77
59
  });
78
- if (exitCode === 0) {
60
+ setOutput(result.output);
61
+ if (result.code === 0) {
79
62
  setStatus("done");
80
63
  setMessage("Init script completed successfully");
81
64
  }
82
65
  else {
83
66
  setStatus("error");
84
- setMessage(`Init script failed (exit code ${exitCode})`);
67
+ setMessage(`Init script failed (exit code ${result.code})`);
85
68
  }
86
69
  }
87
70
  run();
@@ -79,17 +79,9 @@ function isWorktree(cwd) {
79
79
  return false;
80
80
  }
81
81
  }
82
- // Get santree metadata if exists
83
- function getSantreeMetadata(cwd) {
84
- const metadataPath = path.join(cwd, ".santree_metadata.json");
85
- if (!fs.existsSync(metadataPath))
86
- return null;
87
- try {
88
- return JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
89
- }
90
- catch {
91
- return null;
92
- }
82
+ // Check if directory is a santree-managed worktree
83
+ function isSantreeWorktree(cwd) {
84
+ return cwd.includes("/.santree/worktrees/");
93
85
  }
94
86
  // Extract ticket ID from branch name (e.g., feature/TEAM-123-desc -> TEAM-123)
95
87
  function extractTicketId(branch) {
@@ -130,7 +122,7 @@ function formatChanges(changes) {
130
122
  return parts.length > 0 ? parts.join(" ") : `${c.dim}clean${c.reset}`;
131
123
  }
132
124
  // Build statusline for santree worktree
133
- function buildSantreeStatusline(cwd, metadata, model, usedPercentage) {
125
+ function buildSantreeStatusline(cwd, model, usedPercentage) {
134
126
  const parts = [];
135
127
  const branch = git(cwd, "rev-parse --abbrev-ref HEAD") || "unknown";
136
128
  // Ticket ID (prominent)
@@ -217,15 +209,8 @@ export default function Statusline() {
217
209
  // Not a git repo
218
210
  output = buildPlainStatusline(cwd, model, usedPercentage);
219
211
  }
220
- else if (isWorktree(cwd)) {
221
- // Check for santree metadata
222
- const metadata = getSantreeMetadata(cwd);
223
- if (metadata) {
224
- output = buildSantreeStatusline(cwd, metadata, model, usedPercentage);
225
- }
226
- else {
227
- output = buildGitStatusline(cwd, model, usedPercentage);
228
- }
212
+ else if (isWorktree(cwd) && isSantreeWorktree(cwd)) {
213
+ output = buildSantreeStatusline(cwd, model, usedPercentage);
229
214
  }
230
215
  else {
231
216
  // Regular git repo