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

1"""Background completion-manifest refresh — the detached rebuild child. 

2 

3The completion hot path spawns this detached, two ways: 

4 

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. 

12 

13Both rebuild exactly as a real run would and are strictly fire-and-forget: they 

14print nothing and never raise. 

15""" 

16 

17from __future__ import annotations 

18 

19import contextlib 

20 

21 

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() 

27 

28 

29def _rebuild() -> None: 

30 from pathlib import Path 

31 

32 from footman import _paths, compose, config, discover, manifest, registry 

33 

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 

48 

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) 

57 

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 ) 

62 

63 

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) 

69 

70 

71def _rebuild_source(tasks_file: str) -> None: 

72 from pathlib import Path 

73 

74 from footman import _paths, compose, config, discover, manifest, registry 

75 

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) 

82 

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) 

91 

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 )