From c6f28474e47f0bb76bf001cf2e69b2dfc7ae94c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 02:32:02 +0000 Subject: [PATCH] fix: escape rewrite replacement, diagnostic no-op commit, doc + askpass hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use function replacer in rewriteBody to prevent $& / $$ / $` / $' pattern corruption when alt text or siteUrl contains dollar-sign sequences - Detect empty staged index after `git add -A` and throw a clear "No changes to publish" error instead of a cryptic git failure - Correct README flat-slug description: single image → ., multiple → -1., -2. (original filename discarded) - Harden askpass dir resolution: show a Notice and return early if getFullPath is absent (desktop-only guard), rather than passing a bad path Co-Authored-By: Claude --- README.md | 2 +- src/git.test.ts | 19 +++++++++++++++++++ src/git.ts | 10 ++++++++++ src/images.test.ts | 8 ++++++++ src/images.ts | 2 +- src/main.ts | 9 ++++++--- 6 files changed, 45 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0b84a05..d4279b9 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ An Obsidian plugin that publishes the active note as a Jekyll blog post via git. ## Image strategies -**flat-slug** — all images for the post are copied flat into `/` and renamed to `-`. Keeps the images directory shallow; suitable for sites with few images per post. +**flat-slug** — all images for the post are copied flat into `/`. A single image is renamed to `.`; multiple images are renamed to `-1.`, `-2.`, … (the original Obsidian filename is discarded). Keeps the images directory shallow; suitable for sites with few images per post. **per-post-folder** — images are copied into `//` preserving their original filenames. Keeps each post's images grouped together; suitable for posts with many images. diff --git a/src/git.test.ts b/src/git.test.ts index 9929c27..7019fa0 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -50,3 +50,22 @@ test("second sync resets cleanly (fetch+reset path)", async () => { execFileSync("git", ["clone", bare, verify]); expect(readFileSync(join(verify, "_posts/second.md"), "utf8")).toBe("x"); }); + +test("commitAndPush rejects with diagnostic error when nothing is staged", async () => { + // Use a fresh base dir so we get a clean clone + const base2 = join(root, "work2"); + mkdirSync(base2, { recursive: true }); + const client = new ChildProcessGitClient({ baseDir: base2 }); + // Clone and write a file, then commit it + await client.syncClone({ url: bare, branch: "main" }); + await client.writeFiles([{ repoPath: "_posts/no-change.md", data: "same content" }]); + await client.commitAndPush({ message: "initial no-change", branch: "main", authorName: "t", authorEmail: "t@t" }); + + // Now sync again — tree now matches origin, re-writing the same content stages nothing + const client2 = new ChildProcessGitClient({ baseDir: base2 }); + await client2.syncClone({ url: bare, branch: "main" }); + await client2.writeFiles([{ repoPath: "_posts/no-change.md", data: "same content" }]); + await expect( + client2.commitAndPush({ message: "noop", branch: "main", authorName: "t", authorEmail: "t@t" }) + ).rejects.toThrow(/no changes to publish/i); +}); diff --git a/src/git.ts b/src/git.ts index 2160c34..120329d 100644 --- a/src/git.ts +++ b/src/git.ts @@ -66,6 +66,16 @@ export class ChildProcessGitClient implements GitClient { async commitAndPush(o: { message: string; branch: string; authorName?: string; authorEmail?: string }): Promise { await this.git(this.workdir, ["add", "-A"]); + let hasChanges = false; + try { + await this.git(this.workdir, ["diff", "--cached", "--quiet"]); + // exit 0 => no staged changes + } catch { + hasChanges = true; // exit 1 => staged changes exist + } + if (!hasChanges) { + throw new Error("No changes to publish: the post and images are identical to what is already committed."); + } const cfg: string[] = []; if (o.authorName) cfg.push("-c", `user.name=${o.authorName}`); if (o.authorEmail) cfg.push("-c", `user.email=${o.authorEmail}`); diff --git a/src/images.test.ts b/src/images.test.ts index 2c6d5a0..35a9e31 100644 --- a/src/images.test.ts +++ b/src/images.test.ts @@ -67,4 +67,12 @@ describe("rewriteBody", () => { "![](/assets/img/post-1.png)\n![cap](/assets/img/post-2.jpeg)\n![d](/assets/img/post-3.svg)" ); }); + + test("alt text containing $ patterns is preserved verbatim (no replacement-pattern corruption)", () => { + const slug = "my-post"; + const body = "![a $& b $$ c](local.png)"; + const refs = findImageRefs(body); + const { rewrittenBody } = planImages(refs, { slug, strategy: "flat-slug", imagesDir: "assets/img", body }); + expect(rewrittenBody).toBe("![a $& b $$ c](/assets/img/my-post.png)"); + }); }); diff --git a/src/images.ts b/src/images.ts index 2011c66..d9e3a27 100644 --- a/src/images.ts +++ b/src/images.ts @@ -69,7 +69,7 @@ export function rewriteBody(body: string, refs: ImageRef[], plan: ImagePlanItem[ for (const ref of refs) { const siteUrl = url.get(ref.linktext); if (!siteUrl) continue; - out = out.replace(ref.raw, `![${ref.alt}](${siteUrl})`); + out = out.replace(ref.raw, () => `![${ref.alt}](${siteUrl})`); } return out; } diff --git a/src/main.ts b/src/main.ts index 08989cc..791befd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -44,10 +44,13 @@ export default class JekyllPublishPlugin extends Plugin { return; } const dir = normalizePath(this.app.vault.configDir + "/plugins/jekyll-publish"); + const adapter = this.app.vault.adapter as any; + if (typeof adapter.getFullPath !== "function") { + new Notice("Jekyll Publish requires desktop Obsidian (filesystem access unavailable)."); + return; + } const bridge = new AskpassBridge({ - dir: (this.app.vault.adapter as any).getFullPath - ? (this.app.vault.adapter as any).getFullPath(dir) - : dir, + dir: adapter.getFullPath(dir), onPrompt: (prompt) => this.promptCredential(prompt), }); try {