odoo-workspaces 2.1.0__tar.gz → 2.2.0__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 (125) hide show
  1. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/AGENTS.md +11 -1
  2. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/PKG-INFO +1 -1
  3. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/docs/commands.md +79 -0
  4. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/odoo_workspaces.egg-info/PKG-INFO +1 -1
  5. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/odoo_workspaces.egg-info/SOURCES.txt +11 -0
  6. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/odoo_workspaces.egg-info/scm_file_list.json +11 -0
  7. odoo_workspaces-2.2.0/src/odoo_workspaces.egg-info/scm_version.json +8 -0
  8. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/__main__.py +30 -2
  9. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/_version.py +3 -3
  10. odoo_workspaces-2.2.0/src/ow/askpass.py +41 -0
  11. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/commands/__init__.py +4 -0
  12. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/commands/prune.py +83 -7
  13. odoo_workspaces-2.2.0/src/ow/commands/pull.py +230 -0
  14. odoo_workspaces-2.2.0/src/ow/commands/reset.py +274 -0
  15. odoo_workspaces-2.2.0/src/ow/utils/askpass.py +180 -0
  16. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/utils/git.py +30 -1
  17. odoo_workspaces-2.2.0/src/ow/utils/pull_plan.py +104 -0
  18. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/utils/refs.py +22 -1
  19. odoo_workspaces-2.2.0/src/ow/utils/reset_plan.py +94 -0
  20. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/commands/test_prune.py +107 -0
  21. odoo_workspaces-2.2.0/src/tests/commands/test_pull.py +294 -0
  22. odoo_workspaces-2.2.0/src/tests/commands/test_reset.py +301 -0
  23. odoo_workspaces-2.2.0/src/tests/utils/test_askpass.py +129 -0
  24. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/utils/test_git_primitives.py +20 -0
  25. odoo_workspaces-2.2.0/src/tests/utils/test_pull_plan.py +135 -0
  26. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/utils/test_refs.py +22 -0
  27. odoo_workspaces-2.2.0/src/tests/utils/test_reset_plan.py +127 -0
  28. odoo_workspaces-2.1.0/src/odoo_workspaces.egg-info/scm_version.json +0 -8
  29. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/.github/workflows/release.yml +0 -0
  30. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/.gitignore +0 -0
  31. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/.vscode/launch.json +0 -0
  32. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/.vscode/settings.json +0 -0
  33. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/LICENSE +0 -0
  34. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/README.md +0 -0
  35. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/RELEASE.md +0 -0
  36. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/docs/configuration.md +0 -0
  37. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/docs/migrating-to-2.0.md +0 -0
  38. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/docs/sandboxing.md +0 -0
  39. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/docs/services.md +0 -0
  40. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/docs/templates.md +0 -0
  41. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/mise.toml +0 -0
  42. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/pyproject.toml +0 -0
  43. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/scripts/bwrap-claude +0 -0
  44. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/scripts/bwrap-opencode +0 -0
  45. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/scripts/migrate-to-2.0.py +0 -0
  46. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/scripts/tests/__init__.py +0 -0
  47. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/scripts/tests/test_migrate_2.py +0 -0
  48. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/setup.cfg +0 -0
  49. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/odoo_workspaces.egg-info/dependency_links.txt +0 -0
  50. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/odoo_workspaces.egg-info/entry_points.txt +0 -0
  51. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/odoo_workspaces.egg-info/requires.txt +0 -0
  52. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/odoo_workspaces.egg-info/top_level.txt +0 -0
  53. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/__init__.py +0 -0
  54. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/_static/services/compose.yml.j2 +0 -0
  55. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/_static/templates/bwrap/bwrap-claude.j2 +0 -0
  56. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/_static/templates/bwrap/bwrap-opencode.j2 +0 -0
  57. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/_static/templates/common/mise.toml.j2 +0 -0
  58. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/_static/templates/common/odools.toml.j2 +0 -0
  59. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/_static/templates/common/odoorc.j2 +0 -0
  60. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/_static/templates/common/pyrightconfig.json.j2 +0 -0
  61. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/_static/templates/common/requirements-dev.txt +0 -0
  62. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/_static/templates/vscode/.vscode/launch.json.j2 +0 -0
  63. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/_static/templates/vscode/.vscode/settings.json.j2 +0 -0
  64. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/_static/templates/zed/.zed/debug.json.j2 +0 -0
  65. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/_static/templates/zed/.zed/settings.json.j2 +0 -0
  66. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/commands/apply.py +0 -0
  67. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/commands/archive.py +0 -0
  68. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/commands/cd.py +0 -0
  69. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/commands/init.py +0 -0
  70. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/commands/ls.py +0 -0
  71. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/commands/mv.py +0 -0
  72. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/commands/open.py +0 -0
  73. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/commands/rebase.py +0 -0
  74. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/commands/rm.py +0 -0
  75. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/commands/shell_init.py +0 -0
  76. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/commands/status.py +0 -0
  77. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/commands/templates.py +0 -0
  78. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/utils/config.py +0 -0
  79. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/utils/display.py +0 -0
  80. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/utils/drift.py +0 -0
  81. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/utils/index.py +0 -0
  82. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/utils/legacy.py +0 -0
  83. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/utils/paths.py +0 -0
  84. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/utils/rebase_plan.py +0 -0
  85. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/utils/relocate.py +0 -0
  86. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/utils/resolver.py +0 -0
  87. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/ow/utils/templates.py +0 -0
  88. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/__init__.py +0 -0
  89. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/commands/__init__.py +0 -0
  90. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/commands/test_apply.py +0 -0
  91. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/commands/test_archive.py +0 -0
  92. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/commands/test_init.py +0 -0
  93. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/commands/test_init_extended.py +0 -0
  94. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/commands/test_ls.py +0 -0
  95. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/commands/test_misc_extended.py +0 -0
  96. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/commands/test_mv.py +0 -0
  97. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/commands/test_open.py +0 -0
  98. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/commands/test_rebase.py +0 -0
  99. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/commands/test_rebase_scenarios.py +0 -0
  100. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/commands/test_rm.py +0 -0
  101. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/commands/test_shell_init.py +0 -0
  102. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/commands/test_status.py +0 -0
  103. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/commands/test_status_extended.py +0 -0
  104. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/commands/test_status_extended2.py +0 -0
  105. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/commands/test_templates_cmd.py +0 -0
  106. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/conftest.py +0 -0
  107. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/test_cli.py +0 -0
  108. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/test_main_extended.py +0 -0
  109. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/utils/__init__.py +0 -0
  110. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/utils/test_config.py +0 -0
  111. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/utils/test_display.py +0 -0
  112. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/utils/test_drift.py +0 -0
  113. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/utils/test_git.py +0 -0
  114. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/utils/test_index.py +0 -0
  115. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/utils/test_interrupt.py +0 -0
  116. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/utils/test_legacy.py +0 -0
  117. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/utils/test_misc.py +0 -0
  118. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/utils/test_paths.py +0 -0
  119. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/utils/test_progress.py +0 -0
  120. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/utils/test_rebase_plan.py +0 -0
  121. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/utils/test_resolve_template_files.py +0 -0
  122. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/utils/test_resolver.py +0 -0
  123. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/utils/test_templates.py +0 -0
  124. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/utils/test_templates_extended.py +0 -0
  125. {odoo_workspaces-2.1.0 → odoo_workspaces-2.2.0}/src/tests/utils/test_templates_extended2.py +0 -0
