#[derive(Debug, Clone, PartialEq)]
pub enum PushPreviewAction {
MoveForward {
bookmark: String,
from: String,
to: String,
},
MoveSideways {
bookmark: String,
from: String,
to: String,
},
MoveBackward {
bookmark: String,
from: String,
to: String,
},
Add { bookmark: String, to: String },
Delete { bookmark: String, from: String },
}
#[derive(Debug, Clone, PartialEq)]
pub enum PushPreviewResult {
Changes(Vec<PushPreviewAction>),
NothingChanged,
Unparsed,
}
pub fn parse_push_dry_run(output: &str) -> PushPreviewResult {
if output.contains("Nothing changed.") {
return PushPreviewResult::NothingChanged;
}
let mut actions = Vec::new();
for line in output.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("Move forward bookmark ") {
if let Some((name, hashes)) = rest.split_once(" from ")
&& let Some((from, to)) = hashes.split_once(" to ")
{
actions.push(PushPreviewAction::MoveForward {
bookmark: name.to_string(),
from: from.to_string(),
to: to.to_string(),
});
}
} else if let Some(rest) = line.strip_prefix("Move sideways bookmark ") {
if let Some((name, hashes)) = rest.split_once(" from ")
&& let Some((from, to)) = hashes.split_once(" to ")
{
actions.push(PushPreviewAction::MoveSideways {
bookmark: name.to_string(),
from: from.to_string(),
to: to.to_string(),
});
}
} else if let Some(rest) = line.strip_prefix("Move backward bookmark ") {
if let Some((name, hashes)) = rest.split_once(" from ")
&& let Some((from, to)) = hashes.split_once(" to ")
{
actions.push(PushPreviewAction::MoveBackward {
bookmark: name.to_string(),
from: from.to_string(),
to: to.to_string(),
});
}
} else if let Some(rest) = line.strip_prefix("Add bookmark ") {
if let Some((name, hash)) = rest.split_once(" to ") {
actions.push(PushPreviewAction::Add {
bookmark: name.to_string(),
to: hash.to_string(),
});
}
} else if let Some(rest) = line.strip_prefix("Delete bookmark ") {
if let Some((name, hash)) = rest.split_once(" from ") {
actions.push(PushPreviewAction::Delete {
bookmark: name.to_string(),
from: hash.to_string(),
});
}
}
}
if actions.is_empty() {
PushPreviewResult::Unparsed
} else {
PushPreviewResult::Changes(actions)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SkippedRef {
pub name: String,
pub reason: String,
}
pub fn parse_push_skipped(stderr: &str) -> Vec<SkippedRef> {
stderr
.lines()
.filter_map(|line| {
let rest = line.strip_prefix("Won't push ")?;
let (_kind, after_kind) = rest.split_once(' ')?;
let (name_part, reason_part) = after_kind.split_once(": ")?;
let name = name_part.trim().trim_matches('"').to_string();
let reason = reason_part.trim().to_string();
Some(SkippedRef { name, reason })
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_move_forward() {
let output = "Changes to push to origin:\n Move forward bookmark main from 6c733e1ae096 to f70230817ff4\nDry-run requested, not pushing.\n";
let result = parse_push_dry_run(output);
match result {
PushPreviewResult::Changes(actions) => {
assert_eq!(actions.len(), 1);
assert_eq!(
actions[0],
PushPreviewAction::MoveForward {
bookmark: "main".to_string(),
from: "6c733e1ae096".to_string(),
to: "f70230817ff4".to_string(),
}
);
}
_ => panic!("Expected Changes"),
}
}
#[test]
fn test_parse_add_bookmark() {
let output = "Changes to push to origin:\n Add bookmark feature/new to f70230817ff4\nDry-run requested, not pushing.\n";
let result = parse_push_dry_run(output);
match result {
PushPreviewResult::Changes(actions) => {
assert_eq!(actions.len(), 1);
assert_eq!(
actions[0],
PushPreviewAction::Add {
bookmark: "feature/new".to_string(),
to: "f70230817ff4".to_string(),
}
);
}
_ => panic!("Expected Changes"),
}
}
#[test]
fn test_parse_delete_bookmark() {
let output = "Changes to push to origin:\n Delete bookmark old-branch from 6c733e1ae096\nDry-run requested, not pushing.\n";
let result = parse_push_dry_run(output);
match result {
PushPreviewResult::Changes(actions) => {
assert_eq!(actions.len(), 1);
assert_eq!(
actions[0],
PushPreviewAction::Delete {
bookmark: "old-branch".to_string(),
from: "6c733e1ae096".to_string(),
}
);
}
_ => panic!("Expected Changes"),
}
}
#[test]
fn test_parse_multiple_changes() {
let output = "Changes to push to origin:\n Move forward bookmark another-branch from 6c733e1ae096 to f70230817ff4\n Add bookmark fuga to bfeefc809de1\n Add bookmark main to f70230817ff4\nDry-run requested, not pushing.\n";
let result = parse_push_dry_run(output);
match result {
PushPreviewResult::Changes(actions) => {
assert_eq!(actions.len(), 3);
assert!(matches!(&actions[0], PushPreviewAction::MoveForward { .. }));
assert!(
matches!(&actions[1], PushPreviewAction::Add { bookmark, .. } if bookmark == "fuga")
);
assert!(
matches!(&actions[2], PushPreviewAction::Add { bookmark, .. } if bookmark == "main")
);
}
_ => panic!("Expected Changes"),
}
}
#[test]
fn test_parse_nothing_changed() {
let output =
"Bookmark test-feature@origin already matches test-feature\nNothing changed.\n";
let result = parse_push_dry_run(output);
assert_eq!(result, PushPreviewResult::NothingChanged);
}
#[test]
fn test_parse_empty_output() {
let result = parse_push_dry_run("");
assert_eq!(result, PushPreviewResult::Unparsed);
}
#[test]
fn test_parse_unknown_output() {
let result = parse_push_dry_run("Some unexpected jj output format\n");
assert_eq!(result, PushPreviewResult::Unparsed);
}
#[test]
fn test_parse_move_sideways() {
let output = "Changes to push to origin:\n Move sideways bookmark feature from 6c733e1ae096 to f70230817ff4\nDry-run requested, not pushing.\n";
let result = parse_push_dry_run(output);
match result {
PushPreviewResult::Changes(actions) => {
assert_eq!(actions.len(), 1);
assert_eq!(
actions[0],
PushPreviewAction::MoveSideways {
bookmark: "feature".to_string(),
from: "6c733e1ae096".to_string(),
to: "f70230817ff4".to_string(),
}
);
}
_ => panic!("Expected Changes"),
}
}
#[test]
fn test_parse_move_backward() {
let output = "Changes to push to origin:\n Move backward bookmark main from f70230817ff4 to 6c733e1ae096\nDry-run requested, not pushing.\n";
let result = parse_push_dry_run(output);
match result {
PushPreviewResult::Changes(actions) => {
assert_eq!(actions.len(), 1);
assert_eq!(
actions[0],
PushPreviewAction::MoveBackward {
bookmark: "main".to_string(),
from: "f70230817ff4".to_string(),
to: "6c733e1ae096".to_string(),
}
);
}
_ => panic!("Expected Changes"),
}
}
#[test]
fn test_parse_mixed_actions_with_force() {
let output = "Changes to push to origin:\n Move forward bookmark main from 6c733e1ae096 to f70230817ff4\n Move sideways bookmark feature from aaa111bbb222 to ccc333ddd444\nDry-run requested, not pushing.\n";
let result = parse_push_dry_run(output);
match result {
PushPreviewResult::Changes(actions) => {
assert_eq!(actions.len(), 2);
assert!(
matches!(&actions[0], PushPreviewAction::MoveForward { bookmark, .. } if bookmark == "main")
);
assert!(
matches!(&actions[1], PushPreviewAction::MoveSideways { bookmark, .. } if bookmark == "feature")
);
}
_ => panic!("Expected Changes"),
}
}
#[test]
fn test_parse_push_skipped_empty() {
assert!(parse_push_skipped("").is_empty());
assert!(parse_push_skipped("Changes to push to origin:\n").is_empty());
}
#[test]
fn test_parse_push_skipped_private_bookmark() {
let stderr = "Won't push bookmark feature: commit abc123 is private\n";
let skipped = parse_push_skipped(stderr);
assert_eq!(skipped.len(), 1);
assert_eq!(skipped[0].name, "feature");
assert_eq!(skipped[0].reason, "commit abc123 is private");
}
#[test]
fn test_parse_push_skipped_multiple() {
let stderr = "Won't push bookmark foo: commit aaa is private\n\
Changes to push to origin:\n \
Move forward bookmark main from 111 to 222\n\
Won't push bookmark bar: commit bbb has conflicts\n\
Won't push tag v1: commit ccc has conflicts\n";
let skipped = parse_push_skipped(stderr);
assert_eq!(skipped.len(), 3);
assert_eq!(skipped[0].name, "foo");
assert_eq!(skipped[1].name, "bar");
assert_eq!(skipped[2].name, "v1");
}
#[test]
fn test_parse_push_skipped_quoted_name() {
let stderr = "Won't push bookmark \"feat/x\": commit abc has conflicts\n";
let skipped = parse_push_skipped(stderr);
assert_eq!(skipped.len(), 1);
assert_eq!(skipped[0].name, "feat/x");
}
}