@lisang233/pi-sync 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LosLisang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # 🔄 pi-sync
2
+
3
+ A personal Pi extension that syncs Pi configuration through **Git** with git-style `fetch`/`pull`/`merge`/`push`, a single config file, and conflict resolution inside the Pi UI.
4
+
5
+ > **Experimental.** This is a from-scratch rewrite of the classic pi-sync flow: one config file, one git remote, content-level diffs, three-way merges, and deliberate commands that never move your files without asking.
6
+
7
+ ## ✨ Features
8
+
9
+ - **Single config, direct connection** — one `pi-sync.json` points at one git remote and branch. No two-level setup/connection model.
10
+ - **Observe-only automatic** — `automatic: true` fetches at session start and shows a persistent status-bar indicator (up-to-date / ahead / behind / conflict). It never pushes, pulls or merges on its own.
11
+ - **Git-style pull** — `/sync pull` fetches and merges. Clean changes apply directly; divergence without a flag writes nothing; `--force` overwrites local files; `--merge` opens in-UI conflict resolution.
12
+ - **In-UI conflict resolution** — divergent edits are parsed into conflict blocks and resolved one at a time (keep local / keep remote / type a replacement). Progress persists across sessions; `/sync merge` resumes, `/sync merge --abort` restores the pre-merge backup.
13
+ - **JSON-aware merging** — single-line `settings.json`/`keybindings.json`/`models.json` merge field-wise, so formatting or an unrelated field change doesn't conflict.
14
+ - **Closed-loop status** — `/sync status` shows the effective config, the sync state, any in-progress merge, and the exact next step; `/sync status --diff` shows the content-level diff (JSON pretty-printed, secrets masked, bounded).
15
+ - **No stored credentials** — Git uses your existing SSH/credential-helper setup; the config file never holds tokens.
16
+
17
+ ## 📦 Install
18
+
19
+ ```bash
20
+ pi install -l ~/Documents/code/pi-sync
21
+ ```
22
+
23
+ or from npm once published:
24
+
25
+ ```bash
26
+ pi install npm:@lisang233/pi-sync
27
+ ```
28
+
29
+ ## 🚀 Quick start
30
+
31
+ ```bash
32
+ /sync init # first-run wizard: remote, branch, include, automatic
33
+ /sync config # view and edit the config at any time
34
+ /sync status # config + sync state + next step (--diff for content)
35
+ /sync fetch # pull the remote snapshot without applying
36
+ /sync pull # fetch + merge (--force overwrites, --merge resolves)
37
+ /sync merge # continue an in-progress merge (--abort discards)
38
+ /sync push # publish local snapshot (--force overwrites remote)
39
+ ```
40
+
41
+ ## ⚙️ Settings
42
+
43
+ The config lives at `~/.pi/agent/pi-sync.json` (agent dir honors `PI_CODING_AGENT_DIR`):
44
+
45
+ ```json
46
+ {
47
+ "remote": "git@github.com:you/pi-sync.git",
48
+ "branch": "pi-sync",
49
+ "include": [
50
+ "settings.json",
51
+ "keybindings.json",
52
+ "models.json",
53
+ "skills",
54
+ "prompts",
55
+ "themes",
56
+ "extensions",
57
+ "extension-settings"
58
+ ],
59
+ "automatic": true
60
+ }
61
+ ```
62
+
63
+ - `include` selects which agent-dir paths sync. The defaults are `settings.json`, `keybindings.json`, `models.json`, `skills`, `prompts`, `themes`, `extensions`, and `extension-settings`. Sessions and `AGENTS.md` are intentionally not included by default. Edit it any time with `/sync config`.
64
+ - `automatic` only controls whether a non-destructive fetch runs at session start; the status-bar indicator always reflects the last known state.
65
+ - State lives under `<agent-dir>/pi-sync/` (a mirror git repo, `state.json`, `merge-session/`, and backups).
66
+
67
+ ## 🗂️ Package layout
68
+
69
+ ```text
70
+ src/
71
+ index.ts extension entrypoint
72
+ extension.ts lifecycle, /sync command routes, session-start fetch
73
+ config.ts single-file config load/validate/save
74
+ config-ui.ts interactive config editor (view + edit fields)
75
+ paths.ts agent-dir paths and include normalization
76
+ git.ts git subprocess backend (fetch/push/show/merge-file)
77
+ snapshot.ts scan include paths into a hashed snapshot
78
+ state.ts last-applied snapshot + remote revision
79
+ status.ts sync-state derivation and indicator text
80
+ merge-session.ts persistent conflict-resolution session store
81
+ conflict.ts diff3 marker parsing and resolved-text splicing
82
+ resolve.ts structured block-by-block conflict resolver
83
+ diff.ts content-level diff with JSON formatting and secret masking
84
+ merge.ts three-way merge (JSON field-wise + git merge-file fallback)
85
+ operations.ts status/push/pull/fetch/merge
86
+ wizard.ts first-run setup wizard
87
+ test/ vitest unit + local-bare-repo end-to-end tests
88
+ ```
89
+
90
+ ## 🔎 Keywords
91
+
92
+ `pi-package` `pi-extension` `pi` `sync` `git`
93
+
94
+ ## 📄 License
95
+
96
+ MIT
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@lisang233/pi-sync",
3
+ "version": "0.1.0",
4
+ "description": "Personal Pi extension that syncs Pi configuration through Git with background auto-sync and git-style fetch/merge conflict handling.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi-extension",
10
+ "pi",
11
+ "sync",
12
+ "git"
13
+ ],
14
+ "files": [
15
+ "src",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "pi": {
20
+ "extensions": [
21
+ "./src/index.ts"
22
+ ]
23
+ },
24
+ "piExtension": {
25
+ "lifecycle": "experimental"
26
+ },
27
+ "scripts": {
28
+ "check": "biome check . && tsc --noEmit",
29
+ "format": "biome check --write .",
30
+ "test": "vitest run",
31
+ "typecheck": "tsc --noEmit"
32
+ },
33
+ "dependencies": {
34
+ "diff": "8.0.4"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-coding-agent": "*",
38
+ "@earendil-works/pi-tui": "*"
39
+ },
40
+ "devDependencies": {
41
+ "@biomejs/biome": "2.5.7",
42
+ "@earendil-works/pi-coding-agent": "0.84.1",
43
+ "@earendil-works/pi-tui": "0.84.1",
44
+ "@types/node": "^24.0.0",
45
+ "typescript": "7.0.2",
46
+ "vitest": "4.1.10"
47
+ }
48
+ }
@@ -0,0 +1,138 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import { parseConfig, type SyncConfig, saveConfig } from "./config.js";
3
+ import { DEFAULT_INCLUDE, normalizeInclude } from "./paths.js";
4
+
5
+ export function formatConfig(config: SyncConfig): string {
6
+ return [
7
+ `remote: ${config.remote}`,
8
+ `branch: ${config.branch}`,
9
+ `automatic: ${config.automatic ? "enabled" : "disabled"}`,
10
+ `included (${config.include.length}): ${config.include.join(", ") || "none"}`,
11
+ ].join("\n");
12
+ }
13
+
14
+ /**
15
+ * Interactive config editor: show the current config, then loop over the
16
+ * fields (remote/branch/include/automatic) letting the user change one at a
17
+ * time. Each change is validated immediately; everything is saved atomically
18
+ * when the user picks "done". Escaping discards pending changes.
19
+ */
20
+ export async function runConfigEditor(ui: ExtensionUIContext, config: SyncConfig): Promise<void> {
21
+ let current = { ...config };
22
+ let dirty = false;
23
+ ui.notify(formatConfig(current), "info");
24
+ for (;;) {
25
+ const field = await ui.select("pi-sync config — edit a field", [
26
+ "remote",
27
+ "branch",
28
+ "include",
29
+ "automatic",
30
+ "done",
31
+ ]);
32
+ if (field === undefined) {
33
+ if (dirty) ui.notify("Config changes discarded.", "info");
34
+ return;
35
+ }
36
+ if (field === "done") break;
37
+ try {
38
+ if (field === "include") {
39
+ const result = await editInclude(ui, current);
40
+ current = result.config;
41
+ dirty = dirty || result.changed;
42
+ continue;
43
+ }
44
+ let candidate: SyncConfig | undefined;
45
+ if (field === "remote") {
46
+ const value = await ui.input("Git remote URL", current.remote);
47
+ if (value === undefined) continue;
48
+ candidate = parseConfig({ ...current, remote: value.trim() });
49
+ } else if (field === "branch") {
50
+ const value = await ui.input("Remote branch", current.branch);
51
+ if (value === undefined) continue;
52
+ candidate = parseConfig({ ...current, branch: value.trim() || undefined });
53
+ } else if (field === "automatic") {
54
+ const value = await ui.confirm(
55
+ "Enable automatic sync?",
56
+ `Fetch at session start and refresh the indicator.\nCurrently: ${current.automatic ? "enabled" : "disabled"}`,
57
+ );
58
+ candidate = parseConfig({ ...current, automatic: value });
59
+ }
60
+ if (candidate) {
61
+ current = candidate;
62
+ dirty = true;
63
+ const shown =
64
+ field === "automatic"
65
+ ? current.automatic
66
+ ? "enabled"
67
+ : "disabled"
68
+ : field === "branch"
69
+ ? current.branch
70
+ : current.remote;
71
+ ui.notify(`${field}: ${shown}`, "info");
72
+ }
73
+ } catch (error) {
74
+ ui.notify(errorMessage(error), "warning");
75
+ }
76
+ }
77
+ await saveConfig(current);
78
+ ui.notify(`Config saved.\n${formatConfig(current)}`, "info");
79
+ }
80
+
81
+ async function editInclude(
82
+ ui: ExtensionUIContext,
83
+ config: SyncConfig,
84
+ ): Promise<{ config: SyncConfig; changed: boolean }> {
85
+ let current = { ...config };
86
+ let changed = false;
87
+ for (;;) {
88
+ const action = await ui.select(
89
+ `include (${current.include.length}): ${current.include.join(", ") || "none"}`,
90
+ ["+ add a path", "- remove a path", "✔ done"],
91
+ );
92
+ if (action === undefined || action === "✔ done") return { config: current, changed };
93
+ if (action === "+ add a path") {
94
+ const candidates = [
95
+ ...DEFAULT_INCLUDE.filter((name) => !current.include.includes(name)),
96
+ "type a path…",
97
+ ];
98
+ const picked = await ui.select("Add include path", candidates);
99
+ if (picked === undefined) continue;
100
+ let entry = picked;
101
+ if (picked === "type a path…") {
102
+ const typed = await ui.input("Agent-relative path to include (e.g. AGENTS.md)");
103
+ if (typed === undefined) continue;
104
+ entry = typed.trim();
105
+ }
106
+ if (entry.length === 0) continue;
107
+ try {
108
+ current = addInclude(current, entry);
109
+ changed = true;
110
+ ui.notify(`+ ${entry}`, "info");
111
+ } catch (error) {
112
+ ui.notify(errorMessage(error), "warning");
113
+ }
114
+ } else {
115
+ if (current.include.length === 0) {
116
+ ui.notify("Nothing to remove.", "info");
117
+ continue;
118
+ }
119
+ const picked = await ui.select("Remove include path", current.include);
120
+ if (picked === undefined) continue;
121
+ current = {
122
+ ...current,
123
+ include: normalizeInclude(current.include.filter((name) => name !== picked)),
124
+ };
125
+ changed = true;
126
+ ui.notify(`− ${picked}`, "info");
127
+ }
128
+ }
129
+ }
130
+
131
+ function errorMessage(error: unknown): string {
132
+ return error instanceof Error ? error.message : String(error);
133
+ }
134
+
135
+ /** Validate an additional include entry (throws on unsafe or duplicate). */
136
+ function addInclude(config: SyncConfig, entry: string): SyncConfig {
137
+ return { ...config, include: normalizeInclude([...config.include, entry]) };
138
+ }
package/src/config.ts ADDED
@@ -0,0 +1,111 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { agentDir, normalizeInclude } from "./paths.js";
4
+
5
+ export const CONFIG_FILE_NAME = "pi-sync.json";
6
+
7
+ export interface SyncConfig {
8
+ remote: string;
9
+ branch: string;
10
+ include: string[];
11
+ automatic: boolean;
12
+ }
13
+
14
+ export const DEFAULT_CONFIG: SyncConfig = {
15
+ remote: "",
16
+ branch: "pi-sync",
17
+ include: [
18
+ "settings.json",
19
+ "keybindings.json",
20
+ "models.json",
21
+ "skills",
22
+ "prompts",
23
+ "themes",
24
+ "extensions",
25
+ "extension-settings",
26
+ ],
27
+ automatic: true,
28
+ };
29
+
30
+ export const SNAPSHOT_FILE = "snapshot.json";
31
+ export const BACKUP_DIR = "backups";
32
+
33
+ export function configPath(): string {
34
+ return path.join(agentDir(), CONFIG_FILE_NAME);
35
+ }
36
+
37
+ export function stateDir(): string {
38
+ return path.join(agentDir(), "pi-sync");
39
+ }
40
+
41
+ export function mirrorRepoDir(): string {
42
+ return path.join(stateDir(), "mirror");
43
+ }
44
+
45
+ export function snapshotFilePath(): string {
46
+ return path.join(mirrorRepoDir(), "pi-sync", SNAPSHOT_FILE);
47
+ }
48
+
49
+ export function backupRootDir(): string {
50
+ return path.join(stateDir(), BACKUP_DIR);
51
+ }
52
+
53
+ export async function loadConfig(): Promise<SyncConfig> {
54
+ let text: string;
55
+ try {
56
+ text = await fs.readFile(configPath(), "utf8");
57
+ } catch (error) {
58
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
59
+ return { ...DEFAULT_CONFIG };
60
+ }
61
+ throw error;
62
+ }
63
+ let parsed: unknown;
64
+ try {
65
+ parsed = JSON.parse(text);
66
+ } catch (error) {
67
+ throw new Error(`pi-sync config is not valid JSON: ${configPath()}`, { cause: error });
68
+ }
69
+ return parseConfig(parsed);
70
+ }
71
+
72
+ export function parseConfig(value: unknown): SyncConfig {
73
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
74
+ throw new Error("Invalid pi-sync config: expected an object.");
75
+ }
76
+ const record = value as Record<string, unknown>;
77
+ const remote = record.remote;
78
+ if (typeof remote !== "string" || remote.trim().length === 0) {
79
+ throw new Error("Invalid pi-sync config: remote must be a non-empty git URL.");
80
+ }
81
+ const branch = record.branch;
82
+ if (branch !== undefined && typeof branch !== "string") {
83
+ throw new Error("Invalid pi-sync config: branch must be a string.");
84
+ }
85
+ if (typeof branch === "string" && (!/^[\w./-]+$/u.test(branch) || branch.includes(".."))) {
86
+ throw new Error("Invalid pi-sync config: branch contains unsafe characters.");
87
+ }
88
+ const include =
89
+ record.include === undefined ? [...DEFAULT_CONFIG.include] : normalizeInclude(record.include);
90
+ const automatic =
91
+ record.automatic === undefined ? DEFAULT_CONFIG.automatic : parseBoolean(record.automatic);
92
+ return {
93
+ remote: remote.trim(),
94
+ branch: (branch ?? DEFAULT_CONFIG.branch).trim(),
95
+ include,
96
+ automatic,
97
+ };
98
+ }
99
+
100
+ export async function saveConfig(config: SyncConfig): Promise<void> {
101
+ await fs.mkdir(agentDir(), { recursive: true });
102
+ const serialized = `${JSON.stringify(config, null, "\t")}\n`;
103
+ await fs.writeFile(configPath(), serialized, { mode: 0o600 });
104
+ }
105
+
106
+ function parseBoolean(value: unknown): boolean {
107
+ if (typeof value === "boolean") return value;
108
+ if (value === "true" || value === 1) return true;
109
+ if (value === "false" || value === 0) return false;
110
+ throw new Error("Invalid pi-sync config: automatic must be a boolean.");
111
+ }
@@ -0,0 +1,90 @@
1
+ import type { ConflictBlock } from "./merge-session.js";
2
+
3
+ /** A parsed diff3 conflict block with its line span in the merged text. */
4
+ export interface ParsedConflictBlock {
5
+ block: ConflictBlock;
6
+ /** Line index of the "<<<<<<<" marker. */
7
+ startLine: number;
8
+ /** Line index of the ">>>>>>>" marker (inclusive). */
9
+ endLine: number;
10
+ }
11
+
12
+ const MARKER_OPEN = "<<<<<<<";
13
+ const MARKER_BASE = "|||||||";
14
+ const MARKER_SEP = "=======";
15
+ const MARKER_CLOSE = ">>>>>>>";
16
+
17
+ /**
18
+ * Parse diff3 conflict markers (as written by `git merge-file --diff3`) into
19
+ * independent blocks. Malformed blocks are skipped rather than failing the
20
+ * whole file. Marker labels (anything after the marker prefix) are ignored.
21
+ */
22
+ export function parseConflictBlocks(mergedText: string): ParsedConflictBlock[] {
23
+ const lines = mergedText.split(/\r?\n/u);
24
+ const blocks: ParsedConflictBlock[] = [];
25
+ let index = 0;
26
+ while (index < lines.length) {
27
+ if (lines[index].startsWith(MARKER_OPEN)) {
28
+ const startLine = index;
29
+ const localEnd = findMarker(lines, index + 1, [MARKER_BASE, MARKER_SEP, MARKER_CLOSE]);
30
+ if (localEnd === undefined) break;
31
+ const baseStart = localEnd;
32
+ const baseEnd = findMarker(lines, baseStart + 1, [MARKER_SEP, MARKER_CLOSE]);
33
+ if (baseEnd === undefined) break;
34
+ const sep = baseEnd;
35
+ const remoteEnd = findMarker(lines, sep + 1, [MARKER_CLOSE]);
36
+ if (remoteEnd === undefined) break;
37
+ blocks.push({
38
+ block: {
39
+ local: lines.slice(startLine + 1, localEnd).join("\n"),
40
+ base: lines.slice(baseStart + 1, sep).join("\n"),
41
+ remote: lines.slice(sep + 1, remoteEnd).join("\n"),
42
+ resolution: undefined,
43
+ choice: undefined,
44
+ },
45
+ startLine,
46
+ endLine: remoteEnd,
47
+ });
48
+ index = remoteEnd + 1;
49
+ continue;
50
+ }
51
+ index += 1;
52
+ }
53
+ return blocks;
54
+ }
55
+
56
+ /**
57
+ * Rebuild the merged text with conflict blocks replaced by their resolutions.
58
+ * `resolutions[i]` is the resolved content for block i; undefined is an error.
59
+ * Preserves the merged text's line-ending style.
60
+ */
61
+ export function applyResolutions(
62
+ mergedText: string,
63
+ resolutions: Array<string | undefined>,
64
+ ): string {
65
+ const eol = mergedText.includes("\r\n") ? "\r\n" : "\n";
66
+ const lines = mergedText.split(/\r?\n/u);
67
+ const blocks = parseConflictBlocks(mergedText);
68
+ if (blocks.length !== resolutions.length) {
69
+ throw new Error(
70
+ `Conflict block count changed (${blocks.length} on disk vs ${resolutions.length} in session). Re-run /sync pull --merge or /sync merge --abort.`,
71
+ );
72
+ }
73
+ for (let index = blocks.length - 1; index >= 0; index -= 1) {
74
+ const resolution = resolutions[index];
75
+ if (resolution === undefined) {
76
+ throw new Error(`Conflict block ${index + 1} is not resolved yet.`);
77
+ }
78
+ const block = blocks[index];
79
+ const replacement = resolution.split(/\r?\n/u);
80
+ lines.splice(block.startLine, block.endLine - block.startLine + 1, ...replacement);
81
+ }
82
+ return lines.join(eol);
83
+ }
84
+
85
+ function findMarker(lines: string[], from: number, markers: string[]): number | undefined {
86
+ for (let index = from; index < lines.length; index += 1) {
87
+ if (markers.some((marker) => lines[index].startsWith(marker))) return index;
88
+ }
89
+ return undefined;
90
+ }
package/src/diff.ts ADDED
@@ -0,0 +1,154 @@
1
+ import { createTwoFilesPatch } from "diff";
2
+ import { fileHashMap, type Snapshot, snapshotFileContent } from "./snapshot.js";
3
+
4
+ const HUNK_CONTEXT_LINES = 2;
5
+ const MAX_HUNK_FILE_BYTES = 1024 * 1024;
6
+ const MAX_TOTAL_HUNK_LINES = 300;
7
+ const SECRET_MASK = "****";
8
+ const TRUNCATED_MARKER = "… (hunk truncated)";
9
+
10
+ const SECRET_PATTERNS = [
11
+ /AWS_SECRET_ACCESS_KEY\s*[=:]\s*['"]?[A-Za-z0-9/+]{35,}/i,
12
+ /(ANTHROPIC|OPENAI|GEMINI|GOOGLE|FIRECRAWL|GITHUB|CLOUDFLARE|R2|S3)_[A-Z0-9_]*(KEY|TOKEN|SECRET)\s*[=:]\s*['"]?[^\s'"]{12,}/i,
13
+ /sk-ant-[A-Za-z0-9_-]{20,}/,
14
+ /sk-[A-Za-z0-9]{20,}/,
15
+ /gh[pousr]_[A-Za-z0-9_]{20,}/,
16
+ ];
17
+
18
+ export interface SnapshotDiffSummary {
19
+ changed: number;
20
+ added: number;
21
+ removed: number;
22
+ identical: boolean;
23
+ }
24
+
25
+ export function diffSummary(local: Snapshot, remote: Snapshot): SnapshotDiffSummary {
26
+ const localMap = fileHashMap(local);
27
+ const remoteMap = fileHashMap(remote);
28
+ const paths = [...new Set([...localMap.keys(), ...remoteMap.keys()])];
29
+ let added = 0;
30
+ let removed = 0;
31
+ let changed = 0;
32
+ for (const filePath of paths) {
33
+ if (!localMap.has(filePath)) added += 1;
34
+ else if (!remoteMap.has(filePath)) removed += 1;
35
+ else if (localMap.get(filePath) !== remoteMap.get(filePath)) changed += 1;
36
+ }
37
+ return { changed, added, removed, identical: added === 0 && removed === 0 && changed === 0 };
38
+ }
39
+
40
+ /** Content-level diff of local vs remote with JSON pretty-print, masking, and bounds. */
41
+ export function formatSnapshotDiff(local: Snapshot, remote: Snapshot): string {
42
+ const localMap = fileHashMap(local);
43
+ const remoteMap = fileHashMap(remote);
44
+ const allPaths = [...new Set([...localMap.keys(), ...remoteMap.keys()])].sort();
45
+ const lines = [
46
+ `local: ${local.files.length} files`,
47
+ `remote: ${remote.createdAt} (${remote.files.length} files)`,
48
+ "",
49
+ ];
50
+ let totalChanges = 0;
51
+ let hunkBudget = MAX_TOTAL_HUNK_LINES;
52
+ let truncated = false;
53
+ const appendHunks = (hunks: string[]) => {
54
+ if (hunks.length === 0) return;
55
+ lines.push(...hunks.map((line) => ` ${line}`));
56
+ hunkBudget -= hunks.length;
57
+ if (hunks.at(-1) === TRUNCATED_MARKER) truncated = true;
58
+ };
59
+ for (const filePath of allPaths) {
60
+ if (!localMap.has(filePath)) {
61
+ lines.push(`Remote only: ${filePath}`);
62
+ totalChanges += 1;
63
+ if (hunkBudget <= 0) truncated = true;
64
+ else appendHunks(contentHunks("", fileText(remote, filePath), hunkBudget));
65
+ } else if (!remoteMap.has(filePath)) {
66
+ lines.push(`Local only: ${filePath}`);
67
+ totalChanges += 1;
68
+ if (hunkBudget <= 0) truncated = true;
69
+ else appendHunks(contentHunks("", fileText(local, filePath), hunkBudget));
70
+ } else if (localMap.get(filePath) !== remoteMap.get(filePath)) {
71
+ lines.push(`Different: ${filePath}`);
72
+ totalChanges += 1;
73
+ if (hunkBudget <= 0) truncated = true;
74
+ else {
75
+ const texts = diffTexts(fileText(remote, filePath), fileText(local, filePath));
76
+ appendHunks(contentHunks(texts.before, texts.after, hunkBudget));
77
+ }
78
+ }
79
+ }
80
+ if (totalChanges === 0) lines.push("No file differences.");
81
+ else if (truncated) lines.push("(content hunks truncated; all changed paths are listed)");
82
+ return lines.join("\n");
83
+ }
84
+
85
+ function fileText(snapshot: Snapshot, filePath: string): string {
86
+ return snapshotFileContent(snapshot, filePath) ?? "";
87
+ }
88
+
89
+ function diffTexts(before: string | undefined, after: string | undefined) {
90
+ const beforePretty = before !== undefined ? prettyJson(before) : undefined;
91
+ const afterPretty = after !== undefined ? prettyJson(after) : undefined;
92
+ if (beforePretty !== undefined || afterPretty !== undefined) {
93
+ return { before: beforePretty ?? "", after: afterPretty ?? "" };
94
+ }
95
+ return { before: before ?? "", after: after ?? "" };
96
+ }
97
+
98
+ function prettyJson(text: string): string | undefined {
99
+ try {
100
+ return JSON.stringify(JSON.parse(text), null, 2);
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ }
105
+
106
+ function contentHunks(before: string, after: string, maxLines: number): string[] {
107
+ if (before === after) return [];
108
+ if (maxLines <= 0) return [];
109
+ if (before.length > MAX_HUNK_FILE_BYTES || after.length > MAX_HUNK_FILE_BYTES) return [];
110
+ const patch = createTwoFilesPatch("", "", before, after, "", "", {
111
+ context: HUNK_CONTEXT_LINES,
112
+ });
113
+ const lines: string[] = [];
114
+ let inHunks = false;
115
+ for (const line of patch.split("\n")) {
116
+ if (!inHunks) {
117
+ if (line.startsWith("@@")) inHunks = true;
118
+ else continue;
119
+ }
120
+ if (line === "" || line === "\") continue;
121
+ if (lines.length >= maxLines) {
122
+ lines[maxLines - 1] = TRUNCATED_MARKER;
123
+ break;
124
+ }
125
+ lines.push(sanitizeHunkLine(line));
126
+ }
127
+ return lines;
128
+ }
129
+
130
+ function sanitizeHunkLine(line: string): string {
131
+ const prefix =
132
+ line.startsWith("+") || line.startsWith("-") || line.startsWith(" ") ? line[0] : "";
133
+ const content = prefix ? line.slice(1) : line;
134
+ return `${prefix}${safeTerminalText(maskSecrets(content))}`;
135
+ }
136
+
137
+ function maskSecrets(text: string): string {
138
+ let masked = text;
139
+ for (const pattern of SECRET_PATTERNS) {
140
+ masked = masked.replace(new RegExp(pattern.source, "giu"), (match) => {
141
+ const separator = match.search(/[=:]/u);
142
+ if (separator >= 0 && separator < match.length - 1) {
143
+ return `${match.slice(0, separator + 1)}${SECRET_MASK}`;
144
+ }
145
+ return SECRET_MASK;
146
+ });
147
+ }
148
+ return masked;
149
+ }
150
+
151
+ function safeTerminalText(value: string): string {
152
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Escape untrusted terminal controls.
153
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "?");
154
+ }