nugit-cli 0.0.1 → 0.1.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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/api-client.js +10 -11
  3. package/src/github-device-flow.js +1 -1
  4. package/src/github-oauth-client-id.js +11 -0
  5. package/src/github-pr-social.js +42 -0
  6. package/src/github-rest.js +114 -6
  7. package/src/nugit-stack.js +20 -0
  8. package/src/nugit-start.js +4 -4
  9. package/src/nugit.js +37 -22
  10. package/src/review-hub/review-autoapprove.js +95 -0
  11. package/src/review-hub/review-hub-back.js +10 -0
  12. package/src/review-hub/review-hub-ink.js +166 -0
  13. package/src/review-hub/run-review-hub.js +188 -0
  14. package/src/split-view/run-split.js +16 -3
  15. package/src/split-view/split-ink.js +2 -2
  16. package/src/stack-discover.js +9 -1
  17. package/src/stack-infer-from-prs.js +71 -0
  18. package/src/stack-view/diff-line-map.js +62 -0
  19. package/src/stack-view/fetch-pr-data.js +104 -4
  20. package/src/stack-view/infer-chains-to-pick-stacks.js +70 -0
  21. package/src/stack-view/ink-app.js +1853 -156
  22. package/src/stack-view/loading-ink.js +44 -0
  23. package/src/stack-view/merge-alternate-pick-stacks.js +223 -0
  24. package/src/stack-view/patch-preview-merge.js +108 -0
  25. package/src/stack-view/remote-infer-doc.js +93 -0
  26. package/src/stack-view/repo-picker-back.js +10 -0
  27. package/src/stack-view/run-stack-view.js +685 -50
  28. package/src/stack-view/run-view-entry.js +119 -0
  29. package/src/stack-view/sgr-mouse.js +56 -0
  30. package/src/stack-view/stack-branch-graph.js +95 -0
  31. package/src/stack-view/stack-pick-graph.js +93 -0
  32. package/src/stack-view/stack-pick-ink.js +270 -0
  33. package/src/stack-view/stack-pick-layout.js +19 -0
  34. package/src/stack-view/stack-pick-sort.js +188 -0
  35. package/src/stack-view/terminal-fullscreen.js +45 -0
  36. package/src/stack-view/tree-ascii.js +73 -0
  37. package/src/stack-view/view-md-plain.js +23 -0
  38. package/src/stack-view/view-repo-picker-ink.js +293 -0
  39. package/src/stack-view/view-tui-sequential.js +126 -0
