From d545073b895f6a2c8747644bc8c253387bcd134e Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Sat, 13 Jun 2026 01:03:05 -0400 Subject: [PATCH] th pearls: skip git auto-commit from linked worktrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auto_commit_pearl_state committed the .smooth/dolt store to git on whatever branch the caller was on. Dolt rewrites mutable pointer files (journal.idx, manifest, journal chunk) on every open, and each linked worktree has its own checked-out copy — committing those onto feature branches produced binary pointer divergence that can't be merged back to main (recurring conflicts during the smooai SMOODEV-1818 migration). Detect a linked worktree via git-dir != git-common-dir and skip the git commit there (the dolt mutation + th pearls push to refs/dolt/data still capture the change). Primary-worktree behaviour unchanged. Adds is_linked_worktree + tests covering primary vs linked vs non-git dirs. Co-Authored-By: Claude Fable 5 --- .changeset/pearl-autocommit-worktree-guard.md | 19 ++++ crates/smooth-cli/src/main.rs | 105 ++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 .changeset/pearl-autocommit-worktree-guard.md 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())); + } +}