@@ -12,6 +12,8 @@ src/
12
12
  │ │ ├── apply.py # cmd_apply — make the tree match .ow/config.toml (--only narrows the git work, never the rendering)
13
13
  │ │ ├── status.py # cmd_status + display helpers
14
14
  │ │ ├── rebase.py # cmd_rebase + fact gathering, display, execution
15
+ │ │ ├── pull.py # cmd_pull + fact gathering, display, execution (fast-forward only)
16
+ │ │ ├── reset.py # cmd_reset + fact gathering, display, execution (git reset, per repo)
15
17
  │ │ ├── prune.py # cmd_prune
16
18
  │ │ ├── rm.py # cmd_rm — remove a workspace: worktrees, local branches, directory, index entry
17
19
  │ │ └── templates.py # cmd_templates — list/take/diff template files; outdated_templates() used by cmd_apply
@@ -25,6 +27,9 @@ src/
25
27
  │ │ ├── paths.py # ow's locations, resolved from the XDG base directories
26
28
  │ │ ├── refs.py # fetch_workspace_refs
27
29
  │ │ ├── rebase_plan.py # RepoFacts, GitStep, RebasePlan, plan_for (pure)
30
+ │ │ ├── pull_plan.py # PullFacts, PullPlan, plan_pull (pure)
31
+ │ │ ├── reset_plan.py # ResetFacts, ResetPlan, plan_reset (pure)
32
+ │ │ ├── askpass.py # SSH_ASKPASS broker: git children ask ow, ow asks the terminal
28
33
  │ │ ├── resolver.py # resolve_workspace
29
34
  │ │ └── templates.py # file generators, template resolution, application, materialization
30
35
  │ └── _static/
@@ -66,9 +71,12 @@ AGENTS.md
66
71
  - `index.py` — remembers where workspaces live (for name-based resolution and duplicate-branch checks), without owning the truth — the workspace's own `.ow/config.toml` is that.
67
72
  - `display.py` — `console`, `err_console`, `counts`, `print_git_result`.
68
73
  - `drift.py` — `DriftResult`, `check_drift`, `warn_if_drifted`. Detect when worktree branch state doesn't match config. Commands call `warn_if_drifted` to display warnings but proceed anyway.
