Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/src/reference/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ commits that don't match any of the other shas.
Produce the history that would be the result of pushing the passed branches with the
passed filters into the upstream.

### Start filtering from a specific commit **:from(<sha>:filter)**

Produce a history that keeps the original history up to and including the specified commit `<sha>` unchanged,
but applies the given `:filter` to all commits after that commit.

### Prune trivial merge commits **:prune=trivial-merge**

Produce a history that skips all merge commits whose tree is identical to the first parents
Expand Down
21 changes: 20 additions & 1 deletion josh-core/src/filter/grammar.pest
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ filter_spec = { (
filter_group
| filter_message
| filter_rev
| filter_from
| filter_concat
| filter_join
| filter_replace
| filter_squash
Expand Down Expand Up @@ -53,6 +55,24 @@ filter_rev = {
~ ")"
}

filter_from = {
CMD_START ~ "from" ~ "("
~ NEWLINE*
~ (rev ~ filter_spec)?
~ (CMD_SEP+ ~ (rev ~ filter_spec))*
Comment on lines +61 to +62
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The grammar for filter_from is more permissive than the implementation. The grammar allows optional arguments and multiple comma-separated pairs with (rev ~ filter_spec)? ~ (CMD_SEP+ ~ (rev ~ filter_spec))*, but the parsing code (lines 184-193 in parse.rs) requires exactly 2 elements. The grammar should enforce exactly one rev and one filter_spec by using rev ~ filter_spec without the optional ? and without the repetition *.

Suggested change
~ (rev ~ filter_spec)?
~ (CMD_SEP+ ~ (rev ~ filter_spec))*
~ rev ~ filter_spec

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want to combine all those similar rules into one eventually so I'd like to keep it the same as the others.

~ NEWLINE*
~ ")"
}

filter_concat = {
CMD_START ~ "concat" ~ "("
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filter_concat rule incorrectly uses "from" as its keyword instead of "concat". This will cause parsing conflicts since both filter_from and filter_concat would match the same input syntax :from(...). The keyword should be "concat" to match the intended :concat(...) syntax.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

already fixed

~ NEWLINE*
~ (rev ~ filter_spec)?
~ (CMD_SEP+ ~ (rev ~ filter_spec))*
Comment on lines +70 to +71
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The grammar for filter_concat is more permissive than the implementation. The grammar allows optional arguments and multiple comma-separated pairs with (rev ~ filter_spec)? ~ (CMD_SEP+ ~ (rev ~ filter_spec))*, but the parsing code (lines 198-204 in parse.rs) requires exactly 2 elements. The grammar should enforce exactly one rev and one filter_spec by using rev ~ filter_spec without the optional ? and without the repetition *.

Suggested change
~ (rev ~ filter_spec)?
~ (CMD_SEP+ ~ (rev ~ filter_spec))*
~ rev ~ filter_spec

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Want to keep it like that for now to make it easier to see the similarity with other rules.

~ NEWLINE*
~ ")"
}

filter_join = {
CMD_START ~ "join" ~ "("
~ NEWLINE*
Expand All @@ -62,7 +82,6 @@ filter_join = {
~ ")"
}


filter_replace = {
CMD_START ~ "replace" ~ "("
~ NEWLINE*
Expand Down
40 changes: 40 additions & 0 deletions josh-core/src/filter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ enum Op {
Pattern(String),
Message(String, regex::Regex),

HistoryConcat(LazyRef, Filter),

Compose(Vec<Filter>),
Chain(Filter, Filter),
Subtract(Filter, Filter),
Expand Down Expand Up @@ -441,6 +443,14 @@ fn lazy_refs2(op: &Op) -> Vec<String> {
av
}
Op::Rev(filters) => lazy_refs2(&Op::Join(filters.clone())),
Op::HistoryConcat(r, f) => {
let mut lr = Vec::new();
if let LazyRef::Lazy(s) = r {
lr.push(s.to_owned());
}
lr.append(&mut lazy_refs(*f));
lr
}
Op::Join(filters) => {
let mut lr = lazy_refs2(&Op::Compose(filters.values().copied().collect()));
lr.extend(filters.keys().filter_map(|x| {
Expand Down Expand Up @@ -501,6 +511,19 @@ fn resolve_refs2(refs: &std::collections::HashMap<String, git2::Oid>, op: &Op) -
.collect();
Op::Rev(lr)
}
Op::HistoryConcat(r, filter) => {
let f = resolve_refs(refs, *filter);
let resolved_ref = if let LazyRef::Lazy(s) = r {
if let Some(res) = refs.get(s) {
LazyRef::Resolved(*res)
} else {
r.clone()
}
} else {
r.clone()
};
Op::HistoryConcat(resolved_ref, f)
}
Op::Join(filters) => {
let lr = filters
.iter()
Expand Down Expand Up @@ -658,6 +681,9 @@ fn spec2(op: &Op) -> String {
Op::Message(m, r) => {
format!(":{};{}", parse::quote(m), parse::quote(r.as_str()))
}
Op::HistoryConcat(r, filter) => {
format!(":concat({}{})", r.to_string(), spec(*filter))
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing separator between the reference and the filter spec. The format should be :concat({}{}) with a colon separator, similar to how Op::Rev and Op::Join format their output on lines 593 and 601. This should be format!(\"{}{}\", r.to_string(), spec(*filter)) to match the input format expected by the parser.

Suggested change
format!(":concat({}{})", r.to_string(), spec(*filter))
format!(":concat({}:{})", r.to_string(), spec(*filter))

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not true. the : is part of the spec.

}
Op::Hook(hook) => {
format!(":hook={}", parse::quote(hook))
}
Expand Down Expand Up @@ -1113,6 +1139,19 @@ fn apply_to_commit2(

return per_rev_filter(transaction, commit, filter, commit_filter, parent_filters);
}
Op::HistoryConcat(r, f) => {
if let LazyRef::Resolved(c) = r {
let a = apply_to_commit2(&to_op(*f), &repo.find_commit(*c)?, transaction)?;
let a = some_or!(a, { return Ok(None) });
if commit.id() == a {
transaction.insert(filter, commit.id(), *c, true);
return Ok(Some(*c));
}
} else {
return Err(josh_error("unresolved lazy ref"));
}
Apply::from_commit(commit)?
}
_ => apply(transaction, filter, Apply::from_commit(commit)?)?,
};

Expand Down Expand Up @@ -1177,6 +1216,7 @@ fn apply2<'a>(transaction: &'a cache::Transaction, op: &Op, x: Apply<'a>) -> Jos

Ok(x.with_message(text::transform_with_template(&r, &m, &message, &hm)?))
}
Op::HistoryConcat(..) => Ok(x),
Op::Linear => Ok(x),
Op::Prune => Ok(x),
Op::Unsign => Ok(x),
Expand Down
25 changes: 25 additions & 0 deletions josh-core/src/filter/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,31 @@ fn parse_item(pair: pest::iterators::Pair<Rule>) -> JoshResult<Op> {

Ok(Op::Rev(hm))
}
Rule::filter_from => {
let v: Vec<_> = pair.into_inner().map(|x| x.as_str()).collect();

if v.len() == 2 {
let oid = LazyRef::parse(v[0])?;
let filter = parse(v[1])?;
Ok(Op::Chain(
filter,
filter::to_filter(Op::HistoryConcat(oid, filter)),
))
} else {
Err(josh_error("wrong argument count for :from"))
}
}
Rule::filter_concat => {
let v: Vec<_> = pair.into_inner().map(|x| x.as_str()).collect();

if v.len() == 2 {
let oid = LazyRef::parse(v[0])?;
let filter = parse(v[1])?;
Ok(Op::HistoryConcat(oid, filter))
} else {
Err(josh_error("wrong argument count for :concat"))
}
}
Rule::filter_replace => {
let replacements = pair
.into_inner()
Expand Down
26 changes: 26 additions & 0 deletions josh-core/src/filter/persist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,10 @@ impl InMemoryBuilder {
let params_tree = self.build_rev_params(&v)?;
push_tree_entries(&mut entries, [("join", params_tree)]);
}
Op::HistoryConcat(lr, f) => {
let params_tree = self.build_rev_params(&[(lr.to_string(), *f)])?;
push_tree_entries(&mut entries, [("concat", params_tree)]);
}
Op::Squash(Some(ids)) => {
let mut v = ids
.iter()
Expand Down Expand Up @@ -635,6 +639,28 @@ fn from_tree2(repo: &git2::Repository, tree_oid: git2::Oid) -> JoshResult<Op> {
}
Ok(Op::Join(filters))
}
"concat" => {
let concat_tree = repo.find_tree(entry.id())?;
let entry = concat_tree
.get(0)
.ok_or_else(|| josh_error("concat: missing entry"))?;
let inner_tree = repo.find_tree(entry.id())?;
let key_blob = repo.find_blob(
inner_tree
.get_name("o")
.ok_or_else(|| josh_error("concat: missing key"))?
.id(),
)?;
let filter_tree = repo.find_tree(
inner_tree
.get_name("f")
.ok_or_else(|| josh_error("concat: missing filter"))?
.id(),
)?;
let key = std::str::from_utf8(key_blob.content())?.to_string();
let filter = from_tree2(repo, filter_tree.id())?;
Ok(Op::HistoryConcat(LazyRef::parse(&key)?, to_filter(filter)))
}
"squash" => {
// blob -> Squash(None), tree -> Squash(Some(...))
if let Some(kind) = entry.kind() {
Expand Down
42 changes: 42 additions & 0 deletions tests/filter/concat.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
$ export TESTTMP=${PWD}

$ cd ${TESTTMP}
$ git init -q libs 1> /dev/null
$ cd libs

$ mkdir sub1
$ echo contents1 > sub1/file1
$ git add sub1
$ git commit -m "add file1" 1> /dev/null

$ echo contents2 > sub1/file2
$ git add sub1
$ git commit -m "add file2" 1> /dev/null
$ git update-ref refs/heads/from_here HEAD


$ mkdir sub2
$ echo contents1 > sub2/file3
$ git add sub2
$ git commit -m "add file3" 1> /dev/null

$ josh-filter ":\"x\""

$ git log --graph --pretty=%s:%H HEAD
* add file3:667a912db7482f3c8023082c9b4c7b267792633a
* add file2:81b10fb4984d20142cd275b89c91c346e536876a
* add file1:bb282e9cdc1b972fffd08fd21eead43bc0c83cb8

$ git log --graph --pretty=%s:%H FILTERED_HEAD
* x:9d117d96dfdba145df43ebe37d9e526acac4b17c
* x:b232aa8eefaadfb5e38b3ad7355118aa59fb651e
* x:6b4d1f87c2be08f7d0f9d40b6679aab612e259b1

$ josh-filter -p ":from(81b10fb4984d20142cd275b89c91c346e536876a:\"x\")"
:"x":concat(81b10fb4984d20142cd275b89c91c346e536876a:"x")
$ josh-filter ":from(81b10fb4984d20142cd275b89c91c346e536876a:\"x\")"

$ git log --graph --pretty=%s FILTERED_HEAD
* x
* add file2
* add file1
2 changes: 1 addition & 1 deletion tests/proxy/workspace_errors.t
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ Error in filter
remote: 1 | a/b = :b/sub2
remote: | ^---
remote: |
remote: = expected EOI, filter_group, filter_subdir, filter_stored, filter_nop, filter_presub, filter, filter_noarg, filter_message, filter_rev, filter_join, filter_replace, or filter_squash
remote: = expected EOI, filter_group, filter_subdir, filter_stored, filter_nop, filter_presub, filter, filter_noarg, filter_message, filter_rev, filter_from, filter_concat, filter_join, filter_replace, or filter_squash
remote:
remote: a/b = :b/sub2
remote: c = :/sub1
Expand Down