Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
228 linesCodeBlameRaw
1//! `g1t alias`: shortcuts, kept in config.json and expanded before the
2//! command line is read.
3
4use clap::CommandFactory;
5
6use crate::cli::{AliasCommand, Cli};
7use crate::config::Config;
8use crate::error::{Error, Kind, Result};
9
10pub enum Expanded {
11 Args(Vec<String>),
12 /// A `!` alias ran in the shell, with this exit code.
13 Ran(i32),
14}
15
16fn builtin(name: &str) -> bool {
17 Cli::command()
18 .get_subcommands()
19 .any(|c| c.get_name() == name || c.get_all_aliases().any(|a| a == name))
20 || matches!(name, "help")
21}
22
23/// Splits `text` into words the way a shell would, quotes included.
24pub fn split(text: &str) -> Result<Vec<String>> {
25 let mut words = Vec::new();
26 let mut word = String::new();
27 let mut in_word = false;
28 let mut chars = text.chars();
29 let mut quote: Option<char> = None;
30 while let Some(c) = chars.next() {
31 match (quote, c) {
32 (Some(q), c) if c == q => quote = None,
33 (Some('"'), '\\') => {
34 if let Some(next) = chars.next() {
35 word.push(next);
36 }
37 }
38 (Some(_), c) => word.push(c),
39 (None, '\'' | '"') => {
40 quote = Some(c);
41 in_word = true;
42 }
43 (None, '\\') => {
44 if let Some(next) = chars.next() {
45 word.push(next);
46 in_word = true;
47 }
48 }
49 (None, c) if c.is_whitespace() => {
50 if in_word {
51 words.push(std::mem::take(&mut word));
52 in_word = false;
53 }
54 }
55 (None, c) => {
56 word.push(c);
57 in_word = true;
58 }
59 }
60 }
61 if quote.is_some() {
62 return Err(Error::usage(format!("Unclosed quote in `{text}`.")));
63 }
64 if in_word {
65 words.push(word);
66 }
67 Ok(words)
68}
69
70/// Puts `args` into `expansion`'s `$1`, `$2`...; the ones not used go after.
71pub fn substitute(expansion: &[String], args: &[String]) -> Vec<String> {
72 let mut used = vec![false; args.len()];
73 let mut out: Vec<String> = expansion
74 .iter()
75 .map(|word| {
76 let mut word = word.clone();
77 for (i, arg) in args.iter().enumerate().rev() {
78 let marker = format!("${}", i + 1);
79 if word.contains(&marker) {
80 word = word.replace(&marker, arg);
81 used[i] = true;
82 }
83 }
84 word
85 })
86 .collect();
87 out.extend(
88 args.iter()
89 .zip(&used)
90 .filter(|(_, u)| !**u)
91 .map(|(a, _)| a.clone()),
92 );
93 out
94}
95
96pub fn expand(args: Vec<String>) -> Result<Expanded> {
97 let Some(first) = args.get(1) else {
98 return Ok(Expanded::Args(args));
99 };
100 if first.starts_with('-') || builtin(first) {
101 return Ok(Expanded::Args(args));
102 }
103 let config = Config::load()?;
104 let Some(expansion) = config.aliases.get(first) else {
105 return Ok(Expanded::Args(args));
106 };
107 let rest = &args[2..];
108 if let Some(shell_line) = expansion.strip_prefix('!') {
109 let shell_args = substitute(&[shell_line.to_owned()], rest);
110 let line = shell_args.join(" ");
111 let status = std::process::Command::new("sh")
112 .args(["-c", &line])
113 .status()
114 .map_err(|e| Error::failed(format!("Could not run the alias {first} in sh: {e}")))?;
115 return Ok(Expanded::Ran(status.code().unwrap_or(1)));
116 }
117 let words = split(expansion)?;
118 let mut out = vec![args[0].clone()];
119 out.extend(substitute(&words, rest));
120 Ok(Expanded::Args(out))
121}
122
123pub fn run(command: AliasCommand) -> Result<()> {
124 let mut config = Config::load()?;
125 match command {
126 AliasCommand::Set(args) => {
127 if builtin(&args.name) {
128 return Err(Error::usage(format!(
129 "`{}` is already a g1t command.",
130 args.name
131 )));
132 }
133 if !args.expansion.starts_with('!') {
134 let words = split(&args.expansion)?;
135 match words.first() {
136 Some(first) if builtin(first) || config.aliases.contains_key(first) => {}
137 _ => {
138 return Err(Error::usage(format!(
139 "`{}` does not start with a g1t command. Start it with ! to run it in the shell.",
140 args.expansion
141 )));
142 }
143 }
144 }
145 let replaced = config
146 .aliases
147 .insert(args.name.clone(), args.expansion.clone())
148 .is_some();
149 config.save()?;
150 eprintln!(
151 "✓ {} {} now runs `g1t {}`.",
152 if replaced { "Changed:" } else { "Added:" },
153 args.name,
154 args.expansion
155 );
156 Ok(())
157 }
158 AliasCommand::List => {
159 if config.aliases.is_empty() {
160 eprintln!("No aliases. Add one: g1t alias set co 'pr checkout'");
161 return Ok(());
162 }
163 for (name, expansion) in &config.aliases {
164 println!("{name}: {expansion}");
165 }
166 Ok(())
167 }
168 AliasCommand::Delete(args) => {
169 if config.aliases.remove(&args.name).is_none() {
170 return Err(Error {
171 kind: Kind::Failed,
172 message: format!("No alias {}.", args.name),
173 silent: false,
174 });
175 }
176 config.save()?;
177 eprintln!("✓ Deleted the alias {}.", args.name);
178 Ok(())
179 }
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 fn v(words: &[&str]) -> Vec<String> {
188 words.iter().map(|s| s.to_string()).collect()
189 }
190
191 #[test]
192 fn words_split_like_a_shell() {
193 assert_eq!(split("pr checkout").unwrap(), v(&["pr", "checkout"]));
194 assert_eq!(
195 split(r#"issue list --label "good first issue""#).unwrap(),
196 v(&["issue", "list", "--label", "good first issue"])
197 );
198 assert_eq!(
199 split("api '/repos/{owner}/{name}'").unwrap(),
200 v(&["api", "/repos/{owner}/{name}"])
201 );
202 assert_eq!(split(r"a\ b c").unwrap(), v(&["a b", "c"]));
203 assert_eq!(split("x ''").unwrap(), v(&["x", ""]));
204 assert!(split("'open").is_err());
205 }
206
207 #[test]
208 fn arguments_fill_their_places() {
209 assert_eq!(
210 substitute(&v(&["pr", "checkout"]), &v(&["12"])),
211 v(&["pr", "checkout", "12"])
212 );
213 assert_eq!(
214 substitute(
215 &v(&["issue", "list", "--label", "$1"]),
216 &v(&["bug", "-L", "5"])
217 ),
218 v(&["issue", "list", "--label", "bug", "-L", "5"])
219 );
220 }
221
222 #[test]
223 fn commands_are_builtin() {
224 assert!(builtin("pr"));
225 assert!(builtin("help"));
226 assert!(!builtin("co"));
227 }
228}