feat(sync): detect symbolic links and add a configurable handling setting

Symlinks are Git blobs with mode 120000 whose content is the target path.
They were treated as ordinary files, which caused 404s on fetch and could
corrupt the file on pull. Add detection and let the user choose behavior.

- Settings: new "Symbolic links" option (symlinkHandling) with real
  (default) / follow / skip.
- Services: listFilesDetailed() reports each entry's symlink flag from the
  tree mode (120000) for GitHub and GitLab; listFiles() now delegates to it.
- Refresh/discovery: with "skip", remote symlinks are excluded from sync;
  with "follow"/"real" they are included and synced as the target content.

Note: true OS-level symlink recreation on desktop and pushing files as
symlinks (Git Data API) are not yet implemented — on all platforms "real"
currently syncs the target content like "follow"; mobile has no symlink
API regardless. Tracked as a follow-up.

Add tests for symlink detection on both services and update settings fixtures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwioG4CNKUBuKiZdowLFWe
This commit is contained in:
Claude 2026-06-28 04:45:41 +00:00
parent c474a7e6c4
commit 62b475d632
No known key found for this signature in database
10 changed files with 106 additions and 35 deletions

View file

@ -1,11 +1,16 @@
import { requestUrl, RequestUrlResponse } from 'obsidian';
import { logger } from '../utils/logger';
import { GitTreeEntry } from './git-service-interface';
export interface GitFile {
content: string | ArrayBuffer;
sha: string;
}
// Git file mode for a symbolic link. Symlinks are stored as blobs whose content
// is the link target path, so they need special handling during sync.
export const GIT_SYMLINK_MODE = '120000';
export interface GitHubContentResponse {
content: string;
sha: string;
@ -15,6 +20,7 @@ export interface GitHubContentResponse {
export interface GitHubTreeItem {
path: string;
type: string;
mode?: string;
}
export interface GitHubTreeResponse {
@ -32,6 +38,7 @@ export interface GitLabFileResponse {
export interface GitLabTreeItem {
path: string;
type: string;
mode?: string;
}
export abstract class BaseGitService {
@ -197,9 +204,14 @@ export abstract class BaseGitService {
}
}
async listFiles(branch: string, useFilter = true): Promise<string[]> {
const entries = await this.listFilesDetailed(branch, useFilter);
return entries.map(e => e.path);
}
abstract getFile(path: string, branch: string): Promise<GitFile>;
abstract pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }>;
abstract listFiles(branch: string, useFilter?: boolean): Promise<string[]>;
abstract listFilesDetailed(branch: string, useFilter?: boolean): Promise<GitTreeEntry[]>;
abstract deleteFile(path: string, branch: string, message: string): Promise<void>;
abstract testConnection(): Promise<boolean>;
}

View file

@ -3,12 +3,20 @@ export interface GitFile {
sha: string;
}
/** A file entry from the repository tree, with whether it is a symbolic link. */
export interface GitTreeEntry {
path: string;
symlink: boolean;
}
export interface GitServiceInterface {
updateConfig(...args: unknown[]): void;
getFile(path: string, branch: string): Promise<GitFile>;
pushFile(path: string, content: string | ArrayBuffer, branch: string, commitMessage: string, existingSha?: string): Promise<{ path: string, sha?: string }>;
testConnection(): Promise<boolean>;
listFiles(branch: string, useFilter?: boolean): Promise<string[]>;
/** Like listFiles but also reports which entries are symbolic links (mode 120000). */
listFilesDetailed(branch: string, useFilter?: boolean): Promise<GitTreeEntry[]>;
deleteFile(path: string, branch: string, commitMessage: string): Promise<void>;
getRepoGitignores(branch: string): Promise<string[]>;
}

View file

@ -1,5 +1,5 @@
import { GitServiceInterface } from './git-service-interface';
import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse } from './git-service-base';
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 GitHubService extends BaseGitService implements GitServiceInterface {
@ -54,25 +54,25 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
return { path: data.content.path, sha: data.content.sha };
}
async listFiles(branch: string, useFilter = true): Promise<string[]> {
async listFilesDetailed(branch: string, useFilter = true): Promise<GitTreeEntry[]> {
const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`;
const response = await this.safeRequest(url, 'GET');
const data = this.parseJson<GitHubTreeResponse>(response);
if (data.truncated) {
logger.warn('GitHub tree result is truncated. Some files might not be shown.');
}
const files = data.tree
const entries = data.tree
.filter(item => item.type === 'blob')
.map(item => item.path);
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE }));
if (!useFilter) return files;
if (!useFilter) return entries;
return files.filter(p => {
return entries.filter(e => {
if (!this.rootPath) return true;
const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`;
return p === this.rootPath || p.startsWith(cleanRoot);
return e.path === this.rootPath || e.path.startsWith(cleanRoot);
});
}

