@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/CHANGELOG.md +7 -0
- package/lib/client.js +259 -7
- package/lib/events.js +201 -0
- package/lib/index.js +31 -15
- package/lib/routes.js +144 -6
- package/lib/scm-write.js +58 -1
- package/lib/scm.js +290 -0
- package/lib/tools/matrix.js +229 -0
- package/lib/tools/register-collab.js +27 -0
- package/lib/tools/register-ops.js +28 -0
- package/lib/tools/triage.js +213 -0
- package/package.json +1 -1
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
|
-
|
|
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 =
|
|
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
|
+
}
|