diff --git a/src/baudolo/backup/compose.py b/src/baudolo/backup/compose.py
index 6fe9ce9..487209d 100644
--- a/src/baudolo/backup/compose.py
+++ b/src/baudolo/backup/compose.py
@@ -1,13 +1,104 @@
from __future__ import annotations
import os
+import shutil
import subprocess
+from pathlib import Path
+from typing import List, Optional
+
+
+def _detect_env_file(project_dir: Path) -> Optional[Path]:
+ """
+ Detect Compose env file in a directory.
+ Preference (same as Infinito.Nexus wrapper):
+ 1)
/.env (file)
+ 2) /.env/env (file) (legacy layout)
+ """
+ c1 = project_dir / ".env"
+ if c1.is_file():
+ return c1
+
+ c2 = project_dir / ".env" / "env"
+ if c2.is_file():
+ return c2
+
+ return None
+
+
+def _detect_compose_files(project_dir: Path) -> List[Path]:
+ """
+ Detect Compose file stack in a directory (same as Infinito.Nexus wrapper).
+ Always requires docker-compose.yml.
+ Optionals:
+ - docker-compose.override.yml
+ - docker-compose.ca.override.yml
+ """
+ base = project_dir / "docker-compose.yml"
+ if not base.is_file():
+ raise FileNotFoundError(f"Missing docker-compose.yml in: {project_dir}")
+
+ files = [base]
+
+ override = project_dir / "docker-compose.override.yml"
+ if override.is_file():
+ files.append(override)
+
+ ca_override = project_dir / "docker-compose.ca.override.yml"
+ if ca_override.is_file():
+ files.append(ca_override)
+
+ return files
+
+
+def _compose_wrapper_path() -> Optional[str]:
+ """
+ Prefer the Infinito.Nexus compose wrapper if present.
+ Equivalent to: `which compose`
+ """
+ return shutil.which("compose")
+
+
+def _build_compose_cmd(project_dir: str, passthrough: List[str]) -> List[str]:
+ """
+ Build the compose command for this project directory.
+
+ Behavior:
+ - If `compose` wrapper exists: use it with --chdir (so it resolves -f/--env-file itself)
+ - Else: use `docker compose` and replicate wrapper's file/env detection.
+ """
+ pdir = Path(project_dir).resolve()
+
+ wrapper = _compose_wrapper_path()
+ if wrapper:
+ # Wrapper defaults project name to basename of --chdir.
+ # "--" ensures wrapper stops parsing its own args.
+ return [wrapper, "--chdir", str(pdir), "--", *passthrough]
+
+ # Fallback: pure docker compose, but mirror wrapper behavior.
+ files = _detect_compose_files(pdir)
+ env_file = _detect_env_file(pdir)
+
+ cmd: List[str] = ["docker", "compose"]
+ for f in files:
+ cmd += ["-f", str(f)]
+ if env_file:
+ cmd += ["--env-file", str(env_file)]
+
+ cmd += passthrough
+ return cmd
def hard_restart_docker_services(dir_path: str) -> None:
- print(f"Hard restart docker-compose services in: {dir_path}", flush=True)
- subprocess.run(["docker-compose", "down"], cwd=dir_path, check=True)
- subprocess.run(["docker-compose", "up", "-d"], cwd=dir_path, check=True)
+ print(f"Hard restart compose services in: {dir_path}", flush=True)
+
+ down_cmd = _build_compose_cmd(dir_path, ["down"])
+ up_cmd = _build_compose_cmd(dir_path, ["up", "-d"])
+
+ print(">>> " + " ".join(down_cmd), flush=True)
+ subprocess.run(down_cmd, check=True)
+
+ print(">>> " + " ".join(up_cmd), flush=True)
+ subprocess.run(up_cmd, check=True)
def handle_docker_compose_services(
diff --git a/tests/unit/src/baudolo/backup/test_compose.py b/tests/unit/src/baudolo/backup/test_compose.py
new file mode 100644
index 0000000..31e1ba7
--- /dev/null
+++ b/tests/unit/src/baudolo/backup/test_compose.py
@@ -0,0 +1,215 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import List
+
+import pytest
+
+
+@pytest.fixture
+def compose_mod():
+ """
+ Import the module under test.
+ Adjust the import path if your package layout differs.
+ """
+ from baudolo.backup import compose as mod
+
+ return mod
+
+
+def _touch(p: Path) -> None:
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text("x", encoding="utf-8")
+
+
+def _setup_compose_dir(
+ tmp_path: Path,
+ name: str = "mailu",
+ *,
+ with_override: bool = False,
+ with_ca_override: bool = False,
+ env_layout: str | None = None, # None | ".env" | ".env/env"
+) -> Path:
+ d = tmp_path / name
+ d.mkdir(parents=True, exist_ok=True)
+
+ _touch(d / "docker-compose.yml")
+
+ if with_override:
+ _touch(d / "docker-compose.override.yml")
+
+ if with_ca_override:
+ _touch(d / "docker-compose.ca.override.yml")
+
+ if env_layout == ".env":
+ _touch(d / ".env")
+ elif env_layout == ".env/env":
+ _touch(d / ".env" / "env")
+
+ return d
+
+
+def test_detect_env_file_prefers_dotenv_over_legacy(tmp_path: Path, compose_mod):
+ d = _setup_compose_dir(tmp_path, env_layout=".env/env")
+ # Also create .env file -> should be preferred
+ _touch(d / ".env")
+
+ env_file = compose_mod._detect_env_file(d)
+ assert env_file == d / ".env"
+
+
+def test_detect_env_file_uses_legacy_if_no_dotenv(tmp_path: Path, compose_mod):
+ d = _setup_compose_dir(tmp_path, env_layout=".env/env")
+ env_file = compose_mod._detect_env_file(d)
+ assert env_file == d / ".env" / "env"
+
+
+def test_detect_compose_files_requires_base(tmp_path: Path, compose_mod):
+ d = tmp_path / "stack"
+ d.mkdir()
+
+ with pytest.raises(FileNotFoundError):
+ compose_mod._detect_compose_files(d)
+
+
+def test_detect_compose_files_includes_optional_overrides(tmp_path: Path, compose_mod):
+ d = _setup_compose_dir(
+ tmp_path,
+ with_override=True,
+ with_ca_override=True,
+ )
+
+ files = compose_mod._detect_compose_files(d)
+ assert files == [
+ d / "docker-compose.yml",
+ d / "docker-compose.override.yml",
+ d / "docker-compose.ca.override.yml",
+ ]
+
+
+def test_build_cmd_uses_wrapper_when_present(monkeypatch, tmp_path: Path, compose_mod):
+ d = _setup_compose_dir(
+ tmp_path, with_override=True, with_ca_override=True, env_layout=".env"
+ )
+
+ # Pretend "which compose" finds a wrapper.
+ monkeypatch.setattr(
+ compose_mod.shutil, "which", lambda name: "/usr/local/bin/compose"
+ )
+
+ cmd = compose_mod._build_compose_cmd(str(d), ["up", "-d"])
+
+ # Wrapper should be used, and wrapper itself resolves -f / --env-file.
+ assert cmd == [
+ "/usr/local/bin/compose",
+ "--chdir",
+ str(d.resolve()),
+ "--",
+ "up",
+ "-d",
+ ]
+
+
+def test_build_cmd_fallback_docker_compose_with_all_files_and_env(
+ monkeypatch, tmp_path: Path, compose_mod
+):
+ d = _setup_compose_dir(
+ tmp_path,
+ with_override=True,
+ with_ca_override=True,
+ env_layout=".env",
+ )
+
+ # No wrapper found.
+ monkeypatch.setattr(compose_mod.shutil, "which", lambda name: None)
+
+ cmd = compose_mod._build_compose_cmd(str(d), ["up", "-d", "--force-recreate"])
+
+ # Fallback should replicate the wrapper resolution logic.
+ expected: List[str] = [
+ "docker",
+ "compose",
+ "-f",
+ str((d / "docker-compose.yml").resolve()),
+ "-f",
+ str((d / "docker-compose.override.yml").resolve()),
+ "-f",
+ str((d / "docker-compose.ca.override.yml").resolve()),
+ "--env-file",
+ str((d / ".env").resolve()),
+ "up",
+ "-d",
+ "--force-recreate",
+ ]
+ assert cmd == expected
+
+
+def test_hard_restart_calls_run_twice_with_correct_cmds_wrapper(
+ monkeypatch, tmp_path: Path, compose_mod
+):
+ d = _setup_compose_dir(tmp_path, name="mailu", env_layout=".env")
+
+ # Wrapper exists
+ monkeypatch.setattr(
+ compose_mod.shutil, "which", lambda name: "/usr/local/bin/compose"
+ )
+
+ calls = []
+
+ def fake_run(cmd, check: bool):
+ calls.append((cmd, check))
+ return 0
+
+ monkeypatch.setattr(compose_mod.subprocess, "run", fake_run)
+
+ compose_mod.hard_restart_docker_services(str(d))
+
+ assert calls == [
+ (["/usr/local/bin/compose", "--chdir", str(d.resolve()), "--", "down"], True),
+ (
+ ["/usr/local/bin/compose", "--chdir", str(d.resolve()), "--", "up", "-d"],
+ True,
+ ),
+ ]
+
+
+def test_hard_restart_calls_run_twice_with_correct_cmds_fallback(
+ monkeypatch, tmp_path: Path, compose_mod
+):
+ d = _setup_compose_dir(
+ tmp_path,
+ name="mailu",
+ with_override=True,
+ with_ca_override=True,
+ env_layout=".env/env",
+ )
+
+ # No wrapper exists
+ monkeypatch.setattr(compose_mod.shutil, "which", lambda name: None)
+
+ calls = []
+
+ def fake_run(cmd, check: bool):
+ calls.append((cmd, check))
+ return 0
+
+ monkeypatch.setattr(compose_mod.subprocess, "run", fake_run)
+
+ compose_mod.hard_restart_docker_services(str(d))
+
+ # We assert only key structure + ordering to keep it robust.
+ down_cmd = calls[0][0]
+ up_cmd = calls[1][0]
+
+ assert calls[0][1] is True
+ assert calls[1][1] is True
+
+ # down: docker compose -f ... --env-file ... down
+ assert down_cmd[0:2] == ["docker", "compose"]
+ assert down_cmd[-1] == "down"
+ assert "--env-file" in down_cmd
+
+ # up: docker compose ... up -d
+ assert up_cmd[0:2] == ["docker", "compose"]
+ assert up_cmd[-2:] == ["up", "-d"] or up_cmd[-3:] == ["up", "-d"] # tolerance
+ assert "--env-file" in up_cmd