fix(ux+sync): side-bar expand respects clicked path + remote→local branch sync (v0.7.40)
All checks were successful
boundary-lint / PR boundary-claim (Lint (push) Has been skipped
boundary-lint / ban-list lint (Lint (push) Successful in 18s
boundary-lint / duplication-deadline (Layer 1/2) (push) Successful in 19s
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 18s
ci / rust release (push) Successful in 2m54s
ci / rust debug (push) Successful in 2m58s
Release Publish (Gitea session_helper) / publish-linux-x86_64 (push) Successful in 3m38s
ci / python (push) Successful in 1m32s
All checks were successful
boundary-lint / PR boundary-claim (Lint (push) Has been skipped
boundary-lint / ban-list lint (Lint (push) Successful in 18s
boundary-lint / duplication-deadline (Layer 1/2) (push) Successful in 19s
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 18s
ci / rust release (push) Successful in 2m54s
ci / rust debug (push) Successful in 2m58s
Release Publish (Gitea session_helper) / publish-linux-x86_64 (push) Successful in 3m38s
ci / python (push) Successful in 1m32s
Two user-reported regressions, fixed together because both surfaced
during the same interactive session.
1. Side Bar "Expand this folder" fell through to the input panel
--------------------------------------------------------------
Right-clicking a folder in the side bar surfaced the
``sessions_expand_deferred_directory`` command but Sublime did not
populate the ``paths`` kwarg, so the command landed on the no-args
branch and opened the "Expand remote directory:" input panel — the
exact opposite of "expand the folder I just clicked".
Root cause: ``Side Bar.sublime-menu`` declared the entries without
the ``"args": {"paths": []}`` placeholder. Without that, Sublime
does not auto-fill the right-clicked paths into the command's args
dict (the command class's ``is_visible(paths=...)`` signature alone
is not sufficient on the side-bar context menu).
Fix: add ``"args": {"paths": []}`` to both Sessions side-bar
entries (Expand + Delete Remote File). Pinned by a new
``test_side_bar_menu_declares_paths_placeholder`` regression so a
future menu refactor cannot drop it silently.
2. Remote→local branch sync was a no-op
---------------------------------------
Local→remote checkout already worked (post-checkout hook → marker →
``apply_pending_checkout`` → remote ``git checkout``). But running
``git checkout other-branch`` directly on the remote left the local
cache showing the previous branch's bytes: ``materialise_working_tree``
runs ``git status --porcelain=v2`` on the remote, sees every file as
clean (working tree matches index after the checkout), marks every
file ``skip-worktree`` locally, and fetches **zero** files. The local
cache stubs from the previous branch are now hidden from git but
Sublime keeps opening their stale content.
Fix: in ``_run_track_g_refresh``, capture the local ``.git/HEAD``
commit SHA *before* the tar replacement and again *after*. When they
differ (= remote-side checkout happened), ask the remote
``git diff --name-only -z <old> <new>`` for the exact tracked-file
delta and pass it to ``materialise_working_tree`` via the new
``extra_force_refresh`` kwarg, which fetches and overwrites those
local cache copies. Files unchanged between the two commits stay on
the cheap skip-worktree path.
New helpers:
* ``_read_local_head_commit_sha(local_root)`` — resolves
``.git/HEAD`` through both loose refs and ``packed-refs``;
returns '' when HEAD is unreadable so the caller can short-circuit
instead of guessing.
* ``_diff_changed_paths_on_remote(host, root, old, new)`` — wraps
the remote ``git diff --name-only -z`` exec/once call. Returns
``()`` on identical SHAs, transport errors, or non-zero git
exits (rebase garbage-collected the old commit), so a refresh
with a stale baseline degrades to the previous behaviour rather
than spamming spurious refresh requests.
New trace event ``git.remote_head_changed`` carries
``prev_head``/``new_head``/``refresh_count`` so post-mortems can
distinguish a branch swap from a no-op refresh.
Tests:
* 5 ``_read_local_head_commit_sha`` cases (loose ref, packed-refs
fallback, detached HEAD, missing HEAD, unknown ref)
* 4 ``_diff_changed_paths_on_remote`` cases (happy path with argv
assertion, identical-SHA short-circuit, non-zero exit returns (),
transport error returns ())
* 2 ``materialise_working_tree(extra_force_refresh=...)`` cases
(forces fetch even on clean tracked files; deduplicates against
``dirty_modified``)
1,367 tests pass; coverage 80.71% (gate=80%).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "sessions-sublime"
|
name = "sessions-sublime"
|
||||||
version = "0.7.39"
|
version = "0.7.40"
|
||||||
description = "Sublime-facing Python code for Sessions."
|
description = "Sublime-facing Python code for Sessions."
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
|
|||||||
12
rust/Cargo.lock
generated
12
rust/Cargo.lock
generated
@@ -221,7 +221,7 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "local_bridge"
|
name = "local_bridge"
|
||||||
version = "0.7.39"
|
version = "0.7.40"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"glob",
|
"glob",
|
||||||
@@ -432,7 +432,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "session_helper"
|
name = "session_helper"
|
||||||
version = "0.7.39"
|
version = "0.7.40"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"notify",
|
"notify",
|
||||||
@@ -443,7 +443,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "session_protocol"
|
name = "session_protocol"
|
||||||
version = "0.7.39"
|
version = "0.7.40"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -452,14 +452,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sessions_askpass"
|
name = "sessions_askpass"
|
||||||
version = "0.7.39"
|
version = "0.7.40"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sessions_native"
|
name = "sessions_native"
|
||||||
version = "0.7.39"
|
version = "0.7.40"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"notify",
|
"notify",
|
||||||
@@ -773,7 +773,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "workspace_identity"
|
name = "workspace_identity"
|
||||||
version = "0.7.39"
|
version = "0.7.40"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ resolver = "2"
|
|||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
version = "0.7.39"
|
version = "0.7.40"
|
||||||
authors = ["Myeongseon Choi <key262yek@gmail.com>"]
|
authors = ["Myeongseon Choi <key262yek@gmail.com>"]
|
||||||
repository = "https://git.teahaven.kr/sublime-rs/sessions"
|
repository = "https://git.teahaven.kr/sublime-rs/sessions"
|
||||||
homepage = "https://git.teahaven.kr/sublime-rs/sessions"
|
homepage = "https://git.teahaven.kr/sublime-rs/sessions"
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"caption": "Sessions: Expand this folder",
|
"caption": "Sessions: Expand this folder",
|
||||||
"command": "sessions_expand_deferred_directory"
|
"command": "sessions_expand_deferred_directory",
|
||||||
|
"args": {"paths": []}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"caption": "Sessions: Delete Remote File",
|
"caption": "Sessions: Delete Remote File",
|
||||||
"command": "sessions_delete_remote_file"
|
"command": "sessions_delete_remote_file",
|
||||||
|
"args": {"paths": []}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7078,6 +7078,89 @@ def _read_local_head_branch(local_root: Path) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _read_local_head_commit_sha(local_root: Path) -> str:
|
||||||
|
"""Return the 40-char commit SHA HEAD resolves to, or ''.
|
||||||
|
|
||||||
|
Used by the remote→local branch-sync path to detect that a remote
|
||||||
|
``git checkout`` happened between two refreshes (HEAD points to a
|
||||||
|
different commit after ``fetch_remote_dot_git`` rewrote ``.git``).
|
||||||
|
Resolves both loose refs (``.git/refs/heads/<branch>``) and
|
||||||
|
packed-refs entries; returns '' for unreadable HEADs so callers
|
||||||
|
can short-circuit instead of touching the working tree blindly.
|
||||||
|
"""
|
||||||
|
git_dir = local_root / ".git"
|
||||||
|
head_path = git_dir / "HEAD"
|
||||||
|
try:
|
||||||
|
text = head_path.read_text(encoding="utf-8").strip()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
if text.startswith("ref: "):
|
||||||
|
ref = text[len("ref: ") :].strip()
|
||||||
|
ref_path = git_dir / ref
|
||||||
|
try:
|
||||||
|
return ref_path.read_text(encoding="utf-8").strip()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
# Packed-refs fallback for branches whose loose ref was packed
|
||||||
|
# by a recent ``git gc`` on the remote.
|
||||||
|
try:
|
||||||
|
packed = (git_dir / "packed-refs").read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
for line in packed.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith(("#", "^")):
|
||||||
|
continue
|
||||||
|
parts = stripped.split(maxsplit=1)
|
||||||
|
if len(parts) == 2 and parts[1] == ref:
|
||||||
|
return parts[0]
|
||||||
|
return ""
|
||||||
|
# Detached HEAD — text is already a commit SHA.
|
||||||
|
if len(text) == 40 and all(c in "0123456789abcdef" for c in text.lower()):
|
||||||
|
return text
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _diff_changed_paths_on_remote(
|
||||||
|
host_alias: str,
|
||||||
|
remote_root: str,
|
||||||
|
old_sha: str,
|
||||||
|
new_sha: str,
|
||||||
|
) -> Tuple[str, ...]:
|
||||||
|
"""Ask the remote which tracked files differ between two commits.
|
||||||
|
|
||||||
|
Returns the empty tuple when either SHA is missing or the diff
|
||||||
|
fails (e.g. rebase garbage-collected ``old_sha``); the caller
|
||||||
|
treats that as "no extra refresh known", letting the existing
|
||||||
|
skip-worktree path keep stale local bytes — better than corrupting
|
||||||
|
the local cache by guessing.
|
||||||
|
"""
|
||||||
|
if not old_sha or not new_sha or old_sha == new_sha:
|
||||||
|
return ()
|
||||||
|
try:
|
||||||
|
result = execute_remote_exec_once(
|
||||||
|
host_alias,
|
||||||
|
argv=(
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
remote_root,
|
||||||
|
"diff",
|
||||||
|
"--name-only",
|
||||||
|
"-z",
|
||||||
|
old_sha,
|
||||||
|
new_sha,
|
||||||
|
),
|
||||||
|
cwd=remote_root,
|
||||||
|
timeout_ms=30_000,
|
||||||
|
)
|
||||||
|
except SessionHelperStartError:
|
||||||
|
return ()
|
||||||
|
if result.exit_code != 0 or result.timed_out:
|
||||||
|
return ()
|
||||||
|
payload = (result.stdout or "").rstrip("\x00")
|
||||||
|
return tuple(entry for entry in payload.split("\x00") if entry)
|
||||||
|
|
||||||
|
|
||||||
def _synthesize_pending_checkout_if_local_head_diverged(repo) -> None: # noqa: ANN001
|
def _synthesize_pending_checkout_if_local_head_diverged(repo) -> None: # noqa: ANN001
|
||||||
"""Write a synthetic marker when local HEAD branch differs from baseline.
|
"""Write a synthetic marker when local HEAD branch differs from baseline.
|
||||||
|
|
||||||
@@ -7251,6 +7334,20 @@ def _run_track_g_refresh(
|
|||||||
ok_repos += 1
|
ok_repos += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Capture the local HEAD commit BEFORE the tar replacement so
|
||||||
|
# that, if it changes after the fetch (= a remote ``git
|
||||||
|
# checkout`` happened between refreshes), we can ask the
|
||||||
|
# remote which tracked files differ between the two commits
|
||||||
|
# and refresh just those local cache copies. Without this,
|
||||||
|
# the materialise pass would mark every clean tracked file
|
||||||
|
# as ``skip-worktree`` and Sublime keeps showing the previous
|
||||||
|
# branch's bytes.
|
||||||
|
prev_local_head_sha = (
|
||||||
|
_read_local_head_commit_sha(repo.local_root)
|
||||||
|
if local_dot_git_present
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
fetch_result = fetch_remote_dot_git(host_alias, repo)
|
fetch_result = fetch_remote_dot_git(host_alias, repo)
|
||||||
_trace_event(
|
_trace_event(
|
||||||
"git.dot_git_fetch",
|
"git.dot_git_fetch",
|
||||||
@@ -7287,7 +7384,33 @@ def _run_track_g_refresh(
|
|||||||
remote_root=repo.remote_root,
|
remote_root=repo.remote_root,
|
||||||
error=str(error),
|
error=str(error),
|
||||||
)
|
)
|
||||||
materialise_result = materialise_working_tree(host_alias, repo)
|
new_local_head_sha = _read_local_head_commit_sha(repo.local_root)
|
||||||
|
extra_refresh: Tuple[str, ...] = ()
|
||||||
|
if (
|
||||||
|
prev_local_head_sha
|
||||||
|
and new_local_head_sha
|
||||||
|
and prev_local_head_sha != new_local_head_sha
|
||||||
|
):
|
||||||
|
extra_refresh = _diff_changed_paths_on_remote(
|
||||||
|
host_alias,
|
||||||
|
repo.remote_root,
|
||||||
|
prev_local_head_sha,
|
||||||
|
new_local_head_sha,
|
||||||
|
)
|
||||||
|
_trace_event(
|
||||||
|
"git.remote_head_changed",
|
||||||
|
host_alias=host_alias,
|
||||||
|
remote_root=repo.remote_root,
|
||||||
|
prev_head=prev_local_head_sha,
|
||||||
|
new_head=new_local_head_sha,
|
||||||
|
refresh_count=len(extra_refresh),
|
||||||
|
)
|
||||||
|
|
||||||
|
materialise_result = materialise_working_tree(
|
||||||
|
host_alias,
|
||||||
|
repo,
|
||||||
|
extra_force_refresh=extra_refresh,
|
||||||
|
)
|
||||||
_trace_event(
|
_trace_event(
|
||||||
"git.materialise",
|
"git.materialise",
|
||||||
host_alias=host_alias,
|
host_alias=host_alias,
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ def materialise_working_tree(
|
|||||||
exec_once: Optional[ExecOnceFn] = None,
|
exec_once: Optional[ExecOnceFn] = None,
|
||||||
read_file: Optional[ReadFileFn] = None,
|
read_file: Optional[ReadFileFn] = None,
|
||||||
git_local: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
git_local: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||||
|
extra_force_refresh: Iterable[str] = (),
|
||||||
) -> MaterialiseResult:
|
) -> MaterialiseResult:
|
||||||
"""Apply the v0 materialisation policy against one repo.
|
"""Apply the v0 materialisation policy against one repo.
|
||||||
|
|
||||||
@@ -323,8 +324,17 @@ def materialise_working_tree(
|
|||||||
# 3. fetch dirty file content. Sequential reads in v0 — these are
|
# 3. fetch dirty file content. Sequential reads in v0 — these are
|
||||||
# bounded by the user's actually-edited file count, not repo
|
# bounded by the user's actually-edited file count, not repo
|
||||||
# size, so the round-trip cost is acceptable.
|
# size, so the round-trip cost is acceptable.
|
||||||
|
#
|
||||||
|
# ``extra_force_refresh`` carries paths the caller already knows are
|
||||||
|
# stale even though remote ``git status`` calls them clean — e.g.
|
||||||
|
# files that changed between commits across a remote-side branch
|
||||||
|
# checkout. Without this hatch the local cache keeps the previous
|
||||||
|
# branch's bytes (skip-worktree hides the staleness from git but
|
||||||
|
# Sublime opens the wrong content).
|
||||||
|
refresh_set = set(classification.dirty_modified)
|
||||||
|
refresh_set.update(extra_force_refresh)
|
||||||
fetched = 0
|
fetched = 0
|
||||||
for relative in classification.dirty_modified:
|
for relative in sorted(refresh_set):
|
||||||
remote_path = "{}/{}".format(repo.remote_root.rstrip("/"), relative)
|
remote_path = "{}/{}".format(repo.remote_root.rstrip("/"), relative)
|
||||||
local_path = repo.local_root / relative
|
local_path = repo.local_root / relative
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -50,3 +50,33 @@ def test_command_palette_prioritizes_recent_workspace_entry() -> None:
|
|||||||
# ``sessions_show_dev_commands`` is false (the default).
|
# ``sessions_show_dev_commands`` is false (the default).
|
||||||
assert "sessions_open_remote_marimo" in palette_command_set
|
assert "sessions_open_remote_marimo" in palette_command_set
|
||||||
assert "sessions_stop_remote_marimo" in palette_command_set
|
assert "sessions_stop_remote_marimo" in palette_command_set
|
||||||
|
|
||||||
|
|
||||||
|
def test_side_bar_menu_declares_paths_placeholder() -> None:
|
||||||
|
"""Side Bar context-menu commands must carry ``"args": {"paths": []}``.
|
||||||
|
|
||||||
|
Without that placeholder Sublime does NOT auto-populate ``paths`` from
|
||||||
|
the right-clicked items, which makes ``sessions_expand_deferred_directory``
|
||||||
|
and ``sessions_delete_remote_file`` fall through to the no-arg path
|
||||||
|
(input panel for expand, status warning for delete) instead of acting
|
||||||
|
on the clicked folder/file. Pinned to catch a regression where the
|
||||||
|
placeholder gets dropped during a menu refactor.
|
||||||
|
"""
|
||||||
|
menu_path = Path(__file__).resolve().parents[1] / "Side Bar.sublime-menu"
|
||||||
|
payload = json.loads(menu_path.read_text(encoding="utf-8"))
|
||||||
|
sessions_entries = [
|
||||||
|
item for item in payload if str(item.get("command", "")).startswith("sessions_")
|
||||||
|
]
|
||||||
|
assert sessions_entries, (
|
||||||
|
"expected at least one Sessions entry in Side Bar.sublime-menu"
|
||||||
|
)
|
||||||
|
for item in sessions_entries:
|
||||||
|
args = item.get("args")
|
||||||
|
assert isinstance(args, dict), (
|
||||||
|
"Side-bar entry {!r} must declare an 'args' dict so Sublime can "
|
||||||
|
"inject the clicked paths.".format(item.get("command"))
|
||||||
|
)
|
||||||
|
assert args.get("paths") == [], (
|
||||||
|
"Side-bar entry {!r} must declare 'paths': [] as the placeholder "
|
||||||
|
"Sublime fills with the right-clicked paths.".format(item.get("command"))
|
||||||
|
)
|
||||||
|
|||||||
@@ -144,3 +144,134 @@ def test_synthesize_no_op_for_detached_head(tmp_path: Path, monkeypatch) -> None
|
|||||||
commands._synthesize_pending_checkout_if_local_head_diverged(repo)
|
commands._synthesize_pending_checkout_if_local_head_diverged(repo)
|
||||||
marker = repo.local_root / ".git" / "SESSIONS_PENDING_CHECKOUT"
|
marker = repo.local_root / ".git" / "SESSIONS_PENDING_CHECKOUT"
|
||||||
assert not marker.exists()
|
assert not marker.exists()
|
||||||
|
|
||||||
|
|
||||||
|
# --- _read_local_head_commit_sha + _diff_changed_paths_on_remote ---
|
||||||
|
#
|
||||||
|
# These power the remote→local branch-sync path: when remote ``git
|
||||||
|
# checkout`` rewrites ``.git/HEAD`` between two refreshes, the
|
||||||
|
# materialise pass needs to know which tracked files changed so it can
|
||||||
|
# overwrite local cache copies (otherwise skip-worktree hides the
|
||||||
|
# stale bytes).
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_local_head_commit_sha_resolves_loose_branch_ref(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repo = _make_repo(tmp_path)
|
||||||
|
git_dir = repo.local_root / ".git"
|
||||||
|
(git_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
|
||||||
|
refs_main = git_dir / "refs" / "heads" / "main"
|
||||||
|
refs_main.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
refs_main.write_text("abcdef1234567890abcdef1234567890abcdef12\n", encoding="utf-8")
|
||||||
|
assert commands._read_local_head_commit_sha(repo.local_root) == (
|
||||||
|
"abcdef1234567890abcdef1234567890abcdef12"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_local_head_commit_sha_falls_back_to_packed_refs(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repo = _make_repo(tmp_path)
|
||||||
|
git_dir = repo.local_root / ".git"
|
||||||
|
(git_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
|
||||||
|
(git_dir / "packed-refs").write_text(
|
||||||
|
"# pack-refs with: peeled fully-peeled sorted\n"
|
||||||
|
"abcdef1234567890abcdef1234567890abcdef12 refs/heads/main\n"
|
||||||
|
"^cafe1234cafe1234cafe1234cafe1234cafe1234\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert commands._read_local_head_commit_sha(repo.local_root) == (
|
||||||
|
"abcdef1234567890abcdef1234567890abcdef12"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_local_head_commit_sha_returns_detached_head(tmp_path: Path) -> None:
|
||||||
|
repo = _make_repo(tmp_path)
|
||||||
|
sha = "deadbeef00000000000000000000000000000000"
|
||||||
|
(repo.local_root / ".git" / "HEAD").write_text(sha + "\n", encoding="utf-8")
|
||||||
|
assert commands._read_local_head_commit_sha(repo.local_root) == sha
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_local_head_commit_sha_returns_empty_when_unreadable(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repo = _make_repo(tmp_path)
|
||||||
|
# No HEAD written.
|
||||||
|
assert commands._read_local_head_commit_sha(repo.local_root) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_local_head_commit_sha_returns_empty_for_unknown_ref(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repo = _make_repo(tmp_path)
|
||||||
|
(repo.local_root / ".git" / "HEAD").write_text(
|
||||||
|
"ref: refs/heads/missing\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert commands._read_local_head_commit_sha(repo.local_root) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_changed_paths_on_remote_returns_files(monkeypatch) -> None:
|
||||||
|
from sessions.ssh_file_transport import RemoteExecOnceResult
|
||||||
|
|
||||||
|
captured: list = []
|
||||||
|
|
||||||
|
def fake_exec(host_alias, *, argv, cwd, timeout_ms):
|
||||||
|
captured.append((host_alias, tuple(argv), cwd))
|
||||||
|
return RemoteExecOnceResult(
|
||||||
|
exit_code=0,
|
||||||
|
stdout="src/main.py\x00pkg/a.py\x00",
|
||||||
|
stderr="",
|
||||||
|
timed_out=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(commands, "execute_remote_exec_once", fake_exec)
|
||||||
|
out = commands._diff_changed_paths_on_remote(
|
||||||
|
"prod", "/srv/ws", "old1234old1234", "new5678new5678"
|
||||||
|
)
|
||||||
|
assert out == ("src/main.py", "pkg/a.py")
|
||||||
|
assert captured[0][1] == (
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
"/srv/ws",
|
||||||
|
"diff",
|
||||||
|
"--name-only",
|
||||||
|
"-z",
|
||||||
|
"old1234old1234",
|
||||||
|
"new5678new5678",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_changed_paths_on_remote_handles_identical_shas() -> None:
|
||||||
|
"""If old == new, skip the round-trip entirely."""
|
||||||
|
out = commands._diff_changed_paths_on_remote("prod", "/srv/ws", "same", "same")
|
||||||
|
assert out == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_changed_paths_on_remote_returns_empty_on_failure(monkeypatch) -> None:
|
||||||
|
"""Diff failures (e.g. rebase garbage-collected old SHA) yield ()."""
|
||||||
|
from sessions.ssh_file_transport import RemoteExecOnceResult
|
||||||
|
|
||||||
|
def fake_exec(host_alias, *, argv, cwd, timeout_ms):
|
||||||
|
return RemoteExecOnceResult(
|
||||||
|
exit_code=128,
|
||||||
|
stdout="",
|
||||||
|
stderr="fatal: bad revision",
|
||||||
|
timed_out=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(commands, "execute_remote_exec_once", fake_exec)
|
||||||
|
out = commands._diff_changed_paths_on_remote("prod", "/srv/ws", "old", "new")
|
||||||
|
assert out == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_changed_paths_on_remote_handles_transport_error(monkeypatch) -> None:
|
||||||
|
"""SessionHelperStartError must not bubble out — return () so the
|
||||||
|
caller still runs the materialise without the extra refresh."""
|
||||||
|
from sessions.connect_preflight import SessionHelperStartError
|
||||||
|
|
||||||
|
def fake_exec(*args, **kwargs):
|
||||||
|
raise SessionHelperStartError("ssh down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(commands, "execute_remote_exec_once", fake_exec)
|
||||||
|
assert commands._diff_changed_paths_on_remote("prod", "/srv/ws", "old", "new") == ()
|
||||||
|
|||||||
@@ -339,3 +339,100 @@ def test_materialise_reports_dirty_fetch_exception(tmp_path: Path) -> None:
|
|||||||
# Skip-worktree count reflects the work that completed before the
|
# Skip-worktree count reflects the work that completed before the
|
||||||
# fetch failure (zero clean tracked here, but the field is set).
|
# fetch failure (zero clean tracked here, but the field is set).
|
||||||
assert result.skip_worktree_set == 0
|
assert result.skip_worktree_set == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialise_extra_force_refresh_pulls_clean_files_too(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Caller-supplied refresh list overrides the clean-tracked default.
|
||||||
|
|
||||||
|
Exercises the remote→local branch-sync hatch: when the caller has
|
||||||
|
already detected a HEAD swap and asked for specific paths to be
|
||||||
|
refreshed, ``materialise_working_tree`` fetches them via
|
||||||
|
``read_file`` even though remote ``git status`` reports them
|
||||||
|
clean. Without this hatch the local cache keeps the previous
|
||||||
|
branch's bytes.
|
||||||
|
"""
|
||||||
|
repo = _make_repo(tmp_path)
|
||||||
|
repo.local_root.mkdir()
|
||||||
|
|
||||||
|
def fake_exec(
|
||||||
|
host_alias: str, argv, cwd: str, timeout_ms: int
|
||||||
|
) -> RemoteExecOnceResult:
|
||||||
|
if "ls-files" in argv:
|
||||||
|
return _ok_exec(stdout="README.md\x00src/main.py\x00pkg/a.py\x00")
|
||||||
|
if "status" in argv:
|
||||||
|
return _ok_exec(stdout="") # everything clean — branch swap case
|
||||||
|
return _ok_exec(exit_code=2, stderr="unexpected argv")
|
||||||
|
|
||||||
|
read_calls: List[str] = []
|
||||||
|
|
||||||
|
def fake_read(
|
||||||
|
host_alias: str, request: RemoteReadFileRequest
|
||||||
|
) -> RemoteReadFileResult:
|
||||||
|
read_calls.append(request.remote_absolute_path)
|
||||||
|
return _ok_read(b"new branch bytes\n")
|
||||||
|
|
||||||
|
def fake_git_local(argv, **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||||
|
return SimpleNamespace(returncode=0, stdout="", stderr="") # type: ignore[return-value]
|
||||||
|
|
||||||
|
result = materialise_working_tree(
|
||||||
|
"guanine",
|
||||||
|
repo,
|
||||||
|
exec_once=fake_exec,
|
||||||
|
read_file=fake_read,
|
||||||
|
git_local=fake_git_local,
|
||||||
|
extra_force_refresh=("README.md", "pkg/a.py"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.ok
|
||||||
|
assert result.error_detail is None
|
||||||
|
# Skip-worktree still set on every clean tracked path; the refresh
|
||||||
|
# list does not subtract from clean_tracked, it just forces extra fetches.
|
||||||
|
assert result.skip_worktree_set == 3
|
||||||
|
# Both forced paths fetched + written. ``src/main.py`` was clean
|
||||||
|
# AND not in the refresh list — left alone (skip-worktree only).
|
||||||
|
assert sorted(read_calls) == ["/srv/ws/README.md", "/srv/ws/pkg/a.py"]
|
||||||
|
assert result.files_fetched == 2
|
||||||
|
assert (repo.local_root / "README.md").read_bytes() == b"new branch bytes\n"
|
||||||
|
assert (repo.local_root / "pkg" / "a.py").read_bytes() == b"new branch bytes\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialise_extra_force_refresh_merges_with_dirty_modified(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""If a path is both ``dirty_modified`` and force-refreshed, fetch once."""
|
||||||
|
repo = _make_repo(tmp_path)
|
||||||
|
repo.local_root.mkdir()
|
||||||
|
|
||||||
|
def fake_exec(
|
||||||
|
host_alias: str, argv, cwd: str, timeout_ms: int
|
||||||
|
) -> RemoteExecOnceResult:
|
||||||
|
if "ls-files" in argv:
|
||||||
|
return _ok_exec(stdout="src/main.py\x00")
|
||||||
|
if "status" in argv:
|
||||||
|
return _ok_exec(stdout="1 .M N... 100644 100644 100644 a b src/main.py\x00")
|
||||||
|
return _ok_exec(exit_code=2)
|
||||||
|
|
||||||
|
read_calls: List[str] = []
|
||||||
|
|
||||||
|
def fake_read(
|
||||||
|
host_alias: str, request: RemoteReadFileRequest
|
||||||
|
) -> RemoteReadFileResult:
|
||||||
|
read_calls.append(request.remote_absolute_path)
|
||||||
|
return _ok_read(b"x")
|
||||||
|
|
||||||
|
def fake_git_local(argv, **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||||
|
return SimpleNamespace(returncode=0, stdout="", stderr="") # type: ignore[return-value]
|
||||||
|
|
||||||
|
result = materialise_working_tree(
|
||||||
|
"h",
|
||||||
|
repo,
|
||||||
|
exec_once=fake_exec,
|
||||||
|
read_file=fake_read,
|
||||||
|
git_local=fake_git_local,
|
||||||
|
extra_force_refresh=("src/main.py",),
|
||||||
|
)
|
||||||
|
assert result.ok
|
||||||
|
assert read_calls == ["/srv/ws/src/main.py"] # exactly once, not duplicated
|
||||||
|
assert result.files_fetched == 1
|
||||||
|
|||||||
Reference in New Issue
Block a user