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:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user