diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 500d1ef..da48184 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,3 +20,35 @@ jobs: plugin-id: "git-file-sync" secrets: RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + + build-artifact: + name: Upload build artifact + runs-on: ubuntu-latest + if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master' + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + + - run: npm ci + + - run: npm run build + + - name: Set artifact name + id: artifact + run: | + BRANCH=$(echo "${{ github.ref_name }}" | tr '/' '-') + SHA=$(echo "${{ github.sha }}" | cut -c1-7) + echo "name=plugin-${BRANCH}-${SHA}" >> $GITHUB_OUTPUT + + - uses: actions/upload-artifact@v5 + with: + name: ${{ steps.artifact.outputs.name }} + path: | + main.js + manifest.json + styles.css + retention-days: 7 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f9e13da..84d8741 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,19 +24,19 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 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 + uses: github/codeql-action/autobuild@v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 with: category: "/language:${{matrix.language}}" diff --git a/README.md b/README.md index 887bf97..7bfb843 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![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. +**Git File Sync** is a powerful Obsidian plugin that enables seamless synchronization of individual notes with GitLab, GitHub, or self-hosted Gitea 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. [繁體中文使用說明](USAGE_zh.md) @@ -22,6 +22,22 @@ --- +## Supported Providers + +GitHub   +GitLab   +Gitea + +| Provider | Hosting | Min. Version | +| :--- | :--- | :--- | +| GitHub | github.com · GitHub Enterprise | — | +| GitLab | gitlab.com · self-hosted | GitLab 13.0+ | +| Gitea | self-hosted | Gitea 1.12+ | + +> **Gitea compatibility note**: The plugin uses the Gitea API v1 (`/api/v1`). It resolves branch names to commit SHAs before fetching the file tree, which ensures compatibility with Gitea 1.12 and later. Versions before 1.12 are not supported. + +--- + ## Key Features ### Selective Synchronization @@ -31,7 +47,7 @@ Don't sync your whole vault. Selectively push or pull individual notes, or use b A comprehensive dashboard provides a bird's-eye view of your vault's status: - **Status Filtering**: Instantly see what's modified, new, or missing. - **Visual Diffs**: Compare local and remote changes line-by-line before syncing. -- **Remote-Only Detection**: Identify files existing on GitLab/GitHub that aren't in your vault yet. +- **Remote-Only Detection**: Identify files existing on GitLab/GitHub/Gitea that aren't in your vault yet. ### Intelligent Conflict Resolution When versions clash, Git File Sync provides a dedicated diff viewer to help you resolve conflicts manually. Choose the local version, the remote version, or merge them with confidence. @@ -62,13 +78,21 @@ Full support for Obsidian Mobile. Push and pull your notes on the go with a resp *Configure the plugin by selecting your preferred Git service and providing the necessary credentials.* ### 1. Choose Your Service -Go to **Settings** > **Git File Sync** and select either **GitLab** or **GitHub**. +Go to **Settings** > **Git File Sync** and select **GitLab**, **GitHub**, or **Gitea** from the dropdown. ### 2. Provider Setup -| Service | Required Info | Scope Needed | -| :--- | :--- | :--- | -| **GitLab** | Personal Access Token, Project ID, Base URL | `api` | -| **GitHub** | Personal Access Token, Owner, Repo Name | `repo` | + +| | Provider | Required Info | Token Scope | +| :---: | :--- | :--- | :--- | +| GitHub | **GitHub** | Personal Access Token, Owner, Repo Name | `repo` | +| GitLab | **GitLab** | Personal Access Token, Project ID, Base URL | `api` | +| Gitea | **Gitea** | Personal Access Token, Base URL, Owner, Repo Name | (all) | + +**GitHub token**: Settings → Developer settings → Personal access tokens → `repo` scope. + +**GitLab token**: User settings → Access tokens → `api` scope. The Base URL defaults to `https://gitlab.com`; change it for self-hosted instances. + +**Gitea token**: User settings → Applications → Access tokens. Set the Base URL to your Gitea instance (e.g. `https://gitea.example.com`). ### 3. Common Settings - **Branch**: Specify the target branch (default: `main`). @@ -87,9 +111,9 @@ Once configured, you should perform an initial status check: ### Daily Workflow: Pushing Changes When you finish editing a note and want to save it to Git: -- **Current Note**: Use the cloud icon in the ribbon or the command `Push current file to GitLab/GitHub`. +- **Current Note**: Use the cloud icon in the ribbon or the command `Push current file to GitLab/GitHub/Gitea`. - **Multiple Notes**: Open the Sync Status View, use the **Modified** filter, select the files you want to sync, and click **Push selected**. -- **Context Menu**: Right-click any file in the File Explorer and select `Push to GitLab/GitHub`. +- **Context Menu**: Right-click any file in the File Explorer and select `Push to GitLab/GitHub/Gitea`. ### Daily Workflow: Pulling Changes To get the latest updates from other devices: @@ -115,7 +139,7 @@ On mobile devices: ## Privacy and Security -- **Local Storage**: Your Personal Access Tokens (PAT) are stored locally in the plugin's data folder within your vault. They are never sent to any server other than GitLab/GitHub. +- **Local Storage**: Your Personal Access Tokens (PAT) are stored locally in the plugin's data folder within your vault. They are never sent to any server other than your configured Git provider. - **No Telemetry**: This plugin does not collect any data or usage analytics. --- diff --git a/USAGE_zh.md b/USAGE_zh.md index 216199c..a3f8876 100644 --- a/USAGE_zh.md +++ b/USAGE_zh.md @@ -2,7 +2,21 @@ -本指南將引導您如何使用 Git File Sync 插件,在行動裝置與桌面電腦之間,透過 GitLab 或 GitHub 輕鬆同步您的筆記。 +本指南將引導您如何使用 Git File Sync 插件,在行動裝置與桌面電腦之間,透過 GitLab、GitHub 或自架的 Gitea 輕鬆同步您的筆記。 + +--- + +## 支援的 Git 服務 + +GitHub   +GitLab   +Gitea + +| | 服務 | 適用情境 | 最低版本 | +| :---: | :--- | :--- | :--- | +| GitHub | **GitHub** | 公開 / 私人儲存庫 | — | +| GitLab | **GitLab** | gitlab.com 或自架 | GitLab 13.0+ | +| Gitea | **Gitea** | 自架 Git 伺服器 | Gitea 1.12+ | --- @@ -13,10 +27,11 @@ ![Plugin Settings](imgs/plugin-settings.png) *在設定面板選擇您的 Git 服務並填入對應的憑證與路徑。* -1. **選擇服務**:在 `設定` > `Git File Sync` 中選擇 GitLab 或 GitHub。 +1. **選擇服務**:在 `設定` > `Git File Sync` 中選擇 GitLab、GitHub 或 Gitea。 2. **填寫憑證**: - - **GitHub**:需要 個人存取權杖 (PAT)、帳號名稱、儲存庫名稱。 - - **GitLab**:需要 個人存取權杖 (PAT)、專案 ID、伺服器網址 (預設為 gitlab.com)。 + - **GitHub**:需要 個人存取權杖 (PAT)、帳號名稱、儲存庫名稱。權杖需具備 `repo` 權限。 + - **GitLab**:需要 個人存取權杖 (PAT)、專案 ID、伺服器網址(預設為 `https://gitlab.com`,自架請改為您的網址)。權杖需具備 `api` 權限。 + - **Gitea**:需要 個人存取權杖 (PAT)、伺服器網址(例如 `https://gitea.example.com`)、帳號名稱、儲存庫名稱。權杖在 `使用者設定` > `應用程式` > `存取權杖` 中建立。 3. **儲存庫路徑**:如果您想將筆記存放在儲存庫的特定資料夾(例如 `notes/`),請在 `Root Path` 中設定。 --- @@ -41,7 +56,7 @@ 當您寫完筆記,想備份到雲端時: - **單一檔案**: - 點擊左側功能列的 **雲端上傳圖示**。 - - 或者在檔案列表點擊右鍵,選擇 `Push to GitLab/GitHub`。 + - 或者在檔案列表點擊右鍵,選擇 `Push to GitLab/GitHub/Gitea`。 - **批量上傳**: - 在同步面板勾選多個檔案,點擊下方的 **Push selected**。 @@ -50,7 +65,7 @@ ### ⬇️ 如何下載(Pull) 當您在另一台裝置更新了筆記,想同步回目前裝置時: 1. 打開同步面板,點擊 **Refresh status**。 -2. 找到顯示為 **Remote only** 或 **Modified** (雲端版本較新) 的檔案。 +2. 找到顯示為 **Remote only** 或 **Modified**(雲端版本較新)的檔案。 3. 勾選後點擊 **Pull selected**。 4. **注意**:Pull 會覆蓋掉您本機的內容。如果有衝突,會自動開啟衝突解決視窗。 @@ -79,5 +94,5 @@ ## 🔒 隱私與安全 -- 您的存取權杖 (Token) 僅會儲存在您本機的 Obsidian 資料夾內。 +- 您的存取權杖 (Token) 僅會儲存在您本機的 Obsidian 資料夾內,不會傳送至任何第三方伺服器。 - 本插件不會收集任何個人數據或使用紀錄。 diff --git a/src/main.ts b/src/main.ts index 29d4c46..7c1ad7b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { Plugin, TFile, MarkdownView, Notice, Platform } from 'obsidian'; import { DEFAULT_SETTINGS, GitLabFilesPushSettings, GitLabSyncSettingTab, getServiceName } from "./settings"; import { GitLabService } from './services/gitlab-service'; import { GitHubService } from './services/github-service'; +import { GiteaService } from './services/gitea-service'; import { GitServiceInterface } from './services/git-service-interface'; import { SyncManager } from './logic/sync-manager'; import { SyncStatusView, SYNC_STATUS_VIEW_TYPE } from './ui/SyncStatusView'; @@ -235,6 +236,16 @@ export default class GitLabFilesPush extends Plugin { this.settings.rootPath ); this.gitService = service; + } else if (this.settings.serviceType === 'gitea') { + const service = new GiteaService(); + service.updateConfig( + this.settings.giteaBaseUrl, + this.settings.giteaToken, + this.settings.giteaOwner, + this.settings.giteaRepo, + this.settings.rootPath + ); + this.gitService = service; } else { const service = new GitHubService(); service.updateConfig( diff --git a/src/services/gitea-service.ts b/src/services/gitea-service.ts new file mode 100644 index 0000000..c5ffc8e --- /dev/null +++ b/src/services/gitea-service.ts @@ -0,0 +1,108 @@ +import { GitServiceInterface, GitTreeEntry } from './git-service-interface'; +import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE } from './git-service-base'; +import { logger } from '../utils/logger'; + +export class GiteaService extends BaseGitService implements GitServiceInterface { + private baseUrl: string = ''; + private owner: string = ''; + private repo: string = ''; + + updateConfig(baseUrl: string, token: string, owner: string, repo: string, rootPath: string = '') { + this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + this.token = token; + this.owner = owner; + this.repo = repo; + this.rootPath = rootPath; + } + + protected addAuthHeader(headers: Record): void { + headers['Authorization'] = `token ${this.token}`; + } + + private getApiUrl(path: string): string { + const fullPath = this.getFullPath(path); + return `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/contents/${fullPath}`; + } + + async getFile(path: string, branch: string): Promise { + try { + const url = `${this.getApiUrl(path)}?ref=${branch}`; + const response = await this.safeRequest(url, 'GET'); + const data = this.parseJson(response); + + return { + content: this.decodeContent(data.content, path), + sha: data.sha + }; + } catch (e) { + return this.handleFileNotFound(e); + } + } + + async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }> { + const url = this.getApiUrl(path); + const body: { message: string; content: string; branch: string; sha?: string } = { + message, + content: this.encodeContent(content), + branch, + }; + // Only send sha when updating an existing file; a blank sha is rejected. + if (sha) body.sha = sha; + + const method = sha ? 'PUT' : 'POST'; + const response = await this.safeRequest(url, method, body); + const data = this.parseJson<{ content: { path: string, sha: string } }>(response); + return { path: data.content.path, sha: data.content.sha }; + } + + async listFilesDetailed(branch: string, useFilter = true): Promise { + // Resolve branch name to commit SHA first for compatibility with all Gitea versions, + // since the git/trees endpoint requires a SHA (not a ref name) on older instances. + const branchUrl = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/branches/${branch}`; + const branchResponse = await this.safeRequest(branchUrl, 'GET'); + const branchData = this.parseJson<{ commit: { id: string } }>(branchResponse); + const commitSha = branchData.commit.id; + + const treeUrl = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/git/trees/${commitSha}?recursive=1`; + const treeResponse = await this.safeRequest(treeUrl, 'GET'); + const treeData = this.parseJson(treeResponse); + + if (treeData.truncated) { + logger.warn('Gitea tree result is truncated. Some files might not be shown.'); + } + + const entries = treeData.tree + .filter(item => item.type === 'blob') + .map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE })); + + if (!useFilter) return entries; + + return entries.filter(e => { + if (!this.rootPath) return true; + const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`; + return e.path === this.rootPath || e.path.startsWith(cleanRoot); + }); + } + + async deleteFile(path: string, branch: string, message: string): Promise { + const file = await this.getFile(path, branch); + const url = this.getApiUrl(path); + const body = { + message, + sha: file.sha, + branch + }; + + await this.safeRequest(url, 'DELETE', body); + } + + async testConnection(): Promise { + try { + const url = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}`; + await this.safeRequest(url, 'GET'); + return true; + } catch { + return false; + } + } +} diff --git a/src/settings.ts b/src/settings.ts index bc6e886..0fdcae1 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -7,7 +7,7 @@ export interface SyncMetadata { lastKnownPath?: string; } -export type GitServiceType = 'gitlab' | 'github'; +export type GitServiceType = 'gitlab' | 'github' | 'gitea'; /** * How symbolic links (Git blobs with mode 120000) are synced: @@ -26,6 +26,10 @@ export interface GitLabFilesPushSettings { githubToken: string; githubOwner: string; githubRepo: string; + giteaToken: string; + giteaBaseUrl: string; + giteaOwner: string; + giteaRepo: string; branch: string; syncMetadata: Record; rootPath: string; @@ -34,7 +38,9 @@ export interface GitLabFilesPushSettings { } export function getServiceName(settings: GitLabFilesPushSettings): string { - return settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; + if (settings.serviceType === 'gitlab') return 'GitLab'; + if (settings.serviceType === 'gitea') return 'Gitea'; + return 'GitHub'; } export const DEFAULT_SETTINGS: GitLabFilesPushSettings = { @@ -45,6 +51,10 @@ export const DEFAULT_SETTINGS: GitLabFilesPushSettings = { githubToken: '', githubOwner: '', githubRepo: '', + giteaToken: '', + giteaBaseUrl: '', + giteaOwner: '', + giteaRepo: '', rootPath: "", branch: 'main', syncMetadata: {}, @@ -67,10 +77,11 @@ export class GitLabSyncSettingTab extends PluginSettingTab { new Setting(containerEl) .setName('Git service') - .setDesc('Choose between GitLab or GitHub') + .setDesc('Choose your Git hosting service') .addDropdown(dropdown => dropdown .addOption('gitlab', 'GitLab') .addOption('github', 'GitHub') + .addOption('gitea', 'Gitea') .setValue(this.plugin.settings.serviceType) .onChange((value: string) => { this.plugin.settings.serviceType = value as GitServiceType; @@ -83,6 +94,8 @@ export class GitLabSyncSettingTab extends PluginSettingTab { if (this.plugin.settings.serviceType === 'gitlab') { this.displayGitLabSettings(containerEl); + } else if (this.plugin.settings.serviceType === 'gitea') { + this.displayGiteaSettings(containerEl); } else { this.displayGitHubSettings(containerEl); } @@ -188,6 +201,56 @@ export class GitLabSyncSettingTab extends PluginSettingTab { })); } + private displayGiteaSettings(containerEl: HTMLElement): void { + new Setting(containerEl) + .setName('Gitea personal access token') + .setDesc('Create a token in user settings > applications > access tokens') + .addText(text => text + .setPlaceholder('Enter your token') + .setValue(this.plugin.settings.giteaToken) + .onChange((value) => { + this.plugin.settings.giteaToken = value; + void this.plugin.saveSettings(); + this.plugin.initializeGitService(); + })); + + new Setting(containerEl) + .setName('Gitea base URL') + .setDesc('URL of your Gitea instance (e.g. https://gitea.example.com)') + .addText(text => text + .setPlaceholder('https://gitea.example.com') + .setValue(this.plugin.settings.giteaBaseUrl) + .onChange((value) => { + this.plugin.settings.giteaBaseUrl = value; + void this.plugin.saveSettings(); + this.plugin.initializeGitService(); + })); + + new Setting(containerEl) + .setName('Repository owner') + .setDesc('Gitea username or organization name') + .addText(text => text + .setPlaceholder('Username') + .setValue(this.plugin.settings.giteaOwner) + .onChange((value) => { + this.plugin.settings.giteaOwner = value; + void this.plugin.saveSettings(); + this.plugin.initializeGitService(); + })); + + new Setting(containerEl) + .setName('Repository name') + .setDesc('Name of the repository') + .addText(text => text + .setPlaceholder('My notes') + .setValue(this.plugin.settings.giteaRepo) + .onChange((value) => { + this.plugin.settings.giteaRepo = value; + void this.plugin.saveSettings(); + this.plugin.initializeGitService(); + })); + } + private displayGitHubSettings(containerEl: HTMLElement): void { new Setting(containerEl) .setName('GitHub personal access token') diff --git a/tests/logic/sync-manager-mapping.test.ts b/tests/logic/sync-manager-mapping.test.ts index 9df5fd8..bd7ffe9 100644 --- a/tests/logic/sync-manager-mapping.test.ts +++ b/tests/logic/sync-manager-mapping.test.ts @@ -44,6 +44,10 @@ const mockSettings: GitLabFilesPushSettings = { githubToken: '', githubOwner: '', githubRepo: '', + giteaToken: '', + giteaBaseUrl: '', + giteaOwner: '', + giteaRepo: '', branch: 'main', rootPath: 'notes', vaultFolder: 'Work', diff --git a/tests/logic/sync-manager.test.ts b/tests/logic/sync-manager.test.ts index 3a24a41..58fd657 100644 --- a/tests/logic/sync-manager.test.ts +++ b/tests/logic/sync-manager.test.ts @@ -51,6 +51,10 @@ const mockSettings: GitLabFilesPushSettings = { githubToken: '', githubOwner: '', githubRepo: '', + giteaToken: '', + giteaBaseUrl: '', + giteaOwner: '', + giteaRepo: '', branch: 'main', rootPath: '', syncMetadata: {}, diff --git a/tests/services/gitea-service.test.ts b/tests/services/gitea-service.test.ts new file mode 100644 index 0000000..c8cdae7 --- /dev/null +++ b/tests/services/gitea-service.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GiteaService } from '../../src/services/gitea-service'; +import { requestUrl, RequestUrlResponse, RequestUrlParam } from 'obsidian'; +import { getLastRequestCall, mockRequest, sharedTestConnection, sharedGetFileErrorHandling } from './service-test-helpers'; + +describe('GiteaService', () => { + let service: GiteaService; + const baseUrl = 'https://gitea.example.com'; + const token = 'test-token'; + const owner = 'test-owner'; + const repo = 'test-repo'; + + beforeEach(() => { + vi.clearAllMocks(); + service = new GiteaService(); + service.updateConfig(baseUrl, token, owner, repo); + }); + + describe('updateConfig', () => { + it('should strip trailing slash from baseUrl', async () => { + service.updateConfig('https://gitea.example.com/', token, owner, repo); + mockRequest({ status: 200, json: { content: btoa('hello'), sha: 'sha' } }); + await service.getFile('test.md', 'main'); + const call = getLastRequestCall(); + expect(call.url).not.toContain('example.com//api'); + expect(call.url).toContain('example.com/api/v1'); + }); + }); + + describe('getFile', () => { + it('should fetch and decode file content correctly', async () => { + mockRequest({ status: 200, json: { content: btoa('hello world'), sha: 'test-sha' } }); + const result = await service.getFile('test.md', 'main'); + expect(result.content).toBe('hello world'); + expect(result.sha).toBe('test-sha'); + }); + + it('should call the correct Gitea contents API URL', async () => { + mockRequest({ status: 200, json: { content: btoa('data'), sha: 'sha1' } }); + await service.getFile('notes/hello.md', 'main'); + const call = getLastRequestCall(); + expect(call.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/contents/notes/hello.md?ref=main`); + }); + + it('should use Authorization token header', async () => { + mockRequest({ status: 200, json: { content: btoa('data'), sha: 'sha1' } }); + await service.getFile('test.md', 'main'); + const call = getLastRequestCall(); + expect(call.headers).toMatchObject({ 'Authorization': `token ${token}` }); + }); + + it('should handle 404 correctly and return empty content', async () => { + mockRequest({ status: 404, json: { message: 'Not Found' }, text: 'Not Found' }); + const result = await service.getFile('missing.md', 'main'); + expect(result.content).toBe(''); + expect(result.sha).toBe(''); + }); + + it('should bypass rootPath when path starts with / (absolute repo path)', async () => { + service.updateConfig(baseUrl, token, owner, repo, 'vault'); + mockRequest({ status: 200, json: { content: btoa('root content'), sha: 'root-sha' } }); + await service.getFile('/.gitignore', 'main'); + const call = getLastRequestCall(); + expect(call.url).toContain('/contents/.gitignore'); + expect(call.url).not.toContain('/contents/vault/.gitignore'); + }); + + it('should not double-prefix when path already starts with rootPath', async () => { + service.updateConfig(baseUrl, token, owner, repo, 'src/content'); + mockRequest({ status: 200, json: { content: btoa('hello'), sha: 'sha' } }); + await service.getFile('src/content/index.md', 'main'); + const call = getLastRequestCall(); + expect(call.url).toContain('/contents/src/content/index.md'); + expect(call.url).not.toContain('/contents/src/content/src/content/index.md'); + }); + + it('should prepend rootPath when set', async () => { + service.updateConfig(baseUrl, token, owner, repo, 'vault'); + mockRequest({ status: 200, json: { content: btoa('data'), sha: 'sha' } }); + await service.getFile('notes.md', 'main'); + const call = getLastRequestCall(); + expect(call.url).toContain('/contents/vault/notes.md'); + }); + }); + + describe('pushFile', () => { + it('should create new file with POST when no sha provided', async () => { + mockRequest({ status: 201, json: { content: { path: 'new.md', sha: 'new-sha' } } }); + const result = await service.pushFile('new.md', 'new content', 'main', 'create'); + expect(result).toEqual({ path: 'new.md', sha: 'new-sha' }); + const call = getLastRequestCall(); + expect(call.method).toBe('POST'); + }); + + it('should update existing file with PUT when sha provided', async () => { + mockRequest({ status: 200, json: { content: { path: 'existing.md', sha: 'updated-sha' } } }); + const result = await service.pushFile('existing.md', 'updated content', 'main', 'update', 'old-sha'); + expect(result).toEqual({ path: 'existing.md', sha: 'updated-sha' }); + const call = getLastRequestCall(); + expect(call.method).toBe('PUT'); + expect(call.body).toContain('"sha":"old-sha"'); + }); + + it('should send correct content URL for push', async () => { + mockRequest({ status: 201, json: { content: { path: 'notes/test.md', sha: 'sha' } } }); + await service.pushFile('notes/test.md', 'content', 'main', 'commit'); + const call = getLastRequestCall(); + expect(call.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/contents/notes/test.md`); + }); + + it('should base64 encode the file content', async () => { + mockRequest({ status: 201, json: { content: { path: 'test.md', sha: 'sha' } } }); + await service.pushFile('test.md', 'hello world', 'main', 'add file'); + const call = getLastRequestCall(); + const body = JSON.parse(call.body as string) as { content: string }; + expect(atob(body.content)).toContain('hello world'); + }); + }); + + describe('listFiles', () => { + const commitSha = 'abc123commit'; + + function mockListFiles(treeItems: { path: string; type: string }[], truncated = false): void { + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: { commit: { id: commitSha } } } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 200, json: { tree: treeItems, truncated } } as unknown as RequestUrlResponse); + } + + it('should first resolve branch to commit SHA then fetch tree', async () => { + mockListFiles([]); + await service.listFiles('main'); + const calls = vi.mocked(requestUrl).mock.calls; + expect(calls).toHaveLength(2); + expect((calls[0]?.[0] as RequestUrlParam).url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/branches/main`); + expect((calls[1]?.[0] as RequestUrlParam).url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/git/trees/${commitSha}?recursive=1`); + }); + + it('should return only blob files from tree', async () => { + mockListFiles([ + { path: 'file1.md', type: 'blob' }, + { path: 'dir/file2.md', type: 'blob' }, + { path: 'subdir', type: 'tree' }, + ]); + expect(await service.listFiles('main')).toEqual(['file1.md', 'dir/file2.md']); + }); + + it('should filter by rootPath when set', async () => { + service.updateConfig(baseUrl, token, owner, repo, 'vault'); + mockListFiles([ + { path: 'vault/file1.md', type: 'blob' }, + { path: 'other/file2.md', type: 'blob' }, + ]); + expect(await service.listFiles('main')).toEqual(['vault/file1.md']); + }); + + it('should not match sibling paths with same prefix as rootPath', async () => { + service.updateConfig(baseUrl, token, owner, repo, 'src/content'); + mockListFiles([ + { path: 'src/content/index.md', type: 'blob' }, + { path: 'src/content.config.ts', type: 'blob' }, + { path: 'src/contentful.ts', type: 'blob' }, + ]); + expect(await service.listFiles('main')).toEqual(['src/content/index.md']); + }); + + it('should return all files when useFilter is false regardless of rootPath', async () => { + service.updateConfig(baseUrl, token, owner, repo, 'vault'); + mockListFiles([ + { path: 'vault/file1.md', type: 'blob' }, + { path: 'other/file2.md', type: 'blob' }, + ]); + expect(await service.listFiles('main', false)).toEqual(['vault/file1.md', 'other/file2.md']); + }); + + it('should log warning and return files when result is truncated', async () => { + mockListFiles([{ path: 'file1.md', type: 'blob' }], true); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const result = await service.listFiles('main'); + expect(result).toEqual(['file1.md']); + warnSpy.mockRestore(); + }); + }); + + describe('deleteFile', () => { + it('should first get file sha then send DELETE request', async () => { + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: { content: btoa('content'), sha: 'file-sha' } } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse); + + await service.deleteFile('test.md', 'main', 'delete test.md'); + + const calls = vi.mocked(requestUrl).mock.calls; + expect(calls).toHaveLength(2); + const deleteCall = calls[1]?.[0] as RequestUrlParam; + expect(deleteCall.method).toBe('DELETE'); + expect(deleteCall.body).toContain('"sha":"file-sha"'); + expect(deleteCall.body).toContain('"message":"delete test.md"'); + expect(deleteCall.body).toContain('"branch":"main"'); + }); + + it('should use correct delete URL', async () => { + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: { content: btoa('content'), sha: 'sha' } } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse); + + await service.deleteFile('notes/test.md', 'main', 'remove'); + + const calls = vi.mocked(requestUrl).mock.calls; + const deleteCall = calls[1]?.[0] as RequestUrlParam; + expect(deleteCall.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/contents/notes/test.md`); + }); + }); + + describe('testConnection', () => { + sharedTestConnection(() => service); + + it('should call the correct repo API URL', async () => { + mockRequest({ status: 200, json: {} }); + await service.testConnection(); + const call = getLastRequestCall(); + expect(call.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}`); + }); + }); + + describe('getRepoGitignores', () => { + it('should return only .gitignore paths from file list', async () => { + const items = [ + { path: '.gitignore', type: 'blob' }, + { path: 'src/main.ts', type: 'blob' }, + { path: 'sub/.gitignore', type: 'blob' }, + ]; + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: { commit: { id: 'sha123' } } } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 200, json: { tree: items, truncated: false } } as unknown as RequestUrlResponse); + expect(await service.getRepoGitignores('main')).toEqual(['.gitignore', 'sub/.gitignore']); + }); + }); + + describe('getFile error handling', () => { + sharedGetFileErrorHandling(() => service); + }); +});