use std::collections::BTreeSet;
use std::collections::HashSet;
use miette::Diagnostic;
use thiserror::Error;
use super::SelectionResult;
use super::bookmark_gen;
use super::bookmark_gen::BookmarkGenError;
use super::tfidf;
use crate::cli::submit::SubmitArgs;
use crate::graph::types::ChangeGraph;
use crate::graph::types::SegmentCommit;
use crate::submit::BookmarkAssignment;
#[derive(Debug, Error, Diagnostic)]
pub enum ExplicitSelectionError {
#[error("invalid --new value {arg:?}: expected REV or REV=NAME")]
#[diagnostic(
code(stakk::selection::invalid_new_spec),
help(
"pass a change or commit id prefix, optionally followed by =NAME; run `stakk graph` \
to list ids"
)
)]
InvalidNewSpec { arg: String },
#[error("{flag} requires a non-empty REV")]
#[diagnostic(
code(stakk::selection::empty_rev),
help("pass a change or commit id prefix; run `stakk graph` to list ids")
)]
EmptyRev { flag: String },
#[error("no bookmark stacks found")]
#[diagnostic(
code(stakk::selection::no_stacks),
help(
"there is nothing to submit; check --bookmarks-revset / --heads-revset, or run `stakk \
graph` to inspect the repository"
)
)]
NoStacks,
#[error("revision {rev:?} does not match any commit on a submittable stack")]
#[diagnostic(
code(stakk::selection::rev_not_found),
help(
"the rev must prefix-match a change or commit id on a stack; trunk, immutable, and \
revset-excluded commits are not submittable — run `stakk graph` (or `stakk graph \
--format=json`) to list candidates"
)
)]
RevNotFound { rev: String },
#[error("revision {rev:?} is ambiguous: matches {}", candidates.join(", "))]
#[diagnostic(
code(stakk::selection::rev_ambiguous),
help("use a longer prefix; run `stakk graph` to see short change ids")
)]
RevAmbiguous {
rev: String,
candidates: Vec<String>,
},
#[error("revision {rev:?} resolves to an immutable commit")]
#[diagnostic(
code(stakk::selection::rev_immutable),
help(
"immutable commits cannot become PR boundaries; a bookmark there would be invisible \
to subsequent runs (see --bookmarks-revset)"
)
)]
RevImmutable { rev: String },
#[error("bookmark {name:?} not found on any stack")]
#[diagnostic(
code(stakk::selection::keep_not_found),
help(
"run `stakk graph` to list bookmarks; only bookmarks matched by --bookmarks-revset \
appear"
)
)]
KeepNotFound { name: String },
#[error("marks do not lie on a single trunk-to-tip path: {}", marks.join(", "))]
#[diagnostic(
code(stakk::selection::not_colinear),
help(
"all marks must be ancestors or descendants of one another — run `stakk graph` to \
inspect the stacks"
)
)]
MarksNotColinear { marks: Vec<String> },
#[error("marks {a} and {b} target the same commit")]
#[diagnostic(
code(stakk::selection::duplicate_mark),
help("a commit can be at most one PR boundary; drop one of the marks")
)]
DuplicateMarkOnCommit { a: String, b: String },
#[error("bookmark name {name:?} is used by more than one mark")]
#[diagnostic(
code(stakk::selection::duplicate_name),
help("bookmark names must be unique; pass an explicit name with --new REV=NAME")
)]
DuplicateName { name: String },
#[error("bookmark {name:?} already exists")]
#[diagnostic(
code(stakk::selection::name_exists),
help(
"choose another name, or pass --keep with the existing name instead — `jj bookmark \
list` shows every local bookmark, including ones outside the selectable stacks"
)
)]
NewNameExists { name: String },
#[error("--new-command requires a bookmark command")]
#[diagnostic(
code(stakk::selection::bookmark_command_not_configured),
help(
"pass --bookmark-command, set STAKK_BOOKMARK_COMMAND, or add bookmark_command to \
stakk.toml"
)
)]
BookmarkCommandNotConfigured,
#[error(transparent)]
#[diagnostic(transparent)]
Gen(#[from] BookmarkGenError),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewBookmarkSpec {
pub rev: String,
pub name: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SelectionSpec {
pub keep: Vec<String>,
pub new: Vec<NewBookmarkSpec>,
pub new_auto: Vec<String>,
pub new_command: Vec<String>,
}
impl SelectionSpec {
pub fn from_args(args: &SubmitArgs) -> Result<Self, ExplicitSelectionError> {
let mut new = Vec::with_capacity(args.new.len());
for raw in &args.new {
let spec = if let Some((rev, name)) = raw.split_once('=') {
if rev.is_empty() || name.is_empty() {
return Err(ExplicitSelectionError::InvalidNewSpec { arg: raw.clone() });
}
bookmark_gen::validate_bookmark_name(name)?;
NewBookmarkSpec {
rev: rev.to_string(),
name: Some(name.to_string()),
}
} else {
if raw.is_empty() {
return Err(ExplicitSelectionError::InvalidNewSpec { arg: raw.clone() });
}
NewBookmarkSpec {
rev: raw.clone(),
name: None,
}
};
new.push(spec);
}
for (revs, flag) in [
(&args.new_auto, "--new-auto"),
(&args.new_command, "--new-command"),
] {
if revs.iter().any(String::is_empty) {
return Err(ExplicitSelectionError::EmptyRev {
flag: flag.to_string(),
});
}
}
Ok(Self {
keep: args.keep.clone(),
new,
new_auto: args.new_auto.clone(),
new_command: args.new_command.clone(),
})
}
pub fn is_empty(&self) -> bool {
self.keep.is_empty()
&& self.new.is_empty()
&& self.new_auto.is_empty()
&& self.new_command.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum MarkKind {
Keep { name: String },
New { name: Option<String> },
NewAuto,
NewCommand,
}
#[derive(Debug)]
struct Mark {
kind: MarkKind,
display: String,
commit_id: String,
stacks: BTreeSet<usize>,
}
pub async fn resolve_bookmarks_explicitly(
graph: &ChangeGraph,
spec: &SelectionSpec,
auto_prefix: Option<&str>,
bookmark_command: Option<&str>,
reserved: &HashSet<String>,
) -> Result<SelectionResult, ExplicitSelectionError> {
if !spec.new_command.is_empty() && bookmark_command.is_none() {
return Err(ExplicitSelectionError::BookmarkCommandNotConfigured);
}
if graph.stacks.is_empty() {
return Err(ExplicitSelectionError::NoStacks);
}
let linearized: Vec<Vec<&SegmentCommit>> = graph
.stacks
.iter()
.map(|s| s.commits_trunk_to_tip().collect())
.collect();
let mut marks: Vec<Mark> = Vec::new();
let rev_marks = spec
.new
.iter()
.map(|n| {
let display = match &n.name {
Some(name) => format!("--new {}={name}", n.rev),
None => format!("--new {}", n.rev),
};
(
n.rev.clone(),
MarkKind::New {
name: n.name.clone(),
},
display,
)
})
.chain(
spec.new_auto
.iter()
.map(|rev| (rev.clone(), MarkKind::NewAuto, format!("--new-auto {rev}"))),
)
.chain(spec.new_command.iter().map(|rev| {
(
rev.clone(),
MarkKind::NewCommand,
format!("--new-command {rev}"),
)
}));
for (rev, kind, display) in rev_marks {
let (commit, stacks) = resolve_rev(&linearized, &rev)?;
marks.push(Mark {
kind,
display,
commit_id: commit.commit_id.clone(),
stacks,
});
}
let mut kept_names: HashSet<&str> = HashSet::new();
for name in &spec.keep {
if !kept_names.insert(name.as_str()) {
continue;
}
let (commit, stacks) = resolve_keep(&linearized, graph, name)?;
marks.push(Mark {
kind: MarkKind::Keep { name: name.clone() },
display: format!("--keep {name}"),
commit_id: commit.commit_id.clone(),
stacks,
});
}
let candidate_stacks: BTreeSet<usize> = marks
.iter()
.map(|m| m.stacks.clone())
.reduce(|acc, s| acc.intersection(&s).copied().collect())
.expect("a non-empty spec always yields at least one mark");
if candidate_stacks.is_empty() {
return Err(ExplicitSelectionError::MarksNotColinear {
marks: marks.iter().map(|m| m.display.clone()).collect(),
});
}
{
let mut seen: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
for mark in &marks {
if let Some(first) = seen.insert(mark.commit_id.as_str(), mark.display.as_str()) {
return Err(ExplicitSelectionError::DuplicateMarkOnCommit {
a: first.to_string(),
b: mark.display.clone(),
});
}
}
}
{
let mut seen: HashSet<&str> = HashSet::new();
for mark in &marks {
let name = match &mark.kind {
MarkKind::Keep { name } => Some(name.as_str()),
MarkKind::New { name } => name.as_deref(),
MarkKind::NewAuto | MarkKind::NewCommand => None,
};
let Some(name) = name else { continue };
if !seen.insert(name) {
return Err(ExplicitSelectionError::DuplicateName {
name: name.to_string(),
});
}
if matches!(mark.kind, MarkKind::New { .. }) && reserved.contains(name) {
return Err(ExplicitSelectionError::NewNameExists {
name: name.to_string(),
});
}
}
}
let stack_idx = *candidate_stacks
.first()
.expect("candidate_stacks is non-empty: the empty case errors above");
let path: Vec<SegmentCommit> = graph.stacks[stack_idx]
.commits_trunk_to_tip()
.cloned()
.collect();
let position = |commit_id: &str| {
path.iter()
.position(|c| c.commit_id == commit_id)
.expect("every mark is on a candidate stack by construction")
};
let mut marks: Vec<(usize, Mark)> = marks
.into_iter()
.map(|m| (position(&m.commit_id), m))
.collect();
marks.sort_by_key(|(pos, _)| *pos);
let mut assignments = Vec::with_capacity(marks.len());
let mut used_names: HashSet<String> = HashSet::new();
let mut segment_start = 0usize;
for (pos, mark) in &marks {
let commit = &path[*pos];
let segment: Vec<&SegmentCommit> = path[segment_start..=*pos].iter().collect();
let (name, is_new) = match &mark.kind {
MarkKind::Keep { name } => (name.clone(), false),
MarkKind::New { name: Some(name) } => (name.clone(), true),
MarkKind::New { name: None } => {
(bookmark_gen::default_bookmark_name(&commit.change_id), true)
}
MarkKind::NewAuto => {
let taken = |n: &str| reserved.contains(n) || used_names.contains(n);
(
auto_name(&segment, &commit.change_id, auto_prefix, taken),
true,
)
}
MarkKind::NewCommand => {
let command = bookmark_command
.expect("--new-command without a configured command errors above");
let input = bookmark_gen::build_segment_input_from_commits(&segment);
let json =
serde_json::to_string(&input).expect("SegmentInput is always serializable");
let name =
bookmark_gen::run_command(command, &json, bookmark_gen::COMPUTING_TIMEOUT)
.await?;
bookmark_gen::validate_bookmark_name(&name)?;
(name, true)
}
};
if !used_names.insert(name.clone()) {
return Err(ExplicitSelectionError::DuplicateName { name });
}
if is_new && reserved.contains(name.as_str()) {
return Err(ExplicitSelectionError::NewNameExists { name });
}
assignments.push(BookmarkAssignment {
change_id: commit.change_id.clone(),
short_change_id: commit.short_change_id.clone(),
bookmark_name: name,
is_new,
});
segment_start = *pos + 1;
}
Ok(SelectionResult { assignments, path })
}
fn auto_name(
segment: &[&SegmentCommit],
change_id: &str,
auto_prefix: Option<&str>,
taken: impl Fn(&str) -> bool,
) -> String {
let data: Vec<tfidf::CommitData<'_>> = segment
.iter()
.map(|c| tfidf::CommitData {
description: &c.description,
files: &c.files,
})
.collect();
match bookmark_gen::tfidf_prefixed_name(&data, 0, auto_prefix) {
Some(name) if !taken(&name) => name,
_ => bookmark_gen::default_bookmark_name(change_id),
}
}
fn resolve_rev<'a>(
linearized: &[Vec<&'a SegmentCommit>],
rev: &str,
) -> Result<(&'a SegmentCommit, BTreeSet<usize>), ExplicitSelectionError> {
let mut matches: Vec<(&SegmentCommit, BTreeSet<usize>)> = Vec::new();
for (stack_idx, commits) in linearized.iter().enumerate() {
for commit in commits {
if commit.change_id.starts_with(rev) || commit.commit_id.starts_with(rev) {
match matches
.iter_mut()
.find(|(c, _)| c.commit_id == commit.commit_id)
{
Some((_, stacks)) => {
stacks.insert(stack_idx);
}
None => {
matches.push((commit, BTreeSet::from([stack_idx])));
}
}
}
}
}
let any_immutable = matches.iter().any(|(c, _)| c.is_immutable);
let mut mutable: Vec<(&SegmentCommit, BTreeSet<usize>)> = matches
.into_iter()
.filter(|(c, _)| !c.is_immutable)
.collect();
match mutable.len() {
0 if any_immutable => Err(ExplicitSelectionError::RevImmutable {
rev: rev.to_string(),
}),
0 => Err(ExplicitSelectionError::RevNotFound {
rev: rev.to_string(),
}),
1 => Ok(mutable.remove(0)),
_ => Err(ExplicitSelectionError::RevAmbiguous {
rev: rev.to_string(),
candidates: mutable
.iter()
.map(|(c, _)| {
let summary = c.description.lines().next().unwrap_or("").trim();
if summary.is_empty() {
c.short_change_id.clone()
} else {
format!("{} {summary:?}", c.short_change_id)
}
})
.collect(),
}),
}
}
fn resolve_keep<'a>(
linearized: &[Vec<&'a SegmentCommit>],
graph: &ChangeGraph,
name: &str,
) -> Result<(&'a SegmentCommit, BTreeSet<usize>), ExplicitSelectionError> {
let mut found: Option<(&SegmentCommit, BTreeSet<usize>)> = None;
for (stack_idx, stack) in graph.stacks.iter().enumerate() {
for segment in &stack.segments {
if !segment.bookmark_names.iter().any(|n| n == name) {
continue;
}
let Some(boundary) = segment.commits.first() else {
continue;
};
match &mut found {
Some((commit, stacks)) if commit.commit_id == boundary.commit_id => {
stacks.insert(stack_idx);
}
Some(_) => {
unreachable!("bookmark {name} resolves to two different commits");
}
None => {
let commit = linearized[stack_idx]
.iter()
.find(|c| c.commit_id == boundary.commit_id)
.expect("segment commits appear in the stack linearization");
found = Some((commit, BTreeSet::from([stack_idx])));
}
}
}
}
found.ok_or_else(|| ExplicitSelectionError::KeepNotFound {
name: name.to_string(),
})
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::graph::types::BookmarkSegment;
use crate::graph::types::BranchStack;
use crate::jj::types::Signature;
fn sig() -> Signature {
Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "2026-01-01T00:00:00Z".to_string(),
}
}
fn make_commit(change_id: &str, description: &str) -> SegmentCommit {
SegmentCommit {
commit_id: format!("c_{change_id}"),
change_id: change_id.to_string(),
description: description.to_string(),
author: sig(),
committer: sig(),
short_change_id: change_id[..4.min(change_id.len())].to_string(),
files: vec![format!("src/{change_id}.rs")],
is_immutable: false,
local_bookmark_names: vec![],
remote_bookmark_names: vec![],
}
}
fn make_segment(names: &[&str], change_id: &str, description: &str) -> BookmarkSegment {
let mut commit = make_commit(change_id, description);
commit.local_bookmark_names = names.iter().map(ToString::to_string).collect();
BookmarkSegment {
bookmark_names: names.iter().map(ToString::to_string).collect(),
change_id: change_id.to_string(),
commits: vec![commit],
}
}
fn make_graph(stacks: Vec<BranchStack>) -> ChangeGraph {
ChangeGraph {
adjacency_list: HashMap::new(),
stack_leaves: std::collections::HashSet::new(),
segments: HashMap::new(),
tainted_change_ids: std::collections::HashSet::new(),
bookmark_remote_states: HashMap::new(),
excluded_bookmarks: Vec::new(),
excluded_head_count: 0,
stacks,
}
}
fn single_stack_graph() -> ChangeGraph {
make_graph(vec![BranchStack {
segments: vec![
make_segment(&["base"], "aaaa1111", "base work"),
make_segment(&["mid"], "bbbb2222", "mid work"),
make_segment(&["leaf"], "cccc3333", "leaf work"),
],
}])
}
fn forked_graph() -> ChangeGraph {
let base = make_segment(&["base"], "aaaa1111", "base work");
make_graph(vec![
BranchStack {
segments: vec![
base.clone(),
make_segment(&["feat-x"], "xxxx1111", "x work"),
],
},
BranchStack {
segments: vec![base, make_segment(&["feat-y"], "yyyy1111", "y work")],
},
])
}
fn spec(f: impl FnOnce(&mut SelectionSpec)) -> SelectionSpec {
let mut s = SelectionSpec::default();
f(&mut s);
s
}
fn reserved_from_graph(graph: &ChangeGraph) -> HashSet<String> {
graph
.stacks
.iter()
.flat_map(BranchStack::commits_trunk_to_tip)
.flat_map(|c| c.local_bookmark_names.iter())
.cloned()
.collect()
}
async fn resolve(
graph: &ChangeGraph,
s: &SelectionSpec,
) -> Result<SelectionResult, ExplicitSelectionError> {
let reserved = reserved_from_graph(graph);
resolve_with_reserved(graph, s, &reserved).await
}
async fn resolve_with_reserved(
graph: &ChangeGraph,
s: &SelectionSpec,
reserved: &HashSet<String>,
) -> Result<SelectionResult, ExplicitSelectionError> {
resolve_bookmarks_explicitly(graph, s, None, None, reserved).await
}
fn names(result: &SelectionResult) -> Vec<(&str, bool)> {
result
.assignments
.iter()
.map(|a| (a.bookmark_name.as_str(), a.is_new))
.collect()
}
#[tokio::test]
async fn keep_subset_orders_trunk_to_leaf() {
let graph = single_stack_graph();
let s = spec(|s| s.keep = vec!["leaf".into(), "base".into()]);
let result = resolve(&graph, &s).await.unwrap();
assert_eq!(names(&result), vec![("base", false), ("leaf", false)]);
assert_eq!(result.path.len(), 3);
assert_eq!(result.assignments.last().unwrap().bookmark_name, "leaf");
}
#[tokio::test]
async fn keep_repeated_name_dedupes_silently() {
let graph = single_stack_graph();
let s = spec(|s| s.keep = vec!["mid".into(), "mid".into()]);
let result = resolve(&graph, &s).await.unwrap();
assert_eq!(names(&result), vec![("mid", false)]);
}
#[tokio::test]
async fn keep_not_found() {
let graph = single_stack_graph();
let s = spec(|s| s.keep = vec!["nope".into()]);
let err = resolve(&graph, &s).await.unwrap_err();
assert!(matches!(err, ExplicitSelectionError::KeepNotFound { name } if name == "nope"));
}
#[tokio::test]
async fn rev_resolves_by_change_id_and_commit_id_prefix() {
let graph = single_stack_graph();
let s = spec(|s| {
s.new = vec![NewBookmarkSpec {
rev: "bbbb".into(),
name: Some("by-change".into()),
}];
});
let result = resolve(&graph, &s).await.unwrap();
assert_eq!(result.assignments[0].change_id, "bbbb2222");
let s = spec(|s| {
s.new = vec![NewBookmarkSpec {
rev: "c_bbbb".into(),
name: Some("by-commit".into()),
}];
});
let result = resolve(&graph, &s).await.unwrap();
assert_eq!(result.assignments[0].change_id, "bbbb2222");
}
#[tokio::test]
async fn rev_not_found() {
let graph = single_stack_graph();
let s = spec(|s| {
s.new = vec![NewBookmarkSpec {
rev: "zzzz".into(),
name: None,
}];
});
let err = resolve(&graph, &s).await.unwrap_err();
assert!(matches!(err, ExplicitSelectionError::RevNotFound { rev } if rev == "zzzz"));
}
#[tokio::test]
async fn rev_ambiguous_across_distinct_commits() {
let graph = make_graph(vec![BranchStack {
segments: vec![
make_segment(&["a"], "dddd1111", "one"),
make_segment(&["b"], "dddd2222", "two"),
],
}]);
let s = spec(|s| {
s.new_auto = vec!["dddd".into()];
});
let err = resolve(&graph, &s).await.unwrap_err();
assert!(matches!(
err,
ExplicitSelectionError::RevAmbiguous { candidates, .. } if candidates.len() == 2
));
}
#[tokio::test]
async fn rev_shared_clone_is_not_ambiguous() {
let graph = forked_graph();
let s = spec(|s| {
s.new = vec![NewBookmarkSpec {
rev: "aaaa".into(),
name: Some("re-base".into()),
}];
});
let result = resolve(&graph, &s).await.unwrap();
assert_eq!(names(&result), vec![("re-base", true)]);
assert_eq!(result.assignments[0].change_id, "aaaa1111");
}
#[tokio::test]
async fn rev_immutable_errors() {
let mut graph = single_stack_graph();
graph.stacks[0].segments[0].commits[0].is_immutable = true;
let s = spec(|s| {
s.new = vec![NewBookmarkSpec {
rev: "aaaa".into(),
name: None,
}];
});
let err = resolve(&graph, &s).await.unwrap_err();
assert!(matches!(err, ExplicitSelectionError::RevImmutable { .. }));
}
#[tokio::test]
async fn marks_on_diverging_stacks_error() {
let graph = forked_graph();
let s = spec(|s| s.keep = vec!["feat-x".into(), "feat-y".into()]);
let err = resolve(&graph, &s).await.unwrap_err();
assert!(matches!(
err,
ExplicitSelectionError::MarksNotColinear { marks } if marks.len() == 2
));
}
#[tokio::test]
async fn marks_on_shared_prefix_are_colinear() {
let graph = forked_graph();
let s = spec(|s| s.keep = vec!["base".into(), "feat-x".into()]);
let result = resolve(&graph, &s).await.unwrap();
assert_eq!(names(&result), vec![("base", false), ("feat-x", false)]);
}
#[tokio::test]
async fn two_marks_on_one_commit_error() {
let graph = single_stack_graph();
let s = spec(|s| {
s.keep = vec!["mid".into()];
s.new = vec![NewBookmarkSpec {
rev: "bbbb".into(),
name: Some("other".into()),
}];
});
let err = resolve(&graph, &s).await.unwrap_err();
assert!(matches!(
err,
ExplicitSelectionError::DuplicateMarkOnCommit { .. }
));
}
#[tokio::test]
async fn duplicate_explicit_names_error() {
let graph = single_stack_graph();
let s = spec(|s| {
s.new = vec![
NewBookmarkSpec {
rev: "aaaa".into(),
name: Some("same".into()),
},
NewBookmarkSpec {
rev: "cccc".into(),
name: Some("same".into()),
},
];
});
let err = resolve(&graph, &s).await.unwrap_err();
assert!(matches!(
err,
ExplicitSelectionError::DuplicateName { name } if name == "same"
));
}
#[tokio::test]
async fn new_name_matching_existing_bookmark_errors() {
let graph = single_stack_graph();
let s = spec(|s| {
s.new = vec![NewBookmarkSpec {
rev: "bbbb".into(),
name: Some("leaf".into()),
}];
});
let err = resolve(&graph, &s).await.unwrap_err();
assert!(matches!(
err,
ExplicitSelectionError::NewNameExists { name } if name == "leaf"
));
}
#[tokio::test]
async fn new_without_name_uses_stakk_default() {
let graph = single_stack_graph();
let s = spec(|s| {
s.new = vec![NewBookmarkSpec {
rev: "bbbb".into(),
name: None,
}];
});
let result = resolve(&graph, &s).await.unwrap();
assert_eq!(
result.assignments[0].bookmark_name,
bookmark_gen::default_bookmark_name("bbbb2222"),
);
assert!(result.assignments[0].is_new);
}
#[tokio::test]
async fn new_auto_uses_dynamic_segment_and_prefix() {
let mut graph = single_stack_graph();
graph.stacks[0].segments[0].commits[0].description = "database caching layer".into();
graph.stacks[0].segments[1].commits[0].description = "login page styling".into();
let s = spec(|s| s.new_auto = vec!["bbbb".into()]);
let reserved = reserved_from_graph(&graph);
let result = resolve_bookmarks_explicitly(&graph, &s, Some("gb-"), None, &reserved)
.await
.unwrap();
let name = &result.assignments[0].bookmark_name;
assert!(name.starts_with("gb-"), "prefix applied: {name}");
assert!(
name.contains("database") || name.contains("caching") || name.contains("login"),
"tf-idf terms from the dynamic segment: {name}",
);
}
#[tokio::test]
async fn new_auto_falls_back_to_default_name() {
let mut graph = single_stack_graph();
graph.stacks[0].segments[0].commits[0].description = String::new();
graph.stacks[0].segments[0].commits[0].files = vec![];
let s = spec(|s| s.new_auto = vec!["aaaa".into()]);
let result = resolve(&graph, &s).await.unwrap();
assert_eq!(
result.assignments[0].bookmark_name,
bookmark_gen::default_bookmark_name("aaaa1111"),
);
}
#[tokio::test]
async fn second_auto_mark_with_identical_input_falls_back() {
let mut graph = single_stack_graph();
graph.stacks[0].segments[0].commits[0].description = "identical words here".into();
graph.stacks[0].segments[0].commits[0].files = vec![];
graph.stacks[0].segments[1].commits[0].description = "identical words here".into();
graph.stacks[0].segments[1].commits[0].files = vec![];
let s = spec(|s| s.new_auto = vec!["aaaa".into(), "bbbb".into()]);
let result = resolve(&graph, &s).await.unwrap();
assert_eq!(
result.assignments[1].bookmark_name,
bookmark_gen::default_bookmark_name("bbbb2222"),
);
assert_ne!(
result.assignments[0].bookmark_name,
result.assignments[1].bookmark_name,
);
}
#[test]
fn empty_rev_for_auto_and_command_flags_errors() {
for flag in ["--new-auto", "--new-command"] {
let args = parse_submit(&["stakk", "submit", flag, ""]);
let err = SelectionSpec::from_args(&args).unwrap_err();
assert!(
matches!(err, ExplicitSelectionError::EmptyRev { .. }),
"expected EmptyRev for {flag}",
);
}
}
#[tokio::test]
async fn rev_prefix_shared_with_immutable_commit_is_not_ambiguous() {
let mut graph = single_stack_graph();
graph.stacks[0].segments[0].commits[0].change_id = "baaa1111".into();
graph.stacks[0].segments[0].commits[0].is_immutable = true;
let s = spec(|s| {
s.new = vec![NewBookmarkSpec {
rev: "b".into(),
name: Some("picked".into()),
}];
});
let result = resolve(&graph, &s).await.unwrap();
assert_eq!(result.assignments[0].change_id, "bbbb2222");
let s = spec(|s| {
s.new = vec![NewBookmarkSpec {
rev: "ba".into(),
name: Some("picked".into()),
}];
});
let err = resolve(&graph, &s).await.unwrap_err();
assert!(matches!(err, ExplicitSelectionError::RevImmutable { .. }));
}
#[tokio::test]
async fn new_default_name_colliding_with_existing_bookmark_errors() {
let mut graph = single_stack_graph();
let taken = bookmark_gen::default_bookmark_name("bbbb2222");
graph.stacks[0].segments[0].bookmark_names = vec![taken.clone()];
graph.stacks[0].segments[0].commits[0].local_bookmark_names = vec![taken];
let s = spec(|s| {
s.new = vec![NewBookmarkSpec {
rev: "bbbb".into(),
name: None,
}];
});
let err = resolve(&graph, &s).await.unwrap_err();
assert!(matches!(err, ExplicitSelectionError::NewNameExists { .. }));
}
#[tokio::test]
async fn auto_mark_colliding_with_existing_bookmark_falls_back() {
let mut graph = single_stack_graph();
graph.stacks[0].segments[1].commits[0].description = "database caching layer".into();
graph.stacks[0].segments[1].commits[0].files = vec![];
let s = spec(|s| s.new_auto = vec!["bbbb".into()]);
let derived = resolve(&graph, &s)
.await
.unwrap()
.assignments
.swap_remove(0)
.bookmark_name;
graph.stacks[0].segments[0].bookmark_names = vec![derived.clone()];
graph.stacks[0].segments[0].commits[0].local_bookmark_names = vec![derived];
let result = resolve(&graph, &s).await.unwrap();
assert_eq!(
result.assignments[0].bookmark_name,
bookmark_gen::default_bookmark_name("bbbb2222"),
);
}
#[tokio::test]
async fn new_name_colliding_with_bookmark_outside_the_graph_errors() {
let graph = single_stack_graph();
assert!(
!reserved_from_graph(&graph).contains("main"),
"precondition: trunk's bookmark is invisible to the graph",
);
let mut reserved = reserved_from_graph(&graph);
reserved.insert("main".to_string());
let s = spec(|s| {
s.new = vec![NewBookmarkSpec {
rev: "bbbb".into(),
name: Some("main".into()),
}];
});
let err = resolve_with_reserved(&graph, &s, &reserved)
.await
.unwrap_err();
assert!(matches!(
err,
ExplicitSelectionError::NewNameExists { name } if name == "main"
));
}
#[tokio::test]
async fn new_default_name_colliding_outside_the_graph_errors() {
let graph = single_stack_graph();
let mut reserved = reserved_from_graph(&graph);
reserved.insert(bookmark_gen::default_bookmark_name("bbbb2222"));
let s = spec(|s| {
s.new = vec![NewBookmarkSpec {
rev: "bbbb".into(),
name: None,
}];
});
let err = resolve_with_reserved(&graph, &s, &reserved)
.await
.unwrap_err();
assert!(matches!(err, ExplicitSelectionError::NewNameExists { .. }));
}
#[tokio::test]
async fn auto_mark_colliding_outside_the_graph_falls_back() {
let mut graph = single_stack_graph();
graph.stacks[0].segments[1].commits[0].description = "database caching layer".into();
graph.stacks[0].segments[1].commits[0].files = vec![];
let s = spec(|s| s.new_auto = vec!["bbbb".into()]);
let derived = resolve(&graph, &s)
.await
.unwrap()
.assignments
.swap_remove(0)
.bookmark_name;
let mut reserved = reserved_from_graph(&graph);
reserved.insert(derived);
let result = resolve_with_reserved(&graph, &s, &reserved).await.unwrap();
assert_eq!(
result.assignments[0].bookmark_name,
bookmark_gen::default_bookmark_name("bbbb2222"),
);
}
#[tokio::test]
async fn keep_is_not_blocked_by_the_reserved_set() {
let graph = single_stack_graph();
let reserved = reserved_from_graph(&graph);
assert!(reserved.contains("leaf"));
let s = spec(|s| s.keep = vec!["leaf".into()]);
let result = resolve_with_reserved(&graph, &s, &reserved).await.unwrap();
assert_eq!(names(&result), vec![("leaf", false)]);
}
#[tokio::test]
async fn new_command_unconfigured_errors() {
let graph = single_stack_graph();
let s = spec(|s| s.new_command = vec!["bbbb".into()]);
let err = resolve(&graph, &s).await.unwrap_err();
assert!(matches!(
err,
ExplicitSelectionError::BookmarkCommandNotConfigured
));
}
#[cfg(unix)]
#[tokio::test]
async fn new_command_runs_and_names() {
let graph = single_stack_graph();
let s = spec(|s| s.new_command = vec!["bbbb".into()]);
let reserved = reserved_from_graph(&graph);
let result =
resolve_bookmarks_explicitly(&graph, &s, None, Some("echo from-command"), &reserved)
.await
.unwrap();
assert_eq!(names(&result), vec![("from-command", true)]);
}
#[cfg(unix)]
#[tokio::test]
async fn new_command_name_colliding_outside_the_graph_errors() {
let graph = single_stack_graph();
let mut reserved = reserved_from_graph(&graph);
reserved.insert("main".to_string());
let s = spec(|s| s.new_command = vec!["bbbb".into()]);
let err = resolve_bookmarks_explicitly(&graph, &s, None, Some("echo main"), &reserved)
.await
.unwrap_err();
assert!(matches!(
err,
ExplicitSelectionError::NewNameExists { name } if name == "main"
));
}
#[cfg(unix)]
#[tokio::test]
async fn new_command_failure_propagates() {
let graph = single_stack_graph();
let s = spec(|s| s.new_command = vec!["bbbb".into()]);
let reserved = reserved_from_graph(&graph);
let err = resolve_bookmarks_explicitly(&graph, &s, None, Some("false"), &reserved)
.await
.unwrap_err();
assert!(matches!(
err,
ExplicitSelectionError::Gen(BookmarkGenError::CommandFailed { .. })
));
}
#[cfg(unix)]
#[tokio::test]
async fn new_command_receives_dynamic_segment_json() {
use std::io::Write;
let graph = single_stack_graph();
let tmpdir = std::env::temp_dir();
let script = tmpdir.join("stakk_explicit_stdin.sh");
let capture = tmpdir.join("stakk_explicit_stdin_capture.json");
{
let mut f = std::fs::File::create(&script).unwrap();
writeln!(f, "#!/bin/sh").unwrap();
writeln!(f, "cat > {}", capture.display()).unwrap();
writeln!(f, "echo captured-name").unwrap();
}
let s = spec(|s| s.new_command = vec!["bbbb".into()]);
let reserved = reserved_from_graph(&graph);
let result = resolve_bookmarks_explicitly(
&graph,
&s,
None,
Some(&format!("sh {}", script.display())),
&reserved,
)
.await
.unwrap();
assert_eq!(names(&result), vec![("captured-name", true)]);
let captured = std::fs::read_to_string(&capture).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&captured).unwrap();
assert_eq!(parsed["schema_version"], 1);
let commits = parsed["commits"].as_array().unwrap();
assert_eq!(commits.len(), 2, "base folds into mid's segment");
assert_eq!(commits[0]["change_id"], "aaaa1111");
assert_eq!(commits[1]["change_id"], "bbbb2222");
let _ = std::fs::remove_file(&script);
let _ = std::fs::remove_file(&capture);
}
#[tokio::test]
async fn empty_graph_errors_no_stacks() {
let graph = make_graph(vec![]);
let s = spec(|s| s.keep = vec!["base".into()]);
let err = resolve(&graph, &s).await.unwrap_err();
assert!(matches!(err, ExplicitSelectionError::NoStacks));
}
#[tokio::test]
async fn resolves_into_folding_analysis() {
let graph = single_stack_graph();
let s = spec(|s| s.keep = vec!["base".into(), "leaf".into()]);
let result = resolve(&graph, &s).await.unwrap();
let analysis =
crate::submit::analysis_from_selection(&result.path, &result.assignments, "main")
.unwrap();
assert_eq!(analysis.segments.len(), 2);
assert_eq!(analysis.segments[0].bookmark_names, vec!["base"]);
assert_eq!(analysis.segments[1].bookmark_names, vec!["leaf"]);
let leaf_ids: Vec<&str> = analysis.segments[1]
.commits
.iter()
.map(|c| c.change_id.as_str())
.collect();
assert_eq!(leaf_ids, vec!["cccc3333", "bbbb2222"]);
}
fn parse_submit(args: &[&str]) -> crate::cli::submit::SubmitArgs {
use clap::Parser;
let cli = crate::cli::Cli::try_parse_from(args).unwrap();
match cli.command {
Some(crate::cli::Commands::Submit(a)) => *a,
other => panic!("expected Submit, got {other:?}"),
}
}
#[test]
fn from_args_splits_rev_name() {
let args = parse_submit(&["stakk", "submit", "--new", "abc=my-name", "--new", "def"]);
let spec = SelectionSpec::from_args(&args).unwrap();
assert_eq!(
spec.new,
vec![
NewBookmarkSpec {
rev: "abc".into(),
name: Some("my-name".into()),
},
NewBookmarkSpec {
rev: "def".into(),
name: None,
},
],
);
}
#[test]
fn from_args_rejects_empty_rev_or_name() {
for bad in ["=name", "rev=", ""] {
let args = parse_submit(&["stakk", "submit", "--new", bad]);
let err = SelectionSpec::from_args(&args).unwrap_err();
assert!(
matches!(err, ExplicitSelectionError::InvalidNewSpec { .. }),
"expected InvalidNewSpec for {bad:?}",
);
}
}
#[test]
fn from_args_validates_explicit_names() {
let args = parse_submit(&["stakk", "submit", "--new", "abc=has space"]);
let err = SelectionSpec::from_args(&args).unwrap_err();
assert!(matches!(
err,
ExplicitSelectionError::Gen(BookmarkGenError::InvalidName { .. })
));
}
#[test]
fn spec_is_empty() {
let args = parse_submit(&["stakk", "submit"]);
assert!(SelectionSpec::from_args(&args).unwrap().is_empty());
let args = parse_submit(&["stakk", "submit", "--keep", "base"]);
assert!(!SelectionSpec::from_args(&args).unwrap().is_empty());
}
}