@goodandready/dsh-github-ops 0.4.1 → 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.
package/lib/routes.js CHANGED
@@ -5,16 +5,18 @@
5
5
  // stays a composition: the card, the Source Control tab and the sidebar panel each need one
6
6
  // endpoint, and none of them needs a tool.
7
7
 
8
- import { existsSync, statSync } from 'node:fs'
8
+ import { existsSync, statSync, readFileSync } from 'node:fs'
9
9
  import { join as joinPath } from 'node:path'
10
10
 
11
11
  import { statusPayload, isTrustedRequest, isTrustedWriteRequest, sendJson } from './status.js'
12
- import { buildAction, validateCwd, validateStashRef } from './scm-write.js'
13
- import { parseStatus, parseBranches, parseStashes, parseWorktrees, parseCount, scmPayload, mergeStateFrom } from './scm.js'
12
+ import { buildAction, validateCwd, validateStashRef, resolveConflictFile } from './scm-write.js'
13
+ import { parseStatus, parseBranches, parseStashes, parseWorktrees, parseCount, scmPayload, mergeStateFrom, hasConflicts, parseConflictBlocks, parseDiffHunks, parseGitGraph } from './scm.js'
14
+ import { SseEventManager } from './events.js'
14
15
  import { listIssues } from './tools/issues.js'
15
16
  import { listRuns } from './tools/actions.js'
16
17
  import { listNotifications, repoTreeView } from './tools/panel-data.js'
17
18
  import { mirrorStatus, syncMirror } from './tools/mirror.js'
19
+ import { matrixStatus } from './tools/matrix.js'
18
20
 
19
21
  /** Where the settings card reads the access status from. */
20
22
  export const STATUS_PATH = '/dsh-github-ops/status'
@@ -28,6 +30,15 @@ export const PANEL_PATH = '/dsh-github-ops/panel'
28
30
  /** Where mirror inspection and sync are handled. */
29
31
  export const MIRROR_PATH = '/dsh-github-ops/mirror'
30
32
 
