mirror of
https://github.com/bahdotsh/wrkflw.git
synced 2026-05-18 05:05:35 +02:00
ebe8083f339eb3ccb3f14738836d85a733977a21
11 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ebe8083f33 |
feat(ui): ship screens 4, 7, 8 and the Tweaks overlay (#105)
* feat(ui): ship screens 4, 7, 8 and the Tweaks overlay PR #104 landed three screens from the Claude Design handoff (Dashboard, Live Run, Step Inspector) and deliberately punted the rest because "a UI without backing data is worse than no UI." Fair. Picked up the remaining screens that actually *have* backing data today, and left the one that doesn't alone. Three new top-level tabs: - DAG (tab 3) — full topological view of the selected workflow, `g` toggles between the spatial column layout and a stage-list layout. Reuses the existing `dag::topo_levels` so what you see here is exactly what the mini-DAG in the Execution tab shows, just bigger. - Trigger (tab 5) — form + live curl preview, dispatches through `wrkflw_github::trigger_workflow` or `wrkflw_gitlab::trigger_pipeline`. `p` flips platform, `+` adds a k=v input, `Tab` walks fields, `Enter` dispatches, `c` dumps the curl into the log buffer because integrating with every terminal's clipboard is not a fight I want to pick today. - Secrets (tab 6) — reads SecretConfig::default(), shows the providers the user actually has wired, detail pane with `m` to toggle the value mask. Runtime pills on the side share state with the existing runtime cycler. We do *not* render a list of individual secret keys; SecretManager doesn't expose a list_known_keys() and I refuse to fake one. Step Inspector's Matrix sub-tab now calls wrkflw_matrix::expand_matrix and renders up to 32 combos with an overflow badge. Status glyph inherits from the parent job because per-combo status isn't tracked end-to-end yet — a future executor change to surface it will drop straight into this render. Tweaks overlay (`,` key) ports the design's floating TweaksPanel. Only one knob: accent color, five slots matching the design palette. theme::set_accent_override plumbs the chosen color into a thread-local the brand mark and focused borders consult every frame. Dropped theme (dark/light) and density from the panel because they'd be dead toggles today — the rule is the rule. What's still not in: screen 6 (Run history + diff). That one needs a storage layer we don't have — no run persistence, no serialized run records, no diff engine. Building a UI on top of nothing would be exactly the thing PR #104 warned against. Left for a separate PR that starts from storage. Verified: cargo build --workspace, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace (734 tests, all green), cargo test -p wrkflw-ui (25/25). Clean. * docs(examples): add ui-demo workflow set for the new screens The new UI (screens 4, 7, 8 + Tweaks) shipped without a dedicated set of workflows to poke at it. The existing tests/workflows/ fixtures are fine for the parser, but they don't have the shapes you actually want to eyeball — there's no clean diamond, no wide multi-stage fan-out, no workflow_dispatch with a mix of input types. So you end up firing up the TUI and squinting at whatever happens to be checked in. Not great. Add examples/ui-demo/ with one workflow per screen/feature: a diamond and a wide fan-out DAG for screen 4, a workflow_dispatch with choice / string / bool inputs for screen 8, a matrix with include/exclude plus three scopes of env for the Step Inspector, a secrets + services + container job for screen 7, a multi-event trigger for the `d` diff filter, and a mixed-status workflow so the status badges aren't stuck on all-green. Every file carries a header comment explaining which screen it targets and what to look at. All eight parse clean under `wrkflw validate`. * fix(ui): clean up loose ends around the new tabs and Tweaks overlay The DAG / Trigger / Secrets tabs and the Tweaks overlay shipped with the edges unfinished. A review turned up a pile of correctness and consistency bugs that individually didn't look like much but together amounted to "the tabs work but everything around them lies." The status bar's context_hints still only knew about the old 4-tab layout — tab 2 was now DAG but showed Logs hints, tab 3 was Logs but showed Help hints, tabs 4-6 went blank. The Help overlay still said "1-4 / w,x,l,h" and listed four tabs. Every keyboard-help surface was advertising the wrong layout. The Tweaks overlay's doc comment claimed it was modal — "the overlay wins" — but the match arm only handled Esc / \`,\` / \`a\`/\`A\`. Everything else (q, d, 1-7, ?) fell through to the global handler. The comment was aspirational. The code was not. trigger_dispatch had no in-flight guard. Hit Enter twice quickly, fire two workflow_dispatch requests. Against real repos. This is the kind of "oops we double-ran the deploy" bug that nobody enjoys. render_target_pane called resolve_target on every frame, which shelled out to \`git remote get-url origin\` + \`git symbolic-ref\` +/- \`git rev-parse\` *on every frame*. The event loop polls at 50ms, so sitting on the Trigger tab produced ~40-60 git subprocesses per second. Per second. And the DAG tab cheerfully printed fabricated column labels — ["setup", "lint", "build", "test/docs", …] — positionally assigned to topological levels, with no relationship to actual workflow content. A release pipeline's third layer would read "build" even when it was \`deploy\`. Exactly the "UI without backing data" footgun the previous PR was supposed to avoid. The fix is mechanical: - Rewrite status_bar hints and the Help overlay for 7 tabs. - Make the Tweaks overlay actually modal — unmatched keys \`continue\` instead of falling through. - Add a trigger_in_flight AtomicBool; set before spawn, cleared by the spawned task regardless of outcome. - Cache the resolved target on App; invalidate on platform toggle. No more subprocess storm. - Replace the hand-rolled JSON escaper with serde_json, which has been in the tree the whole time. While at it, shell-escape the GitHub ref and double-quote-escape the GitLab k/v pairs so the curl preview is copy-paste safe. - Drop the fabricated stage labels; print "stage N" because that's the honest thing. - Sort the include-only matrix column tail so HashMap order can't cause visual jitter between frames. - switch_tab re-masks secrets when leaving the Secrets tab. The doc had claimed this all along; now it's actually true. - Empty env vars no longer count as a set auth token. - trigger_input_cursor is Option<usize>, not a usize::MAX sentinel. Please don't use usize::MAX for "not set" when Option exists. Sixteen new unit tests cover the new helpers and edge cases. Full workspace build + clippy -D warnings + test suite clean. * fix(ui): stop the Trigger tab from smearing the TUI on dispatch The Trigger tab shipped in the previous commit calls into wrkflw_github::trigger_workflow and wrkflw_gitlab::trigger_pipeline from a tokio::spawn. Both of those are happily scattering println! and eprintln! calls — "Repository: x/y", "Using branch: main", "Workflow triggered successfully!", you name it — straight to the stdout that ratatui is rendering into. The TUI owns the terminal. The SDK crates write to it anyway. The result is a frame full of garbled half-redrawn panels the moment the user hits Enter. wrkflw_logging::set_quiet_mode(true) is already being armed at TUI start, but it only gates the logging subsystem — raw println! skips right past it. Route every one of those prints through wrkflw_logging::{info,warning} so the quiet-mode flag actually does the thing it says on the tin. Adds wrkflw-logging as a dep to both crates, which is fine — it's where those messages belonged in the first place. While at it, fix the rest of what was broken around the Trigger tab: The GitLab curl preview was cheerfully lying. It pointed users at /trigger/pipeline with a PRIVATE-TOKEN header, which is a combination that has never worked — /trigger/pipeline wants a trigger token, and the real dispatcher calls /pipeline with a JSON body. Copy-paste the preview and you'd get a 401, maybe a 404. Rebuilt both previews off the same request shape the dispatcher actually sends. GitHub and GitLab bodies are now built via serde_json end-to-end (github_dispatches_body, gitlab_pipeline_body), shell-escaped with a single helper, and the URL uses the resolved repo from the target cache instead of <owner>/<repo> / <id> placeholders. The dispatch outcome used to reach the user only via the Logs tab — the one on Trigger heard nothing back. Added a mpsc channel so the spawned task reports success/failure back to the main event loop, which mirrors it onto the status bar. Drain runs once per tick next to the existing execution-result drain. The DAG graph view was splitting the area into equal-ratio columns with no floor, so on anything narrower than 18 * stages cells the box-drawing characters wrapped and the whole graph looked like someone spilled coffee on it. Clamped columns to a fixed 18-cell width, render "+N more stages" in the overflow slot, and nudge users toward g for list view. Dropped the duplicated "L1 stage 1" header while I was in there. AccentScope wraps the thread-local accent override in an RAII guard so it lives for exactly one frame. A thread-local that nobody ever clears is a booby trap for whoever adds the second theme knob. The old trigger-dispatch test spawned a real tokio task that called the real wrkflw_github::trigger_workflow. If GITHUB_TOKEN happened to be set in the environment — hello, CI — it would fire a real workflow_dispatch against whatever repo git remote resolved to. That is not a test. Replaced with a synchronous guard test that pre-arms the in-flight flag and asserts rejection. No spawn, no runtime, no network. New tests cover the preview bodies, split_slug, and the outcome-drain mapping. * fix(ui): sand off the UX edges flagged in the screens-4-8 review A review pass over the new DAG / Trigger / Secrets / Tweaks tabs turned up a small but consistent failure mode: several bits of the UI were quietly lying to the user, and a couple of input paths were eating keystrokes that mattered. The Secrets tab's header badge advertised "providers configured", but the tab reads `SecretConfig::default()` unconditionally — no real config file is loaded yet. A user who had carefully customised their secrets config would see the two hard-coded defaults and a badge telling them everything was wired up. That's exactly the "UI without backing data" footgun PR #104 set out to ban. Rename the badge to "default providers" and update the file header so the caveat doesn't get lost the next time someone skims the module. The Trigger tab's edit-mode key handler returned `false` for Up / Down / Left / Right, which meant directional keys fell through to the global handler and called `trigger_tab_prev/next_workflow`. So a user typing `env=prod` who hit ↓ to correct the row above had the *workflow about to be dispatched* silently changed underneath them. Consume the arrows in edit mode — no-op for now; a future enhancement can route them to prev/next field if we want that. `resolve_trigger_target`'s error branch admitted the repo was `<unresolved>` while cheerfully inventing `default_branch: "main"`. Two fields, two voices, one warn badge the user might or might not notice. Mark the branch `<unresolved>` too so the un-resolution story is consistent. The dispatcher has its own `get_repo_info` call in the hot path, so this is strictly a display-honesty fix. The Tweaks overlay was modal to the point of swallowing `q`. Quit is universally modal-safe in this TUI; silently eating it is a discoverability trap. Let it through. While at it, `field_row_hl` was taking `hint: String` where the sibling `field_row` took `&str`. Match the sibling. Tiny thing, but the kind of inconsistency that compounds. Two regression tests pinned to the arrow-swallow path and the resolve-error shape, so a future refactor can't quietly re-open either footgun. * fix(ui): clear out the self-review pile for screens 4-8 A review of the new DAG / Trigger / Secrets tabs and the Tweaks overlay turned up a depressingly long list of "works in the happy path, lies to the user otherwise" bugs. Dealing with them in one sweep rather than trickling out a dozen one-liner fixes. The headliner: the Trigger tab renders a "Branch / ref" row as if it's editable, but *no keystroke* ever wrote into `trigger_branch`. The dispatcher and the curl preview both honoured the field — the field just had no input path. A user wanting anything other than the git-resolved default was out of luck. Add a `b` shortcut to focus the branch row, extend `trigger_handle_input_key` to route into `self.trigger_branch` when focused, and rework `trigger_tab_next_field` so Tab cycles Branch → Input(0).key → Input(0).value → … → wrap. Three tests pin the edit path, the Esc-clears-override path, and the mutual exclusion between branch edit and input edit. `state_for_job` in the DAG tab was using `std::ptr::eq` against `app.workflows` elements to figure out which workflow was the current execution, with `usize::MAX` as the fallback. It worked today because the render path gets its `&Workflow` from the same Vec, but it was one refactor away from returning `Some(usize::MAX)` and silently matching against `app.current_execution`. Thread the index the caller already has and compare that. No more pointer identity. The Secrets tab's "reveal / mask" toggle was theatre. There are no actual secret values at this layer — the "value" being masked was the *provider's source descriptor*, i.e. a filesystem path or an env-var prefix. Bullet-masking a filename protects nothing and teaches the user that "masking" means something it doesn't. Rip out `secrets_reveal`, the `m` handler, and the `switch_tab` auto-revert. When per-secret values land the toggle can come back attached to something it can legitimately hide. The Trigger tab's curl preview was building a `format!` string with Rust's backslash-newline line continuation (which *strips* the backslash) and then splitting the result on `" \\"`. That split never matched anything, so the preview rendered as one long line relying on terminal wrap. Rebuild with explicit ` \\\n` joins and split on `\n` at render time. `format_yaml_scalar`'s fallback runs non-scalar matrix values through `serde_yaml::to_string`, which for sequences and maps returns multi-line YAML. That got shoved into a single ratatui Span. Collapse embedded newlines to a visible ` · ` separator. `drain_trigger_outcomes` overwrote `status_message` on every iteration — if two outcomes arrived in the same tick (rare, but the in-flight guard could relax later) the first was silently lost. Log each drained outcome to `self.logs` as the durable record, and have the status bar prefer errors over later successes in the same drain. Tab indices: stop pretending `2, 3, 4, 5, 6` are self-documenting. Introduce `TAB_WORKFLOWS` / `TAB_EXECUTION` / … constants next to `TAB_LABELS` and use them everywhere. `switch_tab` used to define a local `SECRETS_TAB = 5` that the rest of the file promptly ignored. Please don't do that. While at it, widen the accent override plumb-through. The Tweaks panel claimed to recolour the UI but only `block_focused` and the title_bar brand were actually consulting `current_accent()` — the other 14 `COLORS.accent` call sites stayed on the static palette. A user flipping accent saw the focused borders change and nothing else, which looks broken. Point all accent readers at the thread-local override. Minor: hoist \`provider_entries()\` to one call per frame (was 3×), dedupe the \`truncate(name, 12)\` call in the DAG graph (was computed twice per node). cargo fmt + clippy -D warnings clean; 48/48 UI tests green. * fix(ui): close the screens-4-8 review debt A review pass over the screens-4-8 branch turned up three things that the earlier "done" sweep in |
||
|
|
154df50968 |
feat: add diff-aware selective execution and watch mode (#91)
* feat: add diff-aware selective execution and watch mode
Add two new crates and CLI capabilities that differentiate wrkflw from act:
- `wrkflw-trigger-filter`: Parses workflow `on:` trigger configs (paths,
branches, tags filters) and evaluates them against git diff state to
determine which workflows would actually trigger.
- `wrkflw-watcher`: File system watcher using `notify` crate with
debouncing that auto-detects changes and re-runs affected workflows.
CLI additions:
- `wrkflw run --diff` uses git diff for trigger-aware execution
- `wrkflw run --event <type>` simulates a specific event type
- `wrkflw run --changed-files <list>` manually specifies changed files
- `wrkflw watch` monitors for changes and runs matching workflows
TUI additions:
- Press `d` to toggle diff-aware trigger filter overlay
- Shows green/gray indicators for which workflows would trigger
- Status bar reflects diff filter state
* fix(trigger-filter,watcher): correct glob semantics and fix watcher polling
The glob matchers in both path_matcher and ref_matcher were using
require_literal_separator: false, which means * happily matches /
in file paths and ref names. That's *not* how GitHub Actions works
— * matches anything except /, and ** is what crosses directory
boundaries. So src/* would incorrectly match src/sub/file.rs.
The test that was supposed to catch this (star_does_not_match_slash)
actually tested src/**/*.rs instead. Sneaky.
Set require_literal_separator: true in both matchers and fix the
test to actually assert the correct behavior.
While at it, fix two issues in the watcher's Debouncer:
The mutex handling was inconsistent — add_event and has_pending
silently swallowed poison, but drain called unwrap() and would
panic. Extract a lock_or_recover() helper that consistently
recovers from poison via into_inner() everywhere.
The main watch loop was busy-polling with a 100ms sleep, which is
wasteful and adds needless latency. Replace it with a
tokio::sync::Notify that fires when add_event inserts a path,
so the loop blocks properly until there's actual work to do.
* fix(trigger-filter): correct filter bypass and evaluate activity types
It turns out that when the event context had no branch (e.g., a tag
push), the branch filter check was simply *skipped*, meaning a
workflow with `branches: [main]` would happily match a tag push.
Same problem with the tag filter. The `if let Some(ref branch)`
guard meant "no branch = pass the filter", which is the exact
opposite of what GitHub Actions does.
The fix inverts the logic: check whether the filter *requires*
branches/tags first, and if the context doesn't have one, fail
immediately. While at it, the `types` field was being dutifully
parsed from YAML into `EventFilter.types` and then completely
ignored during evaluation. A workflow with `pull_request: { types:
[opened] }` would trigger on every PR event regardless. That's not
great. Add `activity_type` to `EventContext` and actually evaluate
it.
Also fix the TUI's `toggle_diff_filter` which was running
synchronous git subprocesses on the render thread — blocking the
entire UI while `git diff` thinks about its life choices. Move the
heavy lifting to `std::thread::spawn`. And replace an O(n²)
`Vec::contains` dedup in `get_changed_files` with a HashSet.
* fix(trigger-filter,watcher,ui): fix TUI blocking, watcher event loss, and dual filtering
Three issues found during review, all stemming from the diff-aware
execution feature introduced in the previous commits.
The TUI's toggle_diff_filter() was calling thread::spawn().join(),
which *blocks the main event loop thread* while git commands and
workflow parsing run. If git is slow (large repo, broken index,
network mount), the entire TUI freezes with no way out. Convert to
a fire-and-forget thread with an mpsc channel — the background
thread sends results, and check_diff_filter_results() polls on each
tick via try_recv(). No blocking, no freezing.
The watcher's main loop had a subtle event loss bug. After
notify.notified().await returns and a (potentially long) workflow
execution completes, any events that accumulated during execution
sit in the debouncer. But the loop goes back to notified().await,
which blocks until the *next* filesystem event — the accumulated
ones are orphaned. Add a has_pending() check before awaiting so we
process the backlog immediately.
The executor had an event_filter field on ExecutionConfig, giving it
trigger evaluation responsibilities that don't belong there. The
watcher already filters externally (correctly), creating two
divergent code paths for the same concern. Remove event_filter from
ExecutionConfig entirely and move trigger evaluation to the call
site in main.rs, where it belongs. The executor should just execute.
* fix(trigger-filter,watcher,ui): eliminate duplicated git logic, fix missing untracked files, and parallelize watcher
It turns out the TUI's evaluate_diff_filter was hand-rolling its
own git commands via std::process::Command instead of reusing the
async helpers in trigger-filter::git. Worse, it was only running
`git diff --name-only HEAD` and *not* including untracked files
via `git ls-files --others`. So newly added files were silently
invisible to the TUI diff filter. Not great.
Add synchronous wrappers (get_changed_files_sync,
get_current_branch_sync, get_current_tag_sync) to
trigger-filter::git, sharing the parsing logic with the async
versions via extracted helpers. The TUI now calls these instead
of maintaining its own parallel universe of git invocations.
While at it, fix three more things:
- The watcher was executing triggered workflows sequentially,
meaning a slow workflow would block all others and starve
event processing. Switch to futures::future::join_all for
concurrent execution.
- The debouncer's drain() was unconditionally sleeping for the
full debounce duration even when nothing was pending. Now it
returns immediately when empty and re-checks for new arrivals
after each sleep window so event bursts settle properly.
- The eval reason messages were useless when filters failed due
to missing context (no branch, no tag, no activity type). They
now say *why* the filter couldn't match instead of just dumping
the pattern list.
* fix(watcher,trigger-filter,ui): fix watcher stalls, unbounded concurrency, and sync API duplication
The watcher had a few problems that would make it miserable to use on
any real-world repository.
First, the notify callback was using blocking_send() on the mpsc
channel, which means when the channel fills up (256 slots), the *OS
filesystem event thread* blocks. That's the kind of thing that makes
your entire watcher stop receiving events. Switched to try_send() —
dropping a duplicate event is fine since the debouncer coalesces
anyway.
Second, the watcher was recursively watching the entire repo root
with no exclusions beyond .git/. On any project with a target/ or
node_modules/ directory, this floods the event channel with thousands
of irrelevant build artifact changes. Added a DEFAULT_IGNORE_DIRS
list that filters at the path-component level.
Third, join_all() on the triggered workflow futures meant *unbounded*
concurrent execution. If 20 workflows trigger simultaneously, that's
20 Docker containers spawning at once. Replaced with
buffer_unordered(4) so we don't eat all available resources.
While at it, a few more cleanups from the review:
- Renamed the misleading on_event callback to on_cycle_complete,
since it fires *after* execution completes, not on detection.
- Extracted the 170-line evaluate-and-execute loop from run() into
its own evaluate_and_execute() method.
- Parallelized the two sequential git subprocess calls in
get_changed_files() with tokio::join!.
- Killed the entire sync API in git.rs (three copy-pasted functions).
The TUI's evaluate_diff_filter now spins up a lightweight
current_thread tokio runtime instead of maintaining duplicate sync
wrappers. Having the same logic in two places with two slightly
different implementations is not redundancy, it's a bug farm.
- Renamed LogFilterLevel::to_string() to as_str() because shadowing
the ToString trait with an inherent method is a Clippy lint and
genuinely confusing.
* fix(trigger-filter,watcher,ui): fix debouncer livelock, dead code, and broken diff base
Three issues found during review, all interconnected:
The debouncer's drain() loop had no upper bound on settle rounds.
If filesystem events arrived faster than the debounce window (e.g.
a large build writing hundreds of files), it would spin forever
comparing pending.len() != count_before. Add a MAX_SETTLE_ROUNDS
cap of 10 so it eventually gives up waiting for silence and just
drains what it has.
The trigger-filter crate exported filter_workflows() and
auto_detect_context() — nice reusable helpers — and then both
state.rs and watcher.rs proceeded to ignore them entirely and
inline their own copies of the same logic. Three copies of "build
EventContext, loop over workflows, evaluate triggers" is not
redundancy, it's a maintenance trap. Refactor both callsites to
actually use the helpers they're sitting next to.
The TUI diff filter was hardcoded to diff against HEAD, which only
shows uncommitted changes. Post-commit, the diff is empty and every
workflow gets filtered out. Add get_default_diff_base() which
checks for uncommitted changes first (uses HEAD), then falls back
to merge-base with main/master, then HEAD~1. The TUI now uses this
via auto_detect_context so it shows meaningful results even after
committing.
While at it, cargo fmt touched eval.rs formatting. No logic change.
* fix(trigger-filter): fix staged file blindness, hardcoded default branch, and dead code
get_changed_files() was running `git diff --name-only HEAD` and
proudly calling that "staged + unstaged changes." It turns out
that command only shows *unstaged* changes. Files sitting in the
index after `git add` were completely invisible to trigger
evaluation. Confusion ensues.
Add a parallel `git diff --cached --name-only <base>` call and
merge its results with the unstaged diff, deduplicating paths.
Now we actually see what the user staged. Novel concept.
get_default_diff_base() had "main" and "master" hardcoded as the
only candidates for the merge-base. Repos using "develop", "trunk",
or literally anything else would silently fall back to HEAD~1,
which is almost certainly wrong. Query `git symbolic-ref
refs/remotes/origin/HEAD` first to detect the *actual* default
branch, then fall back to the hardcoded list.
While at it, remove the dead PatternError variant from
TriggerFilterError — it was never constructed anywhere.
* fix(trigger-filter,watcher,ui): consolidate EventContext, fix fallback crash, and harden watch loop
Three call sites — CLI run, TUI toggle, and the watcher — were each
hand-assembling EventContext with subtly different logic. The CLI
forgot to use auto_detect_context, the watcher skipped untracked
files, and the TUI was the only one doing it right. It was only a
matter of time before these diverged further.
Add context_from_diff_range() and context_from_changed_files() to
trigger-filter and use them everywhere. One place to get it right,
zero places to get it wrong.
The get_default_diff_base() fallback was blindly returning "HEAD~1"
which *explodes* on single-commit repos because there is no parent.
Now we verify HEAD~1 resolves first and fall back to the git empty
tree SHA (4b825dc6...) as the ultimate backstop — which means "all
files are changed" rather than "crash and burn."
While at it:
- Wrap on_cycle_complete in spawn_blocking so a blocking callback
can't stall the entire watch loop
- Re-collect workflow files each cycle so newly added .yml files
get picked up without a restart
- Guard rapid 'd' toggle in the TUI against spawning duplicate
background threads by dropping the in-flight receiver first
- Add 15 new tests covering filter_workflows, should_ignore_path,
debouncer livelock prevention, invalid globs, and parser types
* fix(watcher,wrkflw,ui): make watch concurrency configurable and warn on lone --event
Three small things from a code review that were bugging me.
First: the watcher had MAX_CONCURRENT_EXECUTIONS hardcoded to 4.
Fine for a typical .github/workflows directory, terrible for anyone
with 30 workflows who actually wants them to run. Promote it to
DEFAULT_MAX_CONCURRENT_EXECUTIONS, plumb a max_concurrency parameter
through WorkflowWatcher::new, and expose it as --max-concurrency on
the watch command. Clamp to >= 1 because passing 0 to
buffer_unordered would just deadlock, and "deadlock on user input"
is not the user experience I want to ship.
Second: `wrkflw run --event push` (without --diff or
--changed-files) silently builds an EventContext with zero changed
files. Any workflow with a `paths:` filter then "doesn't trigger"
and the user has no idea why. Log a warning explaining exactly what
happened and what to do about it. Not a fix per se — the behavior is
technically correct — but silent surprise is worse than a loud
warning.
While at it: add a comment on the `_watcher` binding in the watch
loop because RecommendedWatcher stops emitting events the moment it
gets dropped, and that's the kind of footgun future-me will
absolutely step on. Also flatten a pointless nested if/else in
status_bar.rs that clippy would have eventually complained about
anyway.
* fix(trigger-filter,watcher,ui): stop lying about which workflows would run
The diff-aware execution feature had a pile of quiet ways to
mislead the user, which is the worst kind of bug in a tool whose
entire purpose is "tell me what would run."
First, invalid glob patterns were silently swallowed. A typo
like `paths: [src/**.rs]` matched nothing and there was no
diagnostic — the workflow just reported "would not trigger" and
everyone assumed it was correct. Compile globs up front in
parse_trigger_config and surface bad patterns as ParseError
pointing at the exact key (e.g. `push.paths`). Pre-compiling
also means we're not re-running Pattern::new on every hot path.
Second, the \`pull_request: branches:\` filter was matched against
the current working branch. GitHub Actions matches it against
the *base* (target) branch, so any workflow guarded by
\`pull_request: branches: [main]\` was reported as "would not
trigger" when evaluated on a feature branch. That's the exact
opposite of what happens on GitHub. Add base_branch to
EventContext, route it through branch_for_filter(), expose a
--base-branch flag on both \`run\` and \`watch\`, and warn loudly
when the user simulates pull_request without it.
Third — and this is the fun one — the TUI's diff filter zipped
background-thread results *positionally* against self.workflows.
The background thread captured workflow_paths at toggle time
and sent back a Vec. If the workflow list reloaded between
toggle and result delivery (a new .yml appears on disk, which
is *exactly* what a file-watcher app does), the positional zip
assigned statuses to the wrong workflows. Send (PathBuf, status)
pairs through the channel and key them by path on receipt.
While at it:
- should_ignore_path matched any component anywhere in the path
against the blacklist, so a file literally named
\`scripts/target\` or \`docs/build\` was silently dropped.
Repo-root-relative + skip-the-leaf fixes it.
- All git commands take an optional cwd and prepend
\`git -C <root>\`, so the watcher queries the repo it's actually
watching instead of whatever the process CWD happens to be.
- Ref names are validated against an allowlist before being
passed to \`git diff\`, so \`--upload-pack=foo\` and friends can't
sneak in through --diff-base.
- For non-HEAD base, the redundant \`git diff --cached <ref>\` was
doing nothing useful — \`git diff <ref>\` already covers staged
+ unstaged. Dropped.
- Workflow files are cached in the watcher and only reparsed
when they actually change. If all of them fail to parse, say
so — before, the user just saw "0 triggered, 0 skipped" and
wondered what they'd broken.
- Empty-tree SHA fallback is now a named constant and logs a
warning when it's used. If you've fallen back to diffing
against the empty tree, you almost certainly don't want to.
- The 200-line Run arm in main.rs is extracted into
run_trigger_prefilter_or_exit().
- Dead TriggerFilterError::IoError variant removed.
Tests: 57 in trigger-filter (incl. PR base_branch semantics,
invalid-glob parse errors, ref validation), 13 in watcher
(incl. file-named-target cases), plus a regression test in the
TUI for the path-keyed mapping.
* fix(watcher,trigger-filter): stop dropping fs events and thrashing globs
A fresh code review turned up a handful of real issues in the
diff-aware execution path. A few of them were silent failure
modes, which is the worst kind.
The watcher had a 256-slot bounded MPSC with `try_send` sitting
between the notify callback and the debouncer. The comment said
dropping was fine "because the debouncer coalesces anyway" — which
is only true for repeat events on the *same* path. Burst 300
distinct file changes at it and ~44 unique paths just vanish, and
any workflow whose `paths:` filter matched exactly one of those
files fails to trigger. Which is the entire point of the feature.
Rip the channel out. The notify callback now pushes straight into
the debouncer's shared set — a hashset insert under a mutex,
bounded in cost, and it dedups for free. No intermediate buffer
to overflow.
It turns out `strip_prefix(&self.repo_root)` was also lying to us.
On macOS, `/var` gets delivered as `/private/var`; symlinked
worktrees have the same problem. Every event would fail the
prefix check, the relative path list ended up empty, and the loop
cheerfully `continue`d without telling anyone. Canonicalize the
repo root once at startup and canonicalize incoming paths before
stripping. Warn in verbose mode when events still don't resolve,
so there's a breadcrumb when it does go wrong.
While in there, the watcher was re-parsing every workflow's
trigger config *and recompiling every glob pattern* on every
cycle, because `filter_workflows` takes `WorkflowDefinition` and
parses on the spot. Add `filter_trigger_configs` taking pre-parsed
`WorkflowTriggerConfig`s, and have the watcher cache them by path,
invalidated only when the backing file actually shows up in
`changed_paths`. Free performance for anyone with more than three
workflow files.
And some smaller stuff the review turned up:
`auto_detect_context` and friends were running three git
subprocesses sequentially for absolutely no reason. `tokio::join!`
them. `validate_ref_name` was rejecting `HEAD@{1}`, `@~1`, and
`origin/main@{upstream}` — all perfectly legitimate git revision
syntax — so extend the allowlist to include `@`, `{`, `}`. The
TUI's diff-filter evaluator was hardcoding `event_name = "push"`;
thread it through as a parameter so future event selection is a
plumbing change, not a rewrite. And `run --diff` crashed with a
confusing "Error parsing workflow" message when pointed at a
directory — catch that up front and point the user at
`wrkflw watch` instead.
* fix(trigger-filter,watcher,ui): plug silent-failure holes and unblock reactor
The review on this branch turned up a handful of "the filter
quietly lies to you" bugs plus a watch loop that cheerfully
calls blocking syscalls from inside an async function. Let's
fix all of it.
First, the *actually* scary one: inline `!`-prefixed patterns
in `branches:`/`tags:`/`paths:` were compiled as literal globs.
The `glob` crate doesn't treat a leading `!` as negation, so
something like `branches: ['main', '!release/old']` would
compile `!release/old` into a pattern that matches the string
`!release/old` and *nothing else*. The exclusion got silently
dropped and the workflow happily triggered on `release/old`.
Route those into the `*_ignore` lists at parse time, support
the `!!literal` escape, and reject mixing inline `!` with a
dedicated `*-ignore` key (GHA itself rejects that). Four new
tests cover it.
Second silent lie: `get_default_diff_base` fell back to the
empty-tree SHA when no better base could be detected, which
made `git diff` list *every tracked file* as changed. The
"filter" then matched everything — the log warning was the
only hint. Make it return an error and let the CLI tell the
user to pass `--diff-base` explicitly. A filter that can't
tell you what it's filtering is worse than no filter.
While at it, wrap every git subprocess in a 10s
`tokio::time::timeout` so a hung git (network fs, credential
prompt, corrupt repo) can't wedge the watch loop forever. And
drop the redundant `git diff --cached HEAD` pass — plain
`git diff HEAD` already covers staged + unstaged + deleted.
It was doing nothing but spawning a second process.
Third problem was the watch loop itself. `collect_workflow_files`
ran `read_dir` synchronously *inside* the async run loop on
every cycle, and the per-cycle canonicalize pass called
`std::fs::canonicalize` on every changed path without ever
hopping onto a blocking thread. On a branch switch touching
thousands of files this stalls the reactor. Move both onto
`spawn_blocking`. Also rewrote `should_ignore_path` to walk
`rel.parent().components()` directly instead of collecting
into a `Vec<Component>` — it runs on every notify event, so
the allocation was genuinely wasteful.
The doc comment on `run()` claimed the callback "will not
stall the async watch loop" because it ran on `spawn_blocking`.
That's misleading — the loop awaits the spawned task, so the
*next* cycle doesn't start until the callback returns. Rewrote
it to describe the actual behavior (events accumulate in the
debouncer during the callback; keep it fast).
While I was in the UI crate, the diff-filter toggle was
spawning a raw OS thread and building a fresh
`current_thread` tokio runtime on it — inside a TUI that's
*already* running on a tokio runtime. This is the kind of
thing that looks fine until you try to debug it. Use
`tokio::task::spawn` on the ambient runtime and push the
workflow-parsing loop onto `spawn_blocking`. Same amount of
concurrency, none of the nested-runtime weirdness.
Also: the TUI hardcodes `push` as the simulated event but
never told the user. A workflow with `on: pull_request` would
show up as SKIPPED and the user had no idea why. Surface it
in the block title as `[DIFF: push]` and mention it in the
toggle log. Extracted to a `const` on `App` so a future event
selector is plumbing-only.
Minor cleanups: consistent `Error:` prefixes in the CLI
prefilter (was mixing "Warning: Failed" with `exit(1)` which
is just confusing), and dropped the now-unused
`wrkflw-logging` dep from `trigger-filter`.
All workspace tests pass, clippy is clean.
* fix(watcher,trigger-filter,ui): plug deleted-file + detached-HEAD holes and kill TUI foot-guns
A principal-engineer review of this branch turned up a pile of
correctness bugs and UX foot-guns that were all individually small
but collectively embarrassing. Let's fix them.
The nastiest one: when a file was deleted, `canonicalize` would fail
on the missing target, the fallback was the raw path from notify, and
on macOS (`/private/var` vs `/var`) or any symlinked working tree the
subsequent `strip_prefix(repo_root)` would drop the event on the
floor. So `rm src/main.rs` would *silently* fail to trigger any
workflow with `paths: [src/**]`. Which — in a tool whose entire point
is "run the workflows for what actually changed" — is not great.
Fixed by walking back to the nearest canonicalizable ancestor and
re-joining the missing tail. It's not pretty, but it's correct, and
it keeps deletions on the happy path.
While at it:
- `should_ignore_path` used to iterate the *absolute* path
components when `strip_prefix` failed, which means a perfectly
innocent `/home/alice/target-acquisition/file.rs` would match the
`target` ignore entry. Now we just refuse to filter anything that
isn't under the watch root. Please don't have directories named
after cargo internals outside your repo, but if you do, we won't
punish you for it.
- `get_current_branch` was returning the literal string `"HEAD"` in
detached-HEAD state, because that's what `git rev-parse
--abbrev-ref HEAD` prints. Any workflow with `branches: [HEAD]`
would then "match" in detached state. Signature is now
`Result<Option<String>, _>`, callers `.ok().flatten()`.
- Notify events are now filtered by kind. Access, metadata, Any,
Other are dropped; only Create, Remove, Modify::Data,
Modify::Name make it to the debouncer. A `git checkout` firing a
thousand rename events would previously blow through
MAX_SETTLE_ROUNDS mid-burst. It still might, but at least we're
not also counting every atime update.
- The run() callback is now fire-and-forget. Previously the watch
loop awaited `spawn_blocking(cb).await`, so a slow reporter (file
sink, Slack webhook, whatever comes next) would stall the next
cycle. Documented as "keep it fast" is not a real defense.
On the TUI side, the diff-filter key (`d`) had three separate
problems.
First, the event was a hardcoded `const DIFF_FILTER_EVENT = "push"`.
Users whose main workflow is `pull_request` would press `d`, see
every single workflow reported as SKIPPED, and file a bug. It's now
a field (`App::diff_filter_event`) so a future event selector is a
data-flow change and not a public API churn.
Second, rapid toggles leaked background tasks. Each press spawned a
fresh git + parse-every-workflow task and just dropped the previous
channel. Now we hold a `JoinHandle` and `abort()` the previous task
before starting a new one.
Third, when `auto_detect_context_default_base` errored (fresh repo,
no default branch, whatever), the UI silently showed every workflow
as None and the summary line said "0/N workflows would trigger" —
with *no* indication that anything had failed. The background task
now returns a `DiffFilterOutcome::{Success, Failure}` and the
failure reason gets pushed into the TUI log.
Also:
- Status bar default hint lost `t trigger` when `d diff` was added.
The `t` keybinding still worked, only discoverability was gone.
Restored.
- Workflows-tab title now reads the dynamic `diff_filter_event`
field instead of the const.
Structural cleanup that was overdue anyway:
- Introduced `WatcherConfig` builder. `WorkflowWatcher::new` already
had eight parameters and a `#[allow(clippy::too_many_arguments)]`
— the next knob was going to push it past ten. The old positional
constructor is kept as a legacy forwarder so the CLI call site
doesn't have to change in the same commit.
- Split `WorkflowWatcher::run` into `refresh_trigger_cache` and
`canonicalize_changed_paths` helpers. The loop was 190 lines of
five concerns inline and completely untestable without spinning
up a real notify watcher.
- New `wrkflw_trigger_filter::load_trigger_config(path)` helper
consolidates read + parse + compile globs. The watcher, the TUI
diff evaluator, and the `run` CLI prefilter now all go through
the same function, so they fail identically on the same broken
file instead of each printing its own flavor of error.
- `parser::resolve_include_and_ignore` replaces 40 lines of near-
identical copy-paste across `branches`/`tags`/`paths`. Having
the same bug in three places is not my idea of redundancy.
- `wrkflw run` prefilter now roots git operations at the repo root
via `find_repo_root()`, matching what the watcher already does.
Consistent behavior when invoked from a subdirectory.
Tests: 12 new tests covering deleted-file canonicalization, event-
kind filtering, detached HEAD, diff-base error path, and the
failure-reporting path in the TUI. Tempfile-backed git tests for
`get_changed_files`, `get_default_diff_base`, and
`get_current_branch`. Regression test for the absolute-path leak in
`should_ignore_path`. 104 tests total across the three crates, all
green, clippy clean.
* fix(watcher,trigger-filter,ui,wrkflw): plug stale cache, silent parse failures, and reactor stalls
- watcher: refresh_trigger_cache compared raw notify paths (absolute,
OS-canonicalized) against read_dir paths (relative), so the
"this workflow file was edited, reparse it" branch was dead code and
watch mode served stale parsed configs forever after the first edit.
Canonicalize both sides into the same form before set membership.
- watcher: refresh_trigger_cache ran sync file I/O on the reactor.
Move it onto a spawn_blocking thread via a new async wrapper; keep
the sync helper as a free function so unit tests don't need a runtime.
- ui: evaluate_diff_filter dropped parse failures via .filter_map(.. .ok()),
leaving users with N workflows showing as "-" and no signal that
their YAML was broken. Capture failures into a new DiffFilterReport
and surface each one in the log pane.
- ui: evaluate_diff_filter ran git helpers with cwd: None, so the TUI
evaluated against whatever directory the user happened to be in
rather than the discovered repo root. Plumb find_repo_root() through.
- trigger-filter: validate_ref_name accepted '..', git's range syntax,
which slipped through into format!("{base}..{head}") interpolation
and produced confusing three-dot errors. Reject it explicitly.
- trigger-filter: move find_repo_root from watcher into trigger-filter
(the right home for our git shell-outs); watcher re-exports it for
source compat.
- wrkflw,watcher: migrate the CLI off the legacy WorkflowWatcher::new
positional ctor to the WatcherConfig builder, then delete the legacy
ctor and the unused filter_workflows API.
* fix(trigger-filter,watcher,wrkflw): plug git zombie + watcher panic + silent filter holes
PR review on the diff-aware/watch branch turned up six issues that
either silently lie to the user or quietly degrade the watch loop
over time. None of them blow up immediately, which is precisely why
they're worth fixing before this lands.
It turns out the 10s `tokio::time::timeout` around every git
subprocess does *nothing* for the failure mode it was built for. A
hung child (NFS stall, credential prompt, corrupt repo) keeps
running after the timeout fires because `tokio::process::Command`
does not enable `kill_on_drop` by default — the future just gets
dropped and the zombie keeps consuming a process slot. On a long-
running watch session against a flaky mount that's exactly how you
end up with hundreds of zombie `git` processes. `kill_on_drop(true)`
on `git_cmd`. One line. Should have been there from the start.
The parser was happy to accept `paths:` + `paths-ignore:` (and the
branches/tags equivalents) on the same event. GitHub Actions rejects
that combination at upload time, so users could iterate locally
against semantics that would later fail in production. Same crate,
`extract_string_list` was silently dropping non-string list entries
via `filter_map(... .as_str())` — a typo like `paths: [{src: foo}]`
yielded an empty list, which then matched everything. The exact
"silently lying about which workflows would run" failure mode the
rest of this branch is built to prevent. Both now surface as
`ParseError` with the offending `event.key[idx]` location.
`auto_detect_context` collapsed a real `git rev-parse` failure to
`branch: None` via `.ok().flatten()`, which made a permission denied
on `.git/HEAD` look identical to a clean detached checkout. The
detached-HEAD path already returns `Ok(None)` from
`get_current_branch`, so the fix is just to drop the `.ok().flatten()`
and let `?` propagate real errors.
The watcher had two ways to silently brick itself. First, when
`context_from_changed_files` errored, it fell back to a context
with `branch: None` and kept going — every `branches:`-filtered
workflow then deterministically rejected for the rest of the session
while the user just saw "0 triggered" forever. Now the cycle short-
circuits, attaches the reason to a new `WatchEvent::error` field,
and the CLI surfaces it. Second, a panic from the executor would
unwind through `buffer_unordered` and kill the entire watch loop —
one rogue workflow takes down the session for everything. I wanted
to use \`tokio::spawn\` for isolation, but the executor's
\`dyn ContainerRuntime\` is not \`Sync\` so the future is not \`Send\`.
\`AssertUnwindSafe(...).catch_unwind()\` from \`futures\` works on the
local future, doesn't require \`Send\`, and gives us the same
"contain it, log it, keep going" behavior.
While at it: cache the canonical workflow path inside each trigger
cache entry so refresh stops re-canonicalizing every workflow on
every cycle (one \`lstat\` walk per file per cycle adds up on a
network mount); drop the no-op \`WorkflowWatcher\` accessor methods
that were pretending to be source-compat shims on a brand-new file;
and split the 130-line, 8-arg \`run_trigger_prefilter_or_exit\` in
main.rs into a \`PrefilterRequest\` struct + two helpers so the
\`#[allow(clippy::too_many_arguments)]\` can go away.
Tests cover all of it: parser combo-rejection (paths/branches/tags),
non-string entry rejection in both \`paths:\` and \`types:\`, the null-
is-empty negative case, canonical-path memoization across two
refresh cycles, and a structural test that
\`AssertUnwindSafe.catch_unwind\` actually classifies a panicking
future as \`Err\` so the watcher's match arm fires. Existing
\`parse_mapping_with_tags\` rewritten to use inline \`!\`-negation
because the old shape now violates the rule it asserts against.
\`cargo test --workspace\`: zero failures. clippy + fmt clean.
* fix(trigger-filter,watcher,ui): kill silent failures and dead code
found in review
A principal-engineer-style review of the diff-aware-execution branch
turned up a small pile of paper cuts. None of them are catastrophic
on their own, but together they're the kind of "everything looks
fine, why is nothing triggering?" failure mode this entire feature
is supposed to *prevent*.
The main offender lives in `extract_glob_list`. It carried a
`compiled.source = display_src;` reassignment with a comment claiming
it preserved the human-visible source for the `!!` escape — except
`pattern_src` and `display_src` are equal in every branch, and
`GlobPattern::new` already wrote `pattern_src` into `compiled.source`
on the line above. So the assignment did nothing, and the comment
lied about it. Collapse the two variables into one `pattern_str`
and delete the dead write. While at it, document the greedy `!!`
behavior properly and pin the `!!!foo` triple-bang case with a
test, since GHA's spec doesn't cover it and we *will* refactor this
function again someday.
The TUI's `toggle_diff_filter` was calling `find_repo_root()`
synchronously on the UI thread. That's a `git rev-parse
--show-toplevel` subprocess. Microseconds on local disk, an
*actual hitch* on a network mount, every single toggle. Move it
into `spawn_blocking` inside the spawned task where it belongs.
`evaluate_and_execute` was also doing two pointless `git`
subprocess calls per cycle when there were zero parseable
workflows. Short-circuit on `configs.is_empty()` and stop paying
for context we'll never use.
While I was in there:
- `canonicalize_changed_paths` used to `filter_map(... .ok())`
away any path it couldn't strip-prefix to the repo root.
Notify *should* never deliver one of those, but if it does,
silently dropping the event is exactly the failure mode this
feature exists to kill. Log the dropped path under `--verbose`.
- `get_default_diff_base` only stripped the `origin/` prefix
from `git symbolic-ref --short` output. If `--short` ever
fails to apply (older git, weird vendor, who knows), the
output is `refs/remotes/origin/main` and the candidate name
ends up wrong. Strip both forms.
- `get_changed_files`'s doc claimed "covers staged + unstaged
+ committed-on-branch." That's *almost* true, but the
modify-then-stage-then-revert-in-WT edge case is genuinely
invisible to `git diff <base>`. Don't promise things we can't
deliver — the trigger filter is an approximation, not a full
index audit.
- `explain_filter_failure` now mentions an empty change set when
a `paths:` filter rejects, so the user who passed `--event
push` without `--diff` gets a directly actionable hint instead
of a `paths: ["src/**"]` dump and a furrowed brow.
- `WorkflowWatcher::run` is an unbounded `loop {}` with no
cancellation path. The CLI fakes graceful shutdown by calling
`process::exit(0)` from a Ctrl+C handler. That works for the
CLI, but anyone embedding this in a daemon or test harness is
going to be unhappy. Document the limitation loudly so the
next person knows it's a known follow-up rather than an
oversight.
- The `JoinHandle::abort` in `toggle_diff_filter` doesn't
actually cancel anything — abort only signals at await
points, and the git futures inside the task may keep running.
Soften the doc to "no longer observed" instead of overstating
the guarantee.
None of this is glamorous. The bias of this entire branch has
been "fail loud, don't lie about what's running," and this just
finishes the cleanup the review surfaced.
Build clean, all 71 trigger-filter / 21 watcher / 19 ui tests
pass, no clippy warnings.
* fix(trigger-filter,watcher,ui,wrkflw): kill five more silent-failure holes from review
A principal-engineer review of the diff-aware/watch branch turned up
five places where this PR was still doing the exact thing the rest of
the branch was built to stop doing: failing quietly. Let's fix them.
`merge_unique` in trigger-filter built a `seen` set from `into` and
then never updated it inside the loop, so duplicates *within* `more`
itself happily slipped through. The function name is a contract — it
just wasn't being honored. Switch to `HashSet::insert`'s "was new"
return so each entry costs one lookup, not two, and add the obvious
regression test.
The watcher's notify callback was the next offender: a stray
`if let Ok(event) = res` swallowed every `notify::Error` — queue
overflow, inotify watch budget exhaustion, kernel disconnects, the
lot. The user gets a cheerful "watching..." banner while the loop is
effectively dead. Log the errors as warnings instead. While at it,
`canonicalize_changed_paths` was doing the same trick with
`unwrap_or_default` on its `JoinError`, mirroring nothing the
neighboring `refresh_trigger_cache_async` was doing. Made them
consistent.
The TUI diff filter had a more subtle one. `toggle_diff_filter`
aborts the previous in-flight evaluation on rapid toggles by dropping
the receiver, which makes the *next* `check_diff_filter_results`
tick see `Disconnected` — and unconditionally log "Diff filter:
evaluation failed". Telling the user their action failed when *they*
caused the disconnect is not great. Add a `diff_filter_aborted`
flag, arm it only when there was actually something to abort, and
clear it when consumed. Genuine task panics still get logged loudly.
And finally, `wrkflw run`'s prefilter was calling
`load_trigger_config` directly from an async fn, despite the
library's own docs saying "wrap in spawn_blocking or call from a
blocking context." The watcher and TUI both already do the right
thing here; the CLI was the odd one out. Drifting on this is
*exactly* how the silent-failure holes accumulated in the first
place, so move it onto the blocking pool.
Tests added for each: merge_unique dedup-within-more, the abort-flag
arming and consumption, and a "real disconnect still gets logged"
companion. trigger-filter 73/73, watcher 21/21, ui 21/21,
wrkflw 3/3, clippy clean.
* fix(trigger-filter,watcher,ui,wrkflw): plug another round of silent-failure holes from review
Another review pass on the diff-aware/watch feature, another batch
of "this thing was lying to the user." Same theme as the previous
five rounds, but the failure modes here are louder and harder to
recover from once they bite, so they go in their own commit.
The TUI's `diff_filter_aborted` flag was a footgun. It got armed
when a toggle aborted an in-flight evaluation, but on the OFF path
the next tick observed `rx is None`, returned early, and never
cleared the flag. The *next* toggle ON spawned a fresh task with a
fresh receiver — and inherited the stale flag. So if that fresh
task genuinely failed, the resulting `Disconnected` was treated as
a self-inflicted abort and silently swallowed. Exactly the failure
mode this whole feature has been built to prevent. Clear the flag
at the start of every new evaluation and pin it with a regression
test that drives the full ON → OFF → tick → ON → fail sequence.
The watcher's `is_relevant_event_kind` had a catch-all `Modify(_)
=> false` that dropped `Modify(Any)` events. It turns out kqueue
(FreeBSD, some macOS configurations) and notify's `PollWatcher`
fallback emit `Modify(Any)` for content changes when the underlying
API can't distinguish data from metadata. So the watcher worked
beautifully on Linux inotify and macOS FSEvents and was *completely
silent* on the other backends. Make the match exhaustive on
`ModifyKind` (no catch-all) so future enum additions surface as a
compile error instead of getting routed to "drop", and treat
`Modify(Any)` as relevant.
The watcher's repo-root canonicalization fallback was the worst of
the three. When `canonicalize` failed, the previous code logged a
warning and continued with the raw path. On macOS that means every
notify event arrives as `/private/var/...`, every `strip_prefix`
against the non-canonical `/var/...` rejects, and the user sees
"Watching..." forever with zero workflows ever firing. A watcher
that *looks* alive but never matches a single event is the absolute
worst possible failure mode for this feature. Make it fatal at
startup. If you can't canonicalize the repo root, you don't get a
watcher.
While at it: stamp `--activity-type` through all three integration
points so workflows with `pull_request: { types: [...] }` filters
actually work in the TUI, the watcher, AND the CLI prefilter
(previously only the CLI bothered, and even then only after the
last round of fixes); make `--diff-base` optional on the run
command so the smart auto-detect (origin/HEAD → main → master →
HEAD~1) is actually reachable instead of being shadowed by a
`default_value = "HEAD"` that silently restricted every `--diff`
invocation to uncommitted-only changes; add an info log when
`get_default_diff_base` falls back to HEAD because the working
tree is dirty, since "I have WIP edits, why is `--diff` reporting
nothing triggered?" was a guaranteed support question; extract
`load_trigger_configs` into the trigger-filter crate so the TUI
and watcher stop maintaining near-duplicate parse-and-partition
loops; tighten the activity-type diagnostic to point at the actual
CLI flag instead of a tautology ("requires one"); warn loudly when
a workflow panics inside the watch loop's `catch_unwind` that the
executor may have left containers, tempdirs, or child processes
uncleaned, because a silent panic-recovery on a long-running watch
session is how you end up with `docker ps -a` looking like a
graveyard; and tune `MAX_SETTLE_ROUNDS` from 10 down to 3 so a
sustained `cargo build` can't delay a debounce drain by 5s while
the user wonders if the watcher hung.
Tests cover all three high-priority bugs explicitly. The
aborted-flag test in particular drives the *exact* sequence the
old code got wrong, because the only thing more embarrassing than
a silent-failure hole in a "no more silent failures" feature is
shipping the same failure mode again three weeks later.
* fix(trigger-filter,watcher): plug three silent-failure holes
Review pass turned up three more places where the trigger filter and
watcher would quietly do the wrong thing instead of telling the user
what was going on. Let's fix that.
First: `parse_events` for the `on: [push, pull_request]` sequence
form was silently dropping non-string entries. A typo like
`on: [push, {pull_request: {branches: [main]}}]` collapsed to
`on: [push]` and the pull_request filter *never applied*. The rest
of the parser has been rejecting this kind of nonsense via
`extract_string_list` for a while — the sequence path just never
got the memo. Now it errors with the offending index and kind, same
as everywhere else.
Second: `explain_filter_failure` was building its branch-mismatch
diagnostic from `filter.branches` only, completely ignoring
`branches_ignore`. A workflow with ONLY `branches-ignore: [main]`
running on `main` rendered as `branch 'main' did not match []` —
factually correct, totally useless. Combine both lists with
`!`-prefixed ignore entries so the diagnostic round-trips to the
surface syntax the user actually wrote. Same treatment for tags,
which had the identical bug.
Third: the watcher's per-cycle `collect_workflow_files` rescan was
swallowing errors with `if let Ok(refreshed) = ...` and silently
reusing the previous snapshot forever. Not wrong — the watch loop
*must* continue on transient failures — but silently falling back
to a stale list with zero diagnostic is exactly the silent-skip
pattern the rest of this PR has been plugging. Log at debug level
so a `--verbose` run leaves a trail.
Regression tests for all three. `cargo test` on the two touched
crates: 101 passed, 0 failed.
* fix(trigger-filter,watcher,wrkflw,ui): plug GHA divergence and CLI foot-guns from review
It turns out the path matcher had a "helpful" fallback that tried the
bare filename when a pattern had no slash in it. Neat trick, except
it's not what GitHub Actions does. `*.rs` would match `src/main.rs`
locally and then silently fail to match the same file on GHA, which
is the single worst outcome for a trigger filter whose entire job is
predicting what the real runner will do. Rip it out. Users who want
"any .rs file anywhere" write `**/*.rs` — that's the GHA idiom and
always has been. Pin the new behavior with a test so nobody resurrects
the fallback during a well-meaning refactor.
While at it, a few CLI foot-guns the same review turned up.
`find_repo_root()` shells out to `git rev-parse --show-toplevel`
synchronously and is *not* covered by `GIT_COMMAND_TIMEOUT` — so a
hung git (credential prompt, stuck NFS mount, corrupt repo) would
cheerfully freeze the tokio reactor on `wrkflw run --diff` and
`wrkflw watch`. The TUI already wrapped this call in `spawn_blocking`;
the CLI just didn't. Fix both call sites.
`wrkflw run --diff .gitlab-ci.yml` ran the GitHub-only trigger
prefilter first and then dumped a confusing "Error parsing workflow"
from deep in the YAML parser. Detect GitLab before the prefilter and
bail out with a message that actually tells the user what went wrong.
`max_concurrency` had a floor of 1 but no ceiling, so
\`--max-concurrency 1000000\` was happily accepted. Each concurrent
workflow carries executor state — containers, tempdirs, child
processes — so unbounded values trivially OOM the host with no
helpful signal. Clamp to 256 and warn loudly on the way down.
The watch loop fire-and-forgets the reporter callback via
\`spawn_blocking\` and then drops the JoinHandle. A panicking callback
vanished into tokio's default panic reporter with zero watch-loop
signal, which is exactly the silent-failure mode this whole PR has
been plugging. Supervisor task now awaits the handle and logs any
panic payload via \`wrkflw_logging::error\`. Not great that the
reporter type still invites \`Fn(WatchEvent)\` directly, but at least
a broken reporter is loud now.
Also: the TUI diff-filter toggle used to pay for two git subprocess
calls even when the workflow list was empty — mirror the watcher's
\`configs.is_empty()\` short-circuit so an empty toggle is free. And
dedup the branches/tags formatting in \`explain_filter_failure\`
behind a \`combined_pattern_sources\` helper so a future diagnostic
tweak can't make the two arms drift apart again.
* fix(trigger-filter,watcher): stop git from stealing TTY and fix symlink ignore hole
Another pass over the PR turned up two ugly ones plus a handful of
smaller silent-failure holes that had been sitting there in plain
sight. Let's deal with all of them at once.
The headline issue: every git subprocess we spawn inherited stdin
from the parent TTY and had no `GIT_TERMINAL_PROMPT=0`. In the TUI,
which runs in raw-terminal mode, that means a backgrounded `git diff`
from the diff-filter toggle will happily consume the user's
keystrokes for the full `GIT_COMMAND_TIMEOUT` window (up to 10
seconds) while ratatui sits there wondering where its input went. On
any repo whose remote wants auth it's even worse — git's askpass
races the UI for every keypress. *This is not great.* Wire
`Stdio::null()` and `GIT_TERMINAL_PROMPT=0` into `git_cmd` so every
subprocess is fully insulated from the parent terminal, and do the
same in `find_repo_root` while we're in there.
The other ugly one: `should_ignore_path` compared events against
`repo_root_canonical` only. That happens to work on macOS because
FSEvents canonicalizes everything, but on Linux with a symlinked
working tree (`/home/alice/proj` pointing at `/mnt/work/proj`),
inotify delivers events rooted at the raw `.watch()` argument, so
`strip_prefix` against the canonical form fails for *every single
event* and the entire ignore filter goes silently dark. You end up
eating every `target/`, `node_modules/` and `.git/` write through the
debouncer. Pass both the raw and canonical forms and try each in
turn — the two regression tests pin both the Linux-symlink and the
macOS `/private/var` paths.
While at it:
- `find_repo_root` had no timeout at all. A hung git there leaks a
blocking-pool thread for the rest of the process's life. Replaced
with a hand-rolled `try_wait` poll + kill-on-deadline so we don't
need tokio in this sync path.
- `get_default_diff_base` trusted `git status` stdout without
checking `status.success()`. On a corrupted repo it would cheerfully
claim a clean tree and fall through. Check the exit code like every
other call site does.
- `parse_event_config` silently accepted non-mapping event values, so
\`on: { push: "main" }\` collapsed to "push with no filters" instead
of surfacing the typo. The one remaining silent-drop hole in a
parser that's otherwise militant about these — fixed, with a
regression test.
- `with_max_concurrency(0)` silently clamped to 1 without the warning
its upper-bound sibling emits. Symmetric now.
- Dropped a stale doc comment on `GlobPattern` that still advertised
the bare-filename fallback `path_matcher` explicitly deleted.
All 79 trigger-filter tests and all 25 watcher tests pass, including
the four new regressions (two symlink cases, the parser typo, and the
ignore-file raw/canonical asymmetry).
* fix(trigger-filter,watcher,wrkflw): plug scalability and platform holes from review
Review flagged three issues that would bite real users the moment this
ships: inotify budget exhaustion on monorepos, a per-cycle git
subprocess storm, and Windows `paths:` filters silently never matching.
Plus a handful of smaller cuts.
**inotify pruning.** `watcher.watch(root, Recursive)` expands into one
inotify watch per directory in the tree, so the recursive-on-root call
was happily registering watches on every directory inside `target/`,
`node_modules/`, and `.git/`. On any serious Rust monorepo that blows
past `fs.inotify.max_user_watches = 8192` before the user edits a
single file, at which point notify fails half-open and the watcher
looks alive but never fires. New `setup_watches()` walks the root,
registers each non-ignored child subtree recursively, and leaves the
high-churn subtrees completely un-watched. The ignore filter on the
event hot path stays too, because macOS FSEvents can still deliver
paths the walker didn't register.
**Branch/tag cache.** Every debounced cycle was shelling out to
`git rev-parse --abbrev-ref HEAD` and `git describe --exact-match
HEAD` — for information that changes on `git checkout` and `git tag`,
i.e. basically never. A 40-file save storm was 80 git spawns for no
reason. New `cached_git_state()` on `WorkflowWatcher` with a 3-second
TTL. Not cache-invalidation-on-`.git/HEAD`-event because that would
require whitelisting `.git/` back past the ignore filter and the TTL
does the same job with a quarter of the complexity.
**Windows path separators.** `canonicalize_changed_paths` handed
backslash-separated strings straight to `path_matcher`, and
`glob::Pattern` with `require_literal_separator: true` expects `/`.
So `paths: ['src/**']` silently didn't match `src\main.rs` and every
Windows user would have seen "0 triggered" with no explanation. New
`normalize_separators()` helper runs after `strip_prefix`.
While at it: `WatcherConfig::with_extra_ignore_dirs()` so users can
add `.terraform`, `coverage`, `.next`, `bazel-bin`, etc. without
waiting for us to land each one as a default. `display_workflow_path()`
renders TRIGGERED/SKIPPED labels as repo-relative instead of the
noisy absolute form. `find_repo_root_detailed()` returns a
classified error (`GitNotInstalled` / `Timeout` / `NotInRepository` /
`Other`) so `wrkflw watch` stops telling users "not inside a git
repository" when the real problem was that git wasn't on PATH or the
subprocess hung — that misdiagnosis was sending people down the
wrong fix path every time. Doc comment on `get_changed_files`
spelling out the rename-as-delete+add behavior since `paths-ignore`
users will eventually trip over it. And a pin test for
`workflow_dispatch`/`schedule`/`repository_dispatch` so a future
refactor that starts defaulting `branches:` can't silently break
manual triggers.
Cancellation token and parallel parse are still deferred — both are
larger surface changes and the review called them out as known
follow-ups. Let's not bundle them here.
* fix(trigger-filter,watcher,ui,wrkflw): fix branches+tags OR, kill silent skips from review
The big one first: a push workflow with BOTH `branches:` and `tags:`
set was being evaluated as AND. So if you had
on:
push:
branches: [main]
tags: ['v*']
the filter would never match *anything*. Branch push? tags axis is
empty (no tag on HEAD), reject. Tag push? branches axis is empty
(detached HEAD, no branch), reject. Elegant symmetry. Completely
wrong.
It turns out GitHub Actions treats that case as OR — the workflow
fires if the branch matches `branches:` OR the tag matches `tags:`.
Which is the only thing that makes sense, because a push is on a
branch *or* a tag, never both simultaneously. The previous
sequential-AND check was literally unsatisfiable for the combo case
and no test covered it, so we shipped a feature that silently
skipped an entire class of real-world workflows.
Extract `ref_filters_pass` that handles all four combinations
explicitly and pin it with four regression tests covering branch
push, tag push, miss, and `tags-ignore` exclusion. Update
`explain_filter_failure` to render "neither ref axis matched" for
the combo case so the diagnostic actually mentions both axes
instead of just one.
While at it, plug the rest of the review punch list.
Retire the `find_repo_root` Option wrapper and migrate every call
site (CLI run prefilter, CLI watch, TUI diff-filter) to
`find_repo_root_detailed`. The wrapper collapsed git-not-installed,
timeout, and not-in-repo into one Option::None, which sent users
down the wrong fix path for three of the four failure modes. The
TUI now surfaces the classified error via `DiffFilterOutcome::
Failure` instead of silently showing "0/N would trigger".
Gate the "diff base = HEAD on dirty tree" info log on a `verbose`
parameter threaded through `get_default_diff_base` and
`auto_detect_context_default_base`. The CLI opts in via its
`--verbose` flag; the TUI passes `false` so a hot-path toggle
doesn't flood the log pane on every invocation. This was a
papercut, not a bug, but it was loud.
Move `process::exit` out of `build_event_context` and its helpers.
The function now returns `Result<EventContext, String>` and the
orchestrator owns the exit policy. Helpers that call `exit` from
deep inside are untestable without spawning subprocesses, and we
were accumulating them for no good reason.
Canonicalize `WatcherConfig::workflow_dir` once in `from_config` so
the trigger cache's `HashMap<PathBuf, _>` keys are stable when the
user passes a relative or symlinked workflow directory. Not a bug
in the current CLI (which always passes an absolute path), but the
foot-gun was there for embedders and it was cheap to remove.
Introduce `ShutdownSignal` — a lightweight cancellation handle
backed by `tokio::sync::watch::channel::<bool>`. `WorkflowWatcher::
run` now takes one as a parameter and observes it via
`tokio::select!` inside the idle wait, so triggering cancellation
wakes the loop immediately instead of parking until the next fs
event. The CLI passes `ShutdownSignal::never()` because it still
relies on `process::exit` from Ctrl+C; long-lived hosts (TUI,
future daemon, tests) construct a real signal.
The first implementation of `ShutdownSignal` used `tokio::sync::
Notify` and had a nasty trigger-before-wait race: `notify_waiters()`
only wakes currently-parked tasks, so a caller that triggered
shutdown and then spawned a task that called `wait()` would park
forever. The watch-channel version stores the latest value, so a
subscriber that joins after trigger sees the fired state on the
very next `wait_for` poll. Pinned by
`wait_after_trigger_sees_triggered_state_immediately` so a future
refactor can't silently reintroduce it.
Split the 1884-line `watcher.rs` into five focused submodules with
their own tests: `ignore` (default ignore set + path filter),
`paths` (canonicalization + separator normalization +
display_workflow_path), `event_kind` (notify event classification),
`trigger_cache` (per-cycle parsing cache), and `setup`
(subtree-by-subtree watch registration). Everything is `pub(crate)`
so the public surface is exactly what `lib.rs` re-exports. Nothing
clever, just stop pretending one file is a reasonable home for
1200 lines of hot-path logic.
Add an end-to-end watcher pipeline test
(`end_to_end_pipeline_delivers_watch_event`) that spins up a
tempdir repo, starts the watcher, touches a file in a pre-existing
subtree, and asserts the callback receives a `WatchEvent` with the
changed file. Plus `run_returns_when_shutdown_signal_is_triggered_
before_any_event` to pin the cancellation interrupt path. Both
tests run under `tokio::task::LocalSet` + `spawn_local` because
`run()` is `!Send` (the executor future holds `dyn ContainerRuntime`).
Document the Ctrl+C resource-leak caveat in `wrkflw watch --help`
— a cycle in flight when the user interrupts will get killed by
the OS without running the executor's cleanup, and users who see
orphaned containers deserve to know why.
All tests green (trigger-filter 84, watcher 39, ui 22, wrkflw 3);
clippy clean.
* fix(trigger-filter,watcher,ui,wrkflw): plug the last silent-skip holes and stop sprinkling constants
Another pass over the diff-aware / watch-mode PR after review. The
recurring theme is the same one the previous 24 fix-ups have been
chasing: warn-and-proceed where we should fail loud, plus a pile
of hardcoded constants that drifted across three call sites because
nobody owned them.
It turns out `--event` without `--diff`/`--changed-files`, and
`pull_request` without `--base-branch`, were both still taking the
log-a-warning-and-run path. In a CI script the warning is invisible
and the user sees a session-long stream of "0 triggered" with no
idea why. Same shape in `get_changed_files`: when `ls-files --others`
failed (safe-directory config, transient permission glitch) we
discarded the error in an `if let Ok(_)` block and dropped every
untracked file from the changed set, silently breaking `paths:`
filters for brand-new files. Not great.
Ctrl+C in `watch` was the other big one. We were using
`ShutdownSignal::never()` and relying on the global cleanup
handler's `process::exit(0)` to tear everything down, which meant
a workflow mid-execution got killed by the OS before the executor's
Docker/tempdir cleanup could run. Install a real
`tokio::signal::ctrl_c` handler that triggers the watcher's
shutdown; the global cleanup still runs after `run()` returns, so
we get a clean two-phase teardown instead of an orphaned-container
parade.
While at it, gather every stray constant (`GIT_COMMAND_TIMEOUT`,
`GIT_STATE_CACHE_TTL`, the glob recompile cost, the hardcoded
`"push"` in the TUI) into a single `TriggerFilterConfig` struct and
thread it through the three call sites (CLI prefilter, watcher hot
loop, TUI diff-filter). Having the same five numbers live in three
different places is not my idea of configurability. The config
also owns a process-wide LRU of compiled trigger configs keyed by
`(path, mtime)`, so toggling the TUI filter or re-running a watch
cycle no longer re-parses every YAML and recompiles every glob.
The watcher picks up three other fixes in the same spirit. The git
state cache now keys on `.git/HEAD`'s mtime on top of the TTL, so a
`git checkout` inside the 3s window is caught on the next cycle
instead of evaluating `branches:` filters against the pre-checkout
branch. The debouncer grows bounded (8192 pending paths by default)
and reports per-cycle drops via `WatchEvent.dropped_events` so a
`cargo clean && cargo build` burst can't grow the pending set to
tens of thousands of PathBufs. And the trigger-cache refresh task
now preserves the *previous* cache on panic instead of returning
`HashMap::new()` — a panicking refresh was previously forcing the
next cycle to re-parse every workflow from scratch, which is the
kind of performance cliff you only notice after the bug report.
Parser learned an allowlist of the documented GHA events and warns
(not errors — GHA adds events faster than we update lists) on any
name outside it, so a typo like `pul_request` surfaces once
instead of producing silent "no matching event" diagnostics
forever. `--changed-files` entries now get validated up front:
absolute paths, Windows drive letters, and `..` components get
rejected with a clear error instead of silently failing every
`paths:` glob.
TUI gains a working event selector. The `diff_filter_event` field
was sitting there hardcoded to `"push"` with a comment about
"future UI" — which is a nice way of saying the TUI was silently
lying about which pull_request/workflow_dispatch/schedule workflows
would run. Shift+D now rotates through the six common events and
re-runs the evaluation against the new one. The log buffer also
grew a cap (5000 lines, enforced at every `mark_logs_for_update`
call site) so a long-lived TUI with a lot of diff-filter toggling
doesn't slowly leak memory.
New tests cover every new behavior: LRU hit + mtime invalidation,
`normalize_user_changed_file` rejecting the bad shapes, the unknown
event warning, the debouncer cap counting drops without counting
deduped paths, HEAD-mtime cache invalidation, and the TUI log cap.
Full workspace still green, clippy silent, fmt clean.
Please don't add more file-local constants to this pipeline. Put
them in `TriggerFilterConfig`.
* fix(trigger-filter,watcher): plug parser, git, and worktree silent-skip holes
Another round of review fallout. Six things were wrong, bundled
because they all share the same failure mode: silently producing
"0 triggered" for the wrong reason and sending users chasing a
flag they never needed.
**`on: {}` and `on: []` were accepted at parse time** and then
surfaced as "Workflow does not listen to any events" from the
evaluator — with no workflow path, no YAML context, nothing. GHA
rejects both at upload time. Reject them here too, at parse,
where the error actually carries the file path and the user has
something to grep for.
**`git diff --name-only` and `git ls-files --others` were
newline-terminated.** So a file called `foo\nbar.txt` got
reported as two bogus half-entries, and any non-ASCII filename
came back as `\302\244`-style octal escapes that no `paths:` glob
could ever match. The fix is three characters: `-z`. Rip out the
old `parse_lines` helper and route everything through a new
`parse_nul_separated` that splits on NUL and tolerates the
trailing NUL git sometimes emits. Pinned with a real-git-repo
end-to-end test that commits a file whose name contains a
literal newline and asserts it survives the round trip.
**`head_mtime` stat'd `.git/HEAD` directly**, which returns None
on every linked worktree because in that layout `.git` is a
regular *file* containing `gitdir: /abs/path/to/real/gitdir`.
The watcher's git-state cache then served stale `(branch, tag)`
pairs until the bare TTL expired. Parse the pointer, resolve
relative paths the same way git does (against the `.git` file's
parent), and stat the real HEAD. Plain repos are unchanged.
**The `buffer_unordered` block in watcher.rs had a comment
claiming "Each future wraps a `tokio::spawn`".** It doesn't.
That's actively *load-bearing* — spawning would detach the
futures from the stream and let max_concurrent_executions be
silently ignored. Rewrite the comment to say what the code
actually does and why, so the next refactor doesn't "fix" it
into an unbounded fork bomb. Please don't do that.
**The path-filter diagnostic told users to "pass --diff or
--changed-files"** even when they had *already* passed `--diff`
and it had just come back empty. Confusion ensues. Add
`EventContext::changed_files_explicit`, set it to `true` in
every constructor that actually ran a diff (the three lib.rs
helpers and the watcher's in-tree path), and split the
diagnostic into two cases so "no diff run yet" and "diff was
empty" read differently.
While at it, mark `EventFilter`, `WorkflowTriggerConfig`, and
`TriggerMatchResult` `#[non_exhaustive]`. No external crate
constructs them today — they only read — so this costs nothing
now and buys room to add fields without a major bump later.
`EventContext` stays exhaustive because the watcher builds it
via struct literal and I'm not rewriting that.
Every fix has a pinned test. `cargo test --workspace` is
629/629. `cargo clippy --all-targets` clean. `cargo fmt` clean.
* fix(trigger-filter,watcher,ui,wrkflw): kill dead plumbing and unlock the pattern cache
Staff-level review of the diff-aware watch branch came back with a
pile of findings. Most were easy, but three of them were actually
embarrassing and needed to go before anyone else looked at this.
First, `PATTERN_CACHE` was holding its mutex *across* the blocking
YAML parse + glob compile in `load_trigger_config`. The whole point
of a process-wide LRU is that concurrent callers hitting different
files don't pay for each other's parses — and we were doing the
exact opposite. One slow network-mounted workflow file would stall
every other caller in the process until it finished. Release the
lock on a miss, parse, re-acquire to insert. Racing duplicate
parses on the same file are harmless because late-writer-wins
produces the same value.
Second, `TriggerFilterConfig` shipped two config knobs —
`git_timeout` and `strict_missing_context` — that had builder
setters, had unit tests pinning the builder setters, and were read
by *absolutely nothing* in the library. The timeout lived as a
file-local const in `git.rs` and strict-mode was implemented
entirely in the CLI. Shipping non-functional config knobs is
precisely the kind of drift this crate has been iteratively
patching, so I deleted them rather than half-wiring them. If
anyone ever needs the git timeout override, thread `Duration`
through `run_git` first and add the field at the same commit.
Third, `KNOWN_GHA_EVENTS` contained `pull_request_comment`, which
is not a real GitHub Actions event. Comments on PRs arrive as
`issue_comment` (PRs are issues). Leaving this in the typo-
detection allowlist silently masked the exact warning the function
exists to produce. One-word fix, regression test pinned.
While at it, I cleaned up the other findings:
- Library logging coupling: `collect_unknown_event_warnings`
(renamed from `warn_on_unknown_events`) and
`get_changed_files_with_warnings` no longer dual-emit via
`wrkflw_logging::warning` AND the return value. They collect
warnings as data; hosts own the rendering policy. Warnings now
ride through a new `WorkflowTriggerConfig::warnings` field in
addition to `EventContext::warnings`. The CLI prefilter renders
both. The one remaining library log call is the verbose-gated
`info!` in `get_default_diff_base` — opt-in, informational, not
dual-emission, and fixing it requires signature cascades I
didn't want in this commit.
- `Debouncer::MAX_SETTLE_ROUNDS` was hardcoded to 3. That was
tuned for the default 500ms debounce (3 × 500 = 1.5s worst
case) but a user passing `--debounce 5000` would see a 15s
worst-case drain delay under sustained churn. Replaced with a
wall-clock `MAX_SETTLE_BUDGET = 1500ms` and a dynamic round
cap derived from the debounce window. Preserves the old
behavior at 500ms and caps long windows correctly.
- `head_mtime` was reachable only via `git::head_mtime`, with the
watcher reaching into a sibling crate's `pub` module. Re-export
at the crate root so the coupling is explicit.
- `normalize_user_changed_file` didn't reject embedded NUL bytes.
NUL is not a valid pathname byte on any supported platform, and
letting one through would only produce downstream confusion.
Reject at the boundary with the rest of the input validation.
- `wrkflw watch` had no CLI flag for `extra_ignore_dirs`. The
watcher supported the knob but users couldn't pass extras
without patching source. Added `--ignore-dir`, comma-separated
or repeated.
- `apply_activity_type` was a one-line function called from one
site. Inlined it.
- `cycle_diff_filter_event` re-ran the evaluation by calling
`toggle_diff_filter` twice in sequence — correct but cryptic.
Extracted `rerun_diff_filter_if_active` so the idiom is named
for what it does.
All 332 tests in the workspace still pass, plus the new
regression pins. Clippy clean, fmt clean.
* fix(ui,watcher): plug TUI warning drop + kill double-toggle fragility
Another review pass. The TUI diff filter was *silently* dropping
every `EventContext::warnings` and every `WorkflowTriggerConfig::warnings`
on the floor — exactly the silent-skip failure mode this entire PR
has been fighting for 28 commits. The library deliberately routes
those diagnostics through struct fields instead of logging them
directly, because hosts own the rendering policy. The CLI prefilter
at crates/wrkflw/src/main.rs does the right thing. The TUI just
didn't.
It turns out that 'hosts own the rendering policy' is a contract
the compiler can't enforce. A git ls-files --others safe-directory
rejection, an 'on: pul_request' typo, a corrupted index — none of
it reached the log pane. User saw '0/N would trigger' with no clue
why.
Let's fix that. DiffFilterReport grows a warnings: Vec<String>
field. evaluate_diff_filter now drains context.warnings via
mem::take and harvests each parsed config's .warnings with the
workflow path prefixed, then check_diff_filter_results renders
them under a 'Diff filter: N warning(s)' line, mirroring the CLI
prefilter byte-for-byte. New regression test pins it.
While at it, rerun_diff_filter_if_active was implemented as two
consecutive toggle_diff_filter() calls. Clever. Also fragile: the
whole thing worked only as long as toggle_diff_filter stayed purely
idempotent in opposing directions, and a future side effect
(metrics, tracing, throttle) would have silently broken the rerun
path with zero compile-time signal. Extract spawn_evaluation() and
abort_in_flight_evaluation() as named helpers so the control flow
matches the intent. The existing abort-flag regression tests still
pin the tricky state-machine invariants through the refactor.
And the Debouncer::MAX_SETTLE_BUDGET doc claimed a hard 1.5s cap
on drain latency that the code doesn't actually enforce when the
user passes a debounce window longer than the budget — a single
tokio::time::sleep is not preemptible and the inner sleep runs the
full user-supplied duration. This is not great, but the alternative
(chopping up the inner sleep) would starve the legitimate coalescing
cases. Soften the doc to describe the real worst case instead of
lying about a cap. The pinning tests already cover both short- and
long-window paths.
Minor: help overlay documents 'd' (toggle diff filter) but not
Shift+D (cycle event). Add the missing line.
* fix(trigger-filter,watcher,wrkflw,ui): plug review holes and enforce warning drain
A code review turned up three real correctness bugs and a handful
of latent traps. Rather than patch them piecemeal, fix the whole
batch in one go so the contract that ties them together — "never
silently drop a diagnostic" — is actually load-bearing in the type
system and not just a convention nobody remembers to follow.
The three correctness bugs first:
1. `cached_git_state` was capturing `head_mtime` *after* the two
git reads. The doc comment claimed this was fine because "a
checkout between the read and the store will show up as an
mtime mismatch on the very next call" — it turns out the
comment was wrong. If a checkout lands between the branch/tag
reads, we store the post-checkout mtime alongside the
pre-checkout branch, and the very next lookup's stat matches
the stored value and happily serves the stale branch until
the TTL expires. Move the mtime capture *before* the git
reads so a race produces a visible mismatch. Regression
test interleaves a real `git checkout` between cache calls.
2. `collect_workflow_files_blocking` was returning
`Err(NoWorkflows)` for an empty directory. Watch mode's
entire value proposition is "react to files that don't exist
yet", so refusing to start on an empty `.github/workflows`
is actively wrong. Worse: mid-session, if the user deleted
every workflow file, the rescan failed, the watch loop fell
back to the stale snapshot, and `refresh_trigger_cache_blocking`
retained the deleted entries in the compiled-pattern cache.
So the evaluator kept running against workflows that no
longer existed. Let's fix that. Empty dir is now `Ok(vec![])`,
the watcher logs an info line and carries on, and the
`retain` step correctly evicts stale entries.
3. The pattern cache was keyed on mtime alone. On FAT32 and
some SMB/NFS configs, two edits within the mtime resolution
window (1-2 seconds) collide, and the second parse gets
served a stale compiled config. Combine `(mtime, len)` as
the key — size changes catch the overwhelming majority of
iterative edits, and it's free because we were already
reading the metadata. A content hash would close the
remaining gap but would require a full file read on every
lookup, which is precisely what the cache exists to avoid.
The bigger structural change is the warning-drain contract.
The trigger-filter crate routes non-fatal diagnostics (unknown
event names, `git ls-files --others` failures) through struct
fields instead of calling `wrkflw_logging::warning` directly,
so hosts own the rendering policy. That's the right design.
But it was enforced entirely by convention, and convention is
not a type system.
Proof: `refresh_trigger_cache_blocking` in the watcher was
silently dropping `WorkflowTriggerConfig::warnings` on the
floor. Nobody noticed because the field was a plain
`Vec<String>` and forgetting to iterate it had no consequences.
Exactly the silent-skip mode the rest of this PR exists to
prevent.
Let's fix that too. New `MustDrainWarnings` newtype with a
`debug_assertions`-gated `Drop` impl that `eprintln!`s when a
non-empty buffer is dropped without being observed via `take()`.
Release builds carry zero overhead. Tests and CI catch the
regression immediately. `EventContext::warnings` and
`WorkflowTriggerConfig::warnings` now use it, every host drains
explicitly (`take()`, not read-only iteration), and the watcher
site that was dropping warnings on the floor now routes them
through `wrkflw_logging::warning` at insert time.
One subtlety: the library's LRU cache stashes a clone of the
parsed config alongside the original it returns to the caller.
The clone's warnings would otherwise trip the Drop check on
eviction. Drain them at insert time — the returned original
still carries the full set for the first observer, and
subsequent cache-hit callers see empty warnings, which is the
correct UX (a first-parse diagnostic should not be re-logged
on every cache hit).
While at it, a few opportunistic cleanups the review flagged:
- `run_trigger_prefilter` returns `Result<PrefilterDecision,
String>` instead of calling `std::process::exit` from half
a dozen sites deep inside the body. The flag matrix is now
unit-testable without spawning a subprocess; three new
tests pin the "directory path", "missing path", and
"non-matching skip" branches.
- The `--strict-filter` / `--no-strict-filter` coalescing
was duplicated across the `run` and `watch` handlers. Extract
`effective_strict_filter(strict, no_strict)` so a third host
can't pick up a slightly different version of the same
bool-toggle pattern.
- `normalize_user_changed_file` was accepting whitespace-only
and empty path components (`src/ /foo.rs`, `src//foo.rs`,
trailing slash). These silently fail every `paths:` glob at
evaluation time — exactly the kind of input the function
exists to reject up front. Reject them with a clear "your
flag was wrong" message.
640+ tests passing across the workspace, clippy clean with
`-D warnings`. Nine new tests cover the fixes, each with a
comment naming the prior failure mode so a future refactor
can't silently regress them.
* fix(trigger-filter,watcher,wrkflw): plug the last six review holes
Review pass flagged eight items. Six code fixes, two coverage
gaps — close all of them so the branch can land without carrying
documented regressions.
The biggest one: the `PATTERN_CACHE` docstring promised that the
CLI, the TUI, and the watcher all shared one parse per (path,
mtime) across the process. It turns out they didn't. Each host
handed `load_trigger_config_cached` a different `PathBuf` shape
— raw user input vs relative `read_dir` output vs canonicalized
notify paths — so each host effectively maintained its own
de-facto private cache entry. The cross-host sharing was pure
aspiration.
Fix is to canonicalize inside the cache lookup via
`canonicalize_allowing_missing` (promoted out of the watcher
crate since three consumers need it now) and rewrite
`workflow_path` back to the caller's original form on return so
UI labels still show what the user typed.
Two more loop-discipline oversights in the watcher.
`debouncer.drain().await` sat outside any `tokio::select!`
against the shutdown signal, so Ctrl+C during an active drain
had to wait up to the full debounce window before the loop
observed it. Every other await in `run()` is cancellation-aware
— this one was the last outlier. And the per-cycle callback
supervisor was a detached `tokio::spawn`: correct on the happy
path, but a stuck reporter accumulates one task per cycle
forever because the runtime's own reaper is the only thing
polling the handle. Now held in a `JoinSet`, reaped via
`try_join_next` at the top of each iteration, with a one-shot
warning when the backlog crosses eight.
The rest is smaller. `--max-pending-events` no longer advertises
a misleading `default: 0` in `--help`; the flag is
`Option<usize>` and `Some(0)` is explicitly rejected since a
zero cap would drop every event. The parser gets a doc note
explaining why the duplicate-event-key concern the reviewer
raised is not reachable on `serde_yaml >= 0.9.4` —
`parse_workflow` errors upstream before ever hitting
`parse_events`. Don't remove it; add the HashSet-walk defence
only if someone swaps in a permissive YAML loader.
Two new tests pin the `PATTERN_CACHE` concurrency contract and
the `--diff-head` without `--diff-base` branch in
`build_event_context`. Both were docstring claims that nothing
actually exercised, which is how these things rot in the first
place.
Version bump to 0.8.0 and the README breaking-change section
are deferred to a follow-up PR so the release ceremony isn't
entangled with the code review.
* fix(trigger-filter,watcher,wrkflw): plug four more review holes
Four things the previous round missed, in order of how much they
would have hurt in production.
The callback supervisor JoinSet in the watcher had a warning
threshold but no hard cap. That's cute when the reporter is
*momentarily* slow, but a truly wedged reporter (deadlocked file
sink, stuck network webhook) just accumulates one supervisor per
cycle forever — the warning never reclaims anything, and memory
grows for the life of the session. Add `SUPERVISOR_HARD_CAP = 128`
and drop the current cycle's `WatchEvent` when we hit it. Loud
error, one-shot latch with hysteresis on the reclaim path, the
watch loop keeps running. Not pretty, but the alternative is
eventually having the OOM killer express an opinion.
`parse_nul_separated` was calling `from_utf8_lossy` on every
record, which silently replaced invalid UTF-8 bytes with U+FFFD
and left the file in the change set under a name that could not
match any real glob. This is the exact "silently lying about
which workflows would run" failure mode the rest of the crate
has been fighting, and it was hiding right inside the NUL
parser. Collect the lossy names in a separate list and surface
them via `EventContext::warnings` so the user at least sees
*which* file was dropped from the filter's view.
Strict-filter default-on (`wrkflw run --event push` now exits
1 without `--diff`/`--changed-files`) is a visible CLI behavior
change and the commit message was the only place it was
documented. That's not great. Add a BREAKING_CHANGES.md entry
with migration examples for each escape hatch, exit-code
semantics, and a pointer at `--no-strict-filter` for scripts
that cannot move yet.
While at it, flesh out the `LOG_BUFFER_CAP = 5000` comment in
the TUI (why not 1000, why not 20000, what the two dominant
log sources are), extract the supervisor constants to module
level so they can be reasoned about, and add tests the review
flagged as missing: a compile-time invariant on the supervisor
cap ordering, a runtime test that shutdown still unblocks
`run()` when the reporter callback is wedged (release-gated
blocking thread so cargo can reap it), and three prefilter
tests pinning the strict-mode rejection paths for
`--event`-alone and `pull_request`-without-`--base-branch`.
|
||
|
|
f650f5533c |
feat(ui): overhaul TUI and CLI output (#85)
* feat(ui): overhaul TUI and CLI output for professional look The terminal UI looked like it was thrown together during a hackathon. Colors were hardcoded across 11 files with zero consistency — Color::Green here, Color::Cyan there, emoji like ✅ and ❌ mixed with Unicode symbols like ○ and ⟳, tab dividers using a raw pipe character. This is not great. The core problem was the complete absence of any centralized theming. Every view file was its own little island of ad-hoc styling decisions, and the non-TUI CLI output had *zero* color support despite the colored crate sitting right there in the workspace Cargo.toml, imported by absolutely nobody. So let's fix all of that: - Add theme.rs as single source of truth for colors, styles, symbols, and block/badge helpers. Every view now imports from here instead of hardcoding Color:: literals everywhere. - Replace the inconsistent emoji zoo (✅❌⏭⟳) with proper single-cell-width Unicode symbols (✔✖⊘◉○) that don't render at double width and break column alignment. - Upgrade ratatui 0.23 -> 0.28 and crossterm 0.26 -> 0.28, migrating all the breaking API changes (Frame generics gone, Table::new signature, f.size() -> f.area()). - Add cli_style.rs and wire colored output through all CLI paths — validate, execute, and list commands now have proper colored output with tree-style rendering. - Add braille spinner animation for running workflow states. - Rip out the redundant instruction headers from workflows and logs tabs (the status bar already shows the same hints), reclaiming vertical space. - Clean up the help tab from emoji section headers to properly styled underlined headers with fixed-width key columns. Net result: -418 lines. The UI got *better* and *smaller*. * fix(ui): replace fragile string-matching in status toast with typed severity The status bar was detecting success/error toasts by doing substring matching on the message text — checking for "success", "Success", and the ✔ symbol. It turns out that any message containing the word "success" anywhere (even in an error context) would render with a green background. This is not great. Add a StatusSeverity enum and a set_success_message() helper so callers declare intent explicitly instead of hoping their message text passes a regex-by-vibes check. Also replace the last ✅ emoji in set_status_message calls and swap the double-width 🔒 (U+1F512) LOCK symbol for single-width ⚿ (U+26BF), because the whole point of the theme overhaul was to kill double-width emoji. * fix(ui): align log detection with new Unicode symbols and clean up API The previous commits replaced all user-facing emoji with single-cell Unicode symbols, but *forgot* to update the two places that actually match against those symbols: LogFilterLevel::matches() and log_processor's type detection. So we had the delightful situation where cli_style outputs ✖ on errors, but the log filter is still looking for ❌. The text-based fallbacks ("Error", "WARN", etc.) masked the breakage, but the symbol branches were effectively dead code. While at it, rename set_status_message() to set_error_message() because a generic "set status" method that silently defaults to Error severity is a trap waiting to happen. Both existing call sites are genuine error paths, so the rename is accurate. Also run cargo fmt across the board -- the previous commits left a fair amount of unformatted code behind. * refactor: centralize Unicode symbols into wrkflw-logging The previous commit introduced a proper theme module with Unicode symbols for the TUI, but then three different places independently hardcoded the *same* symbols: theme::symbols, cli_style.rs, and models/mod.rs. Meanwhile, the executor, runtime, and logging crates were still happily emitting double-width emoji into log output. So we had the worst of both worlds: the UI layer had to pattern- match against *both* old emoji and new Unicode to detect log levels, and the "single source of truth" for symbols existed in triplicate. This is not great. Extract a shared `wrkflw_logging::symbols` module that every crate already depends on. theme.rs now re-exports it instead of defining its own copy. cli_style.rs and LogFilterLevel::matches() import from it. All emoji in executor, runtime, and logging are replaced. The old fallback in main.rs error filtering is gone because the executor no longer emits the old symbols. While at it, expand StatusSeverity with Info and Warning variants so "no matches found" shows as a yellow warning toast instead of an angry red error. Because not finding a search result is not an error, it's just disappointing. * fix(ui): stop hammering Docker on every render frame The status bar was calling docker::is_available() on *every single render tick* — that's 4 times per second, each spawning an OS thread, a docker subprocess, and a throwaway tokio runtime. The check has a 3-second timeout, we're calling it every 250ms. This is not great. It turns out that under this kind of self-inflicted load, the check frequently times out and returns false. So Docker shows as "Unavailable" even when it's sitting right there, perfectly happy. The double-nested with_stderr_to_null wrapping wasn't helping either. While at it, the logging crate was also cheerfully eprintln!()-ing warnings while the TUI had the terminal in raw mode, which is how you get "Docker is not available" splattered across the middle of your carefully rendered UI. Confusion ensues. The fix: cache the availability result in App state, seed it from the existing startup check, refresh it every 30 seconds in tick(), and add a quiet_mode flag to the logging crate so it stops writing to stderr while the TUI owns the terminal. Messages still go into the log buffer for the Logs tab — they just don't corrupt the display anymore. |
||
|
|
14d30b6b57 |
feat(ui): add job selection mode to TUI for running individual jobs (#80)
* feat(ui): add job selection mode to TUI for running individual jobs
The CLI already has --job to run a single job, but the TUI had no
way to do this. You could only run entire workflows, which is the
kind of all-or-nothing approach that gets old fast when you have a
workflow with 12 jobs and you just want to re-run "lint".
Add a job selection sub-view to the Workflows tab. Press Enter on a
workflow to drill into its jobs (parsed via parse_workflow), then
Enter on a job to run just that one (with its transitive deps), or
'a' to run all, or Esc to go back. The selected job flows through
as target_job in ExecutionConfig, reusing the exact same filtering
logic the CLI --job flag already uses.
While at it, updated the status bar hints and help overlay to
document the new keybindings.
* fix(ui): stop target_job from leaking across queued workflows
The job selection feature added in
|
||
|
|
f05cbca3b9 |
feat(ui): make TUI optional behind a cargo feature flag (#79)
* feat(ui): make TUI optional behind a cargo feature flag The TUI is great for interactive use, but if you want to run wrkflw as a headless CI runner, dragging in ratatui and crossterm is pointless baggage. Issue #41 asked for this, and honestly the code was already well-isolated enough that it *should* have been optional from the start. Add a `tui` feature flag (default-on) to both the `wrkflw-ui` crate and the main binary crate. When disabled via `--no-default-features`, ratatui/crossterm are gone, the `tui` subcommand disappears, and running with no args prints help instead of launching the TUI. All CLI subcommands (validate, run, trigger, list) remain fully functional. While at it, removed the direct crossterm/ratatui deps from the binary crate — it never imported them, they were just coming through transitively anyway. * refactor(ui): feature-gate models and utils, clean up cfg imports The previous commit gated the TUI behind a feature flag but left the models and utils modules unconditionally compiled. It turns out that *every single consumer* of those modules is TUI-gated code — they were compiling to dead code when building with --no-default-features. Gate models and utils behind cfg(feature = "tui") where they belong. While at it, consolidate the five separate #[cfg(feature = "tui")] annotations on imports in workflow.rs into a single grouped use block, because repeating the same attribute five times in a row is not my idea of readability. Also add a cargo check --no-default-features step to CI so this kind of thing doesn't silently regress. * ci(build): drop archived actions-rs/cargo, add --workspace to feature check The actions-rs/cargo@v1 action has been archived for a while now, and wrapping every cargo invocation in a GitHub Action that just... calls cargo... was never exactly adding value. Replace all five uses with plain `run: cargo <cmd>` steps. While at it, add --workspace to the --no-default-features check so it actually verifies *all* crates compile without the tui feature, not just the default workspace member. The previous version would happily miss breakage in any non-default crate. |
||
|
|
b1cc74639c | version fix | ||
|
|
51a655f07b | version fixes | ||
|
|
960f7486a2 |
Release 0.7.0
wrkflw@0.7.0 wrkflw-evaluator@0.7.0 wrkflw-executor@0.7.0 wrkflw-github@0.7.0 wrkflw-gitlab@0.7.0 wrkflw-logging@0.7.0 wrkflw-matrix@0.7.0 wrkflw-models@0.7.0 wrkflw-parser@0.7.0 wrkflw-runtime@0.7.0 wrkflw-ui@0.7.0 wrkflw-utils@0.7.0 wrkflw-validators@0.7.0 Generated by cargo-workspaces |
||
|
|
537bf2f9d1 |
chore: bump version to 0.6.0
- Updated workspace version from 0.5.0 to 0.6.0 - Updated all internal crate dependencies to 0.6.0 - Verified all tests pass and builds succeed |
||
|
|
f0b6633cb8 | renamed | ||
|
|
470132c5bf |
Refactor: Migrate modules to workspace crates
- Extracted functionality from the `src/` directory into individual crates within the `crates/` directory. This improves modularity, organization, and separation of concerns. - Migrated modules include: models, evaluator, ui, gitlab, utils, logging, github, matrix, executor, runtime, parser, and validators. - Removed the original source files and directories from `src/` after successful migration. - This change sets the stage for better code management and potentially independent development/versioning of workspace members. |