├── 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 |
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 | [](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 |
12 |
13 | |
14 |
15 |
16 | |
17 |
18 |
19 |
20 |
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 |
133 |
134 |
135 | ## Community Setups Gallery
136 |
137 |
138 |
139 |

140 |
141 |
Classic GitHub Setup
142 |
Default gitfetch display showing comprehensive GitHub statistics with the classic three layouts
143 |
144 |
145 |
146 |
147 |

148 |
149 |
Modified Configuration
150 |
These setups use the visual flags feature to enhance their appearance. Here we have examples of ``--no-(component)``, ``--custom-box``
151 |
152 |
153 |
154 |
155 |

156 |
157 |
Hyprland Integration
158 |
Beautiful integration with Hyprland window manager by @fwtwoo
159 |
160 |
161 |
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 | - Customize your setup - Use gitfetch's extensive configuration options
170 | - Take a screenshot - Capture your terminal with gitfetch running
171 | - Create an issue - Open a GitHub issue with your screenshot and configuration details
172 | - Get featured - Your setup might be added to our gallery!
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 |
--------------------------------------------------------------------------------