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