Skip to content

Commit 8343c3f

Browse files
Extend file filter to support renames
1 parent 5e5ff7c commit 8343c3f

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: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,10 @@ pub fn message(m: &str) -> Filter {
213213
to_filter(Op::Message(m.to_string(), MESSAGE_MATCH_ALL_REGEX.clone()))
214214
}
215215

216+
pub fn file(path: std::path::PathBuf) -> Filter {
217+
to_filter(Op::File(path.clone(), path))
218+
}
219+
216220
pub fn hook(h: &str) -> Filter {
217221
to_filter(Op::Hook(h.to_string()))
218222
}
@@ -304,7 +308,7 @@ enum Op {
304308
Index,
305309
Invert,
306310

307-
File(std::path::PathBuf),
311+
File(std::path::PathBuf, std::path::PathBuf), // File(dest_path, source_path)
308312
Prefix(std::path::PathBuf),
309313
Subdir(std::path::PathBuf),
310314
Workspace(std::path::PathBuf),
@@ -620,7 +624,17 @@ fn spec2(op: &Op) -> String {
620624
Op::Linear => ":linear".to_string(),
621625
Op::Unsign => ":unsign".to_string(),
622626
Op::Subdir(path) => format!(":/{}", parse::quote_if(&path.to_string_lossy())),
623-
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+
}
624638
Op::Prune => ":prune=trivial-merge".to_string(),
625639
Op::Prefix(path) => format!(":prefix={}", parse::quote_if(&path.to_string_lossy())),
626640
Op::Pattern(pattern) => format!("::{}", parse::quote_if(pattern)),
@@ -653,7 +667,7 @@ pub fn src_path(filter: Filter) -> std::path::PathBuf {
653667
fn src_path2(op: &Op) -> std::path::PathBuf {
654668
normalize_path(&match op {
655669
Op::Subdir(path) => path.to_owned(),
656-
Op::File(path) => path.to_owned(),
670+
Op::File(_, source_path) => source_path.to_owned(),
657671
Op::Chain(a, b) => src_path(*a).join(src_path(*b)),
658672
_ => std::path::PathBuf::new(),
659673
})
@@ -666,7 +680,7 @@ pub fn dst_path(filter: Filter) -> std::path::PathBuf {
666680
fn dst_path2(op: &Op) -> std::path::PathBuf {
667681
normalize_path(&match op {
668682
Op::Prefix(path) => path.to_owned(),
669-
Op::File(path) => path.to_owned(),
683+
Op::File(dest_path, _) => dest_path.to_owned(),
670684
Op::Chain(a, b) => dst_path(*b).join(dst_path(*a)),
671685
_ => std::path::PathBuf::new(),
672686
})
@@ -707,13 +721,7 @@ fn resolve_workspace_redirect<'a>(
707721
.unwrap_or_else(|_| to_filter(Op::Empty));
708722

709723
if let Op::Workspace(p) = to_op(f) {
710-
Some((
711-
chain(
712-
to_filter(Op::Exclude(to_filter(Op::File(path.to_owned())))),
713-
f,
714-
),
715-
p,
716-
))
724+
Some((chain(to_filter(Op::Exclude(file(path.to_owned()))), f), p))
717725
} else {
718726
None
719727
}
@@ -725,7 +733,7 @@ fn get_workspace<'a>(repo: &'a git2::Repository, tree: &'a git2::Tree<'a>, path:
725733
} else {
726734
path.to_owned()
727735
};
728-
let wsj_file = to_filter(Op::File(Path::new("workspace.josh").to_owned()));
736+
let wsj_file = file(Path::new("workspace.josh").to_owned());
729737
let base = to_filter(Op::Subdir(path.to_owned()));
730738
let wsj_file = chain(base, wsj_file);
731739
compose(
@@ -1146,13 +1154,19 @@ fn apply2<'a>(transaction: &'a cache::Transaction, op: &Op, x: Apply<'a>) -> Jos
11461154
to_filter(op.clone()).id(),
11471155
)?))
11481156
}
1149-
Op::File(path) => {
1157+
Op::File(dest_path, source_path) => {
11501158
let (file, mode) = x
11511159
.tree()
1152-
.get_path(path)
1160+
.get_path(source_path)
11531161
.map(|x| (x.id(), x.filemode()))
11541162
.unwrap_or((git2::Oid::zero(), git2::FileMode::Blob.into()));
1155-
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+
)?))
11561170
}
11571171

11581172
Op::Subdir(path) => Ok(x.clone().with_tree(
@@ -1296,7 +1310,10 @@ fn unapply_workspace<'a>(
12961310
);
12971311

12981312
let root = to_filter(Op::Subdir(path.to_owned()));
1299-
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+
));
13001317
let wsj_file = chain(root, wsj_file);
13011318
let filter = compose(wsj_file, compose(workspace, root));
13021319
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
@@ -502,7 +502,7 @@ pub fn invert(filter: Filter) -> JoshResult<Filter> {
502502
Op::Unsign => Some(Op::Unsign),
503503
Op::Empty => Some(Op::Empty),
504504
Op::Subdir(path) => Some(Op::Prefix(path)),
505-
Op::File(path) => Some(Op::File(path)),
505+
Op::File(dest_path, source_path) => Some(Op::File(source_path, dest_path)),
506506
Op::Prefix(path) => Some(Op::Subdir(path)),
507507
Op::Pattern(pattern) => Some(Op::Pattern(pattern)),
508508
Op::Rev(_) => Some(Op::Nop),

josh-core/src/filter/parse.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,39 @@ 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 has_second_arg = inner.peek().is_some();
91+
let second_arg = inner.next().map(|x| unquote(x.as_str()));
92+
9093
if arg.ends_with('/') {
9194
let arg = arg.trim_end_matches('/');
9295
Ok(Op::Chain(
9396
to_filter(Op::Subdir(std::path::PathBuf::from(arg))),
9497
to_filter(make_op(&["prefix", arg])?),
9598
))
9699
} else if arg.contains('*') {
100+
// Pattern case - error if combined with = (destination=source syntax)
101+
if has_second_arg {
102+
return Err(josh_error(&format!(
103+
"Pattern filters cannot use destination=source syntax: {}",
104+
arg
105+
)));
106+
}
97107
Ok(Op::Pattern(arg.to_string()))
98108
} else {
99-
Ok(Op::File(Path::new(arg).to_owned()))
109+
// File case - error if source contains * (patterns not supported in source)
110+
if let Some(ref source_arg) = second_arg {
111+
if source_arg.contains('*') {
112+
return Err(josh_error(&format!(
113+
"Pattern filters not supported in source path: {}",
114+
source_arg
115+
)));
116+
}
117+
}
118+
let dest_path = Path::new(arg).to_owned();
119+
let source_path = second_arg
120+
.map(|s| Path::new(&s).to_owned())
121+
.unwrap_or_else(|| dest_path.clone());
122+
Ok(Op::File(dest_path, source_path))
100123
}
101124
}
102125
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)