33
+ /** Where organization matrix status is read. */
34
+ export const MATRIX_PATH = '/dsh-github-ops/matrix'
35
+
36
+ /** Where Git commit graph is read. */
37
+ export const GRAPH_PATH = '/dsh-github-ops/graph'
38
+
39
+ /** Where real-time CI and PR events are streamed via SSE. */
40
+ export const EVENTS_PATH = '/dsh-github-ops/events'
41
+
31
42
  /**
32
43
  * Register the three routes.
33
44
  *
@@ -157,9 +168,22 @@ export function registerRoutes(ctx, deps = {}) {
157
168
  } catch (err) {
158
169
  return sendJson(res, 400, { ok: false, error: String((err && err.message) || err) })
159
170
  }
171
+ if (built.action === 'resolveConflict') {
172
+ try {
173
+ const actionArgs = body.args || body
174
+ resolveConflictFile({
175
+ cwd,
176
+ path: actionArgs.path,
177
+ choice: actionArgs.choice,
178
+ blockIndex: actionArgs.blockIndex,
179
+ })
180
+ } catch (err) {
181
+ return sendJson(res, 400, { ok: false, error: String((err && err.message) || err) })
182
+ }
183
+ }
160
184
  const steps = []
161
185
  for (const argv of built.steps) {
162
- const answer = await git(argv, { cwd, timeoutMs: 120000 })
186
+ const answer = await git(argv, { cwd, timeoutMs: 120000, input: built.input })
163
187
  steps.push({
164
188
  argv,
165
189
  code: answer.code,
@@ -178,7 +202,29 @@ export function registerRoutes(ctx, deps = {}) {
178
202
  const args = staged ? ['diff', '--cached', '--', file] : ['diff', '--', file]
179
203
  const diff = await git(args, { cwd })
180
204
  const text = diff.code === 0 ? diff.stdout : diff.stderr
181
- return sendJson(res, 200, { file, staged, diff: String(text || '').slice(0, 200000) })
205
+ let hasConflictsFlag = false
206
+ let conflicts = []
207
+ try {
208
+ const fullFilePath = joinPath(cwd, file)
209
+ if (existsSync(fullFilePath)) {
210
+ const content = readFileSync(fullFilePath, 'utf8')
211
+ if (hasConflicts(content)) {
212
+ hasConflictsFlag = true
213
+ conflicts = parseConflictBlocks(content)
214
+ }
215
+ }
216
+ } catch {
217
+ // ignore
218
+ }
219
+ const hunks = diff.code === 0 ? parseDiffHunks(text) : []
220
+ return sendJson(res, 200, {
221
+ file,
222
+ staged,
223
+ diff: String(text || '').slice(0, 200000),
224
+ hunks,
225
+ hasConflicts: hasConflictsFlag,
226
+ conflicts,
227
+ })
182
228
  }
183
229
 
184
230
  const stash = url.searchParams.get('stash') || ''
@@ -280,6 +326,12 @@ export function registerRoutes(ctx, deps = {}) {
280
326
  const { data } = await github.get('/user/repos', { query: { per_page: 50, sort: 'pushed', affiliation: 'owner,collaborator,organization_member' } })
281
327
  return sendJson(res, 200, { configured: true, repos: (data || []).filter((r) => !r.archived).map((r) => ({ fullName: r.full_name, private: r.private, pushedAt: r.pushed_at, description: r.description || '', stars: r.stargazers_count })) })
282
328
  }
329
+ if (what === 'matrix') {
330
+ const org = url.searchParams.get('org') || repoOwner || liveConfig().owner || ''
331
+ const limit = Number(url.searchParams.get('limit')) || 30
332
+ const result = await matrixStatus(github, { org, limit })
333
+ return sendJson(res, 200, { configured: true, ...result })
334
+ }
283
335
  if (!repoOwner || !repoName) return sendJson(res, 200, { configured: true, error: 'a repository is required for this view' })
284
336
  const repo = { owner: repoOwner, repo: repoName }
285
337
  if (what === 'tree') {
@@ -311,6 +363,7 @@ export function registerRoutes(ctx, deps = {}) {
311
363
  const result = await listNotifications(github, { limit: 30 })
312
364
  return sendJson(res, 200, { configured: true, ...result })
313
365
  }
366
+
314
367
  return sendJson(res, 200, { configured: true, error: `unknown view "${what}"` })
315
368
  } catch (err) {
316
369
  return sendJson(res, 200, { configured: true, error: describeError(err) })
@@ -390,6 +443,91 @@ export function registerRoutes(ctx, deps = {}) {
390
443
  })
391
444
  if (typeof wctx.effect === 'function') wctx.effect(registerMirror, 'dsh-github-ops: mirror route')
392
445
  else registerMirror()
446
+
447
+ const registerMatrix = () => wctx.webServer.register({
448
+ kind: 'exact',
449
+ path: MATRIX_PATH,
450
+ handler: async (req, res) => {
451
+ try {
452
+ if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
453
+ if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
454
+ const access = await resolveToken()
455
+ if (!access.value) return sendJson(res, 200, { configured: false, guidance: access.guidance })
456
+ const url = new URL(req.url || '/', 'http://localhost')
457
+ const org = url.searchParams.get('org') || liveConfig().owner || ''
458
+ const limit = Number(url.searchParams.get('limit')) || 30
459
+ const github = await client()
460
+ const result = await matrixStatus(github, { org, limit })
461
+ return sendJson(res, 200, { ok: true, ...result })
462
+ } catch (err) {
463
+ return sendJson(res, 500, { ok: false, error: describeError(err) })
464
+ }
465
+ },
466
+ })
467
+ if (typeof wctx.effect === 'function') wctx.effect(registerMatrix, 'dsh-github-ops: matrix route')
468
+ else registerMatrix()
469
+
470
+ const registerGraph = () => wctx.webServer.register({
471
+ kind: 'exact',
472
+ path: GRAPH_PATH,
473
+ handler: async (req, res) => {
474
+ try {
475
+ if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
476
+ if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
477
+ const git = makeGitRunner()
478
+ const url = new URL(req.url || '/', 'http://localhost')
479
+ const requested = validateCwd(url.searchParams.get('cwd') || '')
480
+ let cwd = safeCwd(ctx)
481
+ if (requested) {
482
+ if (!existsSync(requested) || !statSync(requested).isDirectory()) {
483
+ return sendJson(res, 400, { error: `not a directory: ${requested}` })
484
+ }
485
+ cwd = requested
486
+ }
487
+ const limitParam = parseInt(url.searchParams.get('limit') || '15', 10)
488
+ const limit = Math.max(1, Math.min(Number.isFinite(limitParam) ? limitParam : 15, 50))
489
+ const answer = await git(['log', `-n`, String(limit), '--graph', '--date=short', '--format=format:%h|%d|%s|%an|%cd'], { cwd })
490
+ if (answer.code !== 0) {
491
+ return sendJson(res, 200, { ok: false, error: answer.stderr || 'git log failed', entries: [] })
492
+ }
493
+ const entries = parseGitGraph(answer.stdout)
494
+ return sendJson(res, 200, { ok: true, limit, entries, raw: answer.stdout })
495
+ } catch (err) {
496
+ return sendJson(res, 500, { ok: false, error: describeError(err) })
497
+ }
498
+ },
499
+ })
500
+ if (typeof wctx.effect === 'function') wctx.effect(registerGraph, 'dsh-github-ops: graph route')
501
+ else registerGraph()
502
+
503
+ const eventManager = new SseEventManager({
504
+ client,
505
+ liveConfig,
506
+ describeError,
507
+ })
508
+ if (typeof wctx.effect === 'function') {
509
+ wctx.effect(() => () => eventManager.destroy(), 'dsh-github-ops: events teardown')
510
+ }
511
+
512
+ const registerEvents = () => wctx.webServer.register({
513
+ kind: 'exact',
514
+ path: EVENTS_PATH,
515
+ handler: async (req, res) => {
516
+ if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
517
+ if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
518
+ res.writeHead(200, {
519
+ 'Content-Type': 'text/event-stream',
520
+ 'Cache-Control': 'no-cache, no-transform',
521
+ 'Connection': 'keep-alive',
522
+ 'X-Accel-Buffering': 'no',
523
+ })
524
+ if (typeof res.flushHeaders === 'function') res.flushHeaders()
525
+ eventManager.addClient(res, req)
526
+ },
527
+ })
528
+ if (typeof wctx.effect === 'function') wctx.effect(registerEvents, 'dsh-github-ops: events route')
529
+ else registerEvents()
530
+
393
531
  } catch (err) {
394
532
  ctx.logger?.warn?.('[dsh-github-ops] panel route not registered:', err && err.message)
395
533
  }
@@ -401,7 +539,7 @@ export function registerRoutes(ctx, deps = {}) {
401
539
  * Read a small JSON body, bounded, so a POST that names an action cannot exhaust memory.
402
540
  * The panel sends a few hundred bytes; anything larger is refused rather than buffered.
403
541
  */
