Coverage for src/footman/_refresh.py: 93%
46 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
1"""Background completion-manifest refresh — the detached rebuild child.
3The completion hot path spawns this detached, two ways:
5- `_maybe_refresh` → `refresh_cwd` is the stale-while-revalidate path: when a
6 cwd's cached manifest is older than its baked `completion.max_age`, rebuild the
7 *cwd cascade's* manifest so dynamic completers (git branches, file lists) don't
8 go stale for "time since your last real `fm` command here".
9- `_cold_build` → `refresh_source` builds a single `-f <file>`'s (cwd, file)
10 manifest the first time it is TAB-completed in a fresh directory, so `fm -f
11 <file> <TAB>` answers accurately instead of empty.
13Both rebuild exactly as a real run would and are strictly fire-and-forget: they
14print nothing and never raise.
15"""
17from __future__ import annotations
19import contextlib
22def refresh_cwd() -> None:
23 """Rebuild the current directory's completion manifest, swallowing errors."""
24 # A detached background refresh must never crash or print.
25 with contextlib.suppress(Exception):
26 _rebuild()
29def _rebuild() -> None:
30 from pathlib import Path
32 from footman import _paths, compose, config, discover, manifest, registry
34 cwd = Path.cwd()
35 ceiling = _paths.find_repo_root(cwd)
36 cfg = config.load_config(cwd, ceiling)
37 filename = cfg.get("tasks")
38 if not isinstance(filename, str): 38 ↛ 44line 38 didn't jump to line 44 because the condition on line 38 was always true
39 # A branded CLI's default filename isn't knowable here — but the
40 # manifest this child refreshes baked it in.
41 cached = manifest.load_manifest(_paths.manifest_path(cwd))
42 baked = cached.get("tasks_file") if isinstance(cached, dict) else None
43 filename = baked if isinstance(baked, str) else _paths.DEFAULT_TASKS_FILE
44 name = filename
45 files = _paths.task_files(cwd, ceiling, name)
46 if not files:
47 return
49 # Mirror the app layer's cwd cascade build (discovery + config plugins), so
50 # the refreshed manifest matches what a real `fm` run would cache.
51 base = registry.Group("root")
52 plugins = cfg.get("plugins")
53 if isinstance(plugins, list) and plugins:
54 # A broken plugin shouldn't abort the whole refresh.
55 with contextlib.suppress(registry.RegistrationError):
56 compose.mount_plugins(base, plugins)
58 reg = discover.load_tree(files, base=base)
59 manifest.sync_manifest(
60 reg, cwd, completion_max_age=config.completion_max_age(cfg), tasks_file=name
61 )
64def refresh_source(tasks_file: str) -> None:
65 """Rebuild one `-f <file>`'s (cwd, file) manifest, swallowing errors."""
66 # A detached background rebuild must never crash or print.
67 with contextlib.suppress(Exception):
68 _rebuild_source(tasks_file)
71def _rebuild_source(tasks_file: str) -> None:
72 from pathlib import Path
74 from footman import _paths, compose, config, discover, manifest, registry
76 one = Path(tasks_file).expanduser()
77 if not one.is_file():
78 return # a typed-but-missing -f value: nothing to build
79 cwd = Path.cwd()
80 ceiling = _paths.find_repo_root(cwd)
81 cfg = config.load_config(cwd, ceiling)
83 # Mirror a real `-f` run (see _app._run): one file, no cascade, config
84 # plugins mounted, cached under the (cwd, file) key with max_age=0 — no
85 # background refresh, rebuilt on the next -f run or the next cold TAB.
86 base = registry.Group("root")
87 plugins = cfg.get("plugins")
88 if isinstance(plugins, list) and plugins: 88 ↛ 89line 88 didn't jump to line 89 because the condition on line 88 was never true
89 with contextlib.suppress(registry.RegistrationError):
90 compose.mount_plugins(base, plugins)
92 reg = discover.load_tree([one], base=base)
93 manifest.sync_manifest(
94 reg,
95 cwd,
96 completion_max_age=0,
97 tasks_file=tasks_file,
98 path=_paths.source_manifest_path(cwd, one),
99 )