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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function List(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Text, Box } from "ink";
|
|
4
|
+
import Spinner from "ink-spinner";
|
|
5
|
+
import { exec } from "child_process";
|
|
6
|
+
import { promisify } from "util";
|
|
7
|
+
import { listWorktrees, getWorktreeMetadata, isWorktreePath, } from "../lib/git.js";
|
|
8
|
+
import { getPRInfoAsync } from "../lib/github.js";
|
|
9
|
+
const execAsync = promisify(exec);
|
|
10
|
+
async function getCommitsAhead(worktreePath, baseBranch) {
|
|
11
|
+
try {
|
|
12
|
+
const { stdout } = await execAsync(`git -C "${worktreePath}" rev-list --count ${baseBranch}..HEAD`);
|
|
13
|
+
return parseInt(stdout.trim(), 10) || 0;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return -1;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async function isDirty(worktreePath) {
|
|
20
|
+
try {
|
|
21
|
+
const { stdout } = await execAsync(`git -C "${worktreePath}" status --porcelain`);
|
|
22
|
+
return Boolean(stdout.trim());
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export default function List() {
|
|
29
|
+
const [wtInfo, setWtInfo] = useState([]);
|
|
30
|
+
const [error, setError] = useState(null);
|
|
31
|
+
const [loading, setLoading] = useState(true);
|
|
32
|
+
const [loadingMsg, setLoadingMsg] = useState("Loading worktrees...");
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
async function run() {
|
|
35
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
36
|
+
try {
|
|
37
|
+
const worktrees = listWorktrees();
|
|
38
|
+
const info = [];
|
|
39
|
+
for (let i = 0; i < worktrees.length; i++) {
|
|
40
|
+
const wt = worktrees[i];
|
|
41
|
+
setLoadingMsg(`Checking ${i + 1}/${worktrees.length}...`);
|
|
42
|
+
const branch = wt.branch || "(detached)";
|
|
43
|
+
const isMain = !isWorktreePath(wt.path);
|
|
44
|
+
let base = "-";
|
|
45
|
+
let ahead = -1;
|
|
46
|
+
let pr = "-";
|
|
47
|
+
let prState = "";
|
|
48
|
+
let status = "-";
|
|
49
|
+
if (!isMain) {
|
|
50
|
+
const metadata = getWorktreeMetadata(wt.path);
|
|
51
|
+
if (metadata?.base_branch) {
|
|
52
|
+
base = metadata.base_branch;
|
|
53
|
+
}
|
|
54
|
+
// Run async operations in parallel
|
|
55
|
+
const [aheadResult, dirtyResult, prInfo] = await Promise.all([
|
|
56
|
+
base !== "-" ? getCommitsAhead(wt.path, base) : Promise.resolve(-1),
|
|
57
|
+
isDirty(wt.path),
|
|
58
|
+
wt.branch ? getPRInfoAsync(wt.branch) : Promise.resolve(null),
|
|
59
|
+
]);
|
|
60
|
+
ahead = aheadResult;
|
|
61
|
+
status = dirtyResult ? "dirty" : "clean";
|
|
62
|
+
if (prInfo) {
|
|
63
|
+
pr = `#${prInfo.number}`;
|
|
64
|
+
prState = prInfo.state;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
info.push({
|
|
68
|
+
branch,
|
|
69
|
+
base,
|
|
70
|
+
ahead,
|
|
71
|
+
pr,
|
|
72
|
+
prState,
|
|
73
|
+
status,
|
|
74
|
+
path: wt.path,
|
|
75
|
+
isMain,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
setWtInfo(info);
|
|
79
|
+
setLoading(false);
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
setError(e instanceof Error ? e.message : "Unknown error");
|
|
83
|
+
setLoading(false);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
run();
|
|
87
|
+
}, []);
|
|
88
|
+
if (error) {
|
|
89
|
+
return (_jsx(Box, { padding: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }) }));
|
|
90
|
+
}
|
|
91
|
+
if (loading) {
|
|
92
|
+
return (_jsxs(Box, { padding: 1, gap: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: loadingMsg })] }));
|
|
93
|
+
}
|
|
94
|
+
if (wtInfo.length === 0) {
|
|
95
|
+
return (_jsx(Box, { padding: 1, children: _jsx(Text, { color: "yellow", children: "No worktrees found" }) }));
|
|
96
|
+
}
|
|
97
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "\uD83C\uDF33 Worktrees" }), _jsxs(Text, { dimColor: true, children: [" (", wtInfo.length, ")"] })] }), wtInfo.map((w, i) => (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: w.isMain
|
|
98
|
+
? "white"
|
|
99
|
+
: w.prState === "MERGED"
|
|
100
|
+
? "magenta"
|
|
101
|
+
: w.prState === "CLOSED"
|
|
102
|
+
? "red"
|
|
103
|
+
: w.status === "dirty"
|
|
104
|
+
? "yellow"
|
|
105
|
+
: "green", paddingX: 1, marginBottom: i < wtInfo.length - 1 ? 1 : 0, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: w.isMain ? "white" : "cyan", bold: true, children: w.branch }), w.isMain && _jsx(Text, { dimColor: true, children: "(main repo)" })] }), !w.isMain && (_jsxs(_Fragment, { children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "base:" }), _jsx(Text, { children: w.base }), w.ahead > 0 && (_jsxs(Text, { color: "green", bold: true, children: ["+", w.ahead, " ahead"] })), w.ahead === 0 && _jsx(Text, { dimColor: true, children: "up to date" })] }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "status:" }), w.status === "dirty" ? (_jsx(Text, { color: "yellow", bold: true, children: "\u25CF dirty" })) : (_jsx(Text, { color: "green", children: "\u2713 clean" }))] }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "PR:" }), w.pr === "-" ? (_jsx(Text, { dimColor: true, children: "none" })) : w.prState === "MERGED" ? (_jsxs(Text, { color: "magenta", children: [w.pr, " merged"] })) : w.prState === "CLOSED" ? (_jsxs(Text, { color: "red", children: [w.pr, " closed"] })) : (_jsxs(Text, { color: "blue", children: [w.pr, " open"] }))] })] })), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: w.path }) })] }, i)))] }));
|
|
106
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const options: z.ZodObject<{
|
|
3
|
+
draft: z.ZodOptional<z.ZodBoolean>;
|
|
4
|
+
}, z.core.$strip>;
|
|
5
|
+
type Props = {
|
|
6
|
+
options: z.infer<typeof options>;
|
|
7
|
+
};
|
|
8
|
+
export default function PR({ options }: Props): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Text, Box, useApp } from "ink";
|
|
4
|
+
import TextInput from "ink-text-input";
|
|
5
|
+
import Spinner from "ink-spinner";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { exec } from "child_process";
|
|
8
|
+
import { promisify } from "util";
|
|
9
|
+
import { findMainRepoRoot, findRepoRoot, getCurrentBranch, getDefaultBranch, getWorktreeMetadata, hasUncommittedChanges, getCommitsAhead, remoteBranchExists, getUnpushedCommits, extractTicketId, isInWorktree, getLatestCommitMessage, } from "../lib/git.js";
|
|
10
|
+
import { ghCliAvailable, getPRInfoAsync, pushBranch, createPR, } from "../lib/github.js";
|
|
11
|
+
const execAsync = promisify(exec);
|
|
12
|
+
export const options = z.object({
|
|
13
|
+
draft: z.boolean().optional().describe("Create as draft PR"),
|
|
14
|
+
});
|
|
15
|
+
export default function PR({ options }) {
|
|
16
|
+
const { exit } = useApp();
|
|
17
|
+
const [status, setStatus] = useState("checking");
|
|
18
|
+
const [message, setMessage] = useState("");
|
|
19
|
+
const [branch, setBranch] = useState(null);
|
|
20
|
+
const [baseBranch, setBaseBranch] = useState(null);
|
|
21
|
+
const [issueId, setIssueId] = useState(null);
|
|
22
|
+
const [titleInput, setTitleInput] = useState("");
|
|
23
|
+
async function handleTitleSubmit(value) {
|
|
24
|
+
const finalTitle = value.trim();
|
|
25
|
+
if (!finalTitle) {
|
|
26
|
+
setStatus("error");
|
|
27
|
+
setMessage("PR title is required");
|
|
28
|
+
setTimeout(() => exit(), 100);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (!branch || !baseBranch)
|
|
32
|
+
return;
|
|
33
|
+
setStatus("creating");
|
|
34
|
+
setMessage("Creating PR...");
|
|
35
|
+
const result = createPR(finalTitle, baseBranch, branch, options.draft ?? false);
|
|
36
|
+
if (result === 0) {
|
|
37
|
+
setStatus("done");
|
|
38
|
+
setMessage("Opened PR creation page in browser");
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
setStatus("error");
|
|
42
|
+
setMessage("Failed to create PR");
|
|
43
|
+
}
|
|
44
|
+
setTimeout(() => exit(), 100);
|
|
45
|
+
}
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
async function run() {
|
|
48
|
+
// Allow spinner to render first
|
|
49
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
50
|
+
// Check gh CLI is available
|
|
51
|
+
if (!ghCliAvailable()) {
|
|
52
|
+
setStatus("error");
|
|
53
|
+
setMessage("GitHub CLI (gh) is not installed. Install with: brew install gh");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// Yield to let spinner animate
|
|
57
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
58
|
+
// Find repos
|
|
59
|
+
const mainRepoRoot = findMainRepoRoot();
|
|
60
|
+
const currentRepo = findRepoRoot();
|
|
61
|
+
if (!mainRepoRoot || !currentRepo) {
|
|
62
|
+
setStatus("error");
|
|
63
|
+
setMessage("Not inside a git repository");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// Validate we're in a worktree (not the main repo)
|
|
67
|
+
if (!isInWorktree()) {
|
|
68
|
+
setStatus("error");
|
|
69
|
+
setMessage("Not inside a worktree (you are in the main repository)");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// Yield to let spinner animate
|
|
73
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
74
|
+
// Get current branch
|
|
75
|
+
const branchName = getCurrentBranch();
|
|
76
|
+
if (!branchName) {
|
|
77
|
+
setStatus("error");
|
|
78
|
+
setMessage("Could not determine current branch");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
setBranch(branchName);
|
|
82
|
+
// Check for uncommitted changes
|
|
83
|
+
if (hasUncommittedChanges()) {
|
|
84
|
+
setStatus("error");
|
|
85
|
+
setMessage("You have uncommitted changes. Please commit your changes before creating a PR.");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Yield to let spinner animate
|
|
89
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
90
|
+
// Get base branch from metadata
|
|
91
|
+
const metadata = getWorktreeMetadata(currentRepo);
|
|
92
|
+
const base = metadata?.base_branch ?? getDefaultBranch();
|
|
93
|
+
setBaseBranch(base);
|
|
94
|
+
// Check commits ahead
|
|
95
|
+
const commitsAhead = getCommitsAhead(base);
|
|
96
|
+
if (commitsAhead === 0) {
|
|
97
|
+
setStatus("error");
|
|
98
|
+
setMessage(`No commits ahead of ${base}. You need to make commits before creating a PR.`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// Yield to let spinner animate
|
|
102
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
103
|
+
// Check if we need to push
|
|
104
|
+
const remoteExists = remoteBranchExists(branchName);
|
|
105
|
+
const unpushed = getUnpushedCommits(branchName);
|
|
106
|
+
if (!remoteExists || unpushed > 0) {
|
|
107
|
+
setStatus("pushing");
|
|
108
|
+
setMessage("Pushing to remote...");
|
|
109
|
+
// Yield before push
|
|
110
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
111
|
+
if (!pushBranch(branchName)) {
|
|
112
|
+
setStatus("error");
|
|
113
|
+
setMessage("Failed to push branch to remote");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Check if PR already exists
|
|
118
|
+
const existingPr = await getPRInfoAsync(branchName);
|
|
119
|
+
if (existingPr) {
|
|
120
|
+
setStatus("existing");
|
|
121
|
+
setMessage(`PR already exists (#${existingPr.number}) - ${existingPr.state}`);
|
|
122
|
+
if (existingPr.url) {
|
|
123
|
+
try {
|
|
124
|
+
await execAsync(`open "${existingPr.url}"`);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Ignore open errors
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
setTimeout(() => exit(), 100);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// Get the latest commit message for the PR title
|
|
134
|
+
const latestCommit = getLatestCommitMessage();
|
|
135
|
+
let suggestedTitle = latestCommit ?? "";
|
|
136
|
+
// Extract ticket ID from branch name to display in UI
|
|
137
|
+
const ticket = extractTicketId(branchName);
|
|
138
|
+
if (ticket) {
|
|
139
|
+
setIssueId(ticket);
|
|
140
|
+
}
|
|
141
|
+
setTitleInput(suggestedTitle);
|
|
142
|
+
setStatus("awaiting-title");
|
|
143
|
+
}
|
|
144
|
+
run();
|
|
145
|
+
}, [options.draft]);
|
|
146
|
+
const isLoading = status === "checking" || status === "pushing" || status === "creating";
|
|
147
|
+
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"
|
|
148
|
+
? "red"
|
|
149
|
+
: status === "done"
|
|
150
|
+
? "green"
|
|
151
|
+
: status === "existing"
|
|
152
|
+
? "yellow"
|
|
153
|
+
: "blue", paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), baseBranch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "base:" }), _jsx(Text, { color: "blue", children: baseBranch })] })), issueId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "issue:" }), _jsx(Text, { color: "blue", bold: true, children: issueId })] })), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "type:" }), _jsx(Text, { backgroundColor: options.draft ? "yellow" : "green", color: "black", children: options.draft ? " draft " : " ready " })] })] }), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message || "Checking..."] })] })), status === "awaiting-title" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "PR Title: " }), _jsx(TextInput, { value: titleInput, onChange: setTitleInput, onSubmit: handleTitleSubmit })] })), status === "done" && (_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] })), status === "existing" && (_jsxs(Text, { color: "yellow", bold: true, children: ["\u26A0 ", message] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
|
|
154
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const options: z.ZodObject<{
|
|
3
|
+
force: z.ZodOptional<z.ZodBoolean>;
|
|
4
|
+
}, z.core.$strip>;
|
|
5
|
+
export declare const args: z.ZodTuple<[z.ZodString], null>;
|
|
6
|
+
type Props = {
|
|
7
|
+
options: z.infer<typeof options>;
|
|
8
|
+
args: z.infer<typeof args>;
|
|
9
|
+
};
|
|
10
|
+
export default function Remove({ options, args }: Props): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
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 { removeWorktree, findMainRepoRoot } from "../lib/git.js";
|
|
7
|
+
export const options = z.object({
|
|
8
|
+
force: z.boolean().optional().describe("Force removal"),
|
|
9
|
+
});
|
|
10
|
+
export const args = z.tuple([z.string().describe("Branch name to remove")]);
|
|
11
|
+
export default function Remove({ options, args }) {
|
|
12
|
+
const [branchName] = args;
|
|
13
|
+
const [status, setStatus] = useState("idle");
|
|
14
|
+
const [message, setMessage] = useState("");
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
async function run() {
|
|
17
|
+
// Small delay to allow spinner to render
|
|
18
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
19
|
+
const repoRoot = findMainRepoRoot();
|
|
20
|
+
if (!repoRoot) {
|
|
21
|
+
setStatus("error");
|
|
22
|
+
setMessage("Not inside a git repository");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
setStatus("removing");
|
|
26
|
+
setMessage(`Removing worktree ${branchName}...`);
|
|
27
|
+
const result = await removeWorktree(branchName, repoRoot, options.force ?? false);
|
|
28
|
+
if (result.success) {
|
|
29
|
+
setStatus("done");
|
|
30
|
+
setMessage(`Removed worktree and branch: ${branchName}`);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
setStatus("error");
|
|
34
|
+
setMessage(result.error ?? "Unknown error");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
run();
|
|
38
|
+
}, [branchName, options.force]);
|
|
39
|
+
const isLoading = status === "idle" || status === "removing";
|
|
40
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83D\uDDD1\uFE0F Remove" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : status === "done" ? "green" : "yellow", paddingX: 1, width: "100%", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "red", bold: true, children: branchName })] }), options.force && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "force:" }), _jsxs(Text, { backgroundColor: "red", color: "white", children: [" ", "yes", " "] })] }))] }), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message || "Removing..."] })] })), status === "done" && (_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function Setup(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,90 @@
|
|
|
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 { spawn } from "child_process";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import { findMainRepoRoot, getSantreeDir, isInWorktree } from "../lib/git.js";
|
|
9
|
+
export default function Setup() {
|
|
10
|
+
const [status, setStatus] = useState("checking");
|
|
11
|
+
const [message, setMessage] = useState("");
|
|
12
|
+
const [worktreePath, setWorktreePath] = useState("");
|
|
13
|
+
const [scriptPath, setScriptPath] = useState("");
|
|
14
|
+
const [output, setOutput] = useState("");
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
async function run() {
|
|
17
|
+
// Small delay to allow initial render with spinner
|
|
18
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
19
|
+
const mainRepo = findMainRepoRoot();
|
|
20
|
+
if (!mainRepo) {
|
|
21
|
+
setStatus("error");
|
|
22
|
+
setMessage("Not inside a git repository");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const santreeDir = getSantreeDir(mainRepo);
|
|
26
|
+
const initScript = path.join(santreeDir, "init.sh");
|
|
27
|
+
setScriptPath(initScript);
|
|
28
|
+
if (!fs.existsSync(initScript)) {
|
|
29
|
+
setStatus("error");
|
|
30
|
+
setMessage(`No init script found at ${initScript}`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
fs.accessSync(initScript, fs.constants.X_OK);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
setStatus("error");
|
|
38
|
+
setMessage(`Init script is not executable. Run: chmod +x ${initScript}`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const cwd = process.cwd();
|
|
42
|
+
if (!isInWorktree()) {
|
|
43
|
+
setStatus("error");
|
|
44
|
+
setMessage("Not inside a worktree (you are in the main repository)");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
setWorktreePath(cwd);
|
|
48
|
+
setStatus("running");
|
|
49
|
+
// Run script and capture output
|
|
50
|
+
const exitCode = await new Promise((resolve) => {
|
|
51
|
+
const child = spawn(initScript, [], {
|
|
52
|
+
cwd,
|
|
53
|
+
stdio: "pipe",
|
|
54
|
+
env: {
|
|
55
|
+
...process.env,
|
|
56
|
+
SANTREE_WORKTREE_PATH: cwd,
|
|
57
|
+
SANTREE_REPO_ROOT: mainRepo,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
let scriptOutput = "";
|
|
61
|
+
child.stdout?.on("data", (data) => {
|
|
62
|
+
scriptOutput += data.toString();
|
|
63
|
+
setOutput(scriptOutput);
|
|
64
|
+
});
|
|
65
|
+
child.stderr?.on("data", (data) => {
|
|
66
|
+
scriptOutput += data.toString();
|
|
67
|
+
setOutput(scriptOutput);
|
|
68
|
+
});
|
|
69
|
+
child.on("close", (code) => {
|
|
70
|
+
resolve(code ?? 1);
|
|
71
|
+
});
|
|
72
|
+
child.on("error", (err) => {
|
|
73
|
+
setOutput(err.message);
|
|
74
|
+
resolve(1);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
if (exitCode === 0) {
|
|
78
|
+
setStatus("done");
|
|
79
|
+
setMessage("Init script completed successfully");
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
setStatus("error");
|
|
83
|
+
setMessage(`Init script failed (exit code ${exitCode})`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
run();
|
|
87
|
+
}, []);
|
|
88
|
+
const isLoading = status === "checking" || status === "running";
|
|
89
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\u2699\uFE0F Setup" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : status === "done" ? "green" : "blue", paddingX: 1, width: "100%", children: [worktreePath && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "worktree:" }), _jsx(Text, { color: "cyan", children: worktreePath })] })), scriptPath && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "script:" }), _jsx(Text, { dimColor: true, children: scriptPath })] }))] }), output && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Output:" }), _jsx(Text, { children: output })] })), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: status === "checking" ? "Checking..." : "Running init script..." })] })), status === "done" && (_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
|
|
90
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { Text, Box } from "ink";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { getWorktreePath } from "../lib/git.js";
|
|
6
|
+
export const args = z.tuple([z.string().describe("Branch name to switch to")]);
|
|
7
|
+
export default function Switch({ args }) {
|
|
8
|
+
const [branchName] = args;
|
|
9
|
+
const hasOutputRef = useRef(false);
|
|
10
|
+
// Find worktree path synchronously
|
|
11
|
+
const worktreePath = getWorktreePath(branchName);
|
|
12
|
+
// Output SANTREE_CD once (before Ink fully renders)
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (worktreePath && !hasOutputRef.current) {
|
|
15
|
+
hasOutputRef.current = true;
|
|
16
|
+
process.stdout.write(`SANTREE_CD:${worktreePath}\n`);
|
|
17
|
+
}
|
|
18
|
+
}, [worktreePath]);
|
|
19
|
+
const status = worktreePath ? "done" : "error";
|
|
20
|
+
const error = worktreePath ? null : `Worktree not found for branch: ${branchName}`;
|
|
21
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83D\uDD00 Switch" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : "green", paddingX: 1, width: "100%", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branchName })] }), worktreePath && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "path:" }), _jsx(Text, { dimColor: true, children: worktreePath })] }))] }), _jsxs(Box, { marginTop: 1, children: [status === "done" && (_jsx(Text, { color: "green", bold: true, children: "\u2713 Switching to worktree" })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
|
|
22
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const options: z.ZodObject<{
|
|
3
|
+
rebase: z.ZodOptional<z.ZodBoolean>;
|
|
4
|
+
}, z.core.$strip>;
|
|
5
|
+
type Props = {
|
|
6
|
+
options: z.infer<typeof options>;
|
|
7
|
+
};
|
|
8
|
+
export default function Sync({ options }: Props): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
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 { spawn } from "child_process";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { findMainRepoRoot, findRepoRoot, getCurrentBranch, getDefaultBranch, getWorktreeMetadata, hasUncommittedChanges, getCommitsBehind, isInWorktree, } from "../lib/git.js";
|
|
8
|
+
export const options = z.object({
|
|
9
|
+
rebase: z.boolean().optional().describe("Use rebase instead of merge"),
|
|
10
|
+
});
|
|
11
|
+
function runCommand(cmd, args) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const child = spawn(cmd, args, { stdio: "pipe" });
|
|
14
|
+
let output = "";
|
|
15
|
+
child.stdout?.on("data", (data) => {
|
|
16
|
+
output += data.toString();
|
|
17
|
+
});
|
|
18
|
+
child.stderr?.on("data", (data) => {
|
|
19
|
+
output += data.toString();
|
|
20
|
+
});
|
|
21
|
+
child.on("close", (code) => {
|
|
22
|
+
resolve({ code: code ?? 1, output });
|
|
23
|
+
});
|
|
24
|
+
child.on("error", (err) => {
|
|
25
|
+
resolve({ code: 1, output: err.message });
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export default function Sync({ options }) {
|
|
30
|
+
const [status, setStatus] = useState("init");
|
|
31
|
+
const [message, setMessage] = useState("");
|
|
32
|
+
const [branch, setBranch] = useState(null);
|
|
33
|
+
const [baseBranch, setBaseBranch] = useState(null);
|
|
34
|
+
const [commitsBehind, setCommitsBehind] = useState(0);
|
|
35
|
+
const usesRebase = options.rebase ?? false;
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
async function run() {
|
|
38
|
+
// Small delay to allow initial render with spinner
|
|
39
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
40
|
+
// Find repos
|
|
41
|
+
const mainRepo = findMainRepoRoot();
|
|
42
|
+
const currentRepo = findRepoRoot();
|
|
43
|
+
if (!mainRepo || !currentRepo) {
|
|
44
|
+
setStatus("error");
|
|
45
|
+
setMessage("Not inside a git repository");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (!isInWorktree()) {
|
|
49
|
+
setStatus("error");
|
|
50
|
+
setMessage("Not inside a worktree (you are in the main repository)");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const branchName = getCurrentBranch();
|
|
54
|
+
if (!branchName) {
|
|
55
|
+
setStatus("error");
|
|
56
|
+
setMessage("Could not determine current branch");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
setBranch(branchName);
|
|
60
|
+
const metadata = getWorktreeMetadata(currentRepo);
|
|
61
|
+
const base = metadata?.base_branch ?? getDefaultBranch();
|
|
62
|
+
setBaseBranch(base);
|
|
63
|
+
if (hasUncommittedChanges()) {
|
|
64
|
+
setStatus("error");
|
|
65
|
+
setMessage("You have uncommitted changes. Please commit or stash them before syncing.");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Fetch
|
|
69
|
+
setStatus("fetching");
|
|
70
|
+
const fetchResult = await runCommand("git", ["fetch", "origin"]);
|
|
71
|
+
if (fetchResult.code !== 0) {
|
|
72
|
+
setStatus("error");
|
|
73
|
+
setMessage("Failed to fetch from remote");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Check behind
|
|
77
|
+
const behind = getCommitsBehind(base);
|
|
78
|
+
setCommitsBehind(behind);
|
|
79
|
+
if (behind === 0) {
|
|
80
|
+
setStatus("up-to-date");
|
|
81
|
+
setMessage(`Already up to date with origin/${base}`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// Sync
|
|
85
|
+
setStatus("syncing");
|
|
86
|
+
const cmd = usesRebase ? "rebase" : "merge";
|
|
87
|
+
const syncResult = await runCommand("git", [cmd, `origin/${base}`]);
|
|
88
|
+
if (syncResult.code === 0) {
|
|
89
|
+
setStatus("done");
|
|
90
|
+
setMessage(`Successfully synced with origin/${base}`);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
setStatus("error");
|
|
94
|
+
setMessage(usesRebase
|
|
95
|
+
? "Rebase failed - resolve conflicts and run: git rebase --continue"
|
|
96
|
+
: "Merge failed - resolve conflicts and run: git commit");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
run();
|
|
100
|
+
}, [usesRebase]);
|
|
101
|
+
const isLoading = status === "init" || status === "fetching" || status === "syncing";
|
|
102
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83D\uDD04 Sync" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error"
|
|
103
|
+
? "red"
|
|
104
|
+
: status === "done" || status === "up-to-date"
|
|
105
|
+
? "green"
|
|
106
|
+
: "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 })] })), commitsBehind > 0 && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "behind:" }), _jsxs(Text, { color: "yellow", bold: true, children: ["\u2193", commitsBehind] })] })), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "mode:" }), _jsx(Text, { backgroundColor: usesRebase ? "blue" : "magenta", color: "white", children: usesRebase ? " rebase " : " merge " })] })] }), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [status === "init" && "Starting...", status === "fetching" && "Fetching from remote...", status === "syncing" &&
|
|
107
|
+
(usesRebase ? "Rebasing..." : "Merging...")] })] })), (status === "done" || status === "up-to-date") && (_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
|
|
108
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const options: z.ZodObject<{
|
|
3
|
+
plan: z.ZodOptional<z.ZodBoolean>;
|
|
4
|
+
review: z.ZodOptional<z.ZodBoolean>;
|
|
5
|
+
"fix-pr": z.ZodOptional<z.ZodBoolean>;
|
|
6
|
+
}, z.core.$strip>;
|
|
7
|
+
type Props = {
|
|
8
|
+
options: z.infer<typeof options>;
|
|
9
|
+
};
|
|
10
|
+
export default function Work({ options }: Props): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export {};
|