santree 0.5.6 → 0.6.0

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.
Files changed (35) hide show
  1. package/dist/commands/dashboard.js +210 -33
  2. package/dist/commands/doctor.js +2 -2
  3. package/dist/commands/helpers/squirrel.d.ts +2 -0
  4. package/dist/commands/helpers/squirrel.js +12 -0
  5. package/dist/commands/worktree/commit.d.ts +9 -1
  6. package/dist/commands/worktree/commit.js +58 -14
  7. package/dist/lib/ai.d.ts +26 -0
  8. package/dist/lib/ai.js +53 -0
  9. package/dist/lib/claude-todos.d.ts +37 -0
  10. package/dist/lib/claude-todos.js +98 -0
  11. package/dist/lib/dashboard/DetailPanel.js +99 -9
  12. package/dist/lib/dashboard/IssueList.js +2 -0
  13. package/dist/lib/dashboard/MultilineTextArea.js +14 -1
  14. package/dist/lib/dashboard/Overlays.d.ts +5 -0
  15. package/dist/lib/dashboard/Overlays.js +75 -2
  16. package/dist/lib/dashboard/ReviewDetailPanel.d.ts +7 -0
  17. package/dist/lib/dashboard/ReviewDetailPanel.js +269 -77
  18. package/dist/lib/dashboard/ReviewList.js +12 -15
  19. package/dist/lib/dashboard/data.js +158 -7
  20. package/dist/lib/dashboard/types.d.ts +45 -5
  21. package/dist/lib/dashboard/types.js +40 -0
  22. package/dist/lib/diff-parse.d.ts +25 -0
  23. package/dist/lib/diff-parse.js +60 -0
  24. package/dist/lib/git.d.ts +22 -0
  25. package/dist/lib/git.js +41 -0
  26. package/dist/lib/github.d.ts +6 -0
  27. package/dist/lib/github.js +29 -0
  28. package/dist/lib/open-url.d.ts +10 -0
  29. package/dist/lib/open-url.js +20 -0
  30. package/dist/lib/squirrel-loader.d.ts +9 -0
  31. package/dist/lib/squirrel-loader.js +322 -0
  32. package/dist/lib/trackers/index.d.ts +13 -0
  33. package/dist/lib/trackers/index.js +19 -0
  34. package/package.json +1 -1
  35. package/prompts/fill-commit.njk +79 -0
