Compare commits

...

2 Commits

Author SHA1 Message Date
3f6d0c0c1e chore(release): v0.7.17
All checks were successful
ci / mutation test (broker) (push) Has been skipped
ci / test-health gate (push) Successful in 17s
Release Publish (Gitea session_helper) / verify-release-tag (push) Successful in 17s
ci / rust debug (push) Successful in 2m43s
ci / rust release (push) Successful in 2m53s
ci / python (push) Successful in 1m30s
Release Publish (Gitea session_helper) / publish-linux-x86_64 (push) Successful in 4m14s
Bump pyproject.toml, rust workspace, uv.lock, Cargo.lock from
0.7.16 to 0.7.17. Release contents:

- feat: Track G now auto-fires its refresh pipeline once per
  workspace at the tail of the deep-sync success path. Newly-
  connected workspaces no longer leave ``.git`` as Sessions stubs
  (which Sublime Text's built-in git integration would surface as
  ``unable to open index: Failed to read index header``).
  ``_dot_git_excluded_from_mirror`` opt-out still wins; manual
  ``Sessions: Refresh Git State`` still re-runs on demand
  regardless of the auto-trigger's per-cache-key dedup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:21:57 +09:00
22dd0d8260 feat(sublime): auto-fire Track G refresh once per workspace at sync.done
Track G v0 was designed as an explicit user-trigger (``Sessions:
Refresh Git State`` in the palette), but that left freshly-connected
workspaces in a half-mirrored ``.git`` state — the mirror walks
``.git/`` and creates 0-byte stubs for every file inside, Sublime
Text's built-in git integration discovers those stubs and tries to
read ``.git/index``, and the user gets ``unable to open index:
Failed to read index header at sgit::open_index`` in the console
on every connect with no obvious next step.

Fix: hook a once-per-workspace auto-trigger into the deep-sync
success path (``sync.done`` source in
``_sync_remote_tree_to_sidebar_for_context``). The trigger runs
the same pipeline the manual command runs (G2 fetch + G3
materialise + G4 hook install + G6 proxy), gated by
``_dot_git_excluded_from_mirror()`` so the user's explicit opt-out
still wins, and gated by ``_TRACK_G_AUTO_REFRESH_DONE`` so reopens
+ subsequent sync.done events don't re-tar every ``.git``.

