diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 18f5de8..0000000 --- a/.travis.yml +++ /dev/null @@ -1,14 +0,0 @@ -language: python - -python: - - "2.7" - -install: - - pip install . - - pip install -r requirements/test.txt - -script: - - make test - -after_success: - - coveralls \ No newline at end of file diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 93e0a7c..2ea40d4 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,6 +1,18 @@ Changelog ========= +Version 1.0.0 (2025-11-27) +-------------------------- + * BREAKING: Dropped Python 2 support - now requires Python 3.8+ + * Modernized terminal output using Rich library + * Replaced colored/colorama dependencies with rich + * Updated all output to use modern Rich markup syntax + * Improved code quality: PEP8 compliance, f-strings, context managers + * Cross-platform color support without platform-specific initialization + * Enhanced user experience with beautiful, consistent terminal output + * Migrated from setup.py to modern pyproject.toml + * See MIGRATION.md for upgrade guide + Version 0.3.22 (2015-05-05) -------------------------- * Check git repositories from an docker container diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..8736916 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,122 @@ +# Migration Guide: Python 2 to Python 3 with Rich + +This document outlines the changes made to modernize gitcheck for Python 3 and enhance terminal output with the Rich library. + +## Major Changes + +### 1. Build System Modernization +- **Old**: `setup.py` with manual configuration +- **New**: `pyproject.toml` following PEP 517/518 standards +- All package metadata now in standardized TOML format +- Cleaner, more maintainable configuration + +### 2. Python Version Requirements +- **Old**: Python 2.7 and Python 3.3+ +- **New**: Python 3.8+ only +- The `from __future__ import` statements have been removed as they're no longer needed + +### 2. Dependencies Updated +- **Removed**: `colored`, `colorama` +- **Added**: `rich>=13.0.0` + +### 4. Package Configuration +- **Old**: `setup.py` with custom RST processing +- **New**: `pyproject.toml` with standard metadata format +- Installation and build process remain the same for end users +- Follows modern Python packaging standards (PEP 517/518/621) + +### 5. Terminal Output Modernization +All terminal output now uses the Rich library for: +- Beautiful, consistent colored output across all platforms (Windows, Linux, macOS) +- Modern markup syntax for colors and styles +- Better readability and user experience +- Automatic color detection and terminal capability handling + +### 6. Code Quality Improvements +- Fixed PEP8 compliance issues +- Removed ambiguous variable names (e.g., `l` → `line`) +- Used context managers for file operations +- Modern string formatting with f-strings +- Removed Windows-specific color initialization (Rich handles this automatically) + +## Migration Steps for Users + +### 1. Upgrade Python +Ensure you have Python 3.8 or later: +```bash +python --version +``` + +### 2. Install Updated Dependencies +```bash +pip install -r requirements/base.txt +``` + +Or if installing from source: +```bash +pip install -e . +``` + +### 3. Update Custom Color Themes (Optional) +If you have a `~/mygitcheck.py` custom configuration file, update the color theme format: + +**Old format** (using `colored` library): +```python +from colored import fg, bg, attr + +colortheme = { + 'default': attr('reset') + fg('white'), + 'prjchanged': attr('reset') + attr('bold') + fg('deep_pink_1a'), + # ... etc +} +``` + +**New format** (using Rich markup): +```python +colortheme = { + 'default': 'white', + 'prjchanged': 'bold deep_pink1', + 'prjremote': 'magenta', + 'prjname': 'chartreuse1', + 'reponame': 'light_goldenrod2', + 'branchname': 'white', + 'fileupdated': 'light_goldenrod2', + 'remoteto': 'deep_sky_blue3', + 'committo': 'violet', + 'commitinfo': 'deep_sky_blue3', + 'commitstate': 'deep_pink1', +} +``` + +Note: You no longer need to specify `'bell'` and `'reset'` keys - these are handled automatically. + +## Benefits of Rich + +1. **Cross-platform**: Works perfectly on Windows without special initialization +2. **Modern**: Beautiful terminal output with emoji and advanced formatting support +3. **Consistent**: Same appearance across different terminal emulators +4. **Feature-rich**: Built-in support for tables, panels, progress bars, and more +5. **Maintained**: Actively developed and widely used in the Python ecosystem + +## Testing + +After migration, test the tool: + +```bash +# Simple test +gitcheck + +# Verbose output +gitcheck -v + +# Help to see all options +gitcheck -h +``` + +## Rollback + +If you need to use the old version temporarily, check out the last Python 2 compatible commit before upgrading. + +## Questions? + +For issues or questions about the migration, please open an issue on GitHub. diff --git a/Makefile b/Makefile deleted file mode 100644 index 01af6bf..0000000 --- a/Makefile +++ /dev/null @@ -1,42 +0,0 @@ -BASEDIR=$(CURDIR) -DISTDIR=$(BASEDIR)/dist -BUILDDIR=$(BASEDIR)/build -PACKAGE='gitcheck' - -test: pep8 coverage - -build: - @echo 'Running build' - @python setup.py build - -deploy: - @echo 'Upload to PyPi' - @python setup.py sdist upload - @echo 'Done' - -dist: - @echo 'Generating a distributable python package' - @python setup.py sdist - @echo 'Done' - -install: - @echo 'Running install' - @python setup.py install - - -pep8: - @pep8 $(PACKAGE) --config=pep8.rc - @echo 'PEP8: OK' - -coverage: - @echo 'Running test suite with coverage' - @coverage erase - @coverage run --rcfile=coverage.rc tests.py - @coverage html - @coverage report --rcfile=coverage.rc - -clean: - @rm -fr $(DISTDIR) - @rm -fr $(BUILDDIR) - -.PHONY: help doc build test dist install clean diff --git a/README.rst b/README.rst index e967d5f..61b654a 100644 --- a/README.rst +++ b/README.rst @@ -5,23 +5,64 @@ gitcheck ======== +**Modernized for Python 3 with Rich terminal output!** + When working simultaneously on several git repositories, it is easy to -loose the overview on the advancement of your work. This is why I -decided to write gitcheck, a tool which reports the status of the -repositories it finds in a file tree. This report can of course be -displayed on the terminal but also be sent by email. +lose the overview on the advancement of your work. This is why gitcheck +was created - a tool which reports the status of the repositories it +finds in a file tree. This report can be displayed on the terminal with +beautiful, colorful output using the Rich library, or sent by email. + +Now you can also check your host git from a docker container. See the docker section -Now you can also check your host git from an docker container. See the docker section +Requirements +------------ + +- Python 3.8 or higher +- Git Installation ------------ +Using pip: + :: pip install git+git://github.com/badele/gitcheck.git +Using pipx (recommended for CLI tools): + +:: + + pipx install git+https://github.com/badele/gitcheck.git + +Using uv: + +:: + + uv tool install git+https://github.com/badele/gitcheck.git + +Or for development: + +:: + + git clone https://github.com/badele/gitcheck.git + cd gitcheck + pip install -e . + +With uv for development: + +:: + + git clone https://github.com/badele/gitcheck.git + cd gitcheck + uv venv + uv pip install -e . + +The project uses modern ``pyproject.toml`` for configuration. + Examples -------- @@ -58,6 +99,57 @@ commits. Gitcheck detailed report +Interactive mode +~~~~~~~~~~~~~~~~ + +Interactive mode helps you batch-process all repositories with uncommitted changes. +It will pull latest changes, then for each repository with local modifications: + +- Show the changed files +- Open TortoiseGit (if installed) to review changes +- Prompt you to commit, discard, or skip +- Optionally push commits to remote + +.. code:: bash + + $ gitcheck.py -I + # or combine with quiet mode to only process repos needing action + $ gitcheck.py -qI + +**Note:** Requires TortoiseGit to be installed for the visual diff feature. +You can also commit via command line within the interactive mode. + +Auto-pull and parallel processing +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Speed up repository updates with parallel processing and automatic safe pulls: + +.. code:: bash + + # Update remotes in parallel (4 workers by default) + $ gitcheck.py -r -j + + # Use 8 parallel workers + $ gitcheck.py -r -j --jobs=8 + + # Auto-pull safe repositories (no conflicts, no uncommitted changes) + $ gitcheck.py -p + + # Combine parallel + auto-pull for maximum speed + $ gitcheck.py -p -j --jobs=8 + +**Parallel mode benefits:** +- 4x-8x faster for multiple repositories +- Progress bar shows real-time status +- Thread-safe output +- Perfect for automation (TactRMM, cron jobs) + +**Auto-pull safety:** +- Only pulls if no uncommitted changes +- Uses ``--ff-only`` (fast-forward only, no merge commits) +- Skips repos with local commits not yet pushed +- Shows exactly which repos were pulled vs skipped + Gitcheck customization ~~~~~~~~~~~~~~~~~~~~~~ @@ -103,6 +195,11 @@ Options -v, --verbose Show files & commits --debug Show debug message -r, --remote force remote update(slow) + -p, --auto-pull Auto-pull when safe (no conflicts, no local changes) + -j, --parallel Use parallel processing for remote updates (faster) + --jobs= Number of parallel jobs (default: 4) + --use-https Convert git:// and SSH URLs to HTTPS (firewall bypass) + --validate-token Validate GitLab token before checking repositories -u, --untracked Show untracked files -b, --bell bell on action needed -w , --watch= after displaying, wait and run again @@ -113,8 +210,207 @@ Options -e, --email Send an email with result as html, using mail.properties parameters --init-email Initialize mail.properties file (has to be modified by user using JSON Format) +Email Configuration +~~~~~~~~~~~~~~~~~~~ + +To send email reports, first initialize the configuration: + +.. code:: bash + + $ gitcheck --init-email + +This creates a ``mail.properties`` file in ``~/.gitcheck/`` directory. + +Edit the file with your SMTP settings: + +.. code:: json + + { + "smtp": "smtp.gmail.com", + "smtp_port": 587, + "smtp_username": "your_email@gmail.com", + "use_tls": true, + "use_ssl": false, + "from": "your_email@gmail.com", + "to": "recipient@example.com" + } + +**Configuration options:** + +- ``smtp``: SMTP server hostname +- ``smtp_port``: Port number (587 for TLS, 465 for SSL, 25 for unencrypted) +- ``smtp_username``: Username for authentication (usually your email) +- ``use_tls``: Use STARTTLS encryption (recommended for port 587) +- ``use_ssl``: Use implicit SSL encryption (recommended for port 465) +- ``from``: Sender email address +- ``to``: Recipient email address + +**Note:** Set either ``use_tls`` OR ``use_ssl`` to true, not both. + +For SMTP authentication, set the password via environment variable: + +.. code:: bash + + # Linux/Mac + export GITCHECK_SMTP_PASSWORD="your_password" + + # Windows PowerShell + $env:GITCHECK_SMTP_PASSWORD="your_password" + + # Then run gitcheck with email option + gitcheck -e + +**Note:** The password is read from the ``GITCHECK_SMTP_PASSWORD`` environment variable for security (not stored in the config file). + +SSH Key Configuration +~~~~~~~~~~~~~~~~~~~~~ + +If your Git repositories require SSH authentication and you have multiple SSH keys or need to specify a particular key, you can configure it in several ways: + +**For Pageant (PuTTY) users on Windows:** + +If you're using Pageant with a ``.ppk`` key file (common with TortoiseGit): + +.. code:: powershell + + # Make sure Pageant is running with your key loaded + # Then set the key path (even though it's already in Pageant) + $env:GITCHECK_SSH_KEY="C:\Users\YourName\.ssh\yourkey.ppk" + gitcheck -r + +Or use the command-line argument: + +.. code:: powershell + + gitcheck -r --ssh-key="C:\Users\ctremblay\.ssh\ctremblay.ppk" + +**Note:** Gitcheck will automatically detect the ``.ppk`` extension and use TortoiseGitPlink or plink.exe to connect through Pageant. + +**For OpenSSH users (Linux/Mac/Windows):** + +**Option 1: Command-line argument** + +.. code:: bash + + gitcheck -r --ssh-key=/path/to/your/private_key + +**Option 2: Environment variable** (recommended for regular use) + +.. code:: bash + + # Linux/Mac + export GITCHECK_SSH_KEY="$HOME/.ssh/id_rsa_work" + gitcheck -r + + # Windows PowerShell + $env:GITCHECK_SSH_KEY="C:\Users\YourName\.ssh\id_rsa_work" + gitcheck -r + +**Option 3: SSH Config** (best for permanent setup) + +Edit ``~/.ssh/config`` (or ``C:\Users\YourName\.ssh\config`` on Windows): + +.. code:: text + + Host git.servisys.com + HostName git.servisys.com + User git + IdentityFile ~/.ssh/id_rsa_work + IdentitiesOnly yes + +This way, gitcheck (and all git commands) will automatically use the correct key for that host. + +GitLab Token Validation +~~~~~~~~~~~~~~~~~~~~~~~ + +If you're using GitLab with HTTPS authentication (``--use-https``), gitcheck can validate your personal access token before processing repositories. + +**Standalone Token Validation** + +You can run the token validation tool independently: + +.. code:: bash + + # Interactive mode - prompts for token and validates + python -m gitcheck.validate_token + + # Check existing token only (no prompt) + python -m gitcheck.validate_token --check-only + + # Quiet mode for scripting (minimal output, exit code 0=valid, 1=invalid) + python -m gitcheck.validate_token --check-only --quiet + + # Custom GitLab host + python -m gitcheck.validate_token --host git.example.com + +**Integrated Validation** + +You can also validate your token as part of gitcheck: + +.. code:: bash + + # Validate token before checking repositories + gitcheck -r --use-https --validate-token + +**TactRMM/Automation Usage** + +The standalone validator is perfect for automation tools like TactRMM to verify tokens across multiple users: + +.. code:: powershell + + # Check if user's token is valid + python -m gitcheck.validate_token --check-only --quiet + if ($LASTEXITCODE -eq 0) { + Write-Host "Token is valid" + } else { + Write-Host "Token is invalid or expired" + } + +**Token Requirements** + +When creating a GitLab personal access token at ``https://git.servisys.com/-/user_settings/personal_access_tokens``: + +- **Required scopes:** ``read_repository``, ``write_repository`` +- Expiration: Set according to your security policy +- The token will be stored permanently in your environment + +**Token Storage** + +Tokens are stored persistently: + +- **Windows:** ``GITLAB_TOKEN`` environment variable via registry (``setx``) +- **Linux/Mac:** ``GITLAB_TOKEN`` export in ``~/.bashrc``/``~/.zshrc`` + +After saving, restart your terminal or source your shell profile. + + +Project Structure +~~~~~~~~~~~~~~~~~ + +The project is organized into modular components for better maintainability: + +- ``gitcheck/gitcheck.py`` - Main application logic and git operations +- ``gitcheck/https_utils.py`` - HTTPS/OAuth token management utilities + + - Token prompting and validation + - URL conversion (git://, SSH → HTTPS) + - OAuth token injection for GitLab/GitHub + - Persistent token storage + - Authentication error detection + +- ``gitcheck/validate_token.py`` - Standalone GitLab token validation + + - Validates tokens via GitLab API (``/api/v4/user``) + - Interactive and non-interactive modes + - Perfect for TactRMM/automation deployments + - Returns exit code 0 (valid) or 1 (invalid) + +This modular structure makes it easier to maintain and test the HTTPS/OAuth features independently. + + French version ~~~~~~~~~~~~~~ A French version of this document is available here: http://bruno.adele.im/projets/gitcheck/ + diff --git a/coverage.rc b/coverage.rc deleted file mode 100644 index 6db0c99..0000000 --- a/coverage.rc +++ /dev/null @@ -1,24 +0,0 @@ -# coverage.rc to control coverage.py -[run] -omit = /usr/share/* - -[report] -# Regexes for lines to exclude from consideration -exclude_lines = - # Have to re-enable the standard pragma - pragma: no cover - - # Don't complain about missing debug-only code: - def __repr__ - if self\.debug - - # Don't complain if tests don't hit defensive assertion code: - raise AssertionError - raise NotImplementedError - - # Don't complain if non-runnable code isn't run: - if 0: - if __name__ == .__main__.: - -ignore_errors = True - diff --git a/gitcheck/gitcheck.py b/gitcheck/gitcheck.py index 448f0e9..1dd62c7 100755 --- a/gitcheck/gitcheck.py +++ b/gitcheck/gitcheck.py @@ -1,6 +1,5 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import, division, print_function import os import re @@ -14,13 +13,20 @@ from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText import shlex - from os.path import expanduser from time import strftime - import json +from concurrent.futures import ThreadPoolExecutor, as_completed +import threading + +from rich.console import Console +from rich.prompt import Prompt, Confirm +from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn + +from . import https_utils -from colored import fg, bg, attr +console = Console() +console_lock = threading.Lock() # Global vars argopts = {} @@ -35,22 +41,19 @@ if hasattr(userconf, 'colortheme'): colortheme = userconf.colortheme if colortheme is None: - # Default theme - defaultcolor = attr('reset') + fg('white') + # Default theme using rich colors colortheme = { - 'default': defaultcolor, - 'prjchanged': attr('reset') + attr('bold') + fg('deep_pink_1a'), - 'prjremote': attr('reverse') + fg('light_cyan'), - 'prjname': attr('reset') + fg('chartreuse_1'), - 'reponame': attr('reset') + fg('light_goldenrod_2b'), - 'branchname': defaultcolor, - 'fileupdated': attr('reset') + fg('light_goldenrod_2b'), - 'remoteto': attr('reset') + fg('deep_sky_blue_3b'), - 'committo': attr('reset') + fg('violet'), - 'commitinfo': attr('reset') + fg('deep_sky_blue_3b'), - 'commitstate': attr('reset') + fg('deep_pink_1a'), - 'bell': "\a", - 'reset': "\033[2J\033[H" + 'default': 'white', + 'prjchanged': 'bold deep_pink1', + 'prjremote': 'magenta', + 'prjname': 'chartreuse1', + 'reponame': 'light_goldenrod2', + 'branchname': 'white', + 'fileupdated': 'light_goldenrod2', + 'remoteto': 'deep_sky_blue3', + 'committo': 'violet', + 'commitinfo': 'deep_sky_blue3', + 'commitstate': 'deep_pink1', } @@ -66,7 +69,7 @@ class html: def showDebug(mess, level='info'): if argopts.get('debugmod', False): - print(mess) + console.print(f"[dim]{mess}[/dim]") # Search all local repositories from current directory @@ -119,14 +122,7 @@ def checkRepository(rep, branch): ischange = ischange or (count > 0) actionNeeded = actionNeeded or (count > 0) if count > 0: - topush += " %s%s%s[%sTo Push:%s%s]" % ( - colortheme['reponame'], - r, - colortheme['default'], - colortheme['remoteto'], - colortheme['default'], - count - ) + topush += f" [{colortheme['reponame']}]{r}[/][[{colortheme['remoteto']}]To Push:[/]{count}]" html.topush += '%s[To Push:%s]' % ( r, count @@ -137,14 +133,7 @@ def checkRepository(rep, branch): ischange = ischange or (count > 0) actionNeeded = actionNeeded or (count > 0) if count > 0: - topull += " %s%s%s[%sTo Pull:%s%s]" % ( - colortheme['reponame'], - r, - colortheme['default'], - colortheme['remoteto'], - colortheme['default'], - count - ) + topull += f" [{colortheme['reponame']}]{r}[/][[{colortheme['remoteto']}]To Pull:[/]{count}]" html.topull += '%s[To Pull:%s]' % ( r, count @@ -179,18 +168,12 @@ def checkRepository(rep, branch): # Print result if len(changes) > 0: - strlocal = "%sLocal%s[" % (colortheme['reponame'], colortheme['default']) - lenFilesChnaged = len(getLocalFilesChange(rep)) - strlocal += "%sTo Commit:%s%s" % ( - colortheme['remoteto'], - colortheme['default'], - lenFilesChnaged - ) + lenFilesChanged = len(getLocalFilesChange(rep)) + strlocal = f"[{colortheme['reponame']}]Local[/][[{colortheme['remoteto']}]To Commit:[/]{lenFilesChanged}]" html.strlocal = ' Local[' html.strlocal += "To Commit:%s" % ( - lenFilesChnaged + lenFilesChanged ) - strlocal += "]" html.strlocal += "]" else: strlocal = "" @@ -200,42 +183,32 @@ def checkRepository(rep, branch): html.msg += "
  • %s/%s %s %s %s
  • \n" % (html.prjname, branch, html.strlocal, html.topush, html.topull) else: - cbranch = "%s%s" % (colortheme['branchname'], branch) - print("%(prjname)s/%(cbranch)s %(strlocal)s%(topush)s%(topull)s" % locals()) + cbranch = f"[{colortheme['branchname']}]{branch}[/]" + prjname_styled = f"[{colortheme['prjname'] if not ischange else colortheme['prjchanged'] if hasremotes else colortheme['prjremote']}]{repname}[/]" + console.print(f"{prjname_styled}/{cbranch} {strlocal}{topush}{topull}") if argopts.get('verbose', False): if ischange > 0: - filename = " |--Local" if not argopts.get('email', False): - print(filename) + console.print(" [bold]|--Local[/bold]") html.msg += '
    • Local
    \n
      \n' for c in changes: - filename = " |--%s%s%s %s%s" % ( - colortheme['commitstate'], - c[0], - colortheme['fileupdated'], - c[1], - colortheme['default']) html.msg += '
    • [To Commit] %s
    • \n' % c[1] - if not argopts.get('email', False): print(filename) + if not argopts.get('email', False): + console.print(f" |--[{colortheme['commitstate']}]{c[0]}[/] [{colortheme['fileupdated']}]{c[1]}[/]") html.msg += '
    \n' if branch != "": remotes = getRemoteRepositories(rep) for r in remotes: commits = getLocalToPush(rep, r, branch) if len(commits) > 0: - rname = " |--%(r)s" % locals() html.msg += '
    • %(r)s
    • \n
    \n
      \n' % locals() - if not argopts.get('email', False): print(rname) + if not argopts.get('email', False): + console.print(f" |--{r}") for commit in commits: - pcommit = " |--%s[To Push]%s %s%s%s" % ( - colortheme['committo'], - colortheme['default'], - colortheme['commitinfo'], - commit, - colortheme['default']) html.msg += '
    • [To Push] %s
    • \n' % commit - if not argopts.get('email', False): print(pcommit) + if not argopts.get('email', False): + console.print(f" |--[{colortheme['committo']}][To Push][/] [{colortheme['commitinfo']}]{commit}[/]") html.msg += '
    \n' if branch != "": @@ -243,18 +216,13 @@ def checkRepository(rep, branch): for r in remotes: commits = getRemoteToPull(rep, r, branch) if len(commits) > 0: - rname = " |--%(r)s" % locals() html.msg += '
    • %(r)s
    • \n
    \n
      \n' % locals() - if not argopts.get('email', False): print(rname) + if not argopts.get('email', False): + console.print(f" |--{r}") for commit in commits: - pcommit = " |--%s[To Pull]%s %s%s%s" % ( - colortheme['committo'], - colortheme['default'], - colortheme['commitinfo'], - commit, - colortheme['default']) html.msg += '
    • [To Pull] %s
    • \n' % commit - if not argopts.get('email', False): print(pcommit) + if not argopts.get('email', False): + console.print(f" |--[{colortheme['committo']}][To Pull][/] [{colortheme['commitinfo']}]{commit}[/]") html.msg += '
    \n' return actionNeeded @@ -268,9 +236,9 @@ def getLocalFilesChange(rep): result = gitExec(rep, "status -s" + onlyTrackedArg) lines = result.split('\n') - for l in lines: - if not re.match(argopts.get('ignoreLocal', r'^$'), l): - m = snbchange.match(l) + for line in lines: + if not re.match(argopts.get('ignoreLocal', r'^$'), line): + m = snbchange.match(line) if m: files.append([m.group(1), m.group(2)]) @@ -300,8 +268,196 @@ def getRemoteToPull(rep, remote, branch): return [x for x in result.split('\n') if x] +def convertRemoteToHttps(rep, remote_name='origin', force_update=False): + """Convert git:// or SSH remote URLs to HTTPS for firewall compatibility""" + gitlab_token = os.environ.get('GITLAB_TOKEN', '').strip() + return https_utils.convertRemoteToHttps(rep, remote_name, gitlab_token, gitExec, force_update=force_update) + + +def promptForNewToken(reason="expired or invalid"): + """Prompt user for a new token and save it""" + new_token = https_utils.promptForNewToken(console, console_lock, reason) + if new_token: + # Save to environment for current process + os.environ['GITLAB_TOKEN'] = new_token + # Save permanently + https_utils.saveTokenPermanently(new_token, console, console_lock) + return new_token + + +def ensureHttpsRemotes(rep, force_update=False): + """Ensure all remotes use HTTPS URLs""" + return https_utils.ensureHttpsRemotes( + rep, + getRemoteRepositories, + convertRemoteToHttps, + verbose=argopts.get('verbose', False), + console=console, + console_lock=console_lock, + force_update=force_update + ) + + def updateRemote(rep): - gitExec(rep, "remote update") + try: + # Convert to HTTPS if requested (for firewall bypass) + if argopts.get('use_https', False): + converted, info = ensureHttpsRemotes(rep) + if converted and not argopts.get('verbose', False): + with console_lock: + console.print(f" [dim]Converted {len(info)} remote(s) to HTTPS[/dim]") + + # Use verbose mode to show what's being updated + # Set a timeout to prevent hanging on slow/unresponsive remotes + result = gitExec(rep, "remote update", timeout=30) + if argopts.get('verbose', False) and result.strip(): + # Show the output from remote update + for line in result.split('\n'): + if line.strip(): + console.print(f" [dim]{line}[/dim]") + except subprocess.TimeoutExpired: + raise Exception("Network timeout - remote server not responding") + except Exception as e: + # Check for authentication failures that indicate expired/invalid token + if https_utils.isAuthenticationError(str(e)): + # Token appears to be expired or invalid + if argopts.get('use_https', False): + # Only prompt once per session + if not hasattr(updateRemote, '_token_retry_attempted'): + updateRemote._token_retry_attempted = True + + new_token = promptForNewToken() + if new_token: + # Retry with new token - re-convert remotes with force_update=True + converted, info = ensureHttpsRemotes(rep, force_update=True) + # Retry the update + result = gitExec(rep, "remote update", timeout=30) + if argopts.get('verbose', False) and result.strip(): + for line in result.split('\n'): + if line.strip(): + console.print(f" [dim]{line}[/dim]") + return + + raise e + + +def canSafelyPull(rep, branch): + """Check if repository can be safely pulled without conflicts""" + # Check if there are uncommitted changes + changes = getLocalFilesChange(rep) + if len(changes) > 0: + return False, "Has uncommitted changes" + + # Check if branch has remote + remotes = getRemoteRepositories(rep) + if not remotes: + return False, "No remote configured" + + # For each remote, check if we can fast-forward + for remote in remotes: + if not hasRemoteBranch(rep, remote, branch): + continue + + # Check if pull would be a fast-forward (no merge needed) + try: + # Check if local branch is behind remote + behind = getRemoteToPull(rep, remote, branch) + if not behind: + continue # Already up to date + + # Check if local branch has commits not on remote + ahead = getLocalToPush(rep, remote, branch) + if ahead: + return False, f"Branch has local commits not pushed to {remote}" + + # Safe to pull - we're only behind, not ahead + return True, f"Can fast-forward from {remote}" + except Exception as e: + return False, f"Error checking {remote}: {str(e)}" + + return False, "No remote branches to pull from" + + +def autoPullRepository(rep, branch): + """Attempt to safely pull repository if possible""" + can_pull, reason = canSafelyPull(rep, branch) + + if not can_pull: + showDebug(f"Skipping auto-pull for {rep}: {reason}") + return False + + try: + with console_lock: + console.print(f" [cyan]→ Auto-pulling {branch}...[/cyan]") + + result = gitExec(rep, "pull --ff-only") + + if argopts.get('verbose', False) and result.strip(): + with console_lock: + for line in result.split('\n'): + if line.strip(): + console.print(f" [dim]{line}[/dim]") + + with console_lock: + console.print(" [green]✓ Pulled successfully[/green]") + return True + except Exception as e: + # Check for authentication failures + if https_utils.isAuthenticationError(str(e)): + if argopts.get('use_https', False): + if not hasattr(autoPullRepository, '_token_retry_attempted'): + autoPullRepository._token_retry_attempted = True + + new_token = promptForNewToken() + if new_token: + # Re-convert remotes with new token (force update) + ensureHttpsRemotes(rep, force_update=True) + # Retry pull + try: + result = gitExec(rep, "pull --ff-only") + with console_lock: + console.print(" [green]✓ Pulled successfully with new token[/green]") + return True + except Exception as retry_error: + with console_lock: + console.print(f" [red]✗ Pull failed even with new token: {str(retry_error)}[/red]") + return False + + with console_lock: + console.print(f" [yellow]⚠ Auto-pull failed: {str(e)}[/yellow]") + return False + + +def processRepository(repo_path): + """Process a single repository (for parallel execution)""" + result = { + 'path': repo_path, + 'success': False, + 'updated': False, + 'pulled': False, + 'error': None + } + + try: + # Update remotes + updateRemote(repo_path) + result['updated'] = True + + # Auto-pull if enabled + if argopts.get('autopull', False): + branch_set = getDefaultBranch(repo_path) + for branch in branch_set: + if branch: + if autoPullRepository(repo_path, branch): + result['pulled'] = True + + result['success'] = True + except subprocess.TimeoutExpired: + result['error'] = "Timeout (30s) - remote not responding" + except Exception as e: + result['error'] = str(e) + + return result # Get Default branch for repository @@ -336,15 +492,75 @@ def getRemoteRepositories(rep): return remotes -def gitExec(path, cmd): +def gitExec(path, cmd, timeout=None): commandToExecute = "git -C \"%s\" %s" % (path, cmd) cmdargs = shlex.split(commandToExecute) showDebug("EXECUTE GIT COMMAND '%s'" % cmdargs) - p = subprocess.Popen(cmdargs, stdout=PIPE, stderr=PIPE) - output, errors = p.communicate() + + # Prepare environment with SSH key if provided + env = os.environ.copy() + ssh_key = argopts.get('ssh_key') + + # Debug: Show SSH key configuration + env_ssh_key = os.environ.get('GITCHECK_SSH_KEY') + if argopts.get('debugmod', False) and (env_ssh_key or ssh_key): + console.print("[dim]SSH Key Configuration:[/dim]") + console.print(f"[dim] Environment variable GITCHECK_SSH_KEY: {env_ssh_key or 'Not set'}[/dim]") + console.print(f"[dim] argopts ssh_key: {ssh_key or 'Not set'}[/dim]") + + # Check if using Pageant (PuTTY) - indicated by .ppk extension + if ssh_key and ssh_key.lower().endswith('.ppk'): + # Use plink for PuTTY/Pageant integration + # Find plink.exe (usually in same directory as PuTTY or TortoiseGit) + plink_paths = [ + r"C:\Program Files\TortoiseGit\bin\TortoiseGitPlink.exe", + r"C:\Program Files (x86)\TortoiseGit\bin\TortoiseGitPlink.exe", + r"C:\Program Files\PuTTY\plink.exe", + r"C:\Program Files (x86)\PuTTY\plink.exe", + ] + + plink_exe = None + for plink_path in plink_paths: + if os.path.exists(plink_path): + plink_exe = plink_path + break + + if plink_exe: + env['GIT_SSH'] = plink_exe + showDebug(f"Using Pageant with plink: {plink_exe}") + console.print(f"[dim]Using SSH via: {plink_exe}[/dim]") if argopts.get('debugmod', False) else None + else: + console.print("[yellow]Warning: .ppk key specified but plink.exe not found. Install TortoiseGit or PuTTY.[/yellow]") + console.print("[yellow]Searched locations:[/yellow]") + for path in plink_paths: + console.print(f"[dim] - {path}[/dim]") + elif ssh_key and os.path.exists(ssh_key): + # Use GIT_SSH_COMMAND for OpenSSH keys + env['GIT_SSH_COMMAND'] = f'ssh -i "{ssh_key}" -o IdentitiesOnly=yes' + showDebug(f"Using SSH key: {ssh_key}") + + p = subprocess.Popen(cmdargs, stdout=PIPE, stderr=PIPE, env=env) + try: + output, errors = p.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + p.kill() + p.communicate() # Clean up + raise subprocess.TimeoutExpired(cmdargs, timeout) + if p.returncode: - print('Failed running %s' % commandToExecute) - raise Exception(errors) + error_msg = errors.decode('utf-8') if errors else 'Unknown error' + showDebug(f'Git command failed: {commandToExecute}') + showDebug(f'Error output: {error_msg}') + + # Provide helpful error messages for common issues + if 'timed out' in error_msg.lower() or 'timeout' in error_msg.lower(): + raise Exception("Network timeout - check your connection or remote server status") + elif 'could not resolve host' in error_msg.lower(): + raise Exception("DNS resolution failed - check your network connection") + elif 'permission denied' in error_msg.lower(): + raise Exception("Authentication failed - check SSH key or credentials") + else: + raise Exception(error_msg) return output.decode('utf-8') @@ -356,34 +572,299 @@ def gitcheck(): actionNeeded = False if argopts.get('checkremote', False): - for r in repo: - print ("Updating %s remotes..." % r) - updateRemote(r) + # Validate token first if using HTTPS mode + if argopts.get('use_https', False) and argopts.get('validate_token', False): + from . import validate_token + + gitlab_token = os.environ.get('GITLAB_TOKEN', '').strip() + if gitlab_token: + gitlab_host = argopts.get('gitlab_host', 'git.servisys.com') + console.print(f"[cyan]Validating token against {gitlab_host}...[/cyan]") + is_valid, message, user_info = validate_token.check_token_validity(gitlab_token, gitlab_host) + + if not is_valid: + console.print(f"[red]✗ Token validation failed: {message}[/red]") + console.print("[yellow]Please run: gitcheck_token[/yellow]") + return + else: + console.print(f"[green]✓ {message}[/green]") + + # Only prompt for token if use_https is enabled AND token is missing or empty + # Token will be re-prompted automatically if authentication fails during operations + if argopts.get('use_https', False): + gitlab_token = os.environ.get('GITLAB_TOKEN', '').strip() + if gitlab_token == '': # Token is missing or empty + # Prompt for token now, before any parallel processing + gitlab_token = https_utils.promptForToken(console, console_lock) + if gitlab_token: + # Save to environment for current process + os.environ['GITLAB_TOKEN'] = gitlab_token + # Save permanently + https_utils.saveTokenPermanently(gitlab_token, console, console_lock) + else: + # User cancelled token entry + console.print("[yellow]No token provided. Cannot proceed with HTTPS remotes.[/yellow]") + return + + # Test the token with a quick validation before proceeding + # This prevents starting parallel processing with an invalid token + if repo and gitlab_token: + console.print("[cyan]Testing token with first repository...[/cyan]") + test_repo = repo[0] + try: + # Try to convert and update the first repo as a test + converted, info = ensureHttpsRemotes(test_repo) + if converted and not argopts.get('verbose', False): + console.print(f" [dim]Converted {len(info)} remote(s) to HTTPS[/dim]") + + # Try a quick remote update + gitExec(test_repo, "remote update", timeout=15) + console.print("[green]✓ Token verified successfully[/green]") + except Exception as e: + error_str = str(e) + + # Check if it's an SSL certificate error (common on public WiFi) + if https_utils.isSSLError(error_str): + console.print(f"[red]✗ SSL Certificate Error: {error_str}[/red]") + console.print(https_utils.getSSLErrorHelp()) + + if not Confirm.ask("[yellow]Continue anyway?[/yellow]", default=False): + return + # User chose to continue despite SSL error + console.print("[yellow]Continuing with SSL errors - some operations may fail[/yellow]") + + # Check if it's an authentication error + elif https_utils.isAuthenticationError(error_str): + console.print(f"[red]✗ Token authentication failed: {error_str}[/red]") + + # Prompt for new token + new_token = promptForNewToken() + if new_token: + # Test the new token + try: + converted, info = ensureHttpsRemotes(test_repo, force_update=True) + gitExec(test_repo, "remote update", timeout=15) + console.print("[green]✓ New token verified successfully[/green]") + except Exception as retry_error: + retry_str = str(retry_error) + if https_utils.isSSLError(retry_str): + console.print(f"[red]✗ SSL error persists: {retry_str}[/red]") + console.print(https_utils.getSSLErrorHelp()) + return + elif https_utils.isAuthenticationError(retry_str): + console.print("[red]✗ New token also failed. Please run: gitcheck_token[/red]") + return + else: + # Non-auth/SSL error, can continue + console.print(f"[yellow]Warning: {retry_str}[/yellow]") + else: + console.print("[yellow]No token provided. Cannot proceed.[/yellow]") + return + else: + # Non-authentication, non-SSL error, just warn and continue + console.print(f"[yellow]Warning for {test_repo}: {error_str}[/yellow]") + console.print("[cyan]Continuing with other repositories...[/cyan]") + + max_workers = argopts.get('jobs', 4) # Default to 4 parallel jobs + + if argopts.get('parallel', False) and len(repo) > 1: + # Parallel processing with progress bar + try: + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + console=console + ) as progress: + task = progress.add_task(f"[cyan]Processing {len(repo)} repositories...", total=len(repo)) + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_to_repo = {executor.submit(processRepository, r): r for r in repo} + + for future in as_completed(future_to_repo): + r = future_to_repo[future] + try: + result = future.result() + progress.update(task, advance=1) + + with console_lock: + if result['success']: + status = "✓ Updated" + if result['pulled']: + status += " + Pulled" + console.print(f"[green]{result['path']}[/green] - {status}") + else: + console.print(f"[yellow]{result['path']}[/yellow] - Failed: {result['error']}") + except Exception as e: + progress.update(task, advance=1) + with console_lock: + console.print(f"[red]{r}[/red] - Error: {str(e)}") + except KeyboardInterrupt: + console.print("\n[yellow]⚠ Interrupted by user - stopping parallel processing...[/yellow]") + raise + else: + # Sequential processing (original behavior) + for r in repo: + console.print(f"[cyan]Updating {r} remotes...[/cyan]") + try: + updateRemote(r) + console.print(" [green]✓ Updated[/green]") + + # Auto-pull if enabled + if argopts.get('autopull', False): + # Get the current branch for this repo + branch_set = getDefaultBranch(r) + for branch in branch_set: + if branch: # Skip if no branch detected + autoPullRepository(r, branch) + except KeyboardInterrupt: + console.print("\n[yellow]⚠ Interrupted by user[/yellow]") + raise + except Exception as e: + console.print(f"[yellow]Warning: Failed to update remotes for {r}[/yellow]") + if argopts.get('debugmod', False): + console.print(f"[dim]Error: {str(e)}[/dim]") + continue if argopts.get('watchInterval', 0) > 0: - print(colortheme['reset']) - print(strftime("%Y-%m-%d %H:%M:%S")) + console.clear() + console.print(f"[bold]{strftime('%Y-%m-%d %H:%M:%S')}[/bold]") showDebug("Processing repositories... please wait.") for r in repo: - if (argopts.get('checkall', False)): - branch = getAllBranches(r) - else: - branch = getDefaultBranch(r) - for b in branch: - if checkRepository(r, b): - actionNeeded = True + try: + if (argopts.get('checkall', False)): + branch = getAllBranches(r) + else: + branch = getDefaultBranch(r) + for b in branch: + if checkRepository(r, b): + actionNeeded = True + except KeyboardInterrupt: + console.print("\n[yellow]⚠ Interrupted by user[/yellow]") + raise html.timestamp = strftime("%Y-%m-%d %H:%M:%S") html.msg += "\n

    Report created on %s

    \n" % html.timestamp if actionNeeded and argopts.get('bellOnActionNeeded', False): - print(colortheme['bell']) + console.bell() + + # Interactive mode to handle uncommitted changes + if argopts.get('interactive', False): + handleInteractiveMode(repo) + + +def openTortoiseDiff(repo_path): + """Open TortoiseGit diff tool for the repository""" + try: + # TortoiseGitProc.exe /command:repostatus /path:"repo_path" + tortoise_cmd = f'TortoiseGitProc.exe /command:repostatus /path:"{repo_path}"' + subprocess.Popen(tortoise_cmd, shell=True) + return True + except Exception as e: + console.print(f"[yellow]Could not open TortoiseGit: {str(e)}[/yellow]") + console.print("[yellow]Make sure TortoiseGit is installed and in your PATH[/yellow]") + return False + + +def handleInteractiveMode(repositories): + """Interactive mode to review and commit/discard changes""" + console.print("\n[bold cyan]═══ Interactive Mode ═══[/bold cyan]\n") + + repos_with_changes = [] + for repo in repositories: + changes = getLocalFilesChange(repo) + if len(changes) > 0: + repos_with_changes.append((repo, changes)) + + if not repos_with_changes: + console.print("[green]✓ No repositories with uncommitted changes![/green]") + return + + console.print(f"[yellow]Found {len(repos_with_changes)} repository(ies) with uncommitted changes[/yellow]\n") + + for idx, (repo, changes) in enumerate(repos_with_changes, 1): + console.print(f"\n[bold]Repository {idx}/{len(repos_with_changes)}:[/bold] [cyan]{repo}[/cyan]") + console.print(f" [yellow]{len(changes)} file(s) changed[/yellow]") + + # Show changed files + for status, filename in changes[:5]: # Show first 5 + console.print(f" [{colortheme['commitstate']}]{status}[/] {filename}") + if len(changes) > 5: + console.print(f" [dim]... and {len(changes) - 5} more[/dim]") + + # Ask what to do + console.print("\n[bold]What would you like to do?[/bold]") + console.print(" [green]1[/green] - Open TortoiseGit and commit") + console.print(" [yellow]2[/yellow] - Skip this repository") + console.print(" [red]3[/red] - Discard all changes (git reset --hard)") + console.print(" [cyan]4[/cyan] - Commit via command line") + console.print(" [magenta]q[/magenta] - Quit interactive mode") + + choice = Prompt.ask("\nChoice", choices=["1", "2", "3", "4", "q"], default="2") + + if choice == "q": + console.print("[yellow]Exiting interactive mode[/yellow]") + break + elif choice == "1": + # Open TortoiseGit + console.print("[cyan]Opening TortoiseGit...[/cyan]") + if openTortoiseDiff(repo): + if Confirm.ask("Press Enter when done with TortoiseGit commit", default=True): + # Check if changes still exist + remaining = getLocalFilesChange(repo) + if len(remaining) == 0: + console.print("[green]✓ Changes committed successfully![/green]") + # Ask about push + if Confirm.ask(" Push to remote?", default=True): + try: + gitExec(repo, "push") + console.print("[green]✓ Pushed to remote![/green]") + except Exception as e: + console.print(f"[red]✗ Push failed: {str(e)}[/red]") + else: + console.print(f"[yellow]Still {len(remaining)} file(s) uncommitted[/yellow]") + elif choice == "2": + console.print("[yellow]Skipping...[/yellow]") + continue + elif choice == "3": + # Discard changes + if Confirm.ask("[red]⚠ Are you SURE you want to discard all changes? This cannot be undone!", default=False): + try: + gitExec(repo, "reset --hard") + console.print("[green]✓ Changes discarded[/green]") + except Exception as e: + console.print(f"[red]✗ Failed to discard: {str(e)}[/red]") + else: + console.print("[yellow]Cancelled discard operation[/yellow]") + elif choice == "4": + # Command line commit + commit_msg = Prompt.ask("Commit message") + if commit_msg: + try: + gitExec(repo, "add -A") + gitExec(repo, f'commit -m "{commit_msg}"') + console.print("[green]✓ Changes committed![/green]") + # Ask about push + if Confirm.ask(" Push to remote?", default=True): + try: + gitExec(repo, "push") + console.print("[green]✓ Pushed to remote![/green]") + except Exception as e: + console.print(f"[red]✗ Push failed: {str(e)}[/red]") + except Exception as e: + console.print(f"[red]✗ Commit failed: {str(e)}[/red]") + else: + console.print("[yellow]No commit message provided, skipping[/yellow]") + + console.print("\n[bold cyan]═══ Interactive Mode Complete ═══[/bold cyan]\n") def sendReport(content): userPath = expanduser('~') - filepath = r'%s\Documents\.gitcheck' % userPath - filename = filepath + "//mail.properties" + filepath = os.path.join(userPath, '.gitcheck') + filename = os.path.join(filepath, 'mail.properties') config = json.load(open(filename)) # Create message container - the correct MIME type is multipart/alternative. @@ -398,9 +879,9 @@ def sendReport(content): html.path, content ) # Write html file to disk - f = open(filepath + '//result.html', 'w') - f.write(htmlcontent) - print ("File saved under %s\\result.html" % filepath) + with open(os.path.join(filepath, 'result.html'), 'w') as f: + f.write(htmlcontent) + console.print(f"[green]File saved under {os.path.join(filepath, 'result.html')}[/green]") # Record the MIME types of both parts - text/plain and text/html. part1 = MIMEText(text, 'plain') part2 = MIMEText(htmlcontent, 'html') @@ -411,32 +892,74 @@ def sendReport(content): msg.attach(part1) msg.attach(part2) try: - print ("Sending email to %s" % config['to']) - # Send the message via local SMTP server. - s = smtplib.SMTP(config['smtp'], config['smtp_port']) + console.print(f"[cyan]Sending email to {config['to']}[/cyan]") + # Send the message via SMTP server with optional authentication + # Check if using SSL (port 465) or TLS (port 587) + use_ssl = config.get('use_ssl', False) + use_tls = config.get('use_tls', False) + + if use_ssl: + # Use SMTP_SSL for implicit SSL (typically port 465) + s = smtplib.SMTP_SSL(config['smtp'], config['smtp_port'], timeout=30) + console.print("[dim]Connected using SSL...[/dim]") if argopts.get('debugmod', False) else None + else: + # Use regular SMTP (typically port 587 with STARTTLS or port 25) + s = smtplib.SMTP(config['smtp'], config['smtp_port'], timeout=30) + + # Use TLS if configured + if use_tls: + console.print("[dim]Starting TLS...[/dim]") if argopts.get('debugmod', False) else None + s.starttls() + + # Enable debug output if in debug mode + if argopts.get('debugmod', False): + s.set_debuglevel(1) + + # Authenticate if username is provided + smtp_username = config.get('smtp_username') + if smtp_username: + # Get password from environment variable + smtp_password = os.environ.get('GITCHECK_SMTP_PASSWORD', '') + if not smtp_password: + console.print("[yellow]Warning: SMTP username provided but GITCHECK_SMTP_PASSWORD environment variable not set[/yellow]") + else: + console.print(f"[dim]Authenticating as {smtp_username}...[/dim]") if argopts.get('debugmod', False) else None + s.login(smtp_username, smtp_password) + # sendmail function takes 3 arguments: sender's address, recipient's address # and message to send - here it is sent as one string. + console.print("[dim]Sending message...[/dim]") if argopts.get('debugmod', False) else None s.sendmail(config['from'], config['to'], msg.as_string()) s.quit() + console.print("[green]Email sent successfully![/green]") except SMTPException as e: - print("Error sending email : %s" % str(e)) + console.print(f"[red]Error sending email: {str(e)}[/red]") + console.print("[yellow]Tip: Try running with --debug flag for more details[/yellow]") + except Exception as e: + console.print(f"[red]Unexpected error: {str(e)}[/red]") + console.print("[yellow]Check your mail.properties configuration and network connection[/yellow]") def initEmailConfig(): config = { - 'smtp': 'yourserver', - 'smtp_port': 25, - 'from': 'from@server.com', - 'to': 'to@server.com' + 'smtp': 'smtp.example.com', + 'smtp_port': 587, + 'smtp_username': 'your_username@example.com', + 'use_tls': True, + 'use_ssl': False, + 'from': 'from@example.com', + 'to': 'to@example.com' } userPath = expanduser('~') - saveFilePath = r'%s\Documents\.gitcheck' % userPath + saveFilePath = os.path.join(userPath, '.gitcheck') if not os.path.exists(saveFilePath): os.makedirs(saveFilePath) - filename = saveFilePath + '\mail.properties' - json.dump(config, fp=open(filename, 'w'), indent=4) - print('Please, modify config file located here : %s' % filename) + filename = os.path.join(saveFilePath, 'mail.properties') + with open(filename, 'w') as fp: + json.dump(config, fp=fp, indent=4) + console.print(f'[yellow]Please, modify config file located here: {filename}[/yellow]') + console.print('[yellow]Note: Set GITCHECK_SMTP_PASSWORD environment variable for SMTP authentication[/yellow]') def readDefaultConfig(): @@ -446,43 +969,63 @@ def readDefaultConfig(): def usage(): - print("Usage: %s [OPTIONS]" % (sys.argv[0])) - print("Check multiple git repository in one pass") - print("== Common options ==") - print(" -v, --verbose Show files & commits") - print(" --debug Show debug message") - print(" -r, --remote force remote update (slow)") - print(" -u, --untracked Show untracked files") - print(" -b, --bell bell on action needed") - print(" -w , --watch= after displaying, wait and run again") - print(" -i , --ignore-branch= ignore branches matching the regex ") - print(" -d , --dir= Search for repositories (can be used multiple times)") - print(" -m , --maxdepth= Limit the depth of repositories search") - print(" -q, --quiet Display info only when repository needs action") - print(" -e, --email Send an email with result as html, using mail.properties parameters") - print(" -a, --all-branch Show the status of all branches") - print(" -l , --localignore= ignore changes in local files which match the regex ") - print(" --init-email Initialize mail.properties file (has to be modified by user using JSON Format)") + console.print(f"[bold cyan]Usage:[/bold cyan] {sys.argv[0]} [OPTIONS]") + console.print("[bold]Check multiple git repository in one pass[/bold]\n") + console.print("[bold yellow]== Common options ==[/bold yellow]") + console.print(" [green]-v, --verbose[/green] Show files & commits") + console.print(" [green]--debug[/green] Show debug message") + console.print(" [green]-r, --remote[/green] force remote update (slow)") + console.print(" [green]-p, --auto-pull[/green] Auto-pull when safe (no conflicts, no local changes)") + console.print(" [green]-j, --parallel[/green] Use parallel processing for remote updates (faster)") + console.print(" [green]--jobs=[/green] Number of parallel jobs (default: 4)") + console.print(" [green]--use-https[/green] Convert git:// and SSH URLs to HTTPS (firewall bypass)") + console.print(" [green]--validate-token[/green] Validate GitLab token before checking repositories") + console.print(" [green]-u, --untracked[/green] Show untracked files") + console.print(" [green]-b, --bell[/green] bell on action needed") + console.print(" [green]-w , --watch=[/green] after displaying, wait and run again") + console.print(" [green]-i , --ignore-branch=[/green] ignore branches matching the regex ") + console.print(" [green]-d , --dir=[/green] Search for repositories (can be used multiple times)") + console.print(" [green]-m , --maxdepth=[/green] Limit the depth of repositories search") + console.print(" [green]-q, --quiet[/green] Display info only when repository needs action") + console.print(" [green]-e, --email[/green] Send an email with result as html, using mail.properties parameters") + console.print(" [green]-a, --all-branch[/green] Show the status of all branches") + console.print(" [green]-l , --localignore=[/green] ignore changes in local files which match the regex ") + console.print(" [green]-I, --interactive[/green] Interactive mode: review and commit/discard changes with TortoiseGit") + console.print(" [green]--init-email[/green] Initialize mail.properties file (has to be modified by user using JSON Format)") + console.print(" [green]--ssh-key=[/green] Path to SSH private key for git operations") + console.print("\n[bold yellow]== Environment Variables ==[/bold yellow]") + console.print(" [green]GITCHECK_SMTP_PASSWORD[/green] SMTP password for email authentication (if smtp_username is set)") + console.print(" [green]GITCHECK_SSH_KEY[/green] Path to SSH private key (alternative to --ssh-key option)") + console.print(" [green]GITLAB_TOKEN[/green] GitLab/private repo personal access token (for HTTPS with --use-https)") def main(): + # Rich console handles colors automatically on all platforms try: opts, args = getopt.getopt( sys.argv[1:], - "vhrubw:i:d:m:q:e:al:", + "vhrubpjw:i:d:m:qeal:I", [ - "verbose", "debug", "help", "remote", "untracked", "bell", "watch=", "ignore-branch=", - "dir=", "maxdepth=", "quiet", "email", "init-email", "all-branch", "localignore=" + "verbose", "debug", "help", "remote", "untracked", "bell", "auto-pull", "parallel", "watch=", "ignore-branch=", + "dir=", "maxdepth=", "quiet", "email", "init-email", "all-branch", "localignore=", "interactive", + "ssh-key=", "jobs=", "use-https", "validate-token" ] ) - except getopt.GetoptError as e: - if e.opt == 'w' and 'requires argument' in e.msg: - print("Please indicate nb seconds for refresh ex: gitcheck -w10") + except getopt.GetoptError as error: + if error.opt == 'w' and 'requires argument' in error.msg: + console.print("[red]Please indicate nb seconds for refresh ex: gitcheck -w10[/red]") else: - print(e.msg) + console.print(f"[red]{error.msg}[/red]") sys.exit(2) readDefaultConfig() + + # Check for SSH key from environment variable + env_ssh_key = os.environ.get('GITCHECK_SSH_KEY') + if env_ssh_key: + argopts['ssh_key'] = env_ssh_key + showDebug(f"SSH key from environment: {env_ssh_key}") + for opt, arg in opts: if opt in ["-v", "--verbose"]: argopts['verbose'] = True @@ -490,6 +1033,21 @@ def main(): argopts['debugmod'] = True elif opt in ["-r", "--remote"]: argopts['checkremote'] = True + elif opt in ["-p", "--auto-pull"]: + argopts['autopull'] = True + # Auto-pull requires remote check + argopts['checkremote'] = True + elif opt in ["-j", "--parallel"]: + argopts['parallel'] = True + elif opt in ["--jobs"]: + try: + argopts['jobs'] = min(int(arg), 10) # Limit to max 10 jobs + if argopts['jobs'] < 1: + console.print("[red]Number of jobs must be at least 1[/red]") + sys.exit(2) + except ValueError: + console.print(f"[red]option {opt} requires int value[/red]") + sys.exit(2) elif opt in ["-u", "--untracked"]: argopts['checkUntracked'] = True elif opt in ["-b", "--bell"]: @@ -513,7 +1071,7 @@ def main(): try: argopts['depth'] = int(arg) except ValueError: - print("option %s requires int value" % opt) + console.print(f"[red]option {opt} requires int value[/red]") sys.exit(2) elif opt in ["-q", "--quiet"]: argopts['quiet'] = True @@ -521,9 +1079,24 @@ def main(): argopts['email'] = True elif opt in ["-a", "--all-branch"]: argopts['checkall'] = True + elif opt in ["-I", "--interactive"]: + argopts['interactive'] = True + # Interactive mode implies checking remotes first + argopts['checkremote'] = True elif opt in ["--init-email"]: initEmailConfig() sys.exit(0) + elif opt in ["--ssh-key"]: + # Accept both .ppk (Pageant) and regular SSH keys + if os.path.exists(arg) or arg.lower().endswith('.ppk'): + argopts['ssh_key'] = arg + else: + console.print(f"[red]SSH key file not found: {arg}[/red]") + sys.exit(2) + elif opt in ["--use-https"]: + argopts['use_https'] = True + elif opt in ["--validate-token"]: + argopts['validate_token'] = True elif opt in ["-h", "--help"]: usage() sys.exit(0) @@ -541,7 +1114,7 @@ def main(): except (KeyboardInterrupt, SystemExit): raise except Exception as e: - print ("Unexpected error:", str(e)) + console.print(f"[red]Unexpected error: {str(e)}[/red]") if argopts.get('watchInterval', 0) > 0: time.sleep(argopts.get('watchInterval', 0)) diff --git a/gitcheck/https_utils.py b/gitcheck/https_utils.py new file mode 100644 index 0000000..d474b3c --- /dev/null +++ b/gitcheck/https_utils.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +HTTPS/OAuth token management utilities for gitcheck + +This module handles: +- Converting git://, SSH URLs to HTTPS +- OAuth token injection for GitLab/GitHub +- Interactive token prompting +- Persistent token storage +- Token expiration detection and retry logic +""" + +import os +import sys +import re +import subprocess + +from rich.prompt import Prompt + + +def promptForToken(console, console_lock): + """ + Prompt user for GitLab/GitHub personal access token + + Args: + console: Rich Console instance + console_lock: Threading lock for console output + + Returns: + str: Token entered by user, or None if skipped + """ + with console_lock: + console.print("\n[yellow]⚠ GITLAB_TOKEN environment variable not set[/yellow]") + console.print("[yellow]For GitLab/private repos, you need a personal access token[/yellow]") + console.print("[cyan]Get your token at: https://git.servisys.com/-/user_settings/personal_access_tokens[/cyan]") + console.print("[dim]Required scopes: read_repository, write_repository (for pull operations)[/dim]") + + token_input = Prompt.ask( + "\n[cyan]Enter your GitLab/Git personal access token (or press Enter to skip)[/cyan]", + password=True, + default="" + ) + + if token_input.strip(): + return token_input.strip() + else: + console.print("[yellow]Skipping OAuth token injection for this session...\n[/yellow]") + return None + + +def promptForNewToken(console, console_lock, reason="expired or invalid"): + """ + Prompt user for a new token when the current one is expired/invalid + + Args: + console: Rich Console instance + console_lock: Threading lock for console output + reason: Reason why new token is needed + + Returns: + str: New token entered by user, or None if skipped + """ + with console_lock: + console.print(f"\n[yellow]⚠ GitLab token appears to be {reason}[/yellow]") + console.print("[yellow]Authentication failed. Please provide a new token.[/yellow]") + console.print("[cyan]Get your token at: https://git.servisys.com/-/user_settings/personal_access_tokens[/cyan]") + console.print("[dim]Required scopes: read_repository, write_repository (for pull operations)[/dim]") + + token_input = Prompt.ask( + "\n[cyan]Enter your new GitLab/Git personal access token (or press Enter to skip)[/cyan]", + password=False, + default="" + ) + + if token_input.strip(): + return token_input.strip() + else: + console.print("[yellow]No token provided. Skipping...[/yellow]\n") + return None + + +def saveTokenPermanently(token, console, console_lock): + """ + Save token to environment variable permanently + + Args: + token: Token to save + console: Rich Console instance + console_lock: Threading lock for console output + + Returns: + bool: True if saved successfully, False otherwise + """ + try: + if sys.platform == 'win32': + # Windows: Use setx command to save permanently + subprocess.run( + ['setx', 'GITLAB_TOKEN', token], + capture_output=True, + check=True + ) + with console_lock: + console.print("[green]✓ Token saved to Windows registry (GITLAB_TOKEN)[/green]") + + # Reload the token from registry into current session + # This way gitcheck works immediately without restarting terminal + try: + result = subprocess.run( + ['powershell', '-Command', + "[System.Environment]::GetEnvironmentVariable('GITLAB_TOKEN', 'User')"], + capture_output=True, + text=True, + check=True + ) + reloaded_token = result.stdout.strip() + if reloaded_token: + os.environ['GITLAB_TOKEN'] = reloaded_token + with console_lock: + console.print("[green]✓ Token reloaded in current session[/green]") + else: + # Fallback: use the token we just tried to save + os.environ['GITLAB_TOKEN'] = token + except Exception as reload_error: + with console_lock: + console.print(f"[yellow]Warning: Could not reload token from registry: {reload_error}[/yellow]") + # Fallback: use the token we just tried to save + os.environ['GITLAB_TOKEN'] = token + with console_lock: + console.print("[yellow]Note: Token saved but you may need to restart terminal[/yellow]") + else: + # Unix-like: Append to shell profile + shell_profile = os.path.expanduser('~/.bashrc') + if os.path.exists(os.path.expanduser('~/.zshrc')): + shell_profile = os.path.expanduser('~/.zshrc') + + # Read existing file and remove old token line + with open(shell_profile, 'r') as f: + lines = f.readlines() + + with open(shell_profile, 'w') as f: + for line in lines: + if 'GITLAB_TOKEN' not in line: + f.write(line) + f.write(f'\nexport GITLAB_TOKEN="{token}"\n') + + with console_lock: + console.print(f"[green]✓ Token saved to {shell_profile}[/green]") + console.print("[yellow]Note: Run 'source {shell_profile}' or restart terminal[/yellow]") + + # Update current process environment + os.environ['GITLAB_TOKEN'] = token + + return True + except Exception as e: + with console_lock: + console.print(f"[yellow]Warning: Could not save token permanently: {str(e)}[/yellow]") + console.print("[yellow]Token will be used for this session only[/yellow]") + # Still update current process + os.environ['GITLAB_TOKEN'] = token + return False + + +def convertRemoteToHttps(rep, remote_name, gitlab_token, git_exec_func, force_update=False): + """ + Convert git:// or SSH remote URLs to HTTPS for firewall compatibility + + Args: + rep: Repository path + remote_name: Name of the remote (e.g., 'origin') + gitlab_token: OAuth token for authentication (can be None) + git_exec_func: Function to execute git commands + force_update: If True, update URL even if already HTTPS with token (for new token) + + Returns: + tuple: (success: bool, message: str) + """ + try: + # Get current remote URL + result = git_exec_func(rep, f"remote get-url {remote_name}") + current_url = result.strip() + + if not current_url: + return False, "No remote URL found" + + # Strip old token from URL to get clean URL for re-conversion + clean_url = current_url + if 'oauth2:' in current_url: + # Remove old token: https://oauth2:OLD_TOKEN@host/path -> https://host/path + clean_url = re.sub(r'https://oauth2:[^@]+@', 'https://', current_url) + + # Check if already HTTPS with current token (and not forcing update) + if not force_update and current_url.startswith('https://') and gitlab_token and f'oauth2:{gitlab_token}@' in current_url: + return False, "Already using HTTPS with current token" + + new_url = None + + # Convert git:// to https:// + if clean_url.startswith('git://'): + new_url = clean_url.replace('git://', 'https://') + + # Convert SSH URLs (git@host:path) to HTTPS + elif clean_url.startswith('git@'): + # Pattern: git@github.com:user/repo.git -> https://github.com/user/repo.git + match = re.match(r'git@([^:]+):(.+)', clean_url) + if match: + host = match.group(1) + path = match.group(2) + new_url = f'https://{host}/{path}' + + # Convert ssh:// URLs + elif clean_url.startswith('ssh://'): + # Pattern: ssh://git@github.com/user/repo.git -> https://github.com/user/repo.git + new_url = clean_url.replace('ssh://git@', 'https://') + new_url = new_url.replace('ssh://', 'https://') + + # If already HTTPS (use clean URL without old token), inject new token if available + elif clean_url.startswith('https://') and gitlab_token: + # Pattern: https://host/path -> https://oauth2:token@host/path + match = re.match(r'https://(.+)', clean_url) + if match: + new_url = f'https://oauth2:{gitlab_token}@{match.group(1)}' + + if new_url: + # Inject OAuth token if available and not already present + if gitlab_token and 'oauth2:' not in new_url: + # Pattern: https://host/path -> https://oauth2:token@host/path + match = re.match(r'https://(.+)', new_url) + if match: + new_url = f'https://oauth2:{gitlab_token}@{match.group(1)}' + + # Update the remote URL + git_exec_func(rep, f"remote set-url {remote_name} {new_url}") + + # Sanitize token in display message + display_url = new_url + if gitlab_token: + display_url = new_url.replace(gitlab_token, '***TOKEN***') + + return True, f"Converted {current_url} -> {display_url}" + else: + return False, "URL format not recognized for conversion" + + except Exception as e: + return False, f"Error: {str(e)}" + + +def ensureHttpsRemotes(rep, get_remotes_func, convert_func, verbose=False, console=None, console_lock=None, force_update=False): + """ + Ensure all remotes use HTTPS URLs + + Args: + rep: Repository path + get_remotes_func: Function to get list of remotes + convert_func: Function to convert a single remote + verbose: Whether to print verbose output + console: Rich Console instance (required if verbose=True) + console_lock: Threading lock (required if verbose=True) + force_update: If True, update URLs even if already HTTPS (for new token) + + Returns: + tuple: (converted: bool, info: list of tuples) + """ + try: + remotes = get_remotes_func(rep) + converted = [] + + for remote in remotes: + success, message = convert_func(rep, remote, force_update=force_update) + if success: + converted.append((remote, message)) + if verbose and console and console_lock: + with console_lock: + console.print(f" [cyan]→ {remote}: {message}[/cyan]") + + return len(converted) > 0, converted + except Exception as e: + return False, str(e) + + +def isAuthenticationError(error_message): + """ + Check if error message indicates authentication failure + + Args: + error_message: Error message string + + Returns: + bool: True if authentication error detected + """ + error_msg = error_message.lower() + + auth_indicators = [ + 'authentication failed', + 'invalid credentials', + 'token expired', + 'unauthorized', + 'http basic: access denied', + 'could not read username', + 'could not read password', + '401', + '403' + ] + + return any(indicator in error_msg for indicator in auth_indicators) + + +def isSSLError(error_message): + """ + Check if error message indicates SSL/certificate problem + + Args: + error_message: Error message string + + Returns: + bool: True if SSL error detected + """ + error_msg = error_message.lower() + + ssl_indicators = [ + 'ssl certificate problem', + 'certificate verify failed', + 'unable to get local issuer certificate', + 'self signed certificate', + 'certificate has expired', + 'ssl handshake failed' + ] + + return any(indicator in error_msg for indicator in ssl_indicators) + + +def getSSLErrorHelp(): + """ + Get helpful message for SSL certificate errors + + Returns: + str: Help message for SSL errors + """ + return """ +[yellow]SSL Certificate Error Detected[/yellow] + +This usually happens with corporate security tools (Zscaler, Netskope) or public WiFi +that intercepts HTTPS traffic. + +[cyan]Solutions for Zscaler/corporate proxy:[/cyan] + 1. [green]Export Zscaler root certificate:[/green] + - Open Chrome → Settings → Privacy and Security → Manage Certificates + - Find "Zscaler Root CA" in Trusted Root Certification Authorities + - Export as Base64 .cer file + - Save as C:\\certs\\zscaler.pem + + 2. [green]Configure Git to use it:[/green] + git config --global http.sslCAInfo "C:/certs/zscaler.pem" + + 3. [green]Or use Windows certificate store (includes Zscaler):[/green] + git config --global http.sslBackend schannel + + 4. [green]Temporary bypass (not recommended):[/green] + git config --global http.sslVerify false + [yellow](Remember to re-enable later!)[/yellow] + +[cyan]For public WiFi:[/cyan] + - Switch to trusted network (mobile hotspot, home WiFi) + +[dim]Most secure: Option 3 (schannel) works immediately with corporate proxies.[/dim] +""" diff --git a/gitcheck/validate_token.py b/gitcheck/validate_token.py new file mode 100644 index 0000000..0268647 --- /dev/null +++ b/gitcheck/validate_token.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +GitLab Token Validator + +Validates GitLab/GitHub personal access tokens before running gitcheck. +Can be used standalone for TactRMM deployment or as a pre-check. + +Usage: + python -m gitcheck.validate_token # Interactive validation + python -m gitcheck.validate_token --check-only # Non-interactive check (exit code only) + python -m gitcheck.validate_token --host git.servisys.com # Specific host +""" + +import os +import sys +import argparse +import urllib.request +import urllib.error +import json + +from rich.console import Console + +console = Console() + + +def check_token_validity(token, gitlab_host="git.servisys.com"): + """ + Validate token by making a test API call to GitLab + + Args: + token: GitLab personal access token + gitlab_host: GitLab server hostname + + Returns: + tuple: (is_valid: bool, message: str, user_info: dict or None) + """ + if not token: + return False, "Token is empty", None + + # Test token by calling GitLab API /user endpoint + url = f"https://{gitlab_host}/api/v4/user" + + try: + req = urllib.request.Request(url) + req.add_header("PRIVATE-TOKEN", token) + + with urllib.request.urlopen(req, timeout=10) as response: + if response.status == 200: + user_data = json.loads(response.read().decode('utf-8')) + username = user_data.get('username', 'Unknown') + name = user_data.get('name', 'Unknown') + return True, f"Token valid for user: {name} (@{username})", user_data + else: + return False, f"Unexpected response code: {response.status}", None + + except urllib.error.HTTPError as e: + if e.code == 401: + return False, "Token is invalid or expired (401 Unauthorized)", None + elif e.code == 403: + return False, "Token lacks required permissions (403 Forbidden)", None + else: + return False, f"HTTP Error {e.code}: {e.reason}", None + + except urllib.error.URLError as e: + return False, f"Network error: {str(e.reason)}", None + + except Exception as e: + return False, f"Validation error: {str(e)}", None + + +def prompt_for_token(gitlab_host="git.servisys.com"): + """Prompt user for a new token""" + from rich.prompt import Prompt + + console.print(f"\n[cyan]Get your token at: https://{gitlab_host}/-/user_settings/personal_access_tokens[/cyan]") + console.print("[dim]Required scopes: read_api, read_repository, write_repository[/dim]") + + token = Prompt.ask( + "\n[cyan]Enter your GitLab personal access token[/cyan]", + password=False + ) + + return token.strip() if token else None + + +def save_token_permanently(token): + """Save token to environment variable permanently""" + import subprocess + + try: + if sys.platform == 'win32': + # Windows: Use setx command to save permanently + subprocess.run( + ['setx', 'GITLAB_TOKEN', token], + capture_output=True, + check=True + ) + console.print("[green]✓ Token saved to Windows registry (GITLAB_TOKEN)[/green]") + + # Reload the token from registry into current session + # This way the script works immediately without restarting terminal + try: + result = subprocess.run( + ['powershell', '-Command', + "[System.Environment]::GetEnvironmentVariable('GITLAB_TOKEN', 'User')"], + capture_output=True, + text=True, + check=True + ) + reloaded_token = result.stdout.strip() + if reloaded_token: + os.environ['GITLAB_TOKEN'] = reloaded_token + console.print("[green]✓ Token reloaded in current session[/green]") + else: + # Fallback: use the token we just tried to save + os.environ['GITLAB_TOKEN'] = token + except Exception as reload_error: + console.print(f"[yellow]Warning: Could not reload token from registry: {reload_error}[/yellow]") + # Fallback: use the token we just tried to save + os.environ['GITLAB_TOKEN'] = token + console.print("[yellow]Note: Token saved but you may need to restart terminal[/yellow]") + else: + # Unix-like: Append to shell profile + shell_profile = os.path.expanduser('~/.bashrc') + if os.path.exists(os.path.expanduser('~/.zshrc')): + shell_profile = os.path.expanduser('~/.zshrc') + + # Read existing file and remove old token line + with open(shell_profile, 'r') as f: + lines = f.readlines() + + with open(shell_profile, 'w') as f: + for line in lines: + if 'GITLAB_TOKEN' not in line: + f.write(line) + f.write(f'\nexport GITLAB_TOKEN="{token}"\n') + + console.print(f"[green]✓ Token saved to {shell_profile}[/green]") + console.print("[yellow]Note: Run 'source {shell_profile}' or restart terminal[/yellow]") + + # Update current process environment + os.environ['GITLAB_TOKEN'] = token + + return True + + except Exception as e: + console.print(f"[yellow]Warning: Could not save token permanently: {str(e)}[/yellow]") + console.print("[yellow]Token will be used for this session only[/yellow]") + # Still update current process + os.environ['GITLAB_TOKEN'] = token + return False + + +def main(): + parser = argparse.ArgumentParser( + description="Validate GitLab personal access token", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s # Interactive validation with prompt + %(prog)s --check-only # Just check, no prompt (for scripts) + %(prog)s --host git.example.com # Use different GitLab instance + """ + ) + + parser.add_argument( + '--host', + default='git.servisys.com', + help='GitLab server hostname (default: git.servisys.com)' + ) + + parser.add_argument( + '--check-only', + action='store_true', + help='Only check token validity, do not prompt for new token' + ) + + parser.add_argument( + '--quiet', + action='store_true', + help='Minimal output (useful for scripts)' + ) + + args = parser.parse_args() + + # Get token from environment + token = os.environ.get('GITLAB_TOKEN', '').strip() + + if not token: + if not args.quiet: + console.print("[yellow]⚠ GITLAB_TOKEN environment variable not set[/yellow]") + + if args.check_only: + if not args.quiet: + console.print("[red]✗ No token configured[/red]") + sys.exit(1) + + # Interactive mode: prompt for token + token = prompt_for_token(args.host) + if not token: + console.print("[red]✗ No token provided[/red]") + sys.exit(1) + + # Validate token + if not args.quiet: + console.print(f"\n[cyan]Validating token against {args.host}...[/cyan]") + + is_valid, message, user_info = check_token_validity(token, args.host) + + if is_valid: + if not args.quiet: + console.print(f"[green]✓ {message}[/green]") + + # If token was just entered, save it + if not os.environ.get('GITLAB_TOKEN'): + if not args.quiet: + console.print("\n[cyan]Saving token...[/cyan]") + save_token_permanently(token) + + sys.exit(0) + else: + if not args.quiet: + console.print(f"[red]✗ Token validation failed: {message}[/red]") + + if args.check_only: + sys.exit(1) + + # Interactive mode: allow retry + console.print("\n[yellow]Would you like to enter a new token?[/yellow]") + new_token = prompt_for_token(args.host) + + if not new_token: + console.print("[red]✗ No token provided[/red]") + sys.exit(1) + + # Validate new token + is_valid, message, user_info = check_token_validity(new_token, args.host) + + if is_valid: + console.print(f"[green]✓ {message}[/green]") + console.print("\n[cyan]Saving token...[/cyan]") + save_token_permanently(new_token) + sys.exit(0) + else: + console.print(f"[red]✗ New token is also invalid: {message}[/red]") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/mygitcheck.py.sample b/mygitcheck.py.sample deleted file mode 100644 index 67072b5..0000000 --- a/mygitcheck.py.sample +++ /dev/null @@ -1,53 +0,0 @@ -from colored import fg, bg, attr -from colored import colored as cobj - -# In developpement mode, for use -# python ~/mygitcheck.py -# python ~/mygitcheck.py | grep magenta - -defaultcolor = attr('reset') + fg('white') -colortheme = { - 'default': defaultcolor, - 'prjchanged': attr('reset') + attr('bold') + fg('deep_pink_1a'), - 'prjremote': attr('reverse') + fg('light_cyan'), - 'prjname': attr('reset') + fg('chartreuse_1'), - 'reponame': attr('reset') + fg('light_goldenrod_2b'), - 'branchname': defaultcolor, - 'fileupdated': attr('reset') + fg('light_goldenrod_2b'), - 'remoteto': attr('reset') + fg('deep_sky_blue_3b'), - 'committo': attr('reset') + fg('violet'), - 'commitinfo': attr('reset') + fg('deep_sky_blue_3b'), - 'commitstate': attr('reset') + fg('deep_pink_1a'), - 'bell': "\a", - 'reset': "\033[2J\033[H" -} - - -def searchKeyByValue(search): - """Search keyname by value""" - c = cobj(0) - for key, value in c.paint.iteritems(): - if value == str(search): - return key - -def searchMaxColorName(): - """Search Max length colorname""" - c = cobj(0) - maxi = 0 - for key, value in c.paint.iteritems(): - lencolor = len(str(key)) - maxi = max(lencolor, maxi) - - print "Lencolor: %s" % maxi - -if __name__ == "__main__": - #searchMaxColorName() - for idx in range(0, 255): - print "%s%19s %s%s%s%s" % ( - attr('reset') + fg(idx), - searchKeyByValue(idx), - 'Normal', - attr('reset') + fg(idx) + attr('bold') + 'Bold', - attr('reset') + fg(idx) + attr('underlined') + 'Underline', - attr('reset') + fg(idx) + attr('reverse') + 'Reverse', - ) \ No newline at end of file diff --git a/pep8.rc b/pep8.rc deleted file mode 100644 index ea4280b..0000000 --- a/pep8.rc +++ /dev/null @@ -1,4 +0,0 @@ -[pep8] -ignore = E401,E221,E701,E202 -max-line-length = 140 - diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9f85961 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "gitcheck" +version = "1.0.2" +description = "Check multiple git repository in one pass" +readme = "README.rst" +authors = [ + {name = "Bruno Adele", email = "bruno@adele.im"} +] +license = {text = "GPL"} +requires-python = ">=3.8" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License (GPL)", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Software Development", + "Topic :: Software Development :: Version Control", + "Topic :: Utilities", +] +dependencies = [ + "rich>=13.0.0", +] + +[project.urls] +Homepage = "https://github.com/badele/gitcheck" +Repository = "https://github.com/badele/gitcheck" + +[project.scripts] +gitcheck = "gitcheck.gitcheck:main" +gitcheck_token = "gitcheck.validate_token:main" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", +] + +[tool.setuptools] +py-modules = ["gitcheck.gitcheck"] + +[tool.setuptools.packages.find] +where = ["."] +include = ["gitcheck*"] diff --git a/requirements/base.txt b/requirements/base.txt deleted file mode 100644 index 6b7dbb2..0000000 --- a/requirements/base.txt +++ /dev/null @@ -1 +0,0 @@ -colored==1.2.1 \ No newline at end of file diff --git a/requirements/test.txt b/requirements/test.txt deleted file mode 100644 index ab47288..0000000 --- a/requirements/test.txt +++ /dev/null @@ -1,4 +0,0 @@ --r base.txt -pep8==1.4.6 -coveralls==0.3 -gitpython==1.0.1 \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 337518f..0000000 --- a/setup.py +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import re -from setuptools import setup - -PYPI_RST_FILTERS = ( - # Replace code-blocks - (r'\.\.\s? code-block::\s*(\w|\+)+', '::'), - # Replace image - (r'\.\.\s? image::.*', ''), - # Remove travis ci badge - (r'.*travis-ci\.org/.*', ''), - # Remove pypip.in badges - (r'.*pypip\.in/.*', ''), - (r'.*crate\.io/.*', ''), - (r'.*coveralls\.io/.*', ''), -) - - -def rst(filename): - ''' -Load rst file and sanitize it for PyPI. -Remove unsupported github tags: -- code-block directive -- travis ci build badge -''' - content = open(filename).read() - for regex, replacement in PYPI_RST_FILTERS: - content = re.sub(regex, replacement, content) - return content - - -setup( - name='gitcheck', - version='0.3.22', - description='Check multiple git repository in one pass', - long_description=rst('README.rst') + rst('CHANGELOG.txt'), - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Console', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU General Public License (GPL)', - 'Natural Language :: English', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: Implementation :: CPython', - 'Topic :: Software Development', - 'Topic :: Software Development :: Version Control', - 'Topic :: Utilities', - ], - author='Bruno Adele', - author_email='bruno@adele.im', - license='GPL', - url='https://github.com/badele/gitcheck', - setup_requires=[ - 'gitpython', - ], - tests_require=[], - test_suite='tests', - py_modules=['gitcheck.gitcheck',], - entry_points={ - 'console_scripts': ['gitcheck = gitcheck.gitcheck:main'] - } -)