Skip to content
Open
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 @@ -114,6 +114,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 leading up to the specified commit `<sha>` unchanged,
but applies the given `:filter` to all commits from that commit onwards.

### 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 @@ -51,6 +53,24 @@ filter_rev = {
~ ")"
}

filter_from = {
CMD_START ~ "from" ~ "("
~ NEWLINE*
~ (rev ~ filter_spec)?
~ (CMD_SEP+ ~ (rev ~ filter_spec))*
~ NEWLINE*
~ ")"
}

filter_concat = {
CMD_START ~ "from" ~ "("
~ NEWLINE*
~ (rev ~ filter_spec)?
~ (CMD_SEP+ ~ (rev ~ filter_spec))*
~ NEWLINE*
~ ")"
}
Comment on lines +65 to +72
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.

The filter_concat rule incorrectly uses \"from\" as the command keyword instead of \"concat\". This causes the parser to be unable to distinguish between :from() and :concat() operations. Change line 66 to use \"concat\".

Copilot uses AI. Check for mistakes.

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


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

HistoryConcat(LazyRef, Filter),

Compose(Vec<Filter>),
Chain(Filter, Filter),
Subtract(Filter, Filter),
Expand Down Expand Up @@ -410,6 +412,13 @@ fn lazy_refs2(op: &Op) -> Vec<String> {
av
}
Op::Rev(filters) => lazy_refs2(&Op::Join(filters.clone())),
Op::HistoryConcat(r, _) => {
let mut lr = Vec::new();
if let LazyRef::Lazy(s) = r {
lr.push(s.to_owned());
}
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 @@ -470,6 +479,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 @@ -611,6 +633,9 @@ fn spec2(op: &Op) -> String {
Op::Message(m) => {
format!(":{}", parse::quote(m))
}
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.
}
Op::Hook(hook) => {
format!(":hook={}", parse::quote(hook))
}
Expand Down Expand Up @@ -1025,6 +1050,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 @@ -1065,7 +1103,7 @@ fn apply2<'a>(transaction: &'a cache::Transaction, op: &Op, x: Apply<'a>) -> Jos
Op::Nop => Ok(x),
Op::Empty => Ok(x.with_tree(tree::empty(repo))),
Op::Fold => Ok(x),
Op::Squash(None) => Ok(x),
Op::Squash(..) => Ok(x),
Op::Author(author, email) => Ok(x.with_author((author.clone(), email.clone()))),
Op::Committer(author, email) => Ok(x.with_committer((author.clone(), email.clone()))),
Op::Message(m) => Ok(x.with_message(
Expand All @@ -1075,7 +1113,7 @@ fn apply2<'a>(transaction: &'a cache::Transaction, op: &Op, x: Apply<'a>) -> Jos
&std::collections::HashMap::<String, &dyn strfmt::DisplayStr>::new(),
)?,
)),
Op::Squash(Some(_)) => Err(josh_error("not applicable to tree")),
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 @@ -146,6 +146,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 @@ -275,6 +275,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 @@ -572,6 +576,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_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_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