memarium 0.13.1
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 +146 -0
- package/assets/scripts/merge-books.mjs +921 -0
- package/assets/workflows/memarium-aggregate.yml +66 -0
- package/dist/bin/memarium.js +6 -0
- package/dist/src/aggregated-store.js +95 -0
- package/dist/src/cli.js +175 -0
- package/dist/src/commands/cat.js +20 -0
- package/dist/src/commands/doctor.js +383 -0
- package/dist/src/commands/init-wizard.js +201 -0
- package/dist/src/commands/init.js +45 -0
- package/dist/src/commands/list.js +19 -0
- package/dist/src/commands/prune.js +108 -0
- package/dist/src/commands/resume/config-pathmap.js +38 -0
- package/dist/src/commands/resume/fuzzy-match.js +13 -0
- package/dist/src/commands/resume/list-sessions.js +54 -0
- package/dist/src/commands/resume/render-prompt.js +121 -0
- package/dist/src/commands/resume/resume.js +121 -0
- package/dist/src/commands/show.js +21 -0
- package/dist/src/commands/sync.js +279 -0
- package/dist/src/commands/upgrade.js +47 -0
- package/dist/src/commands/workflow.js +126 -0
- package/dist/src/config.js +98 -0
- package/dist/src/content-project-inference.js +185 -0
- package/dist/src/device.js +47 -0
- package/dist/src/digest/manifest.js +121 -0
- package/dist/src/digest/project-filter.js +32 -0
- package/dist/src/digest/session-signal.js +106 -0
- package/dist/src/digest/toc.js +127 -0
- package/dist/src/git-ops.js +359 -0
- package/dist/src/index-store.js +35 -0
- package/dist/src/migrate.js +72 -0
- package/dist/src/project-identity.js +139 -0
- package/dist/src/project-resolve.js +42 -0
- package/dist/src/prompts.js +87 -0
- package/dist/src/repo-data-dir.js +25 -0
- package/dist/src/slug.js +28 -0
- package/dist/src/sources/base.js +1 -0
- package/dist/src/sources/claude-code.js +294 -0
- package/dist/src/sources/vscode-copilot.js +400 -0
- package/dist/src/types.js +1 -0
- package/dist/src/writer.js +240 -0
- package/package.json +60 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { simpleGit } from "simple-git";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
/**
|
|
7
|
+
* Expand a leading `~` to the user's home dir. (Shell expansion doesn't
|
|
8
|
+
* happen for prompt input — `~/edge` would be taken literally otherwise.)
|
|
9
|
+
*/
|
|
10
|
+
export function expandHome(p) {
|
|
11
|
+
if (p === "~")
|
|
12
|
+
return homedir();
|
|
13
|
+
if (p.startsWith("~/"))
|
|
14
|
+
return join(homedir(), p.slice(2));
|
|
15
|
+
return p;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Clone with stdio inherited so git's terminal-based credential prompts
|
|
19
|
+
* (HTTPS username/password, SSH passphrase) actually reach the user instead
|
|
20
|
+
* of hanging forever on a piped FD that no one writes to. Also sets
|
|
21
|
+
* GIT_TERMINAL_PROMPT=0 when stdin isn't a TTY so the call fails fast in CI
|
|
22
|
+
* instead of hanging.
|
|
23
|
+
*/
|
|
24
|
+
function cloneWithProgress(repoUrl, dest) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const env = { ...process.env };
|
|
27
|
+
if (!process.stdin.isTTY)
|
|
28
|
+
env.GIT_TERMINAL_PROMPT = "0";
|
|
29
|
+
const p = spawn("git", ["clone", "--progress", repoUrl, dest], {
|
|
30
|
+
stdio: "inherit",
|
|
31
|
+
env,
|
|
32
|
+
});
|
|
33
|
+
p.on("error", reject);
|
|
34
|
+
p.on("close", (code) => {
|
|
35
|
+
if (code === 0)
|
|
36
|
+
resolve();
|
|
37
|
+
else
|
|
38
|
+
reject(new Error(`git clone exited with code ${code}. If this is an HTTPS URL needing auth, prefer SSH (git@github.com:...) or store a PAT via 'git config credential.helper'.`));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Make sure `localPath` contains the repo at `repoUrl`.
|
|
44
|
+
*
|
|
45
|
+
* - If `localPath` doesn't exist OR exists-and-empty → `git clone repoUrl
|
|
46
|
+
* localPath`. Returns kind:"cloned".
|
|
47
|
+
* - If `localPath` exists and contains `.git` → reuse. Returns kind:"existing"
|
|
48
|
+
* with `existingRemote` so the wizard can warn on URL mismatch. Does NOT
|
|
49
|
+
* change the existing remote.
|
|
50
|
+
* - If `localPath` exists, non-empty, no `.git` → throw with a friendly
|
|
51
|
+
* message; refuse to scribble inside an unrelated dir.
|
|
52
|
+
*/
|
|
53
|
+
export async function materializeRepoAtPath(localPath, repoUrl) {
|
|
54
|
+
const path = resolve(expandHome(localPath));
|
|
55
|
+
if (!existsSync(path)) {
|
|
56
|
+
mkdirSync(path, { recursive: true });
|
|
57
|
+
await cloneWithProgress(repoUrl, path);
|
|
58
|
+
return { kind: "cloned" };
|
|
59
|
+
}
|
|
60
|
+
const entries = readdirSync(path);
|
|
61
|
+
if (entries.length === 0) {
|
|
62
|
+
await cloneWithProgress(repoUrl, path);
|
|
63
|
+
return { kind: "cloned" };
|
|
64
|
+
}
|
|
65
|
+
if (entries.includes(".git")) {
|
|
66
|
+
const git = simpleGit(path);
|
|
67
|
+
let remote = "";
|
|
68
|
+
try {
|
|
69
|
+
remote = (await git.getConfig("remote.origin.url")).value ?? "";
|
|
70
|
+
}
|
|
71
|
+
catch { /* no remote configured */ }
|
|
72
|
+
return { kind: "existing", existingRemote: remote };
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`${path} is not empty and is not a git repo. Pick another path or empty this one first.`);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Adopt a non-git directory full of memarium-plugin output into a fresh git
|
|
78
|
+
* repo bound to `repoUrl`. Use case: user installed memarium-plugin first,
|
|
79
|
+
* which wrote `book/` and `raw_sessions/` under `~/.memarium/session-repo/`
|
|
80
|
+
* but never `git init`'d. Then the user installs the npm CLI and runs
|
|
81
|
+
* `memarium init` — the dir is non-empty but not git, so the strict
|
|
82
|
+
* materialize path refuses.
|
|
83
|
+
*
|
|
84
|
+
* Safety:
|
|
85
|
+
* - All existing files are preserved on disk; this never deletes or moves.
|
|
86
|
+
* - The fetch is "info-only" — we don't checkout any remote ref, so user
|
|
87
|
+
* files can't be overwritten by `main`.
|
|
88
|
+
* - The user lands on a brand-new branch `<deviceBranch>` with their
|
|
89
|
+
* existing files as the first commit. Next `memarium sync` will append
|
|
90
|
+
* normally; first push creates the branch upstream.
|
|
91
|
+
*
|
|
92
|
+
* The caller (init wizard) is responsible for prompting the user before
|
|
93
|
+
* calling this — adopting silently would surprise users.
|
|
94
|
+
*/
|
|
95
|
+
export async function adoptPluginDir(localPath, repoUrl, deviceBranch) {
|
|
96
|
+
const path = resolve(expandHome(localPath));
|
|
97
|
+
if (!existsSync(path)) {
|
|
98
|
+
throw new Error(`adoptPluginDir: ${path} does not exist`);
|
|
99
|
+
}
|
|
100
|
+
if (existsSync(join(path, ".git"))) {
|
|
101
|
+
throw new Error(`adoptPluginDir: ${path} already has a .git dir; use materializeRepoAtPath instead`);
|
|
102
|
+
}
|
|
103
|
+
const git = simpleGit(path);
|
|
104
|
+
await git.init();
|
|
105
|
+
await git.addRemote("origin", repoUrl);
|
|
106
|
+
try {
|
|
107
|
+
// Fetch refs so the user has a reference to `origin/main` for merge
|
|
108
|
+
// later, but don't checkout anything — the working tree is exactly
|
|
109
|
+
// what the user (well, the plugin) put there, and we want to preserve
|
|
110
|
+
// it as the first commit on a fresh device branch.
|
|
111
|
+
await git.fetch("origin");
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Offline or bad URL — adopt continues; user can fetch later.
|
|
115
|
+
}
|
|
116
|
+
await git.checkoutLocalBranch(deviceBranch);
|
|
117
|
+
return { kind: "adopted" };
|
|
118
|
+
}
|
|
119
|
+
export async function ensureRepo(localPath, repoUrl) {
|
|
120
|
+
await materializeRepoAtPath(localPath, repoUrl);
|
|
121
|
+
const git = simpleGit(localPath);
|
|
122
|
+
if (!existsSync(join(localPath, ".git"))) {
|
|
123
|
+
await git.init();
|
|
124
|
+
await git.addRemote("origin", repoUrl).catch(() => { });
|
|
125
|
+
}
|
|
126
|
+
return git;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Make sure the working tree is on `branch`.
|
|
130
|
+
* Priority:
|
|
131
|
+
* 1. Local branch exists → checkout.
|
|
132
|
+
* 2. Remote `origin/<branch>` exists → checkout tracking branch.
|
|
133
|
+
* 3. Neither → create as orphan (empty history, no parent).
|
|
134
|
+
*/
|
|
135
|
+
export async function ensureDeviceBranch(git, branch) {
|
|
136
|
+
const local = await git.branchLocal();
|
|
137
|
+
if (local.all.includes(branch)) {
|
|
138
|
+
if (local.current !== branch)
|
|
139
|
+
await git.checkout(branch);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
let remoteHas = false;
|
|
143
|
+
try {
|
|
144
|
+
const remote = await git.branch(["-r"]);
|
|
145
|
+
remoteHas = remote.all.includes(`origin/${branch}`);
|
|
146
|
+
}
|
|
147
|
+
catch { /* no remotes fetched yet */ }
|
|
148
|
+
if (remoteHas) {
|
|
149
|
+
await git.checkout(["-b", branch, "--track", `origin/${branch}`]);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
await git.checkout(["--orphan", branch]);
|
|
153
|
+
await git.raw(["rm", "-rf", "--cached", "--ignore-unmatch", "."]);
|
|
154
|
+
}
|
|
155
|
+
const SECRET_BLOCK_RE = /GH013|push protection|secret-scanning/i;
|
|
156
|
+
function pushWithProgress(cwd, branch) {
|
|
157
|
+
return new Promise((resolve) => {
|
|
158
|
+
const errBuf = [];
|
|
159
|
+
let bufLen = 0;
|
|
160
|
+
const p = spawn("git", ["push", "--progress", "--set-upstream", "origin", branch], { cwd, stdio: ["ignore", "inherit", "pipe"] });
|
|
161
|
+
p.stderr.on("data", (chunk) => {
|
|
162
|
+
// Tee to both terminal (so live progress still shows) AND buffer (so we
|
|
163
|
+
// can scan for GitHub push-protection markers after exit).
|
|
164
|
+
process.stderr.write(chunk);
|
|
165
|
+
const s = chunk.toString();
|
|
166
|
+
errBuf.push(s);
|
|
167
|
+
bufLen += s.length;
|
|
168
|
+
if (bufLen > 8192) {
|
|
169
|
+
const drop = errBuf.shift();
|
|
170
|
+
if (drop)
|
|
171
|
+
bufLen -= drop.length;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
p.on("error", () => resolve({ ok: false, secretBlocked: false, stderrTail: errBuf.join("") }));
|
|
175
|
+
p.on("close", (code) => {
|
|
176
|
+
const tail = errBuf.join("");
|
|
177
|
+
resolve({
|
|
178
|
+
ok: code === 0,
|
|
179
|
+
secretBlocked: code !== 0 && SECRET_BLOCK_RE.test(tail),
|
|
180
|
+
stderrTail: tail.slice(-4096),
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
export async function commitAndPush(git, message, paths, branch, onProgress) {
|
|
186
|
+
if (paths.length === 0)
|
|
187
|
+
return { committed: false, pushed: false };
|
|
188
|
+
onProgress?.(`git add (${paths.length} paths)...`);
|
|
189
|
+
await git.add(paths);
|
|
190
|
+
const status = await git.status();
|
|
191
|
+
if (status.staged.length === 0)
|
|
192
|
+
return { committed: false, pushed: false };
|
|
193
|
+
onProgress?.(`git commit (${status.staged.length} staged)...`);
|
|
194
|
+
await git.commit(message);
|
|
195
|
+
onProgress?.(`git push origin ${branch} (live progress below):`);
|
|
196
|
+
const cwd = await git.revparse(["--show-toplevel"]).then((s) => s.trim());
|
|
197
|
+
const r = await pushWithProgress(cwd, branch);
|
|
198
|
+
return { committed: true, pushed: r.ok, pushResult: r };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Run `writeFiles()` on a temporary side-checkout of `main` (or create main
|
|
202
|
+
* as an orphan if it doesn't exist remotely), commit + push, then restore
|
|
203
|
+
* the caller's current branch. Used by `memarium workflow init` so CI
|
|
204
|
+
* workflow files land on main (where GitHub Actions actually reads them)
|
|
205
|
+
* without disturbing the user's device branch or working tree.
|
|
206
|
+
*
|
|
207
|
+
* Strategy: use `git worktree add` to materialize main into a temp dir.
|
|
208
|
+
* This avoids switching the user's primary working tree at all — their
|
|
209
|
+
* uncommitted changes, current HEAD, and checked-out branch are untouched.
|
|
210
|
+
*
|
|
211
|
+
* The provided `writeFiles(worktreePath)` callback receives the absolute
|
|
212
|
+
* path of the temp worktree and is responsible for writing the files there.
|
|
213
|
+
*
|
|
214
|
+
* On success returns `{ committed: true, pushed: true }`. On no-op (files
|
|
215
|
+
* already up to date) returns `{ committed: false, pushed: false }`. The
|
|
216
|
+
* temp worktree is always cleaned up.
|
|
217
|
+
*/
|
|
218
|
+
export async function commitToMainViaWorktree(git, repoPath, writeFiles, commitMessage, onProgress) {
|
|
219
|
+
const { mkdtempSync, rmSync } = await import("node:fs");
|
|
220
|
+
const { tmpdir } = await import("node:os");
|
|
221
|
+
const { join } = await import("node:path");
|
|
222
|
+
// Fetch so we know whether origin/main exists.
|
|
223
|
+
try {
|
|
224
|
+
await git.fetch();
|
|
225
|
+
}
|
|
226
|
+
catch { /* offline */ }
|
|
227
|
+
const remoteMainExists = (await git.branch(["-r"]))
|
|
228
|
+
.all.some((b) => b === "origin/main");
|
|
229
|
+
const worktreePath = mkdtempSync(join(tmpdir(), "memarium-main-"));
|
|
230
|
+
// Use a unique temp branch name (not "main") to avoid the worktree conflict
|
|
231
|
+
// git raises if the user's primary working tree is currently on `main`:
|
|
232
|
+
// fatal: 'main' is already used by worktree at <repoPath>
|
|
233
|
+
// We push to origin/main via `HEAD:main` refspec instead of relying on a
|
|
234
|
+
// locally-named branch.
|
|
235
|
+
const tempBranch = `memarium-tmp-main-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
236
|
+
// simple-git doesn't expose worktree directly; use raw.
|
|
237
|
+
try {
|
|
238
|
+
if (remoteMainExists) {
|
|
239
|
+
onProgress?.(`creating temp worktree on main (via ${tempBranch})...`);
|
|
240
|
+
// -b <tempBranch> at origin/main: create the new branch in the worktree.
|
|
241
|
+
// This new branch ref lives in the primary repo (not the worktree only),
|
|
242
|
+
// so we delete it in finally.
|
|
243
|
+
await git.raw(["worktree", "add", "-b", tempBranch, worktreePath, "origin/main"]);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
// Brand-new repo with no main yet. Create an orphan branch (not named main).
|
|
247
|
+
onProgress?.(`creating orphan temp branch ${tempBranch} (no remote main yet)...`);
|
|
248
|
+
await git.raw(["worktree", "add", "--detach", worktreePath]);
|
|
249
|
+
const { simpleGit } = await import("simple-git");
|
|
250
|
+
const wgit = simpleGit(worktreePath);
|
|
251
|
+
await wgit.raw(["checkout", "--orphan", tempBranch]);
|
|
252
|
+
// Clear any inherited files from the parent ref.
|
|
253
|
+
await wgit.raw(["rm", "-rf", "."]).catch(() => undefined);
|
|
254
|
+
}
|
|
255
|
+
onProgress?.(`writing files into temp worktree...`);
|
|
256
|
+
const relPaths = await writeFiles(worktreePath);
|
|
257
|
+
const { simpleGit } = await import("simple-git");
|
|
258
|
+
const wgit = simpleGit(worktreePath);
|
|
259
|
+
await wgit.add(relPaths);
|
|
260
|
+
const status = await wgit.status();
|
|
261
|
+
if (status.staged.length === 0) {
|
|
262
|
+
onProgress?.(`nothing changed on main`);
|
|
263
|
+
return { committed: false, pushed: false };
|
|
264
|
+
}
|
|
265
|
+
onProgress?.(`git commit on ${tempBranch} (${status.staged.length} staged)...`);
|
|
266
|
+
await wgit.commit(commitMessage);
|
|
267
|
+
onProgress?.(`git push origin HEAD:main...`);
|
|
268
|
+
// Push the temp branch's HEAD to the remote `main` ref. This works
|
|
269
|
+
// whether or not we have a local `main` branch — we explicitly use
|
|
270
|
+
// a refspec.
|
|
271
|
+
try {
|
|
272
|
+
await wgit.raw(["push", "origin", `HEAD:main`]);
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
throw new Error(`push origin HEAD:main failed: ${err.message}`);
|
|
276
|
+
}
|
|
277
|
+
return { committed: true, pushed: true };
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
// Always clean up the worktree AND the temp branch, even on failure.
|
|
281
|
+
// `git worktree remove` tolerates a missing dir; we follow it with a
|
|
282
|
+
// filesystem rm just in case. Then delete the temp branch ref so it
|
|
283
|
+
// doesn't leak into the user's repo.
|
|
284
|
+
try {
|
|
285
|
+
await git.raw(["worktree", "remove", "--force", worktreePath]);
|
|
286
|
+
}
|
|
287
|
+
catch { /* ignore */ }
|
|
288
|
+
try {
|
|
289
|
+
rmSync(worktreePath, { recursive: true, force: true });
|
|
290
|
+
}
|
|
291
|
+
catch { /* ignore */ }
|
|
292
|
+
try {
|
|
293
|
+
await git.branch(["-D", tempBranch]);
|
|
294
|
+
}
|
|
295
|
+
catch { /* ignore — never created */ }
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Bring the local device branch in sync with origin before we try to push,
|
|
300
|
+
* so the GitHub Action's auto-commits don't cause non-fast-forward push
|
|
301
|
+
* failures on the next `memarium sync` / `digest` run.
|
|
302
|
+
*
|
|
303
|
+
* Sequence:
|
|
304
|
+
* 1. fetch origin
|
|
305
|
+
* 2. if no remote tracking ref exists → skip (fresh branch, nothing to pull)
|
|
306
|
+
* 3. try `pull --rebase --autostash` — handles both fast-forward and
|
|
307
|
+
* diverged-history cases, and auto-stashes any unstaged digest output
|
|
308
|
+
* sitting in the working tree
|
|
309
|
+
* 4. on rebase conflict: abort cleanly and throw a friendly error pointing
|
|
310
|
+
* the user at the repo path. We deliberately do NOT try to auto-resolve
|
|
311
|
+
* (digest output + rebased remote changes are too risky to merge blindly)
|
|
312
|
+
*
|
|
313
|
+
* Caller is expected to handle the thrown error — surface the path to the
|
|
314
|
+
* user and skip push for this run.
|
|
315
|
+
*/
|
|
316
|
+
export async function fastForwardBranch(git, branch, onProgress) {
|
|
317
|
+
let hasRemote = false;
|
|
318
|
+
try {
|
|
319
|
+
const remotes = await git.getRemotes(false);
|
|
320
|
+
hasRemote = remotes.some((r) => r.name === "origin");
|
|
321
|
+
}
|
|
322
|
+
catch { /* ignore */ }
|
|
323
|
+
if (!hasRemote)
|
|
324
|
+
return { pulled: false, reason: "no-remote" };
|
|
325
|
+
onProgress?.(`git fetch origin...`);
|
|
326
|
+
try {
|
|
327
|
+
await git.fetch("origin", branch);
|
|
328
|
+
}
|
|
329
|
+
catch { /* upstream branch may not exist yet */ }
|
|
330
|
+
// Check whether origin/<branch> ref exists locally after the fetch.
|
|
331
|
+
let hasUpstream = false;
|
|
332
|
+
try {
|
|
333
|
+
const refs = await git.branch(["-r"]);
|
|
334
|
+
hasUpstream = refs.all.includes(`origin/${branch}`);
|
|
335
|
+
}
|
|
336
|
+
catch { /* ignore */ }
|
|
337
|
+
if (!hasUpstream)
|
|
338
|
+
return { pulled: false, reason: "no-tracking" };
|
|
339
|
+
onProgress?.(`git pull --rebase --autostash origin ${branch}...`);
|
|
340
|
+
try {
|
|
341
|
+
await git.raw(["pull", "--rebase", "--autostash", "origin", branch]);
|
|
342
|
+
return { pulled: true };
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
// Rebase conflict (or autostash-pop conflict). Clean up so we leave the
|
|
346
|
+
// working tree in a sane state, then throw with actionable guidance.
|
|
347
|
+
try {
|
|
348
|
+
await git.raw(["rebase", "--abort"]);
|
|
349
|
+
}
|
|
350
|
+
catch { /* not in rebase */ }
|
|
351
|
+
try {
|
|
352
|
+
await git.raw(["stash", "pop"]);
|
|
353
|
+
}
|
|
354
|
+
catch { /* nothing to pop */ }
|
|
355
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
356
|
+
throw new Error(`Could not fast-forward / rebase '${branch}' onto origin/${branch}. ` +
|
|
357
|
+
`Resolve manually in the repo, then re-run. Original error:\n${msg}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { INDEX_REL, dataDirAbs } from "./repo-data-dir.js";
|
|
4
|
+
export function loadIndex(repoRoot) {
|
|
5
|
+
const p = join(repoRoot, INDEX_REL);
|
|
6
|
+
if (!existsSync(p))
|
|
7
|
+
return { version: 1, entries: {} };
|
|
8
|
+
const parsed = JSON.parse(readFileSync(p, "utf8"));
|
|
9
|
+
if (parsed.version !== 1)
|
|
10
|
+
throw new Error(`unsupported index version: ${parsed.version}`);
|
|
11
|
+
return parsed;
|
|
12
|
+
}
|
|
13
|
+
export function saveIndex(repoRoot, idx) {
|
|
14
|
+
const p = join(repoRoot, INDEX_REL);
|
|
15
|
+
mkdirSync(dataDirAbs(repoRoot), { recursive: true });
|
|
16
|
+
writeFileSync(p, JSON.stringify(idx, null, 2) + "\n");
|
|
17
|
+
}
|
|
18
|
+
export function keyFor(tool, sessionId) {
|
|
19
|
+
return `${tool}:${sessionId}`;
|
|
20
|
+
}
|
|
21
|
+
export function upsertEntry(idx, entry) {
|
|
22
|
+
idx.entries[keyFor(entry.tool, entry.sessionId)] = entry;
|
|
23
|
+
}
|
|
24
|
+
export function hasUnchanged(idx, tool, sessionId, mtimeMs, sha256, repoRoot) {
|
|
25
|
+
const e = idx.entries[keyFor(tool, sessionId)];
|
|
26
|
+
if (!e || e.sourceMtimeMs !== mtimeMs || e.sourceSha256 !== sha256)
|
|
27
|
+
return false;
|
|
28
|
+
// The index can survive branch switches that leave raw_sessions/ incomplete
|
|
29
|
+
// — if the indexed file is missing from the working tree, treat it as stale
|
|
30
|
+
// so the entry gets re-extracted. Otherwise the new branch would never
|
|
31
|
+
// collect the file it's missing.
|
|
32
|
+
if (!existsSync(join(repoRoot, e.relativePath)))
|
|
33
|
+
return false;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { simpleGit } from "simple-git";
|
|
4
|
+
import { LEGACY_REPO_DATA_DIRS, REPO_DATA_DIR } from "./repo-data-dir.js";
|
|
5
|
+
/**
|
|
6
|
+
* One-shot migration for repos created before per-device-branches existed.
|
|
7
|
+
*
|
|
8
|
+
* If the local repo has a `main` branch but no `<device>` branch, rename
|
|
9
|
+
* main → <device> (preserving history) so the device branch becomes the
|
|
10
|
+
* new write target. `main` is left unborn on purpose — it will be re-created
|
|
11
|
+
* later (manually or by a future merge-to-main command) as the aggregate view.
|
|
12
|
+
*
|
|
13
|
+
* No-op when:
|
|
14
|
+
* - the device branch already exists (migration was already done, or a
|
|
15
|
+
* fresh clone is already on the right branch)
|
|
16
|
+
* - there is no `main` branch to rename
|
|
17
|
+
*/
|
|
18
|
+
export async function migrateLegacyMainToDevice(repoPath, deviceBranch) {
|
|
19
|
+
const git = simpleGit(repoPath);
|
|
20
|
+
const local = await git.branchLocal();
|
|
21
|
+
if (local.all.includes(deviceBranch))
|
|
22
|
+
return { migrated: false };
|
|
23
|
+
if (!local.all.includes("main"))
|
|
24
|
+
return { migrated: false };
|
|
25
|
+
if (local.current !== "main")
|
|
26
|
+
await git.checkout("main");
|
|
27
|
+
await git.branch(["-m", "main", deviceBranch]);
|
|
28
|
+
return { migrated: true };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* One-shot migration: rename an in-repo legacy data dir → `.memarium/`.
|
|
32
|
+
*
|
|
33
|
+
* The project was renamed memvc → vibebook → memarium. Any sync/digest run
|
|
34
|
+
* that finds a legacy dir (`.vibebook/`, else `.memvc/`) and no `.memarium/`
|
|
35
|
+
* moves it via `git mv` (so history follows) and stages it for the next
|
|
36
|
+
* commit. Returns the legacy dir it migrated from (for logging).
|
|
37
|
+
*
|
|
38
|
+
* No-op when:
|
|
39
|
+
* - the repo has no legacy dir (fresh repo, or migration already done)
|
|
40
|
+
* - the repo already has `.memarium/` (migration already done)
|
|
41
|
+
* - the repo isn't a git repo (we still do a non-git rename so non-pushing
|
|
42
|
+
* local-only mode works)
|
|
43
|
+
*/
|
|
44
|
+
export async function migrateLegacyDataDir(repoPath) {
|
|
45
|
+
const target = join(repoPath, REPO_DATA_DIR);
|
|
46
|
+
if (existsSync(target))
|
|
47
|
+
return { migrated: false, viaGit: false };
|
|
48
|
+
const from = LEGACY_REPO_DATA_DIRS.find((d) => existsSync(join(repoPath, d)));
|
|
49
|
+
if (!from)
|
|
50
|
+
return { migrated: false, viaGit: false };
|
|
51
|
+
const isGitRepo = existsSync(join(repoPath, ".git"));
|
|
52
|
+
if (isGitRepo) {
|
|
53
|
+
const git = simpleGit(repoPath);
|
|
54
|
+
// git mv preserves history. Use the directory form; git stages every file
|
|
55
|
+
// under it. The result is staged but not committed — runSync's commit
|
|
56
|
+
// bundles it with the rest of the sync's paths.
|
|
57
|
+
await git.raw(["mv", from, REPO_DATA_DIR]);
|
|
58
|
+
return { migrated: true, viaGit: true, from };
|
|
59
|
+
}
|
|
60
|
+
// Non-git fallback: plain rename. Used by local-only mode + tests that
|
|
61
|
+
// never init a git repo.
|
|
62
|
+
const { renameSync } = await import("node:fs");
|
|
63
|
+
renameSync(join(repoPath, from), target);
|
|
64
|
+
return { migrated: true, viaGit: false, from };
|
|
65
|
+
}
|
|
66
|
+
/** Returns the list of repo-rooted paths a successful data-dir migration produces, suitable for `git add`. */
|
|
67
|
+
export function migratedDataDirPaths(repoPath) {
|
|
68
|
+
const dir = join(repoPath, REPO_DATA_DIR);
|
|
69
|
+
if (!existsSync(dir))
|
|
70
|
+
return [];
|
|
71
|
+
return readdirSync(dir).map((f) => `${REPO_DATA_DIR}/${f}`);
|
|
72
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { simpleGit } from "simple-git";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { projectSlugFromPath } from "./slug.js";
|
|
4
|
+
/**
|
|
5
|
+
* Canonical, path-INDEPENDENT project identity (P0a).
|
|
6
|
+
*
|
|
7
|
+
* Project memory must aggregate + recall correctly across devices, but the
|
|
8
|
+
* same repo can live at a different filesystem path per machine
|
|
9
|
+
* (`~/edge/memvc` vs `~/work/memvc` vs `~/projects/memvc`). The legacy slug =
|
|
10
|
+
* `projectSlugFromPath` (last two path segments) splits those into different
|
|
11
|
+
* projects, so raw_sessions folders / memory ids / book all diverge and never
|
|
12
|
+
* aggregate. Coding projects are git-maintained, and a repo's `origin` remote
|
|
13
|
+
* is identical on every clone — so the remote is the stable identity.
|
|
14
|
+
*
|
|
15
|
+
* `canonicalProjectId` collapses every remote URL form to one `host/path` id;
|
|
16
|
+
* `resolveProjectId` turns a cwd into a filesystem/index-safe slug, preferring
|
|
17
|
+
* the remote and falling back to the path slug when there's no git remote.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Normalize any git remote URL to a canonical `host/path` id, collapsing SSH
|
|
21
|
+
* SCP (`git@host:org/repo.git`), `https://`, `ssh://`, `git://`, and
|
|
22
|
+
* credentialed forms to the same value. Host is lowercased (clones don't drift
|
|
23
|
+
* in host case); path case is preserved (org/repo can be case-significant).
|
|
24
|
+
* Returns null for unparseable / local (no-host) remotes → caller falls back.
|
|
25
|
+
*
|
|
26
|
+
* git@github.com:june9593/memvc.git -> github.com/june9593/memvc
|
|
27
|
+
* https://github.com/june9593/memvc(.git)(/) -> github.com/june9593/memvc
|
|
28
|
+
* https://x-token:abc@github.com/o/r.git -> github.com/o/r
|
|
29
|
+
* ssh://git@gitlab.corp:22/grp/sub/proj.git -> gitlab.corp/grp/sub/proj
|
|
30
|
+
*/
|
|
31
|
+
export function canonicalProjectId(remoteUrl) {
|
|
32
|
+
const s0 = (remoteUrl ?? "").trim();
|
|
33
|
+
if (!s0)
|
|
34
|
+
return null;
|
|
35
|
+
let host;
|
|
36
|
+
let path;
|
|
37
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(s0)) {
|
|
38
|
+
// scheme://[user[:pass]@]host[:port]/path
|
|
39
|
+
let rest = s0.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "");
|
|
40
|
+
rest = rest.replace(/^[^@/]+@/, ""); // strip credentials
|
|
41
|
+
const slash = rest.indexOf("/");
|
|
42
|
+
if (slash < 0)
|
|
43
|
+
return null;
|
|
44
|
+
host = rest.slice(0, slash);
|
|
45
|
+
path = rest.slice(slash + 1);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
// SCP-like: [user@]host:path (reject host:port/... which isn't SCP)
|
|
49
|
+
const m = s0.match(/^(?:[^@/]+@)?([^/:]+):(.+)$/);
|
|
50
|
+
if (!m)
|
|
51
|
+
return null; // local path / junk → fallback
|
|
52
|
+
if (/^\d+$/.test(m[2].split("/")[0]))
|
|
53
|
+
return null; // host:port/path, not SCP
|
|
54
|
+
host = m[1];
|
|
55
|
+
path = m[2];
|
|
56
|
+
}
|
|
57
|
+
host = host.replace(/:\d+$/, "").toLowerCase(); // strip port, lowercase host
|
|
58
|
+
path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
|
|
59
|
+
if (!host || !path)
|
|
60
|
+
return null;
|
|
61
|
+
return `${host}/${path}`;
|
|
62
|
+
}
|
|
63
|
+
/** Filesystem/index-safe slug from a remote URL, or null if not parseable.
|
|
64
|
+
* `github.com/june9593/memvc` -> `github.com-june9593-memvc`. */
|
|
65
|
+
export function projectSlugFromRemote(remoteUrl) {
|
|
66
|
+
const id = canonicalProjectId(remoteUrl);
|
|
67
|
+
if (!id)
|
|
68
|
+
return null;
|
|
69
|
+
return id.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a cwd / repo-root to its stable project slug. Prefers the git
|
|
73
|
+
* `origin` remote (path-independent → aggregates across devices); falls back to
|
|
74
|
+
* the legacy `projectSlugFromPath` when there's no remote / not a git repo
|
|
75
|
+
* (a non-git project has no cross-device identity anyway). `getRemote` is
|
|
76
|
+
* injectable so the pure logic is testable without a real repo.
|
|
77
|
+
*/
|
|
78
|
+
export async function resolveProjectId(cwdOrRoot, getRemote = defaultGetRemote) {
|
|
79
|
+
let remote = null;
|
|
80
|
+
try {
|
|
81
|
+
remote = await getRemote(cwdOrRoot);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
remote = null;
|
|
85
|
+
}
|
|
86
|
+
const slug = projectSlugFromRemote(remote);
|
|
87
|
+
if (slug)
|
|
88
|
+
return { slug, source: "remote", canonical: canonicalProjectId(remote) };
|
|
89
|
+
return { slug: projectSlugFromPath(cwdOrRoot), source: "path", canonical: null };
|
|
90
|
+
}
|
|
91
|
+
/** Read `remote.origin.url` at `dir` (or any ancestor git repo). Null if none. */
|
|
92
|
+
async function defaultGetRemote(dir) {
|
|
93
|
+
try {
|
|
94
|
+
const v = (await simpleGit(dir).getConfig("remote.origin.url")).value;
|
|
95
|
+
return v && v.trim() ? v.trim() : null;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Synchronous remote read via `git config` — for sync call sites (the read
|
|
102
|
+
* chokepoint) where an async ripple isn't worth it. A short-timeout
|
|
103
|
+
* `spawnSync` shelling out to a CLI, the same pattern doctor.ts uses for its
|
|
104
|
+
* `--version` probes. */
|
|
105
|
+
function defaultGetRemoteSync(dir) {
|
|
106
|
+
const r = spawnSync("git", ["-C", dir, "config", "--get", "remote.origin.url"], {
|
|
107
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000,
|
|
108
|
+
});
|
|
109
|
+
if (r.status !== 0)
|
|
110
|
+
return null;
|
|
111
|
+
const v = (r.stdout ?? "").trim();
|
|
112
|
+
return v ? v : null;
|
|
113
|
+
}
|
|
114
|
+
/** Sync sibling of `resolveProjectId` — same remote-first, path-fallback logic. */
|
|
115
|
+
export function resolveProjectIdSync(cwdOrRoot, getRemote = defaultGetRemoteSync) {
|
|
116
|
+
let remote = null;
|
|
117
|
+
try {
|
|
118
|
+
remote = getRemote(cwdOrRoot);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
remote = null;
|
|
122
|
+
}
|
|
123
|
+
const slug = projectSlugFromRemote(remote);
|
|
124
|
+
if (slug)
|
|
125
|
+
return { slug, source: "remote", canonical: canonicalProjectId(remote) };
|
|
126
|
+
return { slug: projectSlugFromPath(cwdOrRoot), source: "path", canonical: null };
|
|
127
|
+
}
|
|
128
|
+
const _slugCache = new Map();
|
|
129
|
+
/** `resolveProjectIdSync(dir).slug`, memoized per dir for one process. A sync
|
|
130
|
+
* run resolves the same project roots repeatedly (per session / per tool-use
|
|
131
|
+
* path); without this each call re-spawns `git config`. */
|
|
132
|
+
export function cachedProjectSlug(dir) {
|
|
133
|
+
const hit = _slugCache.get(dir);
|
|
134
|
+
if (hit !== undefined)
|
|
135
|
+
return hit;
|
|
136
|
+
const slug = resolveProjectIdSync(dir).slug;
|
|
137
|
+
_slugCache.set(dir, slug);
|
|
138
|
+
return slug;
|
|
139
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { loadIndex } from "./index-store.js";
|
|
2
|
+
import { projectSlugFromPath } from "./slug.js";
|
|
3
|
+
import { resolveProjectIdSync } from "./project-identity.js";
|
|
4
|
+
/**
|
|
5
|
+
* Reverse-lookup a project slug from an absolute cwd.
|
|
6
|
+
*
|
|
7
|
+
* 1. Resolve the stable project identity (git remote → slug; path slug when
|
|
8
|
+
* no remote — `resolveProjectIdSync`) and check the index has a session
|
|
9
|
+
* for it. The sync adapters compute the project the same way, so this is
|
|
10
|
+
* authoritative.
|
|
11
|
+
* 2. Also try the legacy path slug, so a repo whose data was written before
|
|
12
|
+
* the remote-identity switch (or a non-git project) still resolves.
|
|
13
|
+
* 3. Fall back to scanning index entries for one whose `projectRaw` === cwd.
|
|
14
|
+
*
|
|
15
|
+
* Returns null if none match — caller decides how to error.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveProjectFromCwd(cwd, repoPath) {
|
|
18
|
+
const indexFile = loadIndex(repoPath);
|
|
19
|
+
return resolveProjectFromCwdWithIndex(cwd, indexFile.entries);
|
|
20
|
+
}
|
|
21
|
+
/** Variant for when the caller already has the index loaded (avoid double-read). */
|
|
22
|
+
export function resolveProjectFromCwdWithIndex(cwd, entries) {
|
|
23
|
+
// Prefer the stable (remote-based) slug; fall back to the legacy path slug so
|
|
24
|
+
// un-migrated / non-git projects still resolve during the transition.
|
|
25
|
+
const candidates = [];
|
|
26
|
+
const remoteSlug = resolveProjectIdSync(cwd).slug;
|
|
27
|
+
candidates.push(remoteSlug);
|
|
28
|
+
const pathSlug = projectSlugFromPath(cwd);
|
|
29
|
+
if (pathSlug !== remoteSlug)
|
|
30
|
+
candidates.push(pathSlug);
|
|
31
|
+
for (const cand of candidates) {
|
|
32
|
+
for (const e of Object.values(entries)) {
|
|
33
|
+
if (e.project === cand)
|
|
34
|
+
return cand;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (const e of Object.values(entries)) {
|
|
38
|
+
if (e.projectRaw === cwd)
|
|
39
|
+
return e.project;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|