railmux 0.4.0.dev35__tar.gz → 0.4.0.dev36__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. {railmux-0.4.0.dev35/src/railmux.egg-info → railmux-0.4.0.dev36}/PKG-INFO +1 -1
  2. railmux-0.4.0.dev36/src/railmux/__init__.py +1 -0
  3. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/provider_paths.py +3 -1
  4. railmux-0.4.0.dev36/src/railmux/release_version.py +111 -0
  5. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/windows_msys2.py +32 -17
  6. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/windows_ui_transition.py +4 -3
  7. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36/src/railmux.egg-info}/PKG-INFO +1 -1
  8. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux.egg-info/SOURCES.txt +2 -0
  9. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_release_notes.py +26 -1
  10. railmux-0.4.0.dev36/tests/test_release_version.py +128 -0
  11. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_windows_msys2.py +83 -0
  12. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_windows_ui_transition.py +1 -1
  13. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tools/release_notes.py +5 -2
  14. railmux-0.4.0.dev35/src/railmux/__init__.py +0 -1
  15. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/LICENSE +0 -0
  16. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/MANIFEST.in +0 -0
  17. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/README.md +0 -0
  18. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/pyproject.toml +0 -0
  19. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/setup.cfg +0 -0
  20. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/__main__.py +0 -0
  21. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/atomic_file.py +0 -0
  22. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/background_index.py +0 -0
  23. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/cli.py +0 -0
  24. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/codex_index.py +0 -0
  25. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/config.py +0 -0
  26. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/config_cli.py +0 -0
  27. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/diagnostics.py +0 -0
  28. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/discovery.py +0 -0
  29. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/display_transport.py +0 -0
  30. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/entrypoint.py +0 -0
  31. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/fast_display_client.py +0 -0
  32. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/fast_display_history.py +0 -0
  33. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/fast_display_input.py +0 -0
  34. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/fast_display_protocol.py +0 -0
  35. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/fast_display_server.py +0 -0
  36. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/favorites.py +0 -0
  37. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/fuzzy.py +0 -0
  38. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/help_workspace.py +0 -0
  39. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/launcher.py +0 -0
  40. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/legacy_sessions.py +0 -0
  41. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/local_clipboard.py +0 -0
  42. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/local_open.py +0 -0
  43. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/models.py +0 -0
  44. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/modes.py +0 -0
  45. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/mouse_manager.py +0 -0
  46. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/orphan_marker.py +0 -0
  47. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/pane_surface.py +0 -0
  48. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/path_codec.py +0 -0
  49. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/preview_pager.py +0 -0
  50. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/project_favorites.py +0 -0
  51. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/remote_config.py +0 -0
  52. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/renames.py +0 -0
  53. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/restart_state.py +0 -0
  54. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/runtime_config.py +0 -0
  55. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/scroll_agent.py +0 -0
  56. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/scroll_manager.py +0 -0
  57. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/selection_isolation.py +0 -0
  58. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/self_update.py +0 -0
  59. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/session_cache.py +0 -0
  60. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/session_index.py +0 -0
  61. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/session_lease.py +0 -0
  62. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/setting_contracts.py +0 -0
  63. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/settings.py +0 -0
  64. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/ssh_args.py +0 -0
  65. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/ssh_compat.py +0 -0
  66. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/ssh_display_diagnostics.py +0 -0
  67. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/ssh_doctor.py +0 -0
  68. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/system_deps.py +0 -0
  69. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/terminal_status.py +0 -0
  70. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/tmux_binding_manager.py +0 -0
  71. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/tmux_ctl.py +0 -0
  72. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/tmux_health.py +0 -0
  73. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/tmux_server.py +0 -0
  74. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/tool_panes.py +0 -0
  75. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/transcript.py +0 -0
  76. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/ui/__init__.py +0 -0
  77. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/ui/_widgets.py +0 -0
  78. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/ui/app.py +0 -0
  79. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/ui/keymap.py +0 -0
  80. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/ui/modals.py +0 -0
  81. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/ui/projects_pane.py +0 -0
  82. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/ui/running_pane.py +0 -0
  83. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/ui/sessions_pane.py +0 -0
  84. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/ui/sidebar.py +0 -0
  85. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/ui/statusbar.py +0 -0
  86. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/ui/workspace.py +0 -0
  87. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/windows_attach_relay.py +0 -0
  88. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/windows_bootstrap.py +0 -0
  89. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/windows_install_log.py +0 -0
  90. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/windows_pacman.py +0 -0
  91. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/windows_paths.py +0 -0
  92. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux/windows_tmux_lifecycle.py +0 -0
  93. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux.egg-info/dependency_links.txt +0 -0
  94. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux.egg-info/entry_points.txt +0 -0
  95. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux.egg-info/requires.txt +0 -0
  96. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/src/railmux.egg-info/top_level.txt +0 -0
  97. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_agent_workspace.py +0 -0
  98. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_atomic_file.py +0 -0
  99. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_background_index.py +0 -0
  100. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_cli.py +0 -0
  101. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_codex_delete.py +0 -0
  102. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_codex_index.py +0 -0
  103. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_codex_lineage.py +0 -0
  104. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_codex_transcript.py +0 -0
  105. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_config.py +0 -0
  106. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_config_cli.py +0 -0
  107. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_denested_transport_benchmark.py +0 -0
  108. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_diagnostics.py +0 -0
  109. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_discovery.py +0 -0
  110. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_display_transport.py +0 -0
  111. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_fast_full_window.py +0 -0
  112. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_focus_highlight.py +0 -0
  113. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_fullscreen_binding.py +0 -0
  114. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_fuzzy.py +0 -0
  115. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_help_agent.py +0 -0
  116. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_help_workspace.py +0 -0
  117. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_history_cleanup.py +0 -0
  118. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_keymap.py +0 -0
  119. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_launcher.py +0 -0
  120. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_layout_profile.py +0 -0
  121. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_legacy_sessions.py +0 -0
  122. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_local_clipboard.py +0 -0
  123. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_local_open.py +0 -0
  124. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_modals.py +0 -0
  125. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_mode_selection.py +0 -0
  126. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_modes.py +0 -0
  127. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_mouse_manager.py +0 -0
  128. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_new_project.py +0 -0
  129. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_options.py +0 -0
  130. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_orphan_recovery.py +0 -0
  131. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_pane_surface.py +0 -0
  132. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_path_codec.py +0 -0
  133. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_preview_pager.py +0 -0
  134. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_project_favorites.py +0 -0
  135. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_projects_pane.py +0 -0
  136. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_provider_paths.py +0 -0
  137. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_release_workflow.py +0 -0
  138. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_remote_config.py +0 -0
  139. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_renames.py +0 -0
  140. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_restart_state.py +0 -0
  141. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_running_pane.py +0 -0
  142. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_running_sort.py +0 -0
  143. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_runtime_config.py +0 -0
  144. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_scroll_agent.py +0 -0
  145. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_scroll_manager.py +0 -0
  146. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_selection_isolation.py +0 -0
  147. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_self_update.py +0 -0
  148. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_session_cache.py +0 -0
  149. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_session_index.py +0 -0
  150. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_session_lease.py +0 -0
  151. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_sessions_pane.py +0 -0
  152. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_setting_contracts.py +0 -0
  153. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_settings.py +0 -0
  154. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_sidebar.py +0 -0
  155. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_soft_quit.py +0 -0
  156. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_ssh_args.py +0 -0
  157. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_ssh_compat.py +0 -0
  158. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_ssh_display_benchmark.py +0 -0
  159. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_ssh_display_diagnostics.py +0 -0
  160. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_ssh_doctor.py +0 -0
  161. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_status.py +0 -0
  162. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_statusbar.py +0 -0
  163. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_system_deps.py +0 -0
  164. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_terminal_size.py +0 -0
  165. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_terminal_status.py +0 -0
  166. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_termux_local_touch.py +0 -0
  167. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_tmux_binding_manager.py +0 -0
  168. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_tmux_ctl.py +0 -0
  169. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_tmux_health.py +0 -0
  170. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_tmux_integration.py +0 -0
  171. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_tmux_server.py +0 -0
  172. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_tmux_status.py +0 -0
  173. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_tool_panes.py +0 -0
  174. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_transcript.py +0 -0
  175. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_utf8_state.py +0 -0
  176. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_web_demo_recorder.py +0 -0
  177. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_wheel_smoke.py +0 -0
  178. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_widgets.py +0 -0
  179. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_windows_attach_relay.py +0 -0
  180. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_windows_bootstrap.py +0 -0
  181. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_windows_install_log.py +0 -0
  182. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_windows_pacman.py +0 -0
  183. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_windows_paths.py +0 -0
  184. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_windows_tmux_lifecycle.py +0 -0
  185. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_windows_wrapper_contract.py +0 -0
  186. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tests/test_yolo_prompt.py +0 -0
  187. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tools/denested_transport_benchmark.py +0 -0
  188. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tools/wheel_smoke.py +0 -0
  189. {railmux-0.4.0.dev35 → railmux-0.4.0.dev36}/tools/windows_archive_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.0.dev35
