Coverage for app/venv/lib/python3.14/site-packages/weblate/vcs/base.py: 72%

320 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 07:15 +0000

1# Copyright © Michal Čihař <michal@weblate.org> 

2# 

3# SPDX-License-Identifier: GPL-3.0-or-later 

4 

5"""Version control system abstraction for Weblate needs.""" 

6 

7from __future__ import annotations 

8 

9import hashlib 

10import logging 

11import os 

12import os.path 

13import subprocess 

14from typing import TYPE_CHECKING, ClassVar, Self, TypedDict 

15 

16from dateutil import parser 

17from django.core.cache import cache 

18from django.utils.functional import cached_property 

19from django.utils.translation import gettext_lazy 

20from packaging.version import Version 

21 

22from weblate.trans.util import get_clean_env, path_separator 

23from weblate.utils.data import data_path 

24from weblate.utils.errors import add_breadcrumb 

25from weblate.utils.lock import WeblateLock 

26from weblate.vcs.ssh import SSH_WRAPPER 

27 

28if TYPE_CHECKING: 28 ↛ 29line 28 didn't jump to line 29 because the condition on line 28 was never true

29 from collections.abc import Generator, Iterator 

30 from datetime import datetime 

31 

32 import requests 

33 from django_stubs_ext import StrOrPromise 

34 

35 from weblate.trans.models import Component 

36 

37LOGGER = logging.getLogger("weblate.vcs") 

38 

39 

40class SubprocessArgs(TypedDict, total=False): 

41 stdin: int 

42 input: str 

43 

44 

45class RepositoryError(Exception): 

46 """Error while working with a repository.""" 

47 

48 def __init__(self, retcode: int, message: str) -> None: 

49 super().__init__(message) 

50 self.retcode = retcode 

51 

52 def get_message(self): 

53 if self.retcode != 0: 53 ↛ 55line 53 didn't jump to line 55 because the condition on line 53 was always true

54 return f"{self.args[0]} ({self.retcode})" 

55 return self.args[0] 

56 

57 def __str__(self) -> str: 

58 return self.get_message() 

59 

60 

61class Repository: 

62 """Basic repository object.""" 

63 

64 _cmd: ClassVar[str] = "false" 

65 _cmd_last_revision: ClassVar[list[str]] 

66 _cmd_last_remote_revision: ClassVar[list[str]] 

67 _cmd_status: ClassVar[list[str]] = ["status"] 

68 _cmd_list_changed_files: ClassVar[list[str]] 

69 

70 name: ClassVar[StrOrPromise] = "" 

71 identifier: ClassVar[str] = "" 

72 req_version: ClassVar[str | None] = None 

73 default_branch: ClassVar[str] = "" 

74 needs_push_url: ClassVar[bool] = True 

75 supports_push: ClassVar[bool] = True 

76 pushes_to_different_location: ClassVar[bool] = False 

77 push_label: ClassVar[StrOrPromise] = gettext_lazy( 

78 "This will push changes to the upstream repository." 

79 ) 

80 ref_to_remote: ClassVar[str] 

81 ref_from_remote: ClassVar[str] 

82 _version: ClassVar[str | None] = None 

83 

84 @classmethod 

85 def get_identifier(cls) -> str: 

86 return cls.identifier or cls.name.lower() 

87 

88 def __init__( 

89 self, 

90 path: str, 

91 *, 

92 branch: str | None = None, 

93 component: Component | None = None, 

94 local: bool = False, 

95 skip_init: bool = False, 

96 ) -> None: 

97 self.path: str = path 

98 if branch is None: 

99 self.branch = self.default_branch 

100 else: 

101 self.branch = branch 

102 self.component = component 

103 self.last_output = "" 

104 base_path = self.path.rstrip("/").rstrip("\\") 

105 self.lock = WeblateLock( 

106 lock_path=os.path.dirname(base_path), 

107 scope="repo", 

108 key=component.pk if component else os.path.basename(base_path), 

109 slug=os.path.basename(base_path), 

110 file_template="{slug}.lock", 

111 timeout=120, 

112 origin=component.full_slug if component else base_path, 

113 ) 

114 self._config_updated = False 

115 self.local = local 

116 if not local: 

117 # Create ssh wrapper for possible use 

118 SSH_WRAPPER.create() 

119 if not skip_init and not self.is_valid(): 

120 with self.lock: 

121 self.create_blank_repository(self.path) 

122 