View file

@ -1,5 +1,5 @@
import { GitServiceInterface } from './git-service-interface';
import { BaseGitService, GitFile, GitLabFileResponse, GitLabTreeItem } from './git-service-base';
import { GitServiceInterface, GitTreeEntry } from './git-service-interface';
import { BaseGitService, GitFile, GitLabFileResponse, GitLabTreeItem, GIT_SYMLINK_MODE } from './git-service-base';
export class GitLabService extends BaseGitService implements GitServiceInterface {
private baseUrl: string = 'https://gitlab.com';
@ -55,39 +55,39 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
return { path: data.file_path };
}
async listFiles(branch: string, useFilter = true): Promise<string[]> {
async listFilesDetailed(branch: string, useFilter = true): Promise<GitTreeEntry[]> {
const encodedProjectId = encodeURIComponent(this.projectId);
let allPaths: string[] = [];
let allEntries: GitTreeEntry[] = [];
let page = 1;
const perPage = 100;
while (true) {
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/tree?ref=${branch}&recursive=true&per_page=${perPage}&page=${page}`;
const response = await this.safeRequest(url, 'GET');
const data = this.parseJson<GitLabTreeItem[]>(response);
if (!data || data.length === 0) break;
const paths = data
const entries = data
.filter(item => item.type === 'blob')
.map(item => item.path);
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE }));
if (useFilter) {
const filtered = paths.filter(p => {
const filtered = entries.filter(e => {
if (!this.rootPath) return true;
const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`;
return p === this.rootPath || p.startsWith(cleanRoot);
return e.path === this.rootPath || e.path.startsWith(cleanRoot);
});
allPaths = allPaths.concat(filtered);
allEntries = allEntries.concat(filtered);
} else {
allPaths = allPaths.concat(paths);
allEntries = allEntries.concat(entries);
}
if (data.length < perPage) break;
page++;
}
return allPaths;
return allEntries;
}
async deleteFile(path: string, branch: string, message: string): Promise<void> {

View file

@ -9,6 +9,15 @@ export interface SyncMetadata {
export type GitServiceType = 'gitlab' | 'github';
/**
* How symbolic links (Git blobs with mode 120000) are synced:
* - 'real': recreate a real OS symlink on desktop; on mobile (no symlink API)
* fall back to syncing the link target's content as a normal file.
* - 'follow': always sync the target file's content as a normal file.
* - 'skip': ignore symlinks entirely.
*/
export type SymlinkHandling = 'real' | 'follow' | 'skip';
export interface GitLabFilesPushSettings {
serviceType: GitServiceType;
gitlabToken: string;
@ -21,6 +30,7 @@ export interface GitLabFilesPushSettings {
syncMetadata: Record<string, SyncMetadata>;
rootPath: string;
vaultFolder: string;
symlinkHandling: SymlinkHandling;
}
export function getServiceName(settings: GitLabFilesPushSettings): string {
@ -38,7 +48,8 @@ export const DEFAULT_SETTINGS: GitLabFilesPushSettings = {
rootPath: "",
branch: 'main',
syncMetadata: {},
vaultFolder: ''
vaultFolder: '',
symlinkHandling: 'real'
}
export class GitLabSyncSettingTab extends PluginSettingTab {
@ -110,6 +121,19 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
void this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Symbolic links')
.setDesc('How to sync symlinks: "real" recreates the link on desktop and falls back to the target content on mobile, "follow" always syncs the target content, and "skip" ignores symlinks.')
.addDropdown(dropdown => dropdown
.addOption('real', 'Real symlink (recommended)')
.addOption('follow', 'Follow (sync target content)')
.addOption('skip', 'Skip')
.setValue(this.plugin.settings.symlinkHandling)
.onChange((value: string) => {
this.plugin.settings.symlinkHandling = value as SymlinkHandling;
void this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Test connection')
.setDesc(`Verify your ${getServiceName(this.plugin.settings)} settings`)

View file

@ -303,19 +303,21 @@ export class SyncStatusView extends ItemView {
private async discoverFiles() {
const allFiles = this.app.vault.getFiles();
let local = this.plugin.filterFilesByVaultFolder(allFiles);
const remoteFullPaths = await this.plugin.gitService.listFiles(this.plugin.settings.branch);
const remoteEntries = await this.plugin.gitService.listFilesDetailed(this.plugin.settings.branch);
await this.plugin.gitignoreManager.loadGitignores();
// Map remote paths to vault paths
const remoteMap = new Map<string, string>(); // vaultPath -> remoteFullPath
for (const remotePath of remoteFullPaths) {
const normalized = this.getNormalizedRemotePath(remotePath);
const skipSymlinks = this.plugin.settings.symlinkHandling === 'skip';
for (const entry of remoteEntries) {
if (entry.symlink && skipSymlinks) continue; // Symlink handling: skip
const normalized = this.getNormalizedRemotePath(entry.path);
if (normalized === null) continue; // Not under rootPath
const vaultPath = this.plugin.getVaultPath(normalized);
if (!this.plugin.gitignoreManager.isIgnored(normalized)) {
remoteMap.set(vaultPath, remotePath);
remoteMap.set(vaultPath, entry.path);
}
}

View file

@ -48,6 +48,7 @@ const mockSettings: GitLabFilesPushSettings = {
rootPath: 'notes',
vaultFolder: 'Work',
syncMetadata: {},
symlinkHandling: 'real',
};
describe('SyncManager Mapping', () => {

View file

@ -54,7 +54,8 @@ const mockSettings: GitLabFilesPushSettings = {
branch: 'main',
rootPath: '',
syncMetadata: {},
vaultFolder: ''
vaultFolder: '',
symlinkHandling: 'real'
};
describe('SyncManager', () => {

View file

@ -116,6 +116,18 @@ describe('GitHubService', () => {
expect(await service.listFiles('main')).toEqual(['vault/file1.md']);
});
it('listFilesDetailed flags symlinks (mode 120000)', async () => {
mockRequest({ status: 200, json: { tree: [
{ path: 'real.md', type: 'blob', mode: '100644' },
{ path: 'link.md', type: 'blob', mode: '120000' },
{ path: 'dir', type: 'tree', mode: '040000' },
] } });
expect(await service.listFilesDetailed('main')).toEqual([
{ path: 'real.md', symlink: false },
{ path: 'link.md', symlink: true },
]);
});
it('should not match sibling paths with same prefix as rootPath', async () => {
service.updateConfig(token, owner, repo, 'src/content');
mockRequest({ status: 200, json: { tree: [

View file

@ -87,6 +87,17 @@ describe('GitLabService', () => {
expect(await service.listFiles('main')).toEqual(['vault/file1.md']);
});
it('listFilesDetailed flags symlinks (mode 120000)', async () => {
mockRequest({ status: 200, json: [
{ path: 'real.md', type: 'blob', mode: '100644' },
{ path: 'link.md', type: 'blob', mode: '120000' },
] });
expect(await service.listFilesDetailed('main')).toEqual([
{ path: 'real.md', symlink: false },
{ path: 'link.md', symlink: true },
]);
});
it('should not match sibling paths with same prefix as rootPath', async () => {
service.updateConfig(baseUrl, token, projectId, 'src/content');
mockRequest({ status: 200, json: [