69
- - `git.py` — worktree operations: `create_worktree`, `attach_worktree`, `detach_worktree`, `worktree_exists`, `worktree_is_detached`, `get_worktree_branch`, `get_worktree_head`. Bare repo operations: `ensure_bare_repo`, `ensure_ref`. Ref/branch queries: `resolve_spec`, `resolve_spec_local`, `get_all_remote_refs`, `get_remote_ref_for_branch`, `get_remote_url`, `get_upstream`, `set_branch_upstream`, `is_branch_pushed`. Analysis: `rev_parse`, `is_ancestor`, `merge_base`, `count_commits`, `count_new_patches`, `count_unpushed`, `in_progress_operation`, `dirty_files`. Execution: `git`, `git_fetch`, `parallel_per_repo`, `run_cmd`.
74
+ - `git.py` — worktree operations: `create_worktree`, `attach_worktree`, `detach_worktree`, `worktree_exists`, `worktree_is_detached`, `get_worktree_branch`, `get_worktree_head`. Bare repo operations: `ensure_bare_repo`, `ensure_ref`. Ref/branch queries: `resolve_spec`, `resolve_spec_local`, `get_all_remote_refs`, `get_remote_ref_for_branch`, `get_remote_url`, `get_upstream`, `set_branch_upstream`, `is_branch_pushed`. Analysis: `rev_parse`, `is_ancestor`, `merge_base`, `count_commits`, `count_new_patches`, `count_unpushed`, `count_unbacked_commits`, `in_progress_operation`, `dirty_files`. Execution: `git`, `git_fetch`, `parallel_per_repo`, `run_cmd`.
70
75
  - `refs.py` — `fetch_workspace_refs` — three-phase pipeline for fetching workspace refs.
71
76
  - `rebase_plan.py` — `RepoFacts`, `GitStep`, `RebasePlan`, `plan_for` — pure analysis functions for rebase planning.
77
+ - `pull_plan.py` — `PullFacts`, `PullPlan`, `plan_pull` — pure analysis functions for fast-forward planning.
78
+ - `reset_plan.py` — `ResetFacts`, `ResetPlan`, `plan_reset` — pure analysis functions for reset planning.
79
+ - `askpass.py` — `broker()`, `child_env()`. Every git child runs in its own session and so has no controlling terminal; ssh therefore falls back to `SSH_ASKPASS`. The broker points it at a generated shim that forwards the prompt over a Unix socket to ow, which still owns the terminal. Prompts are serialised and answers cached per run. Entered once, in `__main__.main()`.
72
80
  - `resolver.py` — `resolve_workspace(name=None)` resolves a workspace: an explicit path, an explicit name (looked up via the index), the `OW_WORKSPACE` env var, or a cwd walk-up for `.ow/config.toml`. One rule, four branches, no fallback between them.
73
81
  - `templates.py` — `is_odoo_main_repo`, `find_addon_paths`, `build_template_context`, `apply_templates`, `ensure_workspace_materialized`, `available_templates`, `packaged_files`, `resolve_template_files` (local overrides win per file, packaged sibling still reachable via Jinja include/extends).
74
82
 
@@ -80,6 +88,8 @@ AGENTS.md
80
88
  | `ow apply` | `cmd_apply(config, workspace=None, *, only=None)` | Materialize worktrees + re-render templates |
81
89
  | `ow status` | `cmd_status(config, workspace=None)` | Show workspace branch status |
82
90
  | `ow rebase` | `cmd_rebase(config, workspace=None, *, only=None, autostash=False, dry_run=False, yes=False)` | Fetch + rebase workspace branches |
91
+ | `ow pull` | `cmd_pull(config, workspace=None, *, only=None, dry_run=False)` | Fetch, then fast-forward each repo — or replay it on its own upstream, `git pull --rebase` style. Never moves a repo off its base ref; that stays `ow rebase` |
92
+ | `ow reset` | `cmd_reset(config, workspace=None, *, only=None, hard=False, fetch=False, dry_run=False, yes=False)` | Put each repo back on the ref it follows — its branch's upstream, or the base ref when there is none. Plain form moves HEAD and leaves the working tree, so nothing on disk is lost; `--hard` discards it too. No fetch unless `-f`; skips a repo that is not on the branch the config names |
83
93
  | `ow prune` | `cmd_prune(config)` | Clean up stale worktree references, orphaned branches, dead index entries |
84
94
  | `ow rm` | `cmd_rm(name, *, yes=False)` | Remove a workspace: worktrees, local branches, directory, index entry |
85
95
  | `ow templates` | `cmd_templates(take=None, show_diff=False)` | List template files with their state, take one, or diff the stale ones |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: odoo-workspaces
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Odoo workspace manager
5
5
  Author-email: Bruno BOI <boi@odoo.com>
6
6
  License: MIT
@@ -8,6 +8,8 @@
8
8
  | `ow apply` | `[workspace]` | Re-render templates and materialize worktrees |
9
9
  | `ow status` | `[workspace]`, `-f/--fetch` | Show branch status with behind/ahead counts |
10
10
  | `ow rebase` | `[workspace]`, `--only`, `--autostash`, `--dry-run`, `-y/--yes` | Fetch and rebase repos in a workspace |
