├── docs ├── .nojekyll ├── Gemfile ├── _config.yml ├── development.md ├── assets │ └── css │ │ └── custom-dark-moke.css ├── providers.md ├── index.md ├── contributors.md ├── CONTRIBUTING.md ├── configuration.md ├── installation.md ├── gallery.md └── usage.md ├── requirements.txt ├── .gitignore ├── setup.py ├── Makefile ├── src └── gitfetch │ ├── __init__.py │ ├── config.py │ ├── cache.py │ ├── text_patterns.py │ ├── cli.py │ └── fetcher.py ├── .github └── workflows │ ├── ci.yml │ ├── update-aur.yml │ ├── update-homebrew.yml │ └── jekyll-gh-pages.yml ├── flake.nix ├── flake.lock ├── pyproject.toml ├── CONTRIBUTING.md ├── README.md ├── tests └── test_cache.py └── LICENSE /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "jekyll", "~> 4.3" 4 | gem "just-the-docs", "~> 0.8.2" 5 | gem "jekyll-feed" 6 | gem "jekyll-sitemap" 7 | gem "jekyll-seo-tag" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # No Python dependencies required 2 | # Requires GitHub CLI (gh) to be installed and authenticated 3 | requests>=2.0.0 4 | readchar>=4.0.0 5 | pytest>=7.0.0 6 | webcolors>=24.11.1 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # chache 2 | *.pyc 3 | __pycache__/ 4 | .cache/ 5 | .DS_Store 6 | .vscode/ 7 | *.log 8 | .pytest_cache/ 9 | 10 | *.egg-info 11 | 12 | 13 | # env 14 | venv/ 15 | .venv 16 | env/ 17 | .env/ -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Setup configuration for gitfetch 3 | """ 4 | 5 | from setuptools import setup, find_packages 6 | 7 | setup( 8 | packages=find_packages(where="src"), 9 | package_dir={"": "src"}, 10 | ) 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test install dev clean lint 2 | 3 | # Install dependencies 4 | install: 5 | pip install -r requirements.txt 6 | 7 | # Install in development mode 8 | dev: 9 | pip install -e . 10 | 11 | # Run tests 12 | test: 13 | python3 -m pytest tests/ -v 14 | 15 | # Clean up cache and build files 16 | clean: 17 | find . -type d -name __pycache__ -exec rm -rf {} + 18 | find . -type d -name "*.egg-info" -exec rm -rf {} + 19 | find . -type d -name .pytest_cache -exec rm -rf {} + 20 | find . -type f -name "*.pyc" -delete 21 | find . -type f -name "*.pyo" -delete 22 | -------------------------------------------------------------------------------- /src/gitfetch/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | gitfetch - A neofetch-style CLI tool for git provider statistics 3 | """ 4 | 5 | import re 6 | from pathlib import Path 7 | 8 | 9 | def _get_version() -> str: 10 | """Get version from package metadata or pyproject.toml.""" 11 | try: 12 | # Try to get version from package metadata 13 | from importlib import metadata 14 | return metadata.version("gitfetch") 15 | except (ImportError): 16 | pass 17 | 18 | # Fallback: try to read from pyproject.toml (works in development) 19 | pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml" 20 | try: 21 | with open(pyproject_path, "r", encoding="utf-8") as f: 22 | content = f.read() 23 | match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE) 24 | if match: 25 | return match.group(1) 26 | except (FileNotFoundError, OSError): 27 | pass 28 | return "unknown" 29 | 30 | 31 | __version__ = _get_version() 32 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | jobs: 10 | test: 11 | name: Test on ${{ matrix.python-version }} 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: ["3.11", "3.10"] 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Python 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Cache pip 28 | uses: actions/cache@v4 29 | with: 30 | path: ~/.cache/pip 31 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} 32 | restore-keys: | 33 | ${{ runner.os }}-pip- 34 | 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 39 | # install this package so tests can import the package modules 40 | pip install -e . 41 | 42 | - name: Run tests 43 | run: | 44 | python -m pytest tests/ -v 45 | env: 46 | # reduce noisy deprecation warnings in CI logs 47 | PYTHONWARNINGS: ignore::DeprecationWarning 48 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Python 3.13 development environment"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = { self, nixpkgs, flake-utils }: 10 | flake-utils.lib.eachDefaultSystem (system: 11 | let 12 | pkgs = nixpkgs.legacyPackages.${system}; 13 | python = pkgs.python313; 14 | pythonPackages = python.pkgs; 15 | 16 | myPackage = pythonPackages.buildPythonPackage { 17 | pname = "gitfetch"; 18 | version = "1.3.2"; 19 | src = ./.; 20 | 21 | # Main dependencies 22 | propagatedBuildInputs = with pythonPackages; [ 23 | requests 24 | readchar 25 | setuptools 26 | webcolors 27 | ]; 28 | 29 | # Dependencies for testing 30 | nativeCheckInputs = with pythonPackages; [ 31 | pytest 32 | ]; 33 | 34 | # Disable tests when building 35 | doCheck = false; 36 | 37 | format = "pyproject"; 38 | }; 39 | in 40 | { 41 | # nix build 42 | packages.default = myPackage; 43 | 44 | # nix run 45 | apps.default = { 46 | type = "app"; 47 | program = "${myPackage}/bin/gitfetch"; 48 | }; 49 | } 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/update-aur.yml: -------------------------------------------------------------------------------- 1 | name: Update AUR Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | update-aur: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout main repo 13 | uses: actions/checkout@v4 14 | with: 15 | repository: Matars/gitfetch 16 | path: gitfetch 17 | 18 | - name: Get version from pyproject.toml 19 | id: get_version 20 | run: | 21 | VERSION=$(grep '^version =' gitfetch/pyproject.toml | sed 's/version = "\(.*\)"/\1/') 22 | echo "version=$VERSION" >> $GITHUB_OUTPUT 23 | 24 | - name: Checkout AUR repo 25 | uses: actions/checkout@v4 26 | with: 27 | repository: Matars/aur-gitfetch 28 | token: ${{ secrets.AUR_TAP_TOKEN }} 29 | path: aur-repo 30 | 31 | - name: Update PKGBUILD 32 | run: | 33 | cd aur-repo 34 | VERSION=${{ steps.get_version.outputs.version }} 35 | sed -i "s/^pkgver=.*/pkgver=$VERSION/" PKGBUILD 36 | sed -i "s/^pkgrel=.*/pkgrel=1/" PKGBUILD 37 | 38 | - name: Commit and push 39 | run: | 40 | cd aur-repo 41 | git config user.name "GitHub Actions" 42 | git config user.email "actions@github.com" 43 | git add PKGBUILD 44 | git commit -m "Update gitfetch to v${{ steps.get_version.outputs.version }}" 45 | git push 46 | -------------------------------------------------------------------------------- /.github/workflows/update-homebrew.yml: -------------------------------------------------------------------------------- 1 | name: Update Homebrew Formula 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | update-homebrew: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout main repo 13 | uses: actions/checkout@v4 14 | with: 15 | repository: Matars/gitfetch 16 | path: gitfetch 17 | 18 | - name: Get version from pyproject.toml 19 | id: get_version 20 | run: | 21 | VERSION=$(grep '^version =' gitfetch/pyproject.toml | sed 's/version = "\(.*\)"/\1/') 22 | echo "version=$VERSION" >> $GITHUB_OUTPUT 23 | 24 | - name: Checkout tap repo 25 | uses: actions/checkout@v4 26 | with: 27 | repository: Matars/homebrew-gitfetch 28 | token: ${{ secrets.HOMEBREW_TAP_TOKEN }} 29 | path: homebrew-tap 30 | 31 | - name: Update formula 32 | run: | 33 | cd homebrew-tap 34 | chmod +x .github/scripts/update-homebrew.sh 35 | VERSION=${{ steps.get_version.outputs.version }} ./.github/scripts/update-homebrew.sh 36 | 37 | - name: Commit and push 38 | run: | 39 | cd homebrew-tap 40 | git config user.name "GitHub Actions" 41 | git config user.email "actions@github.com" 42 | git add Formula/gitfetch.rb 43 | git commit -m "Update gitfetch to v${{ steps.get_version.outputs.version }}" 44 | git push 45 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | # Site settings 2 | title: gitfetch 3 | description: A neofetch-style CLI tool for GitHub, GitLab, Gitea, Forgejo, Codeberg, and Sourcehut statistics 4 | url: "https://matars.github.io" 5 | baseurl: "/gitfetch" 6 | 7 | # Theme 8 | remote_theme: just-the-docs/just-the-docs@v0.8.2 9 | color_scheme: dark 10 | 11 | # Just-the-docs custom tweaks (used by theme when available) 12 | just_the_docs: 13 | # visual flavor hint; not an official key but useful for future tweaks 14 | flavor: "dark-moke" 15 | # prefer the sidebar visible by default on large screens 16 | show_sidebar: true 17 | 18 | # Just the Docs settings 19 | logo: "/assets/images/logo.png" # Optional logo 20 | search_enabled: true 21 | heading_anchors: true 22 | 23 | # Aux links for the upper right navigation 24 | aux_links: 25 | "GitHub": 26 | - "//github.com/Matars/gitfetch" 27 | 28 | # Footer content 29 | footer_content: 'Copyright © 2025 Matars. Distributed by an GPL-2.0 license.' 30 | 31 | # Collections for documentation 32 | collections: 33 | docs: 34 | permalink: "/:collection/:name/" 35 | output: true 36 | 37 | # Default layout 38 | defaults: 39 | - scope: 40 | path: "" 41 | type: "docs" 42 | values: 43 | layout: "default" 44 | 45 | # Plugins 46 | plugins: 47 | - jekyll-feed 48 | - jekyll-sitemap 49 | - jekyll-seo-tag 50 | 51 | # GitHub Pages 52 | repository: Matars/gitfetch 53 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Development 4 | nav_order: 6 5 | --- 6 | 7 | # Development 8 | 9 | This project uses a Makefile for common development tasks. 10 | 11 | ## Setup 12 | 13 | ```bash 14 | make install # Install runtime dependencies 15 | make dev # Install in development mode (editable install) 16 | ``` 17 | 18 | ## Testing 19 | 20 | ```bash 21 | make test # Run tests with pytest 22 | ``` 23 | 24 | ## Development Workflow 25 | 26 | 1. Clone the repository 27 | 2. Run `make dev` to set up development environment 28 | 3. Make your changes 29 | 4. Run `make test` to ensure tests pass 30 | 31 | ## Project Structure 32 | 33 | ``` 34 | gitfetch/ 35 | ├── src/gitfetch/ # Main package 36 | │ ├── __init__.py 37 | │ ├── cli.py # Command line interface 38 | │ ├── config.py # Configuration handling 39 | │ ├── cache.py # SQLite caching 40 | │ ├── fetcher.py # API data fetching 41 | │ ├── display.py # Terminal display logic 42 | │ └── text_patterns.py # ASCII art patterns 43 | ├── tests/ # Unit tests 44 | ├── pyproject.toml # Project configuration 45 | ├── setup.py # Setup script 46 | └── Makefile # Development tasks 47 | ``` 48 | 49 | ## Contributing 50 | 51 | Contributions are welcome! Please: 52 | 53 | 1. Fork the repository 54 | 2. Create a feature branch 55 | 3. Make your changes 56 | 4. Add tests if applicable 57 | 5. Ensure all tests pass 58 | 6. Submit a pull request 59 | 60 | ## License 61 | 62 | This project is licensed under GPL-2.0. See [LICENSE](../LICENSE) for details. 63 | -------------------------------------------------------------------------------- /.github/workflows/jekyll-gh-pages.yml: -------------------------------------------------------------------------------- 1 | # Sample workflow for building and deploying a Jekyll site to GitHub Pages 2 | name: Deploy Jekyll with GitHub Pages dependencies preinstalled 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Build job 26 | build: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | - name: Setup Pages 32 | uses: actions/configure-pages@v5 33 | - name: Build with Jekyll 34 | uses: actions/jekyll-build-pages@v1 35 | with: 36 | source: ./docs 37 | destination: ./_site 38 | - name: Upload artifact 39 | uses: actions/upload-pages-artifact@v3 40 | 41 | # Deployment job 42 | deploy: 43 | environment: 44 | name: github-pages 45 | url: ${{ steps.deployment.outputs.page_url }} 46 | runs-on: ubuntu-latest 47 | needs: build 48 | steps: 49 | - name: Deploy to GitHub Pages 50 | id: deployment 51 | uses: actions/deploy-pages@v4 52 | -------------------------------------------------------------------------------- /docs/assets/css/custom-dark-moke.css: -------------------------------------------------------------------------------- 1 | /* Custom "dark-moke" tweaks for just-the-docs 2 | This file contains a few conservative overrides to nudge the 3 | theme toward a darker, slightly desaturated "moke" look. 4 | 5 | Note: The theme will only use this automatically if the layout 6 | includes or links to custom CSS. If you want me to inject this 7 | into the site's automatically, tell me and I'll add a 8 | small include that links it. 9 | */ 10 | 11 | :root { 12 | /* slightly desaturated dark background */ 13 | --jtd-body-bg: #0f1113; /* near-black */ 14 | --jtd-page-bg: #0f1113; 15 | --jtd-accent: #8ab4f8; /* soft blue accent */ 16 | --jtd-primary-text: #d6d7d9; /* light gray text */ 17 | --jtd-muted-text: #9aa0a6; /* muted text */ 18 | --jtd-border: rgba(255, 255, 255, 0.06); 19 | } 20 | 21 | body { 22 | background-color: var(--jtd-body-bg) !important; 23 | color: var(--jtd-primary-text) !important; 24 | } 25 | 26 | /* Sidebar tweaks */ 27 | .jtd-sidebar { 28 | background-color: #0b0c0d; /* slightly darker */ 29 | border-right: 1px solid var(--jtd-border); 30 | } 31 | 32 | .jtd-sidebar a, 33 | .jtd-sidebar a:visited { 34 | color: var(--jtd-primary-text); 35 | } 36 | 37 | /* Links and accents */ 38 | a, 39 | .btn, 40 | .site-header a { 41 | color: var(--jtd-accent); 42 | } 43 | 44 | /* Make code blocks slightly warmer */ 45 | .highlight, 46 | pre { 47 | background: #0b0c0d; /* dark */ 48 | color: #e6e6e6; 49 | } 50 | 51 | /* Make the sidebar more persistent visualy (desktop) */ 52 | @media (min-width: 900px) { 53 | .jtd-sidebar { 54 | position: sticky; 55 | top: 0; 56 | height: 100vh; 57 | overflow: auto; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1761114652, 24 | "narHash": "sha256-f/QCJM/YhrV/lavyCVz8iU3rlZun6d+dAiC3H+CDle4=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "01f116e4df6a15f4ccdffb1bcd41096869fb385c", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /docs/providers.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Supported Providers 4 | nav_order: 5 5 | --- 6 | 7 | # Supported Providers 8 | 9 | gitfetch supports multiple Git hosting platforms with different authentication methods. 10 | 11 | ## GitHub 12 | 13 | **Authentication**: Uses GitHub CLI (gh) 14 | 15 | **Setup**: 16 | 17 | 1. Install GitHub CLI: `brew install gh` (macOS) or follow [official instructions](https://cli.github.com/) 18 | 2. Run `gh auth login` to authenticate 19 | 3. gitfetch will detect and use your GitHub credentials 20 | 21 | ## GitLab 22 | 23 | **Authentication**: Uses GitLab CLI (glab) 24 | 25 | **Setup**: 26 | 27 | 1. Install GitLab CLI: `brew install glab` (macOS) or follow [official instructions](https://gitlab.com/gitlab-org/cli) 28 | 2. Run `glab auth login` to authenticate 29 | 3. gitfetch will detect and use your GitLab credentials 30 | 31 | ## Gitea/Forgejo/Codeberg 32 | 33 | **Authentication**: Personal access tokens 34 | 35 | **Setup**: 36 | 37 | 1. Generate a personal access token in your account settings 38 | 2. During gitfetch setup, select Gitea/Forgejo/Codeberg 39 | 3. Enter your instance URL and personal access token 40 | 41 | **Supported Instances**: 42 | 43 | - Gitea (any instance) 44 | - Forgejo (any instance) 45 | - Codeberg (codeberg.org) 46 | 47 | ## Sourcehut 48 | 49 | **Authentication**: Personal access tokens 50 | 51 | **Setup**: 52 | 53 | 1. Generate an OAuth2 personal access token in your [account settings](https://meta.sr.ht/oauth2) 54 | 2. During gitfetch setup, select Sourcehut 55 | 3. Enter your personal access token 56 | 57 | ## Provider Configuration 58 | 59 | You can change your configured provider at any time: 60 | 61 | ```bash 62 | gitfetch --change-provider 63 | ``` 64 | 65 | The provider setting is stored in your configuration file at `~/.config/gitfetch/gitfetch.conf`. 66 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "gitfetch" 7 | version = "1.3.2" 8 | description = "A neofetch-style CLI tool for git provider statistics" 9 | readme = "README.md" 10 | requires-python = ">=3.8" 11 | license = {text = "GPL-2.0"} 12 | authors = [ 13 | {name = "Matar", email = "khaledmatar19733@gmail.com"} 14 | ] 15 | keywords = ["github", "cli", "statistics", "neofetch"] 16 | classifiers = [ 17 | "Development Status :: 3 - Alpha", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", 20 | "Operating System :: OS Independent", 21 | "Programming Language :: Python :: 3", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | ] 28 | 29 | dependencies = [ 30 | "requests>=2.0.0", 31 | "readchar>=4.0.0", 32 | ] 33 | 34 | [project.optional-dependencies] 35 | dev = [ 36 | "pytest>=7.0.0", 37 | "black>=23.0.0", 38 | "mypy>=1.0.0", 39 | ] 40 | 41 | [project.scripts] 42 | gitfetch = "gitfetch.cli:main" 43 | 44 | [project.urls] 45 | Homepage = "https://github.com/Matars/gitfetch" 46 | Repository = "https://github.com/Matars/gitfetch" 47 | Issues = "https://github.com/Matars/gitfetch/issues" 48 | 49 | [tool.setuptools.packages.find] 50 | where = ["src"] 51 | 52 | [tool.setuptools.package-data] 53 | gitfetch = ["docs/*.md"] 54 | 55 | [tool.black] 56 | line-length = 100 57 | target-version = ['py38', 'py39', 'py310', 'py311'] 58 | 59 | [tool.mypy] 60 | python_version = "3.8" 61 | warn_return_any = true 62 | warn_unused_configs = true 63 | disallow_untyped_defs = false 64 | 65 | [tool.pytest.ini_options] 66 | testpaths = ["tests"] 67 | python_files = ["test_*.py"] 68 | python_classes = ["Test*"] 69 | python_functions = ["test_*"] 70 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Home 4 | nav_order: 1 5 | --- 6 | 7 | 68 | 69 |
70 |

