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 +166 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +9 -0
- package/dist/commands/clean.d.ts +10 -0
- package/dist/commands/clean.js +111 -0
- package/dist/commands/commit.d.ts +1 -0
- package/dist/commands/commit.js +163 -0
- package/dist/commands/create.d.ts +16 -0
- package/dist/commands/create.js +181 -0
- package/dist/commands/list.d.ts +1 -0
- package/dist/commands/list.js +106 -0
- package/dist/commands/pr.d.ts +9 -0
- package/dist/commands/pr.js +154 -0
- package/dist/commands/remove.d.ts +11 -0
- package/dist/commands/remove.js +41 -0
- package/dist/commands/setup.d.ts +1 -0
- package/dist/commands/setup.js +90 -0
- package/dist/commands/switch.d.ts +7 -0
- package/dist/commands/switch.js +22 -0
- package/dist/commands/sync.d.ts +9 -0
- package/dist/commands/sync.js +108 -0
- package/dist/commands/work.d.ts +11 -0
- package/dist/commands/work.js +169 -0
- package/dist/lib/git.d.ts +47 -0
- package/dist/lib/git.js +393 -0
- package/dist/lib/github.d.ts +11 -0
- package/dist/lib/github.js +71 -0
- package/package.json +58 -0
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
package/dist/cli.js
ADDED
|
@@ -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
|
+
}
|