11
+ | `ow pull` | `[workspace]`, `--only`, `--dry-run` | Fetch and fast-forward repos in a workspace |
12
+ | `ow reset` | `[workspace]`, `--only`, `--hard`, `-f/--fetch`, `--dry-run`, `-y/--yes` | Put repos back on the refs they follow |
11
13
  | `ow prune` | `--dry-run`, `-y/--yes` | Clean up stale worktree references, orphaned branches, and dead index entries |
12
14
  | `ow rm` | `<name>`, `-y/--yes` | Remove a workspace: worktrees, local branches, directory, and index entry |
13
15
  | `ow ls` | — | List every known workspace, its path, and its repos |
@@ -111,6 +113,83 @@ ever pushed: the `git push --force-with-lease` stays yours.
111
113
  `--dry-run` fetches refs to show you what would happen, but runs no command that
112
114
  touches your worktrees.
113
115
 
116
+ ## `ow pull`
117
+
118
+ Brings every repo of a workspace up to date without moving any of them off the
119
+ base branch they are configured on. `ow rebase` is the command that does move
120
+ them.
121
+
122
+ ```sh
123
+ ow pull # every repo of the current workspace
124
+ ow pull parrot --only community # one repo of a named workspace
125
+ ow pull --dry-run # fetch, then print the plan — no worktree touched
126
+ ```
127
+
128
+ Each repo follows its upstream when it has one — its own branch as pushed
129
+ elsewhere — and its base ref otherwise.
130
+
131
+ - already there: left alone
132
+ - detached: re-detaches onto the fetched ref
133
+ - behind: `git merge --ff-only`, which git refuses if the move would clobber a
134
+ local modification
135
+ - diverged **from its upstream**: `git rebase <upstream>`, exactly what
136
+ `git pull --rebase` does — same branch, same base, nobody else's commits
137
+ - diverged **from its base ref**, with no upstream: reported and left alone.
138
+ Carrying local work over to a moved base needs force-push detection and a
139
+ replay floor, and is `ow rebase`'s job
140
+
141
+ A repo is also left alone, and the run exits non-zero, when a git operation is
142
+ already in progress, when a replay would be needed but the worktree is dirty
143
+ (the message names the files), when the worktree is missing, or when the fetch
144
+ failed — moving onto the stale cached ref would look like success.
145
+
146
+ On conflict during a replay, resolve, `git rebase --continue`, then re-run
147
+ `ow pull --only <alias>`. Nothing is ever pushed.
148
+
149
+ ## `ow reset`
150
+
151
+ Puts every repo back on the ref it follows — `git reset`, one repo at a time.
152
+ Use it when a workspace has wandered, or when an upstream was force-pushed and
153
+ you want the remote's version of the branch, whatever yours has become.
154
+
155
+ ```sh
156
+ ow reset # every repo of the current workspace
157
+ ow reset parrot --only enterprise # one repo of a named workspace
158
+ ow reset --hard # discard the working tree too
159
+ ow reset --hard -f # ... against freshly fetched refs
160
+ ow reset --dry-run # print the plan, touch nothing
161
+ ```
162
+
163
+ **The ref each repo is reset to** is the one `git reset @{u}` would have used:
164
+ the branch's upstream — its own copy on a remote — or, for a branch nobody has
165
+ pushed and for a detached repo, the base ref it was cut from. An attached branch
166
+ is never reset onto its base: that would not put the repo back, it would throw
167
+ the whole branch away.
168
+
169
+ The two forms differ exactly as git's do:
170
+
171
+ - `ow reset` moves HEAD and leaves the working tree alone. The commits are gone
172
+ from the branch, but their content is still on disk as unstaged changes —
173
+ **nothing on disk is lost**.
174
+ - `ow reset --hard` discards the working tree as well. Untracked files are left
175
+ alone, exactly as `git reset --hard` leaves them; `git clean` is its own
176
+ command.
177
+
178
+ No fetch by default: it resets to the refs already in the bare repos, the way
179
+ `git reset origin/master` does. `-f/--fetch` refreshes them first, which is what
180
+ an upstream that was force-pushed needs — otherwise you land on the copy the
181
+ last fetch happened to cache.
182
+
183
+ The summary counts what each repo loses before anything runs, and flags the
184
+ commits no remote carries — the only ones a reset makes unrecoverable outside
185
+ the reflog. The confirmation defaults to no; `-y/--yes` skips it.
186
+
187
+ A repo is skipped, and the run exits non-zero, when a git operation is already
188
+ in progress, when the worktree is missing, when its refs will not resolve
189
+ locally, or when it is not on the branch the config names. That last one
190
+ matters: resetting whatever else happens to be checked out would throw away
191
+ work ow was never told about, and realigning is `ow apply`'s job.
192
+
114
193
  ## `ow prune`
115
194
 
116
195
  Cleans up stale worktree references and orphaned local branches from every bare repo, and drops
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: odoo-workspaces
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Odoo workspace manager
5
5
  Author-email: Bruno BOI <boi@odoo.com>
6
6
  License: MIT
@@ -30,6 +30,7 @@ src/odoo_workspaces.egg-info/top_level.txt
30
30
  src/ow/__init__.py
31
31
  src/ow/__main__.py
32
32
  src/ow/_version.py
