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 @@
-
-
-
-
+# GitSpaces
-# gitspaces - A git development workspace manager
+[](https://badge.fury.io/py/gitspaces)
+[](https://github.com/davfive/gitspaces/actions/workflows/python-tests.yml)
+[](https://opensource.org/licenses/MIT)
+[](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
- [](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