gitfetch

71 |

A neofetch-style CLI tool for GitHub, GitLab, Gitea, Forgejo, Codeberg, and Sourcehut statistics

72 |
73 | Install 74 | Gallery 75 | GitHub 76 |
77 |
78 | 79 | > **Note**: This project is still maturing with only ~20 closed issues as of October 26, 2025. If you encounter bugs, have feature requests, or want to contribute, please [open an issue](https://github.com/Matars/gitfetch/issues) on GitHub! 80 | -------------------------------------------------------------------------------- /docs/contributors.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Contributors & Acknowledgements 4 | nav_order: 7 5 | --- 6 | 7 | # Contributors & Acknowledgements 8 | 9 | ## Contributors 10 | 11 | We'd like to thank all the amazing contributors who have helped make gitfetch better: 12 | 13 | 14 | 15 | - **[@shankar524](https://github.com/shankar524)**: Add CI pipeline for testing 16 | - **[@fwtwoo](https://github.com/fwtwoo)**: Better boot menu flow, Docs update, makes good music! 17 | - **[@ilonic23](https://github.com/ilonic23)**: Added nix packaging support & Documentation 18 | - **[@quunarc](https://github.com/quunarc)**: Small code refactoring 19 | - **[@Zeviraty](https://github.com/Zeviraty)**: Small fixes, color configuration, `--graph-timeline` implementation. 20 | - **[@Vaishnav-Sabari-Girish](https://github.com/Vaishnav-Sabari-Girish)**: Better installation instructions 21 | - **[@Noirbizzarre](https://github.com/Noirbizzarre)**: Added installation instructions using uv and pipx 22 | - **[@Joeliscoding](https://github.com/Joeliscoding)**: Added homebrew formula and fixed README 23 | 24 | ## Acknowledgements 25 | 26 | ### Design Inspiration 27 | 28 | - **[Kusa](https://github.com/Ryu0118/Kusa)** by [@Ryu0118](https://github.com/Ryu0118) - Beautiful contribution graph design that inspired our visual approach 29 | - **[songfetch](https://github.com/fwtwoo/songfetch)** by [@fwtwoo](https://github.com/fwtwoo) - The very cool and extremely fun tool that showed us the power of creative terminal displays 30 | 31 | ### Technical Inspiration 32 | 33 | - **[gitfiti](https://github.com/gelstudios/gitfiti)** by [@gelstudios](https://github.com/gelstudios) - The `--text` and `--shape` contribution-graph simulation features take inspiration from and adapt ideas from this project. Credit to gelstudios for the concept of painting the contribution calendar (our implementation only simulates the appearance and does not modify git history). 34 | 35 | ### Community 36 | 37 | A special thanks to the open source community for creating the tools and libraries that power gitfetch, and to all our users for their feedback and support! 38 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to gitfetch 2 | 3 | We welcome contributions from everyone! This document provides guidelines and information for contributors. 4 | 5 | ## Table of Contents 6 | 7 | - [Getting Started](#getting-started) 8 | - [How to Contribute](#how-to-contribute) 9 | - [Development Setup](#development-setup) 10 | - [Pull Request Process](#pull-request-process) 11 | - [Types of Contributions](#types-of-contributions) 12 | 13 | ## Getting Started 14 | 15 | 1. **Create an issue** on Github 16 | 2. **Fork the repository** on GitHub 17 | 3. **Optional: Create a new Branch** 18 | 4. **Make your changes** 19 | 5. **Test your changes** 20 | 6. **Submit a pull request** 21 | 22 | ## How to Contribute 23 | 24 | ### Issue-First Policy 25 | 26 | **Every change by a contributor must come AFTER creating an issue about it.** 27 | 28 | - **Create an issue first**: Before starting any work, create a GitHub issue describing what you want to implement or fix 29 | - **Link PRs to issues**: Every pull request must reference the related issue 30 | - **Maintainer exception**: Project maintainers can bypass this requirement for urgent fixes or minor changes 31 | 32 | ## Development Setup 33 | 34 | ### Installation 35 | 36 | 1. Clone the repository: 37 | 38 | ```bash 39 | git clone https://github.com/your-username/gitfetch.git 40 | cd gitfetch 41 | ``` 42 | 43 | 2. Install: 44 | 45 | ```bash 46 | make dev 47 | ``` 48 | 49 | 3. Verify installation: 50 | ```bash 51 | gitfetch --help 52 | ``` 53 | 54 | ## Types of Contributions 55 | 56 | We welcome contributions in many forms: 57 | 58 | ### Code Contributions 59 | 60 | - Bug fixes 61 | - New features 62 | - Performance improvements 63 | - Code refactoring 64 | 65 | ### Documentation 66 | 67 | - README updates 68 | - Add screenshots of yourset in 69 | - User guides 70 | - Code comments 71 | 72 | ### Testing 73 | 74 | - Unit tests 75 | - Integration tests 76 | - Test coverage improvements 77 | - CI/CD improvements 78 | 79 | ### Design & UI 80 | 81 | - Terminal output improvements 82 | - Color scheme enhancements 83 | - ASCII art contributions 84 | - User experience improvements 85 | 86 | Thank you for contributing to gitfetch! 87 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: How to Contribute 4 | nav_order: 8 5 | --- 6 | 7 | # Contributing to gitfetch 8 | 9 | We welcome contributions from everyone! This document provides guidelines and information for contributors. 10 | 11 | ## Table of Contents 12 | 13 | - [Getting Started](#getting-started) 14 | - [How to Contribute](#how-to-contribute) 15 | - [Development Setup](#development-setup) 16 | - [Pull Request Process](#pull-request-process) 17 | - [Types of Contributions](#types-of-contributions) 18 | 19 | ## Getting Started 20 | 21 | 1. **Create an issue** on Github 22 | 2. **Fork the repository** on GitHub 23 | 3. **Optional: Create a new Branch** 24 | 4. **Make your changes** 25 | 5. **Test your changes** 26 | 6. **Submit a pull request** 27 | 28 | ## How to Contribute 29 | 30 | ### Issue-First Policy 31 | 32 | **Every change by a contributor must come AFTER creating an issue about it.** 33 | 34 | - **Create an issue first**: Before starting any work, create a GitHub issue describing what you want to implement or fix 35 | - **Link PRs to issues**: Every pull request must reference the related issue 36 | - **Maintainer exception**: Project maintainers can bypass this requirement for urgent fixes or minor changes 37 | 38 | ## Development Setup 39 | 40 | ### Installation 41 | 42 | 1. Clone the repository: 43 | 44 | ```bash 45 | git clone https://github.com/your-username/gitfetch.git 46 | cd gitfetch 47 | ``` 48 | 49 | 2. Install: 50 | 51 | ```bash 52 | make dev 53 | ``` 54 | 55 | 3. Verify installation: 56 | ```bash 57 | gitfetch --help 58 | ``` 59 | 60 | ## Types of Contributions 61 | 62 | We welcome contributions in many forms: 63 | 64 | ### Code Contributions 65 | 66 | - Bug fixes 67 | - New features 68 | - Performance improvements 69 | - Code refactoring 70 | 71 | ### Documentation 72 | 73 | - README updates 74 | - Add screenshots of yourset in 75 | - User guides 76 | - Code comments 77 | 78 | ### Testing 79 | 80 | - Unit tests 81 | - Integration tests 82 | - Test coverage improvements 83 | - CI/CD improvements 84 | 85 | ### Design & UI 86 | 87 | - Terminal output improvements 88 | - Color scheme enhancements 89 | - ASCII art contributions 90 | - User experience improvements 91 | 92 | Thank you for contributing to gitfetch! 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gitfetch 2 | 3 | [![CI](https://github.com/Matars/gitfetch/actions/workflows/ci.yml/badge.svg)](https://github.com/Matars/gitfetch/actions/workflows/ci.yml) 4 | 5 | A neofetch-style CLI tool for GitHub, GitLab, Gitea, Forgejo, Codeberg, and Sourcehut statistics. Display your profile and stats from various git hosting platforms in a beautiful, colorful terminal interface with extensive customization options and intelligent layout adaptation. 6 | 7 | > **Note**: This project is still maturing with only ~30 closed issues as of November 1st, 2025. If you encounter bugs, have feature requests, or want to contribute, please [open an issue](https://github.com/Matars/gitfetch/issues). 8 | 9 | 10 | 11 | 14 | 17 | 18 |
12 | image 13 | 15 | image 16 |
19 | 20 | 2025-10-20-143110_hyprshot 21 | 22 | ## Documentation 23 | 24 | 📖 [Full Documentation](https://matars.github.io/gitfetch/) 25 | 26 | ## Quick Install 27 | 28 | ### macOS (Homebrew) 29 | 30 | ```bash 31 | brew tap matars/gitfetch 32 | brew install gitfetch 33 | ``` 34 | 35 | ### Arch Linux (AUR) 36 | 37 | ```bash 38 | yay -S gitfetch-python 39 | ``` 40 | 41 | ### From Source with pip 42 | 43 | Make sure you have pip installed, then run: 44 | 45 | ```bash 46 | git clone https://github.com/Matars/gitfetch.git 47 | cd gitfetch 48 | make dev 49 | ``` 50 | 51 | ## Features 52 | 53 | - Neofetch-style display with ASCII art 54 | - Comprehensive statistics from multiple git hosting platforms 55 | - Encourages maintaining commit streaks 56 | - Get PR's and issues quick view in terminal 57 | - Smart SQLite-based caching system 58 | - Cross-platform support (macOS and Linux) 59 | - Extensive customization options 60 | 61 | ## Supported Platforms 62 | 63 | - **GitHub** - Uses GitHub CLI (gh) for authentication 64 | - **GitLab** - Uses GitLab CLI (glab) for authentication 65 | - **Gitea/Forgejo/Codeberg** - Uses personal access tokens 66 | - **Sourcehut** - Uses personal access tokens 67 | 68 | ## Uninstall 69 | 70 | ```bash 71 | brew uninstall gitfetch # Homebrew 72 | brew untap matars/gitfetch # Homebrew tap 73 | ``` 74 | 75 | ```bash 76 | pip uninstall gitfetch # pip 77 | ``` 78 | 79 | ```bash 80 | yay -R gitfetch-python # AUR (yay) 81 | ``` 82 | 83 | ## License 84 | 85 | GPL-2.0 86 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Configuration 4 | nav_order: 4 5 | --- 6 | 7 | # Configuration 8 | 9 | Configuration file location: `~/.config/gitfetch/gitfetch.conf` 10 | 11 | The configuration file is automatically created on first run. 12 | 13 | ## [DEFAULT] Section 14 | 15 | ```ini 16 | [DEFAULT] 17 | username = yourusername 18 | cache_expiry_minutes = 15 19 | provider = github 20 | provider_url = https://api.github.com 21 | custom_box = ■ 22 | ``` 23 | 24 | - `username`: Your default username (automatically detected) 25 | - `cache_expiry_minutes`: How long to keep cached data (default: 15 minutes) 26 | - `provider`: Git hosting provider (github, gitlab, gitea, sourcehut) 27 | - `provider_url`: API URL for the provider 28 | - `custom_box`: Character used for contribution blocks (default: ■) 29 | 30 | **Note**: Custom graph dimensions (`--width`, `--height`) and section visibility flags (`--no-*`) are command-line only and not saved in the configuration file. 31 | 32 | ## [COLORS] Section 33 | 34 | gitfetch supports extensive color customization using hex color codes or predefined color names. 35 | 36 | ### Available Colors 37 | 38 | - **Text formatting**: `reset`, `bold`, `dim` 39 | - **Basic colors**: `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white` 40 | - **Special colors**: `orange`, `accent`, `header`, `muted` 41 | - **Contribution graph levels**: `0` (lowest) to `4` (highest) 42 | 43 | ### Example Configuration 44 | 45 | ```ini 46 | [COLORS] 47 | header = #0366d6 48 | accent = #6f42c1 49 | muted = #586069 50 | 0 = #ebedf0 # Light gray background 51 | 1 = #9be9a8 # Light green background 52 | 2 = #40c463 # Medium green 53 | 3 = #30a14e # Dark green 54 | 4 = #216e39 # Darkest green 55 | ``` 56 | 57 | ### Supported Color Names and Hex Codes 58 | 59 | See the full list in the [colors documentation](colors.md). 60 | 61 | ## Custom Dimensions and Coloring Accuracy 62 | 63 | The coloring system uses GitHub's standard contribution levels: 64 | 65 | - **0 contributions**: Lightest gray 66 | - **1-2 contributions**: Light green (Level 1) 67 | - **3-6 contributions**: Medium green (Level 2) 68 | - **7-12 contributions**: Dark green (Level 3) 69 | - **13+ contributions**: Darkest green (Level 4) 70 | 71 | Custom dimensions (`--width`, `--height`) control how many weeks/days are visible, but the coloring thresholds remain the same. 72 | 73 | ## Intelligent Layout System 74 | 75 | gitfetch automatically selects the best layout based on your terminal dimensions: 76 | 77 | - **Full Layout**: Shows all sections when width ≥ 120 columns 78 | - **Compact Layout**: Shows graph and key info side-by-side for medium terminals 79 | - **Minimal Layout**: Shows only the contribution graph for narrow terminals 80 | 81 | The system considers both terminal width AND height to ensure optimal display. 82 | -------------------------------------------------------------------------------- /tests/test_cache.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for cache functionality 3 | """ 4 | 5 | import tempfile 6 | from pathlib import Path 7 | from unittest.mock import patch 8 | 9 | from gitfetch.cache import CacheManager 10 | 11 | 12 | class TestCacheManager: 13 | """Test cases for CacheManager.""" 14 | 15 | def setup_method(self): 16 | """Set up test fixtures.""" 17 | self.temp_dir = tempfile.mkdtemp() 18 | self.cache_manager = CacheManager( 19 | cache_expiry_minutes=15, cache_dir=Path(self.temp_dir)) 20 | 21 | def teardown_method(self): 22 | """Clean up test fixtures.""" 23 | # Remove temp directory 24 | import shutil 25 | shutil.rmtree(self.temp_dir, ignore_errors=True) 26 | 27 | def test_cache_expiry(self): 28 | """Test that cache expires after specified time.""" 29 | username = "testuser" 30 | user_data = {"login": "testuser", "name": "Test User"} 31 | stats = {"repositories": 10, "stars": 5} 32 | 33 | # Cache data 34 | self.cache_manager.cache_user_data(username, user_data, stats) 35 | 36 | # Should be available immediately 37 | cached_data = self.cache_manager.get_cached_user_data(username) 38 | assert cached_data == user_data 39 | 40 | # Mock _is_cache_expired to return True (expired) 41 | with patch.object(self.cache_manager, '_is_cache_expired', 42 | return_value=True): 43 | # Should be expired 44 | cached_data = self.cache_manager.get_cached_user_data(username) 45 | assert cached_data is None 46 | 47 | def test_stale_cache_retrieval(self): 48 | """Test retrieving stale cache for background refresh.""" 49 | username = "testuser" 50 | user_data = {"login": "testuser", "name": "Test User"} 51 | stats = {"repositories": 10, "stars": 5} 52 | 53 | # Cache data 54 | self.cache_manager.cache_user_data(username, user_data, stats) 55 | 56 | # Should get stale data even when expired 57 | stale_data = self.cache_manager.get_stale_cached_user_data(username) 58 | assert stale_data == user_data 59 | 60 | def test_cache_minutes_parameter(self): 61 | """Test that cache_expiry_minutes parameter works.""" 62 | cache_manager = CacheManager( 63 | cache_expiry_minutes=30, cache_dir=Path(self.temp_dir)) 64 | assert cache_manager.cache_expiry_minutes == 30 65 | 66 | def test_cache_timestamp_update(self): 67 | """Test that caching updates the timestamp.""" 68 | username = "testuser" 69 | user_data = {"login": "testuser", "name": "Test User"} 70 | stats = {"repositories": 10, "stars": 5} 71 | 72 | # Cache data initially 73 | self.cache_manager.cache_user_data(username, user_data, stats) 74 | 75 | # Get the initial timestamp 76 | initial_entry = self.cache_manager.get_stale_cached_entry(username) 77 | assert initial_entry is not None 78 | _, _, initial_timestamp = initial_entry 79 | 80 | # Wait a tiny bit and cache again 81 | import time 82 | time.sleep(0.001) # Ensure timestamp difference 83 | 84 | # Cache the same data again 85 | self.cache_manager.cache_user_data(username, user_data, stats) 86 | 87 | # Get the updated timestamp 88 | updated_entry = self.cache_manager.get_stale_cached_entry(username) 89 | assert updated_entry is not None 90 | _, _, updated_timestamp = updated_entry 91 | 92 | # Timestamps should be different 93 | assert updated_timestamp > initial_timestamp 94 | 95 | # Remove test user cache 96 | self.cache_manager.clear_user(username) 97 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Installation 4 | nav_order: 3 5 | --- 6 | 7 | # Installation 8 | 9 | gitfetch can be installed on macOS and Linux without any prerequisites. During first-run setup, you'll be guided to install and authenticate with the necessary CLI tools or provide access tokens for your chosen git hosting platform. 10 | 11 | ## macOS (Homebrew) 12 | 13 | ```bash 14 | brew tap matars/gitfetch 15 | brew install gitfetch 16 | ``` 17 | 18 | ## Arch Linux (AUR) 19 | 20 | ```bash 21 | yay -S gitfetch-python 22 | ``` 23 | 24 | Or with other AUR helpers: 25 | 26 | ```bash 27 | paru -S gitfetch-python 28 | trizen -S gitfetch-python 29 | ``` 30 | 31 | Or manual build: 32 | 33 | ```bash 34 | git clone https://aur.archlinux.org/gitfetch-python.git 35 | cd gitfetch-python 36 | makepkg -si 37 | ``` 38 | 39 | ## NixOS (Flake only) 40 | 41 | ### Installation: 42 | 43 | To install, you should add an input to your flake: 44 | ```nix 45 | gitfetch.url = "github:Matars/gitfetch"; 46 | ``` 47 | 48 | Minimal flake variant for it to work: 49 | ```nix 50 | { 51 | description = "My awesome flake"; 52 | 53 | inputs = { 54 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; # or your version 55 | gitfetch.url = "github:Matars/gitfetch"; 56 | }; 57 | outputs = inputs@{ self, nixpkgs, gitfetch }: 58 | let 59 | system = "x86_64-linux"; 60 | pkgs = import nixpkgs { 61 | inherit system; 62 | }; 63 | in { 64 | nixosConfigurations = { 65 | nixos = nixpkgs.lib.nixosSystem { 66 | specialArgs = { inherit system; inherit inputs; }; 67 | modules = [ 68 | ./configuration.nix 69 | ]; 70 | }; 71 | }; 72 | }; 73 | } 74 | ``` 75 | 76 | Then in ``environment.systemPackages`` add this: 77 | ```nix 78 | environment.systemPackages = with pkgs; [ 79 | # ... 80 | inputs.gitfetch.packages.${system}.default # your actual input name 81 | gh # don't forget to install it and authenticate 82 | ] 83 | ``` 84 | 85 | ### Updating 86 | 87 | To update, run these commands and then rebuild 88 | ```shell 89 | nix flake update gitfetch # gitfetch should be replaced by your input name in the flake 90 | nixos-rebuild switch 91 | ``` 92 | 93 | ## From Source 94 | 95 | ### With pip 96 | 97 | Make sure you have pip installed, then run: 98 | 99 | ```bash 100 | git clone https://github.com/Matars/gitfetch.git 101 | cd gitfetch 102 | make dev 103 | ``` 104 | 105 | ### With uv 106 | 107 | ```bash 108 | uv tool install git+https://github.com/Matars/gitfetch 109 | ``` 110 | 111 | ### With pipx 112 | 113 | ```bash 114 | pipx install git+https://github.com/Matars/gitfetch 115 | ``` 116 | 117 | ## First-run Setup 118 | 119 | When you run `gitfetch` for the first time, you'll be prompted to: 120 | 121 | 1. **Choose your git hosting provider** (GitHub, GitLab, Gitea/Forgejo/Codeberg, or Sourcehut) 122 | 2. **Install required CLI tools** (if using GitHub or GitLab) 123 | 3. **Authenticate** with your chosen platform 124 | 4. **Configure access tokens** (if using Gitea/Forgejo/Codeberg or Sourcehut) 125 | 126 | The setup process will provide helpful error messages and installation instructions if anything is missing. 127 | 128 | ## Uninstall 129 | 130 | ### Homebrew 131 | 132 | ```bash 133 | brew uninstall gitfetch # Uninstall gitfetch 134 | brew untap matars/gitfetch # Remove the tap 135 | ``` 136 | 137 | ### pip 138 | 139 | ```bash 140 | pip uninstall gitfetch 141 | ``` 142 | 143 | ### uv 144 | 145 | ```bash 146 | uv tool uninstall gitfetch 147 | ``` 148 | 149 | ### pipx 150 | 151 | ```bash 152 | pipx uninstall gitfetch 153 | ``` 154 | 155 | ### AUR (Arch Linux) 156 | 157 | ```bash 158 | yay -R gitfetch-python 159 | ``` 160 | 161 | Or with other AUR helpers: 162 | 163 | ```bash 164 | paru -R gitfetch-python 165 | trizen -R gitfetch-python 166 | ``` 167 | 168 | ### NixOS 169 | 170 | Remove the gitfetch input from your flake and remove it from `environment.systemPackages`. 171 | -------------------------------------------------------------------------------- /docs/gallery.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Gallery 4 | nav_order: 9 5 | --- 6 | 7 | 125 | 126 |
127 |

Community Gallery

128 |

Showcase your beautiful terminal setups and get inspired by the community

129 |
130 | Contribute Your Setup 131 | View on GitHub 132 |
133 |
134 | 135 | ## Community Setups Gallery 136 | 137 | 162 | 163 |
164 |

Share Your Setup

165 |

Have a beautiful gitfetch setup? We'd love to feature it in our gallery!

166 | 167 |

How to Contribute

168 |
    169 |
  1. Customize your setup - Use gitfetch's extensive configuration options
  2. 170 |
  3. Take a screenshot - Capture your terminal with gitfetch running
  4. 171 |
  5. Create an issue - Open a GitHub issue with your screenshot and configuration details
  6. 172 |
  7. Get featured - Your setup might be added to our gallery!
  8. 173 |
174 |
175 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Features & Usage 4 | nav_order: 2 5 | --- 6 | 7 | # Features & Usage 8 | 9 | gitfetch brings the magic of neofetch to your git hosting platforms, displaying your coding activity in a stunning, colorful terminal interface that's both beautiful and highly customizable. 10 | 11 | ## Core Features 12 | 13 | - **Neofetch-style display** with stunning ASCII art that brings your stats to life 14 | - **Comprehensive statistics** from GitHub, GitLab, Gitea, Forgejo, Codeberg, and Sourcehut 15 | - **Smart SQLite-based caching** system for lightning-fast subsequent runs 16 | - **Cross-platform support** (macOS and Linux) - works wherever you code 17 | - **View active pull requests and issues** - stay on top of your contributions 18 | - **Display commit streak information** - track your coding momentum 19 | - **Extensive customization** options that let you make it truly yours 20 | 21 | ## Visual Customization 22 | 23 | Transform your gitfetch display with powerful visual options: 24 | 25 | - **Custom contribution characters** - use any symbol or emoji for your graph blocks 26 | - **Dynamic section control** - hide/show achievements, languages, issues, PRs, and more 27 | - **Flexible dimensions** - adjust width and height to fit your terminal perfectly 28 | - **ASCII art simulation** - create pixel art from text or use predefined shapes like kitty, cat, heart, and star 29 | - **Advanced color customization** with hex codes and predefined color schemes 30 | 31 | ## 🧠 Intelligent Layout System 32 | 33 | gitfetch automatically adapts to your terminal environment: 34 | 35 | - **Full Layout**: Complete information display when you have 120+ columns 36 | - **Compact Layout**: Side-by-side graph and stats for medium terminals 37 | - **Minimal Layout**: Clean contribution graph for narrow screens 38 | 39 | The system intelligently considers both terminal width AND height to deliver the optimal viewing experience, no matter your setup. 40 | 41 | ## Basic Usage 42 | 43 | Use default username (from config): 44 | 45 | ```bash 46 | gitfetch 47 | ``` 48 | 49 | Fetch stats for specific user: 50 | 51 | ```bash 52 | gitfetch username 53 | ``` 54 | 55 | ## Repository-Specific Stats 56 | 57 | Display contribution statistics for the current local git repository: 58 | 59 | ```bash 60 | gitfetch --local 61 | ``` 62 | 63 | Shows commit activity over the last year, built from local git history 64 | 65 | ```bash 66 | gitfetch --graph-timeline 67 | ``` 68 | 69 | Displays git commit timeline, built from local git history 70 | 71 | **Current Limitations:** 72 | 73 | - Only shows contribution graph and timeline 74 | - No repository metadata (stars, forks, issues, etc.) 75 | - No language statistics for the repository 76 | - Limited to local git history analysis 77 | 78 | ## Configuration 79 | 80 | Change the configured git provider: 81 | 82 | ```bash 83 | gitfetch --change-provider 84 | ``` 85 | 86 | ## Visual Customization 87 | 88 | ### Contribution Characters 89 | 90 | ```bash 91 | gitfetch --custom-box "██" 92 | gitfetch --custom-box "■" 93 | gitfetch --custom-box "●" 94 | gitfetch --custom-box "*" 95 | gitfetch --custom-box "◆" 96 | gitfetch --custom-box "◉" 97 | ``` 98 | 99 | ### Predefined Shapes 100 | 101 | ```bash 102 | gitfetch --shape kitty 103 | gitfetch --shape heart_shiny 104 | ``` 105 | 106 | Display multiple shapes with vertical spacing: 107 | 108 | ```bash 109 | gitfetch --shape kitty heart_shiny 110 | gitfetch --shape heart kitty 111 | ``` 112 | 113 | #### Available Shapes 114 | 115 | gitfetch includes the following predefined shapes: 116 | 117 | - `kitty` - A cute cat face 118 | - `oneup` - Classic Super Mario mushroom power-up 119 | - `oneup2` - Alternative mushroom design 120 | - `hackerschool` - Hacker School logo 121 | - `octocat` - GitHub's Octocat mascot 122 | - `octocat2` - Alternative Octocat design 123 | - `hello` - "HELLO" text 124 | - `heart1` - Simple heart shape 125 | - `heart2` - Alternative heart design 126 | - `heart` - Standard heart shape 127 | - `heart_shiny` - Heart with sparkle effect 128 | - `hireme` - "HIRE ME" text 129 | - `beer` - Beer mug design 130 | - `gliders` - Conway's Game of Life glider pattern 131 | 132 | ### Graph Dimensions 133 | 134 | ```bash 135 | gitfetch --width 50 --height 5 # 50 chars wide, 5 days high 136 | gitfetch --width 100 # Custom width, default height 137 | gitfetch --height 3 # Default width, 3 days high 138 | ``` 139 | 140 | ### Hide Elements 141 | 142 | ```bash 143 | gitfetch --no-date # Hide month/date labels 144 | gitfetch --graph-only # Show only contribution graph 145 | gitfetch --no-achievements # Hide achievements 146 | gitfetch --no-languages # Hide languages 147 | gitfetch --no-issues # Hide issues section 148 | gitfetch --no-pr # Hide pull requests 149 | gitfetch --no-account # Hide account info 150 | gitfetch --no-grid # Hide contribution grid 151 | ``` 152 | 153 | ### Combined Options 154 | 155 | ```bash 156 | gitfetch --no-date --no-achievements --custom-box "█" --width 60 157 | ``` 158 | 159 | ## Cache Options 160 | 161 | Bypass cache and fetch fresh data: 162 | 163 | ```bash 164 | gitfetch username --no-cache 165 | ``` 166 | 167 | Clear cache: 168 | 169 | ```bash 170 | gitfetch --clear-cache 171 | ``` 172 | 173 | ## Layout Control 174 | 175 | gitfetch automatically adapts to your terminal size, but you can control spacing: 176 | 177 | ```bash 178 | gitfetch --spaced # Enable spaced layout 179 | gitfetch --not-spaced # Disable spaced layout 180 | ``` 181 | 182 | ## Help 183 | 184 | See all available options: 185 | 186 | ```bash 187 | gitfetch --help 188 | ``` 189 | -------------------------------------------------------------------------------- /src/gitfetch/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration manager for gitfetch 3 | """ 4 | 5 | import configparser 6 | from pathlib import Path 7 | from typing import Optional 8 | import webcolors 9 | 10 | class ConfigManager: 11 | """Manages gitfetch configuration.""" 12 | 13 | CONFIG_DIR = Path.home() / ".config" / "gitfetch" 14 | CONFIG_FILE = CONFIG_DIR / "gitfetch.conf" 15 | 16 | def __init__(self): 17 | """Initialize the configuration manager.""" 18 | self.config = configparser.ConfigParser() 19 | self._ensure_config_dir() 20 | self._load_config() 21 | 22 | def _ensure_config_dir(self) -> None: 23 | """Ensure configuration directory exists.""" 24 | self.CONFIG_DIR.mkdir(parents=True, exist_ok=True) 25 | 26 | def _load_config(self) -> None: 27 | """Load configuration from file.""" 28 | default_colors = { 29 | 'reset': '#000000', 30 | 'bold': '#FFFFFF', 31 | 'dim': '#888888', 32 | 'red': '#FF5555', 33 | 'green': '#50FA7B', 34 | 'yellow': '#F1FA8C', 35 | 'blue': '#BD93F9', 36 | 'magenta': '#FF79C6', 37 | 'cyan': '#8BE9FD', 38 | 'white': '#F8F8F2', 39 | 'orange': '#FFB86C', 40 | 'accent': '#FFFFFF', 41 | 'header': '#76D7A1', 42 | 'muted': '#44475A', 43 | # Contribution intensity levels (GitHub-like defaults) 44 | '0': '#ebedf0', # no contributions / very light gray 45 | '1': '#9be9a8', # light 46 | '2': '#40c463', # medium 47 | '3': '#30a14e', # darker 48 | '4': '#216e39' # darkest 49 | } 50 | if self.CONFIG_FILE.exists(): 51 | self.config.read(self.CONFIG_FILE) 52 | # Migrate old cache_expiry_hours to cache_expiry_minutes 53 | if self.config.has_option('DEFAULT', 'cache_expiry_hours'): 54 | old_hours = self.config.get('DEFAULT', 'cache_expiry_hours') 55 | try: 56 | new_minutes = int(old_hours) * 60 # hours to minutes 57 | self.config.set('DEFAULT', 'cache_expiry_minutes', 58 | str(new_minutes)) 59 | self.config.remove_option('DEFAULT', 'cache_expiry_hours') 60 | self.save() # Save migrated config 61 | except ValueError: 62 | pass # Keep default if invalid 63 | if "COLORS" in self.config._sections: 64 | # Filter out non-color keys that might have been corrupted 65 | colors_data = self.config._sections['COLORS'] 66 | valid_color_keys = set(default_colors.keys()) 67 | filtered_colors = {k: v for k, v in colors_data.items() 68 | if k in valid_color_keys} 69 | self.config._sections['COLORS'] = { 70 | **default_colors, **filtered_colors} 71 | else: 72 | self.config.add_section('COLORS') 73 | for key, value in default_colors.items(): 74 | self.config.set('COLORS', key, value) 75 | else: 76 | # Create default config 77 | self.config['DEFAULT'] = { 78 | 'username': '', 79 | 'cache_expiry_minutes': '15', 80 | 'token': '', 81 | } 82 | self.config.add_section('COLORS') 83 | for key, value in default_colors.items(): 84 | self.config.set('COLORS', key, value) 85 | # No longer decode ANSI escapes; store as hex 86 | 87 | def get_default_username(self) -> Optional[str]: 88 | """ 89 | Get the default username from config. 90 | 91 | Returns: 92 | Default username or None if not set 93 | """ 94 | username = self.config.get('DEFAULT', 'username', fallback='') 95 | return username if username else None 96 | 97 | def get_colors(self) -> dict: 98 | """ 99 | Get colors as hex codes from config. 100 | 101 | Returns: 102 | dict: color name to hex code 103 | """ 104 | parsed = {} 105 | for k,v in self.config._sections["COLORS"].items(): 106 | if not v.startswith("#") and v in webcolors.names(): 107 | parsed[k] = webcolors.name_to_hex(v) 108 | else: 109 | parsed[k] = v 110 | return parsed 111 | 112 | def get_ansi_colors(self) -> dict: 113 | """ 114 | Get colors as ANSI escape codes for terminal output. 115 | 116 | Returns: 117 | dict: color name to ANSI code 118 | """ 119 | # Map hex codes to ANSI codes (basic mapping for demonstration) 120 | hex_to_ansi = { 121 | '#000000': '\033[0m', 122 | '#FFFFFF': '\033[1m', 123 | '#888888': '\033[2m', 124 | '#FF5555': '\033[91m', 125 | '#50FA7B': '\033[92m', 126 | '#F1FA8C': '\033[93m', 127 | '#BD93F9': '\033[94m', 128 | '#FF79C6': '\033[95m', 129 | '#8BE9FD': '\033[96m', 130 | '#F8F8F2': '\033[97m', 131 | '#FFB86C': '\033[38;2;255;184;108m', 132 | '#76D7A1': '\033[38;2;118;215;161m', 133 | '#44475A': '\033[38;2;68;71;90m', 134 | '#282A36': '\033[48;5;238m', 135 | '#6272A4': '\033[38;2;98;114;164m', 136 | } 137 | colors = self.get_colors() 138 | ansi_colors = {} 139 | for key, value in colors.items(): 140 | ansi_colors[key] = hex_to_ansi.get(value, value) 141 | return ansi_colors 142 | 143 | def set_default_username(self, username: str) -> None: 144 | """ 145 | Set the default username in config. 146 | 147 | Args: 148 | username: Username to set as default 149 | """ 150 | if 'DEFAULT' not in self.config: 151 | self.config['DEFAULT'] = {} 152 | self.config['DEFAULT']['username'] = username 153 | 154 | def get_cache_expiry_minutes(self) -> int: 155 | """ 156 | Get cache expiry time in minutes. 157 | 158 | Returns: 159 | Number of minutes before cache expires (minimum 1) 160 | """ 161 | minutes_str = self.config.get( 162 | 'DEFAULT', 'cache_expiry_minutes', fallback='15') 163 | try: 164 | minutes = int(minutes_str) 165 | # Ensure cache expiry is at least 1 minute 166 | return max(1, minutes) 167 | except ValueError: 168 | return 15 169 | 170 | def set_cache_expiry_minutes(self, minutes: int) -> None: 171 | """ 172 | Set cache expiry time in minutes. 173 | 174 | Args: 175 | minutes: Number of minutes before cache expires (minimum 1) 176 | """ 177 | # Ensure cache expiry is at least 1 minute 178 | minutes = max(1, minutes) 179 | 180 | if 'DEFAULT' not in self.config: 181 | self.config['DEFAULT'] = {} 182 | self.config['DEFAULT']['cache_expiry_minutes'] = str(minutes) 183 | 184 | def is_initialized(self) -> bool: 185 | """ 186 | Check if gitfetch has been initialized. 187 | 188 | Returns: 189 | True if config exists and has default username and provider 190 | """ 191 | return (self.CONFIG_FILE.exists() and 192 | bool(self.get_default_username()) and 193 | bool(self.get_provider())) 194 | 195 | def get_provider(self) -> Optional[str]: 196 | """ 197 | Get the git provider from config. 198 | 199 | Returns: 200 | Provider name (github, gitlab, gitea, etc.) or None if not set 201 | """ 202 | provider = self.config.get('DEFAULT', 'provider', fallback='') 203 | return provider if provider else None 204 | 205 | def set_provider(self, provider: str) -> None: 206 | """ 207 | Set the git provider in config. 208 | 209 | Args: 210 | provider: Git provider name (github, gitlab, gitea, etc.) 211 | """ 212 | if 'DEFAULT' not in self.config: 213 | self.config['DEFAULT'] = {} 214 | self.config['DEFAULT']['provider'] = provider 215 | 216 | def get_provider_url(self) -> Optional[str]: 217 | """ 218 | Get the provider base URL from config. 219 | 220 | Returns: 221 | Base URL for the git provider or None if not set 222 | """ 223 | url = self.config.get('DEFAULT', 'provider_url', fallback='') 224 | return url if url else None 225 | 226 | def set_provider_url(self, url: str) -> None: 227 | """ 228 | Set the provider base URL in config. 229 | 230 | Args: 231 | url: Base URL for the git provider 232 | """ 233 | if 'DEFAULT' not in self.config: 234 | self.config['DEFAULT'] = {} 235 | self.config['DEFAULT']['provider_url'] = url 236 | 237 | def get_custom_box(self) -> Optional[str]: 238 | """ 239 | Get the custom box character from config. 240 | 241 | Returns: 242 | Custom box character or None if not set 243 | """ 244 | box = self.config.get('DEFAULT', 'custom_box', fallback='') 245 | return box if box else None 246 | 247 | def set_custom_box(self, box: str) -> None: 248 | """ 249 | Set the custom box character in config. 250 | 251 | Args: 252 | box: Custom box character to use 253 | """ 254 | if 'DEFAULT' not in self.config: 255 | self.config['DEFAULT'] = {} 256 | self.config['DEFAULT']['custom_box'] = box 257 | 258 | def get_token(self) -> Optional[str]: 259 | """ 260 | Get the personal access token from config. 261 | 262 | Returns: 263 | Token or None if not set 264 | """ 265 | token = self.config.get('DEFAULT', 'token', fallback='') 266 | return token if token else None 267 | 268 | def set_token(self, token: str) -> None: 269 | """ 270 | Set the personal access token in config. 271 | 272 | Args: 273 | token: Personal access token 274 | """ 275 | if 'DEFAULT' not in self.config: 276 | self.config['DEFAULT'] = {} 277 | self.config['DEFAULT']['token'] = token 278 | 279 | def save(self) -> None: 280 | """Save configuration to file.""" 281 | import os 282 | self._ensure_config_dir() 283 | # Remove the file if it exists to ensure clean write 284 | if self.CONFIG_FILE.exists(): 285 | os.remove(self.CONFIG_FILE) 286 | with open(self.CONFIG_FILE, 'w') as f: 287 | f.write("# gitfetch configuration file\n") 288 | f.write("# See docs/providers.md for provider configuration\n") 289 | f.write("# See docs/colors.md for color customization\n\n") 290 | 291 | f.write("[DEFAULT]\n") 292 | username = self.config.get('DEFAULT', 'username', fallback='') 293 | f.write(f"username = {username}\n") 294 | 295 | cache_minutes = self.config.get('DEFAULT', 'cache_expiry_minutes', 296 | fallback='15') 297 | f.write(f"cache_expiry_minutes = {cache_minutes}\n") 298 | 299 | provider = self.config.get('DEFAULT', 'provider', fallback='') 300 | f.write(f"provider = {provider}\n") 301 | 302 | provider_url = self.config.get('DEFAULT', 'provider_url', 303 | fallback='') 304 | f.write(f"provider_url = {provider_url}\n") 305 | 306 | token = self.config.get('DEFAULT', 'token', fallback='') 307 | if token: 308 | f.write(f"token = {token}\n") 309 | 310 | custom_box = self.config.get('DEFAULT', 'custom_box', fallback='') 311 | if custom_box: 312 | f.write(f"custom_box = {custom_box}\n") 313 | 314 | show_date = self.config.get('DEFAULT', 'show_date', 315 | fallback='true') 316 | if show_date != 'true': # Only write if it's not the default 317 | f.write(f"show_date = {show_date}\n") 318 | 319 | f.write("\n") 320 | 321 | if 'COLORS' in self.config._sections: 322 | f.write("[COLORS]\n") 323 | # Find the longest key for alignment 324 | colors_section = self.config._sections['COLORS'] 325 | if colors_section: 326 | keys = list(colors_section.keys()) 327 | max_key_length = max(len(key) for key in keys) 328 | for key, value in colors_section.items(): 329 | f.write(f"{key:<{max_key_length}} = {value}\n") 330 | f.write("\n") 331 | -------------------------------------------------------------------------------- /src/gitfetch/cache.py: -------------------------------------------------------------------------------- 1 | """ 2 | Cache manager for storing GitHub data locally using SQLite 3 | """ 4 | 5 | import sqlite3 6 | from pathlib import Path 7 | from typing import Optional, Dict, Any 8 | from datetime import datetime, timedelta 9 | import json 10 | 11 | 12 | class CacheManager: 13 | """Manages local caching of GitHub data using SQLite.""" 14 | 15 | def __init__(self, cache_expiry_minutes: int = 15, 16 | cache_dir: Optional[Path] = None): 17 | """ 18 | Initialize the cache manager. 19 | 20 | Args: 21 | cache_expiry_minutes: Minutes before cache expires (default: 15) 22 | cache_dir: Directory to store cache files 23 | (default: ~/.local/share/gitfetch) 24 | """ 25 | self.cache_expiry_minutes = cache_expiry_minutes 26 | self.CACHE_DIR = cache_dir or ( 27 | Path.home() / ".local" / "share" / "gitfetch") 28 | self.DB_FILE = self.CACHE_DIR / "cache.db" 29 | self._ensure_cache_dir() 30 | self._init_database() 31 | 32 | def _ensure_cache_dir(self) -> None: 33 | """Ensure cache directory exists.""" 34 | self.CACHE_DIR.mkdir(parents=True, exist_ok=True) 35 | 36 | def _init_database(self) -> None: 37 | """Initialize SQLite database with required tables.""" 38 | self._ensure_cache_dir() 39 | conn = sqlite3.connect(self.DB_FILE) 40 | cursor = conn.cursor() 41 | cursor.execute(''' 42 | CREATE TABLE IF NOT EXISTS users ( 43 | username TEXT PRIMARY KEY, 44 | user_data TEXT NOT NULL, 45 | stats_data TEXT NOT NULL, 46 | cached_at TIMESTAMP NOT NULL 47 | ) 48 | ''') 49 | cursor.execute( 50 | 'CREATE INDEX IF NOT EXISTS idx_cached_at ON users(cached_at)' 51 | ) 52 | conn.commit() 53 | conn.close() 54 | 55 | def get_cached_user_data(self, username: str) -> Optional[Dict[str, Any]]: 56 | """ 57 | Retrieve cached user data if available and not expired. 58 | 59 | Args: 60 | username: GitHub username 61 | 62 | Returns: 63 | Cached user data or None if not available/expired 64 | """ 65 | entry = self.get_cached_entry(username) 66 | return entry[0] if entry else None 67 | 68 | def get_cached_stats(self, username: str) -> Optional[Dict[str, Any]]: 69 | """ 70 | Retrieve cached statistics if available and not expired. 71 | 72 | Args: 73 | username: GitHub username 74 | 75 | Returns: 76 | Cached stats or None if not available/expired 77 | """ 78 | entry = self.get_cached_entry(username) 79 | return entry[1] if entry else None 80 | 81 | def get_cached_entry(self, username: str) -> Optional[ 82 | tuple[Dict[str, Any], Dict[str, Any]] 83 | ]: 84 | """ 85 | Retrieve both user data and stats if available and not expired. 86 | 87 | Args: 88 | username: GitHub username 89 | 90 | Returns: 91 | Tuple of (user_data, stats) or None if not available/expired 92 | """ 93 | try: 94 | conn = sqlite3.connect(self.DB_FILE) 95 | cursor = conn.cursor() 96 | cursor.execute( 97 | 'SELECT user_data, stats_data, cached_at FROM users ' 98 | 'WHERE username = ?', 99 | (username,) 100 | ) 101 | row = cursor.fetchone() 102 | conn.close() 103 | 104 | if not row: 105 | return None 106 | 107 | cached_at = datetime.fromisoformat(row[2]) 108 | if self._is_cache_expired(cached_at): 109 | return None 110 | 111 | user_data = json.loads(row[0]) 112 | stats_data = json.loads(row[1]) 113 | return (user_data, stats_data) 114 | except (sqlite3.Error, json.JSONDecodeError, ValueError): 115 | return None 116 | 117 | def get_stale_cached_entry(self, username: str) -> Optional[ 118 | tuple[Dict[str, Any], Dict[str, Any], datetime] 119 | ]: 120 | """ 121 | Retrieve cached data even if expired (for background refresh). 122 | 123 | Args: 124 | username: GitHub username 125 | 126 | Returns: 127 | Tuple of (user_data, stats, cached_at) or None if not available 128 | """ 129 | try: 130 | conn = sqlite3.connect(self.DB_FILE) 131 | cursor = conn.cursor() 132 | cursor.execute( 133 | 'SELECT user_data, stats_data, cached_at FROM users ' 134 | 'WHERE username = ?', 135 | (username,) 136 | ) 137 | row = cursor.fetchone() 138 | conn.close() 139 | 140 | if not row: 141 | return None 142 | 143 | cached_at = datetime.fromisoformat(row[2]) 144 | user_data = json.loads(row[0]) 145 | stats_data = json.loads(row[1]) 146 | return (user_data, stats_data, cached_at) 147 | except (sqlite3.Error, json.JSONDecodeError, ValueError): 148 | return None 149 | 150 | def get_stale_cached_user_data( 151 | self, username: str 152 | ) -> Optional[Dict[str, Any]]: 153 | """ 154 | Retrieve cached user data even if expired. 155 | 156 | Args: 157 | username: GitHub username 158 | 159 | Returns: 160 | Cached user data or None if not available 161 | """ 162 | entry = self.get_stale_cached_entry(username) 163 | return entry[0] if entry else None 164 | 165 | def get_stale_cached_stats(self, username: str) -> Optional[ 166 | Dict[str, Any] 167 | ]: 168 | """ 169 | Retrieve cached statistics even if expired. 170 | 171 | Args: 172 | username: GitHub username 173 | 174 | Returns: 175 | Cached stats or None if not available 176 | """ 177 | entry = self.get_stale_cached_entry(username) 178 | return entry[1] if entry else None 179 | 180 | def is_cache_stale(self, username: str) -> bool: 181 | """ 182 | Check if cached data exists but is stale (expired). 183 | 184 | Args: 185 | username: GitHub username 186 | 187 | Returns: 188 | True if cache exists but is stale, False otherwise 189 | """ 190 | entry = self.get_stale_cached_entry(username) 191 | if not entry: 192 | return False 193 | _, _, cached_at = entry 194 | return self._is_cache_expired(cached_at) 195 | 196 | def cache_user_data(self, username: str, user_data: Dict[str, Any], 197 | stats: Dict[str, Any]) -> None: 198 | """ 199 | Cache user data and statistics. 200 | 201 | Args: 202 | username: GitHub username 203 | user_data: User profile data to cache 204 | stats: Statistics data to cache 205 | """ 206 | try: 207 | conn = sqlite3.connect(self.DB_FILE) 208 | cursor = conn.cursor() 209 | cursor.execute(''' 210 | INSERT OR REPLACE INTO users 211 | (username, user_data, stats_data, cached_at) 212 | VALUES (?, ?, ?, ?) 213 | ''', ( 214 | username, 215 | json.dumps(user_data), 216 | json.dumps(stats), 217 | datetime.now().isoformat() 218 | )) 219 | conn.commit() 220 | conn.close() 221 | except sqlite3.Error: 222 | pass # Silently fail on cache errors 223 | 224 | def clear(self) -> None: 225 | """Clear all cached data.""" 226 | try: 227 | conn = sqlite3.connect(self.DB_FILE) 228 | cursor = conn.cursor() 229 | cursor.execute('DELETE FROM users') 230 | conn.commit() 231 | conn.close() 232 | except sqlite3.Error: 233 | pass 234 | 235 | def clear_user(self, username: str) -> None: 236 | """ 237 | Clear cached data for a specific user. 238 | 239 | Args: 240 | username: GitHub username to clear from cache 241 | """ 242 | try: 243 | conn = sqlite3.connect(self.DB_FILE) 244 | cursor = conn.cursor() 245 | cursor.execute('DELETE FROM users WHERE username = ?', (username,)) 246 | conn.commit() 247 | conn.close() 248 | except sqlite3.Error: 249 | pass 250 | 251 | def _is_cache_expired(self, cached_at: datetime) -> bool: 252 | """ 253 | Check if cache timestamp has expired. 254 | 255 | Args: 256 | cached_at: Timestamp when data was cached 257 | 258 | Returns: 259 | True if expired, False otherwise 260 | """ 261 | # Defensive handling: ensure cache_expiry_minutes is a reasonable int 262 | # and avoid passing an extremely large integer to timedelta which can 263 | # raise OverflowError on some platforms (assuming on 32-bit builds). 264 | try: 265 | minutes = int(self.cache_expiry_minutes) 266 | except Exception: 267 | minutes = 15 268 | 269 | # Enforce sensible bounds: minimum 1 minute, cap to MAX_MINUTES 270 | # (10 years expressed in minutes). This prevents OverflowError while 271 | # still allowing very long cache durations when intentionally set. 272 | MAX_MINUTES = 5256000 # 10 years 273 | minutes = max(1, min(minutes, MAX_MINUTES)) 274 | 275 | try: 276 | expiry_time = datetime.now() - timedelta(minutes=minutes) 277 | except OverflowError: 278 | # In the unlikely event timedelta still overflows, treat cache as 279 | # non-expired (safe default) to avoid crashing the program. 280 | return False 281 | 282 | return cached_at < expiry_time 283 | 284 | def list_cached_accounts(self) -> list[tuple[str, datetime]]: 285 | """ 286 | List all cached GitHub accounts with their cache timestamps. 287 | 288 | Returns: 289 | List of tuples (username, cached_at) 290 | """ 291 | try: 292 | conn = sqlite3.connect(self.DB_FILE) 293 | cursor = conn.cursor() 294 | cursor.execute('SELECT username, cached_at FROM users') 295 | rows = cursor.fetchall() 296 | conn.close() 297 | 298 | result = [] 299 | for row in rows: 300 | try: 301 | cached_at = datetime.fromisoformat(row[1]) 302 | result.append((row[0], cached_at)) 303 | except ValueError: 304 | continue 305 | return result 306 | except sqlite3.Error: 307 | return [] 308 | 309 | def get_cache_stats(self) -> Dict[str, Any]: 310 | """ 311 | Get statistics about the cache. 312 | 313 | Returns: 314 | Dictionary with cache statistics (total entries, oldest, newest) 315 | """ 316 | try: 317 | conn = sqlite3.connect(self.DB_FILE) 318 | cursor = conn.cursor() 319 | 320 | # Get total entries 321 | cursor.execute('SELECT COUNT(*) FROM users') 322 | total = cursor.fetchone()[0] 323 | 324 | # Get oldest and newest entries 325 | cursor.execute( 326 | 'SELECT username, cached_at FROM users ' 327 | 'ORDER BY cached_at ASC LIMIT 1' 328 | ) 329 | oldest = cursor.fetchone() 330 | 331 | cursor.execute( 332 | 'SELECT username, cached_at FROM users ' 333 | 'ORDER BY cached_at DESC LIMIT 1' 334 | ) 335 | newest = cursor.fetchone() 336 | 337 | conn.close() 338 | 339 | return { 340 | "total_entries": total, 341 | "oldest_entry": oldest[0] if oldest else None, 342 | "newest_entry": newest[0] if newest else None 343 | } 344 | except sqlite3.Error: 345 | return { 346 | "total_entries": 0, 347 | "oldest_entry": None, 348 | "newest_entry": None 349 | } 350 | 351 | def _execute_query(self, query: str, params: tuple = ()) -> Optional[Any]: 352 | """ 353 | Execute a database query. 354 | 355 | Args: 356 | query: SQL query string 357 | params: Query parameters 358 | 359 | Returns: 360 | Query results or None 361 | """ 362 | try: 363 | conn = sqlite3.connect(self.DB_FILE) 364 | cursor = conn.cursor() 365 | cursor.execute(query, params) 366 | result = cursor.fetchall() 367 | conn.commit() 368 | conn.close() 369 | return result 370 | except sqlite3.Error: 371 | return None 372 | 373 | def close(self) -> None: 374 | """Close database connection.""" 375 | # SQLite connections are created per-operation, so nothing to close 376 | pass 377 | -------------------------------------------------------------------------------- /src/gitfetch/text_patterns.py: -------------------------------------------------------------------------------- 1 | """Predefined 7xN character pixel patterns for text-to-grid rendering. 2 | 3 | Each character pattern is a list of 7 rows; each row is a list of 0/1 values 4 | representing contribution intensity buckets (0 = empty, 1 = filled). These 5 | are used by `DisplayFormatter._text_to_grid` to build a 7xN grid. 6 | """ 7 | 8 | CHAR_PATTERNS = { 9 | 'A': [ 10 | [0, 3, 3, 3, 3, 3, 0], 11 | [3, 0, 0, 0, 0, 0, 3], 12 | [3, 0, 0, 0, 0, 0, 3], 13 | [3, 3, 3, 3, 3, 3, 3], 14 | [3, 0, 0, 0, 0, 0, 3], 15 | [3, 0, 0, 0, 0, 0, 3], 16 | [3, 0, 0, 0, 0, 0, 3], 17 | ], 18 | 'B': [ 19 | [3, 3, 3, 3, 3, 3, 0], 20 | [3, 0, 0, 0, 0, 0, 3], 21 | [3, 0, 0, 0, 0, 0, 3], 22 | [3, 3, 3, 3, 3, 3, 0], 23 | [3, 0, 0, 0, 0, 0, 3], 24 | [3, 0, 0, 0, 0, 0, 3], 25 | [3, 3, 3, 3, 3, 3, 0], 26 | ], 27 | 'C': [ 28 | [0, 3, 3, 3, 3, 3, 0], 29 | [3, 0, 0, 0, 0, 0, 3], 30 | [3, 0, 0, 0, 0, 0, 0], 31 | [3, 0, 0, 0, 0, 0, 0], 32 | [3, 0, 0, 0, 0, 0, 0], 33 | [3, 0, 0, 0, 0, 0, 3], 34 | [0, 3, 3, 3, 3, 3, 0], 35 | ], 36 | 'D': [ 37 | [3, 3, 3, 3, 3, 3, 0], 38 | [3, 0, 0, 0, 0, 0, 3], 39 | [3, 0, 0, 0, 0, 0, 3], 40 | [3, 0, 0, 0, 0, 0, 3], 41 | [3, 0, 0, 0, 0, 0, 3], 42 | [3, 0, 0, 0, 0, 0, 3], 43 | [3, 3, 3, 3, 3, 3, 0], 44 | ], 45 | 'E': [ 46 | [3, 3, 3, 3, 3, 3, 3], 47 | [3, 0, 0, 0, 0, 0, 0], 48 | [3, 0, 0, 0, 0, 0, 0], 49 | [3, 3, 3, 3, 3, 3, 0], 50 | [3, 0, 0, 0, 0, 0, 0], 51 | [3, 0, 0, 0, 0, 0, 0], 52 | [3, 3, 3, 3, 3, 3, 3], 53 | ], 54 | 'F': [ 55 | [3, 3, 3, 3, 3, 3, 3], 56 | [3, 0, 0, 0, 0, 0, 0], 57 | [3, 0, 0, 0, 0, 0, 0], 58 | [3, 3, 3, 3, 3, 3, 0], 59 | [3, 0, 0, 0, 0, 0, 0], 60 | [3, 0, 0, 0, 0, 0, 0], 61 | [3, 0, 0, 0, 0, 0, 0], 62 | ], 63 | 'G': [ 64 | [0, 3, 3, 3, 3, 3, 0], 65 | [3, 0, 0, 0, 0, 0, 3], 66 | [3, 0, 0, 0, 0, 0, 0], 67 | [3, 0, 3, 3, 3, 3, 3], 68 | [3, 0, 0, 0, 0, 0, 3], 69 | [3, 0, 0, 0, 0, 0, 3], 70 | [0, 3, 3, 3, 3, 3, 0], 71 | ], 72 | 'H': [ 73 | [3, 0, 0, 0, 0, 0, 3], 74 | [3, 0, 0, 0, 0, 0, 3], 75 | [3, 0, 0, 0, 0, 0, 3], 76 | [3, 3, 3, 3, 3, 3, 3], 77 | [3, 0, 0, 0, 0, 0, 3], 78 | [3, 0, 0, 0, 0, 0, 3], 79 | [3, 0, 0, 0, 0, 0, 3], 80 | ], 81 | 'I': [ 82 | [3, 3, 3, 3, 3, 3, 3], 83 | [0, 0, 3, 0, 0, 0, 0], 84 | [0, 0, 3, 0, 0, 0, 0], 85 | [0, 0, 3, 0, 0, 0, 0], 86 | [0, 0, 3, 0, 0, 0, 0], 87 | [0, 0, 3, 0, 0, 0, 0], 88 | [3, 3, 3, 3, 3, 3, 3], 89 | ], 90 | 'J': [ 91 | [3, 3, 3, 3, 3, 3, 3], 92 | [0, 0, 0, 0, 0, 3, 0], 93 | [0, 0, 0, 0, 0, 3, 0], 94 | [0, 0, 0, 0, 0, 3, 0], 95 | [0, 0, 0, 0, 0, 3, 0], 96 | [3, 0, 0, 0, 0, 3, 0], 97 | [0, 3, 3, 3, 3, 0, 0], 98 | ], 99 | 'K': [ 100 | [3, 0, 0, 0, 0, 3, 0], 101 | [3, 0, 0, 0, 3, 0, 0], 102 | [3, 0, 0, 3, 0, 0, 0], 103 | [3, 3, 3, 0, 0, 0, 0], 104 | [3, 0, 0, 3, 0, 0, 0], 105 | [3, 0, 0, 0, 3, 0, 0], 106 | [3, 0, 0, 0, 0, 3, 0], 107 | ], 108 | 'L': [ 109 | [3, 0, 0, 0, 0, 0, 0], 110 | [3, 0, 0, 0, 0, 0, 0], 111 | [3, 0, 0, 0, 0, 0, 0], 112 | [3, 0, 0, 0, 0, 0, 0], 113 | [3, 0, 0, 0, 0, 0, 0], 114 | [3, 0, 0, 0, 0, 0, 0], 115 | [3, 3, 3, 3, 3, 3, 3], 116 | ], 117 | 'M': [ 118 | [3, 0, 0, 0, 0, 0, 3], 119 | [3, 3, 0, 0, 0, 3, 3], 120 | [3, 0, 3, 0, 3, 0, 3], 121 | [3, 0, 0, 3, 0, 0, 3], 122 | [3, 0, 0, 0, 0, 0, 3], 123 | [3, 0, 0, 0, 0, 0, 3], 124 | [3, 0, 0, 0, 0, 0, 3], 125 | ], 126 | 'N': [ 127 | [3, 0, 0, 0, 0, 0, 3], 128 | [3, 3, 0, 0, 0, 0, 3], 129 | [3, 0, 3, 0, 0, 0, 3], 130 | [3, 0, 0, 3, 0, 0, 3], 131 | [3, 0, 0, 0, 3, 0, 3], 132 | [3, 0, 0, 0, 0, 3, 3], 133 | [3, 0, 0, 0, 0, 0, 3], 134 | ], 135 | 'O': [ 136 | [0, 3, 3, 3, 3, 3, 0], 137 | [3, 0, 0, 0, 0, 0, 3], 138 | [3, 0, 0, 0, 0, 0, 3], 139 | [3, 0, 0, 0, 0, 0, 3], 140 | [3, 0, 0, 0, 0, 0, 3], 141 | [3, 0, 0, 0, 0, 0, 3], 142 | [0, 3, 3, 3, 3, 3, 0], 143 | ], 144 | 'P': [ 145 | [3, 3, 3, 3, 3, 3, 0], 146 | [3, 0, 0, 0, 0, 0, 3], 147 | [3, 0, 0, 0, 0, 0, 3], 148 | [3, 3, 3, 3, 3, 3, 0], 149 | [3, 0, 0, 0, 0, 0, 0], 150 | [3, 0, 0, 0, 0, 0, 0], 151 | [3, 0, 0, 0, 0, 0, 0], 152 | ], 153 | 'Q': [ 154 | [0, 3, 3, 3, 3, 3, 0], 155 | [3, 0, 0, 0, 0, 0, 3], 156 | [3, 0, 0, 0, 0, 0, 3], 157 | [3, 0, 0, 0, 0, 0, 3], 158 | [3, 0, 3, 0, 0, 0, 3], 159 | [3, 0, 0, 3, 0, 0, 3], 160 | [0, 3, 3, 3, 3, 3, 3], 161 | ], 162 | 'R': [ 163 | [3, 3, 3, 3, 3, 3, 0], 164 | [3, 0, 0, 0, 0, 0, 3], 165 | [3, 0, 0, 0, 0, 0, 3], 166 | [3, 3, 3, 3, 3, 3, 0], 167 | [3, 0, 0, 3, 0, 0, 0], 168 | [3, 0, 0, 0, 3, 0, 0], 169 | [3, 0, 0, 0, 0, 3, 0], 170 | ], 171 | 'S': [ 172 | [0, 3, 3, 3, 3, 3, 3], 173 | [3, 0, 0, 0, 0, 0, 0], 174 | [3, 0, 0, 0, 0, 0, 0], 175 | [0, 3, 3, 3, 3, 3, 0], 176 | [0, 0, 0, 0, 0, 0, 3], 177 | [0, 0, 0, 0, 0, 0, 3], 178 | [3, 3, 3, 3, 3, 3, 0], 179 | ], 180 | 'T': [ 181 | [3, 3, 3, 3, 3, 3, 3], 182 | [0, 0, 3, 0, 0, 0, 0], 183 | [0, 0, 3, 0, 0, 0, 0], 184 | [0, 0, 3, 0, 0, 0, 0], 185 | [0, 0, 3, 0, 0, 0, 0], 186 | [0, 0, 3, 0, 0, 0, 0], 187 | [0, 0, 3, 0, 0, 0, 0], 188 | ], 189 | 'U': [ 190 | [3, 0, 0, 0, 0, 0, 3], 191 | [3, 0, 0, 0, 0, 0, 3], 192 | [3, 0, 0, 0, 0, 0, 3], 193 | [3, 0, 0, 0, 0, 0, 3], 194 | [3, 0, 0, 0, 0, 0, 3], 195 | [3, 0, 0, 0, 0, 0, 3], 196 | [0, 3, 3, 3, 3, 3, 0], 197 | ], 198 | 'V': [ 199 | [3, 0, 0, 0, 0, 0, 3], 200 | [3, 0, 0, 0, 0, 0, 3], 201 | [3, 0, 0, 0, 0, 0, 3], 202 | [3, 0, 0, 0, 0, 0, 3], 203 | [3, 0, 0, 0, 0, 0, 3], 204 | [0, 3, 0, 0, 0, 3, 0], 205 | [0, 0, 3, 3, 3, 0, 0], 206 | ], 207 | 'W': [ 208 | [3, 0, 0, 0, 0, 0, 3], 209 | [3, 0, 0, 0, 0, 0, 3], 210 | [3, 0, 0, 0, 0, 0, 3], 211 | [3, 0, 0, 3, 0, 0, 3], 212 | [3, 0, 3, 0, 3, 0, 3], 213 | [3, 3, 0, 0, 0, 3, 3], 214 | [3, 0, 0, 0, 0, 0, 3], 215 | ], 216 | 'X': [ 217 | [3, 0, 0, 0, 0, 0, 3], 218 | [0, 3, 0, 0, 0, 3, 0], 219 | [0, 0, 3, 0, 3, 0, 0], 220 | [0, 0, 0, 3, 0, 0, 0], 221 | [0, 0, 3, 0, 3, 0, 0], 222 | [0, 3, 0, 0, 0, 3, 0], 223 | [3, 0, 0, 0, 0, 0, 3], 224 | ], 225 | 'Y': [ 226 | [3, 0, 0, 0, 0, 0, 3], 227 | [0, 3, 0, 0, 0, 3, 0], 228 | [0, 0, 3, 0, 3, 0, 0], 229 | [0, 0, 0, 3, 0, 0, 0], 230 | [0, 0, 0, 3, 0, 0, 0], 231 | [0, 0, 0, 3, 0, 0, 0], 232 | [0, 0, 0, 3, 0, 0, 0], 233 | ], 234 | 'Z': [ 235 | [3, 3, 3, 3, 3, 3, 3], 236 | [0, 0, 0, 0, 0, 3, 0], 237 | [0, 0, 0, 0, 3, 0, 0], 238 | [0, 0, 0, 3, 0, 0, 0], 239 | [0, 0, 3, 0, 0, 0, 0], 240 | [0, 3, 0, 0, 0, 0, 0], 241 | [3, 3, 3, 3, 3, 3, 3], 242 | ], 243 | ' ': [ 244 | [0, 0, 0, 0, 0, 0, 0], 245 | [0, 0, 0, 0, 0, 0, 0], 246 | [0, 0, 0, 0, 0, 0, 0], 247 | [0, 0, 0, 0, 0, 0, 0], 248 | [0, 0, 0, 0, 0, 0, 0], 249 | [0, 0, 0, 0, 0, 0, 0], 250 | [0, 0, 0, 0, 0, 0, 0], 251 | ], 252 | # Fun characters from gitfiti (https://github.com/gelstudios/gitfiti) 253 | 'kitty': [ 254 | [0, 0, 0, 4, 0, 0, 0, 0, 4, 0, 0, 0], 255 | [0, 0, 4, 2, 4, 4, 4, 4, 2, 4, 0, 0], 256 | [0, 0, 4, 2, 2, 2, 2, 2, 2, 4, 0, 0], 257 | [2, 2, 4, 2, 4, 2, 2, 4, 2, 4, 2, 2], 258 | [0, 0, 4, 2, 2, 3, 3, 2, 2, 4, 0, 0], 259 | [2, 2, 4, 2, 2, 2, 2, 2, 2, 4, 2, 2], 260 | [0, 0, 0, 3, 4, 4, 4, 4, 3, 0, 0, 0], 261 | ], 262 | 'oneup': [ 263 | [0, 4, 4, 4, 4, 4, 4, 4, 0], 264 | [4, 3, 2, 2, 1, 2, 2, 3, 4], 265 | [4, 2, 2, 1, 1, 1, 2, 2, 4], 266 | [4, 3, 4, 4, 4, 4, 4, 3, 4], 267 | [4, 4, 1, 4, 1, 4, 1, 4, 4], 268 | [0, 4, 1, 1, 1, 1, 1, 4, 0], 269 | [0, 0, 4, 4, 4, 4, 4, 0, 0], 270 | ], 271 | 'oneup2': [ 272 | [0, 0, 4, 4, 4, 4, 4, 4, 4, 0, 0], 273 | [0, 4, 2, 2, 1, 1, 1, 2, 2, 4, 0], 274 | [4, 3, 2, 2, 1, 1, 1, 2, 2, 3, 4], 275 | [4, 3, 3, 4, 4, 4, 4, 4, 3, 3, 4], 276 | [0, 4, 4, 1, 4, 1, 4, 1, 4, 4, 0], 277 | [0, 0, 4, 1, 1, 1, 1, 1, 4, 0, 0], 278 | [0, 0, 0, 4, 4, 4, 4, 4, 0, 0, 0], 279 | ], 280 | 'hackerschool': [ 281 | [4, 4, 4, 4, 4, 4], 282 | [4, 3, 3, 3, 3, 4], 283 | [4, 1, 3, 3, 1, 4], 284 | [4, 3, 3, 3, 3, 4], 285 | [4, 4, 4, 4, 4, 4], 286 | [0, 0, 4, 4, 0, 0], 287 | [4, 4, 4, 4, 4, 4], 288 | ], 289 | 'octocat': [ 290 | [0, 0, 0, 4, 0, 0, 0, 4, 0], 291 | [0, 0, 4, 4, 4, 4, 4, 4, 4], 292 | [0, 0, 4, 1, 3, 3, 3, 1, 4], 293 | [0, 0, 4, 3, 3, 3, 3, 3, 4], 294 | [4, 0, 3, 4, 3, 3, 3, 4, 3], 295 | [0, 4, 0, 0, 4, 4, 4, 0, 0], 296 | [0, 0, 4, 4, 4, 4, 4, 4, 4], 297 | [0, 0, 4, 0, 4, 0, 4, 0, 4], 298 | ], 299 | 'octocat2': [ 300 | [0, 0, 4, 0, 0, 4, 0], 301 | [0, 4, 4, 4, 4, 4, 4], 302 | [0, 4, 1, 3, 3, 1, 4], 303 | [0, 4, 4, 4, 4, 4, 4], 304 | [4, 0, 0, 4, 4, 0, 0], 305 | [0, 4, 4, 4, 4, 4, 0], 306 | [0, 0, 0, 4, 4, 4, 0], 307 | ], 308 | 'hello': [ 309 | [0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 4], 310 | [0, 2, 0, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0, 0, 0, 0, 0, 4], 311 | [0, 3, 3, 3, 0, 2, 3, 3, 0, 3, 0, 3, 0, 1, 3, 1, 0, 3], 312 | [0, 4, 0, 4, 0, 4, 0, 4, 0, 4, 0, 4, 0, 4, 0, 4, 0, 3], 313 | [0, 3, 0, 3, 0, 3, 3, 3, 0, 3, 0, 3, 0, 3, 0, 3, 0, 2], 314 | [0, 2, 0, 2, 0, 2, 0, 0, 0, 2, 0, 2, 0, 2, 0, 2, 0, 0], 315 | [0, 1, 0, 1, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 0, 4], 316 | ], 317 | 'heart1': [ 318 | [0, 1, 1, 0, 1, 1, 0], 319 | [1, 3, 3, 1, 3, 3, 1], 320 | [1, 3, 4, 3, 4, 3, 1], 321 | [1, 3, 4, 4, 4, 3, 1], 322 | [0, 1, 3, 4, 3, 1, 0], 323 | [0, 0, 1, 3, 1, 0, 0], 324 | [0, 0, 0, 1, 0, 0, 0], 325 | ], 326 | 'heart2': [ 327 | [0, 5, 5, 0, 5, 5, 0], 328 | [5, 3, 3, 5, 3, 3, 5], 329 | [5, 3, 1, 3, 1, 3, 5], 330 | [5, 3, 1, 1, 1, 3, 5], 331 | [0, 5, 3, 1, 3, 5, 0], 332 | [0, 0, 5, 3, 5, 0, 0], 333 | [0, 0, 0, 5, 0, 0, 0], 334 | ], 335 | 'hireme': [ 336 | [ 337 | 1, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 338 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 339 | ], 340 | [ 341 | 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 342 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 343 | ], 344 | [ 345 | 3, 3, 3, 0, 2, 0, 3, 3, 3, 0, 2, 3, 346 | 3, 0, 0, 3, 3, 0, 3, 0, 0, 2, 3, 3 347 | ], 348 | [ 349 | 4, 0, 4, 0, 4, 0, 4, 0, 0, 0, 4, 0, 350 | 4, 0, 0, 4, 0, 4, 0, 4, 0, 4, 0, 4 351 | ], 352 | [ 353 | 3, 0, 3, 0, 3, 0, 3, 0, 0, 0, 3, 3, 354 | 3, 0, 0, 3, 0, 3, 0, 3, 0, 3, 3, 3 355 | ], 356 | [ 357 | 2, 0, 2, 0, 2, 0, 2, 0, 0, 0, 2, 0, 358 | 0, 0, 0, 2, 0, 2, 0, 2, 0, 2, 0, 0 359 | ], 360 | [ 361 | 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 1, 362 | 1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1 363 | ], 364 | ], 365 | 'beer': [ 366 | [ 367 | 0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 0, 0, 368 | 3, 3, 3, 0, 3, 3, 3, 0, 3, 3, 3, 0, 0 369 | ], 370 | [ 371 | 0, 0, 1, 1, 1, 1, 0, 3, 0, 0, 3, 0, 372 | 3, 0, 0, 0, 3, 0, 0, 0, 3, 0, 0, 3, 0 373 | ], 374 | [ 375 | 0, 2, 2, 2, 2, 2, 0, 3, 0, 0, 3, 0, 376 | 3, 0, 0, 0, 3, 0, 0, 0, 3, 0, 0, 3, 0 377 | ], 378 | [ 379 | 2, 0, 2, 2, 2, 2, 0, 3, 3, 3, 0, 0, 380 | 3, 3, 3, 0, 3, 3, 3, 0, 3, 3, 3, 0, 0 381 | ], 382 | [ 383 | 2, 0, 2, 2, 2, 2, 0, 3, 0, 0, 3, 0, 384 | 3, 0, 0, 0, 3, 0, 0, 0, 3, 0, 3, 0, 0 385 | ], 386 | [ 387 | 0, 2, 2, 2, 2, 2, 0, 3, 0, 0, 3, 0, 388 | 3, 0, 0, 0, 3, 0, 0, 0, 3, 0, 0, 3, 0 389 | ], 390 | [ 391 | 0, 0, 2, 2, 2, 2, 0, 3, 3, 3, 0, 0, 392 | 3, 3, 3, 0, 3, 3, 3, 0, 3, 0, 0, 3, 0 393 | ], 394 | ], 395 | 'gliders': [ 396 | [0, 0, 0, 4, 0, 4, 0, 0, 0, 0, 4, 0, 0, 0], 397 | [0, 4, 0, 4, 0, 0, 4, 4, 0, 0, 0, 4, 0, 0], 398 | [0, 0, 4, 4, 0, 4, 4, 0, 0, 4, 4, 4, 0, 0], 399 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 400 | [0, 4, 0, 4, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0], 401 | [0, 0, 4, 4, 0, 4, 0, 4, 0, 0, 0, 0, 0, 0], 402 | [0, 0, 4, 0, 0, 0, 4, 4, 0, 0, 0, 0, 0, 0], 403 | ], 404 | 'heart': [ 405 | [0, 4, 4, 0, 4, 4, 0], 406 | [4, 2, 2, 4, 2, 2, 4], 407 | [4, 2, 2, 2, 2, 2, 4], 408 | [4, 2, 2, 2, 2, 2, 4], 409 | [0, 4, 2, 2, 2, 4, 0], 410 | [0, 0, 4, 2, 4, 0, 0], 411 | [0, 0, 0, 4, 0, 0, 0], 412 | ], 413 | 'heart_shiny': [ 414 | [0, 4, 4, 0, 4, 4, 0], 415 | [4, 2, 0, 4, 2, 2, 4], 416 | [4, 0, 2, 2, 2, 2, 4], 417 | [4, 2, 2, 2, 2, 2, 4], 418 | [0, 4, 2, 2, 2, 4, 0], 419 | [0, 0, 4, 2, 4, 0, 0], 420 | [0, 0, 0, 4, 0, 0, 0], 421 | ], 422 | } 423 | 424 | __all__ = ["CHAR_PATTERNS"] 425 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you can satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the intent of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the status of all free software and of promoting the sharing 256 | and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you can redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they don't have to be 323 | called anything like that. 324 | 325 | If you want to incorporate parts of the GPL into other free programs whose 326 | distribution conditions are different, write to the author to ask for 327 | permission. For software which is copyrighted by the Free Software 328 | Foundation, write to the Free Software Foundation; we sometimes make 329 | exceptions for this. Our decision will be guided by the two goals 330 | of preserving the status of all free software and of promoting the sharing 331 | and reuse of software generally. -------------------------------------------------------------------------------- /src/gitfetch/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Command-line interface for gitfetch 3 | """ 4 | 5 | import argparse 6 | import sys 7 | import subprocess 8 | from typing import Optional 9 | 10 | import readchar 11 | 12 | from .display import DisplayFormatter 13 | from .cache import CacheManager 14 | from .config import ConfigManager 15 | from . import __version__ 16 | 17 | 18 | def _background_refresh_cache_subprocess(username: str) -> None: 19 | """ 20 | Background cache refresh function that runs as a standalone script. 21 | This is called by the subprocess, not directly. 22 | """ 23 | try: 24 | # Re-create components 25 | config_manager = ConfigManager() 26 | cache_expiry = config_manager.get_cache_expiry_minutes() 27 | cache_manager = CacheManager(cache_expiry_minutes=cache_expiry) 28 | provider = config_manager.get_provider() 29 | provider_url = config_manager.get_provider_url() 30 | token = config_manager.get_token() 31 | if provider == None: 32 | print("Provider not set") 33 | exit(1) 34 | if provider_url == None: 35 | print("Provider url not set") 36 | exit(1) 37 | fetcher = _create_fetcher(provider, provider_url, token) 38 | 39 | fresh_user_data = fetcher.fetch_user_data(username) 40 | fresh_stats = fetcher.fetch_user_stats(username, fresh_user_data) 41 | cache_manager.cache_user_data(username, fresh_user_data, fresh_stats) 42 | except Exception: 43 | # Silent fail - this is background refresh 44 | pass 45 | 46 | 47 | def parse_args() -> argparse.Namespace: 48 | """Parse command-line arguments.""" 49 | parser = argparse.ArgumentParser( 50 | description="""A neofetch-style CLI tool for git. 51 | Supports GitHub, GitLab, Gitea, and Sourcehut.""", 52 | formatter_class=argparse.RawDescriptionHelpFormatter 53 | ) 54 | 55 | parser.add_argument( 56 | "username", 57 | nargs="?", 58 | help="Username to fetch stats for" 59 | ) 60 | 61 | general_group = parser.add_argument_group('\033[92mGeneral Options\033[0m') 62 | general_group.add_argument( 63 | "--no-cache", 64 | action="store_true", 65 | help="Bypass cache and fetch fresh data" 66 | ) 67 | 68 | general_group.add_argument( 69 | "--clear-cache", 70 | action="store_true", 71 | help="Clear the cache and exit" 72 | ) 73 | 74 | general_group.add_argument( 75 | "--version", 76 | action="store_true", 77 | help="Show version and check for updates" 78 | ) 79 | 80 | general_group.add_argument( 81 | "--change-provider", 82 | action="store_true", 83 | help="Change the configured git provider" 84 | ) 85 | 86 | # Hidden argument for background cache refresh 87 | general_group.add_argument( 88 | "--background-refresh", 89 | type=str, 90 | help=argparse.SUPPRESS # Hide from help 91 | ) 92 | 93 | general_group.add_argument( 94 | "--local", 95 | action="store_true", 96 | help="Fetch data specific to current local repo (requires .git folder)" 97 | ) 98 | 99 | visual_group = parser.add_argument_group('\033[94mVisual Options\033[0m') 100 | visual_group.add_argument( 101 | "--spaced", 102 | action="store_true", 103 | help="Enable spaced layout" 104 | ) 105 | 106 | visual_group.add_argument( 107 | "--not-spaced", 108 | action="store_true", 109 | help="Disable spaced layout" 110 | ) 111 | 112 | visual_group.add_argument( 113 | "--custom-box", 114 | type=str, 115 | help="Custom character for contribution blocks (e.g., '■', '█')" 116 | ) 117 | 118 | visual_group.add_argument( 119 | "--graph-only", 120 | action="store_true", 121 | help="Show only the contribution graph" 122 | ) 123 | 124 | visual_group.add_argument( 125 | "--width", 126 | type=int, 127 | help="Set custom width for contribution graph" 128 | ) 129 | 130 | visual_group.add_argument( 131 | "--height", 132 | type=int, 133 | help="Set custom height for contribution graph" 134 | ) 135 | 136 | visual_group.add_argument( 137 | "--text", 138 | type=str, 139 | help="Display text as contribution graph pattern (simulation only)" 140 | ) 141 | 142 | visual_group.add_argument( 143 | "--shape", 144 | nargs='+', 145 | help=("Display one or more predefined shapes as contribution graph " 146 | "(simulation only). Provide multiple shapes after the option: " 147 | "--shape kitty kitty") 148 | ) 149 | 150 | visual_group.add_argument( 151 | "--graph-timeline", 152 | action="store_true", 153 | help="Show git timeline graph instead of contribution graph" 154 | ) 155 | 156 | visibility_group = parser.add_argument_group('\033[95mVisibility\033[0m') 157 | visibility_group.add_argument( 158 | "--no-date", 159 | action="store_true", 160 | help="Hide month/date labels on contribution graph" 161 | ) 162 | 163 | visibility_group.add_argument( 164 | "--no-achievements", 165 | action="store_true", 166 | help="Hide achievements section" 167 | ) 168 | 169 | visibility_group.add_argument( 170 | "--no-languages", 171 | action="store_true", 172 | help="Hide languages section" 173 | ) 174 | 175 | visibility_group.add_argument( 176 | "--no-issues", 177 | action="store_true", 178 | help="Hide issues section" 179 | ) 180 | 181 | visibility_group.add_argument( 182 | "--no-pr", 183 | action="store_true", 184 | help="Hide pull requests section" 185 | ) 186 | 187 | visibility_group.add_argument( 188 | "--no-account", 189 | action="store_true", 190 | help="Hide account information section" 191 | ) 192 | 193 | visibility_group.add_argument( 194 | "--no-grid", 195 | action="store_true", 196 | help="Hide contribution grid/graph" 197 | ) 198 | 199 | return parser.parse_args() 200 | 201 | 202 | def main() -> int: 203 | """Main entry point for gitfetch CLI.""" 204 | try: 205 | args = parse_args() 206 | 207 | # Check for --local flag 208 | if args.local: 209 | import os 210 | if not os.path.exists('.git'): 211 | print("Error: --local requires .git folder", file=sys.stderr) 212 | return 1 213 | 214 | # Handle background refresh mode (hidden feature) 215 | if args.background_refresh: 216 | _background_refresh_cache_subprocess(args.background_refresh) 217 | return 0 218 | 219 | if args.change_provider: 220 | config_manager = ConfigManager() 221 | print("🔄 Changing git provider...\n") 222 | if not _initialize_gitfetch(config_manager): 223 | print("Error: Failed to change provider", file=sys.stderr) 224 | return 1 225 | print("\n✅ Provider changed successfully!") 226 | return 0 227 | 228 | if args.version: 229 | print(f"gitfetch version: {__version__}") 230 | # Check for updates from GitHub 231 | import requests 232 | try: 233 | resp = requests.get( 234 | "https://api.github.com/repos/Matars/gitfetch/releases/latest", timeout=3) 235 | if resp.status_code == 200: 236 | latest = resp.json()["tag_name"].lstrip("v") 237 | if latest != __version__: 238 | print(f"\033[93mUpdate available: {latest}\n" 239 | + "Get it at: https://github.com/Matars/gitfetch/releases/latest\n" 240 | + "Or update using your package manager:\n" 241 | + "\t\tbrew update && brew upgrade gitfetch\n" 242 | + "\t\tpip install --upgrade gitfetch\n" 243 | + "\t\tpacman -Syu gitfetch-python\n" 244 | + "\t\tsudo apt update && sudo apt install --only-upgrade gitfetch\033[0m") 245 | else: 246 | print("You are using the latest version.") 247 | else: 248 | print("Could not check for updates.") 249 | except Exception: 250 | print("Could not check for updates.") 251 | return 0 252 | 253 | # Initialize config 254 | config_manager = ConfigManager() 255 | 256 | # Check if gitfetch is initialized 257 | if not config_manager.is_initialized(): 258 | print("🚀 Welcome to gitfetch! Let's set up your configuration.\n") 259 | if not _initialize_gitfetch(config_manager): 260 | print("Error: Initialization failed", file=sys.stderr) 261 | return 1 262 | print("\n✅ Configuration saved! You can now use gitfetch.\n") 263 | 264 | # Initialize components 265 | cache_expiry = config_manager.get_cache_expiry_minutes() 266 | cache_manager = CacheManager(cache_expiry_minutes=cache_expiry) 267 | provider = config_manager.get_provider() 268 | provider_url = config_manager.get_provider_url() 269 | token = config_manager.get_token() 270 | if provider == None: 271 | print("Provider not set") 272 | return 1 273 | if provider_url == None: 274 | print("Provider url not set") 275 | return 1 276 | 277 | fetcher = _create_fetcher(provider, provider_url, token) 278 | 279 | # Handle custom box character 280 | custom_box = args.custom_box 281 | 282 | # Handle show date setting 283 | show_date = not args.no_date 284 | 285 | formatter = DisplayFormatter( 286 | config_manager, 287 | custom_box, 288 | show_date, 289 | args.graph_only, 290 | not args.no_achievements, 291 | not args.no_languages, 292 | not args.no_issues, 293 | not args.no_pr, 294 | not args.no_account, 295 | not args.no_grid, 296 | args.width, 297 | args.height, 298 | args.graph_timeline, 299 | args.local, 300 | args.shape, 301 | args.text, 302 | ) 303 | if args.spaced: 304 | spaced = True 305 | elif args.not_spaced: 306 | spaced = False 307 | else: 308 | spaced = True 309 | 310 | # If --text or --shape provided, simulate contribution graph 311 | # and reuse cached metadata (issues, PRs, languages, achievements) 312 | if args.text or args.shape: 313 | if args.text and args.shape: 314 | print("Error: --text and --shape cannot be used together", 315 | file=sys.stderr) 316 | return 1 317 | 318 | try: 319 | if args.text: 320 | # Build a fake contribution_graph from the text 321 | text_grid = formatter._text_to_grid(args.text) 322 | weeks = formatter._generate_weeks_from_text_grid(text_grid) 323 | else: # args.shape 324 | # Use the predefined shape pattern (shape may be a list) 325 | shape_grid = formatter._shape_to_grid(args.shape) 326 | weeks = formatter._generate_weeks_from_text_grid( 327 | shape_grid) 328 | except Exception as e: 329 | print(f"Error generating graph: {e}", file=sys.stderr) 330 | return 1 331 | 332 | # Try to reuse cached user metadata/stats when available 333 | lookup_username = args.username or config_manager.get_default_username() 334 | cached_user = None 335 | cached_stats = None 336 | if lookup_username: 337 | # Prefer fresh cache, but fall back to stale cache so 338 | # simulated graphs can still show metadata like streaks 339 | cached_user = ( 340 | cache_manager.get_cached_user_data(lookup_username) 341 | or cache_manager.get_stale_cached_user_data(lookup_username) 342 | ) 343 | cached_stats = ( 344 | cache_manager.get_cached_stats(lookup_username) 345 | or cache_manager.get_stale_cached_stats(lookup_username) 346 | ) 347 | 348 | if cached_stats: 349 | # Replace only the contribution graph with our simulated weeks 350 | cached_stats['contribution_graph'] = weeks 351 | stats = cached_stats 352 | else: 353 | stats = {'contribution_graph': weeks} 354 | 355 | if cached_user: 356 | user_data = cached_user 357 | display_name = cached_user.get('name') or lookup_username 358 | else: 359 | # Minimal fallback user_data for display purposes 360 | display_name = ( 361 | args.username 362 | or (args.text if args.text else ' '.join(args.shape) if args.shape else None) 363 | ) 364 | user_data = { 365 | 'name': display_name, 366 | 'bio': '', 367 | 'website': '', 368 | } 369 | 370 | if display_name == None: 371 | print("display name not set") 372 | return 1 373 | 374 | formatter.display( 375 | display_name, 376 | user_data, 377 | stats, 378 | spaced=spaced, 379 | ) 380 | return 0 381 | 382 | # Handle cache clearing 383 | if args.clear_cache: 384 | cache_manager.clear() 385 | print("Cache cleared successfully!") 386 | return 0 387 | 388 | # Get username 389 | username = ( 390 | args.username 391 | or config_manager.get_default_username() 392 | or None 393 | ) 394 | 395 | if not username: 396 | # Fall back to authenticated user 397 | try: 398 | username = fetcher.get_authenticated_user() 399 | # Save as default for future use 400 | config_manager.set_default_username(username) 401 | config_manager.save() 402 | except Exception: 403 | username = _prompt_username() 404 | 405 | if not username: 406 | print("Error: Username is required", file=sys.stderr) 407 | return 1 408 | 409 | # Fetch data (with or without cache) 410 | try: 411 | use_cache = not args.no_cache 412 | 413 | if use_cache: 414 | user_data = cache_manager.get_cached_user_data(username) 415 | stats = cache_manager.get_cached_stats(username) 416 | 417 | # If fresh cache is available, just display 418 | if user_data is not None and stats is not None: 419 | formatter.display(username, user_data, 420 | stats, spaced=spaced) 421 | return 0 422 | 423 | # Try stale cache for immediate display 424 | stale_user_data = cache_manager.get_stale_cached_user_data( 425 | username) 426 | stale_stats = cache_manager.get_stale_cached_stats(username) 427 | 428 | if stale_user_data is not None and stale_stats is not None: 429 | formatter.display(username, stale_user_data, 430 | stale_stats, spaced=spaced) 431 | 432 | # Spawn a completely independent background process 433 | # Spawn background refresh process 434 | try: 435 | subprocess.Popen( 436 | [sys.executable, "-m", "gitfetch.cli", 437 | "--background-refresh", username], 438 | stdout=subprocess.DEVNULL, 439 | stderr=subprocess.DEVNULL, 440 | stdin=subprocess.DEVNULL, 441 | start_new_session=True, # detach from parent 442 | ) 443 | except Exception: 444 | # If subprocess fails, silently continue 445 | pass 446 | 447 | return 0 448 | 449 | # No cache at all so fall through to fresh fetch 450 | 451 | # Either no_cache or no valid cache so just fetch fresh data 452 | user_data = fetcher.fetch_user_data(username) 453 | stats = fetcher.fetch_user_stats(username, user_data) 454 | cache_manager.cache_user_data(username, user_data, stats) 455 | 456 | # Display the results 457 | formatter.display(username, user_data, stats, spaced=spaced) 458 | return 0 459 | 460 | except Exception as e: 461 | # When debugging, print full traceback to help diagnose issues 462 | # (useful when users report errors from package builds / other 463 | # environments where the short error message is not enough). 464 | try: 465 | import os 466 | import traceback 467 | if os.environ.get('GITFETCH_DEBUG'): 468 | traceback.print_exc() 469 | else: 470 | print(f"Error: {e}", file=sys.stderr) 471 | except Exception: 472 | # Fallback to simple message if traceback printing fails 473 | print(f"Error: {e}", file=sys.stderr) 474 | return 1 475 | 476 | except KeyboardInterrupt: 477 | print("\nInterrupted by user.", file=sys.stderr) 478 | return 130 479 | 480 | 481 | def _prompt_username() -> Optional[str]: 482 | """Prompt user for username if not provided.""" 483 | try: 484 | username = input("Enter username: ").strip() 485 | return username if username else None 486 | except (KeyboardInterrupt, EOFError): 487 | print() 488 | return None 489 | 490 | 491 | def _prompt_provider() -> Optional[str]: 492 | """Prompt user for git provider with interactive selection.""" 493 | providers = [ 494 | ('github', 'GitHub'), 495 | ('gitlab', 'GitLab'), 496 | ('gitea', 'Gitea/Forgejo/Codeberg'), 497 | ('sourcehut', 'Sourcehut') 498 | ] 499 | 500 | selected = 0 501 | 502 | try: 503 | while True: 504 | # Clear screen and print header 505 | print("\033[2J\033[H", end="") 506 | print("Choose your git provider:") 507 | print() 508 | 509 | # Print options with cursor 510 | for i, (key, name) in enumerate(providers): 511 | indicator = "●" if i == selected else "○" 512 | print(f"{indicator} {name}") 513 | 514 | print() 515 | print("Use ↑/↓ or j/k to navigate, Enter to confirm") 516 | 517 | # Read key 518 | key = readchar.readkey() 519 | 520 | if key == readchar.key.UP or key == 'k': 521 | selected = (selected - 1) % len(providers) 522 | elif key == readchar.key.DOWN or key == 'j': 523 | selected = (selected + 1) % len(providers) 524 | elif key == readchar.key.ENTER: 525 | print() # New line after selection 526 | return providers[selected][0] 527 | 528 | except (KeyboardInterrupt, EOFError): 529 | print() 530 | return None 531 | 532 | 533 | def _create_fetcher(provider: str, base_url: str, token: Optional[str] = None): 534 | """Create the appropriate fetcher for the provider.""" 535 | if provider == 'github': 536 | from .fetcher import GitHubFetcher 537 | return GitHubFetcher(token) 538 | elif provider == 'gitlab': 539 | from .fetcher import GitLabFetcher 540 | return GitLabFetcher(base_url, token) 541 | elif provider == 'gitea': 542 | from .fetcher import GiteaFetcher 543 | return GiteaFetcher(base_url, token) 544 | elif provider == 'sourcehut': 545 | from .fetcher import SourcehutFetcher 546 | return SourcehutFetcher(base_url, token) 547 | else: 548 | raise ValueError(f"Unsupported provider: {provider}") 549 | 550 | 551 | def _initialize_gitfetch(config_manager: ConfigManager) -> bool: 552 | """ 553 | Initialize gitfetch by creating config directory and setting 554 | multiple configuration options. 555 | 556 | Args: 557 | config_manager: ConfigManager instance 558 | 559 | Returns: 560 | True if initialization succeeded, False otherwise 561 | """ 562 | try: 563 | # Ask user for git provider 564 | provider = _prompt_provider() 565 | if not provider: 566 | return False 567 | 568 | config_manager.set_provider(provider) 569 | 570 | # Set default URL for known providers 571 | if provider == 'github': 572 | config_manager.set_provider_url('https://api.github.com') 573 | elif provider == 'gitlab': 574 | config_manager.set_provider_url('https://gitlab.com') 575 | elif provider == 'gitea': 576 | url = input("Enter Gitea/Forgejo/Codeberg URL: ").strip() 577 | if not url: 578 | print("Provider URL required", file=sys.stderr) 579 | return False 580 | config_manager.set_provider_url(url) 581 | elif provider == 'sourcehut': 582 | config_manager.set_provider_url('https://git.sr.ht') 583 | 584 | # Ask for token if needed 585 | token = None 586 | if provider in ['gitlab', 'gitea', 'sourcehut', 'github']: 587 | token_input = input( 588 | f"Enter your {provider} personal access token{', needed for private repositories' if provider == 'github' else ''}\n" 589 | + 590 | "(optional, press Enter to skip): " 591 | ).strip() 592 | if token_input: 593 | token = token_input 594 | config_manager.set_token(token) 595 | 596 | # Create appropriate fetcher 597 | url = config_manager.get_provider_url() 598 | if url == None: 599 | print("Provider url could not be found.", file=sys.stderr) 600 | return False 601 | 602 | fetcher = _create_fetcher( 603 | provider, url, token 604 | ) 605 | 606 | # Try to get authenticated user 607 | try: 608 | username = fetcher.get_authenticated_user() 609 | print(f"Using authenticated user: {username}") 610 | except Exception as e: 611 | print(f"Could not get authenticated user: {e}") 612 | if provider == 'github': 613 | print("Please authenticate with: gh auth login") 614 | elif provider == 'gitlab': 615 | print("Please authenticate with: glab auth login") 616 | else: 617 | print("Please ensure you have a valid token configured") 618 | return False 619 | 620 | # Ask for cache expiry time 621 | cache_expiry_input = input( 622 | "Cache expiry in minutes (default: 15, Enter for default): " 623 | ).strip() 624 | if cache_expiry_input: 625 | try: 626 | cache_expiry = int(cache_expiry_input) 627 | if cache_expiry < 1: 628 | print("Cache expiry must be >= 1 min. Using default: 15") 629 | cache_expiry = 15 630 | config_manager.set_cache_expiry_minutes(cache_expiry) 631 | except ValueError: 632 | print("Invalid input. Using default: 15 minutes.") 633 | config_manager.set_cache_expiry_minutes(15) 634 | else: 635 | config_manager.set_cache_expiry_minutes(15) 636 | 637 | # Save configuration 638 | config_manager.set_default_username(username) 639 | config_manager.save() 640 | 641 | return True 642 | 643 | except Exception as e: 644 | print(f"Error during initialization: {e}", file=sys.stderr) 645 | return False 646 | 647 | 648 | if __name__ == "__main__": 649 | sys.exit(main()) 650 | -------------------------------------------------------------------------------- /src/gitfetch/fetcher.py: -------------------------------------------------------------------------------- 1 | """ 2 | Git data fetcher for various git hosting providers 3 | """ 4 | 5 | from abc import ABC, abstractmethod 6 | from typing import Optional, Dict, Any 7 | import subprocess 8 | import json 9 | import sys 10 | import os 11 | import re 12 | 13 | 14 | class BaseFetcher(ABC): 15 | """Abstract base class for git hosting provider fetchers.""" 16 | 17 | def __init__(self, token: Optional[str] = None): 18 | """ 19 | Initialize the fetcher. 20 | 21 | Args: 22 | token: Optional authentication token 23 | """ 24 | self.token = token 25 | 26 | @abstractmethod 27 | def get_authenticated_user(self) -> str: 28 | """ 29 | Get the authenticated username. 30 | 31 | Returns: 32 | The login of the authenticated user 33 | """ 34 | pass 35 | 36 | @abstractmethod 37 | def fetch_user_data(self, username: str) -> Dict[str, Any]: 38 | """ 39 | Fetch basic user profile data. 40 | 41 | Args: 42 | username: Username to fetch data for 43 | 44 | Returns: 45 | Dictionary containing user profile data 46 | """ 47 | pass 48 | 49 | @abstractmethod 50 | def fetch_user_stats(self, username: str, user_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: 51 | """ 52 | Fetch detailed statistics for a user. 53 | 54 | Args: 55 | username: Username to fetch stats for 56 | user_data: Optional pre-fetched user data 57 | 58 | Returns: 59 | Dictionary containing user statistics 60 | """ 61 | pass 62 | 63 | @staticmethod 64 | def _build_contribution_graph_from_git(repo_path: str = ".") -> list: 65 | """ 66 | Build contribution graph from local .git history. 67 | 68 | Args: 69 | repo_path: Path to the git repository (default: current dir) 70 | 71 | Returns: 72 | List of weeks with contribution data 73 | """ 74 | from datetime import datetime, timedelta 75 | import collections 76 | 77 | try: 78 | # Get commit dates 79 | result = subprocess.run( 80 | ['git', 'log', '--pretty=format:%ai', '--all'], 81 | capture_output=True, text=True, cwd=repo_path, 82 | env={**os.environ, 'GH_TOKEN': os.getenv('GH_TOKEN')} 83 | ) 84 | if result.returncode != 0: 85 | return [] 86 | 87 | commits = result.stdout.strip().split('\n') 88 | if not commits or commits == ['']: 89 | return [] 90 | 91 | # Parse dates and count commits per day 92 | commit_counts = collections.Counter() 93 | for commit in commits: 94 | if commit: 95 | date_str = commit.split(' ')[0] # YYYY-MM-DD 96 | commit_counts[date_str] += 1 97 | 98 | # Get date range (last year) 99 | end_date = datetime.now().date() 100 | start_date = end_date - timedelta(days=365) 101 | 102 | # Build weeks 103 | weeks = [] 104 | current_date = start_date 105 | while current_date <= end_date: 106 | week = {'contributionDays': []} 107 | for i in range(7): 108 | day_date = current_date + timedelta(days=i) 109 | if day_date > end_date: 110 | break 111 | count = commit_counts.get(day_date.isoformat(), 0) 112 | week['contributionDays'].append({ 113 | 'contributionCount': count, 114 | 'date': day_date.isoformat() 115 | }) 116 | if week['contributionDays']: 117 | weeks.append(week) 118 | current_date += timedelta(days=7) 119 | 120 | return weeks 121 | 122 | except Exception: 123 | return [] 124 | 125 | 126 | class GitHubFetcher(BaseFetcher): 127 | """Fetches GitHub user data and statistics using GitHub CLI.""" 128 | 129 | def __init__(self, token: Optional[str] = None): 130 | """ 131 | Initialize the GitHub fetcher. 132 | 133 | Args: 134 | token: Optional GitHub personal access token 135 | """ 136 | self.token=token 137 | pass 138 | 139 | def _check_gh_cli(self) -> None: 140 | """Check if GitHub CLI is installed and authenticated.""" 141 | try: 142 | result = subprocess.run( 143 | ['gh', 'auth', 'status'], 144 | capture_output=True, 145 | text=True, 146 | timeout=5 147 | ) 148 | if result.returncode != 0: 149 | print("\n⚠️ GitHub CLI is not authenticated!", file=sys.stderr) 150 | print("Please run: gh auth login", file=sys.stderr) 151 | print("Then try gitfetch again.\n", file=sys.stderr) 152 | sys.exit(1) 153 | except FileNotFoundError: 154 | print("\n❌ GitHub CLI (gh) is not installed!", file=sys.stderr) 155 | print("\nInstall it with:", file=sys.stderr) 156 | print(" macOS: brew install gh", file=sys.stderr) 157 | print(" Linux: See https://github.com/cli/cli#installation", 158 | file=sys.stderr) 159 | print("\nThen run: gh auth login", file=sys.stderr) 160 | print("And try gitfetch again.\n", file=sys.stderr) 161 | sys.exit(1) 162 | except subprocess.TimeoutExpired: 163 | print("Error: gh CLI command timed out", file=sys.stderr) 164 | sys.exit(1) 165 | 166 | def get_authenticated_user(self) -> str: 167 | """ 168 | Get the authenticated username. 169 | 170 | Returns: 171 | The login of the authenticated user 172 | """ 173 | try: 174 | result = subprocess.run( 175 | ['gh', 'auth', 'status', '--json', 'hosts'], 176 | capture_output=True, 177 | text=True, 178 | timeout=5 179 | ) 180 | if result.returncode != 0: 181 | try: 182 | yml = open(os.path.expanduser( 183 | "~/.config/gh/hosts.yml"), 'r').read() 184 | user = re.findall(" +user: +(.*)", yml) 185 | if len(user) != 0: 186 | return user[0] 187 | else: 188 | raise Exception("Failed to get auth status") 189 | except FileNotFoundError: 190 | raise Exception("Failed to get auth status") 191 | 192 | data = json.loads(result.stdout) 193 | hosts = data.get('hosts', {}) 194 | github_com = hosts.get('github.com', []) 195 | if github_com and len(github_com) > 0: 196 | return github_com[0]['login'] 197 | else: 198 | raise Exception("No GitHub.com auth found") 199 | except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError): 200 | raise Exception("Could not determine authenticated user") 201 | 202 | def _gh_api(self, endpoint: str, method: str = "GET") -> Any: 203 | """ 204 | Call GitHub API using gh CLI. 205 | 206 | Args: 207 | endpoint: API endpoint (e.g., '/users/octocat') 208 | method: HTTP method 209 | 210 | Returns: 211 | Parsed JSON response 212 | """ 213 | self._check_gh_cli() 214 | try: 215 | result = subprocess.run( 216 | ['gh', 'api', endpoint, '-X', method], 217 | capture_output=True, 218 | text=True, 219 | timeout=30, 220 | env={**os.environ, 'GH_TOKEN': os.getenv('GH_TOKEN')} 221 | ) 222 | if result.returncode != 0: 223 | raise Exception(f"gh api failed: {result.stderr}") 224 | return json.loads(result.stdout) 225 | except subprocess.TimeoutExpired: 226 | raise Exception("GitHub API request timed out") 227 | except json.JSONDecodeError as e: 228 | raise Exception(f"Failed to parse GitHub API response: {e}") 229 | 230 | def fetch_user_data(self, username: str) -> Dict[str, Any]: 231 | """ 232 | Fetch basic user profile data from GitHub. 233 | 234 | Args: 235 | username: GitHub username 236 | 237 | Returns: 238 | Dictionary containing user profile data 239 | """ 240 | self._check_gh_cli() 241 | return self._gh_api(f'/users/{username}') 242 | 243 | def fetch_user_stats(self, username: str, user_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: 244 | """ 245 | Fetch detailed statistics for a GitHub user. 246 | 247 | Args: 248 | username: GitHub username 249 | user_data: Optional pre-fetched user data to avoid duplicate API call 250 | 251 | Returns: 252 | Dictionary containing user statistics 253 | """ 254 | self._check_gh_cli() 255 | repos = self._fetch_repos(username) 256 | 257 | total_stars = sum(repo.get('stargazers_count', 0) for repo in repos) 258 | total_forks = sum(repo.get('forks_count', 0) for repo in repos) 259 | languages = self._calculate_language_stats(repos) 260 | 261 | # Fetch contribution graph 262 | contrib_graph = self._fetch_contribution_graph(username) 263 | 264 | # Calculate current contribution streak (most recent consecutive days 265 | # with contributions). Flatten days in chronological order and then 266 | # compute streak ending with the most recent day. 267 | current_streak = 0 268 | try: 269 | all_contributions = [] 270 | for week in contrib_graph: 271 | for day in week.get('contributionDays', []): 272 | all_contributions.append(day.get('contributionCount', 0)) 273 | 274 | # Reverse to make newest first 275 | all_contributions.reverse() 276 | for contrib in all_contributions: 277 | if contrib > 0: 278 | current_streak += 1 279 | else: 280 | break 281 | except Exception: 282 | current_streak = 0 283 | 284 | # Use @me for search queries if this is the authenticated user 285 | search_username = self._get_search_username(username) 286 | 287 | pull_requests = { 288 | 'awaiting_review': self._search_items( 289 | f'is:pr state:open review-requested:{search_username}' 290 | ), 291 | 'open': self._search_items( 292 | f'is:pr state:open author:{search_username}' 293 | ), 294 | 'mentions': self._search_items( 295 | f'is:pr state:open mentions:{search_username}' 296 | ), 297 | } 298 | 299 | issues = { 300 | 'assigned': self._search_items( 301 | f'is:issue state:open assignee:{search_username}' 302 | ), 303 | 'created': self._search_items( 304 | f'is:issue state:open author:{search_username}' 305 | ), 306 | 'mentions': self._search_items( 307 | f'is:issue state:open mentions:{search_username}' 308 | ), 309 | } 310 | 311 | return { 312 | 'total_stars': total_stars, 313 | 'total_forks': total_forks, 314 | 'total_repos': len(repos), 315 | 'languages': languages, 316 | 'contribution_graph': contrib_graph, 317 | # Include current streak so it gets cached and reused by display 318 | 'current_streak': current_streak, 319 | 'pull_requests': pull_requests, 320 | 'issues': issues, 321 | } 322 | 323 | def _get_search_username(self, username: str) -> str: 324 | """ 325 | Get the username to use for search queries. 326 | Uses @me for the authenticated user, otherwise the provided username. 327 | 328 | Args: 329 | username: The username to check 330 | 331 | Returns: 332 | Username for search queries (@me or the actual username) 333 | """ 334 | try: 335 | # Get the authenticated user's login 336 | auth_user = self._gh_api('/user') 337 | if auth_user.get('login') == username: 338 | return '@me' 339 | except Exception: 340 | # If we can't determine auth user, use provided username 341 | pass 342 | return username 343 | 344 | def _fetch_repos(self, username: str) -> list: 345 | """ 346 | Fetch all public repositories for a user. 347 | 348 | Args: 349 | username: GitHub username 350 | 351 | Returns: 352 | List of repository data 353 | """ 354 | repos = [] 355 | page = 1 356 | per_page = 100 357 | 358 | while True: 359 | endpoint = ( 360 | f'/users/{username}/repos?page={page}' 361 | f'&per_page={per_page}&type=owner&sort=updated' 362 | ) 363 | data = self._gh_api(endpoint) 364 | 365 | if not data: 366 | break 367 | 368 | repos.extend(data) 369 | page += 1 370 | 371 | # Stop if we got less than a full page 372 | if len(data) < per_page: 373 | break 374 | 375 | return repos 376 | 377 | def _calculate_language_stats(self, repos: list) -> Dict[str, float]: 378 | """ 379 | Calculate language usage statistics from repositories. 380 | 381 | Args: 382 | repos: List of repository data 383 | 384 | Returns: 385 | Dictionary mapping language names to percentages 386 | """ 387 | from collections import defaultdict 388 | 389 | # First pass: collect all language occurrences with their casing 390 | language_occurrences: Dict[str, Dict[str, int]] = defaultdict( 391 | lambda: defaultdict(int)) 392 | 393 | for repo in repos: 394 | language = repo.get('language') 395 | if language: 396 | # Group by lowercase name, but keep track of different casings 397 | normalized = language.lower() 398 | language_occurrences[normalized][language] += 1 399 | 400 | # Second pass: choose canonical casing (most frequent) and sum counts 401 | language_counts: Dict[str, int] = {} 402 | 403 | for normalized, casings in language_occurrences.items(): 404 | # Find the most common casing 405 | canonical_name = max(casings.items(), key=lambda x: x[1])[0] 406 | # Sum all occurrences for this language 407 | total_count = sum(casings.values()) 408 | language_counts[canonical_name] = total_count 409 | 410 | # Calculate percentages 411 | total = sum(language_counts.values()) 412 | if total == 0: 413 | return {} 414 | 415 | language_percentages = { 416 | lang: (count / total) * 100 417 | for lang, count in language_counts.items() 418 | } 419 | 420 | return language_percentages 421 | 422 | def _search_items(self, query: str, per_page: int = 5) -> Dict[str, Any]: 423 | """Search issues and PRs using GitHub CLI search command.""" 424 | try: 425 | # Determine search type based on query 426 | search_type = 'prs' if 'is:pr' in query else 'issues' 427 | 428 | # Remove is:pr/issue from query as it's implied by search type 429 | query = query.replace('is:pr ', '').replace('is:issue ', '') 430 | 431 | # Parse query string and convert to command-line flags 432 | flags = self._parse_search_query(query) 433 | 434 | # Build command with proper flags 435 | cmd = ['gh', 'search', search_type] + flags + [ 436 | '--limit', str(per_page), 437 | '--json', 'number,title,repository,url,state' 438 | ] 439 | 440 | result = subprocess.run( 441 | cmd, 442 | capture_output=True, 443 | text=True, 444 | timeout=30, 445 | env={**os.environ, 'GH_TOKEN': os.getenv('GH_TOKEN')} 446 | ) 447 | if result.returncode != 0: 448 | return {'total_count': 0, 'items': []} 449 | 450 | data = json.loads(result.stdout) 451 | items = [] 452 | for item in data[:per_page]: 453 | repo_info = item.get('repository', {}) 454 | repo_name = repo_info.get( 455 | 'nameWithOwner', 456 | repo_info.get('name', '') 457 | ) 458 | items.append({ 459 | 'title': item.get('title', ''), 460 | 'repo': repo_name, 461 | 'url': item.get('url', ''), 462 | 'number': item.get('number') 463 | }) 464 | 465 | # gh search doesn't return total count in JSON, use item count 466 | return { 467 | 'total_count': len(items), 468 | 'items': items 469 | } 470 | except (subprocess.TimeoutExpired, json.JSONDecodeError): 471 | return {'total_count': 0, 'items': []} 472 | 473 | def _parse_search_query(self, query: str) -> list: 474 | """Parse search query string into command-line flags.""" 475 | flags = [] 476 | parts = query.split() 477 | 478 | for part in parts: 479 | if ':' in part: 480 | key, value = part.split(':', 1) 481 | if key == 'assignee': 482 | flags.extend(['--assignee', value]) 483 | elif key == 'author': 484 | flags.extend(['--author', value]) 485 | elif key == 'mentions': 486 | flags.extend(['--mentions', value]) 487 | elif key == 'review-requested': 488 | flags.extend(['--review-requested', value]) 489 | elif key == 'state': 490 | flags.extend(['--state', value]) 491 | elif key == 'is': 492 | # Handle is:pr and is:issue 493 | # For prs search, we don't need this flag 494 | # For issues search, is:issue is default 495 | pass 496 | else: 497 | # For other qualifiers, add as search term 498 | flags.append(part) 499 | else: 500 | # Add as general search term 501 | flags.append(part) 502 | 503 | return flags 504 | 505 | @staticmethod 506 | def _extract_repo_name(repo_url: str) -> str: 507 | """Extract owner/repo from a repository API URL.""" 508 | if not repo_url: 509 | return "" 510 | 511 | parts = repo_url.rstrip('/').split('/') 512 | if len(parts) >= 2: 513 | return f"{parts[-2]}/{parts[-1]}" 514 | return repo_url 515 | 516 | def _get_rate_limit(self) -> Dict[str, Any]: 517 | """ 518 | Check current GitHub API rate limit status. 519 | 520 | Returns: 521 | Dictionary containing rate limit info 522 | """ 523 | return self._gh_api('/rate_limit') 524 | 525 | def _fetch_contribution_graph(self, username: str) -> list: 526 | """ 527 | Fetch contribution graph data using GraphQL. 528 | 529 | Args: 530 | username: GitHub username 531 | 532 | Returns: 533 | List of weeks with contribution data 534 | """ 535 | # GraphQL query for contribution calendar (inline username) 536 | query = f'''{{ 537 | user(login: "{username}") {{ 538 | contributionsCollection(includePrivate: true) {{ 539 | contributionCalendar {{ 540 | weeks {{ 541 | contributionDays {{ 542 | contributionCount 543 | date 544 | }} 545 | }} 546 | }} 547 | }} 548 | }} 549 | }}''' 550 | 551 | try: 552 | result = subprocess.run( 553 | ['gh', 'api', 'graphql', '-f', f'query={query}'], 554 | capture_output=True, 555 | text=True, 556 | timeout=30, 557 | env={**os.environ, 'GH_TOKEN': os.getenv('GH_TOKEN')} 558 | ) 559 | 560 | if result.returncode != 0: 561 | return [] 562 | 563 | data = json.loads(result.stdout) 564 | weeks = data.get('data', {}).get('user', {}).get( 565 | 'contributionsCollection', {}).get( 566 | 'contributionCalendar', {}).get('weeks', []) 567 | return weeks 568 | 569 | except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError): 570 | return [] 571 | 572 | 573 | class GitLabFetcher(BaseFetcher): 574 | """Fetches GitLab user data and statistics.""" 575 | 576 | def __init__(self, base_url: str = "https://gitlab.com", 577 | token: Optional[str] = None): 578 | """ 579 | Initialize the GitLab fetcher. 580 | 581 | Args: 582 | base_url: GitLab instance base URL 583 | token: Optional GitLab personal access token 584 | """ 585 | super().__init__(token) 586 | self.base_url = base_url.rstrip('/') 587 | self.api_base = f"{self.base_url}/api/v4" 588 | 589 | def _check_glab_cli(self) -> None: 590 | """Check if GitLab CLI is installed and authenticated.""" 591 | try: 592 | result = subprocess.run( 593 | ['glab', 'auth', 'status'], 594 | capture_output=True, 595 | text=True, 596 | timeout=5 597 | ) 598 | if result.returncode != 0: 599 | print("GitLab CLI not authenticated", file=sys.stderr) 600 | print("Please run: glab auth login", file=sys.stderr) 601 | sys.exit(1) 602 | except FileNotFoundError: 603 | print("GitLab CLI (glab) not installed", file=sys.stderr) 604 | print("Install: https://gitlab.com/gitlab-org/cli", 605 | file=sys.stderr) 606 | sys.exit(1) 607 | except subprocess.TimeoutExpired: 608 | print("Error: glab CLI timeout", file=sys.stderr) 609 | sys.exit(1) 610 | 611 | def get_authenticated_user(self) -> str: 612 | """ 613 | Get the authenticated GitLab username. 614 | 615 | Returns: 616 | The username of the authenticated user 617 | """ 618 | try: 619 | result = subprocess.run( 620 | ['glab', 'api', '/user'], 621 | capture_output=True, 622 | text=True, 623 | timeout=10 624 | ) 625 | if result.returncode != 0: 626 | raise Exception("Failed to get user info") 627 | 628 | data = json.loads(result.stdout) 629 | return data.get('username', '') 630 | except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError): 631 | raise Exception("Could not determine authenticated user") 632 | 633 | def _api_request(self, endpoint: str) -> Any: 634 | """ 635 | Make API request to GitLab. 636 | 637 | Args: 638 | endpoint: API endpoint 639 | 640 | Returns: 641 | Parsed JSON response 642 | """ 643 | cmd = ['glab', 'api', endpoint] 644 | try: 645 | result = subprocess.run( 646 | cmd, 647 | capture_output=True, 648 | text=True, 649 | timeout=30, 650 | env={**os.environ, 'GH_TOKEN': os.getenv('GH_TOKEN')} 651 | ) 652 | if result.returncode != 0: 653 | raise Exception(f"API request failed: {result.stderr}") 654 | return json.loads(result.stdout) 655 | except subprocess.TimeoutExpired: 656 | raise Exception("GitLab API request timed out") 657 | except json.JSONDecodeError as e: 658 | raise Exception(f"Failed to parse API response: {e}") 659 | 660 | def fetch_user_data(self, username: str) -> Dict[str, Any]: 661 | """ 662 | Fetch basic user profile data from GitLab. 663 | 664 | Args: 665 | username: GitLab username 666 | 667 | Returns: 668 | Dictionary containing user profile data 669 | """ 670 | return self._api_request(f'/users?username={username}')[0] 671 | 672 | def fetch_user_stats(self, username: str, user_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: 673 | """ 674 | Fetch detailed statistics for a GitLab user. 675 | 676 | Args: 677 | username: GitLab username 678 | user_data: Optional pre-fetched user data 679 | 680 | Returns: 681 | Dictionary containing user statistics 682 | """ 683 | if not user_data: 684 | user_data = self.fetch_user_data(username) 685 | 686 | user_id = user_data.get('id') 687 | 688 | # Fetch user's projects 689 | repos = self._api_request(f'/users/{user_id}/projects') 690 | 691 | total_stars = sum(repo.get('star_count', 0) for repo in repos) 692 | total_forks = sum(repo.get('forks_count', 0) for repo in repos) 693 | 694 | # Calculate language stats 695 | languages = {} 696 | for repo in repos: 697 | lang = repo.get('language', 'Unknown') 698 | if lang in languages: 699 | languages[lang] += 1 700 | else: 701 | languages[lang] = 1 702 | 703 | # GitLab doesn't have contribution graphs like GitHub 704 | # Return simplified stats 705 | return { 706 | 'total_stars': total_stars, 707 | 'total_forks': total_forks, 708 | 'total_repos': len(repos), 709 | 'languages': languages, 710 | 'contribution_graph': [], # Not available 711 | 'pull_requests': {'open': 0, 'awaiting_review': 0, 'mentions': 0}, 712 | 'issues': {'assigned': 0, 'created': 0, 'mentions': 0}, 713 | } 714 | 715 | 716 | class GiteaFetcher(BaseFetcher): 717 | """Fetches Gitea/Forgejo/Codeberg user data and statistics.""" 718 | 719 | def __init__(self, base_url: str, token: Optional[str] = None): 720 | """ 721 | Initialize the Gitea fetcher. 722 | 723 | Args: 724 | base_url: Gitea instance base URL (required) 725 | token: Optional Gitea personal access token 726 | """ 727 | super().__init__(token) 728 | self.base_url = base_url.rstrip('/') 729 | self.api_base = f"{self.base_url}/api/v1" 730 | 731 | def get_authenticated_user(self) -> str: 732 | """ 733 | Get the authenticated Gitea username. 734 | 735 | Returns: 736 | The username of the authenticated user 737 | """ 738 | if not self.token: 739 | raise Exception("Token required for Gitea authentication") 740 | 741 | try: 742 | import requests 743 | headers = {'Authorization': f'token {self.token}'} 744 | response = requests.get( 745 | f'{self.api_base}/user', headers=headers, timeout=10) 746 | response.raise_for_status() 747 | data = response.json() 748 | return data.get('login', '') 749 | except Exception as e: 750 | raise Exception(f"Could not get authenticated user: {e}") 751 | 752 | def _api_request(self, endpoint: str) -> Any: 753 | """ 754 | Make API request to Gitea. 755 | 756 | Args: 757 | endpoint: API endpoint 758 | 759 | Returns: 760 | Parsed JSON response 761 | """ 762 | if not self.token: 763 | raise Exception("Token required for Gitea API") 764 | 765 | try: 766 | import requests 767 | headers = {'Authorization': f'token {self.token}'} 768 | response = requests.get( 769 | f'{self.api_base}{endpoint}', headers=headers, timeout=30) 770 | response.raise_for_status() 771 | return response.json() 772 | except Exception as e: 773 | raise Exception(f"Gitea API request failed: {e}") 774 | 775 | def fetch_user_data(self, username: str) -> Dict[str, Any]: 776 | """ 777 | Fetch basic user profile data from Gitea. 778 | 779 | Args: 780 | username: Gitea username 781 | 782 | Returns: 783 | Dictionary containing user profile data 784 | """ 785 | return self._api_request(f'/users/{username}') 786 | 787 | def fetch_user_stats(self, username: str, user_data=None): 788 | """ 789 | Fetch detailed statistics for a Gitea user. 790 | 791 | Args: 792 | username: Gitea username 793 | user_data: Optional pre-fetched user data 794 | 795 | Returns: 796 | Dictionary containing user statistics 797 | """ 798 | if not user_data: 799 | user_data = self.fetch_user_data(username) 800 | 801 | # Fetch user's repositories 802 | repos = self._api_request(f'/users/{username}/repos') 803 | 804 | total_stars = sum(repo.get('stars_count', 0) for repo in repos) 805 | total_forks = sum(repo.get('forks_count', 0) for repo in repos) 806 | 807 | # Calculate language stats 808 | languages = {} 809 | for repo in repos: 810 | lang = repo.get('language', 'Unknown') 811 | if lang and lang in languages: 812 | languages[lang] += 1 813 | elif lang: 814 | languages[lang] = 1 815 | 816 | # Gitea doesn't have contribution graphs or PR/issue stats like GitHub 817 | return { 818 | 'total_stars': total_stars, 819 | 'total_forks': total_forks, 820 | 'total_repos': len(repos), 821 | 'languages': languages, 822 | 'contribution_graph': [], # Not available 823 | 'pull_requests': {'open': 0, 'awaiting_review': 0, 'mentions': 0}, 824 | 'issues': {'assigned': 0, 'created': 0, 'mentions': 0}, 825 | } 826 | 827 | 828 | class SourcehutFetcher(BaseFetcher): 829 | """Fetches Sourcehut user data and statistics.""" 830 | 831 | def __init__(self, base_url: str = "https://git.sr.ht", token: Optional[str] = None): 832 | """ 833 | Initialize the Sourcehut fetcher. 834 | 835 | Args: 836 | base_url: Sourcehut instance base URL 837 | token: Optional Sourcehut personal access token 838 | """ 839 | super().__init__(token) 840 | self.base_url = base_url.rstrip('/') 841 | 842 | def get_authenticated_user(self) -> str: 843 | """ 844 | Get the authenticated Sourcehut username. 845 | 846 | Returns: 847 | The username of the authenticated user 848 | """ 849 | if not self.token: 850 | raise Exception("Token required for Sourcehut authentication") 851 | 852 | # Sourcehut uses GraphQL API 853 | try: 854 | import requests 855 | query = """ 856 | query { 857 | me { 858 | username 859 | } 860 | } 861 | """ 862 | headers = {'Authorization': f'Bearer {self.token}'} 863 | response = requests.post( 864 | f'{self.base_url}/graphql', 865 | json={'query': query}, 866 | headers=headers, 867 | timeout=10 868 | ) 869 | response.raise_for_status() 870 | data = response.json() 871 | return data.get('data', {}).get('me', {}).get('username', '') 872 | except Exception as e: 873 | raise Exception(f"Could not get authenticated user: {e}") 874 | 875 | def fetch_user_data(self, username: str) -> Dict[str, Any]: 876 | """ 877 | Fetch basic user profile data from Sourcehut. 878 | 879 | Args: 880 | username: Sourcehut username 881 | 882 | Returns: 883 | Dictionary containing user profile data 884 | """ 885 | # Sourcehut GraphQL query for user 886 | query = f""" 887 | query {{ 888 | user(username: "{username}") {{ 889 | username 890 | name 891 | bio 892 | location 893 | website 894 | }} 895 | }} 896 | """ 897 | try: 898 | import requests 899 | headers = { 900 | 'Authorization': f'Bearer {self.token}'} if self.token else {} 901 | response = requests.post( 902 | f'{self.base_url}/graphql', 903 | json={'query': query}, 904 | headers=headers, 905 | timeout=30 906 | ) 907 | response.raise_for_status() 908 | data = response.json() 909 | return data.get('data', {}).get('user', {}) 910 | except Exception as e: 911 | raise Exception(f"Sourcehut API request failed: {e}") 912 | 913 | def fetch_user_stats(self, username: str, user_data=None): 914 | """ 915 | Fetch detailed statistics for a Sourcehut user. 916 | 917 | Args: 918 | username: Sourcehut username 919 | user_data: Optional pre-fetched user data 920 | 921 | Returns: 922 | Dictionary containing user statistics 923 | """ 924 | # Sourcehut has limited public stats, return minimal data 925 | return { 926 | 'total_stars': 0, # Not available 927 | 'total_forks': 0, # Not available 928 | 'total_repos': 0, # Would need separate API call 929 | 'languages': {}, # Not available 930 | 'contribution_graph': [], # Not available 931 | 'pull_requests': {'open': 0, 'awaiting_review': 0, 'mentions': 0}, 932 | 'issues': {'assigned': 0, 'created': 0, 'mentions': 0}, 933 | } 934 | --------------------------------------------------------------------------------