123 @classmethod 

124 def get_remote_branch(cls, repo: str) -> str: # noqa: ARG003 

125 return cls.default_branch 

126 

127 @classmethod 

128 def add_breadcrumb(cls, message: str, **data) -> None: 

129 add_breadcrumb(category="vcs", message=message, **data) 

130 

131 @classmethod 

132 def add_response_breadcrumb(cls, response: requests.Response) -> None: 

133 cls.add_breadcrumb( 

134 "http.response", 

135 status_code=response.status_code, 

136 text=response.text, 

137 headers=response.headers, 

138 ) 

139 

140 @classmethod 

141 def log(cls, message: str, level: int = logging.DEBUG) -> None: 

142 return LOGGER.log(level, "%s: %s", cls._cmd, message) 

143 

144 def ensure_config_updated(self) -> None: 

145 """Ensure the configuration is periodically checked.""" 

146 if self._config_updated: 

147 return 

148 if self.component is None: 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true

149 msg = "Component not set!" 

150 raise TypeError(msg) 

151 cache_key = f"sp-config-check-{self.component.pk}" 

152 if cache.get(cache_key) is None: 

153 self.check_config() 

154 cache.set(cache_key, True, 86400) 

155 self._config_updated = True 

156 

157 def check_config(self) -> None: 

158 """Check VCS configuration.""" 

159 raise NotImplementedError 

160 

161 def is_valid(self) -> bool: 

162 """Check whether this is a valid repository.""" 

163 raise NotImplementedError 

164 

165 @classmethod 

166 def create_blank_repository(cls, path: str) -> None: 

167 """Initialize the repository.""" 

168 raise NotImplementedError 

169 

170 def resolve_symlinks(self, path: str) -> str: 

171 """Resolve any symlinks in the path.""" 

172 # Resolve symlinks first 

173 real_path = path_separator(os.path.realpath(os.path.join(self.path, path))) 

174 repository_path = path_separator(os.path.realpath(self.path)) 

175 

176 if not real_path.startswith(repository_path): 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true

177 msg = "Too many symlinks or link outside tree" 

178 raise ValueError(msg) 

179 

180 return real_path[len(repository_path) :].lstrip("/") 

181 

182 @staticmethod 

183 def _getenv(environment: dict[str, str] | None = None) -> dict[str, str]: 

184 """Generate environment for process execution.""" 

185 base: dict[str, str] = { 

186 # Avoid prompts from Git 

187 "GIT_TERMINAL_PROMPT": "0", 

188 # Avoid Git traversing outside the data dir 

189 "GIT_CEILING_DIRECTORIES": data_path("vcs").as_posix(), 

190 # Use ssh wrapper 

191 "GIT_SSH": SSH_WRAPPER.filename.as_posix(), 

192 "SVN_SSH": SSH_WRAPPER.filename.as_posix(), 

193 } 

194 if environment: 194 ↛ 195line 194 didn't jump to line 195 because the condition on line 194 was never true

195 base.update(environment) 

196 return get_clean_env(base, extra_path=SSH_WRAPPER.path.as_posix()) 

197 

198 @classmethod 

199 def _popen( 

200 cls, 

201 args: list[str], 

202 *, 

203 cwd: str | None = None, 

204 merge_err: bool = True, 

205 fullcmd: bool = False, 

206 raw: bool = False, 

207 local: bool = False, 

208 stdin: str | None = None, 

209 environment: dict[str, str] | None = None, 

210 ): 

211 """Execute the command using popen.""" 

212 if args is None: 212 ↛ 213line 212 didn't jump to line 213 because the condition on line 212 was never true

213 raise RepositoryError(0, "Not supported functionality") 

214 if not fullcmd: 

215 args = [cls._cmd, *list(args)] 

216 text_cmd = " ".join(args) 

217 try: 

218 # These are mutually exclusive, gevent actually checks 

219 # for their presence, not a avalue 

220 kwargs: SubprocessArgs = {} 

221 if stdin is None: 

222 kwargs["stdin"] = subprocess.PIPE 

223 else: 

224 kwargs["input"] = stdin 

225 

226 process = subprocess.run( 

227 args=args, 

228 cwd=cwd, 

229 env=environment or {} if local else cls._getenv(environment), 

230 stdout=subprocess.PIPE, 

231 stderr=subprocess.STDOUT if merge_err else subprocess.PIPE, 

232 text=not raw, 

233 check=False, 

234 # Excessively long timeout to catch misbehaving processes 

235 timeout=3600, 

236 **kwargs, 

237 ) 

