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 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
  |--------|-------------|
@@ -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
- draft: z.ZodOptional<z.ZodBoolean>;
4
+ fill: z.ZodOptional<z.ZodBoolean>;
5
5
  }, z.core.$strip>;
6
6
  type Props = {
7
7
  options: z.infer<typeof options>;
@@ -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 { 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";
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
- draft: z.boolean().optional().describe("Create as draft PR"),
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 [titleInput, setTitleInput] = useState("");
24
- async function handleTitleSubmit(value) {
25
- const finalTitle = value.trim();
26
- if (!finalTitle) {
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("PR title is required");
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("Creating PR...");
36
- const result = createPR(finalTitle, baseBranch, branch, options.draft ?? false);
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 create PR");
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 your changes before creating a PR.");
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}. You need to make commits before creating a PR.`);
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.draft]);
147
- const isLoading = status === "checking" || status === "pushing" || status === "creating";
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, { 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] }))] })] }));
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 = metadata.branch_name || git(cwd, "rev-parse --abbrev-ref HEAD") || "unknown";
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) {
@@ -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
- const __filename = fileURLToPath(import.meta.url);
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";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Run a shell command and return trimmed stdout, or null on failure.
3
+ */
4
+ export declare function run(command: string, options?: {
5
+ cwd?: string;
6
+ maxBuffer?: number;
7
+ }): string | null;
@@ -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
- try {
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
- 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 {
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
- 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 {
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
- 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 {
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
- try {
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
- try {
71
- const ref = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
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
- 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
- }
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
- 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
- }
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
- catch {
123
- return [];
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 dirName = branchName.replace(/\//g, "__");
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
- try {
249
- const output = execSync("git status --porcelain", { encoding: "utf-8" });
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 = execSync("git ls-files --others --exclude-standard", {
276
- encoding: "utf-8",
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
- try {
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
- try {
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
- 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
- }
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
- 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
- }
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
- 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
- }
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 = execSync("git rev-list --count HEAD", {
344
- encoding: "utf-8",
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 = execSync(`git rev-list --count origin/${branchName}..HEAD`, {
350
- encoding: "utf-8",
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
- try {
386
- return execSync("git log -1 --format=%s", {
387
- encoding: "utf-8",
388
- }).trim();
389
- }
390
- catch {
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
  }
@@ -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
- export declare function createPR(title: string, baseBranch: string, headBranch: string, draft: boolean): number;
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;
@@ -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
- export function createPR(title, baseBranch, headBranch, draft) {
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 draftFlag = draft ? "--draft" : "";
56
- execSync(`gh pr create --title "${title}" --base "${baseBranch}" --head "${headBranch}" --web ${draftFlag}`.trim(), { stdio: "inherit" });
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
- 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
- }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "description": "Git worktree manager with Linear integration",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",
@@ -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