@lisang233/pi-sync 0.1.2 → 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/src/status.ts CHANGED
@@ -1,6 +1,3 @@
1
- import { planMerge } from "./merge.js";
2
- import type { Snapshot } from "./snapshot.js";
3
-
4
1
  export type SyncStateLabel =
5
2
  | "unconfigured"
6
3
  | "unknown"
@@ -17,41 +14,91 @@ export interface SyncStatusInfo {
17
14
  conflicts: number;
18
15
  }
19
16
 
17
+ export interface StateClassify {
18
+ label: SyncStateLabel;
19
+ ahead: number;
20
+ behind: number;
21
+ conflicts: number;
22
+ /** Paths where only the local side changed (local "wins" / pushable). */
23
+ localChanged: string[];
24
+ /** Paths where only the remote side changed (pullable). */
25
+ remoteChanged: string[];
26
+ /** Paths where both sides changed on the same path (needs a real merge). */
27
+ diverged: string[];
28
+ }
29
+
20
30
  /**
21
- * Derive the sync state from the local snapshot, the remote snapshot, and the
22
- * base snapshot (the last remote revision we applied). A missing remote means
23
- * nothing has ever been published; divergent or two-sided changes are a
24
- * conflict, one-sided changes are ahead (push) or behind (pull).
31
+ * Classify the sync state by three-way comparing the agent file tree against
32
+ * the remote branch, using git's real merge-base as the base. Unlike the old
33
+ * snapshot design (which used a local state.json anchor), the base here is the
34
+ * true common ancestor, so a fresh machine or a rewritten remote never
35
+ * produces a false conflict.
36
+ *
37
+ * `base` is the content map at the merge-base (empty when there is none, e.g.
38
+ * a brand-new branch with no shared history). A path present in local & remote
39
+ * with different content and no base is conservatively a divergence.
25
40
  */