Refactored ``SessionsRefreshGitStateCommand.run()`` to share its
work loop with the auto-trigger via ``_run_track_g_refresh
(window, context, *, manual)`` — ``manual=True`` keeps the
status-bar success message users expect from the palette command;
``manual=False`` only emits the message on failure (silent happy
path so the auto-trigger doesn't spam the user). Trace events
``git.dot_git_fetch`` / ``git.materialise`` / ``git.checkout_proxy``
fire the same as before with an added ``manual=…`` field on the
fetch event for post-mortem.

Two new unit tests:
- ``test_track_g_auto_refresh_dedups_per_cache_key`` — three
  scheduled calls fire ``_run_track_g_refresh`` exactly once
- ``test_track_g_auto_refresh_skips_when_dot_git_excluded`` — opt-
  out gate wins, dedup flag stays unset so a later opt-in still
  triggers

Manual ``Sessions: Refresh Git State`` is unchanged behaviourally
— it always re-runs (bypasses the dedup) with full status output.

Full sublime suite 1224 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:21:01 +09:00
6 changed files with 246 additions and 128 deletions

View File

@@ -1,6 +1,6 @@
[project]
name = "sessions-sublime"
version = "0.7.16"
version = "0.7.17"
description = "Sublime-facing Python code for Sessions."
requires-python = ">=3.8"
license = {text = "MIT"}

12
rust/Cargo.lock generated
View File

@@ -221,7 +221,7 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "local_bridge"
version = "0.7.16"
version = "0.7.17"
dependencies = [
"base64",
"glob",
@@ -432,7 +432,7 @@ dependencies = [
[[package]]
name = "session_helper"
version = "0.7.16"
version = "0.7.17"
dependencies = [
"base64",
"notify",
@@ -443,7 +443,7 @@ dependencies = [
[[package]]
name = "session_protocol"
version = "0.7.16"
version = "0.7.17"
dependencies = [
"base64",
"serde",
@@ -452,14 +452,14 @@ dependencies = [
[[package]]
name = "sessions_askpass"
version = "0.7.16"
version = "0.7.17"
dependencies = [
"tempfile",
]
[[package]]
name = "sessions_native"
version = "0.7.16"
version = "0.7.17"
dependencies = [
"serde_json",
"session_protocol",
@@ -770,7 +770,7 @@ dependencies = [
[[package]]
name = "workspace_identity"
version = "0.7.16"
version = "0.7.17"
[[package]]
name = "zmij"

View File

@@ -12,7 +12,7 @@ resolver = "2"
[workspace.package]
edition = "2024"
license = "MIT"
version = "0.7.16"
version = "0.7.17"
authors = ["Myeongseon Choi <key262yek@gmail.com>"]
repository = "https://git.teahaven.kr/sublime-rs/sessions"
homepage = "https://git.teahaven.kr/sublime-rs/sessions"

View File

@@ -1065,6 +1065,12 @@ def _sync_remote_tree_to_sidebar_for_context(
# of them are subproject build manifests (pyproject.toml, etc.)
# that weren't on disk when activation fired its first pass.
_schedule_eager_hydrate_if_needed(window, context)
# Track G v0 auto-trigger: once-per-workspace, replaces the
# ``.git`` stubs the mirror just laid down with their real
# remote content so Sublime Merge / sgit don't see a broken
# half-mirror. No-op when the user opted out via
# ``sessions_mirror_ignore_patterns``.
_schedule_track_g_refresh_if_needed(window, context)
_set_timeout(finish, 0)
@@ -6999,24 +7005,11 @@ class SessionsRefreshGitStateCommand(sublime_plugin.WindowCommand):
def run(self) -> None:
"""Discover repos, pull each ``.git``, and apply v0 materialisation."""
from .git_branch_proxy import (
apply_pending_checkout,
install_post_checkout_hook,
)
from .git_dot_git_sync import fetch_remote_dot_git
from .git_materialise import materialise_working_tree
from .git_repo_discovery import discover_git_repos
settings = SessionsSettings()
context = _workspace_context(self.window, settings)
if context is None:
return
if _dot_git_excluded_from_mirror():
# User explicitly opted out of Track G by keeping ``.git``
# in ``sessions_mirror_ignore_patterns``. Without ``.git``
# in the local mirror nothing downstream works (G1
# discovery returns no repos, G2 has nothing to pull),
# and overriding the user's choice here would be wrong.
_status_message(
"Sessions: Track G disabled — remove `.git` from "
"sessions_mirror_ignore_patterns to enable Sublime Merge "
@@ -7028,131 +7021,179 @@ class SessionsRefreshGitStateCommand(sublime_plugin.WindowCommand):
"Sessions: connect the workspace before refreshing git state."
)
return
host_alias = context.recent_entry.host_alias
local_cache_root = context.local_cache_root
remote_root = context.recent_entry.remote_root
_run_track_g_refresh(self.window, context, manual=True)
def work() -> None:
repos = discover_git_repos(local_cache_root, remote_root)
if not repos:
# Per-cache-key flag so the auto-trigger fires *once* per workspace per
# Sublime session. Manual ``Sessions: Refresh Git State`` always re-runs
# regardless of this gate.
_TRACK_G_AUTO_REFRESH_DONE: Set[str] = set()
def _schedule_track_g_refresh_if_needed(
window: object,
context: "_WorkspaceContext",
) -> None:
"""Auto-fire one Track G refresh per workspace at the tail of connect.
Without this hook, ``.git`` directories arrive in the local mirror as
Sessions stubs (the mirror walks ``.git/`` and creates 0-byte
placeholders for the files inside). Sublime Text's built-in git
integration discovers those ``.git`` dirs, tries to read
``.git/index``, and prints ``unable to open index: Failed to read
index header`` to the console — a confusing first impression even
though the workspace itself is fine.
The fix is to run the manual ``Sessions: Refresh Git State`` flow
once automatically after the deep mirror sync completes:
G2 fetches the real ``.git`` content, G3 sets skip-worktree on
clean tracked files + pulls dirty content, G4 installs the
post-checkout hook. Subsequent runs require an explicit
``Sessions: Refresh Git State`` invocation (or
``_TRACK_G_AUTO_REFRESH_DONE`` reset) so we don't tar+extract
every ``.git`` on every reconnect.
"""
if _dot_git_excluded_from_mirror():
return
cache_key = context.cache_key
if cache_key in _TRACK_G_AUTO_REFRESH_DONE:
return
_TRACK_G_AUTO_REFRESH_DONE.add(cache_key)
_run_track_g_refresh(window, context, manual=False)
def _run_track_g_refresh(
window: object,
context: "_WorkspaceContext",
*,
manual: bool,
) -> None:
"""Shared body for ``SessionsRefreshGitStateCommand`` + auto-trigger.
``manual=True`` shows status-bar messages on success ("refreshed N
repo(s) …"); ``manual=False`` is silent on the happy path so the
auto-trigger doesn't spam the user — failures still surface so a
broken Track G state is visible.
"""
from .git_branch_proxy import (
apply_pending_checkout,
install_post_checkout_hook,
)
from .git_dot_git_sync import fetch_remote_dot_git
from .git_materialise import materialise_working_tree
from .git_repo_discovery import discover_git_repos
host_alias = context.recent_entry.host_alias
local_cache_root = context.local_cache_root
remote_root = context.recent_entry.remote_root
def work() -> None:
repos = discover_git_repos(local_cache_root, remote_root)
if not repos:
if manual:
_set_timeout(
lambda: _status_message(
"Sessions: no .git directories found in this workspace."
),
0,
)
return
ok_repos = 0
failed: list[str] = []
total_skip_worktree = 0
total_files_fetched = 0
for repo in repos:
fetch_result = fetch_remote_dot_git(host_alias, repo)
return
ok_repos = 0
failed: list[str] = []
total_skip_worktree = 0
total_files_fetched = 0
for repo in repos:
fetch_result = fetch_remote_dot_git(host_alias, repo)
_trace_event(
"git.dot_git_fetch",
host_alias=host_alias,
remote_root=repo.remote_root,
kind=repo.kind,
ok=fetch_result.ok,
bytes_received=fetch_result.bytes_received,
error_detail=fetch_result.error_detail,
manual=manual,
)
if not fetch_result.ok:
failed.append(
"{}: {}".format(
repo.remote_root,
fetch_result.error_detail or "(no detail)",
)
)
continue
try:
install_post_checkout_hook(repo.local_root / ".git")
except OSError as error:
_trace_event(
"git.dot_git_fetch",
"git.hook_install_failed",
host_alias=host_alias,
remote_root=repo.remote_root,
kind=repo.kind,
ok=fetch_result.ok,
bytes_received=fetch_result.bytes_received,
error_detail=fetch_result.error_detail,
error=str(error),
)
if not fetch_result.ok:
failed.append(
"{}: {}".format(
repo.remote_root,
fetch_result.error_detail or "(no detail)",
)
proxy_result = apply_pending_checkout(host_alias, repo)
_trace_event(
"git.checkout_proxy",
host_alias=host_alias,
remote_root=repo.remote_root,
proxied=proxy_result.proxied,
ok=proxy_result.ok,
new_head=proxy_result.new_head,
error_detail=proxy_result.error_detail,
)
if proxy_result.proxied and not proxy_result.ok:
failed.append(
"{} (branch switch refused): {}".format(
repo.remote_root,
proxy_result.error_detail or "(no detail)",
)
continue
# G4: install the post-checkout hook so future Sublime
# Merge branch switches drop a marker the next refresh
# consumes. Idempotent — re-runs are cheap.
try:
install_post_checkout_hook(repo.local_root / ".git")
except OSError as error:
_trace_event(
"git.hook_install_failed",
host_alias=host_alias,
remote_root=repo.remote_root,
error=str(error),
)
# G4 (cont.): drain any pending checkout marker. Marker
# is dropped by the hook on prior branch switches; if
# it's there, proxy the checkout to the remote before
# we re-materialise so the new branch's index drives
# G3's classification. Dirty refusal (G6) keeps the
# marker in place and surfaces the stock git error.
proxy_result = apply_pending_checkout(host_alias, repo)
_trace_event(
"git.checkout_proxy",
host_alias=host_alias,
remote_root=repo.remote_root,
proxied=proxy_result.proxied,
ok=proxy_result.ok,
new_head=proxy_result.new_head,
error_detail=proxy_result.error_detail,
)
if proxy_result.proxied and not proxy_result.ok:
failed.append(
"{} (branch switch refused): {}".format(
repo.remote_root,
proxy_result.error_detail or "(no detail)",
)
continue
materialise_result = materialise_working_tree(host_alias, repo)
_trace_event(
"git.materialise",
host_alias=host_alias,
remote_root=repo.remote_root,
ok=materialise_result.ok,
skip_worktree_set=materialise_result.skip_worktree_set,
files_fetched=materialise_result.files_fetched,
error_detail=materialise_result.error_detail,
)
if not materialise_result.ok:
failed.append(
"{} (.git ok, materialise failed): {}".format(
repo.remote_root,
materialise_result.error_detail or "(no detail)",
)
# Don't re-materialise: remote HEAD didn't move,
# so G3's previous classification is still correct.
continue
# G3: classify working tree, set skip-worktree on
# clean tracked files, pull dirty file content. Per-
# repo failure here doesn't roll back the .git pull
# — the user still gets read-only history; staging
# just stays unsupported until materialise succeeds.
materialise_result = materialise_working_tree(host_alias, repo)
_trace_event(
"git.materialise",
host_alias=host_alias,
remote_root=repo.remote_root,
ok=materialise_result.ok,
skip_worktree_set=materialise_result.skip_worktree_set,
files_fetched=materialise_result.files_fetched,
error_detail=materialise_result.error_detail,
)
if not materialise_result.ok:
failed.append(
"{} (.git ok, materialise failed): {}".format(
repo.remote_root,
materialise_result.error_detail or "(no detail)",
)
)
continue
ok_repos += 1
total_skip_worktree += materialise_result.skip_worktree_set
total_files_fetched += materialise_result.files_fetched
continue
ok_repos += 1
total_skip_worktree += materialise_result.skip_worktree_set
total_files_fetched += materialise_result.files_fetched
def finish() -> None:
if failed:
_status_message(
"Sessions: refreshed {}/{} git repos; failures: {}".format(
ok_repos, len(repos), "; ".join(failed)
)
)
else:
_status_message(
(
"Sessions: refreshed git state for {} repo(s) "
"({} clean files marked skip-worktree, "
"{} dirty files materialised)."
).format(ok_repos, total_skip_worktree, total_files_fetched)
def finish() -> None:
if failed:
_status_message(
"Sessions: refreshed {}/{} git repos; failures: {}".format(
ok_repos, len(repos), "; ".join(failed)
)
)
elif manual:
_status_message(
(
"Sessions: refreshed git state for {} repo(s) "
"({} clean files marked skip-worktree, "
"{} dirty files materialised)."
).format(ok_repos, total_skip_worktree, total_files_fetched)
)
_set_timeout(finish, 0)
_set_timeout(finish, 0)
_run_in_background(
work,
task_label="sessions.refresh_git_state",
task_key="sessions_refresh_git_state:{}".format(context.cache_key),
)
_run_in_background(
work,
task_label="sessions.refresh_git_state",
task_key="sessions_refresh_git_state:{}".format(context.cache_key),
)
# ---------------------------------------------------------------------------

View File

@@ -1719,3 +1719,80 @@ def test_two_windows_same_workspace_single_mirror_inflight(
)
# The mock was called only once (for win1), not twice
commands._MIRROR_SYNC_IN_FLIGHT.clear()
def test_track_g_auto_refresh_dedups_per_cache_key(monkeypatch) -> None:
"""``_schedule_track_g_refresh_if_needed`` fires at most once per
workspace per Sublime session — subsequent sync.done events on the
same workspace must not re-tar+extract the entire ``.git``."""
from sessions.recent_state import RecentWorkspace
from sessions.settings_model import SessionsSettings
monkeypatch.setattr(commands, "_dot_git_excluded_from_mirror", lambda: False)
fired: List[str] = []
def fake_run(window: object, context: object, *, manual: bool) -> None:
cache_key = getattr(context, "cache_key", "?")
fired.append("{}:{}".format(cache_key, manual))
monkeypatch.setattr(commands, "_run_track_g_refresh", fake_run)
# Reset dedup set so prior tests don't pollute.
commands._TRACK_G_AUTO_REFRESH_DONE.discard("ck-dedup-test")
entry = RecentWorkspace(
host_alias="prod",
remote_root="/srv/proj",
cache_key="ck-dedup-test",
last_connected_at="2026-04-28T00:00:00Z",
)
ctx = commands._WorkspaceContext(
settings=SessionsSettings(),
recent_entry=entry,
cache_key="ck-dedup-test",
local_cache_root=Path("/tmp/anywhere"),
)
commands._schedule_track_g_refresh_if_needed(FakeWindow(), ctx)
commands._schedule_track_g_refresh_if_needed(FakeWindow(), ctx)
commands._schedule_track_g_refresh_if_needed(FakeWindow(), ctx)
assert fired == ["ck-dedup-test:False"], (
"auto-trigger must fire exactly once per cache_key, got {}".format(fired)
)
def test_track_g_auto_refresh_skips_when_dot_git_excluded(monkeypatch) -> None:
"""Honour the user's opt-out: if ``.git`` is in ignore_patterns the
auto-trigger must not run, even on the first call."""
from sessions.recent_state import RecentWorkspace
from sessions.settings_model import SessionsSettings
monkeypatch.setattr(commands, "_dot_git_excluded_from_mirror", lambda: True)
fired: List[str] = []
monkeypatch.setattr(
commands,
"_run_track_g_refresh",
lambda *a, **k: fired.append("called"),
)
commands._TRACK_G_AUTO_REFRESH_DONE.discard("ck-optout-test")
entry = RecentWorkspace(
host_alias="prod",
remote_root="/srv/proj",
cache_key="ck-optout-test",
last_connected_at="2026-04-28T00:00:00Z",
)
ctx = commands._WorkspaceContext(
settings=SessionsSettings(),
recent_entry=entry,
cache_key="ck-optout-test",
local_cache_root=Path("/tmp/anywhere"),
)
commands._schedule_track_g_refresh_if_needed(FakeWindow(), ctx)
assert fired == []
# And the dedup flag was *not* set, so a future opt-in (user removes
# ``.git`` from ignore_patterns + re-fires sync.done) still triggers.
assert "ck-optout-test" not in commands._TRACK_G_AUTO_REFRESH_DONE

2
uv.lock generated
View File

@@ -854,7 +854,7 @@ wheels = [
[[package]]
name = "sessions-sublime"
version = "0.7.16"
version = "0.7.17"
source = { virtual = "." }
[package.dev-dependencies]