238 except subprocess.TimeoutExpired as error: 

239 stdout = ( 

240 error.stdout.decode() 

241 if isinstance(error.stdout, bytes) 

242 else error.stdout 

243 ) 

244 stderr = ( 

245 error.stderr.decode() 

246 if isinstance(error.stderr, bytes) 

247 else error.stderr 

248 ) 

249 raise RepositoryError( 

250 0, 

251 f"Subprocess didn't complete before {error.timeout} seconds\n{stdout}{stderr or ''}", 

252 ) from error 

253 cls.add_breadcrumb( 

254 text_cmd, 

255 retcode=process.returncode, 

256 output=process.stdout, 

257 stderr=process.stderr, 

258 cwd=cwd, 

259 ) 

260 if process.returncode: 

261 raise RepositoryError( 

262 process.returncode, process.stdout + (process.stderr or "") 

263 ) 

264 return process.stdout 

265 

266 def execute( 

267 self, 

268 args: list[str], 

269 *, 

270 needs_lock: bool = True, 

271 fullcmd: bool = False, 

272 merge_err: bool = True, 

273 stdin: str | None = None, 

274 environment: dict[str, str] | None = None, 

275 ): 

276 """Execute command and caches its output.""" 

277 if needs_lock: 

278 if not self.lock.is_locked: 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true

279 msg = "Repository operation without lock held!" 

280 raise RuntimeError(msg) 

281 if self.component: 

282 self.ensure_config_updated() 

283 is_status = args[0] == self._cmd_status[0] 

284 try: 

285 self.last_output = self._popen( 

286 args, 

287 cwd=self.path, 

288 fullcmd=fullcmd, 

289 local=self.local, 

290 merge_err=merge_err, 

291 stdin=stdin, 

292 environment=environment, 

293 ) 

294 except RepositoryError as error: 

295 if not is_status and not self.local: 

296 self.log_status(error) 

297 raise 

298 return self.last_output 

299 

300 def log_status(self, error: str | RepositoryError) -> None: 

301 try: 

302 self.log(f"failure {error}") 

303 self.log(self.status()) 

304 except RepositoryError: 

305 pass 

306 

307 def clean_revision_cache(self) -> None: 

308 if "last_revision" in self.__dict__: 

309 del self.__dict__["last_revision"] 

310 if "last_remote_revision" in self.__dict__: 310 ↛ 311line 310 didn't jump to line 311 because the condition on line 310 was never true

311 del self.__dict__["last_remote_revision"] 

312 

313 @cached_property 

314 def last_revision(self): 

315 """Return last local revision.""" 

316 return self.get_last_revision() 

317 

318 def get_last_revision(self): 

319 return self.execute(self._cmd_last_revision, needs_lock=False, merge_err=False) 

320 

321 @cached_property 

322 def last_remote_revision(self): 

323 """Return last remote revision.""" 

324 return self.execute( 

325 self._cmd_last_remote_revision, needs_lock=False, merge_err=False 

326 ) 

327 

328 @classmethod 

329 def _clone(cls, source: str, target: str, branch: str) -> None: 

330 """Clone repository.""" 

331 raise NotImplementedError 

332 

333 @classmethod 

334 def clone( 

335 cls, source: str, target: str, branch: str, component: Component | None = None 

336 ) -> Self: 

337 """Clone repository and return object for cloned repository.""" 

338 repo = cls(target, branch=branch, component=component, skip_init=True) 

339 with repo.lock: 

340 cls._clone(source, target, branch) 

341 return repo 

342 

343 def update_remote(self) -> None: 

344 """Update remote repository.""" 

345 raise NotImplementedError 

346 

347 def status(self) -> str: 

348 """Return status of the repository.""" 

349 return self.execute(self._cmd_status, needs_lock=False) 

350 

351 def push(self, branch: str) -> None: 

352 """Push given branch to remote repository.""" 

353 raise NotImplementedError 

354 

355 def unshallow(self) -> None: 

356 """Unshallow working copy.""" 

357 return 

358 

359 def reset(self) -> None: 

360 """Reset working copy to match remote branch.""" 

361 raise NotImplementedError 

362 

363 def merge( 

364 self, abort: bool = False, message: str | None = None, no_ff: bool = False 

365 ) -> None: 

