@code_tin/dsh-sync 0.0.0-stage → 0.2.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/sync.js ADDED
@@ -0,0 +1,645 @@
1
+ /**
2
+ * Git-backed mirror of the DeepSeek Harness session store.
3
+ *
4
+ * The mirror is a dedicated work tree under `$DSH_HOME/dsh-sync/repo`; the
5
+ * Harness session directory itself (`$DSH_HOME/sessions`) never contains a
6
+ * `.git` entry. Push copies local session logs into the mirror, commits, and
7
+ * pushes. Pull fast-forwards the mirror from the remote and copies only
8
+ * sessions that do not exist locally, so a device never loses a local log.
9
+ *
10
+ * Only append-only session log files (`session.*.jsonl.zstd`) are mirrored.
11
+ * Write locks, `.DS_Store`, and any other bookkeeping files stay local.
12
+ */
13
+ import { execFile } from 'node:child_process'
14
+ import { promises as fs } from 'node:fs'
15
+ import os from 'node:os'
16
+ import path from 'node:path'
17
+ import { promisify } from 'node:util'
18
+
19
+ const execFileAsync = promisify(execFile)
20
+
21
+ const SESSION_FILE_PATTERN = /^session\..+\.jsonl\.zstd$/
22
+ const GIT_TIMEOUT_MS = 120_000
23
+ const TEST_TIMEOUT_MS = 45_000
24
+ const MAX_BUFFER = 16 * 1024 * 1024
25
+ const BRANCH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/
26
+
27
+ /** Auto-sync defaults and bounds, in minutes. */
28
+ export const DEFAULT_AUTO_PUSH_INTERVAL_MINUTES = 10
29
+ export const MIN_AUTO_PUSH_INTERVAL_MINUTES = 1
30
+ export const MAX_AUTO_PUSH_INTERVAL_MINUTES = 1440
31
+
32
+ /** A failed operation carrying the raw git result when one exists. */
33
+ export class SyncFailure extends Error {
34
+ constructor(message, details) {
35
+ super(message)
36
+ this.name = 'SyncFailure'
37
+ this.details = details
38
+ }
39
+ }
40
+
41
+ function gitEnvironment() {
42
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C', LANG: 'C' }
43
+ // Never let a missing/passphrase-protected key block the host process on an
44
+ // interactive prompt; the failure is reported back to the caller instead.
45
+ if (!env.GIT_SSH_COMMAND) {
46
+ env.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes -o ConnectTimeout=15'
47
+ }
48
+ return env
49
+ }
50
+
51
+ function gitMessage(result) {
52
+ const text = (result.stderr || result.stdout || result.message || '').trim()
53
+ return text || `git exited with code ${String(result.code)}`
54
+ }
55
+
56
+ /**
57
+ * Run one git command.
58
+ *
59
+ * @param {string[]} args
60
+ * @param {{ cwd?: string, allowFailure?: boolean, timeout?: number }} [options]
61
+ * @returns {Promise<{ ok: boolean, stdout: string, stderr: string, code?: unknown, message?: string }>}
62
+ */
63
+ async function runGit(args, options = {}) {
64
+ const { cwd, allowFailure = false, timeout = GIT_TIMEOUT_MS } = options
65
+ try {
66
+ const { stdout, stderr } = await execFileAsync('git', args, {
67
+ cwd,
68
+ env: gitEnvironment(),
69
+ timeout,
70
+ maxBuffer: MAX_BUFFER,
71
+ windowsHide: true,
72
+ })
73
+ return { ok: true, stdout: stdout.trimEnd(), stderr: stderr.trimEnd() }
74
+ } catch (error) {
75
+ const result = {
76
+ ok: false,
77
+ stdout: typeof error?.stdout === 'string' ? error.stdout.trimEnd() : '',
78
+ stderr: typeof error?.stderr === 'string' ? error.stderr.trimEnd() : '',
79
+ code: error?.code,
80
+ message: error?.message ?? String(error),
81
+ }
82
+ if (allowFailure) return result
83
+ throw new SyncFailure(gitMessage(result), result)
84
+ }
85
+ }
86
+
87
+ async function pathExists(target) {
88
+ try {
89
+ await fs.access(target)
90
+ return true
91
+ } catch {
92
+ return false
93
+ }
94
+ }
95
+
96
+ function isRemoteRefMissing(result) {
97
+ return /couldn't find remote ref|could not find remote ref|unknown revision/i.test(
98
+ `${result.stderr}\n${result.stdout}`,
99
+ )
100
+ }
101
+
102
+ /** Reject a URL that git would misread or that spans multiple arguments. */
103
+ export function validateRemote(remote) {
104
+ if (typeof remote !== 'string' || remote.trim() === '') {
105
+ throw new SyncFailure('Set a git repository URL first.')
106
+ }
107
+ const value = remote.trim()
108
+ if (value.length > 2048) throw new SyncFailure('The git URL is too long.')
109
+ if (/\s/.test(value)) throw new SyncFailure('The git URL must not contain whitespace.')
110
+ if (value.startsWith('-')) throw new SyncFailure('The git URL must not start with "-".')
111
+ const looksLikeUrl = /^(https?|ssh|git|file):\/\//i.test(value)
112
+ const looksLikeScp = /^[^@/\s]+@[^:/\s]+:.+$/.test(value)
113
+ const looksLikePath = path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value)
114
+ if (!looksLikeUrl && !looksLikeScp && !looksLikePath) {
115
+ throw new SyncFailure('Use an https/ssh URL, user@host:path, or an absolute path.')
116
+ }
117
+ return value
118
+ }
119
+
120
+ /** Validate a branch name without relying on `git check-ref-format`. */
121
+ export function validateBranch(branch) {
122
+ const value = typeof branch === 'string' && branch.trim() !== '' ? branch.trim() : 'main'
123
+ if (!BRANCH_PATTERN.test(value)
124
+ || value.includes('..')
125
+ || value.endsWith('/')
126
+ || value.endsWith('.lock')
127
+ || value.includes('//')) {
128
+ throw new SyncFailure('Invalid branch name.')
129
+ }
130
+ return value
131
+ }
132
+
133
+ /**
134
+ * Validate the auto-sync interval (whole minutes).
135
+ *
136
+ * @param {unknown} value
137
+ * @returns {number}
138
+ */
139
+ export function normalizeAutoPushInterval(value) {
140
+ if (value === undefined || value === null || value === '') {
141
+ return DEFAULT_AUTO_PUSH_INTERVAL_MINUTES
142
+ }
143
+ const number = Number(value)
144
+ if (!Number.isInteger(number)
145
+ || number < MIN_AUTO_PUSH_INTERVAL_MINUTES
146
+ || number > MAX_AUTO_PUSH_INTERVAL_MINUTES) {
147
+ throw new SyncFailure(
148
+ `Auto-sync interval must be a whole number between ${MIN_AUTO_PUSH_INTERVAL_MINUTES} and ${MAX_AUTO_PUSH_INTERVAL_MINUTES} minutes.`,
149
+ )
150
+ }
151
+ return number
152
+ }
153
+
154
+ /** Interval for an already persisted settings file; bad values fall back. */
155
+ function storedAutoPushInterval(value) {
156
+ const number = Number(value)
157
+ return Number.isInteger(number)
158
+ && number >= MIN_AUTO_PUSH_INTERVAL_MINUTES
159
+ && number <= MAX_AUTO_PUSH_INTERVAL_MINUTES
160
+ ? number
161
+ : DEFAULT_AUTO_PUSH_INTERVAL_MINUTES
162
+ }
163
+
164
+ async function writeJsonAtomic(file, value) {
165
+ const temporary = `${file}.${process.pid}.${Date.now()}.tmp`
166
+ await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
167
+ await fs.rename(temporary, file)
168
+ }
169
+
170
+ async function readJson(file) {
171
+ try {
172
+ return JSON.parse(await fs.readFile(file, 'utf8'))
173
+ } catch {
174
+ return undefined
175
+ }
176
+ }
177
+
178
+ function summarize(files) {
179
+ let bytes = 0
180
+ const sessions = new Set()
181
+ for (const file of files) {
182
+ sessions.add(`${file.project}/${file.session}`)
183
+ bytes += file.bytes ?? 0
184
+ }
185
+ return { sessions: sessions.size, files: files.length, bytes }
186
+ }
187
+
188
+ /**
189
+ * One settings + mirror owner shared by the `/sync` command and the settings
190
+ * page. Mutating operations are serialized so two clicks cannot race on the
191
+ * same work tree.
192
+ */
193
+ export class SessionGitSync {
194
+ constructor({ home, logger, flush } = {}) {
195
+ this.home = home ?? (process.env.DSH_HOME || path.join(os.homedir(), '.dsh'))
196
+ this.sessionsRoot = path.join(this.home, 'sessions')
197
+ this.dataDir = path.join(this.home, 'dsh-sync')
198
+ this.repoDir = path.join(this.dataDir, 'repo')
199
+ this.settingsPath = path.join(this.dataDir, 'settings.json')
200
+ this.statePath = path.join(this.dataDir, 'state.json')
201
+ this.logger = logger ?? console
202
+ this.flush = typeof flush === 'function' ? flush : async () => {}
203
+ this.queue = Promise.resolve()
204
+ this.gitVersionPromise = undefined
205
+ }
206
+
207
+ /** Serialize mutating operations; reads stay independent. */
208
+ enqueue(task) {
209
+ const run = this.queue.then(task, task)
210
+ this.queue = run.then(() => undefined, () => undefined)
211
+ return run
212
+ }
213
+
214
+ async readSettings() {
215
+ const stored = await readJson(this.settingsPath)
216
+ return {
217
+ remote: typeof stored?.remote === 'string' ? stored.remote : '',
218
+ branch: typeof stored?.branch === 'string' && stored.branch ? stored.branch : 'main',
219
+ autoPush: stored?.autoPush === true,
220
+ autoPushIntervalMinutes: storedAutoPushInterval(stored?.autoPushIntervalMinutes),
221
+ }
222
+ }
223
+
224
+ async readState() {
225
+ const stored = await readJson(this.statePath)
226
+ return {
227
+ lastPushAt: typeof stored?.lastPushAt === 'string' ? stored.lastPushAt : null,
228
+ lastPushOk: typeof stored?.lastPushOk === 'boolean' ? stored.lastPushOk : null,
229
+ lastPushSummary: typeof stored?.lastPushSummary === 'string' ? stored.lastPushSummary : null,
230
+ lastPullAt: typeof stored?.lastPullAt === 'string' ? stored.lastPullAt : null,
231
+ lastPullOk: typeof stored?.lastPullOk === 'boolean' ? stored.lastPullOk : null,
232
+ lastPullSummary: typeof stored?.lastPullSummary === 'string' ? stored.lastPullSummary : null,
233
+ lastAutoPushAt: typeof stored?.lastAutoPushAt === 'string' ? stored.lastAutoPushAt : null,
234
+ lastAutoPushOk: typeof stored?.lastAutoPushOk === 'boolean' ? stored.lastAutoPushOk : null,
235
+ lastError: typeof stored?.lastError === 'string' ? stored.lastError : null,
236
+ }
237
+ }
238
+
239
+ async recordState(patch) {
240
+ const next = { ...(await this.readState()), ...patch }
241
+ await fs.mkdir(this.dataDir, { recursive: true })
242
+ await writeJsonAtomic(this.statePath, next)
243
+ }
244
+
245
+ /** Mark the outcome of one scheduled auto-sync run. */
246
+ async recordAutoPush(result) {
247
+ await this.recordState({
248
+ lastAutoPushAt: new Date().toISOString(),
249
+ lastAutoPushOk: result?.ok !== false,
250
+ })
251
+ }
252
+
253
+ async gitVersion() {
254
+ if (this.gitVersionPromise === undefined) {
255
+ this.gitVersionPromise = (async () => {
256
+ const result = await runGit(['--version'], { cwd: this.home, allowFailure: true, timeout: 10_000 })
257
+ if (!result.ok) return { available: false, version: null }
258
+ return { available: true, version: result.stdout.replace(/^git version\s*/i, '') || result.stdout }
259
+ })()
260
+ }
261
+ return this.gitVersionPromise
262
+ }
263
+
264
+ /** Session log files under one root, sorted for deterministic commits. */
265
+ async collectSessionFiles(root) {
266
+ const files = []
267
+ let projectEntries
268
+ try {
269
+ projectEntries = await fs.readdir(root, { withFileTypes: true })
270
+ } catch {
271
+ return files
272
+ }
273
+ for (const projectEntry of projectEntries.sort((a, b) => a.name.localeCompare(b.name))) {
274
+ if (!projectEntry.isDirectory() || projectEntry.name.startsWith('.')) continue
275
+ const projectDir = path.join(root, projectEntry.name)
276
+ let sessionEntries
277
+ try {
278
+ sessionEntries = await fs.readdir(projectDir, { withFileTypes: true })
279
+ } catch {
280
+ continue
281
+ }
282
+ for (const sessionEntry of sessionEntries.sort((a, b) => a.name.localeCompare(b.name))) {
283
+ if (!sessionEntry.isDirectory() || sessionEntry.name.startsWith('.')) continue
284
+ const sessionDir = path.join(projectDir, sessionEntry.name)
285
+ let fileEntries
286
+ try {
287
+ fileEntries = await fs.readdir(sessionDir, { withFileTypes: true })
288
+ } catch {
289
+ continue
290
+ }
291
+ for (const fileEntry of fileEntries) {
292
+ if (!fileEntry.isFile() || !SESSION_FILE_PATTERN.test(fileEntry.name)) continue
293
+ const abs = path.join(sessionDir, fileEntry.name)
294
+ let bytes = 0
295
+ try {
296
+ bytes = (await fs.stat(abs)).size
297
+ } catch {
298
+ continue
299
+ }
300
+ files.push({ project: projectEntry.name, session: sessionEntry.name, file: fileEntry.name, abs, bytes })
301
+ }
302
+ }
303
+ }
304
+ return files
305
+ }
306
+
307
+ async status() {
308
+ const [settings, state, localFiles, mirrorFiles, git] = await Promise.all([
309
+ this.readSettings(),
310
+ this.readState(),
311
+ this.collectSessionFiles(this.sessionsRoot),
312
+ this.collectSessionFiles(path.join(this.repoDir, 'sessions')),
313
+ this.gitVersion(),
314
+ ])
315
+ const initialized = await pathExists(path.join(this.repoDir, '.git'))
316
+ let lastCommit = null
317
+ if (initialized) {
318
+ const log = await runGit(['log', '-1', '--format=%cI'], { cwd: this.repoDir, allowFailure: true })
319
+ lastCommit = log.ok && log.stdout !== '' ? log.stdout : null
320
+ }
321
+ return {
322
+ configured: settings.remote !== '',
323
+ remote: settings.remote,
324
+ branch: settings.branch,
325
+ autoPush: settings.autoPush,
326
+ autoPushIntervalMinutes: settings.autoPushIntervalMinutes,
327
+ repoDir: this.repoDir,
328
+ sessionsRoot: this.sessionsRoot,
329
+ local: summarize(localFiles),
330
+ mirror: { initialized, ...summarize(mirrorFiles), lastCommit },
331
+ state,
332
+ git,
333
+ }
334
+ }
335
+
336
+ async save(remote, branch, autoPush, autoPushIntervalMinutes) {
337
+ const normalizedRemote = typeof remote === 'string' ? remote.trim() : ''
338
+ const normalizedBranch = validateBranch(branch)
339
+ // Omitted auto-sync fields keep their stored values, so an older client
340
+ // that only sends remote/branch cannot silently disable a running schedule.
341
+ const current = await this.readSettings()
342
+ const normalizedAutoPush = autoPush === undefined ? current.autoPush : autoPush === true
343
+ const normalizedInterval = autoPushIntervalMinutes === undefined
344
+ ? current.autoPushIntervalMinutes
345
+ : normalizeAutoPushInterval(autoPushIntervalMinutes)
346
+ if (normalizedRemote !== '') validateRemote(normalizedRemote)
347
+ return this.enqueue(async () => {
348
+ await fs.mkdir(this.dataDir, { recursive: true })
349
+ await writeJsonAtomic(this.settingsPath, {
350
+ remote: normalizedRemote,
351
+ branch: normalizedBranch,
352
+ autoPush: normalizedAutoPush,
353
+ autoPushIntervalMinutes: normalizedInterval,
354
+ })
355
+ return {
356
+ ok: true,
357
+ settings: {
358
+ remote: normalizedRemote,
359
+ branch: normalizedBranch,
360
+ autoPush: normalizedAutoPush,
361
+ autoPushIntervalMinutes: normalizedInterval,
362
+ },
363
+ }
364
+ })
365
+ }
366
+
367
+ async ensureRepository(settings) {
368
+ await fs.mkdir(this.repoDir, { recursive: true })
369
+ const initialized = await pathExists(path.join(this.repoDir, '.git'))
370
+ if (!initialized) {
371
+ await runGit(['init'], { cwd: this.repoDir })
372
+ // Keep the unborn branch aligned with the configured branch so the first
373
+ // commit lands on the right name.
374
+ await runGit(['symbolic-ref', 'HEAD', `refs/heads/${settings.branch}`], {
375
+ cwd: this.repoDir,
376
+ allowFailure: true,
377
+ })
378
+ }
379
+ await runGit(['config', 'user.name', 'DSH Session Sync'], { cwd: this.repoDir, allowFailure: true })
380
+ await runGit(['config', 'user.email', 'dsh-sync@localhost'], { cwd: this.repoDir, allowFailure: true })
381
+
382
+ const existing = await runGit(['remote', 'get-url', 'origin'], { cwd: this.repoDir, allowFailure: true })
383
+ if (!existing.ok) {
384
+ await runGit(['remote', 'add', 'origin', settings.remote], { cwd: this.repoDir })
385
+ } else if (existing.stdout.trim() !== settings.remote) {
386
+ await runGit(['remote', 'set-url', 'origin', settings.remote], { cwd: this.repoDir })
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Fetch the configured branch.
392
+ *
393
+ * @returns {Promise<{ remoteHasBranch: boolean }>}
394
+ */
395
+ async fetchRemoteBranch(settings) {
396
+ const fetched = await runGit(['fetch', '--prune', 'origin', settings.branch], {
397
+ cwd: this.repoDir,
398
+ allowFailure: true,
399
+ })
400
+ if (!fetched.ok) {
401
+ if (isRemoteRefMissing(fetched)) return { remoteHasBranch: false }
402
+ throw new SyncFailure(gitMessage(fetched), fetched)
403
+ }
404
+ const ref = await runGit(['rev-parse', '--verify', `refs/remotes/origin/${settings.branch}`], {
405
+ cwd: this.repoDir,
406
+ allowFailure: true,
407
+ })
408
+ return { remoteHasBranch: ref.ok }
409
+ }
410
+
411
+ async writeMirrorFiles(files) {
412
+ for (const file of files) {
413
+ const destination = path.join(this.repoDir, 'sessions', file.project, file.session, file.file)
414
+ await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 })
415
+ await fs.copyFile(file.abs, destination)
416
+ await fs.chmod(destination, 0o600).catch(() => {})
417
+ }
418
+ }
419
+
420
+ async writeMirrorDocs() {
421
+ const readme = path.join(this.repoDir, 'README.md')
422
+ if (!(await pathExists(readme))) {
423
+ await fs.writeFile(
424
+ readme,
425
+ [
426
+ '# DSH session mirror',
427
+ '',
428
+ 'This repository is written by the `dsh-sync` plugin for DeepSeek Harness.',
429
+ 'It mirrors `$DSH_HOME/sessions` so sessions can move between devices.',
430
+ '',
431
+ '- `sync push` copies local session logs here and pushes the branch.',
432
+ '- `sync pull` fast-forwards this mirror and copies sessions that are not',
433
+ ' present on the local device (existing local sessions are never replaced).',
434
+ '',
435
+ ].join('\n'),
436
+ 'utf8',
437
+ )
438
+ }
439
+ const ignore = path.join(this.repoDir, '.gitignore')
440
+ if (!(await pathExists(ignore))) {
441
+ await fs.writeFile(ignore, '*.lock\n.DS_Store\n', 'utf8')
442
+ }
443
+ }
444
+
445
+ async flushSessions() {
446
+ try {
447
+ await this.flush()
448
+ return null
449
+ } catch (error) {
450
+ const message = error instanceof Error ? error.message : String(error)
451
+ this.logger?.warn?.(`dsh-sync: session flush before push failed: ${message}`)
452
+ return message
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Copy local session logs into the mirror, commit, and push.
458
+ *
459
+ * A rejected non-fast-forward push is retried once against a freshly fetched
460
+ * remote; local files win for session logs that exist on both sides.
461
+ */
462
+ async push() {
463
+ return this.enqueue(async () => {
464
+ const settings = await this.readSettings()
465
+ if (settings.remote === '') {
466
+ return { ok: false, code: 'not-configured', message: 'Set a git repository URL first.' }
467
+ }
468
+ const flushWarning = await this.flushSessions()
469
+ await this.ensureRepository(settings)
470
+ const localFiles = await this.collectSessionFiles(this.sessionsRoot)
471
+ const local = summarize(localFiles)
472
+ const stamp = new Date().toISOString()
473
+
474
+ for (let attempt = 0; attempt < 2; attempt += 1) {
475
+ const { remoteHasBranch } = await this.fetchRemoteBranch(settings)
476
+
477
+ await runGit(['reset', '--hard'], { cwd: this.repoDir, allowFailure: true })
478
+ await runGit(['clean', '-fd'], { cwd: this.repoDir })
479
+ if (remoteHasBranch) {
480
+ await runGit(['checkout', '-B', settings.branch, `origin/${settings.branch}`], { cwd: this.repoDir })
481
+ } else {
482
+ await runGit(['checkout', '-B', settings.branch], { cwd: this.repoDir })
483
+ }
484
+
485
+ await this.writeMirrorFiles(localFiles)
486
+ await this.writeMirrorDocs()
487
+ await runGit(['add', '-A'], { cwd: this.repoDir })
488
+
489
+ const porcelain = await runGit(['status', '--porcelain'], { cwd: this.repoDir })
490
+ let changed = porcelain.stdout.trim() !== ''
491
+ if (changed) {
492
+ await runGit(
493
+ ['commit', '-m', `sync: ${os.hostname()} ${stamp}`],
494
+ { cwd: this.repoDir },
495
+ )
496
+ } else if (!remoteHasBranch) {
497
+ // An empty session store still needs one commit so the branch exists.
498
+ await runGit(
499
+ ['commit', '--allow-empty', '-m', `init: ${os.hostname()} ${stamp}`],
500
+ { cwd: this.repoDir },
501
+ )
502
+ changed = true
503
+ }
504
+
505
+ if (remoteHasBranch && !changed) {
506
+ const summary = `already up to date (${local.sessions} sessions)`
507
+ await this.recordState({
508
+ lastPushAt: new Date().toISOString(),
509
+ lastPushOk: true,
510
+ lastPushSummary: summary,
511
+ lastError: null,
512
+ })
513
+ return {
514
+ ok: true,
515
+ pushed: false,
516
+ changed: false,
517
+ sessions: local.sessions,
518
+ files: local.files,
519
+ warning: flushWarning,
520
+ }
521
+ }
522
+
523
+ const pushed = await runGit(['push', 'origin', `HEAD:${settings.branch}`], {
524
+ cwd: this.repoDir,
525
+ allowFailure: true,
526
+ })
527
+ if (pushed.ok) {
528
+ const summary = `pushed ${local.sessions} sessions (${local.files} files)`
529
+ await this.recordState({
530
+ lastPushAt: new Date().toISOString(),
531
+ lastPushOk: true,
532
+ lastPushSummary: summary,
533
+ lastError: null,
534
+ })
535
+ return {
536
+ ok: true,
537
+ pushed: true,
538
+ changed,
539
+ sessions: local.sessions,
540
+ files: local.files,
541
+ warning: flushWarning,
542
+ }
543
+ }
544
+
545
+ const race = /non-fast-forward|fetch first|\[rejected\]|remote rejected/i.test(
546
+ `${pushed.stderr}\n${pushed.stdout}`,
547
+ )
548
+ if (attempt === 0 && race) continue
549
+ const message = gitMessage(pushed)
550
+ await this.recordState({
551
+ lastPushAt: new Date().toISOString(),
552
+ lastPushOk: false,
553
+ lastPushSummary: null,
554
+ lastError: message,
555
+ })
556
+ throw new SyncFailure(message, pushed)
557
+ }
558
+
559
+ throw new SyncFailure('git push failed after retrying.')
560
+ })
561
+ }
562
+
563
+ /**
564
+ * Fast-forward the mirror and copy sessions that do not exist locally.
565
+ * Existing local logs always win; the skipped count is reported so the user
566
+ * knows a manual merge may be needed.
567
+ */
568
+ async pull() {
569
+ return this.enqueue(async () => {
570
+ const settings = await this.readSettings()
571
+ if (settings.remote === '') {
572
+ return { ok: false, code: 'not-configured', message: 'Set a git repository URL first.' }
573
+ }
574
+ await this.ensureRepository(settings)
575
+ const { remoteHasBranch } = await this.fetchRemoteBranch(settings)
576
+ if (!remoteHasBranch) {
577
+ return {
578
+ ok: false,
579
+ code: 'empty-remote',
580
+ message: 'The remote repository has no session branch yet. Run push on a device first.',
581
+ }
582
+ }
583
+
584
+ await runGit(['reset', '--hard'], { cwd: this.repoDir, allowFailure: true })
585
+ await runGit(['clean', '-fd'], { cwd: this.repoDir })
586
+ await runGit(['checkout', '-B', settings.branch, `origin/${settings.branch}`], { cwd: this.repoDir })
587
+ await runGit(['reset', '--hard', `origin/${settings.branch}`], { cwd: this.repoDir })
588
+
589
+ const mirrorFiles = await this.collectSessionFiles(path.join(this.repoDir, 'sessions'))
590
+ const addedSessions = new Set()
591
+ let addedFiles = 0
592
+ let skippedFiles = 0
593
+
594
+ for (const file of mirrorFiles) {
595
+ const destination = path.join(this.sessionsRoot, file.project, file.session, file.file)
596
+ if (await pathExists(destination)) {
597
+ skippedFiles += 1
598
+ continue
599
+ }
600
+ await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 })
601
+ await fs.copyFile(file.abs, destination)
602
+ await fs.chmod(destination, 0o600).catch(() => {})
603
+ addedFiles += 1
604
+ addedSessions.add(`${file.project}/${file.session}`)
605
+ }
606
+
607
+ const summary = `pulled ${addedSessions.size} sessions (${addedFiles} files), skipped ${skippedFiles} existing`
608
+ await this.recordState({
609
+ lastPullAt: new Date().toISOString(),
610
+ lastPullOk: true,
611
+ lastPullSummary: summary,
612
+ lastError: null,
613
+ })
614
+ return {
615
+ ok: true,
616
+ addedSessions: addedSessions.size,
617
+ addedFiles,
618
+ skippedFiles,
619
+ }
620
+ })
621
+ }
622
+
623
+ /** Probe a remote without saving it. */
624
+ async test(remote) {
625
+ let normalized
626
+ try {
627
+ normalized = validateRemote(remote)
628
+ } catch (error) {
629
+ return { ok: false, message: error instanceof Error ? error.message : String(error) }
630
+ }
631
+ const result = await runGit(['ls-remote', '--heads', normalized], {
632
+ cwd: this.home,
633
+ allowFailure: true,
634
+ timeout: TEST_TIMEOUT_MS,
635
+ })
636
+ if (!result.ok) return { ok: false, message: gitMessage(result) }
637
+ const branches = result.stdout
638
+ .split('\n')
639
+ .map((line) => line.trim().split(/\s+/)[1])
640
+ .filter((ref) => typeof ref === 'string' && ref.startsWith('refs/heads/'))
641
+ .map((ref) => ref.slice('refs/heads/'.length))
642
+ .filter((name) => name !== '')
643
+ return { ok: true, branches }
644
+ }
645
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Session Git Sync",
4
+ "description": "Mirror DSH sessions into a git repository and share them across devices: /sync status, /sync pull and /sync push, plus a Settings page for the repository URL and timed auto-push."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "会话 Git 同步",
4
+ "description": "把 DSH 会话镜像到 Git 仓库,在多个设备之间共享:/sync、/sync pull、/sync push,并在设置页面中配置仓库地址与定时自动推送。"
5
+ }
6
+ }