Merge pull request #35 from firstsun-dev/claude/trusting-volta-qlg8bk

Claude/trusting volta qlg8bk
This commit is contained in:
ClaudiaFang 2026-06-28 13:01:07 +08:00 committed by GitHub
commit 13d802e33e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 527 additions and 24 deletions

View file

@ -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

View file

@ -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}}"

View file

@ -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
<img src="https://img.shields.io/badge/GitHub-181717?style=for-the-badge&logo=github&logoColor=white" alt="GitHub" height="28"> &nbsp;
<img src="https://img.shields.io/badge/GitLab-FC6D26?style=for-the-badge&logo=gitlab&logoColor=white" alt="GitLab" height="28"> &nbsp;
<img src="https://img.shields.io/badge/Gitea-609926?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" height="28">
| Provider | Hosting | Min. Version |
| :--- | :--- | :--- |
| <img src="https://img.shields.io/badge/GitHub-181717?style=flat-square&logo=github&logoColor=white" alt="GitHub"> | github.com · GitHub Enterprise | — |
| <img src="https://img.shields.io/badge/GitLab-FC6D26?style=flat-square&logo=gitlab&logoColor=white" alt="GitLab"> | gitlab.com · self-hosted | GitLab 13.0+ |
| <img src="https://img.shields.io/badge/Gitea-609926?style=flat-square&logo=gitea&logoColor=white" alt="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 |
| :---: | :--- | :--- | :--- |
| <img src="https://img.shields.io/badge/GitHub-181717?style=flat-square&logo=github&logoColor=white" alt="GitHub"> | **GitHub** | Personal Access Token, Owner, Repo Name | `repo` |
| <img src="https://img.shields.io/badge/GitLab-FC6D26?style=flat-square&logo=gitlab&logoColor=white" alt="GitLab"> | **GitLab** | Personal Access Token, Project ID, Base URL | `api` |
| <img src="https://img.shields.io/badge/Gitea-609926?style=flat-square&logo=gitea&logoColor=white" alt="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.
---

View file

@ -2,7 +2,21 @@
<video src="imgs/git-file-sync-zh.webm" width="100%" controls autoplay loop muted playsinline></video>
本指南將引導您如何使用 Git File Sync 插件,在行動裝置與桌面電腦之間,透過 GitLab 或 GitHub 輕鬆同步您的筆記。
本指南將引導您如何使用 Git File Sync 插件,在行動裝置與桌面電腦之間,透過 GitLab、GitHub 或自架的 Gitea 輕鬆同步您的筆記。
---
## 支援的 Git 服務
<img src="https://img.shields.io/badge/GitHub-181717?style=for-the-badge&logo=github&logoColor=white" alt="GitHub" height="28"> &nbsp;
<img src="https://img.shields.io/badge/GitLab-FC6D26?style=for-the-badge&logo=gitlab&logoColor=white" alt="GitLab" height="28"> &nbsp;
<img src="https://img.shields.io/badge/Gitea-609926?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" height="28">
| | 服務 | 適用情境 | 最低版本 |
| :---: | :--- | :--- | :--- |
| <img src="https://img.shields.io/badge/GitHub-181717?style=flat-square&logo=github&logoColor=white" alt="GitHub"> | **GitHub** | 公開 / 私人儲存庫 | — |
| <img src="https://img.shields.io/badge/GitLab-FC6D26?style=flat-square&logo=gitlab&logoColor=white" alt="GitLab"> | **GitLab** | gitlab.com 或自架 | GitLab 13.0+ |
| <img src="https://img.shields.io/badge/Gitea-609926?style=flat-square&logo=gitea&logoColor=white" alt="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 資料夾內,不會傳送至任何第三方伺服器
- 本插件不會收集任何個人數據或使用紀錄。

View file

@ -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(

View file

@ -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<string, string>): 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<GitFile> {
try {
const url = `${this.getApiUrl(path)}?ref=${branch}`;
const response = await this.safeRequest(url, 'GET');
const data = this.parseJson<GitHubContentResponse>(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<GitTreeEntry[]> {
// 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<GitHubTreeResponse>(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<void> {
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<boolean> {
try {
const url = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}`;
await this.safeRequest(url, 'GET');
return true;
} catch {
return false;
}
}
}

View file

@ -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<string, SyncMetadata>;
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')

View file

@ -44,6 +44,10 @@ const mockSettings: GitLabFilesPushSettings = {
githubToken: '',
githubOwner: '',
githubRepo: '',
giteaToken: '',
giteaBaseUrl: '',
giteaOwner: '',
giteaRepo: '',
branch: 'main',
rootPath: 'notes',
vaultFolder: 'Work',

View file

@ -51,6 +51,10 @@ const mockSettings: GitLabFilesPushSettings = {
githubToken: '',
githubOwner: '',
githubRepo: '',
giteaToken: '',
giteaBaseUrl: '',
giteaOwner: '',
giteaRepo: '',
branch: 'main',
rootPath: '',
syncMetadata: {},

View file

@ -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);
});
});