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 +8 -0
- package/dist/commands/create.js +7 -16
- package/dist/commands/doctor.js +103 -7
- package/dist/commands/list.js +3 -6
- package/dist/commands/pr.d.ts +1 -1
- package/dist/commands/pr.js +87 -27
- package/dist/commands/setup.js +12 -29
- package/dist/commands/statusline.js +6 -21
- package/dist/commands/sync.js +5 -24
- package/dist/commands/work.js +1 -10
- package/dist/lib/exec.d.ts +19 -0
- package/dist/lib/exec.js +40 -0
- package/dist/lib/git.d.ts +161 -2
- package/dist/lib/git.js +291 -172
- package/dist/lib/github.d.ts +39 -1
- package/dist/lib/github.js +51 -11
- package/dist/lib/prompts.d.ts +6 -0
- package/dist/lib/prompts.js +15 -0
- package/package.json +2 -2
- package/prompts/fill-pr.njk +29 -0
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
|
|--------|-------------|
|
package/dist/commands/create.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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);
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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 &&
|
|
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 &&
|
|
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
|
}
|
package/dist/commands/list.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
54
|
+
getCommitsAhead(wt.path, base),
|
|
58
55
|
isDirty(wt.path),
|
|
59
56
|
wt.branch ? getPRInfoAsync(wt.branch) : Promise.resolve(null),
|
|
60
57
|
]);
|
package/dist/commands/pr.d.ts
CHANGED
|
@@ -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
|
-
|
|
4
|
+
fill: z.ZodOptional<z.ZodBoolean>;
|
|
5
5
|
}, z.core.$strip>;
|
|
6
6
|
type Props = {
|
|
7
7
|
options: z.infer<typeof options>;
|
package/dist/commands/pr.js
CHANGED
|
@@ -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 {
|
|
10
|
-
import {
|
|
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
|
-
|
|
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 [
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (
|
|
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("
|
|
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("
|
|
36
|
-
const result = createPR(
|
|
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
|
|
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
|
|
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
|
|
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}.
|
|
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.
|
|
147
|
-
|
|
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, {
|
|
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
|
}
|
package/dist/commands/setup.js
CHANGED
|
@@ -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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
//
|
|
83
|
-
function
|
|
84
|
-
|
|
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,
|
|
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
|
-
|
|
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
|