Compare commits
2 Commits
a469e8b886
...
3f6d0c0c1e
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f6d0c0c1e | |||
| 22dd0d8260 |
@@ -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
12
rust/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user