diff --git a/.changeset/pearl-autocommit-worktree-guard.md b/.changeset/pearl-autocommit-worktree-guard.md new file mode 100644 index 0000000..0646284 --- /dev/null +++ b/.changeset/pearl-autocommit-worktree-guard.md @@ -0,0 +1,19 @@ +--- +"smooai-smooth": patch +--- + +th pearls: skip the git auto-commit of pearl state when run from a linked worktree + +`th pearls` mutations auto-commit the `.smooth/dolt/` store to git so pearl +state syncs across machines. Dolt rewrites its mutable pointer files +(`journal.idx`, `manifest`, the journal chunk) on every store open, and each +linked worktree checks out its own copy — so committing those onto a feature +branch produced binary pointer divergence that couldn't be merged back to +main (recurring `.smooth/dolt` conflicts). + +`auto_commit_pearl_state` now detects a linked worktree (`git rev-parse +--git-dir` ≠ `--git-common-dir`) and skips the git commit there, logging a +hint to run pearl mutations from the primary worktree. The dolt mutation and +`th pearls push` (refs/dolt/data) still capture the change, so nothing is +lost — pearl state simply stays on one lineage. Primary-worktree behaviour is +unchanged. diff --git a/crates/smooth-cli/src/main.rs b/crates/smooth-cli/src/main.rs index de42537..01a8e77 100644 --- a/crates/smooth-cli/src/main.rs +++ b/crates/smooth-cli/src/main.rs @@ -4160,6 +4160,23 @@ fn auto_commit_pearl_state(dolt_dir: &std::path::Path, action: &str) -> Result<( return Ok(()); }; + // SMOODEV-1836: never auto-commit the dolt store from a linked worktree. + // Each worktree checks out its own copy of `.smooth/dolt/`, and Dolt + // rewrites mutable pointer files (journal.idx, manifest, the journal + // chunk) on every open — committing those onto a feature branch produces + // binary pointer divergence that can't be merged back to main. Pearl + // state belongs on the primary worktree's lineage; from a linked worktree + // we skip the git commit (the dolt mutation + `th pearls push` to + // refs/dolt/data still capture the change) and tell the user where to run. + if is_linked_worktree(&repo_root) { + tracing::warn!( + "th pearls: skipping git auto-commit of pearl state — this is a linked \ + worktree. Run pearl mutations from the primary worktree so the dolt \ + store stays on one lineage; sync with `th pearls push`." + ); + return Ok(()); + } + let canonical_repo = repo_root.canonicalize().unwrap_or_else(|_| repo_root.clone()); let canonical_dolt = dolt_dir.canonicalize().unwrap_or_else(|_| dolt_dir.to_path_buf()); let Ok(relative) = canonical_dolt.strip_prefix(&canonical_repo) else { @@ -4226,6 +4243,43 @@ fn git_toplevel(start: &std::path::Path) -> Option { Some(std::path::PathBuf::from(trimmed)) } +/// True if `repo_root` is a *linked* git worktree (created by +/// `git worktree add`) rather than the repository's primary worktree. +/// +/// Detection: in a linked worktree `git rev-parse --git-dir` resolves to +/// `/.git/worktrees/`, which differs from +/// `--git-common-dir` (`/.git`). In the primary worktree the two +/// resolve to the same path. We canonicalize both before comparing so +/// relative-vs-absolute output doesn't produce a false positive. On any +/// git error we return `false` (fail toward the existing behaviour rather +/// than silently dropping a primary-worktree commit). +fn is_linked_worktree(repo_root: &std::path::Path) -> bool { + let rev = |flag: &str| -> Option { + let out = std::process::Command::new("git") + .arg("-C") + .arg(repo_root) + .args(["rev-parse", flag]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8(out.stdout).ok()?; + let trimmed = s.trim(); + if trimmed.is_empty() { + return None; + } + // git prints paths relative to repo_root unless they're absolute. + let p = std::path::Path::new(trimmed); + let abs = if p.is_absolute() { p.to_path_buf() } else { repo_root.join(p) }; + Some(abs.canonicalize().unwrap_or(abs)) + }; + match (rev("--git-dir"), rev("--git-common-dir")) { + (Some(git_dir), Some(common_dir)) => git_dir != common_dir, + _ => false, + } +} + /// Trim a pearl title down to a length that fits comfortably in a /// one-line commit subject (keeps `git log --oneline` readable). fn truncate_for_msg(s: &str) -> String { @@ -6630,3 +6684,54 @@ mod bench_tests { let _ = env!("BENCH_SCORE_JSON"); } } + +#[cfg(test)] +mod worktree_guard_tests { + use super::is_linked_worktree; + use std::process::Command; + + fn git(dir: &std::path::Path, args: &[&str]) { + let ok = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .expect("git launches") + .status + .success(); + assert!(ok, "git {args:?} failed in {dir:?}"); + } + + /// SMOODEV-1836: the primary worktree must NOT be treated as linked + /// (so pearl auto-commit keeps working there), while a worktree created + /// by `git worktree add` MUST be (so it's skipped). + #[test] + fn distinguishes_primary_from_linked_worktree() { + let tmp = tempfile::tempdir().expect("tempdir"); + let primary = tmp.path().join("primary"); + std::fs::create_dir(&primary).unwrap(); + + git(&primary, &["init", "-q", "-b", "main"]); + git(&primary, &["config", "user.email", "t@t.test"]); + git(&primary, &["config", "user.name", "Test"]); + std::fs::write(primary.join("f.txt"), "x").unwrap(); + git(&primary, &["add", "."]); + git(&primary, &["commit", "-q", "-m", "init"]); + + // Primary worktree: not linked. + assert!(!is_linked_worktree(&primary), "primary worktree should not be detected as linked"); + + // Linked worktree via `git worktree add`. + let linked = tmp.path().join("linked"); + git(&primary, &["worktree", "add", "-q", linked.to_str().unwrap(), "-b", "feat"]); + assert!(is_linked_worktree(&linked), "git-worktree-add tree should be detected as linked"); + } + + /// A non-git directory must fail toward `false` (preserve existing + /// behaviour rather than silently dropping a commit). + #[test] + fn non_git_dir_is_not_linked() { + let tmp = tempfile::tempdir().expect("tempdir"); + assert!(!is_linked_worktree(tmp.path())); + } +}