erplibre/test/test_git_tool.py
Mathieu Benoit eb5d6a72a0 [FIX] git tool : garder le dépôt racine quand le manifeste manque
Le traitement de `add_root` est en FIN de fonction, et deux retours
anticipés lui sautaient par-dessus : sans manifeste, la liste revenait vide
alors que la racine était demandée. Le script qui réécrit les remotes ne
trouvait donc rien à faire sur un checkout sans `.repo`, et l'annonçait
comme un succès.
L'ajout de la racine et le tri passent par un point de sortie unique, ce
qui supprime la copie qui gardait le défaut dans une variante sur deux. Une
origine absente vaut l'URL par défaut plutôt qu'une exception.
Vérifié dans un dépôt jetable, dans les deux sens et sans origine ; 4 tests
neufs, qui échouent si le retour anticipé revient.

--- EN ---

The `add_root` handling sits at the END of the function, and two early
returns jumped over it: with no manifest, the list came back empty although
the root had been asked for. The script that rewrites the remotes therefore
found nothing to do on a checkout without `.repo`, and reported it as a
success.
Adding the root and sorting now go through a single exit point, which drops
the copy that kept the fault in one variant out of two. A missing origin
falls back to the default URL rather than raising.
Checked in a throwaway repository, both ways and with no origin; 4 new
tests, which fail if the early return comes back.

Assisted-by: Claude Opus 5
2026-09-04 03:48:32 +00:00

