diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..a10484d --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,178 @@ +name: Publish Python Package + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install -r requirements-dev.txt + + - name: Lint with flake8 + run: | + flake8 src/gitspaces --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 src/gitspaces --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Check code formatting with black + run: | + black --check src/gitspaces tests + + - name: Run tests with pytest + run: | + pytest tests/ -v --cov=src/gitspaces --cov-report=xml --cov-report=term + + build: + needs: test + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Extract version from tag + id: get_version + run: | + # Remove 'v' prefix from tag + VERSION=${GITHUB_REF#refs/tags/v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + + - name: Update version in pyproject.toml + run: | + sed -i 's/^version = ".*"/version = "${{ steps.get_version.outputs.version }}"/' pyproject.toml + + - name: Update version in __init__.py + run: | + sed -i 's/__version__ = ".*"/__version__ = "${{ steps.get_version.outputs.version }}"/' src/gitspaces/__init__.py + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build package + run: python -m build + + - name: Rename distribution files + run: | + cd dist + for file in *; do + # Add gitspaces- prefix if not already present + if [[ ! "$file" =~ ^gitspaces- ]]; then + mv "$file" "gitspaces-${{ steps.get_version.outputs.tag }}-${file}" + fi + done + ls -la + + - name: Check package + run: twine check dist/* + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + test-pypi-publish: + name: Publish to TestPyPI + needs: build + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/gitspaces + permissions: + id-token: write + + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + github-release: + name: Create GitHub Release + needs: test-pypi-publish + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - name: Extract version from tag + id: get_version + run: | + VERSION=${GITHUB_REF#refs/tags/v} + TAG=${GITHUB_REF#refs/tags/} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=$TAG" >> $GITHUB_OUTPUT + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: dist/* + tag_name: ${{ steps.get_version.outputs.tag }} + name: Release ${{ steps.get_version.outputs.tag }} + draft: false + prerelease: false + generate_release_notes: true + + pypi-publish: + name: Publish to PyPI + needs: github-release + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/gitspaces + permissions: + id-token: write + + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..108bf91 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,221 @@ +name: Python Tests + +on: + push: + branches: [ main, v2, copilot/** ] + pull_request: + branches: [ main, v2 ] + +permissions: + contents: read + +jobs: + security-scan: + name: Security Scan + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + + steps: + - uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: python + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install bandit safety + + - name: Run Bandit security scan + run: | + bandit -r src/gitspaces -f json -o bandit-report.json || true + bandit -r src/gitspaces -ll || true + + - name: Check dependencies for vulnerabilities + run: | + safety check --json || true + safety check || echo "Safety check failed or requires authentication" + + test: + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + shell: [default] + include: + # Windows PowerShell tests + - os: windows-latest + python-version: '3.8' + shell: pwsh + - os: windows-latest + python-version: '3.9' + shell: pwsh + - os: windows-latest + python-version: '3.10' + shell: pwsh + - os: windows-latest + python-version: '3.11' + shell: pwsh + - os: windows-latest + python-version: '3.12' + shell: pwsh + - os: windows-latest + python-version: '3.13' + shell: pwsh + # Windows cmd.exe tests + - os: windows-latest + python-version: '3.8' + shell: cmd + - os: windows-latest + python-version: '3.9' + shell: cmd + - os: windows-latest + python-version: '3.10' + shell: cmd + - os: windows-latest + python-version: '3.11' + shell: cmd + - os: windows-latest + python-version: '3.12' + shell: cmd + - os: windows-latest + python-version: '3.13' + shell: cmd + # Windows WSL bash tests + - os: windows-latest + python-version: '3.8' + shell: wsl-bash + - os: windows-latest + python-version: '3.9' + shell: wsl-bash + - os: windows-latest + python-version: '3.10' + shell: wsl-bash + - os: windows-latest + python-version: '3.11' + shell: wsl-bash + - os: windows-latest + python-version: '3.12' + shell: wsl-bash + - os: windows-latest + python-version: '3.13' + shell: wsl-bash + + defaults: + run: + shell: ${{ matrix.shell == 'cmd' && 'cmd' || matrix.shell == 'pwsh' && 'pwsh' || matrix.shell == 'wsl-bash' && 'wsl-bash {0}' || 'bash' }} + + steps: + - uses: actions/checkout@v4 + + - name: Set up WSL + if: matrix.shell == 'wsl-bash' + uses: Vampire/setup-wsl@v3 + with: + distribution: Ubuntu-22.04 + use-cache: false # Disable cache to avoid corruption issues + + - name: Set up Python ${{ matrix.python-version }} (WSL) + if: matrix.shell == 'wsl-bash' + shell: wsl-bash {0} + run: | + sudo apt-get update + sudo apt-get install -y software-properties-common + sudo add-apt-repository -y ppa:deadsnakes/ppa + sudo apt-get update + # Install Python - note: distutils not available for 3.12+, use full setuptools + if [ "${{ matrix.python-version }}" = "3.13" ] || [ "${{ matrix.python-version }}" = "3.12" ]; then + sudo apt-get install -y python${{ matrix.python-version }} python${{ matrix.python-version }}-venv python3-pip python3-setuptools || { + echo "Python ${{ matrix.python-version }} not available in deadsnakes, trying default repos" + sudo apt-get install -y python${{ matrix.python-version }} python${{ matrix.python-version }}-venv python3-pip + } + else + sudo apt-get install -y python${{ matrix.python-version }} python${{ matrix.python-version }}-venv python${{ matrix.python-version }}-distutils python3-pip + fi + python${{ matrix.python-version }} --version + + - name: Set up Python ${{ matrix.python-version }} + if: matrix.shell != 'wsl-bash' + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies (WSL) + if: matrix.shell == 'wsl-bash' + shell: wsl-bash {0} + run: | + # Ensure pip is available for this Python version + python${{ matrix.python-version }} -m ensurepip --default-pip 2>/dev/null || true + python${{ matrix.python-version }} -m pip install --upgrade pip setuptools wheel + python${{ matrix.python-version }} -m pip install -e . + python${{ matrix.python-version }} -m pip install -r requirements-dev.txt + + - name: Install dependencies + if: matrix.shell != 'wsl-bash' + run: | + python -m pip install --upgrade pip setuptools wheel + pip install -e . + pip install -r requirements-dev.txt + + - name: Lint with flake8 (WSL) + if: matrix.shell == 'wsl-bash' + shell: wsl-bash {0} + run: | + # Stop the build if there are Python syntax errors or undefined names + python${{ matrix.python-version }} -m flake8 src/gitspaces --count --select=E9,F63,F7,F82 --show-source --statistics + # Exit-zero treats all errors as warnings + python${{ matrix.python-version }} -m flake8 src/gitspaces --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Lint with flake8 + if: matrix.shell != 'wsl-bash' + run: | + # Stop the build if there are Python syntax errors or undefined names + flake8 src/gitspaces --count --select=E9,F63,F7,F82 --show-source --statistics + # Exit-zero treats all errors as warnings + flake8 src/gitspaces --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Check code formatting with black (WSL) + if: matrix.shell == 'wsl-bash' + shell: wsl-bash {0} + run: | + python${{ matrix.python-version }} -m black --check src/gitspaces tests + + - name: Check code formatting with black + if: matrix.shell != 'wsl-bash' + run: | + black --check src/gitspaces tests + + - name: Run tests with pytest (WSL) + if: matrix.shell == 'wsl-bash' + shell: wsl-bash {0} + run: | + python${{ matrix.python-version }} -m pytest tests/ -v --cov=src/gitspaces --cov-report=xml --cov-report=term + + - name: Run tests with pytest + if: matrix.shell != 'wsl-bash' + run: | + pytest tests/ -v --cov=src/gitspaces --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11' + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella diff --git a/.gitignore b/.gitignore index 4fb6690..67efb42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,22 @@ .DS_Store __debug* build/ + +# Python artifacts +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +*.egg-info/ +dist/ +.eggs/ +*.egg +.pytest_cache/ +.coverage +htmlcov/ +.mypy_cache/ +.tox/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5866740 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing to GitSpaces + +## Getting Started + +1. Fork and clone the repository +2. Create a branch for your changes +3. Make changes and add tests +4. Submit a pull request + +## Development Setup + +Requirements: Python 3.8+, Git + +```bash +git clone https://github.com/davfive/gitspaces.git +cd gitspaces +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +pip install -r requirements-dev.txt +pip install -e . +``` + +## Pull Request Guidelines + +- Update README.md for interface changes +- Add tests for new functionality +- Ensure tests pass: `pytest` +- Format code: `black src/gitspaces tests` +- Lint: `flake8 src/gitspaces tests` +- Security scan: `bandit -r src/gitspaces` + +## Style + +- PEP 8, max line length 100 +- Use `black` for formatting +- Use type hints where appropriate +- Google-style docstrings + +## Testing + +```bash +pytest # run all tests +pytest --cov=src/gitspaces # with coverage +pytest tests/test_config.py # specific file +pytest tests/test_config.py::test_name # specific test +``` + +## Commit Format + +- Present tense, imperative mood +- First line ≤72 characters +- Reference issues/PRs + +Example: +``` +Add extend command for creating additional clones + +- Implement cmd_extend module +- Add -n flag for clone count + +Fixes #123 +``` + +## Release Process + +See [README.DEPLOYMENT.md](README.DEPLOYMENT.md) for deployment details. + +Quick version: +1. Update version in `pyproject.toml` and `src/gitspaces/__init__.py` +2. Tag: `git tag -a v1.0.0 -m "Release v1.0.0"` +3. Push: `git push origin v1.0.0` +4. GitHub Actions handles build and PyPI publish diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..e689673 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,96 @@ +# Development Setup + +## Local Development + +### Prerequisites + +- Python 3.8 or higher +- pip +- git + +### Setup + +1. **Clone the repository:** + ```bash + git clone https://github.com/davfive/gitspaces.git + cd gitspaces + ``` + +2. **Install in editable mode:** + ```bash + pip install -e . + ``` + + This installs the package in development mode, allowing you to make changes to the code and test them immediately. + +3. **Install development dependencies:** + ```bash + pip install -r requirements-dev.txt + ``` + +### Running Tests + +```bash +# Run all tests +pytest tests/ -v + +# Run with coverage +pytest tests/ -v --cov=src/gitspaces --cov-report=term-missing + +# Run specific test file +pytest tests/test_project.py -v +``` + +### Code Quality + +```bash +# Format code +black src/gitspaces tests + +# Lint code +flake8 src/gitspaces + +# Security scan +bandit -r src/gitspaces +``` + +### Troubleshooting + +**Import errors when running tests:** + +If you see errors like: +``` +ImportError: No module named 'gitspaces' +``` + +Make sure you've installed the package in editable mode: +```bash +pip install -e . +``` + +**Module not found after installation:** + +If the module still can't be found, verify your Python path: +```bash +python -c "import sys; print('\n'.join(sys.path))" +pip show gitspaces +``` + +## Project Structure + +``` +gitspaces/ +├── src/gitspaces/ # Main package +│ ├── modules/ # Core modules +│ │ ├── config.py # Configuration management +│ │ ├── project.py # Project management +│ │ ├── space.py # Space management +│ │ ├── runshell.py # External command wrapper +│ │ └── cmd_*.py # CLI commands +│ ├── cli.py # CLI entry point +│ └── __init__.py +├── tests/ # Test suite +├── docs/ # Documentation +├── pyproject.toml # Package metadata +└── requirements*.txt # Dependencies +``` diff --git a/LICENSE b/LICENSE index a25104d..42a82c8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 https://github.com/davfive +Copyright (c) 2024 David Rowe Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile deleted file mode 100755 index 89eb0de..0000000 --- a/Makefile +++ /dev/null @@ -1,68 +0,0 @@ -PACKAGE=github.com/davfive/gitspaces/v2 -GOFLAGS=-ldflags=-X=${PACKAGE}/cmd.Version=${VERSION} -VERSION:=$(shell git describe --long --tags --dirty) -VERSION_SHORT:=$(shell git describe --tags --abbrev=0) -BRANCH:=$(shell git branch --show-current) -MAJVER:=$(shell echo ${VERSION} | cut -d. -f1) -#=----------------------------------------------------------- -.PHONY: checkbranch checkdirty checkpending checkversion -.PHONY: checkbuild checkinstall checkpublish -.PHONY: build install newtag publish -#=----------------------------------------------------------- -build: checkbuild - @echo "[$@] Building ${VERSION} version" - go build ${GOFLAGS} -o build/gitspaces - -install: checkinstall build - @echo "[$@] Installing ${VERSION} version" - go install ${GOFLAGS} - -newtag: - @echo "[$@] Creating new tag for ${VERSION_SHORT} version" - echo "$(tag)" | grep -qE "v2\.\d+\.\d+" # check tag format - echo "{ \"version\": \"$(tag)\" }" > manifest.json - git commit -am "Release $(tag)" - git push - git tag -a $(tag) -m "Release $(tag)" - git push origin $(tag) - -publish: checkpublish - @echo "[$@] Publishing ${VERSION_SHORT} version" - GOPROXY=proxy.golang.org go list -m ${GOFLAGS} ${PACKAGE}@${VERSION_SHORT} - -#=----------------------------------------------------------- -checkbuild: checkversion -checkinstall: checkversion -checkpublish: checkversion checkbranch checkdirty checkpending -#=----------------------------------------------------------- -checkbranch: - @echo "[$@] Checking ${VERSION} version publish branch is ${MAJVER}" - @if [ "${BRANCH}" != "${MAJVER}" ]; then \ - echo "Cannot publish ${VERSION} from branch ${BRANCH}"; \ - echo "Please check ${VERSION} version release branch: ${MAJVER}"; \ - exit 1; \ - fi - -checkdirty: - @echo "[$@] Checking working directory state" - @if [[ "${VERSION}" = *-dirty ]]; then \ - echo "Cannot publish with modified (dirty) working directory: ${VERSION}"; \ - echo "Please commit or stash your changes"; \ - exit 1; \ - fi - -checkpending: - @echo "[$@] Checking that the tag is on the latest commit" - @if ! [[ "${VERSION}" = *-0-* ]]; then \ - echo "Cannot publish tag with later commits: ${VERSION}"; \ - echo "Please create/push a new tag"; \ - exit 1; \ - fi - -checkversion: - @echo "[$@] Checking current git version: ${VERSION_SHORT} (${VERSION})" - @if [ -z "${VERSION}" -o -z "${VERSION_SHORT}" ]; then \ - echo "Failed to get version from 'git describe --long --tags --dirty'"; \ - exit 1; \ - fi - \ No newline at end of file diff --git a/README.DEPLOYMENT.md b/README.DEPLOYMENT.md new file mode 100644 index 0000000..3b15628 --- /dev/null +++ b/README.DEPLOYMENT.md @@ -0,0 +1,440 @@ +# GitSpaces Deployment Guide + +## Overview + +Deploy to PyPI via manual upload or automated GitHub Actions. + +## Quick Reference + +**Upload built packages from `dist/`, not source code:** +- `dist/gitspaces-*.whl` (wheel) +- `dist/gitspaces-*.tar.gz` (source distribution) + +Build: `python -m build` + +## Prerequisites + +- Accounts on both PyPI and TestPyPI +- The package built locally (already done with `python -m build`) +- The `dist/` folder contains: + - `gitspaces-2.0.36-py3-none-any.whl` (wheel file) + - `gitspaces-2.0.36.tar.gz` (source tarball) + +## Step 1: Create Accounts + +### PyPI (Production) +1. Go to https://pypi.org/account/register/ +2. Create your account and verify your email + +### TestPyPI (Testing) +1. Go to https://test.pypi.org/account/register/ +2. Create your account and verify your email + +**Note:** These are separate accounts, so you need to register on both. + +## Step 2: Reserve the Package Name + +### Option A: Manual Upload (Recommended for First Time) + +This is the easiest way to claim the name. You'll upload the **built package files** (not the source code repository) to PyPI. + +**Important:** You're uploading the distribution packages from the `dist/` folder, NOT the git repository! + +#### Step-by-Step Process: + +1. **Build the package** (if not already done): + ```bash + cd /path/to/your/local/gitspaces/clone + python -m build + ``` + + This creates two files in the `dist/` directory: + - `gitspaces-2.0.36-py3-none-any.whl` (wheel file) + - `gitspaces-2.0.36.tar.gz` (source distribution) + +2. **Install twine** (if not already installed): + ```bash + pip install twine + ``` + +3. **Upload to TestPyPI first** (to test before going to production): + ```bash + python -m twine upload --repository testpypi dist/* + ``` + + When prompted: + - Username: Your TestPyPI username (or `__token__` if using API token) + - Password: Your TestPyPI password (or paste your API token starting with `pypi-`) + +4. **Test the TestPyPI installation**: + ```bash + # Create a test virtual environment + python -m venv test-env + source test-env/bin/activate # On Windows: test-env\Scripts\activate + + # Install from TestPyPI + pip install -i https://test.pypi.org/simple/ gitspaces + + # Test the command + gitspaces --version + + # Clean up + deactivate + rm -rf test-env + ``` + +5. **If TestPyPI works, upload to PyPI**: + ```bash + python -m twine upload dist/* + ``` + + When prompted: + - Username: Your PyPI username (or `__token__` if using API token) + - Password: Your PyPI password (or paste your API token starting with `pypi-`) + +**What gets uploaded:** +- The `.whl` file (wheel) - binary distribution +- The `.tar.gz` file (sdist) - source distribution +- NOT the git repository, NOT the source code directly + +**What happens:** +- PyPI/TestPyPI stores these distribution files +- Users can install with `pip install gitspaces` +- pip downloads and installs from these distribution files + +### Option B: Create API Tokens (Recommended for GitHub Actions) + +Instead of using passwords, create API tokens: + +#### For TestPyPI: +1. Go to https://test.pypi.org/manage/account/token/ +2. Click "Add API token" +3. Name: `github-actions-gitspaces` +4. Scope: Select "Entire account" (for first upload) or "Project: gitspaces" (after first upload) +5. Click "Add token" +6. **SAVE THE TOKEN** - you won't be able to see it again! + +#### For PyPI: +1. Go to https://pypi.org/manage/account/token/ +2. Click "Add API token" +3. Name: `github-actions-gitspaces` +4. Scope: Select "Entire account" (for first upload) or "Project: gitspaces" (after first upload) +5. Click "Add token" +6. **SAVE THE TOKEN** - you won't be able to see it again! + +## Step 3: Set Up GitHub Secrets + +After creating your tokens, add them to your GitHub repository: + +1. Go to your GitHub repository: https://github.com/davfive/gitspaces +2. Click "Settings" → "Secrets and variables" → "Actions" +3. Click "New repository secret" +4. Add two secrets: + - Name: `PYPI_API_TOKEN` + Value: Your PyPI API token (starts with `pypi-`) + - Name: `TEST_PYPI_API_TOKEN` + Value: Your TestPyPI API token (starts with `pypi-`) + +## Step 4: Configure GitHub Environments + +The workflow uses GitHub environments for approval gates. This must be configured in your **repository settings** (not user settings). + +### Accessing GitHub Environments + +1. Go to your repository: https://github.com/davfive/gitspaces +2. Click **"Settings"** (top right, repository settings, not user settings) +3. In the left sidebar, find the **"Environments"** section +4. Click **"New environment"** to create each environment + +### Environment 1: TestPyPI (No Approval Needed) + +**Configuration:** +- **Name**: `testpypi` (must match exactly as used in workflow) +- **Protection rules**: None needed (automatic deployment is fine for testing) +- **Environment secrets** (if using API tokens instead of Trusted Publishing): + - Click "Add secret" + - Name: `PYPI_API_TOKEN` + - Value: Your TestPyPI API token + +**What this does:** +- Allows automatic deployment to TestPyPI +- No manual approval required +- Used for testing before production + +### Environment 2: PyPI (With Approval Gate) + +**Configuration:** +- **Name**: `pypi` (must match exactly as used in workflow) +- **Protection rules**: + - ✅ Enable **"Required reviewers"** + - Add reviewers: + - Add yourself: `@davfive` + - Or add team members who can approve releases + - Optionally set **"Wait timer"**: 0 minutes (or add delay if desired) +- **Environment secrets** (if using API tokens instead of Trusted Publishing): + - Click "Add secret" + - Name: `PYPI_API_TOKEN` + - Value: Your PyPI API token + +**What this does:** +- Requires manual approval before deploying to production PyPI +- Prevents accidental releases +- Creates a notification for reviewers to approve/reject + +### Visual Guide to GitHub Environments Setup + +``` +GitHub Repository Settings +├── Settings (tab) + ├── Secrets and variables + │ └── Actions + │ ├── Repository secrets (used by all workflows) + │ │ ├── PYPI_API_TOKEN (optional if using Trusted Publishing) + │ │ └── TEST_PYPI_API_TOKEN (optional if using Trusted Publishing) + │ └── Environment secrets (used by specific environments) + │ + └── Environments + ├── testpypi + │ ├── Protection rules: None + │ └── Secrets: (none needed if using Trusted Publishing) + │ + └── pypi + ├── Protection rules: + │ └── Required reviewers: [@davfive] + └── Secrets: (none needed if using Trusted Publishing) +``` + +### Step-by-Step Environment Creation + +#### Creating the `testpypi` Environment: + +1. Go to: https://github.com/davfive/gitspaces/settings/environments +2. Click **"New environment"** +3. Enter name: `testpypi` +4. Click **"Configure environment"** +5. **Don't add any protection rules** (leave it open for automatic deployment) +6. Click **"Save protection rules"** +7. Done! + +#### Creating the `pypi` Environment: + +1. Go to: https://github.com/davfive/gitspaces/settings/environments +2. Click **"New environment"** +3. Enter name: `pypi` +4. Click **"Configure environment"** +5. **Add protection rules:** + - Check ✅ **"Required reviewers"** + - In the search box, type your username or team name + - Select yourself: `davfive` + - Click outside the box to confirm +6. Click **"Save protection rules"** +7. Done! + +### How Approval Works + +When you push a tag: + +1. ✅ **Tests run** automatically +2. ✅ **Build completes** automatically +3. ✅ **TestPyPI deployment** happens automatically +4. ⏸️ **GitHub creates an approval request** for PyPI environment +5. 📧 **You receive a notification** (email/GitHub) +6. 👀 **You review the deployment request** in GitHub Actions +7. ✅ **You click "Review deployments"** and approve/reject +8. ✅ **If approved, PyPI deployment** proceeds +9. ✅ **GitHub Release** is created with assets + +### Verifying Environment Setup + +After creating both environments, verify: + +```bash +# Check your environments are configured +# Go to: https://github.com/davfive/gitspaces/settings/environments + +# You should see: +# - testpypi (No protection rules) +# - pypi (Required reviewers: davfive) +``` + +### Common Issues + +**"Environment not found" error in workflow:** +- Make sure environment names are exactly `testpypi` and `pypi` (lowercase, no spaces) +- Environments must be created before running the workflow + +**"Approval not triggering":** +- Make sure you added yourself as a required reviewer in the `pypi` environment +- Check that you have the correct permissions (admin or maintainer) + +**"Can't create environments":** +- Environments are only available on public repos, or private repos with GitHub Pro/Enterprise +- Make sure you're in the repository settings, not user settings + +## Step 5: Test the Workflow + +### Option 1: Use GitHub's Trusted Publishing (Recommended - No tokens needed!) + +GitHub Actions now supports "Trusted Publishing" which is more secure than API tokens: + +#### For TestPyPI: +1. Go to https://test.pypi.org/manage/account/publishing/ +2. Add a new publisher: + - PyPI Project Name: `gitspaces` + - Owner: `davfive` + - Repository: `gitspaces` + - Workflow name: `python-publish.yml` + - Environment name: `testpypi` + +#### For PyPI: +1. Go to https://pypi.org/manage/account/publishing/ +2. Add a new publisher: + - PyPI Project Name: `gitspaces` + - Owner: `davfive` + - Repository: `gitspaces` + - Workflow name: `python-publish.yml` + - Environment name: `pypi` + +**Note:** For Trusted Publishing to work on the first upload, you might need to do one manual upload first to create the project. + +### Option 2: Manual First Upload + +If you prefer, do a manual upload first (see Step 2 Option A for detailed instructions): + +```bash +# 1. Build the package distribution files +python -m build + +# 2. Upload to TestPyPI first (test) +python -m twine upload --repository testpypi dist/* + +# 3. Test install from TestPyPI +pip install -i https://test.pypi.org/simple/ gitspaces + +# 4. If successful, upload to PyPI +python -m twine upload dist/* +``` + +**Remember:** You're uploading the built package files from `dist/`, not the git repository! + +## Step 6: Trigger Automated Publishing + +Once everything is set up: + +1. Create a git tag with version: + ```bash + git tag -a v2.0.37 -m "Release v2.0.37" + git push origin v2.0.37 + ``` + +2. The GitHub Actions workflow will: + - ✅ Run all tests + - ✅ Build the package + - ✅ Extract version from tag + - ✅ Publish to TestPyPI automatically + - ⏸️ Wait for approval to publish to PyPI + - ✅ Create GitHub Release with assets + - ✅ Publish to PyPI after approval + +## Verification + +After publishing, verify your package: + +### TestPyPI: +- View: https://test.pypi.org/project/gitspaces/ +- Install test: `pip install -i https://test.pypi.org/simple/ gitspaces` + +### PyPI: +- View: https://pypi.org/project/gitspaces/ +- Install: `pip install gitspaces` + +## Troubleshooting + +### "Package name already taken" +If someone else has registered `gitspaces`, you'll need to choose a different name like `gitspaces-cli` or contact PyPI support. + +### "Invalid credentials" +Make sure you're using the correct API token and that it hasn't expired. + +### "Trusted Publishing not configured" +Do a manual upload first to create the project, then configure Trusted Publishing. + +### "Version already exists" +You can't re-upload the same version. Increment the version number in: +- `pyproject.toml` +- `src/gitspaces/__init__.py` + +## Recommended Approach + +**First Time Setup:** +1. ✅ Create accounts on both platforms +2. ✅ Do ONE manual upload to TestPyPI to claim the name +3. ✅ Set up Trusted Publishing for both platforms +4. ✅ Configure GitHub environments +5. ✅ Push a new tag to test automated publishing + +**For Future Releases:** +1. Update version in code +2. Create and push a tag +3. Approve the PyPI deployment when prompted +4. Done! 🎉 + +## Visual Workflow + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ YOUR LOCAL REPOSITORY │ +│ │ +│ src/gitspaces/ ← Python source code │ +│ tests/ ← Test files │ +│ pyproject.toml ← Package metadata │ +│ README.md ← Documentation │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ $ python -m build │ │ +│ │ (Creates distribution packages) │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ dist/ │ │ +│ │ ├── gitspaces-2.0.36-py3-none-any.whl │ ◄─ UPLOAD │ +│ │ └── gitspaces-2.0.36.tar.gz │ THESE! │ +│ └──────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ + │ $ twine upload --repository testpypi dist/* + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ TEST.PYPI.ORG │ +│ (Testing environment - safe to experiment) │ +│ │ +│ Package: gitspaces │ +│ Version: 2.0.36 │ +│ Files: .whl + .tar.gz stored on TestPyPI servers │ +│ │ +│ Users can install: pip install -i https://test.pypi.org/... gitspaces │ +└─────────────────────────────────────────────────────────────────────┘ + │ + │ (After testing works) + │ $ twine upload dist/* + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ PYPI.ORG │ +│ (Production - the real deal!) │ +│ │ +│ Package: gitspaces │ +│ Version: 2.0.36 │ +│ Files: .whl + .tar.gz stored on PyPI servers │ +│ │ +│ Users can install: pip install gitspaces │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Current Package Status + +The package has been built successfully: +- Source distribution: `dist/gitspaces-2.0.36.tar.gz` +- Wheel: `dist/gitspaces-2.0.36-py3-none-any.whl` + +**These are the files you upload to PyPI/TestPyPI!** diff --git a/README.md b/README.md index b5625b0..0390f21 100644 --- a/README.md +++ b/README.md @@ -1,124 +1,98 @@ - - - Light and Dark logos - +# GitSpaces -# gitspaces - A git development workspace manager +[![PyPI version](https://badge.fury.io/py/gitspaces.svg)](https://badge.fury.io/py/gitspaces) +[![Tests](https://github.com/davfive/gitspaces/actions/workflows/python-tests.yml/badge.svg)](https://github.com/davfive/gitspaces/actions/workflows/python-tests.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Python 3.8+](https://img.shields.io/pypi/pyversions/gitspaces.svg)](https://pypi.org/project/gitspaces/) -> Coming in Spring 2024 +Manage multiple independent clones of a git repository. Similar to ClearCase Views but for Git. -## What is GitSpaces +Work on multiple features, branches, or experiments simultaneously without branch switching overhead. Each "space" is an independent clone of your repository. -If you're familiar with ClearCase Views, think of GitSpaces as their counterpart for Git projects. If not, you're in for a treat. - -GitSpaces manages multiple independent clones of a project for you so you can switch between them as you work on new features or bugs. - -Instead of using `git clone url/to/repo-abc` use `gitspaces create url/to/repo-abc.git` and you will get - -``` -~/.../projects - └── repo-abc - ├── __GITSPACES_PROJECT__ - ├── space-1/ - │   └── ... repo cloned here - ├── space-2-... - ├── space-N/ - └── .zzz # extra clone copies for different tasks -    ├── zzz-0/ -    │   └── ... and cloned here - ├── zzz-1/ - │   └── ... and here here -    ├── zzz-... - └── zzz-N/ -``` - -Where you will be able to work independently on features, bugs, etc - -### Commands - -The `gitspaces` command - -#### USAGE -`gitspaces COMMAND` - -or simplify your life with `alias gs=gitspaces`. - -#### WHERE -COMMAND | Description ----------|------------------------ -`setup` | Helps user setup config.yaml and 'cd' shell wrappers. -`create` | Creates a new GitSpace project from a git repo url -`switch` | Switch spaces. Default, same as `gitspaces` w/o a command. -`rename` | Rename a current gitspace -`sleep` | Archive a gitspace and wakes up another one -`code` | Launches Visual Studio Code Workspace for the space +## Features +- Multiple independent clones per repository +- Fast switching between workspaces +- Inactive spaces can be put to "sleep" +- Add more clones on demand +- Direct editor integration ## Installation -gitspaces is implemented in Go, so - -1. [Install Go](https://go.dev/doc/install) - -2. Install GitSpaces - [![Go Reference](https://pkg.go.dev/badge/github.com/davfive/gitspaces/v2.svg)](https://pkg.go.dev/github.com/davfive/gitspaces/v2) - ``` - $ go install github.com/davfive/gitspaces/v2@latest - -> installs to ~/go/bin/gitspaces - - $ gitspaces setup - -> run once to install .gitspaces config directory and start user setup - ``` - -## Initial Setup and Use - -Run `gitspaces setup` to be walked through first-time configuration. - -If you run any `gitspaces ` and your environment isn't setup properly, `gitspaces setup` setup will automatically run. - -GitSpaces configuration directory is created on first run. - -### Step 1 - Configure where you keep your git projects - -GitSpaces config.yaml file contains a ProjectPaths field. -This field defines a list of paths to your project directories. - -Instructions for setting up ProjectPaths field is in the config file. - -**Windows:** - - %USERPROFILE%/.gitspaces/config.yaml +```bash +pip install gitspaces +``` -**Mac/Linux** +Or from source: +```bash +git clone https://github.com/davfive/gitspaces.git +cd gitspaces +pip install -e . +``` - `MacOS: ~/.gitspaces/config.yaml` +## Quick Start -### Step 2 - Configure the gitspaces shell wrapper +Configure project paths and editor: +```bash +gitspaces setup +``` -Some gitspaces commands change the current working directory of the user. To accomplish this, gitspaces is run through a shell (bash / powershell) wrapper. Once you've setup your shell wrapper, restart your terminal and start using GitSpaces. +Clone a repository with multiple workspaces: +```bash +gitspaces clone https://github.com/user/repo.git -n 3 +``` -**Bash/Zsh (Mac/Linux/Windows)** +Creates: +``` +projects/repo/ +├── __GITSPACES_PROJECT__ +├── main/ # active workspace +└── .zzz/ # sleeping workspaces + ├── zzz-0/ + ├── zzz-1/ + └── zzz-2/ +``` -Copy the following lines into your .bashrc or .zshrc file. +Basic operations: +```bash +gitspaces switch # switch workspace +gitspaces sleep # sleep active, wake another +gitspaces rename old new # rename workspace +gitspaces extend -n 2 # add 2 more clones +gitspaces code # open in editor +``` - . ~/.gitspaces/gitspaces.function.sh - alias gs=gitspaces # optional +## Commands + +```bash +gitspaces setup # configure project paths, editor +gitspaces clone [-n N] [-d DIR] # clone repo with N workspaces +gitspaces switch [SPACE] # switch workspace (interactive if no arg) +gitspaces sleep [SPACE] # sleep workspace, optionally wake another +gitspaces rename OLD NEW # rename workspace +gitspaces extend -n N [SOURCE] # add N more clones +gitspaces code [SPACE] # open workspace in editor +gitspaces config [KEY] [VALUE] # view/set configuration +``` -**PowerShell (Mac/Windows)** +## Configuration -Copy the following lines into your PowerShell $PROFILE +Default location: `~/.gitspaces/config.yaml` -For more information, see [PowerShell > About Profiles](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_profiles) +```yaml +project_paths: + - /home/user/projects +default_editor: code +``` -Copy the following to your $PROFILE +## Contributing - . $HOME/.gitspaces/gitspaces.scriptblock.ps1 - Set-Alias -Name gs -Value gitspaces # optional +See [CONTRIBUTING.md](CONTRIBUTING.md). -### Step 3 - Create your first GitSpaces project +## Deployment -Open a terminal and run +Maintainers: see [README.DEPLOYMENT.md](README.DEPLOYMENT.md) for PyPI deployment. - gitspaces create https://github.com/davfive/gitspaces -n 3 +## License -This will create a new GitSpaces project with three clones of this gitspaces project. By default all spaces are "asleep". You will be asked to wake one up and give it a name. \ No newline at end of file +MIT - see [LICENSE](LICENSE). diff --git a/cmd/code.go b/cmd/code.go deleted file mode 100644 index 6498eae..0000000 --- a/cmd/code.go +++ /dev/null @@ -1,30 +0,0 @@ -package cmd - -import ( - "github.com/davfive/gitspaces/v2/internal/console" - "github.com/davfive/gitspaces/v2/internal/gitspaces" - - "github.com/spf13/cobra" -) - -// codeCmd represents the code command -var codeCmd = &cobra.Command{ - Use: "code", - Short: "Open Space as a workspace in Visual Studio Code", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - space, err := gitspaces.GetSpace() - if err != nil { - return err - } - - if err = space.OpenVSCode(); err != nil { - return console.Errorln("Failed to open Visual Studio Code: %s", err) - } - return nil - }, -} - -func init() { - rootCmd.AddCommand(codeCmd) -} diff --git a/cmd/config.go b/cmd/config.go deleted file mode 100644 index 0f1f36f..0000000 --- a/cmd/config.go +++ /dev/null @@ -1,25 +0,0 @@ -package cmd - -import ( - "github.com/davfive/gitspaces/v2/internal/config" - "github.com/davfive/gitspaces/v2/internal/console" - "github.com/davfive/gitspaces/v2/internal/utils" - "github.com/spf13/cobra" -) - -// codeCmd represents the code command -var configCmd = &cobra.Command{ - Use: "config", - Short: "Open GitSpaces config file (yaml) in default editor", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - if err := utils.OpenFileInDefaultApp(config.User.GetConfigFile()); err != nil { - return console.Errorln("Failed to open GitSpaces config file:: %s", err) - } - return nil - }, -} - -func init() { - rootCmd.AddCommand(configCmd) -} diff --git a/cmd/create.go b/cmd/create.go deleted file mode 100644 index 60ddcef..0000000 --- a/cmd/create.go +++ /dev/null @@ -1,49 +0,0 @@ -package cmd - -import ( - "github.com/davfive/gitspaces/v2/internal/config" - "github.com/davfive/gitspaces/v2/internal/console" - "github.com/davfive/gitspaces/v2/internal/gitspaces" - "github.com/davfive/gitspaces/v2/internal/utils" - "github.com/spf13/cobra" -) - -// createCmd represents the create command -var createCmd = &cobra.Command{ - Use: UseWhere( - "create [flags] [... ] []", - []WhereArg{ - {"repo", "repo as in `git clone `"}, - {"dir", "Directory to use for GitSpaces project. Default is first repo name."}, - }, - ), - Short: "Creates a GitSpace from the provided repo url", - Args: cobra.MinimumNArgs(1), - Aliases: []string{"c"}, - RunE: func(cmd *cobra.Command, args []string) (err error) { - url := args[0] - dir := utils.GetIndex(args, 1, "") - numClones, _ := cmd.Flags().GetInt("num_spaces") - - var project *gitspaces.ProjectStruct - var space *gitspaces.SpaceStruct - - if project, err = gitspaces.CreateProject(dir, url, numClones); err != nil { - return err - } - - if space, err = project.WakeupSpace(); err != nil { - return err - } - - console.Println("\nCreated GitSpace project at '%s' with %d spaces", project.Path, numClones) - - config.User.WriteChdirPath(space.Path) - return nil - }, -} - -func init() { - rootCmd.AddCommand(createCmd) - createCmd.Flags().IntP("num_spaces", "n", 3, "Number of spaces to create in project") -} diff --git a/cmd/rename.go b/cmd/rename.go deleted file mode 100644 index 3491be0..0000000 --- a/cmd/rename.go +++ /dev/null @@ -1,43 +0,0 @@ -/* -Copyright © 2024 NAME HERE -*/ -package cmd - -import ( - "github.com/davfive/gitspaces/v2/internal/config" - "github.com/davfive/gitspaces/v2/internal/console" - "github.com/davfive/gitspaces/v2/internal/gitspaces" - "github.com/spf13/cobra" -) - -// renameCmd represents the rename command -var renameCmd = &cobra.Command{ - Use: UseWhere( - "rename [flags] []", - []WhereArg{ - {"new-name", "name of the new space"}, - }, - ), - Short: "Rename the current space", - Args: cobra.RangeArgs(0, 1), - Aliases: []string{"move", "mv"}, - RunE: func(cmd *cobra.Command, args []string) (err error) { - space, err := gitspaces.GetSpace() - if err != nil { - return err - } - - if err = space.Rename(args...); err != nil { - return console.Errorln("Failed to rename space: %s", err) - } - - console.Println("\nGitSpace renamed. Reopen any open IDEs from the new path") - console.Println("or files saved in the IDE will save to the old GitSpace path.") - config.User.WriteChdirPath(space.Path) - return nil - }, -} - -func init() { - rootCmd.AddCommand(renameCmd) -} diff --git a/cmd/root.go b/cmd/root.go deleted file mode 100644 index c8d1938..0000000 --- a/cmd/root.go +++ /dev/null @@ -1,125 +0,0 @@ -package cmd - -import ( - "os" - "slices" - - "github.com/davfive/gitspaces/v2/internal/config" - "github.com/davfive/gitspaces/v2/internal/console" - "github.com/davfive/gitspaces/v2/internal/utils" - "github.com/spf13/cobra" -) - -// Version string value (priority order) -// 1. -ldflags "-X cmd.X=..." Build Flags -// 2. //go:embed manifest.json (needed for go list publishing) -// 3. default value "" -var Version string = "" - -var rootCmd = &cobra.Command{ - Use: "gitspaces", - Version: Version, - Short: "Concurrent development manager for git projects", - SilenceUsage: true, // handle these below in Execute() call - SilenceErrors: true, - PersistentPreRunE: func(cmd *cobra.Command, args []string) (err error) { - if debug, _ := cmd.Flags().GetBool("debug"); debug { - console.Println("%v", os.Args) - } - - if plain, _ := cmd.Flags().GetBool("plain"); plain { - console.SetUsePrettyPrompts(false) - } - - if pretty, _ := cmd.Flags().GetBool("pretty"); pretty { - console.SetUsePrettyPrompts(true) - } - - var wrapId int - if wrapId, _ = cmd.Flags().GetInt("wrapid"); err != nil { - wrapId = -1 - } - - if err := config.Init(wrapId); err != nil { - return err - } - - if config.RunUserEnvironmentChecks() == false { - os.Exit(1) // Setup ran and user asked to update environment. - } - return nil - }, -} - -func Execute() { - rootCmd.Root().CompletionOptions.DisableDefaultCmd = true - rootCmd.PersistentFlags().Int("wrapid", -1, "wrapper id from calling shell function for communication") - rootCmd.PersistentFlags().MarkHidden("wrapid") - rootCmd.PersistentFlags().BoolP("plain", "p", false, "Only use plain prompts") - rootCmd.PersistentFlags().MarkHidden("plain") - rootCmd.PersistentFlags().BoolP("pretty", "P", false, "Only use pretty prompts") - rootCmd.PersistentFlags().MarkHidden("pretty") - rootCmd.MarkFlagsMutuallyExclusive("plain", "pretty") - rootCmd.PersistentFlags().BoolP("debug", "d", false, "Add additional debugging information") - rootCmd.PersistentFlags().MarkHidden("debug") - - setDefaultCommandIfNonePresent() - if cmd, err := rootCmd.ExecuteC(); err != nil { - skipErrors := []string{"user aborted"} - if !slices.Contains(skipErrors, err.Error()) { - utils.PanicIfFalse(rootCmd.SilenceErrors, "SilenceErrors required for alternate error messaging") - utils.PanicIfFalse(rootCmd.SilenceUsage, "SilenceUsage required for alternate error messaging") - cmd.PrintErrln(cmd.ErrPrefix(), err.Error()) - cmd.PrintErrf("Run '%v -h' for usage.\n", cmd.CommandPath()) - } - os.Exit(1) - } -} - -func SetVersion(version string) { - if Version == "" { - rootCmd.Version = version - } -} - -func flagsContain(flags []string, contains ...string) bool { - for _, flag := range contains { - if slices.Contains(flags, flag) { - return true - } - } - return false -} - -func prefetchCommandAndFlags() (*cobra.Command, []string, error) { - // Taken from cobra source code in command.go::ExecuteC() - var cmd *cobra.Command - var err error - var flags []string - if rootCmd.TraverseChildren { - cmd, flags, err = rootCmd.Traverse(os.Args[1:]) - } else { - cmd, flags, err = rootCmd.Find(os.Args[1:]) - } - return cmd, flags, err -} - -func setArgs(args []string) { - // PowerShell annoyingly converts extra spaces to os.Args empty values, remove them - var newArgs []string - for _, arg := range args { - if arg != "" { - newArgs = append(newArgs, arg) - } - } - rootCmd.SetArgs(newArgs) -} - -func setDefaultCommandIfNonePresent() { - cmd, flags, err := prefetchCommandAndFlags() - if err != nil || cmd.Use == rootCmd.Use { - if !flagsContain(flags, "-v", "-h", "--version", "--help") { - setArgs(append(os.Args[1:], "switch")) - } - } -} diff --git a/cmd/setup.go b/cmd/setup.go deleted file mode 100644 index 121e5f9..0000000 --- a/cmd/setup.go +++ /dev/null @@ -1,22 +0,0 @@ -/* -Copyright © 2024 NAME HERE -*/ -package cmd - -import ( - "github.com/davfive/gitspaces/v2/internal/config" - "github.com/spf13/cobra" -) - -// setupCmd represents the setup command -var setupCmd = &cobra.Command{ - Use: "setup", - Short: "(Re)run gitspaces setup wizard", - Run: func(cmd *cobra.Command, args []string) { - config.RunUserEnvironmentSetup() - }, -} - -func init() { - rootCmd.AddCommand(setupCmd) -} diff --git a/cmd/sleep.go b/cmd/sleep.go deleted file mode 100644 index 743250d..0000000 --- a/cmd/sleep.go +++ /dev/null @@ -1,40 +0,0 @@ -/* -Copyright © 2024 NAME HERE -*/ -package cmd - -import ( - "github.com/davfive/gitspaces/v2/internal/config" - "github.com/davfive/gitspaces/v2/internal/console" - "github.com/davfive/gitspaces/v2/internal/gitspaces" - "github.com/spf13/cobra" -) - -// sleepCmd represents the sleep command -var sleepCmd = &cobra.Command{ - Use: "sleep", - Short: "Put this Space to sleep. Invites user to Wakeup a new Space.", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) (err error) { - space, err := gitspaces.GetSpace() - if err != nil { - return console.Errorln("You're not in a GitSpace") - } - - if err = space.Sleep(); err != nil { - return console.Errorln("Failed to sleep space") - } - - space, err = gitspaces.SwitchSpace() - if err != nil { - return console.Errorln("Failed to choose new space to use") - } - - config.User.WriteChdirPath(space.Path) - return nil - }, -} - -func init() { - rootCmd.AddCommand(sleepCmd) -} diff --git a/cmd/switch.go b/cmd/switch.go deleted file mode 100644 index fdac93b..0000000 --- a/cmd/switch.go +++ /dev/null @@ -1,30 +0,0 @@ -/* -Copyright © 2024 NAME HERE -*/ -package cmd - -import ( - "github.com/davfive/gitspaces/v2/internal/config" - "github.com/davfive/gitspaces/v2/internal/gitspaces" - "github.com/spf13/cobra" -) - -// switchCmd represents the switch command -var switchCmd = &cobra.Command{ - Use: "switch", - Short: "Switch to project/space (user choice)", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) (err error) { - space, err := gitspaces.SwitchSpace() - if err != nil { - return err - } - - config.User.WriteChdirPath(space.Path) - return nil - }, -} - -func init() { - rootCmd.AddCommand(switchCmd) -} diff --git a/cmd/usewhere.go b/cmd/usewhere.go deleted file mode 100644 index f1cb47c..0000000 --- a/cmd/usewhere.go +++ /dev/null @@ -1,45 +0,0 @@ -package cmd - -import ( - "fmt" - "slices" -) - -func UseWhere(use string, whereArgs []WhereArg) string { - // cmd.Command.Use expects a string (why I can't use UseWhereStruct as Stringer) - return useWhereStruct{use, whereArgs}.String() -} - -type WhereArg struct { - Arg string - Short string -} - -func (w WhereArg) Len() int { - return len(w.Arg) -} - -type useWhereStruct struct { - Use string - WhereArgs []WhereArg -} - -func (uw useWhereStruct) String() string { - use := uw.Use - if len(uw.WhereArgs) > 0 { - // Leave order of whereArgs as specified by the caller - use = use + "\n\nWhere:" - maxArgWidth := uw.whereArgMaxLen() - for _, where := range uw.WhereArgs { - use = use + fmt.Sprintf("\n %-*s\t%s", maxArgWidth, where.Arg, where.Short) - } - } - return use -} - -func (uw useWhereStruct) whereArgMaxLen() int { - widestWhereArg := slices.MaxFunc(uw.WhereArgs[:], func(a, b WhereArg) int { - return a.Len() - b.Len() - }) - return widestWhereArg.Len() -} diff --git a/go.mod b/go.mod deleted file mode 100644 index b8fda75..0000000 --- a/go.mod +++ /dev/null @@ -1,56 +0,0 @@ -module github.com/davfive/gitspaces/v2 - -go 1.22.0 - -require ( - github.com/charmbracelet/huh v0.3.0 - github.com/mitchellh/go-ps v1.0.0 - github.com/otiai10/copy v1.14.0 - github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 - github.com/spf13/cobra v1.8.0 - github.com/spf13/viper v1.18.2 - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 -) - -require ( - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/catppuccin/go v0.2.0 // indirect - github.com/charmbracelet/bubbles v0.17.2-0.20240108170749-ec883029c8e6 // indirect - github.com/charmbracelet/bubbletea v0.25.0 // indirect - github.com/charmbracelet/lipgloss v0.9.1 // indirect - github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/google/go-cmp v0.6.0 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/magiconair/properties v1.8.7 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.15.2 // indirect - github.com/pelletier/go-toml/v2 v2.1.0 // indirect - github.com/rivo/uniseg v0.4.4 // indirect - github.com/rogpeppe/go-internal v1.11.0 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/subosito/gotenv v1.6.0 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - golang.org/x/sync v0.5.0 // indirect - golang.org/x/sys v0.15.0 // indirect - golang.org/x/term v0.13.0 // indirect - golang.org/x/text v0.14.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 1c029d3..0000000 --- a/go.sum +++ /dev/null @@ -1,129 +0,0 @@ -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= -github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/bubbles v0.17.2-0.20240108170749-ec883029c8e6 h1:6nVCV8pqGaeyxetur3gpX3AAaiyKgzjIoCPV3NXKZBE= -github.com/charmbracelet/bubbles v0.17.2-0.20240108170749-ec883029c8e6/go.mod h1:9HxZWlkCqz2PRwsCbYl7a3KXvGzFaDHpYbSYMJ+nE3o= -github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= -github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= -github.com/charmbracelet/huh v0.3.0 h1:CxPplWkgW2yUTDDG0Z4S5HH8SJOosWHd4LxCvi0XsKE= -github.com/charmbracelet/huh v0.3.0/go.mod h1:fujUdKX8tC45CCSaRQdw789O6uaCRwx8l2NDyKfC4jA= -github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= -github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= -github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= -github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= -github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= -github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= -github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= -github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= -github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks= -github.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= -github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= -github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= -github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= -github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= -github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= -github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= -golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= -golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= -golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 42dc338..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,21 +0,0 @@ -package config - -var Debug bool = false - -var User *userStruct - -const ( - GsDotDir = ".gitspaces" - GsProjectFile = "__GITSPACES_PROJECT__" - GsSleeperDir = ".zzz" - GsVsCodeWsDir = ".code-workspace" -) - -func Init(wrapId int) (err error) { - User, err = initUser(wrapId) - return err -} - -func ProjectPaths() []string { - return User.projectPaths -} diff --git a/internal/config/setup.go b/internal/config/setup.go deleted file mode 100644 index a2ae672..0000000 --- a/internal/config/setup.go +++ /dev/null @@ -1,122 +0,0 @@ -package config - -import ( - "github.com/davfive/gitspaces/v2/internal/console" - "github.com/davfive/gitspaces/v2/internal/utils" -) - -func RunUserEnvironmentChecks() bool { - checksPassed := runUserEnvironmentChecks() - if !checksPassed { - runUserEnvironmentSetup(false) - } - return checksPassed -} - -func RunUserEnvironmentSetup() { - runUserEnvironmentSetup(true) -} - -func runUserEnvironmentChecks() bool { - return runConfigurationCheck() && runShellWrapperCheck() -} - -func runConfigurationCheck() bool { - return len(User.projectPaths) > 0 -} - -func runShellWrapperCheck() bool { - return User.HasWrapId() -} - -func runUserEnvironmentSetup(force bool) { - console.Println("This will walk you through setting up GitSpaces.") - if force { - console.Println("It covers setting up some configuration and a shell wrapper.") - } - console.Println("See https://github.com/davfive/gitspaces README for full setup and use instructions.") - console.Println("") - - runConfigurationSetup(force) - shellUpdated := runShellWrapperSetup(force) - - console.Println("\nYou are ready to use GitSpaces (assuming you followed the instructions).") - if shellUpdated { - console.Println("Open a new shell and run 'gitspaces' (the shell wrapper) to start using GitSpaces.") - } else { - console.Println("Run 'gitspaces' (the shell wrapper) to start using GitSpaces.") - } -} - -// runProjectPathsCheck() prompts the user to set project paths in the config file -// if they are not already set. Returns true if user asked to update paths. -func runConfigurationSetup(force bool) bool { - if !force && runConfigurationCheck() == true { - return false - } - - console.Println("= Project Paths Setup") - console.Println("GitSpaces config.yaml file contains a ProjectPaths field.") - console.Println("This field defines a list of paths to your project directories.") - console.Println("") - console.Println("The config.yaml file is located at: %s", User.GetConfigFile()) - console.Println("Instructions for setting up ProjectPaths field is in the config file.") - console.Println("") - - if console.NewConfirm().Prompt("Edit config file now?").Run() == true { - if err := utils.OpenFileInDefaultApp(User.GetConfigFile()); err != nil { - console.Errorln("Editing config file failed: %s", err) - } else { - console.NewInput().Prompt("Press when done editing the file ...").Run() - } - } - return true -} - -// runProjectPathsCheck() prompts the user to set project paths in the config file -// if they are not already set. Returns true if user asked to update paths. -func runShellWrapperSetup(force bool) bool { - if !force && runShellWrapperCheck() == true { - return false - } - - console.Println("= Shell Wrapper Setup") - console.Println("GitSpaces must be called using a lightweight shell wrapper function.") - console.Println("The wrapper handles when a 'gitspaces ' needs to 'cd' to a new directory.") - console.Println("") - - shellFiles := GetShellFiles() - shellRcFile := User.getShellRcFile() - shellName := User.GetTerminalType() - askToEdit := false - if User.pterm == "pwsh" { - console.Println("Your PowerShell $PROFILE file: %s", shellRcFile) - console.Println("\nPlease copy the following lines into the $PROFILE file:") - console.Println(". %s", shellFiles["ps1ScriptBlock"].path) - console.Println("Set-Alias -Name gs -Value gitspaces # optional") - askToEdit = true - } else { - console.Println("Your current shell is: %s", utils.Ternary(shellName == "", "unknown", shellName)) - if shellName == "bash" || shellName == "zsh" { - console.Println("Your shell profile/rc file: %s", shellRcFile) - askToEdit = true - } - console.Println("\nPlease copy the following lines into your shell/rc file:") - console.Println(". %s", shellFiles["shellFunction"].path) - console.Println("alias gs=gitspaces") - } - - if askToEdit { - console.Println("") - if console.NewConfirm().Prompt("Update %s now?", shellRcFile).Run() == true { - utils.CreateEmptyFileIfNotExists(shellRcFile) - if err := utils.OpenFileInDefaultApp(shellRcFile); err != nil { - console.Errorln("%s", err) - } else { - console.NewInput().Prompt("Press when done editing the file ...").Run() - } - } - } - - return true -} diff --git a/internal/config/shellfiles.go b/internal/config/shellfiles.go deleted file mode 100644 index ae244dd..0000000 --- a/internal/config/shellfiles.go +++ /dev/null @@ -1,99 +0,0 @@ -package config - -import ( - _ "embed" - "text/template" - - "github.com/davfive/gitspaces/v2/internal/console" - "github.com/davfive/gitspaces/v2/internal/utils" -) - -//go:embed templates/gitspaces.function.tmpl.sh -var shellFunctionTmpl []byte - -//go:embed templates/gitspaces.cmdlet.tmpl.ps1 -var ps1CmdletTmpl []byte - -//go:embed templates/gitspaces.scriptblock.tmpl.ps1 -var ps1ScriptBlockTmpl []byte - -type shellFileStruct struct { - name string - dir string - path string - tmpl string - vars map[string]interface{} - funcs template.FuncMap -} - -func GetShellFiles() map[string]*shellFileStruct { - shellFiles := map[string]*shellFileStruct{ - "shellFunction": NewShellFile("shellFunction"). - File("gitspaces.function.sh"). - Template(string(shellFunctionTmpl)), - "ps1Cmdlet": NewShellFile("ps1Cmdlet"). - File("gitspaces.cmdlet.ps1"). - Template(string(ps1CmdletTmpl)), - "ps1ScriptBlock": NewShellFile("ps1ScriptBlock"). - File("gitspaces.scriptblock.ps1"). - Template(string(ps1ScriptBlockTmpl)), - } - return shellFiles -} - -func NewShellFile(name string) *shellFileStruct { - return &shellFileStruct{ - name: name, - dir: GetUserDotDir(), - vars: map[string]interface{}{}, - funcs: template.FuncMap{ - "cygwinizePath": utils.CygwinizePath, - }, - } -} - -func (shellFile *shellFileStruct) File(file string) *shellFileStruct { - shellFile.path = utils.Join(shellFile.dir, file) - return shellFile -} - -func (shellFile *shellFileStruct) Template(tmpl string) *shellFileStruct { - shellFile.tmpl = tmpl - return shellFile -} - -func (shellFile *shellFileStruct) Vars(vars map[string]interface{}) *shellFileStruct { - for k, v := range vars { - shellFile.vars[k] = v - } - return shellFile -} - -func (shellFile *shellFileStruct) Save() (err error) { - tmpl, err := template. - New(shellFile.name). - Funcs(shellFile.funcs). - Parse(shellFile.tmpl) - if err != nil { - return err - } - - return utils.WriteTemplateToFile(tmpl, shellFile.path, shellFile.vars) -} - -func (user *userStruct) updateShellFiles() bool { - // TODO: Update only if first-run with new version of gitspaces (via config file) - requiredUpdate := true - shellFiles := GetShellFiles() - tmplVars := user.getShellTmplVars(shellFiles) - - for _, shellFile := range shellFiles { - shellFile.Vars(tmplVars) - if err := shellFile.Save(); err != nil { - console.Errorln("failed to save shell file: %s", shellFile.path) - console.Errorln(err.Error()) - continue // not fatal, user just won't have shell file to use - } - } - return requiredUpdate -} diff --git a/internal/config/templates/config.yaml.tmpl b/internal/config/templates/config.yaml.tmpl deleted file mode 100644 index 91bc362..0000000 --- a/internal/config/templates/config.yaml.tmpl +++ /dev/null @@ -1,8 +0,0 @@ -# A list of root directorys where projects are located. -# The ProjectPaths directories would contain your project folders -# e.g., -# ProjectPaths: -# - {{ .homeDir }}/code/projects -# - {{ .homeDir }}/code/play -ProjectPaths: - - diff --git a/internal/config/templates/gitspaces.cmdlet.tmpl.ps1 b/internal/config/templates/gitspaces.cmdlet.tmpl.ps1 deleted file mode 100644 index 22d0441..0000000 --- a/internal/config/templates/gitspaces.cmdlet.tmpl.ps1 +++ /dev/null @@ -1,14 +0,0 @@ -& "{{ .exePath }}" --wrapid $PID $args -$gsExitCode=$LASTEXITCODE - -if ($gsExitCode -eq 0) { - $chdirFile="{{ .userDotDir }}/chdir." + $PID - if ($chdirFile -and (Test-Path $chdirFile)) { - $chdir=Get-Content -Path $chdirFile - if ($chdir) { - Set-Location $chdir - } - Remove-Item $chdirFile - } -} -Exit $gsExitCode diff --git a/internal/config/templates/gitspaces.function.tmpl.sh b/internal/config/templates/gitspaces.function.tmpl.sh deleted file mode 100644 index 8a791f8..0000000 --- a/internal/config/templates/gitspaces.function.tmpl.sh +++ /dev/null @@ -1,14 +0,0 @@ -function gitspaces() { - local exePath="{{ .exePath }}" - local dotDir="{{ .userDotDir }}" - if echo "$(uname -s)" | grep -iq "CYGWIN"; then - exePath="$(cygpath -m "$exePath")" - dotDir="$(cygpath -m "$dotDir")" - fi - "$exePath" --wrapid $$ "$@" - chdirFile="$dotDir/chdir.$$" - if [ -f $chdirFile ]; then - [ $? -eq 0 ] && cd $(cat $chdirFile) - rm -f $chdirFile - fi -} diff --git a/internal/config/templates/gitspaces.scriptblock.tmpl.ps1 b/internal/config/templates/gitspaces.scriptblock.tmpl.ps1 deleted file mode 100644 index b8d33d9..0000000 --- a/internal/config/templates/gitspaces.scriptblock.tmpl.ps1 +++ /dev/null @@ -1,5 +0,0 @@ -new-module -scriptblock { - function gitspaces { - . {{ .ps1CmdletPath }} $args - } -} -name gitspaces-scriptblock -force -export diff --git a/internal/config/user.go b/internal/config/user.go deleted file mode 100644 index c09f5e2..0000000 --- a/internal/config/user.go +++ /dev/null @@ -1,172 +0,0 @@ -package config - -import ( - _ "embed" - "fmt" - "os" - "runtime" - "slices" - "strconv" - "strings" - "text/template" - - "github.com/davfive/gitspaces/v2/internal/console" - "github.com/davfive/gitspaces/v2/internal/utils" - - "github.com/spf13/viper" -) - -//go:embed templates/config.yaml.tmpl -var defaultConfigYaml []byte - -type userStruct struct { - config *viper.Viper - dotDir string - wrapId int - pterm string // Parent os stdout type (uname -o/-s) - projectPaths []string -} - -func (user *userStruct) GetConfigFile() string { - return user.config.ConfigFileUsed() -} - -func (user *userStruct) GetTerminalType() string { - return user.pterm -} - -func GetUserDotDir() string { - return utils.Join(utils.GetUserHomeDir(), GsDotDir) -} - -func (user *userStruct) SetParentProperties(wrapId int) { - user.wrapId = wrapId // 0 = Debug (vscode launcher) mode - user.pterm = utils.GetTerminalType() -} - -func (user *userStruct) HasWrapId() bool { - return user.wrapId >= 0 -} - -func (user *userStruct) WriteChdirPath(newdir string) { - if user.wrapId <= 0 { - return - } - notePath := utils.Join(user.dotDir, "chdir."+strconv.Itoa(user.wrapId)) - if err := os.WriteFile(notePath, []byte(newdir), os.FileMode(0o644)); err != nil { - console.Errorln("auto chdir failed. cd to %s", newdir) - } -} - -func initUser(wrapIdFlag int) (user *userStruct, err error) { - user = &userStruct{dotDir: GetUserDotDir()} - user.SetParentProperties(wrapIdFlag) - - if err := os.MkdirAll(user.dotDir, os.ModePerm); err != nil { - return nil, err - } - - if err = user.initConfig(); err != nil { - return nil, err - } - - // ignore Update result (tells if updated or write errors - not fatal) - user.updateShellFiles() - - return user, nil -} - -func (user *userStruct) getTemplateVariables() map[string]interface{} { - return map[string]interface{}{ - "exePath": utils.Executable(), - "homeDir": utils.GetUserHomeDir(), - "userDotDir": GetUserDotDir(), - } -} - -func (user *userStruct) getShellTmplVars(shellFiles map[string]*shellFileStruct) map[string]interface{} { - tmplVars := user.getTemplateVariables() - for _, shellFile := range shellFiles { - tmplVars[shellFile.name+"Path"] = shellFile.path - } - - return tmplVars -} - -func (user *userStruct) checkProjectPaths() (err error) { - configErrors := []string{} - cleanedPaths := []string{} - // An empty project paths file is handled by the RunUserEnvironmentChecks(), not here - if len(user.projectPaths) > 0 { - for _, path := range user.projectPaths { - path = strings.TrimSpace(path) - if path == "" { - continue - } - - if path, err = utils.Abs(path); err != nil { - configErrors = append(configErrors, fmt.Sprintf("ProjectPath error: %s", err)) - continue - } - - if !utils.PathExists(path) { - configErrors = append(configErrors, fmt.Sprintf("ProjectPath does not exist: %s", path)) - continue - } - - cleanedPaths = append(cleanedPaths, path) - } - } - - if len(configErrors) > 0 { - console.Errorln("Config file errors: %s", user.config.ConfigFileUsed()) - for _, err := range configErrors { - console.Errorln(err) - } - return fmt.Errorf("Config file errors") - } - - user.projectPaths = cleanedPaths - return nil -} - -func (user *userStruct) getShellRcFile() string { - if slices.Contains([]string{"bash", "zsh"}, user.pterm) { - return utils.Join(utils.GetCygwinAwareHomeDir(), fmt.Sprintf(".%src", user.pterm)) - } - - if user.pterm == "pwsh" { - // return os.Getenv("PROFILE") // For some reason this doesn't work (pwsh> $PROFILE) - if runtime.GOOS == "windows" { - return utils.Join(utils.GetUserHomeDir(), "Documents", "WindowsPowerShell", "Microsoft.PowerShell_profile.ps1") - } else { - return utils.Join(utils.GetUserHomeDir(), ".config", "powershell", "Microsoft.PowerShell_profile.ps1") - } - } - - return "" -} - -func (user *userStruct) initConfig() error { - user.config = viper.New() - user.config.SetConfigFile(utils.Join(user.dotDir, "config.yaml")) - user.config.SetConfigType("yaml") - if !utils.PathExists(user.config.ConfigFileUsed()) { - user.writeDefaultConfig() - } - - user.config.ReadInConfig() - - user.config.ReadInConfig() - user.projectPaths = user.config.GetStringSlice("ProjectPaths") - return user.checkProjectPaths() -} - -func (user *userStruct) writeDefaultConfig() error { - tmpl, err := template.New("config").Parse(string(defaultConfigYaml)) - if err != nil { - return err - } - - return utils.WriteTemplateToFile(tmpl, user.config.ConfigFileUsed(), user.getTemplateVariables()) -} diff --git a/internal/console/console.go b/internal/console/console.go deleted file mode 100644 index 5ee95eb..0000000 --- a/internal/console/console.go +++ /dev/null @@ -1,37 +0,0 @@ -package console - -import ( - "fmt" - "os" -) - -var debug bool = false - -func SetDebug(debugFlag bool) { - debug = debugFlag -} - -func Debugln(format string, a ...any) { - if debug { - Println(format, a...) - } -} - -func Println(format string, a ...any) { - fmt.Printf(format, a...) - fmt.Println() -} - -func PrintSeparateln(format string, a ...any) { - fmt.Println() - fmt.Printf(format, a...) - fmt.Println() - fmt.Println() -} - -func Errorln(format string, a ...any) error { - format = "Error: " + format - fmt.Fprintf(os.Stderr, format, a...) - fmt.Fprintln(os.Stderr) - return fmt.Errorf(format, a...) -} diff --git a/internal/console/prompt.go b/internal/console/prompt.go deleted file mode 100644 index e7e1696..0000000 --- a/internal/console/prompt.go +++ /dev/null @@ -1,12 +0,0 @@ -package console - -import "runtime" - -// Windows (powershell, cygwin, git bash) aren't propertly -// supported with pretty prompts (promptui or huh) -// So we'll use raw reads for these "dumb" terminals -var usePrettyPrompts = runtime.GOOS != "windows" - -func SetUsePrettyPrompts(promptStylePretty bool) { - usePrettyPrompts = promptStylePretty -} diff --git a/internal/console/prompt_confirm.go b/internal/console/prompt_confirm.go deleted file mode 100644 index ec442a1..0000000 --- a/internal/console/prompt_confirm.go +++ /dev/null @@ -1,94 +0,0 @@ -package console - -import ( - "fmt" - "os" - "slices" - "strings" - - "github.com/charmbracelet/huh" - "github.com/davfive/gitspaces/v2/internal/utils" -) - -type Confirm struct { - title string - prompt string - affirmative string - negative string - value *bool -} - -func NewConfirm() *Confirm { - return &Confirm{ - affirmative: "Yes", - negative: "No", - value: new(bool), - } -} - -func (c *Confirm) Affirmative(affirmative string) *Confirm { - c.affirmative = affirmative - return c -} - -func (c *Confirm) Negative(negative string) *Confirm { - c.negative = negative - return c -} - -func (c *Confirm) Title(title string) *Confirm { - c.title = title - return c -} - -func (c *Confirm) Prompt(title string, a ...any) *Confirm { - c.title = fmt.Sprintf(title, a...) - return c -} - -func (c *Confirm) Value(value *bool) *Confirm { - c.value = value - return c -} - -func fuzzyMatch(a string, b string) (matched bool) { - if matched = strings.EqualFold(a, b); !matched && len(a) > 0 && len(b) > 0 { - matched = strings.EqualFold(a[0:1], b[0:1]) - } - return matched -} - -func (c *Confirm) Run() bool { - prompt := utils.Get(c.prompt, c.title, "Confirm?") - if usePrettyPrompts { - err := huh.NewConfirm(). - Title(prompt). - Value(c.value). - Run() - if err != nil { - *c.value = false - } - return *c.value - } - - // For Dumb Terminals that Go prompt libraries don't support - // e.g., Windows Powershell, Git Bash, Cygwin - - fmt.Fprintf(os.Stderr, "\n") - if c.title != prompt { - fmt.Fprintln(os.Stderr, c.title) - } - - input := NewInput(). - Prompt("%s [%s/%s]", prompt, c.affirmative, c.negative). - Validate(func(input string) error { - foundMatch := slices.ContainsFunc([]string{c.affirmative, c.negative}, func(v string) bool { - return fuzzyMatch(v, input) - }) - return utils.ErrorIf(!foundMatch, "invalid choice") - }) - if err := input.Run(); err == nil { - *c.value = fuzzyMatch(*input.value, c.affirmative) - } - return *c.value -} diff --git a/internal/console/prompt_input.go b/internal/console/prompt_input.go deleted file mode 100644 index 00c9e49..0000000 --- a/internal/console/prompt_input.go +++ /dev/null @@ -1,79 +0,0 @@ -package console - -import ( - "bufio" - "fmt" - "os" - "strings" - - "github.com/charmbracelet/huh" -) - -type Input struct { - title string - prompt string - value *string - validate func(string) error -} - -func NewInput() *Input { - return &Input{ - value: new(string), - validate: func(s string) error { return nil }, - } -} - -func (i *Input) Prompt(prompt string, a ...any) *Input { - i.prompt = fmt.Sprintf(prompt, a...) - return i -} - -func (i *Input) Title(title string, a ...any) *Input { - i.title = fmt.Sprintf(title, a...) - return i -} - -func (i *Input) Validate(validate func(string) error) *Input { - if validate == nil { - i.validate = func(s string) error { return nil } - } else { - i.validate = validate - } - return i -} - -func (i *Input) Value(value *string) *Input { - i.value = value - return i -} - -func (i *Input) Run() error { - if usePrettyPrompts { - return huh.NewInput(). - Title(i.title). - Prompt(i.prompt). - Value(i.value). - Validate(i.validate). - Run() - } - - // For Dumb Terminals that Go prompt libraries don't support - // e.g., Windows Powershell, Git Bash, Cygwin - r := bufio.NewReader(os.Stdin) - - if i.title != "" { - fmt.Fprintln(os.Stderr, i.title) - } - - input := "" - for { - fmt.Fprintf(os.Stderr, "%s ", i.prompt) - input, _ = r.ReadString('\n') - input = strings.TrimSpace(input) - if err := i.validate(input); err == nil { - break - } - } - *i.value = input - return nil -} diff --git a/internal/console/prompt_select.go b/internal/console/prompt_select.go deleted file mode 100644 index 72ec5ce..0000000 --- a/internal/console/prompt_select.go +++ /dev/null @@ -1,94 +0,0 @@ -package console - -import ( - "errors" - "fmt" - "os" - "strconv" - - "github.com/charmbracelet/huh" - "github.com/davfive/gitspaces/v2/internal/utils" -) - -type Option[T comparable] struct { - Title string - Value T -} - -type Select[T comparable] struct { - title string - prompt string - rawOptions []T - options []Option[T] - value *T -} - -func NewOption[T comparable](title string, value T) Option[T] { - return Option[T]{Title: title, Value: value} -} - -func NewSelect[T comparable]() *Select[T] { - return &Select[T]{ - options: []Option[T]{}, - value: new(T), - } -} - -func (s *Select[T]) Title(title string, a ...any) *Select[T] { - s.title = fmt.Sprintf(title, a...) - return s -} - -func (s *Select[T]) Prompt(prompt string, a ...any) *Select[T] { - s.prompt = fmt.Sprintf(prompt, a...) - return s -} - -func (s *Select[T]) Options(optionsArray []T) *Select[T] { - s.rawOptions = optionsArray - for _, value := range optionsArray { - s.options = append(s.options, NewOption(fmt.Sprintf("%v", value), value)) - } - return s -} - -func (s *Select[T]) Value(value *T) *Select[T] { - s.value = value - return s -} - -func (s *Select[T]) Run() error { - if usePrettyPrompts { - return huh.NewSelect[T](). - Title(s.title). - Options(huh.NewOptions(s.rawOptions...)...). - Value(s.value). - Run() - } - - // For Dumb Terminals that Go prompt libraries don't support - // e.g., Windows Powershell, Git Bash, Cygwin - - fmt.Fprintf(os.Stderr, "\n") - if s.title != "" { - fmt.Fprintln(os.Stderr, s.title) - } - for i, option := range s.options { - fmt.Fprintf(os.Stderr, "%2d: %s\n", i+1, option.Title) - } - - input := NewInput(). - Prompt(utils.Get(s.prompt, "#?")). - Validate(func(input string) error { - if i, err := strconv.Atoi(input); err != nil || i < 1 || i > len(s.options) { - return errors.New("invalid choice") - } - return nil - }) - err := input.Run() - if err == nil { - optidx, _ := strconv.Atoi(*input.value) - *s.value = s.options[optidx-1].Value - } - return err -} diff --git a/internal/console/prompt_validators.go b/internal/console/prompt_validators.go deleted file mode 100644 index 7d06eae..0000000 --- a/internal/console/prompt_validators.go +++ /dev/null @@ -1,17 +0,0 @@ -package console - -import ( - "errors" - "strings" - - "github.com/davfive/gitspaces/v2/internal/utils" -) - -func MakeDirnameAvailableValidator(parentDir string) func(string) error { - return func(dirname string) error { - if strings.HasPrefix(dirname, ".") || utils.PathExists(utils.Join(parentDir, dirname)) { - return errors.New("invalid") - } - return nil - } -} diff --git a/internal/gitspaces/project.go b/internal/gitspaces/project.go deleted file mode 100644 index f7a11fc..0000000 --- a/internal/gitspaces/project.go +++ /dev/null @@ -1,277 +0,0 @@ -package gitspaces - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "slices" - - "github.com/davfive/gitspaces/v2/internal/config" - "github.com/davfive/gitspaces/v2/internal/console" - "github.com/davfive/gitspaces/v2/internal/utils" -) - -type ProjectStruct struct { - Path string - Name string - codeWsDir string - dotfile string - zzzdir string -} - -// Create creates the Project directory, dotfile, sleeping clones, and the default -// clone in the spaces' working directory named after the repo's default branch. -// It returns a pointer to the Project object or an error -func CreateProject(dir string, url string, numSpaces int) (project *ProjectStruct, err error) { - var projectPath string - if projectPath, err = getNewProjectPath(dir, url); err != nil { - return nil, err - } - - project = NewProject(projectPath) - if err = project.init(); err != nil { - return nil, err - } - - firstSpace, err := CreateSpaceFromUrl(project, url, project.getEmptySleeperPath()) - if err != nil { - return nil, err - } - - for i := 1; i < numSpaces; i++ { - if _, err := firstSpace.Duplicate(); err != nil { - return nil, err - } - } - - return project, nil -} - -func GetProjectFromPath(path string) (*ProjectStruct, error) { - switch { - case path == "": - case !utils.PathExists(path): - return nil, errors.New("path not found") - case utils.PathIsFile(path): - path = utils.Dir(path) - } - - var prevPath string - for currPath := path; currPath != prevPath; currPath = utils.Dir(currPath) { - if utils.PathExists(utils.Join(currPath, config.GsProjectFile)) { - return NewProject(currPath), nil - } - prevPath = currPath - } - - return nil, errors.New("no project found from " + path) -} - -func GetProject() (*ProjectStruct, error) { - return GetProjectFromPath(utils.Getwd()) -} - -func NewProject(path string) (project *ProjectStruct) { - path = utils.ToSlash(path) - project = &ProjectStruct{ - Path: path, - Name: utils.Filename(path), - codeWsDir: utils.Join(path, config.GsVsCodeWsDir), - dotfile: utils.Join(path, config.GsProjectFile), - zzzdir: utils.Join(path, config.GsSleeperDir), - } - return project -} - -func ChooseProject() (project *ProjectStruct, err error) { - projectPaths := []string{} - for _, path := range config.ProjectPaths() { - paths, _ := filepath.Glob(utils.Join(path, "*", config.GsProjectFile)) - for i := range paths { - paths[i] = utils.Dir(paths[i]) - } - projectPaths = append(projectPaths, paths...) - } - slices.Sort(projectPaths) - - var projectPath string - err = console.NewSelect[string](). - Title("Choose a project"). - Options(projectPaths). - Value(&projectPath). - Run() - if err != nil { - return nil, err - } - - return GetProjectFromPath(projectPath) -} - -func SwitchSpace() (space *SpaceStruct, err error) { - // Just switch spaces if we're already in a project - var project *ProjectStruct - if project, _ = GetProject(); project == nil { - if project, err = ChooseProject(); err != nil { - return nil, err - } - } - - space, err = project.ChooseSpace() - if err != nil { - return nil, err - } - - return space, nil -} - -func getNewProjectPath(dir string, url string) (projectPath string, err error) { - projectsDir := utils.Getwd() - - // Let user chose which ProjectPaths to put the new project in - err = console.NewSelect[string](). - Title("Create project in:"). - Options(config.ProjectPaths()). - Value(&projectsDir). - Run() - if err != nil { - return "", err - } - - // Get this project directory name - if dir == "" { - dir = utils.Basename(url, ".git") - } - - err = console.NewInput(). - Prompt("Project Directory: "). - Validate(console.MakeDirnameAvailableValidator(projectsDir)). - Value(&dir). - Run() - - return utils.Join(projectsDir, dir), nil -} - -func switchProject() (space *SpaceStruct, err error) { - // Force full switching of projects - var project *ProjectStruct - if project, err = ChooseProject(); err != nil { - return nil, err - } - - space, err = project.ChooseSpace() - if err != nil { - return nil, err - } - - return space, nil -} - -func (project *ProjectStruct) ChooseSpace() (space *SpaceStruct, err error) { - spaceNames := []string{".."} - workerSpaces := project.getWorkerSpaces() - for _, space := range workerSpaces { - spaceNames = append(spaceNames, space.Name) - } - numSleepers := len(project.getSleeperSpacePaths()) - if numSleepers > 0 { - spaceNames = append(spaceNames, fmt.Sprintf("[Wakeup] (%d)", numSleepers)) - } - - var spaceName string - err = console.NewSelect[string](). - Title("Choose a Space"). - Options(spaceNames). - Value(&spaceName). - Run() - if err != nil { - return nil, err - } - - idx := slices.Index(spaceNames, spaceName) - if idx == 0 { - return switchProject() - } else if numSleepers > 0 && idx == len(spaceNames)-1 { - return project.WakeupSpace() - } else { - return workerSpaces[idx-1], nil - } -} - -func (project *ProjectStruct) WakeupSpace() (space *SpaceStruct, err error) { - if space, err = project.getLastSleeperSpace(); err != nil { - return nil, err - } - - if err = space.Wakeup(); err != nil { - return nil, err - } - - return space, nil -} - -func (project *ProjectStruct) getSleeperSpacePaths() []string { - paths, _ := filepath.Glob(utils.Join(project.zzzdir, "zzz-*")) - return utils.FilterDirectories(paths) -} - -func (project *ProjectStruct) getWorkerSpacePaths() []string { - paths, _ := filepath.Glob(utils.Join(project.Path, "[^.]*")) - return utils.FilterDirectories(paths) -} - -func (project *ProjectStruct) getWorkerSpaces() (spaces []*SpaceStruct) { - for _, path := range project.getWorkerSpacePaths() { - spaces = append(spaces, NewSpace(project, path)) - } - return spaces -} - -func (project *ProjectStruct) getLastSleeperSpace() (space *SpaceStruct, err error) { - sleepers := project.getSleeperSpacePaths() - if len(sleepers) == 0 { - return nil, errors.New("no sleeper found") - } - - lastSpacePath := utils.Join(project.zzzdir, fmt.Sprintf("zzz-%d", len(sleepers)-1)) - if !utils.PathExists(lastSpacePath) { - return nil, errors.New("no sleeper found at " + lastSpacePath) - } - return NewSpace(project, lastSpacePath), nil -} - -func (project *ProjectStruct) getEmptySleeperPath() string { - sleepers := project.getSleeperSpacePaths() - return utils.Join(project.zzzdir, fmt.Sprintf("zzz-%d", len(sleepers))) -} - -func (project *ProjectStruct) init() error { - if utils.PathExists(project.Path) { - return errors.New("GitSpaces Project path already exists") - } - - // Create the Project directories - for _, path := range []string{project.Path, project.zzzdir, project.codeWsDir} { - if err := os.MkdirAll(path, os.ModePerm); err != nil { - return err - } - } - - // Create blank Project config - err := utils.CreateEmptyFile(utils.Join(project.Path, config.GsProjectFile)) - if err != nil { - return err - } - - return nil -} - -func (project *ProjectStruct) isWellFormed() bool { - for _, path := range []string{project.Path, project.zzzdir, project.codeWsDir} { - if !utils.PathIsDir(path) { - return false - } - } - - return true -} diff --git a/internal/gitspaces/space.go b/internal/gitspaces/space.go deleted file mode 100644 index 5be34e6..0000000 --- a/internal/gitspaces/space.go +++ /dev/null @@ -1,192 +0,0 @@ -package gitspaces - -import ( - "errors" - "fmt" - "os" - "os/exec" - "strings" - - "github.com/davfive/gitspaces/v2/internal/console" - "github.com/davfive/gitspaces/v2/internal/utils" - - cp "github.com/otiai10/copy" -) - -// Gitspace is a struct that represents a git repository -type SpaceStruct struct { - Name string - Path string - project *ProjectStruct - codeWsFile string -} - -func CreateSpaceFromUrl(project *ProjectStruct, url string, path string) (space *SpaceStruct, err error) { - // go-git doesn't have a robust ssh-cloning implementation (gits tripped up easily by ssh config, ) - cmd := exec.Command("git", "clone", url, path) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err = cmd.Run(); err != nil { - return nil, err - } - - space = NewSpace(project, path) - - if err = space.createCodeWorkspaceFile(); err != nil { - return nil, err - } - - return space, nil -} - -func GetSpaceFromPath(path string) (*SpaceStruct, error) { - project, err := GetProjectFromPath(path) // Path is checked here - if err != nil { - return nil, err - } - - for _, spacePath := range project.getWorkerSpacePaths() { - if strings.HasPrefix(path, spacePath) { - return NewSpace(project, spacePath), nil - } - } - - return nil, errors.New("space not found from " + path) -} - -func GetSpace() (*SpaceStruct, error) { - return GetSpaceFromPath(utils.Getwd()) -} - -// NewSpace creates a new Space struct -func NewSpace(project *ProjectStruct, path string) *SpaceStruct { - space := &SpaceStruct{ - Path: utils.ToSlash(path), - project: project, - } - space.updateName() - space.createCodeWorkspaceFile() - return space -} - -func (space *SpaceStruct) Duplicate() (newSpace *SpaceStruct, err error) { - copyToPath := space.project.getEmptySleeperPath() - - if err = cp.Copy(space.Path, copyToPath); err != nil { - return nil, err - } - - return NewSpace(space.project, copyToPath), nil -} - -func (space *SpaceStruct) GetProject() *ProjectStruct { - return space.project -} - -func (space *SpaceStruct) OpenVSCode() error { - codeExecPath, err := exec.LookPath("code") - if err != nil { - return err - } - return exec.Command(codeExecPath, space.codeWsFile).Start() -} - -// Rename renames the space. -// It takes a variadic parameter of type string, which represents the optional new name for the space. -// If successful, it returns nil. Otherwise, it returns an error. -func (space *SpaceStruct) Rename(arguments ...string) error { - return space.move("Rename", arguments...) -} - -func (space *SpaceStruct) Sleep() (err error) { - space.deleteCodeWorkspaceFile() - - newPath := space.project.getEmptySleeperPath() - space.Name = utils.Filename(space.Path) - if err = os.Rename(space.Path, newPath); err != nil { - return err - } - space.deleteCodeWorkspaceFile() - - return nil -} - -func (space *SpaceStruct) Wakeup() (err error) { - return space.move("Wakeup") -} - -func (space *SpaceStruct) createCodeWorkspaceFile() (err error) { - var file *os.File - - // Ensure it has the correct name (and vscode on windows requires forward slashes in the path) - space.codeWsFile = utils.Join( - space.project.codeWsDir, - fmt.Sprintf("%s~%s.code-workspace", space.project.Name, space.Name), - ) - - if file, err = os.Create(space.codeWsFile); err != nil { - return err - } - defer file.Close() - - _, err = file.WriteString(fmt.Sprintf(`{ - "folders": [ - { - "path": "%s" - } - ], - "settings": {} -}`, strings.Replace(space.Path, "\\", "/", -1))) - - return err -} - -func (space *SpaceStruct) deleteCodeWorkspaceFile() (err error) { - if utils.PathExists(space.codeWsFile) { - if err = os.Remove(space.codeWsFile); err != nil { - fmt.Fprintln(os.Stderr, "Failed to remove "+space.codeWsFile) - return err - } - } - return nil -} - -func (space *SpaceStruct) move(moveVerb string, arguments ...string) error { - var newName string // wishing for default args and/or a ternary operator :/ - if len(arguments) > 0 { - newName = arguments[0] - } - err := console.NewInput(). - Prompt(fmt.Sprintf("%s space as: ", moveVerb)). - Validate(console.MakeDirnameAvailableValidator(space.project.Path)). - Value(&newName). - Run() - if err != nil { - return err - } - - newPath := utils.Join(space.project.Path, newName) - space.deleteCodeWorkspaceFile() - os.Chdir(space.project.Path) - if err = os.Rename(space.Path, newPath); err != nil { - os.Chdir(space.Path) - return err - } - - space.Path = newPath - space.Name = newName - os.Chdir(space.Path) - - if err = space.createCodeWorkspaceFile(); err != nil { - fmt.Fprintln(os.Stderr, "Failed to create code workspace file") - } - - return nil -} - -func (space *SpaceStruct) updateName() { - // It would just be utils.Filename(space.path) but we have to account for sleeper spaces (in .zzz dir) - // The space.Name is used in the code-workspace file name so it can't have any separator characters - space.Name, _ = utils.Rel(space.project.Path, space.Path) - space.Name = strings.ReplaceAll(space.Name, "/", "~") -} diff --git a/internal/utils/error.go b/internal/utils/error.go deleted file mode 100644 index 4a7a958..0000000 --- a/internal/utils/error.go +++ /dev/null @@ -1,41 +0,0 @@ -package utils - -import ( - "fmt" - "os" -) - -func ErrorIf(condition bool, message string) error { - if condition { - return fmt.Errorf("error: %s", message) - } - return nil -} - -func PanicIfError(err error) { - if err != nil { - panic(fmt.Errorf("error: %w", err)) - } -} - -func PanicIfFalse(condition bool, message string) { - if !condition { - panic(fmt.Errorf("error: %s", message)) - } -} - -func PanicIfTrue(condition bool, message string) { - if condition { - panic(fmt.Errorf("error: %s", message)) - } -} - -func PanicIfPathExists(path string) { - _, err := os.Stat(path) - PanicIfTrue(os.IsExist(err), "Directory already exists: "+path) -} - -func PanicIfPathDoesNotExist(path string) { - _, err := os.Stat(path) - PanicIfFalse(os.IsExist(err), "Directory already exists: "+path) -} diff --git a/internal/utils/path.go b/internal/utils/path.go deleted file mode 100644 index 2eabb10..0000000 --- a/internal/utils/path.go +++ /dev/null @@ -1,151 +0,0 @@ -package utils - -// filepath uses the OS-specific path separator, so we need to convert it to a forward slash (which also works on Windows). - -import ( - "errors" - "os" - "os/exec" - "path/filepath" - "regexp" - "runtime" - "strings" -) - -func Abs(path string) (abs string, err error) { - if abs, err = filepath.Abs(path); err == nil { - abs = ToSlash(abs) - } - return abs, err -} - -func Basename(path string, ext string) string { - if ext != "" && strings.HasSuffix(path, ext) { - return strings.TrimSuffix(filepath.Base(path), ext) - } - return filepath.Base(path) -} - -func CreateEmptyFile(path string) (err error) { - if err = os.MkdirAll(Dir(path), os.ModePerm); err != nil { - return err - } - - var file *os.File - if file, err = os.Create(path); err != nil { - return err - } - - defer file.Close() - return nil -} - -func CreateEmptyFileIfNotExists(path string) (err error) { - if PathExists(path) { - return nil - } - - return CreateEmptyFile(path) -} - -func CygwinizePath(path string) string { - driveRe := regexp.MustCompile("^(?P[A-z]+):") - path = driveRe.ReplaceAllString(path, "/${drive}") - path = strings.ReplaceAll(path, "\\", "/") - return path -} - -func Dir(path string) string { - return ToSlash(filepath.Dir(path)) -} - -func EvalSymlinks(path string) (string, error) { - if evalpath, err := filepath.EvalSymlinks(path); err == nil { - return ToSlash(evalpath), nil - } else { - return "", err - } -} - -func Filename(path string) string { - return Basename(path, "") -} - -func FilterDirectories(paths []string) []string { - var dirs []string - for _, path := range paths { - if PathIsDir(path) { - dirs = append(dirs, path) - } - } - return dirs -} - -// GetCygpathHomeDir returns the user's ~/ dir from the cygpath command. -// If cygpath doesn't exist (either not on Windows or Cygwin is not installed) -// then the normal $USERPROFILE or /Users/ is returned. This -// method is only used to determine the location of the user's rc file for -// setup, in all other cases the user's home dir is the normal one. -// Note: on windows in powershell, this resolves to the cygwin home dir (unexpectedly) -func GetCygwinAwareHomeDir() string { - if runtime.GOOS != "windows" || GetTerminalType() == "pwsh" { - return GetUserHomeDir() - } - - cmd := exec.Command("cygpath", "-m", "~") - out, err := cmd.Output() - if err != nil { - return GetUserHomeDir() - } - return ToSlash(strings.TrimSpace(string(out))) // Already Cygwinized -} - -func GetUserHomeDir() string { - userHomeDir, err := os.UserHomeDir() - PanicIfError(err) - return ToSlash(userHomeDir) -} - -func GetShellHomeDir() string { - return GetCygwinAwareHomeDir() -} - -func Getwd() string { - dir, err := os.Getwd() - PanicIfError(err) - return ToSlash(dir) -} - -func Join(paths ...string) string { - return ToSlash(filepath.Join(paths...)) -} - -func PathIsDir(filename string) bool { - info, err := os.Stat(filename) - return !errors.Is(err, os.ErrNotExist) && info.IsDir() -} - -func PathExists(filename string) bool { - _, err := os.Stat(filename) - return !errors.Is(err, os.ErrNotExist) -} - -func PathIsFile(filename string) bool { - info, err := os.Stat(filename) - return os.IsExist(err) && !info.IsDir() -} - -func Rel(basepath string, targpath string) (string, error) { - if relPath, err := filepath.Rel(basepath, targpath); err == nil { - return ToSlash(relPath), nil - } else { - return targpath, err - } -} - -func ToSlash(path string) string { - // Note that filepath.ToSlash() is a no-op on *nix. - // If I am concerned about people using windows path on *nix, - // I should use strings.ReplaceAll(path, "\\", "/") instead. - return filepath.ToSlash(path) -} diff --git a/internal/utils/utils.go b/internal/utils/utils.go deleted file mode 100644 index e1696ea..0000000 --- a/internal/utils/utils.go +++ /dev/null @@ -1,117 +0,0 @@ -package utils - -import ( - "cmp" - "errors" - "os" - "slices" - "strings" - "text/template" - - "github.com/mitchellh/go-ps" - "github.com/skratchdot/open-golang/open" - "golang.org/x/exp/maps" -) - -func Executable() (exe string) { - var err error - - if exe, err = os.Executable(); err == nil { - if exe, err = EvalSymlinks(exe); err == nil { - return exe - } - } - return ToSlash(os.Args[0]) -} - -func Get[E comparable](v E, fallbacks ...E) E { - var zero E - if v != zero { - return v - } else { - for _, f := range fallbacks { - if f != zero { - return f - } - } - return zero - } -} - -func GetIndex[S ~[]E, E any](s S, index int, fallback E) E { - if index < len(s) { - return s[index] - } - return fallback -} - -func GetTerminalType() string { - parentps, _ := ps.FindProcess(os.Getppid()) - if parentps == nil { - return "" - } - - parentProcessName := strings.ToLower(Basename(parentps.Executable(), ".exe")) - switch parentProcessName { - case "pwsh", "powershell": - return "pwsh" - case "bash", "zsh": - return parentProcessName - default: - return "" - } -} - -func OpenFileInDefaultApp(path string) (err error) { - if path, err = EvalSymlinks(path); err != nil { - return err - } - - if err = CreateEmptyFileIfNotExists(path); err != nil { - return err - } - - return open.Start(path) -} - -func SafeWriteTemplateToFile(t *template.Template, path string, vars map[string]interface{}) (err error) { - if PathExists(path) { - return errors.New("template path already exists: " + path) - } - - return WriteTemplateToFile(t, path, vars) -} - -func SortKeys[M ~map[K]V, K cmp.Ordered, V any](m M) []K { - keys := maps.Keys(m) - slices.Sort(keys) - return keys -} - -func Ternary[T any](cond bool, a T, b T) T { - if cond { - return a - } - return b -} - -func WriteTemplateToFile(tmpl *template.Template, path string, vars map[string]interface{}) (err error) { - if err = os.MkdirAll(Dir(path), 0o755); err != nil { - return err - } - - var f *os.File - if f, err = os.Create(path); err != nil { - return err - } - - err = tmpl.Execute(f, vars) - f.Close() - return err -} - -func WriteTemplateToString(tmpl *template.Template, vars map[string]interface{}) (string, error) { - var s strings.Builder - err := tmpl.Execute(&s, vars) - return s.String(), err -} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b9c0f66 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,79 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "gitspaces" +version = "2.0.36" +description = "A git development workspace manager" +readme = "README.md" +requires-python = ">=3.8" +license = {text = "MIT"} +authors = [ + {name = "David Rowe", email = "davfive@gmail.com"} +] +keywords = ["git", "workspace", "development", "clone", "manager"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Version Control :: Git", +] +dependencies = [ + "pyyaml>=6.0", + "gitpython>=3.1.0", + "questionary>=2.0.0", + "rich>=13.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "pytest-mock>=3.10.0", + "black>=23.0.0", + "flake8>=6.0.0", + "mypy>=1.0.0", + "types-PyYAML>=6.0.0", +] + +[project.scripts] +gitspaces = "gitspaces.cli:main" +gs = "gitspaces.cli:main" + +[project.urls] +Homepage = "https://github.com/davfive/gitspaces" +Repository = "https://github.com/davfive/gitspaces" +Documentation = "https://github.com/davfive/gitspaces#readme" +Issues = "https://github.com/davfive/gitspaces/issues" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "-v --cov=src/gitspaces --cov-report=term-missing --cov-report=html" + +[tool.black] +line-length = 100 +target-version = ['py38'] +include = '\.pyi?$' + +[tool.mypy] +python_version = "3.8" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..c67441c --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,12 @@ +-r requirements.txt +pytest>=7.0.0 +pytest-cov>=4.0.0 +pytest-mock>=3.10.0 +black>=23.0.0 +flake8>=6.0.0 +mypy>=1.0.0 +types-PyYAML>=6.0.0 +build>=0.10.0 +twine>=4.0.0 +bandit>=1.7.0 +safety>=2.3.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..37cf61b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +pyyaml>=6.0 +gitpython>=3.1.0 +questionary>=2.0.0 +rich>=13.0.0 diff --git a/src/gitspaces/__init__.py b/src/gitspaces/__init__.py new file mode 100644 index 0000000..35cd887 --- /dev/null +++ b/src/gitspaces/__init__.py @@ -0,0 +1,10 @@ +"""GitSpaces - A git development workspace manager.""" + +__version__ = "2.0.36" +__author__ = "David Rowe" +__email__ = "davfive@gmail.com" + +from .modules.project import Project +from .modules.space import Space + +__all__ = ["Project", "Space", "__version__"] diff --git a/src/gitspaces/__main__.py b/src/gitspaces/__main__.py new file mode 100644 index 0000000..5e9729b --- /dev/null +++ b/src/gitspaces/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for running gitspaces as a module.""" + +from gitspaces.cli import main + +if __name__ == "__main__": + main() diff --git a/src/gitspaces/cli.py b/src/gitspaces/cli.py new file mode 100644 index 0000000..fd501fa --- /dev/null +++ b/src/gitspaces/cli.py @@ -0,0 +1,141 @@ +"""GitSpaces CLI - Command-line interface for gitspaces.""" + +import sys +import argparse +from gitspaces import __version__ +from gitspaces.modules.config import Config, init_config, run_user_environment_checks +from gitspaces.modules.console import Console + + +def create_parser(): + """Create the argument parser with subcommands.""" + parser = argparse.ArgumentParser( + prog="gitspaces", + description="GitSpaces - Concurrent development manager for git projects", + epilog='Use "gitspaces --help" for more information about a command.', + ) + + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + + parser.add_argument( + "--debug", "-d", action="store_true", help="Add additional debugging information" + ) + + # Create subparsers for commands + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # Import and register commands + from gitspaces.modules import ( + cmd_setup, + cmd_clone, + cmd_switch, + cmd_sleep, + cmd_rename, + cmd_code, + cmd_config, + cmd_extend, + ) + + # Setup command + setup_parser = subparsers.add_parser("setup", help="Setup GitSpaces configuration") + setup_parser.set_defaults(func=cmd_setup.setup_command) + + # Clone command + clone_parser = subparsers.add_parser( + "clone", help="Clone a git repository as a GitSpaces project" + ) + clone_parser.add_argument("url", help="Git repository URL") + clone_parser.add_argument( + "-n", "--num-spaces", type=int, default=3, help="Number of spaces to create (default: 3)" + ) + clone_parser.add_argument("-d", "--directory", help="Directory where project will be created") + clone_parser.set_defaults(func=cmd_clone.clone_command) + + # Switch command + switch_parser = subparsers.add_parser("switch", help="Switch to a different space") + switch_parser.add_argument("space", nargs="?", help="Space name to switch to") + switch_parser.set_defaults(func=cmd_switch.switch_command) + + # Sleep command + sleep_parser = subparsers.add_parser( + "sleep", help="Put a space to sleep and optionally wake another" + ) + sleep_parser.add_argument("space", nargs="?", help="Space to put to sleep") + sleep_parser.set_defaults(func=cmd_sleep.sleep_command) + + # Rename command + rename_parser = subparsers.add_parser("rename", help="Rename a space") + rename_parser.add_argument("old_name", help="Current space name") + rename_parser.add_argument("new_name", help="New space name") + rename_parser.set_defaults(func=cmd_rename.rename_command) + + # Code command + code_parser = subparsers.add_parser("code", help="Open space in VS Code") + code_parser.add_argument("space", nargs="?", help="Space to open") + code_parser.set_defaults(func=cmd_code.code_command) + + # Config command + config_parser = subparsers.add_parser("config", help="View or edit configuration") + config_parser.add_argument("key", nargs="?", help="Configuration key") + config_parser.add_argument("value", nargs="?", help="Configuration value") + config_parser.set_defaults(func=cmd_config.config_command) + + # Extend command + extend_parser = subparsers.add_parser("extend", help="Add more clone spaces to the project") + extend_parser.add_argument( + "-n", + "--num-spaces", + type=int, + default=1, + help="Number of additional spaces to create (default: 1)", + ) + extend_parser.add_argument( + "space", nargs="?", help="Space to clone from (default: current or first active)" + ) + extend_parser.set_defaults(func=cmd_extend.extend_command) + + return parser + + +def main(): + """Main entry point for the CLI.""" + parser = create_parser() + args = parser.parse_args() + + # Show debug info if requested + if args.debug: + Console.println(f"Args: {sys.argv}") + + # Initialize configuration + try: + init_config() + if not run_user_environment_checks(): + sys.exit(1) + except Exception as e: + Console.println(f"Error initializing GitSpaces configuration: {e}") + Console.println("Try running 'gitspaces setup' to configure GitSpaces.") + sys.exit(1) + + # If no command is provided, default to switch + if args.command is None: + from gitspaces.modules import cmd_switch + + args.func = cmd_switch.switch_command + + # Execute the command + try: + if hasattr(args, "func"): + args.func(args) + else: + parser.print_help() + except KeyboardInterrupt: + Console.println("\nAborted by user") + sys.exit(1) + except Exception as e: + if str(e) != "user aborted": + Console.println(f"Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/gitspaces/modules/__init__.py b/src/gitspaces/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/gitspaces/modules/cmd_clone.py b/src/gitspaces/modules/cmd_clone.py new file mode 100644 index 0000000..039dd17 --- /dev/null +++ b/src/gitspaces/modules/cmd_clone.py @@ -0,0 +1,45 @@ +"""Clone command for GitSpaces - clone git repositories as GitSpaces projects.""" + +import os +from pathlib import Path +from gitspaces.modules.config import Config +from gitspaces.modules.console import Console +from gitspaces.modules.project import Project + + +def clone_command(args): + """Clone a git repository as a GitSpaces project. + + Args: + args: Parsed command-line arguments containing: + - url: Git repository URL + - num_spaces: Number of spaces to create + - directory: Optional directory where project will be created + """ + config = Config.instance() + url = args.url + num_spaces = args.num_spaces + directory = args.directory + + # Determine the target directory + if directory: + target_dir = Path(directory).expanduser().resolve() + elif config.project_paths: + # Use the first configured project path + target_dir = Path(config.project_paths[0]).expanduser().resolve() + else: + # Default to current directory + target_dir = Path.cwd() + + Console.println(f"Creating GitSpaces project from {url}") + Console.println(f"Location: {target_dir}") + Console.println(f"Number of spaces: {num_spaces}") + + try: + project = Project.create_project(str(target_dir), url, num_spaces) + Console.println(f"\n✓ Successfully created project: {project.name}") + Console.println(f" Path: {project.path}") + Console.println(f"\nUse 'gitspaces switch' to activate a space.") + except Exception as e: + Console.println(f"\n✗ Error creating project: {e}") + raise diff --git a/src/gitspaces/modules/cmd_code.py b/src/gitspaces/modules/cmd_code.py new file mode 100644 index 0000000..0fca1b4 --- /dev/null +++ b/src/gitspaces/modules/cmd_code.py @@ -0,0 +1,59 @@ +"""Code command for GitSpaces - open spaces in editor.""" + +from pathlib import Path +from gitspaces.modules.config import Config +from gitspaces.modules.console import Console +from gitspaces.modules.project import Project +from gitspaces.modules import runshell + + +def code_command(args): + """Open a space in the configured editor (default: VS Code). + + Args: + args: Parsed command-line arguments containing: + - space: Optional space to open + """ + config = Config.instance() + editor = config.default_editor + + # Find the current project + cwd = Path.cwd() + project = Project.find_project(str(cwd)) + + if not project: + Console.println("✗ Not in a GitSpaces project directory") + return + + # Determine which space to open + if hasattr(args, "space") and args.space: + space_name = args.space + else: + # List available active spaces + spaces = project.list_spaces() + active_spaces = [s for s in spaces if not s.startswith(".zzz/")] + + if not active_spaces: + Console.println("✗ No active spaces available") + return + + space_name = Console.prompt_select("Select a space to open:", choices=active_spaces) + + # Construct the space path + space_path = project.path / space_name + + if not space_path.exists(): + Console.println(f"✗ Space '{space_name}' not found") + return + + # Open in editor + try: + Console.println(f"Opening '{space_name}' in {editor}...") + runshell.subprocess.run([editor, str(space_path)], check=True) + Console.println(f"✓ Opened space in {editor}") + except FileNotFoundError: + Console.println(f"✗ Editor '{editor}' not found") + Console.println("Update your editor with: gitspaces config default_editor ") + except Exception as e: + Console.println(f"✗ Error opening editor: {e}") + raise diff --git a/src/gitspaces/modules/cmd_config.py b/src/gitspaces/modules/cmd_config.py new file mode 100644 index 0000000..2089945 --- /dev/null +++ b/src/gitspaces/modules/cmd_config.py @@ -0,0 +1,56 @@ +"""Config command for GitSpaces - view and edit configuration.""" + +from gitspaces.modules.config import Config +from gitspaces.modules.console import Console + + +def config_command(args): + """View or edit configuration values. + + Args: + args: Parsed command-line arguments containing: + - key: Optional configuration key + - value: Optional configuration value + """ + config = Config.instance() + + # If no key provided, show all configuration + if not hasattr(args, "key") or not args.key: + Console.println("GitSpaces Configuration") + Console.println("=" * 50) + Console.println(f"Config file: {config.config_file}") + Console.println("\nSettings:") + Console.println(f" project_paths: {config.project_paths}") + Console.println(f" default_editor: {config.default_editor}") + return + + key = args.key + + # If no value provided, show the current value + if not hasattr(args, "value") or not args.value: + value = config.get(key) + if value is not None: + Console.println(f"{key}: {value}") + else: + Console.println(f"✗ Configuration key '{key}' not found") + return + + # Set the configuration value + value = args.value + + # Handle special keys + if key == "project_paths": + # For project_paths, treat value as a single path to add + current_paths = config.project_paths + if value not in current_paths: + current_paths.append(value) + config.project_paths = current_paths + Console.println(f"✓ Added '{value}' to project_paths") + else: + Console.println(f"Path '{value}' already in project_paths") + else: + config.set(key, value) + Console.println(f"✓ Set {key} = {value}") + + # Save configuration + config.save() diff --git a/src/gitspaces/modules/cmd_extend.py b/src/gitspaces/modules/cmd_extend.py new file mode 100644 index 0000000..3fb9dcd --- /dev/null +++ b/src/gitspaces/modules/cmd_extend.py @@ -0,0 +1,82 @@ +"""Extend command for GitSpaces - add more clones to a project.""" + +from pathlib import Path +from gitspaces.modules.config import Config +from gitspaces.modules.console import Console +from gitspaces.modules.project import Project +from gitspaces.modules.space import Space + + +def extend_command(args): + """Add more clone spaces to the current project. + + Args: + args: Parsed command-line arguments containing: + - num_spaces: Number of additional spaces to create + - space: Optional space to clone from (defaults to current or first active) + """ + # Find the current project + cwd = Path.cwd() + project = Project.find_project(str(cwd)) + + if not project: + Console.println("✗ Not in a GitSpaces project directory") + return + + num_spaces = args.num_spaces if hasattr(args, "num_spaces") and args.num_spaces else 1 + + # Determine which space to clone from + spaces = project.list_spaces() + active_spaces = [s for s in spaces if not s.startswith(".zzz/")] + + if not active_spaces: + Console.println("✗ No active spaces available to clone from") + return + + # If space name provided, use it + if hasattr(args, "space") and args.space: + source_space_name = args.space + if source_space_name not in active_spaces: + Console.println(f"✗ Space '{source_space_name}' not found or is sleeping") + Console.println(f"Available active spaces: {', '.join(active_spaces)}") + return + else: + # Check if we're currently in a space directory + current_space = None + for space_name in active_spaces: + space_path = project.path / space_name + if cwd == space_path or cwd.is_relative_to(space_path): + current_space = space_name + break + + if current_space: + source_space_name = current_space + Console.println(f"Using current space '{source_space_name}' as source") + else: + # Use the first active space + source_space_name = active_spaces[0] + Console.println(f"Using space '{source_space_name}' as source") + + # Create the source space object + source_space_path = project.path / source_space_name + source_space = Space(project, str(source_space_path)) + + # Create the additional clones + Console.println(f"Creating {num_spaces} additional clone(s) from '{source_space_name}'...") + + created_count = 0 + for i in range(num_spaces): + try: + new_space = source_space.duplicate() + created_count += 1 + Console.println(f" ✓ Created clone {i + 1}/{num_spaces}: {new_space.path.name}") + except Exception as e: + Console.println(f" ✗ Error creating clone {i + 1}: {e}") + break + + if created_count > 0: + Console.println(f"\n✓ Successfully created {created_count} additional clone(s)") + Console.println(f"Total spaces in project: {len(project.list_spaces())}") + Console.println("\nUse 'gitspaces sleep' to wake and name the new clones") + else: + Console.println("\n✗ No clones were created") diff --git a/src/gitspaces/modules/cmd_rename.py b/src/gitspaces/modules/cmd_rename.py new file mode 100644 index 0000000..4108825 --- /dev/null +++ b/src/gitspaces/modules/cmd_rename.py @@ -0,0 +1,55 @@ +"""Rename command for GitSpaces - rename spaces.""" + +from pathlib import Path +from gitspaces.modules.config import Config +from gitspaces.modules.console import Console +from gitspaces.modules.project import Project +from gitspaces.modules.space import Space + + +def rename_command(args): + """Rename a space. + + Args: + args: Parsed command-line arguments containing: + - old_name: Current space name + - new_name: New space name + """ + # Find the current project + cwd = Path.cwd() + project = Project.find_project(str(cwd)) + + if not project: + Console.println("✗ Not in a GitSpaces project directory") + return + + old_name = args.old_name + new_name = args.new_name + + # Check if old space exists + spaces = project.list_spaces() + if old_name not in spaces: + Console.println(f"✗ Space '{old_name}' not found") + Console.println(f"Available spaces: {', '.join(spaces)}") + return + + # Check if new name already exists + if new_name in spaces: + Console.println(f"✗ Space '{new_name}' already exists") + return + + # Rename the space + if old_name.startswith(".zzz/"): + space_path = project.path / old_name + else: + space_path = project.path / old_name + + space = Space(project, str(space_path)) + + try: + renamed_space = space.rename(new_name) + Console.println(f"✓ Renamed space '{old_name}' to '{new_name}'") + Console.println(f" New path: {renamed_space.path}") + except Exception as e: + Console.println(f"✗ Error renaming space: {e}") + raise diff --git a/src/gitspaces/modules/cmd_setup.py b/src/gitspaces/modules/cmd_setup.py new file mode 100644 index 0000000..cb8d124 --- /dev/null +++ b/src/gitspaces/modules/cmd_setup.py @@ -0,0 +1,73 @@ +"""Setup command for GitSpaces initial configuration.""" + +from pathlib import Path +from gitspaces.modules.config import Config +from gitspaces.modules.console import Console +from gitspaces.modules.path import ensure_dir + + +def setup_command(args): + """Setup GitSpaces configuration.""" + Console.println("GitSpaces Setup") + Console.println("=" * 50) + + # Run the setup process + result = run_setup() + + if result: + Console.println("\n✓ Setup complete!") + Console.println("You can now use 'gitspaces' or 'gs' commands.") + else: + Console.println("\n✗ Setup incomplete. Please try again.") + + +def run_setup() -> bool: + """Run the interactive setup process. + + Returns: + True if setup was successful, False otherwise. + """ + config = Config.instance() + + Console.println("\n--- Step 1: Configure Project Paths ---") + Console.println("Where do you keep your git projects?") + + # Get project paths + paths = [] + while True: + path = Console.prompt_input( + "Enter a project directory path (or press Enter to finish):", default="" + ) + + if not path: + if paths: + break + Console.println("At least one project path is required.") + continue + + # Expand and validate path + expanded_path = Path(path).expanduser().resolve() + if not expanded_path.exists(): + create = Console.prompt_confirm( + f"Directory {expanded_path} does not exist. Create it?", default=True + ) + if create: + ensure_dir(expanded_path) + paths.append(str(expanded_path)) + else: + continue + else: + paths.append(str(expanded_path)) + + config.project_paths = paths + + Console.println("\n--- Step 2: Configure Default Editor ---") + editor = Console.prompt_input( + "Enter your preferred editor command (e.g., 'code', 'vim'):", default="code" + ) + config.default_editor = editor + + # Save configuration + config.save() + + return True diff --git a/src/gitspaces/modules/cmd_sleep.py b/src/gitspaces/modules/cmd_sleep.py new file mode 100644 index 0000000..d21964f --- /dev/null +++ b/src/gitspaces/modules/cmd_sleep.py @@ -0,0 +1,84 @@ +"""Sleep command for GitSpaces - put spaces to sleep and wake them.""" + +import os +from pathlib import Path +from gitspaces.modules.config import Config +from gitspaces.modules.console import Console +from gitspaces.modules.project import Project +from gitspaces.modules.space import Space + + +def sleep_command(args): + """Put a space to sleep and optionally wake another. + + Args: + args: Parsed command-line arguments containing: + - space: Optional space to put to sleep + """ + # Find the current project + cwd = Path.cwd() + project = Project.find_project(str(cwd)) + + if not project: + Console.println("✗ Not in a GitSpaces project directory") + return + + # List available spaces + spaces = project.list_spaces() + active_spaces = [s for s in spaces if not s.startswith(".zzz/")] + sleeping_spaces = [s for s in spaces if s.startswith(".zzz/")] + + # Determine which space to sleep + if hasattr(args, "space") and args.space: + space_to_sleep = args.space + else: + if not active_spaces: + Console.println("✗ No active spaces to put to sleep") + return + + Console.println("Active spaces:") + space_to_sleep = Console.prompt_select( + "Select a space to put to sleep:", choices=active_spaces + ) + + if space_to_sleep not in active_spaces: + Console.println(f"✗ Space '{space_to_sleep}' not found or already sleeping") + return + + # Sleep the space + space_path = project.path / space_to_sleep + space = Space(project, str(space_path)) + + try: + sleeping_space = space.sleep() + Console.println(f"✓ Space '{space_to_sleep}' is now sleeping") + except Exception as e: + Console.println(f"✗ Error putting space to sleep: {e}") + return + + # Ask if user wants to wake a sleeping space + if sleeping_spaces: + wake_another = Console.prompt_confirm( + "Would you like to wake a sleeping space?", default=True + ) + + if wake_another: + space_to_wake = Console.prompt_select( + "Select a sleeping space to wake:", choices=sleeping_spaces + ) + + # Get new name for the space + new_name = Console.prompt_input( + "Enter a name for the woken space:", + default=space_to_wake.split("/")[-1].replace("zzz-", ""), + ) + + sleeping_space_path = project.path / space_to_wake + sleeping_space_obj = Space(project, str(sleeping_space_path)) + + try: + woken_space = sleeping_space_obj.wake(new_name) + Console.println(f"✓ Space '{space_to_wake}' is now awake as '{new_name}'") + Console.println(f" Path: {woken_space.path}") + except Exception as e: + Console.println(f"✗ Error waking space: {e}") diff --git a/src/gitspaces/modules/cmd_switch.py b/src/gitspaces/modules/cmd_switch.py new file mode 100644 index 0000000..9a9bfe9 --- /dev/null +++ b/src/gitspaces/modules/cmd_switch.py @@ -0,0 +1,61 @@ +"""Switch command for GitSpaces - switch between spaces.""" + +from pathlib import Path +from gitspaces.modules.config import Config +from gitspaces.modules.console import Console +from gitspaces.modules.project import Project +from gitspaces.modules import runshell + + +def switch_command(args): + """Switch to a different space. + + Args: + args: Parsed command-line arguments containing: + - space: Optional space name to switch to + """ + # Find the current project + cwd = Path.cwd() + project = Project.find_project(str(cwd)) + + if not project: + Console.println("✗ Not in a GitSpaces project directory") + Console.println("Run 'gitspaces create ' to create a new project") + return + + # List available spaces + spaces = project.list_spaces() + + if not spaces: + Console.println("✗ No spaces found in project") + return + + # If space name provided, use it + if hasattr(args, "space") and args.space: + target_space = args.space + if target_space not in spaces: + Console.println(f"✗ Space '{target_space}' not found") + Console.println(f"Available spaces: {', '.join(spaces)}") + return + else: + # Interactive selection + Console.println(f"Project: {project.name}") + Console.println(f"Available spaces:") + target_space = Console.prompt_select("Select a space:", choices=spaces) + + # Construct the target path + if target_space.startswith(".zzz/"): + Console.println(f"✗ Cannot switch to sleeping space '{target_space}'") + Console.println("Wake it first with 'gitspaces sleep' (which can wake sleeping spaces)") + return + + target_path = project.path / target_space + + # Change directory + try: + runshell.fs.chdir(str(target_path)) + Console.println(f"✓ Switched to space: {target_space}") + Console.println(f" Path: {target_path}") + except Exception as e: + Console.println(f"✗ Error switching to space: {e}") + raise diff --git a/src/gitspaces/modules/config.py b/src/gitspaces/modules/config.py new file mode 100644 index 0000000..e2793fe --- /dev/null +++ b/src/gitspaces/modules/config.py @@ -0,0 +1,112 @@ +"""Configuration management for GitSpaces.""" + +import os +import yaml +from pathlib import Path +from typing import List, Optional, Dict, Any + + +class Config: + """GitSpaces configuration management.""" + + _instance: Optional["Config"] = None + _config_dir: Optional[Path] = None + _config_file: Optional[Path] = None + _data: Dict[str, Any] = {} + + @classmethod + def instance(cls) -> "Config": + """Get the singleton instance of Config.""" + if cls._instance is None: + cls._instance = cls() + return cls._instance + + @property + def config_dir(self) -> Path: + """Get the configuration directory path.""" + if self._config_dir is None: + home = Path.home() + self._config_dir = home / ".gitspaces" + return self._config_dir + + @property + def config_file(self) -> Path: + """Get the configuration file path.""" + if self._config_file is None: + self._config_file = self.config_dir / "config.yaml" + return self._config_file + + @property + def project_paths(self) -> List[str]: + """Get the list of project paths.""" + return self._data.get("project_paths", []) + + @project_paths.setter + def project_paths(self, paths: List[str]): + """Set the list of project paths.""" + self._data["project_paths"] = paths + + @property + def default_editor(self) -> str: + """Get the default editor.""" + return self._data.get("default_editor", "code") + + @default_editor.setter + def default_editor(self, editor: str): + """Set the default editor.""" + self._data["default_editor"] = editor + + def load(self): + """Load configuration from file.""" + if self.config_file.exists(): + with open(self.config_file, "r") as f: + self._data = yaml.safe_load(f) or {} + else: + self._data = {} + + def save(self): + """Save configuration to file.""" + self.config_dir.mkdir(parents=True, exist_ok=True) + with open(self.config_file, "w") as f: + yaml.safe_dump(self._data, f, default_flow_style=False) + + def exists(self) -> bool: + """Check if configuration file exists.""" + return self.config_file.exists() + + def get(self, key: str, default: Any = None) -> Any: + """Get a configuration value.""" + return self._data.get(key, default) + + def set(self, key: str, value: Any): + """Set a configuration value.""" + self._data[key] = value + + +def init_config(): + """Initialize the configuration system.""" + config = Config.instance() + config.load() + + +def run_user_environment_checks() -> bool: + """Run user environment checks and setup if needed. + + Returns: + True if environment is ready, False otherwise. + """ + config = Config.instance() + + # Check if config exists + if not config.exists(): + from gitspaces.modules.cmd_setup import run_setup + + return run_setup() + + # Check if project paths are configured + if not config.project_paths: + from gitspaces.modules.cmd_setup import run_setup + + return run_setup() + + return True diff --git a/src/gitspaces/modules/console.py b/src/gitspaces/modules/console.py new file mode 100644 index 0000000..fbe6bc3 --- /dev/null +++ b/src/gitspaces/modules/console.py @@ -0,0 +1,74 @@ +"""Console output and prompting utilities.""" + +from typing import Any, List, Optional +from rich.console import Console as RichConsole +import questionary + + +class Console: + """Console utilities for output and user prompts.""" + + _use_pretty_prompts = True + _console = RichConsole() + + @classmethod + def println(cls, message: str, *args: Any): + """Print a message to the console. + + Args: + message: The message format string. + *args: Arguments for string formatting. + """ + if args: + message = message % args + cls._console.print(message) + + @classmethod + def set_use_pretty_prompts(cls, use_pretty: bool): + """Set whether to use pretty prompts. + + Args: + use_pretty: True to use pretty prompts, False for plain. + """ + cls._use_pretty_prompts = use_pretty + + @classmethod + def prompt_input(cls, message: str, default: str = "") -> str: + """Prompt the user for text input. + + Args: + message: The prompt message. + default: The default value. + + Returns: + The user's input. + """ + return questionary.text(message, default=default).ask() or default + + @classmethod + def prompt_confirm(cls, message: str, default: bool = True) -> bool: + """Prompt the user for confirmation. + + Args: + message: The prompt message. + default: The default value. + + Returns: + True if confirmed, False otherwise. + """ + result = questionary.confirm(message, default=default).ask() + return result if result is not None else default + + @classmethod + def prompt_select(cls, message: str, choices: List[str], default: Optional[str] = None) -> str: + """Prompt the user to select from a list of choices. + + Args: + message: The prompt message. + choices: The list of choices. + default: The default choice. + + Returns: + The selected choice. + """ + return questionary.select(message, choices=choices, default=default).ask() diff --git a/src/gitspaces/modules/errors.py b/src/gitspaces/modules/errors.py new file mode 100644 index 0000000..5194d54 --- /dev/null +++ b/src/gitspaces/modules/errors.py @@ -0,0 +1,25 @@ +"""Error handling utilities.""" + + +class GitSpacesError(Exception): + """Base exception for GitSpaces errors.""" + + pass + + +class ConfigError(GitSpacesError): + """Configuration related errors.""" + + pass + + +class ProjectError(GitSpacesError): + """Project related errors.""" + + pass + + +class SpaceError(GitSpacesError): + """Space related errors.""" + + pass diff --git a/src/gitspaces/modules/path.py b/src/gitspaces/modules/path.py new file mode 100644 index 0000000..9b6d8e0 --- /dev/null +++ b/src/gitspaces/modules/path.py @@ -0,0 +1,43 @@ +"""Path utility functions.""" + +import os +from pathlib import Path +from typing import Union + + +def ensure_dir(path: Union[str, Path]) -> Path: + """Ensure a directory exists, creating it if necessary. + + Args: + path: The directory path to ensure exists. + + Returns: + The Path object for the directory. + """ + p = Path(path) + p.mkdir(parents=True, exist_ok=True) + return p + + +def expand_path(path: str) -> str: + """Expand user home directory and environment variables in path. + + Args: + path: The path to expand. + + Returns: + The expanded path as a string. + """ + return os.path.expanduser(os.path.expandvars(path)) + + +def join_paths(*paths: str) -> str: + """Join multiple path components. + + Args: + *paths: Path components to join. + + Returns: + The joined path as a string. + """ + return os.path.join(*paths) diff --git a/src/gitspaces/modules/project.py b/src/gitspaces/modules/project.py new file mode 100644 index 0000000..3a189af --- /dev/null +++ b/src/gitspaces/modules/project.py @@ -0,0 +1,151 @@ +"""Project management for GitSpaces.""" + +import os +import shutil +from pathlib import Path +from typing import Optional, List +from git import Repo +from gitspaces.modules.errors import ProjectError +from gitspaces.modules.path import ensure_dir + + +class Project: + """Represents a GitSpaces project containing multiple spaces.""" + + DOTFILE = "__GITSPACES_PROJECT__" + ZZZ_DIR = ".zzz" + + def __init__(self, path: str): + """Initialize a Project. + + Args: + path: The path to the project directory. + """ + self.path = Path(path) + self.name = self.path.name + self.code_ws_dir = self.path / ".vscode" + self.dotfile = self.path / self.DOTFILE + self.zzz_dir = self.path / self.ZZZ_DIR + + @classmethod + def create_project(cls, directory: str, url: str, num_spaces: int = 1) -> "Project": + """Create a new GitSpaces project. + + Args: + directory: The directory where the project will be created. + url: The git repository URL. + num_spaces: The number of spaces to create. + + Returns: + The created Project instance. + """ + from .space import Space + + # Extract project name from URL + project_name = cls._extract_project_name(url) + project_path = Path(directory) / project_name + + if project_path.exists(): + raise ProjectError(f"Project directory already exists: {project_path}") + + # Create project instance and initialize + project = cls(str(project_path)) + project._init() + + # Create first space from URL + first_space = Space.create_space_from_url(project, url, project._get_empty_sleeper_path()) + + # Duplicate for additional spaces + for _ in range(1, num_spaces): + first_space.duplicate() + + return project + + @staticmethod + def _extract_project_name(url: str) -> str: + """Extract project name from git URL. + + Args: + url: The git repository URL. + + Returns: + The project name. + """ + # Remove .git suffix and extract last part of path + name = url.rstrip("/").split("/")[-1] + if name.endswith(".git"): + name = name[:-4] + return name + + def _init(self): + """Initialize the project directory structure.""" + ensure_dir(self.path) + ensure_dir(self.zzz_dir) + self.dotfile.touch() + + def _get_empty_sleeper_path(self) -> str: + """Get the path for a new sleeper space. + + Returns: + The path for the new sleeper space. + """ + # Find the next available zzz-N directory + i = 0 + while True: + sleeper_path = self.zzz_dir / f"zzz-{i}" + if not sleeper_path.exists(): + return str(sleeper_path) + i += 1 + + def list_spaces(self) -> List[str]: + """List all spaces in the project. + + Returns: + List of space directory names. + """ + spaces = [] + + # List active spaces (top-level directories, excluding special ones) + for item in self.path.iterdir(): + if ( + item.is_dir() + and item.name not in [self.ZZZ_DIR, ".vscode", self.DOTFILE] + and not item.name.startswith(".") + ): + spaces.append(item.name) + + # List sleeping spaces + if self.zzz_dir.exists(): + for item in self.zzz_dir.iterdir(): + if item.is_dir(): + spaces.append(f"{self.ZZZ_DIR}/{item.name}") + + return sorted(spaces) + + def exists(self) -> bool: + """Check if the project exists. + + Returns: + True if the project directory and dotfile exist. + """ + return self.path.exists() and self.dotfile.exists() + + @classmethod + def find_project(cls, path: str) -> Optional["Project"]: + """Find a GitSpaces project by searching upward from the given path. + + Args: + path: The path to start searching from. + + Returns: + The Project instance if found, None otherwise. + """ + current = Path(path).resolve() + + while current != current.parent: + dotfile = current / cls.DOTFILE + if dotfile.exists(): + return cls(str(current)) + current = current.parent + + return None diff --git a/src/gitspaces/modules/runshell.py b/src/gitspaces/modules/runshell.py new file mode 100644 index 0000000..3682ade --- /dev/null +++ b/src/gitspaces/modules/runshell.py @@ -0,0 +1,143 @@ +"""External command execution wrapper for GitSpaces. + +This module encapsulates all external command execution (subprocess, git, and OS operations) +to isolate security scanner warnings and provide OS-agnostic operations. +""" + +import os +import shutil +from pathlib import Path +from typing import Optional +from git import Repo +from gitspaces.modules.errors import GitSpacesError + + +# Subprocess wrapper - isolates security warnings +class subprocess: + """Subprocess execution wrapper. + + Direct wrapper around subprocess.run with security annotations. + All subprocess usage is safe as we never use shell=True and always + pass arguments as lists. + """ + + @staticmethod + def run(*args, **kwargs): + """Execute a subprocess command. + + Args: + *args: Positional arguments to subprocess.run + **kwargs: Keyword arguments to subprocess.run + + Returns: + subprocess.CompletedProcess + """ + import subprocess as sp # nosec B404 + + # Security: Safe usage - args as list, no shell=True + return sp.run(*args, **kwargs) # nosec B603 + + +# Git operations namespace +class git: + """Git operations using GitPython.""" + + @staticmethod + def clone(url: str, target_path: str) -> None: + """Clone a git repository. + + Args: + url: Git repository URL + target_path: Where to clone the repository + + Raises: + GitSpacesError: If clone fails + """ + try: + Repo.clone_from(url, target_path) + except Exception as e: + raise GitSpacesError(f"Failed to clone repository: {e}") + + @staticmethod + def get_repo(path: str) -> Optional[Repo]: + """Get a Repo instance for a path. + + Args: + path: Path to git repository + + Returns: + Repo instance or None if path doesn't exist + """ + p = Path(path) + if p.exists(): + try: + return Repo(path) + except Exception: + return None + return None + + @staticmethod + def get_active_branch(repo: Repo) -> str: + """Get the active branch name. + + Args: + repo: GitPython Repo instance + + Returns: + Branch name or "detached" if HEAD is detached + """ + try: + return repo.active_branch.name + except Exception: + return "detached" + + @staticmethod + def is_valid_repo(path: str) -> bool: + """Check if path is a valid git repository. + + Args: + path: Path to check + + Returns: + True if valid git repository + """ + try: + Repo(path) + return True + except Exception: + return False + + +# OS operations - cross-platform file/directory operations +class fs: + """File system operations wrapper.""" + + @staticmethod + def move(src: str, dst: str) -> None: + """Move a file or directory. + + Args: + src: Source path + dst: Destination path + """ + shutil.move(src, dst) + + @staticmethod + def copy_tree(src: str, dst: str, symlinks: bool = True) -> None: + """Recursively copy a directory tree. + + Args: + src: Source directory + dst: Destination directory + symlinks: If True, preserve symlinks + """ + shutil.copytree(src, dst, symlinks=symlinks) + + @staticmethod + def chdir(path: str) -> None: + """Change the current working directory. + + Args: + path: Directory path + """ + os.chdir(path) diff --git a/src/gitspaces/modules/space.py b/src/gitspaces/modules/space.py new file mode 100644 index 0000000..751c6ad --- /dev/null +++ b/src/gitspaces/modules/space.py @@ -0,0 +1,162 @@ +"""Space management for GitSpaces.""" + +from pathlib import Path +from typing import Optional +from git import Repo +from gitspaces.modules.errors import SpaceError +from gitspaces.modules.path import ensure_dir +from gitspaces.modules import runshell + + +class Space: + """Represents a single workspace (clone) within a GitSpaces project.""" + + def __init__(self, project, path: str): + """Initialize a Space. + + Args: + project: The parent Project instance. + path: The path to the space directory. + """ + self.project = project + self.path = Path(path) + self.name = self.path.name + self._repo: Optional[Repo] = None + + @property + def repo(self) -> Optional[Repo]: + """Get the git repository for this space. + + Returns: + The GitPython Repo instance or None. + """ + if self._repo is None: + self._repo = runshell.git.get_repo(str(self.path)) + return self._repo + + @classmethod + def create_space_from_url(cls, project, url: str, path: str) -> "Space": + """Create a new space by cloning from a URL. + + Args: + project: The parent Project instance. + url: The git repository URL. + path: The path where the space will be created. + + Returns: + The created Space instance. + """ + if Path(path).exists(): + raise SpaceError(f"Space directory already exists: {path}") + + runshell.git.clone(url, path) + space = cls(project, path) + return space + + def duplicate(self) -> "Space": + """Duplicate this space to a new sleeper space. + + Returns: + The new Space instance. + """ + new_path = self.project._get_empty_sleeper_path() + + try: + # Copy the entire directory + runshell.fs.copy_tree(str(self.path), new_path, symlinks=True) + except Exception as e: + raise SpaceError(f"Failed to duplicate space: {e}") + + return Space(self.project, new_path) + + def wake(self, new_name: Optional[str] = None) -> "Space": + """Wake up a sleeping space and optionally rename it. + + Args: + new_name: Optional new name for the space. + + Returns: + The woken Space instance (may be a new instance if renamed). + """ + # Check if this is a sleeping space + if not str(self.path).startswith(str(self.project.zzz_dir)): + raise SpaceError("Space is not sleeping") + + # Determine the new path + if new_name: + new_path = self.project.path / new_name + else: + # Use the default branch name or 'main' + repo = self.repo + if repo: + branch_name = runshell.git.get_active_branch(repo) + else: + branch_name = "main" + new_path = self.project.path / branch_name + + if new_path.exists(): + raise SpaceError(f"Target directory already exists: {new_path}") + + # Move the space + runshell.fs.move(str(self.path), str(new_path)) + + return Space(self.project, str(new_path)) + + def sleep(self) -> "Space": + """Put this space to sleep (move to .zzz directory). + + Returns: + The sleeping Space instance. + """ + # Check if already sleeping + if str(self.path).startswith(str(self.project.zzz_dir)): + raise SpaceError("Space is already sleeping") + + new_path = self.project._get_empty_sleeper_path() + + # Move the space + runshell.fs.move(str(self.path), new_path) + + return Space(self.project, new_path) + + def rename(self, new_name: str) -> "Space": + """Rename this space. + + Args: + new_name: The new name for the space. + + Returns: + The renamed Space instance. + """ + # Check if sleeping + if str(self.path).startswith(str(self.project.zzz_dir)): + new_path = self.project.zzz_dir / new_name + else: + new_path = self.project.path / new_name + + if new_path.exists(): + raise SpaceError(f"Target directory already exists: {new_path}") + + # Rename the space + runshell.fs.move(str(self.path), str(new_path)) + + return Space(self.project, str(new_path)) + + def get_current_branch(self) -> str: + """Get the current branch name. + + Returns: + The current branch name or "detached". + """ + repo = self.repo + if repo: + return runshell.git.get_active_branch(repo) + return "detached" + + def is_sleeping(self) -> bool: + """Check if this space is sleeping. + + Returns: + True if the space is in the .zzz directory. + """ + return str(self.path).startswith(str(self.project.zzz_dir)) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..2d6b06d --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for GitSpaces.""" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..7f13201 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,235 @@ +"""Tests for CLI module.""" + +import sys +from unittest.mock import Mock, patch, MagicMock +import pytest +from gitspaces.cli import create_parser, main + + +def test_create_parser(): + """Test parser creation.""" + parser = create_parser() + assert parser.prog == "gitspaces" + + +def test_parser_version(capsys): + """Test --version argument.""" + parser = create_parser() + with pytest.raises(SystemExit): + parser.parse_args(["--version"]) + + +def test_parser_debug(): + """Test --debug argument.""" + parser = create_parser() + args = parser.parse_args(["--debug"]) + assert args.debug is True + + +def test_parser_clone_command(): + """Test clone command arguments.""" + parser = create_parser() + args = parser.parse_args(["clone", "https://github.com/test/repo.git"]) + assert args.command == "clone" + assert args.url == "https://github.com/test/repo.git" + assert args.num_spaces == 3 + + +def test_parser_clone_with_options(): + """Test clone command with options.""" + parser = create_parser() + args = parser.parse_args( + ["clone", "https://github.com/test/repo.git", "-n", "5", "-d", "/tmp/test"] + ) + assert args.num_spaces == 5 + assert args.directory == "/tmp/test" + + +def test_parser_switch_command(): + """Test switch command.""" + parser = create_parser() + args = parser.parse_args(["switch", "main"]) + assert args.command == "switch" + assert args.space == "main" + + +def test_parser_sleep_command(): + """Test sleep command.""" + parser = create_parser() + args = parser.parse_args(["sleep", "feature"]) + assert args.command == "sleep" + assert args.space == "feature" + + +def test_parser_rename_command(): + """Test rename command.""" + parser = create_parser() + args = parser.parse_args(["rename", "old", "new"]) + assert args.command == "rename" + assert args.old_name == "old" + assert args.new_name == "new" + + +def test_parser_code_command(): + """Test code command.""" + parser = create_parser() + args = parser.parse_args(["code", "main"]) + assert args.command == "code" + assert args.space == "main" + + +def test_parser_config_command(): + """Test config command.""" + parser = create_parser() + args = parser.parse_args(["config", "key", "value"]) + assert args.command == "config" + assert args.key == "key" + assert args.value == "value" + + +def test_parser_extend_command(): + """Test extend command.""" + parser = create_parser() + args = parser.parse_args(["extend", "-n", "2", "main"]) + assert args.command == "extend" + assert args.num_spaces == 2 + assert args.space == "main" + + +def test_parser_setup_command(): + """Test setup command.""" + parser = create_parser() + args = parser.parse_args(["setup"]) + assert args.command == "setup" + + +@patch("gitspaces.cli.init_config") +@patch("gitspaces.cli.run_user_environment_checks") +@patch("gitspaces.cli.Console") +def test_main_with_debug(mock_console, mock_checks, mock_init, monkeypatch): + """Test main with debug flag.""" + mock_checks.return_value = True + monkeypatch.setattr(sys, "argv", ["gitspaces", "--debug", "setup"]) + + mock_func = Mock() + with patch("gitspaces.cli.create_parser") as mock_parser: + parser = MagicMock() + args = MagicMock() + args.debug = True + args.command = "setup" + args.func = mock_func + parser.parse_args.return_value = args + mock_parser.return_value = parser + + main() + + mock_console.println.assert_any_call(f"Args: {sys.argv}") + mock_func.assert_called_once() + + +@patch("gitspaces.cli.init_config") +@patch("gitspaces.cli.run_user_environment_checks") +def test_main_config_error(mock_checks, mock_init, monkeypatch, capsys): + """Test main when config initialization fails.""" + mock_init.side_effect = Exception("Config error") + monkeypatch.setattr(sys, "argv", ["gitspaces", "setup"]) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 1 + + +@patch("gitspaces.cli.init_config") +@patch("gitspaces.cli.run_user_environment_checks") +def test_main_environment_check_fails(mock_checks, mock_init, monkeypatch): + """Test main when environment checks fail.""" + mock_checks.return_value = False + monkeypatch.setattr(sys, "argv", ["gitspaces", "setup"]) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 1 + + +@patch("gitspaces.cli.init_config") +@patch("gitspaces.cli.run_user_environment_checks") +@patch("gitspaces.modules.cmd_switch.switch_command") +def test_main_no_command_defaults_to_switch( + mock_switch, mock_checks, mock_init, monkeypatch +): + """Test main defaults to switch when no command provided.""" + mock_checks.return_value = True + monkeypatch.setattr(sys, "argv", ["gitspaces"]) + + main() + + mock_switch.assert_called_once() + + +@patch("gitspaces.cli.init_config") +@patch("gitspaces.cli.run_user_environment_checks") +def test_main_keyboard_interrupt(mock_checks, mock_init, monkeypatch): + """Test main handles keyboard interrupt.""" + mock_checks.return_value = True + monkeypatch.setattr(sys, "argv", ["gitspaces", "setup"]) + + mock_func = Mock(side_effect=KeyboardInterrupt()) + with patch("gitspaces.cli.create_parser") as mock_parser: + parser = MagicMock() + args = MagicMock() + args.debug = False + args.command = "setup" + args.func = mock_func + parser.parse_args.return_value = args + mock_parser.return_value = parser + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 1 + + +@patch("gitspaces.cli.init_config") +@patch("gitspaces.cli.run_user_environment_checks") +def test_main_user_aborted(mock_checks, mock_init, monkeypatch): + """Test main handles user abort exception.""" + mock_checks.return_value = True + monkeypatch.setattr(sys, "argv", ["gitspaces", "setup"]) + + mock_func = Mock(side_effect=Exception("user aborted")) + with patch("gitspaces.cli.create_parser") as mock_parser: + parser = MagicMock() + args = MagicMock() + args.debug = False + args.command = "setup" + args.func = mock_func + parser.parse_args.return_value = args + mock_parser.return_value = parser + + # User aborted doesn't raise SystemExit, just returns + main() + + +@patch("gitspaces.cli.init_config") +@patch("gitspaces.cli.run_user_environment_checks") +def test_main_general_exception(mock_checks, mock_init, monkeypatch): + """Test main handles general exceptions.""" + mock_checks.return_value = True + monkeypatch.setattr(sys, "argv", ["gitspaces", "setup"]) + + mock_func = Mock(side_effect=Exception("Something went wrong")) + with patch("gitspaces.cli.create_parser") as mock_parser: + parser = MagicMock() + args = MagicMock() + args.debug = False + args.command = "setup" + args.func = mock_func + parser.parse_args.return_value = args + mock_parser.return_value = parser + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 1 diff --git a/tests/test_cmd_code.py b/tests/test_cmd_code.py new file mode 100644 index 0000000..a5a8fe7 --- /dev/null +++ b/tests/test_cmd_code.py @@ -0,0 +1,195 @@ +"""Tests for cmd_code module.""" + +from unittest.mock import Mock, patch, MagicMock +from pathlib import Path +import pytest +from gitspaces.modules.cmd_code import code_command + + +@patch("gitspaces.modules.cmd_code.Console") +@patch("gitspaces.modules.cmd_code.Config") +@patch("gitspaces.modules.cmd_code.Project") +def test_code_command_with_space(mock_project_cls, mock_config_cls, mock_console): + """Test code command with specified space.""" + # Setup mocks + mock_config = Mock() + mock_config.default_editor = "code" + mock_config_cls.instance.return_value = mock_config + + mock_project = Mock() + mock_project.path = Path("/test/project") + mock_project_cls.find_project.return_value = mock_project + + args = Mock() + args.space = "main" + + with patch("gitspaces.modules.cmd_code.runshell.subprocess.run") as mock_run: + with patch("gitspaces.modules.cmd_code.Path.cwd") as mock_cwd: + mock_cwd.return_value = Path("/test/project/main") + + # Create space path mock + space_path = Path("/test/project/main") + with patch.object(Path, "exists", return_value=True): + code_command(args) + + mock_run.assert_called_once() + mock_console.println.assert_any_call(f"Opening 'main' in code...") + + +@patch("gitspaces.modules.cmd_code.Console") +@patch("gitspaces.modules.cmd_code.Config") +@patch("gitspaces.modules.cmd_code.Project") +def test_code_command_no_project(mock_project_cls, mock_config_cls, mock_console): + """Test code command when not in a project.""" + mock_config = Mock() + mock_config.default_editor = "code" + mock_config_cls.instance.return_value = mock_config + + mock_project_cls.find_project.return_value = None + + args = Mock() + args.space = "main" + + with patch("gitspaces.modules.cmd_code.Path.cwd") as mock_cwd: + mock_cwd.return_value = Path("/some/path") + code_command(args) + + mock_console.println.assert_called_with( + "✗ Not in a GitSpaces project directory" + ) + + +@patch("gitspaces.modules.cmd_code.Console") +@patch("gitspaces.modules.cmd_code.Config") +@patch("gitspaces.modules.cmd_code.Project") +def test_code_command_space_not_found( + mock_project_cls, mock_config_cls, mock_console +): + """Test code command when space doesn't exist.""" + mock_config = Mock() + mock_config.default_editor = "code" + mock_config_cls.instance.return_value = mock_config + + mock_project = Mock() + mock_project.path = Path("/test/project") + mock_project_cls.find_project.return_value = mock_project + + args = Mock() + args.space = "nonexistent" + + with patch("gitspaces.modules.cmd_code.Path.cwd") as mock_cwd: + mock_cwd.return_value = Path("/test/project") + with patch.object(Path, "exists", return_value=False): + code_command(args) + + mock_console.println.assert_called_with("✗ Space 'nonexistent' not found") + + +@patch("gitspaces.modules.cmd_code.Console") +@patch("gitspaces.modules.cmd_code.Config") +@patch("gitspaces.modules.cmd_code.Project") +def test_code_command_editor_not_found( + mock_project_cls, mock_config_cls, mock_console +): + """Test code command when editor is not found.""" + mock_config = Mock() + mock_config.default_editor = "nonexistent-editor" + mock_config_cls.instance.return_value = mock_config + + mock_project = Mock() + mock_project.path = Path("/test/project") + mock_project_cls.find_project.return_value = mock_project + + args = Mock() + args.space = "main" + + with patch("gitspaces.modules.cmd_code.runshell.subprocess.run") as mock_run: + mock_run.side_effect = FileNotFoundError() + with patch("gitspaces.modules.cmd_code.Path.cwd") as mock_cwd: + mock_cwd.return_value = Path("/test/project") + with patch.object(Path, "exists", return_value=True): + code_command(args) + + mock_console.println.assert_any_call( + "✗ Editor 'nonexistent-editor' not found" + ) + + +@patch("gitspaces.modules.cmd_code.Console") +@patch("gitspaces.modules.cmd_code.Config") +@patch("gitspaces.modules.cmd_code.Project") +def test_code_command_editor_error(mock_project_cls, mock_config_cls, mock_console): + """Test code command when editor fails.""" + mock_config = Mock() + mock_config.default_editor = "code" + mock_config_cls.instance.return_value = mock_config + + mock_project = Mock() + mock_project.path = Path("/test/project") + mock_project_cls.find_project.return_value = mock_project + + args = Mock() + args.space = "main" + + with patch("gitspaces.modules.cmd_code.runshell.subprocess.run") as mock_run: + mock_run.side_effect = RuntimeError("Editor failed") + with patch("gitspaces.modules.cmd_code.Path.cwd") as mock_cwd: + mock_cwd.return_value = Path("/test/project") + with patch.object(Path, "exists", return_value=True): + with pytest.raises(RuntimeError): + code_command(args) + + +@patch("gitspaces.modules.cmd_code.Console") +@patch("gitspaces.modules.cmd_code.Config") +@patch("gitspaces.modules.cmd_code.Project") +def test_code_command_select_space(mock_project_cls, mock_config_cls, mock_console): + """Test code command with space selection.""" + mock_config = Mock() + mock_config.default_editor = "code" + mock_config_cls.instance.return_value = mock_config + + mock_project = Mock() + mock_project.path = Path("/test/project") + mock_project.list_spaces.return_value = ["main", "feature", ".zzz/sleep1"] + mock_project_cls.find_project.return_value = mock_project + + mock_console.prompt_select.return_value = "main" + + args = Mock() + args.space = None + + with patch("gitspaces.modules.cmd_code.runshell.subprocess.run") as mock_run: + with patch("gitspaces.modules.cmd_code.Path.cwd") as mock_cwd: + mock_cwd.return_value = Path("/test/project") + with patch.object(Path, "exists", return_value=True): + code_command(args) + + mock_console.prompt_select.assert_called_once() + mock_run.assert_called_once() + + +@patch("gitspaces.modules.cmd_code.Console") +@patch("gitspaces.modules.cmd_code.Config") +@patch("gitspaces.modules.cmd_code.Project") +def test_code_command_no_active_spaces( + mock_project_cls, mock_config_cls, mock_console +): + """Test code command when no active spaces available.""" + mock_config = Mock() + mock_config.default_editor = "code" + mock_config_cls.instance.return_value = mock_config + + mock_project = Mock() + mock_project.path = Path("/test/project") + mock_project.list_spaces.return_value = [".zzz/sleep1", ".zzz/sleep2"] + mock_project_cls.find_project.return_value = mock_project + + args = Mock() + args.space = None + + with patch("gitspaces.modules.cmd_code.Path.cwd") as mock_cwd: + mock_cwd.return_value = Path("/test/project") + code_command(args) + + mock_console.println.assert_called_with("✗ No active spaces available") diff --git a/tests/test_cmd_config.py b/tests/test_cmd_config.py new file mode 100644 index 0000000..ad7fe89 --- /dev/null +++ b/tests/test_cmd_config.py @@ -0,0 +1,117 @@ +"""Tests for cmd_config module.""" + +from unittest.mock import Mock, patch +from gitspaces.modules.cmd_config import config_command + + +@patch("gitspaces.modules.cmd_config.Console") +@patch("gitspaces.modules.cmd_config.Config") +def test_config_command_show_all(mock_config_cls, mock_console): + """Test config command showing all configuration.""" + mock_config = Mock() + mock_config.config_file = "/home/user/.config/gitspaces/config.yaml" + mock_config.project_paths = ["/home/user/projects"] + mock_config.default_editor = "code" + mock_config_cls.instance.return_value = mock_config + + args = Mock() + args.key = None + + config_command(args) + + mock_console.println.assert_any_call("GitSpaces Configuration") + + +@patch("gitspaces.modules.cmd_config.Console") +@patch("gitspaces.modules.cmd_config.Config") +def test_config_command_get_value(mock_config_cls, mock_console): + """Test config command getting a value.""" + mock_config = Mock() + mock_config.get.return_value = "code" + mock_config_cls.instance.return_value = mock_config + + args = Mock() + args.key = "default_editor" + args.value = None + + config_command(args) + + mock_config.get.assert_called_with("default_editor") + mock_console.println.assert_called_with("default_editor: code") + + +@patch("gitspaces.modules.cmd_config.Console") +@patch("gitspaces.modules.cmd_config.Config") +def test_config_command_get_nonexistent(mock_config_cls, mock_console): + """Test config command getting nonexistent key.""" + mock_config = Mock() + mock_config.get.return_value = None + mock_config_cls.instance.return_value = mock_config + + args = Mock() + args.key = "nonexistent" + args.value = None + + config_command(args) + + mock_console.println.assert_called_with( + "✗ Configuration key 'nonexistent' not found" + ) + + +@patch("gitspaces.modules.cmd_config.Console") +@patch("gitspaces.modules.cmd_config.Config") +def test_config_command_set_value(mock_config_cls, mock_console): + """Test config command setting a value.""" + mock_config = Mock() + mock_config_cls.instance.return_value = mock_config + + args = Mock() + args.key = "default_editor" + args.value = "vim" + + config_command(args) + + mock_config.set.assert_called_with("default_editor", "vim") + mock_config.save.assert_called_once() + mock_console.println.assert_called_with("✓ Set default_editor = vim") + + +@patch("gitspaces.modules.cmd_config.Console") +@patch("gitspaces.modules.cmd_config.Config") +def test_config_command_add_project_path(mock_config_cls, mock_console): + """Test config command adding project path.""" + mock_config = Mock() + mock_config.project_paths = ["/home/user/projects"] + mock_config_cls.instance.return_value = mock_config + + args = Mock() + args.key = "project_paths" + args.value = "/home/user/newprojects" + + config_command(args) + + assert "/home/user/newprojects" in mock_config.project_paths + mock_config.save.assert_called_once() + mock_console.println.assert_called_with( + "✓ Added '/home/user/newprojects' to project_paths" + ) + + +@patch("gitspaces.modules.cmd_config.Console") +@patch("gitspaces.modules.cmd_config.Config") +def test_config_command_add_existing_project_path(mock_config_cls, mock_console): + """Test config command adding existing project path.""" + mock_config = Mock() + mock_config.project_paths = ["/home/user/projects"] + mock_config_cls.instance.return_value = mock_config + + args = Mock() + args.key = "project_paths" + args.value = "/home/user/projects" + + config_command(args) + + mock_console.println.assert_called_with( + "Path '/home/user/projects' already in project_paths" + ) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..3c63c26 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,102 @@ +"""Tests for configuration module.""" + +import pytest +from pathlib import Path +from gitspaces.modules.config import Config, init_config + + +def test_config_singleton(): + """Test that Config is a singleton.""" + config1 = Config.instance() + config2 = Config.instance() + assert config1 is config2 + + +def test_config_dir(tmp_path, monkeypatch): + """Test configuration directory path.""" + # Reset singleton + Config._instance = None + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + config = Config.instance() + + expected_dir = tmp_path / ".gitspaces" + assert config.config_dir == expected_dir + + +def test_config_file(tmp_path, monkeypatch): + """Test configuration file path.""" + Config._instance = None + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + config = Config.instance() + + expected_file = tmp_path / ".gitspaces" / "config.yaml" + assert config.config_file == expected_file + + +def test_project_paths(): + """Test project paths property.""" + Config._instance = None + config = Config.instance() + + # Default should be empty list + assert config.project_paths == [] + + # Set paths + test_paths = ["/path/1", "/path/2"] + config.project_paths = test_paths + assert config.project_paths == test_paths + + +def test_default_editor(): + """Test default editor property.""" + Config._instance = None + config = Config.instance() + + # Default should be 'code' + assert config.default_editor == "code" + + # Set editor + config.default_editor = "vim" + assert config.default_editor == "vim" + + +def test_config_save_load(tmp_path, monkeypatch): + """Test saving and loading configuration.""" + Config._instance = None + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + config = Config.instance() + + # Set some values + config.project_paths = ["/test/path1", "/test/path2"] + config.default_editor = "emacs" + + # Save + config.save() + + # Verify file exists + assert config.config_file.exists() + + # Create new config and load + Config._instance = None + config2 = Config.instance() + config2.load() + + # Verify values + assert config2.project_paths == ["/test/path1", "/test/path2"] + assert config2.default_editor == "emacs" + + +def test_config_get_set(): + """Test generic get/set methods.""" + Config._instance = None + config = Config.instance() + + # Test set and get + config.set("custom_key", "custom_value") + assert config.get("custom_key") == "custom_value" + + # Test get with default + assert config.get("nonexistent", "default") == "default" diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 0000000..0c43dd8 --- /dev/null +++ b/tests/test_console.py @@ -0,0 +1,100 @@ +"""Tests for console module.""" + +from unittest.mock import patch, MagicMock +import pytest + +from gitspaces.modules.console import Console + + +class TestConsole: + """Test Console class.""" + + @patch("rich.console.Console.print") + def test_println(self, mock_print): + """Test printing message.""" + Console.println("Test message") + + mock_print.assert_called_once_with("Test message") + + @patch("rich.console.Console.print") + def test_println_with_format(self, mock_print): + """Test printing formatted message.""" + Console.println("Test %s", "value") + + mock_print.assert_called_once_with("Test value") + + def test_set_use_pretty_prompts(self): + """Test setting pretty prompts flag.""" + Console.set_use_pretty_prompts(False) + assert Console._use_pretty_prompts is False + + Console.set_use_pretty_prompts(True) + assert Console._use_pretty_prompts is True + + @patch("questionary.text") + def test_prompt_input(self, mock_text): + """Test prompting for input.""" + mock_text.return_value.ask.return_value = "test value" + + result = Console.prompt_input("Enter value:") + + assert result == "test value" + mock_text.assert_called_once_with("Enter value:", default="") + + @patch("questionary.text") + def test_prompt_input_with_default(self, mock_text): + """Test prompting for input with default.""" + mock_text.return_value.ask.return_value = None + + result = Console.prompt_input("Enter value:", default="default") + + assert result == "default" + + @patch("questionary.confirm") + def test_prompt_confirm_yes(self, mock_confirm): + """Test prompting for confirmation (yes).""" + mock_confirm.return_value.ask.return_value = True + + result = Console.prompt_confirm("Continue?") + + assert result is True + mock_confirm.assert_called_once_with("Continue?", default=True) + + @patch("questionary.confirm") + def test_prompt_confirm_no(self, mock_confirm): + """Test prompting for confirmation (no).""" + mock_confirm.return_value.ask.return_value = False + + result = Console.prompt_confirm("Continue?", default=False) + + assert result is False + + @patch("questionary.confirm") + def test_prompt_confirm_none(self, mock_confirm): + """Test prompting for confirmation (None returned, use default).""" + mock_confirm.return_value.ask.return_value = None + + result = Console.prompt_confirm("Continue?", default=True) + + assert result is True + + @patch("questionary.select") + def test_prompt_select(self, mock_select): + """Test prompting to select from list.""" + mock_select.return_value.ask.return_value = "Option 2" + + result = Console.prompt_select("Choose:", ["Option 1", "Option 2", "Option 3"]) + + assert result == "Option 2" + mock_select.assert_called_once_with( + "Choose:", choices=["Option 1", "Option 2", "Option 3"], default=None + ) + + @patch("questionary.select") + def test_prompt_select_with_default(self, mock_select): + """Test prompting to select from list with default.""" + mock_select.return_value.ask.return_value = "Option 1" + + result = Console.prompt_select("Choose:", ["Option 1", "Option 2"], default="Option 1") + + assert result == "Option 1" diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..cc104c8 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,32 @@ +"""Tests for error classes.""" + +import pytest +from gitspaces.modules.errors import GitSpacesError, ConfigError, ProjectError, SpaceError + + +def test_gitspaces_error(): + """Test GitSpacesError base exception.""" + error = GitSpacesError("Test error") + assert str(error) == "Test error" + assert isinstance(error, Exception) + + +def test_config_error(): + """Test ConfigError exception.""" + error = ConfigError("Config error") + assert str(error) == "Config error" + assert isinstance(error, GitSpacesError) + + +def test_project_error(): + """Test ProjectError exception.""" + error = ProjectError("Project error") + assert str(error) == "Project error" + assert isinstance(error, GitSpacesError) + + +def test_space_error(): + """Test SpaceError exception.""" + error = SpaceError("Space error") + assert str(error) == "Space error" + assert isinstance(error, GitSpacesError) diff --git a/tests/test_path.py b/tests/test_path.py new file mode 100644 index 0000000..044a901 --- /dev/null +++ b/tests/test_path.py @@ -0,0 +1,53 @@ +"""Tests for path utilities.""" + +import pytest +from pathlib import Path +from gitspaces.modules.path import ensure_dir, expand_path, join_paths + + +def test_ensure_dir(tmp_path): + """Test directory creation.""" + test_dir = tmp_path / "test" / "nested" / "dir" + + # Should create directory + result = ensure_dir(test_dir) + + assert result.exists() + assert result.is_dir() + assert result == test_dir + + +def test_ensure_dir_existing(tmp_path): + """Test ensure_dir with existing directory.""" + test_dir = tmp_path / "existing" + test_dir.mkdir() + + # Should not raise error + result = ensure_dir(test_dir) + + assert result.exists() + assert result.is_dir() + + +def test_expand_path(monkeypatch): + """Test path expansion.""" + monkeypatch.setenv("TEST_VAR", "/test/value") + + # Test home directory expansion + result = expand_path("~/test") + assert "~" not in result + assert result.endswith("test") + + # Test environment variable expansion + result = expand_path("$TEST_VAR/path") + assert "TEST_VAR" not in result + assert "/test/value/path" in result + + +def test_join_paths(): + """Test path joining.""" + result = join_paths("path", "to", "file.txt") + + assert "path" in result + assert "to" in result + assert "file.txt" in result diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 0000000..4533f8a --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,98 @@ +"""Tests for project module.""" + +import pytest +from pathlib import Path +from gitspaces.modules.project import Project +from gitspaces.modules.errors import ProjectError + + +def test_project_init(): + """Test Project initialization.""" + project = Project("/test/path/myproject") + + assert project.path == Path("/test/path/myproject") + assert project.name == "myproject" + assert project.dotfile == Path("/test/path/myproject/__GITSPACES_PROJECT__") + assert project.zzz_dir == Path("/test/path/myproject/.zzz") + + +def test_extract_project_name(): + """Test project name extraction from URL.""" + # HTTPS URL + name = Project._extract_project_name("https://github.com/user/repo.git") + assert name == "repo" + + # SSH URL + name = Project._extract_project_name("git@github.com:user/myrepo.git") + assert name == "myrepo" + + # Without .git + name = Project._extract_project_name("https://github.com/user/project") + assert name == "project" + + +def test_project_exists(tmp_path): + """Test project existence check.""" + project_path = tmp_path / "testproject" + project = Project(str(project_path)) + + # Should not exist initially + assert not project.exists() + + # Create structure + project.path.mkdir() + project.dotfile.touch() + + # Should exist now + assert project.exists() + + +def test_find_project(tmp_path): + """Test finding a project by searching upward.""" + # Create project structure + project_path = tmp_path / "myproject" + project_path.mkdir() + (project_path / "__GITSPACES_PROJECT__").touch() + + # Create subdirectory + subdir = project_path / "space1" / "src" + subdir.mkdir(parents=True) + + # Find from subdirectory + found = Project.find_project(str(subdir)) + + assert found is not None + assert found.path == project_path + assert found.name == "myproject" + + +def test_find_project_not_found(tmp_path): + """Test finding project when not in a project.""" + # No project marker + result = Project.find_project(str(tmp_path)) + assert result is None + + +def test_list_spaces(tmp_path): + """Test listing spaces in a project.""" + project_path = tmp_path / "testproject" + project = Project(str(project_path)) + + # Create project structure + project_path.mkdir() + project.dotfile.touch() + project.zzz_dir.mkdir() + + # Create some spaces + (project_path / "space1").mkdir() + (project_path / "space2").mkdir() + (project.zzz_dir / "zzz-0").mkdir() + (project.zzz_dir / "zzz-1").mkdir() + + spaces = project.list_spaces() + + assert "space1" in spaces + assert "space2" in spaces + assert ".zzz/zzz-0" in spaces + assert ".zzz/zzz-1" in spaces + assert len(spaces) == 4 diff --git a/tests/test_runshell.py b/tests/test_runshell.py new file mode 100644 index 0000000..a817b23 --- /dev/null +++ b/tests/test_runshell.py @@ -0,0 +1,121 @@ +"""Tests for runshell module.""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +from pathlib import Path +from gitspaces.modules import runshell +from gitspaces.modules.errors import GitSpacesError + + +def test_subprocess_run(): + """Test subprocess.run wrapper.""" + with patch("subprocess.run") as mock_run: + mock_run.return_value = Mock(returncode=0) + result = runshell.subprocess.run(["echo", "test"]) + mock_run.assert_called_once_with(["echo", "test"]) + + +def test_git_clone_success(): + """Test git clone success.""" + with patch("gitspaces.modules.runshell.Repo") as mock_repo: + runshell.git.clone("https://github.com/test/repo.git", "/tmp/test") + mock_repo.clone_from.assert_called_once_with( + "https://github.com/test/repo.git", "/tmp/test" + ) + + +def test_git_clone_failure(): + """Test git clone failure.""" + with patch("gitspaces.modules.runshell.Repo") as mock_repo: + mock_repo.clone_from.side_effect = Exception("Clone failed") + with pytest.raises(GitSpacesError): + runshell.git.clone("https://github.com/test/repo.git", "/tmp/test") + + +def test_git_get_repo_exists(): + """Test get_repo for existing path.""" + with patch("gitspaces.modules.runshell.Repo") as mock_repo: + with patch("gitspaces.modules.runshell.Path.exists", return_value=True): + mock_repo_instance = Mock() + mock_repo.return_value = mock_repo_instance + + result = runshell.git.get_repo("/test/path") + + assert result == mock_repo_instance + + +def test_git_get_repo_not_exists(): + """Test get_repo for non-existing path.""" + with patch("gitspaces.modules.runshell.Path.exists", return_value=False): + result = runshell.git.get_repo("/nonexistent/path") + assert result is None + + +def test_git_get_repo_invalid(): + """Test get_repo for invalid repo.""" + with patch("gitspaces.modules.runshell.Repo") as mock_repo: + with patch("gitspaces.modules.runshell.Path.exists", return_value=True): + mock_repo.side_effect = Exception("Invalid repo") + result = runshell.git.get_repo("/test/path") + assert result is None + + +def test_git_get_active_branch(): + """Test get active branch.""" + mock_repo = Mock() + mock_repo.active_branch.name = "main" + + result = runshell.git.get_active_branch(mock_repo) + assert result == "main" + + +def test_git_get_active_branch_detached(): + """Test get active branch when detached.""" + mock_repo = Mock() + type(mock_repo).active_branch = property(lambda self: (_ for _ in ()).throw(Exception("Detached HEAD"))) + + result = runshell.git.get_active_branch(mock_repo) + assert result == "detached" + + +def test_git_is_valid_repo_true(): + """Test is_valid_repo returns True.""" + with patch("gitspaces.modules.runshell.Repo") as mock_repo: + result = runshell.git.is_valid_repo("/test/path") + assert result is True + + +def test_git_is_valid_repo_false(): + """Test is_valid_repo returns False.""" + with patch("gitspaces.modules.runshell.Repo") as mock_repo: + mock_repo.side_effect = Exception("Invalid repo") + result = runshell.git.is_valid_repo("/test/path") + assert result is False + + +def test_fs_move(): + """Test fs.move.""" + with patch("gitspaces.modules.runshell.shutil.move") as mock_move: + runshell.fs.move("/src", "/dst") + mock_move.assert_called_once_with("/src", "/dst") + + +def test_fs_copy_tree(): + """Test fs.copy_tree.""" + with patch("gitspaces.modules.runshell.shutil.copytree") as mock_copytree: + runshell.fs.copy_tree("/src", "/dst", symlinks=True) + mock_copytree.assert_called_once_with("/src", "/dst", symlinks=True) + + +def test_fs_copy_tree_no_symlinks(): + """Test fs.copy_tree without symlinks.""" + with patch("gitspaces.modules.runshell.shutil.copytree") as mock_copytree: + runshell.fs.copy_tree("/src", "/dst", symlinks=False) + mock_copytree.assert_called_once_with("/src", "/dst", symlinks=False) + + +def test_fs_chdir(): + """Test fs.chdir.""" + with patch("gitspaces.modules.runshell.os.chdir") as mock_chdir: + runshell.fs.chdir("/test/path") + mock_chdir.assert_called_once_with("/test/path") diff --git a/tests/test_space.py b/tests/test_space.py new file mode 100644 index 0000000..cb0a2b8 --- /dev/null +++ b/tests/test_space.py @@ -0,0 +1,270 @@ +"""Tests for space module.""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +from pathlib import Path +from gitspaces.modules.space import Space +from gitspaces.modules.errors import SpaceError + + +@patch("gitspaces.modules.space.runshell") +def test_space_init(mock_runshell): + """Test Space initialization.""" + mock_project = Mock() + space = Space(mock_project, "/test/project/main") + + assert space.project == mock_project + assert space.path == Path("/test/project/main") + assert space.name == "main" + + +@patch("gitspaces.modules.space.runshell") +def test_space_repo_property(mock_runshell): + """Test Space repo property.""" + mock_project = Mock() + mock_repo = Mock() + mock_runshell.git.get_repo.return_value = mock_repo + + space = Space(mock_project, "/test/project/main") + repo = space.repo + + assert repo == mock_repo + mock_runshell.git.get_repo.assert_called_once() + + # Test caching + repo2 = space.repo + assert repo2 == mock_repo + assert mock_runshell.git.get_repo.call_count == 1 + + +@patch("gitspaces.modules.space.runshell") +def test_create_space_from_url(mock_runshell): + """Test creating space from URL.""" + mock_project = Mock() + + with patch("gitspaces.modules.space.Path.exists", return_value=False): + space = Space.create_space_from_url( + mock_project, "https://github.com/test/repo.git", "/test/project/main" + ) + + mock_runshell.git.clone.assert_called_once_with( + "https://github.com/test/repo.git", "/test/project/main" + ) + assert space.name == "main" + + +@patch("gitspaces.modules.space.runshell") +def test_create_space_from_url_exists(mock_runshell): + """Test creating space when path already exists.""" + mock_project = Mock() + + with patch("gitspaces.modules.space.Path.exists", return_value=True): + with pytest.raises(SpaceError, match="already exists"): + Space.create_space_from_url( + mock_project, "https://github.com/test/repo.git", "/test/project/main" + ) + + +@patch("gitspaces.modules.space.runshell") +def test_space_duplicate(mock_runshell): + """Test duplicating a space.""" + mock_project = Mock() + mock_project._get_empty_sleeper_path.return_value = "/test/project/.zzz/sleep1" + + space = Space(mock_project, "/test/project/main") + new_space = space.duplicate() + + mock_runshell.fs.copy_tree.assert_called_once_with( + "/test/project/main", "/test/project/.zzz/sleep1", symlinks=True + ) + assert new_space.name == "sleep1" + + +@patch("gitspaces.modules.space.runshell") +def test_space_duplicate_error(mock_runshell): + """Test duplicate error handling.""" + mock_project = Mock() + mock_project._get_empty_sleeper_path.return_value = "/test/project/.zzz/sleep1" + mock_runshell.fs.copy_tree.side_effect = Exception("Copy failed") + + space = Space(mock_project, "/test/project/main") + + with pytest.raises(SpaceError, match="Failed to duplicate"): + space.duplicate() + + +@patch("gitspaces.modules.space.runshell") +def test_space_wake(mock_runshell): + """Test waking a sleeping space.""" + mock_project = Mock() + mock_project.path = Path("/test/project") + mock_project.zzz_dir = Path("/test/project/.zzz") + + space = Space(mock_project, "/test/project/.zzz/sleep1") + + with patch.object(Path, "exists", return_value=False): + woken_space = space.wake("feature") + + mock_runshell.fs.move.assert_called_once() + assert "feature" in str(mock_runshell.fs.move.call_args[0][1]) + + +@patch("gitspaces.modules.space.runshell") +def test_space_wake_not_sleeping(mock_runshell): + """Test waking a space that's not sleeping.""" + mock_project = Mock() + mock_project.zzz_dir = Path("/test/project/.zzz") + + space = Space(mock_project, "/test/project/main") + + with pytest.raises(SpaceError, match="not sleeping"): + space.wake() + + +@patch("gitspaces.modules.space.runshell") +def test_space_wake_exists(mock_runshell): + """Test waking to existing path.""" + mock_project = Mock() + mock_project.path = Path("/test/project") + mock_project.zzz_dir = Path("/test/project/.zzz") + + space = Space(mock_project, "/test/project/.zzz/sleep1") + + with patch.object(Path, "exists", return_value=True): + with pytest.raises(SpaceError, match="already exists"): + space.wake("main") + + +@patch("gitspaces.modules.space.runshell") +def test_space_wake_auto_name(mock_runshell): + """Test waking with automatic naming.""" + mock_project = Mock() + mock_project.path = Path("/test/project") + mock_project.zzz_dir = Path("/test/project/.zzz") + + mock_repo = Mock() + mock_runshell.git.get_active_branch.return_value = "develop" + + space = Space(mock_project, "/test/project/.zzz/sleep1") + space._repo = mock_repo + + with patch.object(Path, "exists", return_value=False): + woken_space = space.wake() + + mock_runshell.fs.move.assert_called_once() + + +@patch("gitspaces.modules.space.runshell") +def test_space_sleep(mock_runshell): + """Test putting space to sleep.""" + mock_project = Mock() + mock_project.zzz_dir = Path("/test/project/.zzz") + mock_project._get_empty_sleeper_path.return_value = "/test/project/.zzz/sleep1" + + space = Space(mock_project, "/test/project/main") + sleeping_space = space.sleep() + + mock_runshell.fs.move.assert_called_once_with( + "/test/project/main", "/test/project/.zzz/sleep1" + ) + + +@patch("gitspaces.modules.space.runshell") +def test_space_sleep_already_sleeping(mock_runshell): + """Test sleeping space that's already sleeping.""" + mock_project = Mock() + mock_project.zzz_dir = Path("/test/project/.zzz") + + space = Space(mock_project, "/test/project/.zzz/sleep1") + + with pytest.raises(SpaceError, match="already sleeping"): + space.sleep() + + +@patch("gitspaces.modules.space.runshell") +def test_space_rename(mock_runshell): + """Test renaming a space.""" + mock_project = Mock() + mock_project.path = Path("/test/project") + + space = Space(mock_project, "/test/project/main") + + with patch.object(Path, "exists", return_value=False): + renamed_space = space.rename("feature") + + mock_runshell.fs.move.assert_called_once() + assert "feature" in str(mock_runshell.fs.move.call_args[0][1]) + + +@patch("gitspaces.modules.space.runshell") +def test_space_rename_sleeping(mock_runshell): + """Test renaming a sleeping space.""" + mock_project = Mock() + mock_project.path = Path("/test/project") + mock_project.zzz_dir = Path("/test/project/.zzz") + + space = Space(mock_project, "/test/project/.zzz/sleep1") + + with patch.object(Path, "exists", return_value=False): + renamed_space = space.rename("sleep2") + + mock_runshell.fs.move.assert_called_once() + + +@patch("gitspaces.modules.space.runshell") +def test_space_rename_exists(mock_runshell): + """Test renaming to existing name.""" + mock_project = Mock() + mock_project.path = Path("/test/project") + + space = Space(mock_project, "/test/project/main") + + with patch.object(Path, "exists", return_value=True): + with pytest.raises(SpaceError, match="already exists"): + space.rename("feature") + + +@patch("gitspaces.modules.space.runshell") +def test_space_get_current_branch(mock_runshell): + """Test getting current branch.""" + mock_project = Mock() + mock_repo = Mock() + mock_runshell.git.get_active_branch.return_value = "main" + + space = Space(mock_project, "/test/project/main") + space._repo = mock_repo + + branch = space.get_current_branch() + assert branch == "main" + + +@patch("gitspaces.modules.space.runshell") +def test_space_get_current_branch_no_repo(mock_runshell): + """Test getting current branch with no repo.""" + mock_project = Mock() + mock_runshell.git.get_repo.return_value = None + + space = Space(mock_project, "/test/project/main") + + branch = space.get_current_branch() + assert branch == "detached" + + +@patch("gitspaces.modules.space.runshell") +def test_space_is_sleeping_true(mock_runshell): + """Test is_sleeping returns True.""" + mock_project = Mock() + mock_project.zzz_dir = Path("/test/project/.zzz") + + space = Space(mock_project, "/test/project/.zzz/sleep1") + assert space.is_sleeping() is True + + +@patch("gitspaces.modules.space.runshell") +def test_space_is_sleeping_false(mock_runshell): + """Test is_sleeping returns False.""" + mock_project = Mock() + mock_project.zzz_dir = Path("/test/project/.zzz") + + space = Space(mock_project, "/test/project/main") + assert space.is_sleeping() is False