Skip to content

Commit cb1d329

Browse files
authored
feat(command-palette): harden raw jj commands (#59)
1 parent cbd4fb6 commit cb1d329

26 files changed

Lines changed: 1556 additions & 193 deletions
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
use std::path::Path;
2+
use std::process::Command;
3+
4+
use crate::{CoreError, CoreResult, jj_binary};
5+
6+
#[derive(Debug, Clone, PartialEq, Eq)]
7+
pub struct JjCommand {
8+
raw: String,
9+
}
10+
11+
#[derive(Debug, Clone, PartialEq, Eq)]
12+
pub struct JjCommandResult {
13+
pub output: String,
14+
pub exit_code: i32,
15+
}
16+
17+
impl JjCommand {
18+
pub fn new(command: impl Into<String>) -> Self {
19+
Self {
20+
raw: command.into(),
21+
}
22+
}
23+
24+
pub fn from_palette_query(query: &str) -> Option<Self> {
25+
let body_after = |rest: &str| Self::new(rest.trim_start());
26+
if query == "jj" || query == "!" {
27+
return Some(Self::new(String::new()));
28+
}
29+
if let Some(rest) = query.strip_prefix("jj ") {
30+
return Some(body_after(rest));
31+
}
32+
query.strip_prefix('!').map(body_after)
33+
}
34+
35+
pub fn raw(&self) -> &str {
36+
&self.raw
37+
}
38+
39+
pub fn into_raw(self) -> String {
40+
self.raw
41+
}
42+
43+
pub fn parse_args(&self) -> Option<Vec<String>> {
44+
parse_args(&self.raw)
45+
}
46+
47+
pub fn run_in_path(&self, path: &Path) -> CoreResult<JjCommandResult> {
48+
let args = self.parse_args().ok_or_else(|| CoreError::Internal {
49+
message: "Unclosed quote in jj command.".to_owned(),
50+
})?;
51+
if args.is_empty() {
52+
return Err(CoreError::Internal {
53+
message: "No jj command to run.".to_owned(),
54+
});
55+
}
56+
57+
let output = Command::new(jj_binary())
58+
.args(&args)
59+
.current_dir(path)
60+
.output()
61+
.map_err(|e| CoreError::Internal {
62+
message: format!("run jj: {e}"),
63+
})?;
64+
65+
let stdout = trim_output(&output.stdout);
66+
let stderr = trim_output(&output.stderr);
67+
let combined = match (stdout.is_empty(), stderr.is_empty()) {
68+
(true, true) => "(no output)".to_owned(),
69+
(false, true) => stdout.clone(),
70+
(true, false) => stderr.clone(),
71+
(false, false) => format!("{stdout}\n{stderr}"),
72+
};
73+
74+
Ok(JjCommandResult {
75+
output: combined,
76+
exit_code: output.status.code().unwrap_or(-1),
77+
})
78+
}
79+
}
80+
81+
fn parse_args(command: &str) -> Option<Vec<String>> {
82+
let mut args = Vec::new();
83+
let mut current = String::new();
84+
let mut quote = None;
85+
let mut escaping = false;
86+
let mut arg_started = false;
87+
88+
let mut chars = command.chars().peekable();
89+
while let Some(ch) = chars.next() {
90+
if escaping {
91+
current.push(ch);
92+
escaping = false;
93+
arg_started = true;
94+
continue;
95+
}
96+
if let Some(current_quote) = quote {
97+
if ch == current_quote {
98+
quote = None;
99+
} else if current_quote == '"' && ch == '\\' {
100+
match chars.peek().copied() {
101+
Some('"') | Some('\\') => current.push(chars.next().expect("peeked next char")),
102+
_ => current.push(ch),
103+
}
104+
} else {
105+
current.push(ch);
106+
}
107+
arg_started = true;
108+
continue;
109+
}
110+
if ch == '\\' {
111+
escaping = true;
112+
arg_started = true;
113+
continue;
114+
}
115+
if ch == '"' || ch == '\'' {
116+
quote = Some(ch);
117+
arg_started = true;
118+
continue;
119+
}
120+
if ch.is_whitespace() {
121+
if arg_started {
122+
args.push(std::mem::take(&mut current));
123+
arg_started = false;
124+
}
125+
continue;
126+
}
127+
current.push(ch);
128+
arg_started = true;
129+
}
130+
131+
if escaping {
132+
current.push('\\');
133+
}
134+
quote.is_none().then(|| {
135+
if arg_started {
136+
args.push(current);
137+
}
138+
args
139+
})
140+
}
141+
142+
fn trim_output(bytes: &[u8]) -> String {
143+
String::from_utf8_lossy(bytes).trim().to_owned()
144+
}
145+
146+
#[cfg(test)]
147+
mod tests {
148+
use super::*;
149+
150+
#[test]
151+
fn parses_shell_like_args() {
152+
assert_eq!(
153+
JjCommand::new(r#"log -r "description(exact:'fix bug')" --limit 5"#).parse_args(),
154+
Some(vec![
155+
"log".to_owned(),
156+
"-r".to_owned(),
157+
"description(exact:'fix bug')".to_owned(),
158+
"--limit".to_owned(),
159+
"5".to_owned()
160+
])
161+
);
162+
assert_eq!(
163+
JjCommand::new(r#"file\ with\ spaces"#).parse_args(),
164+
Some(vec!["file with spaces".to_owned()])
165+
);
166+
assert_eq!(
167+
JjCommand::new(r#"describe -m "a\"b""#).parse_args(),
168+
Some(vec![
169+
"describe".to_owned(),
170+
"-m".to_owned(),
171+
"a\"b".to_owned()
172+
])
173+
);
174+
assert_eq!(
175+
JjCommand::new(r#"describe -m """#).parse_args(),
176+
Some(vec!["describe".to_owned(), "-m".to_owned(), String::new()])
177+
);
178+
assert_eq!(
179+
JjCommand::new(r#"new '' file\ with\ spaces"#).parse_args(),
180+
Some(vec![
181+
"new".to_owned(),
182+
String::new(),
183+
"file with spaces".to_owned()
184+
])
185+
);
186+
assert_eq!(
187+
JjCommand::new(r#"describe -m 'a\b'"#).parse_args(),
188+
Some(vec![
189+
"describe".to_owned(),
190+
"-m".to_owned(),
191+
r#"a\b"#.to_owned()
192+
])
193+
);
194+
assert_eq!(
195+
JjCommand::new(r#"log -r "description(regex:'\bfix\b')""#).parse_args(),
196+
Some(vec![
197+
"log".to_owned(),
198+
"-r".to_owned(),
199+
r#"description(regex:'\bfix\b')"#.to_owned()
200+
])
201+
);
202+
assert_eq!(JjCommand::new(r#"log -r "mine()"#).parse_args(), None);
203+
}
204+
205+
#[test]
206+
fn extracts_prefixed_command_body() {
207+
assert_eq!(
208+
JjCommand::from_palette_query("jj log").map(JjCommand::into_raw),
209+
Some("log".to_owned())
210+
);
211+
assert_eq!(
212+
JjCommand::from_palette_query("!status").map(JjCommand::into_raw),
213+
Some("status".to_owned())
214+
);
215+
assert_eq!(JjCommand::from_palette_query("status"), None);
216+
}
217+
}

crates/jayjay-core/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ pub use jj_diff::syntax;
33
pub mod dag;
44
pub mod file_tree;
55
pub mod hash;
6+
mod jj_command;
67
mod repo;
78
pub mod review;
89
pub mod tools;
910
mod types;
1011

12+
pub use jj_command::{JjCommand, JjCommandResult};
1113
pub use repo::{
1214
COMMIT_MESSAGE_PROMPT, DEFAULT_REVSET, DEFAULT_REVSET_DEPTH, Repo, build_default_revset,
1315
check_gh_environment, check_jj_environment, detect_ai_provider, find_existing_binary,

crates/jayjay-core/src/repo/diffedit.rs

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -40,29 +40,20 @@ impl Repo {
4040
ignore_whitespace: bool,
4141
) -> CoreResult<()> {
4242
match destination {
43-
DiffEditDestination::RemoveFromSource => self.remove_diff_selection_from_source(
43+
DiffEditDestination::RemoveFromSource => {
44+
self.remove_diff_selection_from_source(rev, selections, ignore_whitespace)
45+
}
46+
DiffEditDestination::MoveToWorkingCopy => {
47+
self.move_diff_selection_to_working_copy(rev, selections, ignore_whitespace)
48+
}
49+
DiffEditDestination::NewChild => self.extract_diff_selection_as_new_child(
4450
rev,
4551
selections,
52+
message,
4653
ignore_whitespace,
4754
),
48-
DiffEditDestination::MoveToWorkingCopy => {
49-
self.move_diff_selection_to_working_copy(rev, selections, ignore_whitespace)
50-
}
51-
DiffEditDestination::NewChild => {
52-
self.extract_diff_selection_as_new_child(
53-
rev,
54-
selections,
55-
message,
56-
ignore_whitespace,
57-
)
58-
}
5955
DiffEditDestination::NewParallel => {
60-
self.extract_diff_selection_as_parallel(
61-
rev,
62-
selections,
63-
message,
64-
ignore_whitespace,
65-
)
56+
self.extract_diff_selection_as_parallel(rev, selections, message, ignore_whitespace)
6657
}
6758
}
6859
}
@@ -158,7 +149,10 @@ impl Repo {
158149
)?;
159150
let rewritten_source = block_on_result(
160151
"rewrite source commit",
161-
repo_mut.rewrite_commit(commit).set_tree(remaining_tree).write(),
152+
repo_mut
153+
.rewrite_commit(commit)
154+
.set_tree(remaining_tree)
155+
.write(),
162156
)?;
163157
let child_tree = self.apply_selection_to_tree(
164158
&source_selection,
@@ -204,7 +198,10 @@ impl Repo {
204198
block_on_result("rewrite source commit", write)?;
205199
let parallel_description = self.diffedit_message(message, commit);
206200
let write = repo_mut
207-
.new_commit(commit.parent_ids().to_vec(), source_selection.selected_tree.clone())
201+
.new_commit(
202+
commit.parent_ids().to_vec(),
203+
source_selection.selected_tree.clone(),
204+
)
208205
.set_description(&parallel_description)
209206
.write();
210207
block_on_result("create parallel change", write)?;
@@ -334,9 +331,16 @@ impl Repo {
334331
) -> CoreResult<MergedTreeValue> {
335332
let metadata = self
336333
.resolved_file_value(source_tree, path, "load selected file metadata")?
337-
.or_else(|| self.resolved_file_value(parent_tree, path, "load parent file metadata").ok().flatten())
334+
.or_else(|| {
335+
self.resolved_file_value(parent_tree, path, "load parent file metadata")
336+
.ok()
337+
.flatten()
338+
})
338339
.ok_or_else(|| CoreError::Internal {
339-
message: format!("selected file metadata missing for {}", path.as_internal_file_string()),
340+
message: format!(
341+
"selected file metadata missing for {}",
342+
path.as_internal_file_string()
343+
),
340344
})?;
341345

342346
let TreeValue::File {
@@ -372,7 +376,10 @@ impl Repo {
372376
) -> CoreResult<Option<TreeValue>> {
373377
let value = block_on_result(context, tree.path_value(path))?;
374378
value.into_resolved().map_err(|_| CoreError::Internal {
375-
message: format!("conflicted file values are not supported: {}", path.as_internal_file_string()),
379+
message: format!(
380+
"conflicted file values are not supported: {}",
381+
path.as_internal_file_string()
382+
),
376383
})
377384
}
378385

crates/jayjay-core/src/repo/environment.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,11 @@ fn resolve_login_shell_path() -> Option<String> {
138138
#[cfg(unix)]
139139
pub fn login_shell() -> String {
140140
login_shell_from_passwd()
141-
.or_else(|| std::env::var("SHELL").ok().filter(|value| !value.is_empty()))
141+
.or_else(|| {
142+
std::env::var("SHELL")
143+
.ok()
144+
.filter(|value| !value.is_empty())
145+
})
142146
.unwrap_or_else(|| "/bin/zsh".to_string())
143147
}
144148

0 commit comments

Comments
 (0)