From e7691c5811bcc9bff68eaf8e216efc7027208b6f Mon Sep 17 00:00:00 2001 From: Christian Tremblay Date: Thu, 7 Jan 2016 09:49:28 -0500 Subject: [PATCH 1/7] -q and -e don't need arguments As e is use as a sys.argv, I renamed getopt.GetoptError as e -> error for clarity Signed-off-by: Christian Tremblay --- gitcheck/gitcheck.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gitcheck/gitcheck.py b/gitcheck/gitcheck.py index 589e1ad..7297896 100755 --- a/gitcheck/gitcheck.py +++ b/gitcheck/gitcheck.py @@ -470,17 +470,17 @@ def main(): try: opts, args = getopt.getopt( sys.argv[1:], - "vhrubw:i:d:m:q:e:al:", + "vhrubw:i:d:m:qeal:", [ "verbose", "debug", "help", "remote", "untracked", "bell", "watch=", "ignore-branch=", "dir=", "maxdepth=", "quiet", "email", "init-email", "all-branch", "localignore=" ] ) - except getopt.GetoptError as e: - if e.opt == 'w' and 'requires argument' in e.msg: + except getopt.GetoptError as error: + if error.opt == 'w' and 'requires argument' in error.msg: print("Please indicate nb seconds for refresh ex: gitcheck -w10") else: - print(e.msg) + print(error.msg) sys.exit(2) readDefaultConfig() From d7b8125e5473aa6dbd32d1f1b3d103cf206e43a6 Mon Sep 17 00:00:00 2001 From: Christian Tremblay Date: Thu, 7 Jan 2016 14:47:06 -0500 Subject: [PATCH 2/7] Added colorama to support terminal colors in Windows Signed-off-by: Christian Tremblay --- gitcheck/gitcheck.py | 3 +++ requirements/base.txt | 3 ++- setup.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/gitcheck/gitcheck.py b/gitcheck/gitcheck.py index 7297896..3722a01 100755 --- a/gitcheck/gitcheck.py +++ b/gitcheck/gitcheck.py @@ -14,6 +14,7 @@ from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText import shlex +from colorama import init as windows_color_terminal from os.path import expanduser from time import strftime @@ -467,6 +468,8 @@ def usage(): def main(): + if 'win' in sys.platform: + windows_color_terminal() try: opts, args = getopt.getopt( sys.argv[1:], diff --git a/requirements/base.txt b/requirements/base.txt index 6b7dbb2..dbfeb7b 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1 +1,2 @@ -colored==1.2.1 \ No newline at end of file +colored==1.2.1 +colorama>=0.3 diff --git a/setup.py b/setup.py index 337518f..93ad962 100644 --- a/setup.py +++ b/setup.py @@ -59,7 +59,7 @@ def rst(filename): license='GPL', url='https://github.com/badele/gitcheck', setup_requires=[ - 'gitpython', + 'gitpython', 'colorama', ], tests_require=[], test_suite='tests', From e2684f9bb3adc8c155a5d1b04e1df37874817777 Mon Sep 17 00:00:00 2001 From: "Christian Tremblay, ing." Date: Thu, 27 Nov 2025 21:41:53 -0500 Subject: [PATCH 3/7] Migration to Python3, using rich --- .travis.yml | 14 ---- CHANGELOG.txt | 12 +++ MIGRATION.md | 122 +++++++++++++++++++++++++++ Makefile | 42 ---------- README.rst | 30 +++++-- coverage.rc | 24 ------ gitcheck/gitcheck.py | 186 +++++++++++++++++------------------------- mygitcheck.py.sample | 53 ------------ pep8.rc | 4 - pyproject.toml | 50 ++++++++++++ requirements/base.txt | 2 - requirements/test.txt | 4 - setup.py | 70 ---------------- 13 files changed, 282 insertions(+), 331 deletions(-) delete mode 100644 .travis.yml create mode 100644 MIGRATION.md delete mode 100644 Makefile delete mode 100644 coverage.rc delete mode 100644 mygitcheck.py.sample delete mode 100644 pep8.rc create mode 100644 pyproject.toml delete mode 100644 requirements/base.txt delete mode 100644 requirements/test.txt delete mode 100644 setup.py 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..6a004e3 100644 --- a/README.rst +++ b/README.rst @@ -5,23 +5,43 @@ 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 +Or for development: + +:: + + git clone https://github.com/badele/gitcheck.git + cd gitcheck + pip install -e . + +The project uses modern ``pyproject.toml`` for configuration. + Examples -------- 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 8b62992..5db6a73 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,14 +13,13 @@ from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText import shlex -from colorama import init as windows_color_terminal - from os.path import expanduser from time import strftime - import json -from colored import fg, bg, attr +from rich.console import Console + +console = Console() # Global vars argopts = {} @@ -36,22 +34,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', } @@ -67,7 +62,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 @@ -120,14 +115,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 @@ -138,14 +126,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 @@ -180,18 +161,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 = "" @@ -201,42 +176,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 != "": @@ -244,18 +209,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 @@ -269,9 +229,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)]) @@ -344,7 +304,7 @@ def gitExec(path, cmd): p = subprocess.Popen(cmdargs, stdout=PIPE, stderr=PIPE) output, errors = p.communicate() if p.returncode: - print('Failed running %s' % commandToExecute) + console.print(f'[red]Failed running {commandToExecute}[/red]') raise Exception(errors) return output.decode('utf-8') @@ -358,12 +318,12 @@ def gitcheck(): if argopts.get('checkremote', False): for r in repo: - print ("Updating %s remotes..." % r) + console.print(f"[cyan]Updating {r} remotes...[/cyan]") updateRemote(r) 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: @@ -378,7 +338,7 @@ def gitcheck(): html.msg += "\n

    Report created on %s

    \n" % html.timestamp if actionNeeded and argopts.get('bellOnActionNeeded', False): - print(colortheme['bell']) + console.bell() def sendReport(content): @@ -399,9 +359,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(filepath + '//result.html', 'w') as f: + f.write(htmlcontent) + console.print(f"[green]File saved under {filepath}\\result.html[/green]") # Record the MIME types of both parts - text/plain and text/html. part1 = MIMEText(text, 'plain') part2 = MIMEText(htmlcontent, 'html') @@ -412,7 +372,7 @@ def sendReport(content): msg.attach(part1) msg.attach(part2) try: - print ("Sending email to %s" % config['to']) + console.print(f"[cyan]Sending email to {config['to']}[/cyan]") # Send the message via local SMTP server. s = smtplib.SMTP(config['smtp'], config['smtp_port']) # sendmail function takes 3 arguments: sender's address, recipient's address @@ -420,7 +380,7 @@ def sendReport(content): s.sendmail(config['from'], config['to'], msg.as_string()) s.quit() except SMTPException as e: - print("Error sending email : %s" % str(e)) + console.print(f"[red]Error sending email: {str(e)}[/red]") def initEmailConfig(): @@ -436,8 +396,9 @@ def initEmailConfig(): 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) + 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]') def readDefaultConfig(): @@ -447,28 +408,27 @@ 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]-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]--init-email[/green] Initialize mail.properties file (has to be modified by user using JSON Format)") def main(): - if 'win' in sys.platform: - windows_color_terminal() + # Rich console handles colors automatically on all platforms try: opts, args = getopt.getopt( sys.argv[1:], @@ -480,9 +440,9 @@ def main(): ) except getopt.GetoptError as error: if error.opt == 'w' and 'requires argument' in error.msg: - print("Please indicate nb seconds for refresh ex: gitcheck -w10") + console.print("[red]Please indicate nb seconds for refresh ex: gitcheck -w10[/red]") else: - print(error.msg) + console.print(f"[red]{error.msg}[/red]") sys.exit(2) readDefaultConfig() @@ -516,7 +476,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 @@ -544,7 +504,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/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..fbfa62a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,50 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "gitcheck" +version = "1.0.0" +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" + +[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 dbfeb7b..0000000 --- a/requirements/base.txt +++ /dev/null @@ -1,2 +0,0 @@ -colored==1.2.1 -colorama>=0.3 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 93ad962..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', 'colorama', - ], - tests_require=[], - test_suite='tests', - py_modules=['gitcheck.gitcheck',], - entry_points={ - 'console_scripts': ['gitcheck = gitcheck.gitcheck:main'] - } -) From 32de8be9d8c94b06f1933aef4a40d9f819fa5ab2 Mon Sep 17 00:00:00 2001 From: "Christian Tremblay, ing." Date: Thu, 27 Nov 2025 23:25:30 -0500 Subject: [PATCH 4/7] Before going threadpool --- .python-version | 1 + README.rst | 182 ++++++++++++++++++ gitcheck/gitcheck.py | 443 ++++++++++++++++++++++++++++++++++++++++--- pyproject.toml | 6 + 4 files changed, 610 insertions(+), 22 deletions(-) create mode 100644 .python-version 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/README.rst b/README.rst index 6a004e3..be3c77c 100644 --- a/README.rst +++ b/README.rst @@ -32,6 +32,18 @@ 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: :: @@ -40,6 +52,15 @@ Or for development: 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. @@ -78,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 ~~~~~~~~~~~~~~~~~~~~~~ @@ -133,6 +205,116 @@ 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. + French version ~~~~~~~~~~~~~~ diff --git a/gitcheck/gitcheck.py b/gitcheck/gitcheck.py index 5db6a73..1a743e5 100755 --- a/gitcheck/gitcheck.py +++ b/gitcheck/gitcheck.py @@ -16,10 +16,15 @@ 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 console = Console() +console_lock = threading.Lock() # Global vars argopts = {} @@ -262,7 +267,112 @@ def getRemoteToPull(rep, remote, branch): def updateRemote(rep): - gitExec(rep, "remote update") + try: + # Use verbose mode to show what's being updated + result = gitExec(rep, "remote update") + 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 Exception as e: + 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: + 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 Exception as e: + result['error'] = str(e) + + return result # Get Default branch for repository @@ -301,11 +411,56 @@ def gitExec(path, cmd): 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) + + # 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) output, errors = p.communicate() if p.returncode: - console.print(f'[red]Failed running {commandToExecute}[/red]') - 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}') + raise Exception(error_msg) return output.decode('utf-8') @@ -317,9 +472,60 @@ def gitcheck(): actionNeeded = False if argopts.get('checkremote', False): - for r in repo: - console.print(f"[cyan]Updating {r} remotes...[/cyan]") - updateRemote(r) + 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 + 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)}") + 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 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: console.clear() @@ -339,12 +545,122 @@ def gitcheck(): if actionNeeded and argopts.get('bellOnActionNeeded', False): 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. @@ -359,9 +675,9 @@ def sendReport(content): html.path, content ) # Write html file to disk - with open(filepath + '//result.html', 'w') as f: + with open(os.path.join(filepath, 'result.html'), 'w') as f: f.write(htmlcontent) - console.print(f"[green]File saved under {filepath}\\result.html[/green]") + 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') @@ -373,32 +689,73 @@ def sendReport(content): msg.attach(part2) try: console.print(f"[cyan]Sending email to {config['to']}[/cyan]") - # Send the message via local SMTP server. - s = smtplib.SMTP(config['smtp'], config['smtp_port']) + # 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: 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' + 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(): @@ -414,6 +771,9 @@ def usage(): 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]-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") @@ -424,7 +784,12 @@ def usage(): 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)") def main(): @@ -432,10 +797,11 @@ def main(): try: opts, args = getopt.getopt( sys.argv[1:], - "vhrubw:i:d:m:qeal:", + "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=" ] ) except getopt.GetoptError as error: @@ -446,6 +812,13 @@ def main(): 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 @@ -453,6 +826,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'] = int(arg) + 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"]: @@ -484,9 +872,20 @@ 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 ["-h", "--help"]: usage() sys.exit(0) diff --git a/pyproject.toml b/pyproject.toml index fbfa62a..1205106 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,12 @@ Repository = "https://github.com/badele/gitcheck" [project.scripts] gitcheck = "gitcheck.gitcheck:main" +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", +] + [tool.setuptools] py-modules = ["gitcheck.gitcheck"] From f6a95d8b93caedea80c9b76ced8a6c213df49590 Mon Sep 17 00:00:00 2001 From: "Christian Tremblay, ing." Date: Wed, 3 Dec 2025 08:51:04 -0500 Subject: [PATCH 5/7] splitting the file. Dealing with token and modification --- gitcheck/gitcheck.py | 213 +++++++++++++++++++++++++------ gitcheck/https_utils.py | 274 ++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 3 files changed, 446 insertions(+), 43 deletions(-) create mode 100644 gitcheck/https_utils.py diff --git a/gitcheck/gitcheck.py b/gitcheck/gitcheck.py index 1a743e5..37db06b 100755 --- a/gitcheck/gitcheck.py +++ b/gitcheck/gitcheck.py @@ -23,6 +23,8 @@ from rich.prompt import Prompt, Confirm from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn +from . import https_utils + console = Console() console_lock = threading.Lock() @@ -266,16 +268,76 @@ 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): 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 - result = gitExec(rep, "remote update") + # 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 @@ -340,6 +402,27 @@ def autoPullRepository(rep, branch): 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 @@ -369,6 +452,8 @@ def processRepository(repo_path): result['pulled'] = True result['success'] = True + except subprocess.TimeoutExpired: + result['error'] = "Timeout (30s) - remote not responding" except Exception as e: result['error'] = str(e) @@ -407,7 +492,7 @@ 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) @@ -455,12 +540,27 @@ def gitExec(path, cmd): showDebug(f"Using SSH key: {ssh_key}") p = subprocess.Popen(cmdargs, stdout=PIPE, stderr=PIPE, env=env) - output, errors = p.communicate() + try: + output, errors = p.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + p.kill() + p.communicate() # Clean up + raise subprocess.TimeoutExpired(cmdargs, timeout) + if p.returncode: error_msg = errors.decode('utf-8') if errors else 'Unknown error' showDebug(f'Git command failed: {commandToExecute}') showDebug(f'Error output: {error_msg}') - raise Exception(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') @@ -472,40 +572,58 @@ def gitcheck(): actionNeeded = False if argopts.get('checkremote', False): + # Only prompt for token if use_https is enabled AND token is missing + # 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: token already exists, will be used automatically + 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 - 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} + 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)) - 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)}") + 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: @@ -521,6 +639,9 @@ def gitcheck(): 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): @@ -533,13 +654,17 @@ def gitcheck(): 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 @@ -774,6 +899,7 @@ def usage(): 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]-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") @@ -790,6 +916,7 @@ def usage(): 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(): @@ -801,7 +928,7 @@ def main(): [ "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=" + "ssh-key=", "jobs=", "use-https" ] ) except getopt.GetoptError as error: @@ -834,7 +961,7 @@ def main(): argopts['parallel'] = True elif opt in ["--jobs"]: try: - argopts['jobs'] = int(arg) + 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) @@ -886,6 +1013,8 @@ def main(): 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 ["-h", "--help"]: usage() sys.exit(0) diff --git a/gitcheck/https_utils.py b/gitcheck/https_utils.py new file mode 100644 index 0000000..4a758d8 --- /dev/null +++ b/gitcheck/https_utils.py @@ -0,0 +1,274 @@ +#!/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 + subprocess.run( + ['setx', 'GITLAB_TOKEN', token], + capture_output=True, + check=True + ) + with console_lock: + console.print("[green]✓ Token saved to environment variable GITLAB_TOKEN[/green]") + console.print("[yellow]Note: Restart your terminal for permanent effect[/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]") + + 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]") + 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', + '401', + '403' + ] + + return any(indicator in error_msg for indicator in auth_indicators) diff --git a/pyproject.toml b/pyproject.toml index 1205106..6e3dd8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gitcheck" -version = "1.0.0" +version = "1.0.1" description = "Check multiple git repository in one pass" readme = "README.rst" authors = [ From 5d5c0ec3624391bcbdeb71811da7b3d92e07672a Mon Sep 17 00:00:00 2001 From: "Christian Tremblay, ing." Date: Wed, 3 Dec 2025 09:33:36 -0500 Subject: [PATCH 6/7] token validation --- README.rst | 94 ++++++++++++++ gitcheck/gitcheck.py | 71 ++++++++++- gitcheck/https_utils.py | 40 +++++- gitcheck/validate_token.py | 252 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 3 +- 5 files changed, 453 insertions(+), 7 deletions(-) create mode 100644 gitcheck/validate_token.py diff --git a/README.rst b/README.rst index be3c77c..61b654a 100644 --- a/README.rst +++ b/README.rst @@ -195,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 @@ -315,8 +320,97 @@ Edit ``~/.ssh/config`` (or ``C:\Users\YourName\.ssh\config`` on Windows): 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/gitcheck/gitcheck.py b/gitcheck/gitcheck.py index 37db06b..e5b4269 100755 --- a/gitcheck/gitcheck.py +++ b/gitcheck/gitcheck.py @@ -572,7 +572,24 @@ def gitcheck(): actionNeeded = False if argopts.get('checkremote', False): - # Only prompt for token if use_https is enabled AND token is missing + # 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() @@ -584,7 +601,52 @@ def gitcheck(): os.environ['GITLAB_TOKEN'] = gitlab_token # Save permanently https_utils.saveTokenPermanently(gitlab_token, console, console_lock) - # else: token already exists, will be used automatically + 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: + # Check if it's an authentication error + if https_utils.isAuthenticationError(str(e)): + console.print(f"[red]✗ Token authentication failed: {str(e)}[/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: + if https_utils.isAuthenticationError(str(retry_error)): + console.print("[red]✗ New token also failed. Please run: gitcheck_token[/red]") + return + else: + # Non-auth error, can continue + console.print(f"[yellow]Warning: {str(retry_error)}[/yellow]") + else: + console.print("[yellow]No token provided. Cannot proceed.[/yellow]") + return + else: + # Non-authentication error, just warn and continue + console.print(f"[yellow]Warning for {test_repo}: {str(e)}[/yellow]") + console.print("[cyan]Continuing with other repositories...[/cyan]") max_workers = argopts.get('jobs', 4) # Default to 4 parallel jobs @@ -900,6 +962,7 @@ def usage(): 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") @@ -928,7 +991,7 @@ def main(): [ "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" + "ssh-key=", "jobs=", "use-https", "validate-token" ] ) except getopt.GetoptError as error: @@ -1015,6 +1078,8 @@ def main(): 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) diff --git a/gitcheck/https_utils.py b/gitcheck/https_utils.py index 4a758d8..f08c702 100644 --- a/gitcheck/https_utils.py +++ b/gitcheck/https_utils.py @@ -95,15 +95,40 @@ def saveTokenPermanently(token, console, console_lock): """ try: if sys.platform == 'win32': - # Windows: Use setx command + # 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 environment variable GITLAB_TOKEN[/green]") - console.print("[yellow]Note: Restart your terminal for permanent effect[/yellow]") + 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') @@ -123,12 +148,17 @@ def saveTokenPermanently(token, console, console_lock): 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 @@ -267,6 +297,10 @@ def isAuthenticationError(error_message): 'token expired', 'unauthorized', 'http basic: access denied', + 'ssl certificate problem', + 'certificate verify failed', + 'could not read username', + 'could not read password', '401', '403' ] 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/pyproject.toml b/pyproject.toml index 6e3dd8e..9f85961 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gitcheck" -version = "1.0.1" +version = "1.0.2" description = "Check multiple git repository in one pass" readme = "README.rst" authors = [ @@ -41,6 +41,7 @@ Repository = "https://github.com/badele/gitcheck" [project.scripts] gitcheck = "gitcheck.gitcheck:main" +gitcheck_token = "gitcheck.validate_token:main" [project.optional-dependencies] dev = [ From 1b13f89bb7bac488d0b316c174a99687948f44c3 Mon Sep 17 00:00:00 2001 From: "Christian Tremblay, ing." Date: Wed, 3 Dec 2025 09:43:51 -0500 Subject: [PATCH 7/7] Dealing with SSL error.... c'Est pas un trouble de token --- gitcheck/gitcheck.py | 31 +++++++++++++++----- gitcheck/https_utils.py | 63 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/gitcheck/gitcheck.py b/gitcheck/gitcheck.py index e5b4269..1dd62c7 100755 --- a/gitcheck/gitcheck.py +++ b/gitcheck/gitcheck.py @@ -621,9 +621,21 @@ def gitcheck(): 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 - if https_utils.isAuthenticationError(str(e)): - console.print(f"[red]✗ Token authentication failed: {str(e)}[/red]") + elif https_utils.isAuthenticationError(error_str): + console.print(f"[red]✗ Token authentication failed: {error_str}[/red]") # Prompt for new token new_token = promptForNewToken() @@ -634,18 +646,23 @@ def gitcheck(): gitExec(test_repo, "remote update", timeout=15) console.print("[green]✓ New token verified successfully[/green]") except Exception as retry_error: - if https_utils.isAuthenticationError(str(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 error, can continue - console.print(f"[yellow]Warning: {str(retry_error)}[/yellow]") + # 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 error, just warn and continue - console.print(f"[yellow]Warning for {test_repo}: {str(e)}[/yellow]") + # 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 diff --git a/gitcheck/https_utils.py b/gitcheck/https_utils.py index f08c702..d474b3c 100644 --- a/gitcheck/https_utils.py +++ b/gitcheck/https_utils.py @@ -297,8 +297,6 @@ def isAuthenticationError(error_message): 'token expired', 'unauthorized', 'http basic: access denied', - 'ssl certificate problem', - 'certificate verify failed', 'could not read username', 'could not read password', '401', @@ -306,3 +304,64 @@ def isAuthenticationError(error_message): ] 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] +"""