santree 0.0.1

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 ADDED
@@ -0,0 +1,166 @@
1
+ # Santree
2
+
3
+ A beautiful CLI for managing Git worktrees with Linear and GitHub integration.
4
+
5
+ Built with [React](https://react.dev/), [Ink](https://github.com/vadimdemedes/ink), and [Pastel](https://github.com/vadimdemedes/pastel).
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install -g santree
11
+ ```
12
+
13
+ ## Requirements
14
+
15
+ ### Required
16
+
17
+ | Tool | Version | Purpose | Installation |
18
+ |------|---------|---------|--------------|
19
+ | **Node.js** | >= 20 | Runtime | [nodejs.org](https://nodejs.org/) or `brew install node` |
20
+ | **Git** | Any recent | Worktree operations | [git-scm.com](https://git-scm.com/) or `brew install git` |
21
+ | **GitHub CLI** | Any recent | PR creation, status, cleanup | `brew install gh` then `gh auth login` |
22
+ | **tmux** | Any recent | Create worktrees in new windows | `brew install tmux` |
23
+ | **Claude Code** | Any recent | AI coding assistant | `npm install -g @anthropic-ai/claude-code` |
24
+ | **Happy** | - | Claude CLI wrapper | Your custom wrapper around Claude Code |
25
+ | **Linear MCP** | - | Linear ticket context | See below |
26
+
27
+ ### Setup
28
+
29
+ #### GitHub CLI
30
+
31
+ After installing, authenticate with GitHub:
32
+
33
+ ```bash
34
+ brew install gh
35
+ gh auth login
36
+ ```
37
+
38
+ #### Happy (Claude Integration)
39
+
40
+ The `santree work` command uses Happy to launch Claude with ticket context. Happy should be configured with the Linear MCP server to fetch ticket details.
41
+
42
+ #### Linear MCP Server
43
+
44
+ Add the Linear MCP server to your Claude configuration for ticket integration:
45
+
46
+ ```bash
47
+ claude mcp add --transport http linear https://mcp.linear.app/mcp
48
+ ```
49
+
50
+ This enables Claude to fetch Linear ticket details and comments when using `santree work`.
51
+
52
+ ## Features
53
+
54
+ - **Worktree Management**: Create, switch, list, and remove Git worktrees
55
+ - **Linear Integration**: Extract ticket IDs from branch names for Claude AI workflows
56
+ - **GitHub Integration**: View PR status, create PRs, and clean up merged branches
57
+ - **Claude AI Integration**: Launch Claude with context about your current ticket
58
+ - **Beautiful UI**: Animated spinners, colored output, and box-styled layouts
59
+
60
+ ## Commands
61
+
62
+ | Command | Description |
63
+ |---------|-------------|
64
+ | `santree list` | List all worktrees with status, PR info, and commits ahead |
65
+ | `santree create <branch>` | Create a new worktree from base branch |
66
+ | `santree switch <branch>` | Switch to another worktree |
67
+ | `santree remove <branch>` | Remove a worktree and its branch |
68
+ | `santree sync` | Sync current worktree with base branch (merge by default) |
69
+ | `santree setup` | Run the init script (`.santree/init.sh`) |
70
+ | `santree work` | Launch Claude to work on the current ticket |
71
+ | `santree clean` | Remove worktrees with merged/closed PRs |
72
+
73
+ ## Options
74
+
75
+ ### create
76
+ - `--base <branch>` - Base branch to create from (default: main/master)
77
+ - `--work` - Launch Claude after creating
78
+ - `--plan` - With --work, only create implementation plan
79
+ - `--no-pull` - Skip pulling latest changes
80
+
81
+ ### sync
82
+ - `--rebase` - Use rebase instead of merge
83
+
84
+ ### work
85
+ - `--plan` - Only create implementation plan
86
+ - `--review` - Review changes against ticket requirements
87
+ - `--fix-pr` - Fetch PR comments and fix them
88
+
89
+ ### remove
90
+ - `--force` - Force removal even with uncommitted changes
91
+
92
+ ### clean
93
+ - `--dry-run` - Show what would be removed without removing
94
+ - `--force` - Skip confirmation prompt
95
+
96
+ ## Setup
97
+
98
+ ### Init Script
99
+
100
+ Create `.santree/init.sh` in your repository root to run custom setup when creating worktrees:
101
+
102
+ ```bash
103
+ #!/bin/bash
104
+ # Example: Copy .env, install dependencies, etc.
105
+ cp "$SANTREE_REPO_ROOT/.env" "$SANTREE_WORKTREE_PATH/.env"
106
+ npm install
107
+ ```
108
+
109
+ Environment variables available:
110
+ - `SANTREE_WORKTREE_PATH` - Path to the new worktree
111
+ - `SANTREE_REPO_ROOT` - Path to the main repository
112
+
113
+ ### Branch Naming
114
+
115
+ For Linear integration, use branch names with ticket IDs:
116
+
117
+ ```
118
+ user/TEAM-123-feature-description
119
+ feature/PROJ-456-add-auth
120
+ ```
121
+
122
+ ## Development
123
+
124
+ ```bash
125
+ # Install dependencies
126
+ npm install
127
+
128
+ # Build
129
+ npm run build
130
+
131
+ # Lint
132
+ npm run lint
133
+
134
+ # Run locally
135
+ node dist/cli.js <command>
136
+ ```
137
+
138
+ ## CI/CD
139
+
140
+ This project uses GitHub Actions for continuous integration and deployment.
141
+
142
+ ### Workflows
143
+
144
+ - **CI** (`ci.yml`): Runs on every push and PR to `main`. Builds the project and runs linting.
145
+ - **Release** (`release.yml`): Publishes to npm when a GitHub release is created.
146
+
147
+ ### Setting Up npm Publishing
148
+
149
+ 1. Generate an npm access token:
150
+ - Go to [npmjs.com](https://www.npmjs.com/) → Account → Access Tokens
151
+ - Create a new **Granular Access Token** with publish permissions
152
+
153
+ 2. Add the token to GitHub:
154
+ - Go to your repo → Settings → Secrets and variables → Actions
155
+ - Create a new secret named `NPM_TOKEN` with your token
156
+
157
+ ### Creating a Release
158
+
159
+ 1. Update the version in `package.json`
160
+ 2. Commit and push to `main`
161
+
162
+ The workflow automatically detects version changes, publishes to npm, creates a git tag, and generates a GitHub release.
163
+
164
+ ## Shell Integration
165
+
166
+ The shell wrapper in `alias.zsh` handles directory switching for `create` and `switch` commands, since child processes cannot change the parent shell's directory.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import Pastel from "pastel";
3
+ const app = new Pastel({
4
+ importMeta: import.meta,
5
+ name: "santree",
6
+ version: "1.0.0",
7
+ description: "Beautiful CLI for managing Git worktrees",
8
+ });
9
+ await app.run();
@@ -0,0 +1,10 @@
1
+ import { z } from "zod";
2
+ export declare const options: z.ZodObject<{
3
+ "dry-run": z.ZodOptional<z.ZodBoolean>;
4
+ force: z.ZodOptional<z.ZodBoolean>;
5
+ }, z.core.$strip>;
6
+ type Props = {
7
+ options: z.infer<typeof options>;
8
+ };
9
+ export default function Clean({ options }: Props): import("react/jsx-runtime").JSX.Element;
10
+ export {};
@@ -0,0 +1,111 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Text, Box, useInput, useApp } from "ink";
4
+ import Spinner from "ink-spinner";
5
+ import { z } from "zod";
6
+ import { findMainRepoRoot, listWorktrees, removeWorktree, isWorktreePath, } from "../lib/git.js";
7
+ import { getPRInfoAsync } from "../lib/github.js";
8
+ export const options = z.object({
9
+ "dry-run": z.boolean().optional().describe("Show what would be removed"),
10
+ force: z.boolean().optional().describe("Skip confirmation"),
11
+ });
12
+ export default function Clean({ options }) {
13
+ const { exit } = useApp();
14
+ const [status, setStatus] = useState("checking");
15
+ const [message, setMessage] = useState("Checking worktrees for merged/closed PRs...");
16
+ const [staleWorktrees, setStaleWorktrees] = useState([]);
17
+ const [failed, setFailed] = useState(0);
18
+ const [repoRoot, setRepoRoot] = useState(null);
19
+ // Handle confirmation input
20
+ useInput((input) => {
21
+ if (status !== "confirming")
22
+ return;
23
+ if (input === "y" || input === "Y") {
24
+ removeStaleWorktrees();
25
+ }
26
+ else if (input === "n" || input === "N" || input === "\x03") {
27
+ setStatus("cancelled");
28
+ setMessage("Cancelled");
29
+ setTimeout(() => exit(), 100);
30
+ }
31
+ });
32
+ async function removeStaleWorktrees() {
33
+ if (!repoRoot)
34
+ return;
35
+ setStatus("removing");
36
+ let removedCount = 0;
37
+ let failedCount = 0;
38
+ for (const wt of staleWorktrees) {
39
+ const result = await removeWorktree(wt.branch, repoRoot, true);
40
+ if (result.success) {
41
+ removedCount++;
42
+ }
43
+ else {
44
+ failedCount++;
45
+ }
46
+ }
47
+ setFailed(failedCount);
48
+ setStatus("done");
49
+ setMessage(failedCount > 0
50
+ ? `Removed ${removedCount} worktree(s), ${failedCount} failed`
51
+ : `Removed ${removedCount} worktree(s)`);
52
+ setTimeout(() => exit(), 100);
53
+ }
54
+ useEffect(() => {
55
+ async function run() {
56
+ // Small delay to allow spinner to render
57
+ await new Promise((r) => setTimeout(r, 100));
58
+ // Find main repo root
59
+ const mainRepo = findMainRepoRoot();
60
+ if (!mainRepo) {
61
+ setStatus("error");
62
+ setMessage("Not inside a git repository");
63
+ return;
64
+ }
65
+ setRepoRoot(mainRepo);
66
+ const worktrees = listWorktrees();
67
+ // Filter to only worktrees (not main repo) with branches
68
+ const candidates = worktrees.filter((wt) => isWorktreePath(wt.path) && wt.branch);
69
+ // Fetch PR info for all worktrees in parallel
70
+ const prInfoResults = await Promise.all(candidates.map((wt) => getPRInfoAsync(wt.branch)));
71
+ // Find worktrees with merged/closed PRs
72
+ const stale = [];
73
+ for (let i = 0; i < candidates.length; i++) {
74
+ const wt = candidates[i];
75
+ const prInfo = prInfoResults[i];
76
+ if (prInfo &&
77
+ (prInfo.state === "MERGED" || prInfo.state === "CLOSED")) {
78
+ stale.push({
79
+ branch: wt.branch,
80
+ path: wt.path,
81
+ prNum: prInfo.number,
82
+ prState: prInfo.state,
83
+ });
84
+ }
85
+ }
86
+ setStaleWorktrees(stale);
87
+ if (stale.length === 0) {
88
+ setStatus("none-found");
89
+ setMessage("No stale worktrees found. All worktrees have open PRs or no PRs.");
90
+ setTimeout(() => exit(), 100);
91
+ return;
92
+ }
93
+ // Dry run - just show what would be removed
94
+ if (options["dry-run"]) {
95
+ setStatus("done");
96
+ setMessage("Dry run - no changes made");
97
+ setTimeout(() => exit(), 100);
98
+ return;
99
+ }
100
+ // Skip confirmation if force flag
101
+ if (options.force) {
102
+ await removeStaleWorktrees();
103
+ return;
104
+ }
105
+ setStatus("confirming");
106
+ }
107
+ run();
108
+ }, [options["dry-run"], options.force]);
109
+ const isLoading = status === "checking" || status === "removing";
110
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "\uD83E\uDDF9 Clean" }), options["dry-run"] && _jsx(Text, { color: "yellow", children: " (dry run)" })] }), isLoading && (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message] })] })), staleWorktrees.length > 0 && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { children: ["Found", " ", _jsx(Text, { color: "yellow", bold: true, children: staleWorktrees.length }), " ", "stale worktree(s):"] }), staleWorktrees.map((wt) => (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: wt.prState === "MERGED" ? "magenta" : "red", paddingX: 1, marginTop: 1, width: "100%", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: wt.branch })] }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "PR:" }), _jsxs(Text, { children: ["#", wt.prNum] }), _jsx(Text, { backgroundColor: wt.prState === "MERGED" ? "magenta" : "red", color: "white", children: ` ${wt.prState.toLowerCase()} ` })] }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "path:" }), _jsx(Text, { dimColor: true, children: wt.path })] })] }, wt.branch)))] })), status === "confirming" && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { bold: true, color: "yellow", children: ["Remove these worktrees? [y/N]:", " "] }) })), status === "none-found" && (_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] })), status === "done" && (_jsxs(Text, { color: failed > 0 ? "yellow" : "green", bold: true, children: ["\u2713 ", message] })), status === "cancelled" && _jsxs(Text, { color: "yellow", children: ["\u2717 ", message] }), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] }));
111
+ }
@@ -0,0 +1 @@
1
+ export default function Commit(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,163 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Text, Box, useInput, useApp } from "ink";
4
+ import TextInput from "ink-text-input";
5
+ import Spinner from "ink-spinner";
6
+ import { exec } from "child_process";
7
+ import { promisify } from "util";
8
+ import { findRepoRoot, getCurrentBranch, extractTicketId, getGitStatus, getStagedDiffStat, hasStagedChanges, hasUnstagedChanges, } from "../lib/git.js";
9
+ const execAsync = promisify(exec);
10
+ export default function Commit() {
11
+ const { exit } = useApp();
12
+ const [status, setStatus] = useState("loading");
13
+ const [message, setMessage] = useState("");
14
+ const [branch, setBranch] = useState(null);
15
+ const [ticketId, setTicketId] = useState(null);
16
+ const [gitStatus, setGitStatus] = useState("");
17
+ const [diffStat, setDiffStat] = useState("");
18
+ const [repoRoot, setRepoRoot] = useState(null);
19
+ const [commitInput, setCommitInput] = useState("");
20
+ // Handle confirmation for staging
21
+ useInput((input, key) => {
22
+ if (status === "confirm-stage") {
23
+ if (input === "y" || input === "Y") {
24
+ stageAndContinue();
25
+ }
26
+ else if (input === "n" || input === "N" || key.escape) {
27
+ if (hasStagedChanges()) {
28
+ setStatus("awaiting-message");
29
+ const prefix = ticketId ? `[${ticketId}] ` : "";
30
+ setCommitInput(prefix);
31
+ }
32
+ else {
33
+ setStatus("no-changes");
34
+ setMessage("No staged changes to commit");
35
+ setTimeout(() => exit(), 100);
36
+ }
37
+ }
38
+ }
39
+ });
40
+ async function stageAndContinue() {
41
+ try {
42
+ await execAsync("git add -A", { cwd: repoRoot ?? undefined });
43
+ setGitStatus(getGitStatus());
44
+ setDiffStat(getStagedDiffStat());
45
+ setStatus("awaiting-message");
46
+ const prefix = ticketId ? `[${ticketId}] ` : "";
47
+ setCommitInput(prefix);
48
+ }
49
+ catch (e) {
50
+ setStatus("error");
51
+ setMessage(`Failed to stage changes: ${e}`);
52
+ }
53
+ }
54
+ async function handleCommitSubmit(value) {
55
+ const trimmed = value.trim();
56
+ if (!trimmed) {
57
+ setStatus("error");
58
+ setMessage("Empty commit message, cancelled");
59
+ setTimeout(() => exit(), 100);
60
+ return;
61
+ }
62
+ // Prepend ticket ID if not already present
63
+ const commitMessage = ticketId && !trimmed.includes(`[${ticketId}]`)
64
+ ? `[${ticketId}] ${trimmed}`
65
+ : trimmed;
66
+ setStatus("committing");
67
+ setMessage("Creating commit...");
68
+ try {
69
+ await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, {
70
+ cwd: repoRoot ?? undefined,
71
+ });
72
+ }
73
+ catch (e) {
74
+ setStatus("error");
75
+ const errorMsg = e instanceof Error ? e.message : String(e);
76
+ setMessage(`Commit failed: ${errorMsg}`);
77
+ setTimeout(() => exit(), 100);
78
+ return;
79
+ }
80
+ setStatus("pushing");
81
+ setMessage("Pushing to origin...");
82
+ try {
83
+ await execAsync(`git push -u origin "${branch}"`, {
84
+ cwd: repoRoot ?? undefined,
85
+ });
86
+ }
87
+ catch (e) {
88
+ setStatus("error");
89
+ const errorMsg = e instanceof Error ? e.message : String(e);
90
+ setMessage(`Push failed: ${errorMsg}`);
91
+ setTimeout(() => exit(), 100);
92
+ return;
93
+ }
94
+ setStatus("done");
95
+ setMessage("Committed and pushed successfully!");
96
+ setTimeout(() => exit(), 100);
97
+ }
98
+ useEffect(() => {
99
+ async function init() {
100
+ await new Promise((r) => setTimeout(r, 100));
101
+ const root = findRepoRoot();
102
+ if (!root) {
103
+ setStatus("error");
104
+ setMessage("Not inside a git repository");
105
+ return;
106
+ }
107
+ setRepoRoot(root);
108
+ const currentBranch = getCurrentBranch();
109
+ if (!currentBranch) {
110
+ setStatus("error");
111
+ setMessage("Could not determine current branch");
112
+ return;
113
+ }
114
+ setBranch(currentBranch);
115
+ const ticket = extractTicketId(currentBranch);
116
+ setTicketId(ticket);
117
+ const statusOutput = getGitStatus();
118
+ if (!statusOutput) {
119
+ setStatus("no-changes");
120
+ setMessage("No changes");
121
+ setTimeout(() => exit(), 100);
122
+ return;
123
+ }
124
+ setGitStatus(statusOutput);
125
+ const unstaged = hasUnstagedChanges();
126
+ const staged = hasStagedChanges();
127
+ if (unstaged) {
128
+ setStatus("confirm-stage");
129
+ }
130
+ else if (staged) {
131
+ setDiffStat(getStagedDiffStat());
132
+ setStatus("awaiting-message");
133
+ const prefix = ticket ? `[${ticket}] ` : "";
134
+ setCommitInput(prefix);
135
+ }
136
+ else {
137
+ setStatus("no-changes");
138
+ setMessage("No changes to commit");
139
+ setTimeout(() => exit(), 100);
140
+ }
141
+ }
142
+ init();
143
+ }, []);
144
+ const isLoading = status === "loading" || status === "committing" || status === "pushing";
145
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83D\uDCBE Commit" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : status === "done" ? "green" : "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 })] })), ticketId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "ticket:" }), _jsx(Text, { color: "blue", bold: true, children: ticketId })] }))] }), gitStatus && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: "Changes:" }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, width: "100%", children: [gitStatus
146
+ .split("\n")
147
+ .slice(0, 10)
148
+ .map((line, i) => {
149
+ let color;
150
+ if (line.startsWith("A ") ||
151
+ line.startsWith("M ") ||
152
+ line.startsWith("D ")) {
153
+ color = "green";
154
+ }
155
+ else if (line.startsWith("??")) {
156
+ color = "gray";
157
+ }
158
+ else if (line.startsWith(" M") || line.startsWith(" D")) {
159
+ color = "yellow";
160
+ }
161
+ return (_jsx(Text, { color: color, children: line }, i));
162
+ }), gitStatus.split("\n").length > 10 && (_jsxs(Text, { dimColor: true, children: ["... and ", gitStatus.split("\n").length - 10, " more"] }))] })] })), diffStat && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: "Staged:" }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, width: "100%", children: diffStat.split("\n").map((line, i) => (_jsx(Text, { dimColor: true, children: line }, i))) })] })), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message || "Loading..."] })] })), status === "confirm-stage" && (_jsxs(Text, { color: "yellow", bold: true, children: ["Stage all changes? [y/N]:", " "] })), status === "awaiting-message" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "Commit message: " }), _jsx(TextInput, { value: commitInput, onChange: setCommitInput, onSubmit: handleCommitSubmit })] })), status === "done" && (_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] })), status === "no-changes" && _jsxs(Text, { dimColor: true, children: ["\u2713 ", message] }), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
163
+ }
@@ -0,0 +1,16 @@
1
+ import { z } from "zod";
2
+ export declare const options: z.ZodObject<{
3
+ base: z.ZodOptional<z.ZodString>;
4
+ work: z.ZodOptional<z.ZodBoolean>;
5
+ plan: z.ZodOptional<z.ZodBoolean>;
6
+ "no-pull": z.ZodOptional<z.ZodBoolean>;
7
+ tmux: z.ZodOptional<z.ZodBoolean>;
8
+ name: z.ZodOptional<z.ZodString>;
9
+ }, z.core.$strip>;
10
+ export declare const args: z.ZodTuple<[z.ZodOptional<z.ZodString>], null>;
11
+ type Props = {
12
+ options: z.infer<typeof options>;
13
+ args: z.infer<typeof args>;
14
+ };
15
+ export default function Create({ options, args }: Props): import("react/jsx-runtime").JSX.Element;
16
+ export {};
@@ -0,0 +1,181 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Text, Box } from "ink";
4
+ import Spinner from "ink-spinner";
5
+ import { z } from "zod";
6
+ import { spawn } from "child_process";
7
+ import * as fs from "fs";
8
+ import { createWorktree, findMainRepoRoot, getDefaultBranch, pullLatest, hasInitScript, getInitScriptPath, extractTicketId, } from "../lib/git.js";
9
+ import { execSync } from "child_process";
10
+ export const options = z.object({
11
+ base: z.string().optional().describe("Base branch to create from"),
12
+ work: z.boolean().optional().describe("Launch Claude after creating"),
13
+ plan: z.boolean().optional().describe("With --work, only plan"),
14
+ "no-pull": z.boolean().optional().describe("Skip pulling latest changes"),
15
+ tmux: z.boolean().optional().describe("Create a new tmux window"),
16
+ name: z.string().optional().describe("Custom tmux window name"),
17
+ });
18
+ export const args = z.tuple([z.string().optional().describe("Branch name")]);
19
+ function isInTmux() {
20
+ return !!process.env.TMUX;
21
+ }
22
+ function createTmuxWindow(name, path, runCommand) {
23
+ try {
24
+ execSync(`tmux new-window -n "${name}" -c "${path}"`, { stdio: "ignore" });
25
+ // If a command is provided, send it to the new window
26
+ if (runCommand) {
27
+ execSync(`tmux send-keys -t "${name}" "${runCommand}" Enter`, { stdio: "ignore" });
28
+ }
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ function getWindowName(branchName, customName) {
36
+ if (customName)
37
+ return customName;
38
+ // Try to extract ticket ID (e.g., "TEAM-123")
39
+ const ticketId = extractTicketId(branchName);
40
+ if (ticketId)
41
+ return ticketId;
42
+ // Fallback to last part of branch name
43
+ const parts = branchName.split("/");
44
+ return parts[parts.length - 1] ?? branchName;
45
+ }
46
+ export default function Create({ options, args }) {
47
+ const [branchName] = args;
48
+ const [status, setStatus] = useState("idle");
49
+ const [message, setMessage] = useState("");
50
+ const [worktreePath, setWorktreePath] = useState("");
51
+ const [baseBranch, setBaseBranch] = useState(null);
52
+ const [tmuxWindowName, setTmuxWindowName] = useState(null);
53
+ function finalize(path, branch) {
54
+ // Handle tmux window creation
55
+ if (options.tmux) {
56
+ if (!isInTmux()) {
57
+ setMessage("Worktree created, but not in tmux session");
58
+ setStatus("done");
59
+ console.log(`SANTREE_CD:${path}`);
60
+ return;
61
+ }
62
+ setStatus("tmux");
63
+ setMessage("Creating tmux window...");
64
+ const windowName = getWindowName(branch, options.name);
65
+ setTmuxWindowName(windowName);
66
+ // Build command to run in new window (if --work is set)
67
+ let runCommand;
68
+ if (options.work) {
69
+ runCommand = options.plan ? "st work --plan" : "st work";
70
+ }
71
+ if (!createTmuxWindow(windowName, path, runCommand)) {
72
+ setMessage("Worktree created, but failed to create tmux window");
73
+ setStatus("done");
74
+ console.log(`SANTREE_CD:${path}`);
75
+ return;
76
+ }
77
+ setStatus("done");
78
+ const workInfo = options.work ? (options.plan ? " + Claude (plan)" : " + Claude") : "";
79
+ setMessage(`Worktree and tmux window created!${workInfo}`);
80
+ // Don't output SANTREE_CD when tmux window is created - user is already in new window
81
+ return;
82
+ }
83
+ setStatus("done");
84
+ setMessage("Worktree created successfully!");
85
+ console.log(`SANTREE_CD:${path}`);
86
+ if (options.work) {
87
+ const mode = options.plan ? "plan" : "implement";
88
+ console.log(`SANTREE_WORK:${mode}`);
89
+ }
90
+ }
91
+ useEffect(() => {
92
+ async function run() {
93
+ // Small delay to allow spinner to render
94
+ await new Promise((r) => setTimeout(r, 100));
95
+ if (!branchName) {
96
+ setStatus("error");
97
+ setMessage("Branch name is required");
98
+ return;
99
+ }
100
+ const branch = branchName; // Capture for closures
101
+ const mainRepo = findMainRepoRoot();
102
+ if (!mainRepo) {
103
+ setStatus("error");
104
+ setMessage("Not inside a git repository");
105
+ return;
106
+ }
107
+ const base = options.base ?? getDefaultBranch();
108
+ setBaseBranch(base);
109
+ // Pull latest unless --no-pull
110
+ if (!options["no-pull"]) {
111
+ setStatus("pulling");
112
+ setMessage(`Fetching latest changes for ${base}...`);
113
+ const pullResult = pullLatest(base, mainRepo);
114
+ if (!pullResult.success) {
115
+ // Just warn, continue anyway
116
+ setMessage(`Warning: ${pullResult.message}`);
117
+ }
118
+ }
119
+ setStatus("creating");
120
+ setMessage(`Creating worktree from ${base}...`);
121
+ const result = await createWorktree(branch, base, mainRepo);
122
+ if (result.success && result.path) {
123
+ setWorktreePath(result.path);
124
+ // Run init script if it exists
125
+ if (hasInitScript(mainRepo)) {
126
+ setStatus("init-script");
127
+ setMessage("Running init script...");
128
+ const initScript = getInitScriptPath(mainRepo);
129
+ // Check if executable
130
+ try {
131
+ fs.accessSync(initScript, fs.constants.X_OK);
132
+ }
133
+ catch {
134
+ setMessage("Warning: Init script exists but is not executable");
135
+ finalize(result.path, branch);
136
+ return;
137
+ }
138
+ const child = spawn(initScript, [], {
139
+ cwd: result.path,
140
+ stdio: "pipe",
141
+ env: {
142
+ ...process.env,
143
+ SANTREE_WORKTREE_PATH: result.path,
144
+ SANTREE_REPO_ROOT: mainRepo,
145
+ },
146
+ });
147
+ // Capture output but don't display (to avoid conflicting with Ink)
148
+ child.stdout?.on("data", () => { });
149
+ child.stderr?.on("data", () => { });
150
+ child.on("error", (err) => {
151
+ setMessage(`Warning: Init script failed: ${err.message}`);
152
+ });
153
+ child.on("close", (code) => {
154
+ if (code !== 0) {
155
+ setMessage(`Warning: Init script exited with code ${code}`);
156
+ }
157
+ finalize(result.path, branch);
158
+ });
159
+ }
160
+ else {
161
+ finalize(result.path, branch);
162
+ }
163
+ }
164
+ else {
165
+ setStatus("error");
166
+ setMessage(result.error ?? "Unknown error");
167
+ }
168
+ }
169
+ run();
170
+ }, [
171
+ branchName,
172
+ options.base,
173
+ options.work,
174
+ options.plan,
175
+ options["no-pull"],
176
+ options.tmux,
177
+ options.name,
178
+ ]);
179
+ const isLoading = status === "pulling" || status === "creating" || status === "init-script" || status === "tmux";
180
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83C\uDF31 Create Worktree" }) }), branchName && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : status === "done" ? "green" : "blue", paddingX: 1, width: "100%", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branchName })] }), baseBranch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "base:" }), _jsx(Text, { color: "blue", children: baseBranch })] })), options["no-pull"] && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "skip pull:" }), _jsx(Text, { color: "yellow", children: "yes" })] })), options.work && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "after:" }), _jsx(Text, { backgroundColor: "magenta", color: "white", children: options.plan ? " plan " : " work " })] })), options.tmux && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "tmux:" }), _jsx(Text, { backgroundColor: "green", color: "white", children: ` ${options.name || "auto"} ` })] }))] })), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message] })] })), status === "done" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] }), _jsxs(Text, { dimColor: true, children: [" ", worktreePath] }), tmuxWindowName && (_jsxs(Text, { dimColor: true, children: [" tmux window: ", tmuxWindowName] }))] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
181
+ }