Synced from monorepo
Synced from monorepo Changes: - Workspace server: surface preview-proxy metrics through the hub metric pump - Shell: reclaim a session’s retained state in one entry - Shell: reclaim a session’s resident state in one entry - Pager: withhold key event types from Alacritty builds that double keys - Tools: cancel a session’s subagents when it closes - Pager: keep the whole plan in scrollback and separate reasoning from output in minimal mode - Pager: probe terminal version over DA2 and include it with feedback - SuperGrok Plus: identity, CLI, and analytics tier surfaces - Shell: inherit the session process scope into subagents - Pager: build @-file-search matcher lazily on first use - Tools: fix description and output contradictions in tool definitions - Workspace: degrade @-file-search instead of aborting on thread exhaustion - Tools: reap a session’s LSP servers when it closes - Tools: fix contradictions and defects in tool descriptions, schemas, and harness pools - MCP: reap stdio MCP children on session close - Shell: reuse spawn-time skill discovery for session telemetry - Tools: stop leaking shell-wrapper positional params into sourced scripts (fixes activate_conda under persistent/static shell) - Shell: self-heal corrupt session-search SQLite cache - Workspace: cap workspace-server tokio workers on many-core hosts - Shell: reap a session’s child processes when it closes - Crash handler: capture SIGABRT so panic-aborts leave crash reports - CLI chat proxy: team-scoped Grok Code managed-config admin routes - MCP: add CLI enable/disable for MCP servers - Shell: cap tokio worker threads for startup thread demand - Workspace: harden git_commit and add git_sync_base operation - Circuit breaker: add feature-gated gRPC retry policy Source-Revision: 2a818575225183d8ca915f5632a09b8067b5156a
This commit is contained in:
parent
02d9359435
commit
5da6962e4a
192 changed files with 10337 additions and 3421 deletions
|
|
@ -248,6 +248,7 @@ fn main() -> anyhow::Result<()> {
|
|||
None
|
||||
};
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(xai_tty_utils::runtime::capped_worker_threads().get())
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(run(args, cwd))
|
||||
|
|
@ -442,6 +443,14 @@ async fn run(args: Args, cwd: PathBuf) -> anyhow::Result<()> {
|
|||
}
|
||||
None => tracing::info!("metric export disabled (not connected)"),
|
||||
}
|
||||
if metric_donation_pump.is_some()
|
||||
&& let Some((tx, control_port)) = &preview_shutdown
|
||||
{
|
||||
tokio::spawn(preview_supervisor::supervise_preview_metrics(
|
||||
*control_port,
|
||||
tx.subscribe(),
|
||||
));
|
||||
}
|
||||
tracing::info!(
|
||||
server_id = ?server_id,
|
||||
"Workspace server connected to hub. Serving tools."
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::{
|
|||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc::{RecvError, RecvTimeoutError, SyncSender, sync_channel},
|
||||
mpsc::{RecvTimeoutError, SyncSender, sync_channel},
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
time::Duration,
|
||||
|
|
@ -18,9 +18,28 @@ use nucleo::{
|
|||
const NUM_NUCLEO_THREADS: usize = 2;
|
||||
const NUM_IGNORE_THREADS: usize = 8;
|
||||
|
||||
/// What a fuzzy matcher can serve. Browsing is a serial depth-1 walk that needs
|
||||
/// no thread pool; only keyed matching needs the nucleo pool, and only the
|
||||
/// daemon needs its worker thread.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum MatcherMode {
|
||||
/// Nucleo pool up: keyed fuzzy queries and empty-query browsing.
|
||||
Full,
|
||||
/// Nucleo pool refused: empty-query browsing only; keyed queries are empty.
|
||||
BrowseOnly,
|
||||
/// Daemon worker thread refused: every result is empty.
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WalkMode {
|
||||
Parallel,
|
||||
Serial,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FuzzyMatchResult {
|
||||
// Path of the matched entry.
|
||||
/// Path of the matched entry.
|
||||
pub path: Utf32String,
|
||||
/// Matcher score, higher is better.
|
||||
pub score: u32,
|
||||
|
|
@ -41,11 +60,85 @@ struct MatchEntry {
|
|||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
fn check_entry<'a>(entry: &'a DirEntry, root: &Path) -> Option<(&'a str, bool)> {
|
||||
let path = entry.path();
|
||||
if path != root
|
||||
&& let Some(file_type) = entry.file_type()
|
||||
&& (file_type.is_file() || file_type.is_dir())
|
||||
&& let Ok(path) = path.strip_prefix(root)
|
||||
&& let Some(path) = path.as_os_str().to_str()
|
||||
&& !path.is_empty()
|
||||
{
|
||||
Some((path, file_type.is_dir()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn push_match(
|
||||
injector: &nucleo::Injector<MatchEntry>,
|
||||
root: &Path,
|
||||
entry: Result<DirEntry, ignore::Error>,
|
||||
) {
|
||||
if let Ok(entry) = entry
|
||||
&& let Some((path, is_dir)) = check_entry(&entry, root)
|
||||
{
|
||||
injector.push(MatchEntry { is_dir }, |_entry, columns| {
|
||||
columns[0] = path.into();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `n` OS threads can be spawned (each joined before return).
|
||||
/// Probe-then-build is racy.
|
||||
fn threads_spawnable(n: usize) -> bool {
|
||||
let gate = Arc::new((Mutex::new(false), std::sync::Condvar::new()));
|
||||
let mut handles = Vec::with_capacity(n);
|
||||
let mut all_spawned = true;
|
||||
for _ in 0..n {
|
||||
let gate = gate.clone();
|
||||
match thread::Builder::new()
|
||||
.name("thread-probe".into())
|
||||
.spawn(move || {
|
||||
let (lock, cv) = &*gate;
|
||||
let mut released = lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
while !*released {
|
||||
released = cv.wait(released).unwrap_or_else(|e| e.into_inner());
|
||||
}
|
||||
}) {
|
||||
Ok(handle) => handles.push(handle),
|
||||
Err(_) => {
|
||||
all_spawned = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (lock, cv) = &*gate;
|
||||
*lock.lock().unwrap_or_else(|e| e.into_inner()) = true;
|
||||
cv.notify_all();
|
||||
for handle in handles {
|
||||
let _ = handle.join();
|
||||
}
|
||||
all_spawned
|
||||
}
|
||||
|
||||
/// Probes `NUM_IGNORE_THREADS + 1` because `build_parallel` runs inside the
|
||||
/// spawned fuzzy-walk thread, which itself builds the ignore pool.
|
||||
fn choose_walk_mode(nucleo_enabled: bool, probe: impl Fn(usize) -> bool) -> WalkMode {
|
||||
if nucleo_enabled && probe(NUM_IGNORE_THREADS + 1) {
|
||||
WalkMode::Parallel
|
||||
} else {
|
||||
WalkMode::Serial
|
||||
}
|
||||
}
|
||||
|
||||
/// A very fast fuzzy matcher that does ignore-walking. Both happen in background threads.
|
||||
pub struct FuzzyFileMatcher {
|
||||
root: PathBuf,
|
||||
query: String,
|
||||
nucleo: Nucleo<MatchEntry>,
|
||||
/// `None` when the matcher pool cannot be spawned; keyed matching then
|
||||
/// degrades to `MatcherMode::BrowseOnly`. See `mode`.
|
||||
nucleo: Option<Nucleo<MatchEntry>>,
|
||||
matcher: Matcher,
|
||||
walk_handle: Option<JoinHandle<()>>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
|
|
@ -55,17 +148,38 @@ pub struct FuzzyFileMatcher {
|
|||
|
||||
impl FuzzyFileMatcher {
|
||||
/// Create a new matcher with default config focused on matching paths.
|
||||
///
|
||||
/// If the matcher thread pool cannot be spawned (cgroup pids / `RLIMIT_NPROC`
|
||||
/// exhaustion), the matcher degrades to browse-only: it logs once and keyed
|
||||
/// queries return no matches (see [`Self::is_enabled`]). Empty-query browsing
|
||||
/// still works from the serial top-level walk.
|
||||
///
|
||||
/// The probe asks for `NUM_NUCLEO_THREADS + 1`: peak demand is the persistent
|
||||
/// nucleo pool plus the daemon's worker thread, so reserving the extra slot
|
||||
/// keeps the daemon spawn (browse) from being starved by the pool.
|
||||
pub fn new(root: &Path) -> Self {
|
||||
let matcher_config = nucleo::Config::DEFAULT.match_paths();
|
||||
// matcher_config.prefer_prefix = true; // yes or no? nucleo docs lean towards no
|
||||
Self::new_inner(root, threads_spawnable(NUM_NUCLEO_THREADS + 1))
|
||||
}
|
||||
|
||||
let mut nucleo = Nucleo::new(
|
||||
matcher_config.clone(),
|
||||
Arc::new(move || ()),
|
||||
Some(NUM_NUCLEO_THREADS),
|
||||
1,
|
||||
);
|
||||
nucleo.pattern = MultiPattern::new(1);
|
||||
fn new_inner(root: &Path, nucleo_available: bool) -> Self {
|
||||
let matcher_config = nucleo::Config::DEFAULT.match_paths();
|
||||
|
||||
let nucleo = nucleo_available.then(|| {
|
||||
let mut nucleo = Nucleo::new(
|
||||
matcher_config.clone(),
|
||||
Arc::new(move || ()),
|
||||
Some(NUM_NUCLEO_THREADS),
|
||||
1,
|
||||
);
|
||||
nucleo.pattern = MultiPattern::new(1);
|
||||
nucleo
|
||||
});
|
||||
if nucleo.is_none() {
|
||||
tracing::error!(
|
||||
"keyed fuzzy search disabled (browse-only): cannot spawn matcher \
|
||||
threads (out of thread slots / cgroup pids cap)"
|
||||
);
|
||||
}
|
||||
|
||||
Self {
|
||||
root: root.to_owned(),
|
||||
|
|
@ -79,71 +193,55 @@ impl FuzzyFileMatcher {
|
|||
}
|
||||
}
|
||||
|
||||
/// This matcher's own capability; it is never `Disabled` because the serial
|
||||
/// top-level walk browses without the nucleo pool.
|
||||
fn mode(&self) -> MatcherMode {
|
||||
if self.nucleo.is_some() {
|
||||
MatcherMode::Full
|
||||
} else {
|
||||
MatcherMode::BrowseOnly
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether keyed fuzzy matching is active. When false, only empty-query
|
||||
/// browsing returns results; keyed queries return empty.
|
||||
fn is_enabled(&self) -> bool {
|
||||
matches!(self.mode(), MatcherMode::Full)
|
||||
}
|
||||
|
||||
pub fn query(&self) -> &str {
|
||||
&self.query
|
||||
}
|
||||
|
||||
/// Start a new walk and restart nucleo matcher.
|
||||
pub fn restart_walk_custom(
|
||||
pub fn restart_walk_with(
|
||||
&mut self,
|
||||
make_walker: impl FnOnce(&mut WalkBuilder) -> &mut WalkBuilder,
|
||||
) {
|
||||
// first, wait for previous walker to finish if it's up
|
||||
// Join the previous walk first so the probe measures freed threads, not
|
||||
// the outgoing walk's.
|
||||
self.join_walk();
|
||||
let mode = choose_walk_mode(self.is_enabled(), threads_spawnable);
|
||||
self.restart_walk_inner(make_walker, mode);
|
||||
}
|
||||
|
||||
/// Cancel the current walk and join its thread. Idempotent.
|
||||
fn join_walk(&mut self) {
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
// Join without unwrapping so a panicked walk thread does not re-panic.
|
||||
if let Some(walk_handle) = self.walk_handle.take() {
|
||||
walk_handle.join().unwrap();
|
||||
let _ = walk_handle.join();
|
||||
}
|
||||
}
|
||||
|
||||
// disconnect all injectors and clear snapshots and streams
|
||||
self.nucleo.restart(true);
|
||||
|
||||
// we're back in business
|
||||
self.cancel.store(false, Ordering::Relaxed);
|
||||
|
||||
// build the walker(s)
|
||||
let walker_builder = make_walker(
|
||||
WalkBuilder::new(&self.root)
|
||||
.threads(NUM_IGNORE_THREADS)
|
||||
.follow_links(false)
|
||||
.git_ignore(true)
|
||||
.git_global(true)
|
||||
.git_exclude(true)
|
||||
.ignore(true)
|
||||
.hidden(true)
|
||||
.require_git(false)
|
||||
.overrides(
|
||||
OverrideBuilder::new(&self.root)
|
||||
.add("!.git")
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap(),
|
||||
),
|
||||
)
|
||||
.clone();
|
||||
|
||||
fn check_entry<'a>(entry: &'a DirEntry, root: &Path) -> Option<(&'a str, bool)> {
|
||||
let path = entry.path();
|
||||
if path != root
|
||||
&& let Some(file_type) = entry.file_type()
|
||||
&& (file_type.is_file() || file_type.is_dir())
|
||||
&& let Ok(path) = path.strip_prefix(root)
|
||||
&& let Some(path) = path.as_os_str().to_str()
|
||||
&& !path.is_empty()
|
||||
{
|
||||
Some((path, file_type.is_dir()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// we'll just do it in a blocking way here assuming it's super fast anyway
|
||||
let top_walker = walker_builder
|
||||
/// Sorted top-level entries for empty-query browsing (a serial, depth-1
|
||||
/// walk, so it works even when the matcher is disabled).
|
||||
fn collect_top_entries(&self, walker_builder: &WalkBuilder) -> Vec<FuzzyMatchResult> {
|
||||
walker_builder
|
||||
.clone()
|
||||
.max_depth(Some(1))
|
||||
.sort_by_file_name(|a, b| a.cmp(b))
|
||||
.build();
|
||||
let top_entries = top_walker
|
||||
.into_iter()
|
||||
.build()
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
let (path, is_dir) = check_entry(&entry, &self.root)?;
|
||||
|
|
@ -154,42 +252,110 @@ impl FuzzyFileMatcher {
|
|||
is_dir,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The shared git/ignore walk configuration, before per-walk tweaks.
|
||||
/// `threads` only affects `build_parallel()`; the serial `build()` fallback
|
||||
/// ignores it and spawns no threads.
|
||||
fn base_walker(&self) -> WalkBuilder {
|
||||
let mut builder = WalkBuilder::new(&self.root);
|
||||
builder
|
||||
.threads(NUM_IGNORE_THREADS)
|
||||
.follow_links(false)
|
||||
.git_ignore(true)
|
||||
.git_global(true)
|
||||
.git_exclude(true)
|
||||
.ignore(true)
|
||||
.hidden(true)
|
||||
.require_git(false)
|
||||
.overrides(
|
||||
OverrideBuilder::new(&self.root)
|
||||
.add("!.git")
|
||||
.expect("static \"!.git\" override must parse")
|
||||
.build()
|
||||
.expect("static \"!.git\" override must build"),
|
||||
);
|
||||
builder
|
||||
}
|
||||
|
||||
/// Restart the walk with a pre-computed [`WalkMode`].
|
||||
///
|
||||
/// Assumes any prior walk is already joined: `restart_walk_with` joins
|
||||
/// before probing, and the direct test-seam callers start from a fresh
|
||||
/// matcher.
|
||||
fn restart_walk_inner(
|
||||
&mut self,
|
||||
make_walker: impl FnOnce(&mut WalkBuilder) -> &mut WalkBuilder,
|
||||
mode: WalkMode,
|
||||
) {
|
||||
debug_assert!(
|
||||
self.walk_handle.is_none(),
|
||||
"restart_walk_inner requires the prior walk to be joined"
|
||||
);
|
||||
if let Some(nucleo) = self.nucleo.as_mut() {
|
||||
nucleo.restart(true);
|
||||
}
|
||||
self.cancel.store(false, Ordering::Relaxed);
|
||||
|
||||
let mut base = self.base_walker();
|
||||
let walker_builder = make_walker(&mut base).clone();
|
||||
|
||||
self.top_entries = self.collect_top_entries(&walker_builder);
|
||||
|
||||
let injector = self.nucleo.as_mut().map(|nucleo| {
|
||||
let injector = nucleo.injector();
|
||||
nucleo.tick(0);
|
||||
injector
|
||||
});
|
||||
let Some(injector) = injector else {
|
||||
// Disabled matcher: browsing still works from `top_entries`, but
|
||||
// there is no background walk to feed.
|
||||
tracing::debug!("fuzzy walk skipped: matcher disabled");
|
||||
return;
|
||||
};
|
||||
|
||||
let injector = self.nucleo.injector();
|
||||
let root = self.root.clone();
|
||||
let cancel = self.cancel.clone();
|
||||
|
||||
// link walker threads with injectors and start it up
|
||||
let walker = walker_builder.build_parallel();
|
||||
let walk_handle = thread::spawn(move || {
|
||||
walker.run(|| {
|
||||
let injector = injector.clone();
|
||||
let root = root.clone();
|
||||
let cancel = cancel.clone();
|
||||
Box::new(move |entry| {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return WalkState::Quit;
|
||||
} else if let Ok(entry) = entry
|
||||
&& let Some((path, is_dir)) = check_entry(&entry, &root)
|
||||
{
|
||||
injector.push(MatchEntry { is_dir }, |_entry, columns| {
|
||||
columns[0] = path.into();
|
||||
});
|
||||
let walk = thread::Builder::new()
|
||||
.name("fuzzy-walk".into())
|
||||
.spawn(move || {
|
||||
if mode == WalkMode::Parallel {
|
||||
walker_builder.build_parallel().run(|| {
|
||||
let injector = injector.clone();
|
||||
let root = root.clone();
|
||||
let cancel = cancel.clone();
|
||||
Box::new(move |entry| {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return WalkState::Quit;
|
||||
}
|
||||
push_match(&injector, &root, entry);
|
||||
WalkState::Continue
|
||||
})
|
||||
});
|
||||
} else {
|
||||
// Serial fallback: `Walk` spawns no threads.
|
||||
tracing::debug!("fuzzy walk running serially (parallel pool unavailable)");
|
||||
for entry in walker_builder.build() {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
push_match(&injector, &root, entry);
|
||||
}
|
||||
WalkState::Continue
|
||||
})
|
||||
}
|
||||
});
|
||||
});
|
||||
self.walk_handle = Some(walk_handle);
|
||||
self.top_entries = top_entries;
|
||||
|
||||
self.nucleo.tick(0);
|
||||
match walk {
|
||||
Ok(handle) => self.walk_handle = Some(handle),
|
||||
Err(e) => tracing::error!(
|
||||
error = %e,
|
||||
"fuzzy walk thread spawn failed; file search results unavailable this walk"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart the walk with default walker parameters.
|
||||
pub fn restart_walk(&mut self) {
|
||||
self.restart_walk_custom(|w| w);
|
||||
self.restart_walk_with(|w| w);
|
||||
}
|
||||
|
||||
/// Set the query to a given string and trigger reparse.
|
||||
|
|
@ -210,10 +376,12 @@ impl FuzzyFileMatcher {
|
|||
.as_bytes()
|
||||
.last()
|
||||
.is_some_and(|ch| ch.is_ascii_whitespace());
|
||||
self.nucleo
|
||||
.pattern
|
||||
.reparse(0, query, CaseMatching::Smart, Normalization::Smart, append);
|
||||
self.nucleo.tick(0);
|
||||
if let Some(nucleo) = self.nucleo.as_mut() {
|
||||
nucleo
|
||||
.pattern
|
||||
.reparse(0, query, CaseMatching::Smart, Normalization::Smart, append);
|
||||
nucleo.tick(0);
|
||||
}
|
||||
self.query = query.to_owned();
|
||||
}
|
||||
|
||||
|
|
@ -225,8 +393,14 @@ impl FuzzyFileMatcher {
|
|||
changed: false,
|
||||
};
|
||||
}
|
||||
let status = self.nucleo.tick(tick_timeout_ms);
|
||||
let done = self.nucleo.active_injectors() == 0 && !status.running;
|
||||
let Some(nucleo) = self.nucleo.as_mut() else {
|
||||
return FuzzyMatcherStatus {
|
||||
done: true,
|
||||
changed: false,
|
||||
};
|
||||
};
|
||||
let status = nucleo.tick(tick_timeout_ms);
|
||||
let done = nucleo.active_injectors() == 0 && !status.running;
|
||||
FuzzyMatcherStatus {
|
||||
done,
|
||||
changed: status.changed,
|
||||
|
|
@ -238,7 +412,9 @@ impl FuzzyFileMatcher {
|
|||
if self.query.is_empty() {
|
||||
self.top_entries.len()
|
||||
} else {
|
||||
self.nucleo.snapshot().item_count() as _
|
||||
self.nucleo
|
||||
.as_ref()
|
||||
.map_or(0, |nucleo| nucleo.snapshot().item_count() as _)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -246,7 +422,7 @@ impl FuzzyFileMatcher {
|
|||
pub fn get_top_k(&mut self, k: usize) -> Vec<FuzzyMatchResult> {
|
||||
// note: &mut only because we access self.matcher which has internal allocations
|
||||
|
||||
// rust is a bit dumb at times, we'll need this for sorting without cloning
|
||||
// A HRTB helper so we can sort by a borrowed key without cloning.
|
||||
fn sort_by_key_hrtb<T, F, K, Q>(slice: &mut [T], f: F)
|
||||
where
|
||||
F: for<'a> Fn(&'a T) -> (Q, &'a K),
|
||||
|
|
@ -256,16 +432,14 @@ impl FuzzyFileMatcher {
|
|||
slice.sort_by(|a, b| f(a).cmp(&f(b)))
|
||||
}
|
||||
|
||||
// special case: if query is empty, return top items only
|
||||
if self.query.is_empty() {
|
||||
return self
|
||||
.top_entries
|
||||
.iter()
|
||||
// dirs_only=true means only directories; dirs_only=false means both files and directories
|
||||
.filter(|e| !self.dirs || e.is_dir)
|
||||
.take(k)
|
||||
.cloned()
|
||||
.collect(); // should be already sorted
|
||||
.collect();
|
||||
}
|
||||
|
||||
// https://github.com/helix-editor/helix/blob/d79cce4e4bfc24dd204f1b294c899ed73f7e9453/helix-term/src/ui/completion.rs#L369
|
||||
|
|
@ -273,15 +447,18 @@ impl FuzzyFileMatcher {
|
|||
let len = self.query.chars().count() as u32;
|
||||
let min_score = 7 + len * 14;
|
||||
|
||||
let Some(nucleo) = self.nucleo.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut items = Vec::with_capacity(k);
|
||||
let pattern = self.nucleo.pattern.column_pattern(0);
|
||||
let snapshot = self.nucleo.snapshot();
|
||||
let pattern = nucleo.pattern.column_pattern(0);
|
||||
let snapshot = nucleo.snapshot();
|
||||
let mut iter = snapshot.matches().iter().peekable();
|
||||
|
||||
while items.len() < k
|
||||
&& let Some(m) = iter.next()
|
||||
// for empty queries, return everything; otherwise, apply heuristic min-score limit
|
||||
&& (self.query.is_empty() || m.score >= min_score)
|
||||
&& m.score >= min_score
|
||||
{
|
||||
fn extract_match(
|
||||
m: &Match,
|
||||
|
|
@ -290,8 +467,9 @@ impl FuzzyFileMatcher {
|
|||
matcher: &mut Matcher,
|
||||
dirs_only: bool,
|
||||
) -> Option<FuzzyMatchResult> {
|
||||
// SAFETY: `m.idx` comes from this snapshot's own match list, so
|
||||
// it is a valid index into the snapshot.
|
||||
let item = unsafe { snapshot.get_item_unchecked(m.idx) };
|
||||
// dirs_only=true means only directories; dirs_only=false means both files and directories
|
||||
if dirs_only && !item.data.is_dir {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -353,8 +531,8 @@ impl FuzzyFileMatcher {
|
|||
|
||||
impl Drop for FuzzyFileMatcher {
|
||||
fn drop(&mut self) {
|
||||
// note: walker threads *may* get detached for a little while but hopefully not for too long
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
// Join the walk (join_walk sets cancel) so it stops before nucleo drops.
|
||||
self.join_walk();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -382,7 +560,12 @@ enum FuzzyMatcherDaemonMessage {
|
|||
pub struct FuzzyFileMatcherDaemon {
|
||||
results: Arc<Mutex<FuzzyMatcherDaemonResults>>,
|
||||
tx: SyncSender<FuzzyMatcherDaemonMessage>,
|
||||
_handle: JoinHandle<()>,
|
||||
/// `None` when the daemon thread cannot be spawned; messages are dropped.
|
||||
/// Joined in `Drop` for deterministic teardown.
|
||||
handle: Option<JoinHandle<()>>,
|
||||
/// Served capability. `Disabled` means the worker thread was refused, so
|
||||
/// `get` yields only empty results; `BrowseOnly` still returns browse hits.
|
||||
mode: MatcherMode,
|
||||
}
|
||||
|
||||
impl FuzzyFileMatcherDaemon {
|
||||
|
|
@ -390,90 +573,329 @@ impl FuzzyFileMatcherDaemon {
|
|||
let results = Arc::new(Mutex::new(FuzzyMatcherDaemonResults::default()));
|
||||
let (tx, rx) = sync_channel(1024);
|
||||
|
||||
let matcher_mode = matcher.mode();
|
||||
let res = results.clone();
|
||||
let handle = thread::spawn(move || {
|
||||
let results = res;
|
||||
let mut done = false;
|
||||
let mut generation = 0;
|
||||
loop {
|
||||
let msg = if !done {
|
||||
rx.recv_timeout(Duration::from_micros(250))
|
||||
} else {
|
||||
rx.recv().map_err(|e| match e {
|
||||
RecvError => RecvTimeoutError::Disconnected,
|
||||
})
|
||||
};
|
||||
match msg {
|
||||
Ok(FuzzyMatcherDaemonMessage::RestartWalk { hidden }) => {
|
||||
if !hidden {
|
||||
tracing::trace!("restarting normal walk");
|
||||
matcher.restart_walk();
|
||||
} else {
|
||||
tracing::trace!("restarting hidden walk");
|
||||
matcher.restart_walk_custom(|w| {
|
||||
w.hidden(false).ignore(false).git_ignore(false)
|
||||
});
|
||||
}
|
||||
generation += 1;
|
||||
*results.lock().unwrap() = FuzzyMatcherDaemonResults::default();
|
||||
done = false;
|
||||
}
|
||||
Ok(FuzzyMatcherDaemonMessage::SetQuery { query, dirs }) => {
|
||||
matcher.set_query(&query, dirs);
|
||||
generation += 1;
|
||||
done = false;
|
||||
}
|
||||
Ok(FuzzyMatcherDaemonMessage::Stop) | Err(RecvTimeoutError::Disconnected) => {
|
||||
break;
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if !done {
|
||||
let status = matcher.tick(10);
|
||||
done = status.done;
|
||||
let num_items = matcher.num_items();
|
||||
let topk: Arc<[_]> = matcher.get_top_k(topk).into();
|
||||
*results.lock().unwrap() = FuzzyMatcherDaemonResults {
|
||||
topk,
|
||||
num_items,
|
||||
status,
|
||||
generation,
|
||||
};
|
||||
let handle = thread::Builder::new()
|
||||
.name("fuzzy-daemon".into())
|
||||
.spawn(move || {
|
||||
let results = res;
|
||||
let mut done = false;
|
||||
let mut generation = 0;
|
||||
loop {
|
||||
let msg = if !done {
|
||||
rx.recv_timeout(Duration::from_micros(250))
|
||||
} else {
|
||||
rx.recv().map_err(|_| RecvTimeoutError::Disconnected)
|
||||
};
|
||||
match msg {
|
||||
Ok(FuzzyMatcherDaemonMessage::RestartWalk { hidden }) => {
|
||||
if !hidden {
|
||||
tracing::trace!("restarting normal walk");
|
||||
matcher.restart_walk();
|
||||
} else {
|
||||
tracing::trace!("restarting hidden walk");
|
||||
matcher.restart_walk_with(|w| {
|
||||
w.hidden(false).ignore(false).git_ignore(false)
|
||||
});
|
||||
}
|
||||
generation += 1;
|
||||
*results.lock().unwrap() = FuzzyMatcherDaemonResults::default();
|
||||
done = false;
|
||||
}
|
||||
Ok(FuzzyMatcherDaemonMessage::SetQuery { query, dirs }) => {
|
||||
matcher.set_query(&query, dirs);
|
||||
generation += 1;
|
||||
done = false;
|
||||
}
|
||||
Ok(FuzzyMatcherDaemonMessage::Stop)
|
||||
| Err(RecvTimeoutError::Disconnected) => {
|
||||
break;
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if !done {
|
||||
let status = matcher.tick(10);
|
||||
done = status.done;
|
||||
let num_items = matcher.num_items();
|
||||
let topk: Arc<[_]> = matcher.get_top_k(topk).into();
|
||||
*results.lock().unwrap() = FuzzyMatcherDaemonResults {
|
||||
topk,
|
||||
num_items,
|
||||
status,
|
||||
generation,
|
||||
};
|
||||
generation += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let (handle, mode) = match handle {
|
||||
Ok(handle) => (Some(handle), matcher_mode),
|
||||
Err(e) => {
|
||||
// nucleo built but the daemon thread was refused: degrade to
|
||||
// fully disabled, so `get` reads empty and terminal.
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
"fuzzy daemon thread spawn failed; file search disabled"
|
||||
);
|
||||
(None, MatcherMode::Disabled)
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Self {
|
||||
results,
|
||||
tx,
|
||||
_handle: handle,
|
||||
handle,
|
||||
mode,
|
||||
}
|
||||
}
|
||||
|
||||
/// Latest results. A `Disabled` daemon (worker thread refused) never
|
||||
/// populated them, so this yields only empty results.
|
||||
pub fn get(&self) -> FuzzyMatcherDaemonResults {
|
||||
if self.mode == MatcherMode::Disabled {
|
||||
// Terminal empty state. `generation` is MAX so it also clears the
|
||||
// callers' `generation >= min_gen` gate: otherwise a disabled search
|
||||
// reads as perpetually pending once the first query bumps min_gen
|
||||
// past zero.
|
||||
return FuzzyMatcherDaemonResults {
|
||||
status: FuzzyMatcherStatus {
|
||||
done: true,
|
||||
changed: false,
|
||||
},
|
||||
generation: usize::MAX,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
self.results.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn set_query(&self, query: impl AsRef<str>, dirs: bool) {
|
||||
let query = query.as_ref().to_owned();
|
||||
_ = self
|
||||
let _ = self
|
||||
.tx
|
||||
.send(FuzzyMatcherDaemonMessage::SetQuery { query, dirs })
|
||||
.ok();
|
||||
.send(FuzzyMatcherDaemonMessage::SetQuery { query, dirs });
|
||||
}
|
||||
|
||||
pub fn restart_walk(&self, hidden: bool) {
|
||||
_ = self
|
||||
let _ = self
|
||||
.tx
|
||||
.send(FuzzyMatcherDaemonMessage::RestartWalk { hidden })
|
||||
.ok();
|
||||
.send(FuzzyMatcherDaemonMessage::RestartWalk { hidden });
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FuzzyFileMatcherDaemon {
|
||||
fn drop(&mut self) {
|
||||
_ = self.tx.send(FuzzyMatcherDaemonMessage::Stop).ok();
|
||||
let _ = self.tx.send(FuzzyMatcherDaemonMessage::Stop);
|
||||
// Join for deterministic teardown: the loop breaks on Stop and dropping
|
||||
// the matcher cancels the walk. `None` if the thread never spawned.
|
||||
if let Some(handle) = self.handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Regression guards: a refused thread degrades fuzzy search rather than
|
||||
//! aborting under `panic = "abort"`.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn temp_repo() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("alpha.txt"), b"x").unwrap();
|
||||
std::fs::write(dir.path().join("beta.txt"), b"y").unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn drain_until_done(matcher: &mut FuzzyFileMatcher) {
|
||||
for _ in 0..1000 {
|
||||
if matcher.tick(10).done {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_matcher_degrades_without_panicking() {
|
||||
let dir = temp_repo();
|
||||
let mut matcher = FuzzyFileMatcher::new_inner(dir.path(), false);
|
||||
assert!(
|
||||
!matcher.is_enabled(),
|
||||
"a refused threadpool disables search instead of building nucleo"
|
||||
);
|
||||
|
||||
matcher.restart_walk();
|
||||
assert!(
|
||||
matcher.num_items() >= 2,
|
||||
"empty-query browsing still lists files from the serial top walk"
|
||||
);
|
||||
assert!(!matcher.get_top_k(10).is_empty());
|
||||
|
||||
matcher.set_query("alpha", false);
|
||||
assert!(matcher.tick(10).done, "a disabled tick is immediately done");
|
||||
assert_eq!(matcher.num_items(), 0);
|
||||
assert!(matcher.get_top_k(10).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_walk_paths_feed_the_matcher() {
|
||||
let dir = temp_repo();
|
||||
for mode in [WalkMode::Serial, WalkMode::Parallel] {
|
||||
let mut matcher = FuzzyFileMatcher::new_inner(dir.path(), true);
|
||||
matcher.restart_walk_inner(|w| w, mode);
|
||||
matcher.set_query("alpha", false);
|
||||
drain_until_done(&mut matcher);
|
||||
assert!(
|
||||
matcher
|
||||
.get_top_k(10)
|
||||
.iter()
|
||||
.any(|h| h.path.to_string().contains("alpha")),
|
||||
"walk (mode={mode:?}) should feed the matcher"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choose_walk_mode_degrades_and_probes_with_walk_thread() {
|
||||
use std::cell::Cell;
|
||||
|
||||
assert_eq!(choose_walk_mode(true, |_| true), WalkMode::Parallel);
|
||||
assert_eq!(
|
||||
choose_walk_mode(true, |_| false),
|
||||
WalkMode::Serial,
|
||||
"a refused probe degrades to serial even with nucleo up"
|
||||
);
|
||||
|
||||
let probed = Cell::new(false);
|
||||
assert_eq!(
|
||||
choose_walk_mode(false, |_| {
|
||||
probed.set(true);
|
||||
true
|
||||
}),
|
||||
WalkMode::Serial,
|
||||
"disabled keyed matching walks serially"
|
||||
);
|
||||
assert!(!probed.get(), "a disabled matcher must not probe threads");
|
||||
|
||||
let asked = Cell::new(0);
|
||||
let _ = choose_walk_mode(true, |n| {
|
||||
asked.set(n);
|
||||
true
|
||||
});
|
||||
assert_eq!(
|
||||
asked.get(),
|
||||
NUM_IGNORE_THREADS + 1,
|
||||
"probe reserves the outer fuzzy-walk thread that builds the pool"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Unix child re-exec under `RLIMIT_NPROC` exercises the real `new()` probe path.
|
||||
#[cfg(all(test, unix))]
|
||||
mod thread_exhaustion_tests {
|
||||
use super::*;
|
||||
|
||||
const CHILD_ENV: &str = "XAI_FUZZY_THREAD_EXHAUSTION_CHILD";
|
||||
const PASS_MARK: &str = "fuzzy-contained:";
|
||||
const SKIP_MARK: &str = "skip-child:";
|
||||
|
||||
/// Child: cap `RLIMIT_NPROC` so no new thread spawns, then build and drive
|
||||
/// the real matcher. Every path must degrade, never abort.
|
||||
fn run_child() -> ! {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(dir.path().join("alpha.txt"), b"x").expect("write fixture");
|
||||
|
||||
let mut lim = libc::rlimit {
|
||||
rlim_cur: 0,
|
||||
rlim_max: 0,
|
||||
};
|
||||
// SAFETY: getrlimit writes only into local `lim`.
|
||||
if unsafe { libc::getrlimit(libc::RLIMIT_NPROC, &mut lim) } != 0 {
|
||||
println!("{SKIP_MARK} getrlimit failed");
|
||||
std::process::exit(0);
|
||||
}
|
||||
lim.rlim_cur = 1.min(lim.rlim_max);
|
||||
// SAFETY: lowers only this process's soft limit; existing threads live on.
|
||||
if unsafe { libc::setrlimit(libc::RLIMIT_NPROC, &lim) } != 0 {
|
||||
println!("{SKIP_MARK} setrlimit failed");
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
let mut matcher = FuzzyFileMatcher::new(dir.path());
|
||||
if matcher.is_enabled() {
|
||||
println!("{SKIP_MARK} threadpool built despite the cap");
|
||||
std::process::exit(0);
|
||||
}
|
||||
// Drive the disabled matcher end to end: restart_walk collects the
|
||||
// top-level entries and returns before spawning a walk thread (nucleo is
|
||||
// None), and every query call returns empty. A panic here would abort
|
||||
// under panic=abort.
|
||||
matcher.restart_walk();
|
||||
matcher.set_query("alpha", false);
|
||||
let _ = matcher.tick(10);
|
||||
let _ = matcher.num_items();
|
||||
let _ = matcher.get_top_k(10);
|
||||
|
||||
// The daemon degrades too: its worker thread fails to spawn under the
|
||||
// cap, so set_query/get must return empty without panicking. Fail (not
|
||||
// skip) if it somehow returns matches.
|
||||
let daemon = FuzzyFileMatcherDaemon::new(FuzzyFileMatcher::new(dir.path()), 10);
|
||||
daemon.set_query("alpha", false);
|
||||
if !daemon.get().topk.is_empty() {
|
||||
eprintln!("disabled daemon returned matches despite the cap");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
println!("{PASS_MARK} degraded to disabled and survived");
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
/// Doubles as the child entry point when `CHILD_ENV` is set.
|
||||
#[test]
|
||||
fn child_entry_matcher_under_thread_exhaustion() {
|
||||
if std::env::var_os(CHILD_ENV).is_some() {
|
||||
run_child();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matcher_construction_under_thread_exhaustion_is_contained() {
|
||||
// module_path!() includes the crate name; libtest filters do not.
|
||||
let filter = module_path!()
|
||||
.split_once("::")
|
||||
.map(|(_, rest)| rest)
|
||||
.unwrap_or_default();
|
||||
let exe = std::env::current_exe().expect("current_exe");
|
||||
let mut cmd = std::process::Command::new(exe);
|
||||
cmd.arg("--exact")
|
||||
.arg(format!(
|
||||
"{filter}::child_entry_matcher_under_thread_exhaustion"
|
||||
))
|
||||
.arg("--nocapture")
|
||||
.arg("--test-threads=1")
|
||||
.env(CHILD_ENV, "1")
|
||||
.stdin(std::process::Stdio::null());
|
||||
xai_tty_utils::detach_std_command(&mut cmd);
|
||||
let out = cmd.output().expect("spawn child test process");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
|
||||
assert!(
|
||||
out.status.success() && !stderr.contains("panicked at"),
|
||||
"child aborted/panicked instead of degrading (status: {:?})\nstdout:\n{stdout}\nstderr:\n{stderr}",
|
||||
out.status
|
||||
);
|
||||
if stdout.contains(SKIP_MARK) {
|
||||
eprintln!("skipped: {stdout}");
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
stdout.contains(PASS_MARK),
|
||||
"no pass/skip marker (filter matched nothing?)\nstdout:\n{stdout}\nstderr:\n{stderr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2634,14 +2634,18 @@ impl WorkspaceHandle {
|
|||
let overrides_map: HashMap<String, McpClientTimeoutOverrides> = HashMap::new();
|
||||
let meta_config_map = McpMetaConfigMap::new();
|
||||
let oauth_config_map = McpOAuthConfigMap::new();
|
||||
let ctx = xai_grok_mcp::servers::McpSpawnCtx::for_session(
|
||||
&session_id_owned,
|
||||
&event_writer,
|
||||
xai_grok_mcp::servers::OauthInteractivity::Interactive,
|
||||
None,
|
||||
);
|
||||
rt_handle.block_on(xai_grok_mcp::servers::start_mcp_servers(
|
||||
configs,
|
||||
Some(&session_id_owned),
|
||||
&overrides_map,
|
||||
&meta_config_map,
|
||||
&oauth_config_map,
|
||||
&event_writer,
|
||||
xai_grok_mcp::servers::OauthInteractivity::Interactive,
|
||||
&ctx,
|
||||
))
|
||||
})
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -755,6 +755,9 @@ impl WorkspaceRpcHandler {
|
|||
<GitCommitReq as WorkspaceRpc>::METHOD => {
|
||||
dispatch_op::<GitCommitReq>(params, &self.workspace, None).await
|
||||
}
|
||||
<GitSyncBaseReq as WorkspaceRpc>::METHOD => {
|
||||
dispatch_op::<GitSyncBaseReq>(params, &self.workspace, None).await
|
||||
}
|
||||
<GitCheckoutReq as WorkspaceRpc>::METHOD => {
|
||||
dispatch_op::<GitCheckoutReq>(params, &self.workspace, None).await
|
||||
}
|
||||
|
|
@ -3186,6 +3189,7 @@ mod tests {
|
|||
<GitUnstageReq as WorkspaceRpc>::METHOD,
|
||||
<GitDiscardReq as WorkspaceRpc>::METHOD,
|
||||
<GitCommitReq as WorkspaceRpc>::METHOD,
|
||||
<GitSyncBaseReq as WorkspaceRpc>::METHOD,
|
||||
<GitCheckoutReq as WorkspaceRpc>::METHOD,
|
||||
<GitStashReq as WorkspaceRpc>::METHOD,
|
||||
<GitInfoReq as WorkspaceRpc>::METHOD,
|
||||
|
|
|
|||
|
|
@ -527,6 +527,81 @@ async fn scrape_activity_loop(
|
|||
}
|
||||
}
|
||||
|
||||
// ── Preview-metrics scraper ────────────────────────────────────────────────
|
||||
|
||||
const PREVIEW_METRICS_PATH: &str = "/__control/metrics";
|
||||
const PREVIEW_METRICS_PREFIX: &str = "preview_proxy_";
|
||||
const PREVIEW_METRICS_SCRAPE_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
fn metrics_url(control_port: u16) -> String {
|
||||
format!(
|
||||
"http://{}:{control_port}{PREVIEW_METRICS_PATH}",
|
||||
Ipv4Addr::LOCALHOST
|
||||
)
|
||||
}
|
||||
|
||||
/// Scrapes the proxy's loopback-only metrics and donates them via the hub pump.
|
||||
pub async fn supervise_preview_metrics(control_port: Option<u16>, shutdown: watch::Receiver<bool>) {
|
||||
scrape_metrics_loop(
|
||||
control_port.unwrap_or(DEFAULT_PREVIEW_CONTROL_PORT),
|
||||
PREVIEW_METRICS_SCRAPE_INTERVAL,
|
||||
shutdown,
|
||||
|body| {
|
||||
if let Some(sink) = xai_computer_hub_sdk::metric_donate::active_metrics_sink() {
|
||||
sink.export_text_exposition(body, PREVIEW_METRICS_PREFIX);
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn scrape_metrics_loop(
|
||||
control_port: u16,
|
||||
interval: Duration,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
mut donate: impl FnMut(&str),
|
||||
) {
|
||||
if *shutdown.borrow() {
|
||||
return;
|
||||
}
|
||||
let url = metrics_url(control_port);
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(PREVIEW_ACTIVITY_SCRAPE_TIMEOUT)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
{
|
||||
Ok(client) => client,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "preview-metrics scraper: HTTP client build failed; disabled");
|
||||
return;
|
||||
}
|
||||
};
|
||||
tracing::info!(%url, "starting preview-metrics scraper");
|
||||
|
||||
// Scrape-first: a short-lived sandbox must not exit with zero samples.
|
||||
loop {
|
||||
match client.get(&url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => match resp.text().await {
|
||||
Ok(body) => donate(&body),
|
||||
Err(e) => {
|
||||
tracing::debug!(%url, error = %e, "preview-metrics scrape body read failed");
|
||||
}
|
||||
},
|
||||
Ok(resp) => {
|
||||
tracing::debug!(%url, status = resp.status().as_u16(), "preview-metrics scrape returned an error status");
|
||||
}
|
||||
// Proxy absent (disabled / starting / restarting): quiet no-op.
|
||||
Err(e) if e.is_connect() || e.is_timeout() => {}
|
||||
Err(e) => {
|
||||
tracing::debug!(%url, error = %e, "preview-metrics scrape failed");
|
||||
}
|
||||
}
|
||||
if sleep_or_shutdown(interval, &mut shutdown).await {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
|
@ -712,6 +787,104 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metrics_url_targets_the_loopback_control_metrics_path() {
|
||||
assert_eq!(
|
||||
metrics_url(6015),
|
||||
"http://127.0.0.1:6015/__control/metrics",
|
||||
"must match the proxy's control router metrics route"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metrics_scrape_loop_donates_the_body_immediately_and_stops_on_shutdown() {
|
||||
const BODY: &str = "preview_proxy_active_ws_connections 3\n";
|
||||
let port = serve_canned("HTTP/1.1 200 OK", BODY, true).await;
|
||||
let donated = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||||
let (tx, rx) = watch::channel(false);
|
||||
let sink = Arc::clone(&donated);
|
||||
let handle = tokio::spawn(scrape_metrics_loop(
|
||||
port,
|
||||
// 1h interval: only the immediate first scrape can donate.
|
||||
Duration::from_secs(3600),
|
||||
rx,
|
||||
move |body| sink.lock().unwrap().push(body.to_owned()),
|
||||
));
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if !donated.lock().unwrap().is_empty() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the first scrape must donate without waiting a full interval");
|
||||
assert_eq!(vec![BODY.to_owned()], *donated.lock().unwrap());
|
||||
|
||||
tx.send(true).expect("receiver alive");
|
||||
tokio::time::timeout(Duration::from_secs(5), handle)
|
||||
.await
|
||||
.expect("metrics scrape loop must stop promptly on shutdown")
|
||||
.expect("task should not panic");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metrics_scrape_loop_skips_error_responses_and_absent_proxy() {
|
||||
let port = serve_canned("HTTP/1.1 500 Internal Server Error", "boom", true).await;
|
||||
let donated = Arc::new(AtomicUsize::new(0));
|
||||
let (tx, rx) = watch::channel(false);
|
||||
let counter = Arc::clone(&donated);
|
||||
let handle = tokio::spawn(scrape_metrics_loop(
|
||||
port,
|
||||
Duration::from_millis(5),
|
||||
rx,
|
||||
move |_| {
|
||||
counter.fetch_add(1, SeqCst);
|
||||
},
|
||||
));
|
||||
tokio::time::sleep(Duration::from_millis(80)).await;
|
||||
assert_eq!(0, donated.load(SeqCst), "error responses must not donate");
|
||||
tx.send(true).expect("receiver alive");
|
||||
tokio::time::timeout(Duration::from_secs(5), handle)
|
||||
.await
|
||||
.expect("must stop on shutdown")
|
||||
.expect("no panic");
|
||||
|
||||
let donated = Arc::new(AtomicUsize::new(0));
|
||||
let (tx, rx) = watch::channel(false);
|
||||
let counter = Arc::clone(&donated);
|
||||
let handle = tokio::spawn(scrape_metrics_loop(
|
||||
reserved_closed_port().await,
|
||||
Duration::from_millis(5),
|
||||
rx,
|
||||
move |_| {
|
||||
counter.fetch_add(1, SeqCst);
|
||||
},
|
||||
));
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(0, donated.load(SeqCst), "an absent proxy must not donate");
|
||||
tx.send(true).expect("receiver alive");
|
||||
tokio::time::timeout(Duration::from_secs(5), handle)
|
||||
.await
|
||||
.expect("must stop on shutdown")
|
||||
.expect("no panic");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metrics_scrape_loop_returns_immediately_when_already_shut_down() {
|
||||
let (_tx, rx) = watch::channel(true);
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
scrape_metrics_loop(6015, Duration::from_millis(5), rx, |_| {
|
||||
panic!("must not scrape after a pre-flipped shutdown")
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("a pre-flipped shutdown must return without scraping");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_activity_body_reads_stamp_and_rejects_bad_shapes() {
|
||||
assert_eq!(
|
||||
|
|
@ -769,6 +942,15 @@ mod tests {
|
|||
.expect("build client")
|
||||
}
|
||||
|
||||
async fn reserved_closed_port() -> u16 {
|
||||
let probe = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
|
||||
.await
|
||||
.expect("reserve");
|
||||
let port = probe.local_addr().expect("addr").port();
|
||||
drop(probe);
|
||||
port
|
||||
}
|
||||
|
||||
async fn serve_canned(status_line: &'static str, body: &'static str, repeat: bool) -> u16 {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@ use tokio::process::Command;
|
|||
use tokio::sync::Mutex;
|
||||
use url::Url;
|
||||
pub use xai_grok_workspace_types::rpc::git::{
|
||||
ChangeType, CommitData, CommitResult, DiscardScope, GitBranchEntry, GitBranchListData,
|
||||
GitDiffsData, GitError, GitFileChange, GitInfoData, GitReadFile, GitReadFilesData,
|
||||
GitStatusData, StageData, VcsKind,
|
||||
ChangeType, CommitData, CommitOutcome, CommitResult, DiscardScope, GitBranchEntry,
|
||||
GitBranchListData, GitCommitReq, GitDiffsData, GitError, GitFileChange, GitInfoData,
|
||||
GitReadFile, GitReadFilesData, GitStatusData, GitSyncBaseOutcome, GitSyncBaseResult,
|
||||
PushStatus, StageData, VcsKind,
|
||||
};
|
||||
pub const ERROR_CODE_DIFF_SIZE_EXCEEDED: &str = "DIFF_SIZE_EXCEEDED";
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -2282,61 +2283,283 @@ pub async fn restage_git_paths(cwd: &Path, git_ref: &GitStateRef, session_id: &s
|
|||
}
|
||||
failed_adds == 0
|
||||
}
|
||||
pub async fn commit(
|
||||
git_root: &Path,
|
||||
message: &str,
|
||||
amend: bool,
|
||||
signoff: bool,
|
||||
push: bool,
|
||||
sync: bool,
|
||||
) -> Result<CommitResult> {
|
||||
/// Run a git CLI command returning `(success, combined stdout+stderr)` instead
|
||||
/// of collapsing failure into an error. `LC_ALL=C` pins git's message locale so
|
||||
/// callers can classify output (e.g. non-fast-forward push rejections) by
|
||||
/// string-match. Errors only on spawn failure.
|
||||
async fn git_cli_raw(cwd: &Path, args: &[&str]) -> Result<(bool, String)> {
|
||||
tracing::debug!(cwd = %cwd.display(), args = ?args, "git_cli_raw");
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.current_dir(cwd).arg("--no-optional-locks");
|
||||
for &(key, val) in xai_tty_utils::GIT_AUTH_SUPPRESSION_ENVS.iter() {
|
||||
cmd.env(key, val);
|
||||
}
|
||||
cmd.env("LC_ALL", "C");
|
||||
cmd.stdin(std::process::Stdio::null());
|
||||
xai_grok_tools::util::detach_command(&mut cmd);
|
||||
cmd.envs(xai_grok_tools::util::pager_env());
|
||||
let output = cmd.args(args).output().await?;
|
||||
let mut combined = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if !stderr.is_empty() {
|
||||
if !combined.is_empty() {
|
||||
combined.push('\n');
|
||||
}
|
||||
combined.push_str(&stderr);
|
||||
}
|
||||
Ok((output.status.success(), combined))
|
||||
}
|
||||
/// Marker line guarding the default-exclude seed. Environments may pre-seed
|
||||
/// the same block at provision time under this marker; whichever side seeds
|
||||
/// first wins and the other becomes a no-op.
|
||||
const DEFAULT_EXCLUDES_MARKER: &str = "grok default excludes";
|
||||
/// Local-only default excludes (`.git/info/exclude` — never enters the repo's
|
||||
/// history) so `stage_all` can't sweep in dependency trees, build output, or
|
||||
/// env files. `git add -f` still overrides.
|
||||
const DEFAULT_EXCLUDES_BLOCK: &str = "\
|
||||
# grok default excludes (local-only; seeded by the workspace git_commit op)
|
||||
.grok/
|
||||
node_modules/
|
||||
.env
|
||||
.env.*
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
.next/
|
||||
.nuxt/
|
||||
.svelte-kit/
|
||||
.turbo/
|
||||
.cache/
|
||||
.parcel-cache/
|
||||
coverage/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
.pnpm-store/
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
";
|
||||
/// Seed [`DEFAULT_EXCLUDES_BLOCK`] into the repo's `info/exclude` unless the
|
||||
/// marker is already present. Resolves the real file via `--git-path` so
|
||||
/// gitfile and linked-worktree checkouts seed the common dir, not a dead
|
||||
/// `.git/info` path.
|
||||
async fn seed_default_excludes(git_root: &Path) -> Result<()> {
|
||||
let exclude_rel = git_cli(git_root, &["rev-parse", "--git-path", "info/exclude"]).await?;
|
||||
let exclude_path = {
|
||||
let p = Path::new(&exclude_rel);
|
||||
if p.is_absolute() {
|
||||
p.to_path_buf()
|
||||
} else {
|
||||
git_root.join(p)
|
||||
}
|
||||
};
|
||||
let existing = tokio::fs::read_to_string(&exclude_path)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if existing.contains(DEFAULT_EXCLUDES_MARKER) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(parent) = exclude_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let mut content = existing;
|
||||
if !content.is_empty() && !content.ends_with('\n') {
|
||||
content.push('\n');
|
||||
}
|
||||
content.push_str(DEFAULT_EXCLUDES_BLOCK);
|
||||
tokio::fs::write(&exclude_path, content).await?;
|
||||
tracing::debug!(path = %exclude_path.display(), "seeded default git excludes");
|
||||
Ok(())
|
||||
}
|
||||
/// Push HEAD to origin, classifying the failure mode. Never forces. Returns
|
||||
/// the combined output alongside the [`PushStatus`].
|
||||
async fn push_classified(git_root: &Path) -> Result<(PushStatus, String)> {
|
||||
let (ok, out) = git_cli_raw(git_root, &["push", "-u", "origin", "HEAD"]).await?;
|
||||
if ok {
|
||||
return Ok((PushStatus::Ok, out));
|
||||
}
|
||||
let lower = out.to_lowercase();
|
||||
let status = if lower.contains("non-fast-forward") || lower.contains("fetch first") {
|
||||
PushStatus::Conflict
|
||||
} else {
|
||||
PushStatus::Failed
|
||||
};
|
||||
Ok((status, out))
|
||||
}
|
||||
/// Refuse unless the workspace is on exactly `expected`. Detached HEAD
|
||||
/// reports "HEAD" and so never matches.
|
||||
async fn ensure_on_branch(git_root: &Path, expected: Option<&str>) -> Result<()> {
|
||||
let Some(expected) = expected else {
|
||||
return Ok(());
|
||||
};
|
||||
let current = git_cli(git_root, &["rev-parse", "--abbrev-ref", "HEAD"]).await?;
|
||||
anyhow::ensure!(
|
||||
current == expected,
|
||||
"workspace is on '{current}', expected '{expected}'"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub async fn commit(git_root: &Path, req: &GitCommitReq) -> Result<CommitResult> {
|
||||
let start = std::time::Instant::now();
|
||||
let mut args = vec!["commit", "-m", message];
|
||||
if amend {
|
||||
args.push("--amend");
|
||||
ensure_on_branch(git_root, req.expected_branch.as_deref()).await?;
|
||||
if req.seed_default_excludes {
|
||||
seed_default_excludes(git_root).await?;
|
||||
}
|
||||
if signoff {
|
||||
args.push("--signoff");
|
||||
if req.stage_all {
|
||||
git_cli(git_root, &["add", "-A"]).await?;
|
||||
}
|
||||
let clean = req.stage_all
|
||||
&& !req.amend
|
||||
&& git_cli_raw(git_root, &["diff", "--cached", "--quiet"])
|
||||
.await?
|
||||
.0;
|
||||
let mut combined_output;
|
||||
if clean {
|
||||
combined_output = "Nothing to commit; working tree clean".to_owned();
|
||||
} else {
|
||||
let mut args = vec!["commit", "-m", &req.message];
|
||||
if req.amend {
|
||||
args.push("--amend");
|
||||
}
|
||||
if req.signoff {
|
||||
args.push("--signoff");
|
||||
}
|
||||
git_cli(git_root, &args).await?;
|
||||
combined_output = String::new();
|
||||
}
|
||||
let mut commit_hash = git_cli(git_root, &["rev-parse", "HEAD"]).await.ok();
|
||||
if !clean {
|
||||
let short_hash = commit_hash
|
||||
.as_ref()
|
||||
.and_then(|h| h.get(..7))
|
||||
.unwrap_or("unknown");
|
||||
combined_output = format!("Committed: {}", short_hash);
|
||||
}
|
||||
git_cli(git_root, &args).await?;
|
||||
let commit_hash = git_cli(git_root, &["rev-parse", "HEAD"]).await.ok();
|
||||
let short_hash = commit_hash
|
||||
.as_ref()
|
||||
.and_then(|h| h.get(..7))
|
||||
.unwrap_or("unknown");
|
||||
let mut combined_output = format!("Committed: {}", short_hash);
|
||||
let mut warning = None;
|
||||
if sync {
|
||||
let mut push_status = PushStatus::NotRequested;
|
||||
if req.sync {
|
||||
match git_cli(git_root, &["pull", "--rebase"]).await {
|
||||
Ok(pull_out) => {
|
||||
combined_output.push_str("\n--- Pull ---\n");
|
||||
combined_output.push_str(&pull_out);
|
||||
commit_hash = git_cli(git_root, &["rev-parse", "HEAD"]).await.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
warning = Some(format!("Couldn't pull the latest changes. {}", e));
|
||||
push_status = PushStatus::Skipped;
|
||||
}
|
||||
}
|
||||
}
|
||||
if warning.is_none() && (push || sync) {
|
||||
match git_cli(git_root, &["push", "-u", "origin", "HEAD"]).await {
|
||||
Ok(push_out) => {
|
||||
if warning.is_none() && (req.push || req.sync) {
|
||||
let (status, push_out) = push_classified(git_root).await?;
|
||||
push_status = status;
|
||||
match status {
|
||||
PushStatus::Ok => {
|
||||
combined_output.push_str("\n--- Push ---\n");
|
||||
combined_output.push_str(&push_out);
|
||||
}
|
||||
Err(e) => {
|
||||
warning = Some(format!("Couldn't push your changes. {}", e));
|
||||
_ => {
|
||||
warning = Some(format!("Couldn't push your changes. {}", push_out));
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::debug!(amend, push, sync, elapsed = ?start.elapsed(), "git.commit");
|
||||
tracing::debug!(
|
||||
amend = req.amend,
|
||||
push = req.push,
|
||||
sync = req.sync,
|
||||
stage_all = req.stage_all,
|
||||
clean,
|
||||
push_status = ?push_status,
|
||||
elapsed = ?start.elapsed(),
|
||||
"git.commit"
|
||||
);
|
||||
Ok(CommitResult {
|
||||
data: CommitData {
|
||||
commit_hash,
|
||||
commit_hash: commit_hash.clone(),
|
||||
output: Some(combined_output),
|
||||
},
|
||||
warning,
|
||||
outcome: Some(CommitOutcome {
|
||||
sha: commit_hash,
|
||||
clean,
|
||||
pushed: push_status == PushStatus::Ok,
|
||||
push: push_status,
|
||||
}),
|
||||
})
|
||||
}
|
||||
/// Merge the base branch into the current branch (`workspace.git_sync_base`).
|
||||
/// Merge, never rebase: conv-branch history must not be rewritten. On
|
||||
/// conflicts the merge is left in progress for resolution; `abort` rolls it
|
||||
/// back.
|
||||
pub async fn sync_base(
|
||||
git_root: &Path,
|
||||
base_ref: Option<&str>,
|
||||
abort: bool,
|
||||
expected_branch: Option<&str>,
|
||||
) -> Result<GitSyncBaseResult> {
|
||||
async fn merge_in_progress(git_root: &Path) -> Result<bool> {
|
||||
Ok(
|
||||
git_cli_raw(git_root, &["rev-parse", "-q", "--verify", "MERGE_HEAD"])
|
||||
.await?
|
||||
.0,
|
||||
)
|
||||
}
|
||||
if abort {
|
||||
let (_ok, out) = git_cli_raw(git_root, &["merge", "--abort"]).await?;
|
||||
anyhow::ensure!(
|
||||
!merge_in_progress(git_root).await?,
|
||||
"merge --abort failed: {out}"
|
||||
);
|
||||
return Ok(GitSyncBaseResult {
|
||||
outcome: GitSyncBaseOutcome::Aborted,
|
||||
});
|
||||
}
|
||||
ensure_on_branch(git_root, expected_branch).await?;
|
||||
anyhow::ensure!(
|
||||
!merge_in_progress(git_root).await?,
|
||||
"a merge is already in progress; resolve it or call again with abort"
|
||||
);
|
||||
let dirty = git_cli(git_root, &["status", "--porcelain"]).await?;
|
||||
anyhow::ensure!(
|
||||
dirty.is_empty(),
|
||||
"working tree is not clean; commit or discard changes before syncing the base"
|
||||
);
|
||||
let base = base_ref.unwrap_or("HEAD");
|
||||
let (fetched, fetch_out) = git_cli_raw(git_root, &["fetch", "origin", base]).await?;
|
||||
anyhow::ensure!(fetched, "fetch of base ref '{base}' failed: {fetch_out}");
|
||||
if git_cli_raw(
|
||||
git_root,
|
||||
&["merge-base", "--is-ancestor", "FETCH_HEAD", "HEAD"],
|
||||
)
|
||||
.await?
|
||||
.0
|
||||
{
|
||||
return Ok(GitSyncBaseResult {
|
||||
outcome: GitSyncBaseOutcome::UpToDate,
|
||||
});
|
||||
}
|
||||
let (merged, merge_out) = git_cli_raw(git_root, &["merge", "--no-edit", "FETCH_HEAD"]).await?;
|
||||
if merged {
|
||||
let sha = git_cli(git_root, &["rev-parse", "HEAD"]).await?;
|
||||
return Ok(GitSyncBaseResult {
|
||||
outcome: GitSyncBaseOutcome::Merged { sha },
|
||||
});
|
||||
}
|
||||
if merge_in_progress(git_root).await? {
|
||||
let files = git_cli(git_root, &["diff", "--name-only", "--diff-filter=U"])
|
||||
.await?
|
||||
.lines()
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
return Ok(GitSyncBaseResult {
|
||||
outcome: GitSyncBaseOutcome::Conflicts { files },
|
||||
});
|
||||
}
|
||||
anyhow::bail!("merge of base ref '{base}' failed: {merge_out}")
|
||||
}
|
||||
pub async fn stage_content(git_root: &Path, path: &str, content: &str) -> Result<()> {
|
||||
let git_root = git_root.to_path_buf();
|
||||
let path = path.to_string();
|
||||
|
|
@ -4207,4 +4430,417 @@ mod restore_code_tests {
|
|||
"no stash entry should remain after the pop, got: {stash_list:?}"
|
||||
);
|
||||
}
|
||||
fn skip_without_git_cli() -> bool {
|
||||
std::env::var("BAZEL_TEST").is_ok()
|
||||
}
|
||||
/// `origin.git` (bare, HEAD → main) plus a work clone on `conv/t` forked
|
||||
/// from a pushed `main`. Returns (tempdir, work path).
|
||||
async fn conv_repo_with_origin() -> (tempfile::TempDir, PathBuf) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bare = tmp.path().join("origin.git");
|
||||
let work = tmp.path().join("work");
|
||||
std::fs::create_dir_all(&bare).unwrap();
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
git_cli(&bare, &["init", "--bare"]).await.unwrap();
|
||||
git_cli(&bare, &["symbolic-ref", "HEAD", "refs/heads/main"])
|
||||
.await
|
||||
.unwrap();
|
||||
git_cli(&work, &["init", "-b", "main"]).await.unwrap();
|
||||
configure_test_identity(&work).await;
|
||||
std::fs::write(work.join("README.md"), "base\n").unwrap();
|
||||
git_cli(&work, &["add", "-A"]).await.unwrap();
|
||||
git_cli(&work, &["commit", "-m", "init"]).await.unwrap();
|
||||
git_cli(&work, &["remote", "add", "origin", bare.to_str().unwrap()])
|
||||
.await
|
||||
.unwrap();
|
||||
git_cli(&work, &["push", "-u", "origin", "main"])
|
||||
.await
|
||||
.unwrap();
|
||||
git_cli(&work, &["checkout", "-b", "conv/t"]).await.unwrap();
|
||||
(tmp, work)
|
||||
}
|
||||
async fn configure_test_identity(repo: &Path) {
|
||||
git_cli(repo, &["config", "user.name", "test"])
|
||||
.await
|
||||
.unwrap();
|
||||
git_cli(repo, &["config", "user.email", "test@test.com"])
|
||||
.await
|
||||
.unwrap();
|
||||
git_cli(repo, &["config", "commit.gpgsign", "false"])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
fn conv_commit_req(push: bool) -> GitCommitReq {
|
||||
GitCommitReq {
|
||||
message: "conv commit".to_owned(),
|
||||
push,
|
||||
stage_all: true,
|
||||
seed_default_excludes: true,
|
||||
expected_branch: Some("conv/t".to_owned()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
#[cfg_attr(
|
||||
not(unix),
|
||||
ignore = "test invokes git CLI which is not always available"
|
||||
)]
|
||||
async fn conv_commit_seeds_excludes_and_pushes() {
|
||||
if skip_without_git_cli() {
|
||||
return;
|
||||
}
|
||||
let (_tmp, work) = conv_repo_with_origin().await;
|
||||
std::fs::create_dir_all(work.join("node_modules")).unwrap();
|
||||
std::fs::write(work.join("node_modules/dep.js"), "x").unwrap();
|
||||
std::fs::write(work.join(".env"), "SECRET=1").unwrap();
|
||||
std::fs::write(work.join("src.txt"), "real").unwrap();
|
||||
let res = commit(&work, &conv_commit_req(true)).await.unwrap();
|
||||
let outcome = res.outcome.expect("git backend returns an outcome");
|
||||
assert!(!outcome.clean);
|
||||
assert!(outcome.pushed);
|
||||
assert_eq!(outcome.push, PushStatus::Ok);
|
||||
let sha = outcome.sha.expect("commit produced a HEAD");
|
||||
let tree = git_cli(&work, &["ls-tree", "-r", "--name-only", "HEAD"])
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(tree.contains("src.txt"));
|
||||
assert!(
|
||||
!tree.contains(".env"),
|
||||
"seeded excludes must hide .env: {tree}"
|
||||
);
|
||||
assert!(!tree.contains("node_modules"), "{tree}");
|
||||
let remote_sha = git_cli(&work, &["rev-parse", "origin/conv/t"])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(remote_sha, sha);
|
||||
let res2 = commit(&work, &conv_commit_req(true)).await.unwrap();
|
||||
let outcome2 = res2.outcome.unwrap();
|
||||
assert!(outcome2.clean);
|
||||
assert_eq!(outcome2.sha.as_deref(), Some(sha.as_str()));
|
||||
let exclude = git_cli(&work, &["rev-parse", "--git-path", "info/exclude"])
|
||||
.await
|
||||
.unwrap();
|
||||
let content = std::fs::read_to_string(work.join(exclude)).unwrap();
|
||||
assert_eq!(content.matches(DEFAULT_EXCLUDES_MARKER).count(), 1);
|
||||
}
|
||||
#[tokio::test]
|
||||
#[cfg_attr(
|
||||
not(unix),
|
||||
ignore = "test invokes git CLI which is not always available"
|
||||
)]
|
||||
async fn conv_commit_legacy_clean_tree_still_errors_without_stage_all() {
|
||||
if skip_without_git_cli() {
|
||||
return;
|
||||
}
|
||||
let (_tmp, work) = conv_repo_with_origin().await;
|
||||
let req = GitCommitReq {
|
||||
message: "nothing staged".to_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
commit(&work, &req).await.is_err(),
|
||||
"legacy contract: committing with nothing staged errors"
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
#[cfg_attr(
|
||||
not(unix),
|
||||
ignore = "test invokes git CLI which is not always available"
|
||||
)]
|
||||
async fn conv_commit_refuses_wrong_branch() {
|
||||
if skip_without_git_cli() {
|
||||
return;
|
||||
}
|
||||
let (_tmp, work) = conv_repo_with_origin().await;
|
||||
let mut req = conv_commit_req(false);
|
||||
req.expected_branch = Some("conv/other".to_owned());
|
||||
let err = commit(&work, &req).await.expect_err("wrong branch refused");
|
||||
assert!(err.to_string().contains("expected 'conv/other'"), "{err}");
|
||||
}
|
||||
#[tokio::test]
|
||||
#[cfg_attr(
|
||||
not(unix),
|
||||
ignore = "test invokes git CLI which is not always available"
|
||||
)]
|
||||
async fn conv_commit_classifies_non_fast_forward_push_and_never_forces() {
|
||||
if skip_without_git_cli() {
|
||||
return;
|
||||
}
|
||||
let (tmp, work) = conv_repo_with_origin().await;
|
||||
std::fs::write(work.join("a.txt"), "a").unwrap();
|
||||
commit(&work, &conv_commit_req(true)).await.unwrap();
|
||||
let work2 = tmp.path().join("work2");
|
||||
git_cli(
|
||||
tmp.path(),
|
||||
&[
|
||||
"clone",
|
||||
"--branch",
|
||||
"conv/t",
|
||||
tmp.path().join("origin.git").to_str().unwrap(),
|
||||
work2.to_str().unwrap(),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
configure_test_identity(&work2).await;
|
||||
std::fs::write(work2.join("b.txt"), "b").unwrap();
|
||||
git_cli(&work2, &["add", "-A"]).await.unwrap();
|
||||
git_cli(&work2, &["commit", "-m", "out of band"])
|
||||
.await
|
||||
.unwrap();
|
||||
git_cli(&work2, &["push", "origin", "conv/t"])
|
||||
.await
|
||||
.unwrap();
|
||||
let diverged_sha = git_cli(&work2, &["rev-parse", "HEAD"]).await.unwrap();
|
||||
std::fs::write(work.join("c.txt"), "c").unwrap();
|
||||
let res = commit(&work, &conv_commit_req(true)).await.unwrap();
|
||||
let outcome = res.outcome.unwrap();
|
||||
assert!(!outcome.clean);
|
||||
assert!(!outcome.pushed);
|
||||
assert_eq!(outcome.push, PushStatus::Conflict);
|
||||
assert!(
|
||||
res.warning.is_some(),
|
||||
"push failure still surfaces a warning"
|
||||
);
|
||||
git_cli(&work, &["fetch", "origin", "conv/t"])
|
||||
.await
|
||||
.unwrap();
|
||||
let remote_sha = git_cli(&work, &["rev-parse", "FETCH_HEAD"]).await.unwrap();
|
||||
assert_eq!(remote_sha, diverged_sha);
|
||||
}
|
||||
#[tokio::test]
|
||||
#[cfg_attr(
|
||||
not(unix),
|
||||
ignore = "test invokes git CLI which is not always available"
|
||||
)]
|
||||
async fn sync_rebase_reports_the_rewritten_head() {
|
||||
if skip_without_git_cli() {
|
||||
return;
|
||||
}
|
||||
let (tmp, work) = conv_repo_with_origin().await;
|
||||
git_cli(&work, &["checkout", "main"]).await.unwrap();
|
||||
let work2 = tmp.path().join("work2");
|
||||
git_cli(
|
||||
tmp.path(),
|
||||
&[
|
||||
"clone",
|
||||
"--branch",
|
||||
"main",
|
||||
tmp.path().join("origin.git").to_str().unwrap(),
|
||||
work2.to_str().unwrap(),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
configure_test_identity(&work2).await;
|
||||
std::fs::write(work2.join("base.txt"), "base").unwrap();
|
||||
git_cli(&work2, &["add", "-A"]).await.unwrap();
|
||||
git_cli(&work2, &["commit", "-m", "advance main"])
|
||||
.await
|
||||
.unwrap();
|
||||
git_cli(&work2, &["push", "origin", "main"]).await.unwrap();
|
||||
std::fs::write(work.join("local.txt"), "local").unwrap();
|
||||
let req = GitCommitReq {
|
||||
message: "local commit".to_owned(),
|
||||
sync: true,
|
||||
stage_all: true,
|
||||
expected_branch: Some("main".to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
let res = commit(&work, &req).await.unwrap();
|
||||
let outcome = res.outcome.unwrap();
|
||||
assert!(outcome.pushed);
|
||||
let head = git_cli(&work, &["rev-parse", "HEAD"]).await.unwrap();
|
||||
assert_eq!(
|
||||
outcome.sha.as_deref(),
|
||||
Some(head.as_str()),
|
||||
"sha must be the post-rebase HEAD"
|
||||
);
|
||||
assert_eq!(res.data.commit_hash.as_deref(), Some(head.as_str()));
|
||||
let remote = git_cli(&work, &["rev-parse", "origin/main"]).await.unwrap();
|
||||
assert_eq!(remote, head);
|
||||
}
|
||||
#[tokio::test]
|
||||
#[cfg_attr(
|
||||
not(unix),
|
||||
ignore = "test invokes git CLI which is not always available"
|
||||
)]
|
||||
async fn sync_base_up_to_date_and_clean_merge() {
|
||||
if skip_without_git_cli() {
|
||||
return;
|
||||
}
|
||||
let (tmp, work) = conv_repo_with_origin().await;
|
||||
std::fs::write(work.join("conv.txt"), "conv").unwrap();
|
||||
commit(&work, &conv_commit_req(true)).await.unwrap();
|
||||
for base in [Some("main"), None] {
|
||||
let res = sync_base(&work, base, false, Some("conv/t")).await.unwrap();
|
||||
assert_eq!(res.outcome, GitSyncBaseOutcome::UpToDate, "base={base:?}");
|
||||
}
|
||||
let work2 = tmp.path().join("work2");
|
||||
git_cli(
|
||||
tmp.path(),
|
||||
&[
|
||||
"clone",
|
||||
"--branch",
|
||||
"main",
|
||||
tmp.path().join("origin.git").to_str().unwrap(),
|
||||
work2.to_str().unwrap(),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
configure_test_identity(&work2).await;
|
||||
std::fs::write(work2.join("base.txt"), "base change").unwrap();
|
||||
git_cli(&work2, &["add", "-A"]).await.unwrap();
|
||||
git_cli(&work2, &["commit", "-m", "advance main"])
|
||||
.await
|
||||
.unwrap();
|
||||
git_cli(&work2, &["push", "origin", "main"]).await.unwrap();
|
||||
let res = sync_base(&work, Some("main"), false, Some("conv/t"))
|
||||
.await
|
||||
.unwrap();
|
||||
match res.outcome {
|
||||
GitSyncBaseOutcome::Merged { sha } => {
|
||||
assert_eq!(sha, git_cli(&work, &["rev-parse", "HEAD"]).await.unwrap());
|
||||
assert!(
|
||||
work.join("base.txt").exists(),
|
||||
"merge brought the base file in"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Merged, got {other:?}"),
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
#[cfg_attr(
|
||||
not(unix),
|
||||
ignore = "test invokes git CLI which is not always available"
|
||||
)]
|
||||
async fn sync_base_conflicts_left_in_progress_and_abort_rolls_back() {
|
||||
if skip_without_git_cli() {
|
||||
return;
|
||||
}
|
||||
let (tmp, work) = conv_repo_with_origin().await;
|
||||
std::fs::write(work.join("README.md"), "conv version\n").unwrap();
|
||||
commit(&work, &conv_commit_req(true)).await.unwrap();
|
||||
let pre_merge_sha = git_cli(&work, &["rev-parse", "HEAD"]).await.unwrap();
|
||||
let work2 = tmp.path().join("work2");
|
||||
git_cli(
|
||||
tmp.path(),
|
||||
&[
|
||||
"clone",
|
||||
"--branch",
|
||||
"main",
|
||||
tmp.path().join("origin.git").to_str().unwrap(),
|
||||
work2.to_str().unwrap(),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
configure_test_identity(&work2).await;
|
||||
std::fs::write(work2.join("README.md"), "main version\n").unwrap();
|
||||
git_cli(&work2, &["add", "-A"]).await.unwrap();
|
||||
git_cli(&work2, &["commit", "-m", "conflicting base change"])
|
||||
.await
|
||||
.unwrap();
|
||||
git_cli(&work2, &["push", "origin", "main"]).await.unwrap();
|
||||
let res = sync_base(&work, Some("main"), false, Some("conv/t"))
|
||||
.await
|
||||
.unwrap();
|
||||
match &res.outcome {
|
||||
GitSyncBaseOutcome::Conflicts { files } => {
|
||||
assert_eq!(files, &vec!["README.md".to_owned()]);
|
||||
}
|
||||
other => panic!("expected Conflicts, got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
git_cli_raw(&work, &["rev-parse", "-q", "--verify", "MERGE_HEAD"])
|
||||
.await
|
||||
.unwrap()
|
||||
.0,
|
||||
"conflicted merge must stay in progress"
|
||||
);
|
||||
assert!(
|
||||
sync_base(&work, Some("main"), false, Some("conv/t"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let res = sync_base(&work, None, true, Some("not-the-branch"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.outcome, GitSyncBaseOutcome::Aborted);
|
||||
assert!(
|
||||
!git_cli_raw(&work, &["rev-parse", "-q", "--verify", "MERGE_HEAD"])
|
||||
.await
|
||||
.unwrap()
|
||||
.0
|
||||
);
|
||||
assert_eq!(
|
||||
git_cli(&work, &["rev-parse", "HEAD"]).await.unwrap(),
|
||||
pre_merge_sha
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(work.join("README.md")).unwrap(),
|
||||
"conv version\n"
|
||||
);
|
||||
let res = sync_base(&work, None, true, None).await.unwrap();
|
||||
assert_eq!(res.outcome, GitSyncBaseOutcome::Aborted);
|
||||
}
|
||||
#[tokio::test]
|
||||
#[cfg_attr(
|
||||
not(unix),
|
||||
ignore = "test invokes git CLI which is not always available"
|
||||
)]
|
||||
async fn sync_base_refuses_wrong_branch() {
|
||||
if skip_without_git_cli() {
|
||||
return;
|
||||
}
|
||||
let (_tmp, work) = conv_repo_with_origin().await;
|
||||
let err = sync_base(&work, Some("main"), false, Some("conv/other"))
|
||||
.await
|
||||
.expect_err("wrong branch refused");
|
||||
assert!(err.to_string().contains("expected 'conv/other'"), "{err}");
|
||||
}
|
||||
#[tokio::test]
|
||||
#[cfg_attr(
|
||||
not(unix),
|
||||
ignore = "test invokes git CLI which is not always available"
|
||||
)]
|
||||
async fn sync_pull_failure_reports_push_skipped() {
|
||||
if skip_without_git_cli() {
|
||||
return;
|
||||
}
|
||||
let (_tmp, work) = conv_repo_with_origin().await;
|
||||
std::fs::write(work.join("local.txt"), "local").unwrap();
|
||||
let req = GitCommitReq {
|
||||
message: "local".to_owned(),
|
||||
sync: true,
|
||||
stage_all: true,
|
||||
..Default::default()
|
||||
};
|
||||
let res = commit(&work, &req).await.unwrap();
|
||||
assert!(res.warning.is_some(), "pull failure surfaces a warning");
|
||||
let outcome = res.outcome.unwrap();
|
||||
assert!(!outcome.pushed);
|
||||
assert_eq!(
|
||||
outcome.push,
|
||||
PushStatus::Skipped,
|
||||
"an implied push skipped by a pull failure must not read as never-requested"
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
#[cfg_attr(
|
||||
not(unix),
|
||||
ignore = "test invokes git CLI which is not always available"
|
||||
)]
|
||||
async fn sync_base_refuses_dirty_tree() {
|
||||
if skip_without_git_cli() {
|
||||
return;
|
||||
}
|
||||
let (_tmp, work) = conv_repo_with_origin().await;
|
||||
std::fs::write(work.join("dirty.txt"), "uncommitted").unwrap();
|
||||
let err = sync_base(&work, Some("main"), false, Some("conv/t"))
|
||||
.await
|
||||
.expect_err("dirty tree refused");
|
||||
assert!(err.to_string().contains("not clean"), "{err}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,6 +166,8 @@ pub async fn commit(cwd: &Path, message: &str) -> Result<CommitResult> {
|
|||
output: Some("Commit described and new change started".to_string()),
|
||||
},
|
||||
warning: None,
|
||||
// The structured outcome is a git-backend concept.
|
||||
outcome: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,9 +47,9 @@ pub use xai_grok_workspace_types::rpc::git::{
|
|||
DiffStatsSummary, GitBranchesReq, GitCheckoutCommitReq, GitCheckoutReq, GitCollectChangesReq,
|
||||
GitCollectChangesResponse, GitCommitReq, GitCurrentCommitReq, GitDiffReq, GitDiscardReq,
|
||||
GitFilesReq, GitInfoReq, GitResolveRootReq, GitStageContentReq, GitStageReq, GitStashReq,
|
||||
GitStatusExtReq, GitStatusExtResponse, GitStatusFormat, GitStatusReq, GitUnstageReq,
|
||||
IdentityData, PublicBaseData, RepoInfo, UNTRACKED_CONTENT_THRESHOLD, UncommittedChangesData,
|
||||
UntrackedFileData,
|
||||
GitStatusExtReq, GitStatusExtResponse, GitStatusFormat, GitStatusReq, GitSyncBaseReq,
|
||||
GitUnstageReq, IdentityData, PublicBaseData, RepoInfo, UNTRACKED_CONTENT_THRESHOLD,
|
||||
UncommittedChangesData, UntrackedFileData,
|
||||
};
|
||||
pub use xai_grok_workspace_types::rpc::hooks::{
|
||||
HookEventNameWire, HookRegistryReq, HookRegistryWire, HookSpecWire,
|
||||
|
|
@ -389,13 +389,24 @@ impl WorkspaceOp for GitCommitReq {
|
|||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||
crate::session::git::commit(
|
||||
crate::session::git::commit(&cwd, self)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WorkspaceOp for GitSyncBaseReq {
|
||||
async fn execute(
|
||||
&self,
|
||||
ws: &WorkspaceHandle,
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||
crate::session::git::sync_base(
|
||||
&cwd,
|
||||
&self.message,
|
||||
self.amend,
|
||||
self.signoff,
|
||||
self.push,
|
||||
self.sync,
|
||||
self.base_ref.as_deref(),
|
||||
self.abort,
|
||||
self.expected_branch.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
|
|
|
|||
Loading…
Reference in a new issue