Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.develop.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ Never `unwrap_or_default()` a store error on a search path — "no results" and

## Changelog highlights (recent)

- **v1.7.3 (pending)** — serve self-heals its registry: a repo whose path is gone and unrelocatable is unregistered at startup and on every status refresh, no more manual `codesearch index prune`
- **v1.6.0** — status CPU = whole serve process tree (incl. scip/dotnet children) instead of the serve process alone
- **v1.6.0** — Razor refs mapped to source: helper emits `GetMappedLineSpan()` paths/lines (#line → .razor/.cshtml), SCIP stamp split (`SCIP_INDEX_BUILDER_VERSION`) so symbol rebuilds skip the text re-embed wave
- **v1.6.0** — FTS commit-failure hardening: per-file transient redrive, crash-safe vector rollback, mixed-generation sweep in refresh, stale note on get_chunk
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ finalized in place with a date — no renaming/migration step needed.

- **Repos registered before identity capture now join their worktree family after a serve restart.** Startup backfills the missing git common-dir identities for every registered repo (previously only group members were backfilled during group resolution), so the TUI family tree groups all worktrees under their real main even when the main predates identity capture.

- **Stale registrations now disappear on their own.** Serve unregisters a repo whose stored path is gone and unrelocatable — at startup, and again on every background status refresh — instead of keeping it listed forever as a `not indexed` ghost until someone ran `codesearch index prune` by hand. Moved repos are still relocated first via their git-remote identity, and the manual verb stays available for the same sweep while serve is not running.

- **The serve TUI no longer decays into unreadable columns, and worktree families render as a tree.** The family note ("worktree of …", up to ~40 chars) was budgeted into the alias column, so on busy fleets the alias column overflowed and ratatui clipped every column from the left. The note now renders in its own dim "Worktree" column (capped at 30 chars with ellipsis), long aliases middle-ellipsis instead of clipping, and the column-width budget accounts for the lock column and ratatui's inter-column spacing. Worktrees now group under their main tree with a `|` indent (families anchor at the main's position; orphans stay indented at the tail), the note text is the bare main alias, and `groups list` renders `alias (main)`. Also fixes main-tree election when the main repo was registered before identity capture existed (identity-less mains now win the exact `.git`-path match over a shortest-path worktree).

## [1.7.2] - 2026-10-27
Expand Down
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,22 +410,29 @@ codesearch index rm /path/to/my-project
# List registered repos
codesearch index list