404
- function readJsonBody(req, limit = 64 * 1024) {
542
+ function readJsonBody(req, limit = 512 * 1024) {
405
543
  return new Promise((resolve, reject) => {
406
544
  let size = 0
407
545
  const chunks = []
package/lib/scm-write.js CHANGED
@@ -1,4 +1,6 @@
1
- import { realpathSync } from 'node:fs'
1
+ import { realpathSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join as joinPath } from 'node:path'
3
+ import { hasConflicts, resolveConflictContent } from './scm.js'
2
4
  // Writing to a working copy, from the Source Control tab.
3
5
  //
4
6
  // Every action is a list of argv arrays — never a shell string. The route runs them one by one
@@ -44,6 +46,19 @@ export function validateStashRef(ref) {
44
46
  return value
45
47
  }
46
48
 
49
+
50
+ export function validatePatch(patch) {
51
+ const value = String(patch == null ? '' : patch)
52
+ if (value.includes('\u0000')) throw new Error('refusing a patch with a NUL byte')
53
+ const trimmed = value.trim()
54
+ if (!trimmed) throw new Error('a patch cannot be empty')
55
+ if (trimmed.length > 500000) throw new Error('refusing a patch longer than 500000 characters')
56
+ if (!trimmed.includes('@@') && !trimmed.includes('---')) {
57
+ throw new Error('refusing an invalid patch format: missing diff hunk markers')
58
+ }
59
+ return value
60
+ }
61
+
47
62
  const CONTINUE_VERBS = ['merge', 'rebase', 'cherry-pick', 'revert']
48
63
 
49
64
  /**
@@ -113,6 +128,28 @@ export function buildAction(action, args = {}) {
113
128
  const verb = String(args.verb || '').trim()
114
129
  if (!CONTINUE_VERBS.includes(verb)) throw new Error(`refusing to abort an unknown operation: "${verb.slice(0, 20)}"`)
115
130
  return result(name, [[verb, '--abort']], destructive, `abort the ${verb}`)
131
+ }
132
+ case 'stagePatch':
133
+ case 'stage_patch': {
134
+ const patch = validatePatch(args.patch)
135
+ const res = result('stage_patch', [['apply', '--cached', '-']], false, 'stage patch hunk')
136
+ res.input = patch
137
+ return res
138
+ }
139
+ case 'unstagePatch':
140
+ case 'unstage_patch': {
141
+ const patch = validatePatch(args.patch)
142
+ const res = result('unstage_patch', [['apply', '--cached', '--reverse', '-']], false, 'unstage patch hunk')
143
+ res.input = patch
144
+ return res
145
+ }
146
+ case 'resolveConflict': {
147
+ const path = validatePath(args.path)
148
+ const choice = String(args.choice || 'ours').trim()
149
+ if (!['ours', 'theirs', 'both'].includes(choice)) {
150
+ throw new Error(`invalid conflict resolution choice: "${choice}". Must be "ours", "theirs", or "both".`)
151
+ }
152
+ return result(name, [['add', '--', path]], false, `resolve conflict in ${path} using ${choice}`)
116
153
  }
117
154
  case 'worktreeAdd': {
118
155
  const path = validateCwd(args.path)
@@ -156,6 +193,7 @@ export const ACTIONS = Object.freeze([
156
193
  'stashPush', 'stashApply', 'stashPop', 'stashDrop', 'stashShow',
157
194
  'worktreeAdd', 'worktreeRemove',
158
195
  'continue', 'abort',
196
+ 'resolveConflict',
159
197
  ])
160
198
 
161
199
  /** The verbs the panel offers for an unfinished operation. */
@@ -193,3 +231,22 @@ export function validateCwd(path) {
193
231
  }
194
232
  return value
195
233
  }
234
+
235
+
236
+ /**
237
+ * Resolves conflict markers directly in the working copy file and prepares it for staging.
238
+ */
239
+ export function resolveConflictFile({ cwd, path, choice = 'ours', blockIndex = null }) {
240
+ const relPath = validatePath(path)
241
+ const fullPath = joinPath(cwd, relPath)
242
+ if (!existsSync(fullPath)) {
243
+ throw new Error(`file not found: ${relPath}`)
244
+ }
245
+ const content = readFileSync(fullPath, 'utf8')
246
+ if (!hasConflicts(content)) {
247
+ throw new Error(`no merge conflict markers found in ${relPath}`)
248
+ }
249
+ const resolved = resolveConflictContent(content, { choice, blockIndex })
250
+ writeFileSync(fullPath, resolved, 'utf8')
251
+ return { path: relPath, choice, blockIndex, resolved: true }
252
+ }
package/lib/scm.js CHANGED
@@ -150,3 +150,293 @@ export function mergeStateFrom({ mergeHead = false, rebaseDir = false, cherryPic
150
150
  if (revertHead) return { inProgress: true, kind: 'revert' }
151
151
  return { inProgress: false, kind: '' }
152
152
  }
153
+
154
+
155
+ const CONFLICT_START_RE = /^<<<<<<<[ \t]*(.*)$/
156
+ const CONFLICT_BASE_RE = /^\|\|\|\|\|\|\|[ \t]*(.*)$/
157
+ const CONFLICT_MID_RE = /^=======$/
158
+ const CONFLICT_END_RE = /^>>>>>>>[ \t]*(.*)$/
159
+
160
+ /**
161
+ * Check whether a string contains merge conflict markers.
162
+ */
163
+ export function hasConflicts(content) {
164
+ if (!content || typeof content !== 'string') return false
165
+ return content.includes('<<<<<<<') && content.includes('=======') && content.includes('>>>>>>>')
166
+ }
167
+
168
+ /**
169
+ * Parse conflict hunks/blocks from file content.
170
+ */
171
+ export function parseConflictBlocks(content) {
172
+ if (!hasConflicts(content)) return []
173
+ const lines = String(content).split(/\r?\n/)
174
+ const blocks = []
175
+
176
+ let inBlock = false
177
+ let inBase = false
178
+ let inTheirs = false
179
+
180
+ let startLine = 0
181
+ let oursHeader = ''
182
+ let baseHeader = ''
183
+ let theirsHeader = ''
184
+ let oursLines = []
185
+ let baseLines = []
186
+ let theirsLines = []
187
+
188
+ for (let i = 0; i < lines.length; i++) {
189
+ const line = lines[i]
190
+ const lineNum = i + 1
191
+
192
+ if (!inBlock) {
193
+ const match = CONFLICT_START_RE.exec(line)
194
+ if (match) {
195
+ inBlock = true
196
+ inBase = false
197
+ inTheirs = false
198
+ startLine = lineNum
199
+ oursHeader = match[1] || 'HEAD'
200
+ baseHeader = ''
201
+ theirsHeader = ''
202
+ oursLines = []
203
+ baseLines = []
204
+ theirsLines = []
205
+ }
206
+ } else if (!inTheirs) {
207
+ const baseMatch = CONFLICT_BASE_RE.exec(line)
208
+ if (baseMatch) {
209
+ inBase = true
210
+ baseHeader = baseMatch[1] || ''
211
+ continue
212
+ }
213
+ if (CONFLICT_MID_RE.test(line)) {
214
+ inTheirs = true
215
+ inBase = false
216
+ continue
217
+ }
218
+ if (inBase) {
219
+ baseLines.push(line)
220
+ } else {
221
+ oursLines.push(line)
222
+ }
223
+ } else {
224
+ const endMatch = CONFLICT_END_RE.exec(line)
225
+ if (endMatch) {
226
+ theirsHeader = endMatch[1] || ''
227
+ const oursStr = oursLines.join('\n')
228
+ const theirsStr = theirsLines.join('\n')
229
+ const baseStr = baseLines.length > 0 ? baseLines.join('\n') : undefined
230
+ const bothStr = [oursStr, theirsStr].filter((s) => s.length > 0).join('\n')
231
+
232
+ blocks.push({
233
+ index: blocks.length,
234
+ startLine,
235
+ endLine: lineNum,
236
+ oursHeader,
237
+ theirsHeader,
238
+ baseHeader: baseHeader || undefined,
239
+ ours: oursStr,
240
+ theirs: theirsStr,
241
+ base: baseStr,
242
+ both: bothStr,
243
+ })
244
+
245
+ inBlock = false
246
+ inBase = false
247
+ inTheirs = false
248
+ } else {
249
+ theirsLines.push(line)
250
+ }
251
+ }
252
+ }
253
+
254
+ return blocks
255
+ }
256
+
257
+ /**
258
+ * Resolve merge conflict markers to chosen resolution: 'ours', 'theirs', or 'both'.
259
+ */
260
+ export function resolveConflictContent(content, resolutions = 'ours') {
261
+ if (!hasConflicts(content)) return content
262
+ const eol = content.includes('\r\n') ? '\r\n' : '\n'
263
+ const lines = String(content).split(/\r?\n/)
264
+ const resultLines = []
265
+
266
+ let inBlock = false
267
+ let inBase = false
268
+ let inTheirs = false
269
+ let blockIndex = 0
270
+
271
+ let oursLines = []
272
+ let theirsLines = []
273
+
274
+ const getChoice = (idx) => {
275
+ if (typeof resolutions === 'string') return resolutions
276
+ if (resolutions && typeof resolutions === 'object') {
277
+ if (Array.isArray(resolutions)) {
278
+ const found = resolutions.find((r) => r.blockIndex === idx)
279
+ return found ? found.choice : null
280
+ }
281
+ if (resolutions.blockIndex === idx) return resolutions.choice
282
+ if (resolutions.blockIndex === null || resolutions.blockIndex === undefined) return resolutions.choice
283
+ }
284
+ return null
285
+ }
286
+
287
+ for (let i = 0; i < lines.length; i++) {
288
+ const line = lines[i]
289
+
290
+ if (!inBlock) {
291
+ if (CONFLICT_START_RE.test(line)) {
292
+ inBlock = true
293
+ inBase = false
294
+ inTheirs = false
295
+ oursLines = []
296
+ theirsLines = []
297
+ } else {
298
+ resultLines.push(line)
299
+ }
300
+ } else if (!inTheirs) {
301
+ if (CONFLICT_BASE_RE.test(line)) {
302
+ inBase = true
303
+ } else if (CONFLICT_MID_RE.test(line)) {
304
+ inTheirs = true
305
+ inBase = false
306
+ } else if (!inBase) {
307
+ oursLines.push(line)
308
+ }
309
+ } else {
310
+ if (CONFLICT_END_RE.test(line)) {
311
+ const choice = getChoice(blockIndex)
312
+ if (choice === 'theirs') {
313
+ resultLines.push(...theirsLines)
314
+ } else if (choice === 'both') {
315
+ if (oursLines.length) resultLines.push(...oursLines)
316
+ if (theirsLines.length) resultLines.push(...theirsLines)
317
+ } else if (choice === 'ours') {
318
+ resultLines.push(...oursLines)
319
+ } else {
320
+ resultLines.push('<<<<<<< HEAD')
321
+ resultLines.push(...oursLines)
322
+ resultLines.push('=======')
323
+ resultLines.push(...theirsLines)
324
+ resultLines.push('>>>>>>> incoming')
325
+ }
326
+ blockIndex++
327
+ inBlock = false
328
+ inTheirs = false
329
+ } else {
330
+ theirsLines.push(line)
331
+ }
332
+ }
333
+ }
334
+
335
+ return resultLines.join(eol)
336
+ }
337
+
338
+ /**
339
+ * Splits a unified diff into individual hunks with their headers and ready-to-apply patches.
340
+ */
341
+ export function parseDiffHunks(diffText) {
342
+ if (typeof diffText !== 'string' || !diffText.trim()) return []
343
+ const lines = diffText.split('\n')
344
+ const fileHeaderLines = []
345
+ let i = 0
346
+ while (i < lines.length && !lines[i].startsWith('@@')) {
347
+ fileHeaderLines.push(lines[i])
348
+ i++
349
+ }
350
+ const fileHeader = fileHeaderLines.length > 0 ? fileHeaderLines.join('\n') + '\n' : ''
351
+
352
+ const hunks = []
353
+ let currentHunk = null
354
+
355
+ while (i < lines.length) {
356
+ const line = lines[i]
357
+ if (line.startsWith('@@')) {
358
+ if (currentHunk) {
359
+ currentHunk.patch = fileHeader + currentHunk.lines.map((l) => l.raw).join('\n') + '\n'
360
+ hunks.push(currentHunk)
361
+ }
362
+ const match = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/)
363
+ currentHunk = {
364
+ header: line,
365
+ oldStart: match ? parseInt(match[1], 10) : 0,
366
+ oldCount: match ? (match[2] !== undefined ? parseInt(match[2], 10) : 1) : 0,
367
+ newStart: match ? parseInt(match[3], 10) : 0,
368
+ newCount: match ? (match[4] !== undefined ? parseInt(match[4], 10) : 1) : 0,
369
+ heading: match ? match[5].trim() : '',
370
+ lines: [{ type: 'hunk_header', text: line, raw: line }],
371
+ patch: '',
372
+ }
373
+ } else if (currentHunk) {
374
+ let type = 'context'
375
+ if (line.startsWith('+')) type = 'add'
376
+ else if (line.startsWith('-')) type = 'del'
377
+ else if (line.startsWith('\\')) type = 'meta'
378
+ currentHunk.lines.push({ type, text: line, raw: line })
379
+ }
380
+ i++
381
+ }
382
+
383
+ if (currentHunk) {
384
+ currentHunk.patch = fileHeader + currentHunk.lines.map((l) => l.raw).join('\n') + '\n'
385
+ hunks.push(currentHunk)
386
+ }
387
+
388
+ return hunks
389
+ }
390
+
391
+ /**
392
+ * Parses git log --graph --date=short --format=format:'%h|%d|%s|%an|%cd' output.
393
+ */
394
+ export function parseGitGraph(text) {
395
+ if (typeof text !== 'string' || !text.trim()) return []
396
+ const lines = text.split('\n')
397
+ const results = []
398
+
399
+ for (const rawLine of lines) {
400
+ if (!rawLine.trim()) continue
401
+ const match = rawLine.match(/^([*|/\\_\-\s]*?)\s*([0-9a-f]{7,40})\|([^|]*)\|([^|]*)\|([^|]*)\|(.*)$/)
402
+ if (match) {
403
+ const graph = match[1] || '*'
404
+ const hash = match[2]
405
+ const rawRefs = match[3].trim()
406
+ const subject = match[4].trim()
407
+ const author = match[5].trim()
408
+ const date = match[6].trim()
409
+
410
+ let refs = []
411
+ if (rawRefs) {
412
+ const cleanRefs = rawRefs.replace(/^\(|\)$/g, '').trim()
413
+ if (cleanRefs) {
414
+ refs = cleanRefs.split(',').map((r) => r.trim()).filter(Boolean)
415
+ }
416
+ }
417
+ results.push({
418
+ isCommit: true,
419
+ graph,
420
+ hash,
421
+ refs,
422
+ subject,
423
+ author,
424
+ date,
425
+ raw: rawLine,
426
+ })
427
+ } else {
428
+ results.push({
429
+ isCommit: false,
430
+ graph: rawLine,
431
+ hash: '',
432
+ refs: [],
433
+ subject: '',
434
+ author: '',
435
+ date: '',
436
+ raw: rawLine,
437
+ })
438
+ }
439
+ }
440
+
441
+ return results
442
+ }