3
+ Version: 0.4.0.dev36
4
4
  Summary: Terminal UI to navigate, resume, and start Claude Code and Codex sessions
5
5
  Author-email: Rightglow <zhang.taian@foxmail.com>
6
6
  License-Expression: MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0.dev36"
@@ -9,13 +9,15 @@ import sys
9
9
  from collections.abc import Mapping
10
10
  from pathlib import Path
11
11
 
12
+ from railmux.release_version import PROJECT_VERSION_PATTERN
13
+
12
14
 
13
15
  _DRIVE_PATH = re.compile(r"^(?:\\\\\?\\)?([A-Za-z]):[\\/](.*)\Z")
14
16
  _MANAGED_BASE_MARKER = Path("/railmux-base.json")
15
17
  _MANAGED_BASE_CONTENT_MARKER = Path("/railmux-base-content-v1.json")
16
18
  _MANAGED_APP_ROOT = Path("/opt/railmux/apps")
17
19
  _MANAGED_APP_ID = re.compile(
18
- r"railmux-[0-9]+(?:\.[0-9]+)*(?:\.dev[0-9]+)?\Z"
20
+ rf"railmux-{PROJECT_VERSION_PATTERN}\Z"
19
21
  )
20
22
 
21
23
 
@@ -0,0 +1,111 @@
1
+ """Narrow version grammar shared by managed Windows app-layer identities."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from functools import total_ordering
6
+ from typing import Any
7
+
8
+
9
+ class InvalidVersion(ValueError):
10
+ """A version is outside Railmux's canonical persisted grammar."""
11
+
12
+
13
+ @total_ordering
14
+ class ProjectVersion:
15
+ """Small standard-library ordering type for native bootstrap identities."""
16
+
17
+ __slots__ = ("_key", "_value")
18
+
19
+ def __init__(
20
+ self,
21
+ value: str,
22
+ release: tuple[int, ...],
23
+ stage: int,
24
+ stage_number: int,
25
+ ) -> None:
26
+ normalized = release
27
+ while len(normalized) > 1 and normalized[-1] == 0:
28
+ normalized = normalized[:-1]
29
+ self._value = value
30
+ self._key = (normalized, stage, stage_number)
31
+
32
+ def __str__(self) -> str:
33
+ return self._value
34
+
35
+ def __repr__(self) -> str:
36
+ return f"ProjectVersion({self._value!r})"
37
+
38
+ def __hash__(self) -> int:
39
+ return hash(self._key)
40
+
41
+ def __eq__(self, other: Any) -> bool:
42
+ if not isinstance(other, ProjectVersion):
43
+ return NotImplemented
44
+ return self._key == other._key
45
+
46
+ def __lt__(self, other: Any) -> bool:
47
+ if not isinstance(other, ProjectVersion):
48
+ return NotImplemented
49
+ return self._key < other._key
50
+
51
+
52
+ # Railmux app directories are security-sensitive persisted identities. Accept
53
+ # only the spellings that this project publishes: a final, an RC, or a
54
+ # development release. PEP 440 aliases, epochs, post releases, and local
55
+ # versions are deliberately excluded so equal versions have one path spelling.
56
+ _CANONICAL_INTEGER_PATTERN = r"(?:0|[1-9][0-9]*)"
57
+ PROJECT_VERSION_PATTERN = (
58
+ rf"{_CANONICAL_INTEGER_PATTERN}"
59
+ rf"(?:\.{_CANONICAL_INTEGER_PATTERN})*"
60
+ rf"(?:rc{_CANONICAL_INTEGER_PATTERN}|\.dev{_CANONICAL_INTEGER_PATTERN})?"
61
+ )
62
+ PROJECT_VERSION_RE = re.compile(PROJECT_VERSION_PATTERN + r"\Z")
63
+ _PROJECT_VERSION_PARTS_RE = re.compile(
64
+ rf"(?P<release>{_CANONICAL_INTEGER_PATTERN}"
65
+ rf"(?:\.{_CANONICAL_INTEGER_PATTERN})*)"
66
+ rf"(?:(?:rc(?P<rc>{_CANONICAL_INTEGER_PATTERN}))|"
67
+ rf"(?:\.dev(?P<dev>{_CANONICAL_INTEGER_PATTERN})))?\Z"
68
+ )
69
+
70
+
71
+ def parse_project_version(value: str) -> ProjectVersion:
72
+ """Return the canonical supported Railmux version or raise InvalidVersion."""
73
+ match = _PROJECT_VERSION_PARTS_RE.fullmatch(value)
74
+ if match is None:
75
+ raise InvalidVersion(value)
76
+ release_parts = match.group("release").split(".")
77
+ stage_text = match.group("rc") or match.group("dev")
78
+ numeric_parts = release_parts + ([stage_text] if stage_text else [])
79
+ if any(
80
+ len(part) > 1 and part.startswith("0")
81
+ for part in numeric_parts
82
+ ):
83
+ raise InvalidVersion(
84
+ f"non-canonical Railmux version spelling: {value!r}"
85
+ )
86
+ if match.group("dev") is not None:
87
+ stage = 0
88
+ stage_number = int(match.group("dev"))
89
+ elif match.group("rc") is not None:
90
+ stage = 1
91
+ stage_number = int(match.group("rc"))
92
+ else:
93
+ stage = 2
94
+ stage_number = 0
95
+ return ProjectVersion(
96
+ value,
97
+ tuple(int(part) for part in release_parts),
98
+ stage,
99
+ stage_number,
100
+ )
101
+
102
+
103
+ def is_project_version(value: object) -> bool:
104
+ """Return whether *value* is one canonical published-version spelling."""
105
+ if not isinstance(value, str):
106
+ return False
107
+ try:
108
+ parse_project_version(value)
109
+ except InvalidVersion:
110
+ return False
111
+ return True
@@ -47,6 +47,13 @@ from railmux.windows_paths import (
47
47
  legacy_local_app_data_root,
48
48
  managed_windows_data_root,
49
49
  )
