Skip to content

Commit dff95db

Browse files
committed
fix(sync): unify push pull and move decisions
1 parent d387193 commit dff95db

12 files changed

Lines changed: 294 additions & 113 deletions

feature_list.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"description": "Preserve sync behavior while extracting SyncStatusView presentation state/controller boundaries and SyncManager scanner/planner/executor/workspace boundaries with regression and integration coverage.",
99
"dependencies": [],
1010
"status": "in-progress",
11-
"evidence": "In progress on refactor/sync-domain-pipeline: View 11.5 KB and Manager facade 13.7 KB; UI/domain workspace, planner/scanner/coordinators/executors/FileDiff boundaries covered by 598 passing tests; automated gates green, desktop/mobile manual smoke pending."
11+
"evidence": "In progress on refactor/sync-domain-pipeline: push, pull, single pull, and move decisions now route through SyncPlanner; View 11.5 KB and Manager facade 13.7 KB; 610 tests and automated gates green, desktop/mobile manual smoke pending."
1212
},
1313
{
1414
"id": "feat-004",

progress.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ Completed work is archived in [archive/](./archive/), one file per calendar mont
44

55
## Current State
66

7-
**Last Updated:** 2026-08-19
8-
**Active Feature:** feat-026 / issue #105 — sync architecture refactor on `refactor/sync-domain-pipeline`. Automated implementation is complete: the actual View is 11.5 KB, the Manager facade is 13.7 KB, UI commands cross `SyncWorkspace`, and scanner/planner/coordinator/executor/diff boundaries have focused and integration coverage. Only real Obsidian desktop/mobile manual verification remains before declaring the feature complete.
7+
**Last Updated:** 2026-08-20
8+
**Active Feature:** feat-026 / issue #105 — sync architecture refactor on `refactor/sync-domain-pipeline`. `SyncPlanner` is now the decision source for normal push, batch pull/preview, single pull, and moves. Edited tracked renames with a free destination plan one move instead of being auto-skipped; remote-only changes pull without false conflicts; real two-sided divergence and occupied move destinations remain conflicts. Automated implementation is green; real Obsidian desktop/mobile manual verification remains before declaring the feature complete.
99
**Parallel Work:** PR #87 (4x Dependabot security alerts via npm overrides) and Issue #57 (live-credential smoke test).
1010

1111
## Outstanding Items
@@ -17,6 +17,8 @@ Completed work is archived in [archive/](./archive/), one file per calendar mont
1717

1818
## Latest Evidence
1919

20+
- [x] Issue #105 unified sync decisions and move regression (2026-08-20): added operation-aware `SyncPlanner.planFor(push|pull)`, `MoveFacts`, and the `move` domain action. Normal push, batch pull and preview, single pull, and tracked moves now consume planner decisions instead of reimplementing SHA conflict checks. Removed `PushCoordinator.queueMove`'s stale-metadata gate, so an edited tracked rename with a free destination appears under Moves and commits once; occupied destinations remain conflicts. Fixed the complementary pull false positive: a remote-only change now pulls, while real two-sided divergence still resolves as conflict. Content-fetched text/binary paths normalize equal bytes to the provider blob SHA before planning, preserving binary and GitLab legacy-baseline behavior. Added planner operation matrix, coordinator move regression, batch pull, and single pull coverage. Verification: `npx eslint .` — 0 errors; `npm run build` — clean including Obsidian 1.11 compatibility; `npx vitest run` — 54 files / 610 tests; `git diff --check` — clean. Manual Obsidian verification remains.
21+
2022
- [x] Issue #105 architecture implementation (2026-08-19): extracted `SyncStatusRenderer` and `SyncStatusComposition`; `SyncStatusView.ts` is 11.5 KB / 251 lines. Extracted `PullCoordinator` and `PushCoordinator`; `SyncManager.ts` is 13.7 KB / 298 lines and retains its public compatibility API. `SyncManagerWorkspace` now owns refresh/tree-snapshot reuse, push/pull, diff, local/remote deletion, move, metadata mutations, provider URLs and UI-safe workspace info; sync-status UI code no longer reaches provider/tree/settings/vault mutation helpers, and `src/logic/**` has no UI imports. Legacy refresh characterization cases now target the extracted service instead of private View delegates; legacy modal tests explicitly inject the Obsidian interaction adapter. Added real refresh integration plus focused push-coordinator/workspace regression tests. Independent verification: `npx eslint .` — 0 errors; `npm run build` — clean including Obsidian 1.11 compatibility; `npx vitest run` — 54 files / 598 tests; `npm run test:e2e -- --provider gitea` — 2 files / 14 tests with container cleanup; `git diff --check` — clean. Desktop/mobile Obsidian smoke remains manual.
2123

2224
- [x] Issue #105 architecture slice 3 (2026-08-19): added tested `SyncDiffService` and `SyncStatusNavigator`, so lazy blob loading/cache/content-kind projection is a domain `FileDiff` boundary. Extracted single-file, batch push/pull, local/remote delete, move revert, remote-tree reuse, progress/confirmation, and optimistic-status orchestration into `SyncStatusOperations`; all View row/group events now enter through `SyncStatusController`. The actual View is about 40 KB (down from 58 KB this slice and 80 KB initially). Independent verification: `npx eslint .` — 0 errors; `npm run build` — clean including Obsidian 1.11 compatibility; `npx vitest run` — 52 files / 594 tests; `npm run test:e2e -- --provider gitea` — 2 files / 14 tests with container cleanup; `git diff --check` — clean. Feature remains in progress because renderer composition and the ~50 KB manager facade are still oversized.

session-handoff.md

Lines changed: 28 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,43 @@
11
# Session Handoff
22

3-
**Date:** 2026-08-19
4-
**Branch:** `refactor/sync-domain-pipeline` based on `origin/main@6fc3d6b`
3+
**Date:** 2026-08-20
4+
**Branch:** `refactor/sync-domain-pipeline` (PR #127)
55
**Active Feature:** feat-026 / issue #105 — sync architecture refactor
66

7-
## Current Stopping Point
7+
## Completed This Session
88

9-
The requested architecture refactor and automated regression safety net are implemented. Do not start the VS Code-like source-control redesign on this branch. The only Definition-of-Done item that cannot be executed in this environment is real Obsidian desktop/mobile manual verification.
9+
Fixed the reported false conflict for a tracked rename whose content was also edited, then audited
10+
and removed the remaining sync-decision bypasses. Move classification previously returned before
11+
reaching `SyncPlanner` and used a duplicate `oldEntry.sha === lastSyncedSha` safety check;
12+
`PushCoordinator`, `PullCoordinator`, and single pull also retained separate SHA predicates after
13+
the planner extraction.
1014

11-
## Implemented Architecture
15+
Added operation-aware `SyncPlanner.planFor(push|pull)`, `MoveFacts`, and the `move` domain action.
16+
Normal push, batch pull/preview, single pull, and tracked move now consume planner decisions. A
17+
free destination produces one move containing current local content; an occupied destination
18+
remains a conflict. A remote-only change now pulls rather than being mistaken for two-sided
19+
divergence. Content-fetched text/binary paths normalize equal bytes to the provider blob SHA, so
20+
binary behavior and GitLab legacy baselines remain compatible.
1221

13-
- `src/ui/SyncStatusView.ts` and `src/logic/sync-manager.ts` are thin compatibility entrypoints.
14-
- Actual `src/ui/sync-status/SyncStatusView.ts` is 11.5 KB / 251 lines and composes state, renderer, controller, navigator, operations and workspace.
15-
- `SyncStatusViewState` owns presentation state; `SyncStatusSelectors` are pure and table-tested; `SyncStatusRenderer` owns list/tree/group/header rendering.
16-
- Every row/group/action command enters `SyncStatusController`; `SyncStatusOperations` performs UI notifications/confirmation and calls `SyncWorkspace` for mutations.
17-
- `SyncManagerWorkspace` owns refresh, remote-tree snapshot validation/reuse, push/pull, `FileDiff`, local/remote deletion, move, metadata mutations, provider URLs and UI-safe workspace info. Sync-status UI code no longer imports provider/tree/settings or vault mutation helpers.
18-
- `SyncStatusRefreshService` owns discovery, hidden files, path mapping, status classification, out-of-band move reconciliation and live modify/rename transitions.
19-
- Actual `src/logic/sync/SyncManager.ts` is 13.7 KB / 298 lines, preserves the historical public API, and delegates batch use cases to `PushCoordinator` and `PullCoordinator`.
20-
- Historical `src/logic/sync-manager.ts` is now a pure domain re-export. `src/logic/**` has no UI imports; production and modal characterization tests inject `ObsidianSyncInteraction` at the composition boundary.
21-
- Domain components include `SyncScanner`, pure `SyncPlanner`, `SyncMetadataStore`, `PushExecutor`, `PullExecutor`, `RemoteDeleteExecutor`, `ConflictResolver`, `SyncExecutor`, `SyncDiffService`, and the injected `SyncInteractionPort`.
22-
- `DiffView` consumes only `FileDiff`.
23-
- New tests cover state/selectors/controller, planner matrix, scanner/metadata, every executor, diff, push coordinator, workspace snapshot behavior, and real Scanner/Manager/Workspace integration paths.
22+
The clean-code skill drove the TDD sequence: the coordinator regression failed first with the
23+
file under `skippedConflicts`, then passed after the planner integration. No commit or push was
24+
performed. The pre-existing untracked `.codex-gitlab.env` remains untouched.
2425

2526
## Verification Evidence
2627

2728
```text
28-
npx eslint . -> PASS, 0 errors
29-
npm run build -> PASS, incl. Obsidian 1.11 compatibility
30-
npx vitest run -> PASS, 54 files / 598 tests
31-
git diff --check -> PASS
32-
npm run test:e2e -- --provider gitea -> PASS, 2 files / 14 tests; container removed
29+
npx eslint . -> PASS, 0 errors
30+
npm run build -> PASS, incl. Obsidian 1.11 compatibility
31+
npx vitest run -> PASS, 54 files / 610 tests
32+
git diff --check -> PASS
3333
```
3434

35-
The independent verifier used the closest available low-tier model because the AGENTS-required Haiku model is unavailable.
35+
The AGENTS-required Haiku verifier was unavailable in this environment, so verification ran
36+
locally in this session.
3637

37-
## Required Manual Smoke Before Marking Complete
38+
## Exact Next Step
3839

39-
Desktop:
40-
41-
1. Open sync view → refresh → local-only file → Push → refresh → synced.
42-
2. Remote-only → Pull → local file created → synced.
43-
3. Modified → Diff → Push/Pull.
44-
4. Conflict → resolve → refresh.
45-
46-
Mobile:
47-
48-
1. Open sync view → refresh → select one file → Diff → Push.
49-
2. Remote-only → select file → Pull.
50-
51-
After manual confirmation, mark feat-026 complete and archive its active progress entry. Then branch `feat/vscode-source-control-ui` from the updated `main`.
52-
53-
## Workspace Safety
54-
55-
- `.codex-gitlab.env` is untracked and must remain untouched/uncommitted.
56-
- Prior detached tracked changes remain preserved in `stash@{0}` with message `pre-refactor preserved tracked changes from detached 1.5.6 checkout`.
57-
- No commit or push has been made for this refactor workspace.
40+
Manually verify in Obsidian that moving and editing a tracked file shows it under Moves and that
41+
Apply creates the new path with edited content while removing the old path. Also verify an
42+
existing remote destination still appears as a skipped conflict. After the remaining desktop and
43+
mobile smoke paths pass, feat-026 can be marked complete.

src/logic/sync/PullCoordinator.ts

Lines changed: 60 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { contentsEqual, isBinaryPath } from '../../utils/path';
77
import type { PullExecutor } from './PullExecutor';
88
import type { SyncScanner } from './SyncScanner';
99
import { SyncPlanner } from './SyncPlanner';
10-
import type { SyncPlan, SyncPlanEntry, SyncResult } from './types';
10+
import type { PlannedFileAction, SyncPlan, SyncPlanEntry, SyncResult } from './types';
1111
import { isSyncPlanEmpty } from './types';
1212

1313
type BatchOutcome = 'done' | 'unchanged' | 'conflict';
@@ -106,13 +106,9 @@ export class PullCoordinator {
106106
const repoPath = this.dependencies.scanner.toRepoPath(path);
107107
const entry = tree?.get(this.dependencies.scanner.toTreePath(repoPath));
108108
if (tree && !entry) return 'skip';
109-
if (!await this.fileExists(file)) return 'addition';
110109
if (!entry?.sha || entry.symlink) return 'modification';
111-
const localSha = await gitBlobSha(await this.dependencies.scanner.readContent(file));
112-
if (localSha === entry.sha) return 'unchanged';
113-
await this.dependencies.migrateBaseline(path, repoPath, entry);
114-
const baseline = this.dependencies.settings.syncMetadata[path];
115-
return baseline && entry.sha !== baseline.lastSyncedSha ? 'conflict' : 'modification';
110+
const decision = await this.planFromTree(file, path, isString, entry);
111+
return this.planKindFor(decision);
116112
}
117113

118114
private async processFile(
@@ -131,15 +127,12 @@ export class PullCoordinator {
131127
}
132128
const remote = await this.dependencies.gitService().getFile(repoPath, this.dependencies.settings.branch);
133129
if (!remote.sha) throw new Error('File not found in remote');
134-
if (await this.fileExists(file)) {
135-
const localContent = await this.dependencies.scanner.readContent(file);
136-
if (contentsEqual(localContent, remote.content)) {
137-
await this.dependencies.updateMetadata(path, remote.sha);
138-
return 'unchanged';
139-
}
140-
const baseline = this.dependencies.settings.syncMetadata[path];
141-
if (baseline && !this.sameBaseline(baseline.lastSyncedSha, remote)) return 'conflict';
130+
const decision = await this.planFromRemote(file, path, isString, remote);
131+
if (decision.action === 'none') {
132+
await this.dependencies.updateMetadata(path, remote.sha);
133+
return 'unchanged';
142134
}
135+
if (decision.action === 'resolve-conflict') return 'conflict';
143136
const target = typeof file === 'string' ? { path, name } : file;
144137
await this.dependencies.executor.pull(target, remote.content, remote.sha, true, this.symlinkTarget(remote));
145138
return 'done';
@@ -151,27 +144,61 @@ export class PullCoordinator {
151144
isString: boolean,
152145
entry: GitTreeEntry,
153146
): Promise<BatchOutcome | null> {
154-
if (entry.symlink || !entry.sha || !await this.fileExists(file)) return null;
155-
const localSha = await gitBlobSha(await this.dependencies.scanner.readContent(file));
156-
const baseline = this.dependencies.settings.syncMetadata[path];
157-
const classification = this.planner.classify({
158-
local: { path, exists: true, blobSha: localSha, kind: isBinaryPath(path) ? 'binary' : 'text' },
159-
remote: {
160-
path,
161-
repoPath: this.dependencies.scanner.toRepoPath(path),
162-
exists: true,
163-
blobSha: entry.sha,
164-
kind: 'text',
165-
},
166-
base: { blobSha: baseline?.lastSyncedSha },
167-
});
168-
if (classification === 'synced') {
147+
if (entry.symlink || !entry.sha) return null;
148+
const decision = await this.planFromTree(file, path, isString, entry);
149+
if (decision.action === 'none') {
169150
await this.dependencies.updateMetadata(path, entry.sha);
170151
return 'unchanged';
171152
}
172-
await this.dependencies.migrateBaseline(path, this.dependencies.scanner.toRepoPath(path), entry);
173-
const migratedBaseline = this.dependencies.settings.syncMetadata[path];
174-
return migratedBaseline && entry.sha !== migratedBaseline.lastSyncedSha ? 'conflict' : null;
153+
return decision.action === 'resolve-conflict' ? 'conflict' : null;
154+
}
155+
156+
private async planFromTree(
157+
file: TFile | string,
158+
path: string,
159+
isString: boolean,
160+
entry: GitTreeEntry,
161+
): Promise<PlannedFileAction> {
162+
const repoPath = this.dependencies.scanner.toRepoPath(path);
163+
await this.dependencies.migrateBaseline(path, repoPath, entry);
164+
const exists = await this.fileExists(file);
165+
const kind = isBinaryPath(path) ? 'binary' : 'text';
166+
const localSha = exists ? await gitBlobSha(await this.dependencies.scanner.readContent(file)) : undefined;
167+
return this.planner.planFor('pull', {
168+
local: { path, exists, blobSha: localSha, kind },
169+
remote: { path, repoPath, exists: true, blobSha: entry.sha, kind },
170+
base: { blobSha: this.dependencies.settings.syncMetadata[path]?.lastSyncedSha },
171+
});
172+
}
173+
174+
private async planFromRemote(
175+
file: TFile | string,
176+
path: string,
177+
isString: boolean,
178+
remote: GitFile,
179+
): Promise<PlannedFileAction> {
180+
const exists = await this.fileExists(file);
181+
const kind = isBinaryPath(path) ? 'binary' : 'text';
182+
const localContent = exists ? await this.dependencies.scanner.readContent(file) : undefined;
183+
let localSha: string | undefined;
184+
if (localContent !== undefined) {
185+
localSha = contentsEqual(localContent, remote.content) ? remote.sha : await gitBlobSha(localContent);
186+
}
187+
const baseline = this.dependencies.settings.syncMetadata[path]?.lastSyncedSha;
188+
const blobBaseline = baseline === remote.revision ? remote.sha : baseline;
189+
return this.planner.planFor('pull', {
190+
local: { path, exists, blobSha: localSha, kind },
191+
remote: { path, repoPath: this.dependencies.scanner.toRepoPath(path), exists: true, blobSha: remote.sha, kind },
192+
base: { blobSha: blobBaseline },
193+
});
194+
}
195+
196+
private planKindFor(decision: PlannedFileAction): PlanKind {
197+
if (decision.action === 'pull-create') return 'addition';
198+
if (decision.action === 'pull-overwrite') return 'modification';
199+
if (decision.action === 'resolve-conflict') return 'conflict';
200+
if (decision.action === 'none') return 'unchanged';
201+
return 'skip';
175202
}
176203

177204
private fileExists(file: TFile | string): Promise<boolean> | boolean {
@@ -190,10 +217,6 @@ export class PullCoordinator {
190217
return remote.isSymlink ? remote.symlinkTarget ?? '' : undefined;
191218
}
192219

193-
private sameBaseline(sha: string, remote: GitFile): boolean {
194-
return sha === remote.sha || sha === remote.revision;
195-
}
196-
197220
private notifyResult(result: SyncResult): void {
198221
if (result.success > 0) this.dependencies.notify(`Pulled ${result.success} file(s) to ${this.dependencies.serviceName()}`);
199222
if (result.conflicts > 0) {

0 commit comments

Comments
 (0)