From 7648eef279ed63a61561f217f206abf892995964 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:09:29 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20use=20two-step=20branch=E2=86=92SHA=20re?= =?UTF-8?q?solution=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', () => {