Skip to content

Commit 5e5ff7c

Browse files
Add regex support to commit message filter
1 parent bfdc931 commit 5e5ff7c

File tree

8 files changed

+252
-51
lines changed

8 files changed

+252
-51
lines changed

Cargo.lock

Lines changed: 23 additions & 30 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/src/reference/filters.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,34 @@ tree.
121121
Normally Josh will keep all commits in the filtered history whose tree differs from any of it's
122122
parents.
123123

124+
### Commit message rewriting **`:"template"`** or **`:"template";"regex"`**
125+
126+
Rewrite commit messages using a template string. The template can use regex capture groups
127+
to extract and reformat parts of the original commit message.
128+
129+
**Simple message replacement:**
130+
```
131+
:"New message"
132+
```
133+
This replaces all commit messages with "New message".
134+
135+
**Using regex with named capture groups:**
136+
```
137+
:"[{type}] {message}";"(?s)^(?P<type>fix|feat|docs): (?P<message>.+)$"
138+
```
139+
This uses a regex to match the original commit message and extract named capture groups (`{type}` and `{message}`)
140+
which are then used in the template. The regex `(?s)^(?P<type>fix|feat|docs): (?P<message>.+)$` matches
141+
commit messages starting with "fix:", "feat:", or "docs:" followed by a message, and the template
142+
reformats them as `[type] message`.
143+
144+
**Removing text from messages:**
145+
```
146+
:"";"TODO"
147+
```
148+
This removes all occurrences of "TODO" from commit messages by matching "TODO" and replacing it with an empty string.
149+
The regex pattern can use `(?s)` to enable dot-all mode (so `.` matches newlines), allowing it to work with
150+
multi-line commit messages that include both a subject line and a body.
151+
124152
### Pin tree contents
125153

126154
Pin revision of a subtree to revision of the parent commit.

josh-core/src/filter/grammar.pest

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ filter_nop = { CMD_START ~ "/" }
4040
filter_presub = { CMD_START ~ ":" ~ argument }
4141
filter = { CMD_START ~ cmd ~ "=" ~ (argument ~ (";" ~ argument)*)? }
4242
filter_noarg = { CMD_START ~ cmd }
43-
filter_message = { CMD_START ~ string }
43+
filter_message = { CMD_START ~ string ~ (";" ~ string)? }
4444

