Skip to content

feat(source-control): replace sync status panel with the source control workflow - #129

Merged
ClaudiaFang merged 112 commits into
mainfrom
claude/source-control-foundation
Aug 31, 2026
Merged

ClaudiaFang merged 112 commits into
mainfrom
claude/source-control-foundation

Conversation

@ClaudiaFang

@ClaudiaFang ClaudiaFang commented Aug 21, 2026 •

Copy link
Copy Markdown
Member

Summary

Umbrella PR for the Source Control foundation: this branch (75 commits) carries the sync-status UI from a legacy read-only panel to the full Source Control workflow — selection → Sync Queue → one-plan/one-commit sync — plus the real-provider E2E harness that gates it. Closes #128, and delivers the issues listed below.

Core: state foundation (#128)

  • PushSelectionStore — tracks which pending sync changes are "Ready to Push" (includeForPush()/excludeFromPush()), independent of the SyncPlanner/SyncExecutor change model. Deliberately avoids VCS stage/unstage terminology; keyed by branded ChangeId so rename/move preserves selection and in-flight intent.
  • OperationState — per-change in-flight operation status (idle/running/success/failed), independent of selection.
  • Both are pure, UI-independent modules — no DOM/provider dependencies, no changes to existing SyncManager/SyncPlanner/SyncExecutor behavior.

Source Control UI (Phase 1–3 + user workflow)

  • SourceControlViewModel + ChangeRepository + OperationState/RefreshState foundation layer (76db082), Phase 2 action service (70f6c9e), Phase 3 UI (7cec661), then legacy UI removal — the Source Control view is now the single entry (4e647fb).
  • Sync Queue / Repository Changes split (VS Code Staged/Changes model): checking a repository row moves it into the Sync Queue above; unchecking moves it back — a change never renders in both regions (9fd1789, 60790a3).
  • Queue grouped by resolved sync action (Upload / Download / Delete) so a mixed batch reads as what will happen; local-deleted routes to delete-remote, not pull-restore (ebd8cb6, 264ae4b).
  • Unified Sync: the whole queue becomes one merged Sync Plan, one confirm, one remote commit — pushes + moves + deletions together (264ae4b), with a single aggregated completion toast, i18n'd (17b361f, b5eb1ae).
  • Diff pane: full-width desktop diff tab; mobile in-panel detail with unified/split layout toggle (f449125).
  • What's-new onboarding layout for the Source Control workflow (88e08dd) + README/docs alignment (d4ce756).

Diff stats (+N/−N) (#93)

  • DiffStatProvider — three-state load result (ready / pending / unavailable). pending (content not yet in memory) is never cached, so rows retry and late-arriving content lands; unavailable (binary/symlink) is cached permanently. Per-row invalidate(id); clear() reserved for full refreshes (2d6cf91).
  • Background bounded loader: all Repository Changes rows (M/moved/remote-modified included) background-load with max 4 concurrent, local-only first, batched settle → one progressive re-render.
  • handleFileCreated reads content before publishing, so a fresh A row shows +N immediately.

Mobile UX

  • Mobile list → diff detail → Back preserves scroll position: View-level MainScrollState + anchor-ChangeId re-anchoring absorbs height/order changes from live status and background stat updates; only selectedChangeId resets on Back (709905a, 10e344f, d7606ff)。
  • Collapsible sections, independent-region scrolling, and the sticky mobile sync bar.

Live status (4009f1d)

  • Vault events (create/modify/delete/rename) update the shared status store live; legacy UI is dead and duplicate-leaf normalization handles old saved workspaces (2bbd042).

Real-provider E2E (#115, #139)

  • Playwright-driven E2E against disposable github/gitea/gitlab instances: source-control selection, conflict, rename/move, divergence/idempotency, and batch-scale coverage (8ed5df9…119ba03).
  • Disposable Gitea CI portability and runner trust separation delivered via merged PR test(e2e): run Gitea safely across local and CI #140 (Docker-assigned loopback port, per-run mktemp workdir, hardened hosted runners).
  • CI: parallelized validation DAG behind a single required-checks gate (395b87e), E2E tiering + concurrency isolation (b1d2208, b5884fc).

Lifecycle hardening + legacy cleanup (final round)

  • Mobile scroll lifecycle (5f6628): navigation scroll restore now runs exactly once on the Back transition (restoreNavigationScrollOnNextRender); checkbox/diff-stat/status rerenders restore their own captured DOM positions and never re-anchor. Regression matrix covers scroll=900 through every rerender class, Back→900→scroll 1400 fidelity, and row-vanished-while-in-detail.
  • DiffStatProvider stale-result rejection: two-level generation guard (global + per-row) means invalidate()/clear() both drop the cache AND deny in-flight old-content responses the cache write; in-flight markers are evicted so the reload is immediate; background loader rejections are retryable (never cached unavailable) and cannot escape as unhandled rejections.
  • Full diff-stat invalidation fingerprint (status/localContent/remoteContent/remoteSha/movedFrom/isSymlink) with stale-path snapshot removal on republish.
  • Resilient A-row creation: handleFileCreated publishes the row immediately (pending, uncached stat) then lands content async under a per-path revision guard — a slow create read can't clobber a raced modify, and a read failure leaves the row visible/retryable.
  • One-sided diff semantics defined once in SyncDiffService (local-only ⇒ remote '', remote-only/local-deleted ⇒ local '') so stats render +N/−N without per-kind UI branching, plus in-flight remote-blob deduplication (stat loader racing user-opened diff ⇒ one fetch).
  • Background stats scoped to rendered rows: collapsed Repository Changes / collapsed Sync Queue fire zero provider fetches.
  • Legacy sync-status presentation cleanup: dead i18n keys removed (81/locale), user-facing wording → "Source Control" (stable command ID kept), ESLint no-restricted-imports guard against resurrecting ui/sync-status, src/ui/source-control/** included in coverage (~90% lines) with conservative thresholds.

Related issues

Test plan

  • npx eslint . — 0 errors
  • npm run build — clean, including Obsidian 1.11.0 compat typecheck
  • npx vitest run — 66 files / 788 tests passed
  • Real-provider E2E (github/gitea/gitlab) green — CI run 33260979664
  • Manual iPad regression (scroll restore, search/filter state, async stat rerender vs scroll, A-row +N, M-row +N/-N)

🤖 Generated with Claude Code

ClaudiaFang and others added 3 commits August 21, 2026 14:42
PushSelectionStore and OperationState were keyed by file path, so a
rename/move would silently drop a pending selection or in-flight
operation status. Introduce a branded ChangeId type and rekey both
stores on it so state stores are keyed by SyncChange identity instead
of file path, preserving user intent across rename/move.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ClaudiaFang
ClaudiaFang force-pushed the claude/source-control-foundation branch from 19dedbc to 5d5645e Compare August 22, 2026 04:56
ClaudiaFang and others added 4 commits August 22, 2026 05:11
Phase 1 of the Source Control refactor: ChangeRepository,
SourceControlFilter, SourceControlViewModel, and ChangeTreeBuilder.
Combines SyncChange[], PushSelectionStore, and OperationState into
UI-ready state, keyed by ChangeId so renames/moves keep identity.

Does not touch SyncManager/SyncPlanner/SyncExecutor or add UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add SourceControlView and its component tree (ChangeTree, FilterMenu,
ChangeSection, ChangeItem, PushButton, OperationIndicator,
SourceControlHeader) built on top of the Phase 1 SourceControlViewModel.
Reuses the existing DiffPanel for diff rendering. Not yet registered in
main.ts; push/diff actions are injected via callbacks pending Phase 2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add SourceControlActionService wrapping the existing SyncWorkspace
facade to unify push, pull, delete-remote, delete-local, and
resolve-conflict actions behind a single ChangeId-keyed API. Resolves
ChangeId to SyncChange via the Phase 1 ChangeRepository and reports
per-change outcomes through OperationState. push()/loadDiffContent()
are directly assignable to Phase 3's SourceControlView callback types.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e legacy UI

Phase A integration + Phase E cleanup. The new SourceControlView (Phase 3)
was built but never registered; this makes it the sole entry point and
deletes the legacy SyncStatusView UI it replaces.

Wiring (src/main.ts):
- Register SourceControlItemView under the legacy view type string
  'sync-status-view' so already-open/pinned leaves resolve into the new view
  instead of an 'unrecognized view type' placeholder.
- Ribbon icon, 'open-sync-status' command, and startup refresh now route
  through activateSourceControlView().
- Construct ChangeRepository / PushSelectionStore / OperationState /
  SourceControlViewModel / SourceControlActionService on the plugin; the
  ribbon/command no longer reach GitService directly.
- Subscribe sync.status -> ChangeRepository.replace(toSyncChanges(...)) so
  the Source Control tree stays in sync with the same SyncStatusService the
  sync domain already publishes to (no separate refresh/polling path);
  unsubscribe in onunload.
- File modify/rename events now go through syncStatusRefresh (shared service
  republishes to the open view) instead of per-view notifySyncStatusViews.

New:
- src/ui/source-control/SourceControlItemView.ts: thin ItemView host that
  delegates rendering to SourceControlView and routes onPush/loadDiffContent
  to plugin.sourceControlActions.
- src/logic/source-control/FileStatusAdapter.ts: toSyncChanges() projects
  FileStatus[] -> SyncChange[] for ChangeRepository. Documents two
  pre-existing FileStatus limits it inherits (not introduces): 'modified'
  can't tell which side changed (maps to local-modified), and FileStatus
  never yields 'conflict' (conflicts are detected only during push via
  SyncPlanner, not pre-computed for display) -- so the CONFLICTS section
  won't populate from the status map alone until Phase B.

Removed (legacy UI replaced by the Source Control layer):
- src/ui/SyncStatusView.ts, src/ui/DiffView.ts, src/ui/components/* (4),
  src/ui/sync-status/* (7), and their tests; styles.css trimmed -547/+174.

Verification: npx eslint . -- 0 errors; npm run build -- PASS incl. Obsidian
1.11.0 compatibility; npx vitest run -- 55 files / 531 tests.
Manual Obsidian verification in a real vault remains (DoD for UI surfaces):
ribbon opens the new panel, tree/filter/push render, live modify+rename
refresh, pinned-leaf migration to the new view type, onunload cleanup.

Plan: docs/source-control-refactor/roadmap.md (Phases A-E).
ClaudiaFang and others added 19 commits August 22, 2026 10:26
Introduce a presentation layer so the UI no longer reads Git status
directly: SourceControlSummary is the single source for every count
(all/changes/remote-changes/ready-to-push/conflicts/synced) and the
ViewModel only forwards its counts.

Filter semantics:
- all = actionable (kind !== 'synced') so All no longer duplicates the
  Synced bucket.
- changes = local-side only (local-only/local-modified/moved).
- ready-to-push excludes synced.

Rendering:
- Every filter (including All) renders one flat tree + an active-filter
  header; the old section breakdown under All is removed, so SYNCED never
  leaks into All.
- Synced hidden by default behind a Show synced toggle; the synced chip
  only surfaces when opted in, and hiding it while on synced falls back
  to All.

Tree grouping:
- ChangeTreeBuilder gains TreeDisplayOptions { maxDepth,
  collapseSingleChild }; the view enables collapseSingleChild so
  single-child folder chains collapse to one path node instead of an
  Explorer-like deep nest.

ChangeSection.ts deleted (no longer used). i18n keys + styles added.

Verification: npx eslint . 0 errors; npm run build PASS (tsc + Obsidian
1.11.0 compat + esbuild); npx vitest run 56 files / 547 tests. Manual
Obsidian verification in a real vault remains.

Scope excludes diff viewer, conflict resolution UI, push/pull pipeline,
and view migration per the fix plan's后续順序.
Add e2e/support/sync-manager-fixture.ts (real-provider service +
verifier + TFile shim + auto-confirming plan/conflict modals, steered
by a per-test conflict resolver) and e2e/support/source-control-
scenarios.ts (high-level seed/modify/assert verbs + the Source Control
selection stack wiring), so workflow suites read as seed -> modify ->
push -> expect instead of 50 lines of setup per test. Add a non-breaking
removeLocal to FakeVault for delete-local conflict scenarios.

No production code touched; existing provider/SyncManager E2E unchanged.
Add e2e/suites/source-control-flows.e2e.test.ts and wire it into
scripts/run-e2e.sh. Phase 2 covers: rename+modify (one commit, metadata
moved to the new path, old path metadata cleared), multi-rename+modify
batch (two moves in one commit), and the Extended nested-directory move
and A->B->C rename-chain collapse (GitHub only, since they exercise
SyncManager rename tracking rather than provider APIs).
Phase 3 locks the current SyncPlanner conflict contract: modify/modify
with a stored baseline IS a conflict (asserted with strengthened
side-effect checks — both sides + baseline + HEAD untouched on skip);
delete/modify (unrelated push leaves the modified remote intact, metadata
not advanced), modify/delete (blind re-create from local), rename with a
remotely-edited source (move drops the old-path edit), and no-baseline
add/add (local overwrites remote) are NOT conflicts today and are locked
as such, so a future change to surface them as conflicts is an
intentional, test-updating decision. No production behavior changed.
Phase 4 verifies the end-to-end resolution paths: keep-local pushes local
content over the remote in one commit and advances metadata to the new
sha; keep-remote pulls the remote blob into the vault (no remote
mutation, no new commit) and updates metadata; skip is retained as a
regression lock confirming local, remote, baseline metadata, and HEAD
are all untouched.
Phase 5: create+modify+rename in one commit (GitHub only), a full
create+modify+pure-rename+rename-with-modify lifecycle batch in one
commit (all providers), and a safe+conflict batch that locks the current
non-atomic contract — safe files land in one commit while the conflict
is skipped and the remote stays on the remote side.
Phase 6 drives the real SourceControlActionService + PushSelectionStore +
ChangeRepository over the real SyncManager (via BoundarySyncWorkspace):
selected-subset push leaves unselected files untouched (core); subset-
then-remaining push yields two separate commits (GitHub only); and a
rename yields a path-derived ChangeId so selecting the new path's change
pushes the move (GitHub only), locking the current status-model
assumption. Add listCommitShas to the scenario helper.
Phase 7: remote-ahead pull advances metadata (core); a remote-ahead
change and an unrelated local change coexist without cross-contamination
(GitHub); a concurrent remote write surfaces as a conflict then
reconciles with no lost update (GitHub); an all-unchanged batch reports
zero work and zero commits (core); repeating a push makes no second
mutation and corrupts no metadata (GitHub); and a skipped conflict can
be resolved then re-synced cleanly with no stale operation state
(GitHub).
Phase 8: unicode filename create+modify+rename, spaces-and-symbols
create+modify, deeply-nested move+modify (GitHub only); 100-file batch
create in one commit and a 100-file mixed modify+create+rename batch in
one commit (GitHub only); plus an opt-in 1000-file stress create behind
E2E_STRESS=1 (never a required CI check).
The provider-e2e job hard-coded the vitest suite list and omitted
source-control-flows.e2e.test.ts, so the new suite never ran in CI
(a "fake green" — the job passed without exercising the new coverage).

Make scripts/e2e-suites.txt the single source of truth: scripts/run-e2e.sh
reads it, expands ${provider}, and runs the listed suites; CI now calls
scripts/run-e2e.sh --provider <provider> (same command local dev uses),
collapsing the separate provision/seed/vitest/verify steps into the one
retry-wrapped entry point. Adding a shared suite now only requires editing
scripts/e2e-suites.txt.

Also make the Gitea-disabled state explicit: the gate step emits a notice
and a step-summary ("Gitea E2E: disabled — runner Docker networking") so a
green gitea leg is never mistaken for three-provider coverage.
Add scripts/check-e2e-suite-registration.mjs and wire it into `npm run lint`
(both the husky pre-commit hook and CI). It fails when an
e2e/suites/*.e2e.test.ts file exists but isn't registered in
scripts/e2e-suites.txt — provider-specific suites (github/gitlab/gitea) are
covered by the ${provider} line; every other shared suite must be listed
explicitly. So adding a suite without wiring CI now breaks the build instead
of silently passing (the original fake-green failure mode).
Replace the standalone check-e2e-suite-registration.mjs (wired into `npm
run lint`) with forward/reverse checks inside scripts/run-e2e.sh itself,
so suite manifest validation lives in the same script CI already calls
instead of a separate Node checker. Also harden GitVerifier.git() to
surface stderr on unexpected git failures while keeping expected
missing-path lookups silent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
vitest's default reporter only prints once a whole file finishes, and
these suites do real network round trips per test — in CI that reads
as a silent hang. Switch to the verbose reporter so each test prints
as it completes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
headAfterFirst was captured before the first push instead of after,
so expectNoCommitSince compared against the pre-push head — failing
on the commit the first push itself legitimately created, not on any
duplicate mutation from the second push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Each remote-read verifier call (getFile/fileMissing/listCommitShas) does
its own `git fetch origin <branch>` even when nothing has mutated the
remote since the last read in the same test — most tests do 3-5 such
reads per push. Cache them in SourceControlScenario, invalidated on any
call to manager.pushFiles/pullFile or service.pushFile/deleteFile.

The invalidation hooks onto the manager/service instances themselves
(via a thin Proxy), not this class's own push()/baseline() wrappers, so
it stays correct even for mutations this class doesn't mediate directly
— e.g. the selection stack's `actionService.push()`, which calls
manager.pushFiles through BoundarySyncWorkspace.

Scoped to source-control-flows.e2e.test.ts only (the sole consumer of
SourceControlScenario); the shared GitVerifier and the other real-provider
suites are untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Missing i18n keys (settings.releaseHistory.name/desc/button) referenced
by settings-implementation.ts's renderReleaseHistorySetting broke
npm run build. Add them to all three locales.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- convergence-assertions.ts: give both path .sort() calls an explicit
  localeCompare comparator instead of relying on default string sort
  (typescript:S2871, reliability bug).
- sync-manager-fixture.ts: suppress typescript:S2245 on the Math.random()
  run-namespace generator with a NOSONAR + comment — it's a test-only
  disambiguator with no security context, not an unsafe PRNG use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f5vayBwAVMfZxBXokTnay
ClaudiaFang and others added 3 commits August 31, 2026 11:55
… static runtime files

Relocates e2e/{config,shim,suites,support} to e2e-tests/provider/ and
replaces scripts/e2e-harness.sh's per-run generation of
obsidian-request-url.ts/window-timers.ts/git-verifier.ts (into
$E2E_RUNTIME_DIR, never committed) with committed static files under
e2e-tests/provider/runtime/ and e2e-tests/provider/support/git-verifier.ts.
GitVerifier now resolves its clone path from $E2E_WORKDIR at call time
instead of a shell-baked constructor default.

Updates scripts/run-e2e.sh, scripts/e2e-suites.txt, vitest.e2e.config.ts,
tsconfig.json, eslint.config.mts, and .github/workflows/ci.yml's
e2e-relevant path filter for the new layout, and extends
tests/ci-workflow.test.ts with contract assertions for it. The path filter
also gains src/logic/sync/** and src/logic/source-control/** (exercised by
the E2E suites but not previously watched) and scripts/e2e-suites.txt /
vitest.e2e.config.ts (previously not path-filtered, so a manifest-only
change could silently skip E2E).

Side effect: removing the generated-file heredoc also removed the file's
only ${var@Q} bash-4-ism, which previously blocked
scripts/e2e-harness.sh provision under macOS system bash 3.2.

Known gap, documented in docs/testing/real-provider-e2e.md and
docs/obsidian-scanner-audit.md: this PR's premise -- that committing these
files under e2e-tests/ (vs. e2e/) is scanner-safe -- is unverified against
the actual Obsidian scanner, and contradicts this repo's own prior audit
finding that the scanner flagged these same APIs regardless of directory.
A real rescan is required before relying on this.

Filed #143 separately for E2E CI retry/tiering
(rate-limit pressure) -- out of scope here, not touched by this commit.

Closes #142

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RQYgJEPDDLNpBxiW7pzibY
…-modified

SyncStatusService.classify() collapsed every two-sided diff into a
direction-blind 'modified', which FileStatusAdapter then mapped to
'local-modified' regardless of which side actually changed. When only the
remote side had moved (the exact shape a second client produces), the
change was routed to the push bucket instead of pull, so it was silently
skipped/conflicted instead of being pulled — caught by the new
two-client-sync E2E suite (P0-1, P0-2) failing against the disposable
Gitea provider in CI.

classify() now takes optional localChanged/remoteChanged facts (derived
from the tracked lastSyncedSha baseline) so it can tell "only remote
changed" apart from "local changed" or "no baseline on record", and emits
the already-modeled but previously unreachable 'remote-modified' status.
FileStatusAdapter, ChangeActionPolicy.canDownload, and ChangeItem's
inline Download button are wired through so a remote-modified row now
routes to pull by default and offers manual download, same as
remote-only.

Also relabels the Source Control "Remote" filter chip to "Incoming" in
all three locales: with remote-modified now reachable, "Remote Changes"
read as "an existing file changed on the remote" and obscured that the
bucket also holds brand-new remote-only files — "Incoming" covers both
without implying a direction that isn't there.
ClaudiaFang and others added 7 commits August 31, 2026 12:34
CI (gitea provider) still failed after the classify() fix: expectConverged
compares trackedPaths(context) (the union of both clients' local paths)
against the remote listing filtered by context.runPrefix, but trackedPaths
itself was never filtered — a real client sync pulls the whole remote
tree, so once a client's local vault also picks up every other suite's
fixtures on the shared disposable-provider branch, the two sides stop
matching by construction. trackedPaths now filters by runPrefix too, per
its own (previously unenforced) doc comment.

Also fixes an off-by-one in P0-3: BatchConflictResolutionModal's
constructor is (app, conflicts, safeCount, ...), so the conflicts array
sits at mock.calls[n][1], not [2] (which is the safeCount number) —
indexing [2] threw `.map is not a function` before the array of
conflicted paths was ever read.
…ture

Last piece of the gitea CI failure: after fixing classification and
convergence scoping, P0-1/P0-2 still failed expectClean — a client's own
just-pushed file stayed 'modified' in its status map instead of flipping
to 'synced'.

In production (main.ts), SyncManager's default-constructed `status` is
threaded into SyncStatusRefreshService as the single shared
SyncStatusService, so a push's SyncMetadataStore.update ->
status.markSynced(path, sha) updates the exact instance the UI reads.
TwoClient instead built refreshService around its own separate `new
SyncStatusService()`, while `newManager()` gave the SyncManager a second,
orphaned instance (also defaulted, since the fixture passes `undefined`
for that constructor param) — so a push's markSynced call landed on an
instance nobody read from, and the row only flipped to 'synced' on the
next full refresh(), which never came before the test's convergence
check. TwoClient now reuses manager.status, matching main.ts.
The real-provider E2E suite (run 33358507732) repeatedly hangs the
two-client-sync P0 tests for exactly their 120s testTimeout with no
error or log output at all — the shim's fetch() call has no network
timeout, so a stalled connection on the CI runner blocks silently
until vitest kills the whole test, indistinguishable from a real
deadlock in the sync logic under test. Bounding the request lets a
stall fail fast with a clear cause instead of masquerading as a hang.
TwoClient's refresh() bypassed vault-folder filtering entirely
(filterFilesByVaultFolder/filterPathByVaultFolder were no-ops) and the
fixture left rootPath/vaultFolder empty, so every refresh() classified
the WHOLE shared branch's remote tree, not just this run's
e2e-tc-<runId>/ namespace. Other suites' leftover fixtures inflated
tree-listing/refresh time and risked the 120s per-test timeout.

Scope both sides via the real production rootPath/vaultFolder model
instead of a test-only filter: createSyncManagerFixture({ scoped: true })
configures the git service's own rootPath (env.ts contexts now accept
one) and settings.vaultFolder to the same e2e-tc-<runId> value, so the
vaultFolder-strip / rootPath-readd round trip cancels out and push/pull
targets stay unchanged while remote-tree classification is actually
scoped. TwoClient's refresh/filter wiring now mirrors main.ts's real
filterFilesByVaultFolder/filterPathByVaultFolder/getNormalizedPath/
getVaultPath instead of bypassing them.

Also adds a fail-fast scope-leakage assertion after every refresh() and
opt-in timing diagnostics (E2E_TIMING_DEBUG=1) around refresh/sync/
baseline, so a future regression or slow run is attributable instead of
surfacing only as a suite timeout.

E2E fixture/support/diagnostics only — no changes to E2E_TEST_TIMEOUT_MS,
retry policy, or production sync code.

npx eslint . — 0 errors (1 pre-existing unrelated warning)
npm run build — passed
npx vitest run — 68 files / 862 tests passed

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SNTkwXym3gvHDn8rhhSPap
Follow-up to the scope-isolation commit, per review feedback:

- Extracted the vaultFolder path-mapping rules (filterPathByVaultFolder/
  filterFilesByVaultFolder/getNormalizedVaultPath/
  getVaultPathFromNormalized) into a pure module
  src/logic/sync/vault-folder-scope.ts, shared by src/main.ts,
  SyncScanner.toRepoPath (both now delegate, behavior unchanged), and
  the E2E TwoClient wiring (now imports the same functions instead of a
  hand-copied duplicate) — production and the E2E fixture can no longer
  silently drift apart on this logic.
- P0-1: removed the redundant second baseline (`other` was baselined
  then immediately treated as "A creates a new file", which was really
  exercising modify, not create); `other` is now a genuine create, one
  fewer real provider push + verifier read.
- P0-2: removed its trailing expectIdempotent + second
  expectTwoClientConvergence — idempotency-under-repeated-sync is
  already covered by P0-1's own expectIdempotent; P0-2's actual contract
  (concurrent edits on different files both survive) is already proven
  by the first convergence check + explicit remote-content assertions.
- convergence-assertions.ts: added captureRemoteSnapshot/RemoteSnapshot
  so expectConverged/expectMetadataConsistent share one getFile-per-path
  + one listFiles instead of each independently re-fetching the same
  remote files; expectTwoClientConvergence now captures once and passes
  it to both.
- Wrapped captureRemoteSnapshot in the existing opt-in timed() helper as
  "remote snapshot (verifier)" for the same E2E_TIMING_DEBUG=1 breakdown.

Not done this round (per plan): no GitLab server-side rootPath listing
optimization, no timeout/retry changes, no production sync semantics
changes — the main.ts/SyncScanner.ts edits are a pure logic-preserving
extraction only.

npx eslint . — 0 errors (1 pre-existing unrelated warning)
npm run build — passed
npx vitest run — 68 files / 862 tests passed

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SNTkwXym3gvHDn8rhhSPap
@ClaudiaFang
ClaudiaFang force-pushed the claude/source-control-foundation branch 6 times, most recently from 5fb4c96 to a589045 Compare August 31, 2026 10:15
@ClaudiaFang
ClaudiaFang force-pushed the claude/source-control-foundation branch from a589045 to ac7b466 Compare August 31, 2026 10:17
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
4.1% Duplication on New Code (required ≤ 3%)
B Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@ClaudiaFang
ClaudiaFang merged commit c13cbae into main Aug 31, 2026
15 of 16 checks passed
@ClaudiaFang
ClaudiaFang deleted the claude/source-control-foundation branch August 31, 2026 11:37
ClaudiaFang pushed a commit that referenced this pull request Sep 1, 2026
## [1.6.0](1.5.9...1.6.0) (2026-09-01)

### Features

* **conflict-modal:** wire SyncDiffService.getConflictStat as the batch conflict diff-stat loader ([246a59f](246a59f))
* **conflict-modal:** wire View Diff data through SyncDiffService ([ec9d356](ec9d356))
* **source-control:** add Phase 2 action service ([70f6c9e](70f6c9e))
* **source-control:** add Phase 3 source control UI ([7cec661](7cec661))
* **source-control:** add push-selection and operation-state foundation ([2d72022](2d72022)), closes [#128](#128)
* **source-control:** add remote-only download action and queue upload/download routing ([f9eb81b](f9eb81b))
* **source-control:** add ViewModel foundation layer ([76db082](76db082))
* **source-control:** full-width diff tab and mobile view-title dedup ([f449125](f449125))
* **source-control:** presentation adapter, diff stat, responsive mobile ([dd8ddd5](dd8ddd5))
* **source-control:** selected-section rows, drop show-synced toggle, colored diff-stat ([853793c](853793c))
* **source-control:** split sync queue/repository regions and extract DiffStatProvider + SelectionController ([9fd1789](9fd1789)), closes [#136](#136) [#135](#135) [#136](#136)
* **source-control:** wire Source Control view as the entry and remove legacy UI ([4e647fb](4e647fb))
* **sync-status:** add refresh and operation feedback ([759b717](759b717))
* **sync-status:** add selection workflow and sync action UI ([625fad2](625fad2))
* **sync:** auto-refresh status on local vault changes and distinguish local deletes ([4009f1d](4009f1d)), closes [#66](#66)
* **whats-new:** add onboarding layout for the Source Control workflow ([88e08dd](88e08dd))

### Bug Fixes

* **ci:** continue after intentionally skipped E2E jobs ([18de6e0](18de6e0))
* **ci:** fold E2E suite registration check into run-e2e.sh ([c2bfeb0](c2bfeb0))
* **ci:** isolate manual E2E concurrency ([b5884fc](b5884fc))
* **ci:** run E2E suites through shared runner ([e9f0d28](e9f0d28))
* **ci:** serialize branch validation workflows ([acd2046](acd2046))
* **conflict-modal:** apply modal sizing CSS and add filename-first rows with progressive diff stats ([ac2bd2a](ac2bd2a))
* **deps:** bump undici and ip-address overrides to patched versions ([44c17ba](44c17ba)), closes [#43-45](#43) [#42](#42) [#34-35](#34)
* **e2e:** bound the requestUrl shim to a 30s timeout ([8d09aad](8d09aad))
* **e2e:** constrain generated runtime imports ([5dcd87e](5dcd87e))
* **e2e:** invalidate SourceControlScenario's remote cache on commitResolvedBatch ([257b2a4](257b2a4))
* **e2e:** replace unsafe dynamic imports and align Obsidian lint ([3e9f709](3e9f709))
* **e2e:** resolve SonarCloud quality gate findings on new code ([38a5d9e](38a5d9e))
* **e2e:** scope two-client convergence checks to the run's own namespace ([2c7a473](2c7a473))
* **e2e:** share the manager's SyncStatusService in the two-client fixture ([120dfe5](120dfe5))
* **i18n:** remove duplicate releaseHistory keys from concurrent fixes ([de55653](de55653))
* **settings:** keep release history accessible after dismiss ([c37e37c](c37e37c))
* **settings:** keep release history accessible after dismiss ([d6cdc36](d6cdc36))
* **source-control:** apply keep-remote-only batch plans and harden resolution tests ([2591e05](2591e05))
* **source-control:** correct mobile queue and diff presentation ([b3720f7](b3720f7))
* **source-control:** correct one-sided diff stat direction ([fbe0787](fbe0787))
* **source-control:** correct status grouping and filter semantics ([0bcc800](0bcc800))
* **source-control:** harden scroll, diff-stat and create lifecycles ([05f6628](05f6628))
* **source-control:** key selection and operation state by ChangeId ([4b09425](4b09425))
* **source-control:** make keep-remote resolution authoritative ([c73c9cc](c73c9cc))
* **source-control:** make sync actions actually sequential and clean up on remote delete ([2dfe78b](2dfe78b)), closes [#129](#129)
* **source-control:** make whole view scroll, add clear-selection, click-to-collapse folders ([a9d3e98](a9d3e98))
* **source-control:** pin Checked Changes, independent scroll for Changes tree ([d7606ff](d7606ff))
* **source-control:** preserve Changes tree scroll position on rerender ([10e344f](10e344f))
* **source-control:** preserve mobile list position after diff ([709905a](709905a))
* **source-control:** prevent duplicate sync status views ([2bbd042](2bbd042))
* **source-control:** refresh the open diff tab when its backing status changes ([10f9ead](10f9ead))
* **source-control:** repair diff stat cache lifecycle ([2d6cf91](2d6cf91))
* **source-control:** route local-deleted to delete-remote, not pull-restore ([ebd8cb6](ebd8cb6)), closes [#129](#129)
* **source-control:** track diff stat requests by generation token ([78a78e8](78a78e8))
* **source-control:** unify sync completion notification ([17b361f](17b361f))
* **sync-status:** preserve refreshed state across live modifications ([49f3033](49f3033))
* **sync:** classify remote-only changes as remote-modified, not local-modified ([264cb47](264cb47))
* **sync:** report added/updated counts in push/pull toasts, i18n them ([b5eb1ae](b5eb1ae))
* **sync:** stop remote-op failures from masking each other's outcome ([02bd3e3](02bd3e3))
* **test:** capture post-push head before asserting no-op repeat push ([039588f](039588f))
* **test:** show per-test progress in real-provider E2E CI logs ([54e3fb7](54e3fb7))

### Performance Improvements

* **ci:** gate and tier real-provider E2E ([b1d2208](b1d2208))
* **test:** memoize remote reads in source-control-flows scenarios ([b9a90b2](b9a90b2))

### Documentation

* add source control refactor roadmap ([5d5645e](5d5645e))
* align guides with source control workflow ([d4ce756](d4ce756))
* **claude:** remove session-handoff references from agent workflow ([a4ccf9f](a4ccf9f))
* **imgs:** add sync-status screenshot ([a07a895](a07a895))
* **progress:** record CI run 33358507732 triage ([9528528](9528528))
* record CI green evidence for the whole-run concurrency ([8c07011](8c07011))
* record final-fix round in session handoff and progress ([17d241c](17d241c))
* record Gitea E2E CI verification ([bf33cd2](bf33cd2))
* record lifecycle hardening round in session handoff and progress ([9eb3713](9eb3713))
* record sync-status-workflow-ui completion in progress + handoff ([8aa8fdf](8aa8fdf))
* record unified sync notification verification ([25dfbc8](25dfbc8))
* restore README demo media ([daf7c81](daf7c81))

### Code Refactoring

* **diff:** extract shared DiffViewer, gfs-conflict-modal shell, and gfs-diff-surface tokens ([769f82d](769f82d))
* **source-control:** converge UI to sync-intent workflow ([639840a](639840a)), closes [#135](#135)
* **source-control:** Selected section becomes a read-only action queue ([7538e0a](7538e0a))
* **source-control:** staged/Changes split with collapsible sections ([60790a3](60790a3))
* **source-control:** unify Sync into one plan, one commit ([264ae4b](264ae4b))
* **sync-status:** integrate source control view model ([8c69cc8](8c69cc8))
* **sync:** compact batch-conflict header and drop totalFiles from the interaction port ([a7dcda7](a7dcda7))
* **test:** move real-provider E2E to e2e-tests/provider/, commit static runtime files ([376901f](376901f)), closes [#143](#143) [#142](#142)
@ClaudiaFang

Copy link
Copy Markdown
Member Author

🎉 This PR is included in version 1.6.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(source-control): add push-selection and operation-state foundation

1 participant