# Clean up stale entries (relocates moved repos, drops the rest)
# Clean up stale entries (relocates moved repos, drops the rest;
# serve also does this itself at startup and on every status refresh)
codesearch index prune
```

Serve reads `~/.codesearch/repos.json` on startup and manages all registered repos.

#### Moved or renamed repositories
#### Moved, renamed or deleted repositories

If you rename or move a registered folder, serve does **not** crash. On startup
it tries to **relocate** each missing repo automatically: it captures every
repo's git remote (`remote.origin.url`) at registration, and on a missing path
it scans nearby folders (bounded depth, override with
`CODESEARCH_RELOCATE_MAX_DEPTH`, default `3`) for a git checkout with the same
remote. A single unambiguous match is rewritten into `repos.json`; otherwise the
entry is logged and skipped (never indexed against a dead path). Run
`codesearch index prune` to relocate what can be relocated and drop the rest.
entry is logged and skipped (never indexed against a dead path).

When a path is unrecoverable, serve now **unregisters it by itself** — at
startup, and again on every background status refresh — so a deleted repo or
worktree stops showing up as a `not indexed` ghost on its own (worktree deletion
fires no git hook, and `rm -rf` bypasses every observer, so serve reconciles
its own registry instead). `codesearch index prune` stays available as the
manual trigger for the same sweep, e.g. while serve is not running.

A hand-edited `repos.json` is also tolerated: empty entries, orphaned metadata,
and group references to unknown repos are cleaned up on load rather than
Expand Down
54 changes: 34 additions & 20 deletions src/serve/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1745,14 +1745,16 @@ impl ServeState {
);
}

/// Reconcile registered repo paths against the filesystem before warmup.
/// Reconcile registered repo paths against the filesystem.
///
/// For each alias whose stored path no longer exists (folder renamed/moved),
/// attempt a best-effort git-identity relocation and rewrite `repos.json`.
/// When relocation fails the entry is left in place and merely logged — it is
/// skipped safely at warmup and never crashes serve. Explicit cleanup of
/// unrecoverable entries is available via `codesearch index prune`.
pub(crate) fn reconcile_all_paths(self: &Arc<Self>) {
/// For each alias whose stored path no longer exists (folder renamed/moved,
/// or a worktree deleted while the hub keeps running), attempt a best-effort
/// git-identity relocation. What cannot be relocated is unregistered, its
/// in-memory state evicted, and `repos.json` rewritten. Boot warmup has its
/// own prune, but a long-lived serve never reboots — without this pass a
/// deleted worktree ghosted `status kind=projects` forever. Explicit manual
/// cleanup remains available via `codesearch index prune`.
pub(crate) fn reconcile_all_paths(&self) {
let aliases = self.aliases();
if aliases.is_empty() {
return;
Expand All @@ -1766,27 +1768,30 @@ impl ServeState {
}
};

let (relocated, unresolved) = config.relocate_missing();
let (relocated, removed) = config.prune_stale();

for (alias, new_path) in &relocated {
info!("reconcile: relocated '{}' → {}", alias, new_path.display());
}
for alias in &unresolved {
let missing = config
.repos
.get(alias)
.map(|p| p.display().to_string())
.unwrap_or_default();
warn!(
"reconcile: '{}' path missing ({}); skipping — \
run `codesearch index prune` to remove it",
alias, missing
for alias in &removed {
info!(
"reconcile: unregistered stale '{}' (path no longer exists)",
alias
);
// Same eviction shape as reload_if_changed: fire the Write task's
// cancel token while removing its RepoState, then detach the FSW
// handle so a draining task never races the config swap. The DB
// dir lived inside the vanished tree — nothing left to delete.
if let Some((_, RepoState::Write { cancel_token, .. })) = self.repos.remove(alias) {
cancel_token.cancel();
}
self.last_access.remove(alias);
self.fsw_tasks.remove(alias);
}

if !relocated.is_empty() {
if !relocated.is_empty() || !removed.is_empty() {
if let Err(e) = self.persist_config(&config) {
warn!("reconcile: failed to persist relocated paths: {}", e);
warn!("reconcile: failed to persist reconciled registry: {}", e);
}
}
}
Expand Down Expand Up @@ -4059,6 +4064,15 @@ impl ServeState {
let repos = self.repo_statuses_lightweight();
let active_sessions = self.active_session_count();

// Periodic registry heal: this pass is the only recurring
// reconciliation in a long-lived serve (boot warmup runs once), and
// it already runs on the blocking pool. It runs AFTER the status
// pass on purpose: reconcile's aliases() may trigger a config
// reload, and the statuses above must measure the config as-was —
// the order the read endpoints implicitly rely on. Cheap when
// nothing moved: one exists() probe per registered alias.
self.reconcile_all_paths();

// CPU usage — serve process PLUS all its descendants. The SCIP
// helpers and their dotnet children do the heavy lifting during a
// rebuild wave; measuring the serve process alone reads near-zero
Expand Down
63 changes: 63 additions & 0 deletions src/serve/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1581,6 +1581,69 @@ fn persist_config_honors_override_path() {
);
}

/// A worktree deleted while serve runs must vanish from the registry on the
/// next reconcile pass. Boot warmup prunes once, so in a long-lived hub the
/// dead alias used to ghost `status kind=projects` forever (the old reconcile
/// kept the entry and only logged "run index prune").
#[test]
fn reconcile_all_paths_unregisters_vanished_repos() {
let tmp = tempfile::tempdir().unwrap();
let config_file = tmp.path().join("repos.json");
let gone = tmp.path().join("gone-repo");
let alive = tmp.path().join("alive-repo");
std::fs::create_dir(&gone).unwrap();
std::fs::create_dir(&alive).unwrap();

let state = std::sync::Arc::new(ServeState::new(
ReposConfig::default(),
Some(config_file.clone()),
));
{
let mut cfg = state.config.write().unwrap();
cfg.register_with_alias(gone.clone(), Some("gone-repo".to_string()))
.unwrap();
cfg.register_with_alias(alive.clone(), Some("alive-repo".to_string()))
.unwrap();
state.persist_config(&cfg).unwrap();
}
std::fs::remove_dir(&gone).unwrap();

// Seed in-memory Write state for the vanished alias so the eviction
// branch (token cancel + map removal + FSW handle detach) is executed,
// not just the config unregister.
let gone_db = tmp.path().join("gone-db");
let stores = std::sync::Arc::new(SharedStores::new(&gone_db, 2).expect("fixture stores"));
let token = CancellationToken::new();
state.repos.insert(
"gone-repo".to_string(),
RepoState::Write {
stores,
index_manager: None,
cancel_token: token.clone(),
},
);

state.reconcile_all_paths();

let on_disk = ReposConfig::load_from(&config_file).unwrap();
assert!(
!on_disk.repos.contains_key("gone-repo"),
"vanished repo must be unregistered and persisted"
);
assert!(
on_disk.repos.contains_key("alive-repo"),
"live repo must survive the reconcile pass"
);
assert!(
state.repos.get("gone-repo").is_none(),
"vanished alias must be evicted from memory"
);
assert!(
token.is_cancelled(),
"the Write task's cancel token must be cancelled"
);
}

#[test]
fn config_reload_picks_up_new_alias() {
let tmp = tempfile::tempdir().unwrap();
Expand Down
Loading