50
+ from railmux.release_version import (
51
+ PROJECT_VERSION_PATTERN,
52
+ PROJECT_VERSION_RE,
53
+ ProjectVersion,
54
+ is_project_version,
55
+ parse_project_version,
56
+ )
50
57
  from railmux.terminal_status import STYLE_ACCENT, STYLE_MUTED, styled
51
58
 
52
59
 
@@ -108,7 +115,7 @@ _DOWNLOAD_PROBE_TIMEOUT = 10.0
108
115
  _DOWNLOAD_SLOW_REMAINING_SECONDS = 60.0
109
116
  _DOWNLOAD_SWITCH_RATIO = 1.25
110
117
  _CONTENT_RANGE_RE = re.compile(r"bytes (\d+)-(\d+)/(\d+)\Z")
111
- _VERSION_RE = re.compile(r"[0-9]+(?:\.[0-9]+)*(?:\.dev[0-9]+)?\Z")
118
+ _VERSION_RE = PROJECT_VERSION_RE
112
119
 
113
120
  _PACMAN_CONFIG = "/etc/railmux-pacman.conf"
114
121
  _PACMAN_PACKAGES = "tmux python python-pip"
@@ -1077,10 +1084,8 @@ def _app_marker_matches(root: Path, *, app_name: str, version: str) -> bool:
1077
1084
  )
