Explorar el Código

fix(image-hook): back up legacy ignore rule

Alvin Unreal hace 1 mes
padre
commit
0c6aef1c56
Se han modificado 2 ficheros con 142 adiciones y 1 borrados
  1. 85 1
      src/hooks/image-hook.test.ts
  2. 57 0
      src/hooks/image-hook.ts

+ 85 - 1
src/hooks/image-hook.test.ts

@@ -4,6 +4,7 @@ import { createHash } from 'node:crypto';
 import {
   chmodSync,
   existsSync,
+  linkSync,
   lstatSync,
   mkdirSync,
   readdirSync,
@@ -26,6 +27,7 @@ const IMG_HASH = createHash('sha1').update(IMG_BYTES).digest('hex').slice(0, 8);
 const IMG_CONTENT_NAME = `image-${IMG_HASH}.png`;
 const LEGACY_GITIGNORE = '*\n';
 const LEGACY_GITIGNORE_BYTES = Buffer.from(LEGACY_GITIGNORE);
+const LEGACY_GITIGNORE_BACKUP = '.gitignore.oh-my-opencode-slim-legacy';
 const IMAGES_GITIGNORE = 'images/\n';
 const IMAGES_GITIGNORE_BYTES = Buffer.from(IMAGES_GITIGNORE);
 
@@ -40,6 +42,10 @@ function gitignorePath(workDir: string): string {
   return path.join(workDir, '.opencode', '.gitignore');
 }
 
+function legacyGitignoreBackupPath(workDir: string): string {
+  return path.join(workDir, '.opencode', LEGACY_GITIGNORE_BACKUP);
+}
+
 function writeOpencodeGitignore(
   workDir: string,
   content: string | Buffer,
@@ -164,18 +170,25 @@ describe('processImageAttachments image routing', () => {
   it('migrates exact legacy * gitignore before early returns (direct/text-only)', () => {
     const workDir = path.join(TEST_DIR, 'gitignore-legacy-direct');
     writeOpencodeGitignore(workDir, LEGACY_GITIGNORE_BYTES);
+    const logs: string[] = [];
 
     processImageAttachments({
       messages: [makeUserMsg([{ type: 'text', text: 'no images' }])],
       workDir,
       imageRouting: 'direct',
       disabledAgents: new Set<string>(),
-      log: () => {},
+      log: (message) => logs.push(message),
     });
 
     expect(readFileSync(gitignorePath(workDir))).toEqual(
       IMAGES_GITIGNORE_BYTES,
     );
+    expect(readFileSync(legacyGitignoreBackupPath(workDir))).toEqual(
+      LEGACY_GITIGNORE_BYTES,
+    );
+    expect(logs.some((message) => message.includes('backup created at'))).toBe(
+      true,
+    );
   });
 
   it('legacy gitignore migration is idempotent', () => {
@@ -195,10 +208,80 @@ describe('processImageAttachments image routing', () => {
     expect(readFileSync(gitignorePath(workDir))).toEqual(
       IMAGES_GITIGNORE_BYTES,
     );
+    const backupAfterFirst = readFileSync(legacyGitignoreBackupPath(workDir));
     run();
     expect(readFileSync(gitignorePath(workDir))).toEqual(
       IMAGES_GITIGNORE_BYTES,
     );
+    expect(readFileSync(legacyGitignoreBackupPath(workDir))).toEqual(
+      backupAfterFirst,
+    );
+  });
+
+  it('uses an exact existing legacy gitignore backup unchanged', () => {
+    const workDir = path.join(TEST_DIR, 'gitignore-existing-backup');
+    const existingBackup = LEGACY_GITIGNORE_BYTES;
+    writeOpencodeGitignore(workDir, LEGACY_GITIGNORE_BYTES);
+    writeFileSync(legacyGitignoreBackupPath(workDir), existingBackup);
+
+    processImageAttachments({
+      messages: [makeUserMsg([{ type: 'text', text: 'hello' }])],
+      workDir,
+      imageRouting: 'direct',
+      disabledAgents: new Set<string>(),
+      log: () => {},
+    });
+
+    expect(readFileSync(gitignorePath(workDir))).toEqual(
+      IMAGES_GITIGNORE_BYTES,
+    );
+    expect(readFileSync(legacyGitignoreBackupPath(workDir))).toEqual(
+      existingBackup,
+    );
+  });
+
+  it('keeps an invalid existing backup and legacy gitignore unchanged', () => {
+    const workDir = path.join(TEST_DIR, 'gitignore-invalid-backup');
+    const invalidBackup = Buffer.from('# unrelated backup\n');
+    writeOpencodeGitignore(workDir, LEGACY_GITIGNORE_BYTES);
+    writeFileSync(legacyGitignoreBackupPath(workDir), invalidBackup);
+    const logs: string[] = [];
+
+    processImageAttachments({
+      messages: [makeUserMsg([{ type: 'text', text: 'hello' }])],
+      workDir,
+      imageRouting: 'direct',
+      disabledAgents: new Set<string>(),
+      log: (message) => logs.push(message),
+    });
+
+    expect(readFileSync(gitignorePath(workDir))).toEqual(
+      LEGACY_GITIGNORE_BYTES,
+    );
+    expect(readFileSync(legacyGitignoreBackupPath(workDir))).toEqual(
+      invalidBackup,
+    );
+    expect(
+      logs.some((message) => message.includes('backup is not an exact')),
+    ).toBe(true);
+  });
+
+  it('keeps a hard-linked backup and legacy gitignore unchanged', () => {
+    const workDir = path.join(TEST_DIR, 'gitignore-hardlink-backup');
+    const gitignore = writeOpencodeGitignore(workDir, LEGACY_GITIGNORE_BYTES);
+    const backup = legacyGitignoreBackupPath(workDir);
+    linkSync(gitignore, backup);
+
+    processImageAttachments({
+      messages: [makeUserMsg([{ type: 'text', text: 'hello' }])],
+      workDir,
+      imageRouting: 'direct',
+      disabledAgents: new Set<string>(),
+      log: () => {},
+    });
+
+    expect(readFileSync(gitignore)).toEqual(LEGACY_GITIGNORE_BYTES);
+    expect(readFileSync(backup)).toEqual(LEGACY_GITIGNORE_BYTES);
   });
 
   it('preserves custom gitignore with wildcard/comment byte-for-byte on migration', () => {
@@ -218,6 +301,7 @@ describe('processImageAttachments image routing', () => {
     });
 
     expect(readFileSync(gitignorePath(workDir))).toEqual(custom);
+    expect(existsSync(legacyGitignoreBackupPath(workDir))).toBe(false);
   });
 
   it('appends images/ exactly once to custom gitignore when saving images', () => {

+ 57 - 0
src/hooks/image-hook.ts

@@ -21,6 +21,8 @@ const CLEANUP_INTERVAL = 10 * 60 * 1000; // 10 minutes
 
 /** Exact bytes previously written by this plugin for `.opencode/.gitignore`. */
 const LEGACY_OPENCODE_GITIGNORE_BYTES = Buffer.from('*\n');
+const LEGACY_OPENCODE_GITIGNORE_BACKUP =
+  '.gitignore.oh-my-opencode-slim-legacy';
 /** Correct scoped rule: ignore only the images directory under `.opencode/`. */
 const IMAGES_GITIGNORE_RULE = 'images/';
 const IMAGES_GITIGNORE_BYTES = Buffer.from(`${IMAGES_GITIGNORE_RULE}\n`);
@@ -33,6 +35,28 @@ function opencodeGitignorePath(workDir: string): string {
   return join(opencodeDirPath(workDir), '.gitignore');
 }
 
+function legacyOpencodeGitignoreBackupPath(workDir: string): string {
+  return join(opencodeDirPath(workDir), LEGACY_OPENCODE_GITIGNORE_BACKUP);
+}
+
+function hasExactLegacyOpencodeGitignoreBackup(
+  gitignorePath: string,
+  backupPath: string,
+  raw: Buffer,
+): boolean {
+  try {
+    const source = statSync(gitignorePath);
+    const backup = lstatSync(backupPath);
+    return (
+      backup.isFile() &&
+      (backup.dev !== source.dev || backup.ino !== source.ino) &&
+      readFileSync(backupPath).equals(raw)
+    );
+  } catch {
+    return false;
+  }
+}
+
 function pathIsSymlink(target: string): boolean {
   try {
     return lstatSync(target).isSymbolicLink();
@@ -81,6 +105,7 @@ function migrateLegacyOpencodeGitignore(
   logFn: (msg: string) => void,
 ): void {
   const gitignorePath = opencodeGitignorePath(workDir);
+  const backupPath = legacyOpencodeGitignoreBackupPath(workDir);
   try {
     if (!existsSync(gitignorePath) && !pathIsSymlink(gitignorePath)) return;
     if (isUnsafeOpencodeGitignorePath(workDir)) {
@@ -89,7 +114,39 @@ function migrateLegacyOpencodeGitignore(
     }
     const raw = readFileSync(gitignorePath);
     if (!raw.equals(LEGACY_OPENCODE_GITIGNORE_BYTES)) return;
+
+    let createdBackup = false;
+    if (!existsSync(backupPath)) {
+      try {
+        writeFileSync(backupPath, raw, { flag: 'wx' });
+        createdBackup = true;
+      } catch (e) {
+        if (
+          !(e instanceof Error) ||
+          (e as NodeJS.ErrnoException).code !== 'EEXIST'
+        ) {
+          logFn(`[image-hook] failed to back up legacy .gitignore: ${e}`);
+          return;
+        }
+      }
+    }
+
+    if (
+      !createdBackup &&
+      !hasExactLegacyOpencodeGitignoreBackup(gitignorePath, backupPath, raw)
+    ) {
+      logFn(
+        '[image-hook] refusing to migrate legacy .gitignore: backup is not an exact regular file',
+      );
+      return;
+    }
+
     writeFileSync(gitignorePath, IMAGES_GITIGNORE_BYTES);
+    logFn(
+      `[image-hook] migrated legacy .gitignore; ${
+        createdBackup ? 'backup created at' : 'using existing backup at'
+      } ${backupPath}`,
+    );
   } catch (e) {
     logFn(`[image-hook] failed to migrate .gitignore: ${e}`);
   }