From 130bd93f84161086bdf7f3574098250ef0950c4b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:04:01 +0000 Subject: [PATCH 1/9] feat: add Gitea support as third-party Git provider - Add GiteaService implementing BaseGitService/GitServiceInterface - Uses Gitea API v1 endpoints (/api/v1/repos/{owner}/{repo}/...) - POST for file creation, PUT for updates (differs from GitHub) - Authorization: token {token} header - Add giteaToken, giteaBaseUrl, giteaOwner, giteaRepo settings fields - Add Gitea option to service type dropdown in settings UI - Add displayGiteaSettings() panel in settings tab - Update initializeGitService() to instantiate GiteaService - Update getServiceName() to handle 'gitea' type - Add comprehensive test suite (gitea-service.test.ts, 15 test cases) - Update existing test fixtures to include new Gitea settings fields Closes #26 https://claude.ai/code/session_019Jbz6HpQvWU1wpm5M6MZpd --- src/main.ts | 11 ++ src/services/gitea-service.ts | 100 ++++++++++ src/settings.ts | 69 ++++++- tests/logic/sync-manager-mapping.test.ts | 4 + tests/logic/sync-manager.test.ts | 4 + tests/services/gitea-service.test.ts | 224 +++++++++++++++++++++++ 6 files changed, 409 insertions(+), 3 deletions(-) create mode 100644 src/services/gitea-service.ts create mode 100644 tests/services/gitea-service.test.ts diff --git a/src/main.ts b/src/main.ts index 5346996..2b41db1 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..309054c --- /dev/null +++ b/src/services/gitea-service.ts @@ -0,0 +1,100 @@ +import { GitServiceInterface } from './git-service-interface'; +import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse } 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 = response.json as GitHubContentResponse; + + 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, + content: this.encodeContent(content), + branch, + sha + }; + + const method = sha ? 'PUT' : 'POST'; + const response = await this.safeRequest(url, method, body); + const data = response.json as { content: { path: string, sha: string } }; + return { path: data.content.path, sha: data.content.sha }; + } + + async listFiles(branch: string, useFilter = true): Promise { + const url = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`; + const response = await this.safeRequest(url, 'GET'); + const data = response.json as GitHubTreeResponse; + + if (data.truncated) { + logger.warn('Gitea tree result is truncated. Some files might not be shown.'); + } + + const files = data.tree + .filter(item => item.type === 'blob') + .map(item => item.path); + + if (!useFilter) return files; + + return files.filter(p => { + if (!this.rootPath) return true; + const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`; + return p === this.rootPath || p.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 7ca0cfb..c0a8b0e 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'; export interface GitLabFilesPushSettings { serviceType: GitServiceType; @@ -17,6 +17,10 @@ export interface GitLabFilesPushSettings { githubToken: string; githubOwner: string; githubRepo: string; + giteaToken: string; + giteaBaseUrl: string; + giteaOwner: string; + giteaRepo: string; branch: string; syncMetadata: Record; rootPath: string; @@ -24,7 +28,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 = { @@ -35,6 +41,10 @@ export const DEFAULT_SETTINGS: GitLabFilesPushSettings = { githubToken: '', githubOwner: '', githubRepo: '', + giteaToken: '', + giteaBaseUrl: '', + giteaOwner: '', + giteaRepo: '', rootPath: "", branch: 'main', syncMetadata: {}, @@ -56,10 +66,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; @@ -72,6 +83,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); } @@ -164,6 +177,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 f69faa5..8a90ca3 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 e9dc277..f85b6ef 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..f366611 --- /dev/null +++ b/tests/services/gitea-service.test.ts @@ -0,0 +1,224 @@ +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, sharedGetRepoGitignores } 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', () => { + it('should call the correct git trees URL with branch', async () => { + mockRequest({ status: 200, json: { tree: [], truncated: false } }); + await service.listFiles('main'); + const call = getLastRequestCall(); + expect(call.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/git/trees/main?recursive=1`); + }); + + it('should return only blob files from tree', async () => { + mockRequest({ status: 200, json: { tree: [ + { path: 'file1.md', type: 'blob' }, + { path: 'dir/file2.md', type: 'blob' }, + { path: 'subdir', type: 'tree' }, + ], truncated: false } }); + 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'); + mockRequest({ status: 200, json: { tree: [ + { path: 'vault/file1.md', type: 'blob' }, + { path: 'other/file2.md', type: 'blob' }, + ], truncated: false } }); + 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'); + mockRequest({ status: 200, json: { tree: [ + { path: 'src/content/index.md', type: 'blob' }, + { path: 'src/content.config.ts', type: 'blob' }, + { path: 'src/contentful.ts', type: 'blob' }, + ], truncated: false } }); + 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'); + mockRequest({ status: 200, json: { tree: [ + { path: 'vault/file1.md', type: 'blob' }, + { path: 'other/file2.md', type: 'blob' }, + ], truncated: false } }); + 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 () => { + mockRequest({ status: 200, json: { tree: [ + { path: 'file1.md', type: 'blob' }, + ], truncated: 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', () => { + sharedGetRepoGitignores(() => service, 'tree'); + }); + + describe('getFile error handling', () => { + sharedGetFileErrorHandling(() => service); + }); +}); From 7648eef279ed63a61561f217f206abf892995964 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:09:29 +0000 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20use=20two-step=20branch=E2=86=92SHA?= =?UTF-8?q?=20resolution=20in=20GiteaService.listFiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitea's git/trees endpoint requires a tree or commit SHA, not a branch name, on instances older than ~1.17. Resolve branch to commit SHA via /branches/{branch} first, then fetch /git/trees/{commitSha}?recursive=1. Also updates README with provider compatibility table and SVG icons for GitHub, GitLab, and Gitea. https://claude.ai/code/session_019Jbz6HpQvWU1wpm5M6MZpd --- README.md | 44 +++++++++++++++++------ src/services/gitea-service.ts | 17 ++++++--- tests/services/gitea-service.test.ts | 52 +++++++++++++++++++--------- 3 files changed, 81 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 80f808d..85db3e1 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/src/services/gitea-service.ts b/src/services/gitea-service.ts index 309054c..1afe9aa 100644 --- a/src/services/gitea-service.ts +++ b/src/services/gitea-service.ts @@ -55,15 +55,22 @@ export class GiteaService extends BaseGitService implements GitServiceInterface } async listFiles(branch: string, useFilter = true): Promise { - const url = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`; - const response = await this.safeRequest(url, 'GET'); - const data = response.json as GitHubTreeResponse; + // 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 = branchResponse.json as { commit: { id: string } }; + const commitSha = branchData.commit.id; - if (data.truncated) { + 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 = treeResponse.json as GitHubTreeResponse; + + if (treeData.truncated) { logger.warn('Gitea tree result is truncated. Some files might not be shown.'); } - const files = data.tree + const files = treeData.tree .filter(item => item.type === 'blob') .map(item => item.path); diff --git a/tests/services/gitea-service.test.ts b/tests/services/gitea-service.test.ts index f366611..c8cdae7 100644 --- a/tests/services/gitea-service.test.ts +++ b/tests/services/gitea-service.test.ts @@ -1,7 +1,7 @@ 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, sharedGetRepoGitignores } from './service-test-helpers'; +import { getLastRequestCall, mockRequest, sharedTestConnection, sharedGetFileErrorHandling } from './service-test-helpers'; describe('GiteaService', () => { let service: GiteaService; @@ -118,54 +118,62 @@ describe('GiteaService', () => { }); describe('listFiles', () => { - it('should call the correct git trees URL with branch', async () => { - mockRequest({ status: 200, json: { tree: [], truncated: false } }); + 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 call = getLastRequestCall(); - expect(call.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/git/trees/main?recursive=1`); + 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 () => { - mockRequest({ status: 200, json: { tree: [ + mockListFiles([ { path: 'file1.md', type: 'blob' }, { path: 'dir/file2.md', type: 'blob' }, { path: 'subdir', type: 'tree' }, - ], truncated: false } }); + ]); 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'); - mockRequest({ status: 200, json: { tree: [ + mockListFiles([ { path: 'vault/file1.md', type: 'blob' }, { path: 'other/file2.md', type: 'blob' }, - ], truncated: false } }); + ]); 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'); - mockRequest({ status: 200, json: { tree: [ + mockListFiles([ { path: 'src/content/index.md', type: 'blob' }, { path: 'src/content.config.ts', type: 'blob' }, { path: 'src/contentful.ts', type: 'blob' }, - ], truncated: false } }); + ]); 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'); - mockRequest({ status: 200, json: { tree: [ + mockListFiles([ { path: 'vault/file1.md', type: 'blob' }, { path: 'other/file2.md', type: 'blob' }, - ], truncated: false } }); + ]); 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 () => { - mockRequest({ status: 200, json: { tree: [ - { path: 'file1.md', type: 'blob' }, - ], truncated: true } }); + 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']); @@ -215,7 +223,17 @@ describe('GiteaService', () => { }); describe('getRepoGitignores', () => { - sharedGetRepoGitignores(() => service, 'tree'); + 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', () => { From 6afeaf673d7db2130e651b37cca69a28fab3c2dc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:10:00 +0000 Subject: [PATCH 3/9] chore: update package-lock.json https://claude.ai/code/session_019Jbz6HpQvWU1wpm5M6MZpd --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0dbdcac..739f060 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "git-file-sync", - "version": "1.0.6", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "git-file-sync", - "version": "1.0.6", + "version": "1.1.0", "license": "MIT", "dependencies": { "ignore": "^7.0.5", - "obsidian": "*" + "obsidian": "latest" }, "devDependencies": { "@eslint/js": "9.30.1", From 615819c39ebbaafe07c20634b191b11202683c3e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:12:06 +0000 Subject: [PATCH 4/9] docs: update USAGE_zh.md with Gitea support and provider compatibility table https://claude.ai/code/session_019Jbz6HpQvWU1wpm5M6MZpd --- USAGE_zh.md | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) 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 資料夾內,不會傳送至任何第三方伺服器。 - 本插件不會收集任何個人數據或使用紀錄。 From 8024939a8904867a650512609c706b04dc088e68 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:15:01 +0000 Subject: [PATCH 5/9] ci: remove SonarQube from CI/CD pipeline https://claude.ai/code/session_019Jbz6HpQvWU1wpm5M6MZpd --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6677ab..b2fc5b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,6 @@ jobs: uses: firstsun-dev/.github/.github/workflows/obsidian-plugin-ci.yml@v1 with: plugin-id: "git-file-sync" - skip-sonar: ${{ vars.ENABLE_CI_SONAR != 'true' }} + skip-sonar: true secrets: RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} From c2c5dd584c0436fff6a6dd1c68568b9573d47d76 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:22:32 +0000 Subject: [PATCH 6/9] ci: upload build artifact on non-main branches for testing https://claude.ai/code/session_019Jbz6HpQvWU1wpm5M6MZpd --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2fc5b1..61a9229 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,3 +21,28 @@ jobs: skip-sonar: true 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@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - run: npm ci + + - run: npm run build + + - uses: actions/upload-artifact@v4 + with: + name: plugin-${{ github.ref_name }}-${{ github.sha }} + path: | + main.js + manifest.json + styles.css + retention-days: 7 From 21a6f984ee708cddda034446dd0e9eb164e676f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:23:56 +0000 Subject: [PATCH 7/9] ci: update GitHub Actions to latest versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actions/setup-node: node 20 → 22 (Node 20 EOL Apr 2026) - github/codeql-action: v3 → v4 (v3 deprecated Dec 2026) https://claude.ai/code/session_019Jbz6HpQvWU1wpm5M6MZpd --- .github/workflows/ci.yml | 2 +- .github/workflows/codeql.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61a9229..d3ee95f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'npm' - run: npm ci diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f9e13da..0a6a6b2 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -27,16 +27,16 @@ jobs: uses: actions/checkout@v4 - 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}}" From 8eb5903336313f27400c47c366e396c7b04f6cae Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:24:39 +0000 Subject: [PATCH 8/9] ci: fix artifact name by sanitizing branch name slashes Replace / with - in branch name and use short SHA (7 chars) e.g. plugin-claude-trusting-volta-qlg8bk-c2c5dd5 https://claude.ai/code/session_019Jbz6HpQvWU1wpm5M6MZpd --- .github/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3ee95f..93fe7e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,9 +38,16 @@ jobs: - 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@v4 with: - name: plugin-${{ github.ref_name }}-${{ github.sha }} + name: ${{ steps.artifact.outputs.name }} path: | main.js manifest.json From e4d72e0f8735cd46e86572fc932f5ff7b7600fe4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:26:39 +0000 Subject: [PATCH 9/9] ci: upgrade actions to Node 24 compatible versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actions/checkout: v4 → v6 - actions/setup-node: v4 → v6 - actions/upload-artifact: v4 → v5 - actions/checkout (codeql.yml): v4 → v6 https://claude.ai/code/session_019Jbz6HpQvWU1wpm5M6MZpd --- .github/workflows/ci.yml | 6 +++--- .github/workflows/codeql.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93fe7e0..dff67a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,9 +27,9 @@ jobs: runs-on: ubuntu-latest if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: '22' cache: 'npm' @@ -45,7 +45,7 @@ jobs: SHA=$(echo "${{ github.sha }}" | cut -c1-7) echo "name=plugin-${BRANCH}-${SHA}" >> $GITHUB_OUTPUT - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: ${{ steps.artifact.outputs.name }} path: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0a6a6b2..84d8741 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Initialize CodeQL uses: github/codeql-action/init@v4