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,169 @@
|
|
|
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 { getCurrentBranch, extractTicketId, findRepoRoot } from "../lib/git.js";
|
|
8
|
+
export const options = z.object({
|
|
9
|
+
plan: z.boolean().optional().describe("Only create implementation plan"),
|
|
10
|
+
review: z.boolean().optional().describe("Review changes against ticket"),
|
|
11
|
+
"fix-pr": z.boolean().optional().describe("Fetch PR comments and fix them"),
|
|
12
|
+
});
|
|
13
|
+
const TEMPLATES = {
|
|
14
|
+
implement: `Fetch Linear ticket {{ticket_id}} using MCP (including issue comments) and analyze what needs to be done.
|
|
15
|
+
|
|
16
|
+
If a PR URL is linked in the ticket, use \`gh\` CLI to fetch PR details, comments, and review feedback.
|
|
17
|
+
|
|
18
|
+
Review the codebase to understand the relevant areas and existing patterns.
|
|
19
|
+
|
|
20
|
+
Create an implementation plan, then implement the changes.
|
|
21
|
+
|
|
22
|
+
After implementation:
|
|
23
|
+
- Run tests if applicable
|
|
24
|
+
- Ensure code follows existing patterns`,
|
|
25
|
+
plan: `Fetch Linear ticket {{ticket_id}} using MCP (including issue comments) and analyze what needs to be done.
|
|
26
|
+
|
|
27
|
+
If a PR URL is linked in the ticket, use \`gh\` CLI to fetch PR details, comments, and review feedback for additional context.
|
|
28
|
+
|
|
29
|
+
Review the codebase to understand:
|
|
30
|
+
- Relevant files and modules
|
|
31
|
+
- Existing patterns and conventions
|
|
32
|
+
- Dependencies and potential impact areas
|
|
33
|
+
|
|
34
|
+
Create a detailed implementation plan with:
|
|
35
|
+
- Step-by-step approach
|
|
36
|
+
- Files to modify
|
|
37
|
+
- Potential risks or edge cases
|
|
38
|
+
|
|
39
|
+
Do NOT implement yet - just plan. Wait for approval before making changes.`,
|
|
40
|
+
review: `Fetch Linear ticket {{ticket_id}} using MCP (including issue comments) to understand the requirements and acceptance criteria.
|
|
41
|
+
|
|
42
|
+
If a PR URL is linked in the ticket, use \`gh\` CLI to fetch PR details and any existing review comments.
|
|
43
|
+
|
|
44
|
+
Review the current changes by running \`git diff\` against the base branch.
|
|
45
|
+
|
|
46
|
+
Analyze:
|
|
47
|
+
- Do the changes fully address the ticket requirements?
|
|
48
|
+
- Are there any missing acceptance criteria?
|
|
49
|
+
- Any potential bugs or edge cases?
|
|
50
|
+
- Code quality and adherence to patterns?
|
|
51
|
+
|
|
52
|
+
Provide a summary of findings and any recommended changes.`,
|
|
53
|
+
"fix-pr": `Fetch Linear ticket {{ticket_id}} using MCP (including issue comments) to understand the original requirements.
|
|
54
|
+
|
|
55
|
+
If a PR URL is linked in the ticket, use \`gh\` CLI to fetch the latest PR comments and review feedback.
|
|
56
|
+
|
|
57
|
+
## PR Comments to Address
|
|
58
|
+
|
|
59
|
+
{{pr_comments}}
|
|
60
|
+
|
|
61
|
+
## Task
|
|
62
|
+
|
|
63
|
+
1. Review each comment and understand what changes are requested
|
|
64
|
+
2. Make the necessary code changes to address each comment
|
|
65
|
+
3. Ensure the changes align with the original ticket requirements
|
|
66
|
+
4. Run tests if applicable to verify the fixes
|
|
67
|
+
|
|
68
|
+
Address all comments systematically, starting from the most critical ones.`,
|
|
69
|
+
};
|
|
70
|
+
function getMode(opts) {
|
|
71
|
+
if (opts["fix-pr"])
|
|
72
|
+
return "fix-pr";
|
|
73
|
+
if (opts.review)
|
|
74
|
+
return "review";
|
|
75
|
+
if (opts.plan)
|
|
76
|
+
return "plan";
|
|
77
|
+
return "implement";
|
|
78
|
+
}
|
|
79
|
+
function getModeLabel(mode) {
|
|
80
|
+
switch (mode) {
|
|
81
|
+
case "implement":
|
|
82
|
+
return "implement";
|
|
83
|
+
case "plan":
|
|
84
|
+
return "plan only";
|
|
85
|
+
case "review":
|
|
86
|
+
return "review";
|
|
87
|
+
case "fix-pr":
|
|
88
|
+
return "fix PR";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function getModeColor(mode) {
|
|
92
|
+
switch (mode) {
|
|
93
|
+
case "implement":
|
|
94
|
+
return "green";
|
|
95
|
+
case "plan":
|
|
96
|
+
return "blue";
|
|
97
|
+
case "review":
|
|
98
|
+
return "yellow";
|
|
99
|
+
case "fix-pr":
|
|
100
|
+
return "magenta";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export default function Work({ options }) {
|
|
104
|
+
const [status, setStatus] = useState("loading");
|
|
105
|
+
const [branch, setBranch] = useState(null);
|
|
106
|
+
const [ticketId, setTicketId] = useState(null);
|
|
107
|
+
const [error, setError] = useState(null);
|
|
108
|
+
const [mode, setMode] = useState("implement");
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
async function init() {
|
|
111
|
+
// Small delay to allow spinner to render
|
|
112
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
113
|
+
const repoRoot = findRepoRoot();
|
|
114
|
+
if (!repoRoot) {
|
|
115
|
+
setStatus("error");
|
|
116
|
+
setError("Not inside a git repository");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const currentBranch = getCurrentBranch();
|
|
120
|
+
if (!currentBranch) {
|
|
121
|
+
setStatus("error");
|
|
122
|
+
setError("Could not determine current branch");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
setBranch(currentBranch);
|
|
126
|
+
const ticket = extractTicketId(currentBranch);
|
|
127
|
+
if (!ticket) {
|
|
128
|
+
setStatus("error");
|
|
129
|
+
setError("Could not extract ticket ID from branch name. Expected format: user/TEAM-123-description");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
setTicketId(ticket);
|
|
133
|
+
setMode(getMode(options));
|
|
134
|
+
setStatus("ready");
|
|
135
|
+
}
|
|
136
|
+
init();
|
|
137
|
+
}, [options]);
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (status !== "ready" || !ticketId)
|
|
140
|
+
return;
|
|
141
|
+
setStatus("launching");
|
|
142
|
+
const template = TEMPLATES[mode];
|
|
143
|
+
const prompt = template.replace(/\{\{ticket_id\}\}/g, ticketId);
|
|
144
|
+
const happyCmd = "happy";
|
|
145
|
+
// Build args array
|
|
146
|
+
const args = [];
|
|
147
|
+
// Allow Linear MCP tools without prompting
|
|
148
|
+
args.push("--allowedTools", "mcp__linear__list_comments mcp__linear__get_issue");
|
|
149
|
+
// Add plan mode flag (Claude's native plan mode)
|
|
150
|
+
if (mode === "plan") {
|
|
151
|
+
args.push("--permission-mode", "plan");
|
|
152
|
+
}
|
|
153
|
+
// Add the prompt
|
|
154
|
+
args.push(prompt);
|
|
155
|
+
// Spawn happy directly with prompt as argument (no shell)
|
|
156
|
+
const child = spawn(happyCmd, args, {
|
|
157
|
+
stdio: "inherit",
|
|
158
|
+
});
|
|
159
|
+
child.on("error", (err) => {
|
|
160
|
+
setStatus("error");
|
|
161
|
+
setError(`Failed to launch happy: ${err.message}`);
|
|
162
|
+
});
|
|
163
|
+
child.on("close", () => {
|
|
164
|
+
process.exit(0);
|
|
165
|
+
});
|
|
166
|
+
}, [status, ticketId, mode]);
|
|
167
|
+
const isLoading = status === "loading";
|
|
168
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83E\uDD16 Work" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : getModeColor(mode), 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 })] })), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "mode:" }), _jsx(Text, { backgroundColor: getModeColor(mode), color: "white", bold: true, children: ` ${getModeLabel(mode)} ` })] })] }), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading..." })] })), status === "launching" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude (through Happy)..." }), _jsxs(Text, { dimColor: true, children: [" ", "happy", mode === "plan" ? " --permission-mode plan" : "", " ", `"<${getModeLabel(mode)} prompt for ${ticketId}>"`] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
|
|
169
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface Worktree {
|
|
2
|
+
path: string;
|
|
3
|
+
branch: string;
|
|
4
|
+
commit: string;
|
|
5
|
+
isBare: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function findRepoRoot(): string | null;
|
|
8
|
+
export declare function findMainRepoRoot(): string | null;
|
|
9
|
+
export declare function isInWorktree(): boolean;
|
|
10
|
+
export declare function isWorktreePath(wtPath: string): boolean;
|
|
11
|
+
export declare function getCurrentBranch(): string | null;
|
|
12
|
+
export declare function getDefaultBranch(): string;
|
|
13
|
+
export declare function listWorktrees(): Worktree[];
|
|
14
|
+
export declare function getSantreeDir(repoRoot: string): string;
|
|
15
|
+
export declare function getWorktreesDir(repoRoot: string): string;
|
|
16
|
+
export declare function createWorktree(branchName: string, baseBranch: string, repoRoot: string): Promise<{
|
|
17
|
+
success: boolean;
|
|
18
|
+
path?: string;
|
|
19
|
+
error?: string;
|
|
20
|
+
}>;
|
|
21
|
+
export declare function removeWorktree(branchName: string, repoRoot: string, force?: boolean): Promise<{
|
|
22
|
+
success: boolean;
|
|
23
|
+
error?: string;
|
|
24
|
+
}>;
|
|
25
|
+
export declare function extractTicketId(branch: string): string | null;
|
|
26
|
+
export declare function getWorktreePath(branchName: string): string | null;
|
|
27
|
+
export declare function getWorktreeMetadata(worktreePath: string): {
|
|
28
|
+
branch_name?: string;
|
|
29
|
+
base_branch?: string;
|
|
30
|
+
created_at?: string;
|
|
31
|
+
} | null;
|
|
32
|
+
export declare function hasUncommittedChanges(): boolean;
|
|
33
|
+
export declare function hasStagedChanges(): boolean;
|
|
34
|
+
export declare function hasUnstagedChanges(): boolean;
|
|
35
|
+
export declare function getGitStatus(): string;
|
|
36
|
+
export declare function getStagedDiffStat(): string;
|
|
37
|
+
export declare function getCommitsBehind(baseBranch: string): number;
|
|
38
|
+
export declare function getCommitsAhead(baseBranch: string): number;
|
|
39
|
+
export declare function remoteBranchExists(branchName: string): boolean;
|
|
40
|
+
export declare function getUnpushedCommits(branchName: string): number;
|
|
41
|
+
export declare function pullLatest(baseBranch: string, repoRoot: string): {
|
|
42
|
+
success: boolean;
|
|
43
|
+
message: string;
|
|
44
|
+
};
|
|
45
|
+
export declare function hasInitScript(repoRoot: string): boolean;
|
|
46
|
+
export declare function getInitScriptPath(repoRoot: string): string;
|
|
47
|
+
export declare function getLatestCommitMessage(): string | null;
|
package/dist/lib/git.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { execSync, exec } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as fs from "fs";
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
export function findRepoRoot() {
|
|
7
|
+
try {
|
|
8
|
+
return execSync("git rev-parse --show-toplevel", {
|
|
9
|
+
encoding: "utf-8",
|
|
10
|
+
}).trim();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function findMainRepoRoot() {
|
|
17
|
+
try {
|
|
18
|
+
const gitCommonDir = execSync("git rev-parse --git-common-dir", {
|
|
19
|
+
encoding: "utf-8",
|
|
20
|
+
}).trim();
|
|
21
|
+
return path.dirname(path.resolve(gitCommonDir));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function isInWorktree() {
|
|
28
|
+
try {
|
|
29
|
+
const gitDir = execSync("git rev-parse --git-dir", {
|
|
30
|
+
encoding: "utf-8",
|
|
31
|
+
}).trim();
|
|
32
|
+
const gitCommonDir = execSync("git rev-parse --git-common-dir", {
|
|
33
|
+
encoding: "utf-8",
|
|
34
|
+
}).trim();
|
|
35
|
+
// If they differ, we're in a worktree
|
|
36
|
+
return path.resolve(gitDir) !== path.resolve(gitCommonDir);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function isWorktreePath(wtPath) {
|
|
43
|
+
try {
|
|
44
|
+
const gitDir = execSync("git rev-parse --git-dir", {
|
|
45
|
+
encoding: "utf-8",
|
|
46
|
+
cwd: wtPath,
|
|
47
|
+
}).trim();
|
|
48
|
+
const gitCommonDir = execSync("git rev-parse --git-common-dir", {
|
|
49
|
+
encoding: "utf-8",
|
|
50
|
+
cwd: wtPath,
|
|
51
|
+
}).trim();
|
|
52
|
+
// If they differ, it's a worktree (not the main repo)
|
|
53
|
+
return path.resolve(wtPath, gitDir) !== path.resolve(wtPath, gitCommonDir);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function getCurrentBranch() {
|
|
60
|
+
try {
|
|
61
|
+
return execSync("git rev-parse --abbrev-ref HEAD", {
|
|
62
|
+
encoding: "utf-8",
|
|
63
|
+
}).trim();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function getDefaultBranch() {
|
|
70
|
+
try {
|
|
71
|
+
const ref = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
|
|
72
|
+
encoding: "utf-8",
|
|
73
|
+
}).trim();
|
|
74
|
+
return ref.replace("refs/remotes/origin/", "");
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Fall back to checking if main/master exists
|
|
78
|
+
for (const branch of ["main", "master"]) {
|
|
79
|
+
try {
|
|
80
|
+
execSync(`git rev-parse --verify refs/heads/${branch}`, {
|
|
81
|
+
stdio: "ignore",
|
|
82
|
+
});
|
|
83
|
+
return branch;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return "main";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function listWorktrees() {
|
|
93
|
+
try {
|
|
94
|
+
const output = execSync("git worktree list --porcelain", {
|
|
95
|
+
encoding: "utf-8",
|
|
96
|
+
});
|
|
97
|
+
const worktrees = [];
|
|
98
|
+
let current = {};
|
|
99
|
+
for (const line of output.split("\n")) {
|
|
100
|
+
if (line.startsWith("worktree ")) {
|
|
101
|
+
current.path = line.replace("worktree ", "");
|
|
102
|
+
}
|
|
103
|
+
else if (line.startsWith("HEAD ")) {
|
|
104
|
+
current.commit = line.replace("HEAD ", "").slice(0, 8);
|
|
105
|
+
}
|
|
106
|
+
else if (line.startsWith("branch ")) {
|
|
107
|
+
current.branch = line.replace("branch refs/heads/", "");
|
|
108
|
+
}
|
|
109
|
+
else if (line === "bare") {
|
|
110
|
+
current.isBare = true;
|
|
111
|
+
}
|
|
112
|
+
else if (line === "" && current.path) {
|
|
113
|
+
worktrees.push(current);
|
|
114
|
+
current = {};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (current.path) {
|
|
118
|
+
worktrees.push(current);
|
|
119
|
+
}
|
|
120
|
+
return worktrees;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export function getSantreeDir(repoRoot) {
|
|
127
|
+
return path.join(repoRoot, ".santree");
|
|
128
|
+
}
|
|
129
|
+
export function getWorktreesDir(repoRoot) {
|
|
130
|
+
return path.join(getSantreeDir(repoRoot), "worktrees");
|
|
131
|
+
}
|
|
132
|
+
export async function createWorktree(branchName, baseBranch, repoRoot) {
|
|
133
|
+
const dirName = branchName.replace(/\//g, "__");
|
|
134
|
+
const worktreesDir = getWorktreesDir(repoRoot);
|
|
135
|
+
const worktreePath = path.join(worktreesDir, dirName);
|
|
136
|
+
if (fs.existsSync(worktreePath)) {
|
|
137
|
+
return {
|
|
138
|
+
success: false,
|
|
139
|
+
error: `Worktree already exists at ${worktreePath}`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
// Ensure worktrees directory exists
|
|
143
|
+
fs.mkdirSync(worktreesDir, { recursive: true });
|
|
144
|
+
// Check if branch exists
|
|
145
|
+
let branchExists = false;
|
|
146
|
+
try {
|
|
147
|
+
execSync(`git rev-parse --verify refs/heads/${branchName}`, {
|
|
148
|
+
cwd: repoRoot,
|
|
149
|
+
stdio: "ignore",
|
|
150
|
+
});
|
|
151
|
+
branchExists = true;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
branchExists = false;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
if (branchExists) {
|
|
158
|
+
await execAsync(`git worktree add "${worktreePath}" "${branchName}"`, {
|
|
159
|
+
cwd: repoRoot,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
await execAsync(`git worktree add -b "${branchName}" "${worktreePath}" "${baseBranch}"`, { cwd: repoRoot });
|
|
164
|
+
}
|
|
165
|
+
// Save metadata
|
|
166
|
+
const metadata = {
|
|
167
|
+
branch_name: branchName,
|
|
168
|
+
base_branch: baseBranch,
|
|
169
|
+
created_at: new Date().toISOString(),
|
|
170
|
+
};
|
|
171
|
+
fs.writeFileSync(path.join(worktreePath, ".santree_metadata.json"), JSON.stringify(metadata, null, 2));
|
|
172
|
+
return { success: true, path: worktreePath };
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
return {
|
|
176
|
+
success: false,
|
|
177
|
+
error: e instanceof Error ? e.message : "Unknown error",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
export async function removeWorktree(branchName, repoRoot, force = false) {
|
|
182
|
+
// Find the worktree by branch name using git's worktree tracking
|
|
183
|
+
const worktreePath = getWorktreePath(branchName);
|
|
184
|
+
if (!worktreePath) {
|
|
185
|
+
return { success: false, error: `Worktree not found: ${branchName}` };
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const forceFlag = force ? "--force" : "";
|
|
189
|
+
await execAsync(`git worktree remove ${forceFlag} "${worktreePath}"`, {
|
|
190
|
+
cwd: repoRoot,
|
|
191
|
+
});
|
|
192
|
+
// Clean up any remaining files (untracked files, node_modules, etc.)
|
|
193
|
+
// git worktree remove doesn't delete untracked files
|
|
194
|
+
if (fs.existsSync(worktreePath)) {
|
|
195
|
+
// Fix permissions first (node_modules often has restricted perms)
|
|
196
|
+
try {
|
|
197
|
+
execSync(`chmod -R u+w "${worktreePath}"`, { stdio: "ignore" });
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// Ignore chmod errors
|
|
201
|
+
}
|
|
202
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
// Also delete the branch
|
|
205
|
+
const deleteFlag = force ? "-D" : "-d";
|
|
206
|
+
try {
|
|
207
|
+
await execAsync(`git branch ${deleteFlag} "${branchName}"`, {
|
|
208
|
+
cwd: repoRoot,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// Branch deletion failed, but worktree was removed
|
|
213
|
+
}
|
|
214
|
+
return { success: true };
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
return {
|
|
218
|
+
success: false,
|
|
219
|
+
error: e instanceof Error ? e.message : "Unknown error",
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
export function extractTicketId(branch) {
|
|
224
|
+
const match = branch.match(/([a-zA-Z]+)-(\d+)/);
|
|
225
|
+
if (match) {
|
|
226
|
+
return `${match[1].toUpperCase()}-${match[2]}`;
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
export function getWorktreePath(branchName) {
|
|
231
|
+
const worktrees = listWorktrees();
|
|
232
|
+
const wt = worktrees.find((w) => w.branch === branchName);
|
|
233
|
+
return wt?.path ?? null;
|
|
234
|
+
}
|
|
235
|
+
export function getWorktreeMetadata(worktreePath) {
|
|
236
|
+
const metadataPath = path.join(worktreePath, ".santree_metadata.json");
|
|
237
|
+
if (!fs.existsSync(metadataPath)) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
return JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
export function hasUncommittedChanges() {
|
|
248
|
+
try {
|
|
249
|
+
const output = execSync("git status --porcelain", { encoding: "utf-8" });
|
|
250
|
+
return Boolean(output.trim());
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
export function hasStagedChanges() {
|
|
257
|
+
try {
|
|
258
|
+
execSync("git diff --cached --quiet", { stdio: "ignore" });
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
export function hasUnstagedChanges() {
|
|
266
|
+
try {
|
|
267
|
+
// Check for modified files
|
|
268
|
+
try {
|
|
269
|
+
execSync("git diff --quiet", { stdio: "ignore" });
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
// Check for untracked files
|
|
275
|
+
const output = execSync("git ls-files --others --exclude-standard", {
|
|
276
|
+
encoding: "utf-8",
|
|
277
|
+
});
|
|
278
|
+
return Boolean(output.trim());
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
export function getGitStatus() {
|
|
285
|
+
try {
|
|
286
|
+
return execSync("git status --short", { encoding: "utf-8" }).trim();
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return "";
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
export function getStagedDiffStat() {
|
|
293
|
+
try {
|
|
294
|
+
return execSync("git diff --cached --stat", { encoding: "utf-8" }).trim();
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return "";
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
export function getCommitsBehind(baseBranch) {
|
|
301
|
+
try {
|
|
302
|
+
const output = execSync(`git rev-list --count HEAD..origin/${baseBranch}`, {
|
|
303
|
+
encoding: "utf-8",
|
|
304
|
+
});
|
|
305
|
+
return parseInt(output.trim(), 10) || 0;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
export function getCommitsAhead(baseBranch) {
|
|
312
|
+
try {
|
|
313
|
+
const output = execSync(`git rev-list --count ${baseBranch}..HEAD`, {
|
|
314
|
+
encoding: "utf-8",
|
|
315
|
+
});
|
|
316
|
+
return parseInt(output.trim(), 10) || 0;
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return 0;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
export function remoteBranchExists(branchName) {
|
|
323
|
+
try {
|
|
324
|
+
const output = execSync(`git ls-remote --heads origin ${branchName}`, {
|
|
325
|
+
encoding: "utf-8",
|
|
326
|
+
});
|
|
327
|
+
return output.includes(branchName);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
export function getUnpushedCommits(branchName) {
|
|
334
|
+
try {
|
|
335
|
+
// Check if remote tracking branch exists
|
|
336
|
+
try {
|
|
337
|
+
execSync(`git rev-parse --verify origin/${branchName}`, {
|
|
338
|
+
stdio: "ignore",
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// No remote branch, count all local commits
|
|
343
|
+
const output = execSync("git rev-list --count HEAD", {
|
|
344
|
+
encoding: "utf-8",
|
|
345
|
+
});
|
|
346
|
+
return parseInt(output.trim(), 10) || 0;
|
|
347
|
+
}
|
|
348
|
+
// Count commits ahead of remote
|
|
349
|
+
const output = execSync(`git rev-list --count origin/${branchName}..HEAD`, {
|
|
350
|
+
encoding: "utf-8",
|
|
351
|
+
});
|
|
352
|
+
return parseInt(output.trim(), 10) || 0;
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return 0;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
export function pullLatest(baseBranch, repoRoot) {
|
|
359
|
+
try {
|
|
360
|
+
// Fetch from origin
|
|
361
|
+
execSync("git fetch origin", { cwd: repoRoot, stdio: "ignore" });
|
|
362
|
+
// Update the base branch
|
|
363
|
+
execSync(`git checkout ${baseBranch}`, { cwd: repoRoot, stdio: "ignore" });
|
|
364
|
+
execSync(`git pull origin ${baseBranch}`, {
|
|
365
|
+
cwd: repoRoot,
|
|
366
|
+
stdio: "ignore",
|
|
367
|
+
});
|
|
368
|
+
return { success: true, message: "Fetched latest changes" };
|
|
369
|
+
}
|
|
370
|
+
catch (e) {
|
|
371
|
+
return {
|
|
372
|
+
success: false,
|
|
373
|
+
message: e instanceof Error ? e.message : "Failed to pull latest",
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
export function hasInitScript(repoRoot) {
|
|
378
|
+
const initScript = path.join(getSantreeDir(repoRoot), "init.sh");
|
|
379
|
+
return fs.existsSync(initScript);
|
|
380
|
+
}
|
|
381
|
+
export function getInitScriptPath(repoRoot) {
|
|
382
|
+
return path.join(getSantreeDir(repoRoot), "init.sh");
|
|
383
|
+
}
|
|
384
|
+
export function getLatestCommitMessage() {
|
|
385
|
+
try {
|
|
386
|
+
return execSync("git log -1 --format=%s", {
|
|
387
|
+
encoding: "utf-8",
|
|
388
|
+
}).trim();
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface PRInfo {
|
|
2
|
+
number: string;
|
|
3
|
+
state: "OPEN" | "MERGED" | "CLOSED";
|
|
4
|
+
url?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function getPRInfo(branchName: string): PRInfo | null;
|
|
7
|
+
export declare function getPRInfoAsync(branchName: string): Promise<PRInfo | null>;
|
|
8
|
+
export declare function ghCliAvailable(): boolean;
|
|
9
|
+
export declare function pushBranch(branchName: string, force?: boolean): boolean;
|
|
10
|
+
export declare function createPR(title: string, baseBranch: string, headBranch: string, draft: boolean): number;
|
|
11
|
+
export declare function getPRComments(prNumber: string): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { execSync, exec } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
const execAsync = promisify(exec);
|
|
4
|
+
export function getPRInfo(branchName) {
|
|
5
|
+
try {
|
|
6
|
+
const output = execSync(`gh pr view "${branchName}" --json number,state,url`, { encoding: "utf-8" });
|
|
7
|
+
const data = JSON.parse(output);
|
|
8
|
+
return {
|
|
9
|
+
number: String(data.number ?? ""),
|
|
10
|
+
state: data.state ?? "OPEN",
|
|
11
|
+
url: data.url,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function getPRInfoAsync(branchName) {
|
|
19
|
+
try {
|
|
20
|
+
const { stdout } = await execAsync(`gh pr view "${branchName}" --json number,state,url`);
|
|
21
|
+
const data = JSON.parse(stdout);
|
|
22
|
+
return {
|
|
23
|
+
number: String(data.number ?? ""),
|
|
24
|
+
state: data.state ?? "OPEN",
|
|
25
|
+
url: data.url,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function ghCliAvailable() {
|
|
33
|
+
try {
|
|
34
|
+
execSync("which gh", { stdio: "ignore" });
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function pushBranch(branchName, force = false) {
|
|
42
|
+
try {
|
|
43
|
+
const forceFlag = force ? "--force-with-lease" : "";
|
|
44
|
+
execSync(`git push -u origin "${branchName}" ${forceFlag}`.trim(), {
|
|
45
|
+
stdio: "inherit",
|
|
46
|
+
});
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function createPR(title, baseBranch, headBranch, draft) {
|
|
54
|
+
try {
|
|
55
|
+
const draftFlag = draft ? "--draft" : "";
|
|
56
|
+
execSync(`gh pr create --title "${title}" --base "${baseBranch}" --head "${headBranch}" --web ${draftFlag}`.trim(), { stdio: "inherit" });
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function getPRComments(prNumber) {
|
|
64
|
+
try {
|
|
65
|
+
const output = execSync(`gh pr view ${prNumber} --json comments --jq '.comments[] | "- \\(.author.login): \\(.body)"'`, { encoding: "utf-8" });
|
|
66
|
+
return output.trim();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
}
|