26
- export function deriveSyncStatus(
27
- local: Snapshot,
28
- remote: Snapshot | undefined,
29
- base: Snapshot | undefined,
30
- ): SyncStatusInfo {
31
- if (!remote) {
32
- return { label: "unpublished", ahead: 0, behind: 0, conflicts: 0 };
33
- }
34
- const plan = planMerge(local, remote, base);
35
- if (plan.conflicts.length > 0 || (plan.takeLocal.length > 0 && plan.takeRemote.length > 0)) {
41
+ export function classifyState(
42
+ local: Map<string, string>,
43
+ remote: Map<string, string>,
44
+ base: Map<string, string>,
45
+ mergePending: boolean,
46
+ ): StateClassify {
47
+ if (mergePending) {
48
+ const diverged = [...new Set([...local.keys(), ...remote.keys()])].filter(
49
+ (p) => local.get(p) !== remote.get(p),
50
+ );
36
51
  return {
37
52
  label: "conflict",
38
- ahead: plan.takeLocal.length,
39
- behind: plan.takeRemote.length,
40
- conflicts: plan.conflicts.length,
53
+ ahead: 0,
54
+ behind: 0,
55
+ conflicts: diverged.length,
56
+ localChanged: [],
57
+ remoteChanged: [],
58
+ diverged,
41
59
  };
42
60
  }
43
- if (plan.takeLocal.length > 0) {
44
- return { label: "ahead", ahead: plan.takeLocal.length, behind: 0, conflicts: 0 };
61
+ const paths = [...new Set([...local.keys(), ...remote.keys()])].sort();
62
+ const localChanged: string[] = [];
63
+ const remoteChanged: string[] = [];
64
+ const diverged: string[] = [];
65
+ for (const filePath of paths) {
66
+ const localContent = local.get(filePath);
67
+ const remoteContent = remote.get(filePath);
68
+ if (localContent === remoteContent) continue;
69
+ const baseContent = base.get(filePath);
70
+ const localChangedSide = baseContent !== undefined ? localContent !== baseContent : true;
71
+ const remoteChangedSide = baseContent !== undefined ? remoteContent !== baseContent : true;
72
+ if (localChangedSide && remoteChangedSide) diverged.push(filePath);
73
+ else if (localChangedSide) localChanged.push(filePath);
74
+ else if (remoteChangedSide) remoteChanged.push(filePath);
45
75
  }
46
- if (plan.takeRemote.length > 0) {
47
- return { label: "behind", ahead: 0, behind: plan.takeRemote.length, conflicts: 0 };
76
+
77
+ let label: SyncStateLabel;
78
+ if (remote.size === 0) label = "unpublished";
79
+ else if (diverged.length > 0 || (localChanged.length > 0 && remoteChanged.length > 0)) {
80
+ label = "conflict";
81
+ } else if (remoteChanged.length > 0) {
82
+ label = "behind";
83
+ } else if (localChanged.length > 0) {
84
+ label = "ahead";
85
+ } else {
86
+ label = "up-to-date";
48
87
  }
49
- return { label: "up-to-date", ahead: 0, behind: 0, conflicts: 0 };
88
+ return {
89
+ label,
90
+ ahead: localChanged.length,
91
+ behind: remoteChanged.length,
92
+ conflicts: diverged.length,
93
+ localChanged,
94
+ remoteChanged,
95
+ diverged,
96
+ };
50
97
  }
51
98
 
52
- /** Short status-bar text while a background sync (automatic fetch) is in flight. */
53
- export function syncBusyText(): string {
54
- return "sync: fetching…";
99
+ /** Status-bar text while a background/foreground sync action is in flight. */
100
+ export function syncBusyText(action: "fetch" | "push" = "fetch"): string {
101
+ return action === "push" ? "sync: pushing…" : "sync: fetching…";
55
102
  }
56
103
 
57
104
  /** Short status-bar text for the sync indicator. */
package/src/tree.ts ADDED
@@ -0,0 +1,231 @@
1
+ import type { Dirent } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { SyncConfig } from "./config.js";
5
+ import { mirrorRepoDir } from "./config.js";
6
+ import { agentDir, syncRootPath } from "./paths.js";
7
+
8
+ export const MAX_SYNC_FILE_BYTES = 16 * 1024 * 1024;
9
+ const MAX_SCAN_FILES = 5_000;
10
+
11
+ export interface LocalFile {
12
+ /** Agent-relative posix path (e.g. "skills/x/SKILL.md"). */
13
+ path: string;
14
+ /** Absolute source path under the agent dir. */
15
+ source: string;
16
+ }
17
+
18
+ /**
19
+ * Collect the real files under the configured include paths in the agent dir.
20
+ * Symlinks are skipped; a path may appear under multiple include entries but is
21
+ * scanned once. This is the "local side" of the sync — real files, not blobs.
22
+ */
23
+ export async function collectAgentFiles(config: SyncConfig): Promise<LocalFile[]> {
24
+ const files: LocalFile[] = [];
25
+ const seen = new Set<string>();
26
+ for (const entry of config.include) {
27
+ const root = syncRootPath(entry);
28
+ await collectRoot(root, files, seen);
29
+ }
30
+ files.sort((left, right) => left.path.localeCompare(right.path));
31
+ return files;
32
+ }
33
+
34
+ async function collectRoot(root: string, files: LocalFile[], seen: Set<string>): Promise<void> {
35
+ let stat: Awaited<ReturnType<typeof fs.lstat>>;
36
+ try {
37
+ stat = await fs.lstat(root);
38
+ } catch (error) {
39
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
40
+ throw error;
41
+ }
42
+ if (stat.isSymbolicLink()) return;
43
+ if (stat.isFile()) {
44
+ await collectFile(root, files, seen);
45
+ return;
46
+ }
47
+ if (stat.isDirectory()) {
48
+ await collectDirectory(root, files, seen);
49
+ }
50
+ }
51
+
52
+ async function collectDirectory(
53
+ directory: string,
54
+ files: LocalFile[],
55
+ seen: Set<string>,
56
+ ): Promise<void> {
57
+ let entries: Dirent[];
58
+ try {
59
+ entries = await fs.readdir(directory, { withFileTypes: true });
60
+ } catch (error) {
61
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
62
+ throw error;
63
+ }
64
+ entries.sort((left, right) => left.name.localeCompare(right.name));
65
+ for (const dirent of entries) {
66
+ if (files.length >= MAX_SCAN_FILES) return;
67
+ if (dirent.isSymbolicLink()) continue;
68
+ const child = path.join(directory, dirent.name);
69
+ if (dirent.isDirectory()) {
70
+ await collectDirectory(child, files, seen);
71
+ } else if (dirent.isFile()) {
72
+ await collectFile(child, files, seen);
73
+ }
74
+ }
75
+ }
76
+
77
+ async function collectFile(filePath: string, files: LocalFile[], seen: Set<string>): Promise<void> {
78
+ const stat = await fs.stat(filePath);
79
+ if (stat.size > MAX_SYNC_FILE_BYTES) return;
80
+ const relative = path.relative(agentDir(), filePath).split(path.sep).join("/");
81
+ if (!relative || relative.startsWith("../") || seen.has(relative)) return;
82
+ seen.add(relative);
83
+ files.push({ path: relative, source: filePath });
84
+ }
85
+
86
+ /** True when a relative path falls under an include entry (exact or dir prefix). */
87
+ export function pathMatchesInclude(relativePath: string, entry: string): boolean {
88
+ const lower = entry.toLowerCase();
89
+ const pathLower = relativePath.toLowerCase();
90
+ return pathLower === lower || pathLower.startsWith(`${lower}/`);
91
+ }
92
+
93
+ /** Map an agent-relative path to its target inside the mirror work tree. */
94
+ export function mirrorTarget(relativePath: string): string {
95
+ return path.join(mirrorRepoDir(), relativePath);
96
+ }
97
+
98
+ /** Map an agent-relative path to its absolute path under the agent dir. */
99
+ export function agentTarget(relativePath: string): string {
100
+ return path.join(agentDir(), relativePath);
101
+ }
102
+
103
+ /** Read agent-file content as a path→content map for the include scope. */
104
+ export async function readAgentContents(config: SyncConfig): Promise<Map<string, string>> {
105
+ const files = await collectAgentFiles(config);
106
+ const map = new Map<string, string>();
107
+ for (const file of files) {
108
+ try {
109
+ map.set(file.path, await fs.readFile(file.source, "utf8"));
110
+ } catch {
111
+ // Unreadable file — omit from the view.
112
+ }
113
+ }
114
+ return map;
115
+ }
116
+
117
+ /** List the agent-relative paths currently present in the mirror include scope. */
118
+ export async function mirrorProjectedFiles(config: SyncConfig): Promise<string[]> {
119
+ return collectMirrorFiles(mirrorRepoDir(), config);
120
+ }
121
+
122
+ /**
123
+ * Overlay the agent dir's include content into the mirror work tree, deleting
124
+ * any mirror files under an include-subtree that no longer exist on the local
125
+ * side. This produces the "local side" tree for git to stage & merge.
126
+ */
127
+ export async function graftAgentIntoMirror(
128
+ config: SyncConfig,
129
+ localFiles: LocalFile[],
130
+ ): Promise<void> {
131
+ const mirrorRoot = mirrorRepoDir();
132
+ for (const file of localFiles) {
133
+ const target = mirrorTarget(file.path);
134
+ await fs.mkdir(path.dirname(target), { recursive: true });
135
+ await fs.copyFile(file.source, target);
136
+ }
137
+ // Remove stale mirror files that are under an include path but absent locally.
138
+ const stale = await collectMirrorFiles(mirrorRoot, config);
139
+ for (const relative of stale) {
140
+ if (!localFiles.some((file) => file.path === relative)) {
141
+ await fs.rm(mirrorTarget(relative), { force: true });
142
+ }
143
+ }
144
+ await removeEmptyDirs(mirrorRoot);
145
+ }
146
+
147
+ /** Collect all files currently in the mirror work tree that fall under an include path. */
148
+ async function collectMirrorFiles(mirrorRoot: string, config: SyncConfig): Promise<string[]> {
149
+ const result: string[] = [];
150
+ const seen = new Set<string>();
151
+ await collectMirrorRoot(mirrorRoot, "", config, result, seen);
152
+ return result;
153
+ }
154
+
155
+ async function collectMirrorRoot(
156
+ mirrorRoot: string,
157
+ relativeDir: string,
158
+ config: SyncConfig,
159
+ result: string[],
160
+ seen: Set<string>,
161
+ ): Promise<void> {
162
+ let entries: Dirent[];
163
+ try {
164
+ entries = await fs.readdir(path.join(mirrorRoot, relativeDir), { withFileTypes: true });
165
+ } catch (error) {
166
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
167
+ throw error;
168
+ }
169
+ entries.sort((left, right) => left.name.localeCompare(right.name));
170
+ for (const dirent of entries) {
171
+ const relative = relativeDir ? `${relativeDir}/${dirent.name}` : dirent.name;
172
+ if (dirent.isSymbolicLink()) continue;
173
+ if (dirent.isDirectory()) {
174
+ await collectMirrorRoot(mirrorRoot, relative, config, result, seen);
175
+ } else if (dirent.isFile()) {
176
+ if (!config.include.some((entry) => pathMatchesInclude(relative, entry))) continue;
177
+ if (seen.has(relative)) continue;
178
+ seen.add(relative);
179
+ result.push(relative);
180
+ }
181
+ }
182
+ }
183
+
184
+ /** Remove now-empty directories under the mirror root, bottom-up. */
185
+ async function removeEmptyDirs(root: string): Promise<void> {
186
+ const dirs: string[] = [];
187
+ const walk = async (dir: string): Promise<void> => {
188
+ let entries: Dirent[];
189
+ try {
190
+ entries = await fs.readdir(dir, { withFileTypes: true });
191
+ } catch {
192
+ return;
193
+ }
194
+ for (const dirent of entries) {
195
+ if (dirent.isDirectory()) await walk(path.join(dir, dirent.name));
196
+ }
197
+ dirs.push(dir);
198
+ };
199
+ await walk(root);
200
+ for (const dir of dirs.reverse()) {
201
+ if (dir === root) continue;
202
+ try {
203
+ const entries = await fs.readdir(dir);
204
+ if (entries.length === 0) await fs.rmdir(dir);
205
+ } catch {
206
+ // ignore
207
+ }
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Apply the mirror work tree to the agent dir: copy include-scoped files over
213
+ * and delete agent files under an include path that are absent from the mirror.
214
+ */
215
+ export async function copyMirrorToAgent(config: SyncConfig): Promise<void> {
216
+ const mirrorRoot = mirrorRepoDir();
217
+ const projected = await mirrorProjectedFiles(config);
218
+ for (const relative of projected) {
219
+ const source = path.join(mirrorRoot, relative);
220
+ const target = agentTarget(relative);
221
+ await fs.mkdir(path.dirname(target), { recursive: true });
222
+ await fs.copyFile(source, target);
223
+ }
224
+ // Delete agent files under an include path that are absent from the mirror.
225
+ const localFiles = await collectAgentFiles(config);
226
+ const projectedSet = new Set(projected);
227
+ for (const file of localFiles) {
228
+ if (projectedSet.has(file.path)) continue;
229
+ await fs.rm(file.source, { force: true });
230
+ }
231
+ }
package/src/conflict.ts DELETED
@@ -1,90 +0,0 @@
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
- }
@@ -1,145 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
- import { stateDir } from "./config.js";
4
-
5
- const SESSION_DIR = "merge-session";
6
- const SESSION_FILE = "session.json";
7
-
8
- export type BlockChoice = "local" | "remote" | "custom";
9
-
10
- /** One divergent block from a merged file; resolution is set when the user resolves it. */
11
- export interface ConflictBlock {
12
- local: string;
13
- base: string;
14
- remote: string;
15
- resolution: string | undefined;
16
- choice: BlockChoice | undefined;
17
- }
18
-
19
- export interface MergeFileState {
20
- path: string;
21
- /** The full merged text with diff3 markers (resume needs no disk state). */
22
- merged: string;
23
- blocks: ConflictBlock[];
24
- }
25
-
26
- /**
27
- * The persistent state of one conflict-resolution session. Lives in its own
28
- * file (block contents can be large) so state.json stays small and backward
29
- * compatible; only the presence of this file marks an incomplete merge.
30
- */
31
- export interface MergeSessionData {
32
- baselineRevision: string;
33
- backupDir: string;
34
- createdAt: string;
35
- /** Files taken from the remote side when the session started. */
36
- takeRemote: number;
37
- /** Files kept from the local side when the session started. */
38
- takeLocal: number;
39
- files: MergeFileState[];
40
- }
41
-
42
- export function mergeSessionDir(): string {
43
- return path.join(stateDir(), SESSION_DIR);
44
- }
45
-
46
- export function mergeSessionFilePath(): string {
47
- return path.join(mergeSessionDir(), SESSION_FILE);
48
- }
49
-
50
- export async function loadMergeSession(): Promise<MergeSessionData | undefined> {
51
- let text: string;
52
- try {
53
- text = await fs.readFile(mergeSessionFilePath(), "utf8");
54
- } catch (error) {
55
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
56
- throw error;
57
- }
58
- try {
59
- return parseMergeSession(JSON.parse(text));
60
- } catch {
61
- return undefined;
62
- }
63
- }
64
-
65
- export async function saveMergeSession(session: MergeSessionData): Promise<void> {
66
- await fs.mkdir(mergeSessionDir(), { recursive: true });
67
- const serialized = `${JSON.stringify(session, null, "\t")}\n`;
68
- await fs.writeFile(mergeSessionFilePath(), serialized, { mode: 0o600 });
69
- }
70
-
71
- export async function clearMergeSession(): Promise<void> {
72
- await fs.rm(mergeSessionDir(), { recursive: true, force: true });
73
- }
74
-
75
- /** True when a merge session file exists and parses. */
76
- export async function hasMergeSession(): Promise<boolean> {
77
- return (await loadMergeSession()) !== undefined;
78
- }
79
-
80
- function parseMergeSession(value: unknown): MergeSessionData | undefined {
81
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
82
- const record = value as Record<string, unknown>;
83
- if (
84
- typeof record.baselineRevision !== "string" ||
85
- typeof record.backupDir !== "string" ||
86
- typeof record.createdAt !== "string" ||
87
- typeof record.takeRemote !== "number" ||
88
- typeof record.takeLocal !== "number" ||
89
- !Array.isArray(record.files)
90
- ) {
91
- return undefined;
92
- }
93
- const files: MergeFileState[] = [];
94
- for (const file of record.files) {
95
- const parsed = parseMergeFile(file);
96
- if (!parsed) return undefined;
97
- files.push(parsed);
98
- }
99
- return {
100
- baselineRevision: record.baselineRevision,
101
- backupDir: record.backupDir,
102
- createdAt: record.createdAt,
103
- takeRemote: record.takeRemote,
104
- takeLocal: record.takeLocal,
105
- files,
106
- };
107
- }
108
-
109
- function parseMergeFile(value: unknown): MergeFileState | undefined {
110
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
111
- const record = value as Record<string, unknown>;
112
- if (
113
- typeof record.path !== "string" ||
114
- typeof record.merged !== "string" ||
115
- !Array.isArray(record.blocks)
116
- ) {
117
- return undefined;
118
- }
119
- const blocks: ConflictBlock[] = [];
120
- for (const block of record.blocks) {
121
- const parsed = parseBlock(block);
122
- if (!parsed) return undefined;
123
- blocks.push(parsed);
124
- }
125
- return { path: record.path, merged: record.merged, blocks };
126
- }
127
-
128
- function parseBlock(value: unknown): ConflictBlock | undefined {
129
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
130
- const record = value as Record<string, unknown>;
131
- if (
132
- typeof record.local !== "string" ||
133
- typeof record.base !== "string" ||
134
- typeof record.remote !== "string"
135
- ) {
136
- return undefined;
137
- }
138
- const resolution = record.resolution ?? undefined;
139
- if (resolution !== undefined && typeof resolution !== "string") return undefined;
140
- const choice = record.choice ?? undefined;
141
- if (choice !== undefined && choice !== "local" && choice !== "remote" && choice !== "custom") {
142
- return undefined;
143
- }
144
- return { local: record.local, base: record.base, remote: record.remote, resolution, choice };
145
- }