santree 0.0.13 → 0.0.15
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 +8 -0
- package/dist/commands/pr.d.ts +1 -1
- package/dist/commands/pr.js +86 -25
- package/dist/commands/statusline.js +1 -1
- package/dist/commands/work.js +1 -10
- package/dist/lib/exec.d.ts +7 -0
- package/dist/lib/exec.js +12 -0
- package/dist/lib/git.d.ts +154 -2
- package/dist/lib/git.js +236 -163
- package/dist/lib/github.d.ts +39 -1
- package/dist/lib/github.js +51 -11
- package/dist/lib/prompts.d.ts +6 -0
- package/dist/lib/prompts.js +15 -0
- package/package.json +1 -1
- package/prompts/fill-pr.njk +29 -0
package/README.md
CHANGED
|
@@ -81,6 +81,7 @@ santree clean
|
|
|
81
81
|
| `santree sync` | Sync current worktree with base branch |
|
|
82
82
|
| `santree setup` | Run the init script (`.santree/init.sh`) |
|
|
83
83
|
| `santree work` | Launch Claude AI to work on the current ticket |
|
|
84
|
+
| `santree pr` | Create a GitHub pull request (opens in browser) |
|
|
84
85
|
| `santree clean` | Remove worktrees with merged/closed PRs |
|
|
85
86
|
| `santree doctor` | Check system requirements and integrations |
|
|
86
87
|
| `santree editor` | Open workspace file in VSCode or Cursor |
|
|
@@ -179,6 +180,13 @@ Shows worktrees with merged/closed PRs and prompts for confirmation before remov
|
|
|
179
180
|
|--------|-------------|
|
|
180
181
|
| `--editor <cmd>` | Editor command to use (default: `code`). Also configurable via `SANTREE_EDITOR` env var |
|
|
181
182
|
|
|
183
|
+
### pr
|
|
184
|
+
| Option | Description |
|
|
185
|
+
|--------|-------------|
|
|
186
|
+
| `--fill` | Use AI to fill the PR template before opening |
|
|
187
|
+
|
|
188
|
+
Automatically pushes, detects existing PRs, and uses the first commit message as the title. If a closed PR exists for the branch, prompts before creating a new one.
|
|
189
|
+
|
|
182
190
|
### work
|
|
183
191
|
| Option | Description |
|
|
184
192
|
|--------|-------------|
|
package/dist/commands/pr.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export declare const description = "Create a GitHub pull request";
|
|
3
3
|
export declare const options: z.ZodObject<{
|
|
4
|
-
|
|
4
|
+
fill: z.ZodOptional<z.ZodBoolean>;
|
|
5
5
|
}, z.core.$strip>;
|
|
6
6
|
type Props = {
|
|
7
7
|
options: z.infer<typeof options>;
|
package/dist/commands/pr.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
|
-
import { Text, Box, useApp } from "ink";
|
|
4
|
-
import TextInput from "ink-text-input";
|
|
3
|
+
import { Text, Box, useInput, useApp } from "ink";
|
|
5
4
|
import Spinner from "ink-spinner";
|
|
6
5
|
import { z } from "zod";
|
|
7
|
-
import { exec } from "child_process";
|
|
6
|
+
import { exec, spawnSync } from "child_process";
|
|
8
7
|
import { promisify } from "util";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { writeFileSync } from "fs";
|
|
10
|
+
import { tmpdir } from "os";
|
|
11
|
+
import { findMainRepoRoot, findRepoRoot, getCurrentBranch, getDefaultBranch, getWorktreeMetadata, hasUncommittedChanges, getCommitsAhead, remoteBranchExists, getUnpushedCommits, extractTicketId, isInWorktree, getFirstCommitMessage, getCommitLog, getDiffStat, getDiffContent, } from "../lib/git.js";
|
|
12
|
+
import { ghCliAvailable, getPRInfoAsync, pushBranch, createPR, getPRTemplate, } from "../lib/github.js";
|
|
13
|
+
import { renderPrompt } from "../lib/prompts.js";
|
|
11
14
|
const execAsync = promisify(exec);
|
|
12
15
|
export const description = "Create a GitHub pull request";
|
|
13
16
|
export const options = z.object({
|
|
14
|
-
|
|
17
|
+
fill: z.boolean().optional().describe("Use AI to fill the PR template"),
|
|
15
18
|
});
|
|
16
19
|
export default function PR({ options }) {
|
|
17
20
|
const { exit } = useApp();
|
|
@@ -20,27 +23,78 @@ export default function PR({ options }) {
|
|
|
20
23
|
const [branch, setBranch] = useState(null);
|
|
21
24
|
const [baseBranch, setBaseBranch] = useState(null);
|
|
22
25
|
const [issueId, setIssueId] = useState(null);
|
|
23
|
-
const [
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (
|
|
26
|
+
const [closedPrInfo, setClosedPrInfo] = useState(null);
|
|
27
|
+
const [pendingCreate, setPendingCreate] = useState(false);
|
|
28
|
+
useInput((input, key) => {
|
|
29
|
+
if (status !== "confirm-reopen")
|
|
30
|
+
return;
|
|
31
|
+
if (input === "y" || input === "Y") {
|
|
32
|
+
setClosedPrInfo(null);
|
|
33
|
+
setPendingCreate(true);
|
|
34
|
+
}
|
|
35
|
+
else if (input === "n" || input === "N" || key.escape) {
|
|
27
36
|
setStatus("error");
|
|
28
|
-
setMessage("
|
|
37
|
+
setMessage("Cancelled");
|
|
29
38
|
setTimeout(() => exit(), 100);
|
|
30
|
-
return;
|
|
31
39
|
}
|
|
40
|
+
});
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!pendingCreate || !branch || !baseBranch)
|
|
43
|
+
return;
|
|
44
|
+
setPendingCreate(false);
|
|
45
|
+
openPR();
|
|
46
|
+
}, [pendingCreate]);
|
|
47
|
+
function openPR() {
|
|
32
48
|
if (!branch || !baseBranch)
|
|
33
49
|
return;
|
|
50
|
+
const title = getFirstCommitMessage(baseBranch) ?? branch;
|
|
51
|
+
let bodyFile;
|
|
52
|
+
if (options.fill) {
|
|
53
|
+
setStatus("filling");
|
|
54
|
+
setMessage("Filling PR template with AI...");
|
|
55
|
+
const prTemplate = getPRTemplate();
|
|
56
|
+
if (!prTemplate) {
|
|
57
|
+
setStatus("error");
|
|
58
|
+
setMessage("No PR template found at .github/pull_request_template.md");
|
|
59
|
+
setTimeout(() => exit(), 100);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const commitLog = getCommitLog(baseBranch) ?? "";
|
|
63
|
+
const diffStat = getDiffStat(baseBranch) ?? "";
|
|
64
|
+
const diff = getDiffContent(baseBranch) ?? "";
|
|
65
|
+
const ticketId = extractTicketId(branch);
|
|
66
|
+
const prompt = renderPrompt("fill-pr", {
|
|
67
|
+
pr_template: prTemplate,
|
|
68
|
+
commit_log: commitLog,
|
|
69
|
+
diff_stat: diffStat,
|
|
70
|
+
diff,
|
|
71
|
+
ticket_id: ticketId ?? "",
|
|
72
|
+
branch_name: branch,
|
|
73
|
+
});
|
|
74
|
+
const result = spawnSync("happy", ["-p", prompt, "--output-format", "text"], {
|
|
75
|
+
encoding: "utf-8",
|
|
76
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
77
|
+
});
|
|
78
|
+
if (result.status !== 0) {
|
|
79
|
+
setStatus("error");
|
|
80
|
+
setMessage("Failed to generate PR body with Claude");
|
|
81
|
+
setTimeout(() => exit(), 100);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const body = result.stdout.trim();
|
|
85
|
+
bodyFile = join(tmpdir(), `santree-pr-${Date.now()}.md`);
|
|
86
|
+
writeFileSync(bodyFile, body);
|
|
87
|
+
}
|
|
34
88
|
setStatus("creating");
|
|
35
|
-
setMessage("
|
|
36
|
-
const result = createPR(
|
|
89
|
+
setMessage("Opening PR in browser...");
|
|
90
|
+
const result = createPR(title, baseBranch, branch, bodyFile);
|
|
37
91
|
if (result === 0) {
|
|
38
92
|
setStatus("done");
|
|
39
93
|
setMessage("Opened PR creation page in browser");
|
|
40
94
|
}
|
|
41
95
|
else {
|
|
42
96
|
setStatus("error");
|
|
43
|
-
setMessage("Failed to
|
|
97
|
+
setMessage("Failed to open PR page");
|
|
44
98
|
}
|
|
45
99
|
setTimeout(() => exit(), 100);
|
|
46
100
|
}
|
|
@@ -83,7 +137,7 @@ export default function PR({ options }) {
|
|
|
83
137
|
// Check for uncommitted changes
|
|
84
138
|
if (hasUncommittedChanges()) {
|
|
85
139
|
setStatus("error");
|
|
86
|
-
setMessage("You have uncommitted changes. Please commit
|
|
140
|
+
setMessage("You have uncommitted changes. Please commit before creating a PR.");
|
|
87
141
|
return;
|
|
88
142
|
}
|
|
89
143
|
// Yield to let spinner animate
|
|
@@ -96,7 +150,7 @@ export default function PR({ options }) {
|
|
|
96
150
|
const commitsAhead = getCommitsAhead(base);
|
|
97
151
|
if (commitsAhead === 0) {
|
|
98
152
|
setStatus("error");
|
|
99
|
-
setMessage(`No commits ahead of ${base}.
|
|
153
|
+
setMessage(`No commits ahead of ${base}. Make commits before creating a PR.`);
|
|
100
154
|
return;
|
|
101
155
|
}
|
|
102
156
|
// Yield to let spinner animate
|
|
@@ -118,6 +172,12 @@ export default function PR({ options }) {
|
|
|
118
172
|
// Check if PR already exists
|
|
119
173
|
const existingPr = await getPRInfoAsync(branchName);
|
|
120
174
|
if (existingPr) {
|
|
175
|
+
if (existingPr.state === "CLOSED") {
|
|
176
|
+
// Closed PR — let user decide to create a new one
|
|
177
|
+
setClosedPrInfo(existingPr);
|
|
178
|
+
setStatus("confirm-reopen");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
121
181
|
setStatus("existing");
|
|
122
182
|
setMessage(`PR already exists (#${existingPr.number}) - ${existingPr.state}`);
|
|
123
183
|
if (existingPr.url) {
|
|
@@ -131,25 +191,26 @@ export default function PR({ options }) {
|
|
|
131
191
|
setTimeout(() => exit(), 100);
|
|
132
192
|
return;
|
|
133
193
|
}
|
|
134
|
-
// Get the latest commit message for the PR title
|
|
135
|
-
const latestCommit = getLatestCommitMessage();
|
|
136
|
-
let suggestedTitle = latestCommit ?? "";
|
|
137
194
|
// Extract ticket ID from branch name to display in UI
|
|
138
195
|
const ticket = extractTicketId(branchName);
|
|
139
196
|
if (ticket) {
|
|
140
197
|
setIssueId(ticket);
|
|
141
198
|
}
|
|
142
|
-
setTitleInput(suggestedTitle);
|
|
143
|
-
setStatus("awaiting-title");
|
|
144
199
|
}
|
|
145
200
|
run();
|
|
146
|
-
}, [options.
|
|
147
|
-
|
|
201
|
+
}, [options.fill]);
|
|
202
|
+
// Once branch and baseBranch are set and we're still checking, go straight to PR
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
if (status === "checking" && branch && baseBranch && !closedPrInfo) {
|
|
205
|
+
openPR();
|
|
206
|
+
}
|
|
207
|
+
}, [status, branch, baseBranch]);
|
|
208
|
+
const isLoading = status === "checking" || status === "pushing" || status === "filling" || status === "creating";
|
|
148
209
|
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"
|
|
149
210
|
? "red"
|
|
150
211
|
: status === "done"
|
|
151
212
|
? "green"
|
|
152
213
|
: status === "existing"
|
|
153
214
|
? "yellow"
|
|
154
|
-
: "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, {
|
|
215
|
+
: "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, { marginTop: 1, flexDirection: "column", children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message || "Checking..."] })] })), status === "confirm-reopen" && closedPrInfo && (_jsxs(Box, { children: [_jsxs(Text, { color: "yellow", children: ["PR #", closedPrInfo.number, " was closed. Create a new one? "] }), _jsx(Text, { color: "green", bold: true, children: "[y]" }), _jsx(Text, { children: " / " }), _jsx(Text, { color: "red", bold: true, children: "[n]" })] })), 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] }))] })] }));
|
|
155
216
|
}
|
|
@@ -132,7 +132,7 @@ function formatChanges(changes) {
|
|
|
132
132
|
// Build statusline for santree worktree
|
|
133
133
|
function buildSantreeStatusline(cwd, metadata, model, usedPercentage) {
|
|
134
134
|
const parts = [];
|
|
135
|
-
const branch =
|
|
135
|
+
const branch = git(cwd, "rev-parse --abbrev-ref HEAD") || "unknown";
|
|
136
136
|
// Ticket ID (prominent)
|
|
137
137
|
const ticketId = extractTicketId(branch);
|
|
138
138
|
if (ticketId) {
|
package/dist/commands/work.js
CHANGED
|
@@ -4,23 +4,14 @@ import { Text, Box } from "ink";
|
|
|
4
4
|
import Spinner from "ink-spinner";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { spawn } from "child_process";
|
|
7
|
-
import { fileURLToPath } from "url";
|
|
8
|
-
import { dirname, join } from "path";
|
|
9
|
-
import nunjucks from "nunjucks";
|
|
10
7
|
import { getCurrentBranch, extractTicketId, findRepoRoot } from "../lib/git.js";
|
|
11
|
-
|
|
12
|
-
const __dirname = dirname(__filename);
|
|
13
|
-
const promptsDir = join(__dirname, "..", "..", "prompts");
|
|
14
|
-
const promptsEnv = new nunjucks.Environment(new nunjucks.FileSystemLoader(promptsDir), { autoescape: false });
|
|
8
|
+
import { renderPrompt } from "../lib/prompts.js";
|
|
15
9
|
export const description = "Launch Claude to work on current ticket";
|
|
16
10
|
export const options = z.object({
|
|
17
11
|
plan: z.boolean().optional().describe("Only create implementation plan"),
|
|
18
12
|
review: z.boolean().optional().describe("Review changes against ticket"),
|
|
19
13
|
"fix-pr": z.boolean().optional().describe("Fetch PR comments and fix them"),
|
|
20
14
|
});
|
|
21
|
-
function renderPrompt(mode, context) {
|
|
22
|
-
return promptsEnv.render(`${mode}.njk`, context);
|
|
23
|
-
}
|
|
24
15
|
function getMode(opts) {
|
|
25
16
|
if (opts["fix-pr"])
|
|
26
17
|
return "fix-pr";
|
package/dist/lib/exec.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Run a shell command and return trimmed stdout, or null on failure.
|
|
4
|
+
*/
|
|
5
|
+
export function run(command, options) {
|
|
6
|
+
try {
|
|
7
|
+
return execSync(command, { encoding: "utf-8", ...options }).trim();
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
package/dist/lib/git.d.ts
CHANGED
|
@@ -4,44 +4,196 @@ export interface Worktree {
|
|
|
4
4
|
commit: string;
|
|
5
5
|
isBare: boolean;
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* Find the toplevel directory of the current git repository.
|
|
9
|
+
* Runs: `git rev-parse --show-toplevel`
|
|
10
|
+
* Returns null if not inside a git repo.
|
|
11
|
+
*/
|
|
7
12
|
export declare function findRepoRoot(): string | null;
|
|
13
|
+
/**
|
|
14
|
+
* Find the root of the main (non-worktree) repository by resolving --git-common-dir.
|
|
15
|
+
* Runs: `git rev-parse --git-common-dir`
|
|
16
|
+
* Returns null if not inside a git repo.
|
|
17
|
+
*/
|
|
8
18
|
export declare function findMainRepoRoot(): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Check whether the current working directory is inside a git worktree (not the main repo).
|
|
21
|
+
* Compares `git rev-parse --git-dir` vs `--git-common-dir` — they differ inside a worktree.
|
|
22
|
+
* Returns false if not in a git repo or if in the main repo.
|
|
23
|
+
*/
|
|
9
24
|
export declare function isInWorktree(): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Check whether a given path is a git worktree (not a main repo checkout).
|
|
27
|
+
* Runs: `git rev-parse --git-dir` and `--git-common-dir` with cwd set to wtPath.
|
|
28
|
+
* Returns false if the path is not a git repo or is the main repo.
|
|
29
|
+
*/
|
|
10
30
|
export declare function isWorktreePath(wtPath: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Get the name of the currently checked-out branch.
|
|
33
|
+
* Runs: `git rev-parse --abbrev-ref HEAD`
|
|
34
|
+
* Returns null if in detached HEAD state or not in a git repo.
|
|
35
|
+
*/
|
|
11
36
|
export declare function getCurrentBranch(): string | null;
|
|
37
|
+
/**
|
|
38
|
+
* Determine the default branch (e.g. main or master) for the origin remote.
|
|
39
|
+
* Runs: `git symbolic-ref refs/remotes/origin/HEAD`
|
|
40
|
+
* Falls back to checking if "main" or "master" branches exist locally.
|
|
41
|
+
* Returns "main" as a last resort.
|
|
42
|
+
*/
|
|
12
43
|
export declare function getDefaultBranch(): string;
|
|
44
|
+
/**
|
|
45
|
+
* List all git worktrees in the current repository.
|
|
46
|
+
* Runs: `git worktree list --porcelain`
|
|
47
|
+
* Returns an empty array on failure.
|
|
48
|
+
*/
|
|
13
49
|
export declare function listWorktrees(): Worktree[];
|
|
50
|
+
/**
|
|
51
|
+
* Get the path to the .santree directory inside a repo root.
|
|
52
|
+
*/
|
|
14
53
|
export declare function getSantreeDir(repoRoot: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* Get the path to the .santree/worktrees directory inside a repo root.
|
|
56
|
+
*/
|
|
15
57
|
export declare function getWorktreesDir(repoRoot: string): string;
|
|
58
|
+
/**
|
|
59
|
+
* Create a new git worktree for a branch, optionally creating the branch from a base.
|
|
60
|
+
* The worktree directory is named after the ticket ID extracted from the branch name.
|
|
61
|
+
* Runs: `git worktree add [-b branchName] <path> <branch|baseBranch>`
|
|
62
|
+
* Returns { success: false, error } if no ticket ID found, path already exists, or git fails.
|
|
63
|
+
*/
|
|
16
64
|
export declare function createWorktree(branchName: string, baseBranch: string, repoRoot: string): Promise<{
|
|
17
65
|
success: boolean;
|
|
18
66
|
path?: string;
|
|
19
67
|
error?: string;
|
|
20
68
|
}>;
|
|
69
|
+
/**
|
|
70
|
+
* Remove a git worktree by branch name, cleaning up the directory and optionally deleting the branch.
|
|
71
|
+
* Runs: `git worktree remove [--force] <path>` then `git branch -d|-D <branchName>`
|
|
72
|
+
* Returns { success: false, error } if worktree not found or git fails.
|
|
73
|
+
*/
|
|
21
74
|
export declare function removeWorktree(branchName: string, repoRoot: string, force?: boolean): Promise<{
|
|
22
75
|
success: boolean;
|
|
23
76
|
error?: string;
|
|
24
77
|
}>;
|
|
78
|
+
/**
|
|
79
|
+
* Extract a ticket ID (e.g. "TEAM-123") from a branch name.
|
|
80
|
+
* Matches the first occurrence of LETTERS-DIGITS in the string.
|
|
81
|
+
* Returns null if no ticket ID pattern is found.
|
|
82
|
+
*/
|
|
25
83
|
export declare function extractTicketId(branch: string): string | null;
|
|
84
|
+
/**
|
|
85
|
+
* Get the filesystem path for a worktree by its branch name.
|
|
86
|
+
* Uses `git worktree list --porcelain` under the hood.
|
|
87
|
+
* Returns null if no worktree is checked out on that branch.
|
|
88
|
+
*/
|
|
26
89
|
export declare function getWorktreePath(branchName: string): string | null;
|
|
90
|
+
/**
|
|
91
|
+
* Read the .santree_metadata.json file from a worktree directory.
|
|
92
|
+
* Returns null if the file doesn't exist or can't be parsed.
|
|
93
|
+
*/
|
|
27
94
|
export declare function getWorktreeMetadata(worktreePath: string): {
|
|
28
|
-
branch_name?: string;
|
|
29
95
|
base_branch?: string;
|
|
30
|
-
created_at?: string;
|
|
31
96
|
} | null;
|
|
97
|
+
/**
|
|
98
|
+
* Check if there are any uncommitted changes (staged or unstaged).
|
|
99
|
+
* Runs: `git status --porcelain`
|
|
100
|
+
* Returns false if not in a git repo.
|
|
101
|
+
*/
|
|
32
102
|
export declare function hasUncommittedChanges(): boolean;
|
|
103
|
+
/**
|
|
104
|
+
* Check if there are staged changes ready to commit.
|
|
105
|
+
* Runs: `git diff --cached --quiet` (exits non-zero if there are staged changes).
|
|
106
|
+
* Returns false if not in a git repo.
|
|
107
|
+
*/
|
|
33
108
|
export declare function hasStagedChanges(): boolean;
|
|
109
|
+
/**
|
|
110
|
+
* Check if there are unstaged modifications or untracked files.
|
|
111
|
+
* Runs: `git diff --quiet` and `git ls-files --others --exclude-standard`
|
|
112
|
+
* Returns false if not in a git repo.
|
|
113
|
+
*/
|
|
34
114
|
export declare function hasUnstagedChanges(): boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Get a short summary of the working tree status.
|
|
117
|
+
* Runs: `git status --short`
|
|
118
|
+
* Returns empty string on failure.
|
|
119
|
+
*/
|
|
35
120
|
export declare function getGitStatus(): string;
|
|
121
|
+
/**
|
|
122
|
+
* Get a diffstat of staged changes.
|
|
123
|
+
* Runs: `git diff --cached --stat`
|
|
124
|
+
* Returns empty string on failure.
|
|
125
|
+
*/
|
|
36
126
|
export declare function getStagedDiffStat(): string;
|
|
127
|
+
/**
|
|
128
|
+
* Count how many commits the current branch is behind origin/baseBranch.
|
|
129
|
+
* Runs: `git rev-list --count HEAD..origin/<baseBranch>`
|
|
130
|
+
* Returns 0 on failure.
|
|
131
|
+
*/
|
|
37
132
|
export declare function getCommitsBehind(baseBranch: string): number;
|
|
133
|
+
/**
|
|
134
|
+
* Count how many commits the current branch is ahead of baseBranch.
|
|
135
|
+
* Runs: `git rev-list --count <baseBranch>..HEAD`
|
|
136
|
+
* Returns 0 on failure.
|
|
137
|
+
*/
|
|
38
138
|
export declare function getCommitsAhead(baseBranch: string): number;
|
|
139
|
+
/**
|
|
140
|
+
* Check if a branch exists on the remote (origin).
|
|
141
|
+
* Runs: `git ls-remote --heads origin <branchName>`
|
|
142
|
+
* Returns false on failure.
|
|
143
|
+
*/
|
|
39
144
|
export declare function remoteBranchExists(branchName: string): boolean;
|
|
145
|
+
/**
|
|
146
|
+
* Count how many local commits haven't been pushed to origin.
|
|
147
|
+
* Runs: `git rev-list --count origin/<branchName>..HEAD`
|
|
148
|
+
* If no remote tracking branch exists, counts all commits on HEAD.
|
|
149
|
+
* Returns 0 on failure.
|
|
150
|
+
*/
|
|
40
151
|
export declare function getUnpushedCommits(branchName: string): number;
|
|
152
|
+
/**
|
|
153
|
+
* Fetch from origin and pull the latest changes on a base branch.
|
|
154
|
+
* Runs: `git fetch origin`, `git checkout <baseBranch>`, `git pull origin <baseBranch>`
|
|
155
|
+
* Returns { success: false, message } if any step fails.
|
|
156
|
+
*/
|
|
41
157
|
export declare function pullLatest(baseBranch: string, repoRoot: string): {
|
|
42
158
|
success: boolean;
|
|
43
159
|
message: string;
|
|
44
160
|
};
|
|
161
|
+
/**
|
|
162
|
+
* Check if a .santree/init.sh script exists in the repo.
|
|
163
|
+
*/
|
|
45
164
|
export declare function hasInitScript(repoRoot: string): boolean;
|
|
165
|
+
/**
|
|
166
|
+
* Get the path to the .santree/init.sh script.
|
|
167
|
+
*/
|
|
46
168
|
export declare function getInitScriptPath(repoRoot: string): string;
|
|
169
|
+
/**
|
|
170
|
+
* Get the subject line of the latest commit.
|
|
171
|
+
* Runs: `git log -1 --format=%s`
|
|
172
|
+
* Returns null if not in a git repo or no commits.
|
|
173
|
+
*/
|
|
47
174
|
export declare function getLatestCommitMessage(): string | null;
|
|
175
|
+
/**
|
|
176
|
+
* Get the subject line of the first commit on the current branch since baseBranch.
|
|
177
|
+
* Runs: `git log <baseBranch>..HEAD --reverse --format=%s`
|
|
178
|
+
* Returns null if there are no commits ahead of baseBranch.
|
|
179
|
+
*/
|
|
180
|
+
export declare function getFirstCommitMessage(baseBranch: string): string | null;
|
|
181
|
+
/**
|
|
182
|
+
* Get a formatted commit log of all commits since baseBranch.
|
|
183
|
+
* Runs: `git log <baseBranch>..HEAD --format="- %s"`
|
|
184
|
+
* Returns null if there are no commits or on failure.
|
|
185
|
+
*/
|
|
186
|
+
export declare function getCommitLog(baseBranch: string): string | null;
|
|
187
|
+
/**
|
|
188
|
+
* Get a diffstat summary of all changes since baseBranch.
|
|
189
|
+
* Runs: `git diff <baseBranch>..HEAD --stat`
|
|
190
|
+
* Returns null if there are no changes or on failure.
|
|
191
|
+
*/
|
|
192
|
+
export declare function getDiffStat(baseBranch: string): string | null;
|
|
193
|
+
/**
|
|
194
|
+
* Get the full diff of all changes since baseBranch.
|
|
195
|
+
* Runs: `git diff <baseBranch>..HEAD`
|
|
196
|
+
* Uses a 10MB max buffer for large diffs.
|
|
197
|
+
* Returns null if there are no changes or on failure.
|
|
198
|
+
*/
|
|
199
|
+
export declare function getDiffContent(baseBranch: string): string | null;
|
package/dist/lib/git.js
CHANGED
|
@@ -2,135 +2,141 @@ import { execSync, exec } from "child_process";
|
|
|
2
2
|
import { promisify } from "util";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import * as fs from "fs";
|
|
5
|
+
import { run } from "./exec.js";
|
|
5
6
|
const execAsync = promisify(exec);
|
|
7
|
+
/**
|
|
8
|
+
* Find the toplevel directory of the current git repository.
|
|
9
|
+
* Runs: `git rev-parse --show-toplevel`
|
|
10
|
+
* Returns null if not inside a git repo.
|
|
11
|
+
*/
|
|
6
12
|
export function findRepoRoot() {
|
|
7
|
-
|
|
8
|
-
return execSync("git rev-parse --show-toplevel", {
|
|
9
|
-
encoding: "utf-8",
|
|
10
|
-
}).trim();
|
|
11
|
-
}
|
|
12
|
-
catch {
|
|
13
|
-
return null;
|
|
14
|
-
}
|
|
13
|
+
return run("git rev-parse --show-toplevel");
|
|
15
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Find the root of the main (non-worktree) repository by resolving --git-common-dir.
|
|
17
|
+
* Runs: `git rev-parse --git-common-dir`
|
|
18
|
+
* Returns null if not inside a git repo.
|
|
19
|
+
*/
|
|
16
20
|
export function findMainRepoRoot() {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
encoding: "utf-8",
|
|
20
|
-
}).trim();
|
|
21
|
-
return path.dirname(path.resolve(gitCommonDir));
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
21
|
+
const gitCommonDir = run("git rev-parse --git-common-dir");
|
|
22
|
+
if (!gitCommonDir)
|
|
24
23
|
return null;
|
|
25
|
-
|
|
24
|
+
return path.dirname(path.resolve(gitCommonDir));
|
|
26
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Check whether the current working directory is inside a git worktree (not the main repo).
|
|
28
|
+
* Compares `git rev-parse --git-dir` vs `--git-common-dir` — they differ inside a worktree.
|
|
29
|
+
* Returns false if not in a git repo or if in the main repo.
|
|
30
|
+
*/
|
|
27
31
|
export function isInWorktree() {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 {
|
|
32
|
+
const gitDir = run("git rev-parse --git-dir");
|
|
33
|
+
const gitCommonDir = run("git rev-parse --git-common-dir");
|
|
34
|
+
if (!gitDir || !gitCommonDir)
|
|
39
35
|
return false;
|
|
40
|
-
|
|
36
|
+
return path.resolve(gitDir) !== path.resolve(gitCommonDir);
|
|
41
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Check whether a given path is a git worktree (not a main repo checkout).
|
|
40
|
+
* Runs: `git rev-parse --git-dir` and `--git-common-dir` with cwd set to wtPath.
|
|
41
|
+
* Returns false if the path is not a git repo or is the main repo.
|
|
42
|
+
*/
|
|
42
43
|
export function isWorktreePath(wtPath) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 {
|
|
44
|
+
const gitDir = run("git rev-parse --git-dir", { cwd: wtPath });
|
|
45
|
+
const gitCommonDir = run("git rev-parse --git-common-dir", { cwd: wtPath });
|
|
46
|
+
if (!gitDir || !gitCommonDir)
|
|
56
47
|
return false;
|
|
57
|
-
|
|
48
|
+
return path.resolve(wtPath, gitDir) !== path.resolve(wtPath, gitCommonDir);
|
|
58
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Get the name of the currently checked-out branch.
|
|
52
|
+
* Runs: `git rev-parse --abbrev-ref HEAD`
|
|
53
|
+
* Returns null if in detached HEAD state or not in a git repo.
|
|
54
|
+
*/
|
|
59
55
|
export function getCurrentBranch() {
|
|
60
|
-
|
|
61
|
-
return execSync("git rev-parse --abbrev-ref HEAD", {
|
|
62
|
-
encoding: "utf-8",
|
|
63
|
-
}).trim();
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
56
|
+
return run("git rev-parse --abbrev-ref HEAD");
|
|
68
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Determine the default branch (e.g. main or master) for the origin remote.
|
|
60
|
+
* Runs: `git symbolic-ref refs/remotes/origin/HEAD`
|
|
61
|
+
* Falls back to checking if "main" or "master" branches exist locally.
|
|
62
|
+
* Returns "main" as a last resort.
|
|
63
|
+
*/
|
|
69
64
|
export function getDefaultBranch() {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
encoding: "utf-8",
|
|
73
|
-
}).trim();
|
|
65
|
+
const ref = run("git symbolic-ref refs/remotes/origin/HEAD");
|
|
66
|
+
if (ref)
|
|
74
67
|
return ref.replace("refs/remotes/origin/", "");
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
catch {
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
68
|
+
// Fall back to checking if main/master exists
|
|
69
|
+
for (const branch of ["main", "master"]) {
|
|
70
|
+
try {
|
|
71
|
+
execSync(`git rev-parse --verify refs/heads/${branch}`, {
|
|
72
|
+
stdio: "ignore",
|
|
73
|
+
});
|
|
74
|
+
return branch;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
continue;
|
|
88
78
|
}
|
|
89
|
-
return "main";
|
|
90
79
|
}
|
|
80
|
+
return "main";
|
|
91
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* List all git worktrees in the current repository.
|
|
84
|
+
* Runs: `git worktree list --porcelain`
|
|
85
|
+
* Returns an empty array on failure.
|
|
86
|
+
*/
|
|
92
87
|
export function listWorktrees() {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
current.isBare = true;
|
|
111
|
-
}
|
|
112
|
-
else if (line === "" && current.path) {
|
|
113
|
-
worktrees.push(current);
|
|
114
|
-
current = {};
|
|
115
|
-
}
|
|
88
|
+
const output = run("git worktree list --porcelain");
|
|
89
|
+
if (!output)
|
|
90
|
+
return [];
|
|
91
|
+
const worktrees = [];
|
|
92
|
+
let current = {};
|
|
93
|
+
for (const line of output.split("\n")) {
|
|
94
|
+
if (line.startsWith("worktree ")) {
|
|
95
|
+
current.path = line.replace("worktree ", "");
|
|
96
|
+
}
|
|
97
|
+
else if (line.startsWith("HEAD ")) {
|
|
98
|
+
current.commit = line.replace("HEAD ", "").slice(0, 8);
|
|
99
|
+
}
|
|
100
|
+
else if (line.startsWith("branch ")) {
|
|
101
|
+
current.branch = line.replace("branch refs/heads/", "");
|
|
102
|
+
}
|
|
103
|
+
else if (line === "bare") {
|
|
104
|
+
current.isBare = true;
|
|
116
105
|
}
|
|
117
|
-
if (current.path) {
|
|
106
|
+
else if (line === "" && current.path) {
|
|
118
107
|
worktrees.push(current);
|
|
108
|
+
current = {};
|
|
119
109
|
}
|
|
120
|
-
return worktrees;
|
|
121
110
|
}
|
|
122
|
-
|
|
123
|
-
|
|
111
|
+
if (current.path) {
|
|
112
|
+
worktrees.push(current);
|
|
124
113
|
}
|
|
114
|
+
return worktrees;
|
|
125
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Get the path to the .santree directory inside a repo root.
|
|
118
|
+
*/
|
|
126
119
|
export function getSantreeDir(repoRoot) {
|
|
127
120
|
return path.join(repoRoot, ".santree");
|
|
128
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Get the path to the .santree/worktrees directory inside a repo root.
|
|
124
|
+
*/
|
|
129
125
|
export function getWorktreesDir(repoRoot) {
|
|
130
126
|
return path.join(getSantreeDir(repoRoot), "worktrees");
|
|
131
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Create a new git worktree for a branch, optionally creating the branch from a base.
|
|
130
|
+
* The worktree directory is named after the ticket ID extracted from the branch name.
|
|
131
|
+
* Runs: `git worktree add [-b branchName] <path> <branch|baseBranch>`
|
|
132
|
+
* Returns { success: false, error } if no ticket ID found, path already exists, or git fails.
|
|
133
|
+
*/
|
|
132
134
|
export async function createWorktree(branchName, baseBranch, repoRoot) {
|
|
133
|
-
const
|
|
135
|
+
const ticketId = extractTicketId(branchName);
|
|
136
|
+
if (!ticketId) {
|
|
137
|
+
return { success: false, error: "No ticket ID found in branch name (expected pattern like TEAM-123)" };
|
|
138
|
+
}
|
|
139
|
+
const dirName = ticketId;
|
|
134
140
|
const worktreesDir = getWorktreesDir(repoRoot);
|
|
135
141
|
const worktreePath = path.join(worktreesDir, dirName);
|
|
136
142
|
if (fs.existsSync(worktreePath)) {
|
|
@@ -164,9 +170,7 @@ export async function createWorktree(branchName, baseBranch, repoRoot) {
|
|
|
164
170
|
}
|
|
165
171
|
// Save metadata
|
|
166
172
|
const metadata = {
|
|
167
|
-
branch_name: branchName,
|
|
168
173
|
base_branch: baseBranch,
|
|
169
|
-
created_at: new Date().toISOString(),
|
|
170
174
|
};
|
|
171
175
|
fs.writeFileSync(path.join(worktreePath, ".santree_metadata.json"), JSON.stringify(metadata, null, 2));
|
|
172
176
|
return { success: true, path: worktreePath };
|
|
@@ -178,6 +182,11 @@ export async function createWorktree(branchName, baseBranch, repoRoot) {
|
|
|
178
182
|
};
|
|
179
183
|
}
|
|
180
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Remove a git worktree by branch name, cleaning up the directory and optionally deleting the branch.
|
|
187
|
+
* Runs: `git worktree remove [--force] <path>` then `git branch -d|-D <branchName>`
|
|
188
|
+
* Returns { success: false, error } if worktree not found or git fails.
|
|
189
|
+
*/
|
|
181
190
|
export async function removeWorktree(branchName, repoRoot, force = false) {
|
|
182
191
|
// Find the worktree by branch name using git's worktree tracking
|
|
183
192
|
const worktreePath = getWorktreePath(branchName);
|
|
@@ -220,6 +229,11 @@ export async function removeWorktree(branchName, repoRoot, force = false) {
|
|
|
220
229
|
};
|
|
221
230
|
}
|
|
222
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Extract a ticket ID (e.g. "TEAM-123") from a branch name.
|
|
234
|
+
* Matches the first occurrence of LETTERS-DIGITS in the string.
|
|
235
|
+
* Returns null if no ticket ID pattern is found.
|
|
236
|
+
*/
|
|
223
237
|
export function extractTicketId(branch) {
|
|
224
238
|
const match = branch.match(/([a-zA-Z]+)-(\d+)/);
|
|
225
239
|
if (match) {
|
|
@@ -227,11 +241,20 @@ export function extractTicketId(branch) {
|
|
|
227
241
|
}
|
|
228
242
|
return null;
|
|
229
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* Get the filesystem path for a worktree by its branch name.
|
|
246
|
+
* Uses `git worktree list --porcelain` under the hood.
|
|
247
|
+
* Returns null if no worktree is checked out on that branch.
|
|
248
|
+
*/
|
|
230
249
|
export function getWorktreePath(branchName) {
|
|
231
250
|
const worktrees = listWorktrees();
|
|
232
251
|
const wt = worktrees.find((w) => w.branch === branchName);
|
|
233
252
|
return wt?.path ?? null;
|
|
234
253
|
}
|
|
254
|
+
/**
|
|
255
|
+
* Read the .santree_metadata.json file from a worktree directory.
|
|
256
|
+
* Returns null if the file doesn't exist or can't be parsed.
|
|
257
|
+
*/
|
|
235
258
|
export function getWorktreeMetadata(worktreePath) {
|
|
236
259
|
const metadataPath = path.join(worktreePath, ".santree_metadata.json");
|
|
237
260
|
if (!fs.existsSync(metadataPath)) {
|
|
@@ -244,15 +267,20 @@ export function getWorktreeMetadata(worktreePath) {
|
|
|
244
267
|
return null;
|
|
245
268
|
}
|
|
246
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Check if there are any uncommitted changes (staged or unstaged).
|
|
272
|
+
* Runs: `git status --porcelain`
|
|
273
|
+
* Returns false if not in a git repo.
|
|
274
|
+
*/
|
|
247
275
|
export function hasUncommittedChanges() {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return Boolean(output.trim());
|
|
251
|
-
}
|
|
252
|
-
catch {
|
|
253
|
-
return false;
|
|
254
|
-
}
|
|
276
|
+
const output = run("git status --porcelain");
|
|
277
|
+
return output !== null && output !== "";
|
|
255
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* Check if there are staged changes ready to commit.
|
|
281
|
+
* Runs: `git diff --cached --quiet` (exits non-zero if there are staged changes).
|
|
282
|
+
* Returns false if not in a git repo.
|
|
283
|
+
*/
|
|
256
284
|
export function hasStagedChanges() {
|
|
257
285
|
try {
|
|
258
286
|
execSync("git diff --cached --quiet", { stdio: "ignore" });
|
|
@@ -262,6 +290,11 @@ export function hasStagedChanges() {
|
|
|
262
290
|
return true;
|
|
263
291
|
}
|
|
264
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* Check if there are unstaged modifications or untracked files.
|
|
295
|
+
* Runs: `git diff --quiet` and `git ls-files --others --exclude-standard`
|
|
296
|
+
* Returns false if not in a git repo.
|
|
297
|
+
*/
|
|
265
298
|
export function hasUnstagedChanges() {
|
|
266
299
|
try {
|
|
267
300
|
// Check for modified files
|
|
@@ -272,64 +305,62 @@ export function hasUnstagedChanges() {
|
|
|
272
305
|
return true;
|
|
273
306
|
}
|
|
274
307
|
// Check for untracked files
|
|
275
|
-
const output =
|
|
276
|
-
|
|
277
|
-
});
|
|
278
|
-
return Boolean(output.trim());
|
|
308
|
+
const output = run("git ls-files --others --exclude-standard");
|
|
309
|
+
return output !== null && output !== "";
|
|
279
310
|
}
|
|
280
311
|
catch {
|
|
281
312
|
return false;
|
|
282
313
|
}
|
|
283
314
|
}
|
|
315
|
+
/**
|
|
316
|
+
* Get a short summary of the working tree status.
|
|
317
|
+
* Runs: `git status --short`
|
|
318
|
+
* Returns empty string on failure.
|
|
319
|
+
*/
|
|
284
320
|
export function getGitStatus() {
|
|
285
|
-
|
|
286
|
-
return execSync("git status --short", { encoding: "utf-8" }).trim();
|
|
287
|
-
}
|
|
288
|
-
catch {
|
|
289
|
-
return "";
|
|
290
|
-
}
|
|
321
|
+
return run("git status --short") ?? "";
|
|
291
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* Get a diffstat of staged changes.
|
|
325
|
+
* Runs: `git diff --cached --stat`
|
|
326
|
+
* Returns empty string on failure.
|
|
327
|
+
*/
|
|
292
328
|
export function getStagedDiffStat() {
|
|
293
|
-
|
|
294
|
-
return execSync("git diff --cached --stat", { encoding: "utf-8" }).trim();
|
|
295
|
-
}
|
|
296
|
-
catch {
|
|
297
|
-
return "";
|
|
298
|
-
}
|
|
329
|
+
return run("git diff --cached --stat") ?? "";
|
|
299
330
|
}
|
|
331
|
+
/**
|
|
332
|
+
* Count how many commits the current branch is behind origin/baseBranch.
|
|
333
|
+
* Runs: `git rev-list --count HEAD..origin/<baseBranch>`
|
|
334
|
+
* Returns 0 on failure.
|
|
335
|
+
*/
|
|
300
336
|
export function getCommitsBehind(baseBranch) {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
encoding: "utf-8",
|
|
304
|
-
});
|
|
305
|
-
return parseInt(output.trim(), 10) || 0;
|
|
306
|
-
}
|
|
307
|
-
catch {
|
|
308
|
-
return 0;
|
|
309
|
-
}
|
|
337
|
+
const output = run(`git rev-list --count HEAD..origin/${baseBranch}`);
|
|
338
|
+
return output ? parseInt(output, 10) || 0 : 0;
|
|
310
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* Count how many commits the current branch is ahead of baseBranch.
|
|
342
|
+
* Runs: `git rev-list --count <baseBranch>..HEAD`
|
|
343
|
+
* Returns 0 on failure.
|
|
344
|
+
*/
|
|
311
345
|
export function getCommitsAhead(baseBranch) {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
encoding: "utf-8",
|
|
315
|
-
});
|
|
316
|
-
return parseInt(output.trim(), 10) || 0;
|
|
317
|
-
}
|
|
318
|
-
catch {
|
|
319
|
-
return 0;
|
|
320
|
-
}
|
|
346
|
+
const output = run(`git rev-list --count ${baseBranch}..HEAD`);
|
|
347
|
+
return output ? parseInt(output, 10) || 0 : 0;
|
|
321
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* Check if a branch exists on the remote (origin).
|
|
351
|
+
* Runs: `git ls-remote --heads origin <branchName>`
|
|
352
|
+
* Returns false on failure.
|
|
353
|
+
*/
|
|
322
354
|
export function remoteBranchExists(branchName) {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
encoding: "utf-8",
|
|
326
|
-
});
|
|
327
|
-
return output.includes(branchName);
|
|
328
|
-
}
|
|
329
|
-
catch {
|
|
330
|
-
return false;
|
|
331
|
-
}
|
|
355
|
+
const output = run(`git ls-remote --heads origin ${branchName}`);
|
|
356
|
+
return output !== null && output.includes(branchName);
|
|
332
357
|
}
|
|
358
|
+
/**
|
|
359
|
+
* Count how many local commits haven't been pushed to origin.
|
|
360
|
+
* Runs: `git rev-list --count origin/<branchName>..HEAD`
|
|
361
|
+
* If no remote tracking branch exists, counts all commits on HEAD.
|
|
362
|
+
* Returns 0 on failure.
|
|
363
|
+
*/
|
|
333
364
|
export function getUnpushedCommits(branchName) {
|
|
334
365
|
try {
|
|
335
366
|
// Check if remote tracking branch exists
|
|
@@ -340,21 +371,22 @@ export function getUnpushedCommits(branchName) {
|
|
|
340
371
|
}
|
|
341
372
|
catch {
|
|
342
373
|
// No remote branch, count all local commits
|
|
343
|
-
const output =
|
|
344
|
-
|
|
345
|
-
});
|
|
346
|
-
return parseInt(output.trim(), 10) || 0;
|
|
374
|
+
const output = run("git rev-list --count HEAD");
|
|
375
|
+
return output ? parseInt(output, 10) || 0 : 0;
|
|
347
376
|
}
|
|
348
377
|
// Count commits ahead of remote
|
|
349
|
-
const output =
|
|
350
|
-
|
|
351
|
-
});
|
|
352
|
-
return parseInt(output.trim(), 10) || 0;
|
|
378
|
+
const output = run(`git rev-list --count origin/${branchName}..HEAD`);
|
|
379
|
+
return output ? parseInt(output, 10) || 0 : 0;
|
|
353
380
|
}
|
|
354
381
|
catch {
|
|
355
382
|
return 0;
|
|
356
383
|
}
|
|
357
384
|
}
|
|
385
|
+
/**
|
|
386
|
+
* Fetch from origin and pull the latest changes on a base branch.
|
|
387
|
+
* Runs: `git fetch origin`, `git checkout <baseBranch>`, `git pull origin <baseBranch>`
|
|
388
|
+
* Returns { success: false, message } if any step fails.
|
|
389
|
+
*/
|
|
358
390
|
export function pullLatest(baseBranch, repoRoot) {
|
|
359
391
|
try {
|
|
360
392
|
// Fetch from origin
|
|
@@ -374,20 +406,61 @@ export function pullLatest(baseBranch, repoRoot) {
|
|
|
374
406
|
};
|
|
375
407
|
}
|
|
376
408
|
}
|
|
409
|
+
/**
|
|
410
|
+
* Check if a .santree/init.sh script exists in the repo.
|
|
411
|
+
*/
|
|
377
412
|
export function hasInitScript(repoRoot) {
|
|
378
413
|
const initScript = path.join(getSantreeDir(repoRoot), "init.sh");
|
|
379
414
|
return fs.existsSync(initScript);
|
|
380
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* Get the path to the .santree/init.sh script.
|
|
418
|
+
*/
|
|
381
419
|
export function getInitScriptPath(repoRoot) {
|
|
382
420
|
return path.join(getSantreeDir(repoRoot), "init.sh");
|
|
383
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* Get the subject line of the latest commit.
|
|
424
|
+
* Runs: `git log -1 --format=%s`
|
|
425
|
+
* Returns null if not in a git repo or no commits.
|
|
426
|
+
*/
|
|
384
427
|
export function getLatestCommitMessage() {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
428
|
+
return run("git log -1 --format=%s");
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Get the subject line of the first commit on the current branch since baseBranch.
|
|
432
|
+
* Runs: `git log <baseBranch>..HEAD --reverse --format=%s`
|
|
433
|
+
* Returns null if there are no commits ahead of baseBranch.
|
|
434
|
+
*/
|
|
435
|
+
export function getFirstCommitMessage(baseBranch) {
|
|
436
|
+
const output = run(`git log ${baseBranch}..HEAD --reverse --format=%s`);
|
|
437
|
+
if (!output)
|
|
391
438
|
return null;
|
|
392
|
-
|
|
439
|
+
const firstLine = output.split("\n")[0];
|
|
440
|
+
return firstLine || null;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Get a formatted commit log of all commits since baseBranch.
|
|
444
|
+
* Runs: `git log <baseBranch>..HEAD --format="- %s"`
|
|
445
|
+
* Returns null if there are no commits or on failure.
|
|
446
|
+
*/
|
|
447
|
+
export function getCommitLog(baseBranch) {
|
|
448
|
+
return run(`git log ${baseBranch}..HEAD --format="- %s"`) || null;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Get a diffstat summary of all changes since baseBranch.
|
|
452
|
+
* Runs: `git diff <baseBranch>..HEAD --stat`
|
|
453
|
+
* Returns null if there are no changes or on failure.
|
|
454
|
+
*/
|
|
455
|
+
export function getDiffStat(baseBranch) {
|
|
456
|
+
return run(`git diff ${baseBranch}..HEAD --stat`) || null;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Get the full diff of all changes since baseBranch.
|
|
460
|
+
* Runs: `git diff <baseBranch>..HEAD`
|
|
461
|
+
* Uses a 10MB max buffer for large diffs.
|
|
462
|
+
* Returns null if there are no changes or on failure.
|
|
463
|
+
*/
|
|
464
|
+
export function getDiffContent(baseBranch) {
|
|
465
|
+
return run(`git diff ${baseBranch}..HEAD`, { maxBuffer: 10 * 1024 * 1024 }) || null;
|
|
393
466
|
}
|
package/dist/lib/github.d.ts
CHANGED
|
@@ -3,9 +3,47 @@ export interface PRInfo {
|
|
|
3
3
|
state: "OPEN" | "MERGED" | "CLOSED";
|
|
4
4
|
url?: string;
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* Get PR info (number, state, url) for a branch using the GitHub CLI.
|
|
8
|
+
* Runs: `gh pr view "<branchName>" --json number,state,url`
|
|
9
|
+
* Returns null if no PR exists for the branch or gh CLI fails.
|
|
10
|
+
*/
|
|
6
11
|
export declare function getPRInfo(branchName: string): PRInfo | null;
|
|
12
|
+
/**
|
|
13
|
+
* Async version of getPRInfo. Get PR info for a branch using the GitHub CLI.
|
|
14
|
+
* Runs: `gh pr view "<branchName>" --json number,state,url`
|
|
15
|
+
* Returns null if no PR exists for the branch or gh CLI fails.
|
|
16
|
+
*/
|
|
7
17
|
export declare function getPRInfoAsync(branchName: string): Promise<PRInfo | null>;
|
|
18
|
+
/**
|
|
19
|
+
* Check if the GitHub CLI (gh) is available on PATH.
|
|
20
|
+
* Runs: `which gh`
|
|
21
|
+
* Returns false if gh is not installed.
|
|
22
|
+
*/
|
|
8
23
|
export declare function ghCliAvailable(): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Push a branch to origin, optionally with --force-with-lease.
|
|
26
|
+
* Runs: `git push -u origin "<branchName>" [--force-with-lease]`
|
|
27
|
+
* Uses stdio: "inherit" so push progress is shown to the user.
|
|
28
|
+
* Returns false if the push fails.
|
|
29
|
+
*/
|
|
9
30
|
export declare function pushBranch(branchName: string, force?: boolean): boolean;
|
|
10
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Create a GitHub pull request and open it in the browser.
|
|
33
|
+
* Runs: `gh pr create --title "<title>" --base "<baseBranch>" --head "<headBranch>" --web [--body-file "<bodyFile>"]`
|
|
34
|
+
* Uses stdio: "inherit" so the browser open is handled by gh.
|
|
35
|
+
* Returns 0 on success, 1 on failure.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createPR(title: string, baseBranch: string, headBranch: string, bodyFile?: string): number;
|
|
38
|
+
/**
|
|
39
|
+
* Fetch the pull request template from the repo's .github/pull_request_template.md.
|
|
40
|
+
* Runs: `gh api repos/{owner}/{repo}/contents/.github/pull_request_template.md --jq .content`
|
|
41
|
+
* Returns the decoded template content, or null if none exists.
|
|
42
|
+
*/
|
|
43
|
+
export declare function getPRTemplate(): string | null;
|
|
44
|
+
/**
|
|
45
|
+
* Fetch all comments on a pull request.
|
|
46
|
+
* Runs: `gh pr view <prNumber> --json comments --jq '.comments[] | "- \(.author.login): \(.body)"'`
|
|
47
|
+
* Returns empty string if the PR has no comments or on failure.
|
|
48
|
+
*/
|
|
11
49
|
export declare function getPRComments(prNumber: string): string;
|
package/dist/lib/github.js
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { execSync, exec } from "child_process";
|
|
2
2
|
import { promisify } from "util";
|
|
3
|
+
import { run } from "./exec.js";
|
|
3
4
|
const execAsync = promisify(exec);
|
|
5
|
+
/**
|
|
6
|
+
* Get PR info (number, state, url) for a branch using the GitHub CLI.
|
|
7
|
+
* Runs: `gh pr view "<branchName>" --json number,state,url`
|
|
8
|
+
* Returns null if no PR exists for the branch or gh CLI fails.
|
|
9
|
+
*/
|
|
4
10
|
export function getPRInfo(branchName) {
|
|
11
|
+
const output = run(`gh pr view "${branchName}" --json number,state,url`);
|
|
12
|
+
if (!output)
|
|
13
|
+
return null;
|
|
5
14
|
try {
|
|
6
|
-
const output = execSync(`gh pr view "${branchName}" --json number,state,url`, { encoding: "utf-8" });
|
|
7
15
|
const data = JSON.parse(output);
|
|
8
16
|
return {
|
|
9
17
|
number: String(data.number ?? ""),
|
|
@@ -15,6 +23,11 @@ export function getPRInfo(branchName) {
|
|
|
15
23
|
return null;
|
|
16
24
|
}
|
|
17
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Async version of getPRInfo. Get PR info for a branch using the GitHub CLI.
|
|
28
|
+
* Runs: `gh pr view "<branchName>" --json number,state,url`
|
|
29
|
+
* Returns null if no PR exists for the branch or gh CLI fails.
|
|
30
|
+
*/
|
|
18
31
|
export async function getPRInfoAsync(branchName) {
|
|
19
32
|
try {
|
|
20
33
|
const { stdout } = await execAsync(`gh pr view "${branchName}" --json number,state,url`);
|
|
@@ -29,6 +42,11 @@ export async function getPRInfoAsync(branchName) {
|
|
|
29
42
|
return null;
|
|
30
43
|
}
|
|
31
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Check if the GitHub CLI (gh) is available on PATH.
|
|
47
|
+
* Runs: `which gh`
|
|
48
|
+
* Returns false if gh is not installed.
|
|
49
|
+
*/
|
|
32
50
|
export function ghCliAvailable() {
|
|
33
51
|
try {
|
|
34
52
|
execSync("which gh", { stdio: "ignore" });
|
|
@@ -38,6 +56,12 @@ export function ghCliAvailable() {
|
|
|
38
56
|
return false;
|
|
39
57
|
}
|
|
40
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Push a branch to origin, optionally with --force-with-lease.
|
|
61
|
+
* Runs: `git push -u origin "<branchName>" [--force-with-lease]`
|
|
62
|
+
* Uses stdio: "inherit" so push progress is shown to the user.
|
|
63
|
+
* Returns false if the push fails.
|
|
64
|
+
*/
|
|
41
65
|
export function pushBranch(branchName, force = false) {
|
|
42
66
|
try {
|
|
43
67
|
const forceFlag = force ? "--force-with-lease" : "";
|
|
@@ -50,22 +74,38 @@ export function pushBranch(branchName, force = false) {
|
|
|
50
74
|
return false;
|
|
51
75
|
}
|
|
52
76
|
}
|
|
53
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Create a GitHub pull request and open it in the browser.
|
|
79
|
+
* Runs: `gh pr create --title "<title>" --base "<baseBranch>" --head "<headBranch>" --web [--body-file "<bodyFile>"]`
|
|
80
|
+
* Uses stdio: "inherit" so the browser open is handled by gh.
|
|
81
|
+
* Returns 0 on success, 1 on failure.
|
|
82
|
+
*/
|
|
83
|
+
export function createPR(title, baseBranch, headBranch, bodyFile) {
|
|
54
84
|
try {
|
|
55
|
-
const
|
|
56
|
-
execSync(`gh pr create --title "${title}" --base "${baseBranch}" --head "${headBranch}" --web ${
|
|
85
|
+
const bodyFlag = bodyFile ? `--body-file "${bodyFile}"` : "";
|
|
86
|
+
execSync(`gh pr create --title "${title}" --base "${baseBranch}" --head "${headBranch}" --web ${bodyFlag}`.trim(), { stdio: "inherit" });
|
|
57
87
|
return 0;
|
|
58
88
|
}
|
|
59
89
|
catch {
|
|
60
90
|
return 1;
|
|
61
91
|
}
|
|
62
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Fetch the pull request template from the repo's .github/pull_request_template.md.
|
|
95
|
+
* Runs: `gh api repos/{owner}/{repo}/contents/.github/pull_request_template.md --jq .content`
|
|
96
|
+
* Returns the decoded template content, or null if none exists.
|
|
97
|
+
*/
|
|
98
|
+
export function getPRTemplate() {
|
|
99
|
+
const output = run(`gh api repos/{owner}/{repo}/contents/.github/pull_request_template.md --jq .content`);
|
|
100
|
+
if (!output)
|
|
101
|
+
return null;
|
|
102
|
+
return Buffer.from(output, "base64").toString("utf-8");
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Fetch all comments on a pull request.
|
|
106
|
+
* Runs: `gh pr view <prNumber> --json comments --jq '.comments[] | "- \(.author.login): \(.body)"'`
|
|
107
|
+
* Returns empty string if the PR has no comments or on failure.
|
|
108
|
+
*/
|
|
63
109
|
export function getPRComments(prNumber) {
|
|
64
|
-
|
|
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
|
-
}
|
|
110
|
+
return run(`gh pr view ${prNumber} --json comments --jq '.comments[] | "- \\(.author.login): \\(.body)"'`) ?? "";
|
|
71
111
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a nunjucks template from the prompts/ directory.
|
|
3
|
+
* @param template - Template name without extension (e.g. "fill-pr")
|
|
4
|
+
* @param context - Variables to inject into the template
|
|
5
|
+
*/
|
|
6
|
+
export declare function renderPrompt(template: string, context: Record<string, string>): string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { fileURLToPath } from "url";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
import nunjucks from "nunjucks";
|
|
4
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
5
|
+
const __dirname = dirname(__filename);
|
|
6
|
+
const promptsDir = join(__dirname, "..", "..", "prompts");
|
|
7
|
+
const promptsEnv = new nunjucks.Environment(new nunjucks.FileSystemLoader(promptsDir), { autoescape: false });
|
|
8
|
+
/**
|
|
9
|
+
* Render a nunjucks template from the prompts/ directory.
|
|
10
|
+
* @param template - Template name without extension (e.g. "fill-pr")
|
|
11
|
+
* @param context - Variables to inject into the template
|
|
12
|
+
*/
|
|
13
|
+
export function renderPrompt(template, context) {
|
|
14
|
+
return promptsEnv.render(`${template}.njk`, context);
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
You are filling out a pull request template. Output ONLY the filled template markdown, nothing else.
|
|
2
|
+
|
|
3
|
+
## PR Template
|
|
4
|
+
|
|
5
|
+
{{ pr_template }}
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
Branch: {{ branch_name }}
|
|
10
|
+
{% if ticket_id %}Ticket: {{ ticket_id }}{% endif %}
|
|
11
|
+
|
|
12
|
+
### Commits
|
|
13
|
+
{{ commit_log }}
|
|
14
|
+
|
|
15
|
+
### Files Changed
|
|
16
|
+
{{ diff_stat }}
|
|
17
|
+
|
|
18
|
+
### Diff
|
|
19
|
+
```
|
|
20
|
+
{{ diff }}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Instructions
|
|
24
|
+
|
|
25
|
+
- Fill the template sections using the commits and diff above
|
|
26
|
+
- Be concise and factual
|
|
27
|
+
- For any screenshot sections, leave a comment: `<!-- Screenshot: describe what to show -->`
|
|
28
|
+
- Do NOT wrap the output in a code block
|
|
29
|
+
- Output ONLY the filled template
|