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.
@@ -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,7 @@
1
+ import { z } from "zod";
2
+ export declare const args: z.ZodTuple<[z.ZodString], null>;
3
+ type Props = {
4
+ args: z.infer<typeof args>;
5
+ };
6
+ export default function Switch({ args }: Props): import("react/jsx-runtime").JSX.Element;
7
+ export {};
@@ -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 {};