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,325 @@
use std::borrow::Cow;
use std::path::Path;
use super::{DbStats, GcReport, RebuildReport};
use kigi_fast_worktree::WorktreeRecord;
use kigi_shell::session::worktree::META_KEY_LABEL;
/// Extract the label from a worktree record's metadata JSON.
fn extract_label(rec: &WorktreeRecord) -> &str {
rec.metadata
.as_ref()
.and_then(|m| m.get(META_KEY_LABEL))
.and_then(|v| v.as_str())
.unwrap_or("")
}
pub fn print_table(records: &[WorktreeRecord]) {
if records.is_empty() {
println!("No worktrees found.");
return;
}
// Compute dynamic ID column width so long IDs are never truncated
let id_width = records
.iter()
.map(|r| r.id.len())
.max()
.unwrap_or(0)
.max(16);
// Compute dynamic label column width (min 5 for header "LABEL")
let label_width = records
.iter()
.map(|r| extract_label(r).len())
.max()
.unwrap_or(0)
.clamp(5, 24);
let header = format!(
" {:<id_width$} {:<8} {:<6} {:<label_width$} {:<20} {:<10} PATH",
"ID", "TYPE", "REPO", "LABEL", "BRANCH", "AGE",
);
println!("{header}");
for rec in records {
let age = format_age(rec.created_at);
let branch = rec.git_ref.as_deref().unwrap_or("(detached)");
let label = extract_label(rec);
let path = abbreviate_home(&rec.path);
let row = format!(
" {:<id_width$} {:<8} {:<6} {:<label_width$} {:<20} {:<10} {}",
rec.id,
rec.kind.as_str(),
truncate(&rec.repo_name, 6),
truncate(label, label_width),
truncate(branch, 20),
age,
path,
);
println!("{row}");
}
let total = records.len();
let by_kind: std::collections::HashMap<&str, usize> =
records
.iter()
.fold(std::collections::HashMap::new(), |mut m, r| {
*m.entry(r.kind.as_str()).or_default() += 1;
m
});
let breakdown: Vec<String> = by_kind.iter().map(|(k, v)| format!("{v} {k}")).collect();
println!(" {} worktrees ({})", total, breakdown.join(", "));
}
pub fn print_json(records: &[WorktreeRecord]) {
let json = serde_json::to_string_pretty(records).unwrap_or_else(|_| "[]".to_string());
println!("{json}");
}
pub fn print_show(rec: &WorktreeRecord) {
println!(" Path: {}", rec.path.display());
println!(" ID: {}", rec.id);
println!(" Type: {}", rec.kind.as_str());
println!(" Source Repo: {}", rec.source_repo.display());
println!(" Creation Mode: {}", rec.creation_mode);
if let Some(ref git_ref) = rec.git_ref {
println!(" Git Ref: {git_ref}");
}
if let Some(ref commit) = rec.head_commit {
let short = if commit.len() > 12 {
&commit[..12]
} else {
commit
};
println!(" HEAD: {short}");
}
println!(" Created: {}", format_timestamp(rec.created_at));
if let Some(ts) = rec.last_accessed_at {
println!(" Last Accessed: {}", format_timestamp(ts));
}
if let Some(ref sid) = rec.session_id {
println!(" Session ID: {sid}");
}
if let Some(pid) = rec.creator_pid {
println!(" Creator PID: {pid}");
}
println!(" Status: {}", rec.status.as_str());
let label = extract_label(rec);
if !label.is_empty() {
println!(" Label: {label}");
}
if rec.path.exists()
&& let Ok(size) = dir_size(&rec.path)
{
println!(" Disk Usage: {}", format_bytes(size));
}
}
pub fn print_stats(stats: &DbStats) {
println!("Worktree DB Statistics");
println!("======================");
println!(" Total records: {}", stats.total_records);
println!(" Alive: {}", stats.alive_count);
println!(" Dead: {}", stats.dead_count);
println!(" DB size: {}", format_bytes(stats.db_file_bytes));
}
pub fn print_gc(report: &GcReport) {
println!("GC report:");
println!(" Dead records removed: {}", report.dead_removed);
println!(" Expired worktrees removed: {}", report.expired_removed);
println!(" Skipped (alive process): {}", report.skipped_alive);
if report.remove_failed > 0 {
println!(" Removal failures: {}", report.remove_failed);
}
}
pub fn print_rebuild(report: &RebuildReport) {
println!("Rebuild report:");
println!(" Discovered: {}", report.discovered);
println!(" Registered: {}", report.registered);
println!(" Already tracked: {}", report.already_tracked);
}
fn format_age(created_at: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let delta = now.saturating_sub(created_at);
if delta < 60 {
format!("{delta}s ago")
} else if delta < 3600 {
format!("{}m ago", delta / 60)
} else if delta < 86400 {
format!("{}h ago", delta / 3600)
} else {
format!("{}d ago", delta / 86400)
}
}
fn format_timestamp(ts: i64) -> String {
let dt = chrono::DateTime::from_timestamp(ts, 0);
match dt {
Some(dt) => dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
None => ts.to_string(),
}
}
fn format_bytes(bytes: u64) -> String {
if bytes == 0 {
return "0 B".to_string();
}
const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
let mut val = bytes as f64;
for unit in UNITS {
if val < 1024.0 {
return format!("{val:.1} {unit}");
}
val /= 1024.0;
}
format!("{val:.1} TB")
}
fn truncate(s: &str, max: usize) -> Cow<'_, str> {
if s.chars().count() <= max {
Cow::Borrowed(s)
} else {
let end = s
.char_indices()
.nth(max.saturating_sub(1))
.map_or(s.len(), |(i, _)| i);
Cow::Owned(format!("{}", &s[..end]))
}
}
fn abbreviate_home(path: &Path) -> String {
crate::util::abbreviate_path(&path.to_string_lossy()).into_owned()
}
fn dir_size(path: &Path) -> std::io::Result<u64> {
let mut total = 0u64;
dir_size_recurse(path, &mut total);
Ok(total)
}
fn dir_size_recurse(dir: &Path, total: &mut u64) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let Ok(ft) = entry.file_type() else { continue };
if ft.is_file() {
if let Ok(meta) = entry.metadata() {
*total += meta.len();
}
} else if ft.is_dir() {
dir_size_recurse(&entry.path(), total);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_bytes() {
assert_eq!(format_bytes(0), "0 B");
assert_eq!(format_bytes(512), "512.0 B");
assert_eq!(format_bytes(1024), "1.0 KB");
assert_eq!(format_bytes(1048576), "1.0 MB");
assert_eq!(format_bytes(1073741824), "1.0 GB");
}
#[test]
fn test_format_age() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
assert!(format_age(now - 30).ends_with("s ago"));
assert!(format_age(now - 120).ends_with("m ago"));
assert!(format_age(now - 7200).ends_with("h ago"));
assert!(format_age(now - 172800).ends_with("d ago"));
}
#[test]
fn test_truncate_no_truncation() {
assert_eq!(truncate("hello", 10).as_ref(), "hello");
assert!(matches!(truncate("hello", 10), Cow::Borrowed(_)));
}
#[test]
fn test_truncate_with_truncation() {
let result = truncate("hello world", 5);
assert_eq!(result.as_ref(), "hell…");
assert!(matches!(result, Cow::Owned(_)));
}
#[test]
fn test_truncate_utf8_safe() {
let result = truncate("héllo wörld", 5);
assert_eq!(result.as_ref(), "héll…");
}
#[test]
fn test_abbreviate_home() {
if let Ok(home) = std::env::var("HOME") {
let path = std::path::PathBuf::from(format!("{home}/work/xai"));
assert_eq!(abbreviate_home(&path), "~/work/xai");
}
}
#[test]
fn test_print_table_long_id_not_truncated() {
let long_id = "a".repeat(40);
// Verify width computation: ID longer than 16 should determine column width
let id_width = long_id.len().max(16);
let formatted = format!("{:<id_width$}", long_id, id_width = id_width);
assert!(formatted.len() >= 40, "ID should not be truncated");
assert!(formatted.contains(&long_id), "Full ID must be present");
}
fn make_test_record(metadata: Option<serde_json::Value>) -> kigi_fast_worktree::WorktreeRecord {
use kigi_fast_worktree::{WorktreeKind, WorktreeRecord, WorktreeStatus};
WorktreeRecord {
id: "test".into(),
path: "/tmp/wt".into(),
source_repo: "/repo".into(),
repo_name: "repo".into(),
kind: WorktreeKind::Session,
creation_mode: "linked".into(),
git_ref: None,
head_commit: None,
session_id: None,
creator_pid: None,
created_at: 0,
last_accessed_at: None,
status: WorktreeStatus::Alive,
metadata,
}
}
#[test]
fn test_extract_label_present() {
let rec = make_test_record(Some(
serde_json::json!({"label": "my-feature", "user_provided": true}),
));
assert_eq!(extract_label(&rec), "my-feature");
}
#[test]
fn test_extract_label_missing_metadata() {
let rec = make_test_record(None);
assert_eq!(extract_label(&rec), "");
}
#[test]
fn test_extract_label_no_label_key() {
let rec = make_test_record(Some(serde_json::json!({"other": "data"})));
assert_eq!(extract_label(&rec), "");
}
}
@@ -0,0 +1,549 @@
mod display;
use anyhow::{Result, bail};
use clap::Subcommand;
use kigi_fast_worktree::WorktreeRecord;
use tokio_util::sync::CancellationToken;
use agent_client_protocol as acp;
use kigi_acp_lib::acp_send;
use kigi_shell::agent::config::Config as AgentConfig;
/// Local response types matching the ACP response shapes.
#[derive(Debug, serde::Deserialize)]
pub struct GcReport {
pub dead_removed: u64,
pub expired_removed: u64,
pub skipped_alive: u64,
// serde(default) so reports from agents predating this field still parse.
#[serde(default)]
pub remove_failed: u64,
}
#[derive(Debug, serde::Deserialize)]
pub struct DbStats {
pub total_records: u64,
pub alive_count: u64,
pub dead_count: u64,
pub db_file_bytes: u64,
}
#[derive(Debug, serde::Deserialize)]
pub struct RebuildReport {
pub discovered: u64,
pub registered: u64,
pub already_tracked: u64,
}
#[derive(Debug, clap::Args, Clone)]
pub struct WorktreeArgs {
#[command(subcommand)]
command: WorktreeCommand,
}
#[derive(Debug, Subcommand, Clone)]
enum WorktreeCommand {
/// List tracked worktrees
#[command(visible_alias = "ls")]
List {
#[arg(long)]
repo: Option<String>,
#[arg(long, value_delimiter = ',')]
r#type: Vec<String>,
#[arg(long)]
json: bool,
#[arg(long)]
all: bool,
},
/// Show details for a specific worktree
Show { id_or_path: String },
/// Remove worktrees
Rm {
#[arg(required = true)]
ids: Vec<String>,
#[arg(short, long)]
force: bool,
#[arg(long)]
dry_run: bool,
},
/// Garbage-collect orphaned/stale worktrees
#[command(alias = "prune")]
Gc {
#[arg(long)]
dry_run: bool,
#[arg(long)]
max_age: Option<String>,
#[arg(short, long)]
force: bool,
},
/// Database maintenance
Db {
#[command(subcommand)]
command: WorktreeDbCommand,
},
}
#[derive(Debug, Subcommand, Clone)]
enum WorktreeDbCommand {
/// Rebuild DB from filesystem scan
Rebuild,
/// Show DB statistics
Stats,
/// Print DB file path
Path,
}
pub async fn run(args: WorktreeArgs, agent_config: &AgentConfig) -> Result<()> {
let cancel = CancellationToken::new();
let spawned = crate::acp::spawn::spawn_grok_shell(agent_config.clone(), &cancel, None).await?;
let _init: acp::InitializeResponse = acp_send(
acp::InitializeRequest::new(acp::ProtocolVersion::V1)
.client_capabilities(
acp::ClientCapabilities::new()
.fs(acp::FileSystemCapabilities::new())
.terminal(false),
)
.meta(
serde_json::json!({
"clientType": crate::client_identity::HEADLESS_CLIENT_TYPE,
"clientVersion": crate::client_identity::PAGER_CLIENT_VERSION
})
.as_object()
.cloned(),
),
&spawned.channel.tx,
)
.await?;
let result = dispatch(args.command, &spawned.channel.tx).await;
cancel.cancel();
result
}
async fn dispatch(command: WorktreeCommand, tx: &kigi_acp_lib::AcpAgentTx) -> Result<()> {
match command {
WorktreeCommand::List {
repo,
r#type,
json,
all,
} => cmd_list(tx, repo, r#type, json, all).await,
WorktreeCommand::Show { id_or_path } => cmd_show(tx, &id_or_path).await,
WorktreeCommand::Rm {
ids,
force,
dry_run,
} => cmd_rm(tx, ids, force, dry_run).await,
WorktreeCommand::Gc {
dry_run,
max_age,
force,
} => cmd_gc(tx, dry_run, max_age, force).await,
WorktreeCommand::Db { command } => cmd_db(tx, command).await,
}
}
fn ext_request<T: serde::Serialize>(
method: &str,
params: &T,
) -> Result<acp::ExtRequest, serde_json::Error> {
let params = serde_json::value::to_raw_value(params)?;
Ok(acp::ExtRequest::new(method, params.into()))
}
/// ACP extension responses are wrapped in `{ "result": T, "error": ... }`.
#[derive(serde::Deserialize)]
struct ExtEnvelope<T> {
result: Option<T>,
error: Option<serde_json::Value>,
}
async fn ext_call<T: serde::de::DeserializeOwned>(
tx: &kigi_acp_lib::AcpAgentTx,
method: &str,
params: &impl serde::Serialize,
) -> Result<T> {
let req =
ext_request(method, params).map_err(|e| anyhow::anyhow!("failed to build request: {e}"))?;
let resp = acp_send(req, tx)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let envelope: ExtEnvelope<T> = serde_json::from_str(resp.0.get())
.map_err(|e| anyhow::anyhow!("response parse error: {e}"))?;
if let Some(err) = envelope.error {
bail!("ACP error: {err}");
}
envelope
.result
.ok_or_else(|| anyhow::anyhow!("ACP response missing result field"))
}
async fn cmd_list(
tx: &kigi_acp_lib::AcpAgentTx,
repo: Option<String>,
types: Vec<String>,
json: bool,
all: bool,
) -> Result<()> {
let records: Vec<WorktreeRecord> = ext_call(
tx,
"x.ai/git/worktree/list",
&serde_json::json!({
"repo": repo,
"type": types,
"includeAll": all,
}),
)
.await?;
if json {
display::print_json(&records);
} else {
display::print_table(&records);
}
Ok(())
}
async fn cmd_show(tx: &kigi_acp_lib::AcpAgentTx, id_or_path: &str) -> Result<()> {
let rec: Option<WorktreeRecord> = ext_call(
tx,
"x.ai/git/worktree/show",
&serde_json::json!({ "idOrPath": id_or_path }),
)
.await?;
match rec {
Some(r) => {
display::print_show(&r);
Ok(())
}
None => bail!("worktree not found: {id_or_path}"),
}
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct RemoveResponse {
removed: bool,
#[serde(default)]
resolved_path: Option<String>,
}
async fn cmd_rm(
tx: &kigi_acp_lib::AcpAgentTx,
ids: Vec<String>,
force: bool,
dry_run: bool,
) -> Result<()> {
for id_or_path in &ids {
let resp: Result<RemoveResponse> = ext_call(
tx,
"x.ai/git/worktree/remove",
&serde_json::json!({
"idOrPath": id_or_path,
"force": force,
"dryRun": dry_run,
}),
)
.await;
match resp {
Ok(r) => {
let path = r.resolved_path.as_deref().unwrap_or(id_or_path);
if dry_run {
println!(" would remove: {path}");
} else if r.removed {
println!(" removed: {path}");
}
}
Err(e) => eprintln!(" error removing {id_or_path}: {e}"),
}
}
Ok(())
}
async fn cmd_gc(
tx: &kigi_acp_lib::AcpAgentTx,
dry_run: bool,
max_age: Option<String>,
force: bool,
) -> Result<()> {
let report: GcReport = ext_call(
tx,
"x.ai/git/worktree/gc",
&serde_json::json!({
"dryRun": dry_run,
"maxAge": max_age,
"force": force,
}),
)
.await?;
if dry_run {
println!("Dry run \u{2014} no changes made.");
}
display::print_gc(&report);
Ok(())
}
async fn cmd_db(tx: &kigi_acp_lib::AcpAgentTx, command: WorktreeDbCommand) -> Result<()> {
match command {
WorktreeDbCommand::Stats => {
let stats: DbStats = ext_call(tx, "x.ai/git/worktree/db/stats", &()).await?;
display::print_stats(&stats);
Ok(())
}
WorktreeDbCommand::Path => {
#[derive(serde::Deserialize)]
struct PathResp {
path: String,
}
let resp: PathResp = ext_call(tx, "x.ai/git/worktree/db/path", &()).await?;
println!("{}", resp.path);
Ok(())
}
WorktreeDbCommand::Rebuild => {
let report: RebuildReport = ext_call(tx, "x.ai/git/worktree/db/rebuild", &()).await?;
display::print_rebuild(&report);
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ext_request_builds_list_with_filters() {
let req = ext_request(
"x.ai/git/worktree/list",
&serde_json::json!({
"repo": "xai",
"type": ["session"],
"includeAll": true,
}),
)
.unwrap();
assert_eq!(req.method.as_ref(), "x.ai/git/worktree/list");
let params: serde_json::Value = serde_json::from_str(req.params.get()).unwrap();
assert_eq!(params["repo"], "xai");
assert_eq!(params["includeAll"], true);
}
#[test]
fn ext_request_builds_gc_with_max_age_string() {
let req = ext_request(
"x.ai/git/worktree/gc",
&serde_json::json!({
"dryRun": true,
"maxAge": "7d",
"force": false,
}),
)
.unwrap();
let params: serde_json::Value = serde_json::from_str(req.params.get()).unwrap();
assert_eq!(params["maxAge"], "7d");
assert_eq!(params["dryRun"], true);
}
#[test]
fn ext_request_builds_remove_with_id_or_path() {
let req = ext_request(
"x.ai/git/worktree/remove",
&serde_json::json!({
"idOrPath": "wt-abc123",
"force": true,
"dryRun": false,
}),
)
.unwrap();
let params: serde_json::Value = serde_json::from_str(req.params.get()).unwrap();
assert_eq!(params["idOrPath"], "wt-abc123");
}
#[test]
fn ext_request_builds_show() {
let req = ext_request(
"x.ai/git/worktree/show",
&serde_json::json!({ "idOrPath": "/some/path" }),
)
.unwrap();
let params: serde_json::Value = serde_json::from_str(req.params.get()).unwrap();
assert_eq!(params["idOrPath"], "/some/path");
}
#[test]
fn ext_request_builds_db_stats_empty_params() {
let req = ext_request("x.ai/git/worktree/db/stats", &()).unwrap();
assert_eq!(req.method.as_ref(), "x.ai/git/worktree/db/stats");
}
#[test]
fn remove_response_deserializes_with_resolved_path() {
let json = r#"{"removed": true, "resolvedPath": "/resolved"}"#;
let resp: RemoveResponse = serde_json::from_str(json).unwrap();
assert!(resp.removed);
assert_eq!(resp.resolved_path.as_deref(), Some("/resolved"));
}
#[test]
fn remove_response_deserializes_without_resolved_path() {
let json = r#"{"removed": true}"#;
let resp: RemoveResponse = serde_json::from_str(json).unwrap();
assert!(resp.removed);
assert!(resp.resolved_path.is_none());
}
#[test]
fn ext_envelope_unwraps_success_result() {
let json = r#"{"result": {"path": "/home/user/.kigi/worktrees.db"}, "error": null}"#;
#[derive(serde::Deserialize)]
struct PathResp {
path: String,
}
let envelope: ExtEnvelope<PathResp> = serde_json::from_str(json).unwrap();
assert!(envelope.error.is_none());
let inner = envelope.result.unwrap();
assert_eq!(inner.path, "/home/user/.kigi/worktrees.db");
}
#[test]
fn ext_envelope_unwraps_error_result() {
let json = r#"{"result": null, "error": "something went wrong"}"#;
let envelope: ExtEnvelope<serde_json::Value> = serde_json::from_str(json).unwrap();
assert!(envelope.result.is_none());
assert!(envelope.error.is_some());
}
#[test]
fn ext_envelope_unwraps_list_of_records() {
let json = r#"{"result": [], "error": null}"#;
let envelope: ExtEnvelope<Vec<WorktreeRecord>> = serde_json::from_str(json).unwrap();
assert!(envelope.error.is_none());
assert!(envelope.result.unwrap().is_empty());
}
#[test]
fn ext_envelope_unwraps_db_stats() {
let json = r#"{"result": {"total_records": 5, "alive_count": 3, "dead_count": 2, "db_file_bytes": 1024}}"#;
let envelope: ExtEnvelope<DbStats> = serde_json::from_str(json).unwrap();
let stats = envelope.result.unwrap();
assert_eq!(stats.total_records, 5);
assert_eq!(stats.alive_count, 3);
}
#[test]
fn ext_envelope_unwraps_gc_report() {
let json = r#"{"result": {"dead_removed": 2, "expired_removed": 1, "skipped_alive": 0}}"#;
let envelope: ExtEnvelope<GcReport> = serde_json::from_str(json).unwrap();
let report = envelope.result.unwrap();
assert_eq!(report.dead_removed, 2);
assert_eq!(report.expired_removed, 1);
// Older agents omit remove_failed; it must default to zero.
assert_eq!(report.remove_failed, 0);
}
#[test]
fn rm_parses_short_force_flag() {
use clap::Parser;
#[derive(Parser)]
struct Cli {
#[command(subcommand)]
command: WorktreeCommand,
}
let cli = Cli::parse_from(["test", "rm", "-f", "wt-1"]);
match cli.command {
WorktreeCommand::Rm {
ids,
force,
dry_run,
} => {
assert!(force);
assert!(!dry_run);
assert_eq!(ids, vec!["wt-1"]);
}
_ => panic!("expected Rm variant"),
}
}
#[test]
fn rm_parses_long_force_flag() {
use clap::Parser;
#[derive(Parser)]
struct Cli {
#[command(subcommand)]
command: WorktreeCommand,
}
let cli = Cli::parse_from(["test", "rm", "--force", "a", "b"]);
match cli.command {
WorktreeCommand::Rm {
ids,
force,
dry_run,
} => {
assert!(force);
assert!(!dry_run);
assert_eq!(ids, vec!["a", "b"]);
}
_ => panic!("expected Rm variant"),
}
}
#[test]
fn gc_parses_short_force_flag() {
use clap::Parser;
#[derive(Parser)]
struct Cli {
#[command(subcommand)]
command: WorktreeCommand,
}
let cli = Cli::parse_from(["test", "gc", "-f"]);
match cli.command {
WorktreeCommand::Gc {
force,
dry_run,
max_age,
} => {
assert!(force);
assert!(!dry_run);
assert!(max_age.is_none());
}
_ => panic!("expected Gc variant"),
}
}
#[test]
fn list_accepts_ls_alias() {
use clap::Parser;
#[derive(Parser)]
struct Cli {
#[command(subcommand)]
command: WorktreeCommand,
}
let cli = Cli::parse_from(["test", "ls", "--json"]);
match cli.command {
WorktreeCommand::List {
repo,
r#type,
json,
all,
} => {
assert!(repo.is_none());
assert!(r#type.is_empty());
assert!(json);
assert!(!all);
}
_ => panic!("expected List variant"),
}
}
}