@@ -0,0 +1,166 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
3
+ import { parseSgrMouse, enableSgrMouse, disableSgrMouse, isWheelUp, isWheelDown } from "../stack-view/sgr-mouse.js";
4
+
5
+ const VIEWPORT_ROWS = 14;
6
+
7
+ /**
8
+ * @typedef {{ kind: "header"; text: string }} HubHeader
9
+ * @typedef {{ kind: "repo"; fullName: string; pending: number; subtitle?: string }} HubRepo
10
+ * @typedef {HubHeader | HubRepo} HubLine
11
+ */
12
+
13
+ /**
14
+ * @param {object} props
15
+ * @param {HubLine[]} props.lines
16
+ * @param {(fullName: string) => void} props.onPickRepo
17
+ */
18
+ export function ReviewHubInk({ lines, onPickRepo }) {
19
+ const { exit } = useApp();
20
+ const { stdout } = useStdout();
21
+ /** @type {React.MutableRefObject<{ firstListRow: number, viewStart: number, slice: HubLine[] } | null>} */
22
+ const layoutRef = useRef(null);
23
+ /** @type {React.MutableRefObject<{ t: number, lineIdx: number } | null>} */
24
+ const lastClickRef = useRef(null);
25
+
26
+ useEffect(() => {
27
+ enableSgrMouse(stdout);
28
+ return () => disableSgrMouse(stdout);
29
+ }, [stdout]);
30
+
31
+ const repoIndices = useMemo(
32
+ () => lines.map((l, i) => (l.kind === "repo" ? i : -1)).filter((i) => i >= 0),
33
+ [lines]
34
+ );
35
+
36
+ const [rpos, setRpos] = useState(0);
37
+ const safeRpos = repoIndices.length === 0 ? 0 : Math.min(rpos, repoIndices.length - 1);
38
+ const idx = repoIndices.length ? repoIndices[safeRpos] : 0;
39
+
40
+ const total = lines.length;
41
+ const viewStart =
42
+ total <= VIEWPORT_ROWS
43
+ ? 0
44
+ : Math.max(0, Math.min(idx - Math.floor(VIEWPORT_ROWS / 2), total - VIEWPORT_ROWS));
45
+ const slice = lines.slice(viewStart, viewStart + VIEWPORT_ROWS);
46
+
47
+ useInput((input, key) => {
48
+ const mouse = parseSgrMouse(input);
49
+ if (mouse) {
50
+ const L = layoutRef.current;
51
+ if (!L) {
52
+ return;
53
+ }
54
+ const { row, col, button, release } = mouse;
55
+ if (col < 2) {
56
+ return;
57
+ }
58
+ if (isWheelUp(button) || isWheelDown(button)) {
59
+ const down = isWheelDown(button);
60
+ if (repoIndices.length) {
61
+ if (down) {
62
+ setRpos((p) => Math.min(p + 1, repoIndices.length - 1));
63
+ } else {
64
+ setRpos((p) => Math.max(p - 1, 0));
65
+ }
66
+ }
67
+ return;
68
+ }
69
+ if (release) {
70
+ return;
71
+ }
72
+ const local = row - L.firstListRow;
73
+ if (local >= 0 && local < L.slice.length) {
74
+ const i = L.viewStart + local;
75
+ const line = lines[i];
76
+ if (line && line.kind === "repo") {
77
+ const ri = repoIndices.indexOf(i);
78
+ if (ri >= 0) {
79
+ setRpos(ri);
80
+ const now = Date.now();
81
+ const prev = lastClickRef.current;
82
+ const dbl = prev && prev.lineIdx === i && now - prev.t < 480;
83
+ lastClickRef.current = { t: now, lineIdx: i };
84
+ if (dbl) {
85
+ onPickRepo(line.fullName);
86
+ exit();
87
+ }
88
+ }
89
+ }
90
+ }
91
+ return;
92
+ }
93
+
94
+ if (input === "q" || key.escape) {
95
+ exit();
96
+ return;
97
+ }
98
+ if (/^[1-9]$/.test(input) && repoIndices.length) {
99
+ const n = Number.parseInt(input, 10) - 1;
100
+ if (n < repoIndices.length) {
101
+ const lineIdx = repoIndices[n];
102
+ const line = lines[lineIdx];
103
+ if (line && line.kind === "repo") {
104
+ onPickRepo(line.fullName);
105
+ exit();
106
+ }
107
+ }
108
+ return;
109
+ }
110
+ if (input === "j" || key.downArrow) {
111
+ if (repoIndices.length) {
112
+ setRpos((p) => Math.min(p + 1, repoIndices.length - 1));
113
+ }
114
+ return;
115
+ }
116
+ if (input === "k" || key.upArrow) {
117
+ setRpos((p) => Math.max(p - 1, 0));
118
+ return;
119
+ }
120
+ if (key.return || input === " ") {
121
+ const line = lines[idx];
122
+ if (line && line.kind === "repo") {
123
+ onPickRepo(line.fullName);
124
+ exit();
125
+ }
126
+ }
127
+ });
128
+
129
+ const status =
130
+ total > VIEWPORT_ROWS
131
+ ? `Lines ${viewStart + 1}–${Math.min(viewStart + VIEWPORT_ROWS, total)} of ${total} · repo ${safeRpos + 1}/${repoIndices.length}`
132
+ : repoIndices.length
133
+ ? `Repo ${safeRpos + 1}/${repoIndices.length}`
134
+ : "";
135
+
136
+ const rendered = slice.map((line, localI) => {
137
+ const i = viewStart + localI;
138
+ if (line.kind === "header") {
139
+ return React.createElement(Text, { key: `h-${i}`, color: "magenta", bold: true }, line.text);
140
+ }
141
+ const mark = i === idx ? "▶ " : " ";
142
+ const pend = line.pending > 0 ? ` · ${line.pending} review request(s)` : "";
143
+ const hot = line.pending > 0;
144
+ return React.createElement(
145
+ Text,
146
+ {
147
+ key: `r-${i}`,
148
+ color: hot ? "yellow" : "white",
149
+ bold: hot
150
+ },
151
+ `${mark}${line.fullName}${pend}`
152
+ );
153
+ });
154
+
155
+ const firstListRow = status ? 4 : 3;
156
+ layoutRef.current = { firstListRow, viewStart, slice };
157
+
158
+ return React.createElement(
159
+ Box,
160
+ { flexDirection: "column", padding: 1 },
161
+ React.createElement(Text, { color: "cyan", bold: true }, "nugit review — repositories"),
162
+ React.createElement(Text, { dimColor: true }, "j/k · 1-9 open · Enter/Space · click · wheel · q quit"),
163
+ status ? React.createElement(Text, { dimColor: true }, status) : null,
164
+ ...rendered
165
+ );
166
+ }
@@ -0,0 +1,188 @@
1
+ import React from "react";
2
+ import { render } from "ink";
3
+ import { authMe } from "../api-client.js";
4
+ import {
5
+ githubListAllUserRepos,
6
+ githubSearchIssues
7
+ } from "../github-rest.js";
8
+ import { resolveGithubToken } from "../auth-token.js";
9
+ import { runStackViewCommand } from "../stack-view/run-stack-view.js";
10
+ import { ReviewHubInk } from "./review-hub-ink.js";
11
+ import { ReviewHubBackError } from "./review-hub-back.js";
12
+ import {
13
+ enterAlternateScreen,
14
+ leaveAlternateScreen
15
+ } from "../stack-view/terminal-fullscreen.js";
16
+
17
+ /**
18
+ * @param {string} login
19
+ * @returns {Promise<Map<string, number>>}
20
+ */
21
+ async function pendingReviewsByRepo(login) {
22
+ /** @type {Map<string, number>} */
23
+ const map = new Map();
24
+ let page = 1;
25
+ const maxPages = 30;
26
+ while (page <= maxPages) {
27
+ const res = await githubSearchIssues(
28
+ `is:open is:pr review-requested:${login}`,
29
+ 100,
30
+ page
31
+ );
32
+ const items = Array.isArray(res.items) ? res.items : [];
33
+ if (items.length === 0) {
34
+ break;
35
+ }
36
+ for (const it of items) {
37
+ if (!it || typeof it !== "object") continue;
38
+ const u = /** @type {Record<string, unknown>} */ (it).repository_url;
39
+ if (typeof u !== "string") continue;
40
+ const m = u.match(/\/repos\/([^/]+)\/([^/]+)$/);
41
+ if (!m) continue;
42
+ const full = `${m[1]}/${m[2]}`;
43
+ map.set(full, (map.get(full) || 0) + 1);
44
+ }
45
+ if (items.length < 100) {
46
+ break;
47
+ }
48
+ page += 1;
49
+ }
50
+ return map;
51
+ }
52
+
53
+ /**
54
+ * @param {unknown} repoObj
55
+ * @param {Map<string, number>} pendingMap
56
+ */
57
+ function normalizeRepo(repoObj, pendingMap) {
58
+ if (!repoObj || typeof repoObj !== "object") return null;
59
+ const r = /** @type {Record<string, unknown>} */ (repoObj);
60
+ const fn = typeof r.full_name === "string" ? r.full_name : "";
61
+ if (!fn) return null;
62
+ const owner = r.owner && typeof r.owner === "object" ? /** @type {Record<string, unknown>} */ (r.owner) : {};
63
+ const ownerType = typeof owner.type === "string" ? owner.type : "User";
64
+ const pending = pendingMap.get(fn) || 0;
65
+ return { fullName: fn, ownerType, pending };
66
+ }
67
+
68
+ /**
69
+ * @param {{ fullName: string, ownerType: string, pending: number }[]} repos
70
+ */
71
+ function buildHubLines(repos) {
72
+ const sorted = [...repos].sort((a, b) => {
73
+ if (b.pending !== a.pending) return b.pending - a.pending;
74
+ return a.fullName.localeCompare(b.fullName);
75
+ });
76
+
77
+ /** @type {import("./review-hub-ink.js").HubLine[]} */
78
+ const lines = [];
79
+ let lastBucket = "";
80
+ for (const r of sorted) {
81
+ const bucket = r.ownerType === "Organization" ? "Organizations" : "Users";
82
+ const ownerLogin = r.fullName.split("/")[0] || "";
83
+ const key = `${bucket}:${ownerLogin}`;
84
+ if (key !== lastBucket) {
85
+ lastBucket = key;
86
+ lines.push({
87
+ kind: "header",
88
+ text: `${bucket} — ${ownerLogin}`
89
+ });
90
+ }
91
+ lines.push({
92
+ kind: "repo",
93
+ fullName: r.fullName,
94
+ pending: r.pending
95
+ });
96
+ }
97
+ return lines;
98
+ }
99
+
100
+ /**
101
+ * @param {{ noTui?: boolean, autoApply?: boolean }} opts
102
+ */
103
+ export async function runReviewHub(opts) {
104
+ if (!resolveGithubToken()) {
105
+ throw new Error(
106
+ "GitHub token required for nugit review. Run `nugit auth login` or set NUGIT_USER_TOKEN / STACKPR_USER_TOKEN " +
107
+ "(PAT must allow repo access and search for review-requested PRs)."
108
+ );
109
+ }
110
+
111
+ const me = await authMe();
112
+ const login = me && typeof me.login === "string" ? me.login : "";
113
+ if (!login) {
114
+ throw new Error("Could not resolve GitHub login for review hub.");
115
+ }
116
+
117
+ const [rawRepos, pendingMap] = await Promise.all([
118
+ githubListAllUserRepos(),
119
+ pendingReviewsByRepo(login)
120
+ ]);
121
+
122
+ const repos = [];
123
+ for (const r of rawRepos) {
124
+ const n = normalizeRepo(r, pendingMap);
125
+ if (n) repos.push(n);
126
+ }
127
+
128
+ if (opts.noTui || !process.stdin.isTTY || !process.stdout.isTTY) {
129
+ for (const r of [...repos].sort((a, b) => b.pending - a.pending || a.fullName.localeCompare(b.fullName))) {
130
+ const p = r.pending ? ` (${r.pending} pending)` : "";
131
+ console.log(`${r.fullName}${p}`);
132
+ }
133
+ return;
134
+ }
135
+
136
+ const tty = process.stdin.isTTY && process.stdout.isTTY;
137
+ if (tty) {
138
+ enterAlternateScreen(process.stdout);
139
+ }
140
+ try {
141
+ for (;;) {
142
+ const lines = buildHubLines(repos);
143
+ if (lines.filter((l) => l.kind === "repo").length === 0) {
144
+ console.error("No repositories visible to this token.");
145
+ return;
146
+ }
147
+
148
+ let picked = /** @type {string | null} */ (null);
149
+ const { waitUntilExit } = render(
150
+ React.createElement(ReviewHubInk, {
151
+ lines,
152
+ onPickRepo: (fn) => {
153
+ picked = fn;
154
+ }
155
+ })
156
+ );
157
+ await waitUntilExit();
158
+ if (!picked) {
159
+ return;
160
+ }
161
+
162
+ try {
163
+ await runStackViewCommand({
164
+ repo: picked,
165
+ noTui: false,
166
+ allowBackToReviewHub: true,
167
+ reviewAutoapply: !!opts.autoApply,
168
+ reviewFetchOpts: {
169
+ viewerLogin: login,
170
+ fullReviewFetch: true
171
+ },
172
+ viewTitle: "nugit review",
173
+ shellMode: false
174
+ });
175
+ break;
176
+ } catch (e) {
177
+ if (e instanceof ReviewHubBackError) {
178
+ continue;
179
+ }
180
+ throw e;
181
+ }
182
+ }
183
+ } finally {
184
+ if (tty) {
185
+ leaveAlternateScreen(process.stdout);
186
+ }
187
+ }
188
+ }
@@ -1,8 +1,9 @@
1
1
  import React from "react";
