/
raiden
/
obsidian-git-encrypt
Обзор
Документация
Войти
/
raiden
/
obsidian-git-encrypt
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/sourceControl/sourceControlService.ts
357 строк
13 KB
Robert Kuzhin
fix: clear staging after manual revert
10 авг 2026, 07:35
10 авг 2026, 07:35
1de1382
Код
Авторство
О чём код?
import type { CryptoService } from "../crypto/cryptoService.js"; import type { KeyHandle } from "../crypto/keyHandle.js"; import { GitRevisionMirror } from "../git/gitRevisionMirror.js"; import type { FetchResult, GitAdapter } from "../git/gitTypes.js"; import type { EncryptedMirror } from "../mirror/encryptedMirror.js"; import { ManifestStore } from "../mirror/manifestStore.js"; import type { Manifest, ManifestEntry } from "../mirror/manifestTypes.js"; import { validateManifest } from "../mirror/manifestValidation.js"; import { SessionError } from "../session/errors.js"; import type { ConflictResolver, SyncCoordinator } from "../sync/syncCoordinator.js"; import type { DeviceStateStore } from "../sync/deviceStateStore.js"; import type { LocalSyncCoordinator } from "../sync/localSyncCoordinator.js"; import { compareEntries, planSourceChanges } from "./changeStatus.js"; import type { DiffTarget, PullChangesResult, SourceControlStatus, SourceDiff, StagingState, } from "./sourceControlTypes.js"; import type { StagingStore } from "./stagingStore.js"; import { renderUnifiedDiff } from "./textDiff.js"; export interface SourceControlServiceOptions { readonly git: GitAdapter; readonly mirror: EncryptedMirror; readonly manifestStore: ManifestStore; readonly crypto: CryptoService; readonly key: KeyHandle; readonly vaultId: string; readonly localSync: LocalSyncCoordinator; readonly staging: StagingStore; readonly sync: SyncCoordinator; readonly deviceState: DeviceStateStore; readonly resolveConflicts?: ConflictResolver; } interface SourceContext { readonly headCommit: string; readonly head: Manifest; readonly working: Manifest; readonly staging: StagingState; readonly stagedManifest: Manifest; } export class SourceControlService { private lastFetch: FetchResult | undefined; private conflictCount = 0; public constructor(private readonly options: SourceControlServiceOptions) {} public setConflictCount(count: number): void { this.conflictCount = Math.max(0, Math.trunc(count)); } public async getStatus(): Promise<SourceControlStatus> { await this.options.localSync.syncNow(); return this.statusFromContext(await this.loadContext()); } public async stage(fileId: string): Promise<void> { await this.options.localSync.syncNow(); const context = await this.loadContext(); const entry = context.working.files[fileId]; if (entry === undefined || compareEntries(context.head.files[fileId], entry) === undefined) { throw new SessionError("The selected file has no local changes to stage"); } const envelope = entry.deleted === true ? undefined : await this.options.mirror.readObject(fileId); try { await this.options.staging.stage(context.headCommit, entry, envelope, this.options.key); } finally { envelope?.fill(0); } } public async stageAll(): Promise<void> { await this.options.localSync.syncNow(); const context = await this.loadContext(); const changes = planSourceChanges(context.head, context.working.files); for (const change of changes) { const entry = context.working.files[change.fileId]; if (entry === undefined) continue; const envelope = entry.deleted === true ? undefined : await this.options.mirror.readObject(change.fileId); try { await this.options.staging.stage(context.headCommit, entry, envelope, this.options.key); } finally { envelope?.fill(0); } } } public async unstage(fileId: string): Promise<void> { await this.options.staging.unstage(fileId, this.options.key); } public async unstageAll(): Promise<void> { await this.options.staging.clear(); } public async commitSelected(message: string): Promise<string> { await this.options.localSync.syncNow(); const context = await this.loadContext(); const entries = Object.values(context.staging.entries); if (entries.length === 0) throw new SessionError("Select at least one changed file to commit"); const candidate = validateManifest( { ...context.stagedManifest, revision: context.head.revision + 1, }, this.options.vaultId, ); const manifestEnvelope = await this.options.manifestStore.encrypt(candidate, this.options.key); const objectChanges = []; try { for (const entry of entries) { if (entry.deleted === true) { objectChanges.push({ fileId: entry.fileId, deleted: true as const }); } else { const envelope = await this.options.staging.readObject(entry.fileId); await this.verifyStagedObject(entry, envelope); objectChanges.push({ fileId: entry.fileId, deleted: false as const, envelope, }); } } const commit = await this.options.git.commitSnapshot( message, context.headCommit, manifestEnvelope, objectChanges, ); await this.options.staging.clear(); return commit; } finally { manifestEnvelope.fill(0); for (const change of objectChanges) change.envelope?.fill(0); } } public async fetch(): Promise<SourceControlStatus> { this.lastFetch = await this.options.git.fetch(); return this.getStatus(); } public async pull(): Promise<PullChangesResult> { const status = await this.getStatus(); if (status.staged.length > 0 || status.changes.length > 0) { throw new SessionError("Commit or unstage local changes before pulling remote updates"); } const fetched = await this.options.git.fetch(); this.lastFetch = fetched; if (fetched.remoteCommit === null) return { status: "no-remote" }; const counts = await this.options.git.aheadBehind(status.headCommit, fetched.remoteCommit); if (counts.behind === 0) return { status: "up-to-date" }; const result = await this.options.sync.syncNow(this.options.resolveConflicts, { push: false }); this.lastFetch = result.fetch; if (result.status === "conflicts") { this.setConflictCount(result.conflicts.length); return { status: "conflicts", conflicts: result.conflicts.length }; } this.setConflictCount(0); if (result.fetch.status === "no-remote") return { status: "no-remote" }; return { status: result.commit === null && result.fetch.localCommit === result.fetch.remoteCommit ? "up-to-date" : "pulled", }; } public async push(): Promise<void> { await this.options.localSync.syncNow(); await this.options.git.push(); const headCommit = await this.requireHeadCommit(); const head = await this.loadManifestAt(headCommit); const state = await this.options.deviceState.load(this.options.key); if (state === undefined) throw new SessionError("Device state is unavailable after push"); await this.options.deviceState.save( { ...state, baseCommit: headCommit, baseManifest: head }, this.options.key, ); this.lastFetch = { status: "fetched", localCommit: headCommit, remoteCommit: headCommit, }; } public async getDiff(fileId: string, target: DiffTarget): Promise<SourceDiff> { await this.options.localSync.syncNow(); const context = await this.loadContext(); const headEntry = context.head.files[fileId]; const stagedEntry = context.stagedManifest.files[fileId]; const workingEntry = context.working.files[fileId]; let beforeEntry: ManifestEntry | undefined; let afterEntry: ManifestEntry | undefined; let beforeSource: "head" | "staged"; let afterSource: "staged" | "working"; if (target === "working") { beforeEntry = headEntry; afterEntry = workingEntry; beforeSource = "head"; afterSource = "working"; } else if (target === "staged") { if (context.staging.entries[fileId] === undefined) { throw new SessionError("The selected file is not staged"); } beforeEntry = headEntry; afterEntry = stagedEntry; beforeSource = "head"; afterSource = "staged"; } else { if (context.staging.entries[fileId] === undefined) { throw new SessionError("The selected file has no staged snapshot"); } beforeEntry = stagedEntry; afterEntry = workingEntry; beforeSource = "staged"; afterSource = "working"; } const before = await this.readEntryBytes(beforeEntry, beforeSource, context.headCommit); const after = await this.readEntryBytes(afterEntry, afterSource, context.headCommit); try { const path = afterEntry?.path ?? beforeEntry?.path ?? "unknown"; const rendered = renderUnifiedDiff(before, after, beforeSource, afterSource); return { path, target, ...rendered }; } finally { before.fill(0); after.fill(0); } } private async loadContext(): Promise<SourceContext> { const headCommit = await this.requireHeadCommit(); const [head, working, loadedStaging] = await Promise.all([ this.loadManifestAt(headCommit), this.options.manifestStore.load(this.options.key), this.options.staging.load(this.options.key), ]); if (loadedStaging.baseCommit !== null && loadedStaging.baseCommit !== headCommit) { throw new SessionError("Git HEAD changed after files were staged; unstage and select them again"); } const revertedFileIds = Object.keys(loadedStaging.entries).filter( (fileId) => compareEntries(head.files[fileId], working.files[fileId]) === undefined, ); if (revertedFileIds.length > 0) { await this.options.staging.unstageMany(revertedFileIds, this.options.key); } const staging = revertedFileIds.length === 0 ? loadedStaging : await this.options.staging.load(this.options.key); const stagedManifest = validateManifest( { ...head, files: { ...head.files, ...staging.entries }, }, this.options.vaultId, ); return { headCommit, head, working, staging, stagedManifest }; } private async statusFromContext(context: SourceContext): Promise<SourceControlStatus> { const staged = planSourceChanges(context.head, context.stagedManifest.files); const changes = planSourceChanges(context.stagedManifest, context.working.files); const deviceState = await this.options.deviceState.load(this.options.key); const remoteCommit = this.lastFetch === undefined ? deviceState?.baseCommit ?? null : this.lastFetch.remoteCommit; const counts = remoteCommit === null ? { ahead: 0, behind: 0 } : await this.options.git.aheadBehind(context.headCommit, remoteCommit); const incoming = this.lastFetch?.remoteCommit === null || this.lastFetch === undefined ? [] : planSourceChanges( context.head, (await this.loadManifestAt(this.lastFetch.remoteCommit)).files, ); return { headCommit: context.headCommit, remoteCommit, remoteKnown: this.lastFetch !== undefined, ahead: counts.ahead, behind: counts.behind, staged, changes, incoming, conflicts: this.conflictCount, }; } private async requireHeadCommit(): Promise<string> { const head = await this.options.git.getHeadCommit(); if (head === null) throw new SessionError("Encrypted repository has no initial commit"); return head; } private loadManifestAt(commit: string): Promise<Manifest> { return new ManifestStore( new GitRevisionMirror(this.options.git, commit), this.options.crypto, this.options.vaultId, ).load(this.options.key); } private async readEntryBytes( entry: ManifestEntry | undefined, source: "head" | "staged" | "working", headCommit: string, ): Promise<Uint8Array> { if (entry === undefined || entry.deleted === true) return new Uint8Array(); const envelope = source === "head" ? await new GitRevisionMirror(this.options.git, headCommit).readObject(entry.fileId) : source === "staged" ? await this.options.staging.readObject(entry.fileId) : await this.options.mirror.readObject(entry.fileId); try { return await this.options.crypto.decryptObject({ key: this.options.key, fileId: entry.fileId, envelope, }); } finally { envelope.fill(0); } } private async verifyStagedObject( entry: ManifestEntry, envelope: Uint8Array, ): Promise<void> { const verificationEnvelope = new Uint8Array(envelope); let plaintext: Uint8Array | undefined; try { plaintext = await this.options.crypto.decryptObject({ key: this.options.key, fileId: entry.fileId, envelope: verificationEnvelope, }); const digest = new Uint8Array( await globalThis.crypto.subtle.digest("SHA-256", new Uint8Array(plaintext)), ); const hash = `sha256:${[...digest] .map((byte) => byte.toString(16).padStart(2, "0")) .join("")}`; digest.fill(0); if (plaintext.length !== entry.size || hash !== entry.plaintextHash) { throw new SessionError("Staged encrypted object does not match its authenticated metadata"); } } finally { plaintext?.fill(0); verificationEnvelope.fill(0); } } }