366 """Merge remote branch or reverts the merge.""" 

367 raise NotImplementedError 

368 

369 def rebase(self, abort: bool = False) -> None: 

370 """Rebase working copy on top of remote branch.""" 

371 raise NotImplementedError 

372 

373 def needs_commit(self, filenames: list[str] | None = None) -> bool: 

374 """Check whether repository needs commit.""" 

375 raise NotImplementedError 

376 

377 def count_missing(self): 

378 """Count missing commits.""" 

379 return len( 

380 self.log_revisions(self.ref_to_remote.format(self.get_remote_branch_name())) 

381 ) 

382 

383 def count_outgoing(self, branch: str | None = None): 

384 """Count outgoing commits.""" 

385 return len( 

386 self.log_revisions( 

387 self.ref_from_remote.format(self.get_remote_branch_name(branch)) 

388 ) 

389 ) 

390 

391 def needs_merge(self): 

392 """ 

393 Check whether repository needs merge with upstream. 

394 

395 It is missing some revisions. 

396 """ 

397 return self.count_missing() > 0 

398 

399 def needs_push(self): 

400 """ 

401 Check whether repository needs push to upstream. 

402 

403 It has additional revisions. 

404 """ 

405 return self.count_outgoing() > 0 

406 

407 def _get_revision_info(self, revision: str) -> dict[str, str]: 

408 """Return dictionary with detailed revision information.""" 

409 raise NotImplementedError 

410 

411 def get_revision_info(self, revision: str) -> dict[str, str]: 

412 """Return dictionary with detailed revision information.""" 

413 key = f"rev-info-{self.get_identifier()}-{revision}" 

414 result = cache.get(key) 

415 if not result: 

416 result = self._get_revision_info(revision) 

417 # Keep the cache for one day 

418 cache.set(key, result, 86400) 

419 

420 # Parse timestamps into datetime objects 

421 for name, value in result.items(): 

422 if "date" in name: 

423 result[name] = parser.parse(value) 

424 

425 return result 

426 

427 @classmethod 

428 def is_configured(cls) -> bool: 

429 return True 

430 

431 @classmethod 

432 def validate_configuration(cls) -> list[str]: 

433 return [] 

434 

435 @classmethod 

436 def is_supported(cls): 

437 """Check whether this VCS backend is supported.""" 

438 try: 

439 version = cls.get_version() 

440 except Exception: 

441 return False 

442 return cls.req_version is None or Version(version) >= Version(cls.req_version) 

443 

444 @classmethod 

445 def get_version(cls): 

446 """Get cached backend version.""" 

447 if cls._version is None: 

448 try: 

449 cls._version = cls._get_version() 

450 except Exception as error: 

451 cls._version = error 

452 if isinstance(cls._version, Exception): 452 ↛ 453line 452 didn't jump to line 453 because the condition on line 452 was never true

453 raise cls._version 

454 return cls._version 

455 

456 @classmethod 

457 def _get_version(cls): 

458 """Return VCS program version.""" 

459 return cls._popen(["--version"], merge_err=False) 

460 

461 def set_committer(self, name: str, mail: str) -> None: 

462 """Configure committer name.""" 

463 raise NotImplementedError 

464 

465 def commit( 

466 self, 

467 message: str, 

468 author: str | None = None, 

469 timestamp: datetime | None = None, 

470 files: list[str] | None = None, 

471 ) -> bool: 

472 """Create new revision.""" 

473 raise NotImplementedError 

474 

475 def remove(self, files: list[str], message: str, author: str | None = None) -> None: 

476 """Remove files and creates new revision.""" 

477 raise NotImplementedError 

478 

479 @staticmethod 

480 def update_hash( 

481 objhash: hashlib._Hash, filename: str, extra: str | None = None 

482 ) -> None: 

483 if os.path.islink(filename): 483 ↛ 484line 483 didn't jump to line 484 because the condition on line 483 was never true

484 objtype = "symlink" 

485 data = os.readlink(filename).encode() 

486 else: 

487 objtype = "blob" 

488 with open(filename, "rb") as handle: 

489 data = handle.read() 

490 if extra: 490 ↛ 491line 490 didn't jump to line 491 because the condition on line 490 was never true

491 objhash.update(extra.encode()) 

492 objhash.update(f"{objtype} {len(data)}\0".encode("ascii")) 

493 objhash.update(data) 

494 

495 def get_object_hash(self, path: str) -> str: 

