Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions crates/strand-core/src/refs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ pub struct Refs {
pub tags: Vec<Tag>,
}

/// 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 <a> <b>`).
/// Powers "review a worktree against its base branch": pinning the review
Expand All @@ -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
/// <ref>`) 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<Option<BaseBranch>> {
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<Refs> {
let repo = self.git2()?;
Expand Down Expand Up @@ -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);
}
}
13 changes: 12 additions & 1 deletion crates/strand-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -224,6 +224,17 @@ pub async fn repo_merge_base(path: String, a: String, b: String) -> CmdResult<St
run_blocking("merge base", move || Ok(Repo::discover(&path)?.merge_base(&a, &b)?)).await
}

/// Detect the branch `target` was forked from and the fork point — powers the
/// worktree Review flow's baseline, so a branch cut from `portal30` reviews
/// against `portal30`, not the repo's main branch (DAN-14).
#[tauri::command(async)]
pub async fn repo_detect_base_branch(path: String, target: String) -> CmdResult<Option<BaseBranch>> {
run_blocking("detect base branch", move || {
Ok(Repo::discover(&path)?.detect_base_branch(&target)?)
})
.await
}

// ── File view (Content / History / Blame tabs) ──

#[tauri::command(async)]
Expand Down
1 change: 1 addition & 0 deletions crates/strand-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 20 additions & 1 deletion ui/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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…',
Expand Down Expand Up @@ -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) });
}
Expand Down
5 changes: 5 additions & 0 deletions ui/src/lib/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Channel, invoke } from '@tauri-apps/api/core';
import type {
AiProvider,
AiProviderStatus,
BaseBranch,
BlameLine,
CheckoutOutcome,
CloneOutcome,
Expand Down Expand Up @@ -107,6 +108,10 @@ export const tauri = {
invoke<FileDiff[]>('repo_diff_since_full', { path, baseline }),
repoMergeBase: (path: string, a: string, b: string) =>
invoke<string>('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<BaseBranch | null>('repo_detect_base_branch', { path, target }),
repoFileContent: (path: string, file: string, rev: string | null) =>
invoke<FileContent>('repo_file_content', { path, file, rev }),
repoFileBlob: (path: string, file: string, rev: string | null, index: boolean) =>
Expand Down
6 changes: 6 additions & 0 deletions ui/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
16 changes: 11 additions & 5 deletions ui/src/views/Worktrees.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}

Expand Down
Loading