@@ -1,6 +1,8 @@
1
- import { listWorktrees, extractTicketId, getBaseBranch, readAllMetadata, readSessionState, isSessionAlive, clearSessionState, getGitStatusAsync, getCommitsAheadAsync, getDiffShortstatAsync, } from "../git.js";
2
- import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRConversationCommentsAsync, getPRViewAsync, getReviewRequestedPRsAsync, getRepoNameAsync, } from "../github.js";
3
- import { getIssueTracker } from "../trackers/index.js";
1
+ import { listWorktrees, extractTicketId, getBaseBranch, getDefaultBranch, readAllMetadata, readSessionState, isSessionAlive, clearSessionState, clearSessionId, getGitStatusAsync, getCommitsAheadAsync, getCommitsBehindAsync, getDiffShortstatAsync, } from "../git.js";
2
+ import { runAsync } from "../exec.js";
3
+ import { readMainAgentTodos, findClaudeSessionCwd } from "../claude-todos.js";
4
+ import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRConversationCommentsAsync, getPRViewAsync, getReviewRequestedPRsAsync, getRepoNameAsync, getGitHubUserNameAsync, } from "../github.js";
5
+ import { getIssueTracker, getCandidateTrackers } from "../trackers/index.js";
4
6
  export async function loadDashboardData(repoRoot) {
5
7
  // Fetch issues and worktrees in parallel
6
8
  const tracker = getIssueTracker(repoRoot);
@@ -50,16 +52,39 @@ export async function loadDashboardData(repoRoot) {
50
52
  sessState = null;
51
53
  }
52
54
  const ss = sessState?.state ?? null;
55
+ const storedId = metadata[issue.identifier]?.session_id ?? null;
56
+ // Verify the session is still resumable. Claude Code clears old
57
+ // transcript files (or `/clear` mints a new ID), leaving our stored
58
+ // session_id pointing at nothing. Without this check the dashboard
59
+ // offers `[↵] Resume`, which fails with "No conversation found with
60
+ // session ID". `findClaudeSessionCwd` also returns the real cwd
61
+ // where the session lives — needed because project conventions
62
+ // (direnv, shell init) sometimes cd into a subdir before Claude
63
+ // was launched, so resume must run from there. On a miss we drop
64
+ // the stored ID from metadata so the next refresh skips this work.
65
+ const sessionCwd = storedId ? findClaudeSessionCwd(wt.path, storedId) : null;
66
+ let sessionId = storedId;
67
+ if (storedId && !sessionCwd) {
68
+ clearSessionId(repoRoot, issue.identifier);
69
+ sessionId = null;
70
+ }
71
+ // Hide stale todos when the session has exited or its file is gone —
72
+ // the on-disk todos file outlives the process and showing them
73
+ // would lie about state.
74
+ const claudeTodos = sessionId && ss !== "exited" ? readMainAgentTodos(sessionId) : null;
53
75
  worktreeInfo = {
54
76
  path: wt.path,
55
77
  branch: wt.branch,
56
78
  dirty: Boolean(gitStatusOutput),
57
79
  commitsAhead: ahead,
58
- sessionId: metadata[issue.identifier]?.session_id ?? null,
80
+ commitsBehind: null,
81
+ sessionId,
82
+ sessionCwd,
59
83
  gitStatus: gitStatusOutput,
60
84
  sessionState: ss === "exited" ? null : ss,
61
85
  sessionMessage: sessState?.message ?? null,
62
86
  diffStats: shortstat,
87
+ claudeTodos,
63
88
  };
64
89
  prInfo = pr;
65
90
  if (pr) {
@@ -112,6 +137,14 @@ export async function loadDashboardData(repoRoot) {
112
137
  sessState = null;
113
138
  }
114
139
  const ss = sessState?.state ?? null;
140
+ const storedId = metadata[tid]?.session_id ?? null;
141
+ const sessionCwd = storedId ? findClaudeSessionCwd(wt.path, storedId) : null;
142
+ let sessionId = storedId;
143
+ if (storedId && !sessionCwd) {
144
+ clearSessionId(repoRoot, tid);
145
+ sessionId = null;
146
+ }
147
+ const claudeTodos = sessionId && ss !== "exited" ? readMainAgentTodos(sessionId) : null;
115
148
  return {
116
149
  issue: {
117
150
  identifier: tid,
@@ -130,11 +163,14 @@ export async function loadDashboardData(repoRoot) {
130
163
  branch: wt.branch,
131
164
  dirty: Boolean(gitStatusOutput),
132
165
  commitsAhead: ahead,
133
- sessionId: metadata[tid]?.session_id ?? null,
166
+ commitsBehind: null,
167
+ sessionId,
168
+ sessionCwd,
134
169
  gitStatus: gitStatusOutput,
135
170
  sessionState: ss === "exited" ? null : ss,
136
171
  sessionMessage: sessState?.message ?? null,
137
172
  diffStats: shortstat,
173
+ claudeTodos,
138
174
  },
139
175
  pr,
140
176
  checks: checksInfo,
@@ -235,8 +271,75 @@ export async function loadDashboardData(repoRoot) {
235
271
  return result;
236
272
  }
237
273
  const flatIssues = groups.flatMap((g) => g.statusGroups.flatMap((sg) => sg.issues.flatMap(flattenWithChildren)));
274
+ // Synthesize a "Main repo" row at the very top so users can commit /
275
+ // view diffs / inspect drift on whatever branch their main checkout
276
+ // happens to be on. The row uses the same WorktreeInfo shape but with
277
+ // state.type === "main" so the renderer can differentiate.
278
+ const mainEntry = await buildMainEntry(repoRoot);
279
+ if (mainEntry) {
280
+ groups.unshift({
281
+ name: "Main repo",
282
+ id: null,
283
+ statusGroups: [{ name: "Main", type: "main", issues: [mainEntry] }],
284
+ });
285
+ flatIssues.unshift(mainEntry);
286
+ }
238
287
  return { groups, flatIssues };
239
288
  }
289
+ /** Build the synthetic dashboard row for the main repo checkout — the
290
+ * non-worktree clone that the user typically commits master/main from.
291
+ * Returns null only if we can't read the current branch (e.g. detached
292
+ * HEAD with no commits). */
293
+ async function buildMainEntry(repoRoot) {
294
+ const branch = (await runAsync(`git -C "${repoRoot}" rev-parse --abbrev-ref HEAD`))?.trim();
295
+ if (!branch || branch === "HEAD")
296
+ return null;
297
+ // `commitsAhead` here is "how many local commits haven't been pushed",
298
+ // `commitsBehind` is "how many upstream commits I haven't pulled" —
299
+ // both relative to origin/<currentBranch>. If there's no upstream,
300
+ // both come back as 0; the renderer treats that as "in sync".
301
+ const [gitStatusOutput, ahead, behind, shortstat] = await Promise.all([
302
+ getGitStatusAsync(repoRoot),
303
+ getCommitsAheadAsync(repoRoot, `origin/${branch}`),
304
+ getCommitsBehindAsync(repoRoot, branch),
305
+ // Diff vs origin so the +/- numbers reflect unpushed work.
306
+ getDiffShortstatAsync(repoRoot, `origin/${branch}`),
307
+ ]);
308
+ const defaultBranch = getDefaultBranch();
309
+ const isDefault = branch === defaultBranch;
310
+ const title = isDefault ? `${branch} (default)` : branch;
311
+ return {
312
+ issue: {
313
+ identifier: branch.toUpperCase(),
314
+ title,
315
+ description: null,
316
+ url: "",
317
+ priority: 0,
318
+ priorityLabel: "None",
319
+ state: { name: "Main", type: "main" },
320
+ labels: [],
321
+ projectId: null,
322
+ projectName: "Main repo",
323
+ },
324
+ worktree: {
325
+ path: repoRoot,
326
+ branch,
327
+ dirty: Boolean(gitStatusOutput),
328
+ commitsAhead: ahead,
329
+ commitsBehind: behind,
330
+ sessionId: null,
331
+ sessionCwd: null,
332
+ gitStatus: gitStatusOutput,
333
+ sessionState: null,
334
+ sessionMessage: null,
335
+ diffStats: shortstat,
336
+ claudeTodos: null,
337
+ },
338
+ pr: null,
339
+ checks: null,
340
+ reviews: null,
341
+ };
342
+ }
240
343
  export async function loadReviewsData(repoRoot) {
241
344
  const repo = await getRepoNameAsync();
242
345
  if (!repo)
@@ -251,13 +354,22 @@ export async function loadReviewsData(repoRoot) {
251
354
  branchToWt.set(wt.branch, { path: wt.path, branch: wt.branch });
252
355
  }
253
356
  const metadata = readAllMetadata(repoRoot);
357
+ // Trackers worth trying when extracting a ticket ID from a PR's branch.
358
+ // We hit the reviews tab to look at OTHER people's PRs — their branches
359
+ // may follow a different convention than the current repo's active
360
+ // tracker (e.g. a GitHub-tracker repo reviewing PRs from a Linear team
361
+ // where branches encode `TEAM-1234`). The active tracker comes first;
362
+ // `getCandidateTrackers` appends Linear as a fallback when GitHub is
363
+ // active and Linear creds exist.
364
+ const candidates = getCandidateTrackers(repoRoot);
254
365
  // Enrich each PR in parallel
255
366
  const enriched = await Promise.all(prs.map(async (pr) => {
256
- const [view, checks, reviews, comments] = await Promise.all([
367
+ const [view, checks, reviews, comments, authorName] = await Promise.all([
257
368
  getPRViewAsync(pr.number),
258
369
  getPRChecksAsync(String(pr.number)),
259
370
  getPRReviewsAsync(String(pr.number)),
260
371
  getPRConversationCommentsAsync(String(pr.number)),
372
+ getGitHubUserNameAsync(pr.author.login),
261
373
  ]);
262
374
  // Check if we have a local worktree for this PR's branch
263
375
  let worktreeInfo = null;
@@ -278,19 +390,56 @@ export async function loadReviewsData(repoRoot) {
278
390
  sessState = null;
279
391
  }
280
392
  const ss = sessState?.state ?? null;
393
+ const storedId = ticketId ? (metadata[ticketId]?.session_id ?? null) : null;
394
+ const sessionCwd = storedId ? findClaudeSessionCwd(wt.path, storedId) : null;
395
+ let sessionId = storedId;
396
+ if (storedId && ticketId && !sessionCwd) {
397
+ clearSessionId(repoRoot, ticketId);
398
+ sessionId = null;
399
+ }
400
+ const claudeTodos = sessionId && ss !== "exited" ? readMainAgentTodos(sessionId) : null;
281
401
  worktreeInfo = {
282
402
  path: wt.path,
283
403
  branch: wt.branch,
284
404
  dirty: Boolean(gitStatusOutput),
285
405
  commitsAhead: ahead,
286
- sessionId: ticketId ? (metadata[ticketId]?.session_id ?? null) : null,
406
+ commitsBehind: null,
407
+ sessionId,
408
+ sessionCwd,
287
409
  gitStatus: gitStatusOutput,
288
410
  sessionState: ss === "exited" ? null : ss,
289
411
  sessionMessage: sessState?.message ?? null,
290
412
  diffStats: shortstat,
413
+ claudeTodos,
291
414
  };
292
415
  }
293
416
  }
417
+ // Resolve linked tracker issue. Try inputs in priority order:
418
+ // branch first (most likely to encode the canonical ID), then PR
419
+ // title (e.g. `[MSG-4084] …`). Each candidate tracker's
420
+ // `extractIdFromBranch` regex is unanchored and works on any
421
+ // text, not just branches — that's the (slightly misnamed but
422
+ // load-bearing) reuse this fallback depends on.
423
+ // Stops at the first hit. Failures stay silent.
424
+ let ticket = null;
425
+ const idInputs = [branch, pr.title].filter((s) => Boolean(s));
426
+ outer: for (const cand of candidates) {
427
+ for (const text of idInputs) {
428
+ const ticketId = cand.extractIdFromBranch(text);
429
+ if (!ticketId)
430
+ continue;
431
+ try {
432
+ const res = await cand.getIssue(ticketId, repoRoot);
433
+ if (res.ok) {
434
+ ticket = res.value;
435
+ break outer;
436
+ }
437
+ }
438
+ catch {
439
+ // swallow — try the next candidate / input
440
+ }
441
+ }
442
+ }
294
443
  return {
295
444
  pr,
296
445
  body: view?.body ?? null,
@@ -303,6 +452,8 @@ export async function loadReviewsData(repoRoot) {
303
452
  reviews,
304
453
  comments,
305
454
  worktree: worktreeInfo,
455
+ ticket,
456
+ authorName,
306
457
  };
307
458
  }));
308
459
  return { flatReviews: enriched };
@@ -1,12 +1,26 @@
1
1
  import type { PRInfo, PRCheck, PRReview, PRConversationComment, SearchPR } from "../github.js";
2
- import type { AssignedIssue } from "../trackers/types.js";
3
- export type { AssignedIssue } from "../trackers/types.js";
2
+ import type { ClaudeTodo } from "../claude-todos.js";
3
+ import type { AssignedIssue, Issue } from "../trackers/types.js";
4
+ export type { AssignedIssue, Issue } from "../trackers/types.js";
4
5
  export interface WorktreeInfo {
5
6
  path: string;
6
7
  branch: string;
7
8
  dirty: boolean;
8
9
  commitsAhead: number;
10
+ /** How many commits HEAD is behind origin/<branch>. Populated for the
11
+ * synthetic main-repo row so users can see how stale their local
12
+ * checkout is. Null on ticket worktrees (the answer is uninteresting
13
+ * because the ticket branch usually has no upstream yet). */
14
+ commitsBehind: number | null;
9
15
  sessionId: string | null;
16
+ /** Real-path cwd from which `claude --resume <sessionId>` actually
17
+ * succeeds. Usually equals `path`, but project conventions (direnv,
18
+ * shell init) sometimes cd into a subdirectory like `backend/canary`
19
+ * before Claude is launched, in which case the session is resumable
20
+ * only from there. The dashboard prepends `cd <sessionCwd>` to the
21
+ * resume command so it survives tmux send-keys cwd drift. Null when
22
+ * sessionId is null or the underlying file can't be located. */
23
+ sessionCwd: string | null;
10
24
  gitStatus: string;
11
25
  sessionState: "waiting" | "idle" | "active" | null;
12
26
  sessionMessage: string | null;
@@ -15,6 +29,7 @@ export interface WorktreeInfo {
15
29
  insertions: number;
16
30
  deletions: number;
17
31
  } | null;
32
+ claudeTodos: ClaudeTodo[] | null;
18
33
  }
19
34
  export interface DashboardIssue {
20
35
  issue: AssignedIssue;
@@ -48,9 +63,17 @@ export interface EnrichedReviewPR {
48
63
  reviews: PRReview[] | null;
49
64
  comments: PRConversationComment[] | null;
50
65
  worktree: WorktreeInfo | null;
66
+ /** Linked tracker issue, parsed from the PR's branch name first, then
67
+ * falling back to the PR title. Null when no recognizable ID is found,
68
+ * the tracker fails, or the issue is gone. The detail panel renders a
69
+ * Ticket section only when this is set. */
70
+ ticket: Issue | null;
71
+ /** GitHub display name for the PR author (`name` field on /users/:login).
72
+ * Null when the user hasn't set one or the lookup failed. */
73
+ authorName: string | null;
51
74
  }
52
75
  export type DashboardTab = "issues" | "reviews";
53
- export type ActionOverlay = "mode-select" | "context-input" | "base-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | "diff" | null;
76
+ export type ActionOverlay = "mode-select" | "context-input" | "base-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | "diff" | "help" | null;
54
77
  export type DiffFileStatus = "M" | "A" | "D" | "R" | "C" | "U" | "?";
55
78
  export interface DiffFile {
56
79
  path: string;
@@ -60,7 +83,7 @@ export interface DiffFile {
60
83
  workingStatus?: string;
61
84
  isUntracked?: boolean;
62
85
  }
63
- export type CommitPhase = "idle" | "confirm-stage" | "awaiting-message" | "committing" | "pushing" | "done" | "error";
86
+ export type CommitPhase = "idle" | "confirm-stage" | "choose-mode" | "filling" | "awaiting-message" | "committing" | "pushing" | "done" | "error";
64
87
  export type PrCreatePhase = "idle" | "choose-mode" | "pushing" | "filling" | "review" | "confirm" | "creating" | "done" | "error";
65
88
  export interface DashboardState {
66
89
  activeTab: DashboardTab;
@@ -107,6 +130,12 @@ export interface DashboardState {
107
130
  diffWorktreePath: string | null;
108
131
  diffBaseBranch: string | null;
109
132
  diffMergeBase: string | null;
133
+ /** When set, the diff is sourced from `gh pr diff <n>` rather than local
134
+ * `git diff` — used by the reviews tab when a PR has no local worktree.
135
+ * The file list and per-file content are parsed from the unified diff
136
+ * once and held in `diffPRContentByPath`. */
137
+ diffPRNumber: number | null;
138
+ diffPRContentByPath: Record<string, string>;
110
139
  diffFiles: DiffFile[];
111
140
  diffFileIndex: number;
112
141
  diffFileScrollOffset: number;
@@ -167,7 +196,9 @@ export type DashboardAction = {
167
196
  type: "DELETE_DONE";
168
197
  } | {
169
198
  type: "COMMIT_START";
170
- ticketId: string;
199
+ /** Null when committing on a non-ticket branch (e.g. the main
200
+ * repo row) — the commit message gets no `[ID]` prefix. */
201
+ ticketId: string | null;
171
202
  worktreePath: string;
172
203
  branch: string;
173
204
  gitStatus: string;
@@ -255,6 +286,15 @@ export type DashboardAction = {
255
286
  ticketId: string;
256
287
  worktreePath: string;
257
288
  baseBranch: string;
289
+ } | {
290
+ type: "DIFF_OPEN_PR";
291
+ label: string;
292
+ prNumber: number;
293
+ baseBranch: string;
294
+ } | {
295
+ type: "DIFF_PR_LOADED";
296
+ files: DiffFile[];
297
+ contentByPath: Record<string, string>;
258
298
  } | {
259
299
  type: "DIFF_FILES_LOADED";
260
300
  files: DiffFile[];
@@ -44,6 +44,8 @@ export const initialState = {
44
44
  diffWorktreePath: null,
45
45
  diffBaseBranch: null,
46
46
  diffMergeBase: null,
47
+ diffPRNumber: null,
48
+ diffPRContentByPath: {},
47
49
  diffFiles: [],
48
50
  diffFileIndex: 0,
49
51
  diffFileScrollOffset: 0,
@@ -288,6 +290,8 @@ export function reducer(state, action) {
288
290
  diffWorktreePath: action.worktreePath,
289
291
  diffBaseBranch: action.baseBranch,
290
292
  diffMergeBase: null,
293
+ diffPRNumber: null,
294
+ diffPRContentByPath: {},
291
295
  diffFiles: [],
292
296
  diffFileIndex: 0,
293
297
  diffFileScrollOffset: 0,
@@ -299,6 +303,40 @@ export function reducer(state, action) {
299
303
  diffPendingDiscard: null,
300
304
  diffRefreshTick: 0,
301
305
  };
306
+ case "DIFF_OPEN_PR":
307
+ return {
308
+ ...state,
309
+ overlay: "diff",
310
+ diffTicketId: action.label,
311
+ diffWorktreePath: null,
312
+ diffBaseBranch: action.baseBranch,
313
+ diffMergeBase: null,
314
+ diffPRNumber: action.prNumber,
315
+ diffPRContentByPath: {},
316
+ diffFiles: [],
317
+ diffFileIndex: 0,
318
+ diffFileScrollOffset: 0,
319
+ diffContent: null,
320
+ diffContentScrollOffset: 0,
321
+ diffLoadingFiles: true,
322
+ // Hold loadingContent on through the DIFF_PR_LOADED dispatch so the
323
+ // right pane shows "Loading diff..." until the content effect fires
324
+ // (avoids a one-frame "(empty diff)" flash between file-list
325
+ // arrival and content selection).
326
+ diffLoadingContent: true,
327
+ diffError: null,
328
+ diffPendingDiscard: null,
329
+ diffRefreshTick: 0,
330
+ };
331
+ case "DIFF_PR_LOADED":
332
+ return {
333
+ ...state,
334
+ diffFiles: action.files,
335
+ diffPRContentByPath: action.contentByPath,
336
+ diffFileIndex: 0,
337
+ diffLoadingFiles: false,
338
+ diffError: null,
339
+ };
302
340
  case "DIFF_FILES_LOADED": {
303
341
  // Preserve the user's selection across reloads (after stage/unstage/
304
342
  // discard) by matching the previously-selected file's path. Falls back
@@ -396,6 +434,8 @@ export function reducer(state, action) {
396
434
  diffWorktreePath: null,
397
435
  diffBaseBranch: null,
398
436
  diffMergeBase: null,
437
+ diffPRNumber: null,
438
+ diffPRContentByPath: {},
399
439
  diffFiles: [],
400
440
  diffFileIndex: 0,
401
441
  diffFileScrollOffset: 0,
@@ -0,0 +1,25 @@
1
+ import type { DiffFile } from "./dashboard/types.js";
2
+ export interface ParsedDiff {
3
+ files: DiffFile[];
4
+ contentByPath: Record<string, string>;
5
+ }
6
+ /**
7
+ * Parse a unified diff blob (e.g. `gh pr diff <n>`) into per-file records
8
+ * suitable for DiffOverlay. Each section starts at `^diff --git a/PATH b/PATH`
9
+ * and runs until the next such header (or EOF).
10
+ *
11
+ * Status is derived from the per-file metadata git emits:
12
+ * - `new file mode …` → "A"
13
+ * - `deleted file mode …` → "D"
14
+ * - `rename from / rename to` → "R"
15
+ * - everything else → "M"
16
+ *
17
+ * For renames, `path` is the new path and `oldPath` is preserved on the file
18
+ * record so DiffOverlay's tree shows the destination location. Deletes use the
19
+ * old path (the new side doesn't exist).
20
+ *
21
+ * Robustness: an unparseable header (a `diff --git` line that doesn't match the
22
+ * expected `a/PATH b/PATH` shape) is skipped rather than fatal — we'd rather
23
+ * lose one entry than crash the overlay.
24
+ */
25
+ export declare function parseUnifiedDiff(text: string): ParsedDiff;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Parse a unified diff blob (e.g. `gh pr diff <n>`) into per-file records
3
+ * suitable for DiffOverlay. Each section starts at `^diff --git a/PATH b/PATH`
4
+ * and runs until the next such header (or EOF).
5
+ *
6
+ * Status is derived from the per-file metadata git emits:
7
+ * - `new file mode …` → "A"
8
+ * - `deleted file mode …` → "D"
9
+ * - `rename from / rename to` → "R"
10
+ * - everything else → "M"
11
+ *
12
+ * For renames, `path` is the new path and `oldPath` is preserved on the file
13
+ * record so DiffOverlay's tree shows the destination location. Deletes use the
14
+ * old path (the new side doesn't exist).
15
+ *
16
+ * Robustness: an unparseable header (a `diff --git` line that doesn't match the
17
+ * expected `a/PATH b/PATH` shape) is skipped rather than fatal — we'd rather
18
+ * lose one entry than crash the overlay.
19
+ */
20
+ export function parseUnifiedDiff(text) {
21
+ const files = [];
22
+ const contentByPath = {};
23
+ if (!text)
24
+ return { files, contentByPath };
25
+ const lines = text.split("\n");
26
+ let i = 0;
27
+ while (i < lines.length) {
28
+ while (i < lines.length && !lines[i].startsWith("diff --git "))
29
+ i++;
30
+ if (i >= lines.length)
31
+ break;
32
+ const sectionStart = i;
33
+ const headerMatch = lines[i].match(/^diff --git a\/(.+?) b\/(.+)$/);
34
+ if (!headerMatch) {
35
+ i++;
36
+ continue;
37
+ }
38
+ const oldPath = headerMatch[1];
39
+ const newPath = headerMatch[2];
40
+ i++;
41
+ let status = "M";
42
+ while (i < lines.length && !lines[i].startsWith("diff --git ")) {
43
+ const line = lines[i];
44
+ if (line.startsWith("new file mode"))
45
+ status = "A";
46
+ else if (line.startsWith("deleted file mode"))
47
+ status = "D";
48
+ else if (line.startsWith("rename from") || line.startsWith("rename to"))
49
+ status = "R";
50
+ i++;
51
+ }
52
+ const path = status === "D" ? oldPath : newPath;
53
+ const file = { status, path };
54
+ if (status === "R" && oldPath !== newPath)
55
+ file.oldPath = oldPath;
56
+ files.push(file);
57
+ contentByPath[path] = lines.slice(sectionStart, i).join("\n");
58
+ }
59
+ return { files, contentByPath };
60
+ }
package/dist/lib/git.d.ts CHANGED
@@ -104,6 +104,16 @@ export declare function getSessionId(repoRoot: string, ticketId: string): string
104
104
  * Store a session ID for a given ticket in .santree/metadata.json.
105
105
  */
106
106
  export declare function setSessionId(repoRoot: string, ticketId: string, sessionId: string): void;
107
+ /**
108
+ * Remove the stored session_id for a ticket. Used when the underlying Claude
109
+ * session file is no longer on disk (Claude Code clears old transcripts) — the
110
+ * stored ID is unresumable, so we drop it to avoid showing a "Resume" action
111
+ * that will fail with "No conversation found with session ID".
112
+ *
113
+ * Leaves any other metadata fields (e.g. `base_branch`) intact and only drops
114
+ * the entry entirely when nothing remains.
115
+ */
116
+ export declare function clearSessionId(repoRoot: string, ticketId: string): void;
107
117
  /**
108
118
  * Get the base branch for a given branch name.
109
119
  * Looks up metadata first, falls back to the default branch.
@@ -151,6 +161,12 @@ export declare function getGitStatusAsync(cwd: string): Promise<string>;
151
161
  * Returns empty string on failure.
152
162
  */
153
163
  export declare function getStagedDiffStat(): string;
164
+ /**
165
+ * Get the full staged diff body for AI commit-message generation.
166
+ * Runs: `git diff --cached`. Uses a 5MB max buffer; very large diffs
167
+ * are truncated by the caller before sending to Claude.
168
+ */
169
+ export declare function getStagedDiffContent(cwd?: string): string;
154
170
  /**
155
171
  * Count how many commits the current branch is behind origin/baseBranch.
156
172
  * Runs: `git rev-list --count HEAD..origin/<baseBranch>`
@@ -168,6 +184,12 @@ export declare function getCommitsAhead(baseBranch: string): number;
168
184
  * Returns 0 on failure.
169
185
  */
170
186
  export declare function getCommitsAheadAsync(cwd: string, baseBranch: string): Promise<number>;
187
+ /**
188
+ * Count how many commits HEAD is BEHIND origin/<baseBranch> in the given
189
+ * cwd. Used by the dashboard's main-repo row to show how stale the local
190
+ * checkout is. Returns 0 on failure (e.g. no upstream tracking branch).
191
+ */
192
+ export declare function getCommitsBehindAsync(cwd: string, baseBranch: string): Promise<number>;
171
193
  /**
172
194
  * Read the SANTREE_DIFF_TOOL env var, returning the configured diff pager
173
195
  * command (e.g. "delta", "diff-so-fancy") or null if unset/invalid.
package/dist/lib/git.js CHANGED
@@ -293,6 +293,30 @@ export function setSessionId(repoRoot, ticketId, sessionId) {
293
293
  all[ticketId] = { ...all[ticketId], session_id: sessionId };
294
294
  writeAllMetadata(repoRoot, all);
295
295
  }
296
+ /**
297
+ * Remove the stored session_id for a ticket. Used when the underlying Claude
298
+ * session file is no longer on disk (Claude Code clears old transcripts) — the
299
+ * stored ID is unresumable, so we drop it to avoid showing a "Resume" action
300
+ * that will fail with "No conversation found with session ID".
301
+ *
302
+ * Leaves any other metadata fields (e.g. `base_branch`) intact and only drops
303
+ * the entry entirely when nothing remains.
304
+ */
305
+ export function clearSessionId(repoRoot, ticketId) {
306
+ const all = readAllMetadata(repoRoot);
307
+ const entry = all[ticketId];
308
+ if (!entry || entry.session_id === undefined)
309
+ return;
310
+ const rest = { ...entry };
311
+ delete rest.session_id;
312
+ if (Object.keys(rest).length === 0) {
313
+ delete all[ticketId];
314
+ }
315
+ else {
316
+ all[ticketId] = rest;
317
+ }
318
+ writeAllMetadata(repoRoot, all);
319
+ }
296
320
  /**
297
321
  * Get the base branch for a given branch name.
298
322
  * Looks up metadata first, falls back to the default branch.
@@ -383,6 +407,14 @@ export async function getGitStatusAsync(cwd) {
383
407
  export function getStagedDiffStat() {
384
408
  return run("git diff --cached --stat") ?? "";
385
409
  }
410
+ /**
411
+ * Get the full staged diff body for AI commit-message generation.
412
+ * Runs: `git diff --cached`. Uses a 5MB max buffer; very large diffs
413
+ * are truncated by the caller before sending to Claude.
414
+ */
415
+ export function getStagedDiffContent(cwd) {
416
+ return run("git diff --cached", { cwd, maxBuffer: 5 * 1024 * 1024 }) ?? "";
417
+ }
386
418
  /**
387
419
  * Count how many commits the current branch is behind origin/baseBranch.
388
420
  * Runs: `git rev-list --count HEAD..origin/<baseBranch>`
@@ -409,6 +441,15 @@ export async function getCommitsAheadAsync(cwd, baseBranch) {
409
441
  const output = await runAsync(`git -C "${cwd}" rev-list --count ${baseBranch}..HEAD`);
410
442
  return output ? parseInt(output, 10) || 0 : 0;
411
443
  }
444
+ /**
445
+ * Count how many commits HEAD is BEHIND origin/<baseBranch> in the given
446
+ * cwd. Used by the dashboard's main-repo row to show how stale the local
447
+ * checkout is. Returns 0 on failure (e.g. no upstream tracking branch).
448
+ */
449
+ export async function getCommitsBehindAsync(cwd, baseBranch) {
450
+ const output = await runAsync(`git -C "${cwd}" rev-list --count HEAD..origin/${baseBranch}`);
451
+ return output ? parseInt(output, 10) || 0 : 0;
452
+ }
412
453
  /**
413
454
  * Read the SANTREE_DIFF_TOOL env var, returning the configured diff pager
414
455
  * command (e.g. "delta", "diff-so-fancy") or null if unset/invalid.
@@ -133,6 +133,12 @@ export declare function getPRViewAsync(prNumber: number): Promise<PRViewDetail |
133
133
  * Returns null if not in a GitHub repo or gh is unavailable.
134
134
  */
135
135
  export declare function getRepoNameAsync(): Promise<string | null>;
136
+ /**
137
+ * Look up a GitHub user's display name (`name` field). Returns null if the
138
+ * user has no display name set, the API call fails, or `gh` isn't available.
139
+ * Caches per process — repeated calls for the same login are free.
140
+ */
141
+ export declare function getGitHubUserNameAsync(login: string): Promise<string | null>;
136
142
  /**
137
143
  * Fetch open PRs where the current user's review is still pending (async).
138
144
  * Uses the GitHub search API with `user-review-requested:@me` which excludes
@@ -256,6 +256,35 @@ export async function getRepoNameAsync() {
256
256
  return null;
257
257
  }
258
258
  }
259
+ /**
260
+ * Per-process cache of `login → display name`. GitHub display names rarely
261
+ * change inside a dashboard session, and `gh api users/<login>` is a network
262
+ * call we don't want to repeat per refresh. Resolves to `null` for users
263
+ * with no display name set (we'll fall back to login at the call site).
264
+ */
265
+ const githubUserNameCache = new Map();
266
+ /**
267
+ * Look up a GitHub user's display name (`name` field). Returns null if the
268
+ * user has no display name set, the API call fails, or `gh` isn't available.
269
+ * Caches per process — repeated calls for the same login are free.
270
+ */
271
+ export function getGitHubUserNameAsync(login) {
272
+ const cached = githubUserNameCache.get(login);
273
+ if (cached)
274
+ return cached;
275
+ const promise = (async () => {
276
+ try {
277
+ const { stdout } = await execAsync(`gh api users/${login} --jq .name`);
278
+ const name = stdout.trim();
279
+ return name && name !== "null" ? name : null;
280
+ }
281
+ catch {
282
+ return null;
283
+ }
284
+ })();
285
+ githubUserNameCache.set(login, promise);
286
+ return promise;
287
+ }
259
288
  /**
260
289
  * Fetch open PRs where the current user's review is still pending (async).
261
290
  * Uses the GitHub search API with `user-review-requested:@me` which excludes
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Open a URL in the platform's default browser.
3
+ * macOS → `open <url>`
4
+ * else → `xdg-open <url>`
5
+ *
6
+ * Returns true on apparent success, false on failure. Callers decide how to
7
+ * surface failures (e.g. a dashboard action message). Uses execSync with
8
+ * `stdio: "ignore"` — fast, no output leak into the dashboard's alt screen.
9
+ */
10
+ export declare function openUrl(url: string): boolean;