Skip to main content

code_kb_core/
sync.rs

1use rusqlite::Connection;
2use sha2::Digest;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use thiserror::Error;
6use tracing::{error, info, warn};
7
8use crate::queries;
9use crate::workspace::Workspace;
10
11#[derive(Debug, Error)]
12pub enum SyncError {
13    #[error(
14        "julie-extract not found. Put it on PATH or set JULIE_EXTRACT_BIN. Download: https://github.com/anortham/julie-extractors/releases"
15    )]
16    BinaryNotFound,
17    #[error("Extractor failed with status {0}: {1}")]
18    ExtractionFailed(i32, String),
19    #[error("IO error during synchronization: {0}")]
20    Io(#[from] std::io::Error),
21    #[error("Database error during synchronization: {0}")]
22    Db(#[from] rusqlite::Error),
23    #[error("Database error: {0}")]
24    DbInit(#[from] crate::db::DbError),
25    #[error("workspace traversal failed: {0}")]
26    Walk(#[from] ignore::Error),
27    #[error("extractor did not index the requested file: {0}")]
28    TargetNotIndexed(String),
29}
30
31pub const PINNED_JULIE_VERSION: &str = "3.9.0";
32
33/// Extraction level code-kb asks for on a new artifact: the call and variable-reference
34/// identifiers with their call metadata, without the annotations and complexity metrics of `full`,
35/// which code-kb never reads.
36pub const EXTRACTION_LEVEL: &str = "references";
37
38static CACHED_JULIE_BIN: std::sync::OnceLock<Option<(PathBuf, String)>> =
39    std::sync::OnceLock::new();
40static HEADER_SCAN_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
41
42/// Discovers the `julie-extract` binary and caches the result.
43///
44/// The first candidate whose version matches the pin wins. When none matches, the first
45/// candidate found is used and a warning is logged.
46pub fn find_julie_extract_binary() -> Option<PathBuf> {
47    installed_extractor().map(|(bin, _)| bin)
48}
49
50/// Version reported by the `julie-extract` binary in use, or the pin when none was found.
51pub fn installed_extractor_version() -> String {
52    installed_extractor()
53        .map(|(_, version)| version)
54        .unwrap_or_else(|| PINNED_JULIE_VERSION.to_string())
55}
56
57fn installed_extractor() -> Option<(PathBuf, String)> {
58    CACHED_JULIE_BIN
59        .get_or_init(|| {
60            let candidates: Vec<(PathBuf, String)> = julie_extract_candidates()
61                .into_iter()
62                .filter_map(|bin| extractor_version(&bin).map(|version| (bin, version)))
63                .collect();
64            let pinned = candidates
65                .iter()
66                .find(|(_, version)| version == PINNED_JULIE_VERSION)
67                .cloned();
68            if pinned.is_some() {
69                return pinned;
70            }
71            let first = candidates.into_iter().next()?;
72            tracing::warn!(
73                found = %first.1,
74                pinned = %PINNED_JULIE_VERSION,
75                binary = %first.0.display(),
76                "julie-extract version differs from pinned version; AST facts may drift"
77            );
78            Some(first)
79        })
80        .clone()
81}
82
83pub(crate) fn extractor_version(bin: &Path) -> Option<String> {
84    let output = Command::new(bin).arg("--version").output().ok()?;
85    let text = String::from_utf8_lossy(&output.stdout);
86    text.split_whitespace().last().map(str::to_string)
87}
88
89fn julie_extract_candidates() -> Vec<PathBuf> {
90    let exe_name = if cfg!(windows) {
91        "julie-extract.exe"
92    } else {
93        "julie-extract"
94    };
95    let mut candidates = Vec::new();
96
97    if let Ok(path_str) = std::env::var("JULIE_EXTRACT_BIN") {
98        candidates.push(PathBuf::from(path_str));
99    }
100
101    // The current directory is never searched: it may be another repository with its own
102    // older `.tools/julie-extract`. A source build finds the checkout's `.tools` above `target/`.
103    if let Some(exe_dir) = std::env::current_exe()
104        .ok()
105        .and_then(|p| p.parent().map(|d| d.to_path_buf()))
106    {
107        candidates.push(exe_dir.join(exe_name));
108        candidates.extend(
109            exe_dir
110                .ancestors()
111                .map(|dir| dir.join(".tools").join(exe_name)),
112        );
113    }
114
115    if let Ok(p) = which::which("julie-extract") {
116        candidates.push(p);
117    }
118
119    candidates
120        .into_iter()
121        .filter(|p| p.is_file())
122        .map(|p| crate::workspace::normalize_path(&p))
123        .collect()
124}
125
126/// Page cache of a julie-extract write, in SQLite's negative-KiB form. julie's own default is an
127/// eighth of physical memory up to 8 GiB, so a scan's memory grows with the index; on hermes-agent
128/// 1 GiB lowers the peak from 5.4 GB to 2.1 GB for 1.4% more scan time.
129const JULIE_CACHE_KIB: &str = "-1048576";
130
131/// Run julie-extract command with arguments. `JULIE_BULK_CACHE_KIB` in the environment overrides
132/// [`JULIE_CACHE_KIB`].
133pub fn execute_julie_extract(args: &[&str]) -> Result<String, SyncError> {
134    let bin = find_julie_extract_binary().ok_or(SyncError::BinaryNotFound)?;
135    let cache_kib =
136        std::env::var("JULIE_BULK_CACHE_KIB").unwrap_or_else(|_| JULIE_CACHE_KIB.to_string());
137
138    let mut attempts = 0;
139    loop {
140        let output = Command::new(&bin)
141            .args(args)
142            .env("JULIE_BULK_CACHE_KIB", &cache_kib)
143            .output()
144            .map_err(SyncError::Io)?;
145
146        if output.status.success() {
147            return Ok(String::from_utf8_lossy(&output.stdout).to_string());
148        }
149
150        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
151        // Retry on database lock / busy collisions with backoff
152        if (stderr.contains("database is locked")
153            || stderr.contains("busy")
154            || stderr.contains("SQLITE_BUSY"))
155            && attempts < 5
156        {
157            attempts += 1;
158            std::thread::sleep(std::time::Duration::from_millis(50 * (1 << attempts)));
159            continue;
160        }
161
162        let code = output.status.code().unwrap_or(-1);
163        return Err(SyncError::ExtractionFailed(code, stderr));
164    }
165}
166
167fn is_header_path(rel_path: &str) -> bool {
168    Path::new(rel_path)
169        .extension()
170        .is_some_and(|extension| extension.eq_ignore_ascii_case("h"))
171}
172
173/// Tier 1 & Incremental Update: updates one file, except headers use a content-aware scan for language detection.
174pub fn update_file(workspace: &Workspace, db_path: &Path, rel_path: &str) -> Result<(), SyncError> {
175    if db_path.is_file() && is_header_path(rel_path) {
176        return scan_header_file(workspace, db_path, rel_path);
177    }
178
179    let root_str = workspace.canonical_root.to_string_lossy();
180    let db_str = db_path.to_string_lossy();
181
182    write_incrementally(
183        workspace,
184        db_path,
185        &[
186            "update", "--root", &root_str, "--db", &db_str, "--file", rel_path,
187        ],
188    )
189}
190
191/// Runs an extractor write into the existing index. julie-extract refuses one when another
192/// extractor build wrote the index (`fingerprint_mismatch`), so the whole index is rescanned in
193/// place instead. A forced scan commits atomically, so open readers never see a swapped file.
194fn write_incrementally(
195    workspace: &Workspace,
196    db_path: &Path,
197    args: &[&str],
198) -> Result<(), SyncError> {
199    match execute_julie_extract(args) {
200        Err(SyncError::ExtractionFailed(_, stderr)) if is_fingerprint_mismatch(&stderr) => {
201            warn!("Index was written by another julie-extract build; rescanning it");
202            scan_workspace(workspace, db_path, true)
203        }
204        Err(error) => Err(error),
205        Ok(_) => crate::db::link_impl_owners_path(db_path).map_err(|e| {
206            error!(db = %db_path.display(), "Linking impl blocks to their types failed: {e}");
207            e.into()
208        }),
209    }
210}
211
212fn is_fingerprint_mismatch(stderr: &str) -> bool {
213    stderr.contains("fingerprint_mismatch")
214}
215
216/// julie-extract cannot read the artifact, or cannot write it before a schema migration that it
217/// does not run, so only a new artifact works.
218fn needs_new_artifact(stderr: &str) -> bool {
219    stderr.contains("schema_incompatible") || stderr.contains("schema_migration_required")
220}
221
222fn scan_header_file(
223    workspace: &Workspace,
224    db_path: &Path,
225    rel_path: &str,
226) -> Result<(), SyncError> {
227    // ponytail: serializes header scans per process; use workspace-scoped locks if concurrent workspaces need throughput.
228    let _scan_lock = HEADER_SCAN_LOCK
229        .lock()
230        .unwrap_or_else(|poisoned| poisoned.into_inner());
231    if header_hash_matches_disk(workspace, db_path, rel_path)? {
232        return Ok(());
233    }
234    // Julie 3.5.0 classifies `.h` files as C in `update`, while a content-aware scan preserves C++ Qt facts.
235    scan_workspace(workspace, db_path, false)?;
236
237    if !header_hash_matches_disk(workspace, db_path, rel_path)? {
238        return Err(SyncError::TargetNotIndexed(rel_path.to_string()));
239    }
240
241    Ok(())
242}
243
244fn header_hash_matches_disk(
245    workspace: &Workspace,
246    db_path: &Path,
247    rel_path: &str,
248) -> Result<bool, SyncError> {
249    let stored_hash = {
250        let conn = crate::db::open_read_only(db_path)?;
251        queries::get_file(&conn, rel_path)
252            .map_err(|error| match error {
253                queries::QueryError::Sqlite(error) => SyncError::Db(error),
254                _ => SyncError::TargetNotIndexed(rel_path.to_string()),
255            })?
256            .map(|file| file.content_hash)
257    };
258    let Some(stored_hash) = stored_hash else {
259        return Ok(false);
260    };
261    let disk_bytes = match std::fs::read(workspace.canonical_root.join(rel_path)) {
262        Ok(bytes) => bytes,
263        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
264        Err(error) => return Err(error.into()),
265    };
266
267    Ok(compute_content_hash_matches(&disk_bytes, &stored_hash))
268}
269
270/// Delete a file's extraction records from the database.
271pub fn delete_file(workspace: &Workspace, db_path: &Path, rel_path: &str) -> Result<(), SyncError> {
272    let root_str = workspace.canonical_root.to_string_lossy();
273    let db_str = db_path.to_string_lossy();
274
275    write_incrementally(
276        workspace,
277        db_path,
278        &[
279            "delete", "--root", &root_str, "--db", &db_str, "--file", rel_path,
280        ],
281    )
282}
283
284/// Initial or full scan to build/refresh the database.
285///
286/// An artifact the extractor cannot read (empty, torn, or older schema) is removed and
287/// rebuilt from scratch.
288pub fn scan_workspace(workspace: &Workspace, db_path: &Path, force: bool) -> Result<(), SyncError> {
289    let root_str = workspace.canonical_root.to_string_lossy();
290    let db_str = db_path.to_string_lossy();
291    let own_pid = std::process::id().to_string();
292
293    ensure_index_dir(db_path)?;
294    let excludes = write_hard_excludes()?;
295    let excludes_str = excludes.path().to_string_lossy();
296
297    let scan_args = |new_artifact: bool| {
298        let mut args = vec![
299            "scan",
300            "--root",
301            &*root_str,
302            "--db",
303            &*db_str,
304            "--ignore-file",
305            &*excludes_str,
306        ];
307        if new_artifact {
308            args.extend(["--level", EXTRACTION_LEVEL]);
309        }
310        if cfg!(unix) {
311            args.extend(["--parent-pid", &*own_pid]);
312        }
313        if force {
314            args.push("--force");
315        }
316        args
317    };
318
319    match execute_julie_extract(&scan_args(!db_path.exists())) {
320        Ok(_) => {}
321        Err(SyncError::ExtractionFailed(_, stderr))
322            if needs_new_artifact(&stderr) && db_path.exists() =>
323        {
324            warn!("Extractor cannot use the existing artifact; rebuilding from scratch");
325            remove_artifact_files(db_path)?;
326            execute_julie_extract(&scan_args(true))?;
327        }
328        Err(SyncError::ExtractionFailed(_, stderr))
329            if is_fingerprint_mismatch(&stderr) && !force && db_path.exists() =>
330        {
331            warn!("Index was written by another julie-extract build; rescanning it");
332            return scan_workspace(workspace, db_path, true);
333        }
334        Err(SyncError::ExtractionFailed(1, stderr))
335            if stderr.starts_with("partial") && db_path.exists() =>
336        {
337            warn!(
338                "Extractor skipped files it could not read; the next reconcile retries them:\n{stderr}"
339            );
340        }
341        Err(e) => return Err(e),
342    }
343
344    crate::db::ensure_fts_index_path(db_path).map_err(|e| {
345        error!(db = %db_path.display(), "FTS index preparation failed: {e}");
346        e
347    })?;
348
349    Ok(())
350}
351
352/// Rebuilds the index when it was written by a `julie-extract` other than the one in use or
353/// at another extraction level. Returns `true` when a rebuild ran. An index that a newer
354/// `julie-extract` wrote is kept, so an older binary never replaces it. The rebuild scans a
355/// fresh artifact because the extractor refuses to write into one with an older schema.
356pub fn ensure_index_matches_extractor(
357    workspace: &Workspace,
358    db_path: &Path,
359    extractor_version: &str,
360) -> Result<bool, SyncError> {
361    if !db_path.exists() {
362        return Ok(false);
363    }
364    // Servers that start together on one stale index must not each scan it into the same
365    // side file; the first rebuilds and the others wait, then find the index current.
366    let _rebuild_lock = std::fs::File::create(format!("{}.rebuild.lock", db_path.display()))
367        .and_then(|file| file.lock().map(|()| file))
368        .map_err(|e| warn!(db = %db_path.display(), "Index rebuild lock unavailable: {e}"))
369        .ok();
370    let Some((recorded, level)) = other_writer(db_path, extractor_version) else {
371        return Ok(false);
372    };
373    if let Some(newer) = newest_writer_version(db_path, &recorded)
374        .filter(|newest| version_is_older(extractor_version, newest))
375    {
376        warn!(
377            written_by = %newer,
378            installed = %extractor_version,
379            "Index was written by a newer julie-extract than the one in use; keeping it"
380        );
381        return Ok(false);
382    }
383    info!(
384        recorded = %recorded,
385        installed = %extractor_version,
386        level = %level,
387        wanted_level = %EXTRACTION_LEVEL,
388        "Index holds rows from a different julie-extract version or level; rebuilding"
389    );
390    rebuild_index(workspace, db_path)?;
391    Ok(true)
392}
393
394/// True when the index records a `julie-extract` version or extraction level other than the ones
395/// in use, or holds a file row that another version wrote. A hook checks this before it reads
396/// or writes the index, because the first write would rescan the whole index in place.
397pub fn index_written_by_other_extractor(db_path: &Path, extractor_version: &str) -> bool {
398    other_writer(db_path, extractor_version).is_some()
399}
400
401/// The recorded version and level of an index that [`index_written_by_other_extractor`] flags.
402fn other_writer(db_path: &Path, extractor_version: &str) -> Option<(String, String)> {
403    let metadata = |key: &str| -> Option<String> {
404        let conn = crate::db::open_read_only(db_path).ok()?;
405        conn.query_row(
406            "SELECT value FROM artifact_metadata WHERE key = ?1",
407            [key],
408            |r| r.get(0),
409        )
410        .ok()
411    };
412    let recorded = metadata("binary_version")?;
413    let level = metadata("index_level").unwrap_or_else(|| "full".to_string());
414    let current = recorded == extractor_version
415        && level == EXTRACTION_LEVEL
416        && !has_file_written_by_another_extractor(db_path, extractor_version);
417    (!current).then_some((recorded, level))
418}
419
420/// Scans into a side file and swaps it in only after the scan succeeds, so a failed rebuild
421/// leaves the previous index in place.
422fn rebuild_index(workspace: &Workspace, db_path: &Path) -> Result<(), SyncError> {
423    let rebuilt = PathBuf::from(format!("{}.rebuild", db_path.display()));
424    remove_artifact_files(&rebuilt)?;
425    let scanned = scan_workspace(workspace, &rebuilt, true).and_then(|()| {
426        let conn = crate::db::open_read_write(&rebuilt)?;
427        crate::db::checkpoint_truncate(&conn)?;
428        Ok(())
429    });
430    if let Err(e) = scanned {
431        let _ = remove_artifact_files(&rebuilt);
432        return Err(e);
433    }
434    remove_artifact_files(db_path)?;
435    for suffix in ["-wal", "-shm"] {
436        let _ = std::fs::remove_file(format!("{}{suffix}", rebuilt.display()));
437    }
438    std::fs::rename(&rebuilt, db_path)?;
439    Ok(())
440}
441
442/// The newest `julie-extract` version recorded for the artifact or any of its file rows.
443fn newest_writer_version(db_path: &Path, recorded: &str) -> Option<String> {
444    let revisions: Vec<String> = crate::db::open_read_only(db_path)
445        .ok()
446        .and_then(|conn| {
447            let mut stmt = conn
448                .prepare("SELECT DISTINCT binary_version FROM extraction_revisions")
449                .ok()?;
450            let versions = stmt
451                .query_map([], |r| r.get(0))
452                .ok()?
453                .filter_map(Result::ok)
454                .collect();
455            Some(versions)
456        })
457        .unwrap_or_default();
458    std::iter::once(recorded.to_string())
459        .chain(revisions)
460        .filter(|version| parse_version(version).is_some())
461        .max_by_key(|version| parse_version(version))
462}
463
464fn parse_version(version: &str) -> Option<Vec<u64>> {
465    version.split('.').map(|part| part.parse().ok()).collect()
466}
467
468/// False when either version is not dotted numbers, so an unreadable version never blocks a rebuild.
469fn version_is_older(candidate: &str, than: &str) -> bool {
470    matches!((parse_version(candidate), parse_version(than)), (Some(a), Some(b)) if a < b)
471}
472
473/// A `julie-extract update` run by a newer binary stamps its version into `artifact_metadata`
474/// but leaves every unchanged file's rows as the older binary wrote them, so the guard also
475/// checks the revision that last wrote each file.
476fn has_file_written_by_another_extractor(db_path: &Path, extractor_version: &str) -> bool {
477    let Ok(conn) = crate::db::open_read_only(db_path) else {
478        return false;
479    };
480    conn.query_row(
481        "SELECT 1 FROM files f
482         JOIN extraction_revisions r ON r.revision_id = f.last_revision_id
483         WHERE r.binary_version != ?1
484         LIMIT 1",
485        [extractor_version],
486        |_| Ok(true),
487    )
488    .unwrap_or(false)
489}
490
491fn remove_artifact_files(db_path: &Path) -> Result<(), SyncError> {
492    for suffix in ["", "-wal", "-shm"] {
493        let sidecar = PathBuf::from(format!("{}{suffix}", db_path.display()));
494        match std::fs::remove_file(&sidecar) {
495            Ok(()) => {}
496            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
497            Err(e) => return Err(e.into()),
498        }
499    }
500    Ok(())
501}
502
503/// Check if disk content matches the stored hash in the database.
504pub fn compute_content_hash_matches(disk_bytes: &[u8], stored_hash: &str) -> bool {
505    if stored_hash.starts_with("blake3:") {
506        let b3 = format!("blake3:{}", blake3::hash(disk_bytes).to_hex());
507        b3 == stored_hash
508    } else {
509        let b3 = blake3::hash(disk_bytes).to_hex().to_string();
510        if b3 == stored_hash {
511            return true;
512        }
513        let mut hasher = sha2::Sha256::new();
514        sha2::Digest::update(&mut hasher, disk_bytes);
515        let sha = hex::encode(sha2::Digest::finalize(hasher));
516        sha == stored_hash || format!("sha256:{sha}") == stored_hash
517    }
518}
519
520/// Tier 2: Just-In-Time Staleness Guard
521/// Checks if a file on disk has been modified since it was indexed in SQLite.
522/// If dirty or missing from index, re-extracts the file synchronously (< 5ms).
523pub fn ensure_fresh_file(
524    workspace: &Workspace,
525    db_path: &Path,
526    conn: &Connection,
527    rel_path: &str,
528) -> Result<bool, SyncError> {
529    let abs_path = workspace.canonical_root.join(rel_path);
530    let meta = match std::fs::metadata(&abs_path) {
531        Ok(meta) => meta,
532        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
533            let existing_file = queries::get_file(conn, rel_path).map_err(|e| match e {
534                queries::QueryError::Sqlite(err) => SyncError::Db(err),
535                _ => SyncError::Db(rusqlite::Error::QueryReturnedNoRows),
536            })?;
537            if existing_file.is_some() {
538                delete_file(workspace, db_path, rel_path)?;
539                return Ok(true);
540            }
541            return Ok(false);
542        }
543        Err(error) => return Err(SyncError::Io(error)),
544    };
545
546    if meta.is_dir() {
547        return Ok(false);
548    }
549
550    let disk_bytes = meta.len() as i64;
551
552    // Look up file in SQLite files table
553    let existing_file = queries::get_file(conn, rel_path).map_err(|e| match e {
554        queries::QueryError::Sqlite(err) => SyncError::Db(err),
555        _ => SyncError::Db(rusqlite::Error::QueryReturnedNoRows),
556    })?;
557
558    let is_dirty = match existing_file {
559        None => true, // Not yet in index
560        Some(f) => {
561            // Fast check: byte count difference
562            if f.content_bytes != disk_bytes {
563                true
564            } else {
565                let disk_content = std::fs::read(&abs_path)?;
566                !compute_content_hash_matches(&disk_content, &f.content_hash)
567            }
568        }
569    };
570
571    if is_dirty {
572        let stored_root: Option<String> = conn
573            .query_row(
574                "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
575                [],
576                |r| r.get(0),
577            )
578            .ok();
579        if let Some(r) = stored_root
580            && !crate::workspace::paths_equal(Path::new(&r), &workspace.canonical_root)
581        {
582            crate::db::retarget_artifact_root(db_path, &workspace.canonical_root)?;
583        }
584
585        update_file(workspace, db_path, rel_path)?;
586        return Ok(true);
587    }
588
589    Ok(false)
590}
591
592/// Cold-Start Reconciliation Report
593#[derive(Debug, Default)]
594pub struct ReconcileReport {
595    pub added: Vec<String>,
596    pub modified: Vec<String>,
597    pub deleted: Vec<String>,
598}
599
600fn extract_error_path(err: &ignore::Error) -> Option<&Path> {
601    match err {
602        ignore::Error::WithPath { path, .. } => Some(path.as_path()),
603        ignore::Error::WithDepth { err, .. } => extract_error_path(err),
604        ignore::Error::WithLineNumber { err, .. } => extract_error_path(err),
605        ignore::Error::Loop { ancestor, .. } => Some(ancestor.as_path()),
606        _ => None,
607    }
608}
609
610/// Cold-start background sweep: checks filesystem against SQLite `files` records.
611/// Streams disk checks and uses an in-memory SQLite index to eliminate repository-wide heap HashMaps.
612/// Creates a missing index. A git worktree copies its parent repository's index and
613/// reconciles it against the worktree files, which is much faster than a full scan;
614/// anything else runs a full scan. A copy that another `julie-extract` wrote is rebuilt.
615pub fn create_index(workspace: &Workspace, db_path: &Path) -> Result<(), SyncError> {
616    if let Some(parent_db) = parent_repository_db(&workspace.canonical_root)
617        && copy_parent_index(workspace, db_path, &parent_db)
618    {
619        ensure_index_matches_extractor(workspace, db_path, &installed_extractor_version())?;
620        return Ok(());
621    }
622    scan_workspace(workspace, db_path, false)
623}
624
625/// Writes the folders and file endings that code-kb never indexes to a temporary ignore file
626/// for julie. julie's own list lacks some of them, and a reconcile would delete what it scanned.
627fn write_hard_excludes() -> std::io::Result<tempfile::NamedTempFile> {
628    use std::io::Write;
629    let dirs = crate::workspace::HARD_EXCLUDED_DIRS
630        .iter()
631        .map(|dir| format!("{dir}/\n"));
632    let suffixes = crate::workspace::HARD_EXCLUDED_SUFFIXES
633        .iter()
634        .map(|suffix| format!("*{suffix}\n"));
635    let mut file = tempfile::NamedTempFile::new()?;
636    file.write_all(dirs.chain(suffixes).collect::<String>().as_bytes())?;
637    file.flush()?;
638    Ok(file)
639}
640
641/// Creates the index directory. A `.code-kb` directory also gets a `.gitignore` that
642/// hides it from git, so no project `.gitignore` edit is ever needed. Any other
643/// directory is left alone: a `*` ignore file there would hide the user's own files
644/// from the extractor and from git.
645pub(crate) fn ensure_index_dir(db_path: &Path) -> std::io::Result<()> {
646    let Some(dir) = db_path.parent() else {
647        return Ok(());
648    };
649    std::fs::create_dir_all(dir)?;
650    if dir.file_name().is_none_or(|name| name != ".code-kb") {
651        return Ok(());
652    }
653    let gitignore = dir.join(".gitignore");
654    if !gitignore.exists() {
655        std::fs::write(gitignore, "*\n")?;
656    }
657    Ok(())
658}
659
660fn parent_repository_db(root: &Path) -> Option<PathBuf> {
661    let git_marker = root.join(".git");
662    if !git_marker.is_file() {
663        return None;
664    }
665    let git_content = std::fs::read_to_string(&git_marker).ok()?;
666    let gitdir = git_content
667        .lines()
668        .find_map(|l| l.strip_prefix("gitdir:"))?
669        .trim();
670    let gitdir = Path::new(gitdir);
671    let mut probe = if gitdir.is_absolute() {
672        gitdir.to_path_buf()
673    } else {
674        root.join(gitdir)
675    };
676    while let Some(parent) = probe.parent() {
677        if parent == probe {
678            break;
679        }
680        if parent.join(".git").exists() {
681            let db = parent.join(".code-kb").join("artifact.db");
682            return db.exists().then_some(db);
683        }
684        probe = parent.to_path_buf();
685    }
686    None
687}
688
689fn copy_parent_index(workspace: &Workspace, db_path: &Path, parent_db: &Path) -> bool {
690    let flushed = crate::db::open_read_write(parent_db)
691        .map(|conn| crate::db::checkpoint_truncate(&conn).is_ok())
692        .unwrap_or(false);
693    if !flushed {
694        return false;
695    }
696    if ensure_index_dir(db_path).is_err() || std::fs::copy(parent_db, db_path).is_err() {
697        return false;
698    }
699    info!(from = %parent_db.display(), to = %db_path.display(), "Worktree fast-path: copied parent database, reconciling");
700    let reconciled = crate::db::retarget_artifact_root(db_path, &workspace.canonical_root)
701        .and_then(|_| crate::db::ensure_fts_index_path(db_path))
702        .is_ok()
703        && crate::db::open_read_only(db_path)
704            .ok()
705            .and_then(|conn| reconcile_offline_edits(workspace, db_path, &conn).ok())
706            .is_some();
707    if !reconciled {
708        warn!(
709            "Failed to retarget worktree database root; removing copied db and falling back to full scan"
710        );
711        let _ = std::fs::remove_file(db_path);
712    }
713    reconciled
714}
715
716pub fn reconcile_offline_edits(
717    workspace: &Workspace,
718    db_path: &Path,
719    conn: &Connection,
720) -> Result<ReconcileReport, SyncError> {
721    // Ensure artifact_metadata root_path matches workspace canonical root
722    // (self-heals worktrees where artifact.db was copied from a parent repository)
723    let stored_root: Option<String> = conn
724        .query_row(
725            "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
726            [],
727            |r| r.get(0),
728        )
729        .ok();
730    if let Some(r) = stored_root
731        && !crate::workspace::paths_equal(Path::new(&r), &workspace.canonical_root)
732    {
733        crate::db::retarget_artifact_root(db_path, &workspace.canonical_root)?;
734    }
735
736    let mut report = ReconcileReport::default();
737
738    // In-memory table to track paths seen on disk without allocating repository-wide HashMaps in heap
739    let temp_conn = Connection::open_in_memory().map_err(SyncError::Db)?;
740    temp_conn
741        .execute(
742            "CREATE TABLE _seen (path TEXT COLLATE NOCASE PRIMARY KEY)",
743            [],
744        )
745        .map_err(SyncError::Db)?;
746
747    let mut insert_seen_stmt = temp_conn
748        .prepare("INSERT OR IGNORE INTO _seen (path) VALUES (?1)")
749        .map_err(SyncError::Db)?;
750
751    let mut check_file_stmt = conn
752        .prepare("SELECT content_bytes, content_hash FROM files WHERE path = ?1")
753        .map_err(SyncError::Db)?;
754    let mut skipped_stmt = if crate::queries::has_table(conn, "skipped_files") {
755        Some(
756            conn.prepare(
757                "SELECT 1 FROM skipped_files WHERE path = ?1 AND content_bytes = ?2 AND mtime_ns = ?3",
758            )
759            .map_err(SyncError::Db)?,
760        )
761    } else {
762        None
763    };
764
765    let mut walker = ignore::WalkBuilder::new(&workspace.canonical_root);
766    walker
767        .standard_filters(true)
768        .hidden(false)
769        .add_custom_ignore_filename(".julieignore")
770        .add_custom_ignore_filename(".code-kb-ignore")
771        .add_custom_ignore_filename(".codekbignore")
772        .filter_entry(|entry| {
773            let name = entry.file_name().to_string_lossy();
774            !crate::workspace::is_hard_excluded(&name)
775        });
776    let walker = walker.build();
777
778    temp_conn
779        .execute("BEGIN TRANSACTION", [])
780        .map_err(SyncError::Db)?;
781
782    let mut unreadable_prefixes: Vec<String> = Vec::new();
783
784    for result in walker {
785        let entry = match result {
786            Ok(e) => e,
787            Err(e) => {
788                warn!("Reconciliation walker encountered error: {e}");
789                if let Some(path) = extract_error_path(&e) {
790                    let norm_path = dunce::simplified(path);
791                    if let Some(rel) =
792                        crate::workspace::strip_prefix_lossy(norm_path, &workspace.canonical_root)
793                    {
794                        unreadable_prefixes.push(crate::workspace::to_forward_slash(rel));
795                    }
796                }
797                continue;
798            }
799        };
800
801        if entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
802            let path = entry.path();
803            let norm_path = dunce::simplified(path);
804            if let Some(rel) =
805                crate::workspace::strip_prefix_lossy(norm_path, &workspace.canonical_root)
806            {
807                let rel_str = crate::workspace::to_forward_slash(rel);
808                if crate::workspace::is_hard_excluded(&rel_str) {
809                    continue;
810                }
811                let meta = match entry.metadata() {
812                    Ok(m) => m,
813                    Err(e) => {
814                        warn!("Failed to read metadata for '{}': {e}", path.display());
815                        let _ = insert_seen_stmt.execute([&rel_str]);
816                        continue;
817                    }
818                };
819                let bytes = meta.len() as i64;
820
821                insert_seen_stmt
822                    .execute([&rel_str])
823                    .map_err(SyncError::Db)?;
824
825                let mut rows = check_file_stmt.query([&rel_str]).map_err(SyncError::Db)?;
826
827                if let Some(row) = rows.next().map_err(SyncError::Db)? {
828                    let indexed_bytes: i64 = row.get(0).map_err(SyncError::Db)?;
829                    let stored_hash: String = row.get(1).map_err(SyncError::Db)?;
830
831                    let is_modified = if indexed_bytes != bytes {
832                        true
833                    } else {
834                        match std::fs::read(path) {
835                            Ok(content) => !compute_content_hash_matches(&content, &stored_hash),
836                            Err(e) => {
837                                warn!(
838                                    "Failed to read '{}' for hash verification: {e}",
839                                    path.display()
840                                );
841                                false
842                            }
843                        }
844                    };
845
846                    if is_modified {
847                        report.modified.push(rel_str);
848                    }
849                } else if !skipped_before(skipped_stmt.as_mut(), &rel_str, bytes, mtime_ns(&meta))?
850                {
851                    report.added.push(rel_str);
852                }
853            }
854        }
855    }
856
857    temp_conn.execute("COMMIT", []).map_err(SyncError::Db)?;
858
859    let mut files_stmt = conn
860        .prepare("SELECT path FROM files")
861        .map_err(SyncError::Db)?;
862
863    let mut exists_seen_stmt = temp_conn
864        .prepare("SELECT 1 FROM _seen WHERE path = ?1")
865        .map_err(SyncError::Db)?;
866
867    let mut file_rows = files_stmt.query([]).map_err(SyncError::Db)?;
868    while let Some(row) = file_rows.next().map_err(SyncError::Db)? {
869        let indexed_path: String = row.get(0).map_err(SyncError::Db)?;
870
871        // If the indexed file belongs to an unreadable directory, preserve it
872        let in_unreadable_prefix = unreadable_prefixes.iter().any(|prefix| {
873            indexed_path == *prefix || indexed_path.starts_with(&format!("{prefix}/"))
874        });
875        if in_unreadable_prefix {
876            continue;
877        }
878
879        let mut seen_rows = exists_seen_stmt
880            .query([&indexed_path])
881            .map_err(SyncError::Db)?;
882        if seen_rows.next().map_err(SyncError::Db)?.is_none() {
883            report.deleted.push(indexed_path);
884        }
885    }
886
887    drop(file_rows);
888    drop(files_stmt);
889    drop(check_file_stmt);
890    drop(skipped_stmt);
891    drop(exists_seen_stmt);
892    drop(insert_seen_stmt);
893    drop(temp_conn);
894
895    let total_changes = report.added.len() + report.modified.len() + report.deleted.len();
896    if total_changes > 0 {
897        info!(
898            "Cold start reconciliation detected {} changes (+{}, ~{}, -{})",
899            total_changes,
900            report.added.len(),
901            report.modified.len(),
902            report.deleted.len()
903        );
904
905        let changed_paths = report.added.iter().chain(&report.modified);
906        let changed_headers = changed_paths.clone().any(|path| is_header_path(path));
907        if total_changes > 50 || changed_headers {
908            scan_workspace(workspace, db_path, false)?;
909            remember_skipped_files(workspace, db_path, conn, changed_paths)?;
910        } else {
911            let mut updated: Vec<&String> = Vec::new();
912            for added in &report.added {
913                match update_file(workspace, db_path, added) {
914                    Ok(()) => updated.push(added),
915                    Err(e) => warn!("Failed to index added file '{}': {e}", added),
916                }
917            }
918            for modified in &report.modified {
919                match update_file(workspace, db_path, modified) {
920                    Ok(()) => updated.push(modified),
921                    Err(e) => warn!("Failed to index modified file '{}': {e}", modified),
922                }
923            }
924            for deleted in &report.deleted {
925                if let Err(e) = delete_file(workspace, db_path, deleted) {
926                    warn!("Failed to remove deleted file '{}': {e}", deleted);
927                }
928            }
929            remember_skipped_files(workspace, db_path, conn, updated.into_iter())?;
930        }
931    }
932
933    Ok(report)
934}
935
936/// A file the extractor reports as unsupported keeps no `files` row, so every reconcile
937/// would send it to the extractor again. `skipped_files` remembers such a file by path,
938/// size, and mtime until it changes.
939const SKIPPED_FILES_DDL: &str = "CREATE TABLE IF NOT EXISTS skipped_files (
940    path TEXT PRIMARY KEY, content_bytes INTEGER NOT NULL, mtime_ns INTEGER NOT NULL)";
941
942fn mtime_ns(meta: &std::fs::Metadata) -> i64 {
943    meta.modified()
944        .ok()
945        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
946        .map_or(0, |d| d.as_nanos() as i64)
947}
948
949fn skipped_before(
950    stmt: Option<&mut rusqlite::Statement<'_>>,
951    path: &str,
952    bytes: i64,
953    mtime: i64,
954) -> Result<bool, SyncError> {
955    let Some(stmt) = stmt else {
956        return Ok(false);
957    };
958    stmt.exists(rusqlite::params![path, bytes, mtime])
959        .map_err(SyncError::Db)
960}
961
962fn remember_skipped_files<'a>(
963    workspace: &Workspace,
964    db_path: &Path,
965    conn: &Connection,
966    paths: impl Iterator<Item = &'a String>,
967) -> Result<(), SyncError> {
968    let mut indexed = conn
969        .prepare("SELECT 1 FROM files WHERE path = ?1")
970        .map_err(SyncError::Db)?;
971    let mut writer: Option<Connection> = None;
972    for path in paths {
973        if indexed.exists([path]).map_err(SyncError::Db)? {
974            continue;
975        }
976        let Ok(meta) = std::fs::metadata(workspace.canonical_root.join(path)) else {
977            continue;
978        };
979        if writer.is_none() {
980            let conn = crate::db::open_read_write(db_path)?;
981            conn.execute_batch(SKIPPED_FILES_DDL)
982                .map_err(SyncError::Db)?;
983            writer = Some(conn);
984        }
985        writer
986            .as_ref()
987            .expect("opened above")
988            .execute(
989                "INSERT OR REPLACE INTO skipped_files (path, content_bytes, mtime_ns) VALUES (?1, ?2, ?3)",
990                rusqlite::params![path, meta.len() as i64, mtime_ns(&meta)],
991            )
992            .map_err(SyncError::Db)?;
993    }
994    Ok(())
995}
996
997#[cfg(test)]
998mod tests {
999    #[test]
1000    fn pinned_version_matches_the_pins_file() {
1001        let pins = include_str!("../../../scripts/julie-pins.json");
1002        assert!(pins.contains(&format!("\"version\": \"{}\"", super::PINNED_JULIE_VERSION)));
1003    }
1004
1005    use super::*;
1006
1007    #[test]
1008    fn test_find_julie_extract_binary() {
1009        let path = find_julie_extract_binary()
1010            .expect("julie-extract binary must be present for tests (see scripts/julie-pins.json)");
1011        assert!(path.exists(), "Discovered path must exist: {:?}", path);
1012    }
1013
1014    #[test]
1015    fn ensure_index_dir_writes_self_ignoring_gitignore() {
1016        let temp = crate::safe_tempdir();
1017        let db_path = temp.path().join(".code-kb").join("artifact.db");
1018        ensure_index_dir(&db_path).unwrap();
1019        let gitignore = db_path.parent().unwrap().join(".gitignore");
1020        assert_eq!(std::fs::read_to_string(&gitignore).unwrap(), "*\n");
1021        std::fs::write(&gitignore, "custom\n").unwrap();
1022        ensure_index_dir(&db_path).unwrap();
1023        assert_eq!(std::fs::read_to_string(&gitignore).unwrap(), "custom\n");
1024    }
1025
1026    #[test]
1027    fn ensure_index_dir_leaves_non_code_kb_directories_alone() {
1028        let temp = crate::safe_tempdir();
1029        let db_path = temp.path().join("test.db");
1030        ensure_index_dir(&db_path).unwrap();
1031        assert!(!temp.path().join(".gitignore").exists());
1032    }
1033
1034    #[test]
1035    fn test_reconcile_offline_edits_drive_case_mismatch() {
1036        let temp = crate::safe_tempdir();
1037        let db_path = temp.path().join("test.db");
1038        let conn = rusqlite::Connection::open(&db_path).unwrap();
1039        conn.execute_batch(
1040            "CREATE TABLE files (
1041                file_id TEXT PRIMARY KEY,
1042                path TEXT NOT NULL,
1043                language TEXT,
1044                content_hash TEXT,
1045                content_bytes INTEGER,
1046                line_count INTEGER,
1047                indexed_at TEXT
1048            );
1049            CREATE TABLE symbols (
1050                symbol_id TEXT PRIMARY KEY,
1051                file_id TEXT,
1052                path TEXT NOT NULL
1053            );",
1054        )
1055        .unwrap();
1056
1057        // Create a real file on disk
1058        let src_dir = temp.path().join("src");
1059        std::fs::create_dir_all(&src_dir).unwrap();
1060        let file_path = src_dir.join("main.rs");
1061        let content = "fn main() {}\n";
1062        std::fs::write(&file_path, content).unwrap();
1063
1064        let hash = sha2::Sha256::digest(content.as_bytes());
1065        let hash_hex = hex::encode(hash);
1066
1067        conn.execute(
1068            "INSERT INTO files VALUES ('f1', 'src/main.rs', 'rust', ?1, ?2, 1, '2026-09-14T00:00:00Z')",
1069            rusqlite::params![hash_hex, content.len() as i64],
1070        )
1071        .unwrap();
1072
1073        #[allow(unused_mut)]
1074        let mut ws = Workspace::new(temp.path().to_path_buf());
1075        #[cfg(windows)]
1076        {
1077            let root_str = ws.canonical_root.to_string_lossy().to_string();
1078            if let Some(first_char) = root_str.chars().next() {
1079                let flipped = if first_char.is_ascii_uppercase() {
1080                    first_char.to_ascii_lowercase()
1081                } else {
1082                    first_char.to_ascii_uppercase()
1083                };
1084                let altered_root = format!("{}{}", flipped, &root_str[1..]);
1085                ws.canonical_root = PathBuf::from(altered_root);
1086            }
1087        }
1088
1089        let report = reconcile_offline_edits(&ws, &db_path, &conn).unwrap();
1090        assert!(
1091            report.deleted.is_empty(),
1092            "Files should not be marked deleted due to drive casing difference: {:?}",
1093            report.deleted
1094        );
1095    }
1096}