diff --git a/crates/strand-core/src/refs.rs b/crates/strand-core/src/refs.rs index 40f8bd4..0c5a388 100644 --- a/crates/strand-core/src/refs.rs +++ b/crates/strand-core/src/refs.rs @@ -72,6 +72,16 @@ pub struct Refs { pub tags: Vec, } +/// The branch a ref was most likely forked from, plus the fork point to +/// review against. Produced by [`Repo::detect_base_branch`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BaseBranch { + /// Short name of the detected base branch, e.g. `portal30`. + pub name: String, + /// merge-base(target, base) — pin the review baseline here. + pub merge_base: String, +} + impl Repo { /// Best common ancestor of two commit-ishes (`git merge-base `). /// Powers "review a worktree against its base branch": pinning the review @@ -84,6 +94,77 @@ impl Repo { Ok(repo.merge_base(a, b)?.to_string()) } + /// Detect the local branch `target` was most likely forked from, and the + /// fork point to review against. + /// + /// Two passes. The branch's reflog creation entry (`branch: Created from + /// `) names the parent exactly when git recorded one, and survives + /// graph shapes the scan can't disambiguate (e.g. the parent was merged + /// back into `target` after forking). Otherwise fall back to the local + /// branch whose merge-base with `target` is nearest — fewest commits + /// between `target` and the fork point. A worktree cut from `portal30` + /// must review against `portal30`, not the repo's main branch: main's + /// merge-base is the *older* fork point, so the diff would swallow all of + /// `portal30`'s own work (DAN-14). + pub fn detect_base_branch(&self, target: &str) -> Result> { + let repo = self.git2()?; + let target_id = repo.revparse_single(target)?.peel_to_commit()?.id(); + + // Pass 1: the reflog's oldest entry records what the branch was + // created from. Only trust it when it names a local branch that still + // exists — "HEAD", raw OIDs, and deleted branches fall through. + let created_from = repo + .reflog(&format!("refs/heads/{target}")) + .ok() + .and_then(|log| { + let oldest = log.get(log.len().checked_sub(1)?)?; + let from = oldest.message()?.strip_prefix("branch: Created from ")?; + (from != target && from != "HEAD").then(|| from.to_string()) + }); + if let Some(from) = created_from { + let hit = repo + .find_branch(&from, git2::BranchType::Local) + .ok() + .and_then(|b| b.get().target()) + .and_then(|tip| repo.merge_base(target_id, tip).ok()) + .map(|mb| BaseBranch { name: from, merge_base: mb.to_string() }); + if hit.is_some() { + return Ok(hit); + } + } + + // Pass 2: nearest-fork-point scan. Rank candidates by commits on + // `target` since the merge-base (fewer = forked later = closer + // parent), tie-break on commits the candidate has since the + // merge-base (a branch still sitting at the fork point beats a + // sibling that moved on), then name for determinism. Candidates that + // *contain* target (merge-base = target tip, i.e. children or + // already-merged integration branches) rank last — pinning the + // baseline at target's own tip would review nothing. + let mut best: Option<((bool, usize, usize), BaseBranch)> = None; + if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) { + for (branch, _) in branches.flatten() { + let name = match branch.name() { + Ok(Some(n)) if n != target => n.to_string(), + _ => continue, + }; + let Some(tip) = branch.get().target() else { continue }; + let Ok(mb) = repo.merge_base(target_id, tip) else { continue }; + let Ok((ahead, _)) = repo.graph_ahead_behind(target_id, mb) else { continue }; + let Ok((base_ahead, _)) = repo.graph_ahead_behind(tip, mb) else { continue }; + let rank = (ahead == 0, ahead, base_ahead); + let better = match &best { + None => true, + Some((r, c)) => (rank, name.as_str()) < (*r, c.name.as_str()), + }; + if better { + best = Some((rank, BaseBranch { name, merge_base: mb.to_string() })); + } + } + } + Ok(best.map(|(_, hit)| hit)) + } + /// All resolvable refs, grouped for the sidebar + branch picker. pub fn refs(&self) -> Result { let repo = self.git2()?; @@ -329,4 +410,64 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn detect_base_branch_prefers_the_actual_parent_over_main() { + let dir = std::env::temp_dir().join(format!( + "strand-detect-base-test-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + git(&dir, &["init", "-q", "-b", "main"]); + git(&dir, &["config", "user.name", "Test"]); + git(&dir, &["config", "user.email", "test@example.com"]); + git(&dir, &["config", "commit.gpgsign", "false"]); + git(&dir, &["config", "core.logAllRefUpdates", "true"]); + + let commit = |name: &str, msg: &str| { + std::fs::write(dir.join(name), msg).unwrap(); + git(&dir, &["add", name]); + git(&dir, &["commit", "-q", "-m", msg]); + }; + + // main ── portal30 (2 commits) ── feature (1 commit); main moves on. + commit("a.txt", "root"); + git(&dir, &["checkout", "-q", "-b", "portal30"]); + commit("p1.txt", "portal work 1"); + commit("p2.txt", "portal work 2"); + let portal_tip = git(&dir, &["rev-parse", "portal30"]); + git(&dir, &["checkout", "-q", "-b", "feature", "portal30"]); + commit("f.txt", "feature work"); + git(&dir, &["checkout", "-q", "main"]); + commit("m.txt", "main moved on"); + git(&dir, &["checkout", "-q", "feature"]); + + let repo = Repo::discover(dir.to_str().unwrap()).unwrap(); + let hit = repo.detect_base_branch("feature").unwrap().unwrap(); + assert_eq!(hit.name, "portal30"); + assert_eq!(hit.merge_base, portal_tip); + + // The reflog names the parent even after merging main into feature, + // where the nearest-merge-base scan alone would pick main. + git(&dir, &["merge", "-q", "--no-edit", "main"]); + let hit = repo.detect_base_branch("feature").unwrap().unwrap(); + assert_eq!(hit.name, "portal30"); + assert_eq!(hit.merge_base, portal_tip); + + // A fresh branch with no commits of its own still detects its parent + // (fork point = its own tip), not main. + git(&dir, &["checkout", "-q", "-b", "fresh", "portal30"]); + let hit = repo.detect_base_branch("fresh").unwrap().unwrap(); + assert_eq!(hit.name, "portal30"); + assert_eq!(hit.merge_base, portal_tip); + + // portal30 itself forked from main. + let hit = repo.detect_base_branch("portal30").unwrap().unwrap(); + assert_eq!(hit.name, "main"); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index f01dcb6..38d55eb 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -21,7 +21,7 @@ use strand_core::{ history::{MergeMode, RebaseEntry, RebaseStep}, log::{Commit, SearchMode}, network::{clone as core_clone, CancelHandle, CloneOutcome, NetworkOutcome, Progress}, reflog::ReflogEntry, - refs::Refs, repo::RepoMeta, reset::{ResetMode, ResetOutcome}, + refs::{BaseBranch, Refs}, repo::RepoMeta, reset::{ResetMode, ResetOutcome}, snapshot::Snapshot, stash::{Stash, StashOutcome}, status::FileStatus, submodule::Submodule, tree::WorkTreeEntry, worktree::Worktree, Repo, }; @@ -224,6 +224,17 @@ pub async fn repo_merge_base(path: String, a: String, b: String) -> CmdResult CmdResult> { + run_blocking("detect base branch", move || { + Ok(Repo::discover(&path)?.detect_base_branch(&target)?) + }) + .await +} + // ── File view (Content / History / Blame tabs) ── #[tauri::command(async)] diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index 0f9af03..6d5a371 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -71,6 +71,7 @@ fn main() { commands::repo_diff_since_full, commands::repo_diff_unstaged_full, commands::repo_merge_base, + commands::repo_detect_base_branch, commands::repo_log, commands::repo_search_log, commands::repo_refs, diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index f26d491..8dd999f 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -6,7 +6,7 @@ import { Icon, type IconName } from './Icon'; import { copyToClipboard, PierreTree, workStatusToGit, type TreeMenuItem } from './PierreTree'; import { ignorePatterns } from '../lib/ignore'; import { worktreeName } from '../lib/repoIdentity'; -import { errMessage } from '../lib/tauri'; +import { errMessage, tauri } from '../lib/tauri'; import { defaultRemote, useRepo } from '../stores/repo'; import type { Branch, RemoteBranch, Stash, Submodule, SubmoduleState, Tag, Worktree } from '../lib/types'; import type { RemoteDialogMode } from '../views/RemoteDialog'; @@ -178,6 +178,7 @@ export function Sidebar({ onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, const removeWorktree = useRepo((s) => s.removeWorktree); const pruneWorktrees = useRepo((s) => s.pruneWorktrees); const rebase = useRepo((s) => s.rebase); + const setBaseline = useRepo((s) => s.setBaseline); const currentBranch = useMemo(() => refs.branches.find((b) => b.is_head)?.name ?? null, [refs]); // Branches that are HEAD of another worktree — checkout here is guaranteed // to fail, so their rows badge the fact and open that worktree instead. @@ -485,6 +486,23 @@ export function Sidebar({ onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, })(); }; + // Pin the review baseline at merge-base(HEAD, base) and open the Review + // view — the current branch's own work, reviewed against the branch the + // user picked instead of an auto-detected one. + const reviewAgainst = (base: string) => { + void (async () => { + if (!meta) return; + try { + const oid = await tauri.repoMergeBase(meta.path, 'HEAD', base); + await setBaseline(oid); + setView('review'); + selectFile(null); + } catch (e) { + onToast(`Can't compare with ${base}: ${errMessage(e)}`, 'error'); + } + })(); + }; + const branchMenu = (b: Branch): MenuItem[] => { const newBranchItem: MenuItem = { label: 'New branch from here…', @@ -525,6 +543,7 @@ export function Sidebar({ onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, renameItem, ]; if (currentBranch) { + items.push({ label: `Review ${currentBranch} vs this`, icon: 'eye', onSelect: () => reviewAgainst(b.name) }); items.push({ label: `Merge into ${currentBranch}`, icon: 'branch', onSelect: () => onMerge(b.name, currentBranch) }); items.push({ label: `Rebase ${currentBranch} onto this`, icon: 'rebase', confirm: true, onSelect: () => runRebase(b.name) }); } diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index baa320d..3da085e 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -3,6 +3,7 @@ import { Channel, invoke } from '@tauri-apps/api/core'; import type { AiProvider, AiProviderStatus, + BaseBranch, BlameLine, CheckoutOutcome, CloneOutcome, @@ -107,6 +108,10 @@ export const tauri = { invoke('repo_diff_since_full', { path, baseline }), repoMergeBase: (path: string, a: string, b: string) => invoke('repo_merge_base', { path, a, b }), + // Which branch `target` forked from + the fork point — the worktree Review + // flow's baseline (review vs the actual parent, not the main branch). + repoDetectBaseBranch: (path: string, target: string) => + invoke('repo_detect_base_branch', { path, target }), repoFileContent: (path: string, file: string, rev: string | null) => invoke('repo_file_content', { path, file, rev }), repoFileBlob: (path: string, file: string, rev: string | null, index: boolean) => diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 83c8ffa..34033df 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -181,6 +181,12 @@ export interface Refs { tags: Tag[]; } +/** The branch a ref was forked from + the fork point to review against. */ +export interface BaseBranch { + name: string; + merge_base: string; +} + export interface CheckoutOutcome { branch: string; } diff --git a/ui/src/views/Worktrees.tsx b/ui/src/views/Worktrees.tsx index 71d4318..efc1bc7 100644 --- a/ui/src/views/Worktrees.tsx +++ b/ui/src/views/Worktrees.tsx @@ -134,16 +134,22 @@ export function Worktrees({ const review = (w: Worktree) => { void (async () => { - const main = worktrees.find((x) => x.is_main); - const base = !w.is_main ? (main?.branch ?? main?.head ?? null) : null; const target = w.branch ?? w.head; let baselineOid: string | null = null; - if (base && target && base !== target) { + // Review against the branch this worktree actually forked from, not + // the main worktree's branch — a worktree cut from `portal30` must + // baseline at merge-base(HEAD, portal30), or the diff swallows all of + // portal30's own work (DAN-14). + if (!w.is_main && target) { try { - baselineOid = await tauri.repoMergeBase(w.path, target, base); + const base = await tauri.repoDetectBaseBranch(w.path, target); + if (base) { + baselineOid = base.merge_base; + onToast(`Reviewing ${w.branch ?? worktreeName(w)} vs ${base.name}`); + } } catch (e) { - onToast(`Can't compare with ${base}: ${errMessage(e)}`, 'error'); + onToast(`Can't detect base branch: ${errMessage(e)}`, 'error'); } }