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:
grokkybara[bot] 2026-07-28 22:50:19 +00:00
commit 5da6962e4a
192 changed files with 10337 additions and 3421 deletions

View file

@ -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."

View file

@ -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}"
);
}
}

View file

@ -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

View file

@ -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,

View file

@ -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))

View file

@ -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}");
}
}

View file

@ -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,
})
}

View file

@ -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()))