Skip to main content

lean_ctx/core/
project_hash.rs

1use std::hash::{Hash, Hasher};
2use std::path::Path;
3
4use lean_ctx_fs::stable_hash::StableHasher;
5
6/// Computes a composite hash from the project root path and any detected
7/// project identity markers (git remote, manifest file, etc.).
8///
9/// This prevents hash collisions when different projects share the same
10/// mount path (e.g. Docker volumes at `/workspace`).
11///
12/// The hash names the project's stores on disk, so it uses
13/// [`StableHasher`]: the same algorithm the engine has used since v3.3.2,
14/// but fixed in code instead of borrowed from std's unspecified
15/// `DefaultHasher`, so a toolchain update cannot move the stores.
16pub fn hash_project_root(root: &str) -> String {
17    // Normalize the path separator/casing first so the SAME directory always
18    // produces the SAME hash regardless of which interface resolved it. On
19    // Windows the MCP server reports forward slashes (`D:/repo`) while the CLI
20    // reports backslashes (`D:\repo`); without normalization these hash to two
21    // different project stores and facts written via one are invisible to the
22    // other (issue #325). `normalize_tool_path` is a no-op for clean POSIX
23    // paths, so non-Windows hashes are unaffected.
24    let root = crate::core::pathutil::normalize_tool_path(root);
25    let mut hasher = StableHasher::new();
26    root.hash(&mut hasher);
27
28    if let Some(identity) = identity_at(&root, Worktrees::Keep) {
29        identity.hash(&mut hasher);
30    }
31
32    format!("{:016x}", hasher.finish())
33}
34
35/// Legacy path-only hash used before v3.3.2.
36/// Kept for auto-migration from old knowledge directories.
37pub(crate) fn hash_path_only(root: &str) -> String {
38    let root = crate::core::pathutil::normalize_tool_path(root);
39    let mut hasher = StableHasher::new();
40    root.hash(&mut hasher);
41    format!("{:016x}", hasher.finish())
42}
43
44/// Extracts a stable project identity string from well-known config files.
45///
46/// Checks (in priority order):
47///   1. `.git/config`   → remote "origin" URL
48///   2. `Cargo.toml`    → `[package] name`
49///   3. `package.json`  → `"name"` field
50///   4. `pyproject.toml`→ `[project] name`
51///   5. `go.mod`        → `module` path
52///   6. `composer.json` → `"name"` field
53///   7. `build.gradle`  / `build.gradle.kts` → existence as a marker
54///   8. `*.sln`         → first `.sln` filename
55///
56/// Returns `None` when no identity marker is found, in which case
57/// the hash falls back to path-only (same behaviour as pre-3.3.2).
58///
59/// A linked git worktree has the origin of its main checkout, so both are
60/// one project (Hive, handoff bundles).
61pub fn project_identity(root: &str) -> Option<String> {
62    identity_at(root, Worktrees::Follow)
63}
64
65/// `git:<origin>` of the checkout at `root` (a linked worktree reads its
66/// main checkout's), spelled exactly as [`project_identity`] spells it when
67/// no `.lean-ctx-id` is present. `None` without an origin remote.
68pub fn origin_identity(root: &str) -> Option<String> {
69    git_remote_url(Path::new(root), Worktrees::Follow).map(|url| format!("git:{url}"))
70}
71
72/// Whether a linked worktree's origin is read from its main checkout.
73#[derive(Clone, Copy, PartialEq, Eq)]
74enum Worktrees {
75    Follow,
76    /// The local store hash keeps the identity it had before worktrees were
77    /// followed: a worktree's knowledge directory must not move.
78    Keep,
79}
80
81fn identity_at(root: &str, worktrees: Worktrees) -> Option<String> {
82    let root = Path::new(root);
83
84    // Explicit identity file — highest priority. Ideal for Docker containers
85    // where the mount path (/workspace) is reused across different projects.
86    // Users create `.lean-ctx-id` with a unique name to disambiguate.
87    if let Some(id) = explicit_identity_file(root) {
88        return Some(format!("explicit:{id}"));
89    }
90    if let Some(url) = git_remote_url(root, worktrees) {
91        return Some(format!("git:{url}"));
92    }
93    if let Some(name) = cargo_package_name(root) {
94        return Some(format!("cargo:{name}"));
95    }
96    if let Some(name) = npm_package_name(root) {
97        return Some(format!("npm:{name}"));
98    }
99    if let Some(name) = pyproject_name(root) {
100        return Some(format!("python:{name}"));
101    }
102    if let Some(module) = go_module(root) {
103        return Some(format!("go:{module}"));
104    }
105    if let Some(name) = composer_name(root) {
106        return Some(format!("composer:{name}"));
107    }
108    if let Some(name) = gradle_project(root) {
109        return Some(format!("gradle:{name}"));
110    }
111    if let Some(name) = dotnet_solution(root) {
112        return Some(format!("dotnet:{name}"));
113    }
114
115    None
116}
117
118/// Hashes computed from the *raw* (un-normalized) project root, as produced
119/// before issue #325 was fixed. Used purely to detect and migrate stores that
120/// were keyed by a platform-specific path separator (most importantly Windows
121/// backslash paths written by the CLI). Returns both the composite and the
122/// path-only variant. Empty when the raw path already normalizes to itself, so
123/// callers can skip migration on POSIX where no split ever occurred.
124pub(crate) fn legacy_unnormalized_hashes(root: &str) -> Vec<String> {
125    let normalized = crate::core::pathutil::normalize_tool_path(root);
126    if normalized == root {
127        return Vec::new();
128    }
129
130    let mut composite = StableHasher::new();
131    root.hash(&mut composite);
132    if let Some(identity) = identity_at(root, Worktrees::Keep) {
133        identity.hash(&mut composite);
134    }
135
136    let mut path_only = StableHasher::new();
137    root.hash(&mut path_only);
138
139    vec![
140        format!("{:016x}", composite.finish()),
141        format!("{:016x}", path_only.finish()),
142    ]
143}
144
145/// Copies all files from `old_hash` dir to `new_hash` dir when the composite
146/// hash differs from the legacy path-only hash.  Leaves the old directory
147/// intact so sibling projects sharing the same mount path can still migrate
148/// their own data independently.
149pub(crate) fn migrate_if_needed(old_hash: &str, new_hash: &str, project_root: &str) {
150    if old_hash == new_hash {
151        return;
152    }
153
154    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
155        return;
156    };
157
158    let old_dir = data_dir.join("knowledge").join(old_hash);
159    let new_dir = data_dir.join("knowledge").join(new_hash);
160
161    if !old_dir.exists() || new_dir.exists() {
162        return;
163    }
164
165    if !verify_ownership(&old_dir, project_root) {
166        return;
167    }
168
169    // Copied into a staging directory and renamed into place in one step: an
170    // interrupted copy must not leave a partial `new_dir`, which would stop
171    // every later attempt and lose the files not copied yet.
172    let staging = data_dir.join("migrations").join("knowledge-staging");
173    let migrated = std::fs::create_dir_all(&staging)
174        .and_then(|()| {
175            tempfile::Builder::new()
176                .prefix(new_hash)
177                .tempdir_in(&staging)
178        })
179        .map_err(|e| e.to_string())
180        .and_then(|staged| {
181            copy_dir_contents(&old_dir, staged.path())?;
182            std::fs::rename(staged.path(), &new_dir).map_err(|e| e.to_string())?;
183            let _ = staged.keep();
184            Ok(())
185        });
186    if let Err(e) = migrated {
187        tracing::error!("lean-ctx: knowledge migration failed: {e}");
188    }
189}
190
191#[cfg(test)]
192thread_local! {
193    /// Test-only: the next copy on this thread fails after its first file.
194    static COPY_FAILS_AFTER_FIRST_FILE: std::cell::Cell<bool> =
195        const { std::cell::Cell::new(false) };
196}
197
198// ---------------------------------------------------------------------------
199// Identity detectors
200// ---------------------------------------------------------------------------
201
202fn explicit_identity_file(root: &Path) -> Option<String> {
203    let path = root.join(".lean-ctx-id");
204    let content = std::fs::read_to_string(path).ok()?;
205    let id = content.trim().to_string();
206    if id.is_empty() || id.len() > 256 {
207        return None;
208    }
209    Some(id)
210}
211
212fn git_remote_url(root: &Path, worktrees: Worktrees) -> Option<String> {
213    let config = match worktrees {
214        Worktrees::Follow => git_config_path(root)?,
215        Worktrees::Keep => root.join(".git").join("config"),
216    };
217    let content = std::fs::read_to_string(config).ok()?;
218
219    let mut in_origin = false;
220    for line in content.lines() {
221        let trimmed = line.trim();
222        if trimmed.starts_with('[') {
223            in_origin = trimmed == r#"[remote "origin"]"#;
224            continue;
225        }
226        if in_origin && let Some(url) = trimmed.strip_prefix("url") {
227            let url = url.trim_start_matches([' ', '=']);
228            let url = url.trim();
229            if !url.is_empty() {
230                return Some(normalize_git_url(url));
231            }
232        }
233    }
234    None
235}
236
237/// The git config of the checkout at `root`. In a linked worktree `.git` is
238/// a file (`gitdir: <common>/.git/worktrees/<name>`); the remotes live in
239/// the common directory its `commondir` names.
240fn git_config_path(root: &Path) -> Option<std::path::PathBuf> {
241    let dot_git = root.join(".git");
242    if dot_git.is_dir() {
243        return Some(dot_git.join("config"));
244    }
245    let pointer = std::fs::read_to_string(&dot_git).ok()?;
246    let gitdir = pointer
247        .lines()
248        .find_map(|line| line.strip_prefix("gitdir:"))?;
249    let gitdir = root.join(gitdir.trim());
250    let common = match std::fs::read_to_string(gitdir.join("commondir")) {
251        Ok(common) => gitdir.join(common.trim()),
252        Err(_) => gitdir,
253    };
254    Some(common.join("config"))
255}
256
257fn normalize_git_url(url: &str) -> String {
258    let url = url.trim_end_matches(".git");
259    let url = url
260        .strip_prefix("git@")
261        .map_or_else(|| url.to_string(), |s| s.replacen(':', "/", 1));
262    url.to_lowercase()
263}
264
265fn cargo_package_name(root: &Path) -> Option<String> {
266    extract_toml_value(&root.join("Cargo.toml"), "name", Some("[package]"))
267}
268
269fn npm_package_name(root: &Path) -> Option<String> {
270    extract_json_string_field(&root.join("package.json"), "name")
271}
272
273fn pyproject_name(root: &Path) -> Option<String> {
274    extract_toml_value(&root.join("pyproject.toml"), "name", Some("[project]"))
275        .or_else(|| extract_toml_value(&root.join("pyproject.toml"), "name", Some("[tool.poetry]")))
276}
277
278fn go_module(root: &Path) -> Option<String> {
279    let content = std::fs::read_to_string(root.join("go.mod")).ok()?;
280    let first = content.lines().next()?;
281    first.strip_prefix("module").map(|s| s.trim().to_string())
282}
283
284fn composer_name(root: &Path) -> Option<String> {
285    extract_json_string_field(&root.join("composer.json"), "name")
286}
287
288fn gradle_project(root: &Path) -> Option<String> {
289    let settings = root.join("settings.gradle");
290    let settings_kts = root.join("settings.gradle.kts");
291
292    let path = if settings.exists() {
293        settings
294    } else if settings_kts.exists() {
295        settings_kts
296    } else {
297        return None;
298    };
299
300    let content = std::fs::read_to_string(path).ok()?;
301    for line in content.lines() {
302        let trimmed = line.trim();
303        if let Some(rest) = trimmed.strip_prefix("rootProject.name") {
304            let rest = rest.trim_start_matches([' ', '=']);
305            let name = rest.trim().trim_matches(['\'', '"']);
306            if !name.is_empty() {
307                return Some(name.to_string());
308            }
309        }
310    }
311    None
312}
313
314fn dotnet_solution(root: &Path) -> Option<String> {
315    let entries = std::fs::read_dir(root).ok()?;
316    for entry in entries.flatten() {
317        if let Some(ext) = entry.path().extension()
318            && ext == "sln"
319        {
320            return entry
321                .path()
322                .file_stem()
323                .and_then(|s| s.to_str())
324                .map(String::from);
325        }
326    }
327    None
328}
329
330// ---------------------------------------------------------------------------
331// TOML / JSON helpers (lightweight, no extra deps)
332// ---------------------------------------------------------------------------
333
334fn extract_toml_value(path: &Path, key: &str, section: Option<&str>) -> Option<String> {
335    let content = std::fs::read_to_string(path).ok()?;
336    let mut in_section = section.is_none();
337    let target_section = section.unwrap_or("");
338
339    for line in content.lines() {
340        let trimmed = line.trim();
341
342        if trimmed.starts_with('[') {
343            in_section = trimmed == target_section;
344            continue;
345        }
346
347        if in_section && let Some(rest) = trimmed.strip_prefix(key) {
348            let rest = rest.trim_start();
349            if let Some(rest) = rest.strip_prefix('=') {
350                let val = rest.trim().trim_matches('"');
351                if !val.is_empty() {
352                    return Some(val.to_string());
353                }
354            }
355        }
356    }
357    None
358}
359
360fn extract_json_string_field(path: &Path, field: &str) -> Option<String> {
361    let content = std::fs::read_to_string(path).ok()?;
362    let needle = format!("\"{field}\"");
363    for line in content.lines() {
364        let trimmed = line.trim();
365        if let Some(rest) = trimmed.strip_prefix(&needle) {
366            let rest = rest.trim_start_matches([' ', ':']);
367            let val = rest.trim().trim_start_matches('"');
368            if let Some(end) = val.find('"') {
369                let name = &val[..end];
370                if !name.is_empty() {
371                    return Some(name.to_string());
372                }
373            }
374        }
375    }
376    None
377}
378
379// ---------------------------------------------------------------------------
380// Migration helpers
381// ---------------------------------------------------------------------------
382
383fn verify_ownership(old_dir: &Path, project_root: &str) -> bool {
384    let knowledge_path = old_dir.join("knowledge.json");
385    let Ok(content) = std::fs::read_to_string(&knowledge_path) else {
386        return true;
387    };
388
389    let stored_root: Option<String> = serde_json::from_str::<serde_json::Value>(&content)
390        .ok()
391        .and_then(|v| v.get("project_root")?.as_str().map(String::from));
392
393    match stored_root {
394        Some(stored) if !stored.is_empty() => stored == project_root,
395        _ => true,
396    }
397}
398
399fn copy_dir_contents(src: &Path, dst: &Path) -> Result<(), String> {
400    std::fs::create_dir_all(dst).map_err(|e| e.to_string())?;
401
402    for entry in std::fs::read_dir(src).map_err(|e| e.to_string())?.flatten() {
403        let src_path = entry.path();
404        let dst_path = dst.join(entry.file_name());
405
406        if src_path.is_dir() {
407            copy_dir_contents(&src_path, &dst_path)?;
408        } else {
409            std::fs::copy(&src_path, &dst_path).map_err(|e| e.to_string())?;
410            #[cfg(test)]
411            if COPY_FAILS_AFTER_FIRST_FILE.with(|armed| armed.replace(false)) {
412                return Err("injected crash during the copy".into());
413            }
414        }
415    }
416    Ok(())
417}
418
419// ---------------------------------------------------------------------------
420// Tests
421// ---------------------------------------------------------------------------
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use std::fs;
427
428    #[test]
429    fn path_only_matches_legacy_behaviour() {
430        let h = hash_path_only("/workspace");
431        assert_eq!(h.len(), 16);
432        let h2 = hash_path_only("/workspace");
433        assert_eq!(h, h2);
434    }
435
436    #[test]
437    fn windows_slash_and_backslash_hash_identically() {
438        // Issue #325: the MCP server reports forward slashes while the CLI
439        // reports backslashes for the same Windows directory. Both must resolve
440        // to the same project hash so the knowledge store is not split.
441        assert_eq!(
442            hash_project_root(r"D:\repos\oref-examples"),
443            hash_project_root("D:/repos/oref-examples"),
444        );
445        assert_eq!(
446            hash_path_only(r"D:\repos\oref-examples"),
447            hash_path_only("D:/repos/oref-examples"),
448        );
449    }
450
451    #[test]
452    fn trailing_slash_does_not_split_hash() {
453        assert_eq!(
454            hash_project_root("/home/user/project/"),
455            hash_project_root("/home/user/project"),
456        );
457    }
458
459    /// Pinned: the store names already on users' disks (the hash of earlier
460    /// releases, written with std's `DefaultHasher`). A change here moves
461    /// every project's knowledge, archive and session stores.
462    #[test]
463    fn store_names_are_pinned() {
464        let root = "/nonexistent/lean-ctx-pin";
465        assert_eq!(hash_path_only(root), "828234413450efd8");
466        assert_eq!(hash_project_root(root), "828234413450efd8");
467        let mut composite = StableHasher::new();
468        root.hash(&mut composite);
469        "cargo:pinned".hash(&mut composite);
470        assert_eq!(format!("{:016x}", composite.finish()), "24e8039aa75448a6");
471    }
472
473    #[test]
474    fn legacy_unnormalized_hashes_empty_for_clean_posix() {
475        // POSIX paths already normalize to themselves: no split ever occurred,
476        // so there is nothing to migrate.
477        {
478            let found = &legacy_unnormalized_hashes("/home/user/project");
479            assert!(found.is_empty(), "{found:?}");
480        }
481    }
482
483    #[test]
484    fn legacy_unnormalized_hashes_present_for_backslash_path() {
485        // A backslash path normalizes to a different string, so the pre-fix
486        // (raw) hashes are offered for migration.
487        let legacy = legacy_unnormalized_hashes(r"D:\repos\oref-examples");
488        assert_eq!(legacy.len(), 2, "composite + path-only raw hashes");
489        // The raw path-only hash must differ from the normalized hash.
490        assert!(!legacy.contains(&hash_project_root(r"D:\repos\oref-examples")));
491    }
492
493    #[test]
494    fn composite_differs_when_identity_present() {
495        let dir = tempfile::tempdir().unwrap();
496        let root = dir.path().to_str().unwrap();
497
498        let old = hash_path_only(root);
499        let no_identity = hash_project_root(root);
500        assert_eq!(old, no_identity, "without identity, hashes must match");
501
502        fs::create_dir_all(dir.path().join(".git")).unwrap();
503        fs::write(
504            dir.path().join(".git").join("config"),
505            "[remote \"origin\"]\n\turl = git@github.com:user/my-repo.git\n",
506        )
507        .unwrap();
508
509        let with_identity = hash_project_root(root);
510        assert_ne!(old, with_identity, "identity must change hash");
511    }
512
513    #[test]
514    fn docker_collision_avoided() {
515        let dir_a = tempfile::tempdir().unwrap();
516        let dir_b = tempfile::tempdir().unwrap();
517
518        let shared_path = "/workspace";
519
520        fs::create_dir_all(dir_a.path().join(".git")).unwrap();
521        fs::write(
522            dir_a.path().join(".git").join("config"),
523            "[remote \"origin\"]\n\turl = git@github.com:user/repo-a.git\n",
524        )
525        .unwrap();
526
527        fs::create_dir_all(dir_b.path().join(".git")).unwrap();
528        fs::write(
529            dir_b.path().join(".git").join("config"),
530            "[remote \"origin\"]\n\turl = git@github.com:user/repo-b.git\n",
531        )
532        .unwrap();
533
534        let hash_a = {
535            let mut hasher = StableHasher::new();
536            shared_path.hash(&mut hasher);
537            let id = project_identity(dir_a.path().to_str().unwrap()).unwrap();
538            id.hash(&mut hasher);
539            format!("{:016x}", hasher.finish())
540        };
541        let hash_b = {
542            let mut hasher = StableHasher::new();
543            shared_path.hash(&mut hasher);
544            let id = project_identity(dir_b.path().to_str().unwrap()).unwrap();
545            id.hash(&mut hasher);
546            format!("{:016x}", hasher.finish())
547        };
548
549        assert_ne!(
550            hash_a, hash_b,
551            "different repos at same path must produce different hashes"
552        );
553    }
554
555    #[test]
556    fn git_url_normalization() {
557        assert_eq!(
558            normalize_git_url("git@github.com:User/Repo.git"),
559            "github.com/user/repo"
560        );
561        assert_eq!(
562            normalize_git_url("https://github.com/User/Repo.git"),
563            "https://github.com/user/repo"
564        );
565        assert_eq!(
566            normalize_git_url("git@gitlab.com:org/sub/project.git"),
567            "gitlab.com/org/sub/project"
568        );
569    }
570
571    #[test]
572    fn identity_from_cargo_toml() {
573        let dir = tempfile::tempdir().unwrap();
574        fs::write(
575            dir.path().join("Cargo.toml"),
576            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
577        )
578        .unwrap();
579
580        let id = project_identity(dir.path().to_str().unwrap());
581        assert_eq!(id, Some("cargo:my-crate".into()));
582    }
583
584    #[test]
585    fn identity_from_package_json() {
586        let dir = tempfile::tempdir().unwrap();
587        fs::write(
588            dir.path().join("package.json"),
589            "{\n  \"name\": \"@scope/my-app\",\n  \"version\": \"1.0.0\"\n}\n",
590        )
591        .unwrap();
592
593        let id = project_identity(dir.path().to_str().unwrap());
594        assert_eq!(id, Some("npm:@scope/my-app".into()));
595    }
596
597    #[test]
598    fn identity_from_pyproject() {
599        let dir = tempfile::tempdir().unwrap();
600        fs::write(
601            dir.path().join("pyproject.toml"),
602            "[project]\nname = \"my-python-lib\"\nversion = \"2.0\"\n",
603        )
604        .unwrap();
605
606        let id = project_identity(dir.path().to_str().unwrap());
607        assert_eq!(id, Some("python:my-python-lib".into()));
608    }
609
610    #[test]
611    fn identity_from_poetry_pyproject() {
612        let dir = tempfile::tempdir().unwrap();
613        fs::write(
614            dir.path().join("pyproject.toml"),
615            "[tool.poetry]\nname = \"poetry-app\"\nversion = \"1.0\"\n",
616        )
617        .unwrap();
618
619        let id = project_identity(dir.path().to_str().unwrap());
620        assert_eq!(id, Some("python:poetry-app".into()));
621    }
622
623    #[test]
624    fn identity_from_go_mod() {
625        let dir = tempfile::tempdir().unwrap();
626        fs::write(
627            dir.path().join("go.mod"),
628            "module github.com/user/myservice\n\ngo 1.21\n",
629        )
630        .unwrap();
631
632        let id = project_identity(dir.path().to_str().unwrap());
633        assert_eq!(id, Some("go:github.com/user/myservice".into()));
634    }
635
636    #[test]
637    fn identity_from_composer() {
638        let dir = tempfile::tempdir().unwrap();
639        fs::write(
640            dir.path().join("composer.json"),
641            "{\n  \"name\": \"vendor/my-php-lib\"\n}\n",
642        )
643        .unwrap();
644
645        let id = project_identity(dir.path().to_str().unwrap());
646        assert_eq!(id, Some("composer:vendor/my-php-lib".into()));
647    }
648
649    #[test]
650    fn identity_from_gradle() {
651        let dir = tempfile::tempdir().unwrap();
652        fs::write(
653            dir.path().join("settings.gradle"),
654            "rootProject.name = 'my-java-app'\n",
655        )
656        .unwrap();
657
658        let id = project_identity(dir.path().to_str().unwrap());
659        assert_eq!(id, Some("gradle:my-java-app".into()));
660    }
661
662    #[test]
663    fn identity_from_dotnet_sln() {
664        let dir = tempfile::tempdir().unwrap();
665        fs::write(dir.path().join("MyApp.sln"), "").unwrap();
666
667        let id = project_identity(dir.path().to_str().unwrap());
668        assert_eq!(id, Some("dotnet:MyApp".into()));
669    }
670
671    #[test]
672    fn identity_git_takes_priority_over_cargo() {
673        let dir = tempfile::tempdir().unwrap();
674        fs::create_dir_all(dir.path().join(".git")).unwrap();
675        fs::write(
676            dir.path().join(".git").join("config"),
677            "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n",
678        )
679        .unwrap();
680        fs::write(
681            dir.path().join("Cargo.toml"),
682            "[package]\nname = \"my-crate\"\n",
683        )
684        .unwrap();
685
686        let id = project_identity(dir.path().to_str().unwrap());
687        assert_eq!(id, Some("git:github.com/user/repo".into()));
688    }
689
690    /// A linked worktree as `git worktree add` lays it out: `.git` is a file
691    /// naming its admin directory, whose `commondir` leads to the config.
692    #[test]
693    fn a_linked_worktree_has_the_origin_of_its_main_checkout() {
694        let tmp = tempfile::tempdir().unwrap();
695        let main = tmp.path().join("main");
696        let admin = main.join(".git").join("worktrees").join("wt");
697        fs::create_dir_all(&admin).unwrap();
698        fs::write(
699            main.join(".git").join("config"),
700            "[core]\n\tbare = false\n[remote \"origin\"]\n\turl = git@github.com:yves/hive.git\n",
701        )
702        .unwrap();
703        fs::write(admin.join("commondir"), "../..\n").unwrap();
704        let worktree = tmp.path().join("wt");
705        fs::create_dir_all(&worktree).unwrap();
706        fs::write(
707            worktree.join(".git"),
708            format!("gitdir: {}\n", admin.display()),
709        )
710        .unwrap();
711        fs::write(worktree.join("Cargo.toml"), "[package]\nname = \"hive\"\n").unwrap();
712        let worktree = worktree.to_str().unwrap();
713
714        assert_eq!(
715            project_identity(worktree),
716            project_identity(main.to_str().unwrap())
717        );
718        assert_eq!(
719            project_identity(worktree),
720            Some("git:github.com/yves/hive".into())
721        );
722        // The local store keeps the hash it had: its knowledge stays put.
723        assert_eq!(
724            identity_at(worktree, Worktrees::Keep),
725            Some("cargo:hive".into())
726        );
727        // Hashed as `hash_project_root` sees it (macOS: /var → /private/var).
728        let mut before = StableHasher::new();
729        crate::core::pathutil::normalize_tool_path(worktree).hash(&mut before);
730        "cargo:hive".hash(&mut before);
731        assert_eq!(
732            hash_project_root(worktree),
733            format!("{:016x}", before.finish())
734        );
735    }
736
737    /// A relative `gitdir:` and an admin directory without `commondir`.
738    #[test]
739    fn a_relative_gitdir_without_commondir_reads_its_own_config() {
740        let tmp = tempfile::tempdir().unwrap();
741        let admin = tmp.path().join("modules").join("sub");
742        fs::create_dir_all(&admin).unwrap();
743        fs::write(
744            admin.join("config"),
745            "[remote \"origin\"]\n\turl = https://gitlab.example.com/team/sub.git\n",
746        )
747        .unwrap();
748        let checkout = tmp.path().join("sub");
749        fs::create_dir_all(&checkout).unwrap();
750        fs::write(checkout.join(".git"), "gitdir: ../modules/sub\n").unwrap();
751        assert_eq!(
752            project_identity(checkout.to_str().unwrap()),
753            Some("git:https://gitlab.example.com/team/sub".into())
754        );
755    }
756
757    #[test]
758    fn no_identity_for_empty_dir() {
759        let dir = tempfile::tempdir().unwrap();
760        let id = project_identity(dir.path().to_str().unwrap());
761        assert!(id.is_none());
762    }
763
764    #[test]
765    fn identity_from_lean_ctx_id() {
766        let dir = tempfile::tempdir().unwrap();
767        fs::write(dir.path().join(".lean-ctx-id"), "my-docker-project\n").unwrap();
768
769        let id = project_identity(dir.path().to_str().unwrap());
770        assert_eq!(id, Some("explicit:my-docker-project".into()));
771    }
772
773    #[test]
774    fn lean_ctx_id_takes_priority_over_git() {
775        let dir = tempfile::tempdir().unwrap();
776        fs::write(dir.path().join(".lean-ctx-id"), "override-name").unwrap();
777        fs::create_dir_all(dir.path().join(".git")).unwrap();
778        fs::write(
779            dir.path().join(".git").join("config"),
780            "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n",
781        )
782        .unwrap();
783
784        let id = project_identity(dir.path().to_str().unwrap());
785        assert_eq!(id, Some("explicit:override-name".into()));
786    }
787
788    #[test]
789    fn docker_different_projects_same_path_with_lean_ctx_id() {
790        let dir_a = tempfile::tempdir().unwrap();
791        let dir_b = tempfile::tempdir().unwrap();
792
793        fs::write(dir_a.path().join(".lean-ctx-id"), "project-alpha").unwrap();
794        fs::write(dir_b.path().join(".lean-ctx-id"), "project-beta").unwrap();
795
796        let id_a = project_identity(dir_a.path().to_str().unwrap());
797        let id_b = project_identity(dir_b.path().to_str().unwrap());
798        assert_ne!(id_a, id_b);
799    }
800
801    #[test]
802    fn fallback_hash_equals_legacy_when_no_identity() {
803        let h_new = hash_project_root("/some/path/without/project");
804        let h_old = hash_path_only("/some/path/without/project");
805        assert_eq!(
806            h_new, h_old,
807            "must be backward-compatible when no identity is found"
808        );
809    }
810
811    #[test]
812    fn migration_copies_files() {
813        let tmp = tempfile::tempdir().unwrap();
814        let knowledge_base = tmp.path().join("knowledge");
815        let old_hash = "aaaa000000000000";
816        let new_hash = "bbbb111111111111";
817
818        let old_dir = knowledge_base.join(old_hash);
819        let new_dir = knowledge_base.join(new_hash);
820        fs::create_dir_all(&old_dir).unwrap();
821        fs::write(
822            old_dir.join("knowledge.json"),
823            r#"{"project_root":"/workspace"}"#,
824        )
825        .unwrap();
826        fs::write(old_dir.join("gotchas.json"), "{}").unwrap();
827
828        copy_dir_contents(&old_dir, &new_dir).unwrap();
829
830        assert!(new_dir.join("knowledge.json").exists());
831        assert!(new_dir.join("gotchas.json").exists());
832        assert!(
833            old_dir.join("knowledge.json").exists(),
834            "old dir must remain intact"
835        );
836    }
837
838    /// M03: a copy interrupted after its first file left a partial new
839    /// partition, and `new_dir.exists()` then stopped every later attempt:
840    /// the files not copied yet were lost to the project.
841    #[test]
842    fn an_interrupted_migration_leaves_no_partial_partition_and_resumes() {
843        let _data = crate::core::data_dir::isolated_data_dir();
844        let knowledge_base = crate::core::data_dir::lean_ctx_data_dir()
845            .unwrap()
846            .join("knowledge");
847        let (old_hash, new_hash) = ("aaaa000000000000", "bbbb111111111111");
848        let old_dir = knowledge_base.join(old_hash);
849        let new_dir = knowledge_base.join(new_hash);
850        fs::create_dir_all(&old_dir).unwrap();
851        fs::write(
852            old_dir.join("knowledge.json"),
853            r#"{"project_root":"/workspace"}"#,
854        )
855        .unwrap();
856        fs::write(old_dir.join("gotchas.json"), "{}").unwrap();
857
858        COPY_FAILS_AFTER_FIRST_FILE.with(|armed| armed.set(true));
859        migrate_if_needed(old_hash, new_hash, "/workspace");
860        assert!(!new_dir.exists(), "no partial partition is left behind");
861
862        migrate_if_needed(old_hash, new_hash, "/workspace");
863        let mut copied: Vec<_> = fs::read_dir(&new_dir)
864            .unwrap()
865            .map(|entry| entry.unwrap().file_name())
866            .collect();
867        copied.sort();
868        assert_eq!(copied, ["gotchas.json", "knowledge.json"]);
869        assert!(old_dir.join("knowledge.json").exists());
870        assert!(old_dir.join("gotchas.json").exists());
871    }
872
873    #[test]
874    fn ownership_check_rejects_foreign_data() {
875        let tmp = tempfile::tempdir().unwrap();
876        let dir = tmp.path().join("knowledge").join("hash123");
877        fs::create_dir_all(&dir).unwrap();
878        fs::write(
879            dir.join("knowledge.json"),
880            r#"{"project_root":"/other/project"}"#,
881        )
882        .unwrap();
883
884        assert!(!verify_ownership(&dir, "/workspace"));
885    }
886
887    #[test]
888    fn ownership_check_accepts_matching_root() {
889        let tmp = tempfile::tempdir().unwrap();
890        let dir = tmp.path().join("knowledge").join("hash123");
891        fs::create_dir_all(&dir).unwrap();
892        fs::write(
893            dir.join("knowledge.json"),
894            r#"{"project_root":"/workspace"}"#,
895        )
896        .unwrap();
897
898        assert!(verify_ownership(&dir, "/workspace"));
899    }
900
901    #[test]
902    fn ownership_check_accepts_empty_stored_root() {
903        let tmp = tempfile::tempdir().unwrap();
904        let dir = tmp.path().join("knowledge").join("hash123");
905        fs::create_dir_all(&dir).unwrap();
906        fs::write(dir.join("knowledge.json"), r#"{"project_root":""}"#).unwrap();
907
908        assert!(verify_ownership(&dir, "/workspace"));
909    }
910
911    #[test]
912    fn ownership_check_accepts_missing_knowledge_json() {
913        let tmp = tempfile::tempdir().unwrap();
914        let dir = tmp.path().join("knowledge").join("hash123");
915        fs::create_dir_all(&dir).unwrap();
916
917        assert!(verify_ownership(&dir, "/workspace"));
918    }
919}