Skip to content

Commit 7f3c160

Browse files
Extend file filter to support renames
1 parent 92ad841 commit 7f3c160

File tree

8 files changed

+488
-27
lines changed

8 files changed

+488
-27
lines changed

docs/src/reference/filters.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,20 @@ Note that ``:/a/b`` and ``:/a:/b`` are equivalent ways to get the same result.
3333
### Directory **`::a/`**
3434
A shorthand for the commonly occurring filter combination ``:/a:prefix=a``.
3535

36-
### File **`::a`**
37-
Produces a tree with only the specified file in it's root.
36+
### File **`::a`** or **`::destination=source`**
37+
Produces a tree with only the specified file.
38+
39+
When using a single argument (`::a`), the file is placed at the same full path as in the source tree.
40+
When using the `destination=source` syntax (`::destination=source`), the file is renamed from `source` to `destination` in the filtered tree.
41+
42+
Examples:
43+
- `::file.txt` - Selects `file.txt` and places it at `file.txt`
44+
- `::src/file.txt` - Selects `src/file.txt` and places it at `src/file.txt`
45+
- `::renamed.txt=src/original.txt` - Selects `src/original.txt` and places it at `renamed.txt`
46+
- `::subdir/file.txt=src/file.txt` - Selects `src/file.txt` and places it at `subdir/file.txt`
47+
3848
Note that `::a/b` is equivalent to `::a/::b`.
49+
Pattern filters (with `*`) cannot be combined with the `destination=source` syntax.
3950

4051
### Prefix **`:prefix=a`**
4152
Take the input tree and place it into subdirectory ``a``.

