use std::process::{Command, ExitStatus, Stdio};
use anyhow::{Context, Result};
const SEP: u8 = 0x1f;
const TEMPLATE: &str = concat!(
"\"\\x1f\" ++ ",
"change_id.short() ++ \"\\x1f\" ++ ",
"change_id.shortest().prefix() ++ \"\\x1f\" ++ ",
"commit_id.short() ++ \"\\x1f\" ++ ",
"commit_id.shortest().prefix() ++ \"\\x1f\" ++ ",
"(separate(\" \", \
format_short_change_id(self.change_id()), \
bookmarks, \
if(conflict, label(\"conflict\", \"conflict\")), \
if(empty, label(\"empty\", \"(empty)\")), \
description.first_line() \
))",
);
pub struct Row {
pub change_id_short: String,
pub change_id_prefix: String,
pub commit_id_short: String,
pub commit_id_prefix: String,
pub plain: String,
pub styled: Vec<u8>,
}
impl Row {
pub fn is_connector(&self) -> bool {
self.change_id_short.is_empty()
}
}
pub fn capture_log() -> Result<Vec<Row>> {
let output = Command::new("jj")
.args([
"--ignore-working-copy",
"--config",
"ui.log-word-wrap=false",
"log",
"--color=always",
"-r",
"all()",
"--limit",
"1000",
"-T",
TEMPLATE,
])
.stderr(Stdio::inherit())
.output()
.context("failed to spawn jj — is it installed and on PATH?")?;
if !output.status.success() {
std::process::exit(output.status.code().unwrap_or(1));
}
let mut rows = Vec::new();
for raw in output.stdout.split(|&b| b == b'\n') {
if raw.is_empty() {
continue;
}
rows.push(parse_row(raw));
}
Ok(rows)
}
fn parse_row(bytes: &[u8]) -> Row {
let mut parts: Vec<&[u8]> = Vec::with_capacity(6);
let mut start = 0;
for (i, &b) in bytes.iter().enumerate() {
if b == SEP {
parts.push(&bytes[start..i]);
start = i + 1;
if parts.len() == 5 {
break;
}
}
}
if parts.len() < 5 {
let styled = bytes.to_vec();
let plain = strip_ansi(&styled);
return Row {
change_id_short: String::new(),
change_id_prefix: String::new(),
commit_id_short: String::new(),
commit_id_prefix: String::new(),
plain,
styled,
};
}
parts.push(&bytes[start..]);
let graph = parts[0];
let change_id_short = strip_ansi(parts[1]);
let change_id_prefix = strip_ansi(parts[2]);
let commit_id_short = strip_ansi(parts[3]);
let commit_id_prefix = strip_ansi(parts[4]);
let payload = parts[5];
let mut styled = Vec::with_capacity(graph.len() + payload.len());
styled.extend_from_slice(graph);
styled.extend_from_slice(payload);
let plain = strip_ansi(&styled);
Row {
change_id_short,
change_id_prefix,
commit_id_short,
commit_id_prefix,
plain,
styled,
}
}
pub fn strip_ansi(bytes: &[u8]) -> String {
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && bytes.get(i + 1) == Some(&b'[') {
i += 2;
while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
i += 1;
}
if i < bytes.len() {
i += 1;
}
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8_lossy(&out).into_owned()
}
pub struct PreviewParts {
pub description: Vec<u8>,
pub files: Vec<u8>,
}
pub fn show_summary(change_id: &str) -> PreviewParts {
let out = Command::new("jj")
.args([
"--ignore-working-copy",
"show",
"--color=always",
"--summary",
"-r",
change_id,
])
.stderr(Stdio::null())
.output();
match out {
Ok(o) if o.status.success() => parse_show_summary(&o.stdout),
_ => PreviewParts {
description: b"(preview unavailable)".to_vec(),
files: Vec::new(),
},
}
}
fn parse_show_summary(bytes: &[u8]) -> PreviewParts {
let body: &[u8] = if let Some(pos) = bytes.windows(2).position(|w| w == b"\n\n") {
&bytes[pos + 2..]
} else {
bytes
};
let lines: Vec<&[u8]> = body.split(|&b| b == b'\n').collect();
let mut desc_end = 0;
for (i, line) in lines.iter().enumerate() {
if line.is_empty() || line.starts_with(b" ") {
desc_end = i + 1;
} else {
break;
}
}
while desc_end > 0 && lines[desc_end - 1].is_empty() {
desc_end -= 1;
}
let mut desc_start = if desc_end > 0 { 1 } else { 0 };
while desc_start < desc_end && lines[desc_start].is_empty() {
desc_start += 1;
}
let mut description = Vec::new();
for (i, line) in lines[desc_start..desc_end].iter().enumerate() {
if i > 0 {
description.push(b'\n');
}
let stripped: &[u8] = line.strip_prefix(b" ").unwrap_or(line);
description.extend_from_slice(stripped);
}
let mut files = Vec::new();
let mut first = true;
for line in lines.iter().skip(desc_end) {
if line.is_empty() {
continue;
}
if !first {
files.push(b'\n');
}
files.extend_from_slice(line);
first = false;
}
PreviewParts { description, files }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RevsetFlag {
Revision,
To,
}
impl RevsetFlag {
pub fn as_str(self) -> &'static str {
match self {
RevsetFlag::Revision => "-r",
RevsetFlag::To => "--to",
}
}
}
#[derive(Debug, Clone)]
pub struct RevsetTarget {
pub leaf: Vec<String>,
pub passthrough: Vec<String>,
pub flag: Option<RevsetFlag>,
pub user_supplied_revset: bool,
}
pub fn resolve_target(args: &[String]) -> Result<RevsetTarget> {
debug_assert!(!args.is_empty(), "resolve_target requires at least one arg");
let first_help = match run_help(std::slice::from_ref(&args[0]))? {
Some(h) => h,
None => {
return Ok(RevsetTarget {
leaf: vec![args[0].clone()],
passthrough: args[1..].to_vec(),
flag: Some(RevsetFlag::Revision),
user_supplied_revset: has_revset_flag(&args[1..]),
});
}
};
let (leaf, leaf_help) = resolve_leaf(args, first_help)?;
let passthrough = args[leaf.len()..].to_vec();
let flag = parse_revset_flag(&leaf_help);
let user_supplied_revset = passthrough_pins_revset(&passthrough, &leaf_help);
Ok(RevsetTarget {
leaf,
passthrough,
flag,
user_supplied_revset,
})
}
fn resolve_leaf(args: &[String], first_help: String) -> Result<(Vec<String>, String)> {
let mut leaf = vec![args[0].clone()];
let mut help = first_help;
for arg in &args[1..] {
if arg.starts_with('-') {
break;
}
let children = parse_commands_section(&help);
if !children.contains(arg.as_str()) {
break;
}
let mut candidate = leaf.clone();
candidate.push(arg.clone());
match run_help(&candidate)? {
Some(next_help) => {
leaf = candidate;
help = next_help;
}
None => break,
}
}
Ok((leaf, help))
}
fn run_help(path: &[String]) -> Result<Option<String>> {
let output = Command::new("jj")
.args(path)
.arg("--help")
.stderr(Stdio::null())
.output()
.context("failed to spawn jj for preflight")?;
if !output.status.success() {
return Ok(None);
}
Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
}
fn parse_commands_section(help: &str) -> std::collections::HashSet<String> {
let mut set = std::collections::HashSet::new();
let mut in_commands = false;
for line in help.lines() {
if line == "Commands:" {
in_commands = true;
continue;
}
if !in_commands {
continue;
}
if line.is_empty() {
break;
}
if !line.starts_with(' ') {
break;
}
let trimmed = line.trim_start();
let indent = line.len() - trimmed.len();
if indent != 2 {
continue;
}
if let Some(name) = trimmed.split_whitespace().next() {
set.insert(name.to_string());
}
scan_aliases(line, &mut set);
}
set
}
fn scan_aliases(line: &str, names: &mut std::collections::HashSet<String>) {
let Some(start) = line.find("[aliases:") else {
return;
};
let after = &line[start + "[aliases:".len()..];
let Some(end) = after.find(']') else {
return;
};
for a in after[..end].split(',') {
let n = a.trim();
if !n.is_empty() {
names.insert(n.to_string());
}
}
}
pub fn parse_revset_flag(help: &str) -> Option<RevsetFlag> {
let opts = parse_options(help);
for opt in &opts {
if opt.is_revset
&& opt
.longs
.iter()
.any(|n| n == "--revision" || n == "--revisions")
{
return Some(RevsetFlag::Revision);
}
}
for opt in &opts {
if opt.is_revset && opt.longs.iter().any(|n| n == "--to") {
return Some(RevsetFlag::To);
}
}
if positional_revset_accepts_r(help) {
return Some(RevsetFlag::Revision);
}
None
}
fn positional_revset_accepts_r(help: &str) -> bool {
let mut in_args = false;
let mut current_is_revset = false;
for line in help.lines() {
if line == "Arguments:" {
in_args = true;
continue;
}
if !in_args {
continue;
}
if !line.is_empty() && !line.starts_with(' ') {
break;
}
if line.is_empty() {
continue;
}
let trimmed = line.trim_start();
let indent = line.len() - trimmed.len();
if indent == 2 && trimmed.starts_with(['[', '<']) {
let name = trimmed
.split_whitespace()
.next()
.unwrap_or("")
.trim_matches(['[', ']', '<', '>'])
.trim_end_matches("...")
.trim_matches(['[', ']', '<', '>']);
current_is_revset = matches!(name, "REVSET" | "REVSETS");
}
if current_is_revset && line.contains("[aliases:") {
let mut aliases = std::collections::HashSet::new();
scan_aliases(line, &mut aliases);
if aliases.iter().any(|a| a == "-r" || a == "--revision") {
return true;
}
}
}
false
}
fn passthrough_pins_revset(passthrough: &[String], leaf_help: &str) -> bool {
if has_revset_flag(passthrough) {
return true;
}
if positional_revset_accepts_r(leaf_help) {
return passthrough_has_bare_positional(passthrough, &value_flags(leaf_help));
}
false
}
fn has_revset_flag(passthrough: &[String]) -> bool {
passthrough.iter().any(|a| {
let name = a.split('=').next().unwrap_or(a);
matches!(name, "-r" | "--revision" | "--revisions" | "-t" | "--to")
})
}
fn passthrough_has_bare_positional(
passthrough: &[String],
value_flags: &std::collections::HashSet<String>,
) -> bool {
let mut i = 0;
while i < passthrough.len() {
let tok = passthrough[i].as_str();
if tok == "--" {
return i + 1 < passthrough.len();
}
if let Some(body) = tok.strip_prefix("--") {
let name = format!("--{}", body.split('=').next().unwrap_or(body));
if !body.contains('=') && value_flags.contains(&name) {
i += 1;
}
i += 1;
continue;
}
if tok.starts_with('-') && tok.len() > 1 {
if value_flags.contains(tok) {
i += 1;
}
i += 1;
continue;
}
return true;
}
false
}
fn value_flags(help: &str) -> std::collections::HashSet<String> {
let mut flags = std::collections::HashSet::new();
for opt in parse_options(help) {
if opt.takes_value {
for f in opt.shorts.into_iter().chain(opt.longs) {
flags.insert(f);
}
}
}
flags
}
#[derive(Debug)]
struct OptionEntry {
shorts: Vec<String>,
longs: Vec<String>,
takes_value: bool,
is_revset: bool,
}
fn parse_options(help: &str) -> Vec<OptionEntry> {
let mut opts = Vec::new();
let mut in_options = false;
let mut current: Option<OptionEntry> = None;
for line in help.lines() {
if !line.is_empty() && !line.starts_with(' ') {
if let Some(o) = current.take() {
opts.push(o);
}
in_options = line.ends_with("Options:");
continue;
}
if !in_options {
continue;
}
if line.is_empty() {
continue;
}
let trimmed = line.trim_start();
let indent = line.len() - trimmed.len();
let is_new_entry = (indent == 2 && trimmed.starts_with('-'))
|| (indent == 6 && trimmed.starts_with("--"));
if is_new_entry {
if let Some(o) = current.take() {
opts.push(o);
}
current = Some(parse_option_header(trimmed));
} else if let Some(ref mut o) = current {
let mut alias_set = std::collections::HashSet::new();
scan_aliases(line, &mut alias_set);
for n in alias_set {
if n.starts_with("--") {
o.longs.push(n);
} else if n.starts_with('-') {
o.shorts.push(n);
}
}
}
}
if let Some(o) = current {
opts.push(o);
}
opts
}
fn parse_option_header(s: &str) -> OptionEntry {
let mut shorts = Vec::new();
let mut longs = Vec::new();
let mut placeholder: Option<String> = None;
for token in s.split(|c: char| c == ',' || c.is_whitespace()) {
let t = token.trim();
if t.is_empty() {
continue;
}
if let Some(rest) = t.strip_prefix("--") {
let name = rest.split('=').next().unwrap_or(rest);
longs.push(format!("--{name}"));
} else if t.starts_with('-') {
shorts.push(t.to_string());
} else if let Some(stripped) = t.strip_prefix('<') {
let inner = stripped.trim_end_matches("...");
if let Some(end) = inner.find('>') {
placeholder = Some(inner[..end].to_string());
}
}
}
let is_revset = matches!(placeholder.as_deref(), Some("REVSET") | Some("REVSETS"));
OptionEntry {
shorts,
longs,
takes_value: placeholder.is_some(),
is_revset,
}
}
pub fn exec(target: &RevsetTarget, ids: &[String]) -> Result<ExitStatus> {
let mut cmd = Command::new("jj");
cmd.args(&target.leaf);
cmd.args(&target.passthrough);
if !ids.is_empty() {
if let Some(flag) = target.flag {
cmd.arg(flag.as_str());
cmd.arg(ids.join("|"));
}
}
let status = cmd.status().context("failed to spawn jj")?;
Ok(status)
}
pub fn command_line(target: &RevsetTarget, ids: &[String]) -> String {
let mut parts: Vec<String> =
Vec::with_capacity(2 + target.leaf.len() + target.passthrough.len() + 2);
parts.push("jj".into());
for w in &target.leaf {
parts.push(w.clone());
}
for a in &target.passthrough {
parts.push(shell_quote(a));
}
if !ids.is_empty() {
if let Some(flag) = target.flag {
parts.push(flag.as_str().into());
parts.push(shell_quote(&ids.join("|")));
}
}
parts.join(" ")
}
pub fn shell_quote(s: &str) -> String {
if s.is_empty() {
return "''".into();
}
if s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"_-./=:@,".contains(&b))
{
return s.to_string();
}
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for c in s.chars() {
if c == '\'' {
out.push_str(r"'\''");
} else {
out.push(c);
}
}
out.push('\'');
out
}
#[cfg(test)]
mod tests {
use super::*;
const TAG_SET_HELP: &str = "Create or update tags
Usage: jj tag set [OPTIONS] <NAMES>...
Arguments:
<NAMES>...
Tag names to create or update
Options:
-r, --revision <REVSET>
Target revision to point to
[default: @]
[aliases: --to]
--allow-move
Allow moving existing tags
-h, --help
Print help (see a summary with '-h')
Global Options:
-R, --repository <REPOSITORY>
Path to repository
";
const BOOKMARK_MOVE_HELP: &str = "Move existing bookmarks to target revision
Usage: jj bookmark move [OPTIONS] <NAMES|--from <REVSETS>>
Arguments:
[NAMES]...
Move bookmarks matching the given name patterns
Options:
-f, --from <REVSETS>
Move bookmarks from the given revisions
-t, --to <REVSET>
Move bookmarks to this revision
[default: @]
-B, --allow-backwards
Allow moving bookmarks backwards or sideways
-h, --help
Print help (see a summary with '-h')
Global Options:
-R, --repository <REPOSITORY>
Path to repository
";
const TAG_PARENT_HELP: &str = "Manage tags
Usage: jj tag [OPTIONS] <COMMAND>
Commands:
delete Delete existing tags [aliases: d]
list List tags and their targets [aliases: l]
set Create or update tags [aliases: s]
Options:
-h, --help
Print help (see a summary with '-h')
Global Options:
-R, --repository <REPOSITORY>
Path to repository
";
const SHOW_HELP: &str = "Show commit description and changes in a revision
Usage: jj show [OPTIONS] [REVSET]
Arguments:
[REVSET]
Show changes in this revision, compared to its parent(s) [default: @] [aliases: -r]
Options:
-T, --template <TEMPLATE>
Render a revision using the given template
-h, --help
Print help (see a summary with '-h')
Diff Formatting Options:
-s, --summary
For each path, show only whether it was modified, added, or deleted
--tool <TOOL>
Generate diff by external command
Global Options:
-R, --repository <REPOSITORY>
Path to repository
";
const LOG_HELP: &str = "Show revision history
Usage: jj log [OPTIONS] [FILESETS]...
Arguments:
[FILESETS]...
Show revisions modifying the given paths
Options:
-r, --revision <REVSETS>
Which revisions to show
-h, --help
Print help (see a summary with '-h')
Global Options:
-R, --repository <REPOSITORY>
Path to repository
";
#[test]
fn revset_flag_detects_show_positional() {
assert_eq!(parse_revset_flag(SHOW_HELP), Some(RevsetFlag::Revision));
}
#[test]
fn revset_flag_log_uses_option_not_positional() {
assert_eq!(parse_revset_flag(LOG_HELP), Some(RevsetFlag::Revision));
assert!(!positional_revset_accepts_r(LOG_HELP));
}
#[test]
fn revset_flag_prefers_revision_over_to_alias() {
assert_eq!(parse_revset_flag(TAG_SET_HELP), Some(RevsetFlag::Revision));
}
#[test]
fn revset_flag_falls_back_to_to_when_no_revision() {
assert_eq!(parse_revset_flag(BOOKMARK_MOVE_HELP), Some(RevsetFlag::To));
}
#[test]
fn revset_flag_none_for_parent_without_options() {
assert_eq!(parse_revset_flag(TAG_PARENT_HELP), None);
}
#[test]
fn commands_section_includes_names_and_aliases() {
let cmds = parse_commands_section(TAG_PARENT_HELP);
for name in ["delete", "list", "set", "d", "l", "s"] {
assert!(cmds.contains(name), "missing {name} in {cmds:?}");
}
}
#[test]
fn commands_section_empty_for_leaf() {
let cmds = parse_commands_section(BOOKMARK_MOVE_HELP);
assert!(cmds.is_empty(), "expected no commands, got {cmds:?}");
}
#[test]
fn command_line_emits_revision_flag() {
let target = RevsetTarget {
leaf: vec!["tag".into(), "set".into()],
passthrough: vec!["v0.2.0".into()],
flag: Some(RevsetFlag::Revision),
user_supplied_revset: false,
};
let s = command_line(&target, &["abcd1234".into()]);
assert_eq!(s, "jj tag set v0.2.0 -r abcd1234");
}
#[test]
fn command_line_emits_to_flag() {
let target = RevsetTarget {
leaf: vec!["bm".into()],
passthrough: vec!["main".into()],
flag: Some(RevsetFlag::To),
user_supplied_revset: false,
};
let s = command_line(&target, &["abcd1234".into()]);
assert_eq!(s, "jj bm main --to abcd1234");
}
#[test]
fn command_line_omits_flag_when_no_ids() {
let target = RevsetTarget {
leaf: vec!["describe".into()],
passthrough: vec!["-r".into(), "@".into()],
flag: Some(RevsetFlag::Revision),
user_supplied_revset: true,
};
let s = command_line(&target, &[]);
assert_eq!(s, "jj describe -r @");
}
fn pins(passthrough: &[&str], help: &str) -> bool {
let owned: Vec<String> = passthrough.iter().map(|s| s.to_string()).collect();
passthrough_pins_revset(&owned, help)
}
#[test]
fn show_bare_positional_pins_revset() {
assert!(pins(&["@"], SHOW_HELP));
assert!(pins(&["abc123"], SHOW_HELP));
assert!(pins(&["@", "--summary"], SHOW_HELP));
assert!(pins(&["--summary", "@"], SHOW_HELP));
}
#[test]
fn show_without_revision_runs_picker() {
assert!(!pins(&[], SHOW_HELP));
assert!(!pins(&["--summary"], SHOW_HELP));
assert!(!pins(&["-s"], SHOW_HELP));
}
#[test]
fn show_option_value_is_not_a_positional() {
assert!(!pins(&["-T", "mytemplate"], SHOW_HELP));
assert!(!pins(&["--tool", "meld"], SHOW_HELP));
assert!(pins(&["-T", "mytemplate", "@"], SHOW_HELP));
}
#[test]
fn show_explicit_revset_flag_pins_revset() {
assert!(pins(&["-r", "@"], SHOW_HELP));
assert!(pins(&["--revision=@"], SHOW_HELP));
}
#[test]
fn dashdash_separates_positionals() {
assert!(pins(&["--", "@"], SHOW_HELP));
assert!(!pins(&["--"], SHOW_HELP));
}
#[test]
fn option_leaf_bare_positional_does_not_pin_revset() {
assert!(!pins(&["v0.2.0"], TAG_SET_HELP));
assert!(pins(&["v0.2.0", "-r", "@"], TAG_SET_HELP));
}
#[test]
fn value_flags_span_all_option_sections() {
let vf = value_flags(SHOW_HELP);
for f in ["-T", "--template", "--tool", "-R", "--repository"] {
assert!(vf.contains(f), "missing value flag {f} in {vf:?}");
}
for f in ["-s", "--summary", "-h", "--help"] {
assert!(!vf.contains(f), "{f} should not be a value flag");
}
}
}