496 """ 

497 Return hash of object in the VCS. 

498 

499 For files in a way compatible with Git (equivalent to git ls-tree HEAD), for 

500 dirs it behaves differently as we do not need to track some attributes (for 

501 example permissions). 

502 """ 

503 real_path = os.path.join(self.path, self.resolve_symlinks(path)) 

504 objhash = hashlib.sha1(usedforsecurity=False) 

505 

506 if os.path.isdir(real_path): 506 ↛ 507line 506 didn't jump to line 507 because the condition on line 506 was never true

507 files = [] 

508 for root, _unused, filenames in os.walk(real_path): 

509 for filename in filenames: 

510 full_name = os.path.join(root, filename) 

511 files.append((full_name, os.path.relpath(full_name, self.path))) 

512 for filename, name in sorted(files): 

513 self.update_hash(objhash, filename, name) 

514 else: 

515 self.update_hash(objhash, real_path) 

516 

517 return objhash.hexdigest() 

518 

519 def configure_remote( 

520 self, pull_url: str, push_url: str, branch: str, fast: bool = True 

521 ) -> None: 

522 """Configure remote repository.""" 

523 raise NotImplementedError 

524 

525 def configure_branch(self, branch: str) -> None: 

526 """Configure repository branch.""" 

527 raise NotImplementedError 

528 

529 def describe(self) -> str: 

530 """Verbosely describes current revision.""" 

531 raise NotImplementedError 

532 

533 def get_file(self, path: str, revision: str) -> str: 

534 """Return content of file at given revision.""" 

535 raise NotImplementedError 

536 

537 @staticmethod 

538 def get_examples_paths() -> Generator[str]: 

539 """ 

540 List possible paths for shipped examples. 

541 

542 Used to locate merge drivers which are shipped there. 

543 """ 

544 yield os.path.join(os.path.dirname(os.path.dirname(__file__)), "examples") 

545 

546 @classmethod 

547 def find_merge_driver(cls, name: str) -> str | None: 

548 for path in cls.get_examples_paths(): 548 ↛ 552line 548 didn't jump to line 552 because the loop on line 548 didn't complete

549 result = os.path.join(path, name) 

550 if os.path.exists(result): 550 ↛ 548line 550 didn't jump to line 548 because the condition on line 550 was always true

551 return os.path.abspath(result) 

552 return None 

553 

554 @classmethod 

555 def get_merge_driver(cls, file_format: str) -> str | None: 

556 merge_driver = None 

557 if file_format == "po": 557 ↛ 559line 557 didn't jump to line 559 because the condition on line 557 was always true

558 merge_driver = cls.find_merge_driver("git-merge-gettext-po") 

559 if merge_driver is None or not os.path.exists(merge_driver): 559 ↛ 560line 559 didn't jump to line 560 because the condition on line 559 was never true

560 return None 

561 return merge_driver 

562 

563 def cleanup(self) -> None: 

564 """Remove not tracked files from the repository.""" 

565 raise NotImplementedError 

566 

567 def log_revisions(self, refspec: str) -> list[str]: 

568 """ 

569 Log revisions for given refspec. 

570 

571 This is not universal as refspec is different per vcs. 

572 """ 

573 raise NotImplementedError 

574 

575 def list_changed_files(self, refspec: str) -> list: 

576 """ 

577 List changed files for given refspec. 

578 

579 This is not universal as refspec is different per vcs. 

580 """ 

581 lines = self.execute( 

582 [*self._cmd_list_changed_files, refspec], needs_lock=False, merge_err=False 

583 ).splitlines() 

584 return list(self.parse_changed_files(lines)) 

585 

586 def parse_changed_files(self, lines: list[str]) -> Iterator[str]: 

587 """Parse output with changed files.""" 

588 raise NotImplementedError 

589 

590 def get_changed_files(self, compare_to: str | None = None): 

591 """Get files missing upstream or changes between revisions.""" 

592 if compare_to is None: 

593 compare_to = self.get_remote_branch_name() 

594 

595 return self.list_changed_files(self.ref_to_remote.format(compare_to)) 

596 

597 def get_remote_branch_name(self, branch: str | None = None) -> str: 

598 return f"origin/{self.branch if branch is None else branch}" 

599 

600 def list_remote_branches(self): 

601 return [] 

602 

603 def compact(self) -> None: 

604 return 

605 

606 def show(self, revision: str) -> str: 

607 raise NotImplementedError