Skip to content

Commit 67a8544

Browse files
committed
Command aliases
Aliases in the Rust version differ from aliases in the Python version and now behave like Git aliases. - Aliases for external commands must be prefixed with '!'. E.g.: git config stgit.alias.long-status '!git status --long' would enable: stg long-status - Aliases for StGit commands are not prefixed. E.g.: git config stgit.alias.list 'series --short' would enable: stg list Other details: - Aliases that attempt to override any builtin command are ignored - Added builtin aliases: add, mv, resolved, rm, status. - Builtin aliases may be overridden or removed (by making empty). - Recursive aliases are detected Resolves #97
1 parent 381fe74 commit 67a8544

File tree

3 files changed

+356
-6
lines changed

3 files changed

+356
-6
lines changed

src/alias.rs

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
use std::collections::BTreeMap;
2+
3+
use git2::{Config, ConfigEntry, ConfigLevel};
4+
5+
use crate::error::Error;
6+
7+
pub(crate) struct StGitAlias(pub String);
8+
pub(crate) struct ShellAlias(pub String);
9+
10+
pub(crate) enum Alias {
11+
StGit(StGitAlias),
12+
Shell(ShellAlias),
13+
}
14+
15+
pub(crate) fn get_aliases(
16+
config: Option<&Config>,
17+
excluded: Vec<&'static str>,
18+
) -> Result<BTreeMap<String, Alias>, Error> {
19+
let mut aliases: BTreeMap<String, Alias> = BTreeMap::from(
20+
[
21+
("add", "git add"),
22+
("mv", "git mv"),
23+
("resolved", "git add"),
24+
("rm", "git rm"),
25+
("status", "git status -s"),
26+
]
27+
.map(|(name, command)| (name.into(), Alias::Shell(ShellAlias(command.into())))),
28+
);
29+
30+
if let Some(config) = config {
31+
if let Ok(entries) = config.entries(Some("stgit.alias.*")) {
32+
for entry in &entries {
33+
if let Ok(entry) = entry {
34+
let (name, alias) = make_alias(entry)?;
35+
if !excluded.iter().any(|n| n == &name) {
36+
if let Some(alias) = alias {
37+
aliases.insert(name, alias);
38+
} else {
39+
aliases.remove(&name);
40+
}
41+
}
42+
}
43+
}
44+
}
45+
}
46+
47+
Ok(aliases)
48+
}
49+
50+
fn make_alias(entry: ConfigEntry<'_>) -> Result<(String, Option<Alias>), Error> {
51+
if let Some(config_key) = entry.name() {
52+
let (_, name) = config_key
53+
.split_once("stgit.alias.")
54+
.expect("stgit.alias.* glob problem");
55+
let name: String = name.into();
56+
if entry.has_value() {
57+
if let Some(value) = entry.value() {
58+
if let Some((_, shell_command)) = value.split_once('!') {
59+
Ok((name, Some(Alias::Shell(ShellAlias(shell_command.into())))))
60+
} else {
61+
Ok((name, Some(Alias::StGit(StGitAlias(value.into())))))
62+
}
63+
} else {
64+
Err(Error::NonUtf8AliasValue(
65+
name,
66+
config_level_to_str(entry.level()).to_string(),
67+
))
68+
}
69+
} else {
70+
Ok((name, None))
71+
}
72+
} else {
73+
Err(Error::NonUtf8AliasName(
74+
String::from_utf8_lossy(entry.name_bytes()).to_string(),
75+
config_level_to_str(entry.level()).to_string(),
76+
))
77+
}
78+
}
79+
80+
pub(crate) fn get_app(name: &str, alias: &Alias) -> clap::App<'static> {
81+
let about = match alias {
82+
Alias::StGit(alias) => format!("Alias for `stg {0}`", alias.command()),
83+
Alias::Shell(alias) => format!("Alias for shell command `{}`", alias.command()),
84+
};
85+
// TODO: future versions of clap may allow about() argument to
86+
// be something other than &'help str, which could avoid having
87+
// to do this leak trick.
88+
let about: &'static str = Box::leak(about.into_boxed_str());
89+
clap::App::new(name)
90+
.about(about)
91+
.setting(clap::AppSettings::TrailingVarArg)
92+
.arg(
93+
clap::Arg::new("args")
94+
.about("Extra arguments to aliased command")
95+
.setting(clap::ArgSettings::MultipleValues)
96+
.setting(clap::ArgSettings::AllowHyphenValues),
97+
)
98+
}
99+
100+
impl StGitAlias {
101+
pub(crate) fn command(&self) -> &str {
102+
&self.0
103+
}
104+
105+
pub(crate) fn split(&self) -> Result<Vec<String>, String> {
106+
split_command_line(&self.0)
107+
}
108+
}
109+
110+
impl ShellAlias {
111+
pub(crate) fn command(&self) -> &str {
112+
&self.0
113+
}
114+
115+
pub(crate) fn run(
116+
&self,
117+
args: clap::OsValues,
118+
repo: Option<&git2::Repository>,
119+
) -> crate::cmd::Result {
120+
let mut args = args;
121+
let mut command = if self.command().find(SHELL_CHARS).is_some() {
122+
// Need to wrap in shell command
123+
let mut command = std::process::Command::new(SHELL_PATH);
124+
command.arg("-c");
125+
if let Some(first_arg) = args.next() {
126+
command.arg(&format!("{} \"$@\"", self.command()));
127+
command.arg(self.command());
128+
command.arg(first_arg);
129+
} else {
130+
command.arg(self.command());
131+
}
132+
command
133+
} else {
134+
std::process::Command::new(self.command())
135+
};
136+
command.args(args);
137+
138+
if let Some(repo) = repo {
139+
if let Some(workdir) = repo.workdir() {
140+
command.current_dir(workdir);
141+
if let Ok(cur_dir) = std::env::current_dir() {
142+
if let Ok(prefix) = cur_dir.strip_prefix(workdir) {
143+
if !cur_dir.starts_with(repo.path()) {
144+
let mut prefix = prefix.as_os_str().to_os_string();
145+
if !prefix.is_empty() {
146+
prefix.push("/");
147+
}
148+
command.env("GIT_PREFIX", &prefix);
149+
}
150+
}
151+
}
152+
if let Ok(rel_dir) = repo.path().strip_prefix(workdir) {
153+
if rel_dir == std::path::PathBuf::from(".git") {
154+
command.env_remove("GIT_DIR");
155+
} else {
156+
command.env("GIT_DIR", rel_dir);
157+
}
158+
} else {
159+
command.env("GIT_DIR", repo.path());
160+
}
161+
} else {
162+
command.env("GIT_DIR", repo.path());
163+
}
164+
}
165+
// TODO: map to custom error
166+
let status = command
167+
.status()
168+
.map_err(|e| Error::ExecuteAlias(self.command().to_string(), e.to_string()))?;
169+
if status.success() {
170+
Ok(())
171+
} else {
172+
std::process::exit(status.code().unwrap_or(-1));
173+
}
174+
}
175+
}
176+
177+
// TODO: Git chooses its shell path at compile time based on OS or user override.
178+
const SHELL_PATH: &str = "sh";
179+
const SHELL_CHARS: &[char] = &[
180+
'|', '&', ';', '<', '>', '(', ')', '$', '`', ' ', '*', '?', '[', '#', '~', '=', '%', '\\', '"',
181+
'\'', '\t', '\n',
182+
];
183+
184+
fn split_command_line(line: &str) -> Result<Vec<String>, String> {
185+
let mut argv = Vec::new();
186+
let mut quote: char = '\0';
187+
let mut skip_spaces = true;
188+
let mut post_backspace = false;
189+
let mut word = String::new();
190+
191+
for c in line.chars() {
192+
if post_backspace {
193+
word.push(c);
194+
post_backspace = false;
195+
} else if c.is_ascii_whitespace() {
196+
if !skip_spaces {
197+
if quote == '\0' {
198+
let completed_word = std::mem::take(&mut word);
199+
argv.push(completed_word);
200+
skip_spaces = true;
201+
} else {
202+
word.push(c)
203+
}
204+
}
205+
} else {
206+
skip_spaces = false;
207+
if quote == '\0' && (c == '\'' || c == '"') {
208+
quote = c;
209+
} else if c == quote {
210+
quote = '\0';
211+
} else if c == '\\' && quote != '\'' {
212+
post_backspace = true;
213+
} else {
214+
word.push(c);
215+
}
216+
}
217+
}
218+
219+
if post_backspace {
220+
Err("command line ends with \\".to_string())
221+
} else if quote != '\0' {
222+
Err("unclosed quote".to_string())
223+
} else {
224+
argv.push(word);
225+
Ok(argv)
226+
}
227+
}
228+
229+
fn config_level_to_str(level: ConfigLevel) -> &'static str {
230+
match level {
231+
git2::ConfigLevel::ProgramData => "program data config",
232+
git2::ConfigLevel::System => "system config",
233+
git2::ConfigLevel::XDG => "XDG config",
234+
git2::ConfigLevel::Global => "global config",
235+
git2::ConfigLevel::Local => "local config",
236+
git2::ConfigLevel::App => "app config",
237+
git2::ConfigLevel::Highest => "highest config",
238+
}
239+
}
240+
241+
#[cfg(test)]
242+
mod tests {
243+
use super::*;
244+
245+
#[test]
246+
fn split_command_lines() {
247+
assert_eq!(
248+
split_command_line("git status"),
249+
Ok(vec![String::from("git"), String::from("status")])
250+
);
251+
assert_eq!(
252+
split_command_line("cat \"foo bar\""),
253+
Ok(vec![String::from("cat"), String::from("foo bar")])
254+
);
255+
assert_eq!(
256+
split_command_line("cat \"foo 'bar'\""),
257+
Ok(vec![String::from("cat"), String::from("foo 'bar'")])
258+
);
259+
assert_eq!(
260+
split_command_line("cat \"foo \\\" 'bar'\""),
261+
Ok(vec![String::from("cat"), String::from("foo \" 'bar'")])
262+
);
263+
assert_eq!(
264+
split_command_line("cat 'dog"),
265+
Err("unclosed quote".to_string()),
266+
);
267+
assert_eq!(
268+
split_command_line("cat \"dog'"),
269+
Err("unclosed quote".to_string()),
270+
);
271+
assert_eq!(
272+
split_command_line("cat dog\\"),
273+
Err("command line ends with \\".to_string()),
274+
);
275+
}
276+
}

src/error.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,21 @@ pub(crate) enum Error {
6666
#[error("{0}")]
6767
NonUtf8Signature(String),
6868

69+
#[error("non-UTF-8 alias name `{0}` in {1}")]
70+
NonUtf8AliasName(String, String),
71+
72+
#[error("non-UTF-8 alias value for `{0}` in {1}")]
73+
NonUtf8AliasValue(String, String),
74+
75+
#[error("bad alias for `{0}`: {1}")]
76+
BadAlias(String, String),
77+
78+
#[error("recursive alias `{0}`")]
79+
RecursiveAlias(String),
80+
81+
#[error("failed to execute shell alias `{0}`: {1}")]
82+
ExecuteAlias(String, String),
83+
6984
#[error("{0}")]
7085
MissingSignature(String),
7186

0 commit comments

Comments
 (0)