odoo-wt 1.0.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.
- odoo_wt-1.0.0/.gitignore +7 -0
- odoo_wt-1.0.0/PKG-INFO +85 -0
- odoo_wt-1.0.0/README.md +75 -0
- odoo_wt-1.0.0/pyproject.toml +24 -0
- odoo_wt-1.0.0/src/odoo_wt/app_config.py +41 -0
- odoo_wt-1.0.0/src/odoo_wt/cli_main.py +50 -0
- odoo_wt-1.0.0/src/odoo_wt/custom_screens.py +290 -0
- odoo_wt-1.0.0/src/odoo_wt/main_tui.py +698 -0
- odoo_wt-1.0.0/src/odoo_wt/setup_wizard.py +132 -0
- odoo_wt-1.0.0/src/odoo_wt/stylesheet.tcss +372 -0
- odoo_wt-1.0.0/src/odoo_wt/system_discovery.py +100 -0
- odoo_wt-1.0.0/test_app.py +117 -0
- odoo_wt-1.0.0/uv.lock +224 -0
- odoo_wt-1.0.0/v1.1.0-Roadmap.md +38 -0
odoo_wt-1.0.0/.gitignore
ADDED
odoo_wt-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: odoo-wt
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Modern TUI-driven Odoo Worktree Manager
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: pyspellchecker>=0.8.1
|
|
7
|
+
Requires-Dist: rich>=13.7.1
|
|
8
|
+
Requires-Dist: textual>=0.52.1
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# Odoo Worktree Assistant (`odoo-wt`)
|
|
12
|
+
|
|
13
|
+
A professional, modern TUI-driven Git worktree manager explicitly designed for Odoo developers. Built with Python, [Textual](https://textual.textualize.io/), and [uv](https://github.com/astral-sh/uv).
|
|
14
|
+
|
|
15
|
+
  
|
|
16
|
+
|
|
17
|
+
## 🚀 Overview
|
|
18
|
+
|
|
19
|
+
`odoo-wt` streamlines the process of spinning up new Odoo development environments. It handles the complex "handshake" between Git worktrees and Python virtual environments so you can focus on code.
|
|
20
|
+
|
|
21
|
+
### Key Features
|
|
22
|
+
|
|
23
|
+
- **Modern TUI:** A sleek, reactive "All-in-One" form built with Textual.
|
|
24
|
+
- **Smart Branching:** Follows the Odoo standard: `[VERSION]-[description]-[SUFFIX]`.
|
|
25
|
+
- **Dual Repo Support:** Simultaneously creates worktrees for both `odoo` (Community) and `enterprise`.
|
|
26
|
+
- **Automated UV Environments:**
|
|
27
|
+
- Centralizes environments in `~/.envs/[VERSION]`.
|
|
28
|
+
- Automatically runs `uv venv` and `uv pip install` if the environment is missing.
|
|
29
|
+
- Instantly symlinks `.venv` into your new worktree folder for automatic VS Code detection.
|
|
30
|
+
- **Proactive Status Checks:** Checks `odoo-dev` and local remotes to detect if branches already exist before creation.
|
|
31
|
+
|
|
32
|
+
## 🛠️ Installation
|
|
33
|
+
|
|
34
|
+
The recommended way to install `odoo-wt` is using `uv tool`:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# From the project directory
|
|
38
|
+
uv tool install . --force
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This creates an isolated environment for the tool and places an `odoo-wt` executable in your `~/.local/bin/`.
|
|
42
|
+
|
|
43
|
+
## ⚙️ Configuration
|
|
44
|
+
|
|
45
|
+
The tool saves its configuration to `~/.config/odoo-wt.json`. You can edit paths directly in the **Settings** tab within the application.
|
|
46
|
+
|
|
47
|
+
- **`wt_root`**: Where your worktree folders live (e.g., `~/repos/Odoo/wt`).
|
|
48
|
+
- **`env_root`**: Where your centralized global Python environments are stored (e.g., `~/.envs`).
|
|
49
|
+
- **`suffix`**: Your default developer quadrigram (e.g., `pian`).
|
|
50
|
+
|
|
51
|
+
## 📖 Usage
|
|
52
|
+
|
|
53
|
+
### Interactive Mode (The Guide)
|
|
54
|
+
Simply run the command with no arguments:
|
|
55
|
+
```bash
|
|
56
|
+
odoo-wt
|
|
57
|
+
```
|
|
58
|
+
- Use **`Tab`** to switch between Version, Description, and Suffix.
|
|
59
|
+
- Type **`custom...`** in any dropdown to reveal a custom input field.
|
|
60
|
+
- Press **`Ctrl+S`** to instantly submit and deploy.
|
|
61
|
+
- Press **`Esc`** to cancel and exit.
|
|
62
|
+
|
|
63
|
+
### Fast Mode
|
|
64
|
+
If you already have a full branch name ready:
|
|
65
|
+
```bash
|
|
66
|
+
odoo-wt 17.0-fix-account-bug-pian
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## ⚠️ Known Limitations
|
|
70
|
+
|
|
71
|
+
- **Clipboard Interactions (Copy/Paste):** Because `odoo-wt` uses mouse-capture to enable clickable buttons and scrollbars in the terminal, your terminal emulator's native click-to-highlight features are disabled by default.
|
|
72
|
+
- **To copy text manually:** Hold the `Shift` key while clicking and dragging over the text to bypass the app and use your terminal's native selection, then use `Ctrl+Shift+C`.
|
|
73
|
+
- **To paste text:** Sometimes native terminal paste (`Ctrl+Shift+V` or middle-click) events are intercepted unreliably by the underlying framework. If pasting into an input field fails, you may need to type the branch name manually.
|
|
74
|
+
|
|
75
|
+
## 📝 Development
|
|
76
|
+
|
|
77
|
+
The source code is located at `/home/odoo/repos/Scripts/odoo-wt/`.
|
|
78
|
+
|
|
79
|
+
To apply changes made to the source:
|
|
80
|
+
```bash
|
|
81
|
+
uv tool install . --force
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## 📜 License
|
|
85
|
+
MIT
|
odoo_wt-1.0.0/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Odoo Worktree Assistant (`odoo-wt`)
|
|
2
|
+
|
|
3
|
+
A professional, modern TUI-driven Git worktree manager explicitly designed for Odoo developers. Built with Python, [Textual](https://textual.textualize.io/), and [uv](https://github.com/astral-sh/uv).
|
|
4
|
+
|
|
5
|
+
  
|
|
6
|
+
|
|
7
|
+
## 🚀 Overview
|
|
8
|
+
|
|
9
|
+
`odoo-wt` streamlines the process of spinning up new Odoo development environments. It handles the complex "handshake" between Git worktrees and Python virtual environments so you can focus on code.
|
|
10
|
+
|
|
11
|
+
### Key Features
|
|
12
|
+
|
|
13
|
+
- **Modern TUI:** A sleek, reactive "All-in-One" form built with Textual.
|
|
14
|
+
- **Smart Branching:** Follows the Odoo standard: `[VERSION]-[description]-[SUFFIX]`.
|
|
15
|
+
- **Dual Repo Support:** Simultaneously creates worktrees for both `odoo` (Community) and `enterprise`.
|
|
16
|
+
- **Automated UV Environments:**
|
|
17
|
+
- Centralizes environments in `~/.envs/[VERSION]`.
|
|
18
|
+
- Automatically runs `uv venv` and `uv pip install` if the environment is missing.
|
|
19
|
+
- Instantly symlinks `.venv` into your new worktree folder for automatic VS Code detection.
|
|
20
|
+
- **Proactive Status Checks:** Checks `odoo-dev` and local remotes to detect if branches already exist before creation.
|
|
21
|
+
|
|
22
|
+
## 🛠️ Installation
|
|
23
|
+
|
|
24
|
+
The recommended way to install `odoo-wt` is using `uv tool`:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# From the project directory
|
|
28
|
+
uv tool install . --force
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This creates an isolated environment for the tool and places an `odoo-wt` executable in your `~/.local/bin/`.
|
|
32
|
+
|
|
33
|
+
## ⚙️ Configuration
|
|
34
|
+
|
|
35
|
+
The tool saves its configuration to `~/.config/odoo-wt.json`. You can edit paths directly in the **Settings** tab within the application.
|
|
36
|
+
|
|
37
|
+
- **`wt_root`**: Where your worktree folders live (e.g., `~/repos/Odoo/wt`).
|
|
38
|
+
- **`env_root`**: Where your centralized global Python environments are stored (e.g., `~/.envs`).
|
|
39
|
+
- **`suffix`**: Your default developer quadrigram (e.g., `pian`).
|
|
40
|
+
|
|
41
|
+
## 📖 Usage
|
|
42
|
+
|
|
43
|
+
### Interactive Mode (The Guide)
|
|
44
|
+
Simply run the command with no arguments:
|
|
45
|
+
```bash
|
|
46
|
+
odoo-wt
|
|
47
|
+
```
|
|
48
|
+
- Use **`Tab`** to switch between Version, Description, and Suffix.
|
|
49
|
+
- Type **`custom...`** in any dropdown to reveal a custom input field.
|
|
50
|
+
- Press **`Ctrl+S`** to instantly submit and deploy.
|
|
51
|
+
- Press **`Esc`** to cancel and exit.
|
|
52
|
+
|
|
53
|
+
### Fast Mode
|
|
54
|
+
If you already have a full branch name ready:
|
|
55
|
+
```bash
|
|
56
|
+
odoo-wt 17.0-fix-account-bug-pian
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## ⚠️ Known Limitations
|
|
60
|
+
|
|
61
|
+
- **Clipboard Interactions (Copy/Paste):** Because `odoo-wt` uses mouse-capture to enable clickable buttons and scrollbars in the terminal, your terminal emulator's native click-to-highlight features are disabled by default.
|
|
62
|
+
- **To copy text manually:** Hold the `Shift` key while clicking and dragging over the text to bypass the app and use your terminal's native selection, then use `Ctrl+Shift+C`.
|
|
63
|
+
- **To paste text:** Sometimes native terminal paste (`Ctrl+Shift+V` or middle-click) events are intercepted unreliably by the underlying framework. If pasting into an input field fails, you may need to type the branch name manually.
|
|
64
|
+
|
|
65
|
+
## 📝 Development
|
|
66
|
+
|
|
67
|
+
The source code is located at `/home/odoo/repos/Scripts/odoo-wt/`.
|
|
68
|
+
|
|
69
|
+
To apply changes made to the source:
|
|
70
|
+
```bash
|
|
71
|
+
uv tool install . --force
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## 📜 License
|
|
75
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "odoo-wt"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Modern TUI-driven Odoo Worktree Manager"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = ["textual>=0.52.1", "rich>=13.7.1", "pyspellchecker>=0.8.1"]
|
|
8
|
+
|
|
9
|
+
[project.scripts]
|
|
10
|
+
odoo-wt = "odoo_wt.cli_main:main"
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["hatchling"]
|
|
14
|
+
build-backend = "hatchling.build"
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["src/odoo_wt"]
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=9.0.2",
|
|
22
|
+
"pytest-asyncio>=1.3.0",
|
|
23
|
+
"rich>=14.3.3",
|
|
24
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import datetime
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# --- CONFIGURATION ---
|
|
7
|
+
CONFIG_FILE = Path.home() / ".config" / "odoo-wt.json"
|
|
8
|
+
LOG_FILE = Path.home() / ".config" / "odoo-wt-logs.jsonl"
|
|
9
|
+
|
|
10
|
+
def append_log(action: str, details: dict = None):
|
|
11
|
+
if details is None: details = {}
|
|
12
|
+
entry = {"timestamp": datetime.datetime.now().isoformat(), "action": action, "details": details}
|
|
13
|
+
try:
|
|
14
|
+
with open(LOG_FILE, "a") as f:
|
|
15
|
+
f.write(json.dumps(entry) + "\n")
|
|
16
|
+
except: pass
|
|
17
|
+
|
|
18
|
+
def load_config():
|
|
19
|
+
default_config = {
|
|
20
|
+
"wt_root": str(Path.home() / "repos" / "Odoo" / "wt"),
|
|
21
|
+
"env_root": str(Path.home() / ".envs"),
|
|
22
|
+
"suffix": "pian",
|
|
23
|
+
"remote_name": "odoo-dev",
|
|
24
|
+
"community_dir": "odoo",
|
|
25
|
+
"enterprise_dir": "enterprise",
|
|
26
|
+
"default_tab": "tab-create",
|
|
27
|
+
"ignored_versions": [],
|
|
28
|
+
"ignored_suffixes": []
|
|
29
|
+
}
|
|
30
|
+
if CONFIG_FILE.exists():
|
|
31
|
+
try:
|
|
32
|
+
with open(CONFIG_FILE, "r") as f:
|
|
33
|
+
default_config.update(json.load(f))
|
|
34
|
+
except:
|
|
35
|
+
pass
|
|
36
|
+
return default_config
|
|
37
|
+
|
|
38
|
+
def save_config(config):
|
|
39
|
+
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
with open(CONFIG_FILE, "w") as f:
|
|
41
|
+
json.dump(config, f, indent=4)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
from .app_config import load_config, CONFIG_FILE
|
|
4
|
+
from .system_discovery import discover_system_data
|
|
5
|
+
from .setup_wizard import WizardApp
|
|
6
|
+
from .main_tui import OdooWtApp
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
if not CONFIG_FILE.exists():
|
|
10
|
+
app = WizardApp()
|
|
11
|
+
config = app.run()
|
|
12
|
+
if not config:
|
|
13
|
+
return
|
|
14
|
+
else:
|
|
15
|
+
config = load_config()
|
|
16
|
+
|
|
17
|
+
# CLI direct creation mode
|
|
18
|
+
if len(sys.argv) > 1:
|
|
19
|
+
branch = sys.argv[1]
|
|
20
|
+
v = branch.split("-")[0] if "-" in branch else "master"
|
|
21
|
+
# We manually build the data for DeployScreen
|
|
22
|
+
data = {"action": "create", "version": v, "desc": branch, "suffix": ""}
|
|
23
|
+
# In CLI mode, we still need to boot OdooWtApp to push the DeployScreen
|
|
24
|
+
# or we just boot a minimal app to host the DeployScreen.
|
|
25
|
+
# For now, let's just launch the full app and it will handle it if we modify init.
|
|
26
|
+
# But easier: just boot the app normally.
|
|
27
|
+
v_list, s_list, worktrees = discover_system_data(config["wt_root"], config["suffix"])
|
|
28
|
+
app = OdooWtApp(config, v_list, s_list, worktrees)
|
|
29
|
+
# We don't have an easy way to auto-start deployment from CLI yet,
|
|
30
|
+
# let's just open the app for now.
|
|
31
|
+
app.run()
|
|
32
|
+
else:
|
|
33
|
+
v_list, s_list, worktrees = discover_system_data(config["wt_root"], config["suffix"])
|
|
34
|
+
app = OdooWtApp(config, v_list, s_list, worktrees)
|
|
35
|
+
data = app.run()
|
|
36
|
+
|
|
37
|
+
# Handle deployment completion actions
|
|
38
|
+
if data and isinstance(data, dict):
|
|
39
|
+
action = data.get("action")
|
|
40
|
+
if action == "terminal":
|
|
41
|
+
target = data["path"]
|
|
42
|
+
os.chdir(target)
|
|
43
|
+
shell = os.environ.get("SHELL", "/bin/bash")
|
|
44
|
+
os.execv(shell, [shell])
|
|
45
|
+
elif action == "vscode":
|
|
46
|
+
target = data["path"]
|
|
47
|
+
os.system(f"code {target}")
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
main()
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from textual import on, work
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.screen import ModalScreen, Screen
|
|
8
|
+
from textual.widgets import Label, Button, ProgressBar, RichLog
|
|
9
|
+
from textual.containers import Vertical, Horizontal, VerticalScroll
|
|
10
|
+
|
|
11
|
+
from .app_config import append_log
|
|
12
|
+
from .system_discovery import get_remote, check_local
|
|
13
|
+
|
|
14
|
+
async def run_cmd_stream(cmd, cwd, log_widget, prefix=""):
|
|
15
|
+
process = await asyncio.create_subprocess_exec(
|
|
16
|
+
*cmd,
|
|
17
|
+
cwd=cwd,
|
|
18
|
+
stdout=asyncio.subprocess.PIPE,
|
|
19
|
+
stderr=asyncio.subprocess.STDOUT
|
|
20
|
+
)
|
|
21
|
+
while True:
|
|
22
|
+
line = await process.stdout.readline()
|
|
23
|
+
if not line:
|
|
24
|
+
break
|
|
25
|
+
text = line.decode('utf-8', errors='replace').rstrip()
|
|
26
|
+
log_widget.write(f"{prefix}{text}")
|
|
27
|
+
await process.wait()
|
|
28
|
+
return process.returncode == 0
|
|
29
|
+
|
|
30
|
+
class DeleteConfirmScreen(ModalScreen[bool]):
|
|
31
|
+
def __init__(self, wt_name: str):
|
|
32
|
+
super().__init__()
|
|
33
|
+
self.wt_name = wt_name
|
|
34
|
+
self.step = 1
|
|
35
|
+
|
|
36
|
+
def on_key(self, event) -> None:
|
|
37
|
+
if event.key == "left":
|
|
38
|
+
self.focus_previous()
|
|
39
|
+
elif event.key == "right":
|
|
40
|
+
self.focus_next()
|
|
41
|
+
|
|
42
|
+
def compose(self):
|
|
43
|
+
with Vertical(id="delete-dialog"):
|
|
44
|
+
yield Label(f"Delete worktree '{self.wt_name}'? (1/3)", id="del-msg")
|
|
45
|
+
with Horizontal(classes="del-btn-row", id="btn-container"):
|
|
46
|
+
yield Button("Yes, delete", variant="error", id="btn-yes")
|
|
47
|
+
yield Button("Cancel", variant="primary", id="btn-cancel")
|
|
48
|
+
|
|
49
|
+
@on(Button.Pressed, "#btn-yes")
|
|
50
|
+
async def on_yes(self):
|
|
51
|
+
append_log("Delete Confirm Step", {"step": self.step, "worktree": self.wt_name})
|
|
52
|
+
self.step += 1
|
|
53
|
+
if self.step > 3:
|
|
54
|
+
self.dismiss(True)
|
|
55
|
+
return
|
|
56
|
+
msg = self.query_one("#del-msg", Label)
|
|
57
|
+
container = self.query_one("#btn-container", Horizontal)
|
|
58
|
+
await container.query_children().remove()
|
|
59
|
+
if self.step == 2:
|
|
60
|
+
msg.update(f"Are you SURE? (2/3)")
|
|
61
|
+
await container.mount(Button("Cancel", variant="primary", id="btn-cancel"))
|
|
62
|
+
await container.mount(Button("Yes, delete", variant="error", id="btn-yes"))
|
|
63
|
+
elif self.step == 3:
|
|
64
|
+
msg.update(f"Final warning: NUKE '{self.wt_name}'? (3/3)")
|
|
65
|
+
await container.mount(Button("Yes, delete", variant="error", id="btn-yes"))
|
|
66
|
+
await container.mount(Button("Cancel", variant="primary", id="btn-cancel"))
|
|
67
|
+
self.query_one("#btn-cancel").focus()
|
|
68
|
+
|
|
69
|
+
@on(Button.Pressed, "#btn-cancel")
|
|
70
|
+
def on_cancel(self):
|
|
71
|
+
append_log("Delete Cancelled", {"worktree": self.wt_name})
|
|
72
|
+
self.dismiss(False)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
from textual.binding import Binding
|
|
77
|
+
class DeployScreen(Screen):
|
|
78
|
+
BINDINGS = [
|
|
79
|
+
Binding("t", "terminal", "Terminal", show=False),
|
|
80
|
+
Binding("v", "vscode", "VS Code", show=False),
|
|
81
|
+
Binding("b", "back", "Back", show=False),
|
|
82
|
+
Binding("x", "exit", "Exit", show=False),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
def action_terminal(self):
|
|
86
|
+
if not self.query_one("#success-footer").has_class("hidden"):
|
|
87
|
+
self.on_terminal()
|
|
88
|
+
|
|
89
|
+
def action_vscode(self):
|
|
90
|
+
if not self.query_one("#success-footer").has_class("hidden"):
|
|
91
|
+
self.on_vscode()
|
|
92
|
+
|
|
93
|
+
def action_back(self):
|
|
94
|
+
if not self.query_one("#success-footer").has_class("hidden"):
|
|
95
|
+
self.on_back()
|
|
96
|
+
|
|
97
|
+
def action_exit(self):
|
|
98
|
+
if not self.query_one("#success-footer").has_class("hidden"):
|
|
99
|
+
self.on_exit()
|
|
100
|
+
|
|
101
|
+
def __init__(self, data, config):
|
|
102
|
+
super().__init__()
|
|
103
|
+
self.data = data
|
|
104
|
+
self.config = config
|
|
105
|
+
|
|
106
|
+
def compose(self):
|
|
107
|
+
with VerticalScroll(id="deploy-logs-scroll"):
|
|
108
|
+
with Vertical(classes="log-box"):
|
|
109
|
+
with Horizontal(classes="log-header"):
|
|
110
|
+
yield Label("Community", classes="log-title")
|
|
111
|
+
yield ProgressBar(id="prog-odoo", show_eta=False)
|
|
112
|
+
yield RichLog(id="log-odoo", markup=False, highlight=False)
|
|
113
|
+
|
|
114
|
+
with Vertical(classes="log-box"):
|
|
115
|
+
with Horizontal(classes="log-header"):
|
|
116
|
+
yield Label("Enterprise", classes="log-title")
|
|
117
|
+
yield ProgressBar(id="prog-ent", show_eta=False)
|
|
118
|
+
yield RichLog(id="log-ent", markup=False, highlight=False)
|
|
119
|
+
|
|
120
|
+
with Vertical(classes="log-uv-box", id="uv-box"):
|
|
121
|
+
with Horizontal(classes="log-header"):
|
|
122
|
+
yield Label("UV Env", classes="log-title")
|
|
123
|
+
yield ProgressBar(id="prog-uv", show_eta=False)
|
|
124
|
+
yield RichLog(id="log-uv", markup=False, highlight=False)
|
|
125
|
+
|
|
126
|
+
with Vertical(id="success-footer", classes="hidden"):
|
|
127
|
+
yield Label("", id="success-message", classes="success-msg")
|
|
128
|
+
with Horizontal(classes="success-btn-row"):
|
|
129
|
+
yield Button("(T)erminal", variant="success", id="btn-terminal")
|
|
130
|
+
yield Button("(V)S Code", variant="primary", id="btn-vscode")
|
|
131
|
+
yield Button("(B)ack to Tool", variant="warning", id="btn-back")
|
|
132
|
+
yield Button("E(x)it", variant="error", id="btn-exit")
|
|
133
|
+
|
|
134
|
+
def on_mount(self):
|
|
135
|
+
self.run_deployment()
|
|
136
|
+
|
|
137
|
+
@work(exclusive=True)
|
|
138
|
+
async def run_deployment(self):
|
|
139
|
+
wt_root = Path(self.config["wt_root"])
|
|
140
|
+
dev_remote = self.config.get("remote_name", "odoo-dev")
|
|
141
|
+
comm_dir = self.config.get("community_dir", "odoo")
|
|
142
|
+
ent_dir = self.config.get("enterprise_dir", "enterprise")
|
|
143
|
+
|
|
144
|
+
clean_desc = self.data["desc"].strip().replace(" ", "_")
|
|
145
|
+
parts = [p for p in [self.data["version"], clean_desc, self.data["suffix"]] if p]
|
|
146
|
+
branch_name = "-".join(parts)
|
|
147
|
+
append_log("Deployment Started", {"branch": branch_name, "version": self.data["version"]})
|
|
148
|
+
target_dir = wt_root / branch_name
|
|
149
|
+
base_odoo = wt_root / "master" / comm_dir
|
|
150
|
+
base_ent = wt_root / "master" / ent_dir
|
|
151
|
+
base_v = self.data["version"] or "master"
|
|
152
|
+
|
|
153
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
|
|
155
|
+
async def deploy_repo(repo, dest, log_id, prog_id):
|
|
156
|
+
log = self.query_one(f"#{log_id}", RichLog)
|
|
157
|
+
prog = self.query_one(f"#{prog_id}", ProgressBar)
|
|
158
|
+
prog.update(total=3)
|
|
159
|
+
|
|
160
|
+
remote = await asyncio.to_thread(get_remote, repo)
|
|
161
|
+
log.write(f"Detected base remote: {remote}")
|
|
162
|
+
prog.advance(1)
|
|
163
|
+
|
|
164
|
+
log.write(f"Fetching '{branch_name}' from '{dev_remote}'...")
|
|
165
|
+
success = await run_cmd_stream(["git", "fetch", dev_remote, f"{branch_name}:{branch_name}", "--force"], repo, log)
|
|
166
|
+
prog.advance(1)
|
|
167
|
+
|
|
168
|
+
if success:
|
|
169
|
+
log.write("Fetch successful. Creating worktree...")
|
|
170
|
+
append_log("Branch Strategy Selected", {"repo": dest, "strategy": "remote_pull", "remote": dev_remote, "branch": branch_name})
|
|
171
|
+
await run_cmd_stream(["git", "worktree", "add", str(target_dir / dest), branch_name], repo, log)
|
|
172
|
+
else:
|
|
173
|
+
log.write(f"Branch not found on '{dev_remote}'. Fetching '{base_v}' from '{remote}'...")
|
|
174
|
+
await run_cmd_stream(["git", "fetch", remote, base_v], repo, log)
|
|
175
|
+
|
|
176
|
+
is_local = await asyncio.to_thread(check_local, repo, branch_name)
|
|
177
|
+
if is_local:
|
|
178
|
+
log.write("Branch exists locally. Creating worktree...")
|
|
179
|
+
append_log("Branch Strategy Selected", {"repo": dest, "strategy": "local_checkout", "branch": branch_name})
|
|
180
|
+
await run_cmd_stream(["git", "worktree", "add", str(target_dir / dest), branch_name], repo, log)
|
|
181
|
+
else:
|
|
182
|
+
log.write(f"Creating new branch from {remote}/{base_v}...")
|
|
183
|
+
append_log("Branch Strategy Selected", {"repo": dest, "strategy": "new_branch", "base": f"{remote}/{base_v}", "branch": branch_name})
|
|
184
|
+
await run_cmd_stream(["git", "worktree", "add", "-b", branch_name, str(target_dir / dest), f"{remote}/{base_v}"], repo, log)
|
|
185
|
+
await run_cmd_stream(["git", "branch", "--set-upstream-to", f"{remote}/{base_v}", branch_name], repo, log)
|
|
186
|
+
|
|
187
|
+
prog.advance(1)
|
|
188
|
+
log.write("✅ Done.")
|
|
189
|
+
|
|
190
|
+
async def setup_uv():
|
|
191
|
+
log_uv = self.query_one("#log-uv", RichLog)
|
|
192
|
+
prog_uv = self.query_one("#prog-uv", ProgressBar)
|
|
193
|
+
prog_uv.update(total=4)
|
|
194
|
+
|
|
195
|
+
env_root = Path(self.config["env_root"])
|
|
196
|
+
env_root.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
target_env = env_root / base_v
|
|
198
|
+
|
|
199
|
+
if not target_env.exists():
|
|
200
|
+
log_uv.write(f"Initializing UV environment for {base_v}...")
|
|
201
|
+
await run_cmd_stream(["uv", "venv", str(target_env), "--python", "3.12"], env_root, log_uv)
|
|
202
|
+
prog_uv.advance(1)
|
|
203
|
+
|
|
204
|
+
# Use base folder requirements to avoid waiting for worktree creation
|
|
205
|
+
base_req = wt_root / "master" / comm_dir / "requirements.txt"
|
|
206
|
+
if base_req.exists():
|
|
207
|
+
log_uv.write(f"Installing requirements from base '{comm_dir}'...")
|
|
208
|
+
await run_cmd_stream([
|
|
209
|
+
"uv", "pip", "install", "-r", str(base_req),
|
|
210
|
+
"--python", str(target_env / "bin" / "python")
|
|
211
|
+
], env_root, log_uv)
|
|
212
|
+
prog_uv.advance(1)
|
|
213
|
+
else:
|
|
214
|
+
log_uv.write(f"UV environment '{base_v}' already exists.")
|
|
215
|
+
prog_uv.advance(2)
|
|
216
|
+
|
|
217
|
+
# Wait for target_dir to definitely exist (though we create it at start)
|
|
218
|
+
# but more importantly, we need to wait for odoo/enterprise worktrees
|
|
219
|
+
# to finish before we can reliably symlink .venv into the target_dir
|
|
220
|
+
# actually target_dir is just a folder, we can symlink now!
|
|
221
|
+
venv_symlink = target_dir / ".venv"
|
|
222
|
+
if not venv_symlink.exists():
|
|
223
|
+
try:
|
|
224
|
+
os.symlink(target_env, venv_symlink)
|
|
225
|
+
log_uv.write("Created .venv symlink.")
|
|
226
|
+
except Exception as e:
|
|
227
|
+
log_uv.write(f"Failed to create symlink: {e}")
|
|
228
|
+
prog_uv.advance(1)
|
|
229
|
+
log_uv.write("✅ Done.")
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
await asyncio.gather(
|
|
233
|
+
deploy_repo(base_odoo, comm_dir, "log-odoo", "prog-odoo"),
|
|
234
|
+
deploy_repo(base_ent, ent_dir, "log-ent", "prog-ent"),
|
|
235
|
+
setup_uv()
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
append_log("Deployment Success", {"branch": branch_name, "path": str(target_dir)})
|
|
239
|
+
self.target_dir = target_dir
|
|
240
|
+
self.show_success_footer()
|
|
241
|
+
|
|
242
|
+
def show_success_footer(self):
|
|
243
|
+
msg = self.query_one("#success-message", Label)
|
|
244
|
+
msg.update(f"SUCCESS! Worktree ready at: {self.target_dir}\nWhat would you like to do next?")
|
|
245
|
+
self.query_one("#success-footer").remove_class("hidden")
|
|
246
|
+
self.query_one("#btn-terminal").focus()
|
|
247
|
+
|
|
248
|
+
@on(Button.Pressed, "#btn-terminal")
|
|
249
|
+
def on_terminal(self):
|
|
250
|
+
append_log("Deployment Complete", {"choice": "terminal"})
|
|
251
|
+
self.app.exit({"action": "terminal", "path": str(self.target_dir)})
|
|
252
|
+
|
|
253
|
+
@on(Button.Pressed, "#btn-vscode")
|
|
254
|
+
def on_vscode(self):
|
|
255
|
+
append_log("Deployment Complete", {"choice": "vscode"})
|
|
256
|
+
self.app.exit({"action": "vscode", "path": str(self.target_dir)})
|
|
257
|
+
|
|
258
|
+
@on(Button.Pressed, "#btn-back")
|
|
259
|
+
def on_back(self):
|
|
260
|
+
append_log("Deployment Complete", {"choice": "back"})
|
|
261
|
+
self.dismiss()
|
|
262
|
+
|
|
263
|
+
@on(Button.Pressed, "#btn-exit")
|
|
264
|
+
def on_exit(self):
|
|
265
|
+
append_log("Deployment Complete", {"choice": "exit"})
|
|
266
|
+
self.app.exit()
|
|
267
|
+
|
|
268
|
+
class LogDetailScreen(ModalScreen[None]):
|
|
269
|
+
def __init__(self, ts, action, details):
|
|
270
|
+
super().__init__()
|
|
271
|
+
self.ts = ts
|
|
272
|
+
self.action = action
|
|
273
|
+
self.details = details
|
|
274
|
+
|
|
275
|
+
def compose(self):
|
|
276
|
+
with Vertical(id="log-detail-dialog"):
|
|
277
|
+
yield Label(f"[{self.ts}] {self.action}", classes="log-detail-title")
|
|
278
|
+
try:
|
|
279
|
+
parsed = json.loads(self.details)
|
|
280
|
+
pretty_details = json.dumps(parsed, indent=4)
|
|
281
|
+
except:
|
|
282
|
+
pretty_details = str(self.details)
|
|
283
|
+
with VerticalScroll():
|
|
284
|
+
yield Label(pretty_details, classes="log-detail-text")
|
|
285
|
+
with Horizontal(classes="log-detail-btn-row"):
|
|
286
|
+
yield Button("Close", variant="primary", id="btn-close-log")
|
|
287
|
+
|
|
288
|
+
@on(Button.Pressed, "#btn-close-log")
|
|
289
|
+
def on_close(self):
|
|
290
|
+
self.dismiss()
|