josh-core/src/filter/grammar.pest

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ filter_spec = { (
3737
filter_group = { CMD_START ~ cmd? ~ GROUP_START ~ compose ~ GROUP_END }
3838
filter_subdir = { CMD_START ~ "/" ~ argument }
3939
filter_nop = { CMD_START ~ "/" }
40-
filter_presub = { CMD_START ~ ":" ~ argument }
40+
filter_presub = { CMD_START ~ ":" ~ argument ~ ("=" ~ argument)? }
4141
filter = { CMD_START ~ cmd ~ "=" ~ (argument ~ (";" ~ argument)*)? }
4242
filter_noarg = { CMD_START ~ cmd }
4343
filter_message = { CMD_START ~ string ~ (";" ~ string)? }

josh-core/src/filter/mod.rs

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,11 @@ pub fn message(m: &str) -> Filter {
212212
to_filter(Op::Message(m.to_string(), MESSAGE_MATCH_ALL_REGEX.clone()))
213213
}
214214

215+
pub fn file(path: impl Into<std::path::PathBuf>) -> Filter {
216+
let p = path.into();
217+
to_filter(Op::File(p.clone(), p))
218+
}
219+
215220
pub fn hook(h: &str) -> Filter {
216221
to_filter(Op::Hook(h.to_string()))
217222
}
@@ -303,7 +308,7 @@ enum Op {
303308
Index,
304309
Invert,
305310

306-
File(std::path::PathBuf),
311+
File(std::path::PathBuf, std::path::PathBuf), // File(dest_path, source_path)
307312
Prefix(std::path::PathBuf),
308313
Subdir(std::path::PathBuf),
309314
Workspace(std::path::PathBuf),
@@ -619,7 +624,17 @@ fn spec2(op: &Op) -> String {
619624
Op::Linear => ":linear".to_string(),
620625
Op::Unsign => ":unsign".to_string(),
621626
Op::Subdir(path) => format!(":/{}", parse::quote_if(&path.to_string_lossy())),
622-
Op::File(path) => format!("::{}", parse::quote_if(&path.to_string_lossy())),
627+
Op::File(dest_path, source_path) => {
628+
if source_path == dest_path {
629+
format!("::{}", parse::quote_if(&dest_path.to_string_lossy()))
630+
} else {
631+
format!(
632+
"::{}={}",
633+
parse::quote_if(&dest_path.to_string_lossy()),
634+
parse::quote_if(&source_path.to_string_lossy())
635+
)
636+
}
637+
}
623638
Op::Prune => ":prune=trivial-merge".to_string(),
624639
Op::Prefix(path) => format!(":prefix={}", parse::quote_if(&path.to_string_lossy())),
625640
Op::Pattern(pattern) => format!("::{}", parse::quote_if(pattern)),
@@ -652,7 +667,7 @@ pub fn src_path(filter: Filter) -> std::path::PathBuf {
652667
fn src_path2(op: &Op) -> std::path::PathBuf {
653668
normalize_path(&match op {
654669
Op::Subdir(path) => path.to_owned(),
655-
Op::File(path) => path.to_owned(),
670+
Op::File(_, source_path) => source_path.to_owned(),
656671
Op::Chain(a, b) => src_path(*a).join(src_path(*b)),
657672
_ => std::path::PathBuf::new(),
658673
})
@@ -665,7 +680,7 @@ pub fn dst_path(filter: Filter) -> std::path::PathBuf {
665680
fn dst_path2(op: &Op) -> std::path::PathBuf {
666681
normalize_path(&match op {
667682
Op::Prefix(path) => path.to_owned(),
668-
Op::File(path) => path.to_owned(),
683+
Op::File(dest_path, _) => dest_path.to_owned(),
669684
Op::Chain(a, b) => dst_path(*b).join(dst_path(*a)),
670685
_ => std::path::PathBuf::new(),
671686
})
@@ -706,13 +721,7 @@ fn resolve_workspace_redirect<'a>(
706721
.unwrap_or_else(|_| to_filter(Op::Empty));
707722

708723
if let Op::Workspace(p) = to_op(f) {
709-
Some((
710-
chain(
711-
to_filter(Op::Exclude(to_filter(Op::File(path.to_owned())))),
712-
f,
713-
),
714-
p,
715-
))
724+
Some((chain(to_filter(Op::Exclude(file(path))), f), p))
716725
} else {
717726
None
718727
}
@@ -724,7 +733,7 @@ fn get_workspace<'a>(repo: &'a git2::Repository, tree: &'a git2::Tree<'a>, path:
724733
} else {
725734
path.to_owned()
726735
};
727-
let wsj_file = to_filter(Op::File(Path::new("workspace.josh").to_owned()));
736+
let wsj_file = file("workspace.josh");
728737
let base = to_filter(Op::Subdir(path.to_owned()));
729738
let wsj_file = chain(base, wsj_file);
730739
compose(
@@ -1145,13 +1154,19 @@ fn apply2<'a>(transaction: &'a cache::Transaction, op: &Op, x: Apply<'a>) -> Jos
11451154
to_filter(op.clone()).id(),
11461155
)?))
11471156
}
1148-
Op::File(path) => {
1157+
Op::File(dest_path, source_path) => {
11491158
let (file, mode) = x
11501159
.tree()
1151-
.get_path(path)
1160+
.get_path(source_path)
11521161
.map(|x| (x.id(), x.filemode()))
11531162
.unwrap_or((git2::Oid::zero(), git2::FileMode::Blob.into()));
1154-
Ok(x.with_tree(tree::insert(repo, &tree::empty(repo), path, file, mode)?))
1163+
Ok(x.with_tree(tree::insert(
1164+
repo,
1165+
&tree::empty(repo),
1166+
dest_path,
1167+
file,
1168+
mode,
1169+
)?))
11551170
}
11561171

11571172
Op::Subdir(path) => Ok(x.clone().with_tree(
@@ -1295,7 +1310,10 @@ fn unapply_workspace<'a>(
12951310
);
12961311

12971312
let root = to_filter(Op::Subdir(path.to_owned()));
1298-
let wsj_file = to_filter(Op::File(Path::new("workspace.josh").to_owned()));
1313+
let wsj_file = to_filter(Op::File(
1314+
Path::new("workspace.josh").to_owned(),
1315+
Path::new("workspace.josh").to_owned(),
1316+
));
12991317
let wsj_file = chain(root, wsj_file);
13001318
let filter = compose(wsj_file, compose(workspace, root));
13011319
let original_filter = compose(wsj_file, compose(original_workspace, root));

josh-core/src/filter/opt.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -501,7 +501,7 @@ pub fn invert(filter: Filter) -> JoshResult<Filter> {
501501
Op::Unsign => Some(Op::Unsign),
502502
Op::Empty => Some(Op::Empty),
503503
Op::Subdir(path) => Some(Op::Prefix(path)),
504-
Op::File(path) => Some(Op::File(path)),
504+
Op::File(dest_path, source_path) => Some(Op::File(source_path, dest_path)),
505505
Op::Prefix(path) => Some(Op::Subdir(path)),
506506
Op::Pattern(pattern) => Some(Op::Pattern(pattern)),
507507
Op::Rev(_) => Some(Op::Nop),

josh-core/src/filter/parse.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,38 @@ fn parse_item(pair: pest::iterators::Pair<Rule>) -> JoshResult<Op> {
8787
Rule::filter_presub => {
8888
let mut inner = pair.into_inner();
8989
let arg = &unquote(inner.next().unwrap().as_str());
90+
let second_arg = inner.next().map(|x| unquote(x.as_str()));
91+
9092
if arg.ends_with('/') {
9193
let arg = arg.trim_end_matches('/');
9294
Ok(Op::Chain(
9395
to_filter(Op::Subdir(std::path::PathBuf::from(arg))),
9496
to_filter(make_op(&["prefix", arg])?),
9597
))
9698
} else if arg.contains('*') {
99+
// Pattern case - error if combined with = (destination=source syntax)
100+
if second_arg.is_some() {
101+
return Err(josh_error(&format!(
102+
"Pattern filters cannot use destination=source syntax: {}",
103+
arg
104+
)));
105+
}
97106
Ok(Op::Pattern(arg.to_string()))
98107
} else {
99-
Ok(Op::File(Path::new(arg).to_owned()))
108+
// File case - error if source contains * (patterns not supported in source)
109+
if let Some(ref source_arg) = second_arg
110+
&& source_arg.contains('*')
111+
{
112+
return Err(josh_error(&format!(
113+
"Pattern filters not supported in source path: {}",
114+
source_arg
115+
)));
116+
}
117+
let dest_path = Path::new(arg).to_owned();
118+
let source_path = second_arg
119+
.map(|s| Path::new(&s).to_owned())
120+
.unwrap_or_else(|| dest_path.clone());
121+
Ok(Op::File(dest_path, source_path))
100122
}
101123
}
102124
Rule::filter_noarg => {

josh-core/src/filter/persist.rs

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,20 @@ impl InMemoryBuilder {
205205
let blob = self.write_blob(path.to_string_lossy().as_bytes());
206206
push_blob_entries(&mut entries, [("prefix", blob)]);
207207
}
208-
Op::File(path) => {
209-
let blob = self.write_blob(path.to_string_lossy().as_bytes());
210-
push_blob_entries(&mut entries, [("file", blob)]);
208+
Op::File(dest_path, source_path) => {
209+
if source_path == dest_path {
210+
// Backward compatibility: use blob format when source and dest are the same
211+
let blob = self.write_blob(dest_path.to_string_lossy().as_bytes());
212+
push_blob_entries(&mut entries, [("file", blob)]);
213+
} else {
214+
// New format: use tree format when source and dest differ
215+
// Store as (dest_path, source_path) to match enum order
216+
let params_tree = self.build_str_params(&[
217+
dest_path.to_string_lossy().as_ref(),
218+
source_path.to_string_lossy().as_ref(),
219+
]);
220+
push_tree_entries(&mut entries, [("file", params_tree)]);
221+
}
211222
}
212223
Op::Pattern(pattern) => {
213224
let blob = self.write_blob(pattern.as_bytes());
@@ -454,9 +465,37 @@ fn from_tree2(repo: &git2::Repository, tree_oid: git2::Oid) -> JoshResult<Op> {
454465
Ok(Op::Prefix(std::path::PathBuf::from(path)))
455466
}
456467
"file" => {
457-
let blob = repo.find_blob(entry.id())?;
458-
let path = std::str::from_utf8(blob.content())?;
459-
Ok(Op::File(std::path::PathBuf::from(path)))
468+
// Try to read as tree (new format with destination path)
469+
if let Ok(inner) = repo.find_tree(entry.id()) {
470+
let dest_blob = repo.find_blob(
471+
inner
472+
.get_name("0")
473+
.ok_or_else(|| josh_error("file: missing destination path"))?
474+
.id(),
475+
)?;
476+
let dest_path_str = std::str::from_utf8(dest_blob.content())?.to_string();
477+
let source_path = inner
478+
.get_name("1")
479+
.and_then(|entry| repo.find_blob(entry.id()).ok())
480+
.and_then(|blob| {
481+
std::str::from_utf8(blob.content())
482+
.ok()
483+
.map(|s| s.to_string())
484+
})
485+
.map(|s| std::path::PathBuf::from(s))
486+
.unwrap_or_else(|| std::path::PathBuf::from(&dest_path_str));
487+
Ok(Op::File(
488+
std::path::PathBuf::from(dest_path_str),
489+
source_path,
490+
))
491+
} else {
492+
// Fall back to blob format (old format, backward compatibility)
493+
let blob = repo.find_blob(entry.id())?;
494+
let path_str = std::str::from_utf8(blob.content())?.to_string();
495+
let path_buf = std::path::PathBuf::from(&path_str);
496+
// When reading from blob format, destination is the same as source
497+
Ok(Op::File(path_buf.clone(), path_buf))
498+
}
460499
}
461500
"pattern" => {
462501
let blob = repo.find_blob(entry.id())?;

0 commit comments

Comments
 (0)