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
16 changes: 12 additions & 4 deletions crates/dv/src/automation/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ fn badge(badge: Option<&ReviewBadge>) -> Value {
/// `state.switcher`.
pub fn switcher(shell: &Shell, cx: &App) -> Value {
let Some(switcher) = shell.switcher() else {
return json!({"open": false, "query": null, "selected": null, "items": []});
return json!({"open": false, "mode": null, "query": null, "selected": null, "items": []});
};
let switcher = switcher.read(cx);
let listing = switcher.listing();
Expand All @@ -97,10 +97,11 @@ pub fn switcher(shell: &Shell, cx: &App) -> Value {
"key": item.key,
"kind": item.kind_key(),
"target": match &item.kind {
ItemKind::Target(offer) => Some(offer.kind.key()),
ItemKind::Target(offer) | ItemKind::Base { offer, .. } => Some(offer.kind.key()),
_ => None,
},
"label": item.label(),
"detail": item.detail(),
"starting": item.is_starting(),
"repo": repo.map(|repo| repo.path.clone()),
"review": badge(item.review()),
Expand All @@ -109,6 +110,7 @@ pub fn switcher(shell: &Shell, cx: &App) -> Value {
.collect();
json!({
"open": true,
"mode": switcher.mode().key(),
"query": switcher.query(),
"selected": switcher.selected_key(),
"items": items,
Expand All @@ -118,7 +120,7 @@ pub fn switcher(shell: &Shell, cx: &App) -> Value {
/// `state.source_picker`.
pub fn source_picker(shell: &Shell, cx: &App) -> Value {
let Some(picker) = shell.source_picker() else {
return json!({"open": false, "selected": null, "items": []});
return json!({"open": false, "selected": null, "items": [], "compare": false});
};
let picker = picker.read(cx);
let items: Vec<Value> = picker
Expand All @@ -132,10 +134,16 @@ pub fn source_picker(shell: &Shell, cx: &App) -> Value {
})
})
.collect();
let selected = if picker.compare_selected() {
Some("compare")
} else {
picker.selected().map(|offer| offer.kind.key())
};
json!({
"open": true,
"selected": picker.selected().map(|offer| offer.kind.key()),
"selected": selected,
"items": items,
"compare": picker.offers_compare(),
})
}

Expand Down
4 changes: 2 additions & 2 deletions crates/dv/src/automation/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,11 @@ fn state_describes_the_empty_shell(cx: &mut TestAppContext) {
assert_eq!(state["backends"], json!([]));
assert_eq!(
state["switcher"],
json!({"open": false, "query": null, "selected": null, "items": []})
json!({"open": false, "mode": null, "query": null, "selected": null, "items": []})
);
assert_eq!(
state["source_picker"],
json!({"open": false, "selected": null, "items": []})
json!({"open": false, "selected": null, "items": [], "compare": false})
);
assert_eq!(
state["file_list"],
Expand Down
248 changes: 248 additions & 0 deletions crates/dv_core/src/git/branches.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
//! The repository's local branches and where each one stacks (PLAN §2.2 "Branches"): what the
//! switcher's branch picker offers (DESIGN §4.1), a `branch` target's branch and its base.
//!
//! **Stack bases.** A branch started from another unmerged branch (`main → feat-a → feat-b`)
//! is reviewed against that branch, not the default one: its *stack base* is the nearest
//! branch tip among its first-parent ancestors that isn't in the default branch's history.
//! One `git log --branches --simplify-by-decoration ^<default>` finds them all: it keeps
//! only the commits a branch points at, outside the default branch, and rewrites each one's
//! parents to its nearest such commit, so a branch's first rewritten parent is its stack
//! base's tip; a parent left out (in the default branch's history) means the default branch.
//! A branch merged into the default branch is outside the walk and has no stack base either.

use std::collections::HashMap;

use super::{GitError, GitRepo, Oid, stdout_text};

/// A local branch (`refs/heads/<name>`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Branch {
/// Its name without `refs/heads/`.
pub name: String,
pub tip: Oid,
/// Its tip's committer date, in seconds since the epoch.
pub committed_at: i64,
/// The unmerged branch it stacks on (module docs); `None` when it stacks on the default
/// branch, is merged into it, or there is no default branch to tell.
pub stack_base: Option<String>,
}

impl GitRepo {
/// Every local branch, the most recently committed first, each with its stack base over
/// `default_base` (module docs; `GitRepo::default_branch`). Two git commands, whatever
/// the number of branches.
pub fn branches(&self, default_base: Option<&str>) -> Result<Vec<Branch>, GitError> {
let output = self.git(
&[
"for-each-ref",
"--sort=-committerdate",
"--format=%(refname)%09%(objectname)%09%(committerdate:unix)",
"refs/heads/",
],
&[0],
)?;
let text = stdout_text("for-each-ref", &output)?;
let mut branches: Vec<Branch> = text
.lines()
.filter_map(|line| {
let mut fields = line.split('\t');
let name = fields.next()?.strip_prefix("refs/heads/")?;
let tip = Oid::parse(fields.next()?)?;
let committed_at = fields.next()?.parse().unwrap_or(0);
Some(Branch {
name: name.to_string(),
tip,
committed_at,
stack_base: None,
})
})
.collect();
let Some(default_base) = default_base else {
return Ok(branches);
};
let tips = self.unmerged_tips(default_base)?;
for branch in &mut branches {
branch.stack_base = tips
.get(branch.tip.as_str())
.and_then(|tip| tip.parent.as_deref())
.and_then(|parent| tips.get(parent))
.and_then(|parent| parent.names.first().cloned());
}
Ok(branches)
}

/// The commits local branches point at outside `default_base`'s history, each with its
/// first parent in the simplified history (module docs) and the branches at it, sorted.
fn unmerged_tips(&self, default_base: &str) -> Result<HashMap<String, Tip>, GitError> {
let exclude = format!("^{default_base}");
let output = self.git(
&[
"log",
"--no-show-signature",
"--simplify-by-decoration",
"--decorate-refs=refs/heads/",
"--format=%H%x09%P%x09%D",
"--branches",
"--end-of-options",
&exclude,
"--",
],
&[0],
)?;
let text = stdout_text("log", &output)?;
Ok(text
.lines()
.filter_map(|line| {
let mut fields = line.split('\t');
let commit = fields.next()?.to_string();
let parent = fields.next()?.split_whitespace().next().map(str::to_string);
// `HEAD -> feat, feat-copy`: ref names hold no spaces, so `, ` parts them.
let mut names: Vec<String> = fields
.next()
.unwrap_or_default()
.split(", ")
.map(|name| name.strip_prefix("HEAD -> ").unwrap_or(name))
.filter(|name| !name.is_empty() && *name != "HEAD")
.map(str::to_string)
.collect();
names.sort();
Some((commit, Tip { parent, names }))
})
.collect())
}
}

/// A commit a branch points at, in the simplified history.
struct Tip {
parent: Option<String>,
names: Vec<String>,
}

#[cfg(test)]
mod tests {
use dv_test_support::FixtureRepo;

use crate::testing::git_repo;

fn bases(repo: &FixtureRepo, default: Option<&str>) -> Vec<(String, Option<String>)> {
let mut found: Vec<(String, Option<String>)> = git_repo(repo)
.branches(default)
.expect("branches list")
.into_iter()
.map(|branch| (branch.name, branch.stack_base))
.collect();
found.sort();
found
}

fn pair(name: &str, base: Option<&str>) -> (String, Option<String>) {
(name.to_string(), base.map(str::to_string))
}

#[test]
fn a_stack_finds_each_branch_below() {
let repo = FixtureRepo::new();
repo.write("f.txt", "m1\n");
repo.commit("m1");
repo.branch("merged");
repo.write("f.txt", "m2\n");
repo.commit("m2");
repo.branch("feat-a")
.checkout("feat-a")
.write("a.txt", "a\n");
repo.commit("a1");
repo.branch("feat-b")
.checkout("feat-b")
.write("b.txt", "b\n");
repo.commit("b1");
repo.branch("feat-c")
.checkout("feat-c")
.write("c.txt", "c\n");
repo.commit("c1");
// A branch on the default branch's history, and the default branch moving on.
repo.checkout("main").branch("side").checkout("side");
repo.write("s.txt", "s\n");
repo.commit("s1");
repo.checkout("main").write("f.txt", "m3\n");
repo.commit("m3");
assert_eq!(
bases(&repo, Some("main")),
vec![
pair("feat-a", None),
pair("feat-b", Some("feat-a")),
pair("feat-c", Some("feat-b")),
pair("main", None),
pair("merged", None),
pair("side", None),
]
);
}

#[test]
fn no_default_branch_means_no_stack_bases() {
let repo = FixtureRepo::new();
repo.write("f.txt", "m1\n");
repo.commit("m1");
repo.branch("feat-a")
.checkout("feat-a")
.write("a.txt", "a\n");
repo.commit("a1");
repo.branch("feat-b")
.checkout("feat-b")
.write("b.txt", "b\n");
repo.commit("b1");
assert_eq!(
bases(&repo, None),
vec![
pair("feat-a", None),
pair("feat-b", None),
pair("main", None)
]
);
}

#[test]
fn a_branch_at_another_ones_tip_stacks_below_both() {
let repo = FixtureRepo::new();
repo.write("f.txt", "m1\n");
repo.commit("m1");
repo.branch("feat-a")
.checkout("feat-a")
.write("a.txt", "a\n");
repo.commit("a1");
// `feat-a2` points where `feat-a` does: neither stacks on the other.
repo.branch("feat-a2");
repo.branch("feat-b")
.checkout("feat-b")
.write("b.txt", "b\n");
repo.commit("b1");
assert_eq!(
bases(&repo, Some("main")),
vec![
pair("feat-a", None),
pair("feat-a2", None),
pair("feat-b", Some("feat-a")),
pair("main", None),
]
);
}

#[test]
fn newest_first() {
let repo = FixtureRepo::new();
repo.write("f.txt", "m1\n");
repo.commit("m1");
repo.branch("older");
repo.write("f.txt", "m2\n");
repo.commit("m2");
repo.branch("newer");
let names: Vec<String> = git_repo(&repo)
.branches(Some("main"))
.expect("branches list")
.into_iter()
.map(|branch| branch.name)
.collect();
assert_eq!(names.first().map(String::as_str), Some("main"));
assert_eq!(names.last().map(String::as_str), Some("older"));
}
}
1 change: 1 addition & 0 deletions crates/dv_core/src/git/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! Revisions from users and reviews are passed after `--end-of-options`, so a revision that
//! starts with `-` is looked up as a revision and never read as an option.

pub mod branches;
pub mod command;
pub(crate) mod temp_index;

Expand Down
8 changes: 7 additions & 1 deletion crates/dv_ui/src/palette/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,13 @@ pub fn build(
let target = match &item.kind {
ItemKind::Target(offer) => Some(offer.kind),
ItemKind::Review { .. } => None,
ItemKind::Start { .. } | ItemKind::Retry { .. } | ItemKind::OpenFolder => continue,
// The branch picker is the switcher's: the palette lists the reviews it opens.
ItemKind::Start { .. }
| ItemKind::Retry { .. }
| ItemKind::OpenFolder
| ItemKind::CompareBranches
| ItemKind::Branch { .. }
| ItemKind::Base { .. } => continue,
};
let (Some((location, request)), Some(repo)) = (
listing.request(ix),
Expand Down
Loading