feat(dogfood): kebab reset --orphans-only — purge stored docs outside walker scope
PR #148 auto-purges only filesystem-missing files (conservative — leaves on-disk-but-out-of-scope docs alone for data safety). This is the explicit complement: when the user has narrowed include / widened exclude / removed a sub-directory from the workspace and WANTS the stored docs reconciled, they invoke 'kebab reset --orphans-only'. Confirm prompt with orphan count + sample paths; --yes required in non-TTY. SQLite purge via existing purge_deleted_workspace_path (PR #148) + vector store delete_by_chunk_ids when configured. No fs existence check — orphans-only is the explicit 'I know what I'm doing' variant. dogfood follow-up to PR #148 (file deletion auto-purge). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -71,7 +71,7 @@ mod staleness;
|
|||||||
|
|
||||||
pub use app::{App, SearchResponse};
|
pub use app::{App, SearchResponse};
|
||||||
pub use ingest_progress::{AggregateCounts, IngestEvent, render_skipped_breakdown};
|
pub use ingest_progress::{AggregateCounts, IngestEvent, render_skipped_breakdown};
|
||||||
pub use reset::{ResetReport, ResetScope};
|
pub use reset::{ResetReport, ResetScope, enumerate_orphans};
|
||||||
pub use error_wire::{ERROR_V1_ID, ErrorV1, StructuredError, classify};
|
pub use error_wire::{ERROR_V1_ID, ErrorV1, StructuredError, classify};
|
||||||
pub use fetch::fetch_with_config;
|
pub use fetch::fetch_with_config;
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
|
|||||||
@@ -9,13 +9,19 @@
|
|||||||
//!
|
//!
|
||||||
//! `--vector-only` additionally truncates `embedding_records` in SQLite
|
//! `--vector-only` additionally truncates `embedding_records` in SQLite
|
||||||
//! so the next `kebab ingest` re-embeds cleanly without orphan rows.
|
//! so the next `kebab ingest` re-embeds cleanly without orphan rows.
|
||||||
|
//!
|
||||||
|
//! `--orphans-only` purges stored docs that are outside the current walker
|
||||||
|
//! scope (config narrowing / removed sub-directory). No filesystem paths are
|
||||||
|
//! removed — this is purely a store-level reconciliation.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use kebab_config::{Config, expand_path};
|
use kebab_config::{Config, expand_path};
|
||||||
|
use kebab_core::WorkspacePath;
|
||||||
|
|
||||||
/// What the user asked to remove. Mutually exclusive — picked by the CLI
|
/// What the user asked to remove. Mutually exclusive — picked by the CLI
|
||||||
/// from a clap `ArgGroup`.
|
/// from a clap `ArgGroup`.
|
||||||
@@ -32,6 +38,13 @@ pub enum ResetScope {
|
|||||||
VectorOnly,
|
VectorOnly,
|
||||||
/// Wipe only the config dir.
|
/// Wipe only the config dir.
|
||||||
ConfigOnly,
|
ConfigOnly,
|
||||||
|
/// Purge stored docs that are outside the current walker scope (no
|
||||||
|
/// filesystem paths are removed). Filesystem existence is NOT checked —
|
||||||
|
/// anything the current walker would not visit is considered an orphan.
|
||||||
|
/// The explicit complement to the conservative `sweep_deleted_files`
|
||||||
|
/// that runs during ingest (which leaves on-disk-but-out-of-scope docs
|
||||||
|
/// alone for data safety).
|
||||||
|
OrphansOnly,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of a successful wipe — emitted as `reset_report.v1` by the
|
/// Result of a successful wipe — emitted as `reset_report.v1` by the
|
||||||
@@ -41,6 +54,16 @@ pub struct ResetReport {
|
|||||||
pub scope: ResetScope,
|
pub scope: ResetScope,
|
||||||
pub removed_paths: Vec<PathBuf>,
|
pub removed_paths: Vec<PathBuf>,
|
||||||
pub embedding_rows_truncated: u64,
|
pub embedding_rows_truncated: u64,
|
||||||
|
/// Number of stored docs purged because they are outside the current
|
||||||
|
/// walker scope. Non-zero only when `scope == OrphansOnly`.
|
||||||
|
/// `#[serde(default)]` preserves back-compat with older callers that
|
||||||
|
/// do not include this field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub orphans_purged: u32,
|
||||||
|
/// Paths of the orphaned docs that were purged. Sorted for deterministic
|
||||||
|
/// output. Non-empty only when `scope == OrphansOnly`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub purged_paths: Vec<WorkspacePath>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the absolute on-disk paths a given scope will wipe, given a
|
/// Compute the absolute on-disk paths a given scope will wipe, given a
|
||||||
@@ -67,6 +90,10 @@ pub fn enumerate_paths(scope: ResetScope, cfg: &Config) -> Vec<PathBuf> {
|
|||||||
vec![vector_dir]
|
vec![vector_dir]
|
||||||
}
|
}
|
||||||
ResetScope::ConfigOnly => vec![cfg_dir],
|
ResetScope::ConfigOnly => vec![cfg_dir],
|
||||||
|
// OrphansOnly operates purely at the store level — no filesystem paths
|
||||||
|
// are removed. Return empty so `estimate_size_bytes` stays zero and
|
||||||
|
// the existing confirm UI path for directory wipes is skipped.
|
||||||
|
ResetScope::OrphansOnly => vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,16 +123,82 @@ pub fn estimate_size_bytes(paths: &[PathBuf]) -> u64 {
|
|||||||
paths.iter().map(|p| walk(p)).sum()
|
paths.iter().map(|p| walk(p)).sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute the workspace paths stored in SQLite that are NOT visited by
|
||||||
|
/// the current walker scope (i.e. they are "orphans" — on disk but
|
||||||
|
/// outside the configured include/exclude rules, or from a sub-directory
|
||||||
|
/// that has since been removed from the workspace).
|
||||||
|
///
|
||||||
|
/// Does NOT check filesystem existence — `OrphansOnly` is the explicit
|
||||||
|
/// "I know what I'm doing" variant; callers that want the conservative
|
||||||
|
/// fs-aware sweep should use `sweep_deleted_files` inside ingest.
|
||||||
|
///
|
||||||
|
/// Returns the list sorted for deterministic output. Called twice by the
|
||||||
|
/// CLI path (once for the confirm UI preview, once inside `execute`);
|
||||||
|
/// the double scan is acceptable for a rare destructive operation.
|
||||||
|
pub fn enumerate_orphans(cfg: &Config) -> Result<Vec<WorkspacePath>> {
|
||||||
|
use kebab_core::DocumentStore as _;
|
||||||
|
use kebab_source_fs::FsSourceConnector;
|
||||||
|
use kebab_core::SourceScope;
|
||||||
|
|
||||||
|
let store = kebab_store_sqlite::SqliteStore::open(cfg)
|
||||||
|
.context("enumerate_orphans: open SqliteStore")?;
|
||||||
|
|
||||||
|
let stored = store
|
||||||
|
.all_workspace_paths()
|
||||||
|
.context("enumerate_orphans: all_workspace_paths")?;
|
||||||
|
|
||||||
|
if stored.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the same SourceScope the CLI's ingest path uses: root from
|
||||||
|
// config, exclude list from config, no include override (full scope).
|
||||||
|
let root = cfg.resolve_workspace_root();
|
||||||
|
let scope = SourceScope {
|
||||||
|
root: root.clone(),
|
||||||
|
exclude: cfg.workspace.exclude.clone(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let connector = FsSourceConnector::new(cfg)
|
||||||
|
.context("enumerate_orphans: build FsSourceConnector")?;
|
||||||
|
let (assets, _skips) = connector
|
||||||
|
.scan_with_skips(&scope)
|
||||||
|
.context("enumerate_orphans: scan workspace")?;
|
||||||
|
|
||||||
|
let scanned: HashSet<WorkspacePath> = assets
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| a.workspace_path)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut orphans: Vec<WorkspacePath> = stored
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| !scanned.contains(p))
|
||||||
|
.collect();
|
||||||
|
orphans.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
Ok(orphans)
|
||||||
|
}
|
||||||
|
|
||||||
/// Wipe every path from `enumerate_paths(scope, cfg)`. For
|
/// Wipe every path from `enumerate_paths(scope, cfg)`. For
|
||||||
/// `ResetScope::VectorOnly`, also truncates the SQLite
|
/// `ResetScope::VectorOnly`, also truncates the SQLite
|
||||||
/// `embedding_records` table so the store doesn't point at the Lance
|
/// `embedding_records` table so the store doesn't point at the Lance
|
||||||
/// rows we just removed off-disk.
|
/// rows we just removed off-disk.
|
||||||
///
|
///
|
||||||
|
/// For `ResetScope::OrphansOnly`, no filesystem directories are removed.
|
||||||
|
/// Instead the store is reconciled: stored docs outside the current walker
|
||||||
|
/// scope are purged from SQLite (+ vector store when configured). The
|
||||||
|
/// caller is expected to have already shown the confirm UI using
|
||||||
|
/// `enumerate_orphans`.
|
||||||
|
///
|
||||||
/// Idempotent: a missing path is treated as already-removed (success).
|
/// Idempotent: a missing path is treated as already-removed (success).
|
||||||
/// Returns a `ResetReport` listing exactly what was removed (paths that
|
/// Returns a `ResetReport` listing exactly what was removed (paths that
|
||||||
/// existed before the call) so `--json` callers see the truth, not the
|
/// existed before the call) so `--json` callers see the truth, not the
|
||||||
/// request.
|
/// request.
|
||||||
pub fn execute(scope: ResetScope, cfg: &Config) -> Result<ResetReport> {
|
pub fn execute(scope: ResetScope, cfg: &Config) -> Result<ResetReport> {
|
||||||
|
if matches!(scope, ResetScope::OrphansOnly) {
|
||||||
|
return execute_orphans_only(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
let paths = enumerate_paths(scope, cfg);
|
let paths = enumerate_paths(scope, cfg);
|
||||||
let mut removed = Vec::new();
|
let mut removed = Vec::new();
|
||||||
|
|
||||||
@@ -128,9 +221,100 @@ pub fn execute(scope: ResetScope, cfg: &Config) -> Result<ResetReport> {
|
|||||||
scope,
|
scope,
|
||||||
removed_paths: removed,
|
removed_paths: removed,
|
||||||
embedding_rows_truncated,
|
embedding_rows_truncated,
|
||||||
|
orphans_purged: 0,
|
||||||
|
purged_paths: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Execute the `OrphansOnly` variant: reconcile stored docs against the
|
||||||
|
/// current walker scope without touching any filesystem directory.
|
||||||
|
fn execute_orphans_only(cfg: &Config) -> Result<ResetReport> {
|
||||||
|
let orphans = enumerate_orphans(cfg)
|
||||||
|
.context("execute_orphans_only: enumerate orphans")?;
|
||||||
|
|
||||||
|
if orphans.is_empty() {
|
||||||
|
return Ok(ResetReport {
|
||||||
|
scope: ResetScope::OrphansOnly,
|
||||||
|
removed_paths: Vec::new(),
|
||||||
|
embedding_rows_truncated: 0,
|
||||||
|
orphans_purged: 0,
|
||||||
|
purged_paths: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = std::sync::Arc::new(
|
||||||
|
kebab_store_sqlite::SqliteStore::open(cfg)
|
||||||
|
.context("execute_orphans_only: open SqliteStore")?,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open vector store if configured. Mirror the same guard the ingest
|
||||||
|
// path uses: only construct when the provider is not "none" / dims > 0.
|
||||||
|
let vector_store: Option<kebab_store_vector::LanceVectorStore> =
|
||||||
|
open_vector_store_if_configured(cfg, store.clone())?;
|
||||||
|
|
||||||
|
let mut purged_paths: Vec<WorkspacePath> = Vec::new();
|
||||||
|
|
||||||
|
for path in &orphans {
|
||||||
|
let chunk_ids = kebab_store_sqlite::purge_deleted_workspace_path(&store, path)
|
||||||
|
.with_context(|| format!("execute_orphans_only: purge {}", path.0))?;
|
||||||
|
|
||||||
|
if let Some(ref vs) = vector_store {
|
||||||
|
if !chunk_ids.is_empty() {
|
||||||
|
use kebab_core::VectorStore as _;
|
||||||
|
if let Err(e) = vs.delete_by_chunk_ids(&chunk_ids) {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "kebab-app",
|
||||||
|
path = %path.0,
|
||||||
|
count = chunk_ids.len(),
|
||||||
|
error = %e,
|
||||||
|
"reset --orphans-only: vector delete failed; SQLite side already cleaned"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
target: "kebab-app",
|
||||||
|
path = %path.0,
|
||||||
|
"reset --orphans-only: purged orphan document"
|
||||||
|
);
|
||||||
|
purged_paths.push(path.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let orphans_purged = u32::try_from(purged_paths.len()).unwrap_or(u32::MAX);
|
||||||
|
|
||||||
|
Ok(ResetReport {
|
||||||
|
scope: ResetScope::OrphansOnly,
|
||||||
|
removed_paths: Vec::new(),
|
||||||
|
embedding_rows_truncated: 0,
|
||||||
|
orphans_purged,
|
||||||
|
purged_paths,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the Lance vector store if the configured embedding provider is
|
||||||
|
/// active (non-"none", dimensions > 0). Returns `None` for lexical-only
|
||||||
|
/// configs. Mirrors the guard in `App::vector`.
|
||||||
|
fn open_vector_store_if_configured(
|
||||||
|
cfg: &Config,
|
||||||
|
store: std::sync::Arc<kebab_store_sqlite::SqliteStore>,
|
||||||
|
) -> Result<Option<kebab_store_vector::LanceVectorStore>> {
|
||||||
|
if cfg.models.embedding.provider == "none" || cfg.models.embedding.dimensions == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
match kebab_store_vector::LanceVectorStore::new(cfg, store) {
|
||||||
|
Ok(vs) => Ok(Some(vs)),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "kebab-app",
|
||||||
|
error = %e,
|
||||||
|
"reset --orphans-only: could not open vector store; skipping vector delete"
|
||||||
|
);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Open the SQLite store at the configured path and run
|
/// Open the SQLite store at the configured path and run
|
||||||
/// `truncate_embedding_records`. Returns the count of truncated rows
|
/// `truncate_embedding_records`. Returns the count of truncated rows
|
||||||
/// (the helper itself reports `DELETE` rowcount). If the SQLite file
|
/// (the helper itself reports `DELETE` rowcount). If the SQLite file
|
||||||
@@ -200,4 +384,14 @@ mod tests {
|
|||||||
let bytes = estimate_size_bytes(&[dir.path().to_path_buf()]);
|
let bytes = estimate_size_bytes(&[dir.path().to_path_buf()]);
|
||||||
assert_eq!(bytes, 5 + 6);
|
assert_eq!(bytes, 5 + 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enumerate_orphans_only_returns_empty_paths() {
|
||||||
|
let cfg = Config::defaults();
|
||||||
|
let paths = enumerate_paths(ResetScope::OrphansOnly, &cfg);
|
||||||
|
assert!(
|
||||||
|
paths.is_empty(),
|
||||||
|
"OrphansOnly must return empty vec from enumerate_paths"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
141
crates/kebab-app/tests/reset_orphans.rs
Normal file
141
crates/kebab-app/tests/reset_orphans.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
//! Integration test for `kebab reset --orphans-only`.
|
||||||
|
//!
|
||||||
|
//! Verifies that stored docs outside the current walker scope are purged
|
||||||
|
//! from the store without removing any files from the filesystem.
|
||||||
|
//!
|
||||||
|
//! Test outline:
|
||||||
|
//! 1. Ingest 3 .rs files (a.rs, b.rs, c.rs) — all New.
|
||||||
|
//! 2. Narrow the config `include` to `["a.rs"]` only; b.rs and c.rs are
|
||||||
|
//! still on disk but outside the walker scope.
|
||||||
|
//! 3. Run `execute(ResetScope::OrphansOnly, &cfg)` — report must show
|
||||||
|
//! `orphans_purged == 2` and `purged_paths` contains b.rs + c.rs.
|
||||||
|
//! 4. `list docs` must show only a.rs.
|
||||||
|
//! 5. b.rs and c.rs must still exist on disk (no filesystem removal).
|
||||||
|
//! 6. Second reset → `orphans_purged == 0` (idempotent).
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use common::TestEnv;
|
||||||
|
use kebab_app::IngestOpts;
|
||||||
|
use kebab_app::reset::{ResetScope, execute};
|
||||||
|
use kebab_core::{DocFilter, DocumentStore, SourceScope};
|
||||||
|
|
||||||
|
/// Open the SqliteStore and list all `workspace_path` values.
|
||||||
|
fn list_doc_paths(env: &TestEnv) -> Vec<String> {
|
||||||
|
use kebab_store_sqlite::SqliteStore;
|
||||||
|
let store = SqliteStore::open(&env.config).unwrap();
|
||||||
|
store.run_migrations().unwrap();
|
||||||
|
store
|
||||||
|
.list_documents(&DocFilter::default())
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|d| d.doc_path.0)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_orphans_only_purges_out_of_scope_docs() {
|
||||||
|
let env = TestEnv::lexical_only();
|
||||||
|
|
||||||
|
// Write three .rs files into the workspace.
|
||||||
|
let a_path = env.workspace_root.join("a.rs");
|
||||||
|
let b_path = env.workspace_root.join("b.rs");
|
||||||
|
let c_path = env.workspace_root.join("c.rs");
|
||||||
|
std::fs::write(&a_path, "// file a\nfn alpha() {}\n").unwrap();
|
||||||
|
std::fs::write(&b_path, "// file b\nfn bravo() {}\n").unwrap();
|
||||||
|
std::fs::write(&c_path, "// file c\nfn charlie() {}\n").unwrap();
|
||||||
|
|
||||||
|
// Ingest all three with a wide scope.
|
||||||
|
let wide_scope = SourceScope {
|
||||||
|
root: env.workspace_root.clone(),
|
||||||
|
include: vec!["**/*.rs".to_string()],
|
||||||
|
exclude: env.config.workspace.exclude.clone(),
|
||||||
|
};
|
||||||
|
let first = kebab_app::ingest_with_config_opts(
|
||||||
|
env.config.clone(),
|
||||||
|
wide_scope,
|
||||||
|
false,
|
||||||
|
IngestOpts::default(),
|
||||||
|
)
|
||||||
|
.expect("first ingest must succeed");
|
||||||
|
// The fixture workspace may contain other .rs files — just assert we
|
||||||
|
// got at least 3 new docs (our a.rs, b.rs, c.rs).
|
||||||
|
assert!(first.new >= 3, "expected at least 3 new docs: {first:?}");
|
||||||
|
assert_eq!(first.errors, 0, "no errors on first ingest");
|
||||||
|
|
||||||
|
// Narrow config to include only a.rs; b.rs + c.rs are still on disk.
|
||||||
|
let mut narrow_cfg = env.config.clone();
|
||||||
|
narrow_cfg.workspace.exclude.clear();
|
||||||
|
// Re-point workspace root (already correct) and restrict include via
|
||||||
|
// the SourceScope in the connector. The config's `workspace.root` is
|
||||||
|
// used by `enumerate_orphans` to build its scope — we keep that
|
||||||
|
// pointing at the workspace root. We simulate narrowing by setting a
|
||||||
|
// glob that only matches a.rs.
|
||||||
|
//
|
||||||
|
// NOTE: `kebab_config::WorkspaceCfg` does not have an `include` field
|
||||||
|
// (it was removed in p9-fb-25). We narrow the scope via the walker
|
||||||
|
// exclude list: exclude b.rs and c.rs explicitly.
|
||||||
|
narrow_cfg.workspace.exclude = vec!["b.rs".to_string(), "c.rs".to_string()];
|
||||||
|
|
||||||
|
// Run orphans-only reset.
|
||||||
|
let report = execute(ResetScope::OrphansOnly, &narrow_cfg)
|
||||||
|
.expect("orphans-only reset must succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
report.orphans_purged, 2,
|
||||||
|
"expected 2 orphans purged (b.rs + c.rs): {report:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut purged: Vec<String> = report
|
||||||
|
.purged_paths
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.0.clone())
|
||||||
|
.collect();
|
||||||
|
purged.sort();
|
||||||
|
assert_eq!(
|
||||||
|
purged,
|
||||||
|
vec!["b.rs".to_string(), "c.rs".to_string()],
|
||||||
|
"purged_paths must list b.rs and c.rs in sorted order: {purged:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// list docs must show only a.rs (and any pre-existing fixture files
|
||||||
|
// that are not excluded by the narrow config).
|
||||||
|
let doc_paths = list_doc_paths(&env);
|
||||||
|
// The narrow_cfg excludes b.rs + c.rs — they must no longer be in store.
|
||||||
|
assert!(
|
||||||
|
!doc_paths.iter().any(|p| p == "b.rs"),
|
||||||
|
"b.rs must be gone from store after orphans-only reset; got: {doc_paths:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!doc_paths.iter().any(|p| p == "c.rs"),
|
||||||
|
"c.rs must be gone from store after orphans-only reset; got: {doc_paths:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
doc_paths.iter().any(|p| p == "a.rs"),
|
||||||
|
"a.rs must still be in store; got: {doc_paths:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both b.rs and c.rs must still exist on the filesystem — no file
|
||||||
|
// removal is performed by orphans-only.
|
||||||
|
assert!(
|
||||||
|
b_path.exists(),
|
||||||
|
"b.rs must still be on disk after orphans-only reset"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
c_path.exists(),
|
||||||
|
"c.rs must still be on disk after orphans-only reset"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second reset must be idempotent: nothing left to purge.
|
||||||
|
let second = execute(ResetScope::OrphansOnly, &narrow_cfg)
|
||||||
|
.expect("second orphans-only reset must succeed");
|
||||||
|
assert_eq!(
|
||||||
|
second.orphans_purged, 0,
|
||||||
|
"second reset must be idempotent (orphans_purged == 0): {second:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
second.purged_paths.is_empty(),
|
||||||
|
"second reset purged_paths must be empty: {:?}",
|
||||||
|
second.purged_paths
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -275,6 +275,14 @@ enum Cmd {
|
|||||||
#[arg(long, group = "reset_scope")]
|
#[arg(long, group = "reset_scope")]
|
||||||
config_only: bool,
|
config_only: bool,
|
||||||
|
|
||||||
|
/// Purge stored docs that are outside the current walker scope
|
||||||
|
/// (config narrowing / removed sub-directory). No filesystem paths
|
||||||
|
/// are removed — this is purely a store-level reconciliation.
|
||||||
|
/// Filesystem existence is NOT checked; anything the current walker
|
||||||
|
/// would not visit is considered an orphan and removed from the store.
|
||||||
|
#[arg(long, group = "reset_scope")]
|
||||||
|
orphans_only: bool,
|
||||||
|
|
||||||
/// Skip the interactive confirm. Required in non-interactive
|
/// Skip the interactive confirm. Required in non-interactive
|
||||||
/// contexts (CI, pipes).
|
/// contexts (CI, pipes).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -1094,6 +1102,7 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
|||||||
data_only: _,
|
data_only: _,
|
||||||
vector_only,
|
vector_only,
|
||||||
config_only,
|
config_only,
|
||||||
|
orphans_only,
|
||||||
yes,
|
yes,
|
||||||
} => {
|
} => {
|
||||||
use kebab_app::ResetScope;
|
use kebab_app::ResetScope;
|
||||||
@@ -1107,11 +1116,50 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
|||||||
ResetScope::VectorOnly
|
ResetScope::VectorOnly
|
||||||
} else if *config_only {
|
} else if *config_only {
|
||||||
ResetScope::ConfigOnly
|
ResetScope::ConfigOnly
|
||||||
|
} else if *orphans_only {
|
||||||
|
ResetScope::OrphansOnly
|
||||||
} else {
|
} else {
|
||||||
ResetScope::DataOnly
|
ResetScope::DataOnly
|
||||||
};
|
};
|
||||||
|
|
||||||
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
|
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
|
||||||
|
|
||||||
|
if matches!(scope, ResetScope::OrphansOnly) {
|
||||||
|
// OrphansOnly: confirm UI shows orphan count + sample paths
|
||||||
|
// rather than on-disk directory sizes.
|
||||||
|
let orphan_paths = kebab_app::enumerate_orphans(&cfg)?;
|
||||||
|
|
||||||
|
if !*yes {
|
||||||
|
use std::io::IsTerminal;
|
||||||
|
if !std::io::stdin().is_terminal() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"reset --orphans-only is destructive and stdin is non-interactive — pass --yes to proceed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !confirm_orphans_only(&orphan_paths)? {
|
||||||
|
if !cli.quiet {
|
||||||
|
eprintln!("aborted.");
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let report = kebab_app::reset::execute(scope, &cfg)?;
|
||||||
|
if cli.json {
|
||||||
|
println!("{}", serde_json::to_string(&wire::wire_reset(&report))?);
|
||||||
|
} else {
|
||||||
|
if report.orphans_purged > 0 {
|
||||||
|
println!("orphans purged: {}", report.orphans_purged);
|
||||||
|
for p in &report.purged_paths {
|
||||||
|
println!(" - {}", p.0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("no orphaned docs found — store is already in sync with walker scope");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let paths = kebab_app::reset::enumerate_paths(scope, &cfg);
|
let paths = kebab_app::reset::enumerate_paths(scope, &cfg);
|
||||||
let bytes = kebab_app::reset::estimate_size_bytes(&paths);
|
let bytes = kebab_app::reset::estimate_size_bytes(&paths);
|
||||||
|
|
||||||
@@ -1450,6 +1498,46 @@ fn confirm_destructive(
|
|||||||
Ok(matches!(s.as_str(), "y" | "yes"))
|
Ok(matches!(s.as_str(), "y" | "yes"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Confirm prompt for `--orphans-only`: shows the orphan count + a
|
||||||
|
/// sample of up to 5 paths so the user knows what will be purged before
|
||||||
|
/// committing. No filesystem paths are removed — only store records.
|
||||||
|
fn confirm_orphans_only(
|
||||||
|
orphan_paths: &[kebab_core::WorkspacePath],
|
||||||
|
) -> anyhow::Result<bool> {
|
||||||
|
use std::io::Write;
|
||||||
|
let n = orphan_paths.len();
|
||||||
|
let mut out = std::io::stderr().lock();
|
||||||
|
|
||||||
|
if n == 0 {
|
||||||
|
writeln!(out, "no orphaned docs found — nothing to purge.")?;
|
||||||
|
out.flush()?;
|
||||||
|
// Nothing to do; treat as confirmed so the caller can emit the
|
||||||
|
// "no orphans" report without prompting.
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sample: Vec<&str> = orphan_paths
|
||||||
|
.iter()
|
||||||
|
.take(5)
|
||||||
|
.map(|p| p.0.as_str())
|
||||||
|
.collect();
|
||||||
|
let sample_str = sample.join(", ");
|
||||||
|
let ellipsis = if n > 5 { ", …" } else { "" };
|
||||||
|
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"Purge {n} stored doc(s) outside the current walker scope? (no filesystem paths removed)"
|
||||||
|
)?;
|
||||||
|
writeln!(out, " sample: {sample_str}{ellipsis}")?;
|
||||||
|
write!(out, "[y/N] ")?;
|
||||||
|
out.flush()?;
|
||||||
|
|
||||||
|
let mut line = String::new();
|
||||||
|
std::io::stdin().read_line(&mut line)?;
|
||||||
|
let s = line.trim().to_ascii_lowercase();
|
||||||
|
Ok(matches!(s.as_str(), "y" | "yes"))
|
||||||
|
}
|
||||||
|
|
||||||
/// p9-fb-35: human-friendly plain output for `kebab fetch`.
|
/// p9-fb-35: human-friendly plain output for `kebab fetch`.
|
||||||
fn render_fetch_plain(r: &kebab_core::FetchResult) {
|
fn render_fetch_plain(r: &kebab_core::FetchResult) {
|
||||||
println!("# {} ({})", r.doc_path.0, format_kind(r.kind));
|
println!("# {} ({})", r.doc_path.0, format_kind(r.kind));
|
||||||
|
|||||||
@@ -365,6 +365,8 @@ mod tests {
|
|||||||
scope: kebab_app::ResetScope::DataOnly,
|
scope: kebab_app::ResetScope::DataOnly,
|
||||||
removed_paths: vec![std::path::PathBuf::from("/tmp/x")],
|
removed_paths: vec![std::path::PathBuf::from("/tmp/x")],
|
||||||
embedding_rows_truncated: 0,
|
embedding_rows_truncated: 0,
|
||||||
|
orphans_purged: 0,
|
||||||
|
purged_paths: vec![],
|
||||||
};
|
};
|
||||||
let v = wire_reset(&r);
|
let v = wire_reset(&r);
|
||||||
assert_eq!(schema_of(&v), Some("reset_report.v1"));
|
assert_eq!(schema_of(&v), Some("reset_report.v1"));
|
||||||
|
|||||||
Reference in New Issue
Block a user