4545
filter_rev = {
4646
CMD_START ~ "rev" ~ "("

josh-core/src/filter/mod.rs

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ lazy_static! {
2323
std::sync::Mutex::new(std::collections::HashMap::new());
2424
}
2525

26+
lazy_static! {
27+
/// Match-all regex pattern used as the default for Op::Message when no regex is specified.
28+
/// The pattern `(?s)^.*$` matches any string (including newlines) from start to end.
29+
static ref MESSAGE_MATCH_ALL_REGEX: regex::Regex = regex::Regex::new("(?s)^.*$").unwrap();
30+
}
31+
2632
/// Filters are represented as `git2::Oid`, however they are not ever stored
2733
/// inside the repo.
2834
#[derive(
@@ -69,6 +75,7 @@ impl std::fmt::Debug for Filter {
6975
#[derive(Debug)]
7076
pub struct Apply<'a> {
7177
tree: git2::Tree<'a>,
78+
commit: git2::Oid,
7279
pub author: Option<(String, String)>,
7380
pub committer: Option<(String, String)>,
7481
pub message: Option<String>,
@@ -78,6 +85,7 @@ impl<'a> Clone for Apply<'a> {
7885
fn clone(&self) -> Self {
7986
Apply {
8087
tree: self.tree.clone(),
88+
commit: self.commit.clone(),
8189
author: self.author.clone(),
8290
committer: self.committer.clone(),
8391
message: self.message.clone(),
@@ -90,6 +98,7 @@ impl<'a> Apply<'a> {
9098
Apply {
9199
tree,
92100
author: None,
101+
commit: git2::Oid::zero(),
93102
committer: None,
94103
message: None,
95104
}
@@ -104,6 +113,7 @@ impl<'a> Apply<'a> {
104113
Apply {
105114
tree,
106115
author,
116+
commit: git2::Oid::zero(),
107117
committer,
108118
message,
109119
}
@@ -125,6 +135,7 @@ impl<'a> Apply<'a> {
125135

126136
Ok(Apply {
127137
tree,
138+
commit: commit.id(),
128139
author,
129140
committer,
130141
message,
@@ -135,6 +146,7 @@ impl<'a> Apply<'a> {
135146
Apply {
136147
tree: self.tree,
137148
author: Some(author),
149+
commit: self.commit,
138150
committer: self.committer,
139151
message: self.message,
140152
}
@@ -144,6 +156,7 @@ impl<'a> Apply<'a> {
144156
Apply {
145157
tree: self.tree,
146158
author: self.author,
159+
commit: self.commit,
147160
committer: Some(committer),
148161
message: self.message,
149162
}
@@ -153,15 +166,27 @@ impl<'a> Apply<'a> {
153166
Apply {
154167
tree: self.tree,
155168
author: self.author,
169+
commit: self.commit,
156170
committer: self.committer,
157171
message: Some(message),
158172
}
159173
}
160174

175+
pub fn with_commit(self, commit: git2::Oid) -> Self {
176+
Apply {
177+
tree: self.tree,
178+
author: self.author,
179+
commit: commit,
180+
committer: self.committer,
181+
message: self.message,
182+
}
183+
}
184+
161185
pub fn with_tree(self, tree: git2::Tree<'a>) -> Self {
162186
Apply {
163187
tree,
164188
author: self.author,
189+
commit: self.commit,
165190
committer: self.committer,
166191
message: self.message,
167192
}
@@ -185,7 +210,7 @@ pub fn empty() -> Filter {
185210
}
186211

187212
pub fn message(m: &str) -> Filter {
188-
to_filter(Op::Message(m.to_string()))
213+
to_filter(Op::Message(m.to_string(), MESSAGE_MATCH_ALL_REGEX.clone()))
189214
}
190215

191216
pub fn hook(h: &str) -> Filter {
@@ -285,7 +310,7 @@ enum Op {
285310
Workspace(std::path::PathBuf),
286311

287312
Pattern(String),
288-
Message(String),
313+
Message(String, regex::Regex),
289314

290315
Compose(Vec<Filter>),
291316
Chain(Filter, Filter),
@@ -609,9 +634,12 @@ fn spec2(op: &Op) -> String {
609634
parse::quote(email)
610635
)
611636
}
612-
Op::Message(m) => {
637+
Op::Message(m, r) if r.as_str() == MESSAGE_MATCH_ALL_REGEX.as_str() => {
613638
format!(":{}", parse::quote(m))
614639
}
640+
Op::Message(m, r) => {
641+
format!(":{};{}", parse::quote(m), parse::quote(r.as_str()))
642+
}
615643
Op::Hook(hook) => {
616644
format!(":hook={}", parse::quote(hook))
617645
}
@@ -1069,14 +1097,27 @@ fn apply2<'a>(transaction: &'a cache::Transaction, op: &Op, x: Apply<'a>) -> Jos
10691097
Op::Squash(None) => Ok(x),
10701098
Op::Author(author, email) => Ok(x.with_author((author.clone(), email.clone()))),
10711099
Op::Committer(author, email) => Ok(x.with_committer((author.clone(), email.clone()))),
1072-
Op::Message(m) => Ok(x.with_message(
1073-
// Pass the message through `strfmt` to enable future extensions
1074-
strfmt::strfmt(
1075-
m,
1076-
&std::collections::HashMap::<String, &dyn strfmt::DisplayStr>::new(),
1077-
)?,
1078-
)),
10791100
Op::Squash(Some(_)) => Err(josh_error("not applicable to tree")),
1101+
Op::Message(m, r) => {
1102+
let tree_id = x.tree().id().to_string();
1103+
let commit = x.commit;
1104+
let commit_id = commit.to_string();
1105+
let mut hm = std::collections::HashMap::<String, String>::new();
1106+
hm.insert("tree".to_string(), tree_id);
1107+
hm.insert("commit".to_string(), commit_id);
1108+
1109+
let message = if let Some(ref m) = x.message {
1110+
m.to_string()
1111+
} else {
1112+
if let Ok(c) = transaction.repo().find_commit(commit) {
1113+
c.message_raw().unwrap_or_default().to_string()
1114+
} else {
1115+
"".to_string()
1116+
}
1117+
};
1118+
1119+
Ok(x.with_message(text::transform_with_template(&r, &m, &message, &hm)?))
1120+
}
10801121
Op::Linear => Ok(x),
10811122
Op::Prune => Ok(x),
10821123
Op::Unsign => Ok(x),

josh-core/src/filter/parse.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,14 @@ fn parse_item(pair: pest::iterators::Pair<Rule>) -> JoshResult<Op> {
105105
}
106106
Rule::filter_message => {
107107
let mut inner = pair.into_inner();
108-
Ok(Op::Message(unquote(inner.next().unwrap().as_str())))
108+
let fmt = unquote(inner.next().unwrap().as_str());
109+
let regex = if let Some(r) = inner.next() {
110+
regex::Regex::new(&unquote(r.as_str()))
111+
.map_err(|e| josh_error(&format!("invalid regex: {}", e)))?
112+
} else {
113+
super::MESSAGE_MATCH_ALL_REGEX.clone()
114+
};
115+
Ok(Op::Message(fmt, regex))
109116
}
110117
Rule::filter_group => {
111118
let v: Vec<_> = pair.into_inner().map(|x| unquote(x.as_str())).collect();

josh-core/src/filter/persist.rs

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,10 @@ impl InMemoryBuilder {
165165
let mut entries = Vec::new();
166166

167167
match op {
168+
Op::Message(fmt, regex) => {
169+
let params_tree = self.build_str_params(&[fmt, regex.as_str()]);
170+
push_tree_entries(&mut entries, [("message", params_tree)]);
171+
}
168172
Op::Author(name, email) => {
169173
let params_tree = self.build_str_params(&[name, email]);
170174
push_tree_entries(&mut entries, [("author", params_tree)]);
@@ -209,10 +213,6 @@ impl InMemoryBuilder {
209213
let blob = self.write_blob(pattern.as_bytes());
210214
push_blob_entries(&mut entries, [("pattern", blob)]);
211215
}
212-
Op::Message(m) => {
213-
let blob = self.write_blob(m.as_bytes());
214-
push_blob_entries(&mut entries, [("message", blob)]);
215-
}
216216
Op::Workspace(path) => {
217217
let blob = self.write_blob(path.to_string_lossy().as_bytes());
218218
push_blob_entries(&mut entries, [("workspace", blob)]);
@@ -423,6 +423,26 @@ fn from_tree2(repo: &git2::Repository, tree_oid: git2::Oid) -> JoshResult<Op> {
423423
let email = std::str::from_utf8(email_blob.content())?.to_string();
424424
Ok(Op::Committer(name, email))
425425
}
426+
"message" => {
427+
let inner = repo.find_tree(entry.id())?;
428+
let fmt_blob = repo.find_blob(
429+
inner
430+
.get_name("0")
431+
.ok_or_else(|| josh_error("message: missing fmt string"))?
432+
.id(),
433+
)?;
434+
let regex_blob = repo.find_blob(
435+
inner
436+
.get_name("1")
437+
.ok_or_else(|| josh_error("message: missing regex"))?
438+
.id(),
439+
)?;
440+
let fmt = std::str::from_utf8(fmt_blob.content())?.to_string();
441+
let regex_str = std::str::from_utf8(regex_blob.content())?;
442+
let regex = regex::Regex::new(regex_str)
443+
.map_err(|e| josh_error(&format!("invalid regex: {}", e)))?;
444+
Ok(Op::Message(fmt, regex))
445+
}
426446
"subdir" => {
427447
let blob = repo.find_blob(entry.id())?;
428448
let path = std::str::from_utf8(blob.content())?;
@@ -443,11 +463,6 @@ fn from_tree2(repo: &git2::Repository, tree_oid: git2::Oid) -> JoshResult<Op> {
443463
let pattern = std::str::from_utf8(blob.content())?.to_string();
444464
Ok(Op::Pattern(pattern))
445465
}
446-
"message" => {
447-
let blob = repo.find_blob(entry.id())?;
448-
let message = std::str::from_utf8(blob.content())?.to_string();
449-
Ok(Op::Message(message))
450-
}
451466
"workspace" => {
452467
let blob = repo.find_blob(entry.id())?;
453468
let path = std::str::from_utf8(blob.content())?;

0 commit comments

Comments
 (0)