From 56c4475750e782867eee181082fd2b428b401c70 Mon Sep 17 00:00:00 2001 From: lukelzlz Date: Mon, 6 Jul 2026 19:22:06 +0800 Subject: [PATCH 1/6] fix(sync): preserve branch for remote content reads --- src/services/syncService.ts | 8 ++++-- src/views/SyncView.ts | 3 +- tests/services/syncService.test.ts | 41 +++++++++++++++++++++++++- tests/views/SyncView.test.ts | 46 ++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 tests/views/SyncView.test.ts diff --git a/src/services/syncService.ts b/src/services/syncService.ts index 846ec43..cc8965c 100644 --- a/src/services/syncService.ts +++ b/src/services/syncService.ts @@ -534,11 +534,12 @@ export class SyncService { owner: string, repo: string, remotePath: string, - localPath: string + localPath: string, + branch?: string ): Promise { try { console.debug(`[Sync] Pulling file: ${remotePath} -> ${localPath}`); - const content = await this.githubService.getFileContent(owner, repo, remotePath); + const content = await this.githubService.getFileContent(owner, repo, remotePath, branch); const isBin = isBinaryFile(localPath); if (isBin) { @@ -606,7 +607,8 @@ export class SyncService { owner, repo, remote.path, - absolutePath + absolutePath, + _branch ); // Store the relative path in the result for consistency result.path = change.path; diff --git a/src/views/SyncView.ts b/src/views/SyncView.ts index 5b56c6a..69bb515 100644 --- a/src/views/SyncView.ts +++ b/src/views/SyncView.ts @@ -371,7 +371,8 @@ export class SyncView extends ItemView { const remote = await this.plugin.githubService.getFileContent( this.plugin.settings.repo.owner, this.plugin.settings.repo.name, - path + path, + this.plugin.settings.repo.branch ); remoteContent = atob(remote.content); } catch { diff --git a/tests/services/syncService.test.ts b/tests/services/syncService.test.ts index 73d7551..f8a23da 100644 --- a/tests/services/syncService.test.ts +++ b/tests/services/syncService.test.ts @@ -1,4 +1,5 @@ -import { FileSyncState, LocalFileEntry, RemoteFileEntry, PersistedSyncState } from '../../src/services/syncService'; +import { App } from 'obsidian'; +import { SyncService, FileSyncState, LocalFileEntry, RemoteFileEntry, PersistedSyncState } from '../../src/services/syncService'; import { matchesIgnorePattern } from '../../src/utils/fileUtils'; /** @@ -294,6 +295,44 @@ describe('SyncService - Deletion Sync Logic', () => { }); }); +describe('SyncService - Branch-aware remote reads', () => { + it('passes the configured branch when pulling remote file contents', async () => { + const githubService = { + getFileContent: jest.fn().mockResolvedValue({ + path: 'notes/branch-only.md', + sha: 'remote-sha', + content: Buffer.from('branch content', 'utf8').toString('base64'), + encoding: 'base64', + size: 14, + }), + }; + + const app = new App(); + const service = new SyncService(app as never, githubService as never); + const changes: FileSyncState[] = [{ + path: 'notes/branch-only.md', + localHash: null, + remoteHash: 'remote-sha', + remoteSha: 'remote-sha', + status: 'added', + localModified: null, + remoteModified: null, + }]; + const remoteIndex = new Map([ + ['notes/branch-only.md', { path: 'notes/branch-only.md', sha: 'remote-sha' }], + ]); + + await service.pullChanges('octo', 'branch-repo', 'feature/sync-target', changes, remoteIndex); + + expect(githubService.getFileContent).toHaveBeenCalledWith( + 'octo', + 'branch-repo', + 'notes/branch-only.md', + 'feature/sync-target' + ); + }); +}); + describe('SyncService - Effective Ignore Patterns', () => { /** * Simulates getEffectiveIgnorePatterns logic for testing diff --git a/tests/views/SyncView.test.ts b/tests/views/SyncView.test.ts new file mode 100644 index 0000000..0507f71 --- /dev/null +++ b/tests/views/SyncView.test.ts @@ -0,0 +1,46 @@ +import { App } from 'obsidian'; +import { SyncView } from '../../src/views/SyncView'; + +describe('SyncView - Branch-aware remote reads', () => { + it('passes the configured branch when loading remote content for diff', async () => { + const app = new App(); + const githubService = { + getFileContent: jest.fn().mockResolvedValue({ + path: 'notes/branch-only.md', + sha: 'remote-sha', + content: Buffer.from('branch content', 'utf8').toString('base64'), + encoding: 'base64', + size: 14, + }), + }; + + const plugin = { + app, + settings: { + repo: { + owner: 'octo', + name: 'branch-repo', + branch: 'feature/sync-target', + }, + }, + githubService, + openDiffView: jest.fn().mockResolvedValue(null), + }; + + const view = Object.create(SyncView.prototype) as SyncView & { + app: App; + plugin: typeof plugin; + }; + view.app = app; + view.plugin = plugin; + + await view['openFileDiff']('notes/branch-only.md'); + + expect(githubService.getFileContent).toHaveBeenCalledWith( + 'octo', + 'branch-repo', + 'notes/branch-only.md', + 'feature/sync-target' + ); + }); +}); From 03b1d1e1d6fd956e1008a50f2f75ef1e31d0bff9 Mon Sep 17 00:00:00 2001 From: lukelzlz Date: Mon, 6 Jul 2026 20:02:11 +0800 Subject: [PATCH 2/6] fix(sync): mark sync as failed when file pulls error --- src/services/syncService.ts | 3 +++ tests/services/syncService.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/services/syncService.ts b/src/services/syncService.ts index cc8965c..710ffd5 100644 --- a/src/services/syncService.ts +++ b/src/services/syncService.ts @@ -863,6 +863,9 @@ export class SyncService { } result.filesProcessed = result.filesPulled + result.filesPushed + result.filesDeleted; + if (result.errors.length > 0) { + result.success = false; + } // Build new sync state const newLocalIndex = await this.buildLocalIndex(); diff --git a/tests/services/syncService.test.ts b/tests/services/syncService.test.ts index f8a23da..e6ee68d 100644 --- a/tests/services/syncService.test.ts +++ b/tests/services/syncService.test.ts @@ -333,6 +333,36 @@ describe('SyncService - Branch-aware remote reads', () => { }); }); +describe('SyncService - Error propagation', () => { + it('marks sync as failed when pull operations return file errors', async () => { + const githubService = { + getFileContent: jest.fn().mockRejectedValue(new Error('Not Found - https://docs.github.com/rest/repos/contents#get-repository-content')), + }; + + const app = new App(); + const service = new SyncService(app as never, githubService as never); + const remoteIndex = new Map([ + ['notes/missing.md', { path: 'notes/missing.md', sha: 'remote-sha' }], + ]); + + jest.spyOn(service, 'buildLocalIndex').mockResolvedValue(new Map()); + jest.spyOn(service, 'buildRemoteIndex').mockResolvedValue(remoteIndex); + + const { result } = await service.sync( + 'octo', + 'branch-repo', + 'feature/sync-target', + 'Sync test' + ); + + expect(result.success).toBe(false); + expect(result.filesProcessed).toBe(0); + expect(result.errors).toEqual([ + 'Not Found - https://docs.github.com/rest/repos/contents#get-repository-content', + ]); + }); +}); + describe('SyncService - Effective Ignore Patterns', () => { /** * Simulates getEffectiveIgnorePatterns logic for testing From ee2c7286b2eba37f6c9226c803d11c9e38633106 Mon Sep 17 00:00:00 2001 From: lukelzlz Date: Mon, 6 Jul 2026 20:02:11 +0800 Subject: [PATCH 3/6] chore(plugin): expose deployed build marker on load --- main.ts | 7 +++++- src/utils/buildInfo.ts | 5 ++++ tests/main.test.ts | 57 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 src/utils/buildInfo.ts create mode 100644 tests/main.test.ts diff --git a/main.ts b/main.ts index 274bc5d..4e0719b 100644 --- a/main.ts +++ b/main.ts @@ -6,6 +6,7 @@ import { DiffView, DIFF_VIEW_TYPE } from './src/views/DiffView'; import { SyncView, SYNC_VIEW_TYPE } from './src/views/SyncView'; import { GitHubOctokitSettingTab, SyncModal } from './src/ui'; import { GitHubOctokitSettings, DEFAULT_SETTINGS, AdditionalRepoConfig, VaultRepoConfig, VAULT_REPOS_CONFIG_PATH } from './src/types/settings'; +import { formatPluginLoadMessage } from './src/utils/buildInfo'; /** Per-repo runtime state for additional repositories */ export interface AdditionalRepoRuntime { @@ -40,7 +41,11 @@ export default class GitHubOctokitPlugin extends Plugin { // Initialize logger this.logger = new LoggerService(this.settings.logging); this.logger.initialize(this.app); - this.logger.info('Plugin', 'GitHub Octokit plugin loaded'); + const pluginLoadMessage = formatPluginLoadMessage(this.manifest.version); + this.logger.info('Plugin', pluginLoadMessage); + if (this.settings.showNotifications) { + new Notice(pluginLoadMessage, 4000); + } // Initialize services this.githubService = new GitHubService(); diff --git a/src/utils/buildInfo.ts b/src/utils/buildInfo.ts new file mode 100644 index 0000000..71a930a --- /dev/null +++ b/src/utils/buildInfo.ts @@ -0,0 +1,5 @@ +export const PLUGIN_BUILD_MARKER = '2026-07-06-sync-debug-marker-01'; + +export function formatPluginLoadMessage(version: string): string { + return `GitHub Octokit plugin loaded v${version} build ${PLUGIN_BUILD_MARKER}`; +} diff --git a/tests/main.test.ts b/tests/main.test.ts new file mode 100644 index 0000000..015ab5f --- /dev/null +++ b/tests/main.test.ts @@ -0,0 +1,57 @@ +jest.mock('octokit', () => ({ + Octokit: class MockOctokit {}, +})); + +import GitHubOctokitPlugin from '../main'; +import { LoggerService } from '../src/services/loggerService'; +import { DEFAULT_SETTINGS } from '../src/types/settings'; +import { PLUGIN_BUILD_MARKER } from '../src/utils/buildInfo'; + +describe('GitHubOctokitPlugin', () => { + it('logs a visible build marker on load', async () => { + (global as typeof globalThis & { + document?: { createElement: (tag: string) => { addClass: () => void; addEventListener: () => void; setText: () => void; }; }; + window?: { setTimeout: (callback: () => void) => number; }; + }).document = { + createElement: () => ({ + addClass: () => {}, + addEventListener: () => {}, + setText: () => {}, + }), + }; + (global as typeof globalThis & { + window?: { setTimeout: (callback: () => void) => number; }; + }).window = { + setTimeout: () => 0, + }; + + const infoSpy = jest.spyOn(LoggerService.prototype, 'info').mockImplementation(() => {}); + const plugin = new GitHubOctokitPlugin(); + plugin.manifest = { version: '0.6.0' }; + jest.spyOn(plugin, 'addRibbonIcon').mockReturnValue({ + addClass: () => {}, + addEventListener: () => {}, + } as never); + jest.spyOn(plugin, 'addStatusBarItem').mockReturnValue({ + setText: () => {}, + setAttr: () => {}, + addClass: () => {}, + addEventListener: () => {}, + } as never); + + jest.spyOn(plugin, 'loadSettings').mockImplementation(async () => { + plugin.settings = { ...DEFAULT_SETTINGS }; + }); + jest.spyOn(plugin, 'loadSyncState').mockResolvedValue(undefined); + jest.spyOn(plugin as never, 'initializeAdditionalRepos').mockResolvedValue(undefined); + + await plugin.onload(); + + expect(infoSpy).toHaveBeenCalledWith( + 'Plugin', + expect.stringContaining(PLUGIN_BUILD_MARKER) + ); + + infoSpy.mockRestore(); + }); +}); From e6dd61a19fe5ad0e366e7c169e193a66c5d57190 Mon Sep 17 00:00:00 2001 From: lukelzlz Date: Mon, 6 Jul 2026 20:03:24 +0800 Subject: [PATCH 4/6] Revert "chore(plugin): expose deployed build marker on load" This reverts commit ee2c7286b2eba37f6c9226c803d11c9e38633106. --- main.ts | 7 +----- src/utils/buildInfo.ts | 5 ---- tests/main.test.ts | 57 ------------------------------------------ 3 files changed, 1 insertion(+), 68 deletions(-) delete mode 100644 src/utils/buildInfo.ts delete mode 100644 tests/main.test.ts diff --git a/main.ts b/main.ts index 4e0719b..274bc5d 100644 --- a/main.ts +++ b/main.ts @@ -6,7 +6,6 @@ import { DiffView, DIFF_VIEW_TYPE } from './src/views/DiffView'; import { SyncView, SYNC_VIEW_TYPE } from './src/views/SyncView'; import { GitHubOctokitSettingTab, SyncModal } from './src/ui'; import { GitHubOctokitSettings, DEFAULT_SETTINGS, AdditionalRepoConfig, VaultRepoConfig, VAULT_REPOS_CONFIG_PATH } from './src/types/settings'; -import { formatPluginLoadMessage } from './src/utils/buildInfo'; /** Per-repo runtime state for additional repositories */ export interface AdditionalRepoRuntime { @@ -41,11 +40,7 @@ export default class GitHubOctokitPlugin extends Plugin { // Initialize logger this.logger = new LoggerService(this.settings.logging); this.logger.initialize(this.app); - const pluginLoadMessage = formatPluginLoadMessage(this.manifest.version); - this.logger.info('Plugin', pluginLoadMessage); - if (this.settings.showNotifications) { - new Notice(pluginLoadMessage, 4000); - } + this.logger.info('Plugin', 'GitHub Octokit plugin loaded'); // Initialize services this.githubService = new GitHubService(); diff --git a/src/utils/buildInfo.ts b/src/utils/buildInfo.ts deleted file mode 100644 index 71a930a..0000000 --- a/src/utils/buildInfo.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const PLUGIN_BUILD_MARKER = '2026-07-06-sync-debug-marker-01'; - -export function formatPluginLoadMessage(version: string): string { - return `GitHub Octokit plugin loaded v${version} build ${PLUGIN_BUILD_MARKER}`; -} diff --git a/tests/main.test.ts b/tests/main.test.ts deleted file mode 100644 index 015ab5f..0000000 --- a/tests/main.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -jest.mock('octokit', () => ({ - Octokit: class MockOctokit {}, -})); - -import GitHubOctokitPlugin from '../main'; -import { LoggerService } from '../src/services/loggerService'; -import { DEFAULT_SETTINGS } from '../src/types/settings'; -import { PLUGIN_BUILD_MARKER } from '../src/utils/buildInfo'; - -describe('GitHubOctokitPlugin', () => { - it('logs a visible build marker on load', async () => { - (global as typeof globalThis & { - document?: { createElement: (tag: string) => { addClass: () => void; addEventListener: () => void; setText: () => void; }; }; - window?: { setTimeout: (callback: () => void) => number; }; - }).document = { - createElement: () => ({ - addClass: () => {}, - addEventListener: () => {}, - setText: () => {}, - }), - }; - (global as typeof globalThis & { - window?: { setTimeout: (callback: () => void) => number; }; - }).window = { - setTimeout: () => 0, - }; - - const infoSpy = jest.spyOn(LoggerService.prototype, 'info').mockImplementation(() => {}); - const plugin = new GitHubOctokitPlugin(); - plugin.manifest = { version: '0.6.0' }; - jest.spyOn(plugin, 'addRibbonIcon').mockReturnValue({ - addClass: () => {}, - addEventListener: () => {}, - } as never); - jest.spyOn(plugin, 'addStatusBarItem').mockReturnValue({ - setText: () => {}, - setAttr: () => {}, - addClass: () => {}, - addEventListener: () => {}, - } as never); - - jest.spyOn(plugin, 'loadSettings').mockImplementation(async () => { - plugin.settings = { ...DEFAULT_SETTINGS }; - }); - jest.spyOn(plugin, 'loadSyncState').mockResolvedValue(undefined); - jest.spyOn(plugin as never, 'initializeAdditionalRepos').mockResolvedValue(undefined); - - await plugin.onload(); - - expect(infoSpy).toHaveBeenCalledWith( - 'Plugin', - expect.stringContaining(PLUGIN_BUILD_MARKER) - ); - - infoSpy.mockRestore(); - }); -}); From 2a5d87639914ecb2c5e75e0f6de4a0eaea54edc3 Mon Sep 17 00:00:00 2001 From: lukelzlz Date: Mon, 6 Jul 2026 20:09:49 +0800 Subject: [PATCH 5/6] fix(diff): resolve remote paths within repo subfolders --- src/views/SyncView.ts | 15 +++++++++++-- tests/views/SyncView.test.ts | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/views/SyncView.ts b/src/views/SyncView.ts index 69bb515..8410b09 100644 --- a/src/views/SyncView.ts +++ b/src/views/SyncView.ts @@ -2,6 +2,7 @@ import { ItemView, WorkspaceLeaf, Notice, TFile } from 'obsidian'; import type GitHubOctokitPlugin from '../../main'; import { FileSyncState } from '../services/syncService'; import { LogEntry } from '../services/loggerService'; +import { normalizePath } from '../utils/fileUtils'; export const SYNC_VIEW_TYPE = 'github-octokit-sync-view'; @@ -31,6 +32,14 @@ export class SyncView extends ItemView { this.loadViewState(); } + private toRepoPath(path: string): string { + const subfolderPath = this.plugin.settings.subfolderPath; + if (!subfolderPath) { + return path; + } + return normalizePath(`${subfolderPath}/${path}`); + } + /** Restore persisted UI state from vault-specific localStorage */ private loadViewState(): void { const state = this.plugin.app.loadLocalStorage('github-octokit-sync-view') as string | null; @@ -348,7 +357,8 @@ export class SyncView extends ItemView { if (this.plugin.settings.repo) { const ghBtn = actions.createEl('button', { text: 'GitHub', cls: 'file-action' }); ghBtn.addEventListener('click', () => { - const url = `https://github.com/${this.plugin.settings.repo!.owner}/${this.plugin.settings.repo!.name}/blob/${this.plugin.settings.repo!.branch}/${file.path}`; + const repoPath = this.toRepoPath(file.path); + const url = `https://github.com/${this.plugin.settings.repo!.owner}/${this.plugin.settings.repo!.name}/blob/${this.plugin.settings.repo!.branch}/${repoPath}`; window.open(url, '_blank'); }); } @@ -368,10 +378,11 @@ export class SyncView extends ItemView { let remoteContent = ''; if (this.plugin.settings.repo) { try { + const repoPath = this.toRepoPath(path); const remote = await this.plugin.githubService.getFileContent( this.plugin.settings.repo.owner, this.plugin.settings.repo.name, - path, + repoPath, this.plugin.settings.repo.branch ); remoteContent = atob(remote.content); diff --git a/tests/views/SyncView.test.ts b/tests/views/SyncView.test.ts index 0507f71..d615a76 100644 --- a/tests/views/SyncView.test.ts +++ b/tests/views/SyncView.test.ts @@ -43,4 +43,47 @@ describe('SyncView - Branch-aware remote reads', () => { 'feature/sync-target' ); }); + + it('prefixes the configured subfolder when loading remote content for diff', async () => { + const app = new App(); + const githubService = { + getFileContent: jest.fn().mockResolvedValue({ + path: 'study-workspace/notes/branch-only.md', + sha: 'remote-sha', + content: Buffer.from('branch content', 'utf8').toString('base64'), + encoding: 'base64', + size: 14, + }), + }; + + const plugin = { + app, + settings: { + repo: { + owner: 'octo', + name: 'branch-repo', + branch: 'feature/sync-target', + }, + subfolderPath: 'study-workspace', + }, + githubService, + openDiffView: jest.fn().mockResolvedValue(null), + }; + + const view = Object.create(SyncView.prototype) as SyncView & { + app: App; + plugin: typeof plugin; + }; + view.app = app; + view.plugin = plugin; + + await view['openFileDiff']('notes/branch-only.md'); + + expect(githubService.getFileContent).toHaveBeenCalledWith( + 'octo', + 'branch-repo', + 'study-workspace/notes/branch-only.md', + 'feature/sync-target' + ); + }); }); From b2de1ef82bd68a447327a24328de8862a77bb90c Mon Sep 17 00:00:00 2001 From: lukelzlz Date: Mon, 6 Jul 2026 20:24:32 +0800 Subject: [PATCH 6/6] fix(diff): auto-detect remote text encodings --- src/utils/fileUtils.ts | 12 +++++++++++- src/views/SyncView.ts | 4 ++-- tests/utils/fileUtils.test.ts | 20 ++++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/utils/fileUtils.ts b/src/utils/fileUtils.ts index 183aca6..4167987 100644 --- a/src/utils/fileUtils.ts +++ b/src/utils/fileUtils.ts @@ -336,6 +336,17 @@ export function decodeBase64(base64: string): string { for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } + + const encodingsToTry = ['utf-8', 'gb18030', 'big5', 'shift_jis'] as const; + + for (const encoding of encodingsToTry) { + try { + return new TextDecoder(encoding, { fatal: true }).decode(bytes); + } catch { + continue; + } + } + return new TextDecoder().decode(bytes); } @@ -362,4 +373,3 @@ export function decodeBase64Binary(base64: string): ArrayBuffer { } return bytes.buffer; } - diff --git a/src/views/SyncView.ts b/src/views/SyncView.ts index 8410b09..429ac8b 100644 --- a/src/views/SyncView.ts +++ b/src/views/SyncView.ts @@ -2,7 +2,7 @@ import { ItemView, WorkspaceLeaf, Notice, TFile } from 'obsidian'; import type GitHubOctokitPlugin from '../../main'; import { FileSyncState } from '../services/syncService'; import { LogEntry } from '../services/loggerService'; -import { normalizePath } from '../utils/fileUtils'; +import { decodeBase64, normalizePath } from '../utils/fileUtils'; export const SYNC_VIEW_TYPE = 'github-octokit-sync-view'; @@ -385,7 +385,7 @@ export class SyncView extends ItemView { repoPath, this.plugin.settings.repo.branch ); - remoteContent = atob(remote.content); + remoteContent = decodeBase64(remote.content); } catch { // File doesn't exist on remote } diff --git a/tests/utils/fileUtils.test.ts b/tests/utils/fileUtils.test.ts index e956c9c..a7a0308 100644 --- a/tests/utils/fileUtils.test.ts +++ b/tests/utils/fileUtils.test.ts @@ -247,6 +247,26 @@ describe('Base64 Encoding', () => { expect(decoded).toBe(original); }); + it('should auto-detect gb18030 encoded text from GitHub', () => { + const original = '函数性质与分段函数'; + const encoder = new TextEncoder(); + const utf8Bytes = encoder.encode(original); + expect(new TextDecoder('gb18030').decode(utf8Bytes)).not.toBe(original); + + const gb18030Bytes = new Uint8Array([ + 186, 175, 202, 253, 208, 212, 214, 202, 211, + 235, 183, 214, 182, 206, 186, 175, 202, 253, + ]); + let binary = ''; + for (const byte of gb18030Bytes) { + binary += String.fromCharCode(byte); + } + const encoded = btoa(binary); + + const decoded = decodeBase64(encoded); + expect(decoded).toBe(original); + }); + it('should handle empty string', () => { const encoded = encodeBase64(''); const decoded = decodeBase64(encoded);