Compare commits
8 Commits
e75e028a63
...
e61e56c21d
| Author | SHA1 | Date | |
|---|---|---|---|
| e61e56c21d | |||
| 60a8ad1f0b | |||
| 0e2fdd959e | |||
| 3eaa697419 | |||
| 007e53628d | |||
| 0b4fdb0abd | |||
| 5194d34180 | |||
| e52239629e |
@@ -5,14 +5,14 @@
|
||||
Current focus:
|
||||
|
||||
- **Completed milestones:** Phase 0–6.2 (all closed), Phase 7 - Stability Hardening (closed), Phase 8 - Rust Transport Expansion (closed), Remote LSP integration track ([#34](https://git.teahaven.kr/sublime-rs/sessions/issues/34), [#35](https://git.teahaven.kr/sublime-rs/sessions/issues/35), [#36](https://git.teahaven.kr/sublime-rs/sessions/issues/36), [#37](https://git.teahaven.kr/sublime-rs/sessions/issues/37) — all closed; `local_bridge lsp-stdio`, persistent broker attach IPC, `session_helper lsp_stdio` supervision, URI rewrite + save barrier, host-scoped install with workspace-scoped env/config). See [`planning/GITEA_ISSUES.md`](planning/GITEA_ISSUES.md).
|
||||
- **Open milestones:** Phase 9 - Quality Gates & Scale ([#10](https://git.teahaven.kr/sublime-rs/sessions/issues/10), [#29](https://git.teahaven.kr/sublime-rs/sessions/issues/29) diff-centric review, [#32](https://git.teahaven.kr/sublime-rs/sessions/issues/32) large-file streaming).
|
||||
- **Execution order (2026-04, Rust-first):** P0.5 stabilization → crate consolidation → artifact publish + manifest/checksum → **[#24](https://git.teahaven.kr/sublime-rs/sessions/issues/24)** Rust runtime ownership → **[#32](https://git.teahaven.kr/sublime-rs/sessions/issues/32)** large-file → **[#29](https://git.teahaven.kr/sublime-rs/sessions/issues/29)** diff-centric product. Normative detail: [`planning/GITEA_ISSUES.md`](planning/GITEA_ISSUES.md) (execution priority and schedule), migration waves: [`planning/PYTHON_RUST_BOUNDARY.md`](planning/PYTHON_RUST_BOUNDARY.md). Distribution-readiness + ownership-migration plan: [`planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md`](planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md).
|
||||
- **Open milestones:** Phase 9 - Quality Gates & Scale ([#10](https://git.teahaven.kr/sublime-rs/sessions/issues/10), [#32](https://git.teahaven.kr/sublime-rs/sessions/issues/32) large-file streaming). [#29](https://git.teahaven.kr/sublime-rs/sessions/issues/29) (diff-centric review) was reframed in the 2026-04-25 distribution review and is **no longer the next feature** — see [`planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md`](planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md) and [`planning/SHIPPED.md`](planning/SHIPPED.md). Track D (in-Sublime agent integration) was dropped 2026-04-27 — see [`planning/BACKLOG.md`](planning/BACKLOG.md).
|
||||
- **Execution order (2026-04, Rust-first):** P0.5 stabilization → crate consolidation → artifact publish + manifest/checksum → **[#24](https://git.teahaven.kr/sublime-rs/sessions/issues/24)** Rust runtime ownership → **[#32](https://git.teahaven.kr/sublime-rs/sessions/issues/32)** large-file → Track G v1 (multi-repo, refs/ fast-path, line-staging polish). #29 diff-centric review/apply is **deprioritized**, not on this order. Normative detail: [`planning/GITEA_ISSUES.md`](planning/GITEA_ISSUES.md) (execution priority and schedule), migration waves: [`planning/PYTHON_RUST_BOUNDARY.md`](planning/PYTHON_RUST_BOUNDARY.md). Distribution-readiness + ownership-migration plan: [`planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md`](planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md).
|
||||
- **P0.5 stabilization (2026-04, closed):** persistent bridge, download-only helper, reconnect, mirror ignore patterns, save conflict UI, wire contract test coverage (bridge stdout fixtures, binary smoke test, ABI smoke test), stability hardening (prune symlink/permission edges, multi-window dedup, refresh race prevention), remote file auto-reload via periodic stat → revert, LSP-ready on-demand fetch via external path mapper + `on_window_command` interceptor.
|
||||
- SSH config driven workspace selection
|
||||
- session-bound helper over SSH stdio
|
||||
- local cache with local-host-independent workspace identity
|
||||
- formatter and linter execution in the remote environment (baseline + #30 pipeline on save)
|
||||
- long-term evolution toward a multi-session agent window (after the MVP above)
|
||||
- ~~long-term evolution toward a multi-session agent window~~ — **frozen 2026-04-27**: the v0.6.0–v0.6.7 in-Sublime agent code (`agent_tmux`, `agent_window_layout`, `agent_switcher_view`, agent palette commands, `tmux`/`claude-code`/`codex-cli` catalog entries) stays in the tree but has no follow-up work; agents now run in an external terminal that the user manages outside Sublime. See [`planning/BACKLOG.md`](planning/BACKLOG.md) Track D.
|
||||
|
||||
## Repository layout
|
||||
|
||||
@@ -30,6 +30,7 @@ Current focus:
|
||||
| [`planning/VSCODE_REMOTE_TRANSPORT_MODEL.md`](planning/VSCODE_REMOTE_TRANSPORT_MODEL.md) | Envelope + logical channels (VS Code–aligned) |
|
||||
| [`planning/REMOTE_DEV_MVP_LSP.md`](planning/REMOTE_DEV_MVP_LSP.md) | Phase 6.2 LSP / tool transport choices |
|
||||
| [`planning/DEEP-RESEARCH-REPORT.md`](planning/DEEP-RESEARCH-REPORT.md) | External audit + **priority reconciliation** (end) |
|
||||
| [`planning/TRACK_G_V1_BIDIRECTIONAL_SYNC.md`](planning/TRACK_G_V1_BIDIRECTIONAL_SYNC.md) | Track G v1 plan: bidirectional `.git` sync redesign (op-log + ref snapshot + `git bundle`, replaces tar-wipe) |
|
||||
|
||||
## Installing In Sublime Text
|
||||
|
||||
|
||||
28
SECURITY.md
28
SECURITY.md
@@ -40,6 +40,34 @@ These are benign — the plugin is simply caching remote files locally and
|
||||
forwarding ports — but the binaries are unsigned local builds, so they have no
|
||||
reputation credit to offset the heuristic.
|
||||
|
||||
## Sync mode (`sessions_sync_mode`)
|
||||
|
||||
The plugin exposes a single product-level knob, `sessions_sync_mode`, that
|
||||
collapses the "first-connect noise" knobs an EDR administrator most often wants
|
||||
to clamp into one named tier:
|
||||
|
||||
- `safe` — quiet first connect for EDR-managed or shared machines. Forces
|
||||
`sessions_mirror_auto_refresh`, `sessions_mirror_include_files`, and
|
||||
`sessions_connect_auto_open_remote_folder` to `false` regardless of their
|
||||
per-key value. The plugin still works, but no periodic background refresh
|
||||
runs, the cache contains directory placeholders only (files materialise on
|
||||
open), and connect does not auto-open the remote folder picker.
|
||||
- `balanced` — historical default. Per-key settings (auto-refresh interval,
|
||||
EDR caps, etc.) take effect unchanged. Recommended for most desktop use.
|
||||
- `full` — same as `balanced` today; reserved for future opt-in "more
|
||||
aggressive" defaults.
|
||||
|
||||
The bandwidth caps that exist independently of the sync mode
|
||||
(`sessions_mirror_max_entries`, `sessions_mirror_max_dir_fanout`,
|
||||
`sessions_mirror_writes_per_second_cap`,
|
||||
`sessions_mirror_auto_prune_stale_cache: false`) still apply in every mode.
|
||||
Picking `safe` is a strict superset of those caps for the periodic and
|
||||
auto-open paths.
|
||||
|
||||
For policy distribution: shipping `Packages/User/Sessions.sublime-settings`
|
||||
with `"sessions_sync_mode": "safe"` is enough to neutralise the three
|
||||
auto-on behaviours without touching individual per-key settings.
|
||||
|
||||
## What the binaries do NOT do
|
||||
|
||||
- Do NOT modify, encrypt, or delete files outside the plugin's own cache root
|
||||
|
||||
@@ -201,6 +201,14 @@ v1 scope: nested repos, submodules, multi-repo workspaces, LFS,
|
||||
packed-`.git` reconcile fast-path, untracked-not-ignored lazy fetch
|
||||
polishing, automatic reconcile loop replacing the manual command.
|
||||
|
||||
**v1 architecture plan**: see
|
||||
[`TRACK_G_V1_BIDIRECTIONAL_SYNC.md`](TRACK_G_V1_BIDIRECTIONAL_SYNC.md) for
|
||||
the full audit + redesign — op log + ref snapshot at every refresh,
|
||||
`git bundle` over the existing bridge replacing the tar-wipe, and
|
||||
conflict-copy semantics for diverged refs/files. Replaces the
|
||||
wipe-and-replace `.git` sync with a CAS-guarded refspec model so
|
||||
local-only branches and unpushed commits survive every refresh.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- GitLens-style inline blame / hover annotations. Sublime Text UI
|
||||
@@ -238,6 +246,133 @@ Final integration agent wires Sublime Merge launch + the manual
|
||||
|
||||
---
|
||||
|
||||
## Track H — Rust ownership migration (Python monolith reduction) — **[opened 2026-04-29]**
|
||||
|
||||
The 2026-04 distribution review (external) flagged that the current
|
||||
shape is closer to "Python calls Rust a lot" than "Rust owns the hot
|
||||
paths" — `commands.py` (7379 LOC), `ssh_file_transport.py` (2240),
|
||||
`_rust_ffi.py` (1337) still carry runtime ownership Python should not.
|
||||
Track H stops the helper-migration cadence and shifts to *ownership*
|
||||
migration. It implements the concrete sub-tracks behind
|
||||
[`PYTHON_RUST_BOUNDARY.md`](PYTHON_RUST_BOUNDARY.md) Wave 2–3 and the
|
||||
[REVIEW_v0_6_4_DISTRIBUTION_PLAN](REVIEW_v0_6_4_DISTRIBUTION_PLAN.md)
|
||||
"Stage 4 ownership" line.
|
||||
|
||||
User-visible behaviour does not change inside Track H. Anything that
|
||||
adds new wire format or new commands belongs to a different track.
|
||||
|
||||
### H1. `open_remote_file_into_local_cache()` → Rust runtime API
|
||||
|
||||
**[file]** `sublime/sessions/ssh_file_transport.py`,
|
||||
`sublime/sessions/_rust_ffi.py`, `rust/crates/local_bridge/src/`,
|
||||
`rust/crates/sessions_native/src/`
|
||||
|
||||
**[conflict with]** Track G v1 (working-tree materialiser shares the
|
||||
read path), M3 (auto-format race lives in the same save flow).
|
||||
|
||||
**[done-when]** Python `open_remote_file_into_local_cache()` shrinks
|
||||
to a thin wrapper around one Rust call; remote read → open guardrail
|
||||
→ local cache write happens inside one Rust transaction. Target:
|
||||
`ssh_file_transport.py` < 1500 LOC. Pairs with Gitea #24 / #27.
|
||||
|
||||
First-PR scope:
|
||||
1. New Rust module (`local_bridge::file_open` or
|
||||
`sessions_native::runtime::file_open`) that bundles the existing
|
||||
`sessions_file_open_guard_reason`, the bridge `file/read`, and the
|
||||
cache write into a single function returning a structured outcome.
|
||||
2. Python wrapper in `_rust_ffi.py` that calls the new ABI; the
|
||||
pre-existing Python implementation is **deleted in the same PR**
|
||||
(single-source-of-truth rule from `PYTHON_RUST_BOUNDARY.md`).
|
||||
3. Save / reload / hydrate / stale-refresh call sites become thin
|
||||
wrappers — the transaction is owned by Rust.
|
||||
4. Regression coverage: `test_remote_file_metadata`,
|
||||
`test_eager_hydrate`, `test_cmd_save`, `test_file_pipeline` pass
|
||||
against the new path.
|
||||
|
||||
Risk: save-conflict UI and the save barrier currently live in Python
|
||||
(Sublime UI thread). Pulling the *decision* into Rust would force a
|
||||
new sync surface; the first PR keeps the decision (warning popup) in
|
||||
Python and only moves guardrail + read + write.
|
||||
|
||||
### H2. `commands.py` service split + module-global state reduction
|
||||
|
||||
**[file]** `sublime/sessions/commands.py` (7379 LOC today), new
|
||||
`sublime/sessions/commands_*.py` modules (the
|
||||
`commands_file_actions.py` / `commands_python_pipeline.py` pattern is
|
||||
already established).
|
||||
|
||||
**[conflict with]** H1 (the save / reload / hydrate sites are touched
|
||||
by H1 too — bundle them in the same PR or H1 will land first), Track
|
||||
G (commands.py hosts much of the git track wiring).
|
||||
|
||||
**[done-when]** `commands.py` < 4000 LOC; six service modules
|
||||
(connect / sync / git / lsp / save / terminal) each own their state;
|
||||
at least half of the module-globals (`_BACKGROUND_PENDING_KEYS`,
|
||||
`_HYDRATE_IN_FLIGHT`, `_MIRROR_AUTO_REFRESH_*`,
|
||||
`_OPEN_FILE_WATCH_WINDOWS`, …) become service-local.
|
||||
|
||||
First-PR scope: extract the **save** service into
|
||||
`commands_save.py` (save / barrier / conflict UI + the related state
|
||||
keys: `_OPEN_REQUEST_SERIAL_BY_WORKSPACE`, `_HYDRATE_REVERT_COOLDOWN`,
|
||||
…). Regression coverage from `test_cmd_save`, `test_cmd_auto_reload`,
|
||||
`test_save_*`. Connect/mirror/git/lsp services follow in their own
|
||||
PRs.
|
||||
|
||||
Risk: naive file split easily creates import cycles. Mitigation:
|
||||
move state and helpers **into the service module** rather than
|
||||
re-export from `commands.py`; allow only `service module → commands`
|
||||
direction in imports, never the reverse.
|
||||
|
||||
### H3. Background queue / mirror queue / open-file watch / auto-reconnect → Rust broker
|
||||
|
||||
**[file]** `sublime/sessions/commands.py` (queue/worker/watch
|
||||
functions), `sublime/sessions/_rust_ffi.py` (broker FFI),
|
||||
`rust/crates/sessions_native/src/broker*.rs`,
|
||||
`rust/crates/local_bridge/`.
|
||||
|
||||
**[conflict with]** H1 (open-file watch shares the read path), H2
|
||||
(landing the commands split first makes this PR much smaller).
|
||||
|
||||
**[done-when]** `_BACKGROUND_TASK_QUEUE`, `_MIRROR_TASK_QUEUE`,
|
||||
`_OPEN_FILE_WATCH_*`, and the auto-reconnect thread no longer exist
|
||||
in Python or are reduced to a status-callback hook on Rust broker
|
||||
events. The boundary doc's "multiplexed stdio / channel supervisor"
|
||||
responsibility is owned by Rust.
|
||||
|
||||
First-PR scope: auto-reconnect thread → Rust broker. The
|
||||
`sessions_broker_*` FFI (open_session, reset, handshake, is_active)
|
||||
already exists; broker drives health probing, Python only receives
|
||||
the status callback. Regression coverage:
|
||||
`test_bridge_lifecycle`, `test_connect_workflow`, the
|
||||
reconnect-specific test cases.
|
||||
|
||||
Risk: moving the queue (later PRs) changes the meaning of the
|
||||
generation token / connect-preempt rule (the `disciscard` typo from
|
||||
2026-04-29 lived in this exact area). Mitigation: the first PR moves
|
||||
*the thread*, not the queue. Queue semantics stay identical until a
|
||||
follow-up PR explicitly re-derives them on the Rust side.
|
||||
|
||||
### Dependency graph (Track H)
|
||||
|
||||
```
|
||||
H1 ──▶ H2-save (save service is a thin wrapper after H1)
|
||||
H1 ──▶ H3 (open-file watch sits on H1's ownership boundary)
|
||||
H2-save ──▶ H3-reconnect (status callbacks land cleanly into a service)
|
||||
```
|
||||
|
||||
Recommended PR order: H1 → H2-save → H3-reconnect → H2-connect →
|
||||
H3-queue → H2-mirror → H3-mirror-queue.
|
||||
|
||||
### Out of scope (Track H)
|
||||
|
||||
- New features, new ABI / protocol / wire format. Track H is
|
||||
**ownership only**; user-visible behaviour must not change inside
|
||||
the track.
|
||||
- Cosmetic clean-up of Python wrappers. That belongs to a separate
|
||||
PR after Track H lands.
|
||||
|
||||
---
|
||||
|
||||
## Track W — Windows parity (surfaced by the v0.6.0/v0.6.1 test pass)
|
||||
|
||||
*Several features rely on POSIX assumptions; v0.6.1 patched the
|
||||
|
||||
271
planning/TRACK_G_V1_BIDIRECTIONAL_SYNC.md
Normal file
271
planning/TRACK_G_V1_BIDIRECTIONAL_SYNC.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Track G v1 — Bidirectional `.git` Sync
|
||||
|
||||
**Status:** Draft plan, post-v0.7.23. Authored from a code audit + external-tool methodology survey.
|
||||
|
||||
**Symptom triggering this plan** (verbatim from `test.log`):
|
||||
|
||||
> Sublime Merge에서 만든 로컬 `test` 브랜치는 살아 있는데 **remote에는 전파되지 않음**.
|
||||
|
||||
The user's framing: 단순 양방향 sync로는 race condition을 못 풀고 한쪽이 다른쪽을 덮어쓰니, 협업 에디터들의 방법론을 차용하자.
|
||||
|
||||
> Note: the Terminus pane-survival diagnosis that originally accompanied this
|
||||
> audit was landed separately as commit `0e2fdd9`
|
||||
> (`fix(sublime/terminal): pin stdio to /dev/tty + auto_close=False`). This
|
||||
> document is now scoped to bidirectional `.git` sync only.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 1. What the audit found (concrete)
|
||||
|
||||
Current Track G v0 architecture (commands.py:7115+):
|
||||
|
||||
```
|
||||
on every mirror sync.done:
|
||||
for each discovered repo:
|
||||
1. read post-checkout marker → git checkout <new_head> on remote
|
||||
2. probe remote ref fingerprint; skip if unchanged
|
||||
3. tar -czf .git | base64 → WIPE local .git → untar
|
||||
4. re-install post-checkout hook
|
||||
5. materialise dirty working-tree files
|
||||
```
|
||||
|
||||
Three classes of problem with this model:
|
||||
|
||||
### A. Hook-based op capture is unreliable in our environment
|
||||
|
||||
`windows.log` evidence: every `git.checkout_proxy` event in the trace shows `proxied: false` — **the post-checkout hook never fired during the user's `test`-branch reproduction**. There are three plausible explanations and they're not mutually exclusive:
|
||||
|
||||
- **A1.** Sublime Merge uses libgit2 internally, and **libgit2 does not invoke client-side hooks by default.** This means a fundamental class of user actions performed via Sublime Merge — `git checkout`, `git checkout -b`, branch deletes — never hit our hook. **If true, the entire marker-based mechanism is dead-on-arrival for our primary user.**
|
||||
- **A2.** `post-checkout` only fires on checkout; `git branch -d`, `git branch -m`, and `git commit` never trigger it regardless of front-end. Failure modes #2 (delete), #4 (commit) are uncovered by design.
|
||||
- **A3.** Multiple checkouts in quick succession overwrite the single per-repo marker file (`git_branch_proxy.py` keeps one marker per repo); intermediate states are lost.
|
||||
|
||||
**A1 is the load-bearing finding.** Before any plan that depends on hooks, we have to verify it. But we should plan as if it's true, because the alternative — building a working hook around libgit2 — is harder than removing the dependency on hooks entirely.
|
||||
|
||||
### B. Wipe-and-replace is structurally hostile to local writes
|
||||
|
||||
`git_dot_git_sync.py:194` — every fetch tick removes the entire local `.git` (preserving only `SESSIONS_PENDING_CHECKOUT`) and replaces it with the remote tarball. Anything the local user wrote into `.git` between fetches that isn't on the preserved-files list is destroyed:
|
||||
|
||||
- A branch ref the user created locally that doesn't exist on remote yet (failure mode #1).
|
||||
- A commit object Sublime Merge wrote locally that hasn't been pushed (failure mode #4).
|
||||
- Stash entries, reflog entries, refs/notes entries.
|
||||
|
||||
The v0.7.23 mirror-boundary fix prevents the *outer* mirror from pruning `.git`, but the *inner* tar replace still does the same damage. This is the single biggest correctness hole.
|
||||
|
||||
### C. No three-way diff over ref state
|
||||
|
||||
Track G has no memory of "what the local refs looked like at the end of the last successful refresh." Without that, it can't tell:
|
||||
|
||||
- Did the user create `refs/heads/test` locally? Or did remote have it last time and we just lost it?
|
||||
- Did the user delete `refs/heads/feature/old`? Or is it just absent from the remote and we should let it be?
|
||||
|
||||
Every failure mode reduces to "we couldn't tell who changed what since the last sync."
|
||||
|
||||
## 2. What we steal from the methodology survey
|
||||
|
||||
The survey (full report in research notes) covered Git refspecs, VS Code/Zed/Gateway remote-dev, CRDTs, OT, file-sync conflict copies, and Jujutsu. The honest landings:
|
||||
|
||||
- **CRDTs**: wrong tool. Ref state is a CAS-on-pointers problem under structural constraints, not a free-form text merge. Adopting Automerge here multiplies storage and replaces a tractable problem (Git already solved it) with an intractable one (semantic merge of pointer values).
|
||||
- **Headless backends (VS Code Server, Zed Headless, JetBrains Backend)**: foreclosed. Sublime Merge is a separate native app that wants a real on-disk `.git`; the whole reason we have a local mirror is to feed it. The headless answer would invalidate the project.
|
||||
- **OT**: the algorithm doesn't apply (refs aren't a stream of insert/delete ops), but the **central-arbitrator pattern does** — and we already have one (the remote box's `.git`).
|
||||
- **Git's own model**: directly applicable. Two clones of the same repo never silently overwrite each other because of refspec namespacing + fast-forward checks + `--force-with-lease`. We are reinventing this badly.
|
||||
- **File-sync conflict copies (Syncthing/Dropbox)**: directly applicable for the working-tree edge cases.
|
||||
- **Jujutsu's operation log**: directly applicable as the foundation we're missing.
|
||||
|
||||
## 3. The redesign — three changes, in dependency order
|
||||
|
||||
### Change #1 — Op log + ref snapshot at every refresh boundary *(foundation)*
|
||||
|
||||
Promoted from "safety net" to foundation because of finding A1: without reliable hooks, **we have to detect ref-state changes by polling**, and polling needs a baseline.
|
||||
|
||||
Add a sessions-owned sidecar under each repo: `.git/sessions/op-log.jsonl` and `.git/sessions/last-snapshot.json`. The snapshot stores `{ref_name → sha}` and the symbolic `HEAD` target for both local and remote at the end of the last successful refresh.
|
||||
|
||||
```text
|
||||
each refresh tick (per repo):
|
||||
before = read_snapshot() # {local: {refs}, remote: {refs}}
|
||||
local_now = read_local_refs() # cheap: walk refs/heads/*
|
||||
remote_now = exec(host, "git for-each-ref ... ; HEAD") # cheap: one exec/once
|
||||
|
||||
diff = three_way(before, local_now, remote_now)
|
||||
apply(diff) # ← Changes #2 + #3
|
||||
write_snapshot({local: local_now, remote: remote_now})
|
||||
append op_log({ts, diff, actions, errors})
|
||||
```
|
||||
|
||||
The diff classifies every ref into one of:
|
||||
|
||||
- `unchanged` — both sides match the snapshot. Skip.
|
||||
- `local_only_new` — local has it, remote doesn't, snapshot didn't have it on either. **User created.** Action in Change #2.
|
||||
- `local_only_deleted` — snapshot had it on both, neither has it now. (Edge case — only happens if user deleted on both sides between ticks.)
|
||||
- `local_deleted` — snapshot had it on local, local doesn't. **User deleted.** Action in Change #2.
|
||||
- `remote_only_new` — remote has it, local doesn't, snapshot didn't have it. **Remote teammate created.** Mirror into local.
|
||||
- `remote_deleted` — snapshot had it on remote, remote doesn't. Mirror local prune.
|
||||
- `local_advanced` — local SHA is descendant of snapshot SHA, remote SHA == snapshot SHA. **User committed.** Action in Change #2.
|
||||
- `remote_advanced` — same on remote side. Fast-forward local.
|
||||
- `diverged` — both sides moved differently. Surface to user; do nothing automatic. Action in Change #3.
|
||||
|
||||
Op log is append-only JSONL, rotated at N=1000 lines or 30 days. Gives us a "Sessions: Undo Last Sync" command that walks the most recent entry and restores ref state via `git update-ref`. Critically: it gives us **debuggability** — when refs vanish, we know which tick wiped them.
|
||||
|
||||
**Invariants:**
|
||||
|
||||
- Every ref-mutating action writes to the log *before* the action (write-ahead).
|
||||
- The log lives under `.git/sessions/` so git itself ignores it.
|
||||
- Snapshots are atomic: write to `last-snapshot.json.tmp`, fsync, rename.
|
||||
- **The whole `read snapshot → diff → apply → write snapshot` sequence runs under a per-repo flock on `.git/sessions/refresh.lock`.** Sessions stacks overlapping refresh ticks (the `mirror_queue` evidence in `windows.log` shows multiple `dequeue` events for the same workspace within the same second); without the lock, two ticks read the same baseline, both compute "local_only_new" for the same ref, both call `update-ref` with the same `expected_old`, the second's CAS fails, and the diff classifier treats it as divergence — false-positive UI noise that trains users to dismiss real divergence. The lock is `fcntl.flock(LOCK_EX | LOCK_NB)`; on contention skip the tick (the next one picks up the new state). This is *not* deferred to v1+; it's part of Change #1 itself.
|
||||
|
||||
**On "undo".** The op log enables a forensic command — `Sessions: Show Last Sync` — that displays the previous tick's diff and resulting ref state side-by-side, lets the user copy SHAs, and offers a *local-only* "restore local refs from snapshot" action. It does **not** undo remote-side changes that have already been pushed (those may have been built on by other consumers; rolling them back via `--force-with-lease` is a separate user-driven decision, not a button in the editor). The naming reflects this: forensic + local-restore, not "undo." If users need remote rollback they run `git push --force-with-lease` themselves with the SHA the readout gave them.
|
||||
|
||||
### Change #2 — Replace tar wipe with `git bundle` over the existing bridge *(eliminates the wipe)*
|
||||
|
||||
Borrow Git's own model. After Change #1's diff classifies what happened, perform the actual sync via Git primitives instead of tar-replace.
|
||||
|
||||
**Transport choice.** The Rust bridge today is `exec/once` only — single round-trip `argv → {exit_code, stdout, stderr}`. There is no streaming/duplex endpoint. That rules out `git fetch ssh://host/path` *through the bridge* (pack-protocol needs a duplex pipe), and it rules out `git fetch ssh://...` running its own SSH child too — that path would respawn `ssh` outside the bridge's ControlMaster on every refresh, regressing the v0.7.21 askpass-flash fix and racing the bridge's auth state.
|
||||
|
||||
The right primitive is **`git bundle`**:
|
||||
|
||||
- `git bundle create - <refspec>` packs refs + objects into a single self-contained file written to stdout. Fits the existing `exec/once` shape (one argv, one stdout payload, one timeout) — exactly what we already use for the `tar -czf .git | base64` path, just with a vastly smaller payload because bundles only contain the *requested* refs plus reachable objects.
|
||||
- Bundles support **incremental ranges**: `git bundle create - <new_sha> ^<last_seen_sha>` writes only objects new since the snapshot. Steady-state bandwidth drops from "26 MB tar" to "kilobytes of new commits."
|
||||
- Local apply: `git bundle unbundle <file>` reads the bundle and writes new objects + advances the named refs. No streaming required either way.
|
||||
|
||||
```text
|
||||
on remote (one exec/once per refresh):
|
||||
set sessions-scoped config (idempotent, one-time per repo):
|
||||
git config receive.denyCurrentBranch updateInstead
|
||||
for each ref in diff.local_only_new ∪ diff.local_advanced:
|
||||
# Send local commits + ref to remote. Reuse `git bundle` in the
|
||||
# other direction: build bundle locally, ship to remote, unbundle.
|
||||
local: git bundle create - <local_sha> ^<snapshot_sha_or_empty>
|
||||
| base64 -w0 → tx
|
||||
remote (via exec/once):
|
||||
printf %s "<bundle_b64>" | base64 -d | git -C <root> bundle unbundle /dev/stdin <ref>
|
||||
git update-ref -m "sessions sync" refs/heads/<name> <local_sha> <snapshot_sha> # CAS
|
||||
for each ref in diff.local_deleted:
|
||||
remote: git update-ref -d refs/heads/<name> <snapshot_sha> # CAS
|
||||
for the active HEAD checkout (the post-checkout case):
|
||||
if user moved HEAD locally: git -C <root> checkout <new_head> # current behaviour, kept
|
||||
|
||||
on local (replaces the tar pull):
|
||||
remote (one exec/once):
|
||||
git -C <root> bundle create - --branches \
|
||||
$(for r in <changed_refs>; do printf '^%s ' "<snapshot_sha_for_$r>"; done)
|
||||
| base64 -w0
|
||||
local:
|
||||
base64 -d | git -C <local-mirror> bundle unbundle /dev/stdin
|
||||
# bundle wrote into refs/heads/* directly per the bundle's ref names — undesirable.
|
||||
# Use --map-refs or rewrite: bundle creates with the source ref name; we want
|
||||
# them under refs/sessions/<host>/heads/*. Fix: bundle uses fully-qualified
|
||||
# ref names, so on the remote side rewrite the bundle's ref list to
|
||||
# refs/sessions/<host>/heads/* before piping. (`git bundle` accepts
|
||||
# "refs/heads/foo" or any other refname; emit them as
|
||||
# "refs/sessions/<host>/heads/foo" by passing explicit names.)
|
||||
for each ref in diff.remote_only_new ∪ diff.remote_advanced:
|
||||
git update-ref refs/heads/<name> refs/sessions/<host>/heads/<name> # only if local is ancestor (FF)
|
||||
for each ref in diff.remote_deleted:
|
||||
git update-ref -d refs/heads/<name> <snapshot_sha> # CAS
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The `refs/sessions/<host>/heads/*` namespace gives Sublime Merge an explicit, separate view of the remote tracking refs. It also means we never write into `refs/heads/*` except through fast-forward/CAS, so user-created branches survive every refresh by construction.
|
||||
- `refs/heads/*` becomes user-territory; the sync layer only **proposes** changes there via the diff classifier. Fast-forwards apply automatically; divergence surfaces to UI (Change #3).
|
||||
- `--force-with-lease`-equivalent for ref updates: `git update-ref -m "sessions sync" <ref> <new_sha> <expected_old_sha>`. Atomic CAS primitive. If the expected-old check fails (someone moved the ref between our snapshot and our update), abort and treat as `diverged`.
|
||||
- **Initial seed.** First sync after migration: snapshot is empty, bundles are full ref histories. Same one-shot cost as the v0 tar pull, never repeated. Backfill `refs/sessions/<host>/heads/*` from this first bundle.
|
||||
|
||||
**`updateInstead` is not a free pass.** It updates the working tree only when the index and worktree match the new commit's tree on the paths being updated; on dirty conflict the push is rejected. So even with the config flipped, a remote with edits in flight on the active branch refuses our update. Explicit handling:
|
||||
|
||||
- The CAS-guarded ref update writes the proposed `new_sha` into the remote's ref store regardless of working-tree state — `update-ref` doesn't touch the worktree.
|
||||
- The *separate* working-tree update (the "make the worktree reflect the new HEAD" step, equivalent to `git checkout`) is the part that fails on dirty trees. That's the existing G6 path.
|
||||
- Therefore: split the proxy into ref-mutation (always proceeds via CAS) and worktree-mutation (subject to dirty-tree rejection, retried on next tick). When the worktree update is deferred, the ref already advanced — `for-each-ref` reports the new tip, the local mirror sees it on the next refresh, but the remote *worktree* still shows the old contents until the user resolves dirty state. Surface this state explicitly: status bar `"Branch advanced; remote worktree out of sync (dirty): <files>"`.
|
||||
|
||||
**Failure modes addressed.** This Change kills failure modes #1 (local-only branches survive — they live in `refs/heads/*` which is never wiped), #2 (deletion is detected via the diff and propagated via CAS-guarded `update-ref -d`), #3 (CAS via `expected_old_sha` rejects concurrent moves), and #4 (commit objects are bundled and unbundled before any clobber risk). Failure mode #5 stays for Change #3.
|
||||
|
||||
### Change #3 — Conflict-copy semantics + divergence UI *(closes the working-tree edge case)*
|
||||
|
||||
Two narrow additions for the cases Change #2 surfaces but doesn't auto-resolve:
|
||||
|
||||
```text
|
||||
during materialise(file):
|
||||
if local.mtime > last_fetch.mtime
|
||||
and hash(local) != hash(remote)
|
||||
and hash(local) != hash(last_fetched_remote_for_this_path):
|
||||
write remote bytes to <file>.sessions-conflict-<ts>
|
||||
leave <file> alone
|
||||
enqueue notification
|
||||
|
||||
during reconcile_ref where diff == "diverged":
|
||||
status bar:
|
||||
"Branch <name> diverged: local=<short_sha> remote=<short_sha>.
|
||||
Run `Sessions: Resolve Diverged Refs` to choose."
|
||||
command-palette resolution prompt: [Keep local | Take remote | Open Sublime Merge]
|
||||
```
|
||||
|
||||
`<file>.sessions-conflict-<ts>` is added to `.gitignore` automatically by Sessions (one-time append on first conflict). Resolution is always user-driven; the sync layer never auto-resolves a divergence.
|
||||
|
||||
## 4. What we explicitly do *not* do
|
||||
|
||||
- **No CRDT for refs.** Wrong tool, wrong constraints.
|
||||
- **No CRDT for working-tree text.** Sublime doesn't expose buffer state as a manipulable structure; we'd be shipping a parallel editor. Conflict-copy is the right depth.
|
||||
- **No headless backend.** Foreclosed by Sublime Merge's local-`.git` requirement.
|
||||
- **No live ref polling between refresh ticks.** The existing refresh cadence is good enough; adding an inotify or filesystem watcher is scope-creep until we have a concrete user complaint about latency.
|
||||
- **No replacement of the post-checkout hook proxy.** Keep it as a *latency optimisation* — when it does fire (real `git` binary, e.g., user runs `git checkout` in a terminal against the local mirror), the marker gives us sub-second response. When it doesn't fire (libgit2 inside Sublime Merge), the polling diff in Change #1 catches it on the next tick. Belt + suspenders.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phased delivery
|
||||
|
||||
| Phase | Scope | Ships fixes for |
|
||||
|------|------|---|
|
||||
| **A0** | Verify finding A1: does Sublime Merge fire client-side hooks? See §5.1 protocol below | (decides A1+ rationale) |
|
||||
| **A1** | Change #1 — op log + snapshot. Pure addition; no behaviour change. Lets us see what's happening. | Debuggability, not user-visible |
|
||||
| **A2** | Change #2 — refspec sync replaces tar wipe. Largest single change. | Failure modes #1, #2, #3, #4 |
|
||||
| **A3** | Change #3 — conflict copies + divergence UI | Failure mode #5, makes A2's diverged-branch case actionable |
|
||||
|
||||
A0 must complete before A2 design is finalised (it changes the rationale, not the design). A1 ships first because it's pure addition with no risk. A2 + A3 ship together because A3 closes the UX hole A2 opens.
|
||||
|
||||
### 5.1 A0 verification protocol
|
||||
|
||||
Sublime Merge has multiple branch-mutation entry points and may use different code paths for each (libgit2 vs shell-out can vary by operation, by platform, and by Sublime Merge version). A one-bit "did we see a marker" answer doesn't generalise. Run the matrix:
|
||||
|
||||
- **Sublime Merge build to test against:** the latest stable on the user's primary platform. Record the build number in the report.
|
||||
- **Setup per repo:** `install_post_checkout_hook` writes the v0 hook; tail `<.git>/SESSIONS_PENDING_CHECKOUT` and the hook's stderr (redirect via `exec 2>>/tmp/sessions-hook-trace.log` in the hook).
|
||||
- **Operations to exercise** (in order, fresh marker between each):
|
||||
1. Branch checkout — sidebar double-click on an existing branch.
|
||||
2. Branch checkout — command palette `Switch Branch`.
|
||||
3. Branch checkout — context menu on a commit, "Checkout Commit."
|
||||
4. Branch create — sidebar "New Branch" dialog.
|
||||
5. Branch create — `git checkout -b` from the embedded terminal (control: this *must* fire the hook; if it doesn't, the hook itself is broken, not Sublime Merge).
|
||||
6. Branch delete — sidebar right-click "Delete."
|
||||
7. Commit — stage + commit a small change.
|
||||
8. Push — push that commit.
|
||||
- **Per-operation record:** marker file present (Y/N), marker contents (paste verbatim if Y), hook stderr (paste).
|
||||
|
||||
Outcomes that change the plan:
|
||||
|
||||
- Hook fires for ops 1–4: A1 is *partially* false; we have a real ops-capture channel for the user's primary path. Plan rationale shifts but Change #1 (polling diff) is still valuable as backstop for delete/commit/push.
|
||||
- Hook fires only for op 5 (the control): A1 is true for Sublime Merge entirely; Change #1 becomes the sole capture mechanism, hook stays for terminal users only.
|
||||
- Hook fires for none, including op 5: the hook installation itself is broken; investigate that *first* before any A1 conclusion.
|
||||
|
||||
---
|
||||
|
||||
## 6. Risks & open questions
|
||||
|
||||
1. **A0 outcome.** If Sublime Merge *does* fire hooks (we were wrong about libgit2), Change #1's polling diff is still a strict improvement, but the urgency drops. Plan stays the same; rationale shifts.
|
||||
2. **`receive.denyCurrentBranch=updateInstead` surprise.** Mutates the user's remote git config. Mitigation: scope per-repo, surface a one-time notification, document in release notes, support opt-out (fall back to current `git checkout` proxy).
|
||||
3. **Object-pack push size.** First sync after adopting Change #2 will push any local-only commits the user accumulated under v0. Could be tens of MB. Mitigation: gate behind a dry-run + confirm.
|
||||
4. **Migration from existing wiped-and-restored `.git` directories.** Some installs will have `refs/sessions/<host>/*` empty until the first Change #2 fetch. Backfill on first run; idempotent.
|
||||
5. **Worktree (`.git` file) repos** — still v1+, deferred. Track G v0 already filters these out (`commands.py:7167`). No regression.
|
||||
6. **Op-log size on busy repos** — refs/heads/* with thousands of entries × N refresh ticks. Mitigation: log only the *diff* (typical: 0–3 entries per tick), rotate at 1000 lines.
|
||||
7. **Concurrent Sessions instances on the same workspace** — two editors open against one host. Today: undefined. Post-A2: each instance's per-repo flock (Change #1 invariant) serialises refresh ticks within an editor; cross-editor contention is also covered because flock is at the OS level on the same `.git/sessions/refresh.lock` file. The losing instance skips its tick and picks up state on the next one.
|
||||
|
||||
8. **Critic adjudication notes (post-review).** This plan was reviewed adversarially before sign-off. The top issue raised — "Change #2's transport story is incoherent" — is addressed by switching from `git fetch ssh://...` to `git bundle` over the existing `exec/once` bridge (§3 Change #2 transport choice). Other significant issues addressed inline: `denyCurrentBranch=updateInstead` on dirty trees (§3 "`updateInstead` is not a free pass"), concurrent refresh atomicity promoted from v1+ to v1 invariant (§3 Change #1 invariants, risk #7), A0 verification protocol made explicit (§5.1), "Undo Last Sync" renamed to forensic "Show Last Sync" (§3 Change #1, "On undo"). Outstanding from the review: bandwidth estimate for `for-each-ref` polling (low priority — order-of-magnitude analysis can land with the A1 implementation; if a thousand-ref repo crosses 100 KB/tick we'll add response compression).
|
||||
|
||||
---
|
||||
|
||||
## 7. Why this is shippable
|
||||
|
||||
- A1 is a pure addition (no behaviour change). Ships behind a feature flag, dark-launches the diff classifier.
|
||||
- A2's footprint replaces `git_dot_git_sync.py:_replace_local_dot_git` (one ~100-line function) with a `git fetch` invocation + a small reconciler. The total spec is **smaller** than what we have.
|
||||
- A3 is two narrow additions, both cheap.
|
||||
- Every change is independently reversible: feature flag at the workspace-state level, fall back to v0 tar-wipe for the duration of a release if A2 ships broken.
|
||||
|
||||
The single most important sentence in this plan: **stop wiping `.git`.** Every other recommendation flows from that, and from the realisation that hooks are a latency optimisation, not the primary ops capture.
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "sessions-sublime"
|
||||
version = "0.7.23"
|
||||
version = "0.7.24"
|
||||
description = "Sublime-facing Python code for Sessions."
|
||||
requires-python = ">=3.8"
|
||||
license = {text = "MIT"}
|
||||
|
||||
12
rust/Cargo.lock
generated
12
rust/Cargo.lock
generated
@@ -221,7 +221,7 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "local_bridge"
|
||||
version = "0.7.23"
|
||||
version = "0.7.24"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"glob",
|
||||
@@ -432,7 +432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "session_helper"
|
||||
version = "0.7.23"
|
||||
version = "0.7.24"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"notify",
|
||||
@@ -443,7 +443,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "session_protocol"
|
||||
version = "0.7.23"
|
||||
version = "0.7.24"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"serde",
|
||||
@@ -452,14 +452,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sessions_askpass"
|
||||
version = "0.7.23"
|
||||
version = "0.7.24"
|
||||
dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sessions_native"
|
||||
version = "0.7.23"
|
||||
version = "0.7.24"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"session_protocol",
|
||||
@@ -770,7 +770,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "workspace_identity"
|
||||
version = "0.7.23"
|
||||
version = "0.7.24"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
version = "0.7.23"
|
||||
version = "0.7.24"
|
||||
authors = ["Myeongseon Choi <key262yek@gmail.com>"]
|
||||
repository = "https://git.teahaven.kr/sublime-rs/sessions"
|
||||
homepage = "https://git.teahaven.kr/sublime-rs/sessions"
|
||||
|
||||
@@ -693,6 +693,82 @@ fn broker_open_session_rejects_malformed_extra_env_json() {
|
||||
assert_eq!(rc, -20, "expected BrokerInvalidJson (-20), got {rc}");
|
||||
}
|
||||
|
||||
// ------------------- truncation contract (output-buffer ABI) -------------------
|
||||
//
|
||||
// Python's ctypes caller relies on the "ask, resize, ask" handshake: when the
|
||||
// out buffer is too small, the function must return a *positive* rc equal to
|
||||
// the required size (including NUL). A regression that returns 0 with a
|
||||
// silently truncated buffer, or a negative error code, would corrupt every
|
||||
// Python helper that does the size dance. Each test below feeds an
|
||||
// intentionally undersized buffer to one ABI function and asserts the
|
||||
// positive-required-size invariant.
|
||||
|
||||
#[test]
|
||||
fn bridge_payload_method_label_returns_required_size_when_buffer_too_small() {
|
||||
let payload = CString::new(r#"{"method":"file/read"}"#).unwrap();
|
||||
let mut tiny = [0i8; 1];
|
||||
let rc = unsafe {
|
||||
sessions_bridge_payload_method_label(payload.as_ptr(), tiny.as_mut_ptr(), tiny.len())
|
||||
};
|
||||
assert!(rc > 0, "expected positive required size, got {rc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_error_message_returns_required_size_when_buffer_too_small() {
|
||||
let payload = CString::new(r#"{"error":{"message":"a much longer message"}}"#).unwrap();
|
||||
let fallback = CString::new("fallback").unwrap();
|
||||
let mut tiny = [0i8; 1];
|
||||
let rc = unsafe {
|
||||
sessions_bridge_error_message(
|
||||
payload.as_ptr(),
|
||||
fallback.as_ptr(),
|
||||
tiny.as_mut_ptr(),
|
||||
tiny.len(),
|
||||
)
|
||||
};
|
||||
assert!(rc > 0, "expected positive required size, got {rc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_extract_handshake_returns_required_size_when_buffer_too_small() {
|
||||
let payload =
|
||||
CString::new(r#"{"ok":true,"result":{"handshake":{"remote_home":"/r","arch":"x86"}}}"#)
|
||||
.unwrap();
|
||||
let mut tiny = [0i8; 1];
|
||||
let rc = unsafe {
|
||||
sessions_bridge_extract_handshake(payload.as_ptr(), tiny.as_mut_ptr(), tiny.len())
|
||||
};
|
||||
assert!(rc > 0, "expected positive required size, got {rc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_parse_response_packet_returns_required_size_when_buffer_too_small() {
|
||||
let payload = CString::new(r#"{"id":"req-a","ok":true,"result":{"entries":[1,2,3]}}"#).unwrap();
|
||||
let mut tiny = [0i8; 1];
|
||||
let rc = unsafe {
|
||||
sessions_bridge_parse_response_packet(payload.as_ptr(), tiny.as_mut_ptr(), tiny.len())
|
||||
};
|
||||
assert!(rc > 0, "expected positive required size, got {rc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_cache_key_returns_required_size_when_buffer_too_small() {
|
||||
let host = CString::new("prod").unwrap();
|
||||
let root = CString::new("/srv/app").unwrap();
|
||||
let profile = CString::new("python").unwrap();
|
||||
let mut tiny = [0i8; 1];
|
||||
let rc = unsafe {
|
||||
sessions_workspace_cache_key(
|
||||
host.as_ptr(),
|
||||
root.as_ptr(),
|
||||
profile.as_ptr(),
|
||||
tiny.as_mut_ptr(),
|
||||
tiny.len(),
|
||||
)
|
||||
};
|
||||
assert!(rc > 0, "expected positive required size, got {rc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_open_session_null_host_returns_null_pointer_code() {
|
||||
let bridge = CString::new("/bin/true").unwrap();
|
||||
|
||||
@@ -87,6 +87,23 @@
|
||||
// mass-file-write rules.
|
||||
"sessions_shared_cache_root": null,
|
||||
|
||||
// Product-level sync mode. One knob that maps to safe / balanced / full
|
||||
// defaults for the most user-visible bandwidth and write-volume controls.
|
||||
//
|
||||
// "safe" — quiet first connect for EDR-managed or shared machines:
|
||||
// forces ``sessions_mirror_auto_refresh``,
|
||||
// ``sessions_mirror_include_files``, and
|
||||
// ``sessions_connect_auto_open_remote_folder`` to ``false``
|
||||
// regardless of their per-key value.
|
||||
// "balanced" — the historical default; per-key settings below take effect
|
||||
// unchanged. Recommended for most desktop use.
|
||||
// "full" — same as ``balanced`` today; reserved for future "more
|
||||
// aggressive" defaults (extra hydrate, eager prune, etc).
|
||||
//
|
||||
// Per-key settings below remain authoritative under balanced/full.
|
||||
// See ``SECURITY.md`` § "Sync mode" for the rationale.
|
||||
"sessions_sync_mode": "balanced",
|
||||
|
||||
// Run periodic background mirror refresh once a workspace is opened.
|
||||
"sessions_mirror_auto_refresh": true,
|
||||
|
||||
@@ -215,5 +232,12 @@
|
||||
// Each entry runs through bridge exec/once:
|
||||
// install_argv -> probe_argv -> (status)
|
||||
// remove_argv -> probe_argv -> (status)
|
||||
//
|
||||
// frozen-experimental: the bundled ``kind="agent"`` entries (``tmux``,
|
||||
// ``claude-code``, ``codex-cli``) are leftovers from the dropped Track D
|
||||
// (in-Sublime agent integration via tmux, dropped 2026-04-27). They stay
|
||||
// installable so existing setups keep working, but the recommended path
|
||||
// is to run agents in an external terminal. Use the LSP entries
|
||||
// (Pyright, Ruff, rust-analyzer) as the supported surface.
|
||||
"sessions_remote_extensions": []
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ from .settings_model import (
|
||||
RemoteExtensionSpec,
|
||||
SessionsSettings,
|
||||
load_sessions_settings_from_sublime,
|
||||
sync_mode_bool,
|
||||
)
|
||||
from .sidebar_project_folders import (
|
||||
merge_sessions_sidebar_folder,
|
||||
@@ -385,7 +386,7 @@ def _preempt_connect_session_for_new_remote_request() -> int:
|
||||
target, args, label, task_key = entry
|
||||
if target is _connect_selected_host_async:
|
||||
if task_key:
|
||||
_BACKGROUND_PENDING_KEYS.disciscard(task_key)
|
||||
_BACKGROUND_PENDING_KEYS.discard(task_key)
|
||||
if len(args) >= 3:
|
||||
prior = args[2]
|
||||
if isinstance(prior, str) and prior.strip():
|
||||
@@ -3335,7 +3336,7 @@ def _mirror_options_from_sublime_settings(
|
||||
return RemoteCacheMirrorOptions(
|
||||
max_traversal_depth=max_depth,
|
||||
max_entries=int(getter("sessions_mirror_max_entries", 1000)),
|
||||
include_files=bool(getter("sessions_mirror_include_files", True)),
|
||||
include_files=sync_mode_bool(getter, "sessions_mirror_include_files", True),
|
||||
ignore_patterns=ignore_patterns,
|
||||
prune_missing=prune_missing,
|
||||
max_dir_fanout=fanout_cap,
|
||||
@@ -3388,7 +3389,7 @@ def _mirror_auto_refresh_enabled() -> bool:
|
||||
getter = getattr(stored, "get", None)
|
||||
if not callable(getter):
|
||||
return True
|
||||
return bool(getter("sessions_mirror_auto_refresh", True))
|
||||
return sync_mode_bool(getter, "sessions_mirror_auto_refresh", True)
|
||||
|
||||
|
||||
def _mirror_auto_refresh_interval_ms() -> int:
|
||||
@@ -6379,7 +6380,7 @@ def _connect_auto_open_remote_folder() -> bool:
|
||||
getter = getattr(stored, "get", None)
|
||||
if not callable(getter):
|
||||
return True
|
||||
return bool(getter("sessions_connect_auto_open_remote_folder", True))
|
||||
return sync_mode_bool(getter, "sessions_connect_auto_open_remote_folder", True)
|
||||
|
||||
|
||||
def _sessions_reset_workspace_ui_state_on_open() -> bool:
|
||||
@@ -6955,14 +6956,16 @@ def _write_connected_host_state(mapping: Dict[str, str]) -> None:
|
||||
|
||||
|
||||
class SessionsOpenRemoteTerminalCommand(sublime_plugin.WindowCommand):
|
||||
"""Open a transient Terminus pane SSH'd into the workspace's remote root.
|
||||
"""Open a Terminus pane SSH'd into the workspace's remote root.
|
||||
|
||||
Scope is intentionally narrow: a fresh ad-hoc shell for short, simple
|
||||
commands (``ls``, ``git status``, running a script). No view-reuse cache,
|
||||
no tmux session multiplexing, no persistence across pane closes — when
|
||||
the shell exits the pane closes (``auto_close=True``). For long-running
|
||||
or tmux-heavy workflows the user is expected to open their own external
|
||||
terminal.
|
||||
no tmux session multiplexing. ``auto_close=False`` so the pane survives
|
||||
an unexpected shell exit — without it a flash-close hides whatever
|
||||
error the shell printed on its way out, which is the only signal the
|
||||
user has to diagnose dotfile breakage or remote-root vanish. For
|
||||
long-running or tmux-heavy workflows the user is expected to open
|
||||
their own external terminal.
|
||||
"""
|
||||
|
||||
def run(self) -> None:
|
||||
@@ -6991,24 +6994,29 @@ class SessionsOpenRemoteTerminalCommand(sublime_plugin.WindowCommand):
|
||||
)
|
||||
return
|
||||
|
||||
# ``-il`` not ``-l``: ``-l`` alone leaves the shell in
|
||||
# non-interactive mode when stdin's tty handshake is racy, and
|
||||
# the shell exits immediately at first read — with
|
||||
# ``auto_close=True`` that flashes the pane closed before the
|
||||
# user can do anything. ``-i`` forces interactive mode so the
|
||||
# shell stays attached to the Terminus pty even when the
|
||||
# initial fd state is ambiguous. Same fix the pre-Terminus
|
||||
# external-terminal path used (``bash -il``) — got dropped in
|
||||
# the d21600f Terminus refactor.
|
||||
# ``exec </dev/tty >/dev/tty 2>/dev/tty`` first: pin the shell's
|
||||
# three standard fds to the SSH-allocated pty (``ssh -t``)
|
||||
# before anything else. Without this pin, a brief Terminus pty
|
||||
# handshake race can leave the shell with an fd that's already
|
||||
# signalling EOF on its first read, which kills an interactive
|
||||
# zsh/bash before the prompt even renders. ``</dev/tty``
|
||||
# bypasses whatever stdio Terminus connected and goes straight
|
||||
# to the controlling terminal.
|
||||
#
|
||||
# ``-il`` not ``-l``: ``-i`` forces interactive mode so the
|
||||
# shell doesn't fall back to "non-interactive, exit at first
|
||||
# EOF" semantics. Combined with the explicit ``</dev/tty``
|
||||
# pin, this covers both the fd race and the interactivity
|
||||
# detection path.
|
||||
#
|
||||
# ``;`` not ``&&``: if ``cd`` fails (remote_root vanished,
|
||||
# perm change, mount unavailable) the shell still spawns
|
||||
# instead of exiting on cd's non-zero exit. ``cd``'s stderr
|
||||
# stays visible inside the pane and the user lands in
|
||||
# ``$HOME``.
|
||||
remote_invocation = "cd {}; exec ${{SHELL:-/bin/sh}} -il".format(
|
||||
shlex.quote(remote_root),
|
||||
)
|
||||
remote_invocation = (
|
||||
"exec </dev/tty >/dev/tty 2>/dev/tty; cd {}; exec ${{SHELL:-/bin/sh}} -il"
|
||||
).format(shlex.quote(remote_root))
|
||||
# ``panel_name`` makes Terminus open the shell as a panel
|
||||
# docked at the bottom of the active window. Without it
|
||||
# Terminus defaults to a new tab in the editor pane group,
|
||||
@@ -7017,13 +7025,20 @@ class SessionsOpenRemoteTerminalCommand(sublime_plugin.WindowCommand):
|
||||
# had with the pre-Terminus external-terminal path. Single
|
||||
# well-known panel name keeps successive invocations reusing
|
||||
# one slot instead of stacking new panels.
|
||||
#
|
||||
# ``auto_close=False``: if the remote shell dies for any
|
||||
# reason (dotfile breakage, ``cd`` to a vanished mount,
|
||||
# SSH disconnect), keep the pane visible so the user can
|
||||
# read the exit message instead of watching the panel
|
||||
# flash-close with no diagnostic. Costs one Ctrl+W on
|
||||
# normal ``exit`` — worth it for the broken-path UX.
|
||||
run_command(
|
||||
"terminus_open",
|
||||
{
|
||||
"cmd": ["ssh", "-t", host_alias, remote_invocation],
|
||||
"cwd": str(context.local_cache_root),
|
||||
"title": "ssh {}:{}".format(host_alias, remote_root),
|
||||
"auto_close": True,
|
||||
"auto_close": False,
|
||||
"panel_name": "Sessions Terminus",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -8,6 +8,16 @@ Each :class:`ManagedRemoteExtensionCatalogEntry` bundles:
|
||||
Add a new built-in extension by appending one frozen row to
|
||||
:data:`BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG` and wiring any host-specific hints in
|
||||
``sessions.commands`` if needed.
|
||||
|
||||
frozen-experimental — agent surface
|
||||
-----------------------------------
|
||||
``kind == "agent"`` rows (``tmux``, ``claude-code``, ``codex-cli``) are leftovers
|
||||
from the v0.6.0–v0.6.7 in-Sublime agent direction. Track D (in-Sublime agent
|
||||
integration via tmux) was dropped 2026-04-27 — agents now run in an external
|
||||
terminal that the user manages outside Sublime. The catalog entries stay in the
|
||||
tree so existing installs keep working, but **do not extend or polish them**;
|
||||
add new agent entries only after coordinating with the maintainers. See
|
||||
``planning/BACKLOG.md`` § "Track D" and ``planning/SHIPPED.md`` v0.6.7.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -419,6 +419,59 @@ def load_sessions_settings_from_sublime() -> SessionsSettings:
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Sessions sync-mode (product-level "safe / balanced / full" knob).
|
||||
#
|
||||
# Sessions ships with a handful of EDR-friendly bandwidth caps (max_entries,
|
||||
# max_dir_fanout, writes_per_second_cap) and several auto-on switches
|
||||
# (mirror_auto_refresh, mirror_include_files, connect_auto_open_remote_folder).
|
||||
# Asking security-sensitive users to clamp each switch by hand was the friction
|
||||
# point flagged in the 2026-04 distribution review. ``sessions_sync_mode`` is
|
||||
# the single product-level knob users see; per-key settings still work and act
|
||||
# as explicit overrides under ``balanced`` / ``full``. Under ``safe`` the keys
|
||||
# in ``_SAFE_MODE_FORCED_OFF`` are forced to ``False`` regardless of their
|
||||
# per-key default, so picking ``safe`` once is enough to get a quiet first
|
||||
# connect on EDR-managed machines.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
SESSIONS_SYNC_MODE_KEY = "sessions_sync_mode"
|
||||
SESSIONS_SYNC_MODE_DEFAULT = "balanced"
|
||||
SESSIONS_SYNC_MODE_VALUES: Tuple[str, ...] = ("safe", "balanced", "full")
|
||||
|
||||
_SAFE_MODE_FORCED_OFF: Tuple[str, ...] = (
|
||||
"sessions_mirror_auto_refresh",
|
||||
"sessions_mirror_include_files",
|
||||
"sessions_connect_auto_open_remote_folder",
|
||||
)
|
||||
|
||||
|
||||
def resolve_sessions_sync_mode(getter) -> str:
|
||||
"""Return the validated sync mode (``safe`` / ``balanced`` / ``full``).
|
||||
|
||||
``getter`` matches the ``Settings.get(key, default)`` shape used at the
|
||||
Sublime API boundary; unknown values fall back to ``balanced`` so a typo in
|
||||
user settings cannot silently change product behavior.
|
||||
"""
|
||||
raw = getter(SESSIONS_SYNC_MODE_KEY, SESSIONS_SYNC_MODE_DEFAULT)
|
||||
if isinstance(raw, str) and raw in SESSIONS_SYNC_MODE_VALUES:
|
||||
return raw
|
||||
return SESSIONS_SYNC_MODE_DEFAULT
|
||||
|
||||
|
||||
def sync_mode_bool(getter, key: str, fallback: bool) -> bool:
|
||||
"""Return effective bool for ``key`` after applying sync-mode overrides.
|
||||
|
||||
Under ``safe`` the small list of bandwidth / auto-open keys collapses to
|
||||
``False`` regardless of what the per-key default or user setting says — that
|
||||
is the point of safe mode. Under ``balanced`` and ``full`` the per-key
|
||||
value (or its fallback) is returned unchanged, so existing user settings
|
||||
keep working without modification.
|
||||
"""
|
||||
if key in _SAFE_MODE_FORCED_OFF and resolve_sessions_sync_mode(getter) == "safe":
|
||||
return False
|
||||
return bool(getter(key, fallback))
|
||||
|
||||
|
||||
def gitea_registry_http_headers(settings: SessionsSettings) -> Dict[str, str]:
|
||||
"""Return headers for Gitea generic package GET/PUT (Cloudflare-safe defaults)."""
|
||||
ua = (settings.gitea_http_user_agent or "").strip() or os.environ.get(
|
||||
|
||||
@@ -409,9 +409,11 @@ def test_open_remote_terminal_opens_transient_terminus_pane(
|
||||
) -> None:
|
||||
"""Resolves workspace alias + remote root and dispatches to ``terminus_open``.
|
||||
|
||||
The terminal is intentionally transient: ``auto_close=True`` so the pane
|
||||
closes when the shell exits, no view-reuse cache, no tmux. For long-lived
|
||||
or tmux-heavy workflows the user runs an external terminal themselves.
|
||||
``auto_close=False`` so an unexpected shell exit (dotfile breakage,
|
||||
vanished remote root, SSH disconnect) leaves the pane visible with the
|
||||
exit message instead of flash-closing. No view-reuse cache, no tmux.
|
||||
For long-lived or tmux-heavy workflows the user runs an external
|
||||
terminal themselves.
|
||||
"""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
_write_ssh_config(ssh_config_path, "Host prod\n HostName prod.example.com\n")
|
||||
@@ -450,18 +452,23 @@ def test_open_remote_terminal_opens_transient_terminus_pane(
|
||||
terminus_calls = [c for c in window.window_commands if c[0] == "terminus_open"]
|
||||
assert len(terminus_calls) == 1
|
||||
args = terminus_calls[0][1]
|
||||
# ``-il`` not ``-l``: bash without ``-i`` exits at EOF if stdin's
|
||||
# tty state is ambiguous on pane spawn, which combined with
|
||||
# ``auto_close=True`` flashes the pane closed before the user
|
||||
# sees anything. ``;`` not ``&&`` so a failed ``cd`` doesn't
|
||||
# take the shell down with it.
|
||||
# ``exec </dev/tty ...`` pins the shell's stdio to the SSH-allocated
|
||||
# pty before anything else, defeating Terminus pty handshake races
|
||||
# that would otherwise leave the shell reading EOF on first read.
|
||||
# ``-il`` then forces interactive + login mode so neither bash nor
|
||||
# zsh falls back to non-interactive "exit at EOF" semantics. ``;``
|
||||
# not ``&&`` so a failed ``cd`` doesn't take the shell down with it.
|
||||
assert args["cmd"] == [
|
||||
"ssh",
|
||||
"-t",
|
||||
"prod",
|
||||
"cd /srv/app; exec ${SHELL:-/bin/sh} -il",
|
||||
"exec </dev/tty >/dev/tty 2>/dev/tty; cd /srv/app; exec ${SHELL:-/bin/sh} -il",
|
||||
]
|
||||
assert args["auto_close"] is True
|
||||
# ``auto_close=False`` so an unexpected shell exit (dotfile error,
|
||||
# missing remote root, SSH drop) keeps the pane visible long enough
|
||||
# for the user to read the exit message. Costs one Ctrl+W on a
|
||||
# normal ``exit`` — worth it for the broken-path UX.
|
||||
assert args["auto_close"] is False
|
||||
assert args["title"] == "ssh prod:/srv/app"
|
||||
assert "cwd" in args
|
||||
# ``panel_name`` makes Terminus dock the shell as a bottom panel.
|
||||
@@ -1014,3 +1021,36 @@ def test_connect_command_reloads_ssh_config_each_run(
|
||||
|
||||
assert window.quick_panels[0] == [["prod", "prod.example.com"]]
|
||||
assert window.quick_panels[1] == [["stage", "stage.example.com"]]
|
||||
|
||||
|
||||
def test_preempt_connect_clears_pending_task_keys() -> None:
|
||||
"""Regression: preempt drains queued connect entries and clears their pending key.
|
||||
|
||||
Earlier code called `set.disciscard()` (typo) on the pending-key set; the
|
||||
resulting AttributeError aborted the queue prune mid-iteration, so a stale
|
||||
key stayed in `_BACKGROUND_PENDING_KEYS` and blocked the next equivalent
|
||||
task from being scheduled.
|
||||
"""
|
||||
task_key = "connect:test-host"
|
||||
inner_args = ("dummy_input_id", 0, "test-host")
|
||||
entry = (commands._connect_selected_host_async, inner_args, "label", task_key)
|
||||
|
||||
with commands._BACKGROUND_TASK_LOCK:
|
||||
commands._BACKGROUND_TASK_QUEUE.append(entry)
|
||||
commands._BACKGROUND_PENDING_KEYS.add(task_key)
|
||||
|
||||
try:
|
||||
commands._preempt_connect_session_for_new_remote_request()
|
||||
|
||||
assert task_key not in commands._BACKGROUND_PENDING_KEYS
|
||||
with commands._BACKGROUND_TASK_LOCK:
|
||||
remaining = [
|
||||
e
|
||||
for e in commands._BACKGROUND_TASK_QUEUE
|
||||
if e[0] is commands._connect_selected_host_async
|
||||
]
|
||||
assert remaining == []
|
||||
finally:
|
||||
with commands._BACKGROUND_TASK_LOCK:
|
||||
commands._BACKGROUND_TASK_QUEUE.clear()
|
||||
commands._BACKGROUND_PENDING_KEYS.discard(task_key)
|
||||
|
||||
@@ -285,6 +285,48 @@ def test_mirror_auto_refresh_with_settings(sublime_settings) -> None:
|
||||
assert commands._mirror_auto_refresh_enabled() is False
|
||||
|
||||
|
||||
def test_mirror_auto_refresh_safe_mode_forces_off(sublime_settings) -> None:
|
||||
# safe sync mode overrides any per-key True; quiet first connect.
|
||||
sublime_settings(
|
||||
{
|
||||
"sessions_sync_mode": "safe",
|
||||
"sessions_mirror_auto_refresh": True,
|
||||
}
|
||||
)
|
||||
assert commands._mirror_auto_refresh_enabled() is False
|
||||
|
||||
|
||||
def test_connect_auto_open_safe_mode_forces_off(sublime_settings) -> None:
|
||||
sublime_settings(
|
||||
{
|
||||
"sessions_sync_mode": "safe",
|
||||
"sessions_connect_auto_open_remote_folder": True,
|
||||
}
|
||||
)
|
||||
assert commands._connect_auto_open_remote_folder() is False
|
||||
|
||||
|
||||
def test_mirror_options_safe_mode_clears_include_files(sublime_settings) -> None:
|
||||
sublime_settings(
|
||||
{
|
||||
"sessions_sync_mode": "safe",
|
||||
"sessions_mirror_include_files": True,
|
||||
}
|
||||
)
|
||||
opts = commands._mirror_options_from_sublime_settings()
|
||||
assert opts.include_files is False
|
||||
|
||||
|
||||
def test_mirror_auto_refresh_balanced_mode_passes_through(sublime_settings) -> None:
|
||||
sublime_settings(
|
||||
{
|
||||
"sessions_sync_mode": "balanced",
|
||||
"sessions_mirror_auto_refresh": True,
|
||||
}
|
||||
)
|
||||
assert commands._mirror_auto_refresh_enabled() is True
|
||||
|
||||
|
||||
def test_mirror_auto_refresh_interval_with_settings(sublime_settings) -> None:
|
||||
sublime_settings({"sessions_mirror_auto_refresh_interval_seconds": 30})
|
||||
assert commands._mirror_auto_refresh_interval_ms() == 30_000
|
||||
|
||||
@@ -4,6 +4,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
from sessions.settings_model import (
|
||||
DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS,
|
||||
SESSIONS_SYNC_MODE_DEFAULT,
|
||||
SESSIONS_SYNC_MODE_KEY,
|
||||
CodeServerSpec,
|
||||
RemoteExtensionSpec,
|
||||
SessionsSettings,
|
||||
@@ -14,6 +16,8 @@ from sessions.settings_model import (
|
||||
normalize_code_server_specs,
|
||||
normalize_remote_extension_specs,
|
||||
normalize_remote_python_tool_pipeline,
|
||||
resolve_sessions_sync_mode,
|
||||
sync_mode_bool,
|
||||
)
|
||||
|
||||
|
||||
@@ -447,3 +451,101 @@ def test_load_settings_bad_base_url_uses_default(monkeypatch) -> None:
|
||||
monkeypatch.setitem(sys.modules, "sublime", fake_sublime)
|
||||
settings = load_sessions_settings_from_sublime()
|
||||
assert settings.gitea_base_url == "https://git.teahaven.kr"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Sessions sync-mode (safe / balanced / full) — see SECURITY.md § "Sync mode"
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _DictGetter:
|
||||
"""Minimal stand-in for ``Sublime.Settings.get`` used by the sync-mode helpers."""
|
||||
|
||||
def __init__(self, store):
|
||||
self._store = dict(store)
|
||||
|
||||
def __call__(self, key, default=None):
|
||||
return self._store.get(key, default)
|
||||
|
||||
|
||||
def test_resolve_sessions_sync_mode_defaults_to_balanced() -> None:
|
||||
assert resolve_sessions_sync_mode(_DictGetter({})) == SESSIONS_SYNC_MODE_DEFAULT
|
||||
assert SESSIONS_SYNC_MODE_DEFAULT == "balanced"
|
||||
|
||||
|
||||
def test_resolve_sessions_sync_mode_falls_back_on_unknown_value() -> None:
|
||||
getter = _DictGetter({SESSIONS_SYNC_MODE_KEY: "ultra"})
|
||||
assert resolve_sessions_sync_mode(getter) == SESSIONS_SYNC_MODE_DEFAULT
|
||||
|
||||
|
||||
def test_resolve_sessions_sync_mode_accepts_known_values() -> None:
|
||||
for value in ("safe", "balanced", "full"):
|
||||
getter = _DictGetter({SESSIONS_SYNC_MODE_KEY: value})
|
||||
assert resolve_sessions_sync_mode(getter) == value
|
||||
|
||||
|
||||
def test_sync_mode_bool_safe_forces_off_three_keys() -> None:
|
||||
getter = _DictGetter(
|
||||
{
|
||||
SESSIONS_SYNC_MODE_KEY: "safe",
|
||||
"sessions_mirror_auto_refresh": True,
|
||||
"sessions_mirror_include_files": True,
|
||||
"sessions_connect_auto_open_remote_folder": True,
|
||||
}
|
||||
)
|
||||
for forced_key in (
|
||||
"sessions_mirror_auto_refresh",
|
||||
"sessions_mirror_include_files",
|
||||
"sessions_connect_auto_open_remote_folder",
|
||||
):
|
||||
assert sync_mode_bool(getter, forced_key, True) is False
|
||||
|
||||
|
||||
def test_sync_mode_bool_safe_does_not_touch_unrelated_keys() -> None:
|
||||
# User-set value for a key NOT in the safe-mode forced-off list passes
|
||||
# through unchanged.
|
||||
getter_user_set = _DictGetter(
|
||||
{
|
||||
SESSIONS_SYNC_MODE_KEY: "safe",
|
||||
"sessions_mirror_show_sidebar_after_sync": True,
|
||||
}
|
||||
)
|
||||
assert (
|
||||
sync_mode_bool(
|
||||
getter_user_set, "sessions_mirror_show_sidebar_after_sync", False
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
# When the key is not set, the caller's fallback is honoured.
|
||||
getter_no_set = _DictGetter({SESSIONS_SYNC_MODE_KEY: "safe"})
|
||||
assert (
|
||||
sync_mode_bool(getter_no_set, "sessions_mirror_show_sidebar_after_sync", True)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
sync_mode_bool(getter_no_set, "sessions_mirror_show_sidebar_after_sync", False)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_sync_mode_bool_balanced_passes_per_key_setting_through() -> None:
|
||||
getter = _DictGetter(
|
||||
{
|
||||
SESSIONS_SYNC_MODE_KEY: "balanced",
|
||||
"sessions_mirror_auto_refresh": False,
|
||||
}
|
||||
)
|
||||
assert sync_mode_bool(getter, "sessions_mirror_auto_refresh", True) is False
|
||||
|
||||
|
||||
def test_sync_mode_bool_full_passes_per_key_setting_through() -> None:
|
||||
getter = _DictGetter({SESSIONS_SYNC_MODE_KEY: "full"})
|
||||
assert sync_mode_bool(getter, "sessions_mirror_auto_refresh", True) is True
|
||||
assert sync_mode_bool(getter, "sessions_mirror_include_files", False) is False
|
||||
|
||||
|
||||
def test_sync_mode_bool_uses_fallback_when_key_missing() -> None:
|
||||
getter = _DictGetter({SESSIONS_SYNC_MODE_KEY: "balanced"})
|
||||
assert sync_mode_bool(getter, "sessions_mirror_auto_refresh", True) is True
|
||||
assert sync_mode_bool(getter, "sessions_mirror_auto_refresh", False) is False
|
||||
|
||||
Reference in New Issue
Block a user