404 lines
14 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import io
import os
import subprocess
import tempfile
import unittest
from collections import OrderedDict
from contextlib import redirect_stdout
from unittest.mock import MagicMock, mock_open, patch
from script.git.git_tool import (
DEFAULT_PROJECT_NAME,
DEFAULT_REMOTE_URL,
DEFAULT_WEBSITE,
EL_GITHUB_TOKEN,
SOURCE_REPO_ADDONS_FILE,
GitTool,
RepoAttrs,
)
class TestRepoAttrs(unittest.TestCase):
def test_basic_attributes(self):
s = RepoAttrs(a=1, b="hello")
self.assertEqual(s.a, 1)
self.assertEqual(s.b, "hello")
def test_empty_struct(self):
s = RepoAttrs()
self.assertEqual(s.__dict__, {})
def test_override_existing(self):
s = RepoAttrs(x=10)
self.assertEqual(s.x, 10)
class TestGetUrl(unittest.TestCase):
def test_https_to_git(self):
url, url_https, url_git = GitTool.get_url(
"https://github.com/OCA/server-tools.git"
)
self.assertEqual(url, "https://github.com/OCA/server-tools.git")
self.assertEqual(url_https, "https://github.com/OCA/server-tools.git")
self.assertEqual(url_git, "git@github.com:OCA/server-tools.git")
def test_git_to_https(self):
url, url_https, url_git = GitTool.get_url(
"git@github.com:OCA/server-tools.git"
)
self.assertEqual(url_https, "https://github.com/OCA/server-tools.git")
self.assertEqual(url_git, "git@github.com:OCA/server-tools.git")
def test_https_without_git_suffix(self):
url, url_https, url_git = GitTool.get_url(
"https://github.com/ERPLibre/ERPLibre"
)
self.assertEqual(url_https, "https://github.com/ERPLibre/ERPLibre")
self.assertEqual(url_git, "git@github.com:ERPLibre/ERPLibre")
class TestGetTransformedRepoInfo(unittest.TestCase):
def setUp(self):
self.gt = GitTool()
def test_https_url_as_submodule(self):
result = self.gt.get_transformed_repo_info_from_url(
"https://github.com/OCA/server-tools.git",
repo_path=".",
get_obj=False,
)
self.assertEqual(result["organization"], "OCA")
self.assertEqual(result["repo_name"], "server-tools")
self.assertEqual(result["project_name"], "server-tools.git")
self.assertEqual(result["path"], "addons/OCA_server-tools")
self.assertTrue(result["is_submodule"])
def test_git_url_as_submodule(self):
result = self.gt.get_transformed_repo_info_from_url(
"git@github.com:ERPLibre/erplibre_addons.git",
repo_path=".",
get_obj=False,
)
self.assertEqual(result["organization"], "ERPLibre")
self.assertEqual(result["repo_name"], "erplibre_addons")
def test_not_submodule(self):
result = self.gt.get_transformed_repo_info_from_url(
"https://github.com/OCA/server-tools.git",
repo_path="/tmp/test",
get_obj=False,
is_submodule=False,
)
self.assertEqual(result["path"], "/tmp/test")
self.assertFalse(result["is_submodule"])
def test_get_obj_true_returns_struct(self):
result = self.gt.get_transformed_repo_info_from_url(
"https://github.com/OCA/web.git",
get_obj=True,
)
self.assertIsInstance(result, RepoAttrs)
self.assertEqual(result.organization, "OCA")
def test_organization_force(self):
result = self.gt.get_transformed_repo_info_from_url(
"https://github.com/OCA/server-tools.git",
get_obj=False,
organization_force="MyOrg",
)
self.assertEqual(result["organization"], "MyOrg")
self.assertEqual(result["original_organization"], "OCA")
self.assertIn("MyOrg", result["url_https"])
def test_custom_sub_path(self):
result = self.gt.get_transformed_repo_info_from_url(
"https://github.com/OCA/server-tools.git",
get_obj=False,
sub_path="custom",
)
self.assertEqual(result["path"], "custom/OCA_server-tools")
def test_empty_sub_path(self):
result = self.gt.get_transformed_repo_info_from_url(
"https://github.com/OCA/server-tools.git",
get_obj=False,
sub_path="",
)
self.assertEqual(result["path"], "server-tools")
def test_dot_sub_path(self):
result = self.gt.get_transformed_repo_info_from_url(
"https://github.com/OCA/server-tools.git",
get_obj=False,
sub_path=".",
)
self.assertEqual(result["path"], "server-tools")
def test_revision_and_clone_depth(self):
result = self.gt.get_transformed_repo_info_from_url(
"https://github.com/OCA/web.git",
get_obj=False,
revision="16.0",
clone_depth="1",
)
self.assertEqual(result["revision"], "16.0")
self.assertEqual(result["clone_depth"], "1")
def test_url_without_git_suffix(self):
result = self.gt.get_transformed_repo_info_from_url(
"https://github.com/OCA/server-tools",
get_obj=False,
)
self.assertEqual(result["repo_name"], "server-tools")
class TestDefaultProperties(unittest.TestCase):
def test_default_project_name(self):
gt = GitTool()
self.assertEqual(gt.default_project_name, DEFAULT_PROJECT_NAME)
def test_default_website(self):
gt = GitTool()
self.assertEqual(gt.default_website, DEFAULT_WEBSITE)
def test_default_remote_url(self):
gt = GitTool()
self.assertEqual(gt.default_remote_url, DEFAULT_REMOTE_URL)
@patch(
"builtins.open",
mock_open(read_data="18.0"),
)
def test_odoo_version(self):
gt = GitTool()
self.assertEqual(gt.odoo_version, "18.0")
@patch(
"builtins.open",
mock_open(read_data="16.0"),
)
def test_odoo_version_long(self):
gt = GitTool()
self.assertEqual(gt.odoo_version_long, "odoo16.0")
class TestStrInsert(unittest.TestCase):
def test_insert_middle(self):
result = GitTool.str_insert("abcdef", "XY", 3)
self.assertEqual(result, "abcXYdef")
def test_insert_beginning(self):
result = GitTool.str_insert("hello", "X", 0)
self.assertEqual(result, "Xhello")
def test_insert_end(self):
result = GitTool.str_insert("hello", "X", 5)
self.assertEqual(result, "helloX")
class TestGetProjectConfig(unittest.TestCase):
def test_reads_github_token(self):
content = (
"#!/bin/bash\n"
'EL_GITHUB_TOKEN="my_token_123"\n'
'OTHER_VAR="value"\n'
)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".sh", delete=False, dir="/tmp"
) as f:
f.write(content)
f.flush()
tmpdir = os.path.dirname(f.name)
tmpname = os.path.basename(f.name)
try:
# We need env_var.sh in a directory
env_var_path = os.path.join(tmpdir, "env_var.sh")
os.rename(f.name, env_var_path)
result = GitTool.get_project_config(repo_path=tmpdir)
self.assertEqual(result[EL_GITHUB_TOKEN], "my_token_123")
finally:
if os.path.exists(env_var_path):
os.unlink(env_var_path)
class TestGetRepoInfoSubmodule(unittest.TestCase):
def test_parses_gitmodules(self):
gitmodules_content = (
'[submodule "addons/OCA_server-tools"]\n'
"\turl = https://github.com/OCA/server-tools.git\n"
"\tpath = addons/OCA_server-tools\n"
"\n"
'[submodule "addons/OCA_web"]\n'
"\turl = https://github.com/OCA/web.git\n"
"\tpath = addons/OCA_web\n"
)
gt = GitTool()
with tempfile.TemporaryDirectory() as tmpdir:
gitmodules_path = os.path.join(tmpdir, ".gitmodules")
with open(gitmodules_path, "w") as f:
f.write(gitmodules_content)
result = gt.get_repo_info_submodule(
repo_path=tmpdir, add_root=False
)
self.assertEqual(len(result), 2)
names = [r["name"] for r in result]
self.assertIn("addons/OCA_server-tools", names)
self.assertIn("addons/OCA_web", names)
def test_single_submodule(self):
gitmodules_content = (
'[submodule "addons/test"]\n'
"\turl = https://github.com/Test/repo.git\n"
"\tpath = addons/test\n"
)
gt = GitTool()
with tempfile.TemporaryDirectory() as tmpdir:
with open(os.path.join(tmpdir, ".gitmodules"), "w") as f:
f.write(gitmodules_content)
result = gt.get_repo_info_submodule(repo_path=tmpdir)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["name"], "addons/test")
self.assertIn("https://", result[0]["url_https"])
self.assertIn("git@", result[0]["url_git"])
class TestTheRootRepoSurvivesAMissingManifest(unittest.TestCase):
"""Le dépôt racine ne dépend ni du manifeste ni des sous-modules.
Une liste vide là où la racine était demandée fait conclure à
l'appelant qu'il n'a rien à faire : sur un checkout sans `.repo`, le
remote du dépôt principal n'était jamais réécrit, sans message.
"""
def _depot(self, chemin, origine="https://github.com/ERPLibre/ERPLibre"):
"""Un vrai dépôt git, sans manifeste. Rien n'est bouchonné : la
lecture de l'origine passe par git, et c'est elle qu'on vérifie."""
subprocess.run(["git", "init", "-q", chemin], check=True)
if origine:
subprocess.run(
["git", "-C", chemin, "remote", "add", "origin", origine],
check=True,
)
return chemin
def test_without_a_manifest_the_root_is_still_returned(self):
gt = GitTool()
with tempfile.TemporaryDirectory() as tmpdir:
self._depot(tmpdir)
result = gt.get_repo_info(tmpdir, add_root=True)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["name"], "")
self.assertEqual(
result[0]["url_git"], "git@github.com:ERPLibre/ERPLibre"
)
def test_without_the_root_asked_nothing_is_returned(self):
"""Deux appelants internes lisent le manifeste SANS la racine : le
correctif ne doit rien rendre à qui n'en veut pas."""
gt = GitTool()
with tempfile.TemporaryDirectory() as tmpdir:
self._depot(tmpdir)
self.assertEqual(gt.get_repo_info(tmpdir), [])
def test_a_root_without_origin_falls_back_instead_of_raising(self):
"""La liste sert à RÉÉCRIRE les remotes : un dépôt sans origine est
précisément un de ceux qu'on vient corriger."""
gt = GitTool()
with tempfile.TemporaryDirectory() as tmpdir:
self._depot(tmpdir, origine="")
with redirect_stdout(io.StringIO()):
result = gt.get_repo_info(tmpdir, add_root=True)
self.assertEqual(len(result), 1)
self.assertTrue(result[0]["url_git"].startswith("git@"))
def test_the_root_comes_first_alongside_the_manifest_projects(self):
"""La racine porte un nom vide : le tri la met en tête, et le
script qui réécrit les remotes commence donc par elle."""
xml_content = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
"<manifest>\n"
' <remote name="OCA" fetch="https://github.com/OCA/"/>\n'
' <default remote="OCA" revision="16.0"/>\n'
' <project name="web.git" path="addons/OCA_web"'
' remote="OCA" groups="odoo16.0"/>\n'
"</manifest>\n"
)
gt = GitTool()
with tempfile.TemporaryDirectory() as tmpdir:
self._depot(tmpdir)
manifeste = os.path.join(tmpdir, "manifest_test.xml")
with open(manifeste, "w") as fh:
fh.write(xml_content)
with patch.object(
GitTool, "get_manifest_file", return_value=manifeste
):
result = gt.get_repo_info(tmpdir, add_root=True)
self.assertEqual([r["name"] for r in result], ["", "addons/OCA_web"])
class TestGetManifestXmlInfo(unittest.TestCase):
def test_parses_manifest(self):
xml_content = """<?xml version="1.0" encoding="UTF-8"?>
<manifest>
<remote name="OCA" fetch="https://github.com/OCA/"/>
<default remote="OCA" revision="16.0"/>
<project name="server-tools.git" path="addons/OCA_server-tools"
remote="OCA" groups="odoo16.0"/>
</manifest>
"""
gt = GitTool()
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", delete=False
) as f:
f.write(xml_content)
f.flush()
try:
remotes, projects, default_remote = gt.get_manifest_xml_info(
filename=f.name
)
self.assertIn("OCA", remotes)
self.assertIn("server-tools.git", projects)
self.assertEqual(default_remote["@remote"], "OCA")
finally:
os.unlink(f.name)
def test_empty_manifest(self):
xml_content = """<?xml version="1.0" encoding="UTF-8"?>
<manifest/>
"""
gt = GitTool()
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", delete=False
) as f:
f.write(xml_content)
f.flush()
try:
remotes, projects, default_remote = gt.get_manifest_xml_info(
filename=f.name
)
self.assertEqual(remotes, {})
self.assertEqual(projects, {})
self.assertIsNone(default_remote)
finally:
os.unlink(f.name)
class TestConstants(unittest.TestCase):
def test_file_source_repo_addons(self):
self.assertEqual(SOURCE_REPO_ADDONS_FILE, "source_repo_addons.csv")
def test_default_project_name(self):
self.assertEqual(DEFAULT_PROJECT_NAME, "ERPLibre")
def test_default_website(self):
self.assertEqual(DEFAULT_WEBSITE, "erplibre.ca")
if __name__ == "__main__":
unittest.main()