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