Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dca8fb5a9c | |||
| 5566e9ec16 | |||
| 836d7e4a73 | |||
| 0dc93212de | |||
| b44f708892 | |||
| 5c8a29efa5 | |||
| 718c7bcc42 | |||
| d51e5f2f05 | |||
| aa0202f287 | |||
| e21b3a4d8a | |||
| 2f237ac265 | |||
| 3a8e86ca6b | |||
| 8b08e5778a | |||
| 291bfc70e4 | |||
| 8db28d609c | |||
| b2f933490a | |||
| 63ef3a8313 | |||
| 05c08e3223 | |||
| 20227dde4d | |||
| b5d5404f73 | |||
| 1fbfa8010b | |||
| 927b685059 | |||
| 6730c9ddfd | |||
| 10868231ae | |||
| 32c3e6241a | |||
| 9691726d99 |
@@ -295,12 +295,14 @@ queue/dispatcher/lane gating + `_CONNECT_GENERATION` token 의미 + `_connect_ge
|
||||
### PR 17+ — 본 plan scope 밖 (별도 갱신)
|
||||
|
||||
PR 16(PR-A) land 후 본 plan을 갱신해서:
|
||||
- **PR-B**: mirror BFS task body, eager_hydrate apply 본체 → orchestrator (PR 13b envelope 위에서)
|
||||
- **H3-queue**: BACKLOG H3 본 이관 (queue 본체)
|
||||
- **H2-save / H2-connect**: BACKLOG H2 분할 (Track H2 main track 흡수)
|
||||
- **`_rust_ffi` 디코더 Rust 이관**: `_parse_*_outcome` Rust ABI typed JSON (Rust schema oracle 도구는 잔존 쟁점 #6 결정 후)
|
||||
- **PR 17 / PR-B** ✅ `9691726` — eager_hydrate apply pass body → `sessions_native::eager_hydrate::run_apply_pass`. Python driver 삭제(`run_eager_hydrate`/`batched`/`EagerHydrateSummary`); 1 Rust round-trip per pass + Python sidecar 쓰기.
|
||||
- **PR 18 / H3-queue 본 이관** ⏸ **architectural blocker** — callable dispatch가 Python 잔존(rust-pragmatist 양보 영역, PR 16c Lint #2 grandfather)이라 deque 본체를 Rust로 옮기려면 PyO3 callback registry가 필요. `_BACKGROUND_PENDING_KEYS` / `_BACKGROUND_INFLIGHT_KEYS` 같은 dedup state만 옮기는 부분적 이관은 critical section 안 FFI cost를 추가하고 LOC 절감은 ~30 LOC로 한계 — 가성비 낮음. 잔존 쟁점 #8 (PyO3 ADR) 결정 시점에 재평가.
|
||||
- **PR 19 / `_rust_ffi` 디코더 Rust 이관** ⏸ — `_parse_open_outcome` / `_parse_request_outcome` 만 잔존(~30 LOC). 현 구현은 *이미* Rust ABI에서 받은 JSON을 typed dataclass로 wrap만 함. 완전 이관에는 C 태그드 유니온 또는 PyO3 — 잔존 쟁점 #8과 묶여 PR 18과 동일한 ADR 의존.
|
||||
- **H2-save / H2-connect**: BACKLOG H2 분할 (Track H2 main track 흡수, *병행* — main track 이관 saturate 후 가시 LOC 절감을 위한 다음 슬라이스).
|
||||
- **데드라인 Layer 3** auto-revert 활성화
|
||||
|
||||
**현 시점 상태:** main track 이관(책임 위치를 Rust로) 의 high-impact 슬라이스는 PR 0–17에서 모두 land. 잔여 PR 18/19는 PyO3 ADR 결정에 묶임. Track H2 (Python 내부 응집 — 파일 분할)이 다음 가시 가치 슬라이스.
|
||||
|
||||
이 시점에 commands.py 예상 LOC: 7394 - (worker loop ~550) - (connect SM ~330 부분) - (hydrate preflight ~300, PR 12–14 영향) ≈ **5500–6000 LOC**.
|
||||
|
||||
> **rust-maximalist의 "2000 LOC 미만" 목표는 본 plan scope 안에서는 미달성.** 그가 1라운드에서 인정한 도전 질문(Wave 5 후 5000+ LOC 잔존) 그대로다. 본 plan은 *책임 위치* 정상화에 집중하고, *파일 분할*은 Track H2(Python 내부 응집)에서 별도 진행.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "sessions-sublime"
|
||||
version = "0.7.26"
|
||||
version = "0.7.43"
|
||||
description = "Sublime-facing Python code for Sessions."
|
||||
requires-python = ">=3.8"
|
||||
license = {text = "MIT"}
|
||||
|
||||
13
rust/Cargo.lock
generated
13
rust/Cargo.lock
generated
@@ -221,7 +221,7 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "local_bridge"
|
||||
version = "0.7.26"
|
||||
version = "0.7.43"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"glob",
|
||||
@@ -432,7 +432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "session_helper"
|
||||
version = "0.7.26"
|
||||
version = "0.7.43"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"notify",
|
||||
@@ -443,7 +443,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "session_protocol"
|
||||
version = "0.7.26"
|
||||
version = "0.7.43"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"serde",
|
||||
@@ -452,16 +452,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sessions_askpass"
|
||||
version = "0.7.26"
|
||||
version = "0.7.43"
|
||||
dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sessions_native"
|
||||
version = "0.7.26"
|
||||
version = "0.7.43"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"notify",
|
||||
"serde_json",
|
||||
"session_protocol",
|
||||
"tempfile",
|
||||
@@ -772,7 +773,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "workspace_identity"
|
||||
version = "0.7.26"
|
||||
version = "0.7.43"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
version = "0.7.26"
|
||||
version = "0.7.43"
|
||||
authors = ["Myeongseon Choi <key262yek@gmail.com>"]
|
||||
repository = "https://git.teahaven.kr/sublime-rs/sessions"
|
||||
homepage = "https://git.teahaven.kr/sublime-rs/sessions"
|
||||
|
||||
@@ -49,12 +49,23 @@ fn main() {
|
||||
.map(String::as_str)
|
||||
.is_some_and(|first| matches!(first, "--version" | "-V" | "version"))
|
||||
{
|
||||
println!("{LOCAL_BRIDGE_VERSION_BANNER}");
|
||||
// Use ``writeln!`` + ``let _`` so EPIPE silently fails through to
|
||||
// ``return`` instead of panicking → SIGABRT under
|
||||
// ``panic = "abort"``. Same rationale as the eprintln sites
|
||||
// hardened in v0.7.39 (b44f708): a parent that closes its end of
|
||||
// our stdout before we write must not generate a phantom crash.
|
||||
let _ = writeln!(std::io::stdout(), "{LOCAL_BRIDGE_VERSION_BANNER}");
|
||||
return;
|
||||
}
|
||||
if args.first().map(String::as_str) == Some("lsp-stdio") {
|
||||
if let Err(error) = run_lsp_stdio(&args[1..]) {
|
||||
eprintln!("{error}");
|
||||
// ``eprintln!`` panics on EPIPE (and ``panic = "abort"`` would then
|
||||
// SIGABRT the process). When the parent (Sublime + Python ctypes)
|
||||
// dies first the bridge inherits a broken stderr pipe, and a
|
||||
// secondary abort here only adds a phantom crash report that
|
||||
// hides the real upstream failure. Use ``writeln!`` + ``let _``
|
||||
// so EPIPE silently fails through to ``exit(1)``.
|
||||
let _ = writeln!(std::io::stderr(), "{error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return;
|
||||
@@ -62,15 +73,27 @@ fn main() {
|
||||
match run(&args) {
|
||||
Ok(output) => match serde_json::to_string(&output) {
|
||||
Ok(encoded) => {
|
||||
println!("{encoded}");
|
||||
// ``writeln!`` + ``let _`` here for the same reason as the
|
||||
// eprintln sites hardened in v0.7.39: when Sublime / the
|
||||
// Python ctypes parent dies first the bridge inherits a
|
||||
// broken stdout pipe, and a bare ``println!`` panics on
|
||||
// EPIPE → SIGABRT under ``panic = "abort"``. The earlier
|
||||
// pass only covered stderr; this is the missed stdout
|
||||
// site that produced the
|
||||
// ``local_bridge::main hf88e153b048e40f5 main.rs:71``
|
||||
// abort signature in user crash reports.
|
||||
let _ = writeln!(std::io::stdout(), "{encoded}");
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("local_bridge output serialization failed: {error}");
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"local_bridge output serialization failed: {error}"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
let _ = writeln!(std::io::stderr(), "{error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
notify = "8.2.0"
|
||||
serde_json = "1"
|
||||
session_protocol = { path = "../session_protocol" }
|
||||
workspace_identity = { path = "../workspace_identity" }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Eager-hydrate placeholder discovery (Wave 2 PR 14).
|
||||
//! Eager-hydrate placeholder discovery (Wave 2 PR 14) + apply pass body
|
||||
//! (Wave 2 PR 17 / PR-B).
|
||||
//!
|
||||
//! Walks a local cache root and yields zero-byte regular files whose basename
|
||||
//! is in an allow-list. Mirrors the Python ``find_placeholder_candidates``
|
||||
@@ -11,15 +12,23 @@
|
||||
//! cache → produces what candidates it can).
|
||||
//! - Empty allow-list returns no candidates.
|
||||
//!
|
||||
//! Batching/sleep pacing stays in Python. The Rust side returns a sorted
|
||||
//! `Vec<String>` of absolute paths so the caller can deterministically batch
|
||||
//! over the result without invoking a Python callback per file (the FFI
|
||||
//! round-trip cost outweighs any LOC savings — see rust-pragmatist's note
|
||||
//! in the team synthesis).
|
||||
//! PR-B (apply pass body) extends the Rust ownership: the loop, batch
|
||||
//! pacing, per-placeholder ``file_open`` transaction, and outcome
|
||||
//! collection all run in Rust. Python becomes a thin caller — one FFI
|
||||
//! round-trip per pass, then writes sidecar metadata for hydrated entries.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::file_open;
|
||||
use crate::map_local_to_remote_path;
|
||||
|
||||
/// Return zero-byte regular files under `cache_root` whose basename is in
|
||||
/// `allowed_basenames`. Order is BFS-stable but not lexicographic.
|
||||
@@ -91,6 +100,174 @@ pub fn find_placeholder_candidates(
|
||||
out
|
||||
}
|
||||
|
||||
/// Drive one eager-hydrate apply pass over placeholders under
|
||||
/// ``cache_root``. Returns a JSON object summarising the pass:
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "hydrated": [{"local_path": "...", "metadata": {...}}, ...],
|
||||
/// "skipped_existing": N,
|
||||
/// "failed": M
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Re-checks zero-byte before fetch (so a concurrent path filling the
|
||||
/// placeholder lands in ``skipped_existing`` rather than re-fetched),
|
||||
/// counts failures without aborting, and pauses ``batch_sleep_ms``
|
||||
/// between batches.
|
||||
///
|
||||
/// Per-batch, runs up to ``parallelism`` ``file_open`` transactions
|
||||
/// concurrently (the broker session multiplexes by envelope id, so
|
||||
/// concurrent file/read requests are safe). ``parallelism = 1``
|
||||
/// preserves the strictly sequential PR-B behaviour. Setting it
|
||||
/// higher cuts the wall-clock of a 50-placeholder pass roughly
|
||||
/// linearly until per-placeholder latency becomes helper-bound rather
|
||||
/// than round-trip-bound.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run_apply_pass(
|
||||
cache_root: &Path,
|
||||
host_alias: &str,
|
||||
remote_workspace_root: &str,
|
||||
allowed_basenames: &[String],
|
||||
batch_size: usize,
|
||||
batch_sleep_ms: u64,
|
||||
max_open_bytes: u64,
|
||||
binary_probe_bytes: usize,
|
||||
allow_empty: bool,
|
||||
timeout_ms: u64,
|
||||
parallelism: usize,
|
||||
) -> Value {
|
||||
let placeholders = find_placeholder_candidates(cache_root, allowed_basenames);
|
||||
let hydrated: Mutex<Vec<Value>> = Mutex::new(Vec::new());
|
||||
let skipped_existing = AtomicUsize::new(0);
|
||||
let failed = AtomicUsize::new(0);
|
||||
|
||||
let batch_size_safe = if batch_size == 0 { 1 } else { batch_size };
|
||||
let parallelism_safe = parallelism.max(1);
|
||||
|
||||
for (batch_index, batch) in placeholders.chunks(batch_size_safe).enumerate() {
|
||||
if batch_index > 0 && batch_sleep_ms > 0 {
|
||||
thread::sleep(Duration::from_millis(batch_sleep_ms));
|
||||
}
|
||||
let workers = parallelism_safe.min(batch.len()).max(1);
|
||||
if workers <= 1 {
|
||||
// Fast path — avoid scope/Mutex overhead for tiny batches.
|
||||
for path in batch {
|
||||
process_placeholder(
|
||||
path,
|
||||
host_alias,
|
||||
remote_workspace_root,
|
||||
cache_root,
|
||||
max_open_bytes,
|
||||
binary_probe_bytes,
|
||||
allow_empty,
|
||||
timeout_ms,
|
||||
&hydrated,
|
||||
&skipped_existing,
|
||||
&failed,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let work_queue: Mutex<Vec<&PathBuf>> = Mutex::new(batch.iter().collect());
|
||||
thread::scope(|s| {
|
||||
for _ in 0..workers {
|
||||
let work_queue_ref = &work_queue;
|
||||
let hydrated_ref = &hydrated;
|
||||
let skipped_ref = &skipped_existing;
|
||||
let failed_ref = &failed;
|
||||
s.spawn(move || {
|
||||
loop {
|
||||
let next = match work_queue_ref.lock() {
|
||||
Ok(mut q) => q.pop(),
|
||||
Err(_) => break,
|
||||
};
|
||||
let Some(path) = next else { break };
|
||||
process_placeholder(
|
||||
path,
|
||||
host_alias,
|
||||
remote_workspace_root,
|
||||
cache_root,
|
||||
max_open_bytes,
|
||||
binary_probe_bytes,
|
||||
allow_empty,
|
||||
timeout_ms,
|
||||
hydrated_ref,
|
||||
skipped_ref,
|
||||
failed_ref,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let hydrated_vec = hydrated.into_inner().unwrap_or_default();
|
||||
json!({
|
||||
"hydrated": hydrated_vec,
|
||||
"skipped_existing": skipped_existing.into_inner(),
|
||||
"failed": failed.into_inner(),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn process_placeholder(
|
||||
path: &Path,
|
||||
host_alias: &str,
|
||||
remote_workspace_root: &str,
|
||||
cache_root: &Path,
|
||||
max_open_bytes: u64,
|
||||
binary_probe_bytes: usize,
|
||||
allow_empty: bool,
|
||||
timeout_ms: u64,
|
||||
hydrated: &Mutex<Vec<Value>>,
|
||||
skipped_existing: &AtomicUsize,
|
||||
failed: &AtomicUsize,
|
||||
) {
|
||||
// Re-check zero-byte: a concurrent path (sidebar hydrate /
|
||||
// on-demand fetch) may have filled the placeholder while we
|
||||
// were iterating. Mirror Python's pre-fetch guard.
|
||||
let still_placeholder = match path.metadata() {
|
||||
Ok(m) => m.is_file() && m.len() == 0,
|
||||
Err(_) => false,
|
||||
};
|
||||
if !still_placeholder {
|
||||
skipped_existing.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
|
||||
let remote = match map_local_to_remote_path(remote_workspace_root, cache_root, path) {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
failed.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let outcome = file_open::run_file_open_transaction(
|
||||
host_alias,
|
||||
&remote,
|
||||
path,
|
||||
max_open_bytes,
|
||||
binary_probe_bytes,
|
||||
allow_empty,
|
||||
timeout_ms,
|
||||
);
|
||||
let outcome_str = outcome.get("outcome").and_then(Value::as_str).unwrap_or("");
|
||||
if outcome_str == "OK" {
|
||||
let metadata = outcome.get("metadata").cloned().unwrap_or(Value::Null);
|
||||
let entry = json!({
|
||||
"local_path": path.to_string_lossy(),
|
||||
"metadata": metadata,
|
||||
});
|
||||
if let Ok(mut h) = hydrated.lock() {
|
||||
h.push(entry);
|
||||
}
|
||||
} else {
|
||||
failed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -7,6 +7,7 @@ mod broker_ffi;
|
||||
mod eager_hydrate;
|
||||
mod file_open;
|
||||
mod interpreter_probe;
|
||||
mod local_watcher;
|
||||
pub mod orchestrator;
|
||||
mod settings_normalize;
|
||||
|
||||
@@ -257,7 +258,7 @@ fn write_output(out_buf: *mut c_char, out_cap: usize, value: &str) -> c_int {
|
||||
0
|
||||
}
|
||||
|
||||
fn normalize_local_path(path: &Path) -> PathBuf {
|
||||
pub(crate) fn normalize_local_path(path: &Path) -> PathBuf {
|
||||
let base = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else if let Ok(cwd) = std::env::current_dir() {
|
||||
@@ -278,6 +279,45 @@ fn normalize_local_path(path: &Path) -> PathBuf {
|
||||
out
|
||||
}
|
||||
|
||||
/// Map ``local_path`` (under ``files_cache_root``) back to a remote POSIX
|
||||
/// path. Returns ``None`` when the path does not belong to this cache root.
|
||||
///
|
||||
/// Mirrors the ABI ``sessions_file_map_local_to_remote`` logic so the
|
||||
/// orchestrator-side (eager hydrate, mirror BFS body) does not need to
|
||||
/// re-implement it.
|
||||
pub(crate) fn map_local_to_remote_path(
|
||||
remote_root: &str,
|
||||
files_cache_root: &Path,
|
||||
local_path: &Path,
|
||||
) -> Option<String> {
|
||||
let cache_root = normalize_local_path(files_cache_root);
|
||||
let local = normalize_local_path(local_path);
|
||||
let extern_root = cache_root.join("__extern");
|
||||
if let Ok(rel) = local.strip_prefix(&extern_root) {
|
||||
let rel_s = rel
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||
.collect::<Vec<String>>()
|
||||
.join("/");
|
||||
return Some(format!("/{}", rel_s));
|
||||
}
|
||||
let rel = local.strip_prefix(&cache_root).ok()?;
|
||||
let rel_s = rel
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||
.collect::<Vec<String>>()
|
||||
.join("/");
|
||||
let root_trim = remote_root.trim_end_matches('/');
|
||||
let remote = if root_trim.is_empty() || root_trim == "/" {
|
||||
format!("/{}", rel_s)
|
||||
} else if rel_s.is_empty() {
|
||||
root_trim.to_string()
|
||||
} else {
|
||||
format!("{}/{}", root_trim, rel_s)
|
||||
};
|
||||
Some(remote)
|
||||
}
|
||||
|
||||
fn split_posix(path: &str) -> Vec<&str> {
|
||||
path.split('/').filter(|part| !part.is_empty()).collect()
|
||||
}
|
||||
@@ -828,35 +868,14 @@ pub unsafe extern "C" fn sessions_file_map_local_to_remote(
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
|
||||
let cache_root = normalize_local_path(Path::new(files_cache_root_s));
|
||||
let local = normalize_local_path(Path::new(local_path_s));
|
||||
let extern_root = cache_root.join("__extern");
|
||||
if let Ok(rel) = local.strip_prefix(&extern_root) {
|
||||
let rel_s = rel
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||
.collect::<Vec<String>>()
|
||||
.join("/");
|
||||
let remote = format!("/{}", rel_s);
|
||||
return write_output(out_buf, out_cap, &remote);
|
||||
match map_local_to_remote_path(
|
||||
remote_root_s,
|
||||
Path::new(files_cache_root_s),
|
||||
Path::new(local_path_s),
|
||||
) {
|
||||
Some(remote) => write_output(out_buf, out_cap, &remote),
|
||||
None => 1,
|
||||
}
|
||||
let Ok(rel) = local.strip_prefix(&cache_root) else {
|
||||
return 1;
|
||||
};
|
||||
let rel_s = rel
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||
.collect::<Vec<String>>()
|
||||
.join("/");
|
||||
let root_trim = remote_root_s.trim_end_matches('/');
|
||||
let remote = if root_trim.is_empty() || root_trim == "/" {
|
||||
format!("/{}", rel_s)
|
||||
} else if rel_s.is_empty() {
|
||||
root_trim.to_string()
|
||||
} else {
|
||||
format!("{}/{}", root_trim, rel_s)
|
||||
};
|
||||
write_output(out_buf, out_cap, &remote)
|
||||
}
|
||||
|
||||
/// Return `1` if local path is under `files_cache_root/__extern`, else `0`.
|
||||
@@ -1386,6 +1405,65 @@ pub unsafe extern "C" fn sessions_file_atomic_write(
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Local cache filesystem watcher (Wave 2 PR-C — cross-platform sync)
|
||||
// ===========================================================================
|
||||
|
||||
/// Start watching ``cache_root`` recursively. Returns a non-zero
|
||||
/// ``i64`` handle on success (the same handle threads through
|
||||
/// ``drain`` / ``stop``); ``0`` when the cache root is missing or the
|
||||
/// platform watcher could not be created (caller should fall back to
|
||||
/// the Sublime ``on_post_save`` listener only).
|
||||
///
|
||||
/// # Safety
|
||||
/// `cache_root` must be a valid UTF-8 C string.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_local_watcher_start(cache_root: *const c_char) -> i64 {
|
||||
if cache_root.is_null() {
|
||||
return 0;
|
||||
}
|
||||
let Ok(cache_root_s) = (unsafe { CStr::from_ptr(cache_root) }).to_str() else {
|
||||
return 0;
|
||||
};
|
||||
local_watcher::start(Path::new(cache_root_s))
|
||||
}
|
||||
|
||||
/// Drain the handle's pending events. Writes the deduplicated, sorted
|
||||
/// list of paths into ``out_buf`` joined by ``\x1F`` (unit separator,
|
||||
/// matches the encoding used by ``sessions_eager_hydrate_*``).
|
||||
/// Returns 0 on success, ``AbiError::NullPointer.code()`` when ``out_buf``
|
||||
/// is null, and ``-1`` when ``handle`` is unknown (caller treats as
|
||||
/// "watcher gone" and stops polling).
|
||||
///
|
||||
/// # Safety
|
||||
/// `out_buf` must be writable for `out_cap` bytes when non-null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_local_watcher_drain(
|
||||
handle: i64,
|
||||
out_buf: *mut c_char,
|
||||
out_cap: usize,
|
||||
) -> c_int {
|
||||
if out_buf.is_null() {
|
||||
return AbiError::NullPointer.code();
|
||||
}
|
||||
match local_watcher::drain(handle) {
|
||||
Some(joined) => write_output(out_buf, out_cap, &joined),
|
||||
None => -1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop watching, releasing the OS handle. Idempotent — safe to call
|
||||
/// repeatedly with the same handle. Returns ``1`` when a watcher was
|
||||
/// removed, ``0`` when ``handle`` was unknown.
|
||||
///
|
||||
/// # Safety
|
||||
/// Pure-int interface; no pointers. Marked ``unsafe extern "C"`` to
|
||||
/// match the rest of the watcher ABI surface.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_local_watcher_stop(handle: i64) -> c_int {
|
||||
if local_watcher::stop(handle) { 1 } else { 0 }
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Orchestrator FFI (Wave 2 PR 16 — PR-A core)
|
||||
// ===========================================================================
|
||||
@@ -1553,6 +1631,81 @@ pub unsafe extern "C" fn sessions_eager_hydrate_find_candidates(
|
||||
write_output(out_buf, out_cap, &joined)
|
||||
}
|
||||
|
||||
/// Run the eager-hydrate apply pass body (Wave 2 PR-B + PR-B.1).
|
||||
///
|
||||
/// One Rust round-trip drives the entire pass: find candidates →
|
||||
/// per-batch sleep → re-check zero-byte → map local→remote → file_open
|
||||
/// transaction (up to ``parallelism`` concurrent in-flight, broker
|
||||
/// multiplexes by envelope id) → collect outcomes. Python writes
|
||||
/// sidecar metadata for the returned ``hydrated`` list.
|
||||
///
|
||||
/// # Safety
|
||||
/// `cache_root`, `host_alias`, `remote_workspace_root`, and
|
||||
/// `allowed_basenames_joined` must be valid UTF-8 C strings (the latter
|
||||
/// uses 0x1F as the unit separator). `out_buf` must be writable for
|
||||
/// `out_cap` bytes when non-null. Returns 0 on success and writes a
|
||||
/// JSON object documented on
|
||||
/// :func:`eager_hydrate::run_apply_pass`.
|
||||
#[unsafe(no_mangle)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub unsafe extern "C" fn sessions_eager_hydrate_apply(
|
||||
cache_root: *const c_char,
|
||||
host_alias: *const c_char,
|
||||
remote_workspace_root: *const c_char,
|
||||
allowed_basenames_joined: *const c_char,
|
||||
batch_size: usize,
|
||||
batch_sleep_ms: u64,
|
||||
max_open_bytes: u64,
|
||||
binary_probe_bytes: usize,
|
||||
allow_empty: c_int,
|
||||
timeout_ms: u64,
|
||||
parallelism: usize,
|
||||
out_buf: *mut c_char,
|
||||
out_cap: usize,
|
||||
) -> c_int {
|
||||
if cache_root.is_null()
|
||||
|| host_alias.is_null()
|
||||
|| remote_workspace_root.is_null()
|
||||
|| allowed_basenames_joined.is_null()
|
||||
{
|
||||
return AbiError::NullPointer.code();
|
||||
}
|
||||
let Ok(cache_root_s) = (unsafe { CStr::from_ptr(cache_root) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let Ok(host_s) = (unsafe { CStr::from_ptr(host_alias) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let Ok(remote_root_s) = (unsafe { CStr::from_ptr(remote_workspace_root) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let Ok(allowed_s) = (unsafe { CStr::from_ptr(allowed_basenames_joined) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let allowed: Vec<String> = allowed_s
|
||||
.split('\x1f')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let summary = eager_hydrate::run_apply_pass(
|
||||
Path::new(cache_root_s),
|
||||
host_s,
|
||||
remote_root_s,
|
||||
&allowed,
|
||||
batch_size,
|
||||
batch_sleep_ms,
|
||||
max_open_bytes,
|
||||
binary_probe_bytes,
|
||||
allow_empty != 0,
|
||||
timeout_ms,
|
||||
parallelism,
|
||||
);
|
||||
let Ok(serialized) = serde_json::to_string(&summary) else {
|
||||
return AbiError::Serialization.code();
|
||||
};
|
||||
write_output(out_buf, out_cap, &serialized)
|
||||
}
|
||||
|
||||
/// Derive a human-friendly venv label from a remote interpreter path.
|
||||
///
|
||||
/// # Safety
|
||||
|
||||
324
rust/crates/sessions_native/src/local_watcher.rs
Normal file
324
rust/crates/sessions_native/src/local_watcher.rs
Normal file
@@ -0,0 +1,324 @@
|
||||
//! Local cache filesystem watcher (Wave 2 PR-C — cross-platform sync).
|
||||
//!
|
||||
//! Sublime Text only fires its ``on_post_save`` event for files saved
|
||||
//! through Sublime itself; external mutators (Sublime Merge stage/discard,
|
||||
//! ``vim``, build tools writing into the cache) bypass the listener and
|
||||
//! their changes never reach the remote. The result was the ``파일이 이미
|
||||
//! 존재한다는 이유`` save-conflict the user hit after a Sublime Merge
|
||||
//! discard: the local cache file diverged silently from the remote and
|
||||
//! the next Sessions save tripped the metadata-mismatch check.
|
||||
//!
|
||||
//! This module wraps the cross-platform ``notify`` crate
|
||||
//! (``RecommendedWatcher`` ⇒ FSEvents on macOS / inotify on Linux /
|
||||
//! ``ReadDirectoryChangesW`` on Windows) and exposes a polling-friendly
|
||||
//! drain API to Python:
|
||||
//!
|
||||
//! 1. ``start(cache_root)`` — recursively watches the workspace cache.
|
||||
//! Returns an opaque handle (``i64`` non-zero on success).
|
||||
//! 2. ``drain(handle)`` — pops every path observed since the last
|
||||
//! drain, deduped + sorted. Python polls this every ~50–100 ms
|
||||
//! from a daemon thread; idle workspaces have zero cost between
|
||||
//! polls because the watcher thread sits on the OS event source.
|
||||
//! 3. ``stop(handle)`` — drops the watcher, releases the OS resources.
|
||||
//!
|
||||
//! Filtering: ``__extern/``, ``.git/``, ``.sessions-metadata`` sidecars,
|
||||
//! and any path under a directory whose basename starts with ``.``
|
||||
//! (dotdir) are silently dropped at the watcher boundary so callers
|
||||
//! never see them. The user-facing save flow already echoes through
|
||||
//! ``SessionsRemoteCachedFileSaveListener``'s ``_RECENT_SELF_SAVE_…``
|
||||
//! cooldown for actual self-save suppression.
|
||||
//!
|
||||
//! Concurrency: all watchers live in a process-wide ``Mutex<HashMap>``
|
||||
//! keyed by an atomically-incrementing ``i64`` handle. The ``notify``
|
||||
//! callback pushes paths into a ``Mutex<Vec<PathBuf>>`` owned by the
|
||||
//! handle's ``WatchEntry`` — the watcher thread never blocks on the
|
||||
//! drain side because the lock is only held for the push duration.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
|
||||
/// One watcher's pending event buffer + the watcher itself (kept alive
|
||||
/// for the duration of the watch — dropping the ``RecommendedWatcher``
|
||||
/// releases the OS handle).
|
||||
struct WatchEntry {
|
||||
pending: Arc<Mutex<Vec<PathBuf>>>,
|
||||
_watcher: RecommendedWatcher,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct WatcherRegistry {
|
||||
entries: Mutex<HashMap<i64, WatchEntry>>,
|
||||
next_handle: AtomicI64,
|
||||
}
|
||||
|
||||
fn registry() -> &'static WatcherRegistry {
|
||||
static INSTANCE: OnceLock<WatcherRegistry> = OnceLock::new();
|
||||
INSTANCE.get_or_init(|| WatcherRegistry {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
next_handle: AtomicI64::new(1),
|
||||
})
|
||||
}
|
||||
|
||||
/// Drop paths the caller never wants to round-trip to the remote:
|
||||
///
|
||||
/// * ``__extern/`` — out-of-workspace cache subtree.
|
||||
/// * ``.git/`` and contents — Track G owns its own sync flow.
|
||||
/// * ``.sessions-metadata`` sidecars — internal mtime/sha bookkeeping.
|
||||
/// * Anything under a dotdir (``.cache/``, ``.idea/``) — generated state
|
||||
/// that's noisy for git but uninteresting for sync.
|
||||
///
|
||||
/// Returns ``true`` when ``path`` should be reported to Python.
|
||||
fn path_is_eligible(cache_root: &Path, path: &Path) -> bool {
|
||||
let Ok(relative) = path.strip_prefix(cache_root) else {
|
||||
return false;
|
||||
};
|
||||
for component in relative.components() {
|
||||
let component_str = component.as_os_str().to_string_lossy();
|
||||
if component_str == "__extern" || component_str == ".git" {
|
||||
return false;
|
||||
}
|
||||
if component_str.starts_with('.') && !component_str.eq_ignore_ascii_case(".python-version")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(name) = path.file_name() {
|
||||
let name_lossy = name.to_string_lossy();
|
||||
if name_lossy.ends_with(".sessions-metadata") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Start watching ``cache_root`` recursively. Returns a non-zero handle
|
||||
/// on success, ``0`` when the watcher could not be created (caller may
|
||||
/// treat ``0`` as "feature unavailable" and skip the polling thread).
|
||||
pub fn start(cache_root: &Path) -> i64 {
|
||||
let cache_root_buf: PathBuf = cache_root.to_path_buf();
|
||||
if !cache_root_buf.is_dir() {
|
||||
return 0;
|
||||
}
|
||||
let pending: Arc<Mutex<Vec<PathBuf>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let pending_for_callback = Arc::clone(&pending);
|
||||
let cache_root_for_callback = cache_root_buf.clone();
|
||||
let watcher_result: notify::Result<RecommendedWatcher> = RecommendedWatcher::new(
|
||||
move |event: notify::Result<Event>| {
|
||||
let Ok(event) = event else {
|
||||
return;
|
||||
};
|
||||
if !matches!(
|
||||
event.kind,
|
||||
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let mut accepted: Vec<PathBuf> = Vec::with_capacity(event.paths.len());
|
||||
for path in event.paths {
|
||||
if path_is_eligible(&cache_root_for_callback, &path) {
|
||||
accepted.push(path);
|
||||
}
|
||||
}
|
||||
if accepted.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut buffer) = pending_for_callback.lock() {
|
||||
buffer.extend(accepted);
|
||||
}
|
||||
},
|
||||
notify::Config::default(),
|
||||
);
|
||||
let mut watcher = match watcher_result {
|
||||
Ok(w) => w,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
if watcher
|
||||
.watch(&cache_root_buf, RecursiveMode::Recursive)
|
||||
.is_err()
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
let handle = registry().next_handle.fetch_add(1, Ordering::Relaxed);
|
||||
let entry = WatchEntry {
|
||||
pending,
|
||||
_watcher: watcher,
|
||||
};
|
||||
if let Ok(mut entries) = registry().entries.lock() {
|
||||
entries.insert(handle, entry);
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
handle
|
||||
}
|
||||
|
||||
/// Drain the handle's pending events. Returns paths since the last
|
||||
/// drain, deduplicated + sorted, joined by ``\x1F`` so the C ABI side
|
||||
/// can ship them as a single string. ``None`` when ``handle`` is
|
||||
/// unknown (handle was stopped or never existed).
|
||||
pub fn drain(handle: i64) -> Option<String> {
|
||||
let entries = registry().entries.lock().ok()?;
|
||||
let entry = entries.get(&handle)?;
|
||||
let mut buffer = entry.pending.lock().ok()?;
|
||||
if buffer.is_empty() {
|
||||
return Some(String::new());
|
||||
}
|
||||
let mut taken = std::mem::take(&mut *buffer);
|
||||
drop(buffer);
|
||||
drop(entries);
|
||||
taken.sort();
|
||||
taken.dedup();
|
||||
let joined: String = taken
|
||||
.iter()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\x1f");
|
||||
Some(joined)
|
||||
}
|
||||
|
||||
/// Stop watching and release OS resources. Returns ``true`` when a
|
||||
/// watcher was removed; ``false`` when ``handle`` was unknown
|
||||
/// (idempotent — safe to call repeatedly on the same handle).
|
||||
pub fn stop(handle: i64) -> bool {
|
||||
let mut entries = match registry().entries.lock() {
|
||||
Ok(e) => e,
|
||||
Err(_) => return false,
|
||||
};
|
||||
entries.remove(&handle).is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error>>;
|
||||
|
||||
fn wait_for_event(handle: i64, expected_substring: &str, max_ms: u64) -> Option<String> {
|
||||
let deadline = Instant::now() + Duration::from_millis(max_ms);
|
||||
loop {
|
||||
if let Some(joined) = drain(handle)
|
||||
&& !joined.is_empty()
|
||||
&& joined.contains(expected_substring)
|
||||
{
|
||||
return Some(joined);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_returns_zero_when_root_missing() -> TestResult {
|
||||
assert_eq!(start(Path::new("/this/path/does/not/exist/sessions")), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_returns_none_for_unknown_handle() -> TestResult {
|
||||
assert!(drain(0xdead_beef).is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modify_event_round_trips_to_drain() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let target = temp.path().join("hello.txt");
|
||||
fs::write(&target, b"v1")?;
|
||||
let handle = start(temp.path());
|
||||
assert!(handle > 0, "watcher start failed");
|
||||
// Settle: notify can fire spurious events on the initial watch
|
||||
// setup; drain those before mutating.
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
let _ = drain(handle);
|
||||
|
||||
fs::write(&target, b"v2")?;
|
||||
let observed = wait_for_event(handle, "hello.txt", 5_000);
|
||||
assert!(
|
||||
observed.is_some(),
|
||||
"watcher did not surface modify event within 5 s"
|
||||
);
|
||||
assert!(stop(handle));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_under_extern_are_filtered() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let extern_dir = temp.path().join("__extern").join("sub");
|
||||
fs::create_dir_all(&extern_dir)?;
|
||||
let extern_file = extern_dir.join("foo.txt");
|
||||
let visible_file = temp.path().join("visible.txt");
|
||||
fs::write(&visible_file, b"v1")?;
|
||||
|
||||
let handle = start(temp.path());
|
||||
assert!(handle > 0);
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
let _ = drain(handle);
|
||||
|
||||
// Mutate both — only the non-__extern one should surface.
|
||||
fs::write(&extern_file, b"hidden")?;
|
||||
fs::write(&visible_file, b"v2")?;
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
let joined = drain(handle).unwrap_or_default();
|
||||
assert!(
|
||||
joined.contains("visible.txt"),
|
||||
"expected visible.txt in drain, got: {joined:?}"
|
||||
);
|
||||
assert!(
|
||||
!joined.contains("__extern"),
|
||||
"__extern should have been filtered, got: {joined:?}"
|
||||
);
|
||||
assert!(stop(handle));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotgit_subtree_is_filtered() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let dotgit = temp.path().join("repo").join(".git").join("refs");
|
||||
fs::create_dir_all(&dotgit)?;
|
||||
let dotgit_file = dotgit.join("HEAD");
|
||||
let repo_dir = temp.path().join("repo");
|
||||
let plain_file = repo_dir.join("README.md");
|
||||
fs::create_dir_all(&repo_dir)?;
|
||||
fs::write(&plain_file, b"v1")?;
|
||||
|
||||
let handle = start(temp.path());
|
||||
assert!(handle > 0);
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
let _ = drain(handle);
|
||||
|
||||
fs::write(&dotgit_file, b"refs/heads/main")?;
|
||||
fs::write(&plain_file, b"v2")?;
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
let joined = drain(handle).unwrap_or_default();
|
||||
assert!(
|
||||
joined.contains("README.md"),
|
||||
"expected README.md in drain, got: {joined:?}"
|
||||
);
|
||||
assert!(
|
||||
!joined.contains(".git"),
|
||||
".git/ should have been filtered, got: {joined:?}"
|
||||
);
|
||||
assert!(stop(handle));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_is_idempotent() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let handle = start(temp.path());
|
||||
assert!(handle > 0);
|
||||
assert!(stop(handle));
|
||||
assert!(!stop(handle), "second stop should return false");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
[
|
||||
{
|
||||
"caption": "Sessions: Expand this folder",
|
||||
"command": "sessions_expand_deferred_directory"
|
||||
"command": "sessions_expand_deferred_directory",
|
||||
"args": {"paths": []}
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Delete Remote File",
|
||||
"command": "sessions_delete_remote_file"
|
||||
"command": "sessions_delete_remote_file",
|
||||
"args": {"paths": []}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -30,6 +30,7 @@ from __future__ import annotations
|
||||
import os # noqa: F401 — re-exported for monkeypatching
|
||||
import sys # noqa: F401 — re-exported for monkeypatching
|
||||
|
||||
from . import _local_watcher as local_watcher # noqa: F401 — module export
|
||||
from ._bridge_parsers import (
|
||||
background_queue_pressure,
|
||||
build_eof_error_envelope,
|
||||
@@ -95,6 +96,7 @@ from ._orchestrator import (
|
||||
)
|
||||
from ._tool_runtime import (
|
||||
derive_venv_name,
|
||||
eager_hydrate_apply,
|
||||
eager_hydrate_find_candidates,
|
||||
merge_remote_extension_catalog_json,
|
||||
normalize_code_server_specs_json,
|
||||
@@ -105,6 +107,8 @@ from ._tool_runtime import (
|
||||
from ._workspace import normalize_remote_root, workspace_cache_key
|
||||
|
||||
__all__ = (
|
||||
# _local_watcher (Wave 2 PR-C — cross-platform sync)
|
||||
"local_watcher",
|
||||
# _loader (public)
|
||||
"AbiError",
|
||||
"SessionsNativeLibraryError",
|
||||
@@ -134,6 +138,7 @@ __all__ = (
|
||||
"save_decision_code",
|
||||
# _tool_runtime
|
||||
"derive_venv_name",
|
||||
"eager_hydrate_apply",
|
||||
"eager_hydrate_find_candidates",
|
||||
"merge_remote_extension_catalog_json",
|
||||
"normalize_code_server_specs_json",
|
||||
|
||||
89
sublime/sessions/_rust_ffi/_local_watcher.py
Normal file
89
sublime/sessions/_rust_ffi/_local_watcher.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Cross-platform local cache filesystem watcher (Wave 2 PR-C).
|
||||
|
||||
Wraps the ``sessions_native::local_watcher`` ABI so the Sublime side
|
||||
can detect external file mutations (Sublime Merge stage/discard,
|
||||
``vim``, build tools writing into the cache) and push the changes back
|
||||
to the remote — Sublime's own ``on_post_save`` listener never sees
|
||||
those writes because they bypass the editor entirely.
|
||||
|
||||
Backed by the cross-platform ``notify`` crate (FSEvents on macOS,
|
||||
inotify on Linux, ReadDirectoryChangesW on Windows). Polling-friendly
|
||||
drain API: Python spawns a daemon thread that calls :func:`drain`
|
||||
every ~50–100 ms; idle workspaces have zero cost between polls
|
||||
because the watcher thread sits on the OS event source inside Rust.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
from typing import Tuple
|
||||
|
||||
from . import _loader
|
||||
from ._loader import SessionsNativeLibraryError
|
||||
|
||||
|
||||
def start(cache_root: str) -> int:
|
||||
"""Start watching ``cache_root`` recursively. Returns a non-zero
|
||||
handle on success, ``0`` when the cache root is missing or the
|
||||
platform watcher could not be created.
|
||||
"""
|
||||
lib = _loader._native_lib()
|
||||
try:
|
||||
func = lib.sessions_local_watcher_start
|
||||
except AttributeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_local_watcher_start symbol unavailable"
|
||||
) from exc
|
||||
func.argtypes = [ctypes.c_char_p]
|
||||
func.restype = ctypes.c_int64
|
||||
return int(func(ctypes.c_char_p(cache_root.encode("utf-8"))))
|
||||
|
||||
|
||||
def drain(handle: int) -> Tuple[str, ...]:
|
||||
"""Drain pending change paths. Returns empty tuple when the
|
||||
watcher has nothing new (or when the handle is unknown)."""
|
||||
if handle <= 0:
|
||||
return ()
|
||||
lib = _loader._native_lib()
|
||||
try:
|
||||
func = lib.sessions_local_watcher_drain
|
||||
except AttributeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_local_watcher_drain symbol unavailable"
|
||||
) from exc
|
||||
func.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.c_size_t]
|
||||
func.restype = ctypes.c_int
|
||||
capacity = 8192
|
||||
while True:
|
||||
out_buf = ctypes.create_string_buffer(capacity)
|
||||
rc = int(func(ctypes.c_int64(handle), out_buf, capacity))
|
||||
if rc == 0:
|
||||
payload = out_buf.value.decode("utf-8")
|
||||
if not payload:
|
||||
return ()
|
||||
return tuple(payload.split("\x1f"))
|
||||
if rc < 0:
|
||||
return ()
|
||||
# rc > 0 — buffer too small. ``write_output`` returns the
|
||||
# required size in this case (matches the ``call_string_abi``
|
||||
# contract). Grow and retry.
|
||||
if rc > capacity:
|
||||
capacity = rc
|
||||
continue
|
||||
return ()
|
||||
|
||||
|
||||
def stop(handle: int) -> bool:
|
||||
"""Stop the watcher and release OS resources. Idempotent."""
|
||||
if handle <= 0:
|
||||
return False
|
||||
lib = _loader._native_lib()
|
||||
try:
|
||||
func = lib.sessions_local_watcher_stop
|
||||
except AttributeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_local_watcher_stop symbol unavailable"
|
||||
) from exc
|
||||
func.argtypes = [ctypes.c_int64]
|
||||
func.restype = ctypes.c_int
|
||||
return int(func(ctypes.c_int64(handle))) == 1
|
||||
@@ -7,7 +7,12 @@ import json
|
||||
from typing import Any, Dict, Sequence, Tuple
|
||||
|
||||
from . import _loader
|
||||
from ._loader import SessionsNativeLibraryError, _bind_abi_symbol, call_string_abi
|
||||
from ._loader import (
|
||||
SessionsNativeLibraryError,
|
||||
_bind_abi_symbol,
|
||||
_call_json_returning_abi,
|
||||
call_string_abi,
|
||||
)
|
||||
|
||||
|
||||
def parse_ruff_diagnostics(
|
||||
@@ -148,6 +153,69 @@ def eager_hydrate_find_candidates(
|
||||
return tuple(out.split("\x1f"))
|
||||
|
||||
|
||||
def eager_hydrate_apply(
|
||||
*,
|
||||
cache_root: str,
|
||||
host_alias: str,
|
||||
remote_workspace_root: str,
|
||||
allowed_basenames: Sequence[str],
|
||||
batch_size: int,
|
||||
batch_sleep_ms: int,
|
||||
max_open_bytes: int,
|
||||
binary_probe_bytes: int,
|
||||
allow_empty: bool,
|
||||
timeout_ms: int,
|
||||
parallelism: int = 1,
|
||||
) -> Dict[str, Any]:
|
||||
"""Drive one Rust eager-hydrate apply pass (PR-B / PR 17 + PR-B.1).
|
||||
|
||||
Rust owns: candidate discovery, batch loop, batch_sleep pacing,
|
||||
re-check zero-byte, local→remote mapping, ``file_open`` transaction,
|
||||
outcome counting. ``parallelism`` controls how many ``file_open``
|
||||
transactions Rust runs concurrently per batch (broker session
|
||||
multiplexes by envelope id, so concurrent file/read is safe).
|
||||
Python writes sidecar metadata for ``hydrated`` entries and emits
|
||||
the trace event.
|
||||
|
||||
Returns a dict with keys ``hydrated`` (list of
|
||||
``{"local_path": ..., "metadata": ...}``), ``skipped_existing``,
|
||||
``failed``.
|
||||
"""
|
||||
decoded = _call_json_returning_abi(
|
||||
"sessions_eager_hydrate_apply",
|
||||
(
|
||||
cache_root,
|
||||
host_alias,
|
||||
remote_workspace_root,
|
||||
"\x1f".join(name for name in allowed_basenames if name),
|
||||
ctypes.c_size_t(int(batch_size)),
|
||||
ctypes.c_uint64(int(batch_sleep_ms)),
|
||||
ctypes.c_uint64(int(max_open_bytes)),
|
||||
ctypes.c_size_t(int(binary_probe_bytes)),
|
||||
ctypes.c_int(1 if allow_empty else 0),
|
||||
ctypes.c_uint64(int(timeout_ms)),
|
||||
ctypes.c_size_t(int(max(1, parallelism))),
|
||||
),
|
||||
argtypes=[
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_size_t,
|
||||
ctypes.c_uint64,
|
||||
ctypes.c_uint64,
|
||||
ctypes.c_size_t,
|
||||
ctypes.c_int,
|
||||
ctypes.c_uint64,
|
||||
ctypes.c_size_t,
|
||||
],
|
||||
initial_buf=64 * 1024,
|
||||
)
|
||||
if decoded is None:
|
||||
return {"hydrated": [], "skipped_existing": 0, "failed": 0}
|
||||
return decoded
|
||||
|
||||
|
||||
def merge_remote_extension_catalog_json(
|
||||
builtin_specs: Sequence[Dict[str, Any]], user_raw: Any
|
||||
) -> Tuple[Dict[str, Any], ...]:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -516,37 +516,6 @@ class SessionsOpenRemoteFileCommand(sublime_plugin.WindowCommand):
|
||||
)
|
||||
|
||||
|
||||
class SessionsSaveRemoteFileCommand(sublime_plugin.WindowCommand):
|
||||
"""Push one cached remote file back to the server for the current workspace."""
|
||||
|
||||
def run(self, remote_file: str = "") -> None:
|
||||
"""Save a cached remote file back to the remote workspace."""
|
||||
settings = SessionsSettings()
|
||||
context = _root._workspace_context(self.window, settings)
|
||||
if context is None:
|
||||
return
|
||||
if (remote_file or "").strip():
|
||||
_root._save_remote_file_for_workspace(
|
||||
self.window,
|
||||
context,
|
||||
remote_file,
|
||||
post_save_view=_root._active_view(self.window),
|
||||
)
|
||||
return
|
||||
self.window.show_input_panel(
|
||||
"Remote file:",
|
||||
"",
|
||||
lambda value: _root._save_remote_file_for_workspace(
|
||||
self.window,
|
||||
context,
|
||||
value,
|
||||
post_save_view=_root._active_view(self.window),
|
||||
),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _delete_remote_file_for_workspace(
|
||||
window: object,
|
||||
context,
|
||||
|
||||
@@ -7,19 +7,17 @@ disk directly — it never flows through Sublime's ``open_file`` hook, so
|
||||
zero-byte placeholder (created by the sidebar mirror pass), the CLI tool
|
||||
reports a malformed manifest and gives up.
|
||||
|
||||
This module walks an already-mirrored local cache once a workspace activates
|
||||
and schedules a bounded bulk fetch for placeholders whose basename matches a
|
||||
small allow-list of "essential" files (``Cargo.toml``, ``pyproject.toml``,
|
||||
``package.json``, …). The actual fetch primitive is injected so the driver
|
||||
stays importable without the Sublime/SSH runtime.
|
||||
This module exposes the candidate discovery + settings normaliser that
|
||||
back the eager-hydrate apply pass. The driver itself (batch loop,
|
||||
re-check, fetch transaction) lives in
|
||||
``sessions_native::eager_hydrate::run_apply_pass`` (Wave 2 PR-B / PR 17)
|
||||
— see :func:`sessions._rust_ffi.eager_hydrate_apply`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable, Iterator, List, Optional, Tuple
|
||||
from typing import Iterable, Iterator, List, Tuple
|
||||
|
||||
from . import _rust_ffi
|
||||
|
||||
@@ -51,38 +49,6 @@ DEFAULT_BATCH_SIZE: int = 20
|
||||
DEFAULT_BATCH_SLEEP_S: float = 0.05
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EagerHydrateSummary:
|
||||
"""Outcome of one eager-hydrate pass.
|
||||
|
||||
Attributes:
|
||||
hydrated: Count of placeholders that were fetched successfully.
|
||||
skipped_existing: Placeholders that turned out to have non-zero size
|
||||
by the time the driver reached them (another worker won the race).
|
||||
failed: Placeholders whose ``fetch_fn`` returned ``False``.
|
||||
"""
|
||||
|
||||
hydrated: int = 0
|
||||
skipped_existing: int = 0
|
||||
failed: int = 0
|
||||
|
||||
|
||||
def _is_placeholder(path: Path) -> bool:
|
||||
"""Return ``True`` if ``path`` is a regular zero-byte file."""
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
return False
|
||||
if stat.st_size != 0:
|
||||
return False
|
||||
# ``Path.is_file`` resolves symlinks; the Sessions cache never uses
|
||||
# symlinks but the guard is cheap.
|
||||
try:
|
||||
return path.is_file()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def find_placeholder_candidates(
|
||||
cache_root: Path,
|
||||
allowed_basenames: Iterable[str],
|
||||
@@ -90,9 +56,8 @@ def find_placeholder_candidates(
|
||||
"""Yield zero-byte files under ``cache_root`` whose basename is allowed.
|
||||
|
||||
Wave 2 PR 14: BFS + size filter run in
|
||||
``sessions_native::eager_hydrate``. Pacing/batching stay in Python so
|
||||
the FFI is one call per pass. Directories that fail to enumerate are
|
||||
silently skipped (Rust matches Python's ``OSError`` swallow).
|
||||
``sessions_native::eager_hydrate``. Directories that fail to enumerate
|
||||
are silently skipped (Rust matches Python's ``OSError`` swallow).
|
||||
"""
|
||||
allowed_list = [name for name in allowed_basenames if name]
|
||||
if not allowed_list:
|
||||
@@ -107,88 +72,6 @@ def find_placeholder_candidates(
|
||||
yield Path(path_str)
|
||||
|
||||
|
||||
def batched(items: Iterable[Path], batch_size: int) -> Iterator[List[Path]]:
|
||||
"""Yield ``items`` in lists of at most ``batch_size``.
|
||||
|
||||
Args:
|
||||
items: Source iterable.
|
||||
batch_size: Maximum list length; values ``<= 0`` collapse to ``1``.
|
||||
"""
|
||||
size = max(1, batch_size)
|
||||
bucket: List[Path] = []
|
||||
for item in items:
|
||||
bucket.append(item)
|
||||
if len(bucket) >= size:
|
||||
yield bucket
|
||||
bucket = []
|
||||
if bucket:
|
||||
yield bucket
|
||||
|
||||
|
||||
FetchFn = Callable[[Path], bool]
|
||||
"""Hydrate one placeholder. Returns ``True`` on success, ``False`` otherwise."""
|
||||
|
||||
|
||||
def run_eager_hydrate(
|
||||
cache_root: Path,
|
||||
*,
|
||||
fetch_fn: FetchFn,
|
||||
allowed_basenames: Iterable[str] = DEFAULT_EAGER_HYDRATE_BASENAMES,
|
||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||
batch_sleep_s: float = DEFAULT_BATCH_SLEEP_S,
|
||||
sleep_fn: Optional[Callable[[float], None]] = None,
|
||||
) -> EagerHydrateSummary:
|
||||
"""Drive one hydrate pass over placeholders under ``cache_root``.
|
||||
|
||||
The driver is deliberately dumb: no retries, no per-file concurrency,
|
||||
no global state. Failures are counted but do not abort the pass — the
|
||||
next placeholder still gets its chance.
|
||||
|
||||
Args:
|
||||
cache_root: Local cache root to walk.
|
||||
fetch_fn: Callable invoked for each placeholder. Return ``True`` on
|
||||
successful hydration. Must not raise; failures should be encoded
|
||||
as ``False`` so the pass can continue.
|
||||
allowed_basenames: Override for the default allow-list.
|
||||
batch_size: Placeholders per batch before pausing.
|
||||
batch_sleep_s: Pause between batches, in seconds.
|
||||
sleep_fn: Injection point for tests; defaults to :func:`time.sleep`.
|
||||
|
||||
Returns:
|
||||
An :class:`EagerHydrateSummary` with per-outcome counts.
|
||||
"""
|
||||
sleeper = sleep_fn if sleep_fn is not None else time.sleep
|
||||
hydrated = 0
|
||||
skipped_existing = 0
|
||||
failed = 0
|
||||
|
||||
placeholders = find_placeholder_candidates(cache_root, allowed_basenames)
|
||||
for batch_index, batch in enumerate(batched(placeholders, batch_size)):
|
||||
if batch_index > 0 and batch_sleep_s > 0:
|
||||
sleeper(batch_sleep_s)
|
||||
for path in batch:
|
||||
# Re-check size right before fetching: a different code path
|
||||
# (``SessionsOnDemandFetchListener`` / sidebar hydrate) may have
|
||||
# filled the placeholder while we were iterating.
|
||||
if not _is_placeholder(path):
|
||||
skipped_existing += 1
|
||||
continue
|
||||
try:
|
||||
ok = bool(fetch_fn(path))
|
||||
except Exception:
|
||||
ok = False
|
||||
if ok:
|
||||
hydrated += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
return EagerHydrateSummary(
|
||||
hydrated=hydrated,
|
||||
skipped_existing=skipped_existing,
|
||||
failed=failed,
|
||||
)
|
||||
|
||||
|
||||
def normalize_eager_hydrate_basenames(
|
||||
raw: object,
|
||||
default: Tuple[str, ...] = DEFAULT_EAGER_HYDRATE_BASENAMES,
|
||||
|
||||
@@ -220,6 +220,7 @@ def materialise_working_tree(
|
||||
exec_once: Optional[ExecOnceFn] = None,
|
||||
read_file: Optional[ReadFileFn] = None,
|
||||
git_local: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||
extra_force_refresh: Iterable[str] = (),
|
||||
) -> MaterialiseResult:
|
||||
"""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
|
||||
# bounded by the user's actually-edited file count, not repo
|
||||
# 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
|
||||
for relative in classification.dirty_modified:
|
||||
for relative in sorted(refresh_set):
|
||||
remote_path = "{}/{}".format(repo.remote_root.rstrip("/"), relative)
|
||||
local_path = repo.local_root / relative
|
||||
try:
|
||||
|
||||
@@ -96,6 +96,56 @@ def test_sessions_plugin_shutdown_clears_refs_and_bridges(monkeypatch) -> None:
|
||||
assert shutdown_calls == 1
|
||||
|
||||
|
||||
def test_sessions_plugin_shutdown_stops_local_cache_watchers(monkeypatch) -> None:
|
||||
"""Plugin shutdown must drop every active local cache watcher.
|
||||
|
||||
Regression: ``_stop_local_cache_watcher`` had zero call sites for
|
||||
several releases — handles leaked across plugin reload until the
|
||||
Sublime process exited. Now wired into ``sessions_plugin_shutdown``.
|
||||
"""
|
||||
monkeypatch.setattr(commands, "shutdown_all_persistent_bridges", lambda: None)
|
||||
stopped: list[int] = []
|
||||
monkeypatch.setattr(
|
||||
commands._rust_ffi.local_watcher, "stop", lambda h: stopped.append(h) or True
|
||||
)
|
||||
with commands._LOCAL_WATCHER_LOCK:
|
||||
commands._LOCAL_WATCHER_HANDLES.clear()
|
||||
commands._LOCAL_WATCHER_HANDLES["cache-A"] = 11
|
||||
commands._LOCAL_WATCHER_HANDLES["cache-B"] = 22
|
||||
commands.sessions_plugin_shutdown()
|
||||
assert sorted(stopped) == [11, 22]
|
||||
assert commands._LOCAL_WATCHER_HANDLES == {}
|
||||
|
||||
|
||||
def test_stop_all_local_cache_watchers_swallows_rust_errors(monkeypatch) -> None:
|
||||
"""If the Rust ABI raises (symbol missing on a fresh dylib), shutdown
|
||||
must still clear Python state so the next plugin load starts clean."""
|
||||
|
||||
def boom(_handle: int) -> bool:
|
||||
raise RuntimeError("simulated abi failure")
|
||||
|
||||
monkeypatch.setattr(commands._rust_ffi.local_watcher, "stop", boom)
|
||||
with commands._LOCAL_WATCHER_LOCK:
|
||||
commands._LOCAL_WATCHER_HANDLES.clear()
|
||||
commands._LOCAL_WATCHER_HANDLES["cache-X"] = 99
|
||||
commands._stop_all_local_cache_watchers()
|
||||
assert commands._LOCAL_WATCHER_HANDLES == {}
|
||||
|
||||
|
||||
def test_stop_all_local_cache_watchers_idempotent(monkeypatch) -> None:
|
||||
"""Calling twice (e.g. plugin double-unload) must not re-stop handles."""
|
||||
stopped: list[int] = []
|
||||
monkeypatch.setattr(
|
||||
commands._rust_ffi.local_watcher, "stop", lambda h: stopped.append(h) or True
|
||||
)
|
||||
with commands._LOCAL_WATCHER_LOCK:
|
||||
commands._LOCAL_WATCHER_HANDLES.clear()
|
||||
commands._LOCAL_WATCHER_HANDLES["cache-Y"] = 77
|
||||
commands._stop_all_local_cache_watchers()
|
||||
commands._stop_all_local_cache_watchers()
|
||||
assert stopped == [77]
|
||||
|
||||
|
||||
def test_bridge_window_add_ref_skips_empty_host_alias() -> None:
|
||||
commands._BRIDGE_HOST_WINDOW_IDS.clear()
|
||||
commands._bridge_window_add_ref(FakeWindow(window_id=201), "")
|
||||
|
||||
@@ -445,17 +445,24 @@ 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]
|
||||
# ``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.
|
||||
# ``-il`` forces interactive + login so neither bash nor zsh falls
|
||||
# back to non-interactive "exit at EOF" semantics if the pty
|
||||
# handshake is racy. ``;`` not ``&&`` so a failed ``cd`` doesn't
|
||||
# take the shell down with it. The earlier ``</dev/tty`` redirect
|
||||
# prefix was dropped — it confused interactive zsh on some macOS →
|
||||
# Linux setups (``zsh: bad option: -/``). The ``${SHELL:-/bin/sh}``
|
||||
# default form re-tripped the same class of zsh setups in v0.7.31+
|
||||
# (``zsh:1: unknown exec flag -/``). v0.7.42 dropped the fallback
|
||||
# entirely on the assumption sshd populates ``$SHELL``; that broke
|
||||
# users where ``ssh -t host cmd`` runs the login shell in non-login
|
||||
# ``-c`` mode and ``$SHELL`` is empty (``permission denied:`` exit
|
||||
# 126). v0.7.43 reinstates the fallback via POSIX ``if`` instead of
|
||||
# ``:-`` so the parser-bug class is avoided.
|
||||
assert args["cmd"] == [
|
||||
"ssh",
|
||||
"-t",
|
||||
"prod",
|
||||
"exec </dev/tty >/dev/tty 2>/dev/tty; cd /srv/app; exec ${SHELL:-/bin/sh} -il",
|
||||
'cd /srv/app; if [ -z "$SHELL" ]; then SHELL=/bin/sh; fi; exec "$SHELL" -il',
|
||||
]
|
||||
# ``auto_close=False`` so an unexpected shell exit (dotfile error,
|
||||
# missing remote root, SSH drop) keeps the pane visible long enough
|
||||
|
||||
@@ -5,12 +5,9 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from conftest import FakeView, FakeWindow
|
||||
from conftest import FakeWindow
|
||||
from sessions import commands
|
||||
from sessions.file_state import (
|
||||
OpenFileResult,
|
||||
OpenOutcome,
|
||||
)
|
||||
from sessions.file_state import OpenFileResult, OpenOutcome
|
||||
from sessions.recent_state import RecentWorkspace, RecentWorkspaceIndex
|
||||
from sessions.remote import RemoteDirectoryEntry, RemoteFileKind, RemoteFileMetadata
|
||||
from sessions.settings_model import SessionsSettings
|
||||
@@ -132,305 +129,3 @@ def test_open_remote_file_browses_remote_tree_before_materializing(
|
||||
|
||||
window.quick_panel_callbacks[0](3)
|
||||
assert opened["remote_file"] == "/srv/ws/a.py"
|
||||
|
||||
|
||||
def test_open_remote_tree_command_opens_selected_file(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_list_remote_directory",
|
||||
lambda host_alias, remote_directory: (
|
||||
RemoteDirectoryEntry(
|
||||
name="pkg",
|
||||
remote_absolute_path="/srv/ws/pkg",
|
||||
kind=RemoteFileKind.DIRECTORY,
|
||||
),
|
||||
RemoteDirectoryEntry(
|
||||
name="a.py",
|
||||
remote_absolute_path="/srv/ws/a.py",
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
),
|
||||
),
|
||||
)
|
||||
opened = {}
|
||||
|
||||
def fake_open(window, context, remote_file, **kwargs):
|
||||
_ = (window, context, kwargs)
|
||||
opened["remote_file"] = remote_file
|
||||
|
||||
monkeypatch.setattr(commands, "_open_remote_file_for_workspace", fake_open)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsOpenRemoteTreeCommand(window).run()
|
||||
tree_view = window.created_views[-1]
|
||||
tree_view.selected_row_value = 7
|
||||
commands.SessionsRemoteTreeOpenSelectionCommand(window).run()
|
||||
assert opened["remote_file"] == "/srv/ws/a.py"
|
||||
|
||||
|
||||
def test_open_remote_directory_explorer_applies_layout_and_focuses_group_zero(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_list_remote_directory",
|
||||
lambda host_alias, remote_directory: (
|
||||
RemoteDirectoryEntry(
|
||||
name="a.py",
|
||||
remote_absolute_path="/srv/ws/a.py",
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
),
|
||||
),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsOpenRemoteDirectoryExplorerCommand(window).run()
|
||||
|
||||
layout_entry = ("set_layout", commands._REMOTE_DIRECTORY_EXPLORER_LAYOUT)
|
||||
assert layout_entry in window.window_commands
|
||||
assert window.focus_group_calls and window.focus_group_calls[0] == 0
|
||||
tree_view = window.created_views[-1]
|
||||
assert tree_view.settings().get("sessions_remote_tree_editor_group") == 1
|
||||
|
||||
|
||||
def test_remote_directory_explorer_creates_tree_in_group_zero_when_editor_was_focused(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Regression: new_file must not open the tree in the wide editor column."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_list_remote_directory",
|
||||
lambda host_alias, remote_directory: (
|
||||
RemoteDirectoryEntry(
|
||||
name="a.py",
|
||||
remote_absolute_path="/srv/ws/a.py",
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
),
|
||||
),
|
||||
)
|
||||
decoy = FakeView()
|
||||
window = FakeWindow(
|
||||
project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}},
|
||||
active_view=decoy,
|
||||
)
|
||||
window._view_index[id(decoy)] = (1, 0)
|
||||
window._focused_group = 1
|
||||
decoy.window_value = window
|
||||
|
||||
commands.SessionsOpenRemoteDirectoryExplorerCommand(window).run()
|
||||
|
||||
tree_view = window.created_views[-1]
|
||||
assert window._view_index[id(tree_view)][0] == 0
|
||||
|
||||
|
||||
def test_explorer_tree_opens_remote_file_into_editor_group(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_list_remote_directory",
|
||||
lambda host_alias, remote_directory: (
|
||||
RemoteDirectoryEntry(
|
||||
name="a.py",
|
||||
remote_absolute_path="/srv/ws/a.py",
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def fake_open(host_alias: str, remote_absolute_path: str, local_cache_path: Path):
|
||||
_ = host_alias
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("hello", encoding="utf-8")
|
||||
return OpenFileResult(
|
||||
outcome=OpenOutcome.OK,
|
||||
local_cache_path=local_cache_path,
|
||||
remote_metadata=RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=5,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(commands, "open_remote_file_into_local_cache", fake_open)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsOpenRemoteDirectoryExplorerCommand(window).run()
|
||||
tree_view = window.created_views[-1]
|
||||
# Row 5 is ``../`` after the fixed header; the file entry is on the next line.
|
||||
tree_view.selected_row_value = 6
|
||||
commands.SessionsRemoteTreeOpenSelectionCommand(window).run()
|
||||
|
||||
expected_path = tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "a.py"
|
||||
assert expected_path.is_file()
|
||||
assert window.window_commands[-1] == (
|
||||
"open_file",
|
||||
{"file": str(expected_path), "group": 1},
|
||||
)
|
||||
|
||||
|
||||
def test_close_remote_file_command_closes_matching_cache_view_from_tree(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
cache_file = tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "a.py"
|
||||
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text("x", encoding="utf-8")
|
||||
|
||||
tree_view = FakeView()
|
||||
tree_view.settings().set("sessions_remote_tree", True)
|
||||
tree_view.settings().set("sessions_remote_tree_workspace_key", "cache-123")
|
||||
tree_view.settings().set("sessions_remote_tree_directory", "/srv/ws")
|
||||
tree_view.settings().set(
|
||||
"sessions_remote_tree_entries",
|
||||
[
|
||||
{
|
||||
"label": "a.py",
|
||||
"action": "open",
|
||||
"remote_path": "/srv/ws/a.py",
|
||||
},
|
||||
],
|
||||
)
|
||||
tree_view.settings().set("sessions_remote_tree_start_row", 5)
|
||||
tree_view.selected_row_value = 5
|
||||
tree_view.window_value = None
|
||||
|
||||
file_view = FakeView(file_name=str(cache_file))
|
||||
window = FakeWindow(
|
||||
project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}},
|
||||
active_view=tree_view,
|
||||
)
|
||||
window.created_views.extend([tree_view, file_view])
|
||||
window._view_index[id(tree_view)] = (0, 0)
|
||||
window._view_index[id(file_view)] = (1, 0)
|
||||
tree_view.window_value = window
|
||||
file_view.window_value = window
|
||||
|
||||
commands.SessionsCloseRemoteFileCommand(window).run()
|
||||
|
||||
assert file_view.closed is True
|
||||
|
||||
|
||||
def test_close_remote_file_command_closes_active_cache_buffer(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
cache_file = tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "b.py"
|
||||
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text("y", encoding="utf-8")
|
||||
|
||||
file_view = FakeView(file_name=str(cache_file))
|
||||
window = FakeWindow(
|
||||
project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}},
|
||||
active_view=file_view,
|
||||
)
|
||||
file_view.window_value = window
|
||||
|
||||
commands.SessionsCloseRemoteFileCommand(window).run()
|
||||
|
||||
assert file_view.closed is True
|
||||
|
||||
@@ -487,47 +487,6 @@ def test_sync_remote_tree_skips_shallow_when_fast_sync_disabled(
|
||||
assert mirror_depths == [5]
|
||||
|
||||
|
||||
def test_remove_sidebar_mirror_folder_command(tmp_path: Path, monkeypatch) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
cache_root = tmp_path / "cache" / "Sessions" / "cache" / "cache-123"
|
||||
cache_root.mkdir(parents=True, exist_ok=True)
|
||||
other_dir = tmp_path / "other"
|
||||
other_dir.mkdir()
|
||||
pdata: Dict[str, object] = {
|
||||
"settings": {PROJECT_SETTINGS_KEY: "cache-123"},
|
||||
"folders": [
|
||||
{"path": str(cache_root.resolve()), "name": "Sessions"},
|
||||
{"path": str(other_dir.resolve()), "name": "Other"},
|
||||
],
|
||||
}
|
||||
window = FakeWindow(project_data=pdata)
|
||||
commands.SessionsRemoveSidebarMirrorFolderCommand(window).run()
|
||||
final = window.set_project_data_calls[-1]
|
||||
paths = {
|
||||
f.get("path")
|
||||
for f in final.get("folders", [])
|
||||
if isinstance(f, dict) and f.get("path")
|
||||
}
|
||||
assert str(cache_root.resolve()) not in paths
|
||||
assert str(other_dir.resolve()) in paths
|
||||
|
||||
|
||||
def test_workspace_activation_listener_primes_refresh_once(monkeypatch) -> None:
|
||||
window = FakeWindow()
|
||||
view = FakeView()
|
||||
@@ -647,6 +606,10 @@ def test_hydrate_precheck_error_skips_read_for_active_view(
|
||||
def test_hydrate_schedule_sets_path_scoped_task_key(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""v0.7.30 reverts hydrate-on-open back to the shared background
|
||||
queue (single worker, sequential dispatch) — v0.7.29's per-view
|
||||
thread spawning crashed on rapid tab-switching due to concurrent
|
||||
Sublime View API calls. The queue's ``task_key`` dedup is back."""
|
||||
context = commands._WorkspaceContext(
|
||||
settings=SessionsSettings(),
|
||||
recent_entry=RecentWorkspace(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
@@ -15,8 +14,6 @@ from sessions.recent_state import RecentWorkspace, RecentWorkspaceIndex
|
||||
from sessions.remote import (
|
||||
RemoteFileKind,
|
||||
RemoteFileMetadata,
|
||||
RemoteReadFileResult,
|
||||
RemoteWriteErrorCode,
|
||||
RemoteWriteFileResult,
|
||||
RunTrigger,
|
||||
ToolExecutionRequest,
|
||||
@@ -165,227 +162,6 @@ def test_remote_cached_file_save_listener_pushes_after_local_save(
|
||||
assert pushed == [("/srv/ws/pkg/a.py", view)]
|
||||
|
||||
|
||||
def test_save_remote_file_writes_using_cached_baseline(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("print('save')\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_write_file",
|
||||
lambda host_alias, request: RemoteWriteFileResult(
|
||||
ok=True,
|
||||
updated_metadata=RemoteFileMetadata(
|
||||
mtime_ns=2,
|
||||
size_bytes=len(request.content),
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_schedule_format_then_pipeline_after_cache_push",
|
||||
lambda *a, **k: None,
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
saved_meta = commands._read_remote_metadata_sidecar(local_cache_path)
|
||||
assert saved_meta is not None
|
||||
assert saved_meta.mtime_ns == 2
|
||||
msg = status_messages[-1]
|
||||
assert "Sessions ready:" in msg
|
||||
assert "/srv/ws/pkg/a.py" in msg
|
||||
|
||||
|
||||
def test_save_remote_file_creates_brand_new_file_without_baseline(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""A buffer the user just saved into the cache mirror has no metadata
|
||||
sidecar yet — and the remote target may also not exist yet (the user
|
||||
might have just created the folder via Sublime's New Folder + saved a
|
||||
new file inside it). The save flow must treat this as a first-time
|
||||
create: hand a ``None`` ``expected_remote_metadata`` to the bridge so
|
||||
the Rust ``Missing`` precondition path fires (mkdir-p + write), then
|
||||
write the resulting metadata as the first sidecar entry.
|
||||
"""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-new",
|
||||
"2026-04-26T10:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-new" / "scratch" / "fresh.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("# brand new\n", encoding="utf-8")
|
||||
# Deliberately NO sidecar — that's what makes this the new-file case.
|
||||
assert commands._read_remote_metadata_sidecar(local_cache_path) is None
|
||||
|
||||
captured: List[Tuple[str, object]] = []
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: None,
|
||||
)
|
||||
|
||||
def _fake_write(host_alias, request) -> RemoteWriteFileResult:
|
||||
captured.append(("write", request))
|
||||
return RemoteWriteFileResult(
|
||||
ok=True,
|
||||
updated_metadata=RemoteFileMetadata(
|
||||
mtime_ns=42,
|
||||
size_bytes=len(request.content),
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(commands, "execute_remote_write_file", _fake_write)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_schedule_format_then_pipeline_after_cache_push",
|
||||
lambda *a, **k: None,
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-new"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="scratch/fresh.py")
|
||||
|
||||
write_calls = [c for c in captured if c[0] == "write"]
|
||||
assert len(write_calls) == 1, "expected exactly one bridge write"
|
||||
request = write_calls[0][1]
|
||||
assert request.remote_absolute_path == "/srv/ws/scratch/fresh.py"
|
||||
assert request.expected_remote_metadata is None, (
|
||||
"Missing precondition signals first-time create to the helper"
|
||||
)
|
||||
saved_meta = commands._read_remote_metadata_sidecar(local_cache_path)
|
||||
assert saved_meta is not None and saved_meta.mtime_ns == 42, (
|
||||
"successful write must seed the sidecar so future saves "
|
||||
"go through the conflict-evaluator path"
|
||||
)
|
||||
assert any("Sessions ready" in msg for msg in status_messages)
|
||||
|
||||
|
||||
def test_save_remote_file_refuses_blind_overwrite_of_unfetched_remote(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""No sidecar AND remote already exists → conservative refusal. The user
|
||||
might be about to clobber a file they have never seen; the right move
|
||||
is to ask them to open the remote file first so a baseline lands."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-conflict",
|
||||
"2026-04-26T10:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-conflict" / "x.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("# local\n", encoding="utf-8")
|
||||
assert commands._read_remote_metadata_sidecar(local_cache_path) is None
|
||||
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: RemoteFileMetadata(
|
||||
mtime_ns=99,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
write_calls: List[object] = []
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_write_file",
|
||||
lambda host_alias, request: write_calls.append(request),
|
||||
)
|
||||
window = FakeWindow(
|
||||
project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-conflict"}}
|
||||
)
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="x.py")
|
||||
|
||||
assert write_calls == [], "must NOT silently overwrite an unfetched remote"
|
||||
assert any("already exists" in msg for msg in status_messages), (
|
||||
"user must see the refusal hint with a 'open it first' suggestion"
|
||||
)
|
||||
|
||||
|
||||
def test_save_remote_file_for_workspace_schedules_ruff_format_when_lsp_format_on_save(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
@@ -561,76 +337,6 @@ def test_save_remote_file_for_workspace_skips_format_without_lsp_flag(
|
||||
assert scheduled == [("/srv/ws/pkg/a.py", False)]
|
||||
|
||||
|
||||
def test_save_remote_file_skips_upload_when_digest_matches_last_push(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
body = b"print('save')\n"
|
||||
local_cache_path.write_bytes(body)
|
||||
digest = hashlib.sha256(body).hexdigest()
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=len(body),
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
last_pushed_sha256=digest,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=len(body),
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
writes: List[object] = []
|
||||
|
||||
def capture_write(host_alias, request):
|
||||
writes.append((host_alias, request))
|
||||
return RemoteWriteFileResult(ok=False)
|
||||
|
||||
monkeypatch.setattr(commands, "execute_remote_write_file", capture_write)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_maybe_schedule_remote_python_pipeline_after_cache_push",
|
||||
lambda *a, **k: None,
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
assert writes == []
|
||||
assert "skipped upload" in status_messages[-1].lower()
|
||||
|
||||
|
||||
def test_read_remote_metadata_sidecar_supports_legacy_filename(tmp_path: Path) -> None:
|
||||
local_cache_path = tmp_path / "cache-123" / "pkg" / "a.py"
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -670,589 +376,6 @@ def test_remove_local_cache_mirror_path_removes_legacy_and_hidden_sidecar(
|
||||
assert not legacy_side.exists()
|
||||
|
||||
|
||||
def test_save_remote_file_reports_conflicts(tmp_path: Path, monkeypatch) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("print('save')\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: RemoteFileMetadata(
|
||||
mtime_ns=9,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
assert len(window.quick_panels) == 1, "conflict should show quick panel"
|
||||
items = window.quick_panels[0]
|
||||
labels = [row[0] for row in items]
|
||||
assert "Overwrite remote" in labels
|
||||
assert "Reload from remote" in labels
|
||||
assert "Cancel" in labels
|
||||
|
||||
|
||||
def test_save_conflict_overwrite_writes_remote(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Choosing 'Overwrite remote' in the conflict panel should force-write."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("print('local')\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1, size_bytes=12, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
),
|
||||
)
|
||||
newer_remote = RemoteFileMetadata(
|
||||
mtime_ns=9, size_bytes=12, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: newer_remote,
|
||||
)
|
||||
written_requests: list = []
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_write_file",
|
||||
lambda host_alias, request: (
|
||||
written_requests.append(request)
|
||||
or RemoteWriteFileResult(
|
||||
ok=True,
|
||||
updated_metadata=RemoteFileMetadata(
|
||||
mtime_ns=20,
|
||||
size_bytes=15,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
assert len(window.quick_panel_callbacks) == 1
|
||||
window.quick_panel_callbacks[0](0)
|
||||
assert len(written_requests) == 1
|
||||
msg = status_messages[-1]
|
||||
assert "Overwritten" in msg
|
||||
|
||||
|
||||
def test_save_conflict_cancel_does_nothing(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Choosing 'Cancel' in the conflict panel should emit a warning only."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("print('local')\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1, size_bytes=12, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: RemoteFileMetadata(
|
||||
mtime_ns=9, size_bytes=12, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
assert len(window.quick_panel_callbacks) == 1
|
||||
window.quick_panel_callbacks[0](2)
|
||||
msg = status_messages[-1]
|
||||
assert "cancelled" in msg
|
||||
|
||||
|
||||
def test_save_conflict_reload_downloads_remote(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Choosing 'Reload from remote' should download remote content and revert."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("print('old local')\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1, size_bytes=12, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
),
|
||||
)
|
||||
newer_meta = RemoteFileMetadata(
|
||||
mtime_ns=9, size_bytes=20, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: newer_meta,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_read_file",
|
||||
lambda host_alias, request: RemoteReadFileResult(
|
||||
metadata=newer_meta,
|
||||
body=b"print('new remote')\n",
|
||||
),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
assert len(window.quick_panel_callbacks) == 1
|
||||
window.quick_panel_callbacks[0](1)
|
||||
assert local_cache_path.read_bytes() == b"print('new remote')\n"
|
||||
msg = status_messages[-1]
|
||||
assert "Reloaded" in msg
|
||||
|
||||
|
||||
def test_save_conflict_overwrite_transport_error(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Transport failure during forced overwrite should show disconnected status."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod", "/srv/ws", "cache-123", "2026-04-12T03:00:00+00:00"
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("x\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1, size_bytes=2, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda h, p: RemoteFileMetadata(
|
||||
mtime_ns=9, size_bytes=2, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_write_file",
|
||||
lambda h, req: RemoteWriteFileResult(
|
||||
ok=False,
|
||||
error_code=RemoteWriteErrorCode.TRANSPORT_ERROR,
|
||||
error_message="pipe broken",
|
||||
),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
window.quick_panel_callbacks[0](0)
|
||||
|
||||
msg = status_messages[-1]
|
||||
assert "disconnected" in msg.lower() or "pipe broken" in msg
|
||||
|
||||
|
||||
def test_save_remote_file_reports_permission_denied(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("print('save')\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_write_file",
|
||||
lambda host_alias, request: RemoteWriteFileResult(
|
||||
ok=False,
|
||||
error_code=RemoteWriteErrorCode.PERMISSION_DENIED,
|
||||
error_message="Permission denied",
|
||||
),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
msg = status_messages[-1]
|
||||
assert "Sessions warning:" in msg
|
||||
assert "Permission denied" in msg
|
||||
|
||||
|
||||
def test_save_remote_file_reports_remote_missing(tmp_path: Path, monkeypatch) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("print('save')\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: None,
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
msg = status_messages[-1]
|
||||
assert "Sessions warning:" in msg
|
||||
assert "disappeared" in msg
|
||||
|
||||
|
||||
def test_save_remote_file_reports_transport_error(tmp_path: Path, monkeypatch) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("print('save')\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_write_file",
|
||||
lambda host_alias, request: RemoteWriteFileResult(
|
||||
ok=False,
|
||||
error_code=RemoteWriteErrorCode.TRANSPORT_ERROR,
|
||||
error_message="Remote file write failed for /srv/ws/pkg/a.py.",
|
||||
),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
msg = status_messages[-1]
|
||||
assert msg.startswith("Sessions disconnected:")
|
||||
assert "Remote file write failed" in msg
|
||||
assert "/srv/ws/pkg/a.py" in msg
|
||||
|
||||
|
||||
# --- Save conflict race / edge case tests ---
|
||||
|
||||
|
||||
def test_save_conflict_cancel_negative_index_does_nothing(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Pressing Escape (idx=-1) on conflict panel should cancel silently."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod", "/srv/ws", "cache-123", "2026-04-12T03:00:00+00:00"
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("conflict\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1, size_bytes=9, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda h, p: RemoteFileMetadata(
|
||||
mtime_ns=99, size_bytes=9, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
),
|
||||
)
|
||||
write_calls: list = []
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_write_file",
|
||||
lambda h, req: write_calls.append(req),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
assert len(window.quick_panel_callbacks) == 1
|
||||
window.quick_panel_callbacks[0](-1)
|
||||
assert write_calls == [], "cancel should not trigger remote write"
|
||||
assert any("cancelled" in m.lower() for m in status_messages)
|
||||
|
||||
|
||||
def test_save_conflict_reload_failure_preserves_dirty_buffer(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""If reload from remote fails, local cache file should stay untouched."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod", "/srv/ws", "cache-123", "2026-04-12T03:00:00+00:00"
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
original_content = "my local edits\n"
|
||||
local_cache_path.write_text(original_content, encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1, size_bytes=12, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda h, p: RemoteFileMetadata(
|
||||
mtime_ns=99,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
from sessions.connect_preflight import SessionHelperStartError
|
||||
|
||||
def read_fails(host, request):
|
||||
raise SessionHelperStartError("Network timeout during reload")
|
||||
|
||||
monkeypatch.setattr(commands, "execute_remote_read_file", read_fails)
|
||||
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
assert len(window.quick_panel_callbacks) == 1
|
||||
window.quick_panel_callbacks[0](1) # "Reload from remote"
|
||||
|
||||
assert local_cache_path.read_text(encoding="utf-8") == original_content
|
||||
assert any("disconnected" in m.lower() for m in status_messages)
|
||||
|
||||
|
||||
def test_remote_python_pipeline_listener_skips_post_save_when_cache_push_pending(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
@@ -1718,78 +841,6 @@ def test_run_format_then_pipeline_async_runs_source_actions_before_format(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_marks_remote_path_as_self_save_for_cooldown(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""The save path stamps the remote path so the watch echo gets ignored."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda *a, **k: None)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-d1",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-d1" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("print('save')\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_write_file",
|
||||
lambda host_alias, request: RemoteWriteFileResult(
|
||||
ok=True,
|
||||
updated_metadata=RemoteFileMetadata(
|
||||
mtime_ns=2,
|
||||
size_bytes=len(request.content),
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_schedule_format_then_pipeline_after_cache_push",
|
||||
lambda *a, **k: None,
|
||||
)
|
||||
commands._RECENT_SELF_SAVE_REMOTE_PATHS.clear()
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-d1"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
assert commands._is_recent_self_save("/srv/ws/pkg/a.py")
|
||||
|
||||
|
||||
def test_reload_changed_remote_views_filters_self_save_echo(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,3 +50,33 @@ def test_command_palette_prioritizes_recent_workspace_entry() -> None:
|
||||
# ``sessions_show_dev_commands`` is false (the default).
|
||||
assert "sessions_open_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"))
|
||||
)
|
||||
|
||||
@@ -199,3 +199,76 @@ def test_progress_panel_ignores_noisy_events() -> None:
|
||||
text_blob = "\n".join(text for text, _ in calls)
|
||||
assert "queue.enqueue" not in text_blob
|
||||
assert "bridge.request_start" not in text_blob
|
||||
|
||||
|
||||
# --- _hide_panel_if_progress branches ---
|
||||
|
||||
|
||||
class _PanelHideWindow:
|
||||
def __init__(self, active_panel_name):
|
||||
self._active = active_panel_name
|
||||
self.run_calls: list = []
|
||||
|
||||
def active_panel(self):
|
||||
return self._active
|
||||
|
||||
def run_command(self, name, args=None):
|
||||
self.run_calls.append((name, args))
|
||||
|
||||
|
||||
def test_hide_panel_if_progress_hides_when_panel_is_active() -> None:
|
||||
win = _PanelHideWindow(
|
||||
active_panel_name="output." + connect_progress._PROGRESS_PANEL_NAME
|
||||
)
|
||||
connect_progress._hide_panel_if_progress(win)
|
||||
assert ("hide_panel", {}) in win.run_calls
|
||||
|
||||
|
||||
def test_hide_panel_if_progress_no_op_when_user_switched_panels() -> None:
|
||||
win = _PanelHideWindow(active_panel_name="output.exec")
|
||||
connect_progress._hide_panel_if_progress(win)
|
||||
assert win.run_calls == []
|
||||
|
||||
|
||||
def test_hide_panel_if_progress_no_op_when_window_lacks_active_panel() -> None:
|
||||
class _NoActivePanel:
|
||||
run_calls: list = []
|
||||
|
||||
def run_command(self, name, args=None):
|
||||
self.run_calls.append((name, args))
|
||||
|
||||
win = _NoActivePanel()
|
||||
connect_progress._hide_panel_if_progress(win)
|
||||
assert win.run_calls == []
|
||||
|
||||
|
||||
# --- ConnectProgressPanel.success / failure branches ---
|
||||
|
||||
|
||||
def test_progress_panel_failure_appends_terminal_line() -> None:
|
||||
window = FakeWindow()
|
||||
panel = connect_progress.ConnectProgressPanel(window, "aws-celery")
|
||||
panel.start()
|
||||
try:
|
||||
panel.failure("ssh down")
|
||||
finally:
|
||||
panel.stop()
|
||||
panel_buf = window.output_panels.get(connect_progress._PROGRESS_PANEL_NAME)
|
||||
assert panel_buf is not None
|
||||
text = "\n".join(text for text, _ in panel_buf.append_calls)
|
||||
assert "Connect FAILED" in text
|
||||
assert "ssh down" in text
|
||||
|
||||
|
||||
def test_progress_panel_success_appends_terminal_line() -> None:
|
||||
window = FakeWindow()
|
||||
panel = connect_progress.ConnectProgressPanel(window, "aws-celery")
|
||||
panel.start()
|
||||
try:
|
||||
panel.success(detail="ready")
|
||||
finally:
|
||||
panel.stop()
|
||||
panel_buf = window.output_panels.get(connect_progress._PROGRESS_PANEL_NAME)
|
||||
assert panel_buf is not None
|
||||
text = "\n".join(text for text, _ in panel_buf.append_calls)
|
||||
assert "Connect SUCCESS" in text
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
"""Unit tests for :mod:`sessions.eager_hydrate`."""
|
||||
"""Unit tests for :mod:`sessions.eager_hydrate`.
|
||||
|
||||
Driver tests (``run_eager_hydrate``, ``batched``, ``EagerHydrateSummary``)
|
||||
were dropped at PR-B / PR 17 — the apply pass body now runs entirely in
|
||||
``sessions_native::eager_hydrate::run_apply_pass`` and is exercised by
|
||||
the Rust unit tests + integration smoke against
|
||||
``sessions_eager_hydrate_apply``. The Python side keeps the candidate
|
||||
discovery wrapper + settings normaliser, which are still tested below.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
from typing import Tuple
|
||||
|
||||
import pytest
|
||||
from sessions.eager_hydrate import (
|
||||
DEFAULT_BATCH_SIZE,
|
||||
DEFAULT_EAGER_HYDRATE_BASENAMES,
|
||||
EagerHydrateSummary,
|
||||
batched,
|
||||
find_placeholder_candidates,
|
||||
normalize_eager_hydrate_basenames,
|
||||
run_eager_hydrate,
|
||||
)
|
||||
|
||||
|
||||
@@ -67,169 +72,6 @@ def test_find_placeholder_candidates_returns_empty_when_allow_list_empty(
|
||||
assert out == []
|
||||
|
||||
|
||||
def test_batched_yields_in_order_and_respects_size() -> None:
|
||||
items = [Path("a"), Path("b"), Path("c"), Path("d"), Path("e")]
|
||||
batches = list(batched(items, 2))
|
||||
assert batches == [
|
||||
[Path("a"), Path("b")],
|
||||
[Path("c"), Path("d")],
|
||||
[Path("e")],
|
||||
]
|
||||
|
||||
|
||||
def test_batched_collapses_nonpositive_size_to_one() -> None:
|
||||
items = [Path("a"), Path("b")]
|
||||
assert list(batched(items, 0)) == [[Path("a")], [Path("b")]]
|
||||
assert list(batched(items, -5)) == [[Path("a")], [Path("b")]]
|
||||
|
||||
|
||||
def test_run_eager_hydrate_fetches_all_placeholders(tmp_path: Path) -> None:
|
||||
_make_placeholder(tmp_path / "Cargo.toml")
|
||||
_make_placeholder(tmp_path / "sub" / "Cargo.lock")
|
||||
calls: List[Path] = []
|
||||
|
||||
def fetch_fn(path: Path) -> bool:
|
||||
calls.append(path)
|
||||
path.write_bytes(b"content")
|
||||
return True
|
||||
|
||||
summary = run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=fetch_fn,
|
||||
allowed_basenames=("Cargo.toml", "Cargo.lock"),
|
||||
sleep_fn=lambda _s: None,
|
||||
)
|
||||
|
||||
assert summary == EagerHydrateSummary(hydrated=2, skipped_existing=0, failed=0)
|
||||
assert sorted(calls) == sorted(
|
||||
[tmp_path / "Cargo.toml", tmp_path / "sub" / "Cargo.lock"]
|
||||
)
|
||||
|
||||
|
||||
def test_run_eager_hydrate_counts_failures_without_aborting(tmp_path: Path) -> None:
|
||||
good = tmp_path / "Cargo.toml"
|
||||
bad = tmp_path / "pyproject.toml"
|
||||
_make_placeholder(good)
|
||||
_make_placeholder(bad)
|
||||
|
||||
def fetch_fn(path: Path) -> bool:
|
||||
if path == bad:
|
||||
return False
|
||||
path.write_bytes(b"ok")
|
||||
return True
|
||||
|
||||
summary = run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=fetch_fn,
|
||||
allowed_basenames=("Cargo.toml", "pyproject.toml"),
|
||||
sleep_fn=lambda _s: None,
|
||||
)
|
||||
|
||||
assert summary.hydrated == 1
|
||||
assert summary.failed == 1
|
||||
assert summary.skipped_existing == 0
|
||||
|
||||
|
||||
def test_run_eager_hydrate_counts_raising_fetch_as_failure(tmp_path: Path) -> None:
|
||||
_make_placeholder(tmp_path / "Cargo.toml")
|
||||
|
||||
def fetch_fn(_path: Path) -> bool:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
summary = run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=fetch_fn,
|
||||
allowed_basenames=("Cargo.toml",),
|
||||
sleep_fn=lambda _s: None,
|
||||
)
|
||||
|
||||
assert summary == EagerHydrateSummary(hydrated=0, skipped_existing=0, failed=1)
|
||||
|
||||
|
||||
def test_run_eager_hydrate_skips_when_placeholder_already_filled(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# Two placeholders at enumeration time; while hydrating one, a concurrent
|
||||
# code path fills the other. Recheck inside ``run_eager_hydrate`` must
|
||||
# treat the now-non-empty peer as ``skipped_existing`` rather than
|
||||
# failing or re-fetching.
|
||||
first = tmp_path / "a" / "Cargo.toml"
|
||||
second = tmp_path / "b" / "Cargo.toml"
|
||||
_make_placeholder(first)
|
||||
_make_placeholder(second)
|
||||
|
||||
def fetch_fn(path: Path) -> bool:
|
||||
# Whichever placeholder runs first, clobber its sibling so the
|
||||
# sibling's recheck trips the ``skipped_existing`` branch regardless
|
||||
# of filesystem ordering.
|
||||
peer = second if path == first else first
|
||||
path.write_bytes(b"fetched body")
|
||||
peer.write_bytes(b"concurrent body")
|
||||
return True
|
||||
|
||||
# Batch size 8 forces both placeholders into one batch, so enumeration
|
||||
# completes before any fetch runs.
|
||||
summary = run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=fetch_fn,
|
||||
allowed_basenames=("Cargo.toml",),
|
||||
batch_size=8,
|
||||
batch_sleep_s=0,
|
||||
sleep_fn=lambda _s: None,
|
||||
)
|
||||
|
||||
assert summary.hydrated == 1
|
||||
assert summary.skipped_existing == 1
|
||||
assert summary.failed == 0
|
||||
|
||||
|
||||
def test_run_eager_hydrate_sleeps_between_batches_but_not_before_first(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
for i in range(5):
|
||||
_make_placeholder(tmp_path / "pkg{}".format(i) / "Cargo.toml")
|
||||
|
||||
sleeps: List[float] = []
|
||||
|
||||
def fetch_fn(path: Path) -> bool:
|
||||
path.write_bytes(b"x")
|
||||
return True
|
||||
|
||||
summary = run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=fetch_fn,
|
||||
allowed_basenames=("Cargo.toml",),
|
||||
batch_size=2,
|
||||
batch_sleep_s=0.123,
|
||||
sleep_fn=lambda s: sleeps.append(s),
|
||||
)
|
||||
|
||||
assert summary.hydrated == 5
|
||||
# 5 items in batches of 2 => batches [2, 2, 1]; sleep fires before
|
||||
# batches 2 and 3, i.e. twice.
|
||||
assert sleeps == [0.123, 0.123]
|
||||
|
||||
|
||||
def test_run_eager_hydrate_skips_sleep_when_interval_zero(tmp_path: Path) -> None:
|
||||
for i in range(3):
|
||||
_make_placeholder(tmp_path / "pkg{}".format(i) / "Cargo.toml")
|
||||
sleeps: List[float] = []
|
||||
|
||||
def fetch_fn(path: Path) -> bool:
|
||||
path.write_bytes(b"x")
|
||||
return True
|
||||
|
||||
run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=fetch_fn,
|
||||
allowed_basenames=("Cargo.toml",),
|
||||
batch_size=1,
|
||||
batch_sleep_s=0.0,
|
||||
sleep_fn=lambda s: sleeps.append(s),
|
||||
)
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_default_batch_size_is_capped_low_enough_for_edr() -> None:
|
||||
# Documented batch size is 20 per spec; guard against silent bumps.
|
||||
assert DEFAULT_BATCH_SIZE == 20
|
||||
|
||||
@@ -1,58 +1,28 @@
|
||||
"""Parity baseline for ``eager_hydrate`` BFS + batching + sleep pacing.
|
||||
"""Parity baseline for ``eager_hydrate`` BFS + apply pass.
|
||||
|
||||
Wave 1.5 amend §D paired parity test PR — PR 14 (envelope land 후 BFS Rust
|
||||
이관, ``local_bridge::remote_cache_mirror`` 통합) 의 baseline. 기존
|
||||
``test_eager_hydrate.py`` 14 시나리오를 보존하면서 +12 추가:
|
||||
- batched edge cases (empty / exact / single).
|
||||
- find_placeholder_candidates 추가 boundary (size>0 ignored, basename
|
||||
Wave 1.5 amend §D paired parity test — PR 14 (BFS Rust 이관) +
|
||||
PR-B / PR 17 (apply pass body Rust 이관) baseline. After PR-B the
|
||||
batched/run_eager_hydrate driver lives entirely in
|
||||
``sessions_native::eager_hydrate::run_apply_pass`` (Rust unit-tested
|
||||
side); the Python parity baseline now pins:
|
||||
- ``find_placeholder_candidates`` boundary (size>0 ignored, basename
|
||||
case-sensitivity, nested traversal, cache_root is file).
|
||||
- run_eager_hydrate 호출 순서 / fetch_fn 인자 검증 / batch boundary.
|
||||
- normalize_eager_hydrate_basenames edge cases.
|
||||
- ``normalize_eager_hydrate_basenames`` edge cases.
|
||||
- Default constants invariants used by Python wrappers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from sessions.eager_hydrate import (
|
||||
DEFAULT_BATCH_SIZE,
|
||||
DEFAULT_BATCH_SLEEP_S,
|
||||
DEFAULT_EAGER_HYDRATE_BASENAMES,
|
||||
EagerHydrateSummary,
|
||||
batched,
|
||||
find_placeholder_candidates,
|
||||
normalize_eager_hydrate_basenames,
|
||||
run_eager_hydrate,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# batched edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_batched_empty_iterable_yields_nothing() -> None:
|
||||
assert list(batched(iter([]), 5)) == []
|
||||
|
||||
|
||||
def test_batched_single_item_yields_single_batch() -> None:
|
||||
items = [Path("/x")]
|
||||
assert list(batched(iter(items), 5)) == [[Path("/x")]]
|
||||
|
||||
|
||||
def test_batched_exact_multiple_no_trailing_partial() -> None:
|
||||
items = [Path(str(i)) for i in range(6)]
|
||||
out = list(batched(iter(items), 3))
|
||||
assert len(out) == 2
|
||||
assert all(len(b) == 3 for b in out)
|
||||
|
||||
|
||||
def test_batched_partial_trailing_batch() -> None:
|
||||
items = [Path(str(i)) for i in range(7)]
|
||||
out = list(batched(iter(items), 3))
|
||||
assert [len(b) for b in out] == [3, 3, 1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_placeholder_candidates boundaries
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -94,55 +64,6 @@ def test_find_placeholder_root_is_file_not_dir(tmp_path: Path) -> None:
|
||||
assert out == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_eager_hydrate behaviour pinning
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_run_eager_hydrate_passes_path_to_fetch_fn(tmp_path: Path) -> None:
|
||||
target = tmp_path / "Cargo.toml"
|
||||
_touch(target, size=0)
|
||||
seen: List[Path] = []
|
||||
|
||||
def fetch(path: Path) -> bool:
|
||||
seen.append(path)
|
||||
# Simulate hydration: write content so the post-fetch check sees it.
|
||||
path.write_text("[package]\n")
|
||||
return True
|
||||
|
||||
summary = run_eager_hydrate(
|
||||
tmp_path, fetch_fn=fetch, batch_sleep_s=0.0, sleep_fn=lambda _s: None
|
||||
)
|
||||
assert seen == [target]
|
||||
assert summary.hydrated == 1
|
||||
|
||||
|
||||
def test_run_eager_hydrate_returns_zero_summary_when_no_candidates(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
summary = run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=lambda _p: True,
|
||||
batch_sleep_s=0.0,
|
||||
sleep_fn=lambda _s: None,
|
||||
)
|
||||
assert summary == EagerHydrateSummary(hydrated=0, skipped_existing=0, failed=0)
|
||||
|
||||
|
||||
def test_run_eager_hydrate_disabled_when_basenames_empty(tmp_path: Path) -> None:
|
||||
_touch(tmp_path / "Cargo.toml", size=0)
|
||||
seen: List[Path] = []
|
||||
summary = run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=lambda p: seen.append(p) or True,
|
||||
allowed_basenames=(),
|
||||
batch_sleep_s=0.0,
|
||||
sleep_fn=lambda _s: None,
|
||||
)
|
||||
assert seen == []
|
||||
assert summary.hydrated == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_eager_hydrate_basenames edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
277
sublime/tests/test_git_local_head_baseline.py
Normal file
277
sublime/tests/test_git_local_head_baseline.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""Tests for the hookless local-HEAD divergence detection helpers.
|
||||
|
||||
When Sublime Merge runs ``git checkout`` without firing the
|
||||
post-checkout hook, the Track G branch proxy never sees a marker file
|
||||
and the remote stays on the old branch. The v0.7.34 fix:
|
||||
|
||||
* Snapshot the local HEAD branch name after every successful Track G
|
||||
refresh (``_remember_local_head_branch``).
|
||||
* On the next refresh, before ``apply_pending_checkout``, compare
|
||||
current local HEAD against the cached baseline; if they differ and
|
||||
no real marker is queued, write a synthetic marker
|
||||
(``_synthesize_pending_checkout_if_local_head_diverged``).
|
||||
|
||||
These tests exercise the helpers in isolation — the round-trip into
|
||||
``apply_pending_checkout`` is exercised by the existing branch-proxy
|
||||
tests via the marker-file contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from sessions import commands
|
||||
from sessions.git_repo_discovery import GitRepo
|
||||
|
||||
|
||||
def _make_repo(tmp_path: Path) -> GitRepo:
|
||||
local_root = tmp_path / "repo"
|
||||
(local_root / ".git").mkdir(parents=True)
|
||||
return GitRepo(local_root=local_root, remote_root="/srv/repo", kind="regular")
|
||||
|
||||
|
||||
def test_read_local_head_branch_returns_branch_name(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
(repo.local_root / ".git" / "HEAD").write_text(
|
||||
"ref: refs/heads/feature-foo\n", encoding="utf-8"
|
||||
)
|
||||
assert commands._read_local_head_branch(repo.local_root) == "feature-foo"
|
||||
|
||||
|
||||
def test_read_local_head_branch_returns_empty_for_detached(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
(repo.local_root / ".git" / "HEAD").write_text(
|
||||
"deadbeef00000000000000000000000000000000\n", encoding="utf-8"
|
||||
)
|
||||
assert commands._read_local_head_branch(repo.local_root) == ""
|
||||
|
||||
|
||||
def test_read_local_head_branch_returns_empty_when_missing(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
# No HEAD file written.
|
||||
assert commands._read_local_head_branch(repo.local_root) == ""
|
||||
|
||||
|
||||
def test_remember_then_synthesize_writes_marker_on_divergence(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Baseline = main, current HEAD = feature → marker is synthesized."""
|
||||
repo = _make_repo(tmp_path)
|
||||
head_path = repo.local_root / ".git" / "HEAD"
|
||||
head_path.write_text("ref: refs/heads/main\n", encoding="utf-8")
|
||||
# First refresh remembers the baseline.
|
||||
monkeypatch.setattr(commands, "_track_g_local_branch_baseline", {})
|
||||
commands._remember_local_head_branch(repo)
|
||||
# User switches branches in Merge — local HEAD changes.
|
||||
head_path.write_text("ref: refs/heads/feature-x\n", encoding="utf-8")
|
||||
marker_path = repo.local_root / ".git" / "SESSIONS_PENDING_CHECKOUT"
|
||||
assert not marker_path.exists()
|
||||
|
||||
commands._synthesize_pending_checkout_if_local_head_diverged(repo)
|
||||
|
||||
assert marker_path.is_file()
|
||||
payload = json.loads(marker_path.read_text(encoding="utf-8"))
|
||||
assert payload["prev_head"] == "main"
|
||||
assert payload["new_head"] == "feature-x"
|
||||
assert payload["branch_flag"] == "1"
|
||||
assert payload["ts"] == "synthetic-from-local-head"
|
||||
|
||||
|
||||
def test_synthesize_no_op_when_baseline_unset(tmp_path: Path, monkeypatch) -> None:
|
||||
"""First-ever refresh has no baseline; do not write a synthetic marker."""
|
||||
repo = _make_repo(tmp_path)
|
||||
(repo.local_root / ".git" / "HEAD").write_text(
|
||||
"ref: refs/heads/main\n", encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(commands, "_track_g_local_branch_baseline", {})
|
||||
commands._synthesize_pending_checkout_if_local_head_diverged(repo)
|
||||
marker = repo.local_root / ".git" / "SESSIONS_PENDING_CHECKOUT"
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_synthesize_no_op_when_baseline_matches(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Same branch as baseline → no marker."""
|
||||
repo = _make_repo(tmp_path)
|
||||
(repo.local_root / ".git" / "HEAD").write_text(
|
||||
"ref: refs/heads/main\n", encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(commands, "_track_g_local_branch_baseline", {})
|
||||
commands._remember_local_head_branch(repo)
|
||||
commands._synthesize_pending_checkout_if_local_head_diverged(repo)
|
||||
marker = repo.local_root / ".git" / "SESSIONS_PENDING_CHECKOUT"
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_synthesize_no_op_when_marker_already_present(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Real post-checkout hook fired → don't overwrite its marker."""
|
||||
repo = _make_repo(tmp_path)
|
||||
(repo.local_root / ".git" / "HEAD").write_text(
|
||||
"ref: refs/heads/feature\n", encoding="utf-8"
|
||||
)
|
||||
marker = repo.local_root / ".git" / "SESSIONS_PENDING_CHECKOUT"
|
||||
marker.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"prev_head": "main",
|
||||
"new_head": "feature",
|
||||
"branch_flag": "1",
|
||||
"ts": "real-hook",
|
||||
}
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands, "_track_g_local_branch_baseline", {"local::/srv/repo": "main"}
|
||||
)
|
||||
commands._synthesize_pending_checkout_if_local_head_diverged(repo)
|
||||
payload = json.loads(marker.read_text(encoding="utf-8"))
|
||||
assert payload["ts"] == "real-hook", "must not overwrite a real hook marker"
|
||||
|
||||
|
||||
def test_synthesize_no_op_for_detached_head(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Detached HEAD (no ``ref: refs/heads/<x>`` shape) → don't synthesize."""
|
||||
repo = _make_repo(tmp_path)
|
||||
(repo.local_root / ".git" / "HEAD").write_text(
|
||||
"deadbeef00000000000000000000000000000000\n", encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands, "_track_g_local_branch_baseline", {"local::/srv/repo": "main"}
|
||||
)
|
||||
commands._synthesize_pending_checkout_if_local_head_diverged(repo)
|
||||
marker = repo.local_root / ".git" / "SESSIONS_PENDING_CHECKOUT"
|
||||
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
|
||||
# fetch failure (zero clean tracked here, but the field is set).
|
||||
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
|
||||
|
||||
@@ -4,12 +4,87 @@ from __future__ import annotations
|
||||
|
||||
from conftest import FakeView
|
||||
from sessions.lsp_save_preferences import (
|
||||
_as_enabled_flag,
|
||||
_settings_getter,
|
||||
lsp_code_actions_on_save_kinds,
|
||||
lsp_fix_all_on_save_enabled,
|
||||
lsp_format_on_save_enabled,
|
||||
lsp_organize_imports_on_save_enabled,
|
||||
)
|
||||
|
||||
# --- _settings_getter / _as_enabled_flag edge branches ---
|
||||
|
||||
|
||||
class _ViewWithoutSettings:
|
||||
pass
|
||||
|
||||
|
||||
class _ViewWithBrokenSettings:
|
||||
def settings(self):
|
||||
class _Store:
|
||||
get = "not callable"
|
||||
|
||||
return _Store()
|
||||
|
||||
|
||||
def test_settings_getter_returns_none_when_view_has_no_settings_method() -> None:
|
||||
assert _settings_getter(_ViewWithoutSettings()) is None
|
||||
|
||||
|
||||
def test_settings_getter_returns_none_when_store_get_is_not_callable() -> None:
|
||||
assert _settings_getter(_ViewWithBrokenSettings()) is None
|
||||
|
||||
|
||||
def test_as_enabled_flag_truthy_int_and_float_branches() -> None:
|
||||
assert _as_enabled_flag(1) is True
|
||||
assert _as_enabled_flag(0) is False
|
||||
assert _as_enabled_flag(1.5) is True
|
||||
assert _as_enabled_flag(0.0) is False
|
||||
|
||||
|
||||
def test_as_enabled_flag_unknown_type_falls_through_to_false() -> None:
|
||||
assert _as_enabled_flag(object()) is False
|
||||
|
||||
|
||||
# --- lsp_code_actions_on_save_kinds list/tuple + filter branches ---
|
||||
|
||||
|
||||
def test_lsp_code_actions_kinds_returns_empty_for_view_without_settings() -> None:
|
||||
assert lsp_code_actions_on_save_kinds(_ViewWithoutSettings()) == ()
|
||||
|
||||
|
||||
def test_lsp_code_actions_kinds_filters_blank_and_non_string_keys() -> None:
|
||||
v = FakeView()
|
||||
v.settings().set(
|
||||
"lsp_code_actions_on_save",
|
||||
{
|
||||
"source.fixAll": True,
|
||||
" ": True, # blank key — must be skipped
|
||||
42: True, # non-string key — must be skipped
|
||||
"source.disabled": False, # disabled flag — must be skipped
|
||||
},
|
||||
)
|
||||
out = lsp_code_actions_on_save_kinds(v)
|
||||
assert out == ("source.fixAll",)
|
||||
|
||||
|
||||
def test_lsp_code_actions_kinds_accepts_list_form() -> None:
|
||||
v = FakeView()
|
||||
v.settings().set(
|
||||
"lsp_code_actions_on_save",
|
||||
["source.organizeImports", " ", 7, "source.fixAll"],
|
||||
)
|
||||
assert lsp_code_actions_on_save_kinds(v) == (
|
||||
"source.organizeImports",
|
||||
"source.fixAll",
|
||||
)
|
||||
|
||||
|
||||
def test_lsp_code_actions_kinds_returns_empty_for_unsupported_shape() -> None:
|
||||
v = FakeView()
|
||||
v.settings().set("lsp_code_actions_on_save", "not a dict or list")
|
||||
assert lsp_code_actions_on_save_kinds(v) == ()
|
||||
|
||||
|
||||
def test_lsp_format_on_save_enabled_bool() -> None:
|
||||
v = FakeView()
|
||||
|
||||
151
sublime/tests/test_rust_local_watcher.py
Normal file
151
sublime/tests/test_rust_local_watcher.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Tests for ``_rust_ffi.local_watcher`` wrapper contracts.
|
||||
|
||||
The Rust side of the watcher is exercised by
|
||||
``sessions_native::local_watcher::tests`` (6 tests covering the live
|
||||
``notify`` event loop, filtering, and stop idempotency). These
|
||||
Python-only tests pin the ctypes-wrapper layer contract:
|
||||
|
||||
* ``start`` returns the integer the Rust ABI returned (handle on
|
||||
success, 0 on failure).
|
||||
* ``drain`` decodes the ``\\x1F``-joined payload, retries on the
|
||||
buffer-too-small sentinel, returns ``()`` on negative rc or
|
||||
zero/negative handle.
|
||||
* ``stop`` returns ``True`` only when the Rust ABI returns ``1``.
|
||||
* All three raise ``SessionsNativeLibraryError`` when the symbol is
|
||||
missing from the cdylib.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
|
||||
import pytest
|
||||
from sessions import _rust_ffi
|
||||
from sessions._rust_ffi import SessionsNativeLibraryError
|
||||
|
||||
|
||||
def _install(monkeypatch, **symbols) -> None:
|
||||
class _Lib:
|
||||
pass
|
||||
|
||||
lib = _Lib()
|
||||
for name, func in symbols.items():
|
||||
setattr(lib, name, func)
|
||||
monkeypatch.setattr(_rust_ffi._loader, "_native_lib", lambda: lib)
|
||||
|
||||
|
||||
class _FakeIntFunc:
|
||||
def __init__(self, rc: int) -> None:
|
||||
self._rc = rc
|
||||
self.argtypes = None
|
||||
self.restype = None
|
||||
|
||||
def __call__(self, *args: object) -> int:
|
||||
return self._rc
|
||||
|
||||
|
||||
class _FakeDrainFunc:
|
||||
"""Mimics ``sessions_local_watcher_drain``.
|
||||
|
||||
Returns ``rc`` and, when ``rc == 0``, writes ``payload`` (UTF-8 +
|
||||
NUL terminator) into the caller's ``out_buf``. When ``rc > out_cap``
|
||||
we expect the wrapper to retry with a bigger buffer.
|
||||
"""
|
||||
|
||||
def __init__(self, *, rc: int = 0, payload: str = "") -> None:
|
||||
self._rc = rc
|
||||
self._payload = payload
|
||||
self.argtypes = None
|
||||
self.restype = None
|
||||
self.calls: list[int] = []
|
||||
|
||||
def __call__(self, _handle: object, out_buf: object, out_cap: int) -> int:
|
||||
self.calls.append(out_cap)
|
||||
if self._rc != 0:
|
||||
return self._rc
|
||||
encoded = self._payload.encode("utf-8") + b"\x00"
|
||||
if out_cap < len(encoded):
|
||||
return len(encoded)
|
||||
ctypes.memmove(out_buf, encoded, len(encoded))
|
||||
return 0
|
||||
|
||||
|
||||
def test_start_returns_handle_from_rust(monkeypatch, tmp_path) -> None:
|
||||
_install(monkeypatch, sessions_local_watcher_start=_FakeIntFunc(rc=42))
|
||||
assert _rust_ffi.local_watcher.start(str(tmp_path)) == 42
|
||||
|
||||
|
||||
def test_start_returns_zero_on_failure(monkeypatch, tmp_path) -> None:
|
||||
_install(monkeypatch, sessions_local_watcher_start=_FakeIntFunc(rc=0))
|
||||
assert _rust_ffi.local_watcher.start(str(tmp_path)) == 0
|
||||
|
||||
|
||||
def test_start_raises_when_symbol_missing(monkeypatch, tmp_path) -> None:
|
||||
_install(monkeypatch) # no symbol bound
|
||||
with pytest.raises(SessionsNativeLibraryError):
|
||||
_rust_ffi.local_watcher.start(str(tmp_path))
|
||||
|
||||
|
||||
def test_drain_with_zero_handle_short_circuits(monkeypatch) -> None:
|
||||
# Should not even reach the Rust ABI; install a func that would
|
||||
# explode if called.
|
||||
_install(monkeypatch, sessions_local_watcher_drain=_FakeIntFunc(rc=-1))
|
||||
assert _rust_ffi.local_watcher.drain(0) == ()
|
||||
assert _rust_ffi.local_watcher.drain(-5) == ()
|
||||
|
||||
|
||||
def test_drain_returns_empty_tuple_on_empty_payload(monkeypatch) -> None:
|
||||
func = _FakeDrainFunc(rc=0, payload="")
|
||||
_install(monkeypatch, sessions_local_watcher_drain=func)
|
||||
assert _rust_ffi.local_watcher.drain(7) == ()
|
||||
|
||||
|
||||
def test_drain_splits_unit_separator(monkeypatch) -> None:
|
||||
func = _FakeDrainFunc(rc=0, payload="/a/b\x1f/c/d\x1f/e")
|
||||
_install(monkeypatch, sessions_local_watcher_drain=func)
|
||||
assert _rust_ffi.local_watcher.drain(1) == ("/a/b", "/c/d", "/e")
|
||||
|
||||
|
||||
def test_drain_returns_empty_on_unknown_handle(monkeypatch) -> None:
|
||||
# Rust returns -1 when ``handle`` is unknown ("watcher gone").
|
||||
_install(monkeypatch, sessions_local_watcher_drain=_FakeIntFunc(rc=-1))
|
||||
assert _rust_ffi.local_watcher.drain(99) == ()
|
||||
|
||||
|
||||
def test_drain_grows_buffer_on_buffer_too_small(monkeypatch) -> None:
|
||||
# First call returns the required size; second succeeds.
|
||||
payload = "/long/path/" + "x" * 16_000
|
||||
func = _FakeDrainFunc(rc=0, payload=payload)
|
||||
_install(monkeypatch, sessions_local_watcher_drain=func)
|
||||
out = _rust_ffi.local_watcher.drain(1)
|
||||
assert out == (payload,)
|
||||
# Two attempts: 8192 (initial), then >= encoded length.
|
||||
assert len(func.calls) >= 2
|
||||
assert func.calls[0] == 8192
|
||||
assert func.calls[-1] >= len(payload.encode("utf-8")) + 1
|
||||
|
||||
|
||||
def test_drain_raises_when_symbol_missing(monkeypatch) -> None:
|
||||
_install(monkeypatch)
|
||||
with pytest.raises(SessionsNativeLibraryError):
|
||||
_rust_ffi.local_watcher.drain(1)
|
||||
|
||||
|
||||
def test_stop_returns_true_when_rust_returned_one(monkeypatch) -> None:
|
||||
_install(monkeypatch, sessions_local_watcher_stop=_FakeIntFunc(rc=1))
|
||||
assert _rust_ffi.local_watcher.stop(1) is True
|
||||
|
||||
|
||||
def test_stop_returns_false_when_rust_returned_zero(monkeypatch) -> None:
|
||||
_install(monkeypatch, sessions_local_watcher_stop=_FakeIntFunc(rc=0))
|
||||
assert _rust_ffi.local_watcher.stop(1) is False
|
||||
|
||||
|
||||
def test_stop_with_zero_handle_short_circuits(monkeypatch) -> None:
|
||||
_install(monkeypatch, sessions_local_watcher_stop=_FakeIntFunc(rc=99))
|
||||
assert _rust_ffi.local_watcher.stop(0) is False
|
||||
assert _rust_ffi.local_watcher.stop(-3) is False
|
||||
|
||||
|
||||
def test_stop_raises_when_symbol_missing(monkeypatch) -> None:
|
||||
_install(monkeypatch)
|
||||
with pytest.raises(SessionsNativeLibraryError):
|
||||
_rust_ffi.local_watcher.stop(7)
|
||||
@@ -602,3 +602,234 @@ def test_per_method_timeouts_fallback_on_garbage_setting(monkeypatch) -> None:
|
||||
"""A non-numeric value falls back to the documented default."""
|
||||
_stub_settings(monkeypatch, {"sessions_file_read_timeout_s": "not-a-number"})
|
||||
assert ssh_ft._file_read_timeout_s() == 30.0
|
||||
|
||||
|
||||
# --- _transport_trace_event branch coverage ---
|
||||
|
||||
|
||||
def test_transport_trace_event_notifies_listeners_even_when_disabled(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
captured: list = []
|
||||
|
||||
def listener(event, fields):
|
||||
captured.append((event, dict(fields)))
|
||||
|
||||
monkeypatch.setattr(ssh_ft, "_transport_trace_enabled", lambda: False)
|
||||
ssh_ft.register_transport_trace_listener(listener)
|
||||
try:
|
||||
ssh_ft._transport_trace_event("ut.event", host="x", count=2)
|
||||
finally:
|
||||
ssh_ft.unregister_transport_trace_listener(listener)
|
||||
assert captured == [("ut.event", {"host": "x", "count": 2})]
|
||||
|
||||
|
||||
def test_transport_trace_event_writes_jsonl_when_enabled(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
import json as _json
|
||||
|
||||
log_path = tmp_path / "logs" / "debug-trace.log"
|
||||
monkeypatch.setattr(ssh_ft, "_transport_trace_enabled", lambda: True)
|
||||
monkeypatch.setattr(ssh_ft, "_transport_trace_log_path", lambda: log_path)
|
||||
ssh_ft._transport_trace_event("ut.event", host="x", count=2)
|
||||
assert log_path.is_file()
|
||||
line = log_path.read_text(encoding="utf-8").strip().splitlines()[-1]
|
||||
payload = _json.loads(line)
|
||||
assert payload["event"] == "ut.event"
|
||||
assert payload["host"] == "x"
|
||||
assert payload["count"] == 2
|
||||
assert "ts" in payload and "time" in payload
|
||||
|
||||
|
||||
def test_transport_trace_event_swallows_listener_exceptions(monkeypatch) -> None:
|
||||
"""A listener that raises must not crash the trace path or the caller."""
|
||||
|
||||
def bad_listener(event, fields):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(ssh_ft, "_transport_trace_enabled", lambda: False)
|
||||
ssh_ft.register_transport_trace_listener(bad_listener)
|
||||
try:
|
||||
ssh_ft._transport_trace_event("ut.event") # must not raise
|
||||
finally:
|
||||
ssh_ft.unregister_transport_trace_listener(bad_listener)
|
||||
|
||||
|
||||
def test_transport_trace_log_path_uses_sublime_cache_root(monkeypatch) -> None:
|
||||
"""Log path lives under sublime.cache_path()/Sessions/logs."""
|
||||
monkeypatch.setattr(ssh_ft.sublime, "cache_path", lambda: "/tmp/fake_cache")
|
||||
out = ssh_ft._transport_trace_log_path()
|
||||
assert str(out).endswith("Sessions/logs/debug-trace.log")
|
||||
assert "/tmp/fake_cache" in str(out)
|
||||
|
||||
|
||||
def test_transport_trace_enabled_returns_false_when_settings_unavailable(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""Missing load_settings must safely return False, not crash."""
|
||||
monkeypatch.setattr(ssh_ft.sublime, "load_settings", None, raising=False)
|
||||
assert ssh_ft._transport_trace_enabled() is False
|
||||
|
||||
|
||||
# --- _emit_bridge_diagnostic_matrix branches ---
|
||||
|
||||
|
||||
def test_emit_bridge_diagnostic_matrix_no_op_when_disabled(monkeypatch) -> None:
|
||||
captured: list = []
|
||||
monkeypatch.setattr(ssh_ft, "_transport_trace_enabled", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
ssh_ft, "_transport_trace_event", lambda *a, **k: captured.append((a, k))
|
||||
)
|
||||
ssh_ft._emit_bridge_diagnostic_matrix("prod", "spawn")
|
||||
assert captured == []
|
||||
|
||||
|
||||
def test_emit_bridge_diagnostic_matrix_includes_optional_payloads(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
captured: list = []
|
||||
monkeypatch.setattr(ssh_ft, "_transport_trace_enabled", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
ssh_ft, "_transport_trace_event", lambda event, **k: captured.append((event, k))
|
||||
)
|
||||
|
||||
class _Proc:
|
||||
pid = 4242
|
||||
|
||||
payload = {
|
||||
"id": "envelope-1",
|
||||
"method": "file/read",
|
||||
"timeout_ms": 5000,
|
||||
}
|
||||
ssh_ft._emit_bridge_diagnostic_matrix(
|
||||
"prod",
|
||||
"after_handshake",
|
||||
bridge_path=None,
|
||||
revision="rev-abc",
|
||||
payload=payload,
|
||||
process=_Proc(),
|
||||
child_env_summary={"SESSIONS_BRIDGE_DIAG_LOG": True},
|
||||
timeout_context={"phase": "handshake", "elapsed_ms": 12},
|
||||
)
|
||||
assert len(captured) == 1
|
||||
event, fields = captured[0]
|
||||
assert event == "bridge.diagnostic_matrix"
|
||||
assert fields["phase"] == "after_handshake"
|
||||
assert fields["host_alias"] == "prod"
|
||||
assert fields["helper_revision"] == "rev-abc"
|
||||
assert fields["envelope_id"] == "envelope-1"
|
||||
assert fields["envelope_method"] == "file/read"
|
||||
assert fields["envelope_timeout_ms"] == 5000
|
||||
assert fields["bridge_subprocess_pid"] == 4242
|
||||
assert fields["child_env_flags"] == {"SESSIONS_BRIDGE_DIAG_LOG": True}
|
||||
assert fields["timeout_context"] == {"phase": "handshake", "elapsed_ms": 12}
|
||||
|
||||
|
||||
# --- transport-trace listener registry ---
|
||||
|
||||
|
||||
def test_binary_stat_snapshot_reports_size_and_mtime(tmp_path: Path) -> None:
|
||||
target = tmp_path / "binary"
|
||||
target.write_bytes(b"abc")
|
||||
snap = ssh_ft._binary_stat_snapshot(target)
|
||||
assert snap["path"] == str(target)
|
||||
assert snap["size_bytes"] == 3
|
||||
assert isinstance(snap["mtime_ns"], int)
|
||||
assert "stat_error" not in snap
|
||||
|
||||
|
||||
def test_binary_stat_snapshot_records_stat_error_for_missing(tmp_path: Path) -> None:
|
||||
snap = ssh_ft._binary_stat_snapshot(tmp_path / "does-not-exist")
|
||||
assert "stat_error" in snap
|
||||
assert "size_bytes" not in snap
|
||||
|
||||
|
||||
def test_bridge_diagnostic_hypothesis_catalog_returns_documented_rows() -> None:
|
||||
rows = ssh_ft._bridge_diagnostic_hypothesis_catalog()
|
||||
assert isinstance(rows, list) and rows, "catalog must list at least one hypothesis"
|
||||
for row in rows:
|
||||
assert {"id", "rust_events", "meaning"}.issubset(row.keys())
|
||||
assert isinstance(row["id"], str) and row["id"].startswith("H")
|
||||
|
||||
|
||||
def test_child_env_session_flags_reflects_bridge_diag_log_presence() -> None:
|
||||
assert ssh_ft._child_env_session_flags({"SESSIONS_BRIDGE_DIAG_LOG": "/tmp/x"}) == {
|
||||
"bridge_diag_log": True
|
||||
}
|
||||
assert ssh_ft._child_env_session_flags({}) == {"bridge_diag_log": False}
|
||||
assert ssh_ft._child_env_session_flags({"SESSIONS_BRIDGE_DIAG_LOG": " "}) == {
|
||||
"bridge_diag_log": False
|
||||
}
|
||||
|
||||
|
||||
# --- pure helpers (envelope id, revision normalization, auth hint) ---
|
||||
|
||||
|
||||
def test_next_envelope_id_is_monotonic_per_prefix() -> None:
|
||||
a = ssh_ft._next_envelope_id("tree-list")
|
||||
b = ssh_ft._next_envelope_id("tree-list")
|
||||
assert a.startswith("tree-list-") and b.startswith("tree-list-")
|
||||
assert int(a.rsplit("-", 1)[1]) < int(b.rsplit("-", 1)[1])
|
||||
|
||||
|
||||
def test_next_bridge_trace_request_id_is_strictly_increasing() -> None:
|
||||
first = ssh_ft._next_bridge_trace_request_id()
|
||||
second = ssh_ft._next_bridge_trace_request_id()
|
||||
assert second == first + 1
|
||||
|
||||
|
||||
def test_revision_cache_segment_short_alnum_passthrough() -> None:
|
||||
assert ssh_ft._revision_cache_segment("v0.7.36") == "v0.7.36"
|
||||
assert ssh_ft._revision_cache_segment("rev_abc-123") == "rev_abc-123"
|
||||
|
||||
|
||||
def test_revision_cache_segment_blank_returns_unknown() -> None:
|
||||
assert ssh_ft._revision_cache_segment("") == "unknown"
|
||||
assert ssh_ft._revision_cache_segment(" ") == "unknown"
|
||||
|
||||
|
||||
def test_revision_cache_segment_unsafe_chars_hash_fallback() -> None:
|
||||
out = ssh_ft._revision_cache_segment("ev!l/path with spaces")
|
||||
assert out.startswith("sha256_")
|
||||
assert len(out) == len("sha256_") + 24 # truncated digest
|
||||
|
||||
|
||||
def test_revision_cache_segment_overlong_hash_fallback() -> None:
|
||||
out = ssh_ft._revision_cache_segment("a" * 200)
|
||||
assert out.startswith("sha256_")
|
||||
|
||||
|
||||
def test_validate_revision_path_segment_accepts_safe_chars() -> None:
|
||||
ssh_ft._validate_revision_path_segment("v0.7.36-rc1")
|
||||
ssh_ft._validate_revision_path_segment("rev_abc-123") # must not raise
|
||||
|
||||
|
||||
def test_validate_revision_path_segment_rejects_path_separators() -> None:
|
||||
with pytest.raises(SessionHelperStartError):
|
||||
ssh_ft._validate_revision_path_segment("../escape")
|
||||
|
||||
|
||||
def test_ssh_auth_failure_hint_returns_empty_for_non_auth_stderr() -> None:
|
||||
assert ssh_ft._ssh_auth_failure_hint("connection refused") == ""
|
||||
assert ssh_ft._ssh_auth_failure_hint("") == ""
|
||||
|
||||
|
||||
def test_ssh_auth_failure_hint_returns_text_on_permission_denied() -> None:
|
||||
hint = ssh_ft._ssh_auth_failure_hint("Permission denied (publickey)")
|
||||
assert hint, "expected a one-line hint when stderr is auth-shaped"
|
||||
|
||||
|
||||
def test_transport_trace_listener_register_and_unregister_round_trip() -> None:
|
||||
def listener(event, fields):
|
||||
return None
|
||||
|
||||
ssh_ft.register_transport_trace_listener(listener)
|
||||
assert listener in ssh_ft._TRANSPORT_TRACE_LISTENERS
|
||||
# Re-registering is idempotent (no duplicates).
|
||||
ssh_ft.register_transport_trace_listener(listener)
|
||||
assert ssh_ft._TRANSPORT_TRACE_LISTENERS.count(listener) == 1
|
||||
ssh_ft.unregister_transport_trace_listener(listener)
|
||||
assert listener not in ssh_ft._TRANSPORT_TRACE_LISTENERS
|
||||
# Unregistering a not-registered listener is a no-op.
|
||||
ssh_ft.unregister_transport_trace_listener(listener)
|
||||
|
||||
Reference in New Issue
Block a user