github2gerrit 0.1.14__py3-none-any.whl → 0.1.16__py3-none-any.whl

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.
github2gerrit/cli.py CHANGED
@@ -36,6 +36,7 @@ from .github_api import get_pr_title_body
36
36
  from .github_api import get_pull
37
37
  from .github_api import get_repo_from_env
38
38
  from .github_api import iter_open_pulls
39
+ from .gitutils import CommandError
39
40
  from .gitutils import run_cmd
40
41
  from .models import GitHubContext
41
42
  from .models import Inputs
@@ -51,6 +52,7 @@ from .ssh_common import build_non_interactive_ssh_env
51
52
  from .utils import append_github_output
52
53
  from .utils import env_bool
53
54
  from .utils import env_str
55
+ from .utils import is_verbose_mode
54
56
  from .utils import log_exception_conditionally
55
57
  from .utils import parse_bool_env
56
58
 
@@ -245,7 +247,7 @@ if "--help" in sys.argv or _is_github_actions_context():
245
247
 
246
248
  app: typer.Typer = typer.Typer(
247
249
  add_completion=False,
248
- no_args_is_help=False,
250
+ no_args_is_help=True,
249
251
  cls=cast(Any, _SingleUsageGroup),
250
252
  rich_markup_mode="rich",
251
253
  help="Tool to convert GitHub pull requests into Gerrit changes",
@@ -989,9 +991,60 @@ def _process_single(
989
991
  else:
990
992
  progress_tracker.change_updated()
991
993
  except Exception as exc:
994
+ # Enhanced error handling for CommandError to show git command
995
+ # details
996
+ if isinstance(exc, CommandError):
997
+ # Always show the basic error message
998
+ cmd_str = " ".join(exc.cmd) if exc.cmd else "unknown command"
999
+ basic_error = f"Git command failed: {cmd_str}"
1000
+ if exc.returncode is not None:
1001
+ basic_error += f" (exit code: {exc.returncode})"
1002
+
1003
+ # In verbose mode, show detailed stdout/stderr
1004
+ if is_verbose_mode():
1005
+ detailed_msg = basic_error
1006
+ if exc.stdout and exc.stdout.strip():
1007
+ detailed_msg += f"\nGit stdout: {exc.stdout.strip()}"
1008
+ if exc.stderr and exc.stderr.strip():
1009
+ detailed_msg += f"\nGit stderr: {exc.stderr.strip()}"
1010
+
1011
+ # Show debugging suggestion for merge failures
1012
+ if "merge --squash" in " ".join(exc.cmd or []):
1013
+ detailed_msg += (
1014
+ "\n💡 For local debugging: python "
1015
+ "test_merge_failure.py --verbose"
1016
+ )
1017
+
1018
+ safe_console_print(f"❌ {detailed_msg}", style="red")
1019
+ if progress_tracker:
1020
+ progress_tracker.add_error(basic_error)
1021
+ if exc.stderr and exc.stderr.strip():
1022
+ progress_tracker.add_error(
1023
+ f"Details: {exc.stderr.strip()}"
1024
+ )
1025
+ else:
1026
+ # In non-verbose mode, show basic error with hint to enable
1027
+ # verbose
1028
+ hint_msg = (
1029
+ basic_error
1030
+ + "\n💡 Run with VERBOSE=true for detailed git output"
1031
+ )
1032
+ safe_console_print(f"❌ {hint_msg}", style="red")
1033
+ if progress_tracker:
1034
+ progress_tracker.add_error(basic_error)
1035
+ else:
1036
+ # For other exceptions, use original handling
1037
+ error_msg = str(exc)
1038
+ if progress_tracker:
1039
+ progress_tracker.add_error(f"Execution failed: {error_msg}")
1040
+ else:
1041
+ safe_console_print(f"❌ Error: {error_msg}", style="red")
1042
+
992
1043
  log.debug("Execution failed; continuing to write outputs: %s", exc)
993
- if progress_tracker:
994
- progress_tracker.add_error("Execution failed")
1044
+
1045
+ # In verbose mode, also log the full exception with traceback
1046
+ if is_verbose_mode():
1047
+ log.exception("Full exception details:")
995
1048
 
996
1049
  result = SubmissionResult(
997
1050
  change_urls=[], change_numbers=[], commit_shas=[]
github2gerrit/core.py CHANGED
@@ -69,7 +69,7 @@ from .reconcile_matcher import LocalCommit
69
69
  from .reconcile_matcher import create_local_commit
70
70
  from .ssh_common import merge_known_hosts_content
71
71
  from .utils import env_bool
72
- from .utils import log_exception_conditionally
72
+ from .utils import is_verbose_mode
73
73
 
74
74
 
75
75
  try:
@@ -708,6 +708,8 @@ class Orchestrator:
708
708
  self._ssh_known_hosts_path: Path | None = None
709
709
  self._ssh_agent_manager: SSHAgentManager | None = None
710
710
  self._use_ssh_agent: bool = False
711
+ # Secure temporary directory for SSH files (outside workspace)
712
+ self._ssh_temp_dir: Path | None = None
711
713
  # Store inputs for access by helper methods
712
714
  self._inputs: Inputs | None = None
713
715
 
@@ -865,6 +867,9 @@ class Orchestrator:
865
867
 
866
868
  self._comment_on_pull_request(gh, gerrit, result)
867
869
 
870
+ # Validate that no unexpected files were committed
871
+ self._validate_committed_files(gh, result)
872
+
868
873
  self._close_pull_request_if_required(gh)
869
874
 
870
875
  log.debug("Pipeline complete: %s", result)
@@ -1111,10 +1116,15 @@ class Orchestrator:
1111
1116
 
1112
1117
  Does not modify user files.
1113
1118
  """
1119
+ log.debug(
1120
+ "Starting SSH setup for Gerrit %s:%d", gerrit.host, gerrit.port
1121
+ )
1114
1122
  if not inputs.gerrit_ssh_privkey_g2g:
1115
1123
  log.debug("SSH private key not provided, skipping SSH setup")
1116
1124
  return
1117
1125
 
1126
+ log.debug("SSH private key provided, proceeding with SSH configuration")
1127
+
1118
1128
  # Check for ssh-keyscan availability early if auto-discovery needed
1119
1129
  if (
1120
1130
  auto_discover_gerrit_host_keys is not None
@@ -1259,16 +1269,25 @@ class Orchestrator:
1259
1269
 
1260
1270
  # Check if SSH agent authentication is preferred
1261
1271
  use_ssh_agent = env_bool("G2G_USE_SSH_AGENT", default=True)
1272
+ log.debug(
1273
+ "SSH agent preference: use_ssh_agent=%s, "
1274
+ "setup_ssh_agent_auth available=%s",
1275
+ use_ssh_agent,
1276
+ setup_ssh_agent_auth is not None,
1277
+ )
1262
1278
 
1263
1279
  if use_ssh_agent and setup_ssh_agent_auth is not None:
1264
1280
  # Try SSH agent first as it's more secure and avoids file
1265
1281
  # permission issues
1282
+ log.debug("Attempting SSH agent-based authentication")
1266
1283
  if self._try_ssh_agent_setup(inputs, effective_known_hosts):
1284
+ log.debug("SSH agent setup successful")
1267
1285
  return
1268
1286
 
1269
1287
  # Fall back to file-based SSH if agent setup fails
1270
1288
  log.info("Falling back to file-based SSH authentication")
1271
1289
 
1290
+ log.debug("Using file-based SSH authentication")
1272
1291
  self._setup_file_based_ssh(inputs, effective_known_hosts)
1273
1292
 
1274
1293
  def _try_ssh_agent_setup(
@@ -1288,8 +1307,36 @@ class Orchestrator:
1288
1307
 
1289
1308
  try:
1290
1309
  log.debug("Setting up SSH agent-based authentication (more secure)")
1310
+ log.debug("Workspace: %s", self.workspace)
1311
+ log.debug(
1312
+ "Private key length: %d characters",
1313
+ len(inputs.gerrit_ssh_privkey_g2g),
1314
+ )
1315
+ log.debug(
1316
+ "Known hosts length: %d characters", len(effective_known_hosts)
1317
+ )
1318
+
1319
+ # Create secure separate temp directory for SSH agent if needed
1320
+ import secrets
1321
+ import tempfile
1322
+
1323
+ if not self._ssh_temp_dir:
1324
+ # Use secure random suffix to prevent predictable paths
1325
+ secure_suffix = secrets.token_hex(8)
1326
+ self._ssh_temp_dir = Path(
1327
+ tempfile.mkdtemp(
1328
+ prefix=f"g2g_ssh_{secure_suffix}_",
1329
+ dir=tempfile.gettempdir(),
1330
+ )
1331
+ )
1332
+ # Ensure directory has restrictive permissions
1333
+ self._ssh_temp_dir.chmod(0o700)
1334
+ log.debug(
1335
+ "Created secure SSH temp directory: %s", self._ssh_temp_dir
1336
+ )
1337
+
1291
1338
  self._ssh_agent_manager = setup_ssh_agent_auth(
1292
- workspace=self.workspace,
1339
+ workspace=self._ssh_temp_dir,
1293
1340
  private_key_content=inputs.gerrit_ssh_privkey_g2g,
1294
1341
  known_hosts_content=effective_known_hosts,
1295
1342
  )
@@ -1297,6 +1344,7 @@ class Orchestrator:
1297
1344
  log.debug("SSH agent authentication configured successfully")
1298
1345
 
1299
1346
  except Exception as exc:
1347
+ log.debug("SSH agent setup failed: %s", exc)
1300
1348
  log.warning(
1301
1349
  "SSH agent setup failed, falling back to file-based SSH: %s",
1302
1350
  exc,
@@ -1317,28 +1365,60 @@ class Orchestrator:
1317
1365
  inputs: Validated input configuration
1318
1366
  effective_known_hosts: Known hosts content
1319
1367
  """
1320
- log.info("Setting up file-based SSH configuration for Gerrit")
1321
- log.debug("Using workspace-specific SSH files to avoid user changes")
1368
+ log.debug("Using file-based SSH configuration for Gerrit")
1369
+ log.debug(
1370
+ "Using secure temporary SSH files outside workspace to prevent "
1371
+ "artifacts"
1372
+ )
1373
+ log.debug(
1374
+ "Private key length: %d characters",
1375
+ len(inputs.gerrit_ssh_privkey_g2g),
1376
+ )
1377
+ log.debug(
1378
+ "Known hosts length: %d characters", len(effective_known_hosts)
1379
+ )
1380
+
1381
+ # Create secure tool-specific SSH directory outside workspace to prevent
1382
+ # SSH artifacts from being accidentally committed
1383
+ import secrets
1384
+ import tempfile
1322
1385
 
1323
- # Create tool-specific SSH directory in workspace to avoid touching
1324
- # user files
1325
- tool_ssh_dir = self.workspace / ".ssh-g2g"
1386
+ if not self._ssh_temp_dir:
1387
+ # Use secure random suffix to prevent predictable paths
1388
+ secure_suffix = secrets.token_hex(8)
1389
+ self._ssh_temp_dir = Path(
1390
+ tempfile.mkdtemp(
1391
+ prefix=f"g2g_ssh_{secure_suffix}_",
1392
+ dir=tempfile.gettempdir(),
1393
+ )
1394
+ )
1395
+ # Ensure directory has restrictive permissions
1396
+ self._ssh_temp_dir.chmod(0o700)
1397
+ log.debug(
1398
+ "Created secure SSH temp directory: %s", self._ssh_temp_dir
1399
+ )
1400
+ tool_ssh_dir = self._ssh_temp_dir
1326
1401
  tool_ssh_dir.mkdir(mode=0o700, exist_ok=True)
1327
1402
 
1328
1403
  # Write SSH private key to tool-specific location with secure
1329
1404
  # permissions
1330
1405
  key_path = tool_ssh_dir / "gerrit_key"
1406
+ log.debug("SSH key file path: %s", key_path)
1331
1407
 
1332
1408
  # Use a more robust approach for creating the file with secure
1333
1409
  # permissions
1334
1410
  key_content = inputs.gerrit_ssh_privkey_g2g.strip() + "\n"
1335
1411
 
1336
1412
  # Multiple strategies to create secure key file
1413
+ log.debug("Attempting to create secure key file")
1337
1414
  success = self._create_secure_key_file(key_path, key_content)
1415
+ log.debug("Secure key file creation success: %s", success)
1338
1416
 
1339
1417
  if not success:
1340
1418
  # If all permission strategies fail, create in memory directory
1419
+ log.debug("Falling back to memory-based key file creation")
1341
1420
  success = self._create_key_in_memory_fs(key_path, key_content)
1421
+ log.debug("Memory-based key file creation success: %s", success)
1342
1422
 
1343
1423
  if not success:
1344
1424
  msg = (
@@ -1347,8 +1427,11 @@ class Orchestrator:
1347
1427
  "Consider using G2G_USE_SSH_AGENT=true (default) for SSH "
1348
1428
  "agent authentication."
1349
1429
  )
1430
+ log.error("SSH key file creation failed: %s", msg)
1350
1431
  raise RuntimeError(msg)
1351
1432
 
1433
+ log.debug("SSH key file created successfully: %s", key_path)
1434
+
1352
1435
  # Write known hosts to tool-specific location
1353
1436
  known_hosts_path = tool_ssh_dir / "known_hosts"
1354
1437
  with open(known_hosts_path, "w", encoding="utf-8") as f:
@@ -1360,6 +1443,7 @@ class Orchestrator:
1360
1443
  # Store paths for later use in git commands
1361
1444
  self._ssh_key_path = key_path
1362
1445
  self._ssh_known_hosts_path = known_hosts_path
1446
+ log.debug("File-based SSH setup completed successfully")
1363
1447
 
1364
1448
  def _create_secure_key_file(self, key_path: Path, key_content: str) -> bool:
1365
1449
  """Try multiple strategies to create a secure SSH key file.
@@ -1564,29 +1648,46 @@ class Orchestrator:
1564
1648
  """Centralized non-interactive SSH/Git environment."""
1565
1649
  from .ssh_common import build_non_interactive_ssh_env
1566
1650
 
1651
+ log.debug("Building SSH environment for git operations")
1567
1652
  env = build_non_interactive_ssh_env()
1568
1653
 
1569
1654
  # Set GIT_SSH_COMMAND based on available configuration
1570
1655
  cmd = self._build_git_ssh_command
1571
1656
  if cmd:
1657
+ log.debug("Using custom GIT_SSH_COMMAND: %s", cmd)
1572
1658
  env["GIT_SSH_COMMAND"] = cmd
1573
1659
  else:
1574
1660
  # Fallback to basic non-interactive SSH command
1575
1661
  from .ssh_common import build_git_ssh_command
1576
1662
 
1577
- env["GIT_SSH_COMMAND"] = build_git_ssh_command()
1663
+ fallback_cmd = build_git_ssh_command()
1664
+ log.debug("Using fallback GIT_SSH_COMMAND: %s", fallback_cmd)
1665
+ env["GIT_SSH_COMMAND"] = fallback_cmd
1578
1666
 
1579
1667
  # Override SSH agent settings if using SSH agent
1580
1668
  if self._use_ssh_agent and self._ssh_agent_manager:
1581
- env.update(self._ssh_agent_manager.get_ssh_env())
1669
+ log.debug("Applying SSH agent environment variables")
1670
+ agent_env = self._ssh_agent_manager.get_ssh_env()
1671
+ log.debug(
1672
+ "SSH agent environment: %s",
1673
+ {k: v for k, v in agent_env.items() if "SSH" in k},
1674
+ )
1675
+ env.update(agent_env)
1676
+ else:
1677
+ log.debug(
1678
+ "Not using SSH agent (use_ssh_agent=%s, manager=%s)",
1679
+ self._use_ssh_agent,
1680
+ self._ssh_agent_manager is not None,
1681
+ )
1582
1682
 
1683
+ log.debug("Final SSH environment contains %d variables", len(env))
1583
1684
  return env
1584
1685
 
1585
1686
  def _cleanup_ssh(self) -> None:
1586
1687
  """Clean up temporary SSH files created by this tool.
1587
1688
 
1588
- Removes the workspace-specific .ssh-g2g directory and all contents.
1589
- This ensures no temporary files are left behind.
1689
+ Securely removes the separate SSH temporary directory and all contents.
1690
+ This ensures no temporary files or credentials are left behind.
1590
1691
  """
1591
1692
  log.debug("Cleaning up temporary SSH configuration files")
1592
1693
 
@@ -1597,15 +1698,47 @@ class Orchestrator:
1597
1698
  self._ssh_agent_manager = None
1598
1699
  self._use_ssh_agent = False
1599
1700
 
1600
- # Remove temporary SSH directory and all contents
1601
- tool_ssh_dir = self.workspace / ".ssh-g2g"
1602
- if tool_ssh_dir.exists():
1701
+ # Securely remove separate SSH temporary directory and all contents
1702
+ if self._ssh_temp_dir and self._ssh_temp_dir.exists():
1703
+ import os
1603
1704
  import shutil
1604
1705
 
1605
- shutil.rmtree(tool_ssh_dir)
1706
+ # First, overwrite any key files to prevent recovery
1707
+ try:
1708
+ for root, _dirs, files in os.walk(self._ssh_temp_dir):
1709
+ for file in files:
1710
+ file_path = Path(root) / file
1711
+ if file_path.exists() and file_path.is_file():
1712
+ # Overwrite file with random data
1713
+ try:
1714
+ size = file_path.stat().st_size
1715
+ if size > 0:
1716
+ import secrets
1717
+
1718
+ with open(file_path, "wb") as f:
1719
+ f.write(secrets.token_bytes(size))
1720
+ # Sync to ensure write completes
1721
+ os.fsync(f.fileno())
1722
+ except Exception as overwrite_exc:
1723
+ log.debug(
1724
+ "Failed to overwrite %s: %s",
1725
+ file_path,
1726
+ overwrite_exc,
1727
+ )
1728
+ except Exception as walk_exc:
1729
+ log.debug(
1730
+ "Failed to walk SSH temp directory for secure "
1731
+ "cleanup: %s",
1732
+ walk_exc,
1733
+ )
1734
+
1735
+ # Remove the directory tree
1736
+ shutil.rmtree(self._ssh_temp_dir)
1606
1737
  log.debug(
1607
- "Cleaned up temporary SSH directory: %s", tool_ssh_dir
1738
+ "Securely cleaned up temporary SSH directory: %s",
1739
+ self._ssh_temp_dir,
1608
1740
  )
1741
+ self._ssh_temp_dir = None
1609
1742
  except Exception as exc:
1610
1743
  log.warning("Failed to clean up temporary SSH files: %s", exc)
1611
1744
 
@@ -1821,8 +1954,8 @@ class Orchestrator:
1821
1954
  cur_msg = _clean_ellipses_from_message(cur_msg)
1822
1955
  needed = [m for m in meta if m not in cur_msg]
1823
1956
  if needed:
1824
- new_msg = (
1825
- cur_msg.rstrip() + "\n" + "\n".join(needed) + "\n"
1957
+ new_msg = Orchestrator._append_missing_trailers(
1958
+ cur_msg, needed
1826
1959
  )
1827
1960
  git_commit_amend(
1828
1961
  message=new_msg,
@@ -1895,10 +2028,99 @@ class Orchestrator:
1895
2028
  # Create temp branch from base and merge-squash PR head
1896
2029
  tmp_branch = f"g2g_tmp_{gh.pr_number or 'pr'!s}_{os.getpid()}"
1897
2030
  os.environ["G2G_TMP_BRANCH"] = tmp_branch
2031
+
2032
+ log.debug(
2033
+ "Git merge preparation: base_sha=%s, head_sha=%s, tmp_branch=%s",
2034
+ base_sha,
2035
+ head_sha,
2036
+ tmp_branch,
2037
+ )
2038
+
2039
+ # Check if we have any commits to merge
2040
+ try:
2041
+ merge_base = run_cmd(
2042
+ ["git", "merge-base", base_sha, head_sha], cwd=self.workspace
2043
+ ).stdout.strip()
2044
+ log.debug("Merge base: %s", merge_base)
2045
+
2046
+ # Check if there are any commits between base and head
2047
+ commits_to_merge = run_cmd(
2048
+ ["git", "rev-list", f"{base_sha}..{head_sha}"],
2049
+ cwd=self.workspace,
2050
+ ).stdout.strip()
2051
+ if not commits_to_merge:
2052
+ log.warning(
2053
+ "No commits found between base (%s) and head (%s)",
2054
+ base_sha,
2055
+ head_sha,
2056
+ )
2057
+ else:
2058
+ commit_count = len(commits_to_merge.splitlines())
2059
+ log.debug("Found %d commits to merge", commit_count)
2060
+
2061
+ except Exception as debug_exc:
2062
+ log.warning("Failed to analyze merge situation: %s", debug_exc)
2063
+
1898
2064
  run_cmd(
1899
2065
  ["git", "checkout", "-b", tmp_branch, base_sha], cwd=self.workspace
1900
2066
  )
1901
- run_cmd(["git", "merge", "--squash", head_sha], cwd=self.workspace)
2067
+
2068
+ # Show git status before attempting merge
2069
+ try:
2070
+ status_output = run_cmd(
2071
+ ["git", "status", "--porcelain"], cwd=self.workspace
2072
+ ).stdout
2073
+ if status_output.strip():
2074
+ log.debug(
2075
+ "Git status before merge (modified files detected):\n%s",
2076
+ status_output,
2077
+ )
2078
+ else:
2079
+ log.debug("Git status before merge: working directory clean")
2080
+
2081
+ # Show current branch
2082
+ current_branch = run_cmd(
2083
+ ["git", "branch", "--show-current"], cwd=self.workspace
2084
+ ).stdout.strip()
2085
+ log.debug("Current branch before merge: %s", current_branch)
2086
+
2087
+ except Exception as status_exc:
2088
+ log.warning("Failed to get git status before merge: %s", status_exc)
2089
+
2090
+ log.debug("About to run: git merge --squash %s", head_sha)
2091
+ try:
2092
+ run_cmd(["git", "merge", "--squash", head_sha], cwd=self.workspace)
2093
+ except CommandError as merge_exc:
2094
+ # Enhanced error handling for git merge failures
2095
+ error_details = self._analyze_merge_failure(
2096
+ merge_exc, base_sha, head_sha
2097
+ )
2098
+
2099
+ # Try to provide recovery suggestions
2100
+ recovery_msg = self._suggest_merge_recovery(
2101
+ merge_exc, base_sha, head_sha
2102
+ )
2103
+
2104
+ # Log detailed error information
2105
+ log.exception("Git merge --squash failed: %s", error_details)
2106
+ if recovery_msg:
2107
+ log.exception("Suggested recovery: %s", recovery_msg)
2108
+
2109
+ # Enhanced debugging if verbose mode is enabled
2110
+ from .utils import is_verbose_mode
2111
+
2112
+ if is_verbose_mode():
2113
+ self._debug_merge_failure_context(base_sha, head_sha)
2114
+
2115
+ # Re-raise with enhanced context
2116
+ raise OrchestratorError(
2117
+ f"Failed to merge PR commits: {error_details}"
2118
+ + (
2119
+ f"\nSuggested recovery: {recovery_msg}"
2120
+ if recovery_msg
2121
+ else ""
2122
+ )
2123
+ ) from merge_exc
1902
2124
 
1903
2125
  def _collect_log_lines() -> list[str]:
1904
2126
  body = run_cmd(
@@ -2113,7 +2335,9 @@ class Orchestrator:
2113
2335
  if meta:
2114
2336
  needed = [m for m in meta if m not in commit_msg]
2115
2337
  if needed:
2116
- commit_msg = commit_msg.rstrip() + "\n" + "\n".join(needed)
2338
+ commit_msg = Orchestrator._append_missing_trailers(
2339
+ commit_msg, needed
2340
+ )
2117
2341
  except Exception as meta_exc:
2118
2342
  log.debug(
2119
2343
  "Skipping metadata trailer injection (squash path): %s",
@@ -2314,7 +2538,9 @@ class Orchestrator:
2314
2538
  ).stdout
2315
2539
  needed = [m for m in meta if m not in cur_msg]
2316
2540
  if needed:
2317
- new_msg = cur_msg.rstrip() + "\n" + "\n".join(needed) + "\n"
2541
+ new_msg = Orchestrator._append_missing_trailers(
2542
+ cur_msg, needed
2543
+ )
2318
2544
  git_commit_amend(
2319
2545
  cwd=self.workspace,
2320
2546
  no_edit=False,
@@ -2327,6 +2553,32 @@ class Orchestrator:
2327
2553
  "Skipping post-apply metadata trailer ensure: %s", meta_exc
2328
2554
  )
2329
2555
 
2556
+ @staticmethod
2557
+ def _append_missing_trailers(
2558
+ message: str, trailers: list[str], *, ensure_final_newline: bool = True
2559
+ ) -> str:
2560
+ """Append missing trailers to a commit message with proper formatting.
2561
+
2562
+ Args:
2563
+ message: The base commit message
2564
+ trailers: List of trailer lines to potentially append
2565
+ ensure_final_newline: Whether to ensure the message ends with a
2566
+ newline
2567
+
2568
+ Returns:
2569
+ The message with missing trailers appended, properly formatted
2570
+ """
2571
+ needed = [trailer for trailer in trailers if trailer not in message]
2572
+ if not needed:
2573
+ return message
2574
+
2575
+ result = message.rstrip() + "\n\n" + "\n".join(needed)
2576
+
2577
+ if ensure_final_newline:
2578
+ result = result.rstrip("\n") + "\n"
2579
+
2580
+ return result
2581
+
2330
2582
  def _push_to_gerrit(
2331
2583
  self,
2332
2584
  *,
@@ -2369,6 +2621,7 @@ class Orchestrator:
2369
2621
  "-t",
2370
2622
  topic,
2371
2623
  ]
2624
+ log.debug("Building git review command with topic: %s", topic)
2372
2625
  collected_change_ids: list[str] = []
2373
2626
  if prepared:
2374
2627
  collected_change_ids.extend(prepared.all_change_ids())
@@ -2396,7 +2649,15 @@ class Orchestrator:
2396
2649
  gerrit, repo, branch, reviewers, topic, env
2397
2650
  )
2398
2651
  return
2399
- log.debug("Executing git review command: %s", " ".join(args))
2652
+
2653
+ log.debug("Final git review command: %s", " ".join(args))
2654
+ log.debug(
2655
+ "Git review environment variables: %s",
2656
+ {k: v for k, v in env.items() if "SSH" in k or "GIT" in k},
2657
+ )
2658
+ log.debug("Working directory: %s", self.workspace)
2659
+
2660
+ # Execute the git review command
2400
2661
  run_cmd(args, cwd=self.workspace, env=env)
2401
2662
  log.debug("Successfully pushed changes to Gerrit")
2402
2663
  except CommandError as exc:
@@ -2498,9 +2759,33 @@ class Orchestrator:
2498
2759
 
2499
2760
  # Analyze the specific failure reason from git review output
2500
2761
  error_details = self._analyze_gerrit_push_failure(exc)
2501
- log_exception_conditionally(
2502
- log, "Gerrit push failed: %s", error_details
2503
- )
2762
+
2763
+ # Always log the error details, even if not in verbose mode
2764
+ log.exception("Gerrit push failed: %s", error_details)
2765
+
2766
+ # In debug mode, also show the raw command output
2767
+ if is_verbose_mode():
2768
+ log.debug("Git review command: %s", " ".join(exc.cmd or []))
2769
+ log.debug("Return code: %s", exc.returncode)
2770
+ if exc.stdout:
2771
+ log.debug("Command stdout:\n%s", exc.stdout)
2772
+ if exc.stderr:
2773
+ log.debug("Command stderr:\n%s", exc.stderr)
2774
+
2775
+ # Include raw output in error message if analysis didn't provide
2776
+ # useful info
2777
+ has_raw_output = exc.stdout or exc.stderr
2778
+ if error_details.startswith("Unknown error") and has_raw_output:
2779
+ raw_output = ""
2780
+ if exc.stdout:
2781
+ raw_output += f"stdout: {exc.stdout.strip()}\n"
2782
+ if exc.stderr:
2783
+ raw_output += f"stderr: {exc.stderr.strip()}"
2784
+ if raw_output:
2785
+ error_details = (
2786
+ f"{error_details}\nRaw output:\n{raw_output}"
2787
+ )
2788
+
2504
2789
  msg = (
2505
2790
  f"Failed to push changes to Gerrit with git-review: "
2506
2791
  f"{error_details}"
@@ -2758,9 +3043,10 @@ class Orchestrator:
2758
3043
  env=env,
2759
3044
  check=False,
2760
3045
  )
2761
- # 2) Clean untracked files/dirs (preserve our SSH known_hosts dir)
3046
+ # 2) Clean untracked files/dirs (SSH files are now outside
3047
+ # workspace)
2762
3048
  run_cmd(
2763
- ["git", "clean", "-fdx", "-e", ".ssh-g2g", "-e", ".ssh-g2g/**"],
3049
+ ["git", "clean", "-fdx"],
2764
3050
  cwd=self.workspace,
2765
3051
  env=env,
2766
3052
  check=False,
@@ -2968,7 +3254,15 @@ class Orchestrator:
2968
3254
  return f"Gerrit rejected the push: {line.strip()}"
2969
3255
  return "Gerrit rejected the push for an unknown reason"
2970
3256
  else:
2971
- return f"Unknown error: {exc}"
3257
+ # For unknown errors, include more context
3258
+ context_parts = []
3259
+ if exc.returncode is not None:
3260
+ context_parts.append(f"exit code {exc.returncode}")
3261
+ if exc.cmd:
3262
+ context_parts.append(f"command: {' '.join(exc.cmd)}")
3263
+
3264
+ context = f" ({', '.join(context_parts)})" if context_parts else ""
3265
+ return f"Unknown error{context}: {exc}"
2972
3266
 
2973
3267
  def _query_gerrit_for_results(
2974
3268
  self,
@@ -3933,6 +4227,295 @@ class Orchestrator:
3933
4227
  out.append(c)
3934
4228
  return out
3935
4229
 
4230
+ def _validate_committed_files(
4231
+ self, gh: GitHubContext, result: SubmissionResult
4232
+ ) -> None:
4233
+ """Validate that only expected files from the GitHub PR were
4234
+ committed to Gerrit.
4235
+
4236
+ This is a safety check to ensure no tool artifacts (like SSH keys) were
4237
+ accidentally included in the Gerrit change.
4238
+ """
4239
+ if not gh.pr_number or not result.commit_shas:
4240
+ log.debug("Skipping file validation - no PR number or commit SHAs")
4241
+ return
4242
+
4243
+ try:
4244
+ # Get files changed in the GitHub PR
4245
+ from .github_api import build_client
4246
+ from .github_api import get_pull
4247
+ from .github_api import get_repo_from_env
4248
+
4249
+ client = build_client()
4250
+ repo = get_repo_from_env(client)
4251
+ pr_obj = get_pull(repo, int(gh.pr_number))
4252
+
4253
+ # Get list of files changed in the PR
4254
+ github_files = set()
4255
+ for file in pr_obj.get_files(): # type: ignore[attr-defined]
4256
+ github_files.add(file.filename)
4257
+
4258
+ log.debug(
4259
+ "GitHub PR files (%d): %s",
4260
+ len(github_files),
4261
+ sorted(github_files),
4262
+ )
4263
+
4264
+ # Check files in each commit SHA that was pushed to Gerrit
4265
+ for commit_sha in result.commit_shas:
4266
+ try:
4267
+ # Get files changed in the Gerrit commit
4268
+ from .gitutils import run_cmd
4269
+
4270
+ files_output = run_cmd(
4271
+ [
4272
+ "git",
4273
+ "show",
4274
+ "--name-only",
4275
+ "--pretty=format:",
4276
+ commit_sha,
4277
+ ],
4278
+ cwd=self.workspace,
4279
+ ).stdout.strip()
4280
+
4281
+ if not files_output:
4282
+ continue
4283
+
4284
+ gerrit_files = {
4285
+ f.strip() for f in files_output.split("\n") if f.strip()
4286
+ }
4287
+ log.debug(
4288
+ "Gerrit commit %s files (%d): %s",
4289
+ commit_sha[:8],
4290
+ len(gerrit_files),
4291
+ sorted(gerrit_files),
4292
+ )
4293
+
4294
+ # Check for unexpected files
4295
+ unexpected_files = gerrit_files - github_files
4296
+ if unexpected_files:
4297
+ # Filter out known safe files that might legitimately
4298
+ # differ
4299
+ suspicious_files = []
4300
+ for f in unexpected_files:
4301
+ # Skip files that are legitimately different
4302
+ if f in [".gitreview", ".gitignore"]:
4303
+ continue
4304
+ # Flag SSH artifacts and other suspicious files
4305
+ if (
4306
+ ".ssh" in f
4307
+ or "known_hosts" in f
4308
+ or f.startswith("gerrit_key")
4309
+ ):
4310
+ suspicious_files.append(f)
4311
+ else:
4312
+ # Other unexpected files - log but don't error
4313
+ log.warning(
4314
+ "Unexpected file in Gerrit commit: %s", f
4315
+ )
4316
+
4317
+ if suspicious_files:
4318
+ log.error(
4319
+ "❌ CRITICAL: SSH artifacts detected in Gerrit "
4320
+ "commit %s: %s",
4321
+ commit_sha[:8],
4322
+ suspicious_files,
4323
+ )
4324
+ log.error(
4325
+ "This indicates a serious bug where tool "
4326
+ "artifacts were committed. The Gerrit change "
4327
+ "may need manual cleanup."
4328
+ )
4329
+ # Don't fail the pipeline, but log prominently for
4330
+ # monitoring
4331
+
4332
+ # Also check if we're missing expected files
4333
+ missing_files = github_files - gerrit_files
4334
+ if missing_files:
4335
+ log.warning(
4336
+ "Files in GitHub PR but not in Gerrit commit "
4337
+ "%s: %s",
4338
+ commit_sha[:8],
4339
+ sorted(missing_files),
4340
+ )
4341
+
4342
+ except Exception as commit_exc:
4343
+ log.debug(
4344
+ "Failed to validate files for commit %s: %s",
4345
+ commit_sha[:8],
4346
+ commit_exc,
4347
+ )
4348
+
4349
+ except Exception as exc:
4350
+ log.debug("File validation failed (non-critical): %s", exc)
4351
+
4352
+ def _analyze_merge_failure(
4353
+ self, merge_exc: CommandError, base_sha: str, head_sha: str
4354
+ ) -> str:
4355
+ """Analyze git merge failure and provide detailed error information."""
4356
+ error_parts = []
4357
+
4358
+ # Include basic command info
4359
+ if merge_exc.cmd:
4360
+ error_parts.append(f"Command: {' '.join(merge_exc.cmd)}")
4361
+ if merge_exc.returncode is not None:
4362
+ error_parts.append(f"Exit code: {merge_exc.returncode}")
4363
+
4364
+ # Analyze stderr for common patterns
4365
+ stderr = merge_exc.stderr or ""
4366
+ if "conflict" in stderr.lower():
4367
+ error_parts.append("Merge conflicts detected")
4368
+ if "abort" in stderr.lower():
4369
+ error_parts.append("Merge was aborted")
4370
+ if "fatal" in stderr.lower():
4371
+ error_parts.append("Fatal git error occurred")
4372
+
4373
+ # Include actual git output
4374
+ if merge_exc.stdout and merge_exc.stdout.strip():
4375
+ error_parts.append(f"Git output: {merge_exc.stdout.strip()}")
4376
+ if stderr and stderr.strip():
4377
+ error_parts.append(f"Git error: {stderr.strip()}")
4378
+
4379
+ return (
4380
+ "; ".join(error_parts) if error_parts else "Unknown merge failure"
4381
+ )
4382
+
4383
+ def _suggest_merge_recovery(
4384
+ self, merge_exc: CommandError, base_sha: str, head_sha: str
4385
+ ) -> str:
4386
+ """Suggest recovery actions based on merge failure analysis."""
4387
+ stderr = (merge_exc.stderr or "").lower()
4388
+
4389
+ if "conflict" in stderr:
4390
+ return "Check for merge conflicts in the PR files and resolve them"
4391
+ elif "fatal: refusing to merge unrelated histories" in stderr:
4392
+ return (
4393
+ "The branches have unrelated histories - check if the PR "
4394
+ "branch is based on the correct target"
4395
+ )
4396
+ elif "nothing to commit" in stderr:
4397
+ return (
4398
+ "No changes to merge - the PR may already be merged or have "
4399
+ "no differences"
4400
+ )
4401
+ elif "abort" in stderr:
4402
+ return (
4403
+ "Previous merge operation may have been interrupted - check "
4404
+ "repository state"
4405
+ )
4406
+
4407
+ # Try to provide generic guidance
4408
+ try:
4409
+ # Check if commits exist between base and head
4410
+ commits_cmd = ["git", "rev-list", f"{base_sha}..{head_sha}"]
4411
+ commits_result = run_cmd(
4412
+ commits_cmd, cwd=self.workspace, check=False
4413
+ )
4414
+ if (
4415
+ commits_result.returncode == 0
4416
+ and not commits_result.stdout.strip()
4417
+ ):
4418
+ return (
4419
+ "No commits found between base and head - PR may be empty "
4420
+ "or already merged"
4421
+ )
4422
+ except Exception as e:
4423
+ log.debug(
4424
+ "Failed to check commit range for recovery suggestion: %s", e
4425
+ )
4426
+
4427
+ return (
4428
+ "Review git repository state and ensure PR branch is properly "
4429
+ "synchronized with target"
4430
+ )
4431
+
4432
+ def _debug_merge_failure_context(
4433
+ self, base_sha: str, head_sha: str
4434
+ ) -> None:
4435
+ """Provide extensive debugging context for merge failures when verbose mode is enabled.""" # noqa: E501
4436
+ log.error("=== VERBOSE MODE: Extended merge failure analysis ===")
4437
+
4438
+ try:
4439
+ # Show detailed git log between base and head
4440
+ log_result = run_cmd(
4441
+ [
4442
+ "git",
4443
+ "log",
4444
+ "--oneline",
4445
+ "--graph",
4446
+ f"{base_sha}..{head_sha}",
4447
+ ],
4448
+ cwd=self.workspace,
4449
+ check=False,
4450
+ )
4451
+ if log_result.returncode == 0:
4452
+ log.error("Commits to be merged:\n%s", log_result.stdout)
4453
+ else:
4454
+ log.error("Failed to get commit log: %s", log_result.stderr)
4455
+
4456
+ # Show file differences
4457
+ diff_result = run_cmd(
4458
+ ["git", "diff", "--name-status", base_sha, head_sha],
4459
+ cwd=self.workspace,
4460
+ check=False,
4461
+ )
4462
+ if diff_result.returncode == 0:
4463
+ log.error(
4464
+ "Files changed between base and head:\n%s",
4465
+ diff_result.stdout,
4466
+ )
4467
+
4468
+ # Show merge-base information
4469
+ merge_base_result = run_cmd(
4470
+ ["git", "merge-base", "--is-ancestor", base_sha, head_sha],
4471
+ cwd=self.workspace,
4472
+ check=False,
4473
+ )
4474
+ if merge_base_result.returncode == 0:
4475
+ log.error(
4476
+ "Base SHA %s is an ancestor of head SHA %s",
4477
+ base_sha[:8],
4478
+ head_sha[:8],
4479
+ )
4480
+ else:
4481
+ log.error(
4482
+ "Base SHA %s is NOT an ancestor of head SHA %s",
4483
+ base_sha[:8],
4484
+ head_sha[:8],
4485
+ )
4486
+
4487
+ # Show current repository state
4488
+ status_result = run_cmd(
4489
+ ["git", "status", "--porcelain"],
4490
+ cwd=self.workspace,
4491
+ check=False,
4492
+ )
4493
+ if status_result.stdout.strip():
4494
+ log.error(
4495
+ "Repository has uncommitted changes:\n%s",
4496
+ status_result.stdout,
4497
+ )
4498
+
4499
+ # Show current branch and HEAD
4500
+ branch_result = run_cmd(
4501
+ ["git", "branch", "--show-current"],
4502
+ cwd=self.workspace,
4503
+ check=False,
4504
+ )
4505
+ if branch_result.returncode == 0:
4506
+ log.error("Current branch: %s", branch_result.stdout.strip())
4507
+
4508
+ head_result = run_cmd(
4509
+ ["git", "rev-parse", "HEAD"], cwd=self.workspace, check=False
4510
+ )
4511
+ if head_result.returncode == 0:
4512
+ log.error("Current HEAD: %s", head_result.stdout.strip())
4513
+
4514
+ except Exception:
4515
+ log.exception("Failed to gather debug context")
4516
+
4517
+ log.error("=== End verbose merge failure analysis ===")
4518
+
3936
4519
 
3937
4520
  # ---------------------
3938
4521
  # Utility functions
@@ -53,7 +53,8 @@ class SSHAgentManager:
53
53
  """Initialize SSH agent manager.
54
54
 
55
55
  Args:
56
- workspace: The workspace directory for storing temporary files
56
+ workspace: Secure temporary directory for storing SSH files (outside
57
+ git workspace)
57
58
  """
58
59
  self.workspace = workspace
59
60
  self.agent_pid: int | None = None
@@ -160,7 +161,9 @@ class SSHAgentManager:
160
161
  known_hosts_content: The known hosts content
161
162
  """
162
163
  try:
163
- # Create tool-specific SSH directory
164
+ # Create tool-specific SSH directory in secure temp location
165
+ # Note: workspace is now a separate secure temp directory outside
166
+ # git workspace
164
167
  tool_ssh_dir = self.workspace / ".ssh-g2g"
165
168
  tool_ssh_dir.mkdir(mode=0o700, exist_ok=True)
166
169
 
@@ -259,7 +262,7 @@ class SSHAgentManager:
259
262
  return result.stdout
260
263
 
261
264
  def cleanup(self) -> None:
262
- """Clean up SSH agent and temporary files."""
265
+ """Securely clean up SSH agent and temporary files."""
263
266
  try:
264
267
  # Kill SSH agent if we started it
265
268
  if self.agent_pid:
@@ -276,14 +279,44 @@ class SSHAgentManager:
276
279
  elif key in os.environ:
277
280
  del os.environ[key]
278
281
 
279
- # Clean up temporary files
282
+ # Securely clean up temporary files
280
283
  tool_ssh_dir = self.workspace / ".ssh-g2g"
281
284
  if tool_ssh_dir.exists():
282
285
  import shutil
283
286
 
287
+ # First, overwrite any key files to prevent recovery
288
+ try:
289
+ for root, _dirs, files in os.walk(tool_ssh_dir):
290
+ for file in files:
291
+ file_path = Path(root) / file
292
+ if file_path.exists() and file_path.is_file():
293
+ # Overwrite file with random data
294
+ try:
295
+ size = file_path.stat().st_size
296
+ if size > 0:
297
+ import secrets
298
+
299
+ with open(file_path, "wb") as f:
300
+ f.write(secrets.token_bytes(size))
301
+ # Sync to ensure write completes
302
+ os.fsync(f.fileno())
303
+ except Exception as overwrite_exc:
304
+ log.debug(
305
+ "Failed to overwrite %s: %s",
306
+ file_path,
307
+ overwrite_exc,
308
+ )
309
+ except Exception as walk_exc:
310
+ log.debug(
311
+ "Failed to walk SSH temp directory for secure "
312
+ "cleanup: %s",
313
+ walk_exc,
314
+ )
315
+
284
316
  shutil.rmtree(tool_ssh_dir)
285
317
  log.debug(
286
- "Cleaned up temporary SSH directory: %s", tool_ssh_dir
318
+ "Securely cleaned up temporary SSH directory: %s",
319
+ tool_ssh_dir,
287
320
  )
288
321
 
289
322
  except Exception as exc:
@@ -300,7 +333,8 @@ def setup_ssh_agent_auth(
300
333
  """Setup SSH agent-based authentication.
301
334
 
302
335
  Args:
303
- workspace: The workspace directory
336
+ workspace: Secure temporary directory for SSH files (outside git
337
+ workspace)
304
338
  private_key_content: SSH private key content
305
339
  known_hosts_content: Known hosts content
306
340
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github2gerrit
3
- Version: 0.1.14
3
+ Version: 0.1.16
4
4
  Summary: Submit a GitHub pull request to a Gerrit repository.
5
5
  Project-URL: Homepage, https://github.com/lfreleng-actions/github2gerrit
6
6
  Project-URL: Repository, https://github.com/lfreleng-actions/github2gerrit
@@ -1,8 +1,8 @@
1
1
  github2gerrit/__init__.py,sha256=N1Vj1HJ28LKCJLAynQdm5jFGQQAz9YSMzZhEfvbBgow,886
2
- github2gerrit/cli.py,sha256=iJ8s15Wslgt9FfHjUEELlrP5JGLDqn9j5GoH5aNZxVU,65286
2
+ github2gerrit/cli.py,sha256=edQGODSo0A1RfvO0C7zAjWwIZCnt4RT67BnJwgL0MAY,67843
3
3
  github2gerrit/commit_normalization.py,sha256=5HqUJ7p3WQzUYNTkganIsu82aW1wZrh7J7ivQvdCYXw,17033
4
4
  github2gerrit/config.py,sha256=khuXAfmOc2wqO4r1Cpp8PYk04AO-JpmnJvSWQZ3fdL8,22315
5
- github2gerrit/core.py,sha256=b6cmEX7Aa2LpcdqXm8VIKM9cqd4wi50vKeYXEo1H_Ek,149356
5
+ github2gerrit/core.py,sha256=hf7XDZ83PD_dYu79u-9u_pboDqu_iL9TpdIaa64_q-0,172360
6
6
  github2gerrit/duplicate_detection.py,sha256=HSL1IpyfPk0yLkzCKA8mWadYB3qENR6Ar3AfiH59lCI,28766
7
7
  github2gerrit/external_api.py,sha256=9483kkgIs1ECOl_f0lcGb8GrJQF9IfYmWfBQwUJT9hk,18480
8
8
  github2gerrit/gerrit_query.py,sha256=7AR9UEwZxtTb9V-l17UDdtWvlvROlnIeJVMa57ilf6s,8343
@@ -17,15 +17,15 @@ github2gerrit/reconcile_matcher.py,sha256=UpFAGqOD0NSKJKqKN_ErpNtqG0MEXxVEMywnKW
17
17
  github2gerrit/rich_display.py,sha256=hA2lXdbG3FOPozI_JTyU52o67oCS9LMUyc2odEpaAjo,15466
18
18
  github2gerrit/rich_logging.py,sha256=AEcPksmg2kj3eFOZHuU-_kWzGCKh-m__ZgDzwECBtaY,10281
19
19
  github2gerrit/similarity.py,sha256=-YHnscTrVh1jdfA3JRLPkbVjaZ5FpFdmXe8UPlTG1ss,15888
20
- github2gerrit/ssh_agent_setup.py,sha256=Wni94Zs86AVYalmVKfZ8lRzBnWybtJUwDltdpMC4Qnw,12864
20
+ github2gerrit/ssh_agent_setup.py,sha256=dwEP7b2EAd0QXm4mCdgbdbGMqmYWk8z1U_EswBAySnI,14610
21
21
  github2gerrit/ssh_common.py,sha256=ClABtIqXiYIfUn58Shw7uHZ6Ham1ysHn0EpcUHjQ8D8,8261
22
22
  github2gerrit/ssh_discovery.py,sha256=g8acjEczGEtYuAJzIRsLDAHTQzyS9RaHOoQV5dAMoes,17754
23
23
  github2gerrit/trailers.py,sha256=9w0vIxPNBNQp56sIy-MF62d22Rm6vY-msh9ao1lX0rQ,8384
24
24
  github2gerrit/utils.py,sha256=1CKTsQo_FO3eyVjzNUT3XyFnyObIxyEFLeSmVKzavVo,3397
25
25
  github2gerrit/orchestrator/__init__.py,sha256=HAEcdCAHOFr8LsdIwAdcIcFZn_ayMbX9rdVUULp8410,864
26
26
  github2gerrit/orchestrator/reconciliation.py,sha256=qLhmjnIblSa_PVmBblWJ1gSvJ6Gto2AIVOVdYvT6zbk,18364
27
- github2gerrit-0.1.14.dist-info/METADATA,sha256=YPSKTfw0f9j33f7uybQvkbeRqnSRdA4lLAxk1fkBw7s,33833
28
- github2gerrit-0.1.14.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
29
- github2gerrit-0.1.14.dist-info/entry_points.txt,sha256=MxN2_liIKo3-xJwtAulAeS5GcOS6JS96nvwOQIkP3W8,56
30
- github2gerrit-0.1.14.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
31
- github2gerrit-0.1.14.dist-info/RECORD,,
27
+ github2gerrit-0.1.16.dist-info/METADATA,sha256=02ry4GJBvmh4lqF27joxLA_s9be7UqOv95-89plq_fQ,33833
28
+ github2gerrit-0.1.16.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
29
+ github2gerrit-0.1.16.dist-info/entry_points.txt,sha256=MxN2_liIKo3-xJwtAulAeS5GcOS6JS96nvwOQIkP3W8,56
30
+ github2gerrit-0.1.16.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
31
+ github2gerrit-0.1.16.dist-info/RECORD,,