@lisang233/pi-sync 0.2.1 → 0.2.3
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/README.md +6 -3
- package/package.json +4 -1
- package/src/extension.ts +20 -2
- package/src/git.ts +62 -1
- package/src/operations.ts +170 -14
- package/src/status.ts +1 -1
package/README.md
CHANGED
|
@@ -18,8 +18,10 @@ detection and merging are handled by git itself.
|
|
|
18
18
|
anchor file. A new machine's first `pull` adopts the remote cleanly; a
|
|
19
19
|
rewritten remote doesn't spuriously conflict.
|
|
20
20
|
- **Git-native three-way merge** — `/sync pull` merges the remote branch into
|
|
21
|
-
the local side. Divergent edits
|
|
22
|
-
|
|
21
|
+
the local side. Divergent edits keep local configuration protected while
|
|
22
|
+
holding conflicts in the mirror; `/sync mergetool` or `--ours`/`--theirs`
|
|
23
|
+
resolve them, and `/sync merge` validates (no markers, valid JSON) before
|
|
24
|
+
applying to local files.
|
|
23
25
|
- **Deliberate commands** — nothing moves your files without being asked;
|
|
24
26
|
`automatic` only observes at session start.
|
|
25
27
|
- **Sensible defaults** — one `pi-sync.json` points at a git remote and branch,
|
|
@@ -46,7 +48,8 @@ pi install npm:@lisang233/pi-sync
|
|
|
46
48
|
/sync status # config + sync state + next step (--diff for content)
|
|
47
49
|
/sync fetch # pull the remote tree without applying
|
|
48
50
|
/sync pull # fetch + merge (--force overwrites local)
|
|
49
|
-
/sync
|
|
51
|
+
/sync mergetool # launch git mergetool for conflicts ([tool])
|
|
52
|
+
/sync merge # complete merge (--abort, --ours, --theirs)
|
|
50
53
|
/sync push # publish the local tree (--force overwrites remote)
|
|
51
54
|
```
|
|
52
55
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lisang233/pi-sync",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Personal Pi extension that syncs Pi configuration through Git with background auto-sync and git-style fetch/merge conflict handling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
"README.md",
|
|
17
17
|
"LICENSE"
|
|
18
18
|
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
19
22
|
"pi": {
|
|
20
23
|
"extensions": [
|
|
21
24
|
"./src/index.ts"
|
package/src/extension.ts
CHANGED
|
@@ -15,7 +15,17 @@ interface BackgroundSync {
|
|
|
15
15
|
settled: Promise<void>;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
const COMMANDS = [
|
|
18
|
+
const COMMANDS = [
|
|
19
|
+
"init",
|
|
20
|
+
"status",
|
|
21
|
+
"push",
|
|
22
|
+
"pull",
|
|
23
|
+
"fetch",
|
|
24
|
+
"merge",
|
|
25
|
+
"mergetool",
|
|
26
|
+
"config",
|
|
27
|
+
"help",
|
|
28
|
+
] as const;
|
|
19
29
|
|
|
20
30
|
type Subcommand = (typeof COMMANDS)[number];
|
|
21
31
|
|
|
@@ -30,7 +40,8 @@ const USAGE = [
|
|
|
30
40
|
" status config + sync state + next step (--diff for content)",
|
|
31
41
|
" fetch fetch the remote tree without applying",
|
|
32
42
|
" pull fetch + merge (--force overwrites local)",
|
|
33
|
-
" merge
|
|
43
|
+
" merge complete merge (--abort, --ours, --theirs)",
|
|
44
|
+
" mergetool launch git mergetool for conflicts ([tool])",
|
|
34
45
|
" push publish local tree (--force overwrites remote)",
|
|
35
46
|
" help show this help",
|
|
36
47
|
].join("\n");
|
|
@@ -211,8 +222,15 @@ async function handleCommand(
|
|
|
211
222
|
case "merge":
|
|
212
223
|
await operations.merge(ctx, config, {
|
|
213
224
|
abort: restTokens.some((token) => token === "--abort"),
|
|
225
|
+
ours: restTokens.some((token) => token === "--ours"),
|
|
226
|
+
theirs: restTokens.some((token) => token === "--theirs"),
|
|
214
227
|
});
|
|
215
228
|
return;
|
|
229
|
+
case "mergetool": {
|
|
230
|
+
const tool = restTokens.find((token) => !token.startsWith("-"));
|
|
231
|
+
await operations.mergetool(ctx, config, tool);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
216
234
|
case "config":
|
|
217
235
|
await runConfigEditor(ctx.ui, config);
|
|
218
236
|
return;
|
package/src/git.ts
CHANGED
|
@@ -271,6 +271,25 @@ export async function readMergeBase(
|
|
|
271
271
|
return readFilesAt(base, options);
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/** Read the content of a file at a specific git ref (e.g. "HEAD:settings.json"). */
|
|
275
|
+
export async function readCommitFile(
|
|
276
|
+
ref: string,
|
|
277
|
+
relativePath: string,
|
|
278
|
+
options: GitRunOptions = {},
|
|
279
|
+
): Promise<string | undefined> {
|
|
280
|
+
const repo = gitCwd();
|
|
281
|
+
try {
|
|
282
|
+
const out = await runGit(["show", `${ref}:${relativePath}`], {
|
|
283
|
+
cwd: repo,
|
|
284
|
+
signal: options.signal,
|
|
285
|
+
timeoutMs: options.timeoutMs,
|
|
286
|
+
});
|
|
287
|
+
return out.stdout;
|
|
288
|
+
} catch {
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
274
293
|
/** Stage all changes (after grafting the local side) in the mirror work tree. */
|
|
275
294
|
export async function stageAll(options: GitRunOptions = {}): Promise<void> {
|
|
276
295
|
await runGit(["add", "-A"], { cwd: gitCwd(), signal: options.signal });
|
|
@@ -346,7 +365,49 @@ export async function completeMerge(
|
|
|
346
365
|
options: GitRunOptions = {},
|
|
347
366
|
): Promise<boolean> {
|
|
348
367
|
await stageAll(options);
|
|
349
|
-
|
|
368
|
+
const repo = gitCwd();
|
|
369
|
+
try {
|
|
370
|
+
await runGit(["commit", "--quiet", "-m", message], { cwd: repo, signal: options.signal });
|
|
371
|
+
return true;
|
|
372
|
+
} catch {
|
|
373
|
+
return !(await isMergeInProgress(options));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Check out our local version for all conflicted files in the mirror work tree. */
|
|
378
|
+
export async function checkoutOurs(options: GitRunOptions = {}): Promise<void> {
|
|
379
|
+
await runGit(["checkout", "--ours", "--", "."], { cwd: gitCwd(), signal: options.signal });
|
|
380
|
+
await stageAll(options);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Check out their remote version for all conflicted files in the mirror work tree. */
|
|
384
|
+
export async function checkoutTheirs(options: GitRunOptions = {}): Promise<void> {
|
|
385
|
+
await runGit(["checkout", "--theirs", "--", "."], { cwd: gitCwd(), signal: options.signal });
|
|
386
|
+
await stageAll(options);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Launch git mergetool interactively in the mirror work tree.
|
|
391
|
+
*/
|
|
392
|
+
export async function launchMergetool(tool?: string, options: GitRunOptions = {}): Promise<void> {
|
|
393
|
+
throwIfAborted(options.signal);
|
|
394
|
+
const repo = gitCwd();
|
|
395
|
+
const args = ["mergetool", "-y"];
|
|
396
|
+
if (tool && tool.trim().length > 0) {
|
|
397
|
+
args.push(`--tool=${tool.trim()}`);
|
|
398
|
+
}
|
|
399
|
+
await new Promise<void>((resolve, reject) => {
|
|
400
|
+
const child = spawn("git", args, {
|
|
401
|
+
cwd: repo,
|
|
402
|
+
stdio: process.stdin.isTTY ? "inherit" : ["ignore", "pipe", "pipe"],
|
|
403
|
+
windowsHide: false,
|
|
404
|
+
});
|
|
405
|
+
child.on("error", reject);
|
|
406
|
+
child.on("close", (code) => {
|
|
407
|
+
if (code === 0) resolve();
|
|
408
|
+
else reject(new Error(`git mergetool exited with status ${code ?? "unknown"}.`));
|
|
409
|
+
});
|
|
410
|
+
});
|
|
350
411
|
}
|
|
351
412
|
|
|
352
413
|
/** Abort an in-progress merge, restoring the work tree to the pre-merge state. */
|
package/src/operations.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import type {
|
|
2
4
|
ExtensionCommandContext,
|
|
3
5
|
ExtensionContext,
|
|
@@ -10,26 +12,34 @@ import { diffSummary, formatDiff } from "./diff.js";
|
|
|
10
12
|
import {
|
|
11
13
|
abortMerge,
|
|
12
14
|
aheadBehind,
|
|
15
|
+
checkoutOurs,
|
|
16
|
+
checkoutTheirs,
|
|
13
17
|
commitSync,
|
|
14
18
|
completeMerge,
|
|
15
19
|
ensureBranch,
|
|
16
20
|
ensureMirror,
|
|
17
21
|
fetchRemote,
|
|
18
22
|
isMergeInProgress,
|
|
23
|
+
launchMergetool,
|
|
19
24
|
listConflictedPaths,
|
|
20
25
|
mergeRemote,
|
|
21
26
|
pushBranch,
|
|
27
|
+
readCommitFile,
|
|
22
28
|
readMergeBase,
|
|
23
29
|
readRemoteFiles,
|
|
24
30
|
readRemoteRevision,
|
|
25
31
|
resetHard,
|
|
26
32
|
stageAll,
|
|
27
33
|
} from "./git.js";
|
|
34
|
+
import { agentDir } from "./paths.js";
|
|
28
35
|
import { classifyState, type StateClassify, syncIndicatorText } from "./status.js";
|
|
29
36
|
import {
|
|
37
|
+
agentTarget,
|
|
30
38
|
collectAgentFiles,
|
|
31
39
|
copyMirrorToAgent,
|
|
32
40
|
graftAgentIntoMirror,
|
|
41
|
+
mirrorProjectedFiles,
|
|
42
|
+
mirrorTarget,
|
|
33
43
|
readAgentContents,
|
|
34
44
|
} from "./tree.js";
|
|
35
45
|
|
|
@@ -116,7 +126,7 @@ function nextStepHint(info: StateClassify): string {
|
|
|
116
126
|
case "behind":
|
|
117
127
|
return "next: /sync pull to fetch and apply remote changes";
|
|
118
128
|
case "conflict":
|
|
119
|
-
return "next: /sync
|
|
129
|
+
return "next: /sync merge (--ours, --theirs, or mergetool), or /sync pull --force to overwrite local";
|
|
120
130
|
case "unknown":
|
|
121
131
|
return "next: /sync fetch to check the remote";
|
|
122
132
|
}
|
|
@@ -209,10 +219,8 @@ export async function pull(
|
|
|
209
219
|
await commitLocalSide({ signal: ctx.signal });
|
|
210
220
|
|
|
211
221
|
const conflicted = await mergeRemote(config, { signal: ctx.signal });
|
|
212
|
-
await copyMirrorToAgent(config);
|
|
213
|
-
await refreshIndicator(ctx, config);
|
|
214
|
-
|
|
215
222
|
if (conflicted) {
|
|
223
|
+
await refreshIndicator(ctx, config);
|
|
216
224
|
const conflicts = await listConflictedPaths({ signal: ctx.signal });
|
|
217
225
|
let remoteRevision = "";
|
|
218
226
|
try {
|
|
@@ -220,13 +228,15 @@ export async function pull(
|
|
|
220
228
|
} catch {
|
|
221
229
|
// ignore
|
|
222
230
|
}
|
|
223
|
-
const
|
|
231
|
+
const conflictList = conflicts.length > 0 ? ` (${conflicts.join(", ")})` : "";
|
|
232
|
+
const message = `Merge conflict in ${conflicts.length} file(s)${conflictList}. Local configuration in ${agentDir()} was preserved. Resolve with /sync merge --ours, /sync merge --theirs, or /sync mergetool. Then run /sync merge. (remote ${shortId(
|
|
224
233
|
remoteRevision,
|
|
225
234
|
)})`;
|
|
226
235
|
ctx.ui.notify(message, "warning");
|
|
227
|
-
return { pushed: false, pulled: false, merged:
|
|
236
|
+
return { pushed: false, pulled: false, merged: false, message, conflicts };
|
|
228
237
|
}
|
|
229
238
|
|
|
239
|
+
await copyMirrorToAgent(config);
|
|
230
240
|
await refreshIndicator(ctx, config);
|
|
231
241
|
const remote = await readRemoteFiles(config, { signal: ctx.signal });
|
|
232
242
|
const message = `Pulled ${remote.size} file(s) from ${config.branch}.`;
|
|
@@ -267,10 +277,78 @@ export async function fetch(
|
|
|
267
277
|
return { pushed: false, pulled: false, merged: false, message };
|
|
268
278
|
}
|
|
269
279
|
|
|
280
|
+
export interface FileValidationError {
|
|
281
|
+
path: string;
|
|
282
|
+
line?: number;
|
|
283
|
+
reason: string;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Validate that the mirror work tree has resolved all conflicts and produced
|
|
288
|
+
* valid JSON for any .json files before allowing completeMerge to commit.
|
|
289
|
+
*/
|
|
290
|
+
export async function validateResolvedMirror(
|
|
291
|
+
config: SyncConfig,
|
|
292
|
+
signal?: AbortSignal,
|
|
293
|
+
): Promise<FileValidationError[]> {
|
|
294
|
+
const errors: FileValidationError[] = [];
|
|
295
|
+
const unmerged = await listConflictedPaths({ signal });
|
|
296
|
+
for (const file of unmerged) {
|
|
297
|
+
errors.push({ path: file, reason: "unmerged git conflict" });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const projected = await mirrorProjectedFiles(config);
|
|
301
|
+
for (const relative of projected) {
|
|
302
|
+
const fullPath = mirrorTarget(relative);
|
|
303
|
+
let content: string;
|
|
304
|
+
try {
|
|
305
|
+
content = await fs.readFile(fullPath, "utf8");
|
|
306
|
+
} catch {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const lines = content.split(/\r?\n/u);
|
|
311
|
+
for (let i = 0; i < lines.length; i++) {
|
|
312
|
+
const line = lines[i];
|
|
313
|
+
if (
|
|
314
|
+
line.startsWith("<<<<<<<") ||
|
|
315
|
+
line.startsWith("=======") ||
|
|
316
|
+
line.startsWith(">>>>>>>") ||
|
|
317
|
+
line.startsWith("|||||||")
|
|
318
|
+
) {
|
|
319
|
+
errors.push({
|
|
320
|
+
path: relative,
|
|
321
|
+
line: i + 1,
|
|
322
|
+
reason: `unresolved conflict marker: ${line.slice(0, 12).trim()}`,
|
|
323
|
+
});
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (relative.toLowerCase().endsWith(".json")) {
|
|
329
|
+
try {
|
|
330
|
+
JSON.parse(content);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
errors.push({
|
|
333
|
+
path: relative,
|
|
334
|
+
reason: `invalid JSON syntax: ${(err as Error).message}`,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return errors;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export interface MergeOptions {
|
|
343
|
+
abort?: boolean;
|
|
344
|
+
ours?: boolean;
|
|
345
|
+
theirs?: boolean;
|
|
346
|
+
}
|
|
347
|
+
|
|
270
348
|
export async function merge(
|
|
271
349
|
ctx: CommandContext,
|
|
272
350
|
config: SyncConfig,
|
|
273
|
-
options:
|
|
351
|
+
options: MergeOptions = {},
|
|
274
352
|
): Promise<SyncResult> {
|
|
275
353
|
if (options.abort) {
|
|
276
354
|
if (!(await isMergeInProgress({ signal: ctx.signal }))) {
|
|
@@ -279,7 +357,6 @@ export async function merge(
|
|
|
279
357
|
return { pushed: false, pulled: false, merged: false, message };
|
|
280
358
|
}
|
|
281
359
|
await abortMerge({ signal: ctx.signal });
|
|
282
|
-
await copyMirrorToAgent(config);
|
|
283
360
|
await refreshIndicator(ctx, config);
|
|
284
361
|
const message = "Merge aborted; local files restored to the pre-merge state.";
|
|
285
362
|
ctx.ui.notify(message, "info");
|
|
@@ -293,21 +370,100 @@ export async function merge(
|
|
|
293
370
|
return { pushed: false, pulled: false, merged: false, message };
|
|
294
371
|
}
|
|
295
372
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
373
|
+
if (options.ours && options.theirs) {
|
|
374
|
+
const message = "Cannot specify both --ours and --theirs.";
|
|
375
|
+
ctx.ui.notify(message, "error");
|
|
376
|
+
return { pushed: false, pulled: false, merged: false, message };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (options.ours) {
|
|
380
|
+
await checkoutOurs({ signal: ctx.signal });
|
|
381
|
+
} else if (options.theirs) {
|
|
382
|
+
await checkoutTheirs({ signal: ctx.signal });
|
|
383
|
+
} else {
|
|
384
|
+
// Graft any user modifications from the agent directory into mirror if the
|
|
385
|
+
// user manually edited the agent file while resolving.
|
|
386
|
+
const localFiles = await collectAgentFiles(config);
|
|
387
|
+
for (const file of localFiles) {
|
|
388
|
+
try {
|
|
389
|
+
const agentContent = await fs.readFile(file.source, "utf8");
|
|
390
|
+
const headContent = await readCommitFile("HEAD", file.path, { signal: ctx.signal });
|
|
391
|
+
if (headContent === undefined || agentContent !== headContent) {
|
|
392
|
+
const target = mirrorTarget(file.path);
|
|
393
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
394
|
+
await fs.writeFile(target, agentContent, "utf8");
|
|
395
|
+
}
|
|
396
|
+
} catch {
|
|
397
|
+
// Unreadable file — ignore
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
await stageAll({ signal: ctx.signal });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Validate mirror files before completing merge.
|
|
404
|
+
const errors = await validateResolvedMirror(config, ctx.signal);
|
|
405
|
+
if (errors.length > 0) {
|
|
406
|
+
const errorDetails = errors
|
|
407
|
+
.slice(0, 5)
|
|
408
|
+
.map((e) => ` - ${agentTarget(e.path)}${e.line ? `:${e.line}` : ""}: ${e.reason}`)
|
|
409
|
+
.join("\n");
|
|
410
|
+
const message = [
|
|
411
|
+
`Cannot complete merge: ${errors.length} unresolved issue(s) detected.`,
|
|
412
|
+
errorDetails,
|
|
413
|
+
"",
|
|
414
|
+
`To resolve manually, edit files in: ${agentDir()}`,
|
|
415
|
+
"Or choose a resolution strategy:",
|
|
416
|
+
" /sync merge --ours (keep local configuration)",
|
|
417
|
+
" /sync merge --theirs (use remote configuration)",
|
|
418
|
+
" /sync mergetool (open 3-way merge tool)",
|
|
419
|
+
" /sync merge --abort (discard merge and restore)",
|
|
420
|
+
].join("\n");
|
|
421
|
+
ctx.ui.notify(message, "error");
|
|
422
|
+
return { pushed: false, pulled: false, merged: false, message };
|
|
423
|
+
}
|
|
424
|
+
|
|
301
425
|
const completed = await completeMerge(LOCAL_COMMIT_MESSAGE, { signal: ctx.signal });
|
|
302
426
|
await copyMirrorToAgent(config);
|
|
303
427
|
await refreshIndicator(ctx, config);
|
|
304
428
|
const message = completed
|
|
305
|
-
? "Merge completed. Run /sync push to publish."
|
|
429
|
+
? "Merge completed. Applied cleanly to local configuration. Run /sync push to publish."
|
|
306
430
|
: "Merge has no further changes to record. Run /sync push to publish.";
|
|
307
431
|
ctx.ui.notify(message, "info");
|
|
308
432
|
return { pushed: false, pulled: false, merged: true, message };
|
|
309
433
|
}
|
|
310
434
|
|
|
435
|
+
export async function mergetool(
|
|
436
|
+
ctx: CommandContext,
|
|
437
|
+
config: SyncConfig,
|
|
438
|
+
tool?: string,
|
|
439
|
+
): Promise<SyncResult> {
|
|
440
|
+
if (!(await isMergeInProgress({ signal: ctx.signal }))) {
|
|
441
|
+
const message =
|
|
442
|
+
"No merge in progress. Run /sync pull to merge remote changes into local files.";
|
|
443
|
+
ctx.ui.notify(message, "info");
|
|
444
|
+
return { pushed: false, pulled: false, merged: false, message };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
await launchMergetool(tool, { signal: ctx.signal });
|
|
449
|
+
await refreshIndicator(ctx, config);
|
|
450
|
+
const conflicts = await listConflictedPaths({ signal: ctx.signal });
|
|
451
|
+
if (conflicts.length === 0) {
|
|
452
|
+
ctx.ui.notify("Mergetool finished. Run /sync merge to validate and apply.", "info");
|
|
453
|
+
} else {
|
|
454
|
+
ctx.ui.notify(
|
|
455
|
+
`Mergetool finished, but ${conflicts.length} file(s) still conflicted.`,
|
|
456
|
+
"warning",
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
return { pushed: false, pulled: false, merged: false, message: "mergetool" };
|
|
460
|
+
} catch (error) {
|
|
461
|
+
const message = `Failed to run mergetool: ${error instanceof Error ? error.message : String(error)}`;
|
|
462
|
+
ctx.ui.notify(message, "error");
|
|
463
|
+
return { pushed: false, pulled: false, merged: false, message };
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
311
467
|
function describeChanges(summary: ReturnType<typeof diffSummary>): string {
|
|
312
468
|
const parts: string[] = [];
|
|
313
469
|
if (summary.added > 0) parts.push(`${summary.added} added`);
|
package/src/status.ts
CHANGED
|
@@ -135,6 +135,6 @@ export function syncIndicatorText(info: SyncStatusInfo): string {
|
|
|
135
135
|
case "behind":
|
|
136
136
|
return `sync: ${info.behind} behind — pull`;
|
|
137
137
|
case "conflict":
|
|
138
|
-
return `sync: conflict (${info.conflicts}) —
|
|
138
|
+
return `sync: conflict (${info.conflicts}) — /sync merge or mergetool`;
|
|
139
139
|
}
|
|
140
140
|
}
|