33
+ src/ow/askpass.py
33
34
  src/ow/_static/services/compose.yml.j2
34
35
  src/ow/_static/templates/bwrap/bwrap-claude.j2
35
36
  src/ow/_static/templates/bwrap/bwrap-opencode.j2
@@ -51,11 +52,14 @@ src/ow/commands/ls.py
51
52
  src/ow/commands/mv.py
52
53
  src/ow/commands/open.py
53
54
  src/ow/commands/prune.py
55
+ src/ow/commands/pull.py
54
56
  src/ow/commands/rebase.py
57
+ src/ow/commands/reset.py
55
58
  src/ow/commands/rm.py
56
59
  src/ow/commands/shell_init.py
57
60
  src/ow/commands/status.py
58
61
  src/ow/commands/templates.py
62
+ src/ow/utils/askpass.py
59
63
  src/ow/utils/config.py
60
64
  src/ow/utils/display.py
61
65
  src/ow/utils/drift.py
@@ -63,9 +67,11 @@ src/ow/utils/git.py
63
67
  src/ow/utils/index.py
64
68
  src/ow/utils/legacy.py
65
69
  src/ow/utils/paths.py
70
+ src/ow/utils/pull_plan.py
66
71
  src/ow/utils/rebase_plan.py
67
72
  src/ow/utils/refs.py
68
73
  src/ow/utils/relocate.py
74
+ src/ow/utils/reset_plan.py
69
75
  src/ow/utils/resolver.py
70
76
  src/ow/utils/templates.py
71
77
  src/tests/__init__.py
@@ -82,8 +88,10 @@ src/tests/commands/test_misc_extended.py
82
88
  src/tests/commands/test_mv.py
83
89
  src/tests/commands/test_open.py
84
90
  src/tests/commands/test_prune.py
91
+ src/tests/commands/test_pull.py
85
92
  src/tests/commands/test_rebase.py
86
93
  src/tests/commands/test_rebase_scenarios.py
94
+ src/tests/commands/test_reset.py
87
95
  src/tests/commands/test_rm.py
88
96
  src/tests/commands/test_shell_init.py
89
97
  src/tests/commands/test_status.py
@@ -91,6 +99,7 @@ src/tests/commands/test_status_extended.py
91
99
  src/tests/commands/test_status_extended2.py
92
100
  src/tests/commands/test_templates_cmd.py
93
101
  src/tests/utils/__init__.py
102
+ src/tests/utils/test_askpass.py
94
103
  src/tests/utils/test_config.py
95
104
  src/tests/utils/test_display.py
96
105
  src/tests/utils/test_drift.py
@@ -102,8 +111,10 @@ src/tests/utils/test_legacy.py
102
111
  src/tests/utils/test_misc.py
103
112
  src/tests/utils/test_paths.py
104
113
  src/tests/utils/test_progress.py
114
+ src/tests/utils/test_pull_plan.py
105
115
  src/tests/utils/test_rebase_plan.py
106
116
  src/tests/utils/test_refs.py
117
+ src/tests/utils/test_reset_plan.py
107
118
  src/tests/utils/test_resolve_template_files.py
108
119
  src/tests/utils/test_resolver.py
109
120
  src/tests/utils/test_templates.py
@@ -35,6 +35,7 @@
35
35
  "src/ow/_static/templates/vscode/.vscode/settings.json.j2",
36
36
  "src/ow/_static/templates/zed/.zed/debug.json.j2",
37
37
  "src/ow/_static/templates/zed/.zed/settings.json.j2",
38
+ "src/ow/askpass.py",
38
39
  "src/ow/commands/__init__.py",
39
40
  "src/ow/commands/apply.py",
40
41
  "src/ow/commands/archive.py",
@@ -44,11 +45,14 @@
44
45
  "src/ow/commands/mv.py",
45
46
  "src/ow/commands/open.py",
46
47
  "src/ow/commands/prune.py",
48
+ "src/ow/commands/pull.py",
47
49
  "src/ow/commands/rebase.py",
50
+ "src/ow/commands/reset.py",
48
51
  "src/ow/commands/rm.py",
49
52
  "src/ow/commands/shell_init.py",
50
53
  "src/ow/commands/status.py",
51
54
  "src/ow/commands/templates.py",
55
+ "src/ow/utils/askpass.py",
52
56
  "src/ow/utils/config.py",
53
57
  "src/ow/utils/display.py",
54
58
  "src/ow/utils/drift.py",
@@ -56,9 +60,11 @@
56
60
  "src/ow/utils/index.py",
57
61
  "src/ow/utils/legacy.py",
58
62
  "src/ow/utils/paths.py",
63
+ "src/ow/utils/pull_plan.py",
59
64
  "src/ow/utils/rebase_plan.py",
60
65
  "src/ow/utils/refs.py",
61
66
  "src/ow/utils/relocate.py",
67
+ "src/ow/utils/reset_plan.py",
62
68
  "src/ow/utils/resolver.py",
63
69
  "src/ow/utils/templates.py",
64
70
  "src/tests/__init__.py",
