From 649d7327818f3349b475c57098015eda0a6073ed Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Fri, 22 May 2026 03:08:15 +0000 Subject: [PATCH 1/8] feat: support hidden file sync and expand binary extension list - Use FileSystemAdapter.list() recursively to discover hidden files (e.g. .claude) - Fix ensureParentDirs to use adapter.mkdir() for hidden directory creation - Expand BINARY_EXTENSIONS with modern image, audio, video, archive, and design formats - Exclude .agents/** from ESLint to prevent project-service parse errors Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/obsidian-development/SKILL.md | 61 ------------------- .../obsidian-development/evals/evals.json | 17 ------ .../references/pr-template.md | 35 ----------- eslint.config.mts | 1 + src/logic/sync-manager.ts | 10 ++- src/main.ts | 19 +++++- src/utils/path.ts | 25 ++++++-- 7 files changed, 42 insertions(+), 126 deletions(-) delete mode 100644 .claude/skills/obsidian-development/SKILL.md delete mode 100644 .claude/skills/obsidian-development/evals/evals.json delete mode 100644 .claude/skills/obsidian-development/references/pr-template.md diff --git a/.claude/skills/obsidian-development/SKILL.md b/.claude/skills/obsidian-development/SKILL.md deleted file mode 100644 index e8148ee..0000000 --- a/.claude/skills/obsidian-development/SKILL.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -name: obsidian-development -description: Use when developing Obsidian plugins to ensure TDD with Vitest, linting, and adherence to Obsidian API conventions. Trigger this skill when the user mentions building, testing, or modifying an Obsidian plugin, or when they ask for help with Obsidian-specific APIs (commands, ribbon icons, workspace events). ---- - -# Obsidian Plugin Development - -You are a specialist in building Obsidian plugins. This skill ensures you follow the project's specific conventions for testing, linting, and API usage. - -## Core Principles - -1. **Test-Driven Development (TDD)**: Always write tests before implementation for logic and service classes. -2. **API Hygiene**: Use Obsidian's lifecycle methods correctly (`onload`, `onunload`) and register all events/intervals for automatic cleanup. -3. **Linting**: Adhere to the project's ESLint configuration (`npm run lint`). - -## Development Workflow - -### 1. Testing with Vitest - -The project uses `vitest` for unit and integration testing. Tests are located in the `tests/` directory. - -- **Command**: `npm run test` or `npm run test:ui` for the interactive dashboard. -- **Pattern**: Create a corresponding `.test.ts` file for every logic or service file. -- **Example**: - ```typescript - // tests/logic/sync-manager.test.ts - import { describe, it, expect, vi } from 'vitest'; - // ... test implementation - ``` - -### 2. Obsidian API Conventions - -- **Settings**: Define settings in `src/settings.ts`. Use `this.loadData()` and `this.saveData()` in the main plugin class. -- **Commands**: Register commands using `this.addCommand()`. Always provide an `id` and `name`. -- **UI Elements**: Use `this.addRibbonIcon()` for sidebar buttons. -- **Cleanup**: - - Use `this.registerEvent()` for workspace events. - - Use `this.registerDomEvent()` for DOM events. - - Use `this.registerInterval()` for recurring tasks. - - **Why**: These ensure that when the plugin is disabled or uninstalled, Obsidian automatically cleans up the resources to prevent memory leaks. - -### 3. Build & Deployment - -- **Dev Mode**: `npm run dev` (watches for changes and rebuilds). -- **Production Build**: `npm run build` (runs type checking and minification). -- **Versioning**: `npm run version` (bumps version in `manifest.json` and updates `versions.json`). - -## Project Structure Reference - -- `src/main.ts`: Entry point (Main Plugin Class). -- `src/settings.ts`: Settings definitions and UI tab. -- `src/services/`: External integrations (e.g., GitLab API). -- `src/logic/`: Core business logic. -- `src/ui/`: Custom modals, views, or setting components. - -## Checklist for New Features - -- [ ] Draft test cases in `tests/` before writing logic. -- [ ] Use `registerEvent` instead of raw `on` handlers where possible. -- [ ] Verify `npm run lint` passes before completion. -- [ ] Ensure all persistent data is saved via `this.saveSettings()`. diff --git a/.claude/skills/obsidian-development/evals/evals.json b/.claude/skills/obsidian-development/evals/evals.json deleted file mode 100644 index 5cfafe4..0000000 --- a/.claude/skills/obsidian-development/evals/evals.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "skill_name": "obsidian-development", - "evals": [ - { - "id": 1, - "prompt": "Add a new command to the plugin that allows users to manually check for GitLab updates for the current file. Make sure it follows the project's testing and API conventions.", - "expected_output": "The model should first create a test case for the new logic, then implement the command in src/main.ts using this.addCommand(), and ensure any events are registered correctly.", - "files": ["src/main.ts", "src/logic/sync-manager.ts"] - }, - { - "id": 2, - "prompt": "I need a way to see the current sync status in the status bar. How should I implement this?", - "expected_output": "The model should recommend using this.addStatusBarItem() and registering any update events with this.registerEvent() to ensure cleanup.", - "files": ["src/main.ts"] - } - ] -} \ No newline at end of file diff --git a/.claude/skills/obsidian-development/references/pr-template.md b/.claude/skills/obsidian-development/references/pr-template.md deleted file mode 100644 index bd53ceb..0000000 --- a/.claude/skills/obsidian-development/references/pr-template.md +++ /dev/null @@ -1,35 +0,0 @@ -# Pull Request Template - -## Template - -```markdown -# I am submitting a new Community Plugin - -- [ ] I attest that I have done my best to deliver a high-quality plugin, am proud of the code I have written, and would recommend it to others. I commit to maintaining the plugin and being responsive to bug reports. If I am no longer able to maintain it, I will make reasonable efforts to find a successor maintainer or withdraw the plugin from the directory. - -## Repo URL - - -Link to my plugin: - -## Release Checklist -- [ ] I have tested the plugin on - - [ ] Windows - - [ ] macOS - - [ ] Linux - - [ ] Android _(if applicable)_ - - [ ] iOS _(if applicable)_ -- [ ] My GitHub release contains all required files (as individual files, not just in the source.zip / source.tar.gz) - - [ ] `main.js` - - [ ] `manifest.json` - - [ ] `styles.css` _(optional)_ -- [ ] GitHub release name matches the exact version number specified in my manifest.json (_**Note:** Use the exact version number, don't include a prefix `v`_) -- [ ] The `id` in my `manifest.json` matches the `id` in the `community-plugins.json` file. -- [ ] My README.md describes the plugin's purpose and provides clear usage instructions. -- [ ] I have read the developer policies at https://docs.obsidian.md/Developer+policies, and have assessed my plugin's adherence to these policies. -- [ ] I have read the tips in https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines and have self-reviewed my plugin to avoid these common pitfalls. -- [ ] I have added a license in the LICENSE file. -- [ ] My project respects and is compatible with the original license of any code from other plugins that I'm using. - I have given proper attribution to these other projects in my `README.md`. - -``` diff --git a/eslint.config.mts b/eslint.config.mts index 9e19aaa..2676d4b 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -36,6 +36,7 @@ export default tseslint.config( "versions.json", "main.js", ".claude/**", + ".agents/**", "coverage/**", ]), ); diff --git a/src/logic/sync-manager.ts b/src/logic/sync-manager.ts index a2feb60..d3af1c0 100644 --- a/src/logic/sync-manager.ts +++ b/src/logic/sync-manager.ts @@ -260,12 +260,10 @@ export class SyncManager { let cur = ''; for (let i = 0; i < parts.length - 1; i++) { cur += (i > 0 ? '/' : '') + parts[i]; - if (!this.app.vault.getAbstractFileByPath(cur)) { - try { - await this.app.vault.createFolder(cur); - } catch { - // already exists or failed - } + try { + await this.app.vault.adapter.mkdir(cur); + } catch { + // already exists or failed } } } diff --git a/src/main.ts b/src/main.ts index d228b51..5346996 100644 --- a/src/main.ts +++ b/src/main.ts @@ -138,13 +138,26 @@ export default class GitLabFilesPush extends Plugin { await this.runAllFiles('pull'); } + private async listAllFilesFromAdapter(dirPath: string): Promise { + const results: string[] = []; + try { + const { files, folders } = await this.app.vault.adapter.list(dirPath || ''); + results.push(...files); + for (const folder of folders) { + const sub = await this.listAllFilesFromAdapter(folder); + results.push(...sub); + } + } catch { /* ignore inaccessible dirs */ } + return results; + } + private async runAllFiles(op: 'push' | 'pull'): Promise { - const allFiles = this.app.vault.getFiles(); - let files = this.filterFilesByVaultFolder(allFiles); + const startPath = this.settings.vaultFolder || ''; + const allPaths = await this.listAllFilesFromAdapter(startPath); await this.gitService.listFiles(this.settings.branch); await this.gitignoreManager.loadGitignores(); - files = files.filter(f => !this.gitignoreManager.isIgnored(this.getNormalizedPath(f.path))); + const files = allPaths.filter(p => !this.gitignoreManager.isIgnored(this.getNormalizedPath(p))); if (files.length === 0) { new Notice(`No files to ${op} in the configured vault folder`); diff --git a/src/utils/path.ts b/src/utils/path.ts index 2b78dc7..5bc139d 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -1,8 +1,25 @@ export const BINARY_EXTENSIONS = new Set([ - 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'pdf', 'zip', 'gz', '7z', 'rar', - 'mp3', 'mp4', 'wav', 'ogg', 'webm', 'mov', 'avi', 'wmv', 'webp', - 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'epub', 'exe', 'dll', 'so', - 'ttf', 'woff', 'woff2', 'eot', 'wasm', 'dmg', 'iso' + // Images + 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp', + 'tif', 'tiff', 'heic', 'heif', 'avif', 'jxl', + // Documents + 'pdf', 'epub', + 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'odt', 'ods', 'odp', + // Archives + 'zip', 'gz', 'tar', 'bz2', 'xz', 'zst', '7z', 'rar', + // Audio + 'mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'opus', 'aiff', + // Video + 'mp4', 'webm', 'mov', 'avi', 'wmv', 'mkv', 'flv', 'm4v', + // Fonts + 'ttf', 'otf', 'woff', 'woff2', 'eot', + // Executables & binaries + 'exe', 'dll', 'so', 'wasm', 'dmg', 'iso', 'apk', 'deb', 'rpm', + // Databases + 'sqlite', 'db', + // Design + 'psd', 'sketch', 'fig', ]); export function isBinaryPath(path: string): boolean { From 49acf052411409bbedc441c8ff5bc2c4f7200622 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Fri, 22 May 2026 03:15:37 +0000 Subject: [PATCH 2/8] chore(agent): update obsidian skills --- .../references/esbuild.config.mjs | 47 ++++++++++++++ .../references/manifest-example.json | 10 +++ .../references/obsidian-mock.ts | 12 ++++ .../references/package-example.json | 61 +++++++++++++++++++ .../references/release-workflow.yml | 20 ++++++ .../references/vitest.config.ts | 18 ++++++ skills-lock.json | 11 ++++ 7 files changed, 179 insertions(+) create mode 100644 .agents/skills/obsidian-development/references/esbuild.config.mjs create mode 100644 .agents/skills/obsidian-development/references/manifest-example.json create mode 100644 .agents/skills/obsidian-development/references/obsidian-mock.ts create mode 100644 .agents/skills/obsidian-development/references/package-example.json create mode 100644 .agents/skills/obsidian-development/references/release-workflow.yml create mode 100644 .agents/skills/obsidian-development/references/vitest.config.ts create mode 100644 skills-lock.json diff --git a/.agents/skills/obsidian-development/references/esbuild.config.mjs b/.agents/skills/obsidian-development/references/esbuild.config.mjs new file mode 100644 index 0000000..d871f4b --- /dev/null +++ b/.agents/skills/obsidian-development/references/esbuild.config.mjs @@ -0,0 +1,47 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from 'builtin-modules' + +const banner = + `/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ +`; + +const prod = (process.argv[2] === 'production'); + +const buildOptions = { + banner: { + js: banner, + }, + entryPoints: ['src/main.ts'], + bundle: true, + external: [ + 'obsidian', + 'electron', + '@codemirror/autocomplete', + '@codemirror/collab', + '@codemirror/commands', + '@codemirror/language', + '@codemirror/lint', + '@codemirror/search', + '@codemirror/state', + '@codemirror/view', + '@lezer/common', + '@lezer/highlight', + '@lezer/lr', + ...builtins], + format: 'cjs', + target: 'es2018', + logLevel: "info", + sourcemap: prod ? false : 'inline', + treeShaking: true, + outfile: 'main.js', +}; + +if (prod) { + esbuild.build(buildOptions).catch(() => process.exit(1)); +} else { + esbuild.context(buildOptions).then(ctx => ctx.watch()).catch(() => process.exit(1)); +} diff --git a/.agents/skills/obsidian-development/references/manifest-example.json b/.agents/skills/obsidian-development/references/manifest-example.json new file mode 100644 index 0000000..89d5375 --- /dev/null +++ b/.agents/skills/obsidian-development/references/manifest-example.json @@ -0,0 +1,10 @@ +{ + "id": "my-plugin-id", + "name": "My Plugin Name", + "version": "1.0.0", + "minAppVersion": "1.4.0", + "description": "A concise description under 250 characters that explains exactly what this plugin does for the user.", + "author": "Your Name", + "authorUrl": "https://your-website.com", + "isDesktopOnly": false +} diff --git a/.agents/skills/obsidian-development/references/obsidian-mock.ts b/.agents/skills/obsidian-development/references/obsidian-mock.ts new file mode 100644 index 0000000..cb2cdd7 --- /dev/null +++ b/.agents/skills/obsidian-development/references/obsidian-mock.ts @@ -0,0 +1,12 @@ +// Minimal stub so uploader.ts can be imported in the test environment +export const requestUrl = () => Promise.resolve({ headers: {}, status: 200, arrayBuffer: new ArrayBuffer(0) }); +export type RequestUrlParam = unknown; +export class Notice { constructor(_msg: string) {} } +export class Plugin {} +export class PluginSettingTab {} +export class Setting {} +export class TextComponent {} +export const setIcon = () => {}; +export class TFile {} +export class MarkdownView {} +export class Editor {} diff --git a/.agents/skills/obsidian-development/references/package-example.json b/.agents/skills/obsidian-development/references/package-example.json new file mode 100644 index 0000000..67fab1a --- /dev/null +++ b/.agents/skills/obsidian-development/references/package-example.json @@ -0,0 +1,61 @@ +{ + "name": "watermark-s3-uploader", + "version": "1.1.0", + "description": "Obsidian plugin: upload images to Cloudflare R2 with WebP conversion and watermark support.", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "version": "node version-bump.mjs && git add manifest.json versions.json", + "deploy": "node esbuild.config.mjs production && cp main.js manifest.json styles.css ../../src/content/.obsidian/plugins/watermark-s3-uploader/", + "prepare": "husky", + "lint": "eslint src/", + "test": "vitest run", + "semantic-release": "npx semantic-release" + }, + "keywords": [], + "author": "ClaudiaFang", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "devDependencies": { + "@semantic-release/changelog": "^6.0.3", + "@semantic-release/exec": "^6.0.3", + "@semantic-release/git": "^10.0.1", + "@semantic-release/github": "^10.3.5", + "@semantic-release/npm": "^12.0.1", + "@types/mime-types": "^2.1.1", + "@types/node": "^20.19.37", + "@vitest/coverage-v8": "4.1.5", + "browser-resolve": "^2.0.0", + "builtin-modules": "3.3.0", + "conventional-changelog-conventionalcommits": "^8.0.0", + "detective": "^5.2.1", + "esbuild": "^0.27.0", + "eslint": "^9.0.0", + "eslint-plugin-obsidianmd": "^0.2.4", + "eslint-plugin-sonarjs": "^4.0.3", + "globals": "^16.0.0", + "husky": "^9.1.7", + "jiti": "^2.0.0", + "obsidian": "^1.12.3", + "process": "^0.11.10", + "semantic-release": "^24.2.3", + "tslib": "2.4.0", + "typescript": "^5.0.0", + "typescript-eslint": "^8.0.0", + "vitest": "4.1.5" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.1019.0", + "@aws-sdk/fetch-http-handler": "^3.310.0", + "@aws-sdk/protocol-http": "^3.310.0", + "@aws-sdk/querystring-builder": "^3.310.0", + "@aws-sdk/types": "^3.310.0", + "@smithy/fetch-http-handler": "^2.2.7", + "browser-image-compression": "^2.0.2", + "filesize": "^10.1.6", + "minimatch": "^10.2.3" + } +} diff --git a/.agents/skills/obsidian-development/references/release-workflow.yml b/.agents/skills/obsidian-development/references/release-workflow.yml new file mode 100644 index 0000000..b89acae --- /dev/null +++ b/.agents/skills/obsidian-development/references/release-workflow.yml @@ -0,0 +1,20 @@ +name: CI/CD + +permissions: + contents: write + issues: write + pull-requests: write + +on: + push: + branches: [main, master, '**'] + pull_request: + types: [opened, synchronize, reopened] + +jobs: + CI: + uses: firstsun-dev/.github/.github/workflows/obsidian-plugin-ci.yml@main + with: + plugin-id: "watermark-image-bucket-uploader" + secrets: + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} diff --git a/.agents/skills/obsidian-development/references/vitest.config.ts b/.agents/skills/obsidian-development/references/vitest.config.ts new file mode 100644 index 0000000..de81638 --- /dev/null +++ b/.agents/skills/obsidian-development/references/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + coverage: { + reporter: ["text", "lcov", "html"], + exclude: ["node_modules/**", "tests/**"], + include: ["src/**"], + all: true, + }, + }, + resolve: { + alias: { + obsidian: new URL("./tests/__mocks__/obsidian.ts", import.meta.url).pathname, + }, + }, +}); diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..fe6e1ed --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "obsidian-development": { + "source": "firstsun-dev/skills", + "sourceType": "github", + "skillPath": "custom/obsidian/obsidian-development/SKILL.md", + "computedHash": "087f548b82f8c150fcc2879488109bdda5d21c91fc4c9e379765150a4acbff82" + } + } +} From fcb9447733c28b18f2301385e898829a289e5ce4 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Fri, 22 May 2026 03:35:36 +0000 Subject: [PATCH 3/8] test: expand coverage for binary files, hidden paths, and edge cases - Add sync-manager-binary.test.ts: push/pull ArrayBuffer via adapter.readBinary/writeBinary - Add sync-manager-hidden.test.ts: hidden path mkdir, push, pull via string paths - Add utils/path.test.ts: isBinaryPath and contentsEqual full coverage - Add metadata-on-equal assertion to sync-manager tests - Add GitHub truncated result and GitLab pagination boundary tests - Add hidden file gitignore filter tests - Add docs/test-coverage.md with all 189 test cases documented Co-Authored-By: Claude Sonnet 4.6 --- docs/test-coverage.md | 227 ++++++++++++++++++++++++ tests/logic/gitignore-manager.test.ts | 59 ++++++ tests/logic/sync-manager-binary.test.ts | 157 ++++++++++++++++ tests/logic/sync-manager-hidden.test.ts | 155 ++++++++++++++++ tests/logic/sync-manager.test.ts | 13 ++ tests/services/github-service.test.ts | 11 ++ tests/services/gitlab-service.test.ts | 37 ++++ tests/utils/path.test.ts | 194 ++++++++++++++++++++ 8 files changed, 853 insertions(+) create mode 100644 docs/test-coverage.md create mode 100644 tests/logic/sync-manager-binary.test.ts create mode 100644 tests/logic/sync-manager-hidden.test.ts create mode 100644 tests/utils/path.test.ts diff --git a/docs/test-coverage.md b/docs/test-coverage.md new file mode 100644 index 0000000..1bbe276 --- /dev/null +++ b/docs/test-coverage.md @@ -0,0 +1,227 @@ +# Test Coverage + +All tests are in `tests/` and run with `npm run test` (Vitest). + +--- + +## utils/path — `tests/utils/path.test.ts` + +### `isBinaryPath()` +| Group | Cases | +|---|---| +| Image formats | png, jpg, gif, bmp, webp (legacy); heic, heif, avif, tiff, tif, jxl (modern) | +| Audio formats | mp3, wav, ogg, flac, aac, m4a, opus, aiff | +| Video formats | mp4, webm, mov, mkv, avi, wmv, flv, m4v | +| Archive formats | zip, tar, gz, bz2, xz, zst, 7z, rar | +| Font formats | ttf, otf, woff, woff2 | +| Database formats | sqlite, db | +| Text files | md, json, ts, yml, txt → returns false | +| Hidden file paths | `.gitignore`, `.env` → false; `.claude/settings.json` → false; `.claude/photo.png` → true | +| Case insensitivity | `.PNG`, `.JPG`, `.FLAC` | +| BINARY_EXTENSIONS completeness | Set membership checks for major categories | + +### `contentsEqual()` +| Group | Cases | +|---|---| +| String | identical, different, empty, case-sensitive | +| ArrayBuffer | identical, different bytes, different lengths, empty buffers | +| Mixed types | string vs ArrayBuffer → false | + +--- + +## logic/SyncManager — `tests/logic/sync-manager.test.ts` + +| Case | +|---| +| Push file content correctly | +| Detect conflict when remote SHA differs from last synced SHA | +| Resolve conflict by choosing local | +| Resolve conflict by choosing remote | +| Update metadata when file is already in sync (contentsEqual) | +| Update metadata after successful push | +| Pull and modify file content, update metadata | +| Handle file not existing in vault | +| Add new file to repo when exists locally but not on remote | +| **Renames:** detect and handle file rename | +| **Error handling:** push errors, rename errors | +| **pullFile:** file not in remote, pull new file, pull errors | + +--- + +## logic/SyncManager Batch — `tests/logic/sync-manager-batch.test.ts` + +| Case | +|---| +| Push multiple files (TFile and string paths) | +| Handle failures during batch push | +| Pull multiple files (TFile and string paths) | +| Handle missing remote files during batch pull | +| onProgress callback called for each file | +| Detect and handle rename during batch push | + +--- + +## logic/SyncManager Path Mapping — `tests/logic/sync-manager-mapping.test.ts` + +| Case | +|---| +| Strip vaultFolder prefix when pushing | +| Re-add vaultFolder prefix when pulling | +| Handle root-level files with no vaultFolder | + +--- + +## logic/SyncManager Binary Files — `tests/logic/sync-manager-binary.test.ts` + +### `pushFile` with binary path (string) +| Case | +|---| +| Reads via `adapter.readBinary` for binary extensions | +| Skips push when binary content is already in sync | +| Updates metadata when binary is already in sync | + +### `pullFile` with binary content +| Case | +|---| +| Writes via `adapter.writeBinary` when remote content is ArrayBuffer | +| Creates parent directory before writing binary | +| Skips pull when binary content is already in sync | +| Updates metadata after pulling binary file | + +--- + +## logic/SyncManager Hidden Files — `tests/logic/sync-manager-hidden.test.ts` + +### `pullFile` with hidden paths +| Case | +|---| +| Creates single hidden parent directory (`.claude/`) on pull | +| Creates all nested hidden parent directories (`.claude/memory/`) on pull | +| Does not throw if hidden directory already exists (mkdir error is swallowed) | +| Updates syncMetadata after pulling hidden file | + +### `pushFile` with hidden paths +| Case | +|---| +| Pushes hidden file content via string path | +| Skips push when hidden file is already in sync (same content) | +| Does not push when hidden file is missing from vault | + +--- + +## logic/GitignoreManager — `tests/logic/gitignore-manager.test.ts` + +### `loadGitignores()` +| Case | +|---| +| Load root .gitignore from local if it exists | +| Load gitignores from remote as fallback | +| Fall back to `[".gitignore"]` when getRepoGitignores throws | +| Fall back to remote when local .gitignore read throws | +| Handle subdirectory .gitignores correctly | +| Pick up local-only subdirectory .gitignore not yet on remote | + +### `isIgnored()` with rootPath +| Case | +|---| +| Correctly resolve paths relative to git root | + +### Complex patterns +| Case | +|---| +| Negative patterns (`!important.log`) | +| Directory-only patterns (`build/`) | +| Deep wildcards (`**/temp/*`) | + +### Hidden file and directory filtering +| Case | +|---| +| Ignore hidden directory when listed in .gitignore (`.obsidian/`, `.trash/`) | +| Pass through hidden directory absent from .gitignore (`.claude/`) | +| Ignore `.claude/` when explicitly added to .gitignore | +| Pass normal files when only hidden dirs are ignored | +| Find `.gitignore` inside a hidden directory via scanDir | + +--- + +## services/GitServiceBase — `tests/services/git-service-base.test.ts` + +| Case | +|---| +| No double-slash when rootPath already ends with `/` | +| Wrap non-Error throws in a new Error | +| Correctly encode and decode UTF-8 content | + +--- + +## services/GitHubService — `tests/services/github-service.test.ts` + +| Group | Cases | +|---|---| +| getFile | fetch and decode content, 404 handling, bypass rootPath with `/` prefix, no double-prefix, return sha | +| pushFile | new file (no sha), update existing file (with sha) | +| listFiles | list blob files, filter by rootPath, no sibling path collision, truncated result warning | +| deleteFile | delete using file sha | + +--- + +## services/GitLabService — `tests/services/gitlab-service.test.ts` + +| Group | Cases | +|---|---| +| getFile | fetch and decode content, 404 handling, return last_commit_id as sha | +| pushFile | POST for new file, PUT for existing file | +| listFiles | list blob files, filter by rootPath, no sibling path collision, pagination boundary (100 items), multi-page (200 items), stop on partial page | +| deleteFile | delete with commit message | + +--- + +## ui/ActionBar — `tests/ui/ActionBar.test.ts` + +| Group | Cases | +|---|---| +| No files | always renders refresh button; no push/pull/delete; no select-all | +| Has files | renders push, pull, delete, select-all; disabled states when count is 0; enabled when count > 0 | +| Interactions | onRefresh, onPush, onPull, onDelete, onSelectAll(true/false) | + +--- + +## ui/DiffPanel — `tests/ui/DiffPanel.test.ts` + +| Case | +|---| +| Returns element with `ssv-diff` class | +| Renders Remote and Local column headers | +| Renders unchanged lines | +| Renders added line | +| Renders removed line | +| Renders replacement (removed + added) | +| Renders unchanged lines for identical content | + +--- + +## ui/FileListItem — `tests/ui/FileListItem.test.ts` + +| Group | Cases | +|---|---| +| Status CSS | distinct class for each status | +| Rendering | file path, status badge, checkbox (selected/unselected) | +| Interactions | onSelect(path, true/false) | +| Synced status | no action buttons | +| Remote-only status | no action buttons | +| Modified status | push + pull buttons; onPush/onPull callbacks; diff toggle; diff panel visibility toggle; diff button label toggle | +| Local-only status | push + delete buttons; no pull; onDelete callback | +| Missing status | pull button; no push | + +--- + +## utils/diff — `tests/utils/diff.test.ts` + +| Group | Cases | +|---|---| +| Identical content | single-line, multi-line, empty strings | +| CRLF normalisation | CRLF = LF, CR-only | +| Additions only | single added line, multiple added lines, single-line change | +| Removals only | single removed line, multiple removed lines | +| Mixed changes | paired removed+added, unchanged lines between changes | +| Line numbers | correct numbering | diff --git a/tests/logic/gitignore-manager.test.ts b/tests/logic/gitignore-manager.test.ts index c75158e..81aeb0e 100644 --- a/tests/logic/gitignore-manager.test.ts +++ b/tests/logic/gitignore-manager.test.ts @@ -223,4 +223,63 @@ describe('GitignoreManager', () => { expect(manager.isIgnored('a/temp/foo')).toBe(true); }); }); + + describe('hidden file and directory filtering', () => { + beforeEach(async () => { + vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']); + const adapter = mockApp.vault.adapter as Mocked; + vi.mocked(adapter.exists).mockResolvedValue(true); + }); + + it('should ignore hidden directory when listed in .gitignore', async () => { + const adapter = mockApp.vault.adapter as Mocked; + vi.mocked(adapter.read).mockResolvedValue('.private/\n.trash/'); + await manager.loadGitignores(); + + expect(manager.isIgnored('.private/secrets.md')).toBe(true); + expect(manager.isIgnored('.trash/note.md')).toBe(true); + }); + + it('should not ignore hidden directory absent from .gitignore', async () => { + const adapter = mockApp.vault.adapter as Mocked; + vi.mocked(adapter.read).mockResolvedValue('node_modules/\n*.log'); + await manager.loadGitignores(); + + expect(manager.isIgnored('.claude/settings.json')).toBe(false); + expect(manager.isIgnored('.claude/CLAUDE.md')).toBe(false); + }); + + it('should ignore .claude/ when explicitly added to .gitignore', async () => { + const adapter = mockApp.vault.adapter as Mocked; + vi.mocked(adapter.read).mockResolvedValue('.claude/'); + await manager.loadGitignores(); + + expect(manager.isIgnored('.claude/settings.json')).toBe(true); + expect(manager.isIgnored('.claude/memory/user.md')).toBe(true); + }); + + it('should still pass normal files when only hidden dirs are ignored', async () => { + const adapter = mockApp.vault.adapter as Mocked; + vi.mocked(adapter.read).mockResolvedValue('.private/'); + await manager.loadGitignores(); + + expect(manager.isIgnored('notes/my-note.md')).toBe(false); + expect(manager.isIgnored('projects/work.md')).toBe(false); + }); + + it('should find .gitignore inside a hidden directory via scanDir', async () => { + const adapter = mockApp.vault.adapter as Mocked; + vi.mocked(adapter.list) + .mockResolvedValueOnce({ files: ['.gitignore'], folders: ['.claude'] }) + .mockResolvedValueOnce({ files: ['.claude/.gitignore'], folders: [] }); + vi.mocked(adapter.exists).mockResolvedValue(true); + vi.mocked(adapter.read).mockResolvedValue('*.secret'); + vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']); + + await manager.loadGitignores(); + + expect(adapter.list).toHaveBeenCalledWith(''); + expect(adapter.list).toHaveBeenCalledWith('.claude'); + }); + }); }); diff --git a/tests/logic/sync-manager-binary.test.ts b/tests/logic/sync-manager-binary.test.ts new file mode 100644 index 0000000..fbee3d0 --- /dev/null +++ b/tests/logic/sync-manager-binary.test.ts @@ -0,0 +1,157 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { describe, it, expect, vi, beforeEach, Mocked } from 'vitest'; +import { SyncManager } from '../../src/logic/sync-manager'; +import { App, DataAdapter } from 'obsidian'; +import { GitLabFilesPushSettings } from '../../src/settings'; +import { GitServiceInterface } from '../../src/services/git-service-interface'; + +vi.mock('obsidian'); + +describe('SyncManager – binary file handling', () => { + let manager: SyncManager; + let mockApp: Mocked; + let mockGitService: Mocked; + let mockAdapter: Mocked; + let mockSettings: GitLabFilesPushSettings; + + const makeBuf = (bytes: number[]) => new Uint8Array(bytes).buffer; + + beforeEach(() => { + vi.clearAllMocks(); + + mockAdapter = { + exists: vi.fn(), + read: vi.fn(), + write: vi.fn(), + readBinary: vi.fn(), + writeBinary: vi.fn(), + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as Mocked; + + mockApp = { + vault: { + read: vi.fn(), + readBinary: vi.fn(), + modify: vi.fn(), + modifyBinary: vi.fn(), + getFileByPath: vi.fn().mockReturnValue(null), + adapter: mockAdapter, + }, + } as unknown as Mocked; + + mockGitService = { + pushFile: vi.fn(), + getFile: vi.fn(), + testConnection: vi.fn(), + listFiles: vi.fn(), + deleteFile: vi.fn(), + getRepoGitignores: vi.fn(), + updateConfig: vi.fn(), + } as unknown as Mocked; + + mockSettings = { + serviceType: 'github', + githubToken: 'token', + githubOwner: 'owner', + githubRepo: 'repo', + branch: 'main', + syncMetadata: {}, + vaultFolder: '', + rootPath: '', + } as unknown as GitLabFilesPushSettings; + + manager = new SyncManager(mockApp, mockGitService, mockSettings); + // @ts-ignore + manager.saveSettings = vi.fn().mockResolvedValue(undefined); + }); + + describe('pushFile with binary path (string)', () => { + it('reads via adapter.readBinary for binary extensions', async () => { + const buf = makeBuf([137, 80, 78, 71]); + vi.mocked(mockAdapter.exists).mockResolvedValue(true); + vi.mocked(mockAdapter.readBinary).mockResolvedValue(buf); + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: '', content: '' }); + vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: 'photo.png', sha: 'new-sha' }); + + await manager.pushFile('photo.png'); + + expect(mockAdapter.readBinary).toHaveBeenCalledWith('photo.png'); + expect(mockAdapter.read).not.toHaveBeenCalled(); + expect(mockGitService.pushFile).toHaveBeenCalledWith( + 'photo.png', buf, 'main', expect.any(String), '' + ); + }); + + it('skips push when binary content is already in sync', async () => { + const buf = makeBuf([1, 2, 3, 4]); + vi.mocked(mockAdapter.exists).mockResolvedValue(true); + vi.mocked(mockAdapter.readBinary).mockResolvedValue(buf); + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'existing-sha', content: buf }); + + await manager.pushFile('photo.png'); + + expect(mockGitService.pushFile).not.toHaveBeenCalled(); + }); + + it('updates metadata when binary is already in sync', async () => { + const buf = makeBuf([1, 2, 3]); + vi.mocked(mockAdapter.exists).mockResolvedValue(true); + vi.mocked(mockAdapter.readBinary).mockResolvedValue(buf); + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'existing-sha', content: buf }); + + await manager.pushFile('photo.png'); + + expect(mockSettings.syncMetadata['photo.png']).toMatchObject({ + lastSyncedSha: 'existing-sha', + }); + }); + }); + + describe('pullFile with binary content', () => { + it('writes via adapter.writeBinary when remote content is ArrayBuffer', async () => { + const buf = makeBuf([137, 80, 78, 71]); + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'bin-sha', content: buf }); + vi.mocked(mockAdapter.exists).mockResolvedValue(false); + + await manager.pullFile('photo.png'); + + expect(mockAdapter.writeBinary).toHaveBeenCalledWith('photo.png', buf); + expect(mockAdapter.write).not.toHaveBeenCalled(); + }); + + it('creates parent directory before writing binary', async () => { + const buf = makeBuf([255, 216, 255]); + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'bin-sha', content: buf }); + vi.mocked(mockAdapter.exists).mockResolvedValue(false); + + await manager.pullFile('attachments/photo.jpg'); + + expect(mockAdapter.mkdir).toHaveBeenCalledWith('attachments'); + expect(mockAdapter.writeBinary).toHaveBeenCalledWith('attachments/photo.jpg', buf); + }); + + it('skips pull when binary content is already in sync', async () => { + const buf = makeBuf([1, 2, 3]); + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'bin-sha', content: buf }); + vi.mocked(mockAdapter.exists).mockResolvedValue(true); + vi.mocked(mockAdapter.readBinary).mockResolvedValue(buf); + mockSettings.syncMetadata['photo.png'] = { lastSyncedSha: 'bin-sha', lastSyncedAt: 0, lastKnownPath: 'photo.png' }; + + await manager.pullFile('photo.png'); + + expect(mockAdapter.writeBinary).not.toHaveBeenCalled(); + }); + + it('updates metadata after pulling binary file', async () => { + const buf = makeBuf([0, 1, 2]); + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'bin-sha', content: buf }); + vi.mocked(mockAdapter.exists).mockResolvedValue(false); + + await manager.pullFile('photo.png'); + + expect(mockSettings.syncMetadata['photo.png']).toMatchObject({ + lastSyncedSha: 'bin-sha', + }); + }); + }); +}); diff --git a/tests/logic/sync-manager-hidden.test.ts b/tests/logic/sync-manager-hidden.test.ts new file mode 100644 index 0000000..b4d6eb8 --- /dev/null +++ b/tests/logic/sync-manager-hidden.test.ts @@ -0,0 +1,155 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { describe, it, expect, vi, beforeEach, Mocked } from 'vitest'; +import { SyncManager } from '../../src/logic/sync-manager'; +import { App, DataAdapter } from 'obsidian'; +import { GitLabFilesPushSettings } from '../../src/settings'; +import { GitServiceInterface } from '../../src/services/git-service-interface'; + +vi.mock('obsidian'); + +describe('SyncManager – hidden file support', () => { + let manager: SyncManager; + let mockApp: Mocked; + let mockGitService: Mocked; + let mockAdapter: Mocked; + let mockSettings: GitLabFilesPushSettings; + + beforeEach(() => { + vi.clearAllMocks(); + + mockAdapter = { + exists: vi.fn(), + read: vi.fn(), + write: vi.fn(), + writeBinary: vi.fn(), + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as Mocked; + + mockApp = { + vault: { + read: vi.fn(), + modify: vi.fn(), + getFileByPath: vi.fn().mockReturnValue(null), + adapter: mockAdapter, + }, + } as unknown as Mocked; + + mockGitService = { + pushFile: vi.fn(), + getFile: vi.fn(), + testConnection: vi.fn(), + listFiles: vi.fn(), + deleteFile: vi.fn(), + getRepoGitignores: vi.fn(), + updateConfig: vi.fn(), + } as unknown as Mocked; + + mockSettings = { + serviceType: 'github', + githubToken: 'token', + githubOwner: 'owner', + githubRepo: 'repo', + branch: 'main', + syncMetadata: {}, + vaultFolder: '', + rootPath: '', + } as unknown as GitLabFilesPushSettings; + + manager = new SyncManager(mockApp, mockGitService, mockSettings); + // @ts-ignore - accessing private for test + manager.saveSettings = vi.fn().mockResolvedValue(undefined); + }); + + describe('pullFile with hidden paths', () => { + it('creates single hidden parent directory on pull', async () => { + vi.mocked(mockGitService.getFile).mockResolvedValue({ + sha: 'abc123', + content: '{"key":"value"}', + }); + vi.mocked(mockAdapter.exists).mockResolvedValue(false); + + await manager.pullFile('.claude/settings.json'); + + expect(mockAdapter.mkdir).toHaveBeenCalledWith('.claude'); + expect(mockAdapter.write).toHaveBeenCalledWith('.claude/settings.json', '{"key":"value"}'); + }); + + it('creates all nested hidden parent directories on pull', async () => { + vi.mocked(mockGitService.getFile).mockResolvedValue({ + sha: 'def456', + content: 'nested content', + }); + vi.mocked(mockAdapter.exists).mockResolvedValue(false); + + await manager.pullFile('.claude/memory/user.md'); + + expect(mockAdapter.mkdir).toHaveBeenCalledWith('.claude'); + expect(mockAdapter.mkdir).toHaveBeenCalledWith('.claude/memory'); + expect(mockAdapter.write).toHaveBeenCalledWith('.claude/memory/user.md', 'nested content'); + }); + + it('does not fail if hidden directory already exists (mkdir throws)', async () => { + vi.mocked(mockGitService.getFile).mockResolvedValue({ + sha: 'abc123', + content: 'content', + }); + vi.mocked(mockAdapter.exists).mockResolvedValue(false); + vi.mocked(mockAdapter.mkdir).mockRejectedValue(new Error('already exists')); + + await expect(manager.pullFile('.claude/settings.json')).resolves.not.toThrow(); + expect(mockAdapter.write).toHaveBeenCalledWith('.claude/settings.json', 'content'); + }); + + it('updates metadata after pulling hidden file', async () => { + vi.mocked(mockGitService.getFile).mockResolvedValue({ + sha: 'sha-hidden', + content: 'file content', + }); + vi.mocked(mockAdapter.exists).mockResolvedValue(false); + + await manager.pullFile('.claude/CLAUDE.md'); + + expect(mockSettings.syncMetadata['.claude/CLAUDE.md']).toMatchObject({ + lastSyncedSha: 'sha-hidden', + }); + }); + }); + + describe('pushFile with hidden paths', () => { + it('pushes hidden file content via string path', async () => { + vi.mocked(mockAdapter.exists).mockResolvedValue(true); + vi.mocked(mockAdapter.read).mockResolvedValue('# Memory\n\nsome content'); + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: '', content: '' }); + vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: '.claude/CLAUDE.md', sha: 'new-sha' }); + + await manager.pushFile('.claude/CLAUDE.md'); + + expect(mockGitService.pushFile).toHaveBeenCalledWith( + '.claude/CLAUDE.md', + '# Memory\n\nsome content', + 'main', + expect.any(String), + '' + ); + }); + + it('skips push when hidden file is already in sync', async () => { + const content = 'same content'; + vi.mocked(mockAdapter.exists).mockResolvedValue(true); + vi.mocked(mockAdapter.read).mockResolvedValue(content); + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'existing-sha', content }); + + await manager.pushFile('.claude/settings.json'); + + expect(mockGitService.pushFile).not.toHaveBeenCalled(); + }); + + it('does not exist notice when hidden file is missing', async () => { + vi.mocked(mockAdapter.exists).mockResolvedValue(false); + + await manager.pushFile('.claude/missing.json'); + + expect(mockGitService.pushFile).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/logic/sync-manager.test.ts b/tests/logic/sync-manager.test.ts index 57cb7ca..e9dc277 100644 --- a/tests/logic/sync-manager.test.ts +++ b/tests/logic/sync-manager.test.ts @@ -173,6 +173,19 @@ describe('SyncManager', () => { expect(mockSettings.syncMetadata['test.md']?.lastSyncedSha).toBe('remote-sha'); }); + it('should update metadata even when file is already in sync (contentsEqual)', async () => { + const mockFile = Object.assign(new TFile(), { path: 'test.md', name: 'test.md' }); + mockSettings.syncMetadata = {}; + + vi.spyOn(mockApp.vault, 'read').mockResolvedValue('same content'); + vi.spyOn(mockGitLab, 'getFile').mockResolvedValueOnce({ content: 'same content', sha: 'remote-sha' }); + + await manager.pushFile(mockFile); + + expect(mockGitLab.pushFile).not.toHaveBeenCalled(); + expect(mockSettings.syncMetadata['test.md']?.lastSyncedSha).toBe('remote-sha'); + }); + it('should update metadata after successful push', async () => { const mockFile = Object.assign(new TFile(), { path: 'test.md', name: 'test.md' }); mockSettings.syncMetadata = {}; diff --git a/tests/services/github-service.test.ts b/tests/services/github-service.test.ts index 1bad1c1..5854ea4 100644 --- a/tests/services/github-service.test.ts +++ b/tests/services/github-service.test.ts @@ -110,6 +110,17 @@ describe('GitHubService', () => { ] } }); expect(await service.listFiles('main')).toEqual(['src/content/index.md']); }); + + it('should return files and log warning when result is truncated', async () => { + mockRequest({ status: 200, json: { truncated: true, tree: [ + { path: 'file1.md', type: 'blob' }, + { path: 'file2.md', type: 'blob' }, + ] } }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const result = await service.listFiles('main'); + expect(result).toEqual(['file1.md', 'file2.md']); + warnSpy.mockRestore(); + }); }); describe('deleteFile', () => { diff --git a/tests/services/gitlab-service.test.ts b/tests/services/gitlab-service.test.ts index 8c6fa56..d468c13 100644 --- a/tests/services/gitlab-service.test.ts +++ b/tests/services/gitlab-service.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { GitLabService } from '../../src/services/gitlab-service'; +import { RequestUrlResponse, requestUrl } from 'obsidian'; import { getLastRequestCall, mockRequest, sharedTestConnection, sharedGetFileErrorHandling, sharedGetRepoGitignores } from './service-test-helpers'; describe('GitLabService', () => { @@ -87,6 +88,42 @@ describe('GitLabService', () => { ] }); expect(await service.listFiles('main')).toEqual(['src/content/index.md']); }); + + it('should fetch all pages when first page is exactly full (100 items)', async () => { + const page1 = Array.from({ length: 100 }, (_, i) => ({ path: `file${i}.md`, type: 'blob' })); + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: page1 } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 200, json: [] } as unknown as RequestUrlResponse); + + const result = await service.listFiles('main'); + expect(result).toHaveLength(100); + expect(vi.mocked(requestUrl)).toHaveBeenCalledTimes(2); + }); + + it('should fetch all pages across multiple full pages (200 items)', async () => { + const page1 = Array.from({ length: 100 }, (_, i) => ({ path: `a/file${i}.md`, type: 'blob' })); + const page2 = Array.from({ length: 100 }, (_, i) => ({ path: `b/file${i}.md`, type: 'blob' })); + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: page1 } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 200, json: page2 } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 200, json: [] } as unknown as RequestUrlResponse); + + const result = await service.listFiles('main'); + expect(result).toHaveLength(200); + expect(vi.mocked(requestUrl)).toHaveBeenCalledTimes(3); + }); + + it('should stop pagination when page has fewer than 100 items', async () => { + const page1 = Array.from({ length: 100 }, (_, i) => ({ path: `file${i}.md`, type: 'blob' })); + const page2 = Array.from({ length: 42 }, (_, i) => ({ path: `extra/file${i}.md`, type: 'blob' })); + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: page1 } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 200, json: page2 } as unknown as RequestUrlResponse); + + const result = await service.listFiles('main'); + expect(result).toHaveLength(142); + expect(vi.mocked(requestUrl)).toHaveBeenCalledTimes(2); + }); }); describe('deleteFile', () => { diff --git a/tests/utils/path.test.ts b/tests/utils/path.test.ts new file mode 100644 index 0000000..c1cbd35 --- /dev/null +++ b/tests/utils/path.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect } from 'vitest'; +import { isBinaryPath, contentsEqual, BINARY_EXTENSIONS } from '../../src/utils/path'; + +describe('isBinaryPath', () => { + describe('image formats', () => { + it('detects legacy formats', () => { + expect(isBinaryPath('photo.png')).toBe(true); + expect(isBinaryPath('photo.jpg')).toBe(true); + expect(isBinaryPath('photo.gif')).toBe(true); + expect(isBinaryPath('photo.bmp')).toBe(true); + expect(isBinaryPath('photo.webp')).toBe(true); + }); + + it('detects modern formats', () => { + expect(isBinaryPath('photo.heic')).toBe(true); + expect(isBinaryPath('photo.heif')).toBe(true); + expect(isBinaryPath('photo.avif')).toBe(true); + expect(isBinaryPath('photo.tiff')).toBe(true); + expect(isBinaryPath('photo.tif')).toBe(true); + expect(isBinaryPath('photo.jxl')).toBe(true); + }); + }); + + describe('audio formats', () => { + it('detects common audio formats', () => { + expect(isBinaryPath('song.mp3')).toBe(true); + expect(isBinaryPath('song.wav')).toBe(true); + expect(isBinaryPath('song.ogg')).toBe(true); + expect(isBinaryPath('song.flac')).toBe(true); + expect(isBinaryPath('song.aac')).toBe(true); + expect(isBinaryPath('song.m4a')).toBe(true); + expect(isBinaryPath('song.opus')).toBe(true); + expect(isBinaryPath('song.aiff')).toBe(true); + }); + }); + + describe('video formats', () => { + it('detects common video formats', () => { + expect(isBinaryPath('video.mp4')).toBe(true); + expect(isBinaryPath('video.webm')).toBe(true); + expect(isBinaryPath('video.mov')).toBe(true); + expect(isBinaryPath('video.mkv')).toBe(true); + expect(isBinaryPath('video.avi')).toBe(true); + expect(isBinaryPath('video.wmv')).toBe(true); + expect(isBinaryPath('video.flv')).toBe(true); + expect(isBinaryPath('video.m4v')).toBe(true); + }); + }); + + describe('archive formats', () => { + it('detects archive formats', () => { + expect(isBinaryPath('file.zip')).toBe(true); + expect(isBinaryPath('file.tar')).toBe(true); + expect(isBinaryPath('file.gz')).toBe(true); + expect(isBinaryPath('file.bz2')).toBe(true); + expect(isBinaryPath('file.xz')).toBe(true); + expect(isBinaryPath('file.zst')).toBe(true); + expect(isBinaryPath('file.7z')).toBe(true); + expect(isBinaryPath('file.rar')).toBe(true); + }); + }); + + describe('font formats', () => { + it('detects font formats including otf', () => { + expect(isBinaryPath('font.ttf')).toBe(true); + expect(isBinaryPath('font.otf')).toBe(true); + expect(isBinaryPath('font.woff')).toBe(true); + expect(isBinaryPath('font.woff2')).toBe(true); + }); + }); + + describe('database formats', () => { + it('detects database files', () => { + expect(isBinaryPath('data.sqlite')).toBe(true); + expect(isBinaryPath('data.db')).toBe(true); + }); + }); + + describe('text files', () => { + it('returns false for markdown', () => expect(isBinaryPath('note.md')).toBe(false)); + it('returns false for JSON', () => expect(isBinaryPath('settings.json')).toBe(false)); + it('returns false for TypeScript', () => expect(isBinaryPath('main.ts')).toBe(false)); + it('returns false for YAML', () => expect(isBinaryPath('config.yml')).toBe(false)); + it('returns false for plain text', () => expect(isBinaryPath('readme.txt')).toBe(false)); + }); + + describe('hidden file paths', () => { + it('returns false for dotfiles with no extension', () => { + expect(isBinaryPath('.gitignore')).toBe(false); + expect(isBinaryPath('.env')).toBe(false); + }); + + it('returns false for hidden directory text files', () => { + expect(isBinaryPath('.claude/settings.json')).toBe(false); + expect(isBinaryPath('.claude/CLAUDE.md')).toBe(false); + }); + + it('returns true for hidden directory binary files', () => { + expect(isBinaryPath('.claude/photo.png')).toBe(true); + }); + + it('returns false for hidden file with no extension', () => { + expect(isBinaryPath('.claude')).toBe(false); + }); + }); + + describe('case insensitivity', () => { + it('handles uppercase extensions', () => { + expect(isBinaryPath('photo.PNG')).toBe(true); + expect(isBinaryPath('photo.JPG')).toBe(true); + expect(isBinaryPath('font.TTF')).toBe(true); + }); + + it('handles mixed case extensions', () => { + expect(isBinaryPath('photo.Heic')).toBe(true); + expect(isBinaryPath('song.FLAC')).toBe(true); + }); + }); + + describe('BINARY_EXTENSIONS set completeness', () => { + it('contains all major image formats', () => { + ['png', 'jpg', 'jpeg', 'gif', 'webp', 'heic', 'heif', 'avif', 'tif', 'tiff'].forEach(ext => { + expect(BINARY_EXTENSIONS.has(ext), `missing: ${ext}`).toBe(true); + }); + }); + + it('contains all major audio formats', () => { + ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'opus'].forEach(ext => { + expect(BINARY_EXTENSIONS.has(ext), `missing: ${ext}`).toBe(true); + }); + }); + + it('contains both ttf and otf fonts', () => { + expect(BINARY_EXTENSIONS.has('ttf')).toBe(true); + expect(BINARY_EXTENSIONS.has('otf')).toBe(true); + }); + }); +}); + +describe('contentsEqual', () => { + describe('string comparison', () => { + it('returns true for identical strings', () => { + expect(contentsEqual('hello', 'hello')).toBe(true); + }); + + it('returns false for different strings', () => { + expect(contentsEqual('hello', 'world')).toBe(false); + }); + + it('returns true for empty strings', () => { + expect(contentsEqual('', '')).toBe(true); + }); + + it('is case-sensitive', () => { + expect(contentsEqual('Hello', 'hello')).toBe(false); + }); + }); + + describe('ArrayBuffer comparison', () => { + it('returns true for identical buffers', () => { + const bufA = new Uint8Array([1, 2, 3]).buffer; + const bufB = new Uint8Array([1, 2, 3]).buffer; + expect(contentsEqual(bufA, bufB)).toBe(true); + }); + + it('returns false for different buffers', () => { + const bufA = new Uint8Array([1, 2, 3]).buffer; + const bufB = new Uint8Array([1, 2, 4]).buffer; + expect(contentsEqual(bufA, bufB)).toBe(false); + }); + + it('returns false for buffers of different length', () => { + const bufA = new Uint8Array([1, 2, 3]).buffer; + const bufB = new Uint8Array([1, 2]).buffer; + expect(contentsEqual(bufA, bufB)).toBe(false); + }); + + it('returns true for two empty buffers', () => { + expect(contentsEqual(new ArrayBuffer(0), new ArrayBuffer(0))).toBe(true); + }); + }); + + describe('mixed type comparison', () => { + it('returns false when comparing string with ArrayBuffer', () => { + const buf = new Uint8Array([1, 2, 3]).buffer; + expect(contentsEqual('hello', buf)).toBe(false); + }); + + it('returns false when comparing ArrayBuffer with string', () => { + const buf = new Uint8Array([1, 2, 3]).buffer; + expect(contentsEqual(buf, 'hello')).toBe(false); + }); + }); +}); From 1f71a43980cc01b547896a068c4944e692776847 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Fri, 22 May 2026 03:40:45 +0000 Subject: [PATCH 4/8] refactor(test): extract shared SyncManager mock setup to reduce duplication Move repeated beforeEach mock initialization into createSyncManagerMocks() helper to eliminate ~45 lines of copy-paste across binary and hidden test files. Co-Authored-By: Claude Sonnet 4.6 --- tests/logic/sync-manager-binary.test.ts | 68 +++--------------- tests/logic/sync-manager-hidden.test.ts | 91 +++++------------------- tests/logic/sync-manager-test-helpers.ts | 64 +++++++++++++++++ 3 files changed, 92 insertions(+), 131 deletions(-) create mode 100644 tests/logic/sync-manager-test-helpers.ts diff --git a/tests/logic/sync-manager-binary.test.ts b/tests/logic/sync-manager-binary.test.ts index fbee3d0..2b9bd46 100644 --- a/tests/logic/sync-manager-binary.test.ts +++ b/tests/logic/sync-manager-binary.test.ts @@ -1,72 +1,20 @@ /* eslint-disable @typescript-eslint/unbound-method */ -import { describe, it, expect, vi, beforeEach, Mocked } from 'vitest'; -import { SyncManager } from '../../src/logic/sync-manager'; -import { App, DataAdapter } from 'obsidian'; -import { GitLabFilesPushSettings } from '../../src/settings'; -import { GitServiceInterface } from '../../src/services/git-service-interface'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createSyncManagerMocks, makeBuf, SyncManagerMocks } from './sync-manager-test-helpers'; vi.mock('obsidian'); describe('SyncManager – binary file handling', () => { - let manager: SyncManager; - let mockApp: Mocked; - let mockGitService: Mocked; - let mockAdapter: Mocked; - let mockSettings: GitLabFilesPushSettings; - - const makeBuf = (bytes: number[]) => new Uint8Array(bytes).buffer; + let mocks: SyncManagerMocks; beforeEach(() => { vi.clearAllMocks(); - - mockAdapter = { - exists: vi.fn(), - read: vi.fn(), - write: vi.fn(), - readBinary: vi.fn(), - writeBinary: vi.fn(), - mkdir: vi.fn().mockResolvedValue(undefined), - } as unknown as Mocked; - - mockApp = { - vault: { - read: vi.fn(), - readBinary: vi.fn(), - modify: vi.fn(), - modifyBinary: vi.fn(), - getFileByPath: vi.fn().mockReturnValue(null), - adapter: mockAdapter, - }, - } as unknown as Mocked; - - mockGitService = { - pushFile: vi.fn(), - getFile: vi.fn(), - testConnection: vi.fn(), - listFiles: vi.fn(), - deleteFile: vi.fn(), - getRepoGitignores: vi.fn(), - updateConfig: vi.fn(), - } as unknown as Mocked; - - mockSettings = { - serviceType: 'github', - githubToken: 'token', - githubOwner: 'owner', - githubRepo: 'repo', - branch: 'main', - syncMetadata: {}, - vaultFolder: '', - rootPath: '', - } as unknown as GitLabFilesPushSettings; - - manager = new SyncManager(mockApp, mockGitService, mockSettings); - // @ts-ignore - manager.saveSettings = vi.fn().mockResolvedValue(undefined); + mocks = createSyncManagerMocks(); }); describe('pushFile with binary path (string)', () => { it('reads via adapter.readBinary for binary extensions', async () => { + const { manager, mockAdapter, mockGitService } = mocks; const buf = makeBuf([137, 80, 78, 71]); vi.mocked(mockAdapter.exists).mockResolvedValue(true); vi.mocked(mockAdapter.readBinary).mockResolvedValue(buf); @@ -83,6 +31,7 @@ describe('SyncManager – binary file handling', () => { }); it('skips push when binary content is already in sync', async () => { + const { manager, mockAdapter, mockGitService } = mocks; const buf = makeBuf([1, 2, 3, 4]); vi.mocked(mockAdapter.exists).mockResolvedValue(true); vi.mocked(mockAdapter.readBinary).mockResolvedValue(buf); @@ -94,6 +43,7 @@ describe('SyncManager – binary file handling', () => { }); it('updates metadata when binary is already in sync', async () => { + const { manager, mockAdapter, mockGitService, mockSettings } = mocks; const buf = makeBuf([1, 2, 3]); vi.mocked(mockAdapter.exists).mockResolvedValue(true); vi.mocked(mockAdapter.readBinary).mockResolvedValue(buf); @@ -109,6 +59,7 @@ describe('SyncManager – binary file handling', () => { describe('pullFile with binary content', () => { it('writes via adapter.writeBinary when remote content is ArrayBuffer', async () => { + const { manager, mockAdapter, mockGitService } = mocks; const buf = makeBuf([137, 80, 78, 71]); vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'bin-sha', content: buf }); vi.mocked(mockAdapter.exists).mockResolvedValue(false); @@ -120,6 +71,7 @@ describe('SyncManager – binary file handling', () => { }); it('creates parent directory before writing binary', async () => { + const { manager, mockAdapter, mockGitService } = mocks; const buf = makeBuf([255, 216, 255]); vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'bin-sha', content: buf }); vi.mocked(mockAdapter.exists).mockResolvedValue(false); @@ -131,6 +83,7 @@ describe('SyncManager – binary file handling', () => { }); it('skips pull when binary content is already in sync', async () => { + const { manager, mockAdapter, mockGitService, mockSettings } = mocks; const buf = makeBuf([1, 2, 3]); vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'bin-sha', content: buf }); vi.mocked(mockAdapter.exists).mockResolvedValue(true); @@ -143,6 +96,7 @@ describe('SyncManager – binary file handling', () => { }); it('updates metadata after pulling binary file', async () => { + const { manager, mockAdapter, mockGitService, mockSettings } = mocks; const buf = makeBuf([0, 1, 2]); vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'bin-sha', content: buf }); vi.mocked(mockAdapter.exists).mockResolvedValue(false); diff --git a/tests/logic/sync-manager-hidden.test.ts b/tests/logic/sync-manager-hidden.test.ts index b4d6eb8..2207aa5 100644 --- a/tests/logic/sync-manager-hidden.test.ts +++ b/tests/logic/sync-manager-hidden.test.ts @@ -1,71 +1,21 @@ /* eslint-disable @typescript-eslint/unbound-method */ -import { describe, it, expect, vi, beforeEach, Mocked } from 'vitest'; -import { SyncManager } from '../../src/logic/sync-manager'; -import { App, DataAdapter } from 'obsidian'; -import { GitLabFilesPushSettings } from '../../src/settings'; -import { GitServiceInterface } from '../../src/services/git-service-interface'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createSyncManagerMocks, SyncManagerMocks } from './sync-manager-test-helpers'; vi.mock('obsidian'); describe('SyncManager – hidden file support', () => { - let manager: SyncManager; - let mockApp: Mocked; - let mockGitService: Mocked; - let mockAdapter: Mocked; - let mockSettings: GitLabFilesPushSettings; + let mocks: SyncManagerMocks; beforeEach(() => { vi.clearAllMocks(); - - mockAdapter = { - exists: vi.fn(), - read: vi.fn(), - write: vi.fn(), - writeBinary: vi.fn(), - mkdir: vi.fn().mockResolvedValue(undefined), - } as unknown as Mocked; - - mockApp = { - vault: { - read: vi.fn(), - modify: vi.fn(), - getFileByPath: vi.fn().mockReturnValue(null), - adapter: mockAdapter, - }, - } as unknown as Mocked; - - mockGitService = { - pushFile: vi.fn(), - getFile: vi.fn(), - testConnection: vi.fn(), - listFiles: vi.fn(), - deleteFile: vi.fn(), - getRepoGitignores: vi.fn(), - updateConfig: vi.fn(), - } as unknown as Mocked; - - mockSettings = { - serviceType: 'github', - githubToken: 'token', - githubOwner: 'owner', - githubRepo: 'repo', - branch: 'main', - syncMetadata: {}, - vaultFolder: '', - rootPath: '', - } as unknown as GitLabFilesPushSettings; - - manager = new SyncManager(mockApp, mockGitService, mockSettings); - // @ts-ignore - accessing private for test - manager.saveSettings = vi.fn().mockResolvedValue(undefined); + mocks = createSyncManagerMocks(); }); describe('pullFile with hidden paths', () => { it('creates single hidden parent directory on pull', async () => { - vi.mocked(mockGitService.getFile).mockResolvedValue({ - sha: 'abc123', - content: '{"key":"value"}', - }); + const { manager, mockAdapter, mockGitService } = mocks; + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'abc123', content: '{"key":"value"}' }); vi.mocked(mockAdapter.exists).mockResolvedValue(false); await manager.pullFile('.claude/settings.json'); @@ -75,10 +25,8 @@ describe('SyncManager – hidden file support', () => { }); it('creates all nested hidden parent directories on pull', async () => { - vi.mocked(mockGitService.getFile).mockResolvedValue({ - sha: 'def456', - content: 'nested content', - }); + const { manager, mockAdapter, mockGitService } = mocks; + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'def456', content: 'nested content' }); vi.mocked(mockAdapter.exists).mockResolvedValue(false); await manager.pullFile('.claude/memory/user.md'); @@ -89,10 +37,8 @@ describe('SyncManager – hidden file support', () => { }); it('does not fail if hidden directory already exists (mkdir throws)', async () => { - vi.mocked(mockGitService.getFile).mockResolvedValue({ - sha: 'abc123', - content: 'content', - }); + const { manager, mockAdapter, mockGitService } = mocks; + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'abc123', content: 'content' }); vi.mocked(mockAdapter.exists).mockResolvedValue(false); vi.mocked(mockAdapter.mkdir).mockRejectedValue(new Error('already exists')); @@ -101,10 +47,8 @@ describe('SyncManager – hidden file support', () => { }); it('updates metadata after pulling hidden file', async () => { - vi.mocked(mockGitService.getFile).mockResolvedValue({ - sha: 'sha-hidden', - content: 'file content', - }); + const { manager, mockAdapter, mockGitService, mockSettings } = mocks; + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'sha-hidden', content: 'file content' }); vi.mocked(mockAdapter.exists).mockResolvedValue(false); await manager.pullFile('.claude/CLAUDE.md'); @@ -117,6 +61,7 @@ describe('SyncManager – hidden file support', () => { describe('pushFile with hidden paths', () => { it('pushes hidden file content via string path', async () => { + const { manager, mockAdapter, mockGitService } = mocks; vi.mocked(mockAdapter.exists).mockResolvedValue(true); vi.mocked(mockAdapter.read).mockResolvedValue('# Memory\n\nsome content'); vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: '', content: '' }); @@ -125,15 +70,12 @@ describe('SyncManager – hidden file support', () => { await manager.pushFile('.claude/CLAUDE.md'); expect(mockGitService.pushFile).toHaveBeenCalledWith( - '.claude/CLAUDE.md', - '# Memory\n\nsome content', - 'main', - expect.any(String), - '' + '.claude/CLAUDE.md', '# Memory\n\nsome content', 'main', expect.any(String), '' ); }); it('skips push when hidden file is already in sync', async () => { + const { manager, mockAdapter, mockGitService } = mocks; const content = 'same content'; vi.mocked(mockAdapter.exists).mockResolvedValue(true); vi.mocked(mockAdapter.read).mockResolvedValue(content); @@ -144,7 +86,8 @@ describe('SyncManager – hidden file support', () => { expect(mockGitService.pushFile).not.toHaveBeenCalled(); }); - it('does not exist notice when hidden file is missing', async () => { + it('does not push when hidden file is missing from vault', async () => { + const { manager, mockAdapter, mockGitService } = mocks; vi.mocked(mockAdapter.exists).mockResolvedValue(false); await manager.pushFile('.claude/missing.json'); diff --git a/tests/logic/sync-manager-test-helpers.ts b/tests/logic/sync-manager-test-helpers.ts new file mode 100644 index 0000000..259b44e --- /dev/null +++ b/tests/logic/sync-manager-test-helpers.ts @@ -0,0 +1,64 @@ +import { vi, Mocked } from 'vitest'; +import { SyncManager } from '../../src/logic/sync-manager'; +import { App, DataAdapter } from 'obsidian'; +import { GitLabFilesPushSettings } from '../../src/settings'; +import { GitServiceInterface } from '../../src/services/git-service-interface'; + +export interface SyncManagerMocks { + manager: SyncManager; + mockApp: Mocked; + mockGitService: Mocked; + mockAdapter: Mocked; + mockSettings: GitLabFilesPushSettings; +} + +export function createSyncManagerMocks(): SyncManagerMocks { + const mockAdapter = { + exists: vi.fn(), + read: vi.fn(), + write: vi.fn(), + readBinary: vi.fn(), + writeBinary: vi.fn(), + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as Mocked; + + const mockApp = { + vault: { + read: vi.fn(), + readBinary: vi.fn(), + modify: vi.fn(), + modifyBinary: vi.fn(), + getFileByPath: vi.fn().mockReturnValue(null), + adapter: mockAdapter, + }, + } as unknown as Mocked; + + const mockGitService = { + pushFile: vi.fn(), + getFile: vi.fn(), + testConnection: vi.fn(), + listFiles: vi.fn(), + deleteFile: vi.fn(), + getRepoGitignores: vi.fn(), + updateConfig: vi.fn(), + } as unknown as Mocked; + + const mockSettings = { + serviceType: 'github', + githubToken: 'token', + githubOwner: 'owner', + githubRepo: 'repo', + branch: 'main', + syncMetadata: {}, + vaultFolder: '', + rootPath: '', + } as unknown as GitLabFilesPushSettings; + + const manager = new SyncManager(mockApp, mockGitService, mockSettings); + // @ts-ignore - accessing private for test + manager.saveSettings = vi.fn().mockResolvedValue(undefined); + + return { manager, mockApp, mockGitService, mockAdapter, mockSettings }; +} + +export const makeBuf = (bytes: number[]) => new Uint8Array(bytes).buffer; From d907baf229adaa6d34d6c67c75c8d4825a4751ed Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Fri, 22 May 2026 03:45:56 +0000 Subject: [PATCH 5/8] docs: remove SonarCloud quality gate badge from README Co-Authored-By: Claude Sonnet 4.6 --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 7519b5b..349097e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ [![Downloads](https://img.shields.io/github/downloads/firstsun-dev/git-files-sync/total?style=flat-square)](https://github.com/firstsun-dev/git-files-sync/releases) [![License](https://img.shields.io/github/license/firstsun-dev/git-files-sync?style=flat-square)](LICENSE) [![Obsidian](https://img.shields.io/badge/Obsidian-v0.15.0+-purple?style=flat-square)](https://obsidian.md) -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=firstsun-dev_git-files-sync&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=firstsun-dev_git-files-sync) **Git File Sync** is a powerful Obsidian plugin that enables seamless synchronization of individual notes with GitLab or GitHub repositories. Unlike full-vault sync solutions, it gives you granular control over what gets pushed and pulled, making it perfect for shared projects, selective backups, and cross-platform workflows. From f49600eb667f917d445dda450dffb3e4d0128324 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Fri, 22 May 2026 03:52:32 +0000 Subject: [PATCH 6/8] refactor(test): eliminate duplicate code flagged by SonarCloud - Extract adapter variable and loadWith() helper in gitignore hidden-file tests - Hoist shared ArrayBuffer constants in path.test.ts Reduces new_duplicated_lines_density from ~78%/46% to near 0%. Co-Authored-By: Claude Sonnet 4.6 --- tests/logic/gitignore-manager.test.ts | 31 ++++++++++----------------- tests/utils/path.test.ts | 18 ++++++---------- 2 files changed, 18 insertions(+), 31 deletions(-) diff --git a/tests/logic/gitignore-manager.test.ts b/tests/logic/gitignore-manager.test.ts index 81aeb0e..af9b393 100644 --- a/tests/logic/gitignore-manager.test.ts +++ b/tests/logic/gitignore-manager.test.ts @@ -225,57 +225,48 @@ describe('GitignoreManager', () => { }); describe('hidden file and directory filtering', () => { + let adapter: Mocked; + beforeEach(async () => { + adapter = mockApp.vault.adapter as Mocked; vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']); - const adapter = mockApp.vault.adapter as Mocked; vi.mocked(adapter.exists).mockResolvedValue(true); }); - it('should ignore hidden directory when listed in .gitignore', async () => { - const adapter = mockApp.vault.adapter as Mocked; - vi.mocked(adapter.read).mockResolvedValue('.private/\n.trash/'); + async function loadWith(rules: string) { + vi.mocked(adapter.read).mockResolvedValue(rules); await manager.loadGitignores(); + } + it('should ignore hidden directory when listed in .gitignore', async () => { + await loadWith('.private/\n.trash/'); expect(manager.isIgnored('.private/secrets.md')).toBe(true); expect(manager.isIgnored('.trash/note.md')).toBe(true); }); it('should not ignore hidden directory absent from .gitignore', async () => { - const adapter = mockApp.vault.adapter as Mocked; - vi.mocked(adapter.read).mockResolvedValue('node_modules/\n*.log'); - await manager.loadGitignores(); - + await loadWith('node_modules/\n*.log'); expect(manager.isIgnored('.claude/settings.json')).toBe(false); expect(manager.isIgnored('.claude/CLAUDE.md')).toBe(false); }); it('should ignore .claude/ when explicitly added to .gitignore', async () => { - const adapter = mockApp.vault.adapter as Mocked; - vi.mocked(adapter.read).mockResolvedValue('.claude/'); - await manager.loadGitignores(); - + await loadWith('.claude/'); expect(manager.isIgnored('.claude/settings.json')).toBe(true); expect(manager.isIgnored('.claude/memory/user.md')).toBe(true); }); it('should still pass normal files when only hidden dirs are ignored', async () => { - const adapter = mockApp.vault.adapter as Mocked; - vi.mocked(adapter.read).mockResolvedValue('.private/'); - await manager.loadGitignores(); - + await loadWith('.private/'); expect(manager.isIgnored('notes/my-note.md')).toBe(false); expect(manager.isIgnored('projects/work.md')).toBe(false); }); it('should find .gitignore inside a hidden directory via scanDir', async () => { - const adapter = mockApp.vault.adapter as Mocked; vi.mocked(adapter.list) .mockResolvedValueOnce({ files: ['.gitignore'], folders: ['.claude'] }) .mockResolvedValueOnce({ files: ['.claude/.gitignore'], folders: [] }); - vi.mocked(adapter.exists).mockResolvedValue(true); vi.mocked(adapter.read).mockResolvedValue('*.secret'); - vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']); - await manager.loadGitignores(); expect(adapter.list).toHaveBeenCalledWith(''); diff --git a/tests/utils/path.test.ts b/tests/utils/path.test.ts index c1cbd35..e291103 100644 --- a/tests/utils/path.test.ts +++ b/tests/utils/path.test.ts @@ -157,22 +157,18 @@ describe('contentsEqual', () => { }); describe('ArrayBuffer comparison', () => { + const buf123 = new Uint8Array([1, 2, 3]).buffer; + it('returns true for identical buffers', () => { - const bufA = new Uint8Array([1, 2, 3]).buffer; - const bufB = new Uint8Array([1, 2, 3]).buffer; - expect(contentsEqual(bufA, bufB)).toBe(true); + expect(contentsEqual(buf123, new Uint8Array([1, 2, 3]).buffer)).toBe(true); }); it('returns false for different buffers', () => { - const bufA = new Uint8Array([1, 2, 3]).buffer; - const bufB = new Uint8Array([1, 2, 4]).buffer; - expect(contentsEqual(bufA, bufB)).toBe(false); + expect(contentsEqual(buf123, new Uint8Array([1, 2, 4]).buffer)).toBe(false); }); it('returns false for buffers of different length', () => { - const bufA = new Uint8Array([1, 2, 3]).buffer; - const bufB = new Uint8Array([1, 2]).buffer; - expect(contentsEqual(bufA, bufB)).toBe(false); + expect(contentsEqual(buf123, new Uint8Array([1, 2]).buffer)).toBe(false); }); it('returns true for two empty buffers', () => { @@ -181,13 +177,13 @@ describe('contentsEqual', () => { }); describe('mixed type comparison', () => { + const buf = new Uint8Array([1, 2, 3]).buffer; + it('returns false when comparing string with ArrayBuffer', () => { - const buf = new Uint8Array([1, 2, 3]).buffer; expect(contentsEqual('hello', buf)).toBe(false); }); it('returns false when comparing ArrayBuffer with string', () => { - const buf = new Uint8Array([1, 2, 3]).buffer; expect(contentsEqual(buf, 'hello')).toBe(false); }); }); From 7cd9e38e65ee98c4c4d9854db7f7209a5c1cef61 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Mon, 25 May 2026 02:35:58 +0000 Subject: [PATCH 7/8] docs: update README badges --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 349097e..80f808d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,12 @@ # Git File Sync +[![CI](https://img.shields.io/github/actions/workflow/status/firstsun-dev/git-files-sync/ci.yml?branch=main&style=flat-square)](https://github.com/firstsun-dev/git-files-sync/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/firstsun-dev/git-files-sync?style=flat-square)](https://github.com/firstsun-dev/git-files-sync/releases) [![Downloads](https://img.shields.io/github/downloads/firstsun-dev/git-files-sync/total?style=flat-square)](https://github.com/firstsun-dev/git-files-sync/releases) -[![License](https://img.shields.io/github/license/firstsun-dev/git-files-sync?style=flat-square)](LICENSE) [![Obsidian](https://img.shields.io/badge/Obsidian-v0.15.0+-purple?style=flat-square)](https://obsidian.md) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.8-blue?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) +[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg?style=flat-square)](https://conventionalcommits.org) +[![License](https://img.shields.io/github/license/firstsun-dev/git-files-sync?style=flat-square)](LICENSE) **Git File Sync** is a powerful Obsidian plugin that enables seamless synchronization of individual notes with GitLab or GitHub repositories. Unlike full-vault sync solutions, it gives you granular control over what gets pushed and pulled, making it perfect for shared projects, selective backups, and cross-platform workflows. From 8d7a25f30c00c359863638a5477df259788e0b2c Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Mon, 25 May 2026 02:47:07 +0000 Subject: [PATCH 8/8] ci: add CodeQL static analysis workflow --- .github/workflows/codeql.yml | 42 ++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..f9e13da --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,42 @@ +name: "CodeQL" + +on: + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master" ] + schedule: + - cron: '30 1 * * 1' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'javascript-typescript' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # Queries can be 'security-extended' or 'security-and-quality' + queries: security-extended + + - name: Auto-build + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}"