1078
1085
 
1079
1086
 
1080
- def _version_key(version: str) -> tuple[tuple[int, ...], int]:
1081
- base, separator, dev = version.partition(".dev")
1082
- numbers = tuple(int(part) for part in base.split("."))
1083
- return numbers, int(dev) if separator else sys.maxsize
1087
+ def _version_key(version: str) -> ProjectVersion:
1088
+ return parse_project_version(version)
1084
1089
 
1085
1090
 
1086
1091
  def _marked_app_versions(root: Path) -> dict[str, Path] | None:
@@ -1113,7 +1118,7 @@ def _marked_app_versions(root: Path) -> dict[str, Path] | None:
1113
1118
  continue
1114
1119
  version = candidate.name[len(prefix):]
1115
1120
  if (
1116
- _VERSION_RE.fullmatch(version)
1121
+ is_project_version(version)
1117
1122
  and _app_marker_matches(
1118
1123
  root, app_name=candidate.name, version=version)
1119
1124
  ):
@@ -1144,8 +1149,8 @@ def _in_use_app_names(
1144
1149
  except UnicodeDecodeError:
1145
1150
  return None
1146
1151
  pattern = re.compile(
1147
- r"(?:^|/)opt/railmux/apps/"
1148
- r"(railmux-[0-9]+(?:\.[0-9]+)*(?:\.dev[0-9]+)?)(?:/|$)"
1152
+ rf"(?:^|/)opt/railmux/apps/"
1153
+ rf"(railmux-{PROJECT_VERSION_PATTERN})(?:/|$)"
1149
1154
  )
1150
1155
  return frozenset(match.group(1) for match in pattern.finditer(text))
1151
1156
 
@@ -1158,7 +1163,7 @@ def plan_managed_runtime_prune(
1158
1163
  probe: Probe = _probe,
1159
1164
  ) -> RuntimePrunePlan:
1160
1165
  """Build a fail-closed plan without deleting any file."""
1161
- if not _VERSION_RE.fullmatch(version):
1166
+ if not is_project_version(version):
1162
1167
  raise RuntimeInstallError("the Railmux package version is invalid")
1163
1168
  if environ.get(_RUNTIME_OVERRIDE, "").strip():
1164
1169
  raise RuntimeInstallError(
@@ -1277,7 +1282,7 @@ def _find_shared_root(base: Path) -> Path | None:
1277
1282
  if _base_marker_matches(canonical):
1278
1283
  return canonical
1279
1284
  legacy_parent = base / MSYS2_RUNTIME_ID
1280
- candidates: list[tuple[tuple[tuple[int, ...], int], Path]] = []
1285
+ candidates: list[tuple[ProjectVersion, Path]] = []
1281
1286
  try:
1282
1287
  paths = list(legacy_parent.glob("railmux-*"))
1283
1288
  except OSError:
@@ -1288,7 +1293,7 @@ def _find_shared_root(base: Path) -> Path | None:
1288
1293
  if (
1289
1294
  not candidate.is_symlink()
1290
1295
  and _base_marker_matches(candidate)
1291
- and _VERSION_RE.fullmatch(version)
1296
+ and is_project_version(version)
1292
1297
  ):
1293
1298
  candidates.append((_version_key(version), candidate))
1294
1299
  if candidates:
@@ -2332,7 +2337,7 @@ def _write_app_marker(app_root: Path, *, version: str) -> None:
2332
2337
 
2333
2338
  def _legacy_candidates(base: Path) -> list[tuple[Path, str]]:
2334
2339
  parent = base / MSYS2_RUNTIME_ID
2335
- candidates: list[tuple[tuple[tuple[int, ...], int], Path, str]] = []
2340
+ candidates: list[tuple[ProjectVersion, Path, str]] = []
2336
2341
  try:
2337
2342
  paths = list(parent.glob("railmux-*"))
2338
2343
  except OSError:
@@ -2346,7 +2351,7 @@ def _legacy_candidates(base: Path) -> list[tuple[Path, str]]:
2346
2351
  version = payload.get("railmux")
2347
2352
  if (
2348
2353
  not isinstance(version, str)
2349
- or not _VERSION_RE.fullmatch(version)
2354
+ or not is_project_version(version)
2350
2355
  or payload
2351
2356
  != {
2352
2357
  "schema": RUNTIME_SCHEMA,
@@ -2492,11 +2497,21 @@ def _venv_site_packages(application: Path) -> Path | None:
2492
2497
  def _native_application_payload(version: str) -> tuple[Path, Path] | None:
2493
2498
  """Locate the currently executing pure-Python package and dist-info."""
2494
2499
  try:
2495
- distribution = importlib_metadata.distribution("railmux")
2496
- except importlib_metadata.PackageNotFoundError:
2500
+ matches = [
2501
+ distribution
2502
+ for distribution in importlib_metadata.distributions(name="railmux")
2503
+ if distribution.version == version
2504
+ ]
2505
+ except (OSError, ValueError):
2497
2506
  return None
2498
- if distribution.version != version:
2507
+ # A long-lived Windows test/user environment can retain an old dist-info
2508
+ # directory even though pip installed the current package code. Selecting
2509
+ # ``distribution("railmux")`` would then be order-dependent and could
2510
+ # force an unnecessary network fallback. Never delete metadata here; use
2511
+ # the unique distribution whose version exactly matches the imported app.
2512
+ if len(matches) != 1:
2499
2513
  return None
2514
+ distribution = matches[0]
2500
2515
  package = Path(__file__).resolve().parent
2501
2516
  if not package.is_dir():
2502
2517
  return None
@@ -2965,7 +2980,7 @@ def install_managed_runtime(
2965
2980
  reuse_only: bool = False,
2966
2981
  ) -> Msys2Runtime:
2967
2982
  """Install a fresh private runtime and activate it only after verification."""
2968
- if not _VERSION_RE.fullmatch(version):
2983
+ if not is_project_version(version):
2969
2984
  raise RuntimeInstallError("the Railmux package version is invalid")
2970
2985
  if environ.get(_RUNTIME_OVERRIDE, "").strip():
2971
2986
  raise RuntimeInstallError(
@@ -23,6 +23,7 @@ from typing import Iterator, Mapping, Sequence
23
23
  from packaging.version import InvalidVersion, Version
24
24
 
25
25
  from railmux import restart_state, tmux_server
26
+ from railmux.release_version import PROJECT_VERSION_PATTERN
26
27
  from railmux.windows_msys2 import MSYS2_BASE_LINEAGE_SHA256
27
28
 
28
29
 
@@ -31,7 +32,7 @@ REQUESTED_APP_OPTION = "@railmux_requested_app_v1"
31
32
  TRANSITION_STATUS_OPTION = "@railmux_app_transition_status_v1"
32
33
  UPGRADE_WAKE_SEQUENCE = "\x1b[33~"
33
34
  _APP_RE = re.compile(
34
- r"railmux-([0-9]+(?:\.[0-9]+)*(?:\.dev[0-9]+)?)\Z"
35
+ rf"railmux-({PROJECT_VERSION_PATTERN})\Z"
35
36
  )
36
37
  _CONTENT_RE = re.compile(r"[0-9a-f]{64}\Z")
37
38
  _PANE_RE = re.compile(r"%[0-9]+\Z")
@@ -557,7 +558,7 @@ def _pane_app_version(
557
558
  pass
558
559
  pattern = re.compile(
559
560
  r"/opt/railmux/apps/railmux-"
560
- r"([0-9]+(?:\.[0-9]+)*(?:\.dev[0-9]+)?)/venv/bin/railmux"
561
+ rf"({PROJECT_VERSION_PATTERN})/venv/bin/railmux"
561
562
  )
562
563
  for candidate in candidates:
563
564
  match = pattern.search(candidate)
@@ -663,7 +664,7 @@ def ensure_current_ui(
663
664
  runtime: str,
664
665
  target_app: str,
665
666
  target_version: str,
666
- timeout: float = 8.0,
667
+ timeout: float = 15.0,
667
668
  ) -> TransitionOutcome:
668
669
  """Converge a detached managed outer UI without touching providers."""
669
670
  content_id = _base_identity(runtime)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.0.dev35
3
+ Version: 0.4.0.dev36
4
4
  Summary: Terminal UI to navigate, resume, and start Claude Code and Codex sessions
5
5
  Author-email: Rightglow <zhang.taian@foxmail.com>
6
6
  License-Expression: MIT
@@ -35,6 +35,7 @@ src/railmux/path_codec.py
35
35
  src/railmux/preview_pager.py
36
36
  src/railmux/project_favorites.py
37
37
  src/railmux/provider_paths.py
38
+ src/railmux/release_version.py
38
39
  src/railmux/remote_config.py
39
40
  src/railmux/renames.py
40
41
  src/railmux/restart_state.py
@@ -126,6 +127,7 @@ tests/test_project_favorites.py
126
127
  tests/test_projects_pane.py
127
128
  tests/test_provider_paths.py
128
129
  tests/test_release_notes.py
130
+ tests/test_release_version.py
129
131
  tests/test_release_workflow.py
130
132
  tests/test_remote_config.py
131
133
  tests/test_renames.py
@@ -79,6 +79,21 @@ def test_renders_development_release_section():
79
79
  )
80
80
 
81
81
 
82
+ def test_renders_release_candidate_section():
83
+ changelog = (
84
+ "## [0.4.0rc1] - 2026-08-07\n\n"
85
+ "- Release candidate.\n\n"
86
+ "[0.4.0rc1]: "
87
+ "https://example.test/compare/v0.4.0.dev36...v0.4.0rc1\n"
88
+ )
89
+
90
+ assert render_release_notes(changelog, "v0.4.0rc1") == (
91
+ "- Release candidate.\n\n"
92
+ "**Full Changelog**: "
93
+ "https://example.test/compare/v0.4.0.dev36...v0.4.0rc1\n"
94
+ )
95
+
96
+
82
97
  def test_last_section_does_not_include_changelog_link_definitions():
83
98
  notes = render_release_notes(CHANGELOG, "0.2.30")
84
99
 
@@ -89,7 +104,17 @@ def test_last_section_does_not_include_changelog_link_definitions():
89
104
 
90
105
  @pytest.mark.parametrize(
91
106
  "version",
92
- ["0.2.4", "Unreleased", "v0.2", "0.2.3-dev1", "0.2.3.dev"],
107
+ [
108
+ "0.2.4",
109
+ "Unreleased",
110
+ "v0.2",
111
+ "0.2.3-dev1",
112
+ "0.2.3.dev",
113
+ "00.2.3",
114
+ "0.02.3",
115
+ "0.2.03",
116
+ "0.2.3rc01",
117
+ ],
93
118
  )
94
119
  def test_rejects_missing_or_invalid_release(version):
95
120
  with pytest.raises(ValueError):
@@ -0,0 +1,128 @@
1
+ import subprocess
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from railmux import __version__, provider_paths, windows_msys2
8
+ from railmux import windows_ui_transition
9
+ from railmux.release_version import (
10
+ InvalidVersion,
11
+ is_project_version,
12
+ parse_project_version,
13
+ )
14
+ from tools.release_notes import normalize_version
15
+
16
+
17
+ ROOT = Path(__file__).parents[1]
18
+
19
+
20
+ @pytest.mark.parametrize(
21
+ "version",
22
+ ["0.4.0.dev35", "0.4.0.dev36", "0.4.0rc1", "0.4.0", "1.2.3"],
23
+ )
24
+ def test_every_persisted_version_consumer_accepts_release_spellings(version):
25
+ app = f"railmux-{version}"
26
+
27
+ assert is_project_version(version)
28
+ assert windows_msys2._VERSION_RE.fullmatch(version)
29
+ assert windows_ui_transition._APP_RE.fullmatch(app)
30
+ assert provider_paths._MANAGED_APP_ID.fullmatch(app)
31
+ assert normalize_version(f"v{version}") == version
32
+
33
+
34
+ @pytest.mark.parametrize(
35
+ "version",
36
+ [
37
+ "0.4.0-rc1",
38
+ "0.4.0RC1",
39
+ "0.4.0rc01",
40
+ "0.4.0.dev01",
41
+ "00.4.0",
42
+ "0.04.0",
43
+ "0.4.00",
44
+ "0.4.0a1",
45
+ "0.4.0b1",
46
+ "0.4.0.post1",
47
+ "0.4.0+local",
48
+ "1!0.4.0",
49
+ ],
50
+ )
51
+ def test_persisted_version_grammar_rejects_aliases_and_unsupported_forms(version):
52
+ with pytest.raises(InvalidVersion):
53
+ parse_project_version(version)
54
+ app = f"railmux-{version}"
55
+ assert windows_msys2._VERSION_RE.fullmatch(version) is None
56
+ assert windows_ui_transition._APP_RE.fullmatch(app) is None
57
+ assert provider_paths._MANAGED_APP_ID.fullmatch(app) is None
58
+
59
+
60
+ def test_release_order_matches_dev_to_rc_to_final():
61
+ versions = ["0.4.0", "0.4.0.dev35", "0.4.0rc1", "0.4.0.dev36"]
62
+
63
+ assert sorted(versions, key=windows_msys2._version_key) == [
64
+ "0.4.0.dev35",
65
+ "0.4.0.dev36",
66
+ "0.4.0rc1",
67
+ "0.4.0",
68
+ ]
69
+
70
+
71
+ def test_release_order_crosses_the_next_development_base():
72
+ versions = ["0.4.1.dev1", "0.4.0", "0.4.0rc2", "0.4.0.dev99"]
73
+
74
+ assert sorted(versions, key=parse_project_version) == [
75
+ "0.4.0.dev99",
76
+ "0.4.0rc2",
77
+ "0.4.0",
78
+ "0.4.1.dev1",
79
+ ]
80
+
81
+
82
+ def test_package_version_uses_its_canonical_persisted_spelling():
83
+ assert str(parse_project_version(__version__)) == __version__
84
+
85
+
86
+ def test_native_bootstrap_version_command_does_not_import_packaging():
87
+ code = f"""
88
+ import importlib.abc
89
+ import sys
90
+ sys.path.insert(0, {str(ROOT / 'src')!r})
91
+ class BlockPackaging(importlib.abc.MetaPathFinder):
92
+ def find_spec(self, fullname, path=None, target=None):
93
+ if fullname == 'packaging' or fullname.startswith('packaging.'):
94
+ raise ModuleNotFoundError(fullname)
95
+ return None
96
+ sys.meta_path.insert(0, BlockPackaging())
97
+ from railmux.windows_bootstrap import main
98
+ raise SystemExit(main(['--version'], version_info=(3, 10)))
99
+ """
100
+
101
+ result = subprocess.run(
102
+ [sys.executable, "-c", code],
103
+ capture_output=True,
104
+ text=True,
105
+ check=False,
106
+ )
107
+
108
+ assert result.returncode == 0, result.stderr
109
+ assert result.stdout.startswith("railmux 0.4.0")
110
+
111
+
112
+ def test_in_use_inventory_retains_an_rc_app_layer(tmp_path):
113
+ output = (
114
+ b"/opt/railmux/apps/railmux-0.4.0rc1/venv/bin/railmux\0"
115
+ b"/opt/railmux/apps/railmux-0.4.0.dev36/venv/bin/railmux\0"
116
+ )
117
+
118
+ result = windows_msys2._in_use_app_names(
119
+ tmp_path,
120
+ environ={},
121
+ probe=lambda argv, **kwargs: subprocess.CompletedProcess(
122
+ argv, 0, stdout=output, stderr=b""
123
+ ),
124
+ )
125
+
126
+ assert result == frozenset(
127
+ {"railmux-0.4.0rc1", "railmux-0.4.0.dev36"}
128
+ )
@@ -2200,6 +2200,49 @@ def test_runtime_status_distinguishes_reusable_base_from_current_app(tmp_path):
2200
2200
  assert snapshot["current_app"] is False
2201
2201
 
2202
2202
 
2203
+ def test_native_payload_selects_exact_version_among_stale_dist_info(
2204
+ tmp_path, monkeypatch,
2205
+ ):
2206
+ current_info = tmp_path / "railmux-0.4.0rc1.dist-info"
2207
+ stale_info = tmp_path / "railmux-0.4.0.dev33.dist-info"
2208
+ current_info.mkdir()
2209
+ stale_info.mkdir()
2210
+
2211
+ def distribution(version, info):
2212
+ return SimpleNamespace(
2213
+ version=version,
2214
+ files=(Path(info.name) / "METADATA",),
2215
+ locate_file=lambda relative: tmp_path / relative,
2216
+ )
2217
+
2218
+ monkeypatch.setattr(
2219
+ windows_msys2.importlib_metadata,
2220
+ "distributions",
2221
+ lambda **_kwargs: iter((
2222
+ distribution("0.4.0.dev33", stale_info),
2223
+ distribution("0.4.0rc1", current_info),
2224
+ )),
2225
+ )
2226
+
2227
+ payload = windows_msys2._native_application_payload("0.4.0rc1")
2228
+
2229
+ assert payload is not None
2230
+ assert payload[1] == current_info
2231
+
2232
+
2233
+ def test_native_payload_rejects_duplicate_exact_metadata(tmp_path, monkeypatch):
2234
+ info = tmp_path / "railmux-0.4.0rc1.dist-info"
2235
+ info.mkdir()
2236
+ distribution = SimpleNamespace(version="0.4.0rc1")
2237
+ monkeypatch.setattr(
2238
+ windows_msys2.importlib_metadata,
2239
+ "distributions",
2240
+ lambda **_kwargs: iter((distribution, distribution)),
2241
+ )
2242
+
2243
+ assert windows_msys2._native_application_payload("0.4.0rc1") is None
2244
+
2245
+
2203
2246
  def test_prune_retains_current_previous_and_process_proven_layers(tmp_path):
2204
2247
  environ = {"LOCALAPPDATA": str(tmp_path)}
2205
2248
  root = managed_root(environ)
@@ -2222,6 +2265,29 @@ def test_prune_retains_current_previous_and_process_proven_layers(tmp_path):
2222
2265
  assert f"railmux-{VERSION}" in plan.retained_apps
2223
2266
 
2224
2267
 
2268
+ def test_prune_retains_running_rc_beyond_current_and_previous_layers(tmp_path):
2269
+ environ = {"LOCALAPPDATA": str(tmp_path)}
2270
+ root = managed_root(environ)
2271
+ assert root is not None
2272
+ make_runtime(root, managed=True, shared=True)
2273
+ old_dev = add_marked_app(root, "0.4.0.dev36")
2274
+ running_rc = add_marked_app(root, "0.4.0rc1")
2275
+ final = add_marked_app(root, "0.4.0")
2276
+ current = add_marked_app(root, "0.4.1.dev1")
2277
+ in_use = b"/opt/railmux/apps/railmux-0.4.0rc1/venv/bin/railmux\0"
2278
+
2279
+ plan = windows_msys2.plan_managed_runtime_prune(
2280
+ version="0.4.1.dev1",
2281
+ environ=environ,
2282
+ probe=lambda argv, **_kwargs: completed(argv, stdout=in_use),
2283
+ )
2284
+
2285
+ assert plan.remove_apps == (old_dev,)
2286
+ assert running_rc.name in plan.retained_apps
2287
+ assert final.name in plan.retained_apps
2288
+ assert current.name in plan.retained_apps
2289
+
2290
+
2225
2291
  def test_prune_ignores_unmarked_directories_and_fails_closed_on_process_probe(
2226
2292
  tmp_path,
2227
2293
  ):
@@ -2243,6 +2309,23 @@ def test_prune_ignores_unmarked_directories_and_fails_closed_on_process_probe(
2243
2309
  assert unmarked.is_dir()
2244
2310
 
2245
2311
 
2312
+ def test_prune_ignores_marked_noncanonical_version_directory(tmp_path):
2313
+ environ = {"LOCALAPPDATA": str(tmp_path)}
2314
+ root = managed_root(environ)
2315
+ assert root is not None
2316
+ make_runtime(root, managed=True, shared=True)
2317
+ noncanonical = add_marked_app(root, "0.04.0")
2318
+
2319
+ plan = windows_msys2.plan_managed_runtime_prune(
2320
+ version=VERSION,
2321
+ environ=environ,
2322
+ probe=lambda argv, **_kwargs: completed(argv, stdout=b""),
2323
+ )
2324
+
2325
+ assert noncanonical not in plan.remove_apps
2326
+ assert noncanonical.is_dir()
2327
+
2328
+
2246
2329
  def test_prune_refuses_linked_application_parent(tmp_path):
2247
2330
  environ = {"LOCALAPPDATA": str(tmp_path)}
2248
2331
  root = managed_root(environ)
@@ -165,7 +165,7 @@ def test_detached_cooperative_ui_requests_exact_upgrade(monkeypatch, tmp_path):
165
165
  "target_app": TARGET_APP,
166
166
  "target_version": TARGET_VERSION,
167
167
  "base_content_id": CONTENT,
168
- "timeout": 8.0,
168
+ "timeout": 15.0,
169
169
  }]
170
170
 
171
171
 
@@ -9,7 +9,10 @@ import sys
9
9
  from pathlib import Path
10
10
 
11
11
 
12
- _VERSION_RE = re.compile(r"\d+\.\d+\.\d+(?:\.dev\d+)?")
12
+ _VERSION_RE = re.compile(
13
+ r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
14
+ r"(?:rc(?:0|[1-9]\d*)|\.dev(?:0|[1-9]\d*))?"
15
+ )
13
16
  _SECTION_RE = re.compile(r"^## \[([^]]+)](?:\s+-\s+.+)?\s*$")
14
17
  _LINK_RE = re.compile(r"^\[([^]]+)]\s*:\s*(\S+)\s*$")
15
18
 
@@ -66,7 +69,7 @@ def main(argv: list[str] | None = None) -> int:
66
69
  "version",
67
70
  help=(
68
71
  "release version or tag, for example 0.2.3, v0.2.3, or "
69
- "v0.2.4.dev1"
72
+ "v0.2.4.dev1, or v0.4.0rc1"
70
73
  ),
71
74
  )
72
75
  parser.add_argument(
@@ -1 +0,0 @@
1
- __version__ = "0.4.0.dev35"
File without changes
File without changes
File without changes
File without changes