@@ -72,8 +78,10 @@
72
78
  "src/tests/commands/test_mv.py",
73
79
  "src/tests/commands/test_open.py",
74
80
  "src/tests/commands/test_prune.py",
81
+ "src/tests/commands/test_pull.py",
75
82
  "src/tests/commands/test_rebase.py",
76
83
  "src/tests/commands/test_rebase_scenarios.py",
84
+ "src/tests/commands/test_reset.py",
77
85
  "src/tests/commands/test_rm.py",
78
86
  "src/tests/commands/test_shell_init.py",
79
87
  "src/tests/commands/test_status.py",
@@ -84,6 +92,7 @@
84
92
  "src/tests/test_cli.py",
85
93
  "src/tests/test_main_extended.py",
86
94
  "src/tests/utils/__init__.py",
95
+ "src/tests/utils/test_askpass.py",
87
96
  "src/tests/utils/test_config.py",
88
97
  "src/tests/utils/test_display.py",
89
98
  "src/tests/utils/test_drift.py",
@@ -95,8 +104,10 @@
95
104
  "src/tests/utils/test_misc.py",
96
105
  "src/tests/utils/test_paths.py",
97
106
  "src/tests/utils/test_progress.py",
107
+ "src/tests/utils/test_pull_plan.py",
98
108
  "src/tests/utils/test_rebase_plan.py",
99
109
  "src/tests/utils/test_refs.py",
110
+ "src/tests/utils/test_reset_plan.py",
100
111
  "src/tests/utils/test_resolve_template_files.py",
101
112
  "src/tests/utils/test_resolver.py",
102
113
  "src/tests/utils/test_templates.py",
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "2.2.0",
3
+ "distance": 0,
4
+ "node": "g2cc2fc2b1e998c35d547d3da26e3f905919335c1",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-09-11"
8
+ }
@@ -14,14 +14,16 @@ from ow.commands import (
14
14
  cmd_mv,
15
15
  cmd_open,
16
16
  cmd_prune,
17
+ cmd_pull,
17
18
  cmd_rebase,
19
+ cmd_reset,
18
20
  cmd_rm,
19
21
  cmd_shell_init,
20
22
  cmd_status,
21
23
  cmd_templates,
22
24
  cmd_unarchive,
23
25
  )
24
- from ow.utils import index
26
+ from ow.utils import askpass, index
25
27
  from ow.utils.config import Config, load_global_config, parse_branch_spec
26
28
  from ow.utils.display import err_console
27
29
  from ow.utils.legacy import check_legacy_layout
@@ -220,6 +222,31 @@ def rebase(
220
222
  )
221
223
 
222
224
 
225
+ @app.command()
226
+ def pull(
227
+ workspace: Optional[str] = typer.Argument(None, help=WORKSPACE_HELP, autocompletion=complete_workspace_name),
228
+ only: Optional[str] = typer.Option(None, "--only", help="Comma-separated repo aliases to pull (default: all)"),
229
+ dry_run: bool = typer.Option(False, "--dry-run", help="Show the git commands without running them"),
230
+ ) -> None:
231
+ """Fetch and fast-forward workspace branches."""
232
+ config = _load_config()
233
+ cmd_pull(config, workspace=workspace, only=only, dry_run=dry_run)
234
+
235
+
236
+ @app.command()
237
+ def reset(
238
+ workspace: Optional[str] = typer.Argument(None, help=WORKSPACE_HELP, autocompletion=complete_workspace_name),
239
+ only: Optional[str] = typer.Option(None, "--only", help="Comma-separated repo aliases to reset (default: all)"),
240
+ hard: bool = typer.Option(False, "--hard", help="Discard the working tree too, not just the commits"),
241
+ fetch: bool = typer.Option(False, "--fetch", "-f", help="Refresh the refs first, instead of resetting to what is already cached"),
242
+ dry_run: bool = typer.Option(False, "--dry-run", help="Show the git commands without running them"),
243
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt"),
244
+ ) -> None:
245
+ """Put workspace repos back on the refs they follow."""
246
+ config = _load_config()
247
+ cmd_reset(config, workspace=workspace, only=only, hard=hard, fetch=fetch, dry_run=dry_run, yes=yes)
248
+
249
+
223
250
  @app.command()