2
2
  import { render } from "ink";
3
- import { getPull, createPullRequest } from "../api-client.js";
3
+ import { authMe, getPull, createPullRequest } from "../api-client.js";
4
4
  import { githubPostIssueComment } from "../github-pr-social.js";
5
5
  import {
6
+ createInitialStackDoc,
6
7
  readStackFile,
7
8
  writeStackFile,
8
9
  validateStackDoc,
@@ -129,7 +130,7 @@ export async function runSplitCommand(ctx) {
129
130
 
130
131
  /** @type {Record<string, unknown> | null} */
131
132
  let docForHistory = null;
132
- const doc = readStackFile(root);
133
+ let doc = readStackFile(root);
133
134
  if (doc) {
134
135
  validateStackDoc(doc);
135
136
  const idx = doc.prs.findIndex((p) => p.pr_number === prNumber);
@@ -151,7 +152,19 @@ export async function runSplitCommand(ctx) {
151
152
  );
152
153
  }
153
154
  } else {
154
- console.error("No .nugit/stack.json skipped local stack file update.");
155
+ const me = await authMe();
156
+ const login = me && typeof me.login === "string" ? me.login : "unknown";
157
+ doc = createInitialStackDoc(`${owner}/${repo}`, login);
158
+ doc.prs = [];
159
+ for (let i = 0; i < newPrNumbers.length; i++) {
160
+ const p2 = await getPull(owner, repo, newPrNumbers[i]);
161
+ doc.prs.push(stackEntryFromGithubPull(p2, i));
162
+ }
163
+ writeStackFile(root, doc);
164
+ docForHistory = doc;
165
+ console.error(
166
+ `Created .nugit/stack.json with ${newPrNumbers.length} PR(s) from this split (repo had no stack file).`
167
+ );
155
168
  }
156
169
 
157
170
  appendStackHistory(root, {
@@ -39,7 +39,7 @@ export function SplitInkApp({ files, exitPayload }) {
39
39
  exit();
40
40
  return;
41
41
  }
42
- if (input === "c") {
42
+ if (input === "c" || key.return || input === " ") {
43
43
  exitPayload.next = {
44
44
  type: "confirm",
45
45
  layerCount,
@@ -82,7 +82,7 @@ export function SplitInkApp({ files, exitPayload }) {
82
82
  React.createElement(
83
83
  Text,
84
84
  { dimColor: true },
85
- `Layers: ${layerCount} (+/-) | assign file to layer 0–${layerCount - 1} (digit) | c confirm | q cancel`
85
+ `Layers: ${layerCount} (+/-) | assign file to layer 0–${layerCount - 1} (digit) | c / Enter / Space confirm | q cancel`
86
86
  ),
87
87
  React.createElement(Text, { marginTop: 1 }, chalk.bold("Files:")),
88
88
  ...files.slice(0, 18).map((f, i) =>
@@ -262,6 +262,13 @@ export async function discoverStacksInRepo(owner, repo, opts = {}) {
262
262
  ? tipObj.head_branch
263
263
  : meta.headRef;
264
264
 
265
+ const tipPull = allPulls.find((pull) => {
266
+ const p = pull && typeof pull === "object" ? /** @type {Record<string, unknown>} */ (pull) : {};
267
+ return p.number === tipPr;
268
+ });
269
+ const tipPu = tipPull && typeof tipPull === "object" ? /** @type {Record<string, unknown>} */ (tipPull) : {};
270
+ const tip_updated_at = typeof tipPu.updated_at === "string" ? tipPu.updated_at : "";
271
+
265
272
  stacks.push({
266
273
  tip_pr_number: tipPr,
267
274
  created_by: String(doc.created_by || ""),
@@ -269,8 +276,9 @@ export async function discoverStacksInRepo(owner, repo, opts = {}) {
269
276
  pr_count: prRows.length,
270
277
  prs: prRows,
271
278
  tip_head_branch: tipHeadBranch,
279
+ tip_updated_at,
272
280
  fetch_command: `nugit stack fetch --repo ${repoFull} --ref ${tipHeadBranch}`,
273
- view_command: `nugit stack view --repo ${repoFull} --ref ${tipHeadBranch}`
281
+ view_command: `nugit view --repo ${repoFull} --ref ${tipHeadBranch}`
274
282
  });
275
283
  }
276
284
 
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Infer linear stacked PR chains from open pulls using base.ref === other.head.ref (same-repo v1).
3
+ * @param {unknown[]} pulls GitHub pull objects (open).
4
+ * @param {string} repoFullName e.g. "owner/repo" — heads must match this repo (fork PRs skipped).
5
+ * @returns {number[][]} Chains ordered bottom → top (PR numbers).
6
+ */
7
+ export function inferPrChainsFromOpenPulls(pulls, repoFullName) {
8
+ /** @type {{ number: number, headRef: string, baseRef: string }[]} */
9
+ const prs = [];
10
+ for (const raw of pulls) {
11
+ if (!raw || typeof raw !== "object") continue;
12
+ const p = /** @type {Record<string, unknown>} */ (raw);
13
+ const num = p.number;
14
+ const head = p.head && typeof p.head === "object" ? /** @type {Record<string, unknown>} */ (p.head) : {};
15
+ const base = p.base && typeof p.base === "object" ? /** @type {Record<string, unknown>} */ (p.base) : {};
16
+ const headRef = typeof head.ref === "string" ? head.ref : "";
17
+ const baseRef = typeof base.ref === "string" ? base.ref : "";
18
+ const headRepo =
19
+ head.repo && typeof head.repo === "object"
20
+ ? String(/** @type {Record<string, unknown>} */ (head.repo).full_name || "")
21
+ : "";
22
+ if (typeof num !== "number" || !headRef) continue;
23
+ if (headRepo && headRepo !== repoFullName) continue;
24
+ prs.push({ number: num, headRef, baseRef });
25
+ }
26
+
27
+ /** child PR# -> parent PR# below (parent.head === child.base) */
28
+ const parentBelow = new Map();
29
+ /** parent PR# -> child PR# above */
30
+ const childAbove = new Map();
31
+ for (const x of prs) {
32
+ const below = prs.find((p) => p.headRef === x.baseRef);
33
+ if (below && below.number !== x.number) {
34
+ parentBelow.set(x.number, below.number);
35
+ childAbove.set(below.number, x.number);
36
+ }
37
+ }
38
+
39
+ const bottoms = prs.filter((p) => !parentBelow.has(p.number));
40
+ /** @type {number[][]} */
41
+ const chains = [];
42
+ for (const b of bottoms) {
43
+ const chain = [];
44
+ let cur = /** @type {number | null} */ (b.number);
45
+ const vis = new Set();
46
+ while (cur != null && !vis.has(cur)) {
47
+ vis.add(cur);
48
+ chain.push(cur);
49
+ const next = childAbove.get(cur);
50
+ cur = next !== undefined ? next : null;
51
+ }
52
+ if (chain.length) {
53
+ chains.push(chain);
54
+ }
55
+ }
56
+
57
+ const inChain = new Set(chains.flat());
58
+ for (const p of prs) {
59
+ if (!inChain.has(p.number)) {
60
+ chains.push([p.number]);
61
+ }
62
+ }
63
+
64
+ chains.sort((a, b) => {
65
+ const ta = a[a.length - 1] ?? 0;
66
+ const tb = b[b.length - 1] ?? 0;
67
+ if (ta !== tb) return ta - tb;
68
+ return (a[0] ?? 0) - (b[0] ?? 0);
69
+ });
70
+ return chains;
71
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Map a 0-based index in a unified diff (patch) line array to the GitHub
3
+ * pull-request review comment `line` and `side` fields.
4
+ *
5
+ * GitHub's `line` is the **1-based** line number in the *final* (RIGHT) or
6
+ * *original* (LEFT) file at that position in the hunk. Context lines map to
7
+ * RIGHT/line in the new file. `+` lines map to RIGHT. `-` lines map to LEFT.
8
+ *
9
+ * Header lines (`@@`, `---`, `+++`) are not commentable — returns null.
10
+ *
11
+ * @param {string[]} patchLines the `patch` string split by `\n`
12
+ * @param {number} idx 0-based index into `patchLines`
13
+ * @returns {{ line: number, side: "LEFT" | "RIGHT" } | null}
14
+ */
15
+ export function diffLineToGitHub(patchLines, idx) {
16
+ if (idx < 0 || idx >= patchLines.length) {
17
+ return null;
18
+ }
19
+ const target = patchLines[idx];
20
+ if (
21
+ target.startsWith("@@") ||
22
+ target.startsWith("--- ") ||
23
+ target.startsWith("+++ ") ||
24
+ target.startsWith("diff ")
25
+ ) {
26
+ return null;
27
+ }
28
+
29
+ let oldLine = 0;
30
+ let newLine = 0;
31
+
32
+ for (let i = 0; i <= idx; i++) {
33
+ const ln = patchLines[i];
34
+ if (ln.startsWith("@@")) {
35
+ const m = ln.match(/@@ -(\d+)(?:,\d+)? \+(\d+)/);
36
+ if (m) {
37
+ oldLine = Number(m[1]);
38
+ newLine = Number(m[2]);
39
+ }
40
+ continue;
41
+ }
42
+ if (ln.startsWith("--- ") || ln.startsWith("+++ ") || ln.startsWith("diff ")) {
43
+ continue;
44
+ }
45
+ if (i === idx) {
46
+ break;
47
+ }
48
+ if (ln.startsWith("+")) {
49
+ newLine++;
50
+ } else if (ln.startsWith("-")) {
51
+ oldLine++;
52
+ } else {
53
+ oldLine++;
54
+ newLine++;
55
+ }
56
+ }
57
+
58
+ if (target.startsWith("-")) {
59
+ return { line: oldLine, side: "LEFT" };
60
+ }
61
+ return { line: newLine, side: "RIGHT" };
62
+ }