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 @@
[](https://conventionalcommits.org)
[](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
+
+
+
+
+
+| Provider | Hosting | Min. Version |
+| :--- | :--- | :--- |
+|
| github.com · GitHub Enterprise | — |
+|
| gitlab.com · self-hosted | GitLab 13.0+ |
+|
| 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** | Personal Access Token, Owner, Repo Name | `repo` |
+|
| **GitLab** | Personal Access Token, Project ID, Base URL | `api` |
+|
| **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** | gitlab.com 或自架 | GitLab 13.0+ |
+|
| **Gitea** | 自架 Git 伺服器 | Gitea 1.12+ |
---
@@ -13,10 +27,11 @@

*在設定面板選擇您的 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);
+ });
+});