@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/LICENSE +21 -0
- package/README.md +91 -2
- package/client.js +567 -0
- package/cordis.patch.yml +8 -0
- package/icon.svg +12 -0
- package/index.js +197 -0
- package/lib/auto.js +115 -0
- package/lib/remote.js +62 -0
- package/lib/sync.js +645 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
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