M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,416 @@
//! SQLite-backed metadata database for tracking worktrees.
//!
//! Gated behind the `metadata` cargo feature. When disabled, all DB operations
//! compile away to no-ops.
mod queries;
mod schema;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use kigi_sqlite_journal::JournalMode;
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WorktreeKind {
Session,
Ab,
Pool,
Fork,
Manual,
Subagent,
}
impl WorktreeKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Session => "session",
Self::Ab => "ab",
Self::Pool => "pool",
Self::Fork => "fork",
Self::Manual => "manual",
Self::Subagent => "subagent",
}
}
pub fn from_str_lossy(s: &str) -> Self {
match s {
"session" => Self::Session,
"ab" => Self::Ab,
"pool" => Self::Pool,
"fork" => Self::Fork,
"manual" => Self::Manual,
"subagent" => Self::Subagent,
_ => Self::Manual,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WorktreeStatus {
Alive,
Dead,
}
impl WorktreeStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Alive => "alive",
Self::Dead => "dead",
}
}
pub fn from_str_lossy(s: &str) -> Self {
match s {
"alive" => Self::Alive,
"dead" => Self::Dead,
_ => Self::Dead,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct WorktreeRecord {
pub id: String,
pub path: PathBuf,
pub source_repo: PathBuf,
pub repo_name: String,
pub kind: WorktreeKind,
pub creation_mode: String,
pub git_ref: Option<String>,
pub head_commit: Option<String>,
pub session_id: Option<String>,
pub creator_pid: Option<u32>,
pub created_at: i64,
pub last_accessed_at: Option<i64>,
pub status: WorktreeStatus,
pub metadata: Option<serde_json::Value>,
}
#[derive(Default)]
pub struct ListFilter {
pub repo_name: Option<String>,
pub source_repo: Option<PathBuf>,
pub kind: Option<WorktreeKind>,
pub status: Option<WorktreeStatus>,
pub include_dead: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct DbStats {
pub total_records: u64,
pub alive_count: u64,
pub dead_count: u64,
pub db_file_bytes: u64,
}
pub struct WorktreeDb {
conn: Connection,
}
impl WorktreeDb {
/// Open (or create) the DB at `kigi_home/worktrees.db`.
pub fn open(kigi_home: &Path) -> Result<Self> {
Self::open_at(&kigi_home.join("worktrees.db"))
}
/// Open with an explicit path.
pub fn open_at(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create dir for DB: {}", parent.display()))?;
}
// The mode decision statfs's the parent dir created above.
Self::open_at_with_journal_mode(path, JournalMode::for_db_path(path))
}
/// Open with an explicit journal mode — the seam tests use to exercise
/// the network-filesystem decision on a local disk.
fn open_at_with_journal_mode(path: &Path, journal_mode: JournalMode) -> Result<Self> {
// Per-host sibling on network mounts (see JournalMode::effective_db_path).
let path = journal_mode.effective_db_path(path);
let conn = Connection::open(&path)
.with_context(|| format!("failed to open worktree DB: {}", path.display()))?;
let db = Self { conn };
db.set_journal_mode(journal_mode)?;
// Normal statement timeout, now that the conversion budget is done.
db.conn
.busy_timeout(std::time::Duration::from_millis(5000))?;
db.init_schema()?;
Ok(db)
}
/// Put the database in `mode`'s journal mode, retrying on `SQLITE_BUSY`
/// under one absolute deadline (~10s total).
///
/// Conversion-lock acquisition only partially honors `busy_timeout` (see
/// `JournalMode::apply`, the single source of truth): a second process
/// opening the same file at the same instant can still get `SQLITE_BUSY`
/// immediately. Without a retry that opener's `open_at` fails, and callers
/// like `register_worktree`/`unregister_worktree` swallow the error
/// (best-effort) — silently dropping worktree tracking, exactly what this DB
/// exists to prevent. A bounded retry rides out the concurrent converter
/// (which finishes in microseconds), while the deadline plus a per-attempt
/// `busy_timeout` cap keeps a held legacy lock from stalling startup by
/// `attempts x busy_timeout`. Once converted the setting persists (WAL) or
/// re-applies as a no-op (TRUNCATE), so later opens are cheap.
fn set_journal_mode(&self, mode: JournalMode) -> Result<()> {
use rusqlite::ErrorCode;
use std::time::{Duration, Instant};
// Total conversion budget; each attempt waits at most 1s for locks.
const DEADLINE: Duration = Duration::from_secs(10);
let start = Instant::now();
let mut last_err = None;
loop {
let remaining = DEADLINE.saturating_sub(start.elapsed());
if remaining.is_zero() {
break;
}
self.conn
.busy_timeout(remaining.min(Duration::from_millis(1000)))?;
match mode.apply(&self.conn) {
Ok(()) => return Ok(()),
Err(e) => {
let busy = matches!(
&e,
rusqlite::Error::SqliteFailure(f, _)
if matches!(f.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
);
if !busy {
return Err(e).with_context(|| {
format!("failed to set journal mode {}", mode.as_str())
});
}
last_err = Some(e);
// Brief pause so fail-fast busy errors don't spin hot.
std::thread::sleep(Duration::from_millis(20));
}
}
}
Err(last_err.expect("deadline allows at least one attempt")).with_context(|| {
format!(
"failed to set journal mode {} (database busy after {:?})",
mode.as_str(),
start.elapsed()
)
})
}
/// Open the default DB at `~/.kigi/worktrees.db`.
///
/// Discovers grok home via `$KIGI_SHARE_DIR`, falling back to the canonicalized
/// `$HOME/.kigi` (matching `kigi_config::kigi_home`).
/// Path is resolved fresh each call (~1µs env var read) to support
/// test overrides. Each call opens its own connection — callers in hot
/// paths should cache the `WorktreeDb` instance.
pub fn open_default() -> Result<Self> {
Self::open(&resolve_kigi_home()?)
}
/// Open an in-memory DB (for tests).
pub fn open_in_memory() -> Result<Self> {
let conn = Connection::open_in_memory().context("failed to open in-memory DB")?;
let db = Self { conn };
db.init_schema()?;
Ok(db)
}
fn init_schema(&self) -> Result<()> {
self.conn
.execute_batch(schema::INIT_SQL)
.context("failed to init worktree DB schema")?;
let stored: Option<String> = self
.conn
.query_row(schema::GET_META, ["schema_version"], |row| row.get(0))
.ok();
let needs_update = match stored {
None => true,
Some(v) => v.parse::<u32>().unwrap_or(0) < schema::SCHEMA_VERSION,
};
if needs_update {
self.conn.execute(
schema::UPSERT_META,
rusqlite::params!["schema_version", schema::SCHEMA_VERSION.to_string()],
)?;
}
Ok(())
}
pub fn register(&self, record: &WorktreeRecord) -> Result<()> {
queries::register(&self.conn, record)
}
pub fn unregister(&self, id: &str) -> Result<bool> {
queries::unregister(&self.conn, id)
}
pub fn unregister_by_path(&self, path: &Path) -> Result<bool> {
queries::unregister_by_path(&self.conn, path)
}
pub fn mark_dead(&self, id: &str) -> Result<bool> {
queries::mark_dead(&self.conn, id)
}
pub fn touch(&self, id: &str) -> Result<bool> {
queries::touch(&self.conn, id)
}
/// Look up a worktree by its DB ID only (no label or path fallback).
pub fn get_by_id(&self, id: &str) -> Result<Option<WorktreeRecord>> {
queries::get_by_id(&self.conn, id)
}
/// Look up by ID, label, or path.
///
/// If `id_or_path` contains `/`, it's treated as a path (canonicalized
/// before lookup). Otherwise it's looked up first as a DB ID, then as a
/// worktree label (stored in `metadata.label`).
pub fn get(&self, id_or_path: &str) -> Result<Option<WorktreeRecord>> {
if id_or_path.contains('/') {
let canon = PathBuf::from(id_or_path);
let canon = dunce::canonicalize(&canon).unwrap_or(canon);
queries::get_by_path(&self.conn, &canon)
} else {
let by_id = queries::get_by_id(&self.conn, id_or_path)?;
if by_id.is_some() {
return Ok(by_id);
}
queries::get_by_label(&self.conn, id_or_path)
}
}
/// Look up a worktree by its label (stored in metadata JSON).
pub fn get_by_label(&self, label: &str) -> Result<Option<WorktreeRecord>> {
queries::get_by_label(&self.conn, label)
}
pub fn list(&self, filter: &ListFilter) -> Result<Vec<WorktreeRecord>> {
queries::list(&self.conn, filter)
}
pub fn stats(&self) -> Result<DbStats> {
queries::stats(&self.conn)
}
/// Mark all records whose paths no longer exist on disk as dead.
/// Returns the number of records marked.
pub fn sweep_dead(&self) -> Result<u64> {
queries::sweep_dead(&self.conn)
}
}
/// Derive a worktree ID from its destination path: `<basename>-<hash of full path>`
/// (the last component, minus any `worktree-` prefix, plus a full-path hash).
///
/// The basename alone collides across repos, and `INSERT OR REPLACE` would then evict
/// the other repo's record; hashing the full path keeps distinct worktrees distinct.
pub fn id_from_path(path: &Path) -> String {
let name = path
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default();
let base = name.strip_prefix("worktree-").unwrap_or(&name);
format!("{base}-{}", crate::copy::shard::short_path_hash(path))
}
/// Extract the repo name (last component) from a source repo path.
pub fn repo_name_from_path(source: &Path) -> String {
source
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "repo".to_string())
}
pub fn now_epoch_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
pub fn resolve_kigi_home() -> Result<PathBuf> {
if let Ok(v) = std::env::var("KIGI_SHARE_DIR") {
return Ok(PathBuf::from(v));
}
let home =
PathBuf::from(std::env::var("HOME").context("neither $KIGI_SHARE_DIR nor $HOME is set")?);
// Canonicalize the home dir so worktree paths share the same physical .kigi
// tree as trust/hooks even when it is symlinked. The dunce canonicalization
// must stay in sync with kigi_config::default_kigi_home();
// home resolution deliberately differs ($HOME here vs std::env::home_dir()).
Ok(dunce::canonicalize(&home).unwrap_or(home).join(".kigi"))
}
/// Serializes tests that mutate the process-global `KIGI_SHARE_DIR` env var so they
/// don't clobber each other under `cargo test`, where tests share one process
/// (nextest isolates per-process, but the suite must also pass under `cargo test`).
#[cfg(test)]
static KIGI_SHARE_DIR_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Test-only isolation for code that resolves the DB via `open_default()`.
///
/// Holds [`KIGI_SHARE_DIR_ENV_LOCK`] (serializing concurrent setters), points
/// `KIGI_SHARE_DIR` at a fresh private tmp dir, and restores the prior value on drop.
/// Use instead of hand-rolling the lock + restore guard + tmp dir per test.
///
/// `Drop` restores `KIGI_SHARE_DIR` before `_lock` releases, so the env is correct
/// before another waiting setter proceeds.
#[cfg(test)]
pub(crate) struct GrokHomeFixture {
_lock: std::sync::MutexGuard<'static, ()>,
prev: Option<std::ffi::OsString>,
/// The isolated grok home; pass to `WorktreeDb::open` to read the same DB
/// `open_default()` writes to.
pub home: PathBuf,
_tmp: tempfile::TempDir,
}
#[cfg(test)]
impl GrokHomeFixture {
pub(crate) fn new() -> Self {
let lock = KIGI_SHARE_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path().join("grok-home");
std::fs::create_dir_all(&home).unwrap();
// Warm up the DB (journal-mode conversion + schema) before exposing it
// via KIGI_SHARE_DIR, sparing the test hot loop set_journal_mode's retry
// sleeps. This open has exclusive access (nothing reaches the path
// until KIGI_SHARE_DIR points here); set_journal_mode's retry is the actual
// race fix.
let _ = WorktreeDb::open(&home);
let prev = std::env::var_os("KIGI_SHARE_DIR");
unsafe { std::env::set_var("KIGI_SHARE_DIR", &home) };
Self {
_lock: lock,
prev,
home,
_tmp: tmp,
}
}
}
#[cfg(test)]
impl Drop for GrokHomeFixture {
fn drop(&mut self) {
unsafe {
match self.prev.take() {
Some(p) => std::env::set_var("KIGI_SHARE_DIR", p),
None => std::env::remove_var("KIGI_SHARE_DIR"),
}
}
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,231 @@
use std::path::Path;
use anyhow::{Context, Result};
use rusqlite::{Connection, params};
use super::{DbStats, ListFilter, WorktreeKind, WorktreeRecord, WorktreeStatus, now_epoch_secs};
fn row_to_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorktreeRecord> {
let kind_str: String = row.get("kind")?;
let status_str: String = row.get("status")?;
let path_str: String = row.get("path")?;
let source_str: String = row.get("source_repo")?;
let metadata_str: Option<String> = row.get("metadata")?;
Ok(WorktreeRecord {
id: row.get("id")?,
path: path_str.into(),
source_repo: source_str.into(),
repo_name: row.get("repo_name")?,
kind: WorktreeKind::from_str_lossy(&kind_str),
creation_mode: row.get("creation_mode")?,
git_ref: row.get("git_ref")?,
head_commit: row.get("head_commit")?,
session_id: row.get("session_id")?,
creator_pid: row.get::<_, Option<i64>>("creator_pid")?.map(|v| v as u32),
created_at: row.get("created_at")?,
last_accessed_at: row.get("last_accessed_at")?,
status: WorktreeStatus::from_str_lossy(&status_str),
metadata: metadata_str.and_then(|s| serde_json::from_str(&s).ok()),
})
}
pub fn register(conn: &Connection, record: &WorktreeRecord) -> Result<()> {
let path_str = record.path.to_string_lossy();
let source_str = record.source_repo.to_string_lossy();
let metadata_str = record
.metadata
.as_ref()
.and_then(|v| serde_json::to_string(v).ok());
conn.execute(
"INSERT OR REPLACE INTO worktrees \
(id, path, source_repo, repo_name, kind, creation_mode, git_ref, \
head_commit, session_id, creator_pid, created_at, last_accessed_at, \
status, metadata) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![
record.id,
path_str.as_ref(),
source_str.as_ref(),
record.repo_name,
record.kind.as_str(),
record.creation_mode,
record.git_ref,
record.head_commit,
record.session_id,
record.creator_pid.map(|p| p as i64),
record.created_at,
record.last_accessed_at,
record.status.as_str(),
metadata_str,
],
)
.context("failed to register worktree")?;
Ok(())
}
pub fn unregister(conn: &Connection, id: &str) -> Result<bool> {
let affected = conn
.execute("DELETE FROM worktrees WHERE id = ?1", params![id])
.context("failed to unregister worktree")?;
Ok(affected > 0)
}
pub fn unregister_by_path(conn: &Connection, path: &Path) -> Result<bool> {
let path_str = path.to_string_lossy();
let affected = conn
.execute(
"DELETE FROM worktrees WHERE path = ?1",
params![path_str.as_ref()],
)
.context("failed to unregister worktree by path")?;
Ok(affected > 0)
}
pub fn mark_dead(conn: &Connection, id: &str) -> Result<bool> {
let affected = conn
.execute(
"UPDATE worktrees SET status = 'dead' WHERE id = ?1",
params![id],
)
.context("failed to mark worktree dead")?;
Ok(affected > 0)
}
pub fn touch(conn: &Connection, id: &str) -> Result<bool> {
let now = now_epoch_secs();
let affected = conn
.execute(
"UPDATE worktrees SET last_accessed_at = ?1 WHERE id = ?2",
params![now, id],
)
.context("failed to touch worktree")?;
Ok(affected > 0)
}
fn get_one(conn: &Connection, sql: &str, param: &str) -> Result<Option<WorktreeRecord>> {
let mut stmt = conn.prepare(sql)?;
let mut rows = stmt.query_map(params![param], row_to_record)?;
match rows.next() {
Some(Ok(record)) => Ok(Some(record)),
Some(Err(e)) => Err(e.into()),
None => Ok(None),
}
}
pub fn get_by_id(conn: &Connection, id: &str) -> Result<Option<WorktreeRecord>> {
get_one(conn, "SELECT * FROM worktrees WHERE id = ?1", id)
}
pub fn get_by_label(conn: &Connection, label: &str) -> Result<Option<WorktreeRecord>> {
get_one(
conn,
"SELECT * FROM worktrees WHERE json_valid(metadata) AND json_extract(metadata, '$.label') = ?1 ORDER BY created_at DESC",
label,
)
}
pub fn get_by_path(conn: &Connection, path: &Path) -> Result<Option<WorktreeRecord>> {
get_one(
conn,
"SELECT * FROM worktrees WHERE path = ?1",
&path.to_string_lossy(),
)
}
pub fn list(conn: &Connection, filter: &ListFilter) -> Result<Vec<WorktreeRecord>> {
let mut sql = String::from("SELECT * FROM worktrees WHERE 1=1");
let mut idx = 0usize;
let status_str = filter.status.map(|s| s.as_str());
let kind_str = filter.kind.map(|k| k.as_str());
let source_repo_str = filter
.source_repo
.as_ref()
.map(|p| p.to_string_lossy().into_owned());
if !filter.include_dead {
sql.push_str(" AND status = 'alive'");
}
if status_str.is_some() {
idx += 1;
sql.push_str(&format!(" AND status = ?{idx}"));
}
if kind_str.is_some() {
idx += 1;
sql.push_str(&format!(" AND kind = ?{idx}"));
}
if filter.repo_name.is_some() {
idx += 1;
sql.push_str(&format!(" AND repo_name = ?{idx}"));
}
if source_repo_str.is_some() {
idx += 1;
sql.push_str(&format!(" AND source_repo = ?{idx}"));
}
sql.push_str(" ORDER BY created_at DESC");
let mut params: Vec<&dyn rusqlite::types::ToSql> = Vec::with_capacity(idx);
if let Some(ref s) = status_str {
params.push(s);
}
if let Some(ref k) = kind_str {
params.push(k);
}
if let Some(ref r) = filter.repo_name {
params.push(r);
}
if let Some(ref s) = source_repo_str {
params.push(s);
}
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params.as_slice(), row_to_record)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
pub fn stats(conn: &Connection) -> Result<DbStats> {
let total: u64 = conn.query_row("SELECT COUNT(*) FROM worktrees", [], |row| row.get(0))?;
let alive: u64 = conn.query_row(
"SELECT COUNT(*) FROM worktrees WHERE status = 'alive'",
[],
|row| row.get(0),
)?;
let page_count: u64 = conn
.query_row("PRAGMA page_count", [], |row| row.get(0))
.unwrap_or(0);
let page_size: u64 = conn
.query_row("PRAGMA page_size", [], |row| row.get(0))
.unwrap_or(0);
Ok(DbStats {
total_records: total,
alive_count: alive,
dead_count: total.saturating_sub(alive),
db_file_bytes: page_count * page_size,
})
}
pub fn sweep_dead(conn: &Connection) -> Result<u64> {
let alive_paths: Vec<(String, String)> = {
let mut stmt = conn.prepare("SELECT id, path FROM worktrees WHERE status = 'alive'")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
rows.filter_map(|r| r.ok()).collect()
};
let mut marked = 0u64;
for (id, path_str) in alive_paths {
if !Path::new(&path_str).exists() {
conn.execute(
"UPDATE worktrees SET status = 'dead' WHERE id = ?1",
params![id],
)?;
marked += 1;
}
}
Ok(marked)
}
@@ -0,0 +1,35 @@
pub const SCHEMA_VERSION: u32 = 1;
pub const INIT_SQL: &str = r#"
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS worktrees (
id TEXT PRIMARY KEY,
path TEXT UNIQUE NOT NULL,
source_repo TEXT NOT NULL,
repo_name TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'session',
creation_mode TEXT NOT NULL DEFAULT 'linked',
git_ref TEXT,
head_commit TEXT,
session_id TEXT,
creator_pid INTEGER,
created_at INTEGER NOT NULL,
last_accessed_at INTEGER,
status TEXT NOT NULL DEFAULT 'alive',
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_worktrees_repo ON worktrees(repo_name);
CREATE INDEX IF NOT EXISTS idx_worktrees_status_kind ON worktrees(status, kind);
CREATE INDEX IF NOT EXISTS idx_worktrees_session ON worktrees(session_id);
CREATE INDEX IF NOT EXISTS idx_worktrees_created ON worktrees(created_at);
"#;
pub const UPSERT_META: &str = "INSERT OR REPLACE INTO meta(key, value) VALUES (?1, ?2)";
pub const GET_META: &str = "SELECT value FROM meta WHERE key = ?1";
@@ -0,0 +1,690 @@
use super::*;
fn make_record(id: &str, path: &str, kind: WorktreeKind) -> WorktreeRecord {
WorktreeRecord {
id: id.to_string(),
path: PathBuf::from(path),
source_repo: PathBuf::from("/src/repo"),
repo_name: "repo".to_string(),
kind,
creation_mode: "linked".to_string(),
git_ref: Some("main".to_string()),
head_commit: Some("abc123".to_string()),
session_id: Some(format!("sess-{id}")),
creator_pid: Some(12345),
created_at: 1000,
last_accessed_at: None,
status: WorktreeStatus::Alive,
metadata: None,
}
}
fn make_labeled_record(id: &str, path: &str, label: &str) -> WorktreeRecord {
let mut rec = make_record(id, path, WorktreeKind::Session);
rec.metadata = Some(serde_json::json!({"label": label, "user_provided": true}));
rec
}
#[test]
fn register_and_get_by_id() {
let db = WorktreeDb::open_in_memory().unwrap();
let rec = make_record("abc", "/tmp/wt-abc", WorktreeKind::Session);
db.register(&rec).unwrap();
let fetched = db.get("abc").unwrap().expect("should find by id");
assert_eq!(fetched.id, "abc");
assert_eq!(fetched.path, PathBuf::from("/tmp/wt-abc"));
assert_eq!(fetched.kind, WorktreeKind::Session);
assert_eq!(fetched.status, WorktreeStatus::Alive);
assert_eq!(fetched.creator_pid, Some(12345));
assert_eq!(fetched.git_ref.as_deref(), Some("main"));
assert_eq!(fetched.session_id.as_deref(), Some("sess-abc"));
}
#[test]
fn get_by_path() {
let db = WorktreeDb::open_in_memory().unwrap();
let rec = make_record("xyz", "/tmp/wt-xyz", WorktreeKind::Fork);
db.register(&rec).unwrap();
let fetched = db.get("/tmp/wt-xyz").unwrap().expect("should find by path");
assert_eq!(fetched.id, "xyz");
assert_eq!(fetched.kind, WorktreeKind::Fork);
}
#[test]
fn get_missing_returns_none() {
let db = WorktreeDb::open_in_memory().unwrap();
assert!(db.get("nonexistent").unwrap().is_none());
assert!(db.get("/no/such/path").unwrap().is_none());
}
#[test]
fn unregister_by_id() {
let db = WorktreeDb::open_in_memory().unwrap();
db.register(&make_record("a", "/tmp/a", WorktreeKind::Session))
.unwrap();
assert!(db.unregister("a").unwrap());
assert!(db.get("a").unwrap().is_none());
assert!(!db.unregister("a").unwrap()); // second call returns false
}
#[test]
fn unregister_by_path() {
let db = WorktreeDb::open_in_memory().unwrap();
db.register(&make_record("b", "/tmp/b", WorktreeKind::Pool))
.unwrap();
assert!(db.unregister_by_path(Path::new("/tmp/b")).unwrap());
assert!(db.get("b").unwrap().is_none());
}
#[test]
fn mark_dead_and_list_filter() {
let db = WorktreeDb::open_in_memory().unwrap();
db.register(&make_record("live", "/tmp/live", WorktreeKind::Session))
.unwrap();
db.register(&make_record("gone", "/tmp/gone", WorktreeKind::Session))
.unwrap();
db.mark_dead("gone").unwrap();
// Default filter excludes dead
let alive = db.list(&ListFilter::default()).unwrap();
assert_eq!(alive.len(), 1);
assert_eq!(alive[0].id, "live");
// include_dead shows both
let all = db
.list(&ListFilter {
include_dead: true,
..Default::default()
})
.unwrap();
assert_eq!(all.len(), 2);
let dead_rec = all.iter().find(|r| r.id == "gone").unwrap();
assert_eq!(dead_rec.status, WorktreeStatus::Dead);
}
#[test]
fn list_filter_by_kind() {
let db = WorktreeDb::open_in_memory().unwrap();
db.register(&make_record("s1", "/tmp/s1", WorktreeKind::Session))
.unwrap();
db.register(&make_record("p1", "/tmp/p1", WorktreeKind::Pool))
.unwrap();
db.register(&make_record("f1", "/tmp/f1", WorktreeKind::Fork))
.unwrap();
let sessions = db
.list(&ListFilter {
kind: Some(WorktreeKind::Session),
..Default::default()
})
.unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "s1");
let pools = db
.list(&ListFilter {
kind: Some(WorktreeKind::Pool),
..Default::default()
})
.unwrap();
assert_eq!(pools.len(), 1);
assert_eq!(pools[0].id, "p1");
}
#[test]
fn list_filter_by_repo() {
let db = WorktreeDb::open_in_memory().unwrap();
let mut r1 = make_record("a", "/tmp/a", WorktreeKind::Session);
r1.repo_name = "myrepo".to_string();
let mut r2 = make_record("b", "/tmp/b", WorktreeKind::Session);
r2.repo_name = "other".to_string();
db.register(&r1).unwrap();
db.register(&r2).unwrap();
let matched = db
.list(&ListFilter {
repo_name: Some("myrepo".to_string()),
..Default::default()
})
.unwrap();
assert_eq!(matched.len(), 1);
assert_eq!(matched[0].id, "a");
}
#[test]
fn touch_updates_last_accessed() {
let db = WorktreeDb::open_in_memory().unwrap();
db.register(&make_record("t", "/tmp/t", WorktreeKind::Session))
.unwrap();
let before = db.get("t").unwrap().unwrap();
assert!(before.last_accessed_at.is_none());
db.touch("t").unwrap();
let after = db.get("t").unwrap().unwrap();
assert!(after.last_accessed_at.is_some());
assert!(after.last_accessed_at.unwrap() > 0);
}
#[test]
fn stats_counts() {
let db = WorktreeDb::open_in_memory().unwrap();
db.register(&make_record("a", "/tmp/a", WorktreeKind::Session))
.unwrap();
db.register(&make_record("b", "/tmp/b", WorktreeKind::Pool))
.unwrap();
db.register(&make_record("c", "/tmp/c", WorktreeKind::Fork))
.unwrap();
db.mark_dead("c").unwrap();
let stats = db.stats().unwrap();
assert_eq!(stats.total_records, 3);
assert_eq!(stats.alive_count, 2);
assert_eq!(stats.dead_count, 1);
}
#[test]
fn sweep_dead_marks_missing_paths() {
let db = WorktreeDb::open_in_memory().unwrap();
let tmp = tempfile::TempDir::new().unwrap();
let existing = tmp.path().join("exists");
std::fs::create_dir(&existing).unwrap();
db.register(&make_record(
"exists",
&existing.to_string_lossy(),
WorktreeKind::Session,
))
.unwrap();
db.register(&make_record(
"gone",
"/nonexistent/path/xyz",
WorktreeKind::Session,
))
.unwrap();
let marked = db.sweep_dead().unwrap();
assert_eq!(marked, 1);
let gone_rec = db
.list(&ListFilter {
include_dead: true,
..Default::default()
})
.unwrap()
.into_iter()
.find(|r| r.id == "gone")
.unwrap();
assert_eq!(gone_rec.status, WorktreeStatus::Dead);
let exists_rec = db.get("exists").unwrap().unwrap();
assert_eq!(exists_rec.status, WorktreeStatus::Alive);
}
#[test]
fn register_upsert_overwrites() {
let db = WorktreeDb::open_in_memory().unwrap();
let mut rec = make_record("up", "/tmp/up", WorktreeKind::Session);
db.register(&rec).unwrap();
rec.head_commit = Some("new-sha".to_string());
rec.kind = WorktreeKind::Fork;
db.register(&rec).unwrap();
let fetched = db.get("up").unwrap().unwrap();
assert_eq!(fetched.head_commit.as_deref(), Some("new-sha"));
assert_eq!(fetched.kind, WorktreeKind::Fork);
}
#[test]
fn list_ordered_by_created_at_desc() {
let db = WorktreeDb::open_in_memory().unwrap();
let mut r1 = make_record("old", "/tmp/old", WorktreeKind::Session);
r1.created_at = 100;
let mut r2 = make_record("new", "/tmp/new", WorktreeKind::Session);
r2.created_at = 200;
let mut r3 = make_record("mid", "/tmp/mid", WorktreeKind::Session);
r3.created_at = 150;
db.register(&r1).unwrap();
db.register(&r2).unwrap();
db.register(&r3).unwrap();
let all = db.list(&ListFilter::default()).unwrap();
assert_eq!(all.len(), 3);
assert_eq!(all[0].id, "new");
assert_eq!(all[1].id, "mid");
assert_eq!(all[2].id, "old");
}
#[test]
fn metadata_json_roundtrip() {
let db = WorktreeDb::open_in_memory().unwrap();
let mut rec = make_record("meta", "/tmp/meta", WorktreeKind::Session);
rec.metadata = Some(serde_json::json!({"tags": ["important"], "notes": "test"}));
db.register(&rec).unwrap();
let fetched = db.get("meta").unwrap().unwrap();
let meta = fetched.metadata.unwrap();
assert_eq!(meta["tags"][0], "important");
assert_eq!(meta["notes"], "test");
}
/// The derived id keeps the basename (minus any `worktree-` prefix) and appends
/// a 16-hex hash of the full path. Assert the shape rather than a literal hash.
fn assert_id_shape(id: &str, basename: &str) {
let hash = id
.strip_prefix(&format!("{basename}-"))
.unwrap_or_else(|| panic!("id {id:?} must keep the `{basename}-` prefix"));
assert_eq!(hash.len(), 16, "hash must be 16 hex chars: {id:?}");
assert!(
hash.bytes().all(|b| b.is_ascii_hexdigit()),
"hash must be hex: {id:?}"
);
}
#[test]
fn id_from_path_strips_worktree_prefix_and_hashes_full_path() {
let p = Path::new("/home/.kigi/worktrees/myrepo/worktree-019caa03");
assert_id_shape(&id_from_path(p), "019caa03");
assert_id_shape(
&id_from_path(Path::new("/home/.kigi/worktree_pool/inst/a1b2c3")),
"a1b2c3",
);
assert_id_shape(&id_from_path(Path::new("/tmp/my-worktree")), "my-worktree");
// No file name → empty basename, still suffixed with a hash.
assert!(id_from_path(Path::new("/")).starts_with('-'));
// Deterministic.
assert_eq!(id_from_path(p), id_from_path(p));
}
#[test]
fn id_from_path_differs_for_same_basename_in_different_repos() {
// The eviction bug root cause: same basename, different repo → must differ.
let a = id_from_path(Path::new("/home/.kigi/worktrees/repo-a/session/wt-abc"));
let b = id_from_path(Path::new("/home/.kigi/worktrees/repo-b/session/wt-abc"));
assert_ne!(
a, b,
"same-basename worktrees in different repos must get distinct ids"
);
assert_id_shape(&a, "wt-abc");
assert_id_shape(&b, "wt-abc");
}
#[test]
fn same_basename_worktrees_in_different_repos_coexist() {
// Two repos each have a `wt-abc` worktree. Registering both (the way
// discovery/register derive ids) must keep BOTH records — neither evicts
// the other via the `id` PRIMARY KEY or the `path UNIQUE` constraint.
let db = WorktreeDb::open_in_memory().unwrap();
let path_a = "/home/.kigi/worktrees/repo-a/session/wt-abc";
let path_b = "/home/.kigi/worktrees/repo-b/session/wt-abc";
let mut rec_a = make_record(
&id_from_path(Path::new(path_a)),
path_a,
WorktreeKind::Session,
);
rec_a.repo_name = "repo-a".into();
rec_a.source_repo = PathBuf::from("/src/repo-a");
let mut rec_b = make_record(
&id_from_path(Path::new(path_b)),
path_b,
WorktreeKind::Session,
);
rec_b.repo_name = "repo-b".into();
rec_b.source_repo = PathBuf::from("/src/repo-b");
db.register(&rec_a).unwrap();
db.register(&rec_b).unwrap();
// Both rows survive and resolve independently by id and by path.
assert_eq!(
db.list(&ListFilter::default()).unwrap().len(),
2,
"both same-basename worktrees must coexist"
);
assert_eq!(db.get(path_a).unwrap().unwrap().repo_name, "repo-a");
assert_eq!(db.get(path_b).unwrap().unwrap().repo_name, "repo-b");
assert_eq!(
db.get_by_id(&rec_a.id).unwrap().unwrap().path,
PathBuf::from(path_a)
);
assert_eq!(
db.get_by_id(&rec_b.id).unwrap().unwrap().path,
PathBuf::from(path_b)
);
// Removing one (by path) leaves the other intact.
assert!(db.unregister_by_path(Path::new(path_a)).unwrap());
assert!(db.get(path_a).unwrap().is_none());
assert_eq!(db.get(path_b).unwrap().unwrap().repo_name, "repo-b");
}
#[test]
fn repo_name_from_path_extracts_last_component() {
assert_eq!(
repo_name_from_path(Path::new("/Users/me/work/myrepo")),
"myrepo"
);
assert_eq!(repo_name_from_path(Path::new("/")), "repo");
}
#[test]
fn kind_str_roundtrip() {
for kind in [
WorktreeKind::Session,
WorktreeKind::Ab,
WorktreeKind::Pool,
WorktreeKind::Fork,
WorktreeKind::Manual,
WorktreeKind::Subagent,
] {
assert_eq!(WorktreeKind::from_str_lossy(kind.as_str()), kind);
}
assert_eq!(
WorktreeKind::from_str_lossy("garbage"),
WorktreeKind::Manual
);
}
#[test]
fn list_filter_by_source_repo() {
let db = WorktreeDb::open_in_memory().unwrap();
let mut r1 = make_record("wt-1", "/wt/1", WorktreeKind::Session);
r1.source_repo = PathBuf::from("/src/repo-A");
r1.repo_name = "repo-A".into();
db.register(&r1).unwrap();
let mut r2 = make_record("wt-2", "/wt/2", WorktreeKind::Session);
r2.source_repo = PathBuf::from("/src/repo-A");
r2.repo_name = "repo-A".into();
db.register(&r2).unwrap();
let mut r3 = make_record("wt-3", "/wt/3", WorktreeKind::Session);
r3.source_repo = PathBuf::from("/src/repo-B");
r3.repo_name = "repo-B".into();
db.register(&r3).unwrap();
// Filter by source_repo = repo-A: should get 2
let filter = ListFilter {
source_repo: Some(PathBuf::from("/src/repo-A")),
..Default::default()
};
let results = db.list(&filter).unwrap();
assert_eq!(results.len(), 2);
assert!(
results
.iter()
.all(|r| r.source_repo == Path::new("/src/repo-A"))
);
// Filter by source_repo = repo-B: should get 1
let filter = ListFilter {
source_repo: Some(PathBuf::from("/src/repo-B")),
..Default::default()
};
let results = db.list(&filter).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "wt-3");
// Filter by nonexistent source_repo: should get 0
let filter = ListFilter {
source_repo: Some(PathBuf::from("/src/nonexistent")),
..Default::default()
};
let results = db.list(&filter).unwrap();
assert!(results.is_empty());
// No source_repo filter: should get all 3
let results = db.list(&ListFilter::default()).unwrap();
assert_eq!(results.len(), 3);
}
#[test]
fn get_by_label_returns_matching_record() {
let db = WorktreeDb::open_in_memory().unwrap();
let rec = make_labeled_record("wt-abc123", "/tmp/wt-abc123", "my-feature");
db.register(&rec).unwrap();
let fetched = db
.get_by_label("my-feature")
.unwrap()
.expect("should find by label");
assert_eq!(fetched.id, "wt-abc123");
assert_eq!(fetched.path, PathBuf::from("/tmp/wt-abc123"));
}
#[test]
fn get_by_label_returns_none_for_no_match() {
let db = WorktreeDb::open_in_memory().unwrap();
let rec = make_labeled_record("wt-1", "/tmp/wt-1", "existing-label");
db.register(&rec).unwrap();
assert!(db.get_by_label("nonexistent-label").unwrap().is_none());
}
#[test]
fn get_by_label_ignores_records_without_metadata() {
let db = WorktreeDb::open_in_memory().unwrap();
let rec = make_record("wt-plain", "/tmp/wt-plain", WorktreeKind::Session);
db.register(&rec).unwrap();
assert!(db.get_by_label("wt-plain").unwrap().is_none());
}
#[test]
fn get_resolves_by_label_when_id_misses() {
let db = WorktreeDb::open_in_memory().unwrap();
let rec = make_labeled_record("wt-abc123", "/tmp/wt-abc123", "test-2");
db.register(&rec).unwrap();
// "test-2" doesn't match any ID, so it should fall back to label lookup
let fetched = db.get("test-2").unwrap().expect("should resolve by label");
assert_eq!(fetched.id, "wt-abc123");
}
#[test]
fn get_prefers_id_over_label() {
let db = WorktreeDb::open_in_memory().unwrap();
// Record whose ID is "ambiguous"
let r1 = make_record("ambiguous", "/tmp/wt-by-id", WorktreeKind::Session);
db.register(&r1).unwrap();
// Record whose label is "ambiguous"
let r2 = make_labeled_record("wt-other", "/tmp/wt-other", "ambiguous");
db.register(&r2).unwrap();
let fetched = db
.get("ambiguous")
.unwrap()
.expect("should find by id first");
assert_eq!(fetched.id, "ambiguous");
assert_eq!(fetched.path, PathBuf::from("/tmp/wt-by-id"));
}
#[test]
fn get_label_fallback_returns_none_when_both_miss() {
let db = WorktreeDb::open_in_memory().unwrap();
let rec = make_labeled_record("wt-x", "/tmp/wt-x", "some-label");
db.register(&rec).unwrap();
assert!(db.get("no-such-id-or-label").unwrap().is_none());
}
#[test]
fn get_by_label_ignores_malformed_metadata() {
let db = WorktreeDb::open_in_memory().unwrap();
let rec = make_record("wt-bad", "/tmp/wt-bad", WorktreeKind::Session);
db.register(&rec).unwrap();
// Overwrite metadata with non-JSON text via raw SQL
db.conn
.execute(
"UPDATE worktrees SET metadata = 'not json at all' WHERE id = 'wt-bad'",
[],
)
.unwrap();
assert!(db.get_by_label("not json at all").unwrap().is_none());
assert!(db.get_by_label("anything").unwrap().is_none());
}
#[test]
fn get_by_label_returns_most_recent_on_duplicate_labels() {
let db = WorktreeDb::open_in_memory().unwrap();
let mut older = make_labeled_record("wt-old", "/tmp/wt-old", "shared-label");
older.created_at = 100;
db.register(&older).unwrap();
let mut newer = make_labeled_record("wt-new", "/tmp/wt-new", "shared-label");
newer.created_at = 200;
db.register(&newer).unwrap();
let fetched = db
.get_by_label("shared-label")
.unwrap()
.expect("should find the most recent");
assert_eq!(fetched.id, "wt-new");
// Also verify via the get() fallback path
let via_get = db
.get("shared-label")
.unwrap()
.expect("should resolve via label fallback");
assert_eq!(via_get.id, "wt-new");
}
#[test]
fn concurrent_open_at_survives_wal_conversion_race() {
// Many openers hitting a FRESH db at once race the one-time WAL conversion
// (which ignores busy_timeout). set_journal_mode's retry must make every
// open succeed rather than intermittently returning Err (which callers
// swallow, silently dropping worktree tracking). Without the retry this
// flakes.
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("worktrees.db");
let handles: Vec<_> = (0..16)
.map(|_| {
let path = path.clone();
std::thread::spawn(move || WorktreeDb::open_at(&path).is_ok())
})
.collect();
for h in handles {
assert!(
h.join().unwrap(),
"concurrent open_at must not fail on the WAL conversion race"
);
}
}
fn journal_mode(db: &WorktreeDb) -> String {
db.conn
.query_row("PRAGMA journal_mode", [], |r| r.get(0))
.unwrap()
}
#[test]
fn open_at_uses_wal_on_local_fs() {
// Ambient kill-switch would override the decision; skip if set.
if std::env::var("KIGI_SQLITE_JOURNAL_MODE").is_ok() {
return;
}
let tmp = tempfile::TempDir::new().unwrap();
let db = WorktreeDb::open_at(&tmp.path().join("worktrees.db")).unwrap();
assert_eq!(journal_mode(&db), "wal");
}
#[test]
fn network_mode_uses_fresh_per_host_truncate_db() {
// Network mode opens a per-host sibling of the given path (the legacy
// shared file is left untouched — a live old binary can flip it back to
// WAL at any time) in rollback-journal mode.
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("worktrees.db");
{
let db = WorktreeDb::open_at(&path).unwrap();
db.register(&make_record(
"wt-legacy",
"/tmp/wt-legacy",
WorktreeKind::Session,
))
.unwrap();
}
let db = WorktreeDb::open_at_with_journal_mode(&path, JournalMode::Truncate).unwrap();
assert_eq!(journal_mode(&db), "truncate");
// Fresh per-host DB: legacy rows are intentionally not visible.
assert!(db.get("wt-legacy").unwrap().is_none());
db.register(&make_record("wt-nfs", "/tmp/wt-nfs", WorktreeKind::Manual))
.unwrap();
assert!(db.get("wt-nfs").unwrap().is_some());
drop(db);
let eff = JournalMode::Truncate.effective_db_path(&path);
assert_ne!(eff, path);
let base = eff.display().to_string();
assert!(!std::fs::exists(format!("{base}-wal")).unwrap());
assert!(!std::fs::exists(format!("{base}-shm")).unwrap());
}
#[test]
fn journal_conversion_respects_deadline_under_contention() {
use std::time::{Duration, Instant};
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("worktrees.db");
// WAL-stamp the exact file the forced-network open will use.
let eff = JournalMode::Truncate.effective_db_path(&path);
{
let conn = rusqlite::Connection::open(&eff).unwrap();
JournalMode::Wal.apply(&conn).unwrap();
conn.execute_batch("CREATE TABLE t (v TEXT); INSERT INTO t VALUES ('x');")
.unwrap();
}
// A held WAL read transaction blocks the exclusive lock the WAL->TRUNCATE
// conversion needs, so the open must give up at the deadline instead of
// stalling for attempts x busy_timeout.
let holder = rusqlite::Connection::open(&eff).unwrap();
holder
.execute_batch("BEGIN; SELECT COUNT(*) FROM t;")
.unwrap();
let start = Instant::now();
let res = WorktreeDb::open_at_with_journal_mode(&path, JournalMode::Truncate);
let elapsed = start.elapsed();
let err = match res {
Ok(_) => panic!("conversion must fail while a WAL reader holds the DB"),
Err(e) => e,
};
assert!(
format!("{err:#}").contains("database busy after"),
"expected the deadline-busy error, got: {err:#}"
);
assert!(
elapsed < Duration::from_secs(20),
"10s budget (+slack) exceeded: {elapsed:?}"
);
// Release the reader: the same open now converts and succeeds.
holder.execute_batch("COMMIT;").unwrap();
drop(holder);
let db = WorktreeDb::open_at_with_journal_mode(&path, JournalMode::Truncate).unwrap();
assert_eq!(journal_mode(&db), "truncate");
}