224
251
  def ls(
225
252
  archived: bool = typer.Option(False, "--archived", help="List archived workspaces instead of active ones"),
@@ -323,7 +350,8 @@ def open_ws(
323
350
 
324
351
  def main() -> None:
325
352
  try:
326
- app()
353
+ with askpass.broker():
354
+ app()
327
355
  except KeyboardInterrupt:
328
356
  # 130 is the conventional shell status for SIGINT. The git children
329
357
  # are already gone, pool or no pool: each one runs in its own session
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.1.0'
22
- __version_tuple__ = version_tuple = (2, 1, 0)
21
+ __version__ = version = '2.2.0'
22
+ __version_tuple__ = version_tuple = (2, 2, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g5f1a5e842'
24
+ __commit_id__ = commit_id = 'g2cc2fc2b1'
@@ -0,0 +1,41 @@
1
+ """SSH_ASKPASS shim: ask the ow process that spawned git, print the answer.
2
+
3
+ ssh execs this with the prompt as its first argument and reads the secret
4
+ from its stdout. It runs in the git child's session, with no terminal of
5
+ its own, so it forwards the question to the broker in ow — which still has
6
+ one. See ow.utils.askpass.
7
+ """
8
+
9
+ import base64
10
+ import os
11
+ import socket
12
+ import sys
13
+
14
+
15
+ def main() -> int:
16
+ sock_path = os.environ.get("OW_ASKPASS_SOCK")
17
+ if not sock_path:
18
+ return 1
19
+
20
+ prompt = sys.argv[1] if len(sys.argv) > 1 else ""
21
+ try:
22
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as conn:
23
+ conn.connect(sock_path)
24
+ conn.sendall(base64.b64encode(prompt.encode("utf-8")) + b"\n")
25
+ chunks: list[bytes] = []
26
+ while not chunks or not chunks[-1].endswith(b"\n"):
27
+ chunk = conn.recv(4096)
28
+ if not chunk:
29
+ break
30
+ chunks.append(chunk)
31
+ except OSError:
32
+ return 1
33
+
34
+ payload = b"".join(chunks).strip()
35
+ answer = base64.b64decode(payload).decode("utf-8", "replace") if payload else ""
36
+ sys.stdout.write(answer + "\n")
37
+ return 0
38
+
39
+
40
+ if __name__ == "__main__":
41
+ sys.exit(main())
@@ -6,7 +6,9 @@ from ow.commands.ls import cmd_ls
6
6
  from ow.commands.mv import cmd_mv
7
7
  from ow.commands.open import cmd_open
8
8
  from ow.commands.prune import cmd_prune
9
+ from ow.commands.pull import cmd_pull
9
10
  from ow.commands.rebase import cmd_rebase
11
+ from ow.commands.reset import cmd_reset
10
12
  from ow.commands.rm import cmd_rm
11
13
  from ow.commands.shell_init import cmd_shell_init
12
14
  from ow.commands.status import cmd_status
@@ -21,7 +23,9 @@ __all__ = [
21
23
  "cmd_mv",
22
24
  "cmd_open",
23
25
  "cmd_prune",
26
+ "cmd_pull",
24
27
  "cmd_rebase",
28
+ "cmd_reset",
25
29
  "cmd_rm",
26
30
  "cmd_shell_init",
27
31
  "cmd_status",
@@ -4,7 +4,8 @@ from typing import NamedTuple
4
4
 
5
5
  from ow.utils.display import confirm, err_console
6
6
  from ow.utils import index, paths
7
- from ow.utils.git import _run, is_branch_pushed, parallel_per_repo
7
+ from ow.utils.config import load_workspace_config
8
+ from ow.utils.git import _run, get_worktree_branch, is_branch_pushed, parallel_per_repo
8
9
 
9
10
 
10
11
  class _PrunePlan(NamedTuple):
@@ -51,7 +52,8 @@ def _survey_worktrees(bare_repo: Path) -> tuple[set[str], list[str]]:
51
52
 
52
53
  A prunable worktree's branch is deliberately not counted as in use: it
53
54
  is about to stop being attached to anything, and the branch pass has to
54
- see the repo as it will be, not as it was.
55
+ see the repo as it will be, not as it was. What keeps that from eating a
56
+ live workspace's branch is _declared_branches(), not this listing.
55
57
  """
56
58
  result = _run(
57
59
  ["git", "-C", str(bare_repo), "worktree", "list", "--porcelain"],
@@ -60,7 +62,12 @@ def _survey_worktrees(bare_repo: Path) -> tuple[set[str], list[str]]:
60
62
  used: set[str] = set()
61
63
  stale: list[str] = []
62
64
  if result.returncode != 0:
63
- return used, stale
65
+ # Not "nothing is in use": that reading turns every branch in the
66
+ # repo into an orphan candidate, which is the one direction this
67
+ # command must never fail in.
68
+ raise RuntimeError(
69
+ f"git worktree list failed: {result.stderr.strip() or 'no output'}"
70
+ )
64
71
 
65
72
  path: str | None = None
66
73
  branch: str | None = None
@@ -92,7 +99,57 @@ def _survey_worktrees(bare_repo: Path) -> tuple[set[str], list[str]]:
92
99
  return used, stale
93
100
 
94
101
 
95
- def _survey_bare_repo(bare_repo: Path) -> _PrunePlan:
102
+ def _declared_branches() -> tuple[dict[str, set[str]], list[tuple[Path, str]]]:
103
+ """Per alias, the branches live workspaces own. Plus the configs that would not read.
104
+
105
+ git's worktree bookkeeping is not the only truth. A registration that
106
+ went stale — a directory moved by hand, a mount that was away when the
107
+ listing ran — makes git call the worktree prunable and its branch
108
+ unattached, while the workspace is still there and still says in its own
109
+ .ow/config.toml which branch it works on. Issue #50: prune offered to
110
+ delete exactly such a branch, and git refused it as in use.
111
+
112
+ Both the declared branch and the branch actually checked out are taken:
113
+ a worktree moved to a branch the config does not name is still work.
114
+ """
115
+ declared: dict[str, set[str]] = {}
116
+ unreadable: list[tuple[Path, str]] = []
117
+
118
+ # list_workspaces, not known_workspaces: the latter rewrites the index
119
+ # as it reads, and --dry-run and a declined confirmation must leave it
120
+ # exactly as they found it. Dead entries are dropped later, by the index
121
+ # pass that already owns that job.
122
+ for ws_dir in index.list_workspaces():
123
+ try:
124
+ config_file = ws_dir / ".ow" / "config.toml"
125
+ if not config_file.is_file():
126
+ # A dead index entry, not a workspace whose config is broken.
127
+ continue
128
+ ws = load_workspace_config(config_file)
129
+ owned_here: dict[str, set[str]] = {}
130
+ for alias, spec in ws.repos.items():
131
+ owned = owned_here.setdefault(alias, set())
132
+ if spec.local_branch:
133
+ # A detached spec owns no branch.
134
+ owned.add(spec.local_branch)
135
+ if (ws_dir / alias).is_dir():
136
+ checked_out = get_worktree_branch(ws_dir / alias)
137
+ if checked_out:
138
+ owned.add(checked_out)
139
+ except Exception as exc:
140
+ # Unparseable, unreadable, on a directory that will not even
141
+ # stat: all the same answer — this workspace's branches cannot
142
+ # be named, so none of them can be shown to be orphaned.
143
+ unreadable.append((ws_dir, str(exc)))
144
+ continue
145
+
146
+ for alias, owned in owned_here.items():
147
+ declared.setdefault(alias, set()).update(owned)
148
+
149
+ return declared, unreadable
150
+
151
+
152
+ def _survey_bare_repo(bare_repo: Path, protected: frozenset[str] = frozenset()) -> _PrunePlan:
96
153
  """Work out what would go from one bare repo, without touching it."""
97
154
  used_branches, stale = _survey_worktrees(bare_repo)
98
155
 
@@ -117,7 +174,7 @@ def _survey_bare_repo(bare_repo: Path) -> _PrunePlan:
117
174
 
118
175
  if branch_result.returncode == 0:
119
176
  all_branches = {b.strip() for b in branch_result.stdout.splitlines() if b.strip()}
120
- for branch in sorted(all_branches - used_branches):
177
+ for branch in sorted(all_branches - used_branches - protected):
121
178
  if branch == head_branch:
122
179
  # Never delete the branch HEAD points at — a dangling HEAD
123
180
  # confuses every subsequent git command and looks like data loss.
@@ -268,14 +325,19 @@ def cmd_prune(*, dry_run: bool = False, yes: bool = False, also_backups: bool =
268
325
  leaves everything untouched, the index included.
269
326
  """
270
327
  dropped = _dead_index_entries()
328
+ declared, unreadable = _declared_branches()
271
329
 
272
330
  bare_repos_dir = paths.repos_dir()
273
- bare_repos = sorted(bare_repos_dir.glob("*.git")) if bare_repos_dir.exists() else []
331
+ # Not every *.git under there is an alias: a stray `.git` directory
332
+ # matches the pattern too, and surveying it fails as "not a repository".
333
+ bare_repos = sorted(
334
+ p for p in bare_repos_dir.glob("*.git") if not p.name.startswith(".")
335
+ ) if bare_repos_dir.exists() else []
274
336
  if not bare_repos:
275
337
  print("No bare repos found.")
276
338
 
277
339
  surveyed = parallel_per_repo({
278
- repo.stem: (lambda r=repo: _survey_bare_repo(r))
340
+ repo.stem: (lambda r=repo: _survey_bare_repo(r, frozenset(declared.get(r.stem, ()))))
279
341
  for repo in bare_repos
280
342
  })
281
343
  plans: list[_PrunePlan] = []
@@ -291,6 +353,20 @@ def cmd_prune(*, dry_run: bool = False, yes: bool = False, also_backups: bool =
291
353
  for alias, exc in survey_errors.items():
292
354
  err_console.print(f" [{alias}] survey failed: {exc}", markup=False)
293
355
 
356
+ for ws_dir, reason in unreadable:
357
+ err_console.print(f" [{ws_dir.name}] unreadable .ow/config.toml: {reason}", markup=False)
358
+ if unreadable:
359
+ # Which branches that workspace owns is exactly what could not be
360
+ # read, so no branch can be shown to be orphaned. The reversible
361
+ # work — stale worktrees, the index, backups — still happens.
362
+ err_console.print(
363
+ " No branch will be deleted this run: ow cannot tell which branches that workspace owns."
364
+ )
365
+ plans = [
366
+ plan._replace(to_delete=[], kept=[*plan.kept, *plan.to_delete])
367
+ for plan in plans
368
+ ]
369
+
294
370
  _display_plan(plans)
295
371
  backups = _stale_backups() if also_backups else []
296
372
  _display_backups(backups)