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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,21 @@ All notable changes to `devloop` will be recorded in this file.

## [Unreleased]

## [0.9.1] - 2026-06-24

### Changed
- Updated the GitHub release publishing action to
`softprops/action-gh-release@v3.0.0`, which uses the Node 24 action
runtime.

### Fixed
- Literal trailing-slash directory watch patterns (for example
`content/`) now match files nested inside the directory, so edits
under a watched directory trigger its workflow. The directory was
already watched recursively, but the change-to-workflow matcher used
the bare pattern, which `globset` treats as a literal that never
matches nested files, so no workflow ran.

## [0.9.0] - 2026-05-04

### Added
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "devloop"
version = "0.9.0"
version = "0.9.1"
edition = "2024"

[dependencies]
Expand Down
38 changes: 36 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ impl WatchGroup {
let mut builder = GlobSetBuilder::new();
for pattern in &self.paths {
builder
.add(Glob::new(pattern).with_context(|| {
.add(Glob::new(&matcher_pattern(pattern)).with_context(|| {
format!("invalid glob '{pattern}' in watch group '{name}'")
})?);
}
Expand All @@ -221,7 +221,7 @@ impl CompiledWatchGroup {
pub fn for_test(patterns: &[&str], workflow: &str) -> Result<Self> {
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
builder.add(Glob::new(pattern)?);
builder.add(Glob::new(&matcher_pattern(pattern))?);
}
Ok(Self {
workflow: workflow.to_owned(),
Expand Down Expand Up @@ -322,6 +322,22 @@ fn pattern_is_literal(pattern: &str) -> bool {
!pattern.split('/').any(segment_has_glob_magic) && !pattern.contains("**")
}

/// Glob used to match change events for a watch pattern.
///
/// A literal directory target (trailing `/`) is registered as a *recursive*
/// watch target (see [`CompiledWatchTarget::from_pattern`]), so its matcher
/// must also match files nested inside it — not just the bare directory path.
/// Without this, the directory is watched and events arrive, but the bare
/// `apps/api/src/` glob never matches `apps/api/src/foo.ts`, so no workflow
/// fires. Expand such patterns to `apps/api/src/**`.
fn matcher_pattern(pattern: &str) -> String {
if pattern.ends_with('/') && pattern_is_literal(pattern) {
format!("{pattern}**")
} else {
pattern.to_owned()
}
}

fn segment_has_glob_magic(segment: &str) -> bool {
segment.contains('*') || segment.contains('?') || segment.contains('[') || segment.contains('{')
}
Expand Down Expand Up @@ -1383,6 +1399,24 @@ mod tests {
);
}

#[test]
fn literal_directory_pattern_matches_nested_files() {
// Regression: a trailing-slash directory is watched recursively, so its
// matcher must match files nested inside it — not just the bare dir.
let group = CompiledWatchGroup::for_test(&["apps/api/src/"], "api").unwrap();
assert!(group.matches(Path::new("apps/api/src/dimensions.ts")));
assert!(group.matches(Path::new("apps/api/src/nested/deep.ts")));
assert!(!group.matches(Path::new("apps/web/src/page.tsx")));
}

#[test]
fn literal_file_pattern_matches_only_that_file() {
// A pattern without a trailing slash stays a single-file target.
let group = CompiledWatchGroup::for_test(&["Cargo.toml"], "rust").unwrap();
assert!(group.matches(Path::new("Cargo.toml")));
assert!(!group.matches(Path::new("src/Cargo.toml")));
}

#[test]
fn config_detects_notify_reload_in_nested_workflow